packages/core/src/features/textures/lib/reaction-diffusion.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.