perf(viewer): keep GLB light pool at a fixed visible count (no WebGPU recompiles)

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) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 07:46:17 -04:00
co-authored by Claude Opus 4.8
parent a5c08b5950
commit df901b1d4e
@@ -296,6 +296,7 @@ function GlbItemLights({
score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState), score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState),
})) }))
scored.sort((a, b) => a.score - b.score) scored.sort((a, b) => a.score - b.score)
const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const))
const desired = scored const desired = scored
.filter((s) => Number.isFinite(s.score)) .filter((s) => Number.isFinite(s.score))
.slice(0, POOL_SIZE) .slice(0, POOL_SIZE)
@@ -328,9 +329,8 @@ function GlbItemLights({
const freeSlotData = slots.current[freeSlot] const freeSlotData = slots.current[freeSlot]
const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null
if (currentKey && !desired.includes(currentKey)) { if (currentKey && !desired.includes(currentKey)) {
const currentScore = const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY
scored.find((s) => s.key === currentKey)?.score ?? Number.POSITIVE_INFINITY const newScore = scoreByKey.get(key) ?? 0
const newScore = scored.find((s) => s.key === key)?.score ?? 0
if (currentScore - newScore < HYSTERESIS) { if (currentScore - newScore < HYSTERESIS) {
freeSlot++ freeSlot++
continue continue
@@ -370,17 +370,20 @@ function GlbItemLights({
} }
// Per-frame: fade, snap position, and track intensity from control state. // 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++) { for (let i = 0; i < POOL_SIZE; i++) {
const light = lightRefs.current[i] const light = lightRefs.current[i]
const slot = slots.current[i] const slot = slots.current[i]
if (!(light && slot)) continue if (!(light && slot)) continue
if (slot.isFadingOut) { if (slot.isFadingOut) {
light.visible = true
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12) light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
if (light.intensity < 0.01) { if (light.intensity < 0.01) {
light.intensity = 0 light.intensity = 0
light.visible = false
slot.isFadingOut = false slot.isFadingOut = false
slot.key = slot.pendingKey slot.key = slot.pendingKey
slot.pendingKey = null slot.pendingKey = null
@@ -396,15 +399,12 @@ function GlbItemLights({
} }
if (!slot.key) { if (!slot.key) {
light.intensity = 0 light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
light.visible = false
continue continue
} }
const reg = regByKey.get(slot.key) const reg = regByKey.get(slot.key)
if (!reg) { if (!reg) {
slot.key = null slot.key = null
light.intensity = 0
light.visible = false
continue continue
} }
@@ -428,13 +428,7 @@ function GlbItemLights({
const targetIntensity = isOn const targetIntensity = isOn
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t) ? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
: reg.effect.intensityRange[0] : reg.effect.intensityRange[0]
if (targetIntensity > 0) light.visible = true
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12) 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) => { ref={(el) => {
lightRefs.current[i] = el lightRefs.current[i] = el
}} }}
visible={false}
/> />
))} ))}
</> </>