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 +21 -0
- package/README.md +24 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +1120 -0
- package/package.json +43 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|