diff --git a/.changeset/effect-registration-frame.md b/.changeset/effect-registration-frame.md new file mode 100644 index 00000000..350e02a9 --- /dev/null +++ b/.changeset/effect-registration-frame.md @@ -0,0 +1,5 @@ +--- +'@camp-dev/shaders': patch +--- + +A parked scene now redraws when an Effect mounts, unmounts, or re-registers on a prop change, such as Dither's `pattern` or Grain's `blend`. Before, the screen kept the old image until something else asked for a frame. diff --git a/docs/agents/tsl.md b/docs/agents/tsl.md index e35f09ce..f14a91ab 100644 --- a/docs/agents/tsl.md +++ b/docs/agents/tsl.md @@ -34,7 +34,7 @@ The rebuild on a new `build` reference is deliberate, and a test asserts it. Mem ### The bare-uniform-write gotcha: a bare uniform write repaints nothing -The scene renders on demand. Once every component votes static, the frame loop parks after one flush, and a value written straight into `uniform.value` waits on the GPU until something else asks for a frame. After any raw write, including `Vector2.set` on a vec uniform, call `scheduler.requestRender()`. `useAnimatableUniform` already calls it after each write. +The scene renders on demand. Once every component votes static, the frame loop parks after one flush, and a value written straight into `uniform.value` waits on the GPU until something else asks for a frame. After any raw write, including `Vector2.set` on a vec uniform, call `scheduler.requestRender()`. `useAnimatableUniform` already calls it after each write. An Effect's registration rebuilds the output material and has the same problem, so `useSceneRegistration` calls it each time an Effect registers or unregisters. For the aspect ratio, use `useAspectUniform`. It exists because twelve components wired `useResize` to an aspect uniform by hand without the call. Back then, `useResize` returned a stub on a component's first effect pass and filled in one render later, after the flush frame, so on a static scene such as the Components banner the 12:1 canvas rendered at the 16:9 fallback until a resize. `useResize` now hands over the scene's own size signal on the first render, and a visible scene redraws straight after each resize; a hidden or off-screen one draws at the new size when it resumes. A pixel-density change still updates the signal with no redraw of its own, so the call still matters. diff --git a/packages/shaders/src/components/palette-map/shader.tsx b/packages/shaders/src/components/palette-map/shader.tsx index b48a5a42..7fc4203d 100644 --- a/packages/shaders/src/components/palette-map/shader.tsx +++ b/packages/shaders/src/components/palette-map/shader.tsx @@ -205,18 +205,5 @@ export function PaletteMapShader({ colors, pixelSize, spread }: PaletteMapShader [colorsKey, pixelSizeUniform, spreadUniform, dprUniform], ); - // A new palette re-registers the resample above, and unmounting removes it. - // Either rebuilds the output material but draws nothing on a static scene, - // so ask for a frame. React runs this effect and its cleanup after - // useResample's, which was declared first, so each request follows the - // rebuild. - useEffect(() => { - shaderContext?.scheduler.requestRender(); - - return () => { - shaderContext?.scheduler.requestRender(); - }; - }, [colorsKey, shaderContext]); - return null; } diff --git a/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx b/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx index e28ce911..887783c9 100644 --- a/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx +++ b/packages/shaders/src/react/components/shader-scene/shader-scene.test.tsx @@ -13,6 +13,7 @@ import { type Vector2, } from '../../../engine.js'; import { ShadersError } from '../../errors/shaders-error.js'; +import { usePostProcessPass } from '../../hooks/use-overlay-pass/use-overlay-pass.js'; import { useResize } from '../../hooks/use-resize/use-resize.js'; import { useShaderContext } from '../../hooks/use-shader-context/use-shader-context.js'; import { useStaticSceneHint } from '../../hooks/use-static-hint/use-static-hint.js'; @@ -119,6 +120,40 @@ describe('ShaderScene', () => { expect(() => unmount()).not.toThrow(); }); + // An Effect's cleanup asks for a frame, and on a parked scene that request + // would queue one. Once the scene has disposed its scheduler, it must not. + // The Effect votes last, so its vote still holds when that cleanup runs. + it('queues no frame when a parked scene unmounts with an Effect mounted', async () => { + const frames: FrameRequestCallback[] = []; + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback); + + return frames.length; + }); + + function Effect() { + usePostProcessPass((color) => color, []); + useStaticSceneHint(true); + + return null; + } + + const { unmount } = render( + + + , + ); + + await waitFor(() => expect(frames.length).toBeGreaterThan(0)); + for (let now = 16; frames.length > 0; now += 16) { + for (const frame of frames.splice(0)) frame(now); + } + + expect(() => unmount()).not.toThrow(); + expect(frames).toEqual([]); + }); + it('fires onError with a renderer-init ShadersError when init fails', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); const cause = new Error('no gpu backend'); diff --git a/packages/shaders/src/react/hooks/use-base-pass-uv/use-base-pass-uv.test.tsx b/packages/shaders/src/react/hooks/use-base-pass-uv/use-base-pass-uv.test.tsx index 8388ab92..33416598 100644 --- a/packages/shaders/src/react/hooks/use-base-pass-uv/use-base-pass-uv.test.tsx +++ b/packages/shaders/src/react/hooks/use-base-pass-uv/use-base-pass-uv.test.tsx @@ -22,7 +22,7 @@ function makeCtx(): { renderer: {} as ShaderContextValue['renderer'], scene: {} as ShaderContextValue['scene'], camera: {} as ShaderContextValue['camera'], - scheduler: {} as ShaderContextValue['scheduler'], + scheduler: { requestRender: () => false } as ShaderContextValue['scheduler'], registerOverlay: () => () => undefined, registerResample: (resample: Resample) => { registered.push(resample); diff --git a/packages/shaders/src/react/hooks/use-overlay-pass/use-overlay-pass.test.tsx b/packages/shaders/src/react/hooks/use-overlay-pass/use-overlay-pass.test.tsx index 34c46197..15718941 100644 --- a/packages/shaders/src/react/hooks/use-overlay-pass/use-overlay-pass.test.tsx +++ b/packages/shaders/src/react/hooks/use-overlay-pass/use-overlay-pass.test.tsx @@ -21,7 +21,7 @@ function makeCtx(): { renderer: {} as ShaderContextValue['renderer'], scene: {} as ShaderContextValue['scene'], camera: {} as ShaderContextValue['camera'], - scheduler: {} as ShaderContextValue['scheduler'], + scheduler: { requestRender: () => false } as ShaderContextValue['scheduler'], registerOverlay: (transform: PostProcessTransform) => { registered.push(transform); @@ -72,7 +72,7 @@ describe('usePostProcessPass', () => { renderer: {} as ShaderContextValue['renderer'], scene: {} as ShaderContextValue['scene'], camera: {} as ShaderContextValue['camera'], - scheduler: {} as ShaderContextValue['scheduler'], + scheduler: { requestRender: () => false } as ShaderContextValue['scheduler'], registerOverlay: () => cleanupFn, } as unknown as ShaderContextValue; diff --git a/packages/shaders/src/react/hooks/use-resample/use-resample.test.tsx b/packages/shaders/src/react/hooks/use-resample/use-resample.test.tsx index f4a86cdf..d61542ca 100644 --- a/packages/shaders/src/react/hooks/use-resample/use-resample.test.tsx +++ b/packages/shaders/src/react/hooks/use-resample/use-resample.test.tsx @@ -19,7 +19,7 @@ function makeContext(): { renderer: {} as ShaderContextValue['renderer'], scene: {} as ShaderContextValue['scene'], camera: {} as ShaderContextValue['camera'], - scheduler: {} as ShaderContextValue['scheduler'], + scheduler: { requestRender: () => false } as ShaderContextValue['scheduler'], registerOverlay: () => () => undefined, registerResample: (resample: Resample) => { registered.push(resample); diff --git a/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.test.tsx b/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.test.tsx index cb329406..16264d83 100644 --- a/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.test.tsx +++ b/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.test.tsx @@ -6,11 +6,26 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ShaderContext, type ShaderContextValue, type Slot } from '../../context/shader-context.js'; import { useSceneRegistration } from './use-scene-registration.js'; -afterEach(cleanup); +// What happened, in order, so a test can check that a frame request follows +// the registration change it has to draw. +const events: string[] = []; -// The hook hands the context to `register` and reads nothing from it, so an -// empty object stands in for a mounted scene. -const context = {} as ShaderContextValue; +afterEach(() => { + cleanup(); + events.length = 0; +}); + +// The hook hands the context to `register` and asks its scheduler for frames, +// so a scheduler that logs each request stands in for a mounted scene. +const context = { + scheduler: { + requestRender: () => { + events.push('request'); + + return true; + }, + }, +} as ShaderContextValue; function Wrapper({ children }: { children: ReactNode }) { return {children}; @@ -23,8 +38,11 @@ function makeProbe() { useSceneRegistration( (_shaderContext, slot) => { slots.push(slot); + events.push('register'); - return () => undefined; + return () => { + events.push('unregister'); + }; }, [mode], ); @@ -36,6 +54,38 @@ function makeProbe() { } describe('useSceneRegistration', () => { + // A registration rebuilds the output material and draws nothing, so a scene + // that has parked needs a frame to show it. + it('asks for a frame after registering', () => { + const { Probe } = makeProbe(); + + render(, { wrapper: Wrapper }); + + expect(events).toEqual(['register', 'request']); + }); + + // Unregistering rebuilds the material too, and a parked scene would keep + // showing the Effect's last frame. + it('asks for a frame after unregistering', () => { + const { Probe } = makeProbe(); + const { unmount } = render(, { wrapper: Wrapper }); + + events.length = 0; + unmount(); + + expect(events).toEqual(['unregister', 'request']); + }); + + it('asks for a frame after each half of a swap when deps change', () => { + const { Probe } = makeProbe(); + const { rerender } = render(, { wrapper: Wrapper }); + + events.length = 0; + rerender(); + + expect(events).toEqual(['unregister', 'request', 'register', 'request']); + }); + // The scene puts a registration under a slot it has seen back in that // slot's place, so an Effect whose deps change keeps its place in the // stack. diff --git a/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.ts b/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.ts index 0972b9e4..d8198cdb 100644 --- a/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.ts +++ b/packages/shaders/src/react/hooks/use-scene-registration/use-scene-registration.ts @@ -2,8 +2,9 @@ // How an Effect joins its and leaves it again. usePostProcessPass // and useResample each pick which list the Effect joins, and this hook owns -// the rest: register on mount, unregister on unmount, and swap registrations -// when a dependency changes. A change to that lifecycle lands here once. +// the rest: register on mount, unregister on unmount, swap registrations when +// a dependency changes, and ask for a frame after each so a parked scene shows +// the change. A change to that lifecycle lands here once. import { type DependencyList, useEffect, useState } from 'react'; import type { ShaderContextValue, Slot } from '../../context/shader-context.js'; @@ -13,8 +14,8 @@ import { useShaderContext } from '../use-shader-context/use-shader-context.js'; * Run `register` against the enclosing scene, and call the unregister * function it returns on unmount and before each re-register. It registers * again whenever a value in `deps` changes, under the same slot, so the - * Effect keeps its place in the stack. Outside a mounted it - * does nothing. + * Effect keeps its place in the stack. Each change asks the scene for a frame. + * Outside a mounted it does nothing. */ export function useSceneRegistration( register: (shaderContext: ShaderContextValue, slot: Slot) => () => void, @@ -28,7 +29,20 @@ export function useSceneRegistration( useEffect(() => { if (!shaderContext) return; - return register(shaderContext, slot); + const { scheduler } = shaderContext; + const unregister = register(shaderContext, slot); + + // Registering and unregistering each rebuild the output material, the + // shader that draws the finished image to the canvas, and draw nothing. A + // parked scene would keep showing the old image: the bare-uniform-write + // gotcha in `docs/agents/tsl.md`, set off by a rebuild. requestRender + // returns at once unless the scene is idle. + scheduler.requestRender(); + + return () => { + unregister(); + scheduler.requestRender(); + }; // `register` is a new closure on every render, so listing it would // re-register every render. `deps` decides when the Effect registers again. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/shaders/src/runtime/frame-scheduler/frame-scheduler.test.ts b/packages/shaders/src/runtime/frame-scheduler/frame-scheduler.test.ts index d2a5d286..7c48dfa6 100644 --- a/packages/shaders/src/runtime/frame-scheduler/frame-scheduler.test.ts +++ b/packages/shaders/src/runtime/frame-scheduler/frame-scheduler.test.ts @@ -529,6 +529,22 @@ describe('dispose invariants', () => { rafCallbacks.forEach((cb) => cb(performance.now())); expect(client).not.toHaveBeenCalled(); }); + + // An Effect's cleanup asks for a frame, and when a whole scene unmounts it + // can run after the scene has disposed its scheduler. + it('queues no frame for a requestRender after dispose', () => { + const scheduler = new FrameScheduler(); + + scheduler.add(vi.fn()); + scheduler.start(); + scheduler.setIdle(true); + scheduler.dispose(); + rafCallbacks = []; + + scheduler.requestRender(); + + expect(rafCallbacks).toEqual([]); + }); }); describe('FrameScheduler phase reset', () => {