instantshader 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -187,10 +187,74 @@ float grain(vec2 uv, float time) {
187
187
  return fract(sin(dot(st, vec2(12.9898, 78.233))) * 43758.5453);
188
188
  }
189
189
  `;
190
+ /**
191
+ * Isotropic frame helpers, shared by every look whose composition is a SHAPE
192
+ * rather than a frame-filling field. worldUv() is 0-1 on both axes of a 16:9
193
+ * world, so raw uv stretches x by 1.78 -- circles render as ellipses and a
194
+ * rotated stripe changes thickness with its angle. isoCoord() scales x by the
195
+ * world aspect so a unit circle is a screen circle (frame half-height = 0.5),
196
+ * and isoHalf() is the frame's visible half-extents in that space, mirroring
197
+ * worldUv()'s cover fit so a square tile and a 16:9 export agree on where the
198
+ * edges are.
199
+ */
200
+ const ISO = `
201
+ const float WORLD_ASPECT = 1000.0 / 562.5;
202
+ vec2 isoCoord(vec2 uv) { return (uv - 0.5) * vec2(WORLD_ASPECT, 1.0); }
203
+ vec2 isoHalf() {
204
+ float canvasAspect = u_resolution.x / u_resolution.y;
205
+ return vec2(0.5 * min(1.0, canvasAspect / WORLD_ASPECT),
206
+ 0.5 * min(1.0, WORLD_ASPECT / canvasAspect))
207
+ * vec2(WORLD_ASPECT, 1.0);
208
+ }
209
+ `;
210
+ /**
211
+ * A phase angle that advances at ~w rad/s and is exactly periodic over the
212
+ * loop -- for HEADLINE motions (a travelling wave, an orbiting colour) that
213
+ * must never freeze. This is the opposite trade to loopFreq, which rounds to
214
+ * zero on short loops to protect slow secondary swells from turning into a
215
+ * throb: here the cycle count is floored at one, so a short loop simply runs
216
+ * the motion faster.
217
+ *
218
+ * The count is also forced ODD. With an even count the phase at half-cycle is
219
+ * a whole number of turns, i.e. the frame at loop/2 would be identical to the
220
+ * frame at 0 for anything driven by this angle alone -- the loop would
221
+ * secretly be half as long as asked for.
222
+ *
223
+ * Not looping, w is snapped to a whole number of turns per 1000s, because
224
+ * u_time itself wraps there (see renderer.ts) and an unsnapped phase would
225
+ * jump once every ~16.7 minutes.
226
+ */
227
+ const LOOP_ANGLE = `
228
+ float loopAngle(float w) {
229
+ if (u_loop <= 0.0) return TAU * floor(w * 1000.0 / TAU + 0.5) * (u_time / 1000.0);
230
+ float n = 2.0 * floor(w * u_loop / TAU * 0.5) + 1.0;
231
+ return TAU * n * (u_time / u_loop);
232
+ }
233
+ `;
234
+ /**
235
+ * Seed decorrelation. Adding u_seed straight onto a noise coordinate makes
236
+ * the seed a PAN: neighbouring seeds show the same field slid sideways, so a
237
+ * seed slider reads as "moves the picture a bit" instead of "new picture".
238
+ * seedOffset() hashes the seed into an unrelated point of the noise plane, so
239
+ * any change of seed lands on a composition with nothing in common with the
240
+ * last one. seedHash(i) is the scalar version, one independent 0-1 value per
241
+ * channel i, for seeded angles and phases.
242
+ *
243
+ * The offset stays inside [8, 72), far below the ~1000 ceiling where snoise's
244
+ * float precision gives out (see the note at the top of this file).
245
+ */
246
+ const SEED = `
247
+ float seedHash(float i) {
248
+ return fract(sin(u_seed * 12.9898 + i * 78.233) * 43758.5453);
249
+ }
250
+ vec2 seedOffset() {
251
+ return vec2(seedHash(11.0), seedHash(23.0)) * 64.0 + 8.0;
252
+ }
253
+ `;
190
254
 
191
255
  //#endregion
192
256
  //#region src/shaders/flow.ts
193
- const FRAGMENT$2 = `
257
+ const FRAGMENT$6 = `
194
258
  uniform float u_scale;
195
259
  uniform float u_curl;
196
260
  uniform float u_drift;
@@ -327,7 +391,7 @@ void main() {
327
391
  const flow = {
328
392
  id: "flow",
329
393
  label: "Flow",
330
- fragment: FRAGMENT$2,
394
+ fragment: FRAGMENT$6,
331
395
  params: [
332
396
  {
333
397
  key: "scale",
@@ -383,7 +447,7 @@ const flow = {
383
447
 
384
448
  //#endregion
385
449
  //#region src/shaders/beam.ts
386
- const FRAGMENT$1 = `
450
+ const FRAGMENT$5 = `
387
451
  uniform float u_scale;
388
452
  uniform float u_width;
389
453
  uniform float u_glow;
@@ -637,7 +701,7 @@ void main() {
637
701
  const beam = {
638
702
  id: "beam",
639
703
  label: "Beam",
640
- fragment: FRAGMENT$1,
704
+ fragment: FRAGMENT$5,
641
705
  params: [
642
706
  {
643
707
  key: "scale",
@@ -693,7 +757,7 @@ const beam = {
693
757
 
694
758
  //#endregion
695
759
  //#region src/shaders/bloom.ts
696
- const FRAGMENT = `
760
+ const FRAGMENT$4 = `
697
761
  uniform float u_scale;
698
762
  uniform float u_petals;
699
763
  uniform float u_pinch;
@@ -852,7 +916,7 @@ void main() {
852
916
  const bloom = {
853
917
  id: "bloom",
854
918
  label: "Bloom",
855
- fragment: FRAGMENT,
919
+ fragment: FRAGMENT$4,
856
920
  params: [
857
921
  {
858
922
  key: "scale",
@@ -925,128 +989,2074 @@ const bloom = {
925
989
  };
926
990
 
927
991
  //#endregion
928
- //#region src/registry.ts
929
- const shaders = [
930
- flow,
931
- beam,
932
- bloom
933
- ];
934
- function getShader(id) {
935
- return shaders.find((s) => s.id === id);
992
+ //#region src/shaders/halo.ts
993
+ const FRAGMENT$3 = `
994
+ uniform float u_radius;
995
+ uniform float u_x;
996
+ uniform float u_y;
997
+ uniform float u_glow;
998
+ uniform float u_crescent;
999
+ uniform float u_flares;
1000
+ uniform float u_grain;
1001
+
1002
+ ${SIMPLEX_2D}
1003
+ ${PERIODIC_2D}
1004
+ ${GRAIN}
1005
+ ${ISO}
1006
+ ${LOOP_ANGLE}
1007
+ ${SEED}
1008
+
1009
+ const float PI = 3.14159265;
1010
+ const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
1011
+
1012
+ void main() {
1013
+ vec2 uv = worldUv();
1014
+ vec2 p = isoCoord(uv);
1015
+ vec2 halfIso = isoHalf();
1016
+
1017
+ vec2 so = seedOffset();
1018
+ vec2 drift = loopDrift(0.04, vec2(1.0, 0.0));
1019
+
1020
+ // ---- the disc -----------------------------------------------------------
1021
+ float R = u_radius;
1022
+ vec2 c = vec2(u_x, u_y) * (halfIso + R);
1023
+
1024
+ vec2 rel = p - c;
1025
+ float r = length(rel);
1026
+ float theta = atan(rel.y, rel.x);
1027
+ float d = r - R;
1028
+
1029
+ // ---- streamers ----------------------------------------------------------
1030
+ // A = 14 angular periods; radial tile 4. The second octave doubles both
1031
+ // the frequency and the tile, and travels twice as far per loop, so it
1032
+ // lands on a whole tile too.
1033
+ float A = 14.0;
1034
+ // 0.7: radial features ~6x longer than they are wide at A = 14, which is
1035
+ // the anisotropy that reads as RAYS. At 2.2 the cells were near-square in
1036
+ // log-polar space and the corona rendered as cloud.
1037
+ float rho = log(max(r, 0.001) / R) * 0.7;
1038
+ float flowOut = loopTravel(0.04, vec2(0.0, 1.0), 4.0).y;
1039
+ vec2 sp = vec2((theta / TAU + 0.5) * A, rho - flowOut + so.x);
1040
+ float fl = pnoise(sp, vec2(A, 4.0)) + 0.5 * pnoise(sp * 2.0, vec2(2.0 * A, 8.0));
1041
+
1042
+ float len = (0.03 + 0.3 * u_glow) * max(1.0 + 1.5 * u_flares * fl, 0.15);
1043
+ float outside = max(d, 0.0);
1044
+ float corona = exp(-outside / len);
1045
+ float veil = 0.18 * exp(-outside / (3.5 * len));
1046
+ float inner = 0.4 * exp(min(d, 0.0) / (0.025 + 0.09 * u_glow));
1047
+ float limb = exp(-abs(d) / 0.007);
1048
+
1049
+ // One-pixel soft disc edge, in frame units.
1050
+ float pixel = 2.0 * halfIso.y / u_resolution.y;
1051
+ float isOut = smoothstep(-pixel, pixel, d);
1052
+
1053
+ // ---- crescent -----------------------------------------------------------
1054
+ // Light direction as a VECTOR blend (no angle wrap to worry about): the
1055
+ // seeded direction for a centred disc, handing over to "toward the frame
1056
+ // centre" as the disc moves off it, plus a slow sway.
1057
+ float aSeed = seedHash(1.0) * TAU;
1058
+ float off = length(c);
1059
+ float sway = 0.35 * snoise(vec2(so.y, 1.0) + drift);
1060
+ vec2 inward = -c / max(off, 0.0001);
1061
+ inward = vec2(inward.x * cos(sway) - inward.y * sin(sway),
1062
+ inward.x * sin(sway) + inward.y * cos(sway));
1063
+ vec2 seedDir = vec2(cos(aSeed + sway), sin(aSeed + sway));
1064
+ vec2 dirL = normalize(mix(seedDir, inward, smoothstep(0.05, 0.5, off)) + seedDir * 0.0001);
1065
+ float facing = 0.5 + 0.5 * dot(rel / max(r, 0.0001), dirL);
1066
+ float arc = mix(1.0, 0.03 + 0.97 * facing * facing * facing, u_crescent);
1067
+
1068
+ // ---- palette by azimuth -------------------------------------------------
1069
+ float theta0 = loopAngle(0.1) + seedHash(2.0) * TAU;
1070
+ float tA = 0.5 + 0.5 * cos(theta - theta0 + 0.7 * u_flares * fl);
1071
+ float t = clamp(tA * 0.85 + 0.15 * (1.0 - exp(-outside * 3.0)), 0.0, 1.0);
1072
+ vec3 col = palette(t);
1073
+ float lum = dot(col, LUMA);
1074
+
1075
+ // ---- background: the ramp's darkest stop, crushed (as in beam) ----------
1076
+ vec3 cA = palette(0.0);
1077
+ vec3 cB = palette(0.5);
1078
+ vec3 cC = palette(1.0);
1079
+ float lA = dot(cA, LUMA);
1080
+ float lB = dot(cB, LUMA);
1081
+ float lC = dot(cC, LUMA);
1082
+ vec3 dk = mix(cA, cB, step(lB, lA));
1083
+ dk = mix(dk, cC, step(lC, min(lA, lB)));
1084
+ vec3 color = dk * 0.1;
1085
+
1086
+ // ---- additive light -----------------------------------------------------
1087
+ // gain/boost: see beam.ts -- bright banks are divided down before they can
1088
+ // clip, dark banks are multiplied up (chroma-preserving) before they vanish.
1089
+ float gain = 1.0 / (1.0 + 1.2 * lum * lum);
1090
+ float boost = 1.0 + 2.0 * (1.0 - lum) * (1.0 - lum) * (1.0 - lum);
1091
+ float radiance = mix(inner, corona + veil, isOut);
1092
+ color += col * radiance * arc * gain * boost * 1.35;
1093
+ // The limb is the hottest thing in frame: palette colour plus a dark-gated
1094
+ // white lift, so it still reads as a filament on near-black banks.
1095
+ color += (col * 0.8 + vec3(0.35 * (1.0 - lum))) * limb * arc;
1096
+
1097
+ float g = grain(uv, u_time) - 0.5;
1098
+ color += g * u_grain;
1099
+
1100
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
936
1101
  }
1102
+ `;
1103
+ const halo = {
1104
+ id: "halo",
1105
+ label: "Halo",
1106
+ fragment: FRAGMENT$3,
1107
+ params: [
1108
+ {
1109
+ key: "radius",
1110
+ label: "Radius",
1111
+ min: .12,
1112
+ max: 1.6,
1113
+ step: .01,
1114
+ default: 1
1115
+ },
1116
+ {
1117
+ key: "x",
1118
+ label: "X",
1119
+ min: -1,
1120
+ max: 1,
1121
+ step: .01,
1122
+ default: 0
1123
+ },
1124
+ {
1125
+ key: "y",
1126
+ label: "Y",
1127
+ min: -1,
1128
+ max: 1,
1129
+ step: .01,
1130
+ default: -.78
1131
+ },
1132
+ {
1133
+ key: "glow",
1134
+ label: "Glow",
1135
+ min: 0,
1136
+ max: 1,
1137
+ step: .01,
1138
+ default: .5
1139
+ },
1140
+ {
1141
+ key: "crescent",
1142
+ label: "Crescent",
1143
+ min: 0,
1144
+ max: 1,
1145
+ step: .01,
1146
+ default: .35
1147
+ },
1148
+ {
1149
+ key: "flares",
1150
+ label: "Flares",
1151
+ min: 0,
1152
+ max: 1,
1153
+ step: .01,
1154
+ default: .5
1155
+ },
1156
+ {
1157
+ key: "grain",
1158
+ label: "Grain",
1159
+ min: 0,
1160
+ max: .3,
1161
+ step: .01,
1162
+ default: .08
1163
+ }
1164
+ ],
1165
+ randomParams(rand) {
1166
+ const horizon = rand() < .6;
1167
+ return {
1168
+ radius: horizon ? .7 + rand() * .8 : .18 + rand() * .24,
1169
+ x: horizon ? -.3 + rand() * .6 : -.4 + rand() * .8,
1170
+ y: horizon ? -.86 + rand() * .16 : -.2 + rand() * .4,
1171
+ glow: .3 + rand() * .6000000000000001,
1172
+ crescent: rand() * .9,
1173
+ flares: .2 + rand() * .7,
1174
+ grain: .08
1175
+ };
1176
+ }
1177
+ };
937
1178
 
938
1179
  //#endregion
939
- //#region src/palette.ts
940
- /** Number of texels in the output ramp. Matches a typical 1D LUT texture size:
941
- * large enough that per-texel banding is invisible, small enough to upload
942
- * as a single texture row every time colors change. */
943
- const RAMP_SIZE = 1024;
944
- /**
945
- * Converts one sRGB channel (0-1, gamma-encoded) to linear light.
946
- * Piecewise per the sRGB spec: a linear segment near black avoids the
947
- * infinite slope a pure power curve would have at 0.
948
- */
949
- function linearize(c) {
950
- return c <= .04045 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
951
- }
952
- /** Inverse of linearize(): linear light back to gamma-encoded sRGB (0-1). */
953
- function delinearize(c) {
954
- return c <= .0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - .055;
955
- }
956
- /**
957
- * sRGB -> OKLab, Björn Ottosson's method (https://bottosson.github.io/posts/oklab/).
958
- * Coefficients copied verbatim from that reference implementation —
959
- * do not hand-retype these, they are fitted constants, not derivable values.
960
- *
961
- * The two 3x3 matrices convert linear sRGB to an LMS-like cone response
962
- * space and then to the final Lab-like OKLab space; the cube root in
963
- * between is what makes the space perceptually uniform.
964
- */
965
- function rgbToOklab(r, g, b) {
966
- const lr = linearize(r), lg = linearize(g), lb = linearize(b);
967
- const l_ = Math.cbrt(.4122214708 * lr + .5363325363 * lg + .0514459929 * lb);
968
- const m_ = Math.cbrt(.2119034982 * lr + .6806995451 * lg + .1073969566 * lb);
969
- const s_ = Math.cbrt(.0883024619 * lr + .2817188376 * lg + .6299787005 * lb);
970
- return [
971
- .2104542553 * l_ + .793617785 * m_ - .0040720468 * s_,
972
- 1.9779984951 * l_ - 2.428592205 * m_ + .4505937099 * s_,
973
- .0259040371 * l_ + .7827717662 * m_ - .808675766 * s_
974
- ];
975
- }
976
- /**
977
- * OKLab -> sRGB, inverse of rgbToOklab. Same fitted-constant caveat as
978
- * above — do not hand-retype these coefficients.
979
- */
980
- function oklabToRgb(L, a, b) {
981
- const l_ = L + .3963377774 * a + .2158037573 * b;
982
- const m_ = L - .1055613458 * a - .0638541728 * b;
983
- const s_ = L - .0894841775 * a - 1.291485548 * b;
984
- const l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
985
- return [
986
- delinearize(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
987
- delinearize(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
988
- delinearize(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
989
- ];
1180
+ //#region src/shaders/strata.ts
1181
+ const FRAGMENT$2 = `
1182
+ uniform float u_scale;
1183
+ uniform float u_layers;
1184
+ uniform float u_warp;
1185
+ uniform float u_ridges;
1186
+ uniform float u_stretch;
1187
+ uniform float u_depth;
1188
+ uniform float u_blend;
1189
+ uniform float u_angle;
1190
+ uniform float u_grain;
1191
+
1192
+ ${SIMPLEX_2D}
1193
+ ${FBM}
1194
+ ${SHAPE}
1195
+ ${GRAIN}
1196
+ ${ISO}
1197
+ ${SEED}
1198
+
1199
+ const float PI = 3.14159265;
1200
+
1201
+ float field(vec2 q) {
1202
+ float n = fbm2(q);
1203
+ // Ridged transform of the SAME sample (free): constants chosen so its mean
1204
+ // and spread roughly match the fbm's, keeping spread()'s equal-area promise
1205
+ // approximately true at any mix.
1206
+ float ridge = 0.55 - 1.6 * abs(n);
1207
+ return spread(mix(n, ridge, u_ridges), 0.35); // 0.35 = fbm2's standard deviation
990
1208
  }
