Skip to content

TSL performance optimizations #34386

Description

@mrdoob

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)

  1. 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×).
  2. 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.
  3. PhysicalLightingModel computes the dielectric multiscatter pair twice (indirectDiffuse and indirectSpecular) and Ess four times. Caching on the model: −53 nodes, identical math.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    TSLThree.js Shading Language

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions