Skip to content

packages/core/src/lib/textures/texture.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 { 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 built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.