You're viewing the v10 deployment.

Typed Resources

Type @react-three/tsl uniforms, nodes, buffers and GPU storage by name across your app with the Register interface, choose strict or loose checking, back uniforms at runtime with configureTSL, and know what an unregistered read is typed as.

Inline creators are always typed: useUniforms({ uTime: 0 }) returns uTime as a float uniform in the component that wrote it. Reading a uniform somewhere else — another component, useFrame, useThree, a creator, a scope — goes through a string name, and a string can't carry a type. Without registration such a read is loose: usable in any TSL expression, but not exactly typed. Registering your uniforms once types them by name for the whole app. Nodes, buffers and GPU storage register the same way.

Register your uniforms

Define the uniforms as plain objects, then register their types with @react-three/tsl:

// uniforms.ts
import { Color } from 'three/webgpu'
import { configureTSL } from '@react-three/tsl'

export const globalUniforms = { uTime: 0, uColor: new Color('hotpink') }
export const playerUniforms = { uHealth: 1, uTint: new Color('white') }

declare module '@react-three/tsl' {
  interface Register {
    uniforms: typeof globalUniforms
    scopes: { player: typeof playerUniforms }
  }
}

// Create them up front, so the registered types hold before the first frame
configureTSL({ uniforms: globalUniforms, scopes: { player: playerUniforms } })

Import that file once, for example next to your <Canvas>. From then on:

const all = useUniforms()
all.uTime.value // number
all.player.uHealth.value // number
all.uTiem // ❌ type error: not registered

const player = useUniforms('player') // 'player' autocompletes
useUniform('uTime').value // number, no generic needed

useFrame(({ uniforms }, delta) => {
  uniforms.uTime.value += delta // state.uniforms is typed too
})
useThree((s) => s.uniforms.uColor) // and in useThree

useNodes(({ uniforms }) => ({
  tinted: mix(uniforms.uColor, uniforms.player.uTint, uniforms.uTime), // and in creators
}))

Creators are checked against what you registered. New keys are always allowed:

useUniforms(() => ({ uTime: 0, uWind: new Vector3() })) // ✅
useUniforms(() => ({ uTime: 'fast' })) // ❌ uTime is registered as a number
useUniforms({ uHealth: new Color() }, 'player') // ❌ uHealth is a number in 'player'

What stays loose

Registration never makes existing code stricter than it has to:

CallNot registeredRegistered
useUniforms(() => ({...}))Inferred from the creatorInferred; registered keys keep their type
useUniforms() / state.uniforms / a creator's uniformsLooseTyped; unknown root keys are errors
useUniforms('player') (registered scope)LooseTyped
useUniforms('enemy') (other scope)LooseLoose
useUniforms<{ blur: number }>('fx')Typed by the explicit schemaSame
useUniform('uTime')Loose unless you pass a typeTyped by name

A loose read is a LooseUniformNode, and it works in TSL as is:

const { uTime, uColor } = useUniforms()
material.colorNode = mix(color('red'), uColor, sin(uTime.mul(2)))
useFrame((_, delta) => (uTime.value += delta))

Why it is typed that way: a read by name can't know what the uniform under that name holds, and three's types only give the operator methods (.mul, .add, swizzles, .toVar()) to a node whose type is known: a float, a vec3, a color. A plain "some uniform" type has none of them, which is why these reads used to need a cast. So an unregistered read is typed as a node that has every member that any of three's uniform nodes has, each typed any. Every operator, TSL function and material slot accepts it, and a misspelt member (uTime.valeu) is still an error.

It's a deliberate simplification, and it gives up two things. What its own methods return is any (TSL functions such as sin() or mix() still return typed nodes), and nothing checks that an operation fits the uniform's real type: cross() wants two vec3s and will take it. Cast once when you want the exact type; a single as works, since every member is any:

import type { Color, UniformNode, Vector3 } from 'three/webgpu'

const uDir = uniforms.uDir as UniformNode<'vec3', Vector3>
const facing = cross(uDir, normalView) // checked as vec3
const uTint = uniforms.uTint as UniformNode<'color', Color>
uTint.value.set('red') // .value is a Color

Feedback wanted. This loose default is new in the v10 alpha. If it gets in your way, or lets through a mistake you'd want TypeScript to catch, please open or comment on a GitHub issue.

UniformNode here is three's (<NodeType, Value>). A schema does the same without a cast: useUniforms<{ blur: number }>('fx'), useUniform<Color>('uTint'), or uniforms.scope<{ uHealth: UniformNode<'float', number> }>('player') in a creator. Registering gives exact types everywhere and also catches a misspelt name, which no loose read can.

