Warmup
Discover a scene, prepare its GPU resources, and restore every temporary mutation before play.
Warmup moves resource discovery, uploads, and representative shader compilation into an explicit startup phase. You provide the renderer operations through a small driver. The application keeps its renderer, assets, loop, and loading UI.
Demo
Warmup steps
'use client'
import { Warmup } from '@joycostudio/xyz/three'
import { useEffect, useRef, useState } from 'react'
import { color, mix, normalView, positionLocal, screenUV, uniform } from 'three/tsl'
import * as THREE from 'three/webgpu'
import { DemoPanel } from '@/components/demos/demo-ui'
export default function SceneDemo() {
const host = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [steps, setSteps] = useState<string[]>([])
useEffect(() => {
if (!host.current) return
const element = document.createElement('canvas')
element.className = 'block size-full'
element.setAttribute('aria-label', 'Animated shader background and cube prepared with Warmup')
host.current.appendChild(element)
let disposed = false
const log = (step: string) => {
if (!disposed) setSteps((previous) => [...previous, step])
}
setSteps([])
setError(null)
const renderer = new THREE.WebGPURenderer({ canvas: element, antialias: true })
let initialized = false
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100)
camera.position.z = 3
// Build both shader graphs once; only their shared time uniform changes per frame.
const elapsed = uniform(0)
const backgroundWave = screenUV.x.mul(8).add(screenUV.y.mul(5)).add(elapsed).sin().mul(0.5).add(0.5)
scene.backgroundNode = mix(color('#071427'), color('#187c88'), backgroundWave)
const material = new THREE.MeshBasicNodeMaterial()
const cubeWave = positionLocal.y.mul(10).add(positionLocal.x.mul(4)).sub(elapsed.mul(2)).sin().mul(0.5).add(0.5)
material.colorNode = mix(color('#7352ef'), color('#ffbd79'), cubeWave).mul(normalView.z.abs().mul(0.4).add(0.6))
const cube = new THREE.Mesh(new THREE.BoxGeometry(), material)
cube.rotation.x = 0.35
scene.add(cube)
let previousTime: number | undefined
function animate(time: number) {
if (previousTime !== undefined) elapsed.value += Math.min((time - previousTime) / 1000, 0.1)
previousTime = time
cube.rotation.y = elapsed.value * 0.4
renderer.render(scene, camera)
}
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
let ready = false
const syncLoop = () => {
const playing = ready && !disposed && !reducedMotion.matches && !document.hidden
previousTime = undefined
if (initialized) void renderer.setAnimationLoop(playing ? animate : null)
}
const resize = (width: number, height: number) => {
if (disposed || width <= 0 || height <= 0) return
renderer.setSize(width, height, false)
camera.aspect = width / height
camera.updateProjectionMatrix()
if (ready) renderer.render(scene, camera)
}
resize(element.clientWidth, element.clientHeight)
const observer = new ResizeObserver(([entry]) => resize(entry.contentRect.width, entry.contentRect.height))
observer.observe(element)
let warmupFrames = 0
const warmup = new Warmup({
pause: () => {
void renderer.setAnimationLoop(null)
log('Animation paused')
},
resume: () => {
if (disposed) return
ready = true
syncLoop()
log(
reducedMotion.matches
? 'Animation paused (reduced motion)'
: document.hidden
? 'Animation paused (tab hidden)'
: 'Animation resumed'
)
},
initTexture: (texture) => {
renderer.initTexture(texture)
log('Texture uploaded')
},
compile: async (scene, camera) => {
log('Compile background and cube shaders')
await renderer.compileAsync(scene, camera)
log('Shaders compiled')
},
render: () => {
renderer.render(scene, camera)
log(`Render warmup frame ${++warmupFrames}`)
},
})
reducedMotion.addEventListener('change', syncLoop)
document.addEventListener('visibilitychange', syncLoop)
const preparation = (async () => {
log('Initialize renderer')
await renderer.init()
initialized = true
if (disposed) return
log('isWebGPUBackend' in renderer.backend ? 'WebGPU ready' : 'WebGL2 fallback ready')
log('Warmup started')
await warmup.start(scene, camera)
log('Warmup complete')
})().catch(() => {
if (disposed) return
ready = false
syncLoop()
log('Warmup failed')
setError('The scene could not be prepared. Check that WebGPU or WebGL2 is available, then reload.')
})
return () => {
disposed = true
observer.disconnect()
reducedMotion.removeEventListener('change', syncLoop)
document.removeEventListener('visibilitychange', syncLoop)
warmup.dispose()
syncLoop()
element.remove()
// Let pending initialization or compilation finish before releasing GPU resources.
void preparation.then(() => {
cube.geometry.dispose()
cube.material.dispose()
if (initialized) renderer.dispose()
})
}
}, [])
return (
<DemoPanel className="[&>[data-slot=demo]]:p-0">
<div data-slot="warmup-frame" className="relative h-[32rem] w-full overflow-hidden bg-black sm:h-[28rem]">
<div ref={host} className="absolute inset-0" />
<div className="absolute left-4 top-4 max-w-[calc(100%-2rem)] bg-black/70 p-3 font-mono text-xs leading-5 text-white/70">
<p className="mb-2 text-white">Warmup steps</p>
<div role="log" aria-label="Warmup steps">
<ol className="list-inside list-decimal">
{steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</div>
{error ? (
<p role="alert" className="mt-2 text-white">
{error}
</p>
) : null}
</div>
</div>
</DemoPanel>
)
}
Usage
import { Warmup } from '@joycostudio/xyz/three'
// Initialize your existing WebGPURenderer before preparing the scene.
await renderer.init()
// Connect your renderer, scene, camera, and animation callback.
const warmup = new Warmup({
pause: () => renderer.setAnimationLoop(null),
resume: () => renderer.setAnimationLoop(animate),
initTexture: (texture) => renderer.initTexture(texture),
compile: async (scene, camera) => {
await renderer.compileAsync(scene, camera)
},
render: () => renderer.render(scene, camera),
})
await warmup.start(scene, camera) // Prepare resources, then resume animation.
warmup.audit(scene) // Check for resources added after warmup.
// When the scene owner is removed:
warmup.dispose()
Warmup lifecycle
Load your assets and initialize the renderer before calling start(scene, camera). Warmup prepares the resources already available in your scene; it does not load assets or create a renderer. The demo's renderer-initialization steps happen before this lifecycle begins.
View Trazo source
flow TD
assets["Assets loaded and renderer ready"] --> pause["Pause driver"]
pause --> hooks["Await hooks in order"]
hooks --> discovery["Discover and expose one scene"]
discovery --> textures["Initialize textures"]
textures --> compile["Compile representative programs"]
compile --> first["Draw exposed state"]
first --> restore["Restore scene and reverse hook cleanup"]
restore --> second["Draw restored state"]
second --> resume["Resume driver"]Preparation and playback
- Pause animation.
pause()stops your normal loop so application state stays still while Warmup temporarily changes the scene. - Expose resources. Registered
Warmable.warmup()hooks run in registration order and may return promises. Each hook settles before the next starts, so custom asynchronous work finishes while the driver remains paused. Warmup then inventories the scene and temporarily exposes hidden resources, saving the original visibility, culling, layer, and draw state for restoration. - Initialize textures.
initTexture(texture)runs for each discovered texture. Warmup waits for all returned promises before continuing; a procedural scene without textures skips these calls. - Compile shaders.
compile(scene, camera)prepares the exposed scene's shaders and pipelines. Warmup awaits it before drawing. In this demo, that includes both the background and cube shaders. - Render warmup frame 1.
render()draws the exposed scene to exercise uploads and draw-time setup that compilation alone may not cover. These resources are recorded for later auditing. - Restore the scene. Temporary scene changes are undone, then cleanup functions returned by the registered hooks run in reverse registration order. The scene is back in its intended playback state before the next draw.
- Render warmup frame 2.
render()draws the restored scene, replacing the temporary preparation view. - Resume animation.
resume()hands control back to your normal loop. After a successful run, thestart()promise resolves andaudit(scene)can report resources introduced later.
Completion and reuse
One Warmup instance runs once. Repeated calls to start() return the same promise, including after a failed run; they do not restart preparation. Register exceptional entities with .add() before starting. After success, .audit() only warns about new resourcesโit does not warm them automatically.
Failure and disposal
If preparation fails after pausing, Warmup restores the scene and runs the registered cleanups, attempts the restored-scene draw, and calls resume() before rejecting start(). Handle that rejection in the scene owner. If pause() itself throws, preparation never starts and resume() is not called.
Calling .dispose() prevents later hooks and preparation stages from starting. If a hook is pending, Warmup waits for it to settle, then runs all returned cleanups in reverse order (including a cleanup returned after disposal) and calls resume() once. Otherwise, temporary state is restored immediately and the paused driver resumes once. Pending uploads or compilation may still finish, but subsequent stages are skipped. Your resume() callback should check whether the scene owner has been removed before restarting animation.
Disposal is idempotent and does not free your renderer, materials, or geometry. The scene owner remains responsible for stopping animation and releasing those resources after pending GPU preparation has settled.
API
new Warmup
new Warmup(driver: WarmupDriver)
Creates one renderer-scoped warmup owner. Your application owns the renderer and animation loop; all callbacks below are required to connect preparation to that setup.
| Argument | Description |
|---|---|
driver.pause | () => void. Stops normal animation before Warmup temporarily exposes hidden scene resources, keeping application state still during preparation. |
driver.resume | () => void. Returns control to your animation loop after restoration, including after failure or disposal while paused. Respect your application's visibility, reduced-motion, and teardown state before restarting. |
driver.initTexture | (texture: THREE.Texture) => void | Promise<void>. Receives each discovered texture. Connect to renderer.initTexture(texture) to allocate and upload textures ahead of their first draw. |
driver.compile | (scene: THREE.Scene, camera: THREE.Camera) => Promise<void>. Receives the exposed scene and camera. Connect to renderer.compileAsync(scene, camera) and await completion to prepare shaders and pipelines. |
driver.render | () => void. Draws your existing scene and camera without advancing animation, exercising uploads and draw-time setup. Also redraws the scene after restoration. |
// After renderer.init(), connect your existing scene, camera, and loop.
const warmup = new Warmup({
pause: () => renderer.setAnimationLoop(null),
resume: () => renderer.setAnimationLoop(animate),
initTexture: (texture) => renderer.initTexture(texture),
compile: async (scene, camera) => {
await renderer.compileAsync(scene, camera)
},
render: () => renderer.render(scene, camera),
})
add
warmup.add<T extends Warmable>(entity: T): T
Registers an exceptional entity and returns it unchanged. Call before start(). Adding to a disposed instance throws.
| Argument | Description |
|---|---|
entity | An object with warmup(): void | WarmupCleanup | Promise<void | WarmupCleanup>. Hooks may expose representative state or prepare external resources. Each is awaited in order; returned cleanup functions run in reverse registration order. |
// Expose a material variant that is not normally assigned to the mesh.
warmup.add({
warmup() {
const original = mesh.material
mesh.material = alternateMaterial
return () => {
mesh.material = original
}
},
})
Asynchronous hooks
A hook can await custom preparation that scene discovery cannot perform. For example, compile a separate scene used only by a later transition:
warmup.add({
async warmup() {
await renderer.compileAsync(transitionScene, transitionCamera)
},
})
Hooks finish in registration order before discovery begins. An async hook may also return a cleanup function; Warmup runs returned cleanups in reverse order after restoring the scene. If a hook changes state and then throws before returning a cleanup, it must undo that partial change itself.
Calling dispose() does not interrupt an asynchronous hook's own operation.
Use that operation's cancellation API when available, and await the start()
promise before releasing resources it still uses.
start
warmup.start(scene: THREE.Scene, camera: THREE.Camera): Promise<void>
Runs preparation once: expose, upload, compile, render, restore, and resume. Initialize the renderer and load assets first. Repeated calls return the same promise, including after failure; calls after disposal resolve without running preparation.
| Argument | Description |
|---|---|
scene | Scene containing the resources to prepare. Temporary visibility, culling, layer, and draw-state changes are restored before playback. |
camera | Camera used for compilation. Its layers are temporarily enabled and then restored. |
await warmup.start(scene, camera)
// Temporary scene changes are restored and animation has resumed.
audit
warmup.audit(scene: THREE.Scene): void
After successful preparation, warns once for each newly discovered geometry, material, or texture. Does nothing before success or after disposal, and does not upload or prepare new resources.
| Argument | Description |
|---|---|
scene | Scene to inspect for resources introduced after warmup. |
await warmup.start(scene, camera)
scene.add(lateMesh)
warmup.audit(scene) // Warns about resources that were not prepared.
dispose
warmup.dispose(): void
Cancels subsequent work and clears registered entities. Pending hooks settle before cleanup and resume; otherwise restoration is immediate. Resumes the driver once if paused. Takes no arguments and is idempotent. Await the in-flight start() promise before releasing renderer resources used by asynchronous work.
Representative state and first-use limits
Discovery inventories the scene supplied to start(): geometry and materials,
texture references in material slots, uniforms and TSL object graphs, and scene
background or environment textures. Render-target textures belong to their render
passes. Discovery does not enumerate every shader variant or effect-graph branch.
Use hooks to expose exceptional state before discovery, returning a cleanup that
restores exactly what changed. For states that cannot be exposed together, a hook
can perform representative draws and restore its state with try/finally. Keep
one startup owner in control of a renderer's pause/resume sequence.
Preparation draws can advance temporal effects and Three-managed time nodes.
Reset effect history through the effect's own API if needed. Keep a loading cover
active until start() resolves when temporarily exposed content must stay hidden;
Warmup does not create a loading UI.
New shader variants, larger buffers, or later assets can still require first-use
work. audit() diagnoses newly discovered resources after a successful run; it
does not perform another warmup.
Low-level scene discovery
discoverScene(scene, camera) is also exported for specialized startup drivers.
It returns { geometries, materials, textures, restore }. The first three are
maps recording the discovered resource and its owner; restore() reverses the
temporary exposure and is idempotent. Discovery itself performs no uploads,
compilation, or rendering.
import { discoverScene } from '@joycostudio/xyz/three'
const discovery = discoverScene(scene, camera)
try {
await Promise.all([...discovery.textures.keys()].map((texture) => renderer.initTexture(texture)))
await renderer.compileAsync(scene, camera)
// Perform your representative draw with the appropriate renderer/target.
} finally {
discovery.restore()
}
On this path, the application owns pausing, target selection, the representative
draw, and resuming. Prefer Warmup unless you need to integrate discovery into an
existing preparation lifecycle. Never leave the exposed state active across
normal application animation.