RFC: named dirty signals for event-driven frame jobs
Problem
v10 makes both sides of this pattern easy in isolation:
- shared mutable GPU state can live in registries such as
useUniforms - work can be scheduled through phased
useFramejobs
What is missing is a generic bridge between them: a producer should be able to mark a named input or resource as changed, and independent frame jobs should be able to run once in response without being coupled to that producer.
A pointer example:
- One input component updates a shared
uPointerWorlduniform. - A particle system needs to dispatch a compute kernel once after that update.
- Other materials, compute systems, post-processing effects, or libraries may also consume the same pointer.
Today the practical choices are:
- dispatch every consumer directly from the pointer handler, coupling input to every system
- run each expensive consumer every frame even when the value did not change
- build an application-specific ref/map/event bus around
useFrame - use React state, causing renders for mutable frame data that otherwise does not need React
invalidate() is related but broader: it schedules a demand-mode root frame. It does not identify what changed or let individual scheduler jobs run conditionally.
Proposal
Add root-scoped, named dirty signals (or change revisions) that integrate with the v10 scheduler.
Strawman API:
const pointerChanged = useFrameSignal('pointer-world')
const onPointerMove = (event) => {
uPointerWorld.value.copy(event.point)
pointerChanged.mark()
}
useFrame(
() => renderer.compute(computeHit),
{ phase: 'update', when: pointerChanged },
)A lower-level store API could expose the same primitive if that fits the existing scheduler better:
const changes = useThree((state) => state.changes)
changes.mark('pointer-world')
useFrame(() => {
if (!changes.changed('pointer-world')) return
renderer.compute(computeHit)
})The exact names are open. The important part is that this should be a versioned signal, not one shared boolean:
mark(key)increments a revision- every subscribed job tracks its own last-seen revision
- multiple consumers can all observe one change; the first consumer cannot clear it for the others
- multiple marks before the relevant frame/phase may coalesce into one job run
The signal does not need to own the value. It can mark changes to uniforms, refs, physics state, storage buffers, or arbitrary external resources.
Scheduler semantics to define
- Marking a signal should request a frame for a
frameloop="demand"root. - A mark from an earlier phase should be observable by jobs in a later phase that frame; marks after a job's phase should run it next frame.
enabled, pause/resume,fps, and before/after ordering should continue to compose normally.- A paused/throttled job should retain the unseen revision until it can run.
- Signals should be root-scoped and safe for multiple subscribers, StrictMode, unmount/remount, and HMR.
Why core?
This is scheduler coordination rather than pointer-specific behavior. The same primitive covers on-demand compute dispatch, physics synchronization, expensive spatial-index rebuilds, resource uploads, post-processing refreshes, and library-to-library coordination without introducing React renders or direct producer/consumer knowledge.
Source: pmndrs/react-three-fiber