A root read (useUniforms().x, state.uniforms.x, a creator's uniforms.x) may name a scope as well as a uniform, so state.uniforms.player.uHealth is loose the same way. Cast the uniform, not the scope.

Strict or loose

By default, reading an unregistered key from the root (useUniforms(), state.uniforms, a creator's uniforms) is a type error. That is what catches typos. If your app creates many uniforms on the fly and reads them from the root elsewhere, opt out:

declare module '@react-three/tsl' {
  interface Register {
    uniforms: typeof globalUniforms
    strict: false
  }
}

Registered keys stay fully typed. Unregistered root keys are allowed and read loose, so a typo compiles. strict covers registered nodes, buffers and GPU storage the same way.

Register your nodes

Nodes register the same way, by their node types. nodes holds root-level nodes, nodeScopes named scopes:

import type { Node } from 'three/webgpu'
import { Fn, sin, vec3 } from 'three/tsl'
import type { CreatorState } from '@react-three/tsl'

export const rimLightFn = Fn(() => vec3(1, 0.5, 0))
export const sharedNodes = ({ uniforms }: CreatorState) => ({
  wobble: sin(uniforms.uTime.mul(2)).toVar(),
  rimLight: rimLightFn,
})

declare module '@react-three/tsl' {
  interface Register {
    nodes: ReturnType<typeof sharedNodes> // or spelled out: { wobble: Node<'float'>; rimLight: typeof rimLightFn }
    nodeScopes: { fx: { blur: Node<'vec4'> } }
  }
}

Then useNodes().wobble, useNodes('fx').blur, state.nodes.wobble and a creator's nodes.rimLight() are typed by name, typos on the root are errors (unless strict: false), and creators are checked: useNodes(() => ({ wobble: vec3(1) })) is an error once wobble is registered as a float node. A scope only known at runtime (useNodes(creator, props.scope)) is not checked.

Unlike uniforms there is no configureTSL for nodes: a registered node exists once the component that creates it (useNodes(sharedNodes)) has rendered. A component rendered before that reads undefined, whatever the type says.

Without registration a node read is a loose LooseNode, for the same reason a uniform read is a LooseUniformNode: it has every member of three's nodes and of an Fn(), typed any, and can be called, so nodes.wobble.mul(2), material.colorNode = nodes.wobble and nodes.rimLightFn() compile. One as makes it exact: nodes.blur as Node<'vec4'>, nodes.rimLight as typeof rimLightFn.

Register buffers and GPU storage

useBuffers and useGPUStorage register the same way: buffers / bufferScopes and gpuStorage / gpuStorageScopes, each by the type the hook stores.

import type { StorageBufferNode, StorageTexture } from 'three/webgpu'

declare module '@react-three/tsl' {
  interface Register {
    buffers: { positions: StorageBufferNode<'vec3'>; seeds: Float32Array }
    bufferScopes: { particles: { colors: StorageBufferNode<'vec4'> } }
    gpuStorage: { heightMap: StorageTexture }
    gpuStorageScopes: { terrain: { normals: StorageTexture } }
  }
}

Then useBuffers().positions.element(instanceIndex), useBuffers('particles').colors (the scope name autocompletes), state.buffers.seeds[0], texture(useGPUStorage().heightMap) and a creator's buffers / gpuStorage are typed by name, root typos are errors (unless strict: false), and creators are checked: useBuffers(() => ({ positions: new Float32Array(12) })) is an error once positions is registered as a buffer node. A scope only known at runtime is not checked. As with nodes, a registered entry exists only once the component that creates it has rendered.

Unregistered reads are not loose here. A read is the union of what the hook stores (BufferLike: typed arrays, buffer attributes and buffer nodes; StorageLike: storage textures and nodes), since a CPU array and a GPU buffer node have little in common to make loose. Narrow it with one as (buffers.positions as StorageBufferNode<'vec3'>) or a schema (useBuffers<{ positions: StorageBufferNode<'vec3'> }>('particles')). With strict: false, an unregistered root key reads as that union too.

configureTSL

Register only describes types. configureTSL makes them true at runtime: it creates the uniforms on every primary canvas's RootState, including canvases already mounted, before any component runs. Secondaries and portals see them like any shared uniform.

  • A hook that declares the same name later gets the configured node, not a new one.
  • Calling it again adds to the configuration. Uniforms that already exist are never replaced.
  • It is optional. Without it, a registered uniform only exists once some component creates it, so a read before that returns undefined even though its type says otherwise.

Pass the same objects you registered, so the types and the runtime come from one place.

Notes

  • Register is global to your TypeScript program, like any module augmentation. Declare it once, with every resource family you register in the same interface.
  • Each family is independent: registering nodes leaves uniforms loose, and so on. strict applies to every family you register.