TSL composables
@tresjs/core/webgpu since v5.11. Their API can change in a minor release.@tresjs/core/webgpu has four composables for wiki work:
| Composable | Use it for |
|---|---|
useUniforms / useUniform | Values. Bind refs, getters or Leches controls to uniforms. |
useNodes | Shared graphs that many components read (noise, functions, varyings). Built once. |
useLocalNodes | The graph of one component, built from shared uniforms and nodes. |
useRenderPipeline | Post-processing with three's RenderPipeline. |
They work only in components inside a TresCanvas from @tresjs/core/webgpu.
Values and structure
Keep this rule in mind:
- A value goes in a uniform. A uniform change writes
uniform.value. Three sends it to the GPU, with no graph rebuild and no shader recompile. - A structure change goes through a rebuild. A new graph recompiles every material that uses it.
If a prop changes the color, the strength or the speed, put it in a uniform. Rebuild the graph only when a prop changes its shape: which node to use, or how many times a JavaScript loop runs.
The canvas store
Each canvas has one store with three collections: uniforms, nodes and passes. The store is created when a component first uses a TSL composable.
- First registration wins. When two components register the same name, the second one gets the entry of the first.
- Entries live as long as the canvas. They stay when the component that registered them unmounts, so a uniform or node you register in a
v-ifcomponent stays in the store after it hides. Remove entries withremove*orclear*. Everything is freed when the canvas unmounts. - Scopes. Pass a scope name to keep entries apart (
useUniforms({ uHealth: 100 }, 'player')). Read a scoped entry withuniforms.player.uHealth. - Raw nodes. The store never wraps a node in a Vue proxy.
useUniforms
import { useUniforms } from '@tresjs/core/webgpu'
const props = defineProps<{ density: number }>()
const color = ref('#82dbc5')
const { uColor, uDensity } = useUniforms({ uColor: color, uDensity: () => props.density }, 'fog')
Each value can be a plain value, a ref or a getter. When a ref or getter changes, the uniform value changes. The uniform node stays the same object.
If the uniform already exists, the call reuses the same node. Who changes its value:
- The call that created the uniform owns its value. When that component mounts again (and no other instance of it is mounted), or after a hot update, its new values apply. If a value changes type (a number becomes a color), the uniform gets a new node.
- Other callers do not reset the value. This includes another component that reuses the name, and a second instance of the same component (for example in a
v-for). A ref or getter they pass still writes its later changes.
To give each instance its own value, use one scope per instance:
useUniforms({ uFlicker: () => props.flicker }, `torch-${props.id}`)
The input is converted to a uniform value:
| Input | Uniform value |
|---|---|
number, boolean | the same |
color string ('#ff0000', 'red'), Color | Color |
{ x, y }, { x, y, z }, { x, y, z, w } | Vector2, Vector3, Vector4 |
Vector2/3/4, Matrix3/4 | the same |
TSL node (color('#f00'), vec3(0, 1, 0)) | a typed uniform with the node's value |
Leches controls are refs, so they work directly:
const { color, amplitude } = useControls({ color: '#82dbc5', amplitude: { value: 0.2, min: 0, max: 1 } })
useUniforms({ uColor: color, uAmplitude: amplitude })
A Leches control is a Ref<any>, so the uniform bound to it is typed loosely (LooseUniformNode). TSL accepts it as any node. To get the exact type, cast it once: uColor as UniformNode<'color', Color>.
onBeforeRender, not through a ref. This needs no watcher:const { uTime } = useUniforms({ uTime: 0 })
const { onBeforeRender } = useLoop()
onBeforeRender(({ elapsed }) => { uTime.value = elapsed })
Destructure the uniforms you use every frame. A read through the returned object goes through two proxies and costs about 200 times more than a read from a variable (about 65 ns).
In on-demand render mode, a change from a ref or getter requests a new frame.
Read uniforms
const fog = useUniforms('fog') // one scope
const all = useUniforms() // the root entries and every scope
The result is live: it reads the store each time. It is also reactive to entries that are added, replaced or removed, so you can use it in templates and computed.
useUniform
const uTime = useUniform('uTime', 0) // create or reuse a root uniform
const same = useUniform('uTime') // read it. Throws if it does not exist
Utils
useUniforms also returns removeUniforms(names, scope?), clearUniforms(scope?) and rebuildUniforms(scope?). clearUniforms('root') clears only the root entries. With no argument, it clears everything.
useNodes
import { useNodes } from '@tresjs/core/webgpu'
import { Fn, normalLocal, positionLocal, sin } from 'three/tsl'
useNodes(({ uniforms }) => {
const { uAmplitude, uFrequency } = uniforms.wave
return {
wave: positionLocal.add(normalLocal.mul(sin(positionLocal.y.mul(uFrequency)).mul(uAmplitude))),
}
})
Other components read the same node object:
const { wave } = useNodes()
const fx = useNodes('player') // a scope
The creator gets the store (uniforms, nodes, passes) and the canvas objects (scene, camera, renderer, sizes).
When the creator runs
Once per generation. It does not run again when a value it read from a prop changes. A new node would not reach the components that already built their graphs from the old one, and every material that uses it would recompile. So:
- A value that changes goes in a uniform, and the node reads the uniform.
- A structure change goes through
rebuildNodes(scope?). All creators run again, anduseLocalNodesconsumers that read a replaced node rebuild.
A creator that reads an entry before it exists gets undefined until the next rebuild. A warning in development names the entry. Register entries higher in the tree, or earlier in the same component.
useNodes also returns removeNodes, clearNodes and rebuildNodes.
Good candidates: complex functions, multi-octave noise, reusable effects, shared varyings. Poor candidates: short expressions and math for one component (use useLocalNodes).
useLocalNodes
import { useLocalNodes } from '@tresjs/core/webgpu'
const { colorNode } = useLocalNodes(({ uniforms, nodes }) => ({
colorNode: nodes.wave.mul(uniforms.uStrength),
}))
<TresMeshStandardNodeMaterial :color-node="colorNode" />
It returns a record of shallowRefs. After a rebuild, the refs hold the new nodes and Tres puts them on the material.
When it rebuilds
The creator runs untracked. Reading props.color in it does not make the graph rebuild when props.color changes. It rebuilds only when:
- a source in the second argument changes,
- a store entry that the creator read is replaced, removed or added,
rebuild*starts a new generation, or a hot update replaces the component.
Declare a source only for an input that decides the structure of the graph:
const props = defineProps<{ octaves: number, strength: number }>()
useUniforms({ uStrength: () => props.strength }) // a value: no rebuild
const { colorNode } = useLocalNodes(
({ uniforms }) => ({ colorNode: fbm(positionWorld, props.octaves).mul(uniforms.uStrength) }),
[() => props.octaves], // structure: a JS loop runs `octaves` times
)
If the graph rebuilds many times per second, a warning in development tells you to move the input into a uniform.
Fn(() => ...) runs later, when three builds the shader. Read the entry in the creator and use the variable in Fn:useLocalNodes(({ uniforms }) => {
const strength = uniforms.uStrength // tracked
return { effect: Fn(() => strength.mul(2))() }
})
Install nodes on an object
Some nodes go on an object, such as scene.fogNode or scene.backgroundNode. Return a function instead of a record. It runs after the build. The function it returns undoes the install:
import { fog, rangeFogFactor } from 'three/tsl'
useUniforms({ uFogColor: '#1a1a2e', uNear: 3, uFar: 10 }, 'fog')
useLocalNodes(({ scene, uniforms }) => {
const { uFogColor, uNear, uFar } = uniforms.fog
const fogNode = fog(uFogColor, rangeFogFactor(uNear, uFar))
return () => {
scene.fogNode = fogNode
return () => { scene.fogNode = null }
}
})
On a rebuild, the old cleanup runs before the new install. On unmount, the cleanup runs.
useRenderPipeline
import { useRenderPipeline, useUniforms } from '@tresjs/core/webgpu'
import { bloom } from 'three/addons/tsl/display/BloomNode.js'
const props = defineProps<{ strength: number }>()
useUniforms({ uStrength: () => props.strength })
useRenderPipeline(({ renderPipeline, passes, uniforms }) => {
const sceneColor = passes.scenePass.getTextureNode()
renderPipeline.outputNode = sceneColor.add(bloom(sceneColor, uniforms.uStrength))
})
With a callback, it:
- creates three's
RenderPipelineand ascenePass(the scene with the active camera), - sets
outputNodeto the scene pass, then runs your callbacks, - renders the canvas through the pipeline,
- frees the pipeline and restores normal rendering when the component unmounts.
Mount the effect component to turn the effect on, and unmount it to turn it off:
<BloomEffect v-if="bloomEnabled" :strength="strength" />
Callbacks
useRenderPipeline(
({ renderPipeline, passes }) => { /* main: set outputNode */ },
({ passes }) => { passes.scenePass.setMRT(mrt({ output, velocity })) }, // setup: runs first
)
The callbacks run when the pipeline is created, when the scene or the active camera changes (with a new scene pass), when a store entry they read changes, and on rebuild(). They do not run on re-render. Put dynamic values in uniforms.
Return a record from a callback to register more passes. scenePass is reserved.
useRenderPipeline(({ renderPipeline, scene, camera }) => {
const depthPass = pass(scene, camera)
renderPipeline.outputNode = depthPass
return { depthPass }
})
Read the pipeline
const { renderPipeline, passes, isReady, rebuild, reset, clearPasses } = useRenderPipeline()
Without callbacks, it only reads. It never creates the pipeline and does not keep one alive.
renderPipelineis a ref: the pipeline, ornullwhen no component configures one.rebuild()runs the callbacks again with a new scene pass.reset()frees the pipeline now. The canvas renders without it until the next rebuild.clearPasses()removes and disposes the passes that callbacks registered.scenePassstays, so the pipeline keeps drawing.
One pipeline renders per canvas. If two components configure it, a warning shows and the one mounted last renders. When it unmounts, the other one configures the pipeline again.
useRenderPipeline throws under a WebGLRenderer. It uses the render function of the loop, so it works with the always, on-demand and manual render modes.