Skip to content

packages/core/src/lib/transitions/embers.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 { emberSimulation, resolveEmbersPhysics, type PibblEmbersPhysicsOptions } from './embers-physics.js';
2 import { lazyTransitionKernel } from './wasm/kernel.js';
3 import { bytes } from './wasm/embers-bytes.js';
4 const createKernel = lazyTransitionKernel(bytes, 2);
5 const createPhysicsKernel = lazyTransitionKernel(bytes, 4);
6 import { opacity } from '../../filters/native.js';
7 import { filterBoundaryExecutor, type CustomPreparedFilter } from '../../filters/filter-executor.js';
8 import { GLOBAL_STATE } from '../global-state.js';
9 import { getOptionalService } from '../optional-services.js';
10 import { PIBBL_FILTER_EXECUTOR, type PibblFilterExecutor } from '../style/filter-types.js';
11 import type { PibblInstance } from '../types.js';
12 import { defineTransitionEffect, type PibblTransitionEffect } from './fade.js';
13 
14 /**
15  * Appearance, motion, and timing controls for an ember visibility transition.
16  *
17  * @see {@link PibblEmbersPhysicsOptions}
18  * @see {@link embers}
19  */
20 export interface PibblEmbersOptions {
21   /** Local coordinates enclosing the subject; the burn travels upward through this area. */
22   readonly region: Readonly<{
23     /**
24      * Horizontal coordinate or displacement in the containing coordinate system. See
25      * {@link PibblEmbersOptions}.
26      */
27     x: number;
28     /**
29      * Vertical coordinate or displacement in the containing coordinate system. See
30      * {@link PibblEmbersOptions}.
31      */
32     y: number;
33     /**
34      * Horizontal extent in the units of the containing geometry or surface. See
35      * {@link PibblEmbersOptions}.
36      */
37     width: number;
38     /**
39      * Vertical extent in the units of the containing geometry or surface. See
40      * {@link PibblEmbersOptions}.
41      */
42     height: number;
43   }>;
44   /**
45    * Depth of the darkened char region along the ember transition boundary. See
46    * {@link PibblEmbersOptions}.
47    */
48   readonly charDepth?: number;
49   /**
50    * Width of the glowing burn region along the transition boundary. See {@link PibblEmbersOptions}.
51    */
52   readonly burnWidth?: number;
53   /** Horizontal drift in local units; negative blows left. */
54   readonly wind?: number;
55   /**
56    * Seed used for deterministic sampling or simulation initialization. See
57    * {@link PibblEmbersOptions}.
58    */
59   readonly seed?: number;
60   /**
61    * Maximum airborne particle lifetime in milliseconds; completion includes this tail. Default
62    * 700.
63    */
64   readonly linger?: number;
65   /** Opt-in local force simulation and collision. Requires a positive linger. */
66   readonly physics?: PibblEmbersPhysicsOptions;
67 }
68 const SCRATCH = Symbol('pibbl.transition.embers');
69 class EmberSurfaces {
70   private surfaces: OffscreenCanvas[] = [];
71   used = false;
72   private committed = false;
73   constructor(private readonly instance: PibblInstance) {}
74   beginRender(): void { this.used = false; }
75   endRender(success: boolean): void {
76     if (success && this.used) { this.committed = true; return; }
77     if (success || !this.committed) this.dispose();
78   }
79   context(index: number, width: number, height: number): OffscreenCanvasRenderingContext2D {
80     const canvas = this.surfaces[index] ??= new OffscreenCanvas(width, height);
81     if (canvas.width !== width) canvas.width = width;
82     if (canvas.height !== height) canvas.height = height;
83     const context = canvas.getContext('2d');
84     if (!context) throw new Error('Embers requires a Canvas 2D surface.');
85     context.reset();
86     return context;
87   }
88   dispose(): void {
89     for (const surface of this.surfaces) { surface.width = 0; surface.height = 0; }
90     this.surfaces = [];
91     this.instance.optionalServices.delete(SCRATCH);
92   }
93 }
94 const clamp = (value: number) => Math.max(0, Math.min(1, value));
95 
96 /**
97  * A seeded burn/char front over one captured subtree, with outward drifting fragments.
98  *
99  * @param options - Ember emission, motion, appearance, and optional collision settings. See
100  * {@link PibblEmbersOptions} .
101  * @returns An ember transition effect. See {@link PibblTransitionEffect}.
102  *
103  * @see {@link PibblEmbersOptions}
104  * @see {@link PibblTransitionEffect}
105  */
106 export function embers(options: PibblEmbersOptions): PibblTransitionEffect {
107   const region = Object.freeze({ ...options.region });
108   const charDepth = options.charDepth ?? 65, burnWidth = options.burnWidth ?? 12;
109   const wind = options.wind ?? 110, seed = options.seed ?? 0, linger = options.linger ?? 700;
110   for (const [name, value] of Object.entries({ ...region, charDepth, burnWidth, wind, seed, linger })) {
111     if (!Number.isFinite(value)) throw new RangeError(`embers ${name} must be finite.`);
112   }
113   if (!(region.width > 0 && region.height > 0) || charDepth < 0 || burnWidth < 0 || linger < 0) throw new RangeError('embers requires a positive region and nonnegative charDepth/burnWidth/linger.');
114   const physics = options.physics === undefined ? undefined : resolveEmbersPhysics(options.physics);
115   if (physics && linger === 0) throw new RangeError('Ember physics requires a positive linger.');
116   const sampler = (kernel: ReturnType<typeof createKernel>, simulation?: ReturnType<typeof emberSimulation>): import('./fade.js').TransitionSampler => (q, phase, frame) => {
117     if (simulation?.disposed) return [opacity({ amount: 0 })];
118     if ((!frame.active || !linger) && q === 1) return [];
119     if ((!frame.active || !linger) && q === 0) return [opacity({ amount: 0 })];
120     const executor: PibblFilterExecutor = {
121       render: filterBoundaryExecutor.render,
122       prepare(): CustomPreparedFilter {
123         return {
124           executor: filterBoundaryExecutor,
125           reverseInfluence: output => ({ ...output }),
126           process(source, windows) {
127             const instance = GLOBAL_STATE.currentPibblInstance;
128             if (!instance) throw new Error('Embers requires an active Pibbl instance.');
129             const scratch = getOptionalService(instance, SCRATCH, () => new EmberSurfaces(instance));
130             scratch.used = true;
131             const c = scratch.context(0, source.width, source.height);
132             const tint = scratch.context(1, source.width, source.height);
133             const matrix = windows.source.renderTransform;
134             const roughness = Math.min(5, region.height * .015);
135             const front = region.y + region.height + roughness * 2 - q * (region.height + roughness * 4);
136             const segments = Math.min(512, Math.max(1, Math.ceil(region.width / 2)));
137             const { exports, values } = kernel();
138             exports.births!(frame.elapsed, linger);
139             // The controller remains the source of truth for reversal history.
140             if (linger) for (let i = 0; i < 1200; i++) values[11166 + i] = frame.progressAt(values[12366 + i]!);
141             exports.sample!(q, phase === 'enter' ? 1 : 0, frame.elapsed, linger, wind, burnWidth, region.x, region.y, region.width, region.height, segments);
142             simulation?.present();
143             const bandPath = (depth: number) => {
144               c.beginPath();
145               for (let i = 0; i <= segments; i++) c.lineTo(values[i * 2]!, values[i * 2 + 1]!);
146               for (let i = segments; i >= 0; i--) c.lineTo(values[i * 2]!, values[i * 2 + 1]! + depth);
147               c.closePath();
148             };
149             const path = (depth: number) => {
150               c.beginPath(); c.moveTo(region.x, region.y + region.height + charDepth + 40);
151               for (let i = 0; i <= segments; i++) c.lineTo(values[i * 2]!, values[i * 2 + 1]! + depth);
152               c.lineTo(region.x + region.width, values[segments * 2 + 1]! + depth);
153               c.lineTo(region.x + region.width, region.y + region.height + charDepth + 40); c.closePath();
154             };
155             if (q > 0) {
156             c.save(); c.setTransform(matrix); path(0); c.clip();
157             c.setTransform(1, 0, 0, 1, 0, 0); c.drawImage(source, 0, 0); c.restore();
158             c.setTransform(matrix); c.globalCompositeOperation = 'source-atop';
159             const strength = clamp((1 - q) / .12);
160             // Continuous overlapping contour bands avoid vertical striping at the hot edge.
161             for (let band = 48; band > 0 && charDepth > 0; band--) {
162               const depth = charDepth * band / 48;
163               c.save();
164               bandPath(depth); c.clip();
165               c.fillStyle = `rgba(27,16,12,${.12 * strength})`;
166               c.fillRect(region.x, front - roughness * 2, region.width, depth + roughness * 4);
167               c.restore();
168             }
169             // A connected thermal front carries the light; individual coals only
170             // add texture. Source-atop preserves holes in the captured silhouette.
171             if (burnWidth > 0) {
172               c.save(); c.globalAlpha = strength;
173               c.filter = `blur(${Math.min(3, burnWidth * .16) * windows.density}px)`;
174               bandPath(burnWidth * 1.45); c.fillStyle = '#8d2a0c'; c.fill();
175               c.filter = 'none';
176               bandPath(burnWidth * .85); c.fillStyle = '#d9510c'; c.fill();
177               bandPath(burnWidth * .48); c.fillStyle = '#ff942c'; c.fill();
178               bandPath(burnWidth * .2); c.fillStyle = '#ffd27c'; c.fill();
179               c.restore();
180             }
181             for (let i = 0; i < 180; i++) {
182               const offset = 1026 + i * 3;
183               const x = values[offset]!, y = values[offset + 1]!, r = values[offset + 2]!;
184               const heat = c.createRadialGradient(x, y, 0, x, y, r);
185               heat.addColorStop(0, `rgba(255,222,145,${strength * .5})`);
186               heat.addColorStop(.3, `rgba(247,92,15,${strength * .4})`);
187               heat.addColorStop(1, 'rgba(100,20,0,0)');
188               c.fillStyle = heat; c.fillRect(x - r, y - r, r * 2, r * 2);
189             }
190             }
191             c.setTransform(matrix);
192             // Tint a copy of the subject once. Sampling small patches means transparent
193             // gaps cannot emit particles, without reading pixels back from the canvas.
194             tint.drawImage(source, 0, 0); tint.globalCompositeOperation = 'source-in';
195             tint.fillStyle = '#fff4cf'; tint.fillRect(0, 0, source.width, source.height);
196             const ash = scratch.context(2, source.width, source.height);
197             ash.drawImage(source, 0, 0); ash.globalCompositeOperation = 'source-in';
198             ash.fillStyle = '#766b60'; ash.fillRect(0, 0, source.width, source.height);
199             c.globalCompositeOperation = 'source-over';
200             for (let i = 0; i < 1200; i++) {
201               const offset = 1566 + i * 8, alpha = values[offset + 5]!;
202               if (alpha < 0) continue;
203               const x = values[offset]!, y = values[offset + 1]!, size = values[offset + 2]!;
204               const sample = new DOMPoint(x, y).matrixTransform(matrix);
205               const dx = values[offset + 3]!, dy = values[offset + 4]!, age = values[offset + 7]!;
206               c.save(); c.globalAlpha = alpha;
207               c.translate(x + dx, y + dy); c.rotate(values[offset + 6]!);
208               // Hot fragments cool continuously into dim ash rather than switching off.
209               const cooling = clamp((age - .2) / .8);
210               c.globalAlpha = alpha * (1 - cooling);
211               c.drawImage(tint.canvas, sample.x, sample.y, size * windows.density, size * windows.density, -size / 2, -size / 2, size, size);
212               if (cooling > 0) {
213                 c.globalAlpha = alpha * cooling;
214                 c.drawImage(ash.canvas, sample.x, sample.y, size * windows.density, size * windows.density, -size / 2, -size / 2, size, size);
215               }
216               c.restore();
217             }
218             return c.canvas.transferToImageBitmap();
219           },
220         };
221       },
222     };
223     return [Object.freeze({ [PIBBL_FILTER_EXECUTOR]: executor })];
224   };
225   return defineTransitionEffect(sampler(createKernel(seed)), linger, physics ? () => {
226     const kernel = createPhysicsKernel(seed);
227     const simulation = emberSimulation(kernel, physics, region, linger, burnWidth);
228     return { sample: sampler(kernel, simulation), advance: simulation.advance, reset: simulation.reset, dispose: simulation.dispose };
229   } : undefined);
230 }
231 

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