991
- /**
992
- * OKLab -> OKLCh: rectangular (L, a, b) to polar (L, C, h). C is just the
993
- * distance of (a, b) from the achromatic origin; h is the angle, in
994
- * radians, measured the same way atan2 always does (range (-pi, pi]).
995
- */
996
- function oklabToOklch(L, a, b) {
997
- return [
998
- L,
999
- Math.hypot(a, b),
1000
- Math.atan2(b, a)
1001
- ];
1209
+
1210
+ // Frame-space vector -> field-space vector: rotate onto the seeded stretch
1211
+ // axis, then compress along it. Used for positions AND for the tap offsets,
1212
+ // so shadows keep their true on-screen length however stretched the field is.
1213
+ vec2 toField(vec2 v, vec2 axis) {
1214
+ return vec2(dot(v, axis) / (1.0 + 3.0 * u_stretch), dot(v, vec2(-axis.y, axis.x)));
1002
1215
  }
1003
- /**
1004
- * OKLCh -> OKLab: polar back to rectangular, so the existing oklabToRgb
1005
- * matrices can be reused unchanged instead of duplicating the LMS math for
1006
- * a polar input.
1007
- */
1008
- function oklchToOklab(L, C, h) {
1009
- return [
1010
- L,
1011
- C * Math.cos(h),
1012
- C * Math.sin(h)
1013
- ];
1216
+
1217
+ void main() {
1218
+ vec2 uv = worldUv();
1219
+ vec2 iso = isoCoord(uv);
1220
+
1221
+ vec2 drift = loopDrift(0.03, vec2(1.0, 0.0));
1222
+ float freq = 0.8 * u_scale;
1223
+
1224
+ // Domain warp, evaluated ONCE and shared by every tap below. The taps are
1225
+ // at most ~0.07 frame units apart, over which a 0.5x-frequency warp is
1226
+ // effectively constant -- re-evaluating it per tap would cost 6 more
1227
+ // snoise calls to move each tap by a fraction of a pixel.
1228
+ float axisAngle = seedHash(5.0) * PI;
1229
+ vec2 axis = vec2(cos(axisAngle), sin(axisAngle));
1230
+ vec2 fp = toField(iso, axis);
1231
+ vec2 so = seedOffset();
1232
+ vec2 wq = fp * (0.5 * u_scale) + so.yx + 40.0 - drift.yx;
1233
+ vec2 warp = u_warp * 0.45 * vec2(snoise(wq), snoise(wq + vec2(13.1, 7.7)));
1234
+ vec2 q = fp * freq + so + drift + warp;
1235
+
1236
+ float L = floor(u_layers + 0.5);
1237
+
1238
+ // Direction TOWARD the light, and the shadow length in frame units.
1239
+ float ang = u_angle * PI / 180.0;
1240
+ vec2 toLight = vec2(cos(ang), sin(ang));
1241
+ vec2 side = vec2(-toLight.y, toLight.x);
1242
+ float reach = 0.012 + 0.06 * u_depth;
1243
+ vec2 qLight = toField(toLight * reach, axis) * freq;
1244
+ vec2 qSide = toField(side * reach, axis) * freq;
1245
+
1246
+ float v = field(q) * L;
1247
+ float vFar = field(q + qLight) * L; // shadow tap
1248
+ float vNear = field(q + qLight * 0.3) * L; // rim tap
1249
+ float vSide = field(q + qSide) * L; // gradient only
1250
+
1251
+ float band = min(floor(v), L - 1.0);
1252
+ float f = v - band;
1253
+
1254
+ // ---- analytic edge AA ---------------------------------------------------
1255
+ // |grad v| per frame unit, from the two orthogonal taps, times the size of
1256
+ // a pixel in frame units (frame height is 2*isoHalf().y over resolution.y).
1257
+ float gradV = length(vec2(vFar - v, vSide - v)) / reach;
1258
+ float pixel = 2.0 * isoHalf().y / u_resolution.y;
1259
+ float aa = clamp(gradV * pixel * 1.5, 0.002, 0.5);
1260
+ float toNext = smoothstep(1.0 - aa, 1.0, f) * step(band, L - 1.5);
1261
+
1262
+ // ---- colour -------------------------------------------------------------
1263
+ float denom = max(L - 1.0, 1.0);
1264
+ float tHere = mix(band / denom, v / L, u_blend);
1265
+ float tNext = mix((band + 1.0) / denom, v / L, u_blend);
1266
+ vec3 color = mix(palette(tHere), palette(tNext), toNext);
1267
+
1268
+ // ---- shadow + rim -------------------------------------------------------
1269
+ // Both fade out across the AA band so the shadow ends exactly where the
1270
+ // upper sheet begins instead of printing a dark fringe onto its edge.
1271
+ float shadow = smoothstep(band + 0.85, band + 1.4, vFar) * (1.0 - toNext);
1272
+ color *= 1.0 - shadow * 0.6 * u_depth;
1273
+
1274
+ float rim = smoothstep(band, band - 0.45, vNear) * step(0.5, band);
1275
+ color += (vec3(1.0) - clamp(color, 0.0, 1.0)) * rim * 0.22 * u_depth;
1276
+
1277
+ float g = grain(uv, u_time) - 0.5;
1278
+ color += g * u_grain;
1279
+
1280
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
1014
1281
  }
1015
- /**
1016
- * Chroma below which a hue angle is noise rather than signal. Near a=b=0,
1017
- * atan2 is numerically unstable (its output can swing wildly for a tiny
1018
- * change in a near-zero a or b), and perceptually a near-gray stop simply
1019
- * doesn't have a hue to speak of. Below this threshold a stop is treated as
1020
- * achromatic for interpolation purposes: see lerpHue().
1021
- *
1022
- * ~0.005 in OKLCh chroma units is comfortably below any stop actually used
1023
- * for color (the lab palettes' least-saturated non-neutral stops sit at
1024
- * 0.075+) and comfortably above float rounding noise from the hex -> OKLab
1025
- * round trip.
1026
- */
1027
- const ACHROMATIC_CHROMA = .005;
1028
- const TAU = Math.PI * 2;
1029
- /**
1030
- * Interpolates hue from h0 toward h1 by fraction t, taking the SHORTER way
1031
- * around the circle (e.g. 350deg -> 10deg moves +20deg through 0, not -340deg
1032
- * backward through 180deg) instead of a plain linear lerp, which would sweep
1033
- * through every intermediate hue on the long way round.
1034
- *
1035
- * Two edge cases handled explicitly:
1036
- *
1037
- * - Achromatic endpoint: if one side's chroma is below ACHROMATIC_CHROMA,
1038
- * its "hue" is meaningless (see the constant's comment), so it inherits
1039
- * the other endpoint's hue instead of contributing its own noisy angle.
1040
- * Concretely this means a gray-to-color segment holds a constant hue
1041
- * while only chroma ramps up, rather than spiraling through unrelated
1042
- * hues the gray's atan2 noise happened to produce. If both endpoints are
1043
- * achromatic the chosen hue is never visible: C stays near zero for the
1044
- * whole segment (it's lerped independently, from ~0 to ~0), so whatever
1045
- * angle comes out of cos/sin gets multiplied by ~0.
1046
- * - Exact opposition (180 degrees apart): both directions around the circle
1047
- * are equally short, so "shorter arc" is ambiguous. The wrap below always
1048
- * normalizes a signed delta of exactly +-pi to -pi (see the comment on
1049
- * the wrap), which picks one direction consistently rather than depending
1282
+ `;
1283
+ const strata = {
1284
+ id: "strata",
1285
+ label: "Strata",
1286
+ fragment: FRAGMENT$2,
1287
+ params: [
1288
+ {
1289
+ key: "scale",
1290
+ label: "Scale",
1291
+ min: .4,
1292
+ max: 2.5,
1293
+ step: .05,
1294
+ default: 1
1295
+ },
1296
+ {
1297
+ key: "layers",
1298
+ label: "Layers",
1299
+ min: 3,
1300
+ max: 16,
1301
+ step: 1,
1302
+ default: 7
1303
+ },
1304
+ {
1305
+ key: "warp",
1306
+ label: "Warp",
1307
+ min: 0,
1308
+ max: 1,
1309
+ step: .01,
1310
+ default: .5
1311
+ },
1312
+ {
1313
+ key: "ridges",
1314
+ label: "Ridges",
1315
+ min: 0,
1316
+ max: 1,
1317
+ step: .01,
1318
+ default: .25
1319
+ },
1320
+ {
1321
+ key: "stretch",
1322
+ label: "Stretch",
1323
+ min: 0,
1324
+ max: 1,
1325
+ step: .01,
1326
+ default: .2
1327
+ },
1328
+ {
1329
+ key: "depth",
1330
+ label: "Depth",
1331
+ min: 0,
1332
+ max: 1,
1333
+ step: .01,
1334
+ default: .6
1335
+ },
1336
+ {
1337
+ key: "blend",
1338
+ label: "Blend",
1339
+ min: 0,
1340
+ max: 1,
1341
+ step: .01,
1342
+ default: .15
1343
+ },
1344
+ {
1345
+ key: "angle",
1346
+ label: "Light",
1347
+ min: 0,
1348
+ max: 360,
1349
+ step: 1,
1350
+ default: 125
1351
+ },
1352
+ {
1353
+ key: "grain",
1354
+ label: "Grain",
1355
+ min: 0,
1356
+ max: .3,
1357
+ step: .01,
1358
+ default: .06
1359
+ }
1360
+ ],
1361
+ randomParams(rand) {
1362
+ return {
1363
+ scale: .6 + rand() * 1.2000000000000002,
1364
+ layers: Math.floor(4 + rand() * 9),
1365
+ warp: rand() * 1,
1366
+ ridges: rand() * 1,
1367
+ stretch: rand() * rand(),
1368
+ depth: .35 + rand() * .65,
1369
+ blend: rand() * .6,
1370
+ angle: Math.floor(rand() * 360),
1371
+ grain: .06
1372
+ };
1373
+ }
1374
+ };
1375
+
1376
+ //#endregion
1377
+ //#region src/shaders/dune.ts
1378
+ const FRAGMENT$1 = `
1379
+ uniform float u_layers;
1380
+ uniform float u_swell;
1381
+ uniform float u_waves;
1382
+ uniform float u_fade;
1383
+ uniform float u_soft;
1384
+ uniform float u_angle;
1385
+ uniform float u_grain;
1386
+
1387
+ ${GRAIN}
1388
+ ${ISO}
1389
+ ${LOOP_ANGLE}
1390
+ ${SEED}
1391
+
1392
+ const float PI = 3.14159265;
1393
+
1394
+ void main() {
1395
+ vec2 uv = worldUv();
1396
+ vec2 iso = isoCoord(uv);
1397
+ vec2 halfIso = isoHalf();
1398
+
1399
+ // Layer frame: x runs along the crests, y is "up", normalised so the
1400
+ // visible frame spans y = -1..1 at every angle and aspect.
1401
+ float ang = u_angle * PI / 180.0;
1402
+ vec2 ax = vec2(cos(ang), sin(ang));
1403
+ vec2 ay = vec2(-ax.y, ax.x);
1404
+ float spanY = halfIso.x * abs(ay.x) + halfIso.y * abs(ay.y);
1405
+ float x = dot(iso, ax);
1406
+ float y = dot(iso, ay) / spanY;
1407
+
1408
+ float n = floor(u_layers + 0.5);
1409
+ float a1 = loopAngle(0.22);
1410
+ float a2 = loopAngle(0.36);
1411
+ float k = PI * u_waves;
1412
+
1413
+ float pixel = 2.0 * halfIso.y / u_resolution.y / spanY;
1414
+ float w = pixel + u_soft * 0.22;
1415
+
1416
+ // Sky: the first stop, itself graded a little so it is not a flat fill.
1417
+ vec3 color = palette(0.04 * (1.0 - y));
1418
+
1419
+ for (int li = 0; li < 12; li++) {
1420
+ float i = float(li);
1421
+ if (i >= n) break;
1422
+
1423
+ float h1 = seedHash(i * 3.0 + 1.0);
1424
+ float h2 = seedHash(i * 3.0 + 2.0);
1425
+ float h3 = seedHash(i * 3.0 + 3.0);
1426
+
1427
+ // Crest baselines spread from near the top to near the bottom.
1428
+ float base = mix(0.72, -0.8, i / max(n - 1.0, 1.0));
1429
+ float fi = k * (0.75 + 0.5 * h1);
1430
+ float dir = h3 < 0.5 ? -1.0 : 1.0;
1431
+ float edge = base + u_swell * 0.5 * (0.6 * sin(fi * x + h2 * TAU + dir * a1)
1432
+ + 0.4 * sin(fi * 1.7 * x + h3 * TAU - a2));
1433
+
1434
+ float depth = edge - y;
1435
+ float coverage = smoothstep(-w, w, depth);
1436
+
1437
+ float ti = (i + 1.0) / n;
1438
+ float body = 1.0 - exp(-max(depth, 0.0) / 0.3);
1439
+ // Lengthwise drift: the crest colour wanders a third of a stop along x.
1440
+ float along = 0.35 * sin(x * 1.1 + h1 * TAU + dir * a2);
1441
+ float t = ti + (along - u_fade * 1.15 * body) / n;
1442
+
1443
+ color = mix(color, palette(clamp(t, 0.0, 1.0)), coverage);
1444
+ }
1445
+
1446
+ float g = grain(uv, u_time) - 0.5;
1447
+ color += g * u_grain;
1448
+
1449
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
1450
+ }
1451
+ `;
1452
+ const dune = {
1453
+ id: "dune",
1454
+ label: "Dune",
1455
+ fragment: FRAGMENT$1,
1456
+ params: [
1457
+ {
1458
+ key: "layers",
1459
+ label: "Layers",
1460
+ min: 2,
1461
+ max: 12,
1462
+ step: 1,
1463
+ default: 6
1464
+ },
1465
+ {
1466
+ key: "swell",
1467
+ label: "Swell",
1468
+ min: 0,
1469
+ max: 1.6,
1470
+ step: .01,
1471
+ default: .7
1472
+ },
1473
+ {
1474
+ key: "waves",
1475
+ label: "Waves",
1476
+ min: .3,
1477
+ max: 5,
1478
+ step: .05,
1479
+ default: 1.4
1480
+ },
1481
+ {
1482
+ key: "fade",
1483
+ label: "Fade",
1484
+ min: 0,
1485
+ max: 1.5,
1486
+ step: .01,
1487
+ default: .9
1488
+ },
1489
+ {
1490
+ key: "soft",
1491
+ label: "Soft",
1492
+ min: 0,
1493
+ max: 1,
1494
+ step: .01,
1495
+ default: .02
1496
+ },
1497
+ {
1498
+ key: "angle",
1499
+ label: "Angle",
1500
+ min: 0,
1501
+ max: 360,
1502
+ step: 1,
1503
+ default: 352
1504
+ },
1505
+ {
1506
+ key: "grain",
1507
+ label: "Grain",
1508
+ min: 0,
1509
+ max: .3,
1510
+ step: .01,
1511
+ default: .05
1512
+ }
1513
+ ],
1514
+ randomParams(rand) {
1515
+ return {
1516
+ layers: Math.floor(4 + rand() * 6),
1517
+ swell: .4 + rand() * .7999999999999999,
1518
+ waves: .7 + rand() * 2.0999999999999996,
1519
+ fade: .6 + rand() * .7000000000000001,
1520
+ soft: rand() < .75 ? rand() * .06 : .2 + rand() * .5,
1521
+ angle: Math.floor((rand() < .7 ? -25 + rand() * 50 : rand() * 360) + 360) % 360,
1522
+ grain: .05
1523
+ };
1524
+ }
1525
+ };
1526
+
1527
+ //#endregion
1528
+ //#region src/shaders/whorl.ts
1529
+ const FRAGMENT = `
1530
+ uniform float u_blades;
1531
+ uniform float u_twist;
1532
+ uniform float u_depth;
1533
+ uniform float u_scale;
1534
+ uniform float u_wobble;
1535
+ uniform float u_x;
1536
+ uniform float u_y;
1537
+ uniform float u_grain;
1538
+
1539
+ ${SIMPLEX_2D}
1540
+ ${GRAIN}
1541
+ ${ISO}
1542
+ ${LOOP_ANGLE}
1543
+ ${SEED}
1544
+
1545
+ void main() {
1546
+ vec2 uv = worldUv();
1547
+ vec2 p = isoCoord(uv);
1548
+ vec2 halfIso = isoHalf();
1549
+
1550
+ vec2 c = vec2(u_x, u_y) * halfIso;
1551
+ vec2 rel = p - c;
1552
+ float r = max(length(rel), 0.0001);
1553
+ float theta = atan(rel.y, rel.x);
1554
+
1555
+ float k = floor(u_blades + 0.5);
1556
+ float rot = loopAngle(0.5) / k + seedHash(1.0) * TAU;
1557
+
1558
+ vec2 drift = loopDrift(0.035, vec2(1.0, 0.0));
1559
+ float wob = u_wobble * 0.45 * snoise(p * 0.9 + seedOffset() + drift);
1560
+
1561
+ float u = k * ((theta - rot) + u_twist * log(r)) / TAU + wob;
1562
+ float s = u - floor(u);
1563
+
1564
+ // One-pixel blend across the sawtooth's drop (see header, point 3).
1565
+ float pixel = 2.0 * halfIso.y / u_resolution.y;
1566
+ float aa = clamp(k * sqrt(1.0 + u_twist * u_twist) / (TAU * r) * pixel * 1.5, 0.0, 0.5);
1567
+ s *= 1.0 - smoothstep(1.0 - aa, 1.0, s);
1568
+
1569
+ float core = smoothstep(0.0, 0.08, r);
1570
+ float t = (1.0 - exp(-r / (0.6 * u_scale))) + u_depth * 0.5 * (s - 0.5) * core;
1571
+ vec3 color = palette(clamp(t, 0.0, 1.0));
1572
+
1573
+ float g = grain(uv, u_time) - 0.5;
1574
+ color += g * u_grain;
1575
+
1576
+ gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
1577
+ }
1578
+ `;
1579
+ const whorl = {
1580
+ id: "whorl",
1581
+ label: "Whorl",
1582
+ fragment: FRAGMENT,
1583
+ params: [
1584
+ {
1585
+ key: "blades",
1586
+ label: "Blades",
1587
+ min: 2,
1588
+ max: 24,
1589
+ step: 1,
1590
+ default: 9
1591
+ },
1592
+ {
1593
+ key: "twist",
1594
+ label: "Twist",
1595
+ min: -3,
1596
+ max: 3,
1597
+ step: .05,
1598
+ default: 1.1
1599
+ },
1600
+ {
1601
+ key: "depth",
1602
+ label: "Depth",
1603
+ min: 0,
1604
+ max: 1.5,
1605
+ step: .01,
1606
+ default: .8
1607
+ },
1608
+ {
1609
+ key: "scale",
1610
+ label: "Scale",
1611
+ min: .3,
1612
+ max: 3,
1613
+ step: .05,
1614
+ default: 1.2
1615
+ },
1616
+ {
1617
+ key: "wobble",
1618
+ label: "Wobble",
1619
+ min: 0,
1620
+ max: 1,
1621
+ step: .01,
1622
+ default: .4
1623
+ },
1624
+ {
1625
+ key: "x",
1626
+ label: "X",
1627
+ min: -1.5,
1628
+ max: 1.5,
1629
+ step: .01,
1630
+ default: -.55
1631
+ },
1632
+ {
1633
+ key: "y",
1634
+ label: "Y",
1635
+ min: -1.5,
1636
+ max: 1.5,
1637
+ step: .01,
1638
+ default: -.7
1639
+ },
1640
+ {
1641
+ key: "grain",
1642
+ label: "Grain",
1643
+ min: 0,
1644
+ max: .3,
1645
+ step: .01,
1646
+ default: .06
1647
+ }
1648
+ ],
1649
+ randomParams(rand) {
1650
+ return {
1651
+ blades: Math.floor(5 + rand() * 12),
1652
+ twist: (rand() < .5 ? -1 : 1) * (.5 + rand() * 2),
1653
+ depth: .5 + rand() * .7,
1654
+ scale: .8 + rand() * 1.2,
1655
+ wobble: rand() * .8,
1656
+ x: -1.1 + rand() * 2.2,
1657
+ y: -1.1 + rand() * 2.2,
1658
+ grain: .06
1659
+ };
1660
+ }
1661
+ };
1662
+
1663
+ //#endregion
1664
+ //#region src/effects/pixelate.ts
1665
+ const CELL_FRAGMENT$3 = `
1666
+ uniform float u_levels;
1667
+
1668
+ void main() {
1669
+ vec3 c = cellSource().rgb;
1670
+ // Posterize per channel. Rounds to the nearest level, so black and white
1671
+ // stay put. levels = 1 would divide by zero and means nothing; 0 is "off".
1672
+ if (u_levels >= 2.0) {
1673
+ float n = u_levels - 1.0;
1674
+ c = floor(c * n + 0.5) / n;
1675
+ }
1676
+ gl_FragColor = vec4(c, 1.0);
1677
+ }
1678
+ `;
1679
+ const DRAW_FRAGMENT$2 = `
1680
+ uniform float u_gap;
1681
+ uniform vec3 u_gapColor;
1682
+
1683
+ void main() {
1684
+ vec3 color = coverage();
1685
+ if (u_gap > 0.0) {
1686
+ // A gap line is u_gap of a cell wide, centred on every cell boundary.
1687
+ // Its weight is the exact share of this output pixel it covers, and the
1688
+ // blend happens in linear light, for the same reason coverage() works
1689
+ // that way: a smoothstep edge in gamma space draws sub-pixel lines far
1690
+ // too heavy, so a small preview would look darker than its own export.
1691
+ vec2 px = cellPx();
1692
+ vec2 d = min(cellLocal(), 1.0 - cellLocal()) * px; // px to nearest boundary
1693
+ vec2 hw = 0.5 * u_gap * px; // line half-width in px
1694
+ vec2 cov = clamp(min(hw, d + 0.5) - max(-hw, d - 0.5), 0.0, 1.0);
1695
+ float m = 1.0 - (1.0 - cov.x) * (1.0 - cov.y);
1696
+ color = toSrgb(mix(toLinear(color), toLinear(u_gapColor), m));
1697
+ }
1698
+ gl_FragColor = vec4(color, 1.0);
1699
+ }
1700
+ `;
1701
+ const pixelate = {
1702
+ id: "pixelate",
1703
+ label: "Pixelate",
1704
+ fragment: DRAW_FRAGMENT$2,
1705
+ grid: {
1706
+ cell: (p) => [p.size, p.size],
1707
+ fragment: CELL_FRAGMENT$3
1708
+ },
1709
+ params: [
1710
+ {
1711
+ key: "size",
1712
+ label: "Size",
1713
+ min: 2,
1714
+ max: 160,
1715
+ step: 1,
1716
+ default: 24
1717
+ },
1718
+ {
1719
+ key: "levels",
1720
+ label: "Levels",
1721
+ min: 0,
1722
+ max: 16,
1723
+ step: 1,
1724
+ default: 0
1725
+ },
1726
+ {
1727
+ key: "gap",
1728
+ label: "Gap",
1729
+ min: 0,
1730
+ max: .4,
1731
+ step: .01,
1732
+ default: 0
1733
+ },
1734
+ {
1735
+ key: "gapColor",
1736
+ label: "Gap color",
1737
+ type: "color",
1738
+ default: "#000000"
1739
+ }
1740
+ ],
1741
+ randomParams(rand) {
1742
+ return {
1743
+ size: Math.round(10 + rand() * 50),
1744
+ levels: rand() < .5 ? 0 : Math.round(3 + rand() * 5),
1745
+ gap: rand() < .6 ? 0 : .05 + rand() * .15,
1746
+ gapColor: "#000000"
1747
+ };
1748
+ }
1749
+ };
1750
+
1751
+ //#endregion
1752
+ //#region src/effects/chunks.ts
1753
+ /** sRGB transfer functions. Blending and averaging must happen in linear
1754
+ * light: a fine black/white checkerboard averaged in gamma space comes out
1755
+ * sRGB 128 where the eye (and a downscaled export) sees 188. */
1756
+ const COLOR_MATH = `
1757
+ vec3 toLinear(vec3 c) {
1758
+ c = max(c, vec3(0.0));
1759
+ return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
1760
+ }
1761
+ vec3 toSrgb(vec3 c) {
1762
+ c = max(c, vec3(0.0));
1763
+ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
1764
+ }
1765
+ `;
1766
+ /**
1767
+ * Available to every effect stage.
1768
+ *
1769
+ * - `loopStep(rate)`: integer step counter for motion that jumps instead of
1770
+ * gliding. Exactly periodic when looping, because it counts a whole number
1771
+ * of steps per loop and u_time itself wraps at u_loop.
1772
+ * - `hash12` / `hash22`: Dave Hoskins' sine-free hashes. The classic
1773
+ * fract(sin(x) * 43758.5) returns different values on different GPUs,
1774
+ * which would make a saved design animate differently per machine.
1775
+ * - `luma`: Rec.709 weights on GAMMA-ENCODED sRGB, on purpose. Thresholding
1776
+ * in gamma space is what every dither tool does and what users compare
1777
+ * against; see SPEC-effects.md 9.1.
1778
+ */
1779
+ const EFFECT_COMMON = `
1780
+ uniform vec2 u_refSize; // frame size in reference pixels (16:9 = 1920x1080)
1781
+ uniform float u_outputScale; // device pixels per reference pixel
1782
+ ${COLOR_MATH}
1783
+ float loopStep(float rate) {
1784
+ if (rate <= 0.0) return 0.0;
1785
+ if (u_loop <= 0.0) return floor(u_time * rate);
1786
+ float n = max(1.0, floor(rate * u_loop + 0.5));
1787
+ return min(floor(u_time / u_loop * n), n - 1.0);
1788
+ }
1789
+ float hash12(vec2 p) {
1790
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
1791
+ p3 += dot(p3, p3.yzx + 33.33);
1792
+ return fract((p3.x + p3.y) * p3.z);
1793
+ }
1794
+ vec2 hash22(vec2 p) {
1795
+ vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
1796
+ p3 += dot(p3, p3.yzx + 33.33);
1797
+ return fract((p3.xx + p3.yz) * p3.zy);
1798
+ }
1799
+ float luma(vec3 c) {
1800
+ return dot(c, vec3(0.2126, 0.7152, 0.0722));
1801
+ }
1802
+ `;
1803
+ /** Soft effects: one fragment per output pixel, reading the layer below. */
1804
+ const SOFT_PREAMBLE = `
1805
+ uniform sampler2D u_source; // the layer below, frame size
1806
+ vec4 source(vec2 uv) { return texture2D(u_source, uv); }
1807
+ // This pixel in reference pixels, origin at the frame centre.
1808
+ vec2 refCoord() { return (gl_FragCoord.xy - 0.5 * u_resolution) / u_outputScale; }
1809
+ // Reference pixels back to 0-1 frame UV, for sampling u_source.
1810
+ vec2 refToUv(vec2 r) { return r * u_outputScale / u_resolution + 0.5; }
1811
+ `;
1812
+ /** Cell stage of a grid effect: one fragment per CELL. */
1813
+ const CELL_PREAMBLE = `
1814
+ uniform sampler2D u_source; // cell source: one texel per cell, already averaged
1815
+ uniform vec2 u_gridSize; // cols, rows
1816
+ uniform vec2 u_cellRef; // cell size in reference pixels
1817
+ uniform float u_gridAngle; // radians; 0 unless the effect uses a lattice
1818
+ // Integer cell coordinates with the origin at the grid (= frame) centre.
1819
+ // Index patterns by this, never by gl_FragCoord of the output.
1820
+ vec2 cellIndex() { return floor(gl_FragCoord.xy) - 0.5 * u_gridSize; }
1821
+ // The input picture averaged over this cell.
1822
+ vec4 cellSource() { return texture2D(u_source, (floor(gl_FragCoord.xy) + 0.5) / u_gridSize); }
1823
+ `;
1824
+ /**
1825
+ * Draw stage of a grid effect: one fragment per output pixel, reading the
1826
+ * cell buffer.
1827
+ *
1828
+ * coverage() is the default draw stage: exact area coverage of this output
1829
+ * pixel's footprint over the cells it touches, blended in linear light. It
1830
+ * is libretro's "pixellate" filter with gamma-correct blending. When a cell
1831
+ * spans a whole number of output pixels the footprint sits inside one cell
1832
+ * and the texel is returned untouched, so 1080p and 4K exports are exact
1833
+ * nearest-neighbour. 3x3 taps cover footprints up to 3 cells wide, i.e.
1834
+ * down to 0.5 output pixels per cell.
1835
+ */
1836
+ const DRAW_PREAMBLE = `
1837
+ uniform sampler2D u_cells; // cell buffer, NEAREST, sRGB-encoded
1838
+ uniform vec2 u_gridSize;
1839
+ uniform vec2 u_cellRef;
1840
+ uniform float u_gridAngle; // radians; 0 unless the effect uses a lattice
1841
+ vec2 cellPx() { return u_cellRef * u_outputScale; }
1842
+ // Continuous cell coordinate of this output pixel: integer part is the cell
1843
+ // (0-based from the grid's bottom-left), fraction is the position inside it.
1844
+ // The angle branch is skipped for ordinary grids so their arithmetic, and
1845
+ // with it the exactness of whole-pixel cells, is untouched.
1846
+ vec2 cellCoordAt(vec2 fragCoord) {
1847
+ vec2 p = fragCoord - 0.5 * u_resolution;
1848
+ if (u_gridAngle != 0.0) {
1849
+ float c = cos(u_gridAngle);
1850
+ float s = sin(u_gridAngle);
1851
+ p = vec2(c * p.x + s * p.y, -s * p.x + c * p.y);
1852
+ }
1853
+ return p / cellPx() + 0.5 * u_gridSize;
1854
+ }
1855
+ vec2 cellCoord() { return cellCoordAt(gl_FragCoord.xy); }
1856
+ vec2 cellIndexAt() { return floor(cellCoord()) - 0.5 * u_gridSize; }
1857
+ vec2 cellLocal() { return fract(cellCoord()); }
1858
+ // Raw cell-buffer texel for a centred cell index (as cellIndexAt returns).
1859
+ vec4 cellValue(vec2 index) {
1860
+ vec2 k = clamp(index + 0.5 * u_gridSize, vec2(0.0), u_gridSize - 1.0);
1861
+ return texture2D(u_cells, (k + 0.5) / u_gridSize);
1862
+ }
1863
+ vec3 coverage() {
1864
+ vec2 c = cellCoord();
1865
+ vec2 h = 0.5 / cellPx();
1866
+ vec2 lo = c - h;
1867
+ vec2 hi = c + h;
1868
+ vec2 base = floor(c);
1869
+ vec3 acc = vec3(0.0);
1870
+ float wsum = 0.0;
1871
+ vec3 best = vec3(0.0);
1872
+ float bestW = -1.0;
1873
+ for (int j = -1; j <= 1; j++) {
1874
+ for (int i = -1; i <= 1; i++) {
1875
+ vec2 k = base + vec2(float(i), float(j));
1876
+ vec2 ov = max(vec2(0.0), min(hi, k + 1.0) - max(lo, k));
1877
+ float w = ov.x * ov.y;
1878
+ if (w <= 0.0) continue;
1879
+ vec2 kk = clamp(k, vec2(0.0), u_gridSize - 1.0);
1880
+ vec3 v = texture2D(u_cells, (kk + 0.5) / u_gridSize).rgb;
1881
+ if (w > bestW) { bestW = w; best = v; }
1882
+ acc += w * toLinear(v);
1883
+ wsum += w;
1884
+ }
1885
+ }
1886
+ // Footprint (almost) entirely inside one cell: return the texel itself
1887
+ // rather than a decode/encode round trip of it.
1888
+ if (bestW >= 0.9999 * 4.0 * h.x * h.y) return best;
1889
+ return toSrgb(acc / wsum);
1890
+ }
1891
+ `;
1892
+ /** Draw stage used when a grid effect does not supply its own. */
1893
+ const COVERAGE_DRAW = `
1894
+ void main() {
1895
+ gl_FragColor = vec4(coverage(), 1.0);
1896
+ }
1897
+ `;
1898
+ /**
1899
+ * Color modes, shared by Dither, Halftone and ASCII so the params and the
1900
+ * behaviour are identical across them:
1901
+ *
1902
+ * - source: keep the source's colors (the effect still quantizes/masks them)
1903
+ * - duotone: tone 0 -> ink, tone 1 -> paper
1904
+ * - palette: tone -> the stack's palette ramp
1905
+ *
1906
+ * toneOf() is what "tone" means. Normally it is luma. In palette mode
1907
+ * directly over a GENERATOR it is instead the color's position on the
1908
+ * palette ramp, recovered by nearest-color search. A generator's colors came
1909
+ * from that ramp, so this hands each cell back the exact ramp coordinate the
1910
+ * generator used and the effect reproduces the gradient's own color layout.
1911
+ * Luma would only do that for palettes that happen to run dark to light; a
1912
+ * pink -> blue -> yellow palette would come out rearranged. Over media, or
1913
+ * over another effect, there is no ramp coordinate to recover and tone stays
1914
+ * luma (a gradient map).
1915
+ *
1916
+ * The search is 33 coarse + 17 fine ramp samples. That is affordable
1917
+ * because tone is evaluated per cell or per lattice point, not per pixel.
1918
+ * The ends snap to exactly 0 and 1: generators clamp their coordinate, so
1919
+ * large flat areas sit at an end stop, and 8-bit rounding would otherwise
1920
+ * leave them a hair inside the ramp and sprinkle them with the next level.
1921
+ */
1922
+ const COLOR_MODE = `
1923
+ uniform float u_colorMode;
1924
+ uniform vec3 u_ink;
1925
+ uniform vec3 u_paper;
1926
+ uniform float u_invert;
1927
+ uniform float u_sourceIsRamp; // 1 when this layer reads a generator directly
1928
+ float rampPosition(vec3 c) {
1929
+ float best = 0.0;
1930
+ float bd = 1e9;
1931
+ for (int i = 0; i <= 32; i++) {
1932
+ float t = float(i) / 32.0;
1933
+ vec3 d = palette(t) - c;
1934
+ float dd = dot(d, d);
1935
+ if (dd < bd) { bd = dd; best = t; }
1936
+ }
1937
+ float lo = best - 1.0 / 32.0;
1938
+ for (int i = 0; i <= 16; i++) {
1939
+ float t = clamp(lo + float(i) / 256.0, 0.0, 1.0);
1940
+ vec3 d = palette(t) - c;
1941
+ float dd = dot(d, d);
1942
+ if (dd < bd) { bd = dd; best = t; }
1943
+ }
1944
+ if (best > 1.0 - 1.5 / 256.0) return 1.0;
1945
+ if (best < 1.5 / 256.0) return 0.0;
1946
+ return best;
1947
+ }
1948
+ bool toneIsRamp() { return u_colorMode > 1.5 && u_sourceIsRamp > 0.5; }
1949
+ float toneOf(vec3 c) {
1950
+ float t = toneIsRamp() ? rampPosition(c) : luma(c);
1951
+ return u_invert > 0.5 ? 1.0 - t : t;
1952
+ }
1953
+ vec3 colorize(float tone, vec3 src) {
1954
+ if (u_colorMode < 0.5) return src;
1955
+ if (u_colorMode < 1.5) return mix(u_ink, u_paper, tone);
1956
+ return palette(tone);
1957
+ }
1958
+ `;
1959
+ /** The param defs that go with COLOR_MODE. `paperAlways` is for effects
1960
+ * where paper is a visible ground in every mode (ASCII, Halftone). */
1961
+ function colorModeParams(opts) {
1962
+ return [
1963
+ {
1964
+ key: "colorMode",
1965
+ label: "Colors",
1966
+ type: "enum",
1967
+ options: [
1968
+ {
1969
+ value: "source",
1970
+ label: "Source"
1971
+ },
1972
+ {
1973
+ value: "duotone",
1974
+ label: "Duotone"
1975
+ },
1976
+ {
1977
+ value: "palette",
1978
+ label: "Palette"
1979
+ }
1980
+ ],
1981
+ default: opts.mode
1982
+ },
1983
+ {
1984
+ key: "ink",
1985
+ label: "Ink",
1986
+ type: "color",
1987
+ default: opts.ink ?? "#111111",
1988
+ when: {
1989
+ key: "colorMode",
1990
+ in: ["duotone"]
1991
+ }
1992
+ },
1993
+ {
1994
+ key: "paper",
1995
+ label: "Paper",
1996
+ type: "color",
1997
+ default: opts.paper ?? "#f4f1ea",
1998
+ ...opts.paperAlways ? {} : { when: {
1999
+ key: "colorMode",
2000
+ in: ["duotone"]
2001
+ } }
2002
+ },
2003
+ {
2004
+ key: "invert",
2005
+ label: "Invert",
2006
+ type: "bool",
2007
+ default: false
2008
+ }
2009
+ ];
2010
+ }
2011
+
2012
+ //#endregion
2013
+ //#region src/effects/blueNoise.ts
2014
+ const BLUE_NOISE_SIZE = 128;
2015
+ const BASE64 = "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";
2016
+ let cached = null;
2017
+ /** The tile's bytes, row-major, decoded once. */
2018
+ function blueNoiseBytes() {
2019
+ if (cached) return cached;
2020
+ const bin = atob(BASE64);
2021
+ const out = new Uint8Array(bin.length);
2022
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2023
+ cached = out;
2024
+ return out;
2025
+ }
2026
+
2027
+ //#endregion
2028
+ //#region src/effects/dither.ts
2029
+ const CELL_FRAGMENT$2 = `
2030
+ uniform float u_pattern;
2031
+ uniform float u_levels;
2032
+ uniform float u_bias;
2033
+ uniform float u_linear;
2034
+ uniform float u_shimmer;
2035
+ uniform sampler2D u_noise;
2036
+ ${COLOR_MODE}
2037
+
2038
+ // Recursive Bayer matrices without bit operations (GLSL ES 1.0 has none).
2039
+ // bayer2 yields M/4 for the canonical 2x2 matrix [0 2; 3 1]; each larger
2040
+ // size interleaves a half-resolution copy. Verified against the canonical
2041
+ // 4x4 and 8x8 matrices value for value. The mod() keeps the square term
2042
+ // exact at any cell index and makes negative indices (the grid is centred on
2043
+ // the frame) tile correctly.
2044
+ float bayer2(vec2 a) {
2045
+ a = mod(floor(a), 2.0);
2046
+ return fract(a.x / 2.0 + a.y * a.y * 0.75);
2047
+ }
2048
+ #define bayer4(a) (bayer2(0.5 * (a)) * 0.25 + bayer2(a))
2049
+ #define bayer8(a) (bayer4(0.5 * (a)) * 0.25 + bayer2(a))
2050
+
2051
+ // Centred thresholds: (M + 0.5) / N^2, strictly inside (0,1). The noise
2052
+ // texture gets the same treatment, since its raw 0 would otherwise never
2053
+ // fire and its raw 255 (= 1.0) would light pure black.
2054
+ float threshold(vec2 idx) {
2055
+ if (u_pattern < 0.5) return bayer2(idx) + 0.5 / 4.0;
2056
+ if (u_pattern < 1.5) return bayer4(idx) + 0.5 / 16.0;
2057
+ if (u_pattern < 2.5) return bayer8(idx) + 0.5 / 64.0;
2058
+ float v = texture2D(u_noise, (mod(idx, ${BLUE_NOISE_SIZE}.0) + 0.5) / ${BLUE_NOISE_SIZE}.0).r;
2059
+ return (floor(v * 255.0 + 0.5) + 0.5) / 256.0;
2060
+ }
2061
+
2062
+ // gammaTone: v is a gamma-encoded brightness, so the linear-light option
2063
+ // applies. A ramp position (see toneOf) is not a brightness and skips it.
2064
+ float quantize(float v, float t, bool gammaTone) {
2065
+ float n = max(u_levels, 2.0) - 1.0;
2066
+ v = clamp(v + u_bias, 0.0, 1.0);
2067
+ // Optional linear-light thresholding: physically correct brightness, at
2068
+ // the cost of starving dark gradients (sRGB 0-0.25 is linear 0-0.05, where
2069
+ // a 4x4 matrix has no level at all). Off by default; see SPEC 9.1.
2070
+ bool lin = gammaTone && u_linear > 0.5;
2071
+ if (lin) v = toLinear(vec3(v)).r;
2072
+ float q = floor(v * n + t) / n;
2073
+ if (lin) q = toSrgb(vec3(q)).r;
2074
+ return q;
2075
+ }
2076
+
2077
+ void main() {
2078
+ vec3 c = cellSource().rgb;
2079
+
2080
+ // Shimmer: every step, slide the whole pattern by a hashed whole number of
2081
+ // cells. Each frame stays spatially identical (still Bayer, still blue
2082
+ // noise); only its position jumps, which reads as film grain rather than
2083
+ // as a crawling pattern. loopStep makes it exactly periodic.
2084
+ vec2 idx = cellIndex();
2085
+ if (u_shimmer > 0.0) {
2086
+ idx += floor(hash22(vec2(loopStep(u_shimmer), u_seed) + 0.5) * ${BLUE_NOISE_SIZE}.0);
2087
+ }
2088
+ float t = threshold(idx);
2089
+
2090
+ vec3 color;
2091
+ if (u_colorMode < 0.5) {
2092
+ // Source colors: one shared threshold, quantized per channel.
2093
+ if (u_invert > 0.5) c = 1.0 - c;
2094
+ color = vec3(quantize(c.r, t, true), quantize(c.g, t, true), quantize(c.b, t, true));
2095
+ } else {
2096
+ color = colorize(quantize(toneOf(c), t, !toneIsRamp()), c);
2097
+ }
2098
+ gl_FragColor = vec4(color, 1.0);
2099
+ }
2100
+ `;
2101
+ const dither = {
2102
+ id: "dither",
2103
+ label: "Dither",
2104
+ grid: {
2105
+ cell: (p) => [p.size, p.size],
2106
+ fragment: CELL_FRAGMENT$2
2107
+ },
2108
+ textures: [{
2109
+ uniform: "u_noise",
2110
+ key: () => "blue-noise",
2111
+ build: () => ({
2112
+ source: {
2113
+ data: blueNoiseBytes(),
2114
+ width: BLUE_NOISE_SIZE,
2115
+ height: BLUE_NOISE_SIZE
2116
+ },
2117
+ filter: "nearest",
2118
+ wrap: "repeat"
2119
+ })
2120
+ }],
2121
+ params: [
2122
+ {
2123
+ key: "pattern",
2124
+ label: "Pattern",
2125
+ type: "enum",
2126
+ options: [
2127
+ {
2128
+ value: "bayer2",
2129
+ label: "Bayer 2x2"
2130
+ },
2131
+ {
2132
+ value: "bayer4",
2133
+ label: "Bayer 4x4"
2134
+ },
2135
+ {
2136
+ value: "bayer8",
2137
+ label: "Bayer 8x8"
2138
+ },
2139
+ {
2140
+ value: "blueNoise",
2141
+ label: "Blue noise"
2142
+ }
2143
+ ],
2144
+ default: "bayer4"
2145
+ },
2146
+ {
2147
+ key: "size",
2148
+ label: "Size",
2149
+ min: 1,
2150
+ max: 16,
2151
+ step: 1,
2152
+ default: 4
2153
+ },
2154
+ {
2155
+ key: "levels",
2156
+ label: "Levels",
2157
+ min: 2,
2158
+ max: 8,
2159
+ step: 1,
2160
+ default: 2
2161
+ },
2162
+ {
2163
+ key: "bias",
2164
+ label: "Bias",
2165
+ min: -.5,
2166
+ max: .5,
2167
+ step: .01,
2168
+ default: 0
2169
+ },
2170
+ {
2171
+ key: "linear",
2172
+ label: "Linear light",
2173
+ type: "bool",
2174
+ default: false
2175
+ },
2176
+ {
2177
+ key: "shimmer",
2178
+ label: "Shimmer",
2179
+ min: 0,
2180
+ max: 12,
2181
+ step: 1,
2182
+ default: 0
2183
+ },
2184
+ ...colorModeParams({ mode: "source" })
2185
+ ],
2186
+ randomParams(rand) {
2187
+ const patterns = [
2188
+ "bayer2",
2189
+ "bayer4",
2190
+ "bayer8",
2191
+ "blueNoise"
2192
+ ];
2193
+ const modes = [
2194
+ "source",
2195
+ "duotone",
2196
+ "palette"
2197
+ ];
2198
+ return {
2199
+ pattern: patterns[Math.floor(rand() * patterns.length)],
2200
+ size: Math.round(2 + rand() * 6),
2201
+ levels: Math.round(2 + rand() * 3),
2202
+ bias: 0,
2203
+ linear: false,
2204
+ shimmer: 0,
2205
+ colorMode: modes[Math.floor(rand() * modes.length)],
2206
+ ink: "#111111",
2207
+ paper: "#f4f1ea",
2208
+ invert: false
2209
+ };
2210
+ }
2211
+ };
2212
+
2213
+ //#endregion
2214
+ //#region src/effects/halftone.ts
2215
+ const ROW = .8660254;
2216
+ const CELL_FRAGMENT$1 = `
2217
+ uniform float u_contrast;
2218
+ ${COLOR_MODE}
2219
+
2220
+ void main() {
2221
+ vec3 sc = cellSource().rgb;
2222
+
2223
+ // Ink amount. Halftone sizes its shapes by brightness in every color mode,
2224
+ // so this is luma even where toneOf() would return a ramp position.
2225
+ float tone = luma(sc);
2226
+ if (u_invert > 0.5) tone = 1.0 - tone;
2227
+ float ink = clamp((1.0 - tone - 0.5) * u_contrast + 0.5, 0.0, 1.0);
2228
+
2229
+ // Ink color. Duotone inks every shape with u_ink (colorize() would mix
2230
+ // toward paper by tone, which is dither's meaning of duotone). Palette
2231
+ // colors by the un-inverted tone, so Invert flips sizes and leaves colors.
2232
+ vec3 color = sc;
2233
+ if (u_colorMode > 1.5) color = palette(toneIsRamp() ? rampPosition(sc) : luma(sc));
2234
+ else if (u_colorMode > 0.5) color = u_ink;
2235
+
2236
+ gl_FragColor = vec4(color, ink);
2237
+ }
2238
+ `;
2239
+ const DRAW_FRAGMENT$1 = `
2240
+ uniform float u_grid;
2241
+ uniform float u_shape;
2242
+ uniform float u_radius;
2243
+ uniform float u_softness;
2244
+ uniform float u_pulse;
2245
+ ${COLOR_MODE}
2246
+
2247
+ const float ROW = ${ROW};
2248
+
2249
+ vec4 lattice(vec2 k) {
2250
+ vec2 kk = clamp(k, vec2(0.0), u_gridSize - 1.0);
2251
+ return texture2D(u_cells, (kk + 0.5) / u_gridSize);
2252
+ }
2253
+
2254
+ // The shape covering lattice coordinate g most, blended over paper, in
2255
+ // linear light. \`aa\` is the width, in lattice units, edges are ramped over.
2256
+ vec3 shade(vec2 g, float aa, float w) {
2257
+ bool hex = u_grid > 0.5;
2258
+ bool line = u_shape > 0.5 && u_shape < 1.5;
2259
+ // Texel units -> lattice units (1 = the pitch the user set as Size).
2260
+ vec2 unit = hex ? vec2(0.5, ROW) : vec2(1.0);
2261
+ float stride = hex ? 2.0 : 1.0;
2262
+ float j0 = floor(g.y);
2263
+ float bestMask = 0.0;
2264
+ vec3 bestInk = vec3(0.0);
2265
+
2266
+ for (int dj = -1; dj <= 1; dj++) {
2267
+ float j = j0 + float(dj);
2268
+ // Hex: lattice points sit where column + row is even.
2269
+ float par = hex ? mod(j, 2.0) : 0.0;
2270
+ float dy = (g.y - (j + 0.5)) * unit.y;
2271
+
2272
+ if (line) {
2273
+ // Lines run along the lattice rows. Their width is interpolated between
2274
+ // the two lattice points that bracket this pixel, so it varies smoothly
2275
+ // instead of stepping once per cell.
2276
+ float kl = stride * floor((g.x - 0.5 - par) / stride) + par;
2277
+ float f = (g.x - 0.5 - kl) / stride;
2278
+ vec4 v = mix(lattice(vec2(kl, j)), lattice(vec2(kl + stride, j)), f);
2279
+ float swell = 1.0 + 0.25 * u_pulse * sin(w * u_time + 0.22 * (j - 0.5 * u_gridSize.y) + u_seed);
2280
+ float extent = min(0.5 * v.a * unit.y * u_radius * swell, 1.0);
2281
+ float ramp = aa + u_softness * extent;
2282
+ float mask = extent <= 0.0 ? 0.0 : clamp((extent - abs(dy)) / ramp + 0.5, 0.0, 1.0);
2283
+ if (mask > bestMask) { bestMask = mask; bestInk = v.rgb; }
2284
+ continue;
2285
+ }
2286
+
2287
+ float k0 = stride * floor((g.x - 0.5 - par) / stride + 0.5) + par;
2288
+ for (int di = -1; di <= 1; di++) {
2289
+ vec2 k = vec2(k0 + stride * float(di), j);
2290
+ vec4 v = lattice(k);
2291
+ vec2 d = vec2((g.x - (k.x + 0.5)) * unit.x, dy);
2292
+ vec2 id = k - 0.5 * u_gridSize;
2293
+ float swell = 1.0 + 0.25 * u_pulse * sin(w * u_time + dot(id * unit, vec2(0.35, 0.22)) + u_seed);
2294
+
2295
+ float dist;
2296
+ float extent;
2297
+ if (u_shape < 0.5) {
2298
+ // Dot: area pi * rho^2 out of a cell of area 1 (square) or ROW (hex).
2299
+ dist = length(d);
2300
+ extent = sqrt(v.a * (hex ? ROW : 1.0) / 3.14159265);
2301
+ } else {
2302
+ dist = max(abs(d.x), abs(d.y));
2303
+ extent = 0.5 * sqrt(v.a);
2304
+ }
2305
+ extent = min(extent * u_radius * swell, 1.0);
2306
+
2307
+ // Coverage of a straight edge by a box is linear in the distance to
2308
+ // it. Softness widens that ramp into a blur.
2309
+ float ramp = aa + u_softness * extent;
2310
+ float mask = extent <= 0.0 ? 0.0 : clamp((extent - dist) / ramp + 0.5, 0.0, 1.0);
2311
+ if (mask > bestMask) { bestMask = mask; bestInk = v.rgb; }
2312
+ }
2313
+ }
2314
+ return mix(toLinear(u_paper), toLinear(bestInk), bestMask);
2315
+ }
2316
+
2317
+ void main() {
2318
+ float pitchRef = u_grid > 0.5 ? u_cellRef.x * 2.0 : u_cellRef.x;
2319
+ // One output pixel, in lattice units.
2320
+ float px = 1.0 / (pitchRef * u_outputScale);
2321
+
2322
+ // Pulse: every shape swells and shrinks, phase-shifted across the lattice
2323
+ // so the swell travels through the frame instead of throbbing in lockstep.
2324
+ // Snapped to whole cycles per loop and floored at one: unlike a generator's
2325
+ // slow secondary swell, this is the effect's only motion, so freezing it at
2326
+ // short loops (loopFreq's choice) would read as the knob being broken.
2327
+ float w = 0.8;
2328
+ if (u_loop > 0.0) w = TAU * max(1.0, floor(w * u_loop / TAU + 0.5)) / u_loop;
2329
+
2330
+ // Supersampled: n x n samples per pixel, each ramping its edges over 1/n
2331
+ // of a pixel. One sample with a one-pixel linear ramp is exact only for a
2332
+ // straight edge parallel to the pixel grid; on a 5px preview dot every
2333
+ // edge pixel is curved and diagonal, and the error showed up as the
2334
+ // preview disagreeing with its own downscaled export. Small shapes get
2335
+ // 4x4 (they are the ones that need it, and a frame of small shapes is a
2336
+ // small frame or a cheap one); large shapes get 2x2. Averaged in linear
2337
+ // light, so fine dots in a small preview have the brightness of the export
2338
+ // seen from afar.
2339
+ float n = pitchRef * u_outputScale < 20.0 ? 4.0 : 2.0;
2340
+ vec3 acc = vec3(0.0);
2341
+ for (int sy = 0; sy < 4; sy++) {
2342
+ for (int sx = 0; sx < 4; sx++) {
2343
+ if (float(sx) >= n || float(sy) >= n) continue;
2344
+ vec2 o = (vec2(float(sx), float(sy)) + 0.5) / n - 0.5;
2345
+ acc += shade(cellCoordAt(gl_FragCoord.xy + o), px / n, w);
2346
+ }
2347
+ }
2348
+ gl_FragColor = vec4(toSrgb(acc / (n * n)), 1.0);
2349
+ }
2350
+ `;
2351
+ function cellOf$1(p) {
2352
+ const size = p.size;
2353
+ return p.grid === "hex" ? [size / 2, size * ROW] : [size, size];
2354
+ }
2355
+ const halftone = {
2356
+ id: "halftone",
2357
+ label: "Halftone",
2358
+ fragment: DRAW_FRAGMENT$1,
2359
+ grid: {
2360
+ cell: cellOf$1,
2361
+ lattice: (p) => ({ angle: p.angle }),
2362
+ fragment: CELL_FRAGMENT$1
2363
+ },
2364
+ params: [
2365
+ {
2366
+ key: "grid",
2367
+ label: "Grid",
2368
+ type: "enum",
2369
+ options: [{
2370
+ value: "square",
2371
+ label: "Square"
2372
+ }, {
2373
+ value: "hex",
2374
+ label: "Hex"
2375
+ }],
2376
+ default: "square"
2377
+ },
2378
+ {
2379
+ key: "shape",
2380
+ label: "Shape",
2381
+ type: "enum",
2382
+ options: [
2383
+ {
2384
+ value: "dot",
2385
+ label: "Dot"
2386
+ },
2387
+ {
2388
+ value: "line",
2389
+ label: "Line"
2390
+ },
2391
+ {
2392
+ value: "square",
2393
+ label: "Square"
2394
+ }
2395
+ ],
2396
+ default: "dot"
2397
+ },
2398
+ {
2399
+ key: "size",
2400
+ label: "Size",
2401
+ min: 6,
2402
+ max: 160,
2403
+ step: .5,
2404
+ default: 28
2405
+ },
2406
+ {
2407
+ key: "angle",
2408
+ label: "Angle",
2409
+ min: 0,
2410
+ max: 180,
2411
+ step: 1,
2412
+ default: 45
2413
+ },
2414
+ {
2415
+ key: "radius",
2416
+ label: "Radius",
2417
+ min: .2,
2418
+ max: 1.5,
2419
+ step: .01,
2420
+ default: 1
2421
+ },
2422
+ {
2423
+ key: "softness",
2424
+ label: "Softness",
2425
+ min: 0,
2426
+ max: 1,
2427
+ step: .01,
2428
+ default: 0
2429
+ },
2430
+ {
2431
+ key: "contrast",
2432
+ label: "Contrast",
2433
+ min: 0,
2434
+ max: 2,
2435
+ step: .01,
2436
+ default: 1
2437
+ },
2438
+ {
2439
+ key: "pulse",
2440
+ label: "Pulse",
2441
+ min: 0,
2442
+ max: 1,
2443
+ step: .01,
2444
+ default: 0
2445
+ },
2446
+ ...colorModeParams({
2447
+ mode: "duotone",
2448
+ paperAlways: true
2449
+ })
2450
+ ],
2451
+ randomParams(rand) {
2452
+ const shapes = [
2453
+ "dot",
2454
+ "dot",
2455
+ "line",
2456
+ "square"
2457
+ ];
2458
+ const modes = [
2459
+ "source",
2460
+ "duotone",
2461
+ "palette"
2462
+ ];
2463
+ return {
2464
+ grid: rand() < .5 ? "square" : "hex",
2465
+ shape: shapes[Math.floor(rand() * shapes.length)],
2466
+ size: Math.round(14 + rand() * 50),
2467
+ angle: Math.round(rand() * 180),
2468
+ radius: .8 + rand() * .5,
2469
+ softness: rand() < .7 ? 0 : rand() * .5,
2470
+ contrast: 1,
2471
+ pulse: 0,
2472
+ colorMode: modes[Math.floor(rand() * modes.length)],
2473
+ ink: "#111111",
2474
+ paper: "#f4f1ea",
2475
+ invert: false
2476
+ };
2477
+ }
2478
+ };
2479
+
2480
+ //#endregion
2481
+ //#region src/effects/asciiAtlas.ts
2482
+ /** Cell width / height. Close to a monospace font's advance at line-height 1.2. */
2483
+ const CELL_ASPECT = .6;
2484
+ /** Glyphs are measured, and by default rasterized, at this cell height. */
2485
+ const MEASURE_HEIGHT = 64;
2486
+ const MAX_GLYPH_HEIGHT = 256;
2487
+ const CHARSETS = {
2488
+ standard: " .:-=+*#%@",
2489
+ dense: " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
2490
+ minimal: " .:oO@",
2491
+ binary: " 01",
2492
+ katakana: " ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン",
2493
+ blocks: "012345"
2494
+ };
2495
+ const BLOCK_LEVELS = [
2496
+ 0,
2497
+ .12,
2498
+ .28,
2499
+ .48,
2500
+ .72,
2501
+ 1
2502
+ ];
2503
+ function cellWidth(height) {
2504
+ return Math.max(2, Math.round(height * CELL_ASPECT));
2505
+ }
2506
+ /** Font size and baseline that fit the font's full line box (not any single
2507
+ * glyph's bounding box) into a cell. One shared baseline for every glyph:
2508
+ * centring each glyph on its own bounds would float "." and "_" to mid-cell. */
2509
+ function fitFont(ctx, fontFamily, width, height) {
2510
+ ctx.font = `100px ${fontFamily}`;
2511
+ const m = ctx.measureText("M");
2512
+ const ascent = (m.fontBoundingBoxAscent || 80) / 100;
2513
+ const descent = (m.fontBoundingBoxDescent || 20) / 100;
2514
+ const advance = (m.width || 60) / 100;
2515
+ const fontPx = Math.min(height / (ascent + descent), width / advance);
2516
+ return {
2517
+ fontPx,
2518
+ baseline: (height - (ascent + descent) * fontPx) / 2 + ascent * fontPx
2519
+ };
2520
+ }
2521
+ /** Draws one glyph, white on transparent, into the cell at (x, y). A glyph
2522
+ * wider than the cell (a fallback font for katakana, say) is squeezed to fit
2523
+ * rather than clipped. */
2524
+ function drawGlyph(ctx, info, index, x, y, w, h, metrics) {
2525
+ ctx.fillStyle = "#ffffff";
2526
+ if (info.procedural) {
2527
+ const k = Math.sqrt(BLOCK_LEVELS[index]);
2528
+ ctx.fillRect(x + w * (1 - k) / 2, y + h * (1 - k) / 2, w * k, h * k);
2529
+ return;
2530
+ }
2531
+ const ch = info.glyphs[index];
2532
+ const advance = ctx.measureText(ch).width;
2533
+ ctx.save();
2534
+ ctx.beginPath();
2535
+ ctx.rect(x, y, w, h);
2536
+ ctx.clip();
2537
+ ctx.translate(x + w / 2, y + metrics.baseline);
2538
+ if (advance > w) ctx.scale(w / advance, 1);
2539
+ ctx.textAlign = "center";
2540
+ ctx.textBaseline = "alphabetic";
2541
+ ctx.fillText(ch, 0, 0);
2542
+ ctx.restore();
2543
+ }
2544
+ /** A transparent canvas. Text drawn on an OPAQUE canvas can get LCD subpixel
2545
+ * antialiasing, which would bake color fringes into the atlas; on a
2546
+ * transparent one browsers fall back to grayscale, and we read alpha only. */
2547
+ function makeCanvas(width, height) {
2548
+ const canvas = document.createElement("canvas");
2549
+ canvas.width = width;
2550
+ canvas.height = height;
2551
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
2552
+ if (!ctx) throw new Error("[instantshader] 2D context unavailable for the ASCII atlas");
2553
+ return [canvas, ctx];
2554
+ }
2555
+ /** Sorts by coverage, drops near-duplicates, normalizes to the densest.
2556
+ * Exported for tests. */
2557
+ function orderByCoverage(glyphs, raw) {
2558
+ const order = glyphs.map((g, i) => ({
2559
+ g,
2560
+ c: raw[i]
2561
+ })).sort((a, b) => a.c - b.c);
2562
+ const max = Math.max(order[order.length - 1]?.c ?? 0, 1e-6);
2563
+ const out = [];
2564
+ for (const e of order) {
2565
+ const c = e.c / max;
2566
+ if (out.length > 0 && c - out[out.length - 1].c < .004) continue;
2567
+ out.push({
2568
+ g: e.g,
2569
+ c
2570
+ });
2571
+ }
2572
+ return {
2573
+ glyphs: out.map((e) => e.g),
2574
+ coverage: out.map((e) => e.c)
2575
+ };
2576
+ }
2577
+ const infoCache = /* @__PURE__ */ new Map();
2578
+ /** The charset's glyphs ordered by measured ink coverage. Cached per
2579
+ * charset + font: this is the frozen ordering the spec asks for. */
2580
+ function charsetInfo(charset, fontFamily) {
2581
+ const key = `${charset}|${fontFamily}`;
2582
+ const hit = infoCache.get(key);
2583
+ if (hit) return hit;
2584
+ let info;
2585
+ if (charset === "blocks") info = {
2586
+ glyphs: CHARSETS.blocks.split(""),
2587
+ coverage: BLOCK_LEVELS.slice(),
2588
+ procedural: true
2589
+ };
2590
+ else {
2591
+ const chars = Array.from(new Set(Array.from(CHARSETS[charset] ?? CHARSETS.standard)));
2592
+ const h = MEASURE_HEIGHT;
2593
+ const w = cellWidth(h);
2594
+ const [, ctx] = makeCanvas(w, h);
2595
+ const metrics = fitFont(ctx, fontFamily, w, h);
2596
+ ctx.font = `${metrics.fontPx}px ${fontFamily}`;
2597
+ const probe = {
2598
+ glyphs: chars,
2599
+ coverage: [],
2600
+ procedural: false
2601
+ };
2602
+ const alphaOf = (index) => {
2603
+ ctx.clearRect(0, 0, w, h);
2604
+ drawGlyph(ctx, probe, index, 0, 0, w, h, metrics);
2605
+ const rgba = ctx.getImageData(0, 0, w, h).data;
2606
+ const a = new Uint8ClampedArray(w * h);
2607
+ for (let i = 0; i < a.length; i++) a[i] = rgba[i * 4 + 3];
2608
+ return a;
2609
+ };
2610
+ probe.glyphs = [...chars, "￿"];
2611
+ const tofu = alphaOf(chars.length);
2612
+ probe.glyphs = chars;
2613
+ const kept = [];
2614
+ const raw = [];
2615
+ chars.forEach((ch, i) => {
2616
+ const a = alphaOf(i);
2617
+ let sum = 0;
2618
+ let same = true;
2619
+ for (let p = 0; p < a.length; p++) {
2620
+ sum += a[p];
2621
+ if (a[p] !== tofu[p]) same = false;
2622
+ }
2623
+ if (same && ch !== " " && sum > 0) return;
2624
+ kept.push(ch);
2625
+ raw.push(sum / (255 * a.length));
2626
+ });
2627
+ if (kept.length < 2) info = charset === "standard" ? {
2628
+ glyphs: [" ", "#"],
2629
+ coverage: [0, 1],
2630
+ procedural: false
2631
+ } : charsetInfo("standard", fontFamily);
2632
+ else info = {
2633
+ ...orderByCoverage(kept, raw),
2634
+ procedural: false
2635
+ };
2636
+ }
2637
+ infoCache.set(key, info);
2638
+ return info;
2639
+ }
2640
+ /**
2641
+ * The tone -> glyph lookup: 256x1 RGBA, one texel per 8-bit tone.
2642
+ * R = index of the densest glyph not denser than the tone, G = the next
2643
+ * glyph up, B = how far the tone sits between the two. Placing glyphs by
2644
+ * their measured coverage, not by their position in the list, is what keeps
2645
+ * tone even when a ramp's steps are not. Exported for tests.
2646
+ */
2647
+ function toneLookup(coverage) {
2648
+ const out = new Uint8Array(256 * 4);
2649
+ const last = coverage.length - 1;
2650
+ let lo = 0;
2651
+ for (let i = 0; i < 256; i++) {
2652
+ const v = i / 255;
2653
+ while (lo < last && coverage[lo + 1] <= v) lo++;
2654
+ const hi = Math.min(lo + 1, last);
2655
+ const span = coverage[hi] - coverage[lo];
2656
+ const f = span > 0 ? Math.min(1, Math.max(0, (v - coverage[lo]) / span)) : 0;
2657
+ out[i * 4] = lo;
2658
+ out[i * 4 + 1] = hi;
2659
+ out[i * 4 + 2] = Math.round(f * 255);
2660
+ out[i * 4 + 3] = 255;
2661
+ }
2662
+ return out;
2663
+ }
2664
+ /** Height glyphs are rasterized at for a given output cell height. Cells
2665
+ * under 64px all share the 64px atlas and the shader minifies it with a box
2666
+ * of taps, so a small preview shows the SAME letterforms as the export,
2667
+ * filtered, instead of separately hinted 11px ones. */
2668
+ function atlasGlyphHeight(cellPxHeight) {
2669
+ if (cellPxHeight <= MEASURE_HEIGHT) return MEASURE_HEIGHT;
2670
+ return Math.min(MAX_GLYPH_HEIGHT, Math.round(cellPxHeight));
2671
+ }
2672
+ function buildAtlas(charset, fontFamily, glyphHeight$1) {
2673
+ const info = charsetInfo(charset, fontFamily);
2674
+ const count = info.glyphs.length;
2675
+ const gh = glyphHeight$1;
2676
+ const gw = cellWidth(gh);
2677
+ const cw = gw + 2;
2678
+ const chh = gh + 2;
2679
+ const cols = Math.ceil(Math.sqrt(count));
2680
+ const rows = Math.ceil(count / cols);
2681
+ const [canvas, ctx] = makeCanvas(cols * cw, rows * chh);
2682
+ const metrics = fitFont(ctx, fontFamily, gw, gh);
2683
+ ctx.font = `${metrics.fontPx}px ${fontFamily}`;
2684
+ for (let i = 0; i < count; i++) {
2685
+ const x = i % cols * cw + 1;
2686
+ const y = Math.floor(i / cols) * chh + 1;
2687
+ drawGlyph(ctx, info, i, x, y, gw, gh, metrics);
2688
+ }
2689
+ return {
2690
+ source: canvas,
2691
+ filter: "linear",
2692
+ wrap: "clamp",
2693
+ uniforms: {
2694
+ u_atlasGrid: [cols, rows],
2695
+ u_atlasSize: [canvas.width, canvas.height],
2696
+ u_glyphPx: [gw, gh],
2697
+ u_glyphCount: count
2698
+ }
2699
+ };
2700
+ }
2701
+
2702
+ //#endregion
2703
+ //#region src/effects/ascii.ts
2704
+ const CELL_FRAGMENT = `
2705
+ uniform float u_smooth;
2706
+ uniform float u_cycle;
2707
+ uniform float u_cycleAmount;
2708
+ uniform float u_glyphCount;
2709
+ uniform sampler2D u_lut;
2710
+ ${COLOR_MODE}
2711
+
2712
+ void main() {
2713
+ vec3 sc = cellSource().rgb;
2714
+ float tone = luma(sc);
2715
+ if (u_invert > 0.5) tone = 1.0 - tone;
2716
+
2717
+ // The lookup places glyphs by measured ink coverage: R = the glyph just
2718
+ // below this tone, G = the one just above, B = how far between them.
2719
+ vec4 l = texture2D(u_lut, vec2((floor(tone * 255.0 + 0.5) + 0.5) / 256.0, 0.5));
2720
+ float lo = floor(l.r * 255.0 + 0.5);
2721
+ float hi = floor(l.g * 255.0 + 0.5);
2722
+ // Smooth: dither between the two neighbouring glyphs, so a gradient does
2723
+ // not band into flat fields of one character. The threshold is a per-cell
2724
+ // hash, not a Bayer matrix: with only ~10 glyphs an ordered pattern shows
2725
+ // up as mechanical "%=%=%=" stripes, where noise reads as hand-set type.
2726
+ float t = u_smooth > 0.5 ? 0.02 + 0.96 * hash12(cellIndex() + 0.5) : 0.5;
2727
+ float glyph = l.b > t ? hi : lo;
2728
+
2729
+ // Cycle: every step each cell re-rolls its glyph among neighbours of
2730
+ // similar weight, so the picture flickers but stays readable. Blank cells
2731
+ // stay blank, or dark areas would fill with sparks.
2732
+ if (u_cycle > 0.0 && glyph > 0.5) {
2733
+ float h = hash12(cellIndex() + vec2(37.0, 17.0) * loopStep(u_cycle) + u_seed);
2734
+ float span = u_cycleAmount * u_glyphCount * 0.25;
2735
+ glyph = clamp(glyph + floor((h * 2.0 - 1.0) * span + 0.5), 1.0, u_glyphCount - 1.0);
2736
+ }
2737
+
2738
+ // Glyph color. Tone already lives in the glyph's density, so source colors
2739
+ // are lifted most of the way to full brightness; using them as they are
2740
+ // would darken every dark area twice.
2741
+ vec3 color = sc;
2742
+ if (u_colorMode < 0.5) {
2743
+ float peak = max(max(sc.r, sc.g), max(sc.b, 1e-3));
2744
+ color = mix(sc, sc / peak, 0.75);
2745
+ } else if (u_colorMode < 1.5) {
2746
+ color = u_ink;
2747
+ } else {
2748
+ color = palette(toneIsRamp() ? rampPosition(sc) : luma(sc));
2749
+ }
2750
+
2751
+ gl_FragColor = vec4(color, glyph / 255.0);
2752
+ }
2753
+ `;
2754
+ const DRAW_FRAGMENT = `
2755
+ uniform sampler2D u_atlas;
2756
+ uniform vec2 u_atlasGrid; // glyph columns, rows
2757
+ uniform vec2 u_atlasSize; // atlas size in texels
2758
+ uniform vec2 u_glyphPx; // one glyph's size in atlas texels (without gutter)
2759
+ ${COLOR_MODE}
2760
+
2761
+ void main() {
2762
+ vec4 cell = cellValue(cellIndexAt());
2763
+ float glyph = floor(cell.a * 255.0 + 0.5);
2764
+ vec2 slot = vec2(mod(glyph, u_atlasGrid.x), floor(glyph / u_atlasGrid.x));
2765
+ // Top-left texel of the glyph, inside its one-texel gutter. The atlas is a
2766
+ // canvas uploaded unflipped, so its row 0 is the top.
2767
+ vec2 origin = slot * (u_glyphPx + 2.0) + 1.0;
2768
+
2769
+ // The atlas is rasterized at 64px or more and minified here by a box of
2770
+ // LINEAR taps sized to this output pixel's footprint: a small preview sees
2771
+ // the same letterforms as the export, filtered, rather than separately
2772
+ // hinted tiny ones. One tap per atlas texel the pixel spans, up to 6x6.
2773
+ vec2 local = cellLocal();
2774
+ vec2 texelsPerPx = u_glyphPx / cellPx();
2775
+ float n = clamp(ceil(max(texelsPerPx.x, texelsPerPx.y)), 1.0, 6.0);
2776
+ float a = 0.0;
2777
+ for (int j = 0; j < 6; j++) {
2778
+ for (int i = 0; i < 6; i++) {
2779
+ if (float(i) >= n || float(j) >= n) continue;
2780
+ vec2 o = ((vec2(float(i), float(j)) + 0.5) / n - 0.5) / cellPx();
2781
+ vec2 q = clamp(local + o, 0.0, 1.0);
2782
+ vec2 texel = origin + vec2(q.x, 1.0 - q.y) * u_glyphPx;
2783
+ a += texture2D(u_atlas, texel / u_atlasSize).a;
2784
+ }
2785
+ }
2786
+ a /= n * n;
2787
+
2788
+ vec3 color = toSrgb(mix(toLinear(u_paper), toLinear(cell.rgb), a));
2789
+ gl_FragColor = vec4(color, 1.0);
2790
+ }
2791
+ `;
2792
+ function cellOf(p) {
2793
+ const size = p.size;
2794
+ return [Math.max(1, Math.round(size * CELL_ASPECT)), size];
2795
+ }
2796
+ function glyphHeight(env) {
2797
+ return atlasGlyphHeight(cellOf(env.params)[1] * env.outputScale);
2798
+ }
2799
+ const ascii = {
2800
+ id: "ascii",
2801
+ label: "ASCII",
2802
+ fragment: DRAW_FRAGMENT,
2803
+ grid: {
2804
+ cell: cellOf,
2805
+ fragment: CELL_FRAGMENT
2806
+ },
2807
+ textures: [{
2808
+ uniform: "u_atlas",
2809
+ key: (env) => `${env.params.charset}|${env.fontFamily}|${glyphHeight(env)}`,
2810
+ build: (env) => buildAtlas(env.params.charset, env.fontFamily, glyphHeight(env))
2811
+ }, {
2812
+ uniform: "u_lut",
2813
+ key: (env) => `${env.params.charset}|${env.fontFamily}`,
2814
+ build: (env) => ({
2815
+ source: {
2816
+ data: toneLookup(charsetInfo(env.params.charset, env.fontFamily).coverage),
2817
+ width: 256,
2818
+ height: 1
2819
+ },
2820
+ filter: "nearest",
2821
+ wrap: "clamp"
2822
+ })
2823
+ }],
2824
+ params: [
2825
+ {
2826
+ key: "charset",
2827
+ label: "Characters",
2828
+ type: "enum",
2829
+ options: [
2830
+ {
2831
+ value: "standard",
2832
+ label: "Standard"
2833
+ },
2834
+ {
2835
+ value: "dense",
2836
+ label: "Dense"
2837
+ },
2838
+ {
2839
+ value: "blocks",
2840
+ label: "Blocks"
2841
+ },
2842
+ {
2843
+ value: "minimal",
2844
+ label: "Minimal"
2845
+ },
2846
+ {
2847
+ value: "binary",
2848
+ label: "Binary"
2849
+ },
2850
+ {
2851
+ value: "katakana",
2852
+ label: "Katakana"
2853
+ }
2854
+ ],
2855
+ default: "standard"
2856
+ },
2857
+ {
2858
+ key: "size",
2859
+ label: "Size",
2860
+ min: 8,
2861
+ max: 96,
2862
+ step: 1,
2863
+ default: 24
2864
+ },
2865
+ {
2866
+ key: "smooth",
2867
+ label: "Smooth",
2868
+ type: "bool",
2869
+ default: true
2870
+ },
2871
+ {
2872
+ key: "cycle",
2873
+ label: "Cycle",
2874
+ min: 0,
2875
+ max: 12,
2876
+ step: 1,
2877
+ default: 0
2878
+ },
2879
+ {
2880
+ key: "cycleAmount",
2881
+ label: "Cycle amount",
2882
+ min: 0,
2883
+ max: 1,
2884
+ step: .01,
2885
+ default: .3
2886
+ },
2887
+ ...colorModeParams({
2888
+ mode: "source",
2889
+ ink: "#e8ffe8",
2890
+ paper: "#000000",
2891
+ paperAlways: true
2892
+ })
2893
+ ],
2894
+ randomParams(rand) {
2895
+ const sets = [
2896
+ "standard",
2897
+ "dense",
2898
+ "blocks",
2899
+ "minimal",
2900
+ "binary"
2901
+ ];
2902
+ const modes = [
2903
+ "source",
2904
+ "duotone",
2905
+ "palette"
2906
+ ];
2907
+ return {
2908
+ charset: sets[Math.floor(rand() * sets.length)],
2909
+ size: Math.round(14 + rand() * 30),
2910
+ smooth: true,
2911
+ cycle: 0,
2912
+ cycleAmount: .3,
2913
+ colorMode: modes[Math.floor(rand() * modes.length)],
2914
+ ink: "#e8ffe8",
2915
+ paper: "#000000",
2916
+ invert: false
2917
+ };
2918
+ }
2919
+ };
2920
+
2921
+ //#endregion
2922
+ //#region src/registry.ts
2923
+ const shaders = [
2924
+ flow,
2925
+ beam,
2926
+ bloom,
2927
+ halo,
2928
+ strata,
2929
+ dune,
2930
+ whorl
2931
+ ];
2932
+ function getShader(id) {
2933
+ return shaders.find((s) => s.id === id);
2934
+ }
2935
+
2936
+ //#endregion
2937
+ //#region src/effectRegistry.ts
2938
+ const effects = [
2939
+ pixelate,
2940
+ dither,
2941
+ halftone,
2942
+ ascii
2943
+ ];
2944
+ function getEffect(id) {
2945
+ return effects.find((e) => e.id === id);
2946
+ }
2947
+
2948
+ //#endregion
2949
+ //#region src/palette.ts
2950
+ /** Number of texels in the output ramp. Matches a typical 1D LUT texture size:
2951
+ * large enough that per-texel banding is invisible, small enough to upload
2952
+ * as a single texture row every time colors change. */
2953
+ const RAMP_SIZE = 1024;
2954
+ /**
2955
+ * Converts one sRGB channel (0-1, gamma-encoded) to linear light.
2956
+ * Piecewise per the sRGB spec: a linear segment near black avoids the
2957
+ * infinite slope a pure power curve would have at 0.
2958
+ */
2959
+ function linearize(c) {
2960
+ return c <= .04045 ? c / 12.92 : Math.pow((c + .055) / 1.055, 2.4);
2961
+ }
2962
+ /** Inverse of linearize(): linear light back to gamma-encoded sRGB (0-1). */
2963
+ function delinearize(c) {
2964
+ return c <= .0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - .055;
2965
+ }
2966
+ /**
2967
+ * sRGB -> OKLab, Björn Ottosson's method (https://bottosson.github.io/posts/oklab/).
2968
+ * Coefficients copied verbatim from that reference implementation —
2969
+ * do not hand-retype these, they are fitted constants, not derivable values.
2970
+ *
2971
+ * The two 3x3 matrices convert linear sRGB to an LMS-like cone response
2972
+ * space and then to the final Lab-like OKLab space; the cube root in
2973
+ * between is what makes the space perceptually uniform.
2974
+ */
2975
+ function rgbToOklab(r, g, b) {
2976
+ const lr = linearize(r), lg = linearize(g), lb = linearize(b);
2977
+ const l_ = Math.cbrt(.4122214708 * lr + .5363325363 * lg + .0514459929 * lb);
2978
+ const m_ = Math.cbrt(.2119034982 * lr + .6806995451 * lg + .1073969566 * lb);
2979
+ const s_ = Math.cbrt(.0883024619 * lr + .2817188376 * lg + .6299787005 * lb);
2980
+ return [
2981
+ .2104542553 * l_ + .793617785 * m_ - .0040720468 * s_,
2982
+ 1.9779984951 * l_ - 2.428592205 * m_ + .4505937099 * s_,
2983
+ .0259040371 * l_ + .7827717662 * m_ - .808675766 * s_
2984
+ ];
2985
+ }
2986
+ /**
2987
+ * OKLab -> sRGB, inverse of rgbToOklab. Same fitted-constant caveat as
2988
+ * above — do not hand-retype these coefficients.
2989
+ */
2990
+ function oklabToRgb(L, a, b) {
2991
+ const l_ = L + .3963377774 * a + .2158037573 * b;
2992
+ const m_ = L - .1055613458 * a - .0638541728 * b;
2993
+ const s_ = L - .0894841775 * a - 1.291485548 * b;
2994
+ const l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
2995
+ return [
2996
+ delinearize(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s),
2997
+ delinearize(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s),
2998
+ delinearize(-.0041960863 * l - .7034186147 * m + 1.707614701 * s)
2999
+ ];
3000
+ }
3001
+ /**
3002
+ * OKLab -> OKLCh: rectangular (L, a, b) to polar (L, C, h). C is just the
3003
+ * distance of (a, b) from the achromatic origin; h is the angle, in
3004
+ * radians, measured the same way atan2 always does (range (-pi, pi]).
3005
+ */
3006
+ function oklabToOklch(L, a, b) {
3007
+ return [
3008
+ L,
3009
+ Math.hypot(a, b),
3010
+ Math.atan2(b, a)
3011
+ ];
3012
+ }
3013
+ /**
3014
+ * OKLCh -> OKLab: polar back to rectangular, so the existing oklabToRgb
3015
+ * matrices can be reused unchanged instead of duplicating the LMS math for
3016
+ * a polar input.
3017
+ */
3018
+ function oklchToOklab(L, C, h) {
3019
+ return [
3020
+ L,
3021
+ C * Math.cos(h),
3022
+ C * Math.sin(h)
3023
+ ];
3024
+ }
3025
+ /**
3026
+ * Chroma below which a hue angle is noise rather than signal. Near a=b=0,
3027
+ * atan2 is numerically unstable (its output can swing wildly for a tiny
3028
+ * change in a near-zero a or b), and perceptually a near-gray stop simply
3029
+ * doesn't have a hue to speak of. Below this threshold a stop is treated as
3030
+ * achromatic for interpolation purposes: see lerpHue().
3031
+ *
3032
+ * ~0.005 in OKLCh chroma units is comfortably below any stop actually used
3033
+ * for color (the lab palettes' least-saturated non-neutral stops sit at
3034
+ * 0.075+) and comfortably above float rounding noise from the hex -> OKLab
3035
+ * round trip.
3036
+ */
3037
+ const ACHROMATIC_CHROMA = .005;
3038
+ const TAU = Math.PI * 2;
3039
+ /**
3040
+ * Interpolates hue from h0 toward h1 by fraction t, taking the SHORTER way
3041
+ * around the circle (e.g. 350deg -> 10deg moves +20deg through 0, not -340deg
3042
+ * backward through 180deg) instead of a plain linear lerp, which would sweep
3043
+ * through every intermediate hue on the long way round.
3044
+ *
3045
+ * Two edge cases handled explicitly:
3046
+ *
3047
+ * - Achromatic endpoint: if one side's chroma is below ACHROMATIC_CHROMA,
3048
+ * its "hue" is meaningless (see the constant's comment), so it inherits
3049
+ * the other endpoint's hue instead of contributing its own noisy angle.
3050
+ * Concretely this means a gray-to-color segment holds a constant hue
3051
+ * while only chroma ramps up, rather than spiraling through unrelated
3052
+ * hues the gray's atan2 noise happened to produce. If both endpoints are
3053
+ * achromatic the chosen hue is never visible: C stays near zero for the
3054
+ * whole segment (it's lerped independently, from ~0 to ~0), so whatever
3055
+ * angle comes out of cos/sin gets multiplied by ~0.
3056
+ * - Exact opposition (180 degrees apart): both directions around the circle
3057
+ * are equally short, so "shorter arc" is ambiguous. The wrap below always
3058
+ * normalizes a signed delta of exactly +-pi to -pi (see the comment on
3059
+ * the wrap), which picks one direction consistently rather than depending
1050
3060
  * on float rounding to break the tie.
1051
3061
  */
1052
3062
  function lerpHue(h0, c0, h1, c1, t) {
@@ -1056,7 +3066,7 @@ function lerpHue(h0, c0, h1, c1, t) {
1056
3066
  return start + delta * t;
1057
3067
  }
1058
3068
  /** Parses a "#rrggbb" (or "rrggbb") hex string into 0-1 sRGB components. */
1059
- function hexToRgb(hex) {
3069
+ function hexToRgb$1(hex) {
1060
3070
  const h = hex.replace("#", "");
1061
3071
  return [
1062
3072
  parseInt(h.substring(0, 2), 16) / 255,
@@ -1082,7 +3092,7 @@ function buildPaletteRamp(colors) {
1082
3092
  const stops = colors.length === 1 ? [colors[0], colors[0]] : colors;
1083
3093
  const stopCount = stops.length;
1084
3094
  const stopsOklch = stops.map((hex) => {
1085
- const [r, g, b] = hexToRgb(hex);
3095
+ const [r, g, b] = hexToRgb$1(hex);
1086
3096
  return oklabToOklch(...rgbToOklab(r, g, b));
1087
3097
  });
1088
3098
  const breakpoints = [];
@@ -1115,7 +3125,7 @@ function clamp255(v) {
1115
3125
  }
1116
3126
 
1117
3127
  //#endregion
1118
- //#region src/renderer.ts
3128
+ //#region src/preamble.ts
1119
3129
  /**
1120
3130
  * GLSL preamble prepended to every shader's fragment source. Declares the
1121
3131
  * uniforms/varying every InstantShader shader can rely on, plus two helpers:
@@ -1231,35 +3241,29 @@ float loopFreq(float w) {
1231
3241
  }
1232
3242
  `;
1233
3243
  /** Fullscreen-triangle-strip vertex shader. Four vertices covering [-1,1]^2,
1234
- * with v_uv carrying the matching 0-1 UV for the fragment shader. */
3244
+ * with v_uv carrying the matching 0-1 UV for the fragment shader.
3245
+ *
3246
+ * v_uv = u_uvMatrix * a_position + u_uvOffset. For every ordinary pass that
3247
+ * is (0.5, 0, 0, 0.5) and (0.5, 0.5), i.e. exactly the a_position * 0.5 + 0.5
3248
+ * this shader always computed. The stack changes it only while rendering a
3249
+ * source straight into a grid effect's cell buffer: that grid can overhang
3250
+ * the frame (partial cells at the edges) or be rotated (a halftone lattice),
3251
+ * and each cell has to see its own centre as v_uv. See gridUvTransform(). */
1235
3252
  const VERTEX_SHADER = `attribute vec2 a_position;
3253
+ uniform vec4 u_uvMatrix;
3254
+ uniform vec2 u_uvOffset;
1236
3255
  varying vec2 v_uv;
1237
3256
  void main() {
1238
- v_uv = a_position * 0.5 + 0.5;
3257
+ v_uv = mat2(u_uvMatrix.xy, u_uvMatrix.zw) * a_position + u_uvOffset;
1239
3258
  gl_Position = vec4(a_position, 0.0, 1.0);
1240
3259
  }
1241
3260
  `;
1242
- const QUAD_VERTICES = new Float32Array([
1243
- -1,
1244
- -1,
1245
- 1,
1246
- -1,
1247
- -1,
1248
- 1,
1249
- 1,
1250
- 1
1251
- ]);
1252
- /** Non-negative modulo. JS's `%` is a remainder operator, not a mathematical
1253
- * mod — `-5 % 100` is `-5`, not `95`. u_time/u_seed are documented to land
1254
- * in [0, m), so a negative timeMs (e.g. from an out-of-range seek) or a
1255
- * negative seed must still floor into that range rather than going negative
1256
- * on the GPU. */
1257
- function floorMod(value, m) {
1258
- return (value % m + m) % m;
1259
- }
3261
+
3262
+ //#endregion
3263
+ //#region src/gl.ts
1260
3264
  /** Compiles one shader stage, logging the info log and throwing on failure
1261
- * so a broken ShaderDef fails loudly at mount time instead of rendering a
1262
- * blank canvas. */
3265
+ * so a broken def fails loudly at mount time instead of rendering a blank
3266
+ * canvas. */
1263
3267
  function compileShader(gl, type, source) {
1264
3268
  const shader = gl.createShader(type);
1265
3269
  if (!shader) throw new Error("[instantshader] gl.createShader returned null");
@@ -1280,6 +3284,7 @@ function linkProgram(gl, vertexShader, fragmentShader) {
1280
3284
  if (!program) throw new Error("[instantshader] gl.createProgram returned null");
1281
3285
  gl.attachShader(program, vertexShader);
1282
3286
  gl.attachShader(program, fragmentShader);
3287
+ gl.bindAttribLocation(program, 0, "a_position");
1283
3288
  gl.linkProgram(program);
1284
3289
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1285
3290
  const info = gl.getProgramInfoLog(program);
@@ -1291,6 +3296,323 @@ function linkProgram(gl, vertexShader, fragmentShader) {
1291
3296
  }
1292
3297
  return program;
1293
3298
  }
3299
+ function createProgram(gl, vertexSource, fragmentSource) {
3300
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
3301
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
3302
+ const program = linkProgram(gl, vs, fs);
3303
+ gl.deleteShader(vs);
3304
+ gl.deleteShader(fs);
3305
+ const locs = /* @__PURE__ */ new Map();
3306
+ return {
3307
+ program,
3308
+ loc(name) {
3309
+ let l = locs.get(name);
3310
+ if (l === void 0) {
3311
+ l = gl.getUniformLocation(program, name);
3312
+ locs.set(name, l);
3313
+ }
3314
+ return l;
3315
+ },
3316
+ dispose() {
3317
+ gl.deleteProgram(program);
3318
+ }
3319
+ };
3320
+ }
3321
+ /**
3322
+ * Creates an RGBA8 render target. RGBA8 is the one format WebGL1 guarantees
3323
+ * is renderable. NPOT sizes are the norm here, which WebGL1 only allows with
3324
+ * CLAMP_TO_EDGE and no mipmaps.
3325
+ */
3326
+ function createTarget(gl, width, height, filter) {
3327
+ const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);
3328
+ const maxRb = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE);
3329
+ const limit = Math.min(maxTex, maxRb);
3330
+ if (width > limit || height > limit) throw new Error(`[instantshader] a ${width}x${height} render target exceeds this GPU's limit of ${limit}px (MAX_TEXTURE_SIZE ${maxTex}, MAX_RENDERBUFFER_SIZE ${maxRb})`);
3331
+ const texture = gl.createTexture();
3332
+ const framebuffer = gl.createFramebuffer();
3333
+ if (!texture || !framebuffer) throw new Error("[instantshader] failed to allocate a render target");
3334
+ const f = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
3335
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3336
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3337
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3338
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, f);
3339
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, f);
3340
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
3341
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
3342
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
3343
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
3344
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
3345
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
3346
+ gl.deleteTexture(texture);
3347
+ gl.deleteFramebuffer(framebuffer);
3348
+ throw new Error(`[instantshader] ${width}x${height} framebuffer incomplete (status 0x${status.toString(16)})`);
3349
+ }
3350
+ return {
3351
+ texture,
3352
+ framebuffer,
3353
+ width,
3354
+ height
3355
+ };
3356
+ }
3357
+ function deleteTarget(gl, target) {
3358
+ if (!target) return;
3359
+ gl.deleteTexture(target.texture);
3360
+ gl.deleteFramebuffer(target.framebuffer);
3361
+ }
3362
+
3363
+ //#endregion
3364
+ //#region src/grid.ts
3365
+ const REF_WIDTH = 1920;
3366
+ const REF_HEIGHT = 1080;
3367
+ /** Device pixels per reference pixel. 1 at 1920x1080, 2 at 3840x2160. */
3368
+ function outputScale(width, height) {
3369
+ return Math.max(width / REF_WIDTH, height / REF_HEIGHT);
3370
+ }
3371
+ /**
3372
+ * The frame's size in reference pixels. The dominant axis is returned as the
3373
+ * exact constant rather than as width / (width / 1920): that round trip can
3374
+ * land on 1919.9999999 or 1920.0000001, and gridFor() takes a ceil of it.
3375
+ */
3376
+ function refSize(width, height) {
3377
+ if (width * REF_HEIGHT >= height * REF_WIDTH) return [REF_WIDTH, height * REF_WIDTH / width];
3378
+ return [width * REF_HEIGHT / height, REF_HEIGHT];
3379
+ }
3380
+ /**
3381
+ * The grid a cell size produces on a frame. Centred on the frame with a cell
3382
+ * boundary at the centre, so cols and rows are always even and a different
3383
+ * aspect ratio crops the grid evenly. Depends on the frame's aspect ratio
3384
+ * and the cell size only, never on its pixel dimensions.
3385
+ *
3386
+ * Two flavours:
3387
+ *
3388
+ * - default: cells are whole reference pixels and axis-aligned, so that
3389
+ * 1080p-class and 4K-class exports get whole output pixels per cell.
3390
+ * - `lattice`: for effects that draw soft, antialiased shapes (halftone).
3391
+ * The grid may be rotated and its pitch need not be whole, since nothing
3392
+ * snaps to pixels; it is sized to cover the rotated frame plus a margin of
3393
+ * two cells, because a shape can spill into its neighbours' cells.
3394
+ */
3395
+ function gridFor(width, height, cell, lattice) {
3396
+ const ref = refSize(width, height);
3397
+ const s = outputScale(width, height);
3398
+ if (!lattice) {
3399
+ const nx$1 = Math.max(1, Math.round(cell[0]));
3400
+ const ny$1 = Math.max(1, Math.round(cell[1]));
3401
+ const cols$1 = 2 * Math.max(1, Math.ceil(ref[0] / (2 * nx$1) - 1e-6));
3402
+ const rows$1 = 2 * Math.max(1, Math.ceil(ref[1] / (2 * ny$1) - 1e-6));
3403
+ return {
3404
+ cols: cols$1,
3405
+ rows: rows$1,
3406
+ cell: [nx$1, ny$1],
3407
+ ref,
3408
+ pxPerCell: [nx$1 * s, ny$1 * s],
3409
+ angle: 0,
3410
+ extent: [cols$1 * nx$1 / ref[0], rows$1 * ny$1 / ref[1]]
3411
+ };
3412
+ }
3413
+ const nx = Math.max(.5, cell[0]);
3414
+ const ny = Math.max(.5, cell[1]);
3415
+ const angle = lattice.angle * Math.PI / 180;
3416
+ const c = Math.abs(Math.cos(angle));
3417
+ const sn = Math.abs(Math.sin(angle));
3418
+ const hx = (c * ref[0] + sn * ref[1]) / 2;
3419
+ const hy = (sn * ref[0] + c * ref[1]) / 2;
3420
+ const cols = 2 * (Math.ceil(hx / nx - 1e-6) + 2);
3421
+ const rows = 2 * (Math.ceil(hy / ny - 1e-6) + 2);
3422
+ return {
3423
+ cols,
3424
+ rows,
3425
+ cell: [nx, ny],
3426
+ ref,
3427
+ pxPerCell: [nx * s, ny * s],
3428
+ angle,
3429
+ extent: [cols * nx / ref[0], rows * ny / ref[1]]
3430
+ };
3431
+ }
3432
+ /**
3433
+ * How the vertex shader must map the fullscreen quad so that, while rendering
3434
+ * INTO a grid's cell buffer, v_uv is each cell's centre in 0-1 frame UV:
3435
+ * v_uv = matrix * a_position + offset, with a_position in [-1,1]^2.
3436
+ * `cellU` / `cellV` are one cell's edge vectors in frame UV, for passes that
3437
+ * spread taps across the cell.
3438
+ */
3439
+ function gridUvTransform(grid) {
3440
+ const c = Math.cos(grid.angle);
3441
+ const s = Math.sin(grid.angle);
3442
+ const [nx, ny] = grid.cell;
3443
+ const [rx, ry] = grid.ref;
3444
+ const hx = grid.cols * nx / 2;
3445
+ const hy = grid.rows * ny / 2;
3446
+ return {
3447
+ matrix: [
3448
+ c * hx / rx,
3449
+ s * hx / ry,
3450
+ -s * hy / rx,
3451
+ c * hy / ry
3452
+ ],
3453
+ offset: [.5, .5],
3454
+ cellU: [c * nx / rx, s * nx / ry],
3455
+ cellV: [-s * ny / rx, c * ny / ry]
3456
+ };
3457
+ }
3458
+
3459
+ //#endregion
3460
+ //#region src/effectParams.ts
3461
+ const warned = /* @__PURE__ */ new Set();
3462
+ /** Logs once per distinct problem. A bad value arrives on every slider
3463
+ * drag and every frame otherwise. */
3464
+ function warnOnce(message) {
3465
+ if (warned.has(message)) return;
3466
+ warned.add(message);
3467
+ console.warn(`[instantshader] ${message}`);
3468
+ }
3469
+ const HEX = /^#?[0-9a-fA-F]{6}$/;
3470
+ /** "#rrggbb" to gamma-encoded sRGB in 0-1, or null when malformed. */
3471
+ function hexToRgb(hex) {
3472
+ if (!HEX.test(hex)) return null;
3473
+ const h = hex.replace("#", "");
3474
+ return [
3475
+ parseInt(h.substring(0, 2), 16) / 255,
3476
+ parseInt(h.substring(2, 4), 16) / 255,
3477
+ parseInt(h.substring(4, 6), 16) / 255
3478
+ ];
3479
+ }
3480
+ function isValid(def, value) {
3481
+ switch (def.type) {
3482
+ case "enum": return typeof value === "string" && def.options.some((o) => o.value === value);
3483
+ case "bool": return typeof value === "boolean";
3484
+ case "color": return typeof value === "string" && HEX.test(value);
3485
+ default: return typeof value === "number" && Number.isFinite(value);
3486
+ }
3487
+ }
3488
+ /**
3489
+ * Builds a full param record (every key present) from a possibly partial
3490
+ * override map. A value of the wrong type, an unknown enum value or a
3491
+ * malformed color falls back to the param's default and warns once, so a
3492
+ * stale saved design degrades instead of throwing.
3493
+ */
3494
+ function resolveEffectParams(def, overrides) {
3495
+ const resolved = {};
3496
+ for (const p of def.params) {
3497
+ const value = overrides?.[p.key];
3498
+ if (value === void 0) resolved[p.key] = p.default;
3499
+ else if (isValid(p, value)) resolved[p.key] = value;
3500
+ else {
3501
+ warnOnce(`${def.id}.${p.key}: ignoring invalid value ${JSON.stringify(value)}`);
3502
+ resolved[p.key] = p.default;
3503
+ }
3504
+ }
3505
+ return resolved;
3506
+ }
3507
+ /**
3508
+ * What a resolved param becomes on the GPU: one float for float/enum/bool,
3509
+ * three for a color. Enums upload the option's index, which is what lets
3510
+ * their stored value stay a stable string.
3511
+ */
3512
+ function paramUniform(def, value) {
3513
+ switch (def.type) {
3514
+ case "enum": return Math.max(0, def.options.findIndex((o) => o.value === value));
3515
+ case "bool": return value ? 1 : 0;
3516
+ case "color": return hexToRgb(value) ?? [
3517
+ 0,
3518
+ 0,
3519
+ 0
3520
+ ];
3521
+ default: return value;
3522
+ }
3523
+ }
3524
+
3525
+ //#endregion
3526
+ //#region src/params.ts
3527
+ /** Builds a full param record (every ParamDef.key present) from a possibly
3528
+ * partial override map, falling back to each param's declared default. */
3529
+ function resolveParams(def, overrides) {
3530
+ const resolved = {};
3531
+ for (const paramDef of def.params) {
3532
+ const value = overrides?.[paramDef.key];
3533
+ resolved[paramDef.key] = value === void 0 ? paramDef.default : value;
3534
+ }
3535
+ return resolved;
3536
+ }
3537
+
3538
+ //#endregion
3539
+ //#region src/stack.ts
3540
+ const QUAD_VERTICES = new Float32Array([
3541
+ -1,
3542
+ -1,
3543
+ 1,
3544
+ -1,
3545
+ -1,
3546
+ 1,
3547
+ 1,
3548
+ 1
3549
+ ]);
3550
+ /** Media wider or taller than this is scaled down through a 2D canvas before
3551
+ * upload. 4096 is the texture size ~99.9% of WebGL1 devices support. */
3552
+ const MAX_MEDIA_SIZE = 4096;
3553
+ /**
3554
+ * Maps a 0-1 frame UV to the media and returns its color flattened over the
3555
+ * background. Y is flipped here instead of with UNPACK_FLIP_Y_WEBGL, and
3556
+ * alpha is flattened here instead of with UNPACK_PREMULTIPLY_ALPHA_WEBGL:
3557
+ * both pixel-store flags are unspecified for VideoFrame and deprecated for
3558
+ * non-DOM sources in Firefox.
3559
+ */
3560
+ const MEDIA_SAMPLER = `
3561
+ uniform sampler2D u_media;
3562
+ uniform vec2 u_mediaScale; // frame UV -> media UV, about the centre
3563
+ uniform vec3 u_background;
3564
+ vec3 pictureAt(vec2 uv) {
3565
+ vec2 m = (uv - 0.5) * u_mediaScale + 0.5;
3566
+ float inside = step(0.0, m.x) * step(m.x, 1.0) * step(0.0, m.y) * step(m.y, 1.0);
3567
+ vec4 c = texture2D(u_media, vec2(m.x, 1.0 - m.y));
3568
+ return mix(u_background, c.rgb, c.a * inside);
3569
+ }
3570
+ `;
3571
+ const FRAME_SAMPLER = `
3572
+ uniform sampler2D u_source;
3573
+ vec3 pictureAt(vec2 uv) { return texture2D(u_source, uv).rgb; }
3574
+ `;
3575
+ const MEDIA_FRAME_FRAGMENT = `precision highp float;
3576
+ varying vec2 v_uv;
3577
+ ${MEDIA_SAMPLER}
3578
+ void main() { gl_FragColor = vec4(pictureAt(v_uv), 1.0); }
3579
+ `;
3580
+ /**
3581
+ * Averages a picture over each cell of a grid: the CELL SOURCE for a grid
3582
+ * effect that sits on media or on another effect. v_uv is remapped by the
3583
+ * vertex shader to span the grid's extent, so it is the cell's centre in
3584
+ * frame UV. The taps form a fixed pattern in picture space that does not
3585
+ * depend on the output size, which is what keeps the averages (and therefore
3586
+ * every threshold decision made from them) identical between preview and
3587
+ * export. Averaged in linear light.
3588
+ */
3589
+ function cellAverageFragment(sampler, taps) {
3590
+ return `precision highp float;
3591
+ varying vec2 v_uv;
3592
+ uniform vec2 u_cellU; // one cell's edge vectors in frame UV (the grid
3593
+ uniform vec2 u_cellV; // may be rotated, so they need not be axis-aligned)
3594
+ ${COLOR_MATH}
3595
+ ${sampler}
3596
+ void main() {
3597
+ vec3 acc = vec3(0.0);
3598
+ for (int j = 0; j < ${taps}; j++) {
3599
+ for (int i = 0; i < ${taps}; i++) {
3600
+ vec2 o = (vec2(float(i), float(j)) + 0.5) / ${taps}.0 - 0.5;
3601
+ acc += toLinear(pictureAt(v_uv + o.x * u_cellU + o.y * u_cellV));
3602
+ }
3603
+ }
3604
+ gl_FragColor = vec4(toSrgb(acc / ${taps * taps}.0), 1.0);
3605
+ }
3606
+ `;
3607
+ }
3608
+ /** Non-negative modulo. JS's `%` is a remainder operator, not a mathematical
3609
+ * mod — `-5 % 100` is `-5`, not `95`. u_time/u_seed are documented to land
3610
+ * in [0, m), so a negative timeMs (e.g. from an out-of-range seek) or a
3611
+ * negative seed must still floor into that range rather than going negative
3612
+ * on the GPU. */
3613
+ function floorMod(value, m) {
3614
+ return (value % m + m) % m;
3615
+ }
1294
3616
  /** Normalizes a loop period to the "off" sentinel the GLSL side expects.
1295
3617
  * Non-finite and non-positive values all mean "don't loop", so callers can
1296
3618
  * pass through user input without pre-validating it. */
@@ -1298,30 +3620,32 @@ function normalizeLoop(seconds) {
1298
3620
  if (seconds === void 0 || !Number.isFinite(seconds) || seconds <= 0) return 0;
1299
3621
  return seconds;
1300
3622
  }
1301
- function createRenderer(opts) {
1302
- const { canvas, shader } = opts;
3623
+ function mediaSize(media) {
3624
+ const m = media;
3625
+ return [m.videoWidth || m.naturalWidth || m.displayWidth || m.width || 1, m.videoHeight || m.naturalHeight || m.displayHeight || m.height || 1];
3626
+ }
3627
+ function createStackRenderer(opts) {
3628
+ const { canvas } = opts;
1303
3629
  let colors = opts.colors;
1304
- let params = opts.params;
1305
- const seed = opts.seed;
3630
+ const seed = opts.seed ?? 0;
1306
3631
  let loopSeconds = normalizeLoop(opts.loopSeconds);
3632
+ const background = hexToRgb(opts.background ?? "#000000") ?? [
3633
+ 0,
3634
+ 0,
3635
+ 0
3636
+ ];
3637
+ const fontFamily = opts.fontFamily ?? "ui-monospace, Menlo, Consolas, monospace";
1307
3638
  const glOrNull = canvas.getContext("webgl", {
1308
3639
  preserveDrawingBuffer: true,
1309
3640
  antialias: false
1310
3641
  });
1311
3642
  if (!glOrNull) throw new Error("[instantshader] failed to acquire a WebGL context");
1312
3643
  const gl = glOrNull;
1313
- const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
1314
- const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, BASE_UNIFORMS + shader.fragment);
1315
- const program = linkProgram(gl, vertexShader, fragmentShader);
1316
- gl.deleteShader(vertexShader);
1317
- gl.deleteShader(fragmentShader);
1318
- gl.useProgram(program);
1319
3644
  const quadBuffer = gl.createBuffer();
1320
3645
  gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
1321
3646
  gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW);
1322
- const positionLoc = gl.getAttribLocation(program, "a_position");
1323
- gl.enableVertexAttribArray(positionLoc);
1324
- gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
3647
+ gl.enableVertexAttribArray(0);
3648
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
1325
3649
  const paletteTexture = gl.createTexture();
1326
3650
  gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
1327
3651
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -1334,85 +3658,444 @@ function createRenderer(opts) {
1334
3658
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, ramp);
1335
3659
  }
1336
3660
  uploadPalette(colors);
1337
- const resolutionLoc = gl.getUniformLocation(program, "u_resolution");
1338
- const timeLoc = gl.getUniformLocation(program, "u_time");
1339
- const seedLoc = gl.getUniformLocation(program, "u_seed");
1340
- const loopLoc = gl.getUniformLocation(program, "u_loop");
1341
- const paletteLoc = gl.getUniformLocation(program, "u_palette");
1342
- const paramLocs = /* @__PURE__ */ new Map();
1343
- for (const paramDef of shader.params) paramLocs.set(paramDef.key, gl.getUniformLocation(program, `u_${paramDef.key}`));
1344
- function applyParams() {
1345
- for (const paramDef of shader.params) {
1346
- const loc = paramLocs.get(paramDef.key);
1347
- if (loc == null) continue;
1348
- const value = params[paramDef.key] ?? paramDef.default;
1349
- gl.uniform1f(loc, value);
3661
+ let source = opts.source;
3662
+ let generatorProgram = null;
3663
+ let generatorParams = {};
3664
+ let mediaTexture = null;
3665
+ let mediaDims = [1, 1];
3666
+ let mediaFrameProgram = null;
3667
+ let mediaCellProgram = null;
3668
+ let downsampleProgram = null;
3669
+ function uploadMedia() {
3670
+ if (source.kind !== "media") return;
3671
+ let media = source.media;
3672
+ let [w, h] = mediaSize(media);
3673
+ if (Math.max(w, h) > MAX_MEDIA_SIZE) {
3674
+ const k = MAX_MEDIA_SIZE / Math.max(w, h);
3675
+ const scaled = document.createElement("canvas");
3676
+ scaled.width = Math.max(1, Math.round(w * k));
3677
+ scaled.height = Math.max(1, Math.round(h * k));
3678
+ const ctx = scaled.getContext("2d");
3679
+ if (!ctx) throw new Error("[instantshader] 2D context unavailable for media downscale");
3680
+ ctx.drawImage(media, 0, 0, scaled.width, scaled.height);
3681
+ media = scaled;
3682
+ w = scaled.width;
3683
+ h = scaled.height;
3684
+ }
3685
+ mediaDims = [w, h];
3686
+ if (!mediaTexture) {
3687
+ mediaTexture = gl.createTexture();
3688
+ gl.bindTexture(gl.TEXTURE_2D, mediaTexture);
3689
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
3690
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
3691
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
3692
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1350
3693
  }
3694
+ gl.bindTexture(gl.TEXTURE_2D, mediaTexture);
3695
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
3696
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
3697
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, media);
1351
3698
  }
1352
- function renderAt(timeMs) {
1353
- gl.viewport(0, 0, canvas.width, canvas.height);
1354
- gl.useProgram(program);
1355
- gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
1356
- const timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
1357
- gl.uniform1f(timeLoc, timeSec);
1358
- gl.uniform1f(seedLoc, floorMod(seed, 100));
1359
- gl.uniform1f(loopLoc, loopSeconds);
1360
- gl.activeTexture(gl.TEXTURE0);
1361
- gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
1362
- gl.uniform1i(paletteLoc, 0);
1363
- applyParams();
1364
- gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
1365
- gl.enableVertexAttribArray(positionLoc);
1366
- gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
3699
+ function applySource(next) {
3700
+ const prev = source;
3701
+ source = next;
3702
+ if (next.kind === "generator") {
3703
+ if (!generatorProgram || prev.kind !== "generator" || prev.shader !== next.shader) {
3704
+ generatorProgram?.dispose();
3705
+ generatorProgram = createProgram(gl, VERTEX_SHADER, BASE_UNIFORMS + next.shader.fragment);
3706
+ }
3707
+ generatorParams = resolveParams(next.shader, next.params);
3708
+ } else uploadMedia();
3709
+ }
3710
+ applySource(opts.source);
3711
+ let layers = [];
3712
+ function effectSource(preamble, fragment) {
3713
+ return BASE_UNIFORMS + EFFECT_COMMON + preamble + fragment;
3714
+ }
3715
+ function disposeLayer(state) {
3716
+ state.soft?.dispose();
3717
+ state.cell?.dispose();
3718
+ state.draw?.dispose();
3719
+ for (const t of state.textures) gl.deleteTexture(t.texture);
3720
+ }
3721
+ function buildLayer(layer) {
3722
+ const def = layer.effect;
3723
+ const state = {
3724
+ def,
3725
+ params: resolveEffectParams(def, layer.params),
3726
+ enabled: layer.enabled ?? true,
3727
+ soft: null,
3728
+ cell: null,
3729
+ draw: null,
3730
+ textures: (def.textures ?? []).map((t) => ({
3731
+ def: t,
3732
+ key: "",
3733
+ texture: null,
3734
+ uniforms: {}
3735
+ }))
3736
+ };
3737
+ if ((def.textures?.length ?? 0) > 4) throw new Error(`[instantshader] effect "${def.id}" declares more than 4 textures`);
3738
+ if (def.grid) {
3739
+ state.cell = createProgram(gl, VERTEX_SHADER, effectSource(CELL_PREAMBLE, def.grid.fragment));
3740
+ state.draw = createProgram(gl, VERTEX_SHADER, effectSource(DRAW_PREAMBLE, def.fragment ?? COVERAGE_DRAW));
3741
+ } else if (def.fragment) state.soft = createProgram(gl, VERTEX_SHADER, effectSource(SOFT_PREAMBLE, def.fragment));
3742
+ else throw new Error(`[instantshader] effect "${def.id}" has neither a fragment nor a grid`);
3743
+ return state;
3744
+ }
3745
+ function applyEffects(next) {
3746
+ const built = [];
3747
+ for (let i = 0; i < next.length; i++) {
3748
+ const old = layers[i];
3749
+ if (old && old.def === next[i].effect) {
3750
+ old.params = resolveEffectParams(old.def, next[i].params);
3751
+ old.enabled = next[i].enabled ?? true;
3752
+ built.push(old);
3753
+ } else built.push(buildLayer(next[i]));
3754
+ }
3755
+ for (let i = 0; i < layers.length; i++) if (!built.includes(layers[i])) disposeLayer(layers[i]);
3756
+ layers = built;
3757
+ }
3758
+ applyEffects(opts.effects ?? []);
3759
+ let frameTargets = [null, null];
3760
+ const cellTargets = /* @__PURE__ */ new Map();
3761
+ const cellTargetsUsed = /* @__PURE__ */ new Set();
3762
+ function frameTarget(i) {
3763
+ const t = frameTargets[i];
3764
+ if (t && t.width === canvas.width && t.height === canvas.height) return t;
3765
+ deleteTarget(gl, t);
3766
+ const created = createTarget(gl, canvas.width, canvas.height, "linear");
3767
+ frameTargets[i] = created;
3768
+ return created;
3769
+ }
3770
+ function cellTargetPair(grid) {
3771
+ const key = `${grid.cols}x${grid.rows}`;
3772
+ cellTargetsUsed.add(key);
3773
+ let pair = cellTargets.get(key);
3774
+ if (!pair) {
3775
+ pair = [createTarget(gl, grid.cols, grid.rows, "nearest"), createTarget(gl, grid.cols, grid.rows, "nearest")];
3776
+ cellTargets.set(key, pair);
3777
+ }
3778
+ return pair;
3779
+ }
3780
+ /** Frees cell buffers the frame just rendered did not use, so dragging a
3781
+ * size slider does not accumulate one pair per grid size visited. */
3782
+ function pruneCellTargets() {
3783
+ for (const [key, pair] of cellTargets) {
3784
+ if (cellTargetsUsed.has(key)) continue;
3785
+ deleteTarget(gl, pair[0]);
3786
+ deleteTarget(gl, pair[1]);
3787
+ cellTargets.delete(key);
3788
+ }
3789
+ cellTargetsUsed.clear();
3790
+ }
3791
+ let timeSec = 0;
3792
+ function bindOutput(target) {
3793
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target ? target.framebuffer : null);
3794
+ gl.viewport(0, 0, target ? target.width : canvas.width, target ? target.height : canvas.height);
3795
+ }
3796
+ function bindTexture(unit, texture) {
3797
+ gl.activeTexture(gl.TEXTURE0 + unit);
3798
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3799
+ }
3800
+ const IDENTITY_UV = {
3801
+ matrix: [
3802
+ .5,
3803
+ 0,
3804
+ 0,
3805
+ .5
3806
+ ],
3807
+ offset: [.5, .5]
3808
+ };
3809
+ function useProgram(p, resolution, uv = IDENTITY_UV) {
3810
+ gl.useProgram(p.program);
3811
+ gl.uniform2f(p.loc("u_resolution"), resolution[0], resolution[1]);
3812
+ gl.uniform1f(p.loc("u_time"), timeSec);
3813
+ gl.uniform1f(p.loc("u_seed"), floorMod(seed, 100));
3814
+ gl.uniform1f(p.loc("u_loop"), loopSeconds);
3815
+ gl.uniform4f(p.loc("u_uvMatrix"), uv.matrix[0], uv.matrix[1], uv.matrix[2], uv.matrix[3]);
3816
+ gl.uniform2f(p.loc("u_uvOffset"), uv.offset[0], uv.offset[1]);
3817
+ bindTexture(0, paletteTexture);
3818
+ gl.uniform1i(p.loc("u_palette"), 0);
3819
+ }
3820
+ function draw() {
1367
3821
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
1368
3822
  }
1369
- function setColors(next) {
1370
- colors = next;
1371
- uploadPalette(colors);
3823
+ function setMediaUniforms(p) {
3824
+ if (source.kind !== "media") return;
3825
+ const frameAspect = canvas.width / canvas.height;
3826
+ const mediaAspect = mediaDims[0] / mediaDims[1];
3827
+ const scale = ((source.fit ?? "cover") === "cover" ? frameAspect > mediaAspect : frameAspect < mediaAspect) ? [1, mediaAspect / frameAspect] : [frameAspect / mediaAspect, 1];
3828
+ bindTexture(1, mediaTexture);
3829
+ gl.uniform1i(p.loc("u_media"), 1);
3830
+ gl.uniform2f(p.loc("u_mediaScale"), scale[0], scale[1]);
3831
+ gl.uniform3f(p.loc("u_background"), background[0], background[1], background[2]);
3832
+ }
3833
+ function applyGeneratorParams(p) {
3834
+ if (source.kind !== "generator") return;
3835
+ for (const paramDef of source.shader.params) {
3836
+ const loc = p.loc(`u_${paramDef.key}`);
3837
+ if (loc == null) continue;
3838
+ gl.uniform1f(loc, generatorParams[paramDef.key] ?? paramDef.default);
3839
+ }
3840
+ }
3841
+ /** Source at frame size, into a frame target or (null) the canvas. */
3842
+ function renderSourceToFrame(target) {
3843
+ bindOutput(target);
3844
+ if (source.kind === "generator") {
3845
+ const p = generatorProgram;
3846
+ useProgram(p, [canvas.width, canvas.height]);
3847
+ applyGeneratorParams(p);
3848
+ } else {
3849
+ mediaFrameProgram ??= createProgram(gl, VERTEX_SHADER, MEDIA_FRAME_FRAGMENT);
3850
+ useProgram(mediaFrameProgram, [canvas.width, canvas.height]);
3851
+ setMediaUniforms(mediaFrameProgram);
3852
+ }
3853
+ draw();
3854
+ }
3855
+ /**
3856
+ * Source straight into a cell buffer, skipping the frame-size render.
3857
+ *
3858
+ * A generator is simply evaluated once per cell. It is told the frame is
3859
+ * `ref / cell` pixels large: the aspect ratio is unchanged, so the
3860
+ * composition is identical, and generators that antialias an edge over
3861
+ * "one pixel" now antialias over one cell, which is the correct area
3862
+ * average. That resolution depends on the grid alone, so the cell values
3863
+ * are the same at every output size. Grain is switched off for this pass.
3864
+ */
3865
+ function renderSourceToCells(grid, target) {
3866
+ bindOutput(target);
3867
+ const uv = gridUvTransform(grid);
3868
+ if (source.kind === "generator") {
3869
+ const p = generatorProgram;
3870
+ const n = Math.max(grid.cell[0], grid.cell[1]);
3871
+ useProgram(p, [grid.ref[0] / n, grid.ref[1] / n], uv);
3872
+ applyGeneratorParams(p);
3873
+ gl.uniform1f(p.loc("u_grain"), 0);
3874
+ } else {
3875
+ mediaCellProgram ??= createProgram(gl, VERTEX_SHADER, cellAverageFragment(MEDIA_SAMPLER, 8));
3876
+ const p = mediaCellProgram;
3877
+ useProgram(p, [canvas.width, canvas.height], uv);
3878
+ setMediaUniforms(p);
3879
+ gl.uniform2f(p.loc("u_cellU"), uv.cellU[0], uv.cellU[1]);
3880
+ gl.uniform2f(p.loc("u_cellV"), uv.cellV[0], uv.cellV[1]);
3881
+ }
3882
+ draw();
3883
+ }
3884
+ /** A frame-size layer output averaged into a cell buffer. */
3885
+ function downsampleToCells(input, grid, target) {
3886
+ bindOutput(target);
3887
+ downsampleProgram ??= createProgram(gl, VERTEX_SHADER, cellAverageFragment(FRAME_SAMPLER, 4));
3888
+ const p = downsampleProgram;
3889
+ const uv = gridUvTransform(grid);
3890
+ useProgram(p, [canvas.width, canvas.height], uv);
3891
+ bindTexture(1, input.texture);
3892
+ gl.uniform1i(p.loc("u_source"), 1);
3893
+ gl.uniform2f(p.loc("u_cellU"), uv.cellU[0], uv.cellU[1]);
3894
+ gl.uniform2f(p.loc("u_cellV"), uv.cellV[0], uv.cellV[1]);
3895
+ draw();
1372
3896
  }
1373
- function setParams(next) {
1374
- params = next;
3897
+ function uploadTextureData(texture, data) {
3898
+ gl.bindTexture(gl.TEXTURE_2D, texture);
3899
+ const wrap = data.wrap === "repeat" ? gl.REPEAT : gl.CLAMP_TO_EDGE;
3900
+ const filter = data.filter === "nearest" ? gl.NEAREST : gl.LINEAR;
3901
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
3902
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
3903
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
3904
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
3905
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
3906
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0);
3907
+ const src = data.source;
3908
+ if ("data" in src && src.data instanceof Uint8Array) {
3909
+ const format = src.data.length === src.width * src.height ? gl.LUMINANCE : gl.RGBA;
3910
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
3911
+ gl.texImage2D(gl.TEXTURE_2D, 0, format, src.width, src.height, 0, format, gl.UNSIGNED_BYTE, src.data);
3912
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
3913
+ } else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
1375
3914
  }
1376
- function setLoopSeconds(seconds) {
1377
- loopSeconds = normalizeLoop(seconds);
3915
+ function layerGrid(state) {
3916
+ const g = state.def.grid;
3917
+ return gridFor(canvas.width, canvas.height, g.cell(state.params), g.lattice?.(state.params));
1378
3918
  }
1379
- function resize(width, height) {
1380
- canvas.width = width;
1381
- canvas.height = height;
3919
+ /** Rebuilds any of the layer's effect textures whose cache key changed. */
3920
+ function syncTextures(state) {
3921
+ if (state.textures.length === 0) return;
3922
+ const env = {
3923
+ params: state.params,
3924
+ width: canvas.width,
3925
+ height: canvas.height,
3926
+ outputScale: outputScale(canvas.width, canvas.height),
3927
+ fontFamily
3928
+ };
3929
+ for (const t of state.textures) {
3930
+ const key = t.def.key(env);
3931
+ if (t.texture && key === t.key) continue;
3932
+ const data = t.def.build(env);
3933
+ t.texture ??= gl.createTexture();
3934
+ uploadTextureData(t.texture, data);
3935
+ t.key = key;
3936
+ t.uniforms = data.uniforms ?? {};
3937
+ }
3938
+ }
3939
+ /** Effect uniforms: reference-frame info, the layer's params, its effect
3940
+ * textures (units 3+) and whatever uniforms those textures declared. */
3941
+ function applyEffectUniforms(p, state, grid, overGenerator) {
3942
+ const ref = refSize(canvas.width, canvas.height);
3943
+ gl.uniform1f(p.loc("u_sourceIsRamp"), overGenerator ? 1 : 0);
3944
+ gl.uniform2f(p.loc("u_refSize"), ref[0], ref[1]);
3945
+ gl.uniform1f(p.loc("u_outputScale"), outputScale(canvas.width, canvas.height));
3946
+ if (grid) {
3947
+ gl.uniform2f(p.loc("u_gridSize"), grid.cols, grid.rows);
3948
+ gl.uniform2f(p.loc("u_cellRef"), grid.cell[0], grid.cell[1]);
3949
+ gl.uniform1f(p.loc("u_gridAngle"), grid.angle);
3950
+ }
3951
+ for (const paramDef of state.def.params) {
3952
+ const loc = p.loc(`u_${paramDef.key}`);
3953
+ if (loc == null) continue;
3954
+ const v = paramUniform(paramDef, state.params[paramDef.key]);
3955
+ if (typeof v === "number") gl.uniform1f(loc, v);
3956
+ else gl.uniform3f(loc, v[0], v[1], v[2]);
3957
+ }
3958
+ state.textures.forEach((t, i) => {
3959
+ bindTexture(3 + i, t.texture);
3960
+ gl.uniform1i(p.loc(t.def.uniform), 3 + i);
3961
+ for (const [name, value] of Object.entries(t.uniforms)) {
3962
+ const loc = p.loc(name);
3963
+ if (loc == null) continue;
3964
+ if (typeof value === "number") gl.uniform1f(loc, value);
3965
+ else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
3966
+ else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
3967
+ else gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
3968
+ }
3969
+ });
3970
+ }
3971
+ function renderAt(timeMs) {
3972
+ timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
3973
+ const active = layers.filter((l) => l.enabled);
3974
+ if (active.length === 0) {
3975
+ renderSourceToFrame(null);
3976
+ return;
3977
+ }
3978
+ const frameSize = [canvas.width, canvas.height];
3979
+ let input = null;
3980
+ active.forEach((state, i) => {
3981
+ const last = i === active.length - 1;
3982
+ const outIndex = input === frameTargets[0] ? 1 : 0;
3983
+ const overGenerator = input === null && source.kind === "generator";
3984
+ syncTextures(state);
3985
+ if (state.def.grid) {
3986
+ const grid = layerGrid(state);
3987
+ const [cellIn, cellOut] = cellTargetPair(grid);
3988
+ if (input === null) renderSourceToCells(grid, cellIn);
3989
+ else downsampleToCells(input, grid, cellIn);
3990
+ const cellProgram = state.cell;
3991
+ bindOutput(cellOut);
3992
+ useProgram(cellProgram, frameSize);
3993
+ applyEffectUniforms(cellProgram, state, grid, overGenerator);
3994
+ bindTexture(1, cellIn.texture);
3995
+ gl.uniform1i(cellProgram.loc("u_source"), 1);
3996
+ draw();
3997
+ const out = last ? null : frameTarget(outIndex);
3998
+ const drawProgram = state.draw;
3999
+ bindOutput(out);
4000
+ useProgram(drawProgram, frameSize);
4001
+ applyEffectUniforms(drawProgram, state, grid, overGenerator);
4002
+ bindTexture(2, cellOut.texture);
4003
+ gl.uniform1i(drawProgram.loc("u_cells"), 2);
4004
+ draw();
4005
+ input = out;
4006
+ } else {
4007
+ if (input === null) {
4008
+ input = frameTarget(0);
4009
+ renderSourceToFrame(input);
4010
+ }
4011
+ const out = last ? null : frameTarget(input === frameTargets[0] ? 1 : 0);
4012
+ const p = state.soft;
4013
+ bindOutput(out);
4014
+ useProgram(p, frameSize);
4015
+ applyEffectUniforms(p, state, null, overGenerator);
4016
+ bindTexture(1, input.texture);
4017
+ gl.uniform1i(p.loc("u_source"), 1);
4018
+ draw();
4019
+ input = out;
4020
+ }
4021
+ });
4022
+ pruneCellTargets();
1382
4023
  }
1383
4024
  function dispose() {
4025
+ for (const state of layers) disposeLayer(state);
4026
+ layers = [];
4027
+ generatorProgram?.dispose();
4028
+ mediaFrameProgram?.dispose();
4029
+ mediaCellProgram?.dispose();
4030
+ downsampleProgram?.dispose();
4031
+ deleteTarget(gl, frameTargets[0]);
4032
+ deleteTarget(gl, frameTargets[1]);
4033
+ frameTargets = [null, null];
4034
+ for (const pair of cellTargets.values()) {
4035
+ deleteTarget(gl, pair[0]);
4036
+ deleteTarget(gl, pair[1]);
4037
+ }
4038
+ cellTargets.clear();
4039
+ gl.deleteTexture(mediaTexture);
1384
4040
  gl.deleteTexture(paletteTexture);
1385
- gl.deleteProgram(program);
1386
4041
  gl.deleteBuffer(quadBuffer);
1387
4042
  gl.getExtension("WEBGL_lose_context")?.loseContext();
1388
4043
  }
1389
4044
  return {
1390
4045
  renderAt,
1391
- setColors,
1392
- setParams,
1393
- setLoopSeconds,
1394
- resize,
4046
+ setColors(next) {
4047
+ colors = next;
4048
+ uploadPalette(colors);
4049
+ },
4050
+ setSource(next) {
4051
+ applySource(next);
4052
+ },
4053
+ setSourceParams(next) {
4054
+ if (source.kind === "generator") generatorParams = next;
4055
+ },
4056
+ refreshMedia() {
4057
+ uploadMedia();
4058
+ },
4059
+ setEffects(next) {
4060
+ applyEffects(next);
4061
+ },
4062
+ setEffectParams(index, params) {
4063
+ const state = layers[index];
4064
+ if (!state) return;
4065
+ state.params = resolveEffectParams(state.def, {
4066
+ ...state.params,
4067
+ ...params
4068
+ });
4069
+ },
4070
+ setLoopSeconds(seconds) {
4071
+ loopSeconds = normalizeLoop(seconds);
4072
+ },
4073
+ resize(width, height) {
4074
+ canvas.width = width;
4075
+ canvas.height = height;
4076
+ },
4077
+ getGridInfo() {
4078
+ const info = [];
4079
+ layers.forEach((state, index) => {
4080
+ if (!state.enabled || !state.def.grid) return;
4081
+ const grid = layerGrid(state);
4082
+ info.push({
4083
+ index,
4084
+ cols: grid.cols,
4085
+ rows: grid.rows,
4086
+ pxPerCell: grid.pxPerCell
4087
+ });
4088
+ });
4089
+ return info;
4090
+ },
1395
4091
  dispose
1396
4092
  };
1397
4093
  }
1398
4094
 
1399
- //#endregion
1400
- //#region src/params.ts
1401
- /** Builds a full param record (every ParamDef.key present) from a possibly
1402
- * partial override map, falling back to each param's declared default. */
1403
- function resolveParams(def, overrides) {
1404
- const resolved = {};
1405
- for (const paramDef of def.params) {
1406
- const value = overrides?.[paramDef.key];
1407
- resolved[paramDef.key] = value === void 0 ? paramDef.default : value;
1408
- }
1409
- return resolved;
1410
- }
1411
-
1412
4095
  //#endregion
1413
4096
  //#region src/mount.ts
1414
4097
  /**
1415
- * Mounts a live, animated gradient into `container` and returns a handle to
4098
+ * Mounts a live, animated stack (a source plus effect layers) into `container` and returns a handle to
1416
4099
  * control it. Owns a canvas (sized to the container via ResizeObserver, DPR
1417
4100
  * capped at 2 to bound fill-rate cost on high-density displays) and a RAF
1418
4101
  * loop that runs ONLY while playing — the same lifecycle used by the
@@ -1427,24 +4110,23 @@ function resolveParams(def, overrides) {
1427
4110
  * callers may mount many simultaneously-paused instances (e.g. a screenshot
1428
4111
  * grid) that must never carry a perpetual 60fps draw loop each.
1429
4112
  */
1430
- function mountGradient(container, opts) {
1431
- const def = opts.shader;
4113
+ function mountStack(container, opts) {
1432
4114
  const canvas = document.createElement("canvas");
1433
4115
  canvas.style.display = "block";
1434
4116
  canvas.style.width = "100%";
1435
4117
  canvas.style.height = "100%";
1436
4118
  container.appendChild(canvas);
1437
- let colors = opts.colors;
1438
- let params = resolveParams(def, opts.params);
4119
+ let source = opts.source;
1439
4120
  let speed = opts.speed ?? 1;
1440
- const seed = opts.seed ?? 0;
1441
- const renderer = createRenderer({
4121
+ const renderer = createStackRenderer({
1442
4122
  canvas,
1443
- shader: def,
1444
- colors,
1445
- params,
1446
- seed,
1447
- loopSeconds: opts.loopSeconds
4123
+ source,
4124
+ effects: opts.effects,
4125
+ colors: opts.colors,
4126
+ seed: opts.seed ?? 0,
4127
+ loopSeconds: opts.loopSeconds,
4128
+ background: opts.background,
4129
+ fontFamily: opts.fontFamily
1448
4130
  });
1449
4131
  let clockMs = 0;
1450
4132
  let epoch = performance.now();
@@ -1471,36 +4153,71 @@ function mountGradient(container, opts) {
1471
4153
  rafId = requestAnimationFrame(tick);
1472
4154
  }
1473
4155
  rafId = requestAnimationFrame(tick);
1474
- function applySize(cssWidth, cssHeight) {
1475
- const dpr = Math.min(window.devicePixelRatio || 1, 2);
1476
- const width = Math.max(1, Math.round(cssWidth * dpr));
1477
- const height = Math.max(1, Math.round(cssHeight * dpr));
4156
+ /** Sets the backing store to an exact device-pixel size (DPR capped at 2). */
4157
+ function applyDeviceSize(deviceWidth, deviceHeight) {
4158
+ const dpr = window.devicePixelRatio || 1;
4159
+ const cap = dpr > 2 ? 2 / dpr : 1;
4160
+ const width = Math.max(1, Math.round(deviceWidth * cap));
4161
+ const height = Math.max(1, Math.round(deviceHeight * cap));
1478
4162
  if (canvas.width !== width || canvas.height !== height) {
1479
4163
  renderer.resize(width, height);
1480
4164
  if (!playing) renderOnce();
1481
4165
  }
1482
4166
  }
4167
+ function applyCssSize(cssWidth, cssHeight) {
4168
+ const dpr = window.devicePixelRatio || 1;
4169
+ applyDeviceSize(cssWidth * dpr, cssHeight * dpr);
4170
+ }
1483
4171
  const initialRect = container.getBoundingClientRect();
1484
- applySize(initialRect.width || 1, initialRect.height || 1);
4172
+ applyCssSize(initialRect.width || 1, initialRect.height || 1);
1485
4173
  const resizeObserver = new ResizeObserver((entries) => {
1486
- for (const entry of entries) applySize(entry.contentRect.width, entry.contentRect.height);
4174
+ for (const entry of entries) {
4175
+ const device = entry.devicePixelContentBoxSize?.[0];
4176
+ if (device) applyDeviceSize(device.inlineSize, device.blockSize);
4177
+ else applyCssSize(entry.contentRect.width, entry.contentRect.height);
4178
+ }
1487
4179
  });
1488
- resizeObserver.observe(container);
4180
+ try {
4181
+ resizeObserver.observe(container, { box: "device-pixel-content-box" });
4182
+ } catch {
4183
+ resizeObserver.observe(container);
4184
+ }
1489
4185
  return {
1490
4186
  canvas,
1491
4187
  setColors(next) {
1492
- colors = next;
1493
- renderer.setColors(colors);
4188
+ renderer.setColors(next);
4189
+ if (!playing) renderOnce();
4190
+ },
4191
+ setSource(next) {
4192
+ source = next;
4193
+ renderer.setSource(next);
1494
4194
  if (!playing) renderOnce();
1495
4195
  },
1496
- setParams(next) {
1497
- params = {
1498
- ...params,
1499
- ...next
4196
+ setSourceParams(next) {
4197
+ if (source.kind !== "generator") return;
4198
+ source = {
4199
+ ...source,
4200
+ params: {
4201
+ ...resolveParams(source.shader, source.params),
4202
+ ...next
4203
+ }
1500
4204
  };
1501
- renderer.setParams(params);
4205
+ renderer.setSourceParams(source.params);
1502
4206
  if (!playing) renderOnce();
1503
4207
  },
4208
+ setEffects(next) {
4209
+ renderer.setEffects(next);
4210
+ if (!playing) renderOnce();
4211
+ },
4212
+ setEffectParams(index, next) {
4213
+ renderer.setEffectParams(index, next);
4214
+ if (!playing) renderOnce();
4215
+ },
4216
+ refreshMedia() {
4217
+ renderer.refreshMedia();
4218
+ if (!playing) renderOnce();
4219
+ },
4220
+ getGridInfo: renderer.getGridInfo,
1504
4221
  setLoopSeconds(seconds) {
1505
4222
  renderer.setLoopSeconds(seconds);
1506
4223
  if (!playing) renderOnce();
@@ -1541,31 +4258,58 @@ function mountGradient(container, opts) {
1541
4258
  }
1542
4259
  };
1543
4260
  }
4261
+ /** mountStack() for the common case of one generator and no effects. */
4262
+ function mountGradient(container, opts) {
4263
+ const handle = mountStack(container, {
4264
+ source: {
4265
+ kind: "generator",
4266
+ shader: opts.shader,
4267
+ params: opts.params
4268
+ },
4269
+ colors: opts.colors,
4270
+ speed: opts.speed,
4271
+ seed: opts.seed,
4272
+ loopSeconds: opts.loopSeconds
4273
+ });
4274
+ return {
4275
+ canvas: handle.canvas,
4276
+ setColors: handle.setColors,
4277
+ setParams: handle.setSourceParams,
4278
+ setSpeed: handle.setSpeed,
4279
+ setLoopSeconds: handle.setLoopSeconds,
4280
+ pause: handle.pause,
4281
+ resume: handle.resume,
4282
+ seek: handle.seek,
4283
+ getTimeMs: handle.getTimeMs,
4284
+ dispose: handle.dispose
4285
+ };
4286
+ }
1544
4287
 
1545
4288
  //#endregion
1546
4289
  //#region src/frame.ts
1547
4290
  /**
1548
- * Renders a single frame into a detached (not-in-DOM) canvas at an exact
1549
- * pixel size, for export/thumbnail use cases that need a synchronous
1550
- * snapshot rather than a live animation.
4291
+ * Renders a single frame of a stack (a source plus effect layers) into a
4292
+ * detached (not-in-DOM) canvas at an exact pixel size, for export/thumbnail
4293
+ * use cases that need a synchronous snapshot rather than a live animation.
1551
4294
  *
1552
4295
  * The returned canvas is NOT disposed automatically — its GL context must
1553
4296
  * stay alive after this function returns so callers can scrape pixels from
1554
4297
  * it (toDataURL/toBlob/getImageData/drawImage). Once the caller is done
1555
4298
  * with it, release the GL context by calling the returned `dispose()`.
1556
4299
  */
1557
- function renderGradientFrame(opts) {
1558
- const def = opts.shader;
4300
+ function renderStackFrame(opts) {
1559
4301
  const canvas = document.createElement("canvas");
1560
4302
  canvas.width = opts.width;
1561
4303
  canvas.height = opts.height;
1562
- const renderer = createRenderer({
4304
+ const renderer = createStackRenderer({
1563
4305
  canvas,
1564
- shader: def,
4306
+ source: opts.source,
4307
+ effects: opts.effects,
1565
4308
  colors: opts.colors,
1566
- params: resolveParams(def, opts.params),
1567
4309
  seed: opts.seed ?? 0,
1568
- loopSeconds: opts.loopSeconds
4310
+ loopSeconds: opts.loopSeconds,
4311
+ background: opts.background,
4312
+ fontFamily: opts.fontFamily
1569
4313
  });
1570
4314
  renderer.renderAt(opts.timeMs ?? 0);
1571
4315
  const gl = canvas.getContext("webgl");
@@ -1578,6 +4322,46 @@ function renderGradientFrame(opts) {
1578
4322
  dispose: renderer.dispose
1579
4323
  };
1580
4324
  }
4325
+ /** renderStackFrame() for the common case of one generator and no effects. */
4326
+ function renderGradientFrame(opts) {
4327
+ return renderStackFrame({
4328
+ source: {
4329
+ kind: "generator",
4330
+ shader: opts.shader,
4331
+ params: opts.params
4332
+ },
4333
+ colors: opts.colors,
4334
+ seed: opts.seed,
4335
+ timeMs: opts.timeMs,
4336
+ loopSeconds: opts.loopSeconds,
4337
+ width: opts.width,
4338
+ height: opts.height
4339
+ });
4340
+ }
4341
+
4342
+ //#endregion
4343
+ //#region src/renderer.ts
4344
+ function createRenderer(opts) {
4345
+ const stack = createStackRenderer({
4346
+ canvas: opts.canvas,
4347
+ source: {
4348
+ kind: "generator",
4349
+ shader: opts.shader,
4350
+ params: opts.params
4351
+ },
4352
+ colors: opts.colors,
4353
+ seed: opts.seed,
4354
+ loopSeconds: opts.loopSeconds
4355
+ });
4356
+ return {
4357
+ renderAt: stack.renderAt,
4358
+ setColors: stack.setColors,
4359
+ setParams: stack.setSourceParams,
4360
+ setLoopSeconds: stack.setLoopSeconds,
4361
+ resize: stack.resize,
4362
+ dispose: stack.dispose
4363
+ };
4364
+ }
1581
4365
 
1582
4366
  //#endregion
1583
- export { beam, bloom, buildPaletteRamp, createRenderer, flow, getShader, mountGradient, renderGradientFrame, shaders };
4367
+ export { ascii, beam, bloom, buildPaletteRamp, createRenderer, createStackRenderer, dither, dune, effects, flow, getEffect, getShader, gridFor, halftone, halo, mountGradient, mountStack, pixelate, renderGradientFrame, renderStackFrame, shaders, strata, whorl };