Skip to content

packages/core/src/features/textures/lib/tendrils.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 { ElectricMemory, type ElectricThread } from "./wasm/electric.js";
2 import { lazyKernel } from "./wasm/memory.js";
3 import { bytes } from "./wasm/tendrils-bytes.js";
4 const wasmModule = /* @__PURE__ */ lazyKernel(bytes);
5 import { boundary } from "./electric-boundary.js";
6 import { defineTexture } from "@pibbl/core";
7 import { validateSeed } from "./palette.js";
8 import {
9   electricOptions,
10   point,
11   range,
12   type ElectricControl,
13   type ElectricOptions,
14   type ElectricPoint,
15 } from "./electric-shared.js";
16 import {
17   MAX_ELECTRIC_SOURCES,
18   site,
19   source,
20   sourceId,
21   sources,
22   type ElectricSource,
23   type ElectricSourceMessage,
24   type SourceSite,
25 } from "./electric-sources.js";
26 
27 /**
28  * Electrical appearance, source electrodes, boundary, rise, and discharge controls for tendrils.
29  *
30  * @see {@link ElectricOptions}
31  * @see {@link ElectricPoint}
32  * @see {@link ElectricSource}
33  * @see {@link tendrils}
34  */
35 export interface TendrilsOptions extends ElectricOptions {
36   /** Common electrode. Sources seed the outer endpoints. */
37   readonly center?: ElectricPoint;
38   /** Named electrical source electrodes. See {@link ElectricSource}. */
39   readonly sources?: readonly ElectricSource[];
40   /** Optional simple closed vessel outline in normalized texture coordinates. */
41   readonly boundary?: readonly ElectricPoint[];
42   /**
43    * Number of particles or sources requested by this configuration. See {@link TendrilsOptions}.
44    */
45   readonly count?: number;
46   /** Continuous upward drift speed, 0..1. No circular boundary is assumed. */
47   readonly rise?: number;
48   /** Terminal discharge size, 0..1. Source weight also scales the discharge. */
49   readonly crackle?: number;
50   /** Rate at which the pattern or motion repeats. See {@link TendrilsOptions}. */
51   readonly frequency?: number;
52 }
53 /**
54  * Control, source-editing, and live appearance commands for a tendrils texture.
55  *
56  * @see {@link ElectricControl}
57  * @see {@link ElectricSourceMessage}
58  */
59 export type TendrilsMessage =
60   | ElectricControl
61   | ElectricSourceMessage
62   | {
63       /** The literal "configure" identifying this variant. See {@link TendrilsMessage}. */
64       readonly type: "configure";
65       /** Amount of secondary electrical branching. See {@link TendrilsMessage}. */
66       readonly branching?: number;
67       /** Intensity of the electrical halo around the core. See {@link TendrilsMessage}. */
68       readonly glow?: number;
69       /** Upward drift strength of the tendrils. See {@link TendrilsMessage}. */
70       readonly rise?: number;
71       /** Intensity of terminal discharges. See {@link TendrilsMessage}. */
72       readonly crackle?: number;
73       /** Rate at which the pattern or motion repeats. See {@link TendrilsMessage}. */
74       readonly frequency?: number;
75     };
76 function options(o: TendrilsOptions) {
77   const count = range(o.count ?? 15, 1, 32, "Tendril count");
78   if (!Number.isInteger(count))
79     throw new RangeError("Tendril count must be an integer.");
80   return {
81     ...electricOptions(o),
82     center: point(o.center ?? { x: 0.5, y: 0.5 }),
83     sources: sources(o.sources ?? []),
84     boundary: boundary(o.boundary),
85     count,
86     rise: range(o.rise ?? 0, 0, 1, "Rise"),
87     crackle: range(o.crackle ?? 0, 0, 1, "Crackle"),
88     frequency: range(o.frequency ?? 18, 1, 30, "Frequency"),
89   };
90 }
91 type Thread = ElectricThread;
92 interface State {
93   kernel: ElectricMemory;
94   config: ReturnType<typeof options>;
95   inputKey: string;
96   sites: Map<string, SourceSite>;
97   ordered: SourceSite[];
98   threads: Thread[];
99   time: number;
100   paused: boolean;
101 }
102 function strength(weight: number) {
103   return weight / (1 + weight);
104 }
105 function rebuild(s: State) {
106   s.ordered = [...s.sites.values()].sort((a, b) =>
107     a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
108   );
109   s.kernel.setSources(s.ordered);
110 }
111 function bind(t: Thread, next: SourceSite, snap = false) {
112   if (t.sourceId !== next.id || snap) {
113     t.trajectory = { ...next.point };
114     t.sourcePoint = { ...next.point };
115   }
116   t.sourceId = next.id;
117   t.releasedAge = undefined;
118   t.visible = true;
119   if (snap) {
120     t.point = { ...next.point };
121     t.strength = strength(next.weight);
122   }
123 }
124 function detach(t: Thread) {
125   t.sourceId = undefined;
126   t.releasedAge ??= 0;
127 }
128 function replaceSites(
129   s: State,
130   values: readonly ElectricSource[],
131   reconsider: boolean,
132 ) {
133   s.sites = new Map(values.map((v) => [v.id, site(v)]));
134   rebuild(s);
135   s.threads.forEach((t, i) => {
136     const current = t.sourceId && s.sites.get(t.sourceId);
137     if (!current || current.weight === 0) detach(t);
138     if (reconsider) {
139       const next = s.kernel.choose(s.config.seed, i + t.generation * 37);
140       if (next) bind(t, next, s.paused || !t.visible);
141     }
142   });
143 }
144 function reset(s: State) {
145   s.time = 0;
146   s.sites = new Map(s.config.sources.map((v) => [v.id, site(v)]));
147   rebuild(s);
148   s.threads = Array.from({ length: s.config.count }, (_, i) => {
149     const next = s.kernel.choose(s.config.seed, i);
150     return Object.assign(s.kernel.thread(i), {
151       releasedAge: undefined,
152       sourceId: next?.id,
153       point: { ...(next?.point ?? s.config.center) },
154       strength: next ? strength(next.weight) : 0,
155       age: (i / s.config.count) * 5,
156       generation: 0,
157       trajectory: { ...(next?.point ?? s.config.center) },
158       sourcePoint: { ...(next?.point ?? s.config.center) },
159       visible: !!next,
160       born: false,
161     });
162   });
163 }
164 function reconsider(s: State) {
165   s.threads.forEach((t, i) => {
166     const next = s.kernel.choose(s.config.seed, i + t.generation * 37);
167     if (next) bind(t, next, s.paused || !t.visible);
168     else detach(t);
169   });
170 }
171 const recipe = /* @__PURE__ */ defineTexture<
172   TendrilsOptions,
173   State,
174   TendrilsMessage
175 >({
176   defaultPlacement: { mode: "stretch" },
177   create(context, input) {
178     const config = options(input);
179     const s: State = {
180       kernel: new ElectricMemory(wasmModule()),
181       config,
182       inputKey: JSON.stringify(input),
183       sites: new Map(),
184       ordered: [],
185       threads: [],
186       time: 0,
187       paused: false,
188     };
189     reset(s);
190     if (s.ordered.some((v) => v.weight > 0)) context.requestFrame();
191     return s;
192   },
193   update(s, input, context) {
194     const key = JSON.stringify(input);
195     if (key === s.inputKey) return;
196     const next = options(input),
197       previous = s.config;
198     s.config = next;
199     s.inputKey = key;
200     if (next.seed !== previous.seed || next.count !== previous.count) reset(s);
201     else if (JSON.stringify(next.sources) !== JSON.stringify(previous.sources))
202       replaceSites(s, next.sources, true);
203     context.invalidate();
204     if (!s.paused && s.threads.some((t) => t.visible)) context.requestFrame();
205   },
206   receive(s, m, context) {
207     switch (m.type) {
208       case "pause":
209         s.paused = m.paused;
210         break;
211       case "reset":
212         s.config.seed =
213           m.seed === undefined ? s.config.seed : validateSeed(m.seed);
214         reset(s);
215         break;
216       case "configure":
217         s.config = options({ ...s.config, ...m });
218         break;
219       case "set-sources": {
220         const next = sources(m.sources);
221         s.config.sources = next;
222         replaceSites(s, next, true);
223         break;
224       }
225       case "upsert-source": {
226         const next = source(m.source),
227           previous = s.sites.get(next.id);
228         if (!previous && s.sites.size >= MAX_ELECTRIC_SOURCES)
229           throw new RangeError("At most 512 electric sources may be active.");
230         const changedWeight = !previous || previous.weight !== next.weight;
231         if (previous) {
232           previous.point = next.point;
233           previous.weight = next.weight;
234         } else {
235           s.sites.set(next.id, site(next));
236           rebuild(s);
237         }
238         s.kernel.setSources(s.ordered);
239         if (next.weight === 0) {
240           for (const t of s.threads) if (t.sourceId === next.id) detach(t);
241         } else if (changedWeight) reconsider(s);
242         if (s.paused)
243           for (const t of s.threads) {
244             const target = t.sourceId && s.sites.get(t.sourceId);
245             if (target) bind(t, target, true);
246           }
247         break;
248       }
249       case "remove-source": {
250         const id = sourceId(m.id);
251         if (!s.sites.delete(id)) return;
252         rebuild(s);
253         for (const t of s.threads) if (t.sourceId === id) detach(t);
254         break;
255       }
256       default:
257         throw new TypeError("Unknown tendrils message.");
258     }
259     context.invalidate();
260     if (!s.paused && s.threads.some((t) => t.visible)) context.requestFrame();
261   },
262   advance(s, frame, context) {
263     if (s.paused) return;
264     const dt = Math.min(50, frame.delta) / 1000;
265     s.time += dt;
266     s.kernel.boundary(s.config.boundary);
267     s.kernel.advanceTendrils(
268       s.threads.length,
269       s.config.seed,
270       dt,
271       s.config.rise,
272     );
273     context.invalidate();
274     if (s.threads.some((t) => t.visible)) context.requestFrame();
275   },
276   rasterize(s, context) {
277     s.kernel.tendrils(s.config, s.time, s.threads.length);
278     s.kernel.draw(context, s.config.glow);
279   },
280   dispose(s) {
281     s.kernel.dispose();
282     s.sites.clear();
283     s.ordered.length = 0;
284     s.threads.length = 0;
285     s.config.sources.length = 0;
286   },
287 });
288 /**
289  * Weighted attachment sites and persistent filaments, independent of vessel geometry.
290  *
291  * @param config - Tendril motion and appearance settings. See {@link TendrilsOptions}.
292  * @returns A tendril texture recipe for useTexture.
293  *
294  * @see {@link TendrilsOptions}
295  */
296 export function tendrils(config: TendrilsOptions = {}) {
297   return recipe(config);
298 }
299 

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