From d653f74d8d90dad318415b49fc106ba249b3fb56 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 23:11:57 -0400 Subject: [PATCH] feat: InstancedMesh optimizations for repeated geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app.js | 129 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 88 insertions(+), 41 deletions(-) diff --git a/app.js b/app.js index 9a03e40..019a170 100644 --- a/app.js +++ b/app.js @@ -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');