@dopaminefx/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engine/color.d.ts +71 -0
- package/dist/engine/color.d.ts.map +1 -0
- package/dist/engine/color.js +107 -0
- package/dist/engine/color.js.map +1 -0
- package/dist/engine/context.d.ts +54 -0
- package/dist/engine/context.d.ts.map +1 -0
- package/dist/engine/context.js +0 -0
- package/dist/engine/context.js.map +1 -0
- package/dist/engine/gl.d.ts +9 -0
- package/dist/engine/gl.d.ts.map +1 -0
- package/dist/engine/gl.js +39 -0
- package/dist/engine/gl.js.map +1 -0
- package/dist/engine/look/glsl.d.ts +95 -0
- package/dist/engine/look/glsl.d.ts.map +1 -0
- package/dist/engine/look/glsl.js +171 -0
- package/dist/engine/look/glsl.js.map +1 -0
- package/dist/engine/look/particles.glsl.d.ts +21 -0
- package/dist/engine/look/particles.glsl.d.ts.map +1 -0
- package/dist/engine/look/particles.glsl.js +44 -0
- package/dist/engine/look/particles.glsl.js.map +1 -0
- package/dist/engine/sdf.d.ts +77 -0
- package/dist/engine/sdf.d.ts.map +1 -0
- package/dist/engine/sdf.js +255 -0
- package/dist/engine/sdf.js.map +1 -0
- package/dist/engine/seed.d.ts +10 -0
- package/dist/engine/seed.d.ts.map +1 -0
- package/dist/engine/seed.js +20 -0
- package/dist/engine/seed.js.map +1 -0
- package/dist/engine/shadow.d.ts +41 -0
- package/dist/engine/shadow.d.ts.map +1 -0
- package/dist/engine/shadow.js +39 -0
- package/dist/engine/shadow.js.map +1 -0
- package/dist/engine/tempo.d.ts +33 -0
- package/dist/engine/tempo.d.ts.map +1 -0
- package/dist/engine/tempo.js +51 -0
- package/dist/engine/tempo.js.map +1 -0
- package/dist/framework/conductor.d.ts +100 -0
- package/dist/framework/conductor.d.ts.map +1 -0
- package/dist/framework/conductor.js +493 -0
- package/dist/framework/conductor.js.map +1 -0
- package/dist/framework/content.d.ts +67 -0
- package/dist/framework/content.d.ts.map +1 -0
- package/dist/framework/content.js +72 -0
- package/dist/framework/content.js.map +1 -0
- package/dist/framework/dope-pass.d.ts +131 -0
- package/dist/framework/dope-pass.d.ts.map +1 -0
- package/dist/framework/dope-pass.js +346 -0
- package/dist/framework/dope-pass.js.map +1 -0
- package/dist/framework/dope-zip.d.ts +22 -0
- package/dist/framework/dope-zip.d.ts.map +1 -0
- package/dist/framework/dope-zip.js +116 -0
- package/dist/framework/dope-zip.js.map +1 -0
- package/dist/framework/effect.d.ts +128 -0
- package/dist/framework/effect.d.ts.map +1 -0
- package/dist/framework/effect.js +19 -0
- package/dist/framework/effect.js.map +1 -0
- package/dist/framework/frame-expr.d.ts +124 -0
- package/dist/framework/frame-expr.d.ts.map +1 -0
- package/dist/framework/frame-expr.js +135 -0
- package/dist/framework/frame-expr.js.map +1 -0
- package/dist/framework/load-effect.d.ts +77 -0
- package/dist/framework/load-effect.d.ts.map +1 -0
- package/dist/framework/load-effect.js +135 -0
- package/dist/framework/load-effect.js.map +1 -0
- package/dist/framework/loader.d.ts +309 -0
- package/dist/framework/loader.d.ts.map +1 -0
- package/dist/framework/loader.js +266 -0
- package/dist/framework/loader.js.map +1 -0
- package/dist/framework/mood-registry.d.ts +58 -0
- package/dist/framework/mood-registry.d.ts.map +1 -0
- package/dist/framework/mood-registry.js +58 -0
- package/dist/framework/mood-registry.js.map +1 -0
- package/dist/framework/panel-runner.d.ts +96 -0
- package/dist/framework/panel-runner.d.ts.map +1 -0
- package/dist/framework/panel-runner.js +137 -0
- package/dist/framework/panel-runner.js.map +1 -0
- package/dist/framework/pass-common.d.ts +97 -0
- package/dist/framework/pass-common.d.ts.map +1 -0
- package/dist/framework/pass-common.js +178 -0
- package/dist/framework/pass-common.js.map +1 -0
- package/dist/framework/pass-runner.d.ts +183 -0
- package/dist/framework/pass-runner.d.ts.map +1 -0
- package/dist/framework/pass-runner.js +212 -0
- package/dist/framework/pass-runner.js.map +1 -0
- package/dist/framework/programs.d.ts +54 -0
- package/dist/framework/programs.d.ts.map +1 -0
- package/dist/framework/programs.js +33 -0
- package/dist/framework/programs.js.map +1 -0
- package/dist/framework/registry.d.ts +29 -0
- package/dist/framework/registry.d.ts.map +1 -0
- package/dist/framework/registry.js +38 -0
- package/dist/framework/registry.js.map +1 -0
- package/dist/framework/runtime.d.ts +19 -0
- package/dist/framework/runtime.d.ts.map +1 -0
- package/dist/framework/runtime.js +37 -0
- package/dist/framework/runtime.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/overlay.d.ts +46 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +79 -0
- package/dist/overlay.js.map +1 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/engine/color.ts +154 -0
- package/src/engine/context.ts +0 -0
- package/src/engine/gl.ts +46 -0
- package/src/engine/look/glsl.ts +183 -0
- package/src/engine/look/particles.glsl.ts +44 -0
- package/src/engine/sdf.ts +298 -0
- package/src/engine/seed.ts +23 -0
- package/src/engine/shadow.ts +66 -0
- package/src/engine/tempo.ts +54 -0
- package/src/framework/conductor.ts +604 -0
- package/src/framework/content.ts +113 -0
- package/src/framework/dope-pass.ts +432 -0
- package/src/framework/dope-zip.ts +125 -0
- package/src/framework/effect.ts +127 -0
- package/src/framework/frame-expr.ts +217 -0
- package/src/framework/load-effect.ts +204 -0
- package/src/framework/loader.ts +502 -0
- package/src/framework/mood-registry.ts +87 -0
- package/src/framework/panel-runner.ts +233 -0
- package/src/framework/pass-common.ts +222 -0
- package/src/framework/pass-runner.ts +391 -0
- package/src/framework/programs.ts +62 -0
- package/src/framework/registry.ts +44 -0
- package/src/framework/runtime.ts +38 -0
- package/src/index.ts +227 -0
- package/src/overlay.ts +109 -0
- package/src/types.ts +63 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic fullscreen-pass runner — the shared backbone for PURE-SHADER effects.
|
|
3
|
+
*
|
|
4
|
+
* A pure-shader effect (Solarbloom, Calligraphic Verdict, the Fail stamp) is a
|
|
5
|
+
* full-screen triangle that runs a fragment shader twice — once into the light
|
|
6
|
+
* (screen) context and, when present, once into the shadow (multiply) context.
|
|
7
|
+
* Before this runner, each effect hand-wired the SAME plumbing: link/bind the
|
|
8
|
+
* program + VAO, set the standard uniforms (resolution, origin, time, life,
|
|
9
|
+
* envelope amp, palette, style, the shadow-pass uniforms), upload + bind any aux
|
|
10
|
+
* textures (a baked SDF icon) into BOTH contexts, run the two passes, and free
|
|
11
|
+
* the per-fire GPU resources. That ~120 lines of identical glue is now here,
|
|
12
|
+
* once.
|
|
13
|
+
*
|
|
14
|
+
* What stays per-effect (the honest boundary): the GLSL itself, and a tiny
|
|
15
|
+
* `frame()` hook that computes the genuinely effect-specific TIME-VARYING values
|
|
16
|
+
* (which envelope, which confirm/draw/stamp progress, any shake) into named
|
|
17
|
+
* uniforms. Everything else — standard uniforms, scalar `render.params` →
|
|
18
|
+
* `u<Name>` auto-binding, aux-texture upload/bind, the light+shadow loop, the
|
|
19
|
+
* "animate on twos" stepping, dispose — is generic and data-driven from a small
|
|
20
|
+
* config object.
|
|
21
|
+
*
|
|
22
|
+
* The output is byte-identical to the bespoke renderers it replaces: it sets the
|
|
23
|
+
* same uniforms, in a superset that the shader samples by name, and uses the same
|
|
24
|
+
* `shadowGeometry` / envelope / progress math (supplied by the config).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { NPR_TIME_STEP_MS } from "../engine/tempo.js";
|
|
28
|
+
import type { DecodedSdf } from "../engine/sdf.js";
|
|
29
|
+
import type { RGB } from "../engine/color.js";
|
|
30
|
+
import type { GLContext } from "../engine/context.js";
|
|
31
|
+
import type { EffectContext, EffectInstance } from "./effect.js";
|
|
32
|
+
import {
|
|
33
|
+
STANDARD_COMMON,
|
|
34
|
+
allocTexture,
|
|
35
|
+
applyFloatMap,
|
|
36
|
+
beginProgram,
|
|
37
|
+
bindFrameUniforms,
|
|
38
|
+
bindPalette,
|
|
39
|
+
bindScalars,
|
|
40
|
+
bindShadowGeometry,
|
|
41
|
+
bindTarget,
|
|
42
|
+
compositeLightFragment,
|
|
43
|
+
computeScalarBinds,
|
|
44
|
+
resolveTargetPx,
|
|
45
|
+
} from "./pass-common.js";
|
|
46
|
+
|
|
47
|
+
/** A resolved param bag (the loader's output + any composed fields). */
|
|
48
|
+
export type PassParams = Record<string, unknown> & {
|
|
49
|
+
durationMs: number;
|
|
50
|
+
palette: [RGB, RGB, RGB] | RGB[];
|
|
51
|
+
style: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Per-frame timing context handed to a config's `frame()` hook. */
|
|
55
|
+
export interface FrameInfo {
|
|
56
|
+
/** The "on twos"-snapped animation clock in ms (stepping already applied). */
|
|
57
|
+
animMs: number;
|
|
58
|
+
/** Normalized life 0..1 (animMs / durationMs, clamped). */
|
|
59
|
+
life: number;
|
|
60
|
+
/**
|
|
61
|
+
* The REAL un-stepped wall clock in ms (the raw `renderAt` argument, before
|
|
62
|
+
* the "on twos" snap). Mirrors the Swift/Android runners, which hand their
|
|
63
|
+
* `frame()` hooks the same un-stepped clock for stamp/shake-style timing.
|
|
64
|
+
*/
|
|
65
|
+
elapsedMs: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An aux texture the shader samples. Two sources fit the same model:
|
|
70
|
+
* - `kind:"sdf"` — a baked single-channel R8 distance field (an icon outline),
|
|
71
|
+
* - `kind:"canvas"` — an RGBA Canvas2D source (a rasterized glyph, a panel).
|
|
72
|
+
* The runner uploads it into BOTH the light and shadow contexts (canvas sources
|
|
73
|
+
* upload once, on first bind, then cache), binds it on each pass, and computes
|
|
74
|
+
* per-pass derived uniforms (px ranges that depend on the live canvas size).
|
|
75
|
+
*/
|
|
76
|
+
export type AuxTextureSpec =
|
|
77
|
+
| {
|
|
78
|
+
kind: "sdf";
|
|
79
|
+
/** Texture unit to bind on (e.g. 1 for TEXTURE1). */
|
|
80
|
+
unit: number;
|
|
81
|
+
/** The decoded SDF to upload as an R8 single-channel texture. */
|
|
82
|
+
sdf: DecodedSdf;
|
|
83
|
+
/** Sampler uniform name (bound to `unit`). */
|
|
84
|
+
sampler: string;
|
|
85
|
+
/** "On" flag uniform name (set to 1 when present). */
|
|
86
|
+
onUniform?: string;
|
|
87
|
+
/** Per-pass scalar uniforms (canvas-size-dependent). */
|
|
88
|
+
uniforms?(canvas: HTMLCanvasElement, params: PassParams): Record<string, number>;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
kind: "canvas";
|
|
92
|
+
unit: number;
|
|
93
|
+
/** The Canvas2D source uploaded as an RGBA texture. */
|
|
94
|
+
source: HTMLCanvasElement;
|
|
95
|
+
sampler: string;
|
|
96
|
+
onUniform?: string;
|
|
97
|
+
uniforms?(canvas: HTMLCanvasElement, params: PassParams): Record<string, number>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Config for one pure-shader effect. The genuinely code-shaped bits live here. */
|
|
101
|
+
export interface PassConfig {
|
|
102
|
+
/** Vertex + fragment GLSL (the per-effect look — code under this boundary). */
|
|
103
|
+
vertex: string;
|
|
104
|
+
fragment: string;
|
|
105
|
+
/** Every uniform name the shader reads (for location pre-resolution). */
|
|
106
|
+
uniforms: readonly string[];
|
|
107
|
+
/**
|
|
108
|
+
* Whether the shader reads `uOrigin` (anchored radial effects do; a gesture
|
|
109
|
+
* that composes across the whole surface does not). Default false.
|
|
110
|
+
*/
|
|
111
|
+
usesOrigin?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* The seamless loop period in ms (`tempo.loop.periodMs`) for a CONTINUOUS
|
|
114
|
+
* effect. When set, the runner computes the standard periodic clock uniforms
|
|
115
|
+
* each frame from the snapped clock: `uLoopS` (seconds within the current
|
|
116
|
+
* loop) and `uPhase` (normalized [0, 1)) — so a looping shader needs no
|
|
117
|
+
* per-effect period plumbing. Absent for one-shot effects.
|
|
118
|
+
*/
|
|
119
|
+
loopPeriodMs?: number;
|
|
120
|
+
/**
|
|
121
|
+
* Explicit `param name → uniform name` overrides for the auto scalar binding.
|
|
122
|
+
* By convention a numeric param `bloomRadius` binds to `uBloomRadius`; list an
|
|
123
|
+
* entry here only for exceptions (e.g. `inkSeed → uSeed`). Map to `null` to
|
|
124
|
+
* NOT bind a param to any uniform (e.g. a scatter seed the shader ignores).
|
|
125
|
+
*/
|
|
126
|
+
bindings?: Record<string, string | null>;
|
|
127
|
+
/** Declared aux textures (baked SDF icons), uploaded to light + shadow. */
|
|
128
|
+
auxTextures?(params: PassParams, ctx: EffectContext): AuxTextureSpec[];
|
|
129
|
+
/**
|
|
130
|
+
* Extra per-pass scalar uniforms that depend on the live canvas/target size
|
|
131
|
+
* but are NOT tied to an aux texture (e.g. an icon box size the shader uses
|
|
132
|
+
* even in its SDF-less analytic fallback). Computed once per pass.
|
|
133
|
+
* `targetPx` is the targeted element box in device px with the full-canvas
|
|
134
|
+
* fallback already applied (the same box `uTarget` binds).
|
|
135
|
+
*/
|
|
136
|
+
passUniforms?(
|
|
137
|
+
canvas: HTMLCanvasElement,
|
|
138
|
+
params: PassParams,
|
|
139
|
+
targetPx: { width: number; height: number },
|
|
140
|
+
dpr: number,
|
|
141
|
+
): Record<string, number>;
|
|
142
|
+
/**
|
|
143
|
+
* The shadow occluder "height" as a fraction of min canvas dim — Solarbloom's
|
|
144
|
+
* bloom radius, Verdict's stroke scale, a constant for the Fail stamp.
|
|
145
|
+
*/
|
|
146
|
+
shadowHeightFrac: number | ((params: PassParams) => number);
|
|
147
|
+
/**
|
|
148
|
+
* Compute the genuinely effect-specific TIME-VARYING uniforms for a frame
|
|
149
|
+
* (the envelope amp, confirm/draw/stamp progress, shake, …). Returns a map of
|
|
150
|
+
* uniform name → float. `amp` is returned under a well-known key so the runner
|
|
151
|
+
* can feed it into `shadowGeometry`.
|
|
152
|
+
*/
|
|
153
|
+
frame(info: FrameInfo, params: PassParams): { amp: number } & Record<string, number>;
|
|
154
|
+
/**
|
|
155
|
+
* OPTIONAL dynamic sprite panel. A full-screen pass effect whose look is mostly
|
|
156
|
+
* procedural (a bloom, a flash) but which also has a SPARSE element layer (motes,
|
|
157
|
+
* sparks) shouldn't loop those elements at every pixel — that's O(pixels ×
|
|
158
|
+
* elements). Instead it rasterizes them into an offscreen Canvas2D ONCE per frame
|
|
159
|
+
* here; the runner uploads that canvas into BOTH passes and binds it as `sampler`
|
|
160
|
+
* on `unit`, and the shader samples it. (This is the pass-runner analogue of the
|
|
161
|
+
* hybrid panel-runner, but it COMPOSES with the static aux textures above — e.g.
|
|
162
|
+
* solarbloom keeps its baked-SDF/glyph checkmark AND gains a mote panel.)
|
|
163
|
+
*/
|
|
164
|
+
panel?: {
|
|
165
|
+
/** Texture unit to bind the panel on (distinct from any auxTextures unit). */
|
|
166
|
+
unit: number;
|
|
167
|
+
/** Sampler uniform name the shader reads the panel from. */
|
|
168
|
+
sampler: string;
|
|
169
|
+
/** Draw one frame of the sprite layer into the offscreen canvas. */
|
|
170
|
+
draw(
|
|
171
|
+
panelCtx: CanvasRenderingContext2D,
|
|
172
|
+
width: number,
|
|
173
|
+
height: number,
|
|
174
|
+
params: PassParams,
|
|
175
|
+
info: FrameInfo & { centerPx: { x: number; y: number }; dpr: number },
|
|
176
|
+
): void;
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* OPTIONAL per-frame ARRAY uniforms (vec2/3/4 arrays). For effects that
|
|
180
|
+
* precompute geometry on the CPU each frame and feed it to the shader as a
|
|
181
|
+
* uniform array — e.g. lightning's bolt polyline, far cheaper than re-deriving
|
|
182
|
+
* it with `fbm` at every pixel. Computed ONCE per frame and bound (uniformNfv)
|
|
183
|
+
* in both passes. `geom` carries the live canvas size + the gl-coords strike
|
|
184
|
+
* origin (anchor). Each returned `name` must also appear in `uniforms`.
|
|
185
|
+
*/
|
|
186
|
+
frameArrays?(
|
|
187
|
+
info: FrameInfo,
|
|
188
|
+
params: PassParams,
|
|
189
|
+
geom: { width: number; height: number; dpr: number; origin: { x: number; y: number } },
|
|
190
|
+
): ReadonlyArray<{ name: string; size: 2 | 3 | 4; data: Float32Array }>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The pure-shader runner adds `uOrigin` (anchored radial effects) to the shared
|
|
194
|
+
// standard set.
|
|
195
|
+
const STANDARD = ["uOrigin", ...STANDARD_COMMON] as const;
|
|
196
|
+
|
|
197
|
+
/** Upload a decoded SDF as a single-channel R8 texture (FLIP_Y, edge-clamped). */
|
|
198
|
+
function uploadSdf(glc: GLContext, sdf: DecodedSdf): WebGLTexture {
|
|
199
|
+
const { gl } = glc;
|
|
200
|
+
const tex = gl.createTexture();
|
|
201
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
202
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
203
|
+
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
204
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, sdf.size, sdf.size, 0, gl.RED, gl.UNSIGNED_BYTE, sdf.bytes);
|
|
205
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
206
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
207
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
208
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
209
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
210
|
+
return tex!;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a drawable {@link EffectInstance} for a pure-shader effect from its
|
|
215
|
+
* config + resolved params + the runtime context. This is the `create()` an
|
|
216
|
+
* effect (or a registered program) hands to the conductor.
|
|
217
|
+
*/
|
|
218
|
+
export function createPassInstance(
|
|
219
|
+
config: PassConfig,
|
|
220
|
+
params: PassParams,
|
|
221
|
+
ctx: EffectContext,
|
|
222
|
+
): EffectInstance {
|
|
223
|
+
const pal = params.palette as RGB[];
|
|
224
|
+
const dpr = ctx.dpr;
|
|
225
|
+
const panelCfg = config.panel;
|
|
226
|
+
// In backdrop-aware mode the LIGHT pass emits premultiplied light (alpha =
|
|
227
|
+
// brightness) so it composites source-over and stays visible on any surface;
|
|
228
|
+
// the SHADOW pass keeps the original opaque fragment for its multiply blend.
|
|
229
|
+
const lightFragment = ctx.composite?.premultiplied
|
|
230
|
+
? compositeLightFragment(config.fragment)
|
|
231
|
+
: config.fragment;
|
|
232
|
+
const allUniforms = [
|
|
233
|
+
...new Set([...STANDARD, ...config.uniforms, ...(panelCfg ? [panelCfg.sampler] : [])]),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
// Optional dynamic sprite panel (drawn once per frame, uploaded to both passes).
|
|
237
|
+
const panelCanvas = panelCfg ? document.createElement("canvas") : null;
|
|
238
|
+
const panelCtx2d = panelCanvas ? panelCanvas.getContext("2d", { alpha: true }) : null;
|
|
239
|
+
const panelTexLight = panelCfg ? allocTexture(ctx.light) : null;
|
|
240
|
+
const panelTexShadow = panelCfg && ctx.shadow ? allocTexture(ctx.shadow) : null;
|
|
241
|
+
|
|
242
|
+
// The numeric params that auto-bind to a uniform: `name → u<Name>` unless an
|
|
243
|
+
// explicit binding overrides it (or maps it to null to skip).
|
|
244
|
+
const scalarBinds = computeScalarBinds(params, config.bindings ?? {});
|
|
245
|
+
|
|
246
|
+
// Aux textures. SDF sources upload up front (static R8 data); canvas sources
|
|
247
|
+
// allocate the texture now but upload pixels lazily on first bind (the canvas
|
|
248
|
+
// may still be drawn after create()).
|
|
249
|
+
const auxSpecs = config.auxTextures?.(params, ctx) ?? [];
|
|
250
|
+
interface AuxLive { spec: AuxTextureSpec; light: WebGLTexture; shadow: WebGLTexture | null }
|
|
251
|
+
const uploaded = new WeakSet<WebGLTexture>();
|
|
252
|
+
const aux: AuxLive[] = auxSpecs.map((spec) => {
|
|
253
|
+
if (spec.kind === "sdf") {
|
|
254
|
+
return { spec, light: uploadSdf(ctx.light, spec.sdf), shadow: ctx.shadow ? uploadSdf(ctx.shadow, spec.sdf) : null };
|
|
255
|
+
}
|
|
256
|
+
return { spec, light: allocTexture(ctx.light), shadow: ctx.shadow ? allocTexture(ctx.shadow) : null };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const heightFrac = (): number =>
|
|
260
|
+
typeof config.shadowHeightFrac === "function" ? config.shadowHeightFrac(params) : config.shadowHeightFrac;
|
|
261
|
+
|
|
262
|
+
const bindFrameArrays = (
|
|
263
|
+
gl: WebGL2RenderingContext,
|
|
264
|
+
u: Record<string, WebGLUniformLocation | null>,
|
|
265
|
+
arrays: ReadonlyArray<{ name: string; size: 2 | 3 | 4; data: Float32Array }> | undefined,
|
|
266
|
+
): void => {
|
|
267
|
+
if (!arrays) return;
|
|
268
|
+
for (const a of arrays) {
|
|
269
|
+
const loc = u[a.name];
|
|
270
|
+
if (!loc) continue;
|
|
271
|
+
if (a.size === 2) gl.uniform2fv(loc, a.data);
|
|
272
|
+
else if (a.size === 3) gl.uniform3fv(loc, a.data);
|
|
273
|
+
else gl.uniform4fv(loc, a.data);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const drawPass = (
|
|
278
|
+
glc: GLContext,
|
|
279
|
+
info: FrameInfo,
|
|
280
|
+
frameUniforms: { amp: number } & Record<string, number>,
|
|
281
|
+
isShadow: boolean,
|
|
282
|
+
frameArrs?: ReadonlyArray<{ name: string; size: 2 | 3 | 4; data: Float32Array }>,
|
|
283
|
+
): void => {
|
|
284
|
+
const { gl } = glc;
|
|
285
|
+
const c = glc.canvas;
|
|
286
|
+
const { u } = beginProgram(glc, config.vertex, isShadow ? config.fragment : lightFragment, allUniforms);
|
|
287
|
+
|
|
288
|
+
// Aux textures (baked SDF icons / rasterized canvas glyphs).
|
|
289
|
+
for (const a of aux) {
|
|
290
|
+
const tex = isShadow ? a.shadow : a.light;
|
|
291
|
+
if (tex) {
|
|
292
|
+
gl.activeTexture(gl.TEXTURE0 + a.spec.unit);
|
|
293
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
294
|
+
// Canvas sources upload their pixels lazily, once per texture.
|
|
295
|
+
if (a.spec.kind === "canvas" && !uploaded.has(tex)) {
|
|
296
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
297
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
298
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, a.spec.source);
|
|
299
|
+
uploaded.add(tex);
|
|
300
|
+
}
|
|
301
|
+
const loc = u[a.spec.sampler];
|
|
302
|
+
if (loc) gl.uniform1i(loc, a.spec.unit);
|
|
303
|
+
}
|
|
304
|
+
if (a.spec.onUniform && u[a.spec.onUniform]) gl.uniform1f(u[a.spec.onUniform], tex ? 1 : 0);
|
|
305
|
+
applyFloatMap(gl, u, a.spec.uniforms?.(c, params));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Dynamic sprite panel (drawn this frame in renderAt): upload + bind here.
|
|
309
|
+
if (panelCfg && panelCanvas) {
|
|
310
|
+
const tex = isShadow ? panelTexShadow : panelTexLight;
|
|
311
|
+
if (tex) {
|
|
312
|
+
gl.activeTexture(gl.TEXTURE0 + panelCfg.unit);
|
|
313
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
314
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
315
|
+
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
316
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, panelCanvas);
|
|
317
|
+
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
318
|
+
if (u[panelCfg.sampler]) gl.uniform1i(u[panelCfg.sampler], panelCfg.unit);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Extra per-pass scalar uniforms (canvas/target-size-dependent, non-aux).
|
|
323
|
+
applyFloatMap(gl, u, config.passUniforms?.(c, params, resolveTargetPx(c, ctx.targetSize, dpr), dpr));
|
|
324
|
+
|
|
325
|
+
// Standard uniforms.
|
|
326
|
+
gl.uniform2f(u.uResolution, c.width, c.height);
|
|
327
|
+
bindTarget(gl, u, c, ctx.targetSize, dpr);
|
|
328
|
+
if (config.usesOrigin && u.uOrigin) {
|
|
329
|
+
// gl_FragCoord origin is bottom-left, so flip the anchor's y.
|
|
330
|
+
gl.uniform2f(u.uOrigin, ctx.anchor.x * dpr, c.height - ctx.anchor.y * dpr);
|
|
331
|
+
}
|
|
332
|
+
gl.uniform1f(u.uLife, info.life);
|
|
333
|
+
gl.uniform1f(u.uTimeS, info.animMs / 1000);
|
|
334
|
+
if (config.loopPeriodMs) {
|
|
335
|
+
// Standard periodic clocks for a looping effect, off the SAME snapped
|
|
336
|
+
// clock as uTimeS (so the on-twos seam guarantee carries over).
|
|
337
|
+
const loopMs = info.animMs % config.loopPeriodMs;
|
|
338
|
+
gl.uniform1f(u.uLoopS, loopMs / 1000);
|
|
339
|
+
gl.uniform1f(u.uPhase, loopMs / config.loopPeriodMs);
|
|
340
|
+
}
|
|
341
|
+
gl.uniform1f(u.uStyle, params.style);
|
|
342
|
+
bindPalette(gl, u, pal);
|
|
343
|
+
bindScalars(gl, u, params, scalarBinds);
|
|
344
|
+
bindFrameUniforms(gl, u, frameUniforms);
|
|
345
|
+
bindFrameArrays(gl, u, frameArrs);
|
|
346
|
+
|
|
347
|
+
gl.uniform1f(u.uShadow, isShadow ? 1 : 0);
|
|
348
|
+
if (isShadow) bindShadowGeometry(gl, u, c, heightFrac(), frameUniforms.amp, params.style);
|
|
349
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
let disposed = false;
|
|
353
|
+
return {
|
|
354
|
+
durationMs: params.durationMs,
|
|
355
|
+
renderAt(elapsedMs: number): void {
|
|
356
|
+
if (disposed) return;
|
|
357
|
+
// "Animate on twos": snap the clock toward a coarse grid as style rises.
|
|
358
|
+
const stepped = Math.floor(elapsedMs / NPR_TIME_STEP_MS) * NPR_TIME_STEP_MS;
|
|
359
|
+
const animMs = elapsedMs + (stepped - elapsedMs) * params.style;
|
|
360
|
+
const life = Math.min(Math.max(animMs, 0) / params.durationMs, 1);
|
|
361
|
+
const info: FrameInfo = { animMs, life, elapsedMs };
|
|
362
|
+
const frameUniforms = config.frame(info, params);
|
|
363
|
+
// Draw the dynamic sprite panel once (shared by both passes), if present.
|
|
364
|
+
if (panelCfg && panelCanvas && panelCtx2d) {
|
|
365
|
+
const c = ctx.light.canvas;
|
|
366
|
+
if (panelCanvas.width !== c.width || panelCanvas.height !== c.height) {
|
|
367
|
+
panelCanvas.width = c.width;
|
|
368
|
+
panelCanvas.height = c.height;
|
|
369
|
+
}
|
|
370
|
+
const centerPx = { x: ctx.anchor.x * dpr, y: ctx.anchor.y * dpr };
|
|
371
|
+
panelCfg.draw(panelCtx2d, c.width, c.height, params, { ...info, centerPx, dpr });
|
|
372
|
+
}
|
|
373
|
+
// Per-frame array uniforms (CPU-precomputed geometry), computed once.
|
|
374
|
+
const c = ctx.light.canvas;
|
|
375
|
+
const origin = { x: ctx.anchor.x * dpr, y: c.height - ctx.anchor.y * dpr };
|
|
376
|
+
const frameArrs = config.frameArrays?.(info, params, { width: c.width, height: c.height, dpr, origin });
|
|
377
|
+
if (ctx.shadow) drawPass(ctx.shadow, info, frameUniforms, true, frameArrs);
|
|
378
|
+
drawPass(ctx.light, info, frameUniforms, false, frameArrs);
|
|
379
|
+
},
|
|
380
|
+
dispose(): void {
|
|
381
|
+
if (disposed) return;
|
|
382
|
+
disposed = true;
|
|
383
|
+
for (const a of aux) {
|
|
384
|
+
ctx.light.gl.deleteTexture(a.light);
|
|
385
|
+
if (a.shadow && ctx.shadow) ctx.shadow.gl.deleteTexture(a.shadow);
|
|
386
|
+
}
|
|
387
|
+
if (panelTexLight) ctx.light.gl.deleteTexture(panelTexLight);
|
|
388
|
+
if (panelTexShadow && ctx.shadow) ctx.shadow.gl.deleteTexture(panelTexShadow);
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-program registry — the seam that makes `loadEffect()` real.
|
|
3
|
+
*
|
|
4
|
+
* A `.dope` is renderer-agnostic data, but it references a SHADER PROGRAM by key
|
|
5
|
+
* (`render.backends.webgl2.shader.program`, e.g. "solarbloom"). The runtime ships
|
|
6
|
+
* a small set of bundled programs; each is a `create(params, ctx)` renderer (the
|
|
7
|
+
* GLSL body + uniform plumbing) plus the metadata the loader needs to resolve the
|
|
8
|
+
* `.dope` into that renderer's params: the per-fire scatter key (moteSeed /
|
|
9
|
+
* inkSeed / …), the integer-clamp consts (MAX_MOTES / MAX_DROPS), whether it
|
|
10
|
+
* casts a shadow, and its reduced-motion peak.
|
|
11
|
+
*
|
|
12
|
+
* Built-in effects register their renderer here (in addition to registering
|
|
13
|
+
* themselves as an `EffectFactory`), so `loadEffect(anyDopeDoc)` can bind an
|
|
14
|
+
* arbitrary, host-authored `.dope` to a bundled program with NO new code — the
|
|
15
|
+
* whole point of the format. This is "the format references shader bodies; it is
|
|
16
|
+
* not a transpiler" made concrete: the doc carries data + a program key; the
|
|
17
|
+
* runtime owns the GLSL the key resolves to.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { EffectContext, EffectInstance } from "./effect.js";
|
|
21
|
+
|
|
22
|
+
/** A bundled renderer + the metadata the loader needs to feed it from a `.dope`. */
|
|
23
|
+
export interface RenderProgram<Params = Record<string, unknown>> {
|
|
24
|
+
/** Build a drawable instance from resolved params (same shape as EffectFactory.create). */
|
|
25
|
+
create(params: Params, ctx: EffectContext): EffectInstance;
|
|
26
|
+
/** The per-fire scatter-offset key this renderer reads (moteSeed / inkSeed / …). */
|
|
27
|
+
scatterKey: string;
|
|
28
|
+
/** Integer-clamp constants referenced by the `.dope` mapping (MAX_MOTES, …). */
|
|
29
|
+
consts: Record<string, number>;
|
|
30
|
+
/** Whether the renderer wants a shadow (multiply) companion canvas. Default true. */
|
|
31
|
+
castsShadow?: boolean;
|
|
32
|
+
/** Reduced-motion peak/hold (ms). */
|
|
33
|
+
reducedMotion?: { holdMs?: number; peakMs?: number };
|
|
34
|
+
/**
|
|
35
|
+
* Optional hook to compose NON-numeric, code-shaped params on top of the
|
|
36
|
+
* loader's numeric/palette bag (e.g. Solarbloom's whimsy-picked check glyph,
|
|
37
|
+
* Comic's word + typography). Pure; receives the feeling. Most data-driven
|
|
38
|
+
* effects (incl. the fail effect) need none.
|
|
39
|
+
*/
|
|
40
|
+
composeParams?(
|
|
41
|
+
numeric: Record<string, unknown>,
|
|
42
|
+
feeling: { mood: string; intensity: number; whimsy: number; seed: number },
|
|
43
|
+
): Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const programs = new Map<string, RenderProgram>();
|
|
47
|
+
|
|
48
|
+
/** Register (or override) a bundled render program by key. */
|
|
49
|
+
export function registerProgram<P>(name: string, program: RenderProgram<P>): RenderProgram<P> {
|
|
50
|
+
programs.set(name, program as RenderProgram);
|
|
51
|
+
return program;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Look up a bundled render program by key. */
|
|
55
|
+
export function getProgram(name: string): RenderProgram | undefined {
|
|
56
|
+
return programs.get(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Names of all registered render programs. */
|
|
60
|
+
export function programNames(): string[] {
|
|
61
|
+
return [...programs.keys()];
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect registry — the lookup table the runtime uses to find an effect by name.
|
|
3
|
+
*
|
|
4
|
+
* Effects self-register on import (see `effects/*.ts`), which keeps the registry
|
|
5
|
+
* tree-shakeable: if you never import an effect, it never lands in the bundle
|
|
6
|
+
* *or* the registry. The public `play({ effect })` / element / React surfaces
|
|
7
|
+
* all route through here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { EffectFactory } from "./effect.js";
|
|
11
|
+
|
|
12
|
+
const effects = new Map<string, EffectFactory>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register (or override) an effect. Returns the factory so registration can be
|
|
16
|
+
* the module's export pattern:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* export default registerEffect({
|
|
20
|
+
* name: "confetti",
|
|
21
|
+
* resolve(feeling, mood) { ... },
|
|
22
|
+
* create(params, ctx) { ... },
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function registerEffect<P>(factory: EffectFactory<P>): EffectFactory<P> {
|
|
27
|
+
effects.set(factory.name, factory as EffectFactory);
|
|
28
|
+
return factory;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Look up an effect by name, or `undefined` if it hasn't been registered. */
|
|
32
|
+
export function getEffect(name: string): EffectFactory | undefined {
|
|
33
|
+
return effects.get(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether an effect name is registered. */
|
|
37
|
+
export function hasEffect(name: string): boolean {
|
|
38
|
+
return effects.has(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Names of all registered effects. */
|
|
42
|
+
export function effectNames(): string[] {
|
|
43
|
+
return [...effects.keys()];
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-environment guards. Every access to a browser-only global
|
|
3
|
+
* (`document`, `window`, `matchMedia`, `devicePixelRatio`) goes through here so
|
|
4
|
+
* the whole library is SSR-safe: importing `@dopaminefx/core` on a server, or
|
|
5
|
+
* calling `celebrate()` where there is no DOM, is a no-op rather than a crash.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** True only in a real browser with a DOM we can mount an overlay into. */
|
|
9
|
+
export function isBrowser(): boolean {
|
|
10
|
+
return typeof document !== "undefined" && typeof window !== "undefined";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Whether the document is currently hidden (background tab). SSR-safe. */
|
|
14
|
+
export function isDocumentHidden(): boolean {
|
|
15
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the user has asked for reduced motion. SSR-safe and defensive: if
|
|
20
|
+
* `matchMedia` is unavailable or throws we assume motion is fine (the prior
|
|
21
|
+
* default behaviour).
|
|
22
|
+
*/
|
|
23
|
+
export function prefersReducedMotion(): boolean {
|
|
24
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Device-pixel ratio, capped at 2 to bound fill cost under software WebGL. */
|
|
35
|
+
export function deviceDpr(): number {
|
|
36
|
+
if (typeof window === "undefined") return 1;
|
|
37
|
+
return Math.min(window.devicePixelRatio || 1, 2);
|
|
38
|
+
}
|