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