Plan 007: Profile the editor render loop's per-frame allocation cost, then apply identity fast-paths only if the numbers justify it
Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.
This plan is profile-gated. Step 4 (the structural fix) runs ONLY if
Step 1's measurements clear the threshold defined there. "Measured it,
numbers are too small, removed instrumentation, recorded results" is a
successful, complete outcome of this plan — not a failure.
Drift check (run first): git diff --stat 84c1d09..HEAD -- src/hooks/use-editor-renderer.ts src/renderer/create-webgpu-renderer.ts src/renderer/contracts.ts src/renderer/pipeline-manager.ts src/lib/editor/parameter-schema.ts
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.
Status
- Priority: P3
- Effort: M
- Risk: MED (change-detection fast-paths can cause stale rendering if the identity assumptions are wrong — the manual checks in step 5 exist for exactly that)
- Depends on: plans/001-test-baseline-pure-logic.md
- Category: perf
- Planned at: commit
84c1d09, 2026-06-10
Why this matters
Every animation frame, the editor rebuilds its entire renderer frame and recomputes per-layer change-detection signatures — even when nothing on screen is changing. The allocation sites are confirmed by reading the code (all run per frame, per visible layer where noted): two Maps and per-layer object spreads in buildRendererFrame; a [...frame.layers].reverse() array copy; and a createLayerSignature string build per layer that internally does Object.entries(...).sort((a, b) => a.localeCompare(b)).map(...).join(...) over every parameter of every layer. With ~10 layers at 60fps that is thousands of short-lived arrays/strings per second plus repeated localeCompare calls (notably slower than < comparison), purely to answer "did anything change?".
What is NOT confirmed is whether this costs enough to matter — GC of short-lived objects is cheap in modern engines, and no profile exists. Optimizing change detection blind is how stale-canvas bugs get introduced. So this plan measures first, applies one unconditionally-safe micro-fix, and gates the structural fix on the measured numbers.
Current state
The per-frame call chain (all verified at 84c1d09):
-
src/hooks/use-editor-renderer.ts:144 — inside the requestAnimationFrame loop (self-rescheduling at lines 166–173), every frame calls:
const frame = buildRendererFrame({
assets: assetState.assets,
clockTime,
delta,
layers: layerState.layers,
...
})
...
renderer.render(frame)
-
src/renderer/contracts.ts:79 — buildRendererFrame allocates per frame: new Map(input.assets.map(...)), a full evaluateTimelineForLayers(...) pass, new Map(evaluatedLayers.map(...)), and per visible layer a fresh params object and a fresh layer object:
// contracts.ts:95-122 (abridged)
const params = evaluation
? { ...getCachedClone(layer.params), ...evaluation.params }
: getCachedClone(layer.params)
return {
asset: layer.assetId ? (assetById.get(layer.assetId) ?? null) : null,
layer: { ...layer, hue: ..., opacity: ..., saturation: ..., visible: ... },
params,
}
Note the existing memoization exemplar in the same file — paramsCloneCache, a WeakMap<LayerParameterValues, LayerParameterValues> at contracts.ts:65-77. The fix in step 4 follows this exact pattern. Also note evaluateTimelineForLayers already early-returns [] when tracks.length === 0 (src/lib/editor/timeline/evaluate.ts:217-219), so a static composition with no tracks skips evaluation — but still pays the spreads.
-
src/renderer/create-webgpu-renderer.ts:29 — renderFrame copies the layer array every frame: pipeline.syncLayers([...frame.layers].reverse()).
-
src/renderer/pipeline-manager.ts:206 — syncLayers computes createLayerSignature(renderableLayer) for every layer every frame; the signature (lines ~85–106) is a ~17-element array joined with "|", ending in parameterValuesSignature(layer.params).
-
src/lib/editor/parameter-schema.ts:70-75:
export function parameterValuesSignature(values: LayerParameterValues): string {
return Object.entries(values)
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([key, value]) => `${key}:${valueSignature(value)}`)
.join("|")
}
Callers of parameterValuesSignature/valueSignature (verify with grep -rn "parameterValuesSignature\|valueSignature" src/ | grep -v __tests__): only pipeline-manager.ts and the definition. Signatures are ephemeral — compared against the previous frame's value in layerSignatures: Map<string, string> (pipeline-manager.ts:119,217), never persisted. A change in signature format is therefore safe as long as it remains deterministic and injective on the same inputs.
Why signatures exist: when a signature differs from last frame, applyLayerState pushes the new state into the pass (pipeline-manager.ts:217-225). During timeline playback with animated params, signatures legitimately change every frame. The waste is the static case: nothing animating, no edits, yet full rebuild + recompute every frame.
Key identity assumption for step 4 (must be verified in step 3): zustand stores update immutably, so EditorLayer objects in layerState.layers are reference-stable between edits. If a store mutates layer objects in place anywhere, the WeakMap approach is unsound — that's a STOP condition.
Repo conventions: double quotes, no semicolons, sorted object keys (Biome), strict TS (noUncheckedIndexedAccess, exactOptionalPropertyTypes).
Commands you will need
| Purpose |
Command |
Expected on success |
| Install |
bun install --frozen-lockfile |
exit 0 |
| Build runtime pkg |
bun run build:runtime |
exit 0 |
| Dev server |
bun dev |
editor at localhost:3000 |
| Tests |
bun test |
all pass |
| Typecheck |
bun run typecheck:tsc |
exit 0 |
| Lint |
bun run lint |
exit 0 |
Suggested executor toolkit
- If Chrome DevTools MCP tools (
mcp__chrome-devtools__performance_start_trace / performance_stop_trace) are available in your environment, use them in step 1 to capture a trace alongside the in-code timers; otherwise the in-code timers alone are sufficient.
Scope
In scope:
src/renderer/create-webgpu-renderer.ts (temporary instrumentation around syncLayers; step-4 reverse-copy removal)
src/hooks/use-editor-renderer.ts (temporary instrumentation around buildRendererFrame — MUST be fully reverted before done)
src/lib/editor/parameter-schema.ts (step 2 comparator swap)
src/lib/editor/__tests__/parameter-schema.test.ts (extend)
src/renderer/contracts.ts (step 4 only: per-layer entry memoization)
src/renderer/pipeline-manager.ts (step 4 only: reference-equality fast-path)
plans/007-artifacts/profile.md (create — the measurement record)
plans/README.md (status row)
Out of scope:
packages/shader-lab-react/** — the package has parallel copies of pipeline-manager.ts/contracts.ts; mirroring is a decision for plan 006's consolidation design, not this plan.
- Zustand stores and
src/lib/editor/timeline/evaluate.ts internals.
- Any change to what triggers
applyLayerState — only to how cheaply "nothing changed" is detected.
- React-side re-render optimization (separate finding, needs React Profiler evidence).
Git workflow
- Branch:
advisor/007-render-loop-allocations
- Commit per step; style:
perf: replace localeCompare in parameter signatures, perf: skip frame rebuild for unchanged layers
- App-only change: no changeset required.
- Do NOT push or open a PR unless the operator instructed it.
Steps
Step 1: Instrument and measure
Add temporary timing (clearly marked // TEMP instrumentation — plan 007, remove before merge):
- In
src/hooks/use-editor-renderer.ts, around the buildRendererFrame call: accumulate elapsed performance.now() time and a frame count.
- In
src/renderer/create-webgpu-renderer.ts renderFrame, around pipeline.syncLayers(...) (including the [...frame.layers].reverse()): same.
- Once per second,
console.log median-ish stats: { buildMs: <avg per frame>, syncMs: <avg per frame>, frames }, then reset accumulators.
Measure two scenarios in bun dev (Chrome), each observed for ≥ 30 seconds, and record the numbers in plans/007-artifacts/profile.md:
- Scenario A (static): a composition with ~10 layers of mixed types (e.g. gradient, halftone, ascii, crt, dithering — duplicate types as needed), no timeline tracks, playback paused, no pointer interaction.
- Scenario B (animating): same composition with 3+ animated parameter tracks, timeline playing.
If DevTools tracing is available, also capture a 10 s Performance trace of Scenario A and note GC events and any long frames (> 16.7 ms) attributable to scripting rather than GPU.
Verify: plans/007-artifacts/profile.md exists with per-scenario buildMs/syncMs averages and the machine/browser noted.
Step 2: Unconditional micro-fix — drop localeCompare
In src/lib/editor/parameter-schema.ts parameterValuesSignature, replace the comparator:
.sort(([leftKey], [rightKey]) =>
leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
)
Parameter keys are plain ASCII identifiers; lexicographic ordering is just as deterministic and avoids ICU collation per comparison. Extend src/lib/editor/__tests__/parameter-schema.test.ts: equal inputs → equal signatures; two values differing in one param → different signatures; key order in the input object does not affect the signature.
Verify: bun test parameter-schema → all pass. bun run typecheck:tsc && bun run lint → exit 0.
Step 3: Gate decision
Proceed to step 4 only if, in Scenario A (static), buildMs + syncMs ≥ 0.3 ms per frame on the test machine, or the DevTools trace showed GC-attributable long frames during Scenario A. Otherwise: remove all step-1 instrumentation, write the conclusion in profile.md ("static-frame overhead X ms — below threshold; structural fix not justified"), skip to step 5, and mark the plan DONE with that outcome.
Verify: the decision and its numbers are recorded in profile.md.
Step 4: Identity fast-paths (conditional)
First verify the identity assumption: read src/store/layer-store.ts update actions and confirm layers are replaced immutably (look for in-place mutation of layer objects; spread/map-replacement patterns are what you expect to find). If any action mutates a layer object in place, STOP.
Then, modeled on the existing paramsCloneCache WeakMap (contracts.ts:65-77):
buildRendererFrame entry memo (src/renderer/contracts.ts): cache the built { asset, layer, params } entry in a WeakMap<EditorLayer, { asset: EditorAsset | null; entry: RenderableLayerPass }>. Reuse the cached entry when (a) the EditorLayer reference is unchanged, (b) the resolved asset is the same reference, and (c) the layer has no timeline evaluation this frame (evaluatedById.get(layer.id) is undefined). Animated or just-edited layers fall through to the existing build path. Result: in a static scene, frame.layers entries become reference-stable across frames.
syncLayers fast-path (src/renderer/pipeline-manager.ts): keep a Map<string, RenderableLayerPass> of last frame's entry per layer id; when the incoming entry is reference-equal, skip createLayerSignature and the signature comparison entirely for that layer (the existing layerSignatures map stays as the slow-path mechanism). Clear this map wherever layerSignatures is cleared/deleted (lines 196, 411).
- Drop the array copy (
src/renderer/create-webgpu-renderer.ts:29): iterate in reverse inside syncLayers or build a reversed array once in buildRendererFrame — pick the smaller diff; do not mutate frame.layers in place.
Re-run both Scenario A and B measurements; append before/after numbers to profile.md. Then remove all step-1 instrumentation.
Verify: Scenario A buildMs + syncMs reduced vs step 1 (numbers in profile.md); grep -rn "TEMP instrumentation" src/ → no matches.
Step 5: Behavioral regression checks (manual, in bun dev)
These guard the classic fast-path failure mode — stale canvas:
- Edit a parameter via the properties sidebar (slider drag) → canvas updates live, every drag tick.
- Toggle a layer's visibility off/on → canvas reflects it immediately.
- Reorder layers → composite order changes immediately.
- Undo (history) a parameter change → canvas reverts immediately.
- Play timeline with animated params → animation unchanged from before (Scenario B).
- Import a
.lab project → renders correctly.
Verify: all six checks pass; bun test && bun run typecheck:tsc && bun run lint → exit 0.
Test plan
- Step 2's signature tests in
src/lib/editor/__tests__/parameter-schema.test.ts (model after the plan-001 tests there).
- The fast-path logic depends on DOM/WebGPU and is covered by the manual matrix in step 5, recorded in the PR description with the before/after frame numbers from
profile.md.
Done criteria
STOP conditions
Stop and report back (do not improvise) if:
- Any layer-store action mutates an
EditorLayer object in place (breaks the WeakMap identity assumption — report the action name and line).
parameterValuesSignature or valueSignature turns out to have callers beyond pipeline-manager.ts and tests (signature format would then be load-bearing elsewhere).
- After implementing step 4, any step-5 check shows a stale canvas and the cause isn't found within one focused fix attempt — revert step 4 (keep steps 1–2 results) and report.
- The
use-editor-renderer.ts render loop differs structurally from the excerpt (drift).
Maintenance notes
- The package tree (
packages/shader-lab-react/src/renderer/) has parallel copies of pipeline-manager.ts and contracts.ts-equivalent logic; whether to mirror these fast-paths is a plan-006 consolidation-design question — flag it there rather than hand-porting.
- Anyone adding a new field to
EditorLayer that affects rendering must ensure it flows through createLayerSignature (slow path) — the fast path is reference-based and unaffected, but the slow path remains the correctness backstop.
- If the React-side re-render finding (zustand selectors) is ever investigated, reuse Scenario A/B from
profile.md as the benchmark fixtures.
Plan 007: Profile the editor render loop's per-frame allocation cost, then apply identity fast-paths only if the numbers justify it
Status
84c1d09, 2026-06-10Why this matters
Every animation frame, the editor rebuilds its entire renderer frame and recomputes per-layer change-detection signatures — even when nothing on screen is changing. The allocation sites are confirmed by reading the code (all run per frame, per visible layer where noted): two
Maps and per-layer object spreads inbuildRendererFrame; a[...frame.layers].reverse()array copy; and acreateLayerSignaturestring build per layer that internally doesObject.entries(...).sort((a, b) => a.localeCompare(b)).map(...).join(...)over every parameter of every layer. With ~10 layers at 60fps that is thousands of short-lived arrays/strings per second plus repeatedlocaleComparecalls (notably slower than<comparison), purely to answer "did anything change?".What is NOT confirmed is whether this costs enough to matter — GC of short-lived objects is cheap in modern engines, and no profile exists. Optimizing change detection blind is how stale-canvas bugs get introduced. So this plan measures first, applies one unconditionally-safe micro-fix, and gates the structural fix on the measured numbers.
Current state
The per-frame call chain (all verified at
84c1d09):src/hooks/use-editor-renderer.ts:144— inside therequestAnimationFrameloop (self-rescheduling at lines 166–173), every frame calls:src/renderer/contracts.ts:79—buildRendererFrameallocates per frame:new Map(input.assets.map(...)), a fullevaluateTimelineForLayers(...)pass,new Map(evaluatedLayers.map(...)), and per visible layer a fresh params object and a fresh layer object:Note the existing memoization exemplar in the same file —
paramsCloneCache, aWeakMap<LayerParameterValues, LayerParameterValues>atcontracts.ts:65-77. The fix in step 4 follows this exact pattern. Also noteevaluateTimelineForLayersalready early-returns[]whentracks.length === 0(src/lib/editor/timeline/evaluate.ts:217-219), so a static composition with no tracks skips evaluation — but still pays the spreads.src/renderer/create-webgpu-renderer.ts:29—renderFramecopies the layer array every frame:pipeline.syncLayers([...frame.layers].reverse()).src/renderer/pipeline-manager.ts:206—syncLayerscomputescreateLayerSignature(renderableLayer)for every layer every frame; the signature (lines ~85–106) is a ~17-element array joined with"|", ending inparameterValuesSignature(layer.params).src/lib/editor/parameter-schema.ts:70-75:Callers of
parameterValuesSignature/valueSignature(verify withgrep -rn "parameterValuesSignature\|valueSignature" src/ | grep -v __tests__): onlypipeline-manager.tsand the definition. Signatures are ephemeral — compared against the previous frame's value inlayerSignatures: Map<string, string>(pipeline-manager.ts:119,217), never persisted. A change in signature format is therefore safe as long as it remains deterministic and injective on the same inputs.Why signatures exist: when a signature differs from last frame,
applyLayerStatepushes the new state into the pass (pipeline-manager.ts:217-225). During timeline playback with animated params, signatures legitimately change every frame. The waste is the static case: nothing animating, no edits, yet full rebuild + recompute every frame.Key identity assumption for step 4 (must be verified in step 3): zustand stores update immutably, so
EditorLayerobjects inlayerState.layersare reference-stable between edits. If a store mutates layer objects in place anywhere, the WeakMap approach is unsound — that's a STOP condition.Repo conventions: double quotes, no semicolons, sorted object keys (Biome), strict TS (
noUncheckedIndexedAccess,exactOptionalPropertyTypes).Commands you will need
bun install --frozen-lockfilebun run build:runtimebun devbun testbun run typecheck:tscbun run lintSuggested executor toolkit
mcp__chrome-devtools__performance_start_trace/performance_stop_trace) are available in your environment, use them in step 1 to capture a trace alongside the in-code timers; otherwise the in-code timers alone are sufficient.Scope
In scope:
src/renderer/create-webgpu-renderer.ts(temporary instrumentation aroundsyncLayers; step-4 reverse-copy removal)src/hooks/use-editor-renderer.ts(temporary instrumentation aroundbuildRendererFrame— MUST be fully reverted before done)src/lib/editor/parameter-schema.ts(step 2 comparator swap)src/lib/editor/__tests__/parameter-schema.test.ts(extend)src/renderer/contracts.ts(step 4 only: per-layer entry memoization)src/renderer/pipeline-manager.ts(step 4 only: reference-equality fast-path)plans/007-artifacts/profile.md(create — the measurement record)plans/README.md(status row)Out of scope:
packages/shader-lab-react/**— the package has parallel copies ofpipeline-manager.ts/contracts.ts; mirroring is a decision for plan 006's consolidation design, not this plan.src/lib/editor/timeline/evaluate.tsinternals.applyLayerState— only to how cheaply "nothing changed" is detected.Git workflow
advisor/007-render-loop-allocationsperf: replace localeCompare in parameter signatures,perf: skip frame rebuild for unchanged layersSteps
Step 1: Instrument and measure
Add temporary timing (clearly marked
// TEMP instrumentation — plan 007, remove before merge):src/hooks/use-editor-renderer.ts, around thebuildRendererFramecall: accumulate elapsedperformance.now()time and a frame count.src/renderer/create-webgpu-renderer.tsrenderFrame, aroundpipeline.syncLayers(...)(including the[...frame.layers].reverse()): same.console.logmedian-ish stats:{ buildMs: <avg per frame>, syncMs: <avg per frame>, frames }, then reset accumulators.Measure two scenarios in
bun dev(Chrome), each observed for ≥ 30 seconds, and record the numbers inplans/007-artifacts/profile.md:If DevTools tracing is available, also capture a 10 s Performance trace of Scenario A and note GC events and any long frames (> 16.7 ms) attributable to scripting rather than GPU.
Verify:
plans/007-artifacts/profile.mdexists with per-scenariobuildMs/syncMsaverages and the machine/browser noted.Step 2: Unconditional micro-fix — drop
localeCompareIn
src/lib/editor/parameter-schema.tsparameterValuesSignature, replace the comparator:Parameter keys are plain ASCII identifiers; lexicographic ordering is just as deterministic and avoids ICU collation per comparison. Extend
src/lib/editor/__tests__/parameter-schema.test.ts: equal inputs → equal signatures; two values differing in one param → different signatures; key order in the input object does not affect the signature.Verify:
bun test parameter-schema→ all pass.bun run typecheck:tsc && bun run lint→ exit 0.Step 3: Gate decision
Proceed to step 4 only if, in Scenario A (static),
buildMs + syncMs ≥ 0.3ms per frame on the test machine, or the DevTools trace showed GC-attributable long frames during Scenario A. Otherwise: remove all step-1 instrumentation, write the conclusion inprofile.md("static-frame overhead X ms — below threshold; structural fix not justified"), skip to step 5, and mark the plan DONE with that outcome.Verify: the decision and its numbers are recorded in
profile.md.Step 4: Identity fast-paths (conditional)
First verify the identity assumption: read
src/store/layer-store.tsupdate actions and confirm layers are replaced immutably (look for in-place mutation of layer objects; spread/map-replacement patterns are what you expect to find). If any action mutates a layer object in place, STOP.Then, modeled on the existing
paramsCloneCacheWeakMap (contracts.ts:65-77):buildRendererFrameentry memo (src/renderer/contracts.ts): cache the built{ asset, layer, params }entry in aWeakMap<EditorLayer, { asset: EditorAsset | null; entry: RenderableLayerPass }>. Reuse the cached entry when (a) theEditorLayerreference is unchanged, (b) the resolved asset is the same reference, and (c) the layer has no timeline evaluation this frame (evaluatedById.get(layer.id)is undefined). Animated or just-edited layers fall through to the existing build path. Result: in a static scene,frame.layersentries become reference-stable across frames.syncLayersfast-path (src/renderer/pipeline-manager.ts): keep aMap<string, RenderableLayerPass>of last frame's entry per layer id; when the incoming entry is reference-equal, skipcreateLayerSignatureand the signature comparison entirely for that layer (the existinglayerSignaturesmap stays as the slow-path mechanism). Clear this map whereverlayerSignaturesis cleared/deleted (lines 196, 411).src/renderer/create-webgpu-renderer.ts:29): iterate in reverse insidesyncLayersor build a reversed array once inbuildRendererFrame— pick the smaller diff; do not mutateframe.layersin place.Re-run both Scenario A and B measurements; append before/after numbers to
profile.md. Then remove all step-1 instrumentation.Verify: Scenario A
buildMs + syncMsreduced vs step 1 (numbers inprofile.md);grep -rn "TEMP instrumentation" src/→ no matches.Step 5: Behavioral regression checks (manual, in
bun dev)These guard the classic fast-path failure mode — stale canvas:
.labproject → renders correctly.Verify: all six checks pass;
bun test && bun run typecheck:tsc && bun run lint→ exit 0.Test plan
src/lib/editor/__tests__/parameter-schema.test.ts(model after the plan-001 tests there).profile.md.Done criteria
plans/007-artifacts/profile.mdexists with Scenario A and B numbers, the gate decision, and (if step 4 ran) before/after comparisonparameterValuesSignatureno longer callslocaleCompare(grep -n "localeCompare" src/lib/editor/parameter-schema.ts→ no matches)grep -rn "TEMP instrumentation" src/→ no matches (instrumentation fully removed)grep -n "WeakMap" src/renderer/contracts.tsshows the new entry memo, and the step-5 manual matrix is recorded in PR notesbun test,bun run typecheck:tsc,bun run lintall exit 0git status)plans/README.mdstatus row updated (including the "below threshold, stopped after step 2" outcome if that's what happened)STOP conditions
Stop and report back (do not improvise) if:
EditorLayerobject in place (breaks the WeakMap identity assumption — report the action name and line).parameterValuesSignatureorvalueSignatureturns out to have callers beyondpipeline-manager.tsand tests (signature format would then be load-bearing elsewhere).use-editor-renderer.tsrender loop differs structurally from the excerpt (drift).Maintenance notes
packages/shader-lab-react/src/renderer/) has parallel copies ofpipeline-manager.tsandcontracts.ts-equivalent logic; whether to mirror these fast-paths is a plan-006 consolidation-design question — flag it there rather than hand-porting.EditorLayerthat affects rendering must ensure it flows throughcreateLayerSignature(slow path) — the fast path is reference-based and unaffected, but the slow path remains the correctness backstop.profile.mdas the benchmark fixtures.