Threeclass

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.

    Warmup lifecycleAssets loaded and renderer readyPause driverAwait hooks in orderDiscover and expose one sceneInitialize texturesCompile representative programsDraw exposed stateRestore scene and reverse hook cleanupDraw restored stateResume driver
    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

    1. Pause animation. pause() stops your normal loop so application state stays still while Warmup temporarily changes the scene.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.
    7. Render warmup frame 2. render() draws the restored scene, replacing the temporary preparation view.
    8. Resume animation. resume() hands control back to your normal loop. After a successful run, the start() promise resolves and audit(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.

    ArgumentDescription
    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.

    ArgumentDescription
    entityAn 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.

    ArgumentDescription
    sceneScene containing the resources to prepare. Temporary visibility, culling, layer, and draw-state changes are restored before playback.
    cameraCamera 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.

    ArgumentDescription
    sceneScene 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.