packages/core/src/lib/textures/texture.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
1 import { GLOBAL_STATE } from "../global-state.js";
2 import {
3 requireHookContext,
4 useHookSlot,
5 withHooksForbidden,
6 } from "../hooks/hook-slot.js";
7 import { registerAdvanceParticipant } from "../scheduler/realm-scheduler.js";
8 import { signal } from "../signals/graph.js";
9 import type { PibblInstance, RenderingContext2D } from "../types.js";
10 import {
11 registerTexturePaint,
12 type BorrowedTextureRaster,
13 type TexturePaint,
14 type TexturePlacement,
15 } from "./texture-protocol.js";
16
17 export type { TexturePaint, TexturePlacement } from "./texture-protocol.js";
18
19 declare const recipeBrand: unique symbol;
20 /**
21 * A mount-owned texture handle that provides paint and accepts recipe messages.
22 *
23 * @see {@link TexturePaint}
24 * @see {@link TexturePlacement}
25 * @see {@link useTexture}
26 */
27 export interface Texture<Message = never> extends TexturePaint {
28 /**
29 * Delivers a typed message to the mounted recipe instance. See {@link Texture}.
30 * @param message - Message delivered to the texture definition's receive callback.
31 */
32 send(message: Message): void;
33 /**
34 * Returns a paint view with the requested placement while retaining the same texture ownership.
35 * See {@link TexturePaint}.
36 * @param placement - Destination bounds and texture placement policy. See
37 * {@link TexturePlacement} .
38 * @returns A paint descriptor referencing this mounted texture. See {@link TexturePaint}.
39 */
40 paint(placement: TexturePlacement): TexturePaint;
41 }
42 /**
43 * Resolution and runtime configuration for a mounted texture recipe.
44 *
45 * @see {@link useTexture}
46 */
47 export interface TextureOptions {
48 /** Dimensions of the simulation or raster grid. See {@link TextureOptions}. */
49 readonly resolution?: {
50 /**
51 * Horizontal extent in the units of the containing geometry or surface. See
52 * {@link TextureOptions}.
53 */
54 readonly width: number;
55 /**
56 * Vertical extent in the units of the containing geometry or surface. See
57 * {@link TextureOptions}.
58 */
59 readonly height: number;
60 };
61 /** Ordered colors used to present texture intensity. See {@link TextureOptions}. */
62 readonly palette?: readonly string[];
63 }
64 /**
65 * Surface and invalidation facilities supplied to texture lifecycle callbacks.
66 *
67 * @see {@link TextureDefinition}
68 */
69 export interface TextureContext {
70 /** Canvas surface associated with this resource or mount. See {@link TextureContext}. */
71 readonly canvas: OffscreenCanvas;
72 /** Ordered colors used to present texture intensity. See {@link TextureContext}. */
73 readonly palette: readonly string[] | undefined;
74 /** Marks the source pixels dirty and requests a Pibbl update. */
75 invalidate(): void;
76 /** Requests another Pibbl frame; implementations stop by not requesting again. */
77 requestFrame(): void;
78 }
79 /**
80 * Shared logical time and elapsed milliseconds supplied while advancing a texture.
81 *
82 * @see {@link TextureDefinition}
83 */
84 export interface TextureFrame {
85 /** Elapsed time since the preceding texture frame, in milliseconds. See {@link TextureFrame}. */
86 readonly delta: number;
87 /** Current shared logical time, in milliseconds. See {@link TextureFrame}. */
88 readonly time: number;
89 }
90 /**
91 * Lifecycle callbacks for creating, updating, painting, and disposing texture resources.
92 *
93 * @see {@link TexturePlacement}
94 * @see {@link TextureContext}
95 * @see {@link TextureFrame}
96 * @see {@link defineTexture}
97 */
98 export interface TextureDefinition<Config, State, Message = never> {
99 /**
100 * Texture placement used when no paint-specific override is supplied. See
101 * {@link TexturePlacement}.
102 */
103 readonly defaultPlacement?: TexturePlacement;
104 /**
105 * Allocates state for a mounted texture recipe. See {@link TextureDefinition}.
106 * @param context - Texture-owned drawing surface and scheduling services. See
107 * {@link TextureContext} .
108 * @param config - Initial recipe configuration.
109 * @returns State retained for this mounted texture lifetime.
110 */
111 create(context: TextureContext, config: Readonly<Config>): State;
112 /**
113 * Updates existing state when the recipe configuration changes. See {@link TextureDefinition}.
114 * @param state - State returned by create.
115 * @param config - Latest recipe configuration.
116 * @param context - Texture-owned drawing surface and scheduling services. See
117 * {@link TextureContext} .
118 */
119 update?(state: State, config: Readonly<Config>, context: TextureContext): void;
120 /**
121 * Handles a typed application message for this recipe instance. See {@link TextureDefinition}.
122 * @param state - State returned by create.
123 * @param message - Message sent through the mounted texture handle.
124 * @param context - Texture-owned drawing surface and scheduling services. See
125 * {@link TextureContext} .
126 */
127 receive?(state: State, message: Message, context: TextureContext): void;
128 /**
129 * Advances simulation using the shared Pibbl frame timing. See {@link TextureDefinition}.
130 * @param state - State returned by create.
131 * @param frame - Shared animation time and delta. See {@link TextureFrame}.
132 * @param context - Texture-owned drawing surface and scheduling services. See
133 * {@link TextureContext} .
134 */
135 advance?(state: State, frame: TextureFrame, context: TextureContext): void;
136 /**
137 * Draws current texture pixels into the owned offscreen surface. See {@link TextureDefinition}.
138 * @param state - State returned by create.
139 * @param context - Texture-owned drawing surface and scheduling services. See
140 * {@link TextureContext} .
141 */
142 rasterize(state: State, context: TextureContext): void;
143 /**
144 * Releases state and resources when the mounted texture is disposed. See
145 * {@link TextureDefinition}.
146 * @param state - State returned by create.
147 * @param context - Texture-owned drawing surface and scheduling services. See
148 * {@link TextureContext} .
149 */
150 dispose?(state: State, context: TextureContext): void;
151 }
152 /**
153 * A reusable texture definition together with the options used to instantiate it.
154 *
155 * @see {@link defineTexture}
156 * @see {@link useTexture}
157 */
158 export interface TextureRecipe<Message = never> {
159 readonly [recipeBrand]: (message: Message) => Message;
160 }
161 type Definition = TextureDefinition<any, any, any>;
162 interface Recipe {
163 definition: Definition;
164 config: any;
165 }
166 const recipes = new WeakMap<object, Recipe>();
167
168 /**
169 * Defines a pure recipe factory; resource allocation starts at useTexture.
170 *
171 * @param definition - State creation, update, simulation, rasterization, and disposal callbacks.
172 * See {@link TextureDefinition} .
173 * @returns A configuration-to-recipe factory; resources are allocated when a recipe is mounted.
174 * See {@link TextureRecipe} .
175 *
176 * @see {@link TextureDefinition}
177 * @see {@link TextureRecipe}
178 */
179 export function defineTexture<Config, State, Message = never>(
180 definition: TextureDefinition<Config, State, Message>,
181 ): (config: Config) => TextureRecipe<Message> {
182 const stable = Object.freeze({ ...definition });
183 return (config) => {
184 const recipe = Object.freeze({}) as TextureRecipe<Message>;
185 recipes.set(recipe, {
186 definition: stable,
187 config: structuredClone(config),
188 });
189 return recipe;
190 };
191 }
192 function placement(value: TexturePlacement = {}): Readonly<TexturePlacement> {
193 if (
194 value.mode !== undefined &&
195 value.mode !== "repeat" &&
196 value.mode !== "stretch"
197 )
198 throw new TypeError("Unknown texture placement mode.");
199 for (const key of [
200 "tileWidth",
201 "tileHeight",
202 "offsetX",
203 "offsetY",
204 "rotation",
205 ] as const) {
206 const n = value[key];
207 if (
208 n !== undefined &&
209 (!Number.isFinite(n) ||
210 ((key === "tileWidth" || key === "tileHeight") && n <= 0))
211 )
212 throw new RangeError(`Invalid texture placement ${key}.`);
213 }
214 return Object.freeze({ ...value });
215 }
216 function dimensions(options: TextureOptions): [number, number] {
217 const width = options.resolution?.width ?? 256,
218 height = options.resolution?.height ?? 160;
219 if (
220 ![width, height].every((n) => Number.isInteger(n) && n >= 8 && n <= 1024) ||
221 width * height > 262144
222 )
223 throw new RangeError(
224 "Texture resolution must be 8..1024 cells per axis, at most 262144 cells.",
225 );
226 return [width, height];
227 }
228 class Resource {
229 readonly revision = signal(0);
230 readonly canvas: OffscreenCanvas;
231 private committed: OffscreenCanvas | undefined;
232 private spare: OffscreenCanvas | undefined;
233 private provisional: { canvas: OffscreenCanvas; transaction: NonNullable<typeof GLOBAL_STATE.renderTransaction> } | undefined;
234 readonly context: TextureContext;
235 readonly handle: Texture<any>;
236 state: any;
237 definition: Definition;
238 disposed = false;
239 private hasState = false;
240 dirty = true;
241 private sequence = 0;
242 private publicationCanvasesCreated = 0;
243 private messages: unknown[] = [];
244 private unregister?: () => void;
245 private continuing = false;
246 private lastTime?: number;
247 private initializing = true;
248 private retryScheduled = false;
249 private palette?: readonly string[];
250 private optionsKey: string;
251 constructor(
252 private owner: PibblInstance | undefined,
253 recipe: Recipe,
254 options: TextureOptions,
255 ) {
256 const [width, height] = dimensions(options);
257 this.optionsKey = JSON.stringify(options);
258 this.palette = options.palette && Object.freeze([...options.palette]);
259 this.canvas = new OffscreenCanvas(width, height);
260 this.definition = recipe.definition;
261 const self = this;
262 this.context = Object.freeze({
263 canvas: this.canvas,
264 get palette() {
265 return self.palette;
266 },
267 invalidate() {
268 if (!self.disposed) {
269 self.dirty = true;
270 if (!self.initializing) self.schedule();
271 }
272 },
273 requestFrame() {
274 if (!self.disposed) {
275 self.continuing = true;
276 self.schedule();
277 }
278 },
279 });
280 const registerPaint = (value: TexturePaint, placement: Readonly<TexturePlacement>) =>
281 registerTexturePaint(value, {
282 resolvePaint: (ctx, bounds) => this.resolvePaint(ctx, bounds, placement),
283 validate: () => this.assertOwner(),
284 borrow: callback => callback(this.borrow(placement)),
285 });
286 this.handle = Object.freeze({
287 send: (message: unknown) => {
288 if (this.disposed) return;
289 if (GLOBAL_STATE.currentPibblInstance)
290 throw new Error("Texture.send cannot run during Pibbl evaluation.");
291 if (!this.definition.receive)
292 throw new Error("This texture does not accept messages.");
293 if (this.messages.length >= 8192)
294 throw new RangeError("Texture message queue is full.");
295 this.messages.push(structuredClone(message));
296 this.schedule();
297 },
298 paint: (value: TexturePlacement) => {
299 const paint = Object.freeze({}) as TexturePaint;
300 registerPaint(paint, placement(value));
301 return paint;
302 },
303 }) as Texture<any>;
304 registerPaint(this.handle, placement(this.definition.defaultPlacement));
305 try {
306 this.state = this.call(() =>
307 this.definition.create(this.context, recipe.config),
308 );
309 this.hasState = true;
310 this.publishInitial();
311 this.initializing = false;
312 } catch (error) {
313 try {
314 this.dispose();
315 } catch {
316 // The initialization failure remains the actionable error.
317 }
318 throw error;
319 }
320 }
321 private call<T>(fn: () => T): T {
322 return withHooksForbidden(
323 "Texture implementations cannot call Pibbl hooks.",
324 fn,
325 );
326 }
327 update(recipe: Recipe, options: TextureOptions) {
328 if (this.disposed) return;
329 if (recipe.definition !== this.definition)
330 throw new Error(
331 "Changing a texture definition requires a new keyed owner.",
332 );
333 const [width, height] = dimensions(options);
334 const nextKey = JSON.stringify(options);
335 if (nextKey !== this.optionsKey) {
336 this.palette = options.palette && Object.freeze([...options.palette]);
337 this.optionsKey = nextKey;
338 if (width !== this.canvas.width || height !== this.canvas.height) {
339 try {
340 this.disposeState();
341 this.canvas.width = width;
342 this.canvas.height = height;
343 this.messages.length = 0;
344 this.lastTime = undefined;
345 this.state = this.call(() =>
346 this.definition.create(this.context, recipe.config),
347 );
348 this.hasState = true;
349 } catch (error) {
350 try { this.dispose(); } catch { /* Preserve the recreate failure. */ }
351 throw error;
352 }
353 }
354 this.context.invalidate();
355 }
356 this.call(() =>
357 this.definition.update?.(this.state, recipe.config, this.context),
358 );
359 }
360 private unschedule() {
361 this.unregister?.();
362 this.unregister = undefined;
363 }
364 private schedule() {
365 if (this.unregister || this.disposed) return;
366 const participant = (frame: { logicalTime: number }, batch: any) => {
367 const retryAtEntry = this.retryScheduled;
368 this.unschedule();
369 const wasContinuing = this.continuing;
370 this.continuing = false;
371 try {
372 const messages = this.messages;
373 this.messages = [];
374 for (const message of messages)
375 this.call(() =>
376 this.definition.receive?.(this.state, message, this.context),
377 );
378 if (wasContinuing)
379 this.call(() =>
380 this.definition.advance?.(
381 this.state,
382 {
383 delta:
384 this.lastTime === undefined
385 ? 0
386 : Math.max(0, frame.logicalTime - this.lastTime),
387 time: frame.logicalTime,
388 },
389 this.context,
390 ),
391 );
392 this.lastTime = this.continuing ? frame.logicalTime : undefined;
393 if (this.dirty) {
394 const candidate = this.rasterizeCandidate();
395 let previous: OffscreenCanvas | undefined;
396 batch.stage(this.revision, ++this.sequence, participant);
397 batch.registerLifecycle(participant, {
398 apply: () => {
399 if (this.disposed) {
400 candidate.width = candidate.height = 0;
401 } else {
402 previous = this.committed;
403 this.committed = candidate;
404 this.spare = previous;
405 this.retryScheduled = false;
406 }
407 },
408 rollback: () => {
409 if (!this.disposed) {
410 this.committed = previous;
411 this.spare = candidate;
412 }
413 },
414 abort: () => {
415 this.spare = candidate;
416 if (!this.disposed) {
417 this.dirty = true;
418 this.schedule();
419 }
420 },
421 });
422 }
423 if (!this.continuing && this.messages.length === 0) {
424 this.unschedule();
425 }
426 } catch (error) {
427 this.unschedule();
428 this.continuing = false;
429 if (this.retryScheduled && !retryAtEntry) this.schedule();
430 throw error;
431 }
432 };
433 this.unregister = registerAdvanceParticipant(participant);
434 }
435 private rasterizeCandidate(): OffscreenCanvas {
436 const ctx = this.canvas.getContext("2d");
437 if (!ctx) throw new Error("Textures require OffscreenCanvas 2D.");
438 ctx.save();
439 try {
440 ctx.resetTransform();
441 ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
442 this.call(() => this.definition.rasterize(this.state, this.context));
443 this.dirty = false;
444 } finally {
445 ctx.restore();
446 }
447 const spare = this.spare;
448 this.spare = undefined;
449 const reusable = spare?.width === this.canvas.width && spare.height === this.canvas.height ?
450 spare : undefined;
451 if (spare && spare !== reusable) spare.width = spare.height = 0;
452 let candidate: OffscreenCanvas | undefined;
453 try {
454 candidate = reusable ?? this.createPublicationCanvas();
455 const candidateContext = candidate.getContext("2d");
456 if (!candidateContext) throw new Error("Textures require OffscreenCanvas 2D.");
457 // Publication canvases are reused; transparent source pixels must erase old paint.
458 candidateContext.clearRect(0, 0, candidate.width, candidate.height);
459 candidateContext.drawImage(this.canvas, 0, 0);
460 return candidate;
461 } catch (error) {
462 if (candidate) candidate.width = candidate.height = 0;
463 this.dirty = true;
464 if (!this.retryScheduled) {
465 this.retryScheduled = true;
466 this.schedule();
467 }
468 throw error;
469 }
470 }
471 private createPublicationCanvas(): OffscreenCanvas {
472 this.publicationCanvasesCreated++;
473 return new OffscreenCanvas(this.canvas.width, this.canvas.height);
474 }
475 /** @internal Structural ownership evidence for texture publication tests. */
476 publicationSnapshot(): Readonly<{ publicationCanvasesCreated: number; hasOwner: boolean }> {
477 return Object.freeze({ publicationCanvasesCreated: this.publicationCanvasesCreated, hasOwner: this.owner !== undefined });
478 }
479 private publishInitial(): void {
480 const candidate = this.rasterizeCandidate();
481 const transaction = GLOBAL_STATE.renderTransaction;
482 if (!transaction) {
483 this.committed = candidate;
484 return;
485 }
486 this.provisional = { canvas: candidate, transaction };
487 transaction.onFinish(success => {
488 if (this.provisional?.transaction !== transaction) return;
489 const provisional = this.provisional;
490 this.provisional = undefined;
491 if (success && !this.disposed) this.committed = provisional.canvas;
492 else provisional.canvas.width = provisional.canvas.height = 0;
493 });
494 }
495 private assertOwner(): void {
496 if (this.disposed) throw new Error("Cannot paint with a disposed texture.");
497 let root = GLOBAL_STATE.currentPibblInstance;
498 while (root?.parent) root = GLOBAL_STATE.pibblInstances.get(root.parent);
499 let owner = this.owner;
500 while (owner?.parent) owner = GLOBAL_STATE.pibblInstances.get(owner.parent);
501 if (root !== owner) throw new Error("Textures may only be shared within their owning Pibbl tree.");
502 }
503 private borrow(placement: Readonly<TexturePlacement>): BorrowedTextureRaster {
504 this.assertOwner();
505 const revision = this.revision.get();
506 const provisional = this.provisional;
507 const source =
508 provisional !== undefined && provisional.transaction === GLOBAL_STATE.renderTransaction ?
509 provisional.canvas : this.committed;
510 if (!source) throw new Error("Texture pixels are not committed for this render transaction.");
511 return Object.freeze({ source, width: source.width, height: source.height, revision, resource: this, placement });
512 }
513 private resolvePaint(ctx: RenderingContext2D, bounds: { x: number; y: number; width: number; height: number }, p: Readonly<TexturePlacement>): string | CanvasGradient | CanvasPattern {
514 const borrowed = this.borrow(p);
515 const pattern = ctx.createPattern(borrowed.source, "repeat");
516 if (!pattern) throw new Error("Could not create texture paint.");
517 const stretch = p.mode === "stretch";
518 const width = stretch ? bounds.width : (p.tileWidth ?? borrowed.width);
519 const height = stretch ? bounds.height : (p.tileHeight ?? borrowed.height);
520 pattern.setTransform(new DOMMatrix().translate(bounds.x + (p.offsetX ?? 0), bounds.y + (p.offsetY ?? 0)).rotate(p.rotation ?? 0).scale(Math.max(0.000001, width) / borrowed.width, Math.max(0.000001, height) / borrowed.height));
521 return pattern;
522 }
523 dispose() {
524 if (this.disposed) return;
525 this.disposed = true;
526 this.unschedule();
527 this.messages.length = 0;
528 try {
529 this.disposeState();
530 } finally {
531 this.canvas.width = this.canvas.height = 0;
532 this.committed && (this.committed.width = this.committed.height = 0);
533 this.spare && (this.spare.width = this.spare.height = 0);
534 this.provisional && (this.provisional.canvas.width = this.provisional.canvas.height = 0);
535 this.state = undefined;
536 this.hasState = false;
537 this.committed = undefined;
538 this.spare = undefined;
539 this.provisional = undefined;
540 this.owner = undefined;
541 }
542 }
543 private disposeState() {
544 if (!this.hasState) return;
545 this.hasState = false;
546 const state = this.state;
547 this.state = undefined;
548 this.call(() => this.definition.dispose?.(state, this.context));
549 }
550 }
551
552 /**
553 * Owns one persistent texture resource for the current mounted hook slot.
554 *
555 * @param input - Texture recipe to mount or update. See {@link TextureRecipe}.
556 * @param options - Resolution and lifecycle options for the mounted texture. See
557 * {@link TextureOptions} .
558 * @returns A mount-owned texture handle for sending messages and creating paint placements. See
559 * {@link Texture} .
560 *
561 * @see {@link TextureRecipe}
562 * @see {@link TextureOptions}
563 * @see {@link Texture}
564 */
565 export function useTexture<Message>(
566 input: TextureRecipe<Message>,
567 options: TextureOptions = {},
568 ): Texture<Message> {
569 const recipe = recipes.get(input);
570 if (!recipe)
571 throw new TypeError("useTexture requires a defineTexture recipe.");
572 const { pibblInstance } = requireHookContext();
573 let created = false;
574 const resource = useHookSlot("texture", (teardowns) => {
575 created = true;
576 const value = new Resource(pibblInstance, recipe, options);
577 teardowns.add(() => value.dispose());
578 return value;
579 }).value;
580 if (!created)
581 GLOBAL_STATE.renderTransaction!.onFinish((success) => {
582 if (success) resource.update(recipe, options);
583 });
584 return resource.handle;
585 }
586
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.