feat: InstancedMesh optimizations for repeated geometry
Some checks failed
CI / validate (pull_request) Failing after 6s
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:
129
app.js
129
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');
|
||||
|
||||
Reference in New Issue
Block a user