Description
Asked Fable to analyze the current TSL architecture and look for performance optimizations:
Summary
The complaint is real, but the expensive part isn't the JS node graph, it's the GPU pipeline compile of an oversized WGSL shader. For one lit cube on Metal: JS node build ≈ 17 ms cold (3 ms warm), Tint parse ≈ 2 ms, MSL+Metal pipeline compile ≈ 180 ms (visible only when the shader isn't already in Metal's on-disk cache). Node count and shader size are the levers, and most of both was redundancy. I found four output-neutral fixes, verified in a scratch copy; the patch is at /private/tmp/claude-501/…/scratchpad/tsl_perf.patch (4 files, +36/−21, applies cleanly), not applied to the repo.
Root causes (all verified with the copy)
LightsNode.analyze() double-analyzes the whole lighting stack (src/nodes/lighting/LightsNode.js:219). It loops over properties.nodes (including the intent VarNodes StackNode.build deliberately skips) and then builds outputNode, which bypasses the same stack. Every intermediate gets usageCount 2, so TempNode emits a var for each one (nodeVar1…nodeVar134 chains like nodeVar5 = nodeVar4;). Dropping the loop: fragment WGSL 16.5 KB → 10.3 KB (−38%), same expressions inlined. It also stops the loop from re-running Fn bodies that live in isolate caches (PMREM sampling executed 15× instead of 4×).
ContextNode.setup() returns the child it built under the context, then Node.build rebuilds that outputNode generically outside the context/isolate cache (src/nodes/core/ContextNode.js:144). The per-cache properties are empty there, so every If() body, bilinearCubeUV, and TextureNode clone executes again and mints garbage nodes (which also land in updateNodes). ContextNode overrides generateNodeType/analyze/generate, so returning null is safe: WGSL byte-identical, cube+env 2823 → 1199 nodes.
PhysicalLightingModel computes the dielectric multiscatter pair twice (indirectDiffuse and indirectSpecular) and Ess four times. Caching on the model: −53 nodes, identical math.
- All flow vars are module-scope
var<private> in WGSL. Tint's MSL backend packs them into a struct passed by pointer, which Metal optimizes badly. Emitting vars that no codes function references as function-local var in main(): pipeline compile −14…17% (measured standalone: 178 → 155 ms, 332 → 277 ms). Compute already had this path (allowGlobalVariables).
Combined effect (real browser, cold Metal compile via unique constant)
| scene |
nodes |
JS build |
GPU compile |
| 1 point light |
862 → 809 |
16.5 → 15.8 ms |
209 → 185 ms |
| 1 light + env |
3242 → 1542 |
18 → 12 ms |
250 → 208 ms |
| 3 lights + shadows + env |
10656 → 5688 |
48 → 36 ms |
404 → 333 ms |
Validation: 57 WebGPU examples run against the patched builder with no shader/page errors; deterministic screenshots (e2e injection) vs. baseline are pixel-identical except sub-noise (worst 0.01% of pixels > 24/765 in webgpu_custom_fog).
Not worth it / notes
- JS micro-optimizations (
_getChildren via Object.keys, no Set) were within noise; warm build cost is dominated by node visits, not any one hot function. Cold cost is JIT warm-up.
- Remaining per-light duplication (
Roughness*Roughness, interleavedGradientNoise per shadow, env-rotation matmul repeated in getFace/getUV) is small and Metal CSEs it once vars are local.
- Fix 4 keeps any var whose name appears in the
codes section global (substring check, errs on the safe side). GLSL builder has the same module-scope pattern; I didn't measure ANGLE.
- The new
PhysicalLightingModel fields could use JSDoc like dfg has; indirectSpecular now assumes indirectDiffuse ran first (true for indirect()).
@sunag @Mugen87 Are any of these worth pursuing? Happy to do a PR for each.
Description
Asked Fable to analyze the current TSL architecture and look for performance optimizations:
@sunag @Mugen87 Are any of these worth pursuing? Happy to do a PR for each.