@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.
Files changed (136) hide show
  1. package/dist/engine/color.d.ts +71 -0
  2. package/dist/engine/color.d.ts.map +1 -0
  3. package/dist/engine/color.js +107 -0
  4. package/dist/engine/color.js.map +1 -0
  5. package/dist/engine/context.d.ts +54 -0
  6. package/dist/engine/context.d.ts.map +1 -0
  7. package/dist/engine/context.js +0 -0
  8. package/dist/engine/context.js.map +1 -0
  9. package/dist/engine/gl.d.ts +9 -0
  10. package/dist/engine/gl.d.ts.map +1 -0
  11. package/dist/engine/gl.js +39 -0
  12. package/dist/engine/gl.js.map +1 -0
  13. package/dist/engine/look/glsl.d.ts +95 -0
  14. package/dist/engine/look/glsl.d.ts.map +1 -0
  15. package/dist/engine/look/glsl.js +171 -0
  16. package/dist/engine/look/glsl.js.map +1 -0
  17. package/dist/engine/look/particles.glsl.d.ts +21 -0
  18. package/dist/engine/look/particles.glsl.d.ts.map +1 -0
  19. package/dist/engine/look/particles.glsl.js +44 -0
  20. package/dist/engine/look/particles.glsl.js.map +1 -0
  21. package/dist/engine/sdf.d.ts +77 -0
  22. package/dist/engine/sdf.d.ts.map +1 -0
  23. package/dist/engine/sdf.js +255 -0
  24. package/dist/engine/sdf.js.map +1 -0
  25. package/dist/engine/seed.d.ts +10 -0
  26. package/dist/engine/seed.d.ts.map +1 -0
  27. package/dist/engine/seed.js +20 -0
  28. package/dist/engine/seed.js.map +1 -0
  29. package/dist/engine/shadow.d.ts +41 -0
  30. package/dist/engine/shadow.d.ts.map +1 -0
  31. package/dist/engine/shadow.js +39 -0
  32. package/dist/engine/shadow.js.map +1 -0
  33. package/dist/engine/tempo.d.ts +33 -0
  34. package/dist/engine/tempo.d.ts.map +1 -0
  35. package/dist/engine/tempo.js +51 -0
  36. package/dist/engine/tempo.js.map +1 -0
  37. package/dist/framework/conductor.d.ts +100 -0
  38. package/dist/framework/conductor.d.ts.map +1 -0
  39. package/dist/framework/conductor.js +493 -0
  40. package/dist/framework/conductor.js.map +1 -0
  41. package/dist/framework/content.d.ts +67 -0
  42. package/dist/framework/content.d.ts.map +1 -0
  43. package/dist/framework/content.js +72 -0
  44. package/dist/framework/content.js.map +1 -0
  45. package/dist/framework/dope-pass.d.ts +131 -0
  46. package/dist/framework/dope-pass.d.ts.map +1 -0
  47. package/dist/framework/dope-pass.js +346 -0
  48. package/dist/framework/dope-pass.js.map +1 -0
  49. package/dist/framework/dope-zip.d.ts +22 -0
  50. package/dist/framework/dope-zip.d.ts.map +1 -0
  51. package/dist/framework/dope-zip.js +116 -0
  52. package/dist/framework/dope-zip.js.map +1 -0
  53. package/dist/framework/effect.d.ts +128 -0
  54. package/dist/framework/effect.d.ts.map +1 -0
  55. package/dist/framework/effect.js +19 -0
  56. package/dist/framework/effect.js.map +1 -0
  57. package/dist/framework/frame-expr.d.ts +124 -0
  58. package/dist/framework/frame-expr.d.ts.map +1 -0
  59. package/dist/framework/frame-expr.js +135 -0
  60. package/dist/framework/frame-expr.js.map +1 -0
  61. package/dist/framework/load-effect.d.ts +77 -0
  62. package/dist/framework/load-effect.d.ts.map +1 -0
  63. package/dist/framework/load-effect.js +135 -0
  64. package/dist/framework/load-effect.js.map +1 -0
  65. package/dist/framework/loader.d.ts +309 -0
  66. package/dist/framework/loader.d.ts.map +1 -0
  67. package/dist/framework/loader.js +266 -0
  68. package/dist/framework/loader.js.map +1 -0
  69. package/dist/framework/mood-registry.d.ts +58 -0
  70. package/dist/framework/mood-registry.d.ts.map +1 -0
  71. package/dist/framework/mood-registry.js +58 -0
  72. package/dist/framework/mood-registry.js.map +1 -0
  73. package/dist/framework/panel-runner.d.ts +96 -0
  74. package/dist/framework/panel-runner.d.ts.map +1 -0
  75. package/dist/framework/panel-runner.js +137 -0
  76. package/dist/framework/panel-runner.js.map +1 -0
  77. package/dist/framework/pass-common.d.ts +97 -0
  78. package/dist/framework/pass-common.d.ts.map +1 -0
  79. package/dist/framework/pass-common.js +178 -0
  80. package/dist/framework/pass-common.js.map +1 -0
  81. package/dist/framework/pass-runner.d.ts +183 -0
  82. package/dist/framework/pass-runner.d.ts.map +1 -0
  83. package/dist/framework/pass-runner.js +212 -0
  84. package/dist/framework/pass-runner.js.map +1 -0
  85. package/dist/framework/programs.d.ts +54 -0
  86. package/dist/framework/programs.d.ts.map +1 -0
  87. package/dist/framework/programs.js +33 -0
  88. package/dist/framework/programs.js.map +1 -0
  89. package/dist/framework/registry.d.ts +29 -0
  90. package/dist/framework/registry.d.ts.map +1 -0
  91. package/dist/framework/registry.js +38 -0
  92. package/dist/framework/registry.js.map +1 -0
  93. package/dist/framework/runtime.d.ts +19 -0
  94. package/dist/framework/runtime.d.ts.map +1 -0
  95. package/dist/framework/runtime.js +37 -0
  96. package/dist/framework/runtime.js.map +1 -0
  97. package/dist/index.d.ts +63 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +126 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/overlay.d.ts +46 -0
  102. package/dist/overlay.d.ts.map +1 -0
  103. package/dist/overlay.js +79 -0
  104. package/dist/overlay.js.map +1 -0
  105. package/dist/types.d.ts +68 -0
  106. package/dist/types.d.ts.map +1 -0
  107. package/dist/types.js +10 -0
  108. package/dist/types.js.map +1 -0
  109. package/package.json +37 -0
  110. package/src/engine/color.ts +154 -0
  111. package/src/engine/context.ts +0 -0
  112. package/src/engine/gl.ts +46 -0
  113. package/src/engine/look/glsl.ts +183 -0
  114. package/src/engine/look/particles.glsl.ts +44 -0
  115. package/src/engine/sdf.ts +298 -0
  116. package/src/engine/seed.ts +23 -0
  117. package/src/engine/shadow.ts +66 -0
  118. package/src/engine/tempo.ts +54 -0
  119. package/src/framework/conductor.ts +604 -0
  120. package/src/framework/content.ts +113 -0
  121. package/src/framework/dope-pass.ts +432 -0
  122. package/src/framework/dope-zip.ts +125 -0
  123. package/src/framework/effect.ts +127 -0
  124. package/src/framework/frame-expr.ts +217 -0
  125. package/src/framework/load-effect.ts +204 -0
  126. package/src/framework/loader.ts +502 -0
  127. package/src/framework/mood-registry.ts +87 -0
  128. package/src/framework/panel-runner.ts +233 -0
  129. package/src/framework/pass-common.ts +222 -0
  130. package/src/framework/pass-runner.ts +391 -0
  131. package/src/framework/programs.ts +62 -0
  132. package/src/framework/registry.ts +44 -0
  133. package/src/framework/runtime.ts +38 -0
  134. package/src/index.ts +227 -0
  135. package/src/overlay.ts +109 -0
  136. 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
+ }