Aus dem Quell-Repository gerendert; Überschriften, Beispiele, Code, Tabellen, Links und Bilder bleiben erhalten.
Review and refactor TSL node graphs for performance, clarity, and portability
Goal
Review and refactor the provided TSL TypeScript shader logic (nodes + material slots) to reduce GPU cost and improve readability without visible regressions.
For full TSL documentation refer to: https://github.com/mrdoob/three.js/wiki/Three.js-Shading-Language
Success criteria
- Visual output is indistinguishable under normal viewing (small numeric drift is acceptable).
- No feature regressions; public APIs (uniforms, exported functions, material slot assignments) remain compatible unless explicitly justified.
- Fewer or cheaper instructions in the bottleneck stage and no added texture fetches unless justified.
- Code stays in TSL (no raw GLSL strings).
Inputs
- Current TSL sources (TypeScript) and where they are bound (material slots, post-processing pass, compute).
- (Optional) Active light count, material flags, pipeline notes, target backend (WebGL2/WebGPU).
Procedure (follow in order)
- Establish evidence + stage mapping
- State the suspected bottleneck and the evidence for it. Treat vertex/fragment/bandwidth limits as hypotheses until profiling supports them; pass timing alone does not identify the limiting stage.
- Map the heavy work to stage:
material.positionNode/geometryNodeis vertex-stage,material.colorNode/fragmentNode/postprocessing is fragment-stage. - Note any
vertexStage()/varying()usage and whether it matches the actual consumption site. - When benchmarking is requested or a suitable local fixture is available, use the standalone
$threenix-benchmark-webgpuskill to measure the actual target graph. Capture its baseline before editing and an unchanged repeat to establish noise. If that skill or a faithful fixture is unavailable, state the limitation and keep performance estimates unmeasured.
- Static audit (find issues)
- Unused or redundant nodes, uniforms, varyings, or cached vars (
toVar). - Implicit or unclear type conversions; prefer explicit
float/vec*or.to*(). - Hot ops in fragment: trig (
sin/cos),pow,exp, divisions, conditionals/loops, multiple texture fetches. - Recomputation: repeated expressions or UV transforms; missing
toVar()/toConst()where reuse matters. - Interface bloat:
varying()created but only used in vertex stage (usevertexStage()instead). - Particle storage: audit buffer bindings, padded strides, and compatible
vec3+ scalar pairs using Particle buffer management.
- Plan (write before changing code)
- List concrete edits you will make, ordered by expected impact (largest -> smallest), tied to the identified bottleneck.
- Note any quality trade-offs and why they are acceptable.
- Refactor (apply changes)
- Move work out of fragment when acceptable: use
vertexStage(); usevarying()only when fragment truly needs it. - Cache and reuse: use
.toVar()for repeated expressions;.toConst()orfloat/vec*for literals. - Explicit conversions: use
float/vec2/vec3/vec4or.toVec*()for clear types. - Reduce branching: replace
If/Elsewithmix/step/smoothstepwhen visually safe. - Texture discipline: sample once and reuse; keep UV math minimal; avoid duplicate
texture()calls. - Function hygiene (TS safe + pure):
- Use
Fn()for reusable node functions. - Prefer typed tuple/object params (e.g.,
[t, a]: [t: VarNode, a: VarNode]). - Add
/*#__PURE__*/(or/*@__PURE__*/) to exportedFn()for tree-shaking. - Use
.setLayout({ name, type, inputs })for exported helpers to lock in typings. - Uniform updates: use
uniform()for dynamic values; preferuniform.onFrameUpdate/onRenderUpdate/onObjectUpdateover captured mutable JS state. - Keep interfaces lean: only emit nodes/varyings that are actually consumed; preserve slot semantics.
- Output (deliverables)
- Measured comparison, when captured: use
$threenix-benchmark-webgpuwith the same fixture/settings and saved baseline after edits. Inspect output differences and repeat A/B runs before claiming a win. Report GPU pass timing separately from stage hypotheses and application FPS; include artifact paths and any inconclusive result. - Findings table
| Issue | Location | Severity | Fix summary |
|---|
- Refactor plan (bulleted, 5-10 lines).
- Refactored code: updated TSL TypeScript node code.
- Change diff: minimal unified diff or
changestool entries. - Impact estimate: which stage should get cheaper and why (e.g., removed N trig ops per fragment), clearly distinguished from measured results and compiler-dependent assumptions.
- TODOs: further safe optimizations or optional quality dials.
Checks (single consolidated checklist)
- Bottleneck-aligned: Optimization focuses on the limiting stage (vertex vs fragment).
- Stage-correct:
vertexStage()/varying()usage matches where results are consumed. - Type clarity: conversions are explicit; no accidental JS math or type drift.
- Redundancy removed: repeated work cached or hoisted with
toVar()/toConst(). - Branching minimized: replaced with branchless math where safe.
- Texture fetches minimized: no duplicate samples; UV math kept light.
- Function hygiene:
Fn()usage is pure, typed, and layouted when exported. - Interface stable: material slots, uniforms, and public function signatures remain compatible.
- Particle buffers: packed lanes preserve initialization/reset semantics, CPU strides match GPU layout, and each stage fits the device's binding limits.
- Readability maintained: clear naming; minimal noise; portable across WebGL2/WebGPU where possible.
Notes & guardrails
- Do not change color spaces, tonemapping, or gamma unless explicitly requested.
- Preserve semantic behavior; call out any intentional approximations.
- Avoid raw GLSL strings or
onBeforeCompile; keep logic in TSL nodes. - If a backend limitation applies (WebGL2 vs WebGPU), document it explicitly.
Particle buffer management
Pack related data into existing lanes
For 32-bit floats, a WGSL storage array<vec3<f32>> has a 16-byte element stride, despite each vector containing only 12 bytes of data. array<vec4<f32>> also has a 16-byte stride. A separate scalar storage array adds 4 bytes per particle and another binding: packing vec3 + float into one vec4 reduces their combined storage from 20 to 16 bytes per particle and two bindings to one. This follows WGSL alignment and size rules; it is a layout saving, not a measured frame-time improvement.
- Prefer position
.xyz+ lifetime.w, velocity.xyz+ scale.w, or colour.rgb+ palette/sprite index.wwhen the values share a particle count and compatible access patterns. - Document every lane at allocation. Update all initialization, spawn, reset, compute, rendering, and CPU upload/readback consumers together. Mutate
.xyzwhen changing position so a whole-vector assignment cannot erase lifetime in.w. - Allocate CPU-packed data as
Float32Array(count * 4)with offsetsindex * 4; changing only the TSL type fromvec3tovec4reinterprets the data incorrectly. Three's WebGPU backend pads three-component storage attributes, but padding does not populate your extra semantic lane. - Keep geometric math on
.xyz: distance, length, normalization, and dot products must not include lifetime or metadata. Explicitly pass.xyztopositionNodeand.rgbtocolorNodewhen.whas another meaning. - Pack small metadata fields into
vec2/vec4when it removes bindings. Do not promote every scalar orvec2tovec4: storage arrays off32andvec2<f32>already have 4- and 8-byte strides. Nor does changing a lonevec3to an unusedvec4save memory. - Preserve integer semantics: float lanes can represent small palette/batch indices exactly, but are not a substitute for arbitrary 32-bit integer IDs or flags. Use integer storage or an explicit bit-preserving representation when required.
TSL pattern, with particleCount supplied by the owning component:
import { Fn, instanceIndex, instancedArray, vec4 } from 'three/tsl'
// xyz = position, w = remaining lifetime
const positionLifeBuffer = instancedArray(particleCount, 'vec4')
const initialize = Fn(() => {
positionLifeBuffer.element(instanceIndex).assign(vec4(0, 0, 0, 1))
})().compute(particleCount)
// Inside compute: use element(instanceIndex).xyz and .w separately.
const positionNode = positionLifeBuffer.toAttribute().xyzBudget bindings per shader stage
8 is the default `maxStorageBuffersPerShaderStage` limit in core WebGPU, not a universal maximum number of buffers per program or scene. It counts storage-buffer binding entries visible to a stage across the pipeline layout's bind groups, including read-only storage. Initialization and update compute pipelines have separate budgets. Uniform buffers, vertex buffers, and textures have separate limits. See WebGPU limits.
- Inspect the actual
GPUDevice.limits.maxStorageBuffersPerShaderStage; an adapter advertising more does not mean the created device enabled more. Higher limits must be supported and requested at device creation (requiredLimits; also supported by Three's WebGPU renderer). Prefer packing before raising the application's hardware requirements. - Count generated bindings for each compute/render pipeline, including non-particle storage used by helpers or renderer features. Keep headroom instead of treating all eight slots as particle-owned. Compatibility mode can impose lower vertex/fragment storage limits; verify the target backend rather than assuming core limits everywhere.
- Check
count * strideagainstmaxStorageBufferBindingSizeand the allocation againstmaxBufferSize. Packing reduces binding pressure but does not remove buffer-size limits. - Reuse the same storage node for compute and drawing through
.toAttribute()when appropriate; do not copy GPU results back to the CPU to feed a second render buffer. Verify generated vertex inputs and bindings: vertex attributes have their own budget, and moving a read to a different stage changes where it counts.
Preserve ownership and update behavior
- Keep allocations stable until count or source data changes. Seed static data once; update mutable state on the GPU. Copy cached source arrays before exposing them as mutable storage, but retain a separate immutable rest-position buffer when the simulation needs it.
- Initialize a new allocation before updates or drawing consume it, and reset initialization state when buffers are replaced. Audit disposal on replacement/unmount through the project's existing resource ownership mechanism;
useMemoalone does not free GPU storage. - In-place writes are suitable when each invocation owns its particle and reads only stable inputs. For neighbor reads, use a stable input snapshot/separate output or separate dispatches as appropriate; packing does not fix cross-invocation read/write races. Avoid merging fields written by different invocations without checking ownership of the resulting vector stores.
- Skip or throttle dispatches only when stored render values remain valid. Hiding a mesh does not stop compute. Preserve queued events and the final update that settles opacity/position; use elapsed simulation time rather than one render-frame delta when updates skip frames.
Retrieve particle references through MCP
The installed skill does not include the Threenix monorepo or component sources. When a concrete reference would help, call get_component_reference on the threenix MCP server with one of the following arguments and inspect the returned files[]. Fetch only the reference relevant to the optimization; do not assume local packages/ or apps/ paths exist or copy a component into the user's project just to review it.
| Tool arguments | Pattern to inspect |
|---|---|
{ "slug": "fireworks" } | Position/life, velocity/scale, RGB/sprite frame, and batch/seed/phase/scale-fade metadata packed into vec4s; lane-specific initialization, spawning, and updates; elapsed simulation time for throttled updates. |
{ "slug": "linked-particles" } | Position/life packing; opportunities to combine velocity and seed after auditing all readers/writers. Include link-output storage in binding counts and review neighbor reads during position writes for races. |
{ "slug": "mesh-surface-sampled-particles" } | Separate initial and mutable positions preserve the rest shape; do not remove the immutable buffer merely to reduce the buffer count. |
Treat returned sources as authoritative: inspect their current layout rather than assuming these patterns are unchanged or fully optimized. If the tool or server is unavailable, report that the reference could not be retrieved; do not reconstruct it from local or bundled sources. The self-contained guidance above can still be applied to the user's supplied code.
For buffer refactors, verify initialization, respawn/reset, count changes, lane preservation, binding validation, and visual parity. Measure performance with the same fixture before claiming a speedup.

