@brochington/shader-backgrounds 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.
@@ -0,0 +1,445 @@
1
+ import { Color } from 'ogl';
2
+ import { ShaderPlugin } from '../core/types';
3
+
4
+ export type GradientEasing =
5
+ | 'linear'
6
+ | 'smoothstep'
7
+ | 'easeInOutQuad'
8
+ | 'easeInOutCubic';
9
+
10
+ export type GradientMotionMode = 'none' | 'path' | 'random';
11
+
12
+ export type GradientMotionBounds = {
13
+ minX: number;
14
+ maxX: number;
15
+ minY: number;
16
+ maxY: number;
17
+ };
18
+
19
+ export type GradientMotion = {
20
+ /**
21
+ * - "none": static point (default)
22
+ * - "path": moves between points in `path`
23
+ * - "random": picks random targets and eases between them
24
+ */
25
+ mode?: GradientMotionMode;
26
+ /** Waypoints for "path" mode. */
27
+ path?: Array<{ x: number; y: number }>;
28
+ /** Seconds to move from start -> target. Default 3.0 */
29
+ duration?: number;
30
+ /** Easing curve for interpolation. Default "smoothstep" */
31
+ easing?: GradientEasing;
32
+ /**
33
+ * Bounds for clamping/random target generation. Defaults to [-1..1] in both axes.
34
+ * (These are in the same -1..1 coordinate space as `x`/`y`.)
35
+ */
36
+ bounds?: Partial<GradientMotionBounds>;
37
+ /**
38
+ * Random mode only: if > 0, choose random targets within this radius around the point's
39
+ * base `x`/`y` (then clamp to bounds). If omitted/0, choose targets anywhere in bounds.
40
+ */
41
+ randomRadius?: number;
42
+ };
43
+
44
+ export type GradientPoint = {
45
+ x: number; // Range -1 to 1
46
+ y: number; // Range -1 to 1
47
+ colors: string[]; // Hex colors e.g., ["#ff0000", "#0000ff"]
48
+ speed?: number; // Speed of color cycle, default 1.0
49
+ motion?: GradientMotion;
50
+ };
51
+
52
+ export type GradientPluginOptions = {
53
+ /** Defaults applied to any point that doesn't specify a `motion` field. */
54
+ defaultMotion?: GradientMotion;
55
+ };
56
+
57
+ export class GradientPlugin implements ShaderPlugin {
58
+ name = 'gradient-points';
59
+
60
+ // Constants
61
+ private static MAX_POINTS = 16; // Hard limit for WebGL uniform array size
62
+
63
+ // Shader
64
+ // We use Inverse Distance Weighting for soft interpolation
65
+ fragmentShader = /* glsl */ `
66
+ precision highp float;
67
+
68
+ uniform float uTime;
69
+ uniform vec2 uResolution;
70
+
71
+ uniform int uPointCount;
72
+ uniform vec2 uPoints[${GradientPlugin.MAX_POINTS}];
73
+ uniform vec3 uColors[${GradientPlugin.MAX_POINTS}];
74
+
75
+ varying vec2 vUv;
76
+
77
+ void main() {
78
+ // 1. Normalize UVs to preserve aspect ratio
79
+ // This ensures points placed at (0.5, 0.5) look correct on rectangles
80
+ float aspect = uResolution.x / uResolution.y;
81
+ vec2 uv = vUv * 2.0 - 1.0; // Transform UV 0..1 to -1..1
82
+ uv.x *= aspect;
83
+
84
+ vec3 finalColor = vec3(0.0);
85
+ float totalWeight = 0.0;
86
+
87
+ for (int i = 0; i < ${GradientPlugin.MAX_POINTS}; i++) {
88
+ if (i >= uPointCount) break;
89
+
90
+ vec2 p = uPoints[i];
91
+ p.x *= aspect; // Apply same aspect correction to point
92
+
93
+ // Calculate Distance
94
+ float dist = distance(uv, p);
95
+
96
+ // Weight Function: 1 / (dist^power)
97
+ // Power controls how "fat" the points are.
98
+ // 2.0 is standard (Gravity), lower (e.g. 1.5) is softer/fuzzier.
99
+ float w = 1.0 / pow(dist, 2.0);
100
+
101
+ // Clamp weight to avoid infinity at exact point location
102
+ w = min(w, 1000.0);
103
+
104
+ finalColor += uColors[i] * w;
105
+ totalWeight += w;
106
+ }
107
+
108
+ // Avoid division by zero
109
+ if (totalWeight > 0.0) {
110
+ finalColor /= totalWeight;
111
+ }
112
+
113
+ gl_FragColor = vec4(finalColor, 1.0);
114
+ }
115
+ `;
116
+
117
+ uniforms: any;
118
+
119
+ // State for Color Animation
120
+ private pointsConfig: GradientPoint[];
121
+ // Pre-parsed RGB colors per point to avoid per-frame allocations
122
+ #parsedColors: Array<Array<[number, number, number]>> = [];
123
+ private colorStates: Array<{
124
+ currentIdx: number;
125
+ nextIdx: number;
126
+ t: number;
127
+ }>;
128
+
129
+ // State for Motion Animation
130
+ private motionStates: Array<{
131
+ mode: GradientMotionMode;
132
+ easing: GradientEasing;
133
+ duration: number;
134
+ bounds: GradientMotionBounds;
135
+ randomRadius: number;
136
+ // current lerp segment
137
+ startX: number;
138
+ startY: number;
139
+ targetX: number;
140
+ targetY: number;
141
+ t: number; // 0..1
142
+ // path
143
+ pathIndex: number;
144
+ // current value
145
+ x: number;
146
+ y: number;
147
+ }>;
148
+
149
+ private defaultMotion: GradientMotion;
150
+
151
+ constructor(points: GradientPoint[], options: GradientPluginOptions = {}) {
152
+ // Validate count
153
+ if (points.length > GradientPlugin.MAX_POINTS) {
154
+ console.warn(
155
+ `GradientPlugin: Max points is ${GradientPlugin.MAX_POINTS}. Truncating.`
156
+ );
157
+ points = points.slice(0, GradientPlugin.MAX_POINTS);
158
+ }
159
+
160
+ this.pointsConfig = points;
161
+ this.defaultMotion = options.defaultMotion ?? {};
162
+
163
+ // Pre-parse colors once to avoid per-frame allocations in onRender.
164
+ this.#parsedColors = points.map((p) =>
165
+ (p.colors ?? []).map((hex) => {
166
+ const c = new Color(hex);
167
+ return [c.r, c.g, c.b];
168
+ })
169
+ );
170
+
171
+ // Initialize color animation state for each point
172
+ this.colorStates = points.map(() => ({
173
+ currentIdx: 0,
174
+ nextIdx: 1 % 2, // Safe default will be fixed in loop
175
+ t: 0,
176
+ }));
177
+
178
+ this.motionStates = points.map((p) => this.#createMotionState(p));
179
+
180
+ // Prepare Uniform Arrays
181
+ const pointArray = new Array(GradientPlugin.MAX_POINTS);
182
+ const colorArray = new Array(GradientPlugin.MAX_POINTS);
183
+
184
+ // Initial Population
185
+ points.forEach((p, i) => {
186
+ // Coords as vec2 array [x, y]
187
+ pointArray[i] = [p.x, p.y];
188
+
189
+ // Initial Color as vec3 array [r, g, b]
190
+ const rgb0 = this.#parsedColors[i]?.[0] ?? [0, 0, 0];
191
+ colorArray[i] = [rgb0[0], rgb0[1], rgb0[2]];
192
+
193
+ // Fix state logic if array length is small
194
+ if (p.colors.length < 2) {
195
+ // If only one color, it just loops to itself
196
+ this.colorStates[i].nextIdx = 0;
197
+ } else {
198
+ this.colorStates[i].nextIdx = 1;
199
+ }
200
+ });
201
+
202
+ // Fill remaining slots with default values
203
+ for (let i = points.length; i < GradientPlugin.MAX_POINTS; i++) {
204
+ pointArray[i] = [0, 0]; // Default center
205
+ colorArray[i] = [0, 0, 0]; // Default black
206
+ }
207
+
208
+ this.uniforms = {
209
+ uPointCount: { value: points.length },
210
+ uPoints: { value: pointArray },
211
+ uColors: { value: colorArray },
212
+ };
213
+ }
214
+
215
+ #clamp(n: number, min: number, max: number) {
216
+ return Math.max(min, Math.min(max, n));
217
+ }
218
+
219
+ #lerp(a: number, b: number, t: number) {
220
+ return a + (b - a) * t;
221
+ }
222
+
223
+ #ease(t: number, easing: GradientEasing) {
224
+ const x = this.#clamp(t, 0, 1);
225
+ switch (easing) {
226
+ case 'linear':
227
+ return x;
228
+ case 'easeInOutQuad':
229
+ return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
230
+ case 'easeInOutCubic':
231
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
232
+ case 'smoothstep':
233
+ default:
234
+ return x * x * (3 - 2 * x);
235
+ }
236
+ }
237
+
238
+ #resolveMotion(point: GradientPoint) {
239
+ const m: GradientMotion = { ...this.defaultMotion, ...(point.motion ?? {}) };
240
+ const bounds: GradientMotionBounds = {
241
+ minX: -1,
242
+ maxX: 1,
243
+ minY: -1,
244
+ maxY: 1,
245
+ ...(m.bounds ?? {}),
246
+ };
247
+ // ensure sane ordering
248
+ if (bounds.minX > bounds.maxX) [bounds.minX, bounds.maxX] = [bounds.maxX, bounds.minX];
249
+ if (bounds.minY > bounds.maxY) [bounds.minY, bounds.maxY] = [bounds.maxY, bounds.minY];
250
+
251
+ return {
252
+ mode: (m.mode ?? 'none') as GradientMotionMode,
253
+ path: m.path ?? [],
254
+ duration: Math.max(0.001, m.duration ?? 3.0),
255
+ easing: (m.easing ?? 'smoothstep') as GradientEasing,
256
+ bounds,
257
+ randomRadius: Math.max(0, m.randomRadius ?? 0),
258
+ };
259
+ }
260
+
261
+ #randomTarget(
262
+ baseX: number,
263
+ baseY: number,
264
+ bounds: GradientMotionBounds,
265
+ radius: number
266
+ ) {
267
+ if (radius > 0) {
268
+ // uniform disk
269
+ const a = Math.random() * Math.PI * 2;
270
+ const r = Math.sqrt(Math.random()) * radius;
271
+ const x = baseX + Math.cos(a) * r;
272
+ const y = baseY + Math.sin(a) * r;
273
+ return {
274
+ x: this.#clamp(x, bounds.minX, bounds.maxX),
275
+ y: this.#clamp(y, bounds.minY, bounds.maxY),
276
+ };
277
+ }
278
+ return {
279
+ x: this.#lerp(bounds.minX, bounds.maxX, Math.random()),
280
+ y: this.#lerp(bounds.minY, bounds.maxY, Math.random()),
281
+ };
282
+ }
283
+
284
+ #createMotionState(point: GradientPoint) {
285
+ const r = this.#resolveMotion(point);
286
+ const state = {
287
+ mode: r.mode,
288
+ easing: r.easing,
289
+ duration: r.duration,
290
+ bounds: r.bounds,
291
+ randomRadius: r.randomRadius,
292
+ startX: point.x,
293
+ startY: point.y,
294
+ targetX: point.x,
295
+ targetY: point.y,
296
+ t: 1,
297
+ pathIndex: 0,
298
+ x: point.x,
299
+ y: point.y,
300
+ };
301
+
302
+ if (r.mode === 'path' && r.path.length > 0) {
303
+ state.targetX = this.#clamp(r.path[0].x, r.bounds.minX, r.bounds.maxX);
304
+ state.targetY = this.#clamp(r.path[0].y, r.bounds.minY, r.bounds.maxY);
305
+ state.t = 0;
306
+ } else if (r.mode === 'random') {
307
+ const tgt = this.#randomTarget(point.x, point.y, r.bounds, r.randomRadius);
308
+ state.targetX = tgt.x;
309
+ state.targetY = tgt.y;
310
+ state.t = 0;
311
+ }
312
+
313
+ return state;
314
+ }
315
+
316
+ #stepMotion(point: GradientPoint, state: GradientPlugin['motionStates'][number], dt: number) {
317
+ const r = this.#resolveMotion(point);
318
+
319
+ // If motion config changed, re-seed state but preserve current position.
320
+ const modeChanged = state.mode !== r.mode;
321
+ const easingChanged = state.easing !== r.easing;
322
+ const durationChanged = state.duration !== r.duration;
323
+ const boundsChanged =
324
+ state.bounds.minX !== r.bounds.minX ||
325
+ state.bounds.maxX !== r.bounds.maxX ||
326
+ state.bounds.minY !== r.bounds.minY ||
327
+ state.bounds.maxY !== r.bounds.maxY;
328
+ const radiusChanged = state.randomRadius !== r.randomRadius;
329
+
330
+ if (modeChanged || easingChanged || durationChanged || boundsChanged || radiusChanged) {
331
+ state.mode = r.mode;
332
+ state.easing = r.easing;
333
+ state.duration = r.duration;
334
+ state.bounds = r.bounds;
335
+ state.randomRadius = r.randomRadius;
336
+
337
+ state.startX = state.x;
338
+ state.startY = state.y;
339
+ state.t = 0;
340
+ state.pathIndex = 0;
341
+
342
+ if (r.mode === 'path' && r.path.length > 0) {
343
+ state.targetX = this.#clamp(r.path[0].x, r.bounds.minX, r.bounds.maxX);
344
+ state.targetY = this.#clamp(r.path[0].y, r.bounds.minY, r.bounds.maxY);
345
+ } else if (r.mode === 'random') {
346
+ const tgt = this.#randomTarget(point.x, point.y, r.bounds, r.randomRadius);
347
+ state.targetX = tgt.x;
348
+ state.targetY = tgt.y;
349
+ } else {
350
+ state.targetX = point.x;
351
+ state.targetY = point.y;
352
+ state.t = 1;
353
+ }
354
+ }
355
+
356
+ if (r.mode === 'none') {
357
+ // always track the base point position (useful if user mutates x/y without recreating plugin)
358
+ state.x = this.#clamp(point.x, r.bounds.minX, r.bounds.maxX);
359
+ state.y = this.#clamp(point.y, r.bounds.minY, r.bounds.maxY);
360
+ state.startX = state.x;
361
+ state.startY = state.y;
362
+ state.targetX = state.x;
363
+ state.targetY = state.y;
364
+ state.t = 1;
365
+ return;
366
+ }
367
+
368
+ // Advance time
369
+ state.t += dt / (r.duration * 1000);
370
+ if (state.t >= 1) {
371
+ // snap to target and pick next
372
+ state.x = state.targetX;
373
+ state.y = state.targetY;
374
+ state.startX = state.x;
375
+ state.startY = state.y;
376
+ state.t = 0;
377
+
378
+ if (r.mode === 'path') {
379
+ if (r.path.length === 0) {
380
+ state.targetX = state.x;
381
+ state.targetY = state.y;
382
+ state.t = 1;
383
+ return;
384
+ }
385
+ state.pathIndex = (state.pathIndex + 1) % r.path.length;
386
+ const wp = r.path[state.pathIndex];
387
+ state.targetX = this.#clamp(wp.x, r.bounds.minX, r.bounds.maxX);
388
+ state.targetY = this.#clamp(wp.y, r.bounds.minY, r.bounds.maxY);
389
+ } else {
390
+ // random
391
+ const tgt = this.#randomTarget(point.x, point.y, r.bounds, r.randomRadius);
392
+ state.targetX = tgt.x;
393
+ state.targetY = tgt.y;
394
+ }
395
+ }
396
+
397
+ const e = this.#ease(state.t, r.easing);
398
+ state.x = this.#lerp(state.startX, state.targetX, e);
399
+ state.y = this.#lerp(state.startY, state.targetY, e);
400
+ }
401
+
402
+ onRender(dt: number) {
403
+ const pointArray = this.uniforms.uPoints.value;
404
+ const colorArray = this.uniforms.uColors.value;
405
+
406
+ this.pointsConfig.forEach((p, i) => {
407
+ // Update motion (position) first
408
+ const motionState = this.motionStates[i];
409
+ this.#stepMotion(p, motionState, dt);
410
+ pointArray[i][0] = motionState.x;
411
+ pointArray[i][1] = motionState.y;
412
+
413
+ if (p.colors.length <= 1) return;
414
+
415
+ const state = this.colorStates[i];
416
+ const speed = p.speed || 1.0;
417
+
418
+ // Update Interpolation T
419
+ // dt is in ms, we want speed relative to seconds roughly
420
+ state.t += dt * 0.001 * speed;
421
+
422
+ if (state.t >= 1.0) {
423
+ state.t = 0;
424
+ state.currentIdx = state.nextIdx;
425
+ state.nextIdx = (state.nextIdx + 1) % p.colors.length;
426
+ }
427
+
428
+ // Perform Lerp
429
+ const c1 = this.#parsedColors[i][state.currentIdx];
430
+ const c2 = this.#parsedColors[i][state.nextIdx];
431
+
432
+ // Simple RGB Linear Interpolation
433
+ const r = c1[0] + (c2[0] - c1[0]) * state.t;
434
+ const g = c1[1] + (c2[1] - c1[1]) * state.t;
435
+ const b = c1[2] + (c2[2] - c1[2]) * state.t;
436
+
437
+ // Update array element
438
+ colorArray[i][0] = r;
439
+ colorArray[i][1] = g;
440
+ colorArray[i][2] = b;
441
+ });
442
+
443
+ // No need to reassign the array - OGL will detect the changes
444
+ }
445
+ }
@@ -0,0 +1,139 @@
1
+ import { Color } from 'ogl';
2
+ import { ShaderPlugin } from '../core/types';
3
+
4
+ export type GrainyFogConfig = {
5
+ firstColor: string;
6
+ secondColor: string;
7
+ backgroundColor: string;
8
+ grainAmount?: number; // 0.0 to 1.0, default 0.12
9
+ speed?: number; // default 1.0
10
+ scale?: number; // Noise scale, default 2.25
11
+ octaves?: number; // 1..6, default 4
12
+ lacunarity?: number; // default 2.0
13
+ gain?: number; // default 0.5
14
+ contrast?: number; // 0.5..2.5, default 1.25
15
+ };
16
+
17
+ export class GrainyFogPlugin implements ShaderPlugin {
18
+ name = 'grainy-fog';
19
+
20
+ fragmentShader = /* glsl */ `
21
+ precision highp float;
22
+ uniform float uTimeInternal;
23
+ uniform vec2 uResolution;
24
+ uniform vec3 uColor1;
25
+ uniform vec3 uColor2;
26
+ uniform vec3 uBgColor;
27
+ uniform float uGrain;
28
+ uniform float uScale;
29
+ uniform float uContrast;
30
+ uniform int uOctaves;
31
+ uniform float uLacunarity;
32
+ uniform float uGain;
33
+
34
+ varying vec2 vUv;
35
+
36
+ // --- Value Noise / FBM ---
37
+ float hash12(vec2 p) {
38
+ // Dave Hoskins-ish: cheap, stable
39
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
40
+ p3 += dot(p3, p3.yzx + 33.33);
41
+ return fract((p3.x + p3.y) * p3.z);
42
+ }
43
+
44
+ float noise(in vec2 st) {
45
+ vec2 i = floor(st);
46
+ vec2 f = fract(st);
47
+
48
+ float a = hash12(i);
49
+ float b = hash12(i + vec2(1.0, 0.0));
50
+ float c = hash12(i + vec2(0.0, 1.0));
51
+ float d = hash12(i + vec2(1.0, 1.0));
52
+
53
+ vec2 u = f * f * (3.0 - 2.0 * f);
54
+ return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
55
+ }
56
+
57
+ float fbm(in vec2 st) {
58
+ float value = 0.0;
59
+ float amplitude = 0.5;
60
+ float freq = 1.0;
61
+
62
+ // rotate to reduce axial bias
63
+ mat2 rot = mat2(0.80, -0.60, 0.60, 0.80);
64
+
65
+ for (int i = 0; i < 6; i++) {
66
+ if (i >= uOctaves) break;
67
+ value += amplitude * noise(st * freq);
68
+ st = rot * st + 19.19;
69
+ freq *= uLacunarity;
70
+ amplitude *= uGain;
71
+ }
72
+
73
+ return value;
74
+ }
75
+
76
+ void main() {
77
+ vec2 uv = vUv;
78
+
79
+ // aspect-correct domain
80
+ float aspect = uResolution.x / uResolution.y;
81
+ vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
82
+
83
+ float t = uTimeInternal;
84
+
85
+ // Two-layer flow field -> richer motion
86
+ vec2 q;
87
+ q.x = fbm(p * uScale + vec2(0.0, 0.12 * t));
88
+ q.y = fbm(p * (uScale * 0.9) + vec2(3.1, -0.08 * t));
89
+
90
+ vec2 r;
91
+ r.x = fbm(p * (uScale * 1.2) + 1.7 * q + vec2(1.7, 9.2) + 0.15 * t);
92
+ r.y = fbm(p * (uScale * 1.1) + 1.3 * q + vec2(8.3, 2.8) + 0.11 * t);
93
+
94
+ float f = fbm(p * uScale + r);
95
+
96
+ // Contrast curve (keeps highlights punchy)
97
+ f = pow(clamp(f, 0.0, 1.0), 1.0 / max(0.001, uContrast));
98
+
99
+ // Color mix
100
+ vec3 col = uBgColor;
101
+ col = mix(col, uColor1, smoothstep(0.10, 0.85, f));
102
+ col = mix(col, uColor2, smoothstep(0.15, 0.95, length(q)));
103
+
104
+ // Film grain in pixel space (stable-ish)
105
+ vec2 px = uv * uResolution;
106
+ float g = (hash12(px + t * 60.0) - 0.5) * 2.0;
107
+ col += g * uGrain;
108
+
109
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
110
+ }
111
+ `;
112
+
113
+ uniforms: any;
114
+ private speed: number;
115
+
116
+ constructor(config: GrainyFogConfig) {
117
+ const c1 = new Color(config.firstColor);
118
+ const c2 = new Color(config.secondColor);
119
+ const bg = new Color(config.backgroundColor);
120
+ this.speed = config.speed ?? 1.0;
121
+
122
+ this.uniforms = {
123
+ uColor1: { value: [c1.r, c1.g, c1.b] },
124
+ uColor2: { value: [c2.r, c2.g, c2.b] },
125
+ uBgColor: { value: [bg.r, bg.g, bg.b] },
126
+ uGrain: { value: config.grainAmount ?? 0.12 },
127
+ uScale: { value: config.scale ?? 2.25 },
128
+ uContrast: { value: config.contrast ?? 1.25 },
129
+ uOctaves: { value: Math.max(1, Math.min(6, config.octaves ?? 4)) },
130
+ uLacunarity: { value: config.lacunarity ?? 2.0 },
131
+ uGain: { value: config.gain ?? 0.5 },
132
+ uTimeInternal: { value: 0 },
133
+ };
134
+ }
135
+
136
+ onRender(dt: number) {
137
+ this.uniforms.uTimeInternal.value += dt * 0.001 * this.speed;
138
+ }
139
+ }