Skip to content

packages/core/src/features/textures/lib/reaction-diffusion.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { defineRenderLayer, type PibblRenderLayerInput } from "@pibbl/core";
2 import { TextureRaster, type TextureRenderer } from "./presentation.js";
3 import { InkSimulation } from "./simulation.js";
4 import {
5   paletteLut,
6   texturePalettes,
7   validateResolution,
8   validateSeed,
9   type TexturePalette,
10   type TextureResolution,
11 } from "./palette.js";
12 
13 /**
14  * Radius and strength of a reaction-diffusion brush stroke.
15  *
16  * @see {@link ReactionDiffusionProps}
17  */
18 export interface TextureBrush {
19   /** Radius in the coordinate system of this geometry or effect. See {@link TextureBrush}. */
20   readonly radius: number;
21   /** Magnitude of the displacement or brush effect. See {@link TextureBrush}. */
22   readonly strength: number;
23 }
24 /**
25  * Authored inputs for ReactionDiffusion, including the declared data and presentation options.
26  *
27  * @see {@link TextureRenderer}
28  * @see {@link TextureResolution}
29  * @see {@link TexturePalette}
30  * @see {@link TextureBrush}
31  * @see {@link ReactionDiffusion}
32  */
33 export interface ReactionDiffusionProps {
34   /**
35    * Renders the current data or resources using the supplied context. See {@link TextureRenderer}
36    * .
37    */
38   readonly render?: TextureRenderer;
39   /** Dimensions of the simulation or raster grid. See {@link TextureResolution}. */
40   readonly resolution?: TextureResolution;
41   /**
42    * Seed used for deterministic sampling or simulation initialization. See
43    * {@link ReactionDiffusionProps}.
44    */
45   readonly seed?: number;
46   /**
47    * Named pattern configuration used to initialize the simulation. See
48    * {@link ReactionDiffusionProps}.
49    */
50   readonly preset?: "spots" | "stripes";
51   /** Ordered colors used to present texture intensity. See {@link TexturePalette}. */
52   readonly palette?: TexturePalette;
53   /**
54    * Whether simulation or playback advancement is suspended. See {@link ReactionDiffusionProps}.
55    */
56   readonly paused?: boolean;
57   /**
58    * Changing this revision requests a fresh simulation initialization. See
59    * {@link ReactionDiffusionProps}.
60    */
61   readonly resetRevision?: number;
62   /**
63    * Brush configuration for interactive reaction-diffusion input, or false to disable it. See
64    * {@link TextureBrush}.
65    */
66   readonly brush?: TextureBrush | false;
67 }
68 interface Options {
69   resolution: TextureResolution;
70   seed: number;
71   preset: "spots" | "stripes";
72   palette: TexturePalette;
73   paused: boolean;
74   resetRevision: number;
75   brush: TextureBrush | false;
76 }
77 function options(props: ReactionDiffusionProps): Options {
78   const preset = props.preset ?? "spots";
79   if (preset !== "spots" && preset !== "stripes")
80     throw new TypeError("Unknown reaction-diffusion preset.");
81   const brush = props.brush ?? { radius: 6, strength: 0.8 };
82   if (
83     brush &&
84     (!Number.isFinite(brush.radius) ||
85       brush.radius <= 0 ||
86       brush.radius > 128 ||
87       !Number.isFinite(brush.strength) ||
88       brush.strength < 0 ||
89       brush.strength > 1)
90   )
91     throw new RangeError(
92       "Brush radius must be in (0,128] and strength in [0,1].",
93     );
94   if (!Number.isSafeInteger(props.resetRevision ?? 0))
95     throw new RangeError("resetRevision must be a safe integer.");
96   return {
97     resolution: validateResolution(props.resolution),
98     seed: validateSeed(props.seed),
99     preset,
100     palette: [...(props.palette ?? texturePalettes.lagoon)],
101     paused: props.paused ?? false,
102     resetRevision: props.resetRevision ?? 0,
103     brush: brush && { ...brush },
104   };
105 }
106 interface Stroke {
107   x: number;
108   y: number;
109   radius: number;
110   strength: number;
111 }
112 interface Resources {
113   render?: TextureRenderer;
114   config: Options;
115   simulation: InkSimulation;
116   raster: TextureRaster;
117   output: OffscreenCanvasRenderingContext2D;
118   lut: Uint8ClampedArray;
119   elapsed: number;
120   fresh: boolean;
121   dirty: boolean;
122   disposed: boolean;
123   target: object;
124   strokes: Stroke[];
125   pointer?: { id: number; x: number; y: number; release: () => void };
126   invalidate: () => void;
127 }
128 function reset(r: Resources, config: Options) {
129   const { width, height } = config.resolution;
130   const simulation = new InkSimulation(
131     width,
132     height,
133     config.seed,
134     config.preset,
135   );
136   let raster: TextureRaster;
137   try {
138     raster = new TextureRaster(width, height);
139   } catch (error) {
140     simulation.dispose();
141     throw error;
142   }
143   r.simulation.dispose();
144   r.simulation = simulation;
145   r.raster.dispose();
146   r.raster = raster;
147   r.strokes.length = 0;
148   r.pointer?.release();
149   r.pointer = undefined;
150   r.elapsed = 0;
151   r.fresh = true;
152   r.dirty = true;
153 }
154 function input(
155   r: Resources,
156   event: Readonly<PibblRenderLayerInput<object>>,
157   width: number,
158   height: number,
159 ) {
160   if (r.disposed) return;
161   const original = event.originalEvent as PointerEvent;
162   if (event.type === "pointerup" || event.type === "pointercancel") {
163     if (r.pointer?.id === original.pointerId) {
164       r.pointer.release();
165       r.pointer = undefined;
166     }
167     return;
168   }
169   const brush = r.config.brush;
170   if (!brush || width <= 0 || height <= 0) return;
171   const x = (event.point.x / width) * r.simulation.width;
172   const y = (event.point.y / height) * r.simulation.height;
173   if (event.type === "pointerdown") {
174     if (r.pointer || original.button !== 0 || original.isPrimary === false)
175       return;
176     event.setPointerCapture(original.pointerId);
177     r.pointer = {
178       id: original.pointerId,
179       x,
180       y,
181       release: () => event.releasePointerCapture(original.pointerId),
182     };
183   } else if (
184     event.type !== "pointermove" ||
185     r.pointer?.id !== original.pointerId
186   )
187     return;
188   const previous = r.pointer!;
189   const distance = Math.hypot(x - previous.x, y - previous.y);
190   // Bound work for off-surface capture. Stamps outside the field are ignored, never clamped to its edge.
191   const count = Math.min(
192     4096,
193     Math.max(1, Math.ceil(distance / Math.max(0.5, brush.radius * 0.3))),
194   );
195   for (let i = 1; i <= count; i++) {
196     const px = previous.x + ((x - previous.x) * i) / count,
197       py = previous.y + ((y - previous.y) * i) / count;
198     if (
199       px >= 0 &&
200       py >= 0 &&
201       px < r.simulation.width &&
202       py < r.simulation.height &&
203       r.strokes.length < 8192
204     )
205       r.strokes.push({ x: px, y: py, ...brush });
206   }
207   previous.x = x;
208   previous.y = y;
209   r.invalidate();
210 }
211 
212 /**
213  * An independently sized, persistent Gray–Scott surface, advanced by Pibbl's scheduler.
214  *
215  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
216  * props and style types.
217  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
218  *
219  * @see {@link ReactionDiffusionProps}
220  */
221 export const ReactionDiffusion = /* @__PURE__ */ defineRenderLayer<
222   ReactionDiffusionProps,
223   Resources
224 >({
225   create(context, props) {
226     const config = options(props),
227       lut = paletteLut(config.palette);
228     const output = context.canvas.getContext("2d");
229     if (!output) throw new Error("ReactionDiffusion requires Canvas 2D.");
230     const { width, height } = config.resolution;
231     const raster = new TextureRaster(width, height);
232     try {
233       return {
234         config,
235         lut,
236         raster,
237         output,
238         render: props.render,
239         simulation: new InkSimulation(
240           width,
241           height,
242           config.seed,
243           config.preset,
244         ),
245         elapsed: 0,
246         fresh: true,
247         dirty: true,
248         disposed: false,
249         target: {},
250         strokes: [],
251         invalidate: context.invalidate,
252       };
253     } catch (error) {
254       raster.dispose();
255       throw error;
256     }
257   },
258   update(r, props) {
259     const next = options(props),
260       old = r.config;
261     if (next.palette.join() !== old.palette.join()) {
262       r.lut = paletteLut(next.palette);
263       r.dirty = true;
264     }
265     if (
266       next.seed !== old.seed ||
267       next.preset !== old.preset ||
268       next.resetRevision !== old.resetRevision ||
269       next.resolution.width !== old.resolution.width ||
270       next.resolution.height !== old.resolution.height
271     )
272       reset(r, next);
273     if (next.paused !== old.paused) {
274       r.elapsed = 0;
275       r.fresh = true;
276     }
277     if (!next.brush) {
278       r.pointer?.release();
279       r.pointer = undefined;
280     }
281     r.config = next;
282     r.render = props.render;
283   },
284   render(r, frame, context) {
285     // The frame invalidator does not gate the committed input snapshot between brush samples.
286     r.invalidate = frame.invalidate;
287     for (const stroke of r.strokes)
288       r.simulation.paint(stroke.x, stroke.y, stroke.radius, stroke.strength);
289     if (r.strokes.length) r.dirty = true;
290     r.strokes.length = 0;
291     if (!r.config.paused && !r.fresh) {
292       r.elapsed += Math.max(0, Math.min(50, frame.delta));
293       const steps = Math.min(24, Math.floor(r.elapsed * 0.48 + 1e-9));
294       r.elapsed -= steps / 0.48;
295       if (steps) {
296         r.simulation.advance(steps);
297         r.dirty = true;
298       }
299     }
300     r.fresh = false;
301     if (r.dirty) {
302       r.raster.update(r.simulation.b, r.lut, 650 / 255);
303       r.dirty = false;
304     }
305     r.raster.draw(
306       r.output,
307       context.size.width,
308       context.size.height,
309       context.size.backingWidth,
310       context.size.backingHeight,
311       r.render,
312     );
313     if (!r.config.paused) frame.invalidate();
314   },
315   interaction: {
316     settle(r, _props, size) {
317       return {
318         targets: [
319           {
320             target: r.target,
321             cursor: r.config.brush ? "crosshair" : "default",
322           },
323         ],
324         hitTest: () => ({ kind: "target", target: r.target }),
325         onInput: (event) => input(r, event, size.width, size.height),
326       };
327     },
328   },
329   dispose(r) {
330     if (r.disposed) return;
331     r.disposed = true;
332     r.pointer?.release();
333     r.pointer = undefined;
334     r.strokes.length = 0;
335     r.invalidate = () => {};
336     r.simulation.dispose();
337     r.raster.dispose();
338     r.render = undefined;
339   },
340 });
341 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.