feat: InstancedMesh optimizations for repeated geometry
Some checks failed
CI / validate (pull_request) Failing after 6s

Re-implement InstancedMesh optimizations from reference/v2-modular into
the v0-golden monolithic app.js:

- Portal tori: 3 individual MeshStandardMaterial Meshes → 1 InstancedMesh
  with per-instance colors (AdditiveBlending). Reduces 3 draw calls to 1.
- Runestones: 5 individual MeshStandardMaterial Meshes (each fetched via
  scene.getObjectByName per frame) → 1 InstancedMesh. Reduces 5 draw calls
  to 1 and eliminates 5 scene traversals per frame.
- Raycasting updated to use intersectObject(portalRingIM) with instanceId.
- Animation loop updated to write instance matrices via _imDummy (no
  per-frame Object3D allocation).

Fixes #482

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 23:11:57 -04:00
parent a377da05de
commit d653f74d8d

129
app.js
View File

@@ -39,6 +39,14 @@ let thoughtStreamMesh;
let harnessPulseMesh;
let powerMeterBars = [];
let particles, dustParticles;
// InstancedMesh references for repeated geometry (performance optimizations)
let portalRingIM = null; // InstancedMesh: all portal tori → 1 draw call
let runestonesIM = null; // InstancedMesh: ambient runestones → 1 draw call
// Reusable helpers — avoid per-frame allocations
const _imDummy = new THREE.Object3D();
const _imColor = new THREE.Color();
// Runestone world-space XZ positions (populated in createAmbientStructures)
const _runestoneXZ = [];
let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
@@ -726,13 +734,41 @@ function createVisionPoint(config) {
// ═══ PORTAL SYSTEM ═══
function createPortals(data) {
data.forEach(config => {
const portal = createPortal(config);
if (!data.length) return;
// One InstancedMesh for all portal tori — N portals = 1 draw call instead of N.
const _ringGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
const _ringMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
depthWrite: false,
});
portalRingIM = new THREE.InstancedMesh(_ringGeo, _ringMat, data.length);
portalRingIM.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(portalRingIM);
data.forEach((config, i) => {
// Per-instance color encodes the portal's tint (AdditiveBlending: output = bg + color).
_imColor.set(config.color).multiplyScalar(0.85);
portalRingIM.setColorAt(i, _imColor);
// Initial matrix — position only; animated rotations are set every frame.
_imDummy.position.set(config.position.x, (config.position.y || 0) + 3.5, config.position.z);
_imDummy.rotation.set(0, config.rotation?.y || 0, 0, 'YXZ');
_imDummy.updateMatrix();
portalRingIM.setMatrixAt(i, _imDummy.matrix);
const portal = createPortal(config, i);
portals.push(portal);
});
portalRingIM.instanceColor.needsUpdate = true;
portalRingIM.instanceMatrix.needsUpdate = true;
}
function createPortal(config) {
function createPortal(config, instanceIdx) {
const group = new THREE.Group();
group.position.set(config.position.x, config.position.y, config.position.z);
if (config.rotation) {
@@ -741,19 +777,8 @@ function createPortal(config) {
const portalColor = new THREE.Color(config.color);
// Torus Ring
const torusGeo = new THREE.TorusGeometry(3, 0.15, 16, 64);
const torusMat = new THREE.MeshStandardMaterial({
color: portalColor,
emissive: portalColor,
emissiveIntensity: 1.5,
roughness: 0.2,
metalness: 0.8,
});
const ring = new THREE.Mesh(torusGeo, torusMat);
ring.position.y = 3.5;
ring.name = `portal_ring_${config.id}`;
group.add(ring);
// Torus ring is now an InstancedMesh instance managed by createPortals().
// No per-portal ring Mesh is created here.
// Swirl Disc
const swirlGeo = new THREE.CircleGeometry(2.8, 64);
@@ -857,7 +882,7 @@ function createPortal(config) {
return {
config,
group,
ring,
instanceIdx, // index into portalRingIM for matrix/color updates
swirl,
pSystem,
light
@@ -986,20 +1011,28 @@ function createAmbientStructures() {
scene.add(crystal);
});
// Ambient runestones → single InstancedMesh: 5 stones = 1 draw call.
const _runeGeo = new THREE.OctahedronGeometry(0.4, 0);
const _runeMat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.5,
});
runestonesIM = new THREE.InstancedMesh(_runeGeo, _runeMat, 5);
runestonesIM.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(runestonesIM);
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const r = 10;
const geo = new THREE.OctahedronGeometry(0.4, 0);
const mat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.5,
});
const stone = new THREE.Mesh(geo, mat);
stone.position.set(Math.cos(angle) * r, 5 + Math.sin(i * 1.3) * 1.5, Math.sin(angle) * r);
stone.name = 'runestone_' + i;
scene.add(stone);
const x = Math.cos(angle) * r;
const z = Math.sin(angle) * r;
_runestoneXZ.push({ x, z });
_imDummy.position.set(x, 5 + Math.sin(i * 1.3) * 1.5, z);
_imDummy.rotation.set(0, 0, 0);
_imDummy.updateMatrix();
runestonesIM.setMatrixAt(i, _imDummy.matrix);
}
runestonesIM.instanceMatrix.needsUpdate = true;
const coreGeo = new THREE.IcosahedronGeometry(0.6, 2);
const coreMat = new THREE.MeshPhysicalMaterial({
@@ -1091,18 +1124,17 @@ function setupControls() {
orbitState.lastX = e.clientX;
orbitState.lastY = e.clientY;
// Raycasting for portals
if (!portalOverlayActive) {
// Raycasting for portals (uses InstancedMesh — instanceId maps to portals[])
if (!portalOverlayActive && portalRingIM) {
const mouse = new THREE.Vector2(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(portals.map(p => p.ring));
const intersects = raycaster.intersectObject(portalRingIM);
if (intersects.length > 0) {
const clickedRing = intersects[0].object;
const portal = portals.find(p => p.ring === clickedRing);
const portal = portals[intersects[0].instanceId];
if (portal) activatePortal(portal);
}
}
@@ -1413,9 +1445,20 @@ function gameLoop() {
});
// Animate Portals
portals.forEach(portal => {
portal.ring.rotation.z = elapsed * 0.3;
portal.ring.rotation.x = Math.sin(elapsed * 0.5) * 0.1;
portals.forEach((portal, i) => {
// Update instance matrix for this portal's torus ring.
if (portalRingIM) {
const cfg = portal.config;
_imDummy.position.set(cfg.position.x, (cfg.position.y || 0) + 3.5, cfg.position.z);
_imDummy.rotation.set(
Math.sin(elapsed * 0.5) * 0.1, // x: gentle tilt
cfg.rotation?.y || 0, // y: portal facing direction
elapsed * 0.3, // z: slow spin
'YXZ'
);
_imDummy.updateMatrix();
portalRingIM.setMatrixAt(i, _imDummy.matrix);
}
if (portal.swirl.material.uniforms) {
portal.swirl.material.uniforms.uTime.value = elapsed;
}
@@ -1428,6 +1471,7 @@ function gameLoop() {
}
portal.pSystem.geometry.attributes.position.needsUpdate = true;
});
if (portalRingIM) portalRingIM.instanceMatrix.needsUpdate = true;
// Animate Vision Points
visionPoints.forEach(vp => {
@@ -1463,13 +1507,16 @@ function gameLoop() {
dustParticles.rotation.y = elapsed * 0.01;
}
for (let i = 0; i < 5; i++) {
const stone = scene.getObjectByName('runestone_' + i);
if (stone) {
stone.position.y = 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8;
stone.rotation.y = elapsed * 0.5 + i;
stone.rotation.x = elapsed * 0.3 + i * 0.7;
// Animate runestones via InstancedMesh — no scene traversal per frame.
if (runestonesIM) {
for (let i = 0; i < 5; i++) {
const { x, z } = _runestoneXZ[i];
_imDummy.position.set(x, 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8, z);
_imDummy.rotation.set(elapsed * 0.3 + i * 0.7, elapsed * 0.5 + i, 0);
_imDummy.updateMatrix();
runestonesIM.setMatrixAt(i, _imDummy.matrix);
}
runestonesIM.instanceMatrix.needsUpdate = true;
}
const core = scene.getObjectByName('nexus-core');