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