prag-matt-ic/threenix-plugin

threenix-optimize-tsl

Review and refactor TSL shader node graphs for performance, clarity, and portability.

Quelltext ansehen
Originales Skill-Dokument

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)

  1. 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/geometryNode is 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-webgpu skill 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.
  1. 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 (use vertexStage() instead).
  • Particle storage: audit buffer bindings, padded strides, and compatible vec3 + scalar pairs using Particle buffer management.
  1. 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.
  1. Refactor (apply changes)
  • Move work out of fragment when acceptable: use vertexStage(); use varying() only when fragment truly needs it.
  • Cache and reuse: use .toVar() for repeated expressions; .toConst() or float/vec* for literals.
  • Explicit conversions: use float/vec2/vec3/vec4 or .toVec*() for clear types.
  • Reduce branching: replace If/Else with mix/step/smoothstep when 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 exported Fn() for tree-shaking.
  • Use .setLayout({ name, type, inputs }) for exported helpers to lock in typings.
  • Uniform updates: use uniform() for dynamic values; prefer uniform.onFrameUpdate/onRenderUpdate/onObjectUpdate over captured mutable JS state.
  • Keep interfaces lean: only emit nodes/varyings that are actually consumed; preserve slot semantics.
  1. Output (deliverables)
  • Measured comparison, when captured: use $threenix-benchmark-webgpu with 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
IssueLocationSeverityFix summary
  • Refactor plan (bulleted, 5-10 lines).
  • Refactored code: updated TSL TypeScript node code.
  • Change diff: minimal unified diff or changes tool 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 .w when 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 .xyz when changing position so a whole-vector assignment cannot erase lifetime in .w.
  • Allocate CPU-packed data as Float32Array(count * 4) with offsets index * 4; changing only the TSL type from vec3 to vec4 reinterprets 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 .xyz to positionNode and .rgb to colorNode when .w has another meaning.
  • Pack small metadata fields into vec2/vec4 when it removes bindings. Do not promote every scalar or vec2 to vec4: storage arrays of f32 and vec2<f32> already have 4- and 8-byte strides. Nor does changing a lone vec3 to an unused vec4 save 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:

ts
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().xyz

Budget 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 * stride against maxStorageBufferBindingSize and the allocation against maxBufferSize. 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; useMemo alone 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 argumentsPattern 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.

aus demselben Repository

Weitere Skills

Alle Skills