From df901b1d4eb76967b203ac5feafdf57d516b6acf Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 07:46:17 -0400 Subject: [PATCH] perf(viewer): keep GLB light pool at a fixed visible count (no WebGPU recompiles) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling pointLight.visible changes the active-light count, which makes the WebGPU renderer rebuild every material's lighting node + pipeline — a multi- hundred-ms stall. The pool reassigns on every camera move, so zooming churned visibility and tanked FPS. Keep all 12 pool lights permanently visible and animate only intensity (an idle light lerps to 0); the light-set never changes, so no recompiles. Also make reassignment O(n) (score lookup map, not find). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/viewer/glb-interactive.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/viewer/src/components/viewer/glb-interactive.tsx b/packages/viewer/src/components/viewer/glb-interactive.tsx index e6d1f22f..bb657924 100644 --- a/packages/viewer/src/components/viewer/glb-interactive.tsx +++ b/packages/viewer/src/components/viewer/glb-interactive.tsx @@ -296,6 +296,7 @@ function GlbItemLights({ score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState), })) scored.sort((a, b) => a.score - b.score) + const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const)) const desired = scored .filter((s) => Number.isFinite(s.score)) .slice(0, POOL_SIZE) @@ -328,9 +329,8 @@ function GlbItemLights({ const freeSlotData = slots.current[freeSlot] const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null if (currentKey && !desired.includes(currentKey)) { - const currentScore = - scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY - const newScore = scored.find((s) => s.key === key)?.score ?? 0 + const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY + const newScore = scoreByKey.get(key) ?? 0 if (currentScore - newScore < HYSTERESIS) { freeSlot++ continue @@ -370,17 +370,20 @@ function GlbItemLights({ } // Per-frame: fade, snap position, and track intensity from control state. + // The pool lights stay permanently `visible` — only `intensity` is animated + // (an idle light just lerps to 0). Toggling `visible` would change the + // active-light count, which forces the WebGPU renderer to recompile every + // material's lighting node — a hard frame-time spike on every reassignment + // (i.e. on every camera move). Keeping the count fixed avoids that entirely. for (let i = 0; i < POOL_SIZE; i++) { const light = lightRefs.current[i] const slot = slots.current[i] if (!(light && slot)) continue if (slot.isFadingOut) { - light.visible = true light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) if (light.intensity < 0.01) { light.intensity = 0 - light.visible = false slot.isFadingOut = false slot.key = slot.pendingKey slot.pendingKey = null @@ -396,15 +399,12 @@ function GlbItemLights({ } if (!slot.key) { - light.intensity = 0 - light.visible = false + light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) continue } const reg = regByKey.get(slot.key) if (!reg) { slot.key = null - light.intensity = 0 - light.visible = false continue } @@ -428,13 +428,7 @@ function GlbItemLights({ const targetIntensity = isOn ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) : reg.effect.intensityRange[0] - - if (targetIntensity > 0) light.visible = true light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) - if (targetIntensity <= 0 && light.intensity < 0.01) { - light.intensity = 0 - light.visible = false - } } }) @@ -448,7 +442,6 @@ function GlbItemLights({ ref={(el) => { lightRefs.current[i] = el }} - visible={false} /> ))}