instantshader 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 InstantGradient (instantgradient.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # instantshader
2
+
3
+ Animated WebGL gradient shaders with zero dependencies. Mount a live, resizable
4
+ gradient into any DOM element, or render a single frame to a detached canvas
5
+ for export pipelines. Built by [InstantGradient](https://instantgradient.com/shaders).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install instantshader
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { mountGradient, flow } from "instantshader";
17
+
18
+ const handle = mountGradient(document.getElementById("bg")!, {
19
+ shader: flow,
20
+ colors: ["#4f46e5", "#ec4899", "#22d3ee"],
21
+ });
22
+
23
+ // handle.pause() / handle.resume() / handle.dispose() when done
24
+ ```
@@ -0,0 +1,158 @@
1
+ //#region src/renderer.d.ts
2
+
3
+ type Renderer = {
4
+ renderAt(timeMs: number): void;
5
+ setColors(colors: string[]): void;
6
+ setParams(params: Record<string, number>): void;
7
+ resize(width: number, height: number): void;
8
+ dispose(): void;
9
+ };
10
+ declare function createRenderer(opts: RendererOptions): Renderer;
11
+ //#endregion
12
+ //#region src/types.d.ts
13
+ /**
14
+ * Describes a single tunable knob exposed by a shader (e.g. "frequency",
15
+ * "warp amount"). The renderer uses this metadata to build UI controls and
16
+ * to validate/clamp incoming values; it does not carry a value itself.
17
+ */
18
+ type ParamDef = {
19
+ /** Param identifier. The GLSL uniform name is always "u_" + key. */
20
+ key: string;
21
+ /** Human-readable label for UI controls (sliders, etc). */
22
+ label: string;
23
+ min: number;
24
+ max: number;
25
+ step: number;
26
+ /** Value used when no override is supplied in MountOptions.params. */
27
+ default: number;
28
+ };
29
+ /**
30
+ * A registered shader "look". `fragment` is raw GLSL source that assumes
31
+ * BASE_UNIFORMS (time, resolution, palette texture, etc — defined elsewhere
32
+ * in the kit) plus one `uniform float u_<key>` per entry in `params`.
33
+ */
34
+ type ShaderDef = {
35
+ /** Stable identifier used to look this shader up via getShader(id). */
36
+ id: string;
37
+ label: string;
38
+ fragment: string;
39
+ params: ParamDef[];
40
+ /**
41
+ * Produces a full param set for "randomize" flows. Takes a seeded RNG
42
+ * (0-1 uniform) rather than calling Math.random() directly so results are
43
+ * reproducible when the same seed is replayed via MountOptions.seed.
44
+ */
45
+ randomParams: (rand: () => number) => Record<string, number>;
46
+ };
47
+ /** Options accepted by the kit's mount() entry point. */
48
+ type MountOptions = {
49
+ /** The shader to render. */
50
+ shader: ShaderDef;
51
+ /** Hex color stops forming the gradient's palette ramp, in order. */
52
+ colors: string[];
53
+ /** Overrides for the shader's params; unset keys fall back to ParamDef.default. */
54
+ params?: Record<string, number>;
55
+ /** Animation speed multiplier. Defaults to 1. */
56
+ speed?: number;
57
+ /** RNG seed for any randomized/time-offset behavior. Defaults to 0. */
58
+ seed?: number;
59
+ };
60
+ /** Live handle returned by mount(), used to control a running gradient instance. */
61
+ type MountHandle = {
62
+ canvas: HTMLCanvasElement;
63
+ setColors(colors: string[]): void;
64
+ setParams(params: Record<string, number>): void;
65
+ setSpeed(speed: number): void;
66
+ pause(): void;
67
+ resume(): void;
68
+ /** Jumps playback to an absolute time position, in milliseconds. */
69
+ seek(ms: number): void;
70
+ getTimeMs(): number;
71
+ /** Tears down the WebGL context and stops the render loop. Idempotent. */
72
+ dispose(): void;
73
+ };
74
+ /** Result of a one-shot renderGradientFrame() call: the rendered canvas plus
75
+ * an explicit disposer for its GL context. */
76
+ type RenderFrameResult = {
77
+ canvas: HTMLCanvasElement;
78
+ /** Releases the GL context. Call once the caller is done reading pixels
79
+ * from `canvas` (toDataURL/toBlob/getImageData/drawImage). */
80
+ dispose(): void;
81
+ };
82
+ /** Options accepted by createRenderer() — the low-level, seekable renderer
83
+ * that mountGradient/renderGradientFrame both build on. */
84
+ type RendererOptions = {
85
+ canvas: HTMLCanvasElement;
86
+ shader: ShaderDef;
87
+ colors: string[];
88
+ params: Record<string, number>;
89
+ seed: number;
90
+ };
91
+ //#endregion
92
+ //#region src/shaders/flow.d.ts
93
+ declare const flow: ShaderDef;
94
+ //#endregion
95
+ //#region src/shaders/beam.d.ts
96
+ declare const beam: ShaderDef;
97
+ //#endregion
98
+ //#region src/registry.d.ts
99
+ declare const shaders: readonly ShaderDef[];
100
+ declare function getShader(id: string): ShaderDef | undefined;
101
+ //#endregion
102
+ //#region src/mount.d.ts
103
+ /**
104
+ * Mounts a live, animated gradient into `container` and returns a handle to
105
+ * control it. Owns a canvas (sized to the container via ResizeObserver, DPR
106
+ * capped at 2 to bound fill-rate cost on high-density displays) and a RAF
107
+ * loop that runs ONLY while playing — the same lifecycle used by the
108
+ * InstantGradient app's canvas preview, which stops scheduling
109
+ * requestAnimationFrame entirely while paused/frozen rather than continuing
110
+ * to tick with no-op frames. pause() cancels the
111
+ * in-flight frame and freezes `clockMs`; resume() restarts the loop from
112
+ * there. Since the loop is fully stopped while paused, setColors/setParams/
113
+ * seek/resize (via the ResizeObserver) each trigger a single on-demand
114
+ * `renderer.renderAt(clockMs)` so a paused canvas still repaints immediately
115
+ * instead of going stale until the next resume() — this matters because
116
+ * callers may mount many simultaneously-paused instances (e.g. a screenshot
117
+ * grid) that must never carry a perpetual 60fps draw loop each.
118
+ */
119
+ declare function mountGradient(container: HTMLElement, opts: MountOptions): MountHandle;
120
+ //#endregion
121
+ //#region src/frame.d.ts
122
+ /**
123
+ * Renders a single frame into a detached (not-in-DOM) canvas at an exact
124
+ * pixel size, for export/thumbnail use cases that need a synchronous
125
+ * snapshot rather than a live animation.
126
+ *
127
+ * The returned canvas is NOT disposed automatically — its GL context must
128
+ * stay alive after this function returns so callers can scrape pixels from
129
+ * it (toDataURL/toBlob/getImageData/drawImage). Once the caller is done
130
+ * with it, release the GL context by calling the returned `dispose()`.
131
+ */
132
+ declare function renderGradientFrame(opts: {
133
+ shader: ShaderDef;
134
+ colors: string[];
135
+ params?: Record<string, number>;
136
+ seed?: number;
137
+ timeMs?: number;
138
+ width: number;
139
+ height: number;
140
+ }): RenderFrameResult;
141
+ //#endregion
142
+ //#region src/palette.d.ts
143
+ /**
144
+ * Builds a 1024-texel RGBA ramp (Uint8Array, length 1024*4) by interpolating
145
+ * the given hex color stops in OKLCh space (see the top-of-file comment for
146
+ * why polar rather than Cartesian OKLab).
147
+ *
148
+ * Stops are placed at evenly spaced positions: color i sits at t = i/(n-1)
149
+ * (a single-color palette is treated as that color duplicated at t=0 and
150
+ * t=1, so it produces a flat ramp rather than dividing by zero). The ramp
151
+ * is NOT wrapped: texel 0 is exactly the first color, texel 1023 is exactly
152
+ * the last. Shaders that want a mirrored/looping gradient are responsible
153
+ * for remapping their sample coordinate (e.g. abs(fract(t)*2-1)) before
154
+ * sampling this texture.
155
+ */
156
+ declare function buildPaletteRamp(colors: string[]): Uint8Array;
157
+ //#endregion
158
+ export { type MountHandle, type MountOptions, type ParamDef, type RenderFrameResult, type Renderer, type RendererOptions, type ShaderDef, beam, buildPaletteRamp, createRenderer, flow, getShader, mountGradient, renderGradientFrame, shaders };
package/dist/index.js ADDED
@@ -0,0 +1,1120 @@
1
+ //#region src/shaders/noise.ts
2
+ /**
3
+ * 2D simplex noise, copied VERBATIM from the standard reference
4
+ * implementation (Ashima Arts / Ian McEwan, public domain). Do not hand-edit
5
+ * the constants below -- they are fitted values for the simplex lattice
6
+ * skew/unskew and permutation polynomial, not numbers you can derive or
7
+ * "clean up".
8
+ */
9
+ const SIMPLEX_2D = `
10
+ vec3 mod289_3(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
11
+ vec2 mod289_2(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
12
+ vec3 permute(vec3 x) { return mod289_3(((x * 34.0) + 1.0) * x); }
13
+
14
+ float snoise(vec2 v) {
15
+ const vec4 C = vec4(0.211324865405187, 0.366025403784439,
16
+ -0.577350269189626, 0.024390243902439);
17
+ vec2 i = floor(v + dot(v, C.yy));
18
+ vec2 x0 = v - i + dot(i, C.xx);
19
+ vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
20
+ vec4 x12 = x0.xyxy + C.xxzz;
21
+ x12.xy -= i1;
22
+ i = mod289_2(i);
23
+ vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
24
+ + i.x + vec3(0.0, i1.x, 1.0));
25
+ vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
26
+ m = m * m; m = m * m;
27
+ vec3 x = 2.0 * fract(p * C.www) - 1.0;
28
+ vec3 h = abs(x) - 0.5;
29
+ vec3 ox = floor(x + 0.5);
30
+ vec3 a0 = x - ox;
31
+ m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
32
+ vec3 g;
33
+ g.x = a0.x * x0.x + h.x * x0.y;
34
+ g.yz = a0.yz * x12.xz + h.yz * x12.yw;
35
+ return 130.0 * dot(m, g);
36
+ }
37
+ `;
38
+ /**
39
+ * Fractal Brownian motion: sums octaves of snoise at doubling frequency
40
+ * (lacunarity 2.0) and halving amplitude (gain 0.5), so each added octave
41
+ * layers in finer detail at proportionally less visual weight. This is
42
+ * what turns a single flat simplex "blob" field into the layered, natural
43
+ * texture readers associate with clouds, drifting currents, or silk. Both
44
+ * variants divide by the total amplitude used so their output stays in
45
+ * roughly [-1, 1] no matter how many octaves are summed -- callers can mix
46
+ * fbm2/fbm3 output the same way they'd mix a raw snoise() call.
47
+ *
48
+ * fbm2 (2 octaves) is cheap and reads as a soft, single-scale warp -- good
49
+ * for silky/large-scale distortion. fbm3 (3 octaves) adds one more, finer
50
+ * top layer for looks that want visible internal detail, at the cost of one
51
+ * extra snoise() evaluation per sample.
52
+ */
53
+ const FBM = `
54
+ float fbm2(vec2 p) {
55
+ float sum = 0.0;
56
+ float amp = 0.5; // gain: each octave contributes half the previous one's weight
57
+ float freq = 1.0; // lacunarity: each octave doubles sampling frequency
58
+ sum += snoise(p * freq) * amp; amp *= 0.5; freq *= 2.0;
59
+ sum += snoise(p * freq) * amp;
60
+ return sum / 0.75; // normalize by total amplitude (0.5 + 0.25)
61
+ }
62
+
63
+ float fbm3(vec2 p) {
64
+ float sum = 0.0;
65
+ float amp = 0.5;
66
+ float freq = 1.0;
67
+ sum += snoise(p * freq) * amp; amp *= 0.5; freq *= 2.0;
68
+ sum += snoise(p * freq) * amp; amp *= 0.5; freq *= 2.0;
69
+ sum += snoise(p * freq) * amp;
70
+ return sum / 0.875; // normalize by total amplitude (0.5 + 0.25 + 0.125)
71
+ }
72
+ `;
73
+ /**
74
+ * Distribution shaping for palette lookups.
75
+ *
76
+ * `spread(x, sd)` maps a raw fbm value onto [0,1] with an approximately
77
+ * UNIFORM distribution, so every palette stop gets its fair share of screen
78
+ * area. It exists because the obvious remap, `x * 0.5 + 0.5`, does not:
79
+ * summed simplex octaves are roughly Gaussian (fbm2 sd ~0.35, fbm3 sd ~0.31,
80
+ * 90% of samples inside +-0.5), so a linear remap parks ~90% of the image in
81
+ * the middle half of the ramp and the outer stops barely appear. Measured on
82
+ * the linear version: 0-2% of frame for the outer stops of an 8-color bank.
83
+ *
84
+ * The right transform for that is the Gaussian CDF. `sd` is the standard
85
+ * deviation of the fbm being passed in (0.35 for fbm2, 0.31 for fbm3), and
86
+ * the algebraic sigmoid below approximates Phi(x/sd) closely out to about
87
+ * 2 sd. Two things it is deliberately NOT:
88
+ *
89
+ * - not a wide linear gain, which would reach the ramp ends but clip its
90
+ * tails into flat posterized patches of the end colors;
91
+ * - not a steeper sigmoid. An earlier version normalized so that |x| = 0.8
92
+ * hit the ramp end exactly, on the theory that the outermost stop should
93
+ * be reachable. But fbm has real mass well before 0.8, so that version
94
+ * hard-clamped ~8% of the frame onto EACH end color, and the interior
95
+ * stops of the 8-color banks collapsed to 3-4% of frame apiece. Reaching
96
+ * the end color matters much less than not drowning everything else.
97
+ *
98
+ * The residual 1/0.96 stretch is because the algebraic sigmoid has fatter
99
+ * tails than a true Gaussian and would otherwise stop ~4% short of the ramp
100
+ * ends. The clamp is effectively unreachable for real fbm input (it needs
101
+ * |x| > 1.28 at sd 0.35) and is there as a guard, not as a shaping step.
102
+ */
103
+ const SHAPE = `
104
+ float spread(float x, float sd) {
105
+ float k = 1.07 * sd; // fitted at the 1-sigma point so s(sd) ~= Phi(1)
106
+ float s = x / sqrt(x * x + k * k);
107
+ return clamp(0.5 + 0.5 * s / 0.96, 0.0, 1.0);
108
+ }
109
+ `;
110
+ /**
111
+ * Cheap per-pixel hash noise for film grain -- deliberately NOT simplex
112
+ * based. Grain needs to look like uncorrelated static at the pixel level;
113
+ * a band-limited noise function like snoise would need an impractically
114
+ * huge input scale to look that fine-grained, which would push it right
115
+ * back into the float-precision dead zone described above. Matches the same
116
+ * hash-based grain technique used for this purpose in the InstantGradient
117
+ * app (origin repo). `time` is folded into the hash input (not just added
118
+ * as a phase) so the grain pattern itself re-randomizes every frame instead
119
+ * of sitting static on top of a moving gradient.
120
+ */
121
+ const GRAIN = `
122
+ float grain(vec2 uv, float time) {
123
+ vec2 st = uv * 200.0 + time * 0.1; // 200x: grain must read as per-pixel static, not a soft blob
124
+ return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
125
+ }
126
+ `;
127
+
128
+ //#endregion
129
+ //#region src/shaders/flow.ts
130
+ const FRAGMENT$1 = `
131
+ uniform float u_scale;
132
+ uniform float u_drift;
133
+ uniform float u_openness;
134
+ uniform float u_grain;
135
+
136
+ ${SIMPLEX_2D}
137
+ ${FBM}
138
+ ${SHAPE}
139
+ ${GRAIN}
140
+
141
+ // Curl of a scalar simplex field: the finite-difference gradient of snoise,
142
+ // rotated 90 degrees -- (dPsi/dy, -dPsi/dx) instead of (dPsi/dx, dPsi/dy).
143
+ // A rotated gradient is always divergence-free, which is the whole trick:
144
+ // advecting a point along it produces swirling motion with nothing to make
145
+ // it converge or diverge, unlike advecting along the gradient itself.
146
+ vec2 curl(vec2 p) {
147
+ // Finite-difference step: small enough to approximate a derivative,
148
+ // large enough that snoise's own float precision doesn't swamp the
149
+ // difference between the two samples.
150
+ float eps = 0.05;
151
+ float dx = (snoise(p + vec2(eps, 0.0)) - snoise(p - vec2(eps, 0.0))) / (2.0 * eps);
152
+ float dy = (snoise(p + vec2(0.0, eps)) - snoise(p - vec2(0.0, eps))) / (2.0 * eps);
153
+ return vec2(dy, -dx);
154
+ }
155
+
156
+ void main() {
157
+ vec2 uv = worldUv();
158
+ // Slow crawl so currents read as continuous, not jittery. Half the rate an
159
+ // earlier prototype of this shader used, because this one has leverage: the
160
+ // drift only enters the curl coordinate, and rotating the advection field
161
+ // moves the sampled point much further than nudging an fbm coordinate
162
+ // directly would. At 0.05 (that earlier prototype's rate) the whole
163
+ // composition reorganized every ~3 seconds, measured as more pixel change
164
+ // over 3.5s than the beam prototype showed over 7.5s.
165
+ float drift = u_time * 0.025;
166
+
167
+ // Advect the sample point along the curl field in 3 FIXED steps (written
168
+ // out explicitly rather than a variable-length loop, which risks the
169
+ // driver's "shader too complex" downsampling heuristic on some GPUs --
170
+ // see BASE_UNIFORMS / project notes on avoiding uniform-array loops).
171
+ // Each step nudges the point further along the local current, and it is
172
+ // the NUMBER of steps that turns advection into rotation: one step is a
173
+ // plain directional shove, and at two the point still travels an almost
174
+ // straight chord. The third is where it curves enough to close visible
175
+ // eddies, which is the whole point of the look. Step gain is dropped from
176
+ // 0.08 to 0.055 to keep the total travel about where it was.
177
+ //
178
+ // The curl field is sampled at 0.55x the fbm's frequency, i.e. the
179
+ // currents are deliberately LARGER than the colour masses they carry.
180
+ // Sampled at the same frequency (as it was) each mass sat inside its own
181
+ // little eddy, so the advection only roughened mass edges and the result
182
+ // was indistinguishable from a plain warped fbm.
183
+ float curlScale = u_scale * 0.55;
184
+ vec2 advected = uv;
185
+ advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
186
+ advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
187
+ advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
188
+
189
+ float t = fbm2(advected * u_scale + u_seed);
190
+
191
+ // Remap to [0,1]. NOT a plain t * 0.5 + 0.5: fbm2 is roughly Gaussian with
192
+ // sd ~0.35, so a linear remap parks ~90% of the frame in the middle half of
193
+ // the ramp. Measured on the linear version, the first palette colour took
194
+ // 0.0% of the frame on bright-8 and jewel-6. See spread() in noise.ts.
195
+ t = spread(t, 0.35); // 0.35 = fbm2's standard deviation
196
+
197
+ // Openness: mixing toward t*t pulls low values further down (t*t < t for
198
+ // t in (0,1)) while leaving values near 1 nearly untouched. Low t samples
199
+ // the palette's first colour, so this widens that colour into the calm
200
+ // negative space the look is built around, rather than dimming the whole
201
+ // image uniformly. It has to come AFTER spread(): applied to the raw
202
+ // Gaussian remap it was biasing an already centre-heavy distribution, which
203
+ // is what put the washed-out midtone zones on dark-8.
204
+ t = mix(t, t * t, u_openness);
205
+
206
+ // No mirror-wrap: spread() already bounds t to [0,1], so the old
207
+ // abs(fract(t*0.5)*2-1) reduced to a plain 1 - t. That silent reversal was
208
+ // also what turned the openness bias above into a bias toward the palette's
209
+ // LAST colour, which is how the first colour ended up at 0% of frame.
210
+ //
211
+ // No final smoothstep either: t is already uniformly distributed and there
212
+ // is no wrap fold to soften, so an S-curve here would only widen the two
213
+ // end colours at the expense of everything in between.
214
+ vec3 color = palette(t);
215
+
216
+ // Grain: cheap per-pixel dither, centered at 0 so it can darken or
217
+ // lighten symmetrically instead of just brightening the whole frame.
218
+ float g = grain(uv, u_time) - 0.5;
219
+ color += g * u_grain;
220
+
221
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
222
+ }
223
+ `;
224
+ const flow = {
225
+ id: "flow",
226
+ label: "Flow",
227
+ fragment: FRAGMENT$1,
228
+ params: [
229
+ {
230
+ key: "scale",
231
+ label: "Scale",
232
+ min: .6,
233
+ max: 2.6,
234
+ step: .05,
235
+ default: 1.7
236
+ },
237
+ {
238
+ key: "drift",
239
+ label: "Drift",
240
+ min: 0,
241
+ max: 1,
242
+ step: .01,
243
+ default: .5
244
+ },
245
+ {
246
+ key: "openness",
247
+ label: "Openness",
248
+ min: 0,
249
+ max: 1,
250
+ step: .01,
251
+ default: .28
252
+ },
253
+ {
254
+ key: "grain",
255
+ label: "Grain",
256
+ min: 0,
257
+ max: .3,
258
+ step: .01,
259
+ default: .08
260
+ }
261
+ ],
262
+ randomParams(rand) {
263
+ return {
264
+ scale: .6 + rand() * 2,
265
+ drift: rand() * 1,
266
+ openness: rand() * 1,
267
+ grain: .08
268
+ };
269
+ }
270
+ };
271
+
272
+ //#endregion
273
+ //#region src/shaders/beam.ts
274
+ const FRAGMENT = `
275
+ uniform float u_scale;
276
+ uniform float u_width;
277
+ uniform float u_glow;
278
+ uniform float u_angle;
279
+ uniform float u_grain;
280
+
281
+ ${SIMPLEX_2D}
282
+ ${GRAIN}
283
+
284
+ const float PI = 3.14159265;
285
+
286
+ // Rec.709 luma weights -- used both to find the palette's darkest stop for
287
+ // the background and to attenuate the white core-lift on bright palettes.
288
+ const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
289
+
290
+ void main() {
291
+ vec2 uv = worldUv();
292
+
293
+ // ---- isotropic beam frame ---------------------------------------------
294
+ // worldUv() is 0-1 on BOTH axes of a 1000x562.5 world, so one uv unit of x
295
+ // is 1.78x more screen distance than one of y. flow can ignore that (a
296
+ // noise field filling the whole frame doesn't care), but a BEAM cannot:
297
+ // sampled in raw uv, a vertical beam renders 1.78x thinner than a
298
+ // horizontal one at the same u_width, and the angle param would double as
299
+ // a hidden thickness control. Scaling x by the world aspect makes the
300
+ // space isotropic -- a unit circle is a screen circle -- so thickness is
301
+ // angle-independent. halfIso mirrors worldUv()'s cover fit in the same
302
+ // space, so the frame's visible half-extents stay correct for a square
303
+ // preview tile and a 16:9 export alike.
304
+ float worldAspect = 1000.0 / 562.5;
305
+ float canvasAspect = u_resolution.x / u_resolution.y;
306
+ vec2 iso = (uv - 0.5) * vec2(worldAspect, 1.0);
307
+ vec2 halfIso = vec2(0.5 * min(1.0, canvasAspect / worldAspect),
308
+ 0.5 * min(1.0, worldAspect / canvasAspect))
309
+ * vec2(worldAspect, 1.0);
310
+
311
+ float ang = u_angle * PI / 180.0;
312
+ vec2 dir = vec2(cos(ang), sin(ang));
313
+ vec2 perp = vec2(-dir.y, dir.x);
314
+
315
+ // Projections of the frame's corner onto the beam axes: the largest |s|
316
+ // and |q| the visible frame can produce. halfSpan pins the palette ramp to
317
+ // the beam's visible length (below); crossHalf scales the seed's
318
+ // perpendicular placement so "55% toward an edge" means the same thing at
319
+ // every angle and aspect.
320
+ float halfSpan = halfIso.x * abs(dir.x) + halfIso.y * abs(dir.y);
321
+ float crossHalf = halfIso.x * abs(perp.x) + halfIso.y * abs(perp.y);
322
+
323
+ float s = dot(iso, dir); // along the beam
324
+ float q = dot(iso, perp); // across the beam
325
+ float sn = s / halfSpan; // -1..1 over the beam's visible length
326
+
327
+ // ---- per-instance composition, all derived from u_seed ----------------
328
+ // seedRow picks which horizontal SLICE of the 2D noise field this
329
+ // instance's bend lives on: different seeds get
330
+ // genuinely different curves, not the same curve translated. u_seed is
331
+ // pre-modded to [0,100), so seedRow stays well inside snoise's precision
332
+ // range.
333
+ float seedRow = u_seed * 0.37 + 11.0;
334
+
335
+ // Same slow crawl rate as the siblings. It slides the bend's sample window
336
+ // along the noise slice, so the whole beam sways -- the S migrates -- with
337
+ // no other motion source needed for the silhouette.
338
+ float drift = u_time * 0.02;
339
+
340
+ // Where the beam sits across the frame. One static seed term (placement)
341
+ // plus the animated bend. Placement is held to 50% of crossHalf so the
342
+ // beam never starts life hugging an edge; the bend adds up to ~45% more
343
+ // locally, and the clamp stops the sum at 75% so the worst seed still
344
+ // keeps the core inside the frame instead of showing only its halo.
345
+ float off0 = snoise(vec2(seedRow, 3.7));
346
+ float bend = snoise(vec2(sn * (0.55 * u_scale) + drift, seedRow));
347
+ float c = crossHalf * clamp(0.5 * off0 + 0.45 * bend, -0.75, 0.75);
348
+
349
+ // Breathing: the width swells ~10% over a ~57s cycle, phase-shifted along
350
+ // the beam (the sn * 2.0 term) so it travels as a slow peristaltic wave
351
+ // rather than the whole beam pulsing in lockstep, which read as a strobe
352
+ // precursor even at this amplitude.
353
+ float w = u_width * (1.0 + 0.10 * sin(u_time * 0.11 + sn * 2.0 + u_seed));
354
+
355
+ // Signed cross distance in units of the beam's own width. Everything
356
+ // profile-shaped below is a function of this one number.
357
+ float nd = (q - c) / w;
358
+ float nd2 = nd * nd;
359
+
360
+ // ---- the two profiles --------------------------------------------------
361
+ // core: exp(-nd^4). A plain Gaussian (exp(-nd^2)) was tried first and is
362
+ // exactly the old shader's mistake in one dimension -- its long tails mean
363
+ // the "edge" is a 2-width-wide smear and the silhouette dissolves. The
364
+ // quartic exponent gives a flat luminous top and a falloff that is soft
365
+ // but FAST (0.22 at one width, 0.01 at 1.3 widths), which is what makes
366
+ // the streak readable as a shape while still having no hard line anywhere.
367
+ float core = exp(-nd2 * nd2 * 1.5);
368
+ // halo: a wide true Gaussian, ~3x the core's width, carrying the "light
369
+ // leak" atmosphere into the dark. This one WANTS long tails -- it is the
370
+ // diffuse spill, and u_glow scales it (below) rather than the core, so
371
+ // the glow slider changes how far the light bleeds without ever blurring
372
+ // the silhouette itself.
373
+ float halo = exp(-nd2 * 0.28);
374
+
375
+ // ---- palette along the beam --------------------------------------------
376
+ // sn is -1..1 over the visible length, so this sweeps the whole ramp
377
+ // corner to corner. The 1.05 overdrive tucks the exact ramp ends a hair
378
+ // outside the frame; palette() clamps, so the first and last stops each
379
+ // hold a short solid run at the beam's ends instead of appearing only in
380
+ // the final pixel row.
381
+ float t = clamp(sn * 1.05 * 0.5 + 0.5, 0.0, 1.0);
382
+
383
+ // ---- internal filaments ------------------------------------------------
384
+ // One noise field does both filament jobs. Its anisotropy is the point:
385
+ // high frequency ACROSS the beam (nd * 2.6), low frequency ALONG it
386
+ // (0.9 * u_scale over a 2-unit sn range), so its iso-lines are long
387
+ // streaks running WITH the beam that wander slowly -- hair-thin light
388
+ // strands, not speckle. The crawl term slides the field along s at ~2.5x
389
+ // the sway rate, giving the "light travelling down the beam" motion the
390
+ // brief asks for while the silhouette itself barely moves.
391
+ // Sampled in ABSOLUTE cross-distance (q - c), not width-relative nd: in nd
392
+ // units the field gets magnified with the beam, and past width ~0.25 its
393
+ // ridge lines blew up into jagged chevron kinks. Absolute sampling keeps
394
+ // strands hair-thin at every width (26.0 = the old 2.6/nd density at the
395
+ // original 0.1 default, preserving the approved look there).
396
+ float crawl = u_time * 0.05;
397
+ float fil = snoise(vec2(sn * (0.9 * u_scale) - crawl, (q - c) * 26.0 + seedRow * 1.7));
398
+
399
+ // Holographic banding: the same field nudges the ramp position inside the
400
+ // core, so colour bands streak lengthwise through the beam (the foil-like
401
+ // internal banding of the second reference). 0.05 is about half a stop's
402
+ // width on an 8-colour bank -- enough to see, never enough to reorder the
403
+ // sequence. Masked by core so the banding cannot tint the background.
404
+ t = clamp(t + 0.05 * fil * core, 0.0, 1.0);
405
+
406
+ // Bright filament lines: the ridge transform (1 - |noise|) peaks where the
407
+ // field crosses zero, i.e. along thin wandering lines. The 0.78 threshold
408
+ // keeps only the top ~10% of the ridge, which at this anisotropy yields
409
+ // 2-3 visible strands inside the core.
410
+ //
411
+ // The wideFade term keeps the extended width range clean: with absolute
412
+ // cross-sampling a wide beam fits MANY strands, which reads busy right
413
+ // when the "wall of light" settings want a pure soft field. Fading the
414
+ // lines out over the top half of the width range hands the wide beam to
415
+ // the halo + grain alone.
416
+ float wideFade = 1.0 - smoothstep(0.25, 0.5, u_width);
417
+ float filLine = smoothstep(0.78, 0.97, 1.0 - abs(fil)) * wideFade;
418
+
419
+ // ---- background: the palette's darkest stop, crushed --------------------
420
+ // Sample three fixed ramp positions and keep the darkest (branchless --
421
+ // step/mix, no if-ladder). Three samples is enough: palette() is an OKLCh
422
+ // ramp, so the darkest point of the whole ramp is always at or near a
423
+ // stop, and ends+middle bracket every bank in the lab set. The 0.12
424
+ // multiplier is the "mixed ~88% toward black" from the art direction:
425
+ // dark enough that even cream-4's darkest stop reads as near-black, light
426
+ // enough that the tint survives (measured ~RGB 20-30 on mid banks).
427
+ vec3 cA = palette(0.0);
428
+ vec3 cB = palette(0.5);
429
+ vec3 cC = palette(1.0);
430
+ float lA = dot(cA, LUMA);
431
+ float lB = dot(cB, LUMA);
432
+ float lC = dot(cC, LUMA);
433
+ vec3 dk = mix(cA, cB, step(lB, lA));
434
+ float ld = min(lA, lB);
435
+ dk = mix(dk, cC, step(lC, ld));
436
+ vec3 bg = dk * 0.12;
437
+
438
+ // ---- compositing: additive light on the dark ----------------------------
439
+ // Everything below ADDS light to bg, never mixes toward it -- on a dark
440
+ // ground, additive is what makes the beam read as emission rather than as
441
+ // a painted stripe.
442
+ vec3 beamCol = palette(t);
443
+ float blum = dot(beamCol, LUMA);
444
+
445
+ // Luma-compensating gain on the whole beam stack. The stack below sums to
446
+ // ~1.65x beamCol at the centerline; on a bright bank (pastel-5, cream-4,
447
+ // whose stops sit near luma 0.9) that clipped every channel and the entire
448
+ // beam collapsed into one featureless white band -- the palette sequence,
449
+ // the filaments and the silhouette's soft edge all vanished into the
450
+ // clamp. Dividing by 1 + 1.2*luma^2 caps the bright banks' centerline
451
+ // near 0.95 (hues survive, edge survives) while the quadratic leaves dark
452
+ // banks -- which NEED the full additive energy to register at all --
453
+ // almost untouched.
454
+ float gain = 1.0 / (1.0 + 1.2 * blum * blum);
455
+
456
+ vec3 color = bg;
457
+
458
+ // Halo first (widest, dimmest). The 0.10 floor keeps a trace of spill even
459
+ // at glow 0 so the beam never looks laser-cut out of the dark; u_glow
460
+ // scales the rest, which is the slider's entire visible job.
461
+ color += beamCol * halo * (0.10 + 0.40 * u_glow) * gain;
462
+
463
+ // The core -- the beam's body. The chroma boost is the dark-bank mirror of
464
+ // gain: multiplying a dark stop up (rather than adding white to it)
465
+ // scales all three channels together, so dark-8's muted violets and blues
466
+ // brighten WITHOUT greying out -- the first pass leaned on the white lift
467
+ // alone and the whole beam read as monochrome silver on the dark banks.
468
+ // Cubic in (1 - luma) so it is ~1.0 for any mid-or-brighter bank and only
469
+ // really wakes up below luma ~0.3, where there is guaranteed headroom.
470
+ float boost = 1.0 + 2.0 * (1.0 - blum) * (1.0 - blum) * (1.0 - blum);
471
+ color += beamCol * core * gain * boost;
472
+
473
+ // A hot centerline: core^2 halves the effective width, so this reads as
474
+ // the brightest inner lane of the beam. Palette-coloured, not white,
475
+ // because a white hotline desaturated the saturated banks (neon-5) into
476
+ // pastel -- same failure the old shader documented for its bloom.
477
+ color += beamCol * core * core * 0.35 * gain;
478
+
479
+ // The one white term, and it is gated hard: (1 - luma)^2 means it only
480
+ // registers where the palette itself is dark. This is the floor for
481
+ // near-black-3, whose stops are so dark that even the chroma boost above
482
+ // cannot lift them to visibility (3x of luma 0.03 is still 0.09) -- some
483
+ // achromatic light is the only thing that can separate that bank's beam
484
+ // from its own backdrop. Kept smaller than the boost's contribution so it
485
+ // supplements the colour instead of silvering it, and quadratic (not
486
+ // linear) falloff, quadratic because linear still
487
+ // chalked the pastels.
488
+ color += vec3((1.0 - blum) * (1.0 - blum) * core * core * (0.08 + 0.16 * u_glow));
489
+
490
+ // Filaments ride on top, inside the core only. Mostly beam-coloured with
491
+ // a small dark-gated white lift so they also survive the near-black banks.
492
+ color += (beamCol * 0.6 + vec3(0.4 * (1.0 - blum))) * filLine * core * 0.45;
493
+
494
+ // No secondary echo streak: an earlier version had one, and the owner read
495
+ // it as a second competing pattern -- ONE beam is the composition, full
496
+ // stop. The negative space it left behind is carried by grain alone.
497
+
498
+ // Grain: cheap per-pixel dither, centered at 0 so it can darken or lighten
499
+ // symmetrically. It matters more here than in any sibling: the dark field
500
+ // is most of the frame, and grain is the only thing giving it surface --
501
+ // without it the negative space reads as dead #000 flatness instead of
502
+ // atmosphere.
503
+ float g = grain(uv, u_time) - 0.5;
504
+ color += g * u_grain;
505
+
506
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
507
+ }
508
+ `;
509
+ const beam = {
510
+ id: "beam",
511
+ label: "Beam",
512
+ fragment: FRAGMENT,
513
+ params: [
514
+ {
515
+ key: "scale",
516
+ label: "Scale",
517
+ min: .5,
518
+ max: 2,
519
+ step: .05,
520
+ default: 1
521
+ },
522
+ {
523
+ key: "width",
524
+ label: "Width",
525
+ min: .04,
526
+ max: .6,
527
+ step: .005,
528
+ default: .14
529
+ },
530
+ {
531
+ key: "glow",
532
+ label: "Glow",
533
+ min: 0,
534
+ max: 1,
535
+ step: .01,
536
+ default: .5
537
+ },
538
+ {
539
+ key: "angle",
540
+ label: "Angle",
541
+ min: 0,
542
+ max: 360,
543
+ step: 1,
544
+ default: 28
545
+ },
546
+ {
547
+ key: "grain",
548
+ label: "Grain",
549
+ min: 0,
550
+ max: .3,
551
+ step: .01,
552
+ default: .08
553
+ }
554
+ ],
555
+ randomParams(rand) {
556
+ return {
557
+ scale: .7 + rand() * .8,
558
+ width: .09 + rand() * .15,
559
+ glow: .3 + rand() * .5,
560
+ angle: rand() * 360,
561
+ grain: .08
562
+ };
563
+ }
564
+ };
565
+
566
+ //#endregion
567
+ //#region src/registry.ts
568
+ const shaders = [flow, beam];
569
+ function getShader(id) {
570
+ return shaders.find((s) => s.id === id);
571
+ }
572
+
573
+ //#endregion
574
+ //#region src/palette.ts
575
+ /** Number of texels in the output ramp. Matches a typical 1D LUT texture size:
576
+ * large enough that per-texel banding is invisible, small enough to upload
577
+ * as a single texture row every time colors change. */
578
+ const RAMP_SIZE = 1024;
579
+ /**
580
+ * Converts one sRGB channel (0-1, gamma-encoded) to linear light.
581
+ * Piecewise per the sRGB spec: a linear segment near black avoids the
582
+ * infinite slope a pure power curve would have at 0.
583
+ */
584
+ function linearize(c) {
585
+ return c <= .04045 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
586
+ }
587
+ /** Inverse of linearize(): linear light back to gamma-encoded sRGB (0-1). */
588
+ function delinearize(c) {
589
+ return c <= .0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - .055;
590
+ }
591
+ /**
592
+ * sRGB -> OKLab, Björn Ottosson's method (https://bottosson.github.io/posts/oklab/).
593
+ * Coefficients copied verbatim from that reference implementation —
594
+ * do not hand-retype these, they are fitted constants, not derivable values.
595
+ *
596
+ * The two 3x3 matrices convert linear sRGB to an LMS-like cone response
597
+ * space and then to the final Lab-like OKLab space; the cube root in
598
+ * between is what makes the space perceptually uniform.
599
+ */
600
+ function rgbToOklab(r, g, b) {
601
+ const lr = linearize(r), lg = linearize(g), lb = linearize(b);
602
+ const l_ = Math.cbrt(.4122214708 * lr + .5363325363 * lg + .0514459929 * lb);
603
+ const m_ = Math.cbrt(.2119034982 * lr + .6806995451 * lg + .1073969566 * lb);
604
+ const s_ = Math.cbrt(.0883024619 * lr + .2817188376 * lg + .6299787005 * lb);
605
+ return [
606
+ .2104542553 * l_ + .793617785 * m_ - .0040720468 * s_,
607
+ 1.9779984951 * l_ - 2.428592205 * m_ + .4505937099 * s_,
608
+ .0259040371 * l_ + .7827717662 * m_ - .808675766 * s_
609
+ ];
610
+ }
611
+ /**
612
+ * OKLab -> sRGB, inverse of rgbToOklab. Same fitted-constant caveat as
613
+ * above — do not hand-retype these coefficients.
614
+ */
615
+ function oklabToRgb(L, a, b) {
616
+ const l_ = L + .3963377774 * a + .2158037573 * b;
617
+ const m_ = L - .1055613458 * a - .0638541728 * b;
618
+ const s_ = L - .0894841775 * a - 1.291485548 * b;
619
+ const l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
620
+ return [
621
+ delinearize(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
622
+ delinearize(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
623
+ delinearize(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
624
+ ];
625
+ }
626
+ /**
627
+ * OKLab -> OKLCh: rectangular (L, a, b) to polar (L, C, h). C is just the
628
+ * distance of (a, b) from the achromatic origin; h is the angle, in
629
+ * radians, measured the same way atan2 always does (range (-pi, pi]).
630
+ */
631
+ function oklabToOklch(L, a, b) {
632
+ return [
633
+ L,
634
+ Math.hypot(a, b),
635
+ Math.atan2(b, a)
636
+ ];
637
+ }
638
+ /**
639
+ * OKLCh -> OKLab: polar back to rectangular, so the existing oklabToRgb
640
+ * matrices can be reused unchanged instead of duplicating the LMS math for
641
+ * a polar input.
642
+ */
643
+ function oklchToOklab(L, C, h) {
644
+ return [
645
+ L,
646
+ C * Math.cos(h),
647
+ C * Math.sin(h)
648
+ ];
649
+ }
650
+ /**
651
+ * Chroma below which a hue angle is noise rather than signal. Near a=b=0,
652
+ * atan2 is numerically unstable (its output can swing wildly for a tiny
653
+ * change in a near-zero a or b), and perceptually a near-gray stop simply
654
+ * doesn't have a hue to speak of. Below this threshold a stop is treated as
655
+ * achromatic for interpolation purposes: see lerpHue().
656
+ *
657
+ * ~0.005 in OKLCh chroma units is comfortably below any stop actually used
658
+ * for color (the lab palettes' least-saturated non-neutral stops sit at
659
+ * 0.075+) and comfortably above float rounding noise from the hex -> OKLab
660
+ * round trip.
661
+ */
662
+ const ACHROMATIC_CHROMA = .005;
663
+ const TAU = Math.PI * 2;
664
+ /**
665
+ * Interpolates hue from h0 toward h1 by fraction t, taking the SHORTER way
666
+ * around the circle (e.g. 350deg -> 10deg moves +20deg through 0, not -340deg
667
+ * backward through 180deg) instead of a plain linear lerp, which would sweep
668
+ * through every intermediate hue on the long way round.
669
+ *
670
+ * Two edge cases handled explicitly:
671
+ *
672
+ * - Achromatic endpoint: if one side's chroma is below ACHROMATIC_CHROMA,
673
+ * its "hue" is meaningless (see the constant's comment), so it inherits
674
+ * the other endpoint's hue instead of contributing its own noisy angle.
675
+ * Concretely this means a gray-to-color segment holds a constant hue
676
+ * while only chroma ramps up, rather than spiraling through unrelated
677
+ * hues the gray's atan2 noise happened to produce. If both endpoints are
678
+ * achromatic the chosen hue is never visible: C stays near zero for the
679
+ * whole segment (it's lerped independently, from ~0 to ~0), so whatever
680
+ * angle comes out of cos/sin gets multiplied by ~0.
681
+ * - Exact opposition (180 degrees apart): both directions around the circle
682
+ * are equally short, so "shorter arc" is ambiguous. The wrap below always
683
+ * normalizes a signed delta of exactly +-pi to -pi (see the comment on
684
+ * the wrap), which picks one direction consistently rather than depending
685
+ * on float rounding to break the tie.
686
+ */
687
+ function lerpHue(h0, c0, h1, c1, t) {
688
+ const start = c0 < ACHROMATIC_CHROMA && c1 >= ACHROMATIC_CHROMA ? h1 : h0;
689
+ let delta = (c1 < ACHROMATIC_CHROMA && c0 >= ACHROMATIC_CHROMA ? h0 : h1) - start;
690
+ delta -= TAU * Math.round(delta / TAU);
691
+ return start + delta * t;
692
+ }
693
+ /** Parses a "#rrggbb" (or "rrggbb") hex string into 0-1 sRGB components. */
694
+ function hexToRgb(hex) {
695
+ const h = hex.replace("#", "");
696
+ return [
697
+ parseInt(h.substring(0, 2), 16) / 255,
698
+ parseInt(h.substring(2, 4), 16) / 255,
699
+ parseInt(h.substring(4, 6), 16) / 255
700
+ ];
701
+ }
702
+ /**
703
+ * Builds a 1024-texel RGBA ramp (Uint8Array, length 1024*4) by interpolating
704
+ * the given hex color stops in OKLCh space (see the top-of-file comment for
705
+ * why polar rather than Cartesian OKLab).
706
+ *
707
+ * Stops are placed at evenly spaced positions: color i sits at t = i/(n-1)
708
+ * (a single-color palette is treated as that color duplicated at t=0 and
709
+ * t=1, so it produces a flat ramp rather than dividing by zero). The ramp
710
+ * is NOT wrapped: texel 0 is exactly the first color, texel 1023 is exactly
711
+ * the last. Shaders that want a mirrored/looping gradient are responsible
712
+ * for remapping their sample coordinate (e.g. abs(fract(t)*2-1)) before
713
+ * sampling this texture.
714
+ */
715
+ function buildPaletteRamp(colors) {
716
+ if (colors.length === 0) throw new Error("buildPaletteRamp requires at least one color");
717
+ const stops = colors.length === 1 ? [colors[0], colors[0]] : colors;
718
+ const stopCount = stops.length;
719
+ const stopsOklch = stops.map((hex) => {
720
+ const [r, g, b] = hexToRgb(hex);
721
+ return oklabToOklch(...rgbToOklab(r, g, b));
722
+ });
723
+ const breakpoints = [];
724
+ for (let j = 0; j < stopCount; j++) breakpoints.push(Math.floor(j * (RAMP_SIZE - 1) / (stopCount - 1)));
725
+ const out = new Uint8Array(RAMP_SIZE * 4);
726
+ let seg = 0;
727
+ for (let i = 0; i < RAMP_SIZE; i++) {
728
+ while (seg < stopCount - 2 && i > breakpoints[seg + 1]) seg++;
729
+ const segStart = breakpoints[seg];
730
+ const segEnd = breakpoints[seg + 1];
731
+ const localT = segEnd === segStart ? 0 : (i - segStart) / (segEnd - segStart);
732
+ const [L0, C0, h0] = stopsOklch[seg];
733
+ const [L1, C1, h1] = stopsOklch[seg + 1];
734
+ const L = L0 + (L1 - L0) * localT;
735
+ const [, a, bLab] = oklchToOklab(L, C0 + (C1 - C0) * localT, lerpHue(h0, C0, h1, C1, localT));
736
+ const [r, g, b] = oklabToRgb(L, a, bLab);
737
+ const idx = i * 4;
738
+ out[idx] = clamp255(r);
739
+ out[idx + 1] = clamp255(g);
740
+ out[idx + 2] = clamp255(b);
741
+ out[idx + 3] = 255;
742
+ }
743
+ return out;
744
+ }
745
+ /** Converts a 0-1 linear-ish sRGB channel to a clamped 0-255 byte. OKLab
746
+ * round-trips can slightly overshoot 0-1 for saturated/out-of-gamut
747
+ * midpoints, so clamping (rather than wrapping) is required here. */
748
+ function clamp255(v) {
749
+ return Math.max(0, Math.min(255, Math.round(v * 255)));
750
+ }
751
+
752
+ //#endregion
753
+ //#region src/renderer.ts
754
+ /**
755
+ * GLSL preamble prepended to every shader's fragment source. Declares the
756
+ * uniforms/varying every InstantShader shader can rely on, plus two helpers:
757
+ *
758
+ * - `worldUv()`: maps the 0-1 quad UV into a fixed 1000x562.5 "world" space,
759
+ * cover-fit to the canvas aspect ratio. Shaders should sample noise/pattern
760
+ * functions with this instead of the raw UV so pattern density (frequency
761
+ * of waves, blobs, etc) stays identical between a small preview and a 4K
762
+ * export of the same scene — otherwise the same "scale" param would look
763
+ * like a completely different pattern at different resolutions.
764
+ * - `palette(t)`: samples the 1D OKLCh-interpolated color ramp texture.
765
+ *
766
+ * `u_time` and `u_seed` arrive pre-modded (see renderAt below) so that a
767
+ * shader doing `sin(u_time * freq)` never loses float32 precision from a
768
+ * time value that has grown large over a long-running session.
769
+ */
770
+ const BASE_UNIFORMS = `precision highp float;
771
+ uniform vec2 u_resolution; // canvas pixels
772
+ uniform float u_time; // seconds, pre-modded to [0,1000)
773
+ uniform float u_seed; // pre-modded to [0,100)
774
+ uniform sampler2D u_palette; // 1024x1 OKLCh-interpolated ramp
775
+ varying vec2 v_uv; // 0-1 quad UV
776
+ // World-space UV: cover-fit a fixed 1000x562.5 world so pattern density
777
+ // is identical between the preview and a 4K export of the same scene.
778
+ vec2 worldUv() {
779
+ float worldAspect = 1000.0 / 562.5;
780
+ float canvasAspect = u_resolution.x / u_resolution.y;
781
+ vec2 uv = v_uv - 0.5;
782
+ if (canvasAspect > worldAspect) { uv.y *= worldAspect / canvasAspect; }
783
+ else { uv.x *= canvasAspect / worldAspect; }
784
+ return uv + 0.5;
785
+ }
786
+ vec3 palette(float t) {
787
+ return texture2D(u_palette, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
788
+ }
789
+ `;
790
+ /** Fullscreen-triangle-strip vertex shader. Four vertices covering [-1,1]^2,
791
+ * with v_uv carrying the matching 0-1 UV for the fragment shader. */
792
+ const VERTEX_SHADER = `attribute vec2 a_position;
793
+ varying vec2 v_uv;
794
+ void main() {
795
+ v_uv = a_position * 0.5 + 0.5;
796
+ gl_Position = vec4(a_position, 0.0, 1.0);
797
+ }
798
+ `;
799
+ const QUAD_VERTICES = new Float32Array([
800
+ -1,
801
+ -1,
802
+ 1,
803
+ -1,
804
+ -1,
805
+ 1,
806
+ 1,
807
+ 1
808
+ ]);
809
+ /** Non-negative modulo. JS's `%` is a remainder operator, not a mathematical
810
+ * mod — `-5 % 100` is `-5`, not `95`. u_time/u_seed are documented to land
811
+ * in [0, m), so a negative timeMs (e.g. from an out-of-range seek) or a
812
+ * negative seed must still floor into that range rather than going negative
813
+ * on the GPU. */
814
+ function floorMod(value, m) {
815
+ return (value % m + m) % m;
816
+ }
817
+ /** Compiles one shader stage, logging the info log and throwing on failure
818
+ * so a broken ShaderDef fails loudly at mount time instead of rendering a
819
+ * blank canvas. */
820
+ function compileShader(gl, type, source) {
821
+ const shader = gl.createShader(type);
822
+ if (!shader) throw new Error("[instantshader] gl.createShader returned null");
823
+ gl.shaderSource(shader, source);
824
+ gl.compileShader(shader);
825
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
826
+ const info = gl.getShaderInfoLog(shader);
827
+ gl.deleteShader(shader);
828
+ console.error("[instantshader] shader compile error:", info);
829
+ throw new Error(`[instantshader] shader compile error: ${info}`);
830
+ }
831
+ return shader;
832
+ }
833
+ /** Links a vertex + fragment shader pair into a program, logging and
834
+ * throwing on link failure (e.g. varying mismatch between stages). */
835
+ function linkProgram(gl, vertexShader, fragmentShader) {
836
+ const program = gl.createProgram();
837
+ if (!program) throw new Error("[instantshader] gl.createProgram returned null");
838
+ gl.attachShader(program, vertexShader);
839
+ gl.attachShader(program, fragmentShader);
840
+ gl.linkProgram(program);
841
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
842
+ const info = gl.getProgramInfoLog(program);
843
+ gl.deleteShader(vertexShader);
844
+ gl.deleteShader(fragmentShader);
845
+ gl.deleteProgram(program);
846
+ console.error("[instantshader] program link error:", info);
847
+ throw new Error(`[instantshader] program link error: ${info}`);
848
+ }
849
+ return program;
850
+ }
851
+ function createRenderer(opts) {
852
+ const { canvas, shader } = opts;
853
+ let colors = opts.colors;
854
+ let params = opts.params;
855
+ const seed = opts.seed;
856
+ const glOrNull = canvas.getContext("webgl", {
857
+ preserveDrawingBuffer: true,
858
+ antialias: false
859
+ });
860
+ if (!glOrNull) throw new Error("[instantshader] failed to acquire a WebGL context");
861
+ const gl = glOrNull;
862
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
863
+ const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, BASE_UNIFORMS + shader.fragment);
864
+ const program = linkProgram(gl, vertexShader, fragmentShader);
865
+ gl.deleteShader(vertexShader);
866
+ gl.deleteShader(fragmentShader);
867
+ gl.useProgram(program);
868
+ const quadBuffer = gl.createBuffer();
869
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
870
+ gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW);
871
+ const positionLoc = gl.getAttribLocation(program, "a_position");
872
+ gl.enableVertexAttribArray(positionLoc);
873
+ gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
874
+ const paletteTexture = gl.createTexture();
875
+ gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
876
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
877
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
878
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
879
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
880
+ function uploadPalette(hexColors) {
881
+ const ramp = buildPaletteRamp(hexColors);
882
+ gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
883
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, ramp);
884
+ }
885
+ uploadPalette(colors);
886
+ const resolutionLoc = gl.getUniformLocation(program, "u_resolution");
887
+ const timeLoc = gl.getUniformLocation(program, "u_time");
888
+ const seedLoc = gl.getUniformLocation(program, "u_seed");
889
+ const paletteLoc = gl.getUniformLocation(program, "u_palette");
890
+ const paramLocs = /* @__PURE__ */ new Map();
891
+ for (const paramDef of shader.params) paramLocs.set(paramDef.key, gl.getUniformLocation(program, `u_${paramDef.key}`));
892
+ function applyParams() {
893
+ for (const paramDef of shader.params) {
894
+ const loc = paramLocs.get(paramDef.key);
895
+ if (loc == null) continue;
896
+ const value = params[paramDef.key] ?? paramDef.default;
897
+ gl.uniform1f(loc, value);
898
+ }
899
+ }
900
+ function renderAt(timeMs) {
901
+ gl.viewport(0, 0, canvas.width, canvas.height);
902
+ gl.useProgram(program);
903
+ gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
904
+ const timeSec = floorMod(timeMs / 1e3, 1e3);
905
+ gl.uniform1f(timeLoc, timeSec);
906
+ gl.uniform1f(seedLoc, floorMod(seed, 100));
907
+ gl.activeTexture(gl.TEXTURE0);
908
+ gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
909
+ gl.uniform1i(paletteLoc, 0);
910
+ applyParams();
911
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
912
+ gl.enableVertexAttribArray(positionLoc);
913
+ gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
914
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
915
+ }
916
+ function setColors(next) {
917
+ colors = next;
918
+ uploadPalette(colors);
919
+ }
920
+ function setParams(next) {
921
+ params = next;
922
+ }
923
+ function resize(width, height) {
924
+ canvas.width = width;
925
+ canvas.height = height;
926
+ }
927
+ function dispose() {
928
+ gl.deleteTexture(paletteTexture);
929
+ gl.deleteProgram(program);
930
+ gl.deleteBuffer(quadBuffer);
931
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
932
+ }
933
+ return {
934
+ renderAt,
935
+ setColors,
936
+ setParams,
937
+ resize,
938
+ dispose
939
+ };
940
+ }
941
+
942
+ //#endregion
943
+ //#region src/params.ts
944
+ /** Builds a full param record (every ParamDef.key present) from a possibly
945
+ * partial override map, falling back to each param's declared default. */
946
+ function resolveParams(def, overrides) {
947
+ const resolved = {};
948
+ for (const paramDef of def.params) {
949
+ const value = overrides?.[paramDef.key];
950
+ resolved[paramDef.key] = value === void 0 ? paramDef.default : value;
951
+ }
952
+ return resolved;
953
+ }
954
+
955
+ //#endregion
956
+ //#region src/mount.ts
957
+ /**
958
+ * Mounts a live, animated gradient into `container` and returns a handle to
959
+ * control it. Owns a canvas (sized to the container via ResizeObserver, DPR
960
+ * capped at 2 to bound fill-rate cost on high-density displays) and a RAF
961
+ * loop that runs ONLY while playing — the same lifecycle used by the
962
+ * InstantGradient app's canvas preview, which stops scheduling
963
+ * requestAnimationFrame entirely while paused/frozen rather than continuing
964
+ * to tick with no-op frames. pause() cancels the
965
+ * in-flight frame and freezes `clockMs`; resume() restarts the loop from
966
+ * there. Since the loop is fully stopped while paused, setColors/setParams/
967
+ * seek/resize (via the ResizeObserver) each trigger a single on-demand
968
+ * `renderer.renderAt(clockMs)` so a paused canvas still repaints immediately
969
+ * instead of going stale until the next resume() — this matters because
970
+ * callers may mount many simultaneously-paused instances (e.g. a screenshot
971
+ * grid) that must never carry a perpetual 60fps draw loop each.
972
+ */
973
+ function mountGradient(container, opts) {
974
+ const def = opts.shader;
975
+ const canvas = document.createElement("canvas");
976
+ canvas.style.display = "block";
977
+ canvas.style.width = "100%";
978
+ canvas.style.height = "100%";
979
+ container.appendChild(canvas);
980
+ let colors = opts.colors;
981
+ let params = resolveParams(def, opts.params);
982
+ let speed = opts.speed ?? 1;
983
+ const seed = opts.seed ?? 0;
984
+ const renderer = createRenderer({
985
+ canvas,
986
+ shader: def,
987
+ colors,
988
+ params,
989
+ seed
990
+ });
991
+ let clockMs = 0;
992
+ let epoch = performance.now();
993
+ let playing = true;
994
+ let disposed = false;
995
+ let rafId = 0;
996
+ /** Resyncs `epoch` so that (now - epoch) * speed === clockMs, i.e. the
997
+ * next tick continues smoothly from the current position at the current
998
+ * speed. Guards speed === 0 since that division is undefined and the
999
+ * product would be zero regardless of epoch. */
1000
+ function resyncEpoch() {
1001
+ const now = performance.now();
1002
+ epoch = speed === 0 ? now : now - clockMs / speed;
1003
+ }
1004
+ /** Draws the current clock position once, without touching the RAF loop.
1005
+ * Used so paused-canvas mutations (colors/params/size) show up right
1006
+ * away instead of waiting for the next resume(). */
1007
+ function renderOnce() {
1008
+ renderer.renderAt(clockMs);
1009
+ }
1010
+ function tick() {
1011
+ if (speed !== 0) clockMs = (performance.now() - epoch) * speed;
1012
+ renderer.renderAt(clockMs);
1013
+ rafId = requestAnimationFrame(tick);
1014
+ }
1015
+ rafId = requestAnimationFrame(tick);
1016
+ function applySize(cssWidth, cssHeight) {
1017
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
1018
+ const width = Math.max(1, Math.round(cssWidth * dpr));
1019
+ const height = Math.max(1, Math.round(cssHeight * dpr));
1020
+ if (canvas.width !== width || canvas.height !== height) {
1021
+ renderer.resize(width, height);
1022
+ if (!playing) renderOnce();
1023
+ }
1024
+ }
1025
+ const initialRect = container.getBoundingClientRect();
1026
+ applySize(initialRect.width || 1, initialRect.height || 1);
1027
+ const resizeObserver = new ResizeObserver((entries) => {
1028
+ for (const entry of entries) applySize(entry.contentRect.width, entry.contentRect.height);
1029
+ });
1030
+ resizeObserver.observe(container);
1031
+ return {
1032
+ canvas,
1033
+ setColors(next) {
1034
+ colors = next;
1035
+ renderer.setColors(colors);
1036
+ if (!playing) renderOnce();
1037
+ },
1038
+ setParams(next) {
1039
+ params = {
1040
+ ...params,
1041
+ ...next
1042
+ };
1043
+ renderer.setParams(params);
1044
+ if (!playing) renderOnce();
1045
+ },
1046
+ setSpeed(next) {
1047
+ if (playing && speed !== 0) clockMs = (performance.now() - epoch) * speed;
1048
+ speed = next;
1049
+ resyncEpoch();
1050
+ },
1051
+ pause() {
1052
+ if (!playing) return;
1053
+ if (speed !== 0) clockMs = (performance.now() - epoch) * speed;
1054
+ playing = false;
1055
+ cancelAnimationFrame(rafId);
1056
+ renderOnce();
1057
+ },
1058
+ resume() {
1059
+ if (playing) return;
1060
+ playing = true;
1061
+ resyncEpoch();
1062
+ rafId = requestAnimationFrame(tick);
1063
+ },
1064
+ seek(ms) {
1065
+ clockMs = ms;
1066
+ resyncEpoch();
1067
+ if (!playing) renderOnce();
1068
+ },
1069
+ getTimeMs() {
1070
+ return clockMs;
1071
+ },
1072
+ dispose() {
1073
+ if (disposed) return;
1074
+ disposed = true;
1075
+ cancelAnimationFrame(rafId);
1076
+ resizeObserver.disconnect();
1077
+ renderer.dispose();
1078
+ canvas.remove();
1079
+ }
1080
+ };
1081
+ }
1082
+
1083
+ //#endregion
1084
+ //#region src/frame.ts
1085
+ /**
1086
+ * Renders a single frame into a detached (not-in-DOM) canvas at an exact
1087
+ * pixel size, for export/thumbnail use cases that need a synchronous
1088
+ * snapshot rather than a live animation.
1089
+ *
1090
+ * The returned canvas is NOT disposed automatically — its GL context must
1091
+ * stay alive after this function returns so callers can scrape pixels from
1092
+ * it (toDataURL/toBlob/getImageData/drawImage). Once the caller is done
1093
+ * with it, release the GL context by calling the returned `dispose()`.
1094
+ */
1095
+ function renderGradientFrame(opts) {
1096
+ const def = opts.shader;
1097
+ const canvas = document.createElement("canvas");
1098
+ canvas.width = opts.width;
1099
+ canvas.height = opts.height;
1100
+ const renderer = createRenderer({
1101
+ canvas,
1102
+ shader: def,
1103
+ colors: opts.colors,
1104
+ params: resolveParams(def, opts.params),
1105
+ seed: opts.seed ?? 0
1106
+ });
1107
+ renderer.renderAt(opts.timeMs ?? 0);
1108
+ const gl = canvas.getContext("webgl");
1109
+ if (gl) {
1110
+ const pixel = new Uint8Array(4);
1111
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
1112
+ }
1113
+ return {
1114
+ canvas,
1115
+ dispose: renderer.dispose
1116
+ };
1117
+ }
1118
+
1119
+ //#endregion
1120
+ export { beam, buildPaletteRamp, createRenderer, flow, getShader, mountGradient, renderGradientFrame, shaders };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "instantshader",
3
+ "version": "0.1.0",
4
+ "description": "Animated WebGL gradient shaders. Zero dependencies.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ugolbck/instantshader.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "homepage": "https://instantgradient.com/shaders",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "sideEffects": false,
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "keywords": [
31
+ "webgl",
32
+ "shader",
33
+ "gradient",
34
+ "mesh-gradient",
35
+ "animated-gradient",
36
+ "background"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsdown",
40
+ "test": "vitest run",
41
+ "lint:pkg": "publint && attw --pack . --profile esm-only"
42
+ }
43
+ }