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/README.md +30 -0
- package/dist/index.d.ts +287 -5
- package/dist/index.js +3036 -252
- package/package.json +1 -1
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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/
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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/
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
|
1262
|
-
*
|
|
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
|
|
1302
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1323
|
-
gl.
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
|
1374
|
-
|
|
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
|
|
1377
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
|
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
|
|
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
|
|
1438
|
-
let params = resolveParams(def, opts.params);
|
|
4119
|
+
let source = opts.source;
|
|
1439
4120
|
let speed = opts.speed ?? 1;
|
|
1440
|
-
const
|
|
1441
|
-
const renderer = createRenderer({
|
|
4121
|
+
const renderer = createStackRenderer({
|
|
1442
4122
|
canvas,
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
const
|
|
1477
|
-
const
|
|
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
|
-
|
|
4172
|
+
applyCssSize(initialRect.width || 1, initialRect.height || 1);
|
|
1485
4173
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
1486
|
-
for (const entry of entries)
|
|
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
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
...
|
|
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.
|
|
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
|
|
1549
|
-
* pixel size, for export/thumbnail
|
|
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
|
|
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 =
|
|
4304
|
+
const renderer = createStackRenderer({
|
|
1563
4305
|
canvas,
|
|
1564
|
-
|
|
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 };
|