koi-ascii 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,934 +1,1150 @@
1
- /** Every glyph the ASCII pass can emit, keyed by its GLSL constant (G_<KEY>). */
2
- var GLYPHS = {
3
- SPACE: " ",
4
- DOT: ".",
5
- COMMA: ",",
6
- QUOTE: "'",
7
- BACKTICK: "`",
8
- COLON: ":",
9
- MINUS: "-",
10
- UNDERSCORE: "_",
11
- TILDE: "~",
12
- EQUALS: "=",
13
- PLUS: "+",
14
- STAR: "*",
15
- O: "o",
16
- HASH: "#",
17
- PERCENT: "%",
18
- AT: "@",
19
- LPAREN: "(",
20
- RPAREN: ")",
21
- SLASH: "/",
22
- BSLASH: "\\",
23
- PIPE: "|",
24
- BIG_O: "O"
25
- };
26
- var CHARS = Object.values(GLYPHS);
27
- var LINE_HEIGHT = 1.2;
28
- function glyphDefines() {
29
- return Object.keys(GLYPHS).map((k, i) => `#define G_${k} ${i}\n`).join("");
30
- }
1
+ /** The pond's own background (`#06131a`), before time-of-day grading. */
2
+ var POND_BACKGROUND = "#06131a";
31
3
  /**
32
- * Rasterises the glyph set into a single-row R8 texture, one cell per glyph,
33
- * at the exact device-pixel cell size so the ASCII pass can copy texels 1:1.
4
+ * The same background as the shaders take it (`#06131a` to the byte). These
5
+ * exact values have fed the shaders since before palettes; keep them so the
6
+ * default pond stays pixel-identical.
34
7
  */
35
- function buildAtlas(gl, fontPx, family) {
36
- const font = `600 ${fontPx}px ${family}`;
37
- const probe = document.createElement("canvas").getContext("2d");
38
- if (!probe) throw new Error("2D canvas unavailable for the glyph atlas");
39
- probe.font = font;
40
- const cellW = Math.max(1, Math.round(probe.measureText("M").width));
41
- const cellH = Math.max(1, Math.round(fontPx * LINE_HEIGHT));
42
- let ascent = 0;
43
- let descent = 0;
44
- for (const c of CHARS) {
45
- const m = probe.measureText(c);
46
- ascent = Math.max(ascent, m.actualBoundingBoxAscent);
47
- descent = Math.max(descent, m.actualBoundingBoxDescent);
48
- }
49
- const baseline = Math.round((cellH - (ascent + descent)) / 2 + ascent);
50
- const canvas = document.createElement("canvas");
51
- canvas.width = cellW * CHARS.length;
52
- canvas.height = cellH;
53
- const ctx = canvas.getContext("2d");
54
- if (!ctx) throw new Error("2D canvas unavailable for the glyph atlas");
55
- ctx.fillStyle = "#000";
56
- ctx.fillRect(0, 0, canvas.width, canvas.height);
57
- ctx.font = font;
58
- ctx.fillStyle = "#fff";
59
- ctx.textAlign = "center";
60
- ctx.textBaseline = "alphabetic";
61
- CHARS.forEach((c, i) => {
62
- const m = ctx.measureText(c);
63
- const inkW = m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
64
- const sx = inkW > cellW - .5 ? (cellW - .5) / inkW : 1;
65
- const cx = (m.actualBoundingBoxRight - m.actualBoundingBoxLeft) / 2;
66
- ctx.save();
67
- ctx.beginPath();
68
- ctx.rect(i * cellW, 0, cellW, cellH);
69
- ctx.clip();
70
- ctx.translate(i * cellW + cellW / 2, 0);
71
- ctx.scale(sx, 1);
72
- ctx.fillText(c, -cx, baseline);
73
- ctx.restore();
74
- });
75
- const texture = gl.createTexture();
76
- if (!texture) throw new Error("Could not allocate the glyph atlas texture");
77
- gl.bindTexture(gl.TEXTURE_2D, texture);
78
- gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
79
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
80
- gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
81
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, gl.RED, gl.UNSIGNED_BYTE, canvas);
82
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
83
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
84
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
85
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
86
- return {
87
- texture,
88
- cellW,
89
- cellH,
90
- fontPx
91
- };
92
- }
93
- var NIGHT = {
94
- color: [
95
- .36,
96
- .4,
97
- .58
98
- ],
99
- ambient: [
100
- .36,
101
- .35,
102
- .44
103
- ],
104
- water: [
105
- .58,
106
- .7,
107
- 1
108
- ],
109
- sky: [
110
- 0,
111
- .01,
112
- .04
113
- ],
114
- glint: .6,
115
- caustic: .25,
116
- floor: .5,
117
- shadowAlpha: .4,
118
- glow: .55
119
- };
120
- var BLUE = {
121
- color: [
122
- .1,
123
- .11,
124
- .18
125
- ],
126
- ambient: [
127
- .36,
128
- .36,
129
- .48
130
- ],
131
- water: [
132
- .62,
133
- .68,
134
- .95
135
- ],
136
- sky: [
137
- .03,
138
- .02,
139
- .06
140
- ],
141
- glint: .3,
142
- caustic: .2,
143
- floor: .48,
144
- shadowAlpha: .1,
145
- glow: .5
146
- };
147
- var GOLDEN = {
148
- color: [
149
- 1,
150
- .6,
151
- .3
152
- ],
153
- ambient: [
154
- .46,
155
- .36,
156
- .4
157
- ],
158
- water: [
159
- .8,
160
- .72,
161
- .7
162
- ],
163
- sky: [
164
- .2,
165
- .09,
166
- .02
167
- ],
168
- glint: 1.5,
169
- caustic: .55,
170
- floor: .3,
171
- shadowAlpha: .75,
172
- glow: .65
173
- };
174
- var MORNING = {
175
- color: [
176
- .78,
177
- .7,
178
- .58
179
- ],
180
- ambient: [
181
- .42,
182
- .42,
183
- .46
184
- ],
185
- water: [
186
- .98,
187
- .96,
188
- .96
189
- ],
190
- sky: [
191
- .03,
192
- .02,
193
- .01
194
- ],
195
- glint: 1.1,
196
- caustic: .85,
197
- floor: .25,
198
- shadowAlpha: .9,
199
- glow: .45
200
- };
201
- var NOON = {
202
- color: [
203
- .72,
204
- .71,
205
- .67
206
- ],
207
- ambient: [
208
- .44,
209
- .46,
210
- .5
211
- ],
212
- water: [
213
- 1.08,
214
- 1.08,
215
- 1.04
216
- ],
217
- sky: [
218
- 0,
219
- 0,
220
- 0
221
- ],
222
- glint: 1,
223
- caustic: 1,
224
- floor: .25,
225
- shadowAlpha: 1,
226
- glow: .35
8
+ var POND_BG = [
9
+ .024,
10
+ .075,
11
+ .102
12
+ ];
13
+ /** Pond's red markings and Ogon gold (the scene's colours, look.ts): the role defaults for accent and metallic. */
14
+ var POND_ACCENT = [
15
+ .95,
16
+ .31,
17
+ .17
18
+ ];
19
+ var POND_METALLIC = [
20
+ .97,
21
+ .72,
22
+ .24
23
+ ];
24
+ var WHITE = [
25
+ 1,
26
+ 1,
27
+ 1
28
+ ];
29
+ /** Derived highlight on a light background. */
30
+ var NEAR_BLACK = [
31
+ .08,
32
+ .09,
33
+ .1
34
+ ];
35
+ /** Q13: a background brighter than this (WCAG relative luminance) is light. */
36
+ var LIGHT_LUMINANCE = .4;
37
+ /** Missing role slots sit this far from background toward highlight (Q12). */
38
+ var DERIVE = {
39
+ water: .3,
40
+ ink: .45,
41
+ pale: .85
227
42
  };
228
- var KEYS = [
229
- {
230
- at: 0,
231
- part: "night",
232
- ...NIGHT
233
- },
234
- {
235
- at: .21,
236
- part: "blue hour",
237
- ...BLUE
238
- },
239
- {
240
- at: .265,
241
- part: "golden hour",
242
- ...GOLDEN
243
- },
244
- {
245
- at: .34,
246
- part: "morning",
247
- ...MORNING
248
- },
249
- {
250
- at: .5,
251
- part: "noon",
252
- ...NOON
43
+ var POND = Object.freeze({
44
+ mode: "filter",
45
+ background: null,
46
+ css: POND_BACKGROUND,
47
+ light: false,
48
+ tint: WHITE,
49
+ saturation: 1,
50
+ invert: false
51
+ });
52
+ var presets = {
53
+ pond: { mode: "filter" },
54
+ mono: {
55
+ mode: "ramp",
56
+ colors: [
57
+ "#0b0b0b",
58
+ "#5c5c5c",
59
+ "#f2f2f2"
60
+ ]
253
61
  },
254
- {
255
- at: .66,
256
- part: "afternoon",
257
- ...MORNING
62
+ phosphor: {
63
+ mode: "ramp",
64
+ colors: [
65
+ "#020a04",
66
+ "#1b7a35",
67
+ "#b6ffbf"
68
+ ]
258
69
  },
259
- {
260
- at: .745,
261
- part: "golden hour",
262
- ...GOLDEN
70
+ amber: {
71
+ mode: "ramp",
72
+ colors: [
73
+ "#0c0600",
74
+ "#8a4f00",
75
+ "#ffe2a8"
76
+ ]
263
77
  },
264
- {
265
- at: .79,
266
- part: "blue hour",
267
- ...BLUE
78
+ paper: {
79
+ mode: "roles",
80
+ background: "#f3efe6",
81
+ water: "#b3bcbb",
82
+ highlight: "#1d2629",
83
+ fish: {
84
+ pale: "#4c5759",
85
+ ink: "#111416",
86
+ accent: "#c8452c",
87
+ metallic: "#b5832a"
88
+ }
268
89
  },
269
- {
270
- at: 1,
271
- part: "night",
272
- ...NIGHT
90
+ brand: {
91
+ mode: "roles",
92
+ background: "#14102a",
93
+ water: "#3b2f6e",
94
+ highlight: "#f1edff",
95
+ fish: {
96
+ pale: "#c8c2ff",
97
+ ink: "#5a4a9a",
98
+ accent: "#ff5c8a",
99
+ metallic: "#ffc2d6"
100
+ }
273
101
  }
274
- ];
275
- KEYS.slice(0, -1).map(({ at, part }) => ({
276
- at,
277
- name: part
278
- }));
279
- /** The moon hands over to the sun across [a, b] and back across [1 - b, 1 - a]. */
280
- var HANDOFF = [.19, .23];
281
- /**
282
- * The sun's path is tilted on screen so the noon light still comes from one
283
- * side: a fish swimming any way but along that diagonal shows a lit flank.
284
- */
285
- var PATH_TILT = -.45;
286
- /** Sun elevation (rad) at sunrise/sunset and at noon; never 0 so it always lights something. */
287
- var SUN_LOW = .1;
288
- var SUN_HIGH = 1;
289
- var MOON_LOW = .25;
290
- var MOON_HIGH = .95;
291
- /** Shadow length per unit of cot(elevation), in pond units, and its cap for a low sun. */
292
- var SHADOW_PER_COT = .16;
293
- var SHADOW_MAX = .24;
294
- var TAU$1 = Math.PI * 2;
295
- /** GLSL smoothstep. */
296
- var smooth = (a, b, x) => {
297
- const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
298
- return t * t * (3 - 2 * t);
299
102
  };
300
- var mix$1 = (a, b, t) => a + (b - a) * t;
301
- var mix3 = (a, b, t) => [
302
- mix$1(a[0], b[0], t),
303
- mix$1(a[1], b[1], t),
304
- mix$1(a[2], b[2], t)
305
- ];
306
- /**
307
- * Scratch that azimuth() (x, y, length) and arcDir() (sun, moon) write into, so a frame
308
- * allocates no temporaries: single-threaded, and each is read straight after its write.
309
- */
310
- var AZ = /* @__PURE__ */ new Float64Array(3);
311
- var SUN_D = /* @__PURE__ */ new Float64Array(3);
312
- var MOON_D = /* @__PURE__ */ new Float64Array(3);
313
- /**
314
- * Direction toward a body, into `out`, on an arc that rises at angle 0 (screen
315
- * left) and sets at π (screen right), with elevation lo..hi following sin(angle).
316
- */
317
- function arcDir(angle, lo, hi, out) {
318
- const el = mix$1(lo, hi, Math.max(0, Math.sin(angle)));
319
- azimuth(angle);
320
- const h = Math.cos(el) / AZ[2];
321
- out[0] = AZ[0] * h;
322
- out[1] = AZ[1] * h;
323
- out[2] = Math.sin(el);
103
+ function deepFreeze(value) {
104
+ if (value && typeof value === "object") {
105
+ for (const v of Object.values(value)) deepFreeze(v);
106
+ Object.freeze(value);
107
+ }
108
+ return value;
324
109
  }
325
- /** A body's azimuth on screen at `angle` (see arcDir), tilted, into AZ; its length is the same before and after. */
326
- function azimuth(angle) {
327
- const ax = -Math.cos(angle);
328
- const ay = -.8 * Math.sin(angle);
329
- const c = Math.cos(PATH_TILT);
330
- const s = Math.sin(PATH_TILT);
331
- AZ[0] = ax * c - ay * s;
332
- AZ[1] = ax * s + ay * c;
333
- AZ[2] = Math.hypot(ax, ay) || 1;
110
+ /** The presets behind the public `koiPalettes`, frozen all the way down. */
111
+ var PRESETS = deepFreeze(presets);
112
+ function warn(message) {
113
+ console.warn(`koi-ascii: ${message}`);
334
114
  }
335
- /** Half-extents of the path reflections follow, as a share of the pond's width and height. */
336
- var BODY_BAND = [.38, .36];
337
- /** Superellipse exponent of that path: high enough to hug the edges, low enough to round the corners. */
338
- var BODY_ROUND = 6;
339
- /** The moon's radius as a share of the pond's shorter side. */
340
- var MOON_RADIUS = .065;
341
- /** The moon fades in after the evening handoff and out before the morning one (D6). */
342
- var MOON_RISE = [1 - HANDOFF[0], 1 - HANDOFF[0] + .05];
343
- var MOON_SET = [HANDOFF[0] - .05, HANDOFF[0]];
344
- /** The sun's glitter patch: its outer edge as a share of the pond's shorter side. */
345
- var SUN_RADIUS = .1;
346
- /** The sun fades in after the morning handoff and out before the evening one (D6). */
347
- var SUN_RISE = [HANDOFF[1], HANDOFF[1] + .05];
348
- var SUN_SET = [1 - HANDOFF[1] - .05, 1 - HANDOFF[1]];
349
- /**
350
- * The sun's path runs at this share of its light's pace, so its visible day
351
- * (HANDOFF[1] … 1 − HANDOFF[1]) covers exactly the moon's visible night: up
352
- * the left side, over the top, out near the top-right corner. At the light's
353
- * own pace it would also run down the right side (P1: the top edge and one side).
354
- */
355
- var SUN_PACE = 2 * HANDOFF[0] / (1 - 2 * HANDOFF[1]);
356
- /**
357
- * Where a body's reflection sits: toward its azimuth from the pond's centre,
358
- * out on a rounded rectangle near the edges (half-extents BODY_BAND, as a
359
- * share of width and height). Every point of it is at least 0.89 of the way
360
- * out in one axis, so with a radius under 0.12 of the shorter side the disc
361
- * keeps clear of the middle 40 % of the pond at any aspect (P1).
362
- */
363
- function bodyPos(angle) {
364
- azimuth(angle);
365
- const x = AZ[0];
366
- const y = AZ[1];
367
- const k = ((Math.abs(x) / BODY_BAND[0]) ** BODY_ROUND + (Math.abs(y) / BODY_BAND[1]) ** BODY_ROUND) ** (-1 / BODY_ROUND);
368
- return [.5 + x * k, .5 + y * k];
115
+ /** WCAG 2 relative luminance of an sRGB colour. */
116
+ function relativeLuminance([r, g, b]) {
117
+ const lin = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
118
+ return .2126 * lin(r) + .7152 * lin(g) + .0722 * lin(b);
119
+ }
120
+ function isLight(color) {
121
+ return relativeLuminance(color) > LIGHT_LUMINANCE;
122
+ }
123
+ function mix$1(a, b, t) {
124
+ return [
125
+ a[0] + (b[0] - a[0]) * t,
126
+ a[1] + (b[1] - a[1]) * t,
127
+ a[2] + (b[2] - a[2]) * t
128
+ ];
129
+ }
130
+ function toCss(c) {
131
+ return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
369
132
  }
370
133
  /**
371
- * The moon's reflection at `phase`, from the phase alone (not the blended
372
- * light direction, which swings to the sun at the handoff). It follows the
373
- * moon's own azimuth: up the left side after dusk, over the top through the
374
- * night, and gone before either handoff starts, so it never jumps.
134
+ * Resolves a palette. Never throws: an invalid field falls back on its own,
135
+ * an unknown preset/mode (or anything unreadable) falls back to `pond`, each
136
+ * with a `console.warn` (Q8). `undefined`/`null` is `pond`, silently.
375
137
  */
376
- function moonAt(phase) {
377
- const p = (phase % 1 + 1) % 1;
378
- const alpha = 1 - smooth(MOON_SET[0], MOON_SET[1], p) * (1 - smooth(MOON_RISE[0], MOON_RISE[1], p));
138
+ function resolvePalette(input, parse) {
139
+ if (input === void 0 || input === null) return POND;
140
+ try {
141
+ return resolve(input, parse);
142
+ } catch (err) {
143
+ warn(`could not read the palette (${String(err)}); using 'pond'.`);
144
+ return POND;
145
+ }
146
+ }
147
+ function resolve(input, parse) {
148
+ if (typeof input === "string") {
149
+ if (Object.hasOwn(PRESETS, input)) return resolve(PRESETS[input], parse);
150
+ warn(`unknown palette '${input}'; using 'pond'.`);
151
+ return POND;
152
+ }
153
+ const p = typeof input === "object" ? input : {};
154
+ const mode = p.mode;
155
+ if (mode === "ramp") return resolveRamp(p, parse);
156
+ if (mode === "roles") return resolveRoles(p, parse);
157
+ if (mode === "filter") return resolveFilter(p, parse);
158
+ warn(`unknown palette mode '${String(mode)}' (expected 'ramp', 'roles' or 'filter'); using 'pond'.`);
159
+ return POND;
160
+ }
161
+ /** A colour field: undefined when absent (no warning), null when invalid (warned). */
162
+ function color(value, field, parse) {
163
+ if (value === void 0) return void 0;
164
+ const parsed = typeof value === "string" ? safeParse(value, parse) : null;
165
+ if (!parsed) warn(`palette ${field} ${show$2(value)} is not a CSS colour (var() is not supported); ignoring it.`);
166
+ return parsed;
167
+ }
168
+ function resolveRamp(p, parse) {
169
+ const colors = p.colors;
170
+ if (!Array.isArray(colors) || colors.length < 2 || colors.length > 5) {
171
+ warn(`a ramp palette needs 2 to 5 colors; using 'pond'.`);
172
+ return POND;
173
+ }
174
+ const stops = [];
175
+ for (let i = 0; i < colors.length; i++) {
176
+ const c = typeof colors[i] === "string" ? safeParse(colors[i], parse) : null;
177
+ if (!c) {
178
+ warn(`ramp colors[${i}] ${show$2(colors[i])} is not a CSS colour; using 'pond'.`);
179
+ return POND;
180
+ }
181
+ stops.push(c);
182
+ }
183
+ const background = stops[0];
379
184
  return {
380
- pos: bodyPos(TAU$1 * (p - .75)),
381
- radius: MOON_RADIUS,
382
- alpha
185
+ mode: "ramp",
186
+ background,
187
+ css: toCss(background),
188
+ light: isLight(background),
189
+ stops
383
190
  };
384
191
  }
385
- /**
386
- * The sun's glitter at `phase`, from the phase alone, on the moon's path
387
- * (see moonAt and SUN_PACE): rises on the left after the morning handoff,
388
- * over the top at noon, gone near the top-right corner before the evening
389
- * handoff, so it never jumps and never shares the sky with the moon.
390
- */
391
- function sunAt(phase) {
392
- const p = (phase % 1 + 1) % 1;
393
- const alpha = smooth(SUN_RISE[0], SUN_RISE[1], p) * (1 - smooth(SUN_SET[0], SUN_SET[1], p));
192
+ /** A value for a warning message; never throws. */
193
+ function show$2(value) {
194
+ return typeof value === "string" ? JSON.stringify(value) : typeof value === "object" && value ? "object" : String(value);
195
+ }
196
+ function safeParse(css, parse) {
197
+ try {
198
+ return parse(css);
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function resolveRoles(p, parse) {
204
+ let background = color(p.background, "background", parse);
205
+ if (background === void 0) warn(`a roles palette needs a background; using ${POND_BACKGROUND}.`);
206
+ background ??= POND_BG;
207
+ const light = isLight(background);
208
+ const highlight = color(p.highlight, "highlight", parse) ?? (light ? NEAR_BLACK : WHITE);
209
+ const toward = (t) => mix$1(background, highlight, t);
210
+ const water = color(p.water, "water", parse) ?? toward(DERIVE.water);
211
+ let fish = {};
212
+ if (p.fish !== void 0) {
213
+ if (p.fish && typeof p.fish === "object") fish = p.fish;
214
+ else warn(`palette fish must be an object; deriving the fish colours.`);
215
+ }
216
+ const roles = {
217
+ background,
218
+ water,
219
+ highlight,
220
+ pale: color(fish.pale, "fish.pale", parse) ?? toward(DERIVE.pale),
221
+ ink: color(fish.ink, "fish.ink", parse) ?? toward(DERIVE.ink),
222
+ accent: color(fish.accent, "fish.accent", parse) ?? POND_ACCENT,
223
+ metallic: color(fish.metallic, "fish.metallic", parse) ?? POND_METALLIC
224
+ };
394
225
  return {
395
- pos: bodyPos(TAU$1 * (.25 + (p - .5) * SUN_PACE)),
396
- radius: SUN_RADIUS,
397
- alpha
226
+ mode: "roles",
227
+ background,
228
+ css: toCss(background),
229
+ light,
230
+ roles
398
231
  };
399
232
  }
400
- /** Flat water and a graded background at full night with `darkNight` on. */
401
- var DARK_WATER = .7;
402
- /** Flat-water density at full night with `darkNight` on: about half the caustics show. */
403
- var DARK_CAUSTIC = .5;
404
- /** The blue hours the night fades across: out at dawn, back in at dusk. */
405
- var NIGHT_DAWN = [.17, .24];
406
- var NIGHT_DUSK = [.76, .83];
407
- /**
408
- * How much of the night shows at a phase: 1 from the end of the evening blue
409
- * hour to the start of the morning one, 0 by day, a smooth fade across each
410
- * blue hour (so golden hour is still pure day).
411
- */
412
- function nightWeight(phase) {
413
- const p = (phase % 1 + 1) % 1;
414
- return 1 - smooth(NIGHT_DAWN[0], NIGHT_DAWN[1], p) * (1 - smooth(NIGHT_DUSK[0], NIGHT_DUSK[1], p));
233
+ function resolveFilter(p, parse) {
234
+ const background = color(p.background, "background", parse) ?? null;
235
+ const tint = color(p.tint, "tint", parse) ?? WHITE;
236
+ let saturation = 1;
237
+ const s = p.saturation;
238
+ if (typeof s === "number" && !Number.isNaN(s)) {
239
+ saturation = Math.min(1, Math.max(0, s));
240
+ if (saturation !== s) warn(`palette saturation ${s} is outside [0, 1]; using ${saturation}.`);
241
+ } else if (s !== void 0) warn(`palette saturation must be a number from 0 to 1; using 1.`);
242
+ let invert = false;
243
+ if (typeof p.invert === "boolean") invert = p.invert;
244
+ else if (p.invert !== void 0) warn(`palette invert must be true or false; using false.`);
245
+ const light = background ? isLight(background) : false;
246
+ if (light && !invert) warn(`a light filter background needs invert: true, or the glyphs barely show.`);
247
+ if (!light && invert) warn(`invert: true needs a light filter background, or the glyphs barely show.`);
248
+ return {
249
+ mode: "filter",
250
+ background,
251
+ css: background ? toCss(background) : POND_BACKGROUND,
252
+ light,
253
+ tint,
254
+ saturation,
255
+ invert
256
+ };
415
257
  }
416
- /** Night weight below which no star shows: they arrive after the night and leave before it. */
417
- var STARS_FROM = .35;
418
258
  /**
419
- * How strongly the reflected stars show at a phase (the `stars` sky
420
- * feature): `smoothstep(0.35, 1, nightWeight)`, so 0 through the golden
421
- * hours and the start of each blue hour, 1 at full night.
259
+ * Validates an intensity (Q9, R3): a number in [0, 1]. Out-of-range numbers
260
+ * clamp, anything else (NaN, a string, a missing setter argument) keeps
261
+ * `fallback`; both with a warning. `optional`: undefined is the omitted
262
+ * option, which takes `fallback` silently.
422
263
  */
423
- function starWeight(phase) {
424
- return smooth(STARS_FROM, 1, nightWeight(phase));
264
+ function resolveIntensity(value, fallback, optional) {
265
+ if (value === void 0 && optional) return fallback;
266
+ if (typeof value !== "number" || Number.isNaN(value)) {
267
+ warn(`intensity must be a number from 0 to 1; keeping ${fallback}.`);
268
+ return fallback;
269
+ }
270
+ const clamped = Math.min(1, Math.max(0, value));
271
+ if (clamped !== value) warn(`intensity ${value} is outside [0, 1]; using ${clamped}.`);
272
+ return clamped;
425
273
  }
426
- /** The fireflies' dusk window: in after the golden hour, out into the night (never at dawn). */
427
- var FIREFLIES_IN = [.745, .8];
428
- var FIREFLIES_OUT = [.88, .95];
274
+ var parserCtx;
429
275
  /**
430
- * How strongly the fireflies show at a phase (the `fireflies` sky feature):
431
- * 0 until the golden hour ends, 1 through the evening blue hour and early
432
- * night, 0 again from 0.95 through the night, the morning and the day.
276
+ * The browser's own colour parser (Q7): any CSS colour a canvas accepts, in
277
+ * sRGB, alpha dropped. `var()` and junk are rejected by `fillStyle`. Needs a
278
+ * DOM; call it only when a palette is set, never at import.
433
279
  */
434
- function fireflyWeight(phase) {
435
- const p = (phase % 1 + 1) % 1;
436
- return smooth(FIREFLIES_IN[0], FIREFLIES_IN[1], p) * (1 - smooth(FIREFLIES_OUT[0], FIREFLIES_OUT[1], p));
437
- }
438
- /** Lighting at `phase`; without options (or `darkNight` false) exactly the 0.1 lighting. */
439
- function lightingAt(phase, options = {}) {
440
- const p = (phase % 1 + 1) % 1;
441
- let i = 0;
442
- while (i < KEYS.length - 2 && p >= KEYS[i + 1].at) i++;
443
- const a = KEYS[i];
444
- const b = KEYS[i + 1];
445
- const t = smooth(a.at, b.at, p);
446
- arcDir(TAU$1 * (p - .25), SUN_LOW, SUN_HIGH, SUN_D);
447
- arcDir(TAU$1 * (p - .75), MOON_LOW, MOON_HIGH, MOON_D);
448
- const w = smooth(HANDOFF[0], HANDOFF[1], p) * (1 - smooth(1 - HANDOFF[1], 1 - HANDOFF[0], p));
449
- const d0 = mix$1(MOON_D[0], SUN_D[0], w);
450
- const d1 = mix$1(MOON_D[1], SUN_D[1], w);
451
- const d2 = mix$1(MOON_D[2], SUN_D[2], w);
452
- const dl = Math.hypot(d0, d1, d2);
453
- const dir = [
454
- d0 / dl,
455
- d1 / dl,
456
- d2 / dl
280
+ var parseCssColor = (css) => {
281
+ if (parserCtx === void 0) {
282
+ const canvas = document.createElement("canvas");
283
+ canvas.width = canvas.height = 1;
284
+ parserCtx = canvas.getContext("2d", { willReadFrequently: true });
285
+ }
286
+ const ctx = parserCtx;
287
+ if (!ctx) return null;
288
+ ctx.fillStyle = "#000000";
289
+ ctx.fillStyle = css;
290
+ const a = String(ctx.fillStyle);
291
+ ctx.fillStyle = "#ffffff";
292
+ ctx.fillStyle = css;
293
+ if (String(ctx.fillStyle) !== a) return null;
294
+ const hex = /^#([0-9a-f]{6})$/i.exec(a);
295
+ if (hex) {
296
+ const n = parseInt(hex[1], 16);
297
+ return [
298
+ (n >> 16 & 255) / 255,
299
+ (n >> 8 & 255) / 255,
300
+ (n & 255) / 255
301
+ ];
302
+ }
303
+ const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(a);
304
+ if (rgba) return [
305
+ Number(rgba[1]) / 255,
306
+ Number(rgba[2]) / 255,
307
+ Number(rgba[3]) / 255
457
308
  ];
458
- const horiz = Math.hypot(dir[0], dir[1]);
459
- const len = Math.min(SHADOW_MAX, SHADOW_PER_COT * horiz / Math.max(dir[2], .001));
460
- const shadow = horiz > 1e-6 ? [-dir[0] / horiz * len, -dir[1] / horiz * len] : [0, 0];
461
- const floor = mix$1(a.floor, b.floor, t);
462
- const water = [
463
- Math.max(mix$1(a.water[0], b.water[0], t), floor),
464
- Math.max(mix$1(a.water[1], b.water[1], t), floor),
465
- Math.max(mix$1(a.water[2], b.water[2], t), floor)
309
+ ctx.fillStyle = a.replace(/\s*\/\s*[^/)]+\)$/, ")");
310
+ ctx.globalCompositeOperation = "copy";
311
+ ctx.fillRect(0, 0, 1, 1);
312
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
313
+ return [
314
+ r / 255,
315
+ g / 255,
316
+ b / 255
466
317
  ];
467
- const night = options.darkNight ? nightWeight(p) : 0;
318
+ };
319
+ var DEFAULT_FADE = Object.freeze({
320
+ strength: 1,
321
+ feather: 56
322
+ });
323
+ function avoidAmounts(strength) {
468
324
  return {
469
- dir,
470
- color: mix3(a.color, b.color, t),
471
- ambient: mix3(a.ambient, b.ambient, t),
472
- water,
473
- sky: mix3(a.sky, b.sky, t),
474
- glint: mix$1(a.glint, b.glint, t),
475
- caustic: mix$1(a.caustic, b.caustic, t),
476
- floor,
477
- shadow,
478
- shadowAlpha: mix$1(a.shadowAlpha, b.shadowAlpha, t),
479
- glowStrength: mix$1(a.glow, b.glow, t),
480
- darkWater: night <= 0 ? 1 : mix$1(1, DARK_WATER, night),
481
- darkCaustic: night <= 0 ? 1 : mix$1(1, DARK_CAUSTIC, night),
482
- moon: moonAt(p),
483
- sun: sunAt(p)
325
+ waterThin: .8 * strength,
326
+ fishThin: .51 * strength,
327
+ waterDim: .6 * strength,
328
+ fishDim: .85 * strength
484
329
  };
485
330
  }
486
- /** Wraps any number into [0, 1). */
487
- function wrapPhase(p) {
488
- return p - Math.floor(p);
331
+ /** A value for a warning message; never throws. */
332
+ function show$1(value) {
333
+ if (typeof value === "string") return JSON.stringify(value);
334
+ if (Array.isArray(value)) return "an array";
335
+ return typeof value === "object" && value ? "an object" : String(value);
489
336
  }
490
- /** Stays at whatever phase it was last set to. */
491
- var FixedSource = class {
492
- kind = "fixed";
493
- phase;
494
- constructor(phase) {
495
- this.phase = wrapPhase(phase);
496
- }
497
- tick() {
498
- return this.phase;
499
- }
500
- seek(phase) {
501
- this.phase = wrapPhase(phase);
337
+ var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
338
+ /**
339
+ * Resolves `avoidFade` (D9). Fields left out keep `current` (the defaults for
340
+ * the option, the fade in use for setAvoidFade), so animating `strength`
341
+ * keeps a custom feather. `strength` clamps to [0, 1]; a non-finite one, a
342
+ * negative or non-finite feather, or a non-object warn and fall back to the
343
+ * defaults (1 and 56).
344
+ */
345
+ function resolveAvoidFade(input, current = DEFAULT_FADE) {
346
+ if (input === void 0 || input === null) return current;
347
+ if (typeof input !== "object" || Array.isArray(input)) {
348
+ warn(`avoidFade must be an object; got ${show$1(input)}, keeping the fade as it is.`);
349
+ return current;
350
+ }
351
+ const fade = input;
352
+ let { strength, feather } = current;
353
+ if (fade.strength !== void 0) {
354
+ if (!isNumber(fade.strength)) {
355
+ warn(`avoidFade.strength must be a number from 0 to 1; got ${show$1(fade.strength)}, using ${DEFAULT_FADE.strength}.`);
356
+ strength = DEFAULT_FADE.strength;
357
+ } else {
358
+ strength = Math.min(1, Math.max(0, fade.strength));
359
+ if (strength !== fade.strength) warn(`avoidFade.strength ${fade.strength} is outside [0, 1]; using ${strength}.`);
360
+ }
502
361
  }
503
- };
504
- /** Runs through a whole day every `cycle` seconds. */
505
- var LoopSource = class {
506
- kind = "loop";
507
- phase;
508
- cycleS;
509
- constructor(cycle, phase) {
510
- this.cycleS = validCycle(cycle) ?? 600;
511
- this.phase = wrapPhase(phase);
362
+ if (fade.feather !== void 0) {
363
+ if (!isNumber(fade.feather) || fade.feather < 0) {
364
+ warn(`avoidFade.feather must be a number of CSS px, 0 or more; got ${show$1(fade.feather)}, using 56.`);
365
+ feather = 56;
366
+ } else feather = fade.feather;
512
367
  }
513
- get cycle() {
514
- return this.cycleS;
368
+ return strength === current.strength && feather === current.feather ? current : {
369
+ strength,
370
+ feather
371
+ };
372
+ }
373
+ var NO_RADIUS = Object.freeze({
374
+ px: 0,
375
+ frac: 0
376
+ });
377
+ /**
378
+ * Parses a computed `border-top-left-radius` ("8px", "50%", "8px 4px" for an
379
+ * ellipse, whose horizontal radius it takes). One radius per zone (M13).
380
+ */
381
+ function parseRadius(css) {
382
+ const first = (css ?? "").trim().split(/\s+/)[0] ?? "";
383
+ const n = parseFloat(first);
384
+ if (!Number.isFinite(n) || n <= 0) return NO_RADIUS;
385
+ return first.endsWith("%") ? {
386
+ px: 0,
387
+ frac: n / 100
388
+ } : {
389
+ px: n,
390
+ frac: 0
391
+ };
392
+ }
393
+ /**
394
+ * Builds the frame's zones (A5, A8–A10): each element's box, canvas-relative
395
+ * and grown by its pad, with a corner radius of its border radius plus the
396
+ * pad. Zero-size boxes are skipped, and so are zones outside the visible part
397
+ * of the canvas (its rect ∩ the viewport, `viewW` × `viewH`) grown by
398
+ * CULL_MARGIN plus the feather. Past AVOID_MAX zones, the first in list
399
+ * order win. Writes `rects` (x, y, w, h per zone) and `radii` (one per zone),
400
+ * all in world px: the canvas's CSS px, whatever the device pixel ratio or
401
+ * backing-store size. Returns the visible zones: the first
402
+ * min(visible, AVOID_MAX) were written, the rest dropped. A plain number, so
403
+ * a frame allocates nothing here.
404
+ */
405
+ function buildZones(sources, canvas, viewW, viewH, feather, rects, radii) {
406
+ const reach = 50 + feather;
407
+ const x0 = Math.max(canvas.left, 0) - reach;
408
+ const y0 = Math.max(canvas.top, 0) - reach;
409
+ const x1 = Math.min(canvas.left + canvas.width, viewW) + reach;
410
+ const y1 = Math.min(canvas.top + canvas.height, viewH) + reach;
411
+ let count = 0;
412
+ for (let i = 0; i < sources.length; i++) {
413
+ const { rect, pad, radius } = sources[i];
414
+ if (!(rect.width > 0 && rect.height > 0)) continue;
415
+ const left = rect.left - pad;
416
+ const top = rect.top - pad;
417
+ const w = rect.width + 2 * pad;
418
+ const h = rect.height + 2 * pad;
419
+ if (left + w < x0 || left > x1 || top + h < y0 || top > y1) continue;
420
+ if (count >= 16) {
421
+ count++;
422
+ continue;
423
+ }
424
+ rects[count * 4] = left - canvas.left;
425
+ rects[count * 4 + 1] = top - canvas.top;
426
+ rects[count * 4 + 2] = w;
427
+ rects[count * 4 + 3] = h;
428
+ radii[count] = radius.px + radius.frac * Math.min(rect.width, rect.height) + pad;
429
+ count++;
430
+ }
431
+ return count;
432
+ }
433
+ var isElement = (v) => !!v && typeof v === "object" && typeof v.getBoundingClientRect === "function" && "isConnected" in v;
434
+ /**
435
+ * Validates an `avoid` list (M4): not an array warns and is empty; an entry
436
+ * that is neither an Element nor `{ element, pad? }` (null, a DOMRect, a
437
+ * string) warns and is skipped. A bad pad warns and falls back to 12 (D9).
438
+ */
439
+ function resolveAvoidList(input) {
440
+ if (input === void 0 || input === null) return [];
441
+ if (!Array.isArray(input)) {
442
+ warn(`avoid must be an array of elements; got ${show$1(input)}, avoiding nothing.`);
443
+ return [];
444
+ }
445
+ const out = [];
446
+ input.forEach((item, i) => {
447
+ if (isElement(item)) {
448
+ out.push({
449
+ element: item,
450
+ pad: 12
451
+ });
452
+ return;
453
+ }
454
+ const entry = item;
455
+ if (!entry || typeof entry !== "object" || !isElement(entry.element)) {
456
+ warn(`avoid[${i}] must be an Element or { element, pad }; got ${show$1(item)}, skipping it.`);
457
+ return;
458
+ }
459
+ let pad = 12;
460
+ if (entry.pad !== void 0) {
461
+ if (isNumber(entry.pad) && entry.pad >= 0) pad = entry.pad;
462
+ else warn(`avoid[${i}].pad must be a number of CSS px, 0 or more; got ${show$1(entry.pad)}, using 12.`);
463
+ }
464
+ out.push({
465
+ element: entry.element,
466
+ pad
467
+ });
468
+ });
469
+ return out;
470
+ }
471
+ var readRadius = (el) => parseRadius(getComputedStyle(el).borderTopLeftRadius);
472
+ /**
473
+ * The pond's zone list. Holds its elements until set() replaces them or
474
+ * clear() (destroy, failure) drops them. Reads `border-radius` only in
475
+ * set() and readRadii() (mount and pond resize), and once more for an
476
+ * element coming back from zero size (R1); never per frame otherwise.
477
+ */
478
+ var AvoidZones = class {
479
+ rects = /* @__PURE__ */ new Float32Array(64);
480
+ radii = /* @__PURE__ */ new Float32Array(16);
481
+ entries = [];
482
+ /** The cap warning has been given for this list. */
483
+ warned = false;
484
+ get size() {
485
+ return this.entries.length;
486
+ }
487
+ /** Replaces the list; `read`: read the radii now (setAvoid), not at the next resize (mount). */
488
+ set(list, read = true) {
489
+ this.entries = resolveAvoidList(list).map(({ element, pad }) => ({
490
+ element,
491
+ pad,
492
+ rect: {
493
+ left: 0,
494
+ top: 0,
495
+ width: 0,
496
+ height: 0
497
+ },
498
+ radius: NO_RADIUS,
499
+ zero: false
500
+ }));
501
+ this.warned = false;
502
+ if (read) this.readRadii();
515
503
  }
516
- /** Ignores lengths outside [MIN_CYCLE_S, MAX_CYCLE_S]; keeps the phase. */
517
- set cycle(s) {
518
- this.cycleS = validCycle(s) ?? this.cycleS;
504
+ clear() {
505
+ this.entries = [];
519
506
  }
520
- tick(dt) {
521
- this.phase = wrapPhase(this.phase + dt / this.cycleS);
522
- return this.phase;
507
+ /** Re-reads every element's border radius (mount, pond resize). */
508
+ readRadii() {
509
+ for (const e of this.entries) {
510
+ e.radius = readRadius(e.element);
511
+ e.zero = !e.element.isConnected;
512
+ }
523
513
  }
524
- seek(phase) {
525
- this.phase = wrapPhase(phase);
514
+ /**
515
+ * Measures every element against the canvas's rect, writes `rects` and
516
+ * `radii`, and returns the zone count. Warns once per list when visible
517
+ * zones were dropped past AVOID_MAX.
518
+ */
519
+ measure(canvas, viewW, viewH, feather) {
520
+ for (let i = 0; i < this.entries.length; i++) {
521
+ const e = this.entries[i];
522
+ e.rect = e.element.getBoundingClientRect();
523
+ const zero = !(e.rect.width > 0 && e.rect.height > 0);
524
+ if (!zero && e.zero) e.radius = readRadius(e.element);
525
+ e.zero = zero;
526
+ }
527
+ const visible = buildZones(this.entries, canvas, viewW, viewH, feather, this.rects, this.radii);
528
+ if (visible > 16 && !this.warned) {
529
+ this.warned = true;
530
+ warn(`avoid: ${visible} zones are visible but the pond has 16 slots; the first 16 in list order are used.`);
531
+ }
532
+ return Math.min(visible, 16);
526
533
  }
527
534
  };
535
+ /** Every glyph the ASCII pass can emit, keyed by its GLSL constant (G_<KEY>). */
536
+ var GLYPHS = {
537
+ SPACE: " ",
538
+ DOT: ".",
539
+ COMMA: ",",
540
+ QUOTE: "'",
541
+ BACKTICK: "`",
542
+ COLON: ":",
543
+ MINUS: "-",
544
+ UNDERSCORE: "_",
545
+ TILDE: "~",
546
+ EQUALS: "=",
547
+ PLUS: "+",
548
+ STAR: "*",
549
+ O: "o",
550
+ HASH: "#",
551
+ PERCENT: "%",
552
+ AT: "@",
553
+ LPAREN: "(",
554
+ RPAREN: ")",
555
+ SLASH: "/",
556
+ BSLASH: "\\",
557
+ PIPE: "|",
558
+ BIG_O: "O"
559
+ };
560
+ var CHARS = Object.values(GLYPHS);
561
+ var LINE_HEIGHT = 1.2;
562
+ function glyphDefines() {
563
+ return Object.keys(GLYPHS).map((k, i) => `#define G_${k} ${i}\n`).join("");
564
+ }
528
565
  /**
529
- * The visitor's local time of day: 06:00 is 0.25 (sunrise), 18:00 is 0.75
530
- * (sunset). Fixed hours, no latitude; rendered time and seeks are ignored.
566
+ * Rasterises the glyph set into a single-row R8 texture, one cell per glyph,
567
+ * at the exact device-pixel cell size so the ASCII pass can copy texels 1:1.
531
568
  */
532
- var ClockSource = class {
533
- now;
534
- kind = "clock";
535
- constructor(now = () => /* @__PURE__ */ new Date()) {
536
- this.now = now;
537
- }
538
- tick() {
539
- const d = this.now();
540
- return wrapPhase((d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600 + d.getMilliseconds() / 36e5) / 24);
569
+ function buildAtlas(gl, fontPx, family) {
570
+ const font = `600 ${fontPx}px ${family}`;
571
+ const probe = document.createElement("canvas").getContext("2d");
572
+ if (!probe) throw new Error("2D canvas unavailable for the glyph atlas");
573
+ probe.font = font;
574
+ const cellW = Math.max(1, Math.round(probe.measureText("M").width));
575
+ const cellH = Math.max(1, Math.round(fontPx * LINE_HEIGHT));
576
+ let ascent = 0;
577
+ let descent = 0;
578
+ for (const c of CHARS) {
579
+ const m = probe.measureText(c);
580
+ ascent = Math.max(ascent, m.actualBoundingBoxAscent);
581
+ descent = Math.max(descent, m.actualBoundingBoxDescent);
541
582
  }
583
+ const baseline = Math.round((cellH - (ascent + descent)) / 2 + ascent);
584
+ const canvas = document.createElement("canvas");
585
+ canvas.width = cellW * CHARS.length;
586
+ canvas.height = cellH;
587
+ const ctx = canvas.getContext("2d");
588
+ if (!ctx) throw new Error("2D canvas unavailable for the glyph atlas");
589
+ ctx.fillStyle = "#000";
590
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
591
+ ctx.font = font;
592
+ ctx.fillStyle = "#fff";
593
+ ctx.textAlign = "center";
594
+ ctx.textBaseline = "alphabetic";
595
+ CHARS.forEach((c, i) => {
596
+ const m = ctx.measureText(c);
597
+ const inkW = m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
598
+ const sx = inkW > cellW - .5 ? (cellW - .5) / inkW : 1;
599
+ const cx = (m.actualBoundingBoxRight - m.actualBoundingBoxLeft) / 2;
600
+ ctx.save();
601
+ ctx.beginPath();
602
+ ctx.rect(i * cellW, 0, cellW, cellH);
603
+ ctx.clip();
604
+ ctx.translate(i * cellW + cellW / 2, 0);
605
+ ctx.scale(sx, 1);
606
+ ctx.fillText(c, -cx, baseline);
607
+ ctx.restore();
608
+ });
609
+ const texture = gl.createTexture();
610
+ if (!texture) throw new Error("Could not allocate the glyph atlas texture");
611
+ gl.bindTexture(gl.TEXTURE_2D, texture);
612
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
613
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
614
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
615
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, gl.RED, gl.UNSIGNED_BYTE, canvas);
616
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
617
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
618
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
619
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
620
+ return {
621
+ texture,
622
+ cellW,
623
+ cellH,
624
+ fontPx
625
+ };
626
+ }
627
+ var NIGHT = {
628
+ color: [
629
+ .36,
630
+ .4,
631
+ .58
632
+ ],
633
+ ambient: [
634
+ .36,
635
+ .35,
636
+ .44
637
+ ],
638
+ water: [
639
+ .58,
640
+ .7,
641
+ 1
642
+ ],
643
+ sky: [
644
+ 0,
645
+ .01,
646
+ .04
647
+ ],
648
+ glint: .6,
649
+ caustic: .25,
650
+ floor: .5,
651
+ shadowAlpha: .4,
652
+ glow: .55
653
+ };
654
+ var BLUE = {
655
+ color: [
656
+ .1,
657
+ .11,
658
+ .18
659
+ ],
660
+ ambient: [
661
+ .36,
662
+ .36,
663
+ .48
664
+ ],
665
+ water: [
666
+ .62,
667
+ .68,
668
+ .95
669
+ ],
670
+ sky: [
671
+ .03,
672
+ .02,
673
+ .06
674
+ ],
675
+ glint: .3,
676
+ caustic: .2,
677
+ floor: .48,
678
+ shadowAlpha: .1,
679
+ glow: .5
680
+ };
681
+ var GOLDEN = {
682
+ color: [
683
+ 1,
684
+ .6,
685
+ .3
686
+ ],
687
+ ambient: [
688
+ .46,
689
+ .36,
690
+ .4
691
+ ],
692
+ water: [
693
+ .8,
694
+ .72,
695
+ .7
696
+ ],
697
+ sky: [
698
+ .2,
699
+ .09,
700
+ .02
701
+ ],
702
+ glint: 1.5,
703
+ caustic: .55,
704
+ floor: .3,
705
+ shadowAlpha: .75,
706
+ glow: .65
542
707
  };
543
- /**
544
- * The pond's clock: a time source plus a manual override. While the overlay
545
- * slider is held the phase follows it; on release the source continues from
546
- * there (a loop resumes from the released phase, a slider stays put).
547
- */
548
- var Sky = class {
549
- /** Phase used for the current frame. */
550
- phase;
551
- held = null;
552
- src;
553
- constructor(source) {
554
- this.src = source;
555
- this.phase = source.tick(0);
556
- }
557
- get source() {
558
- return this.src;
559
- }
560
- /** Swaps the source (setTime); a held phase still wins until release. */
561
- setSource(source) {
562
- this.src = source;
563
- this.phase = this.held ?? source.tick(0);
564
- }
565
- get holding() {
566
- return this.held !== null;
567
- }
568
- /** Call once per rendered frame with unscaled dt; returns the phase. */
569
- update(dt) {
570
- this.phase = this.held ?? this.source.tick(dt);
571
- return this.phase;
572
- }
573
- /** Takes over from the source (slider drag); applies immediately. */
574
- hold(phase) {
575
- this.held = wrapPhase(phase);
576
- this.phase = this.held;
577
- }
578
- release() {
579
- if (this.held === null) return;
580
- this.source.seek?.(this.held);
581
- this.held = null;
582
- }
708
+ var MORNING = {
709
+ color: [
710
+ .78,
711
+ .7,
712
+ .58
713
+ ],
714
+ ambient: [
715
+ .42,
716
+ .42,
717
+ .46
718
+ ],
719
+ water: [
720
+ .98,
721
+ .96,
722
+ .96
723
+ ],
724
+ sky: [
725
+ .03,
726
+ .02,
727
+ .01
728
+ ],
729
+ glint: 1.1,
730
+ caustic: .85,
731
+ floor: .25,
732
+ shadowAlpha: .9,
733
+ glow: .45
583
734
  };
584
- function validCycle(s) {
585
- return Number.isFinite(s) && s >= 1 && s <= 86400 ? s : null;
586
- }
587
- /** A phase in [0, 1] (1 wraps to 0), or null for anything else. */
588
- function validPhase(p) {
589
- return typeof p === "number" && p >= 0 && p <= 1 ? wrapPhase(p) : null;
590
- }
591
- /**
592
- * Turns a `time` option into a source. Never throws: an unknown mode is a
593
- * loop, a cycle outside [MIN_CYCLE_S, MAX_CYCLE_S] is DEFAULT_CYCLE_S and a
594
- * phase outside [0, 1] is 0. A loop without a phase starts from `current`,
595
- * so switching to it carries on from the phase on screen.
596
- */
597
- function timeSource(time, current) {
598
- const t = time ?? {};
599
- if (t.mode === "clock") return new ClockSource();
600
- if (t.mode === "fixed") return new FixedSource(validPhase(t.phase) ?? 0);
601
- const phase = t.phase === void 0 ? current : validPhase(t.phase) ?? 0;
602
- return new LoopSource(typeof t.cycle === "number" ? t.cycle : NaN, phase);
603
- }
604
- var slots = /* @__PURE__ */ new WeakMap();
605
- /** Called by createKoiPond for every instance. */
606
- function register(handle) {
607
- const slot = {
608
- hooks: {},
609
- core: null
610
- };
611
- slots.set(handle, slot);
612
- return slot;
613
- }
614
- /** The pond's own background (`#06131a`), before time-of-day grading. */
615
- var POND_BACKGROUND = "#06131a";
616
- /**
617
- * The same background as the shaders take it (`#06131a` to the byte). These
618
- * exact values have fed the shaders since before palettes; keep them so the
619
- * default pond stays pixel-identical.
620
- */
621
- var POND_BG = [
622
- .024,
623
- .075,
624
- .102
625
- ];
626
- /** Pond's red markings and Ogon gold (the scene's colours, look.ts): the role defaults for accent and metallic. */
627
- var POND_ACCENT = [
628
- .95,
629
- .31,
630
- .17
631
- ];
632
- var POND_METALLIC = [
633
- .97,
634
- .72,
635
- .24
636
- ];
637
- var WHITE = [
638
- 1,
639
- 1,
640
- 1
641
- ];
642
- /** Derived highlight on a light background. */
643
- var NEAR_BLACK = [
644
- .08,
645
- .09,
646
- .1
647
- ];
648
- /** Q13: a background brighter than this (WCAG relative luminance) is light. */
649
- var LIGHT_LUMINANCE = .4;
650
- /** Missing role slots sit this far from background toward highlight (Q12). */
651
- var DERIVE = {
652
- water: .3,
653
- ink: .45,
654
- pale: .85
735
+ var NOON = {
736
+ color: [
737
+ .72,
738
+ .71,
739
+ .67
740
+ ],
741
+ ambient: [
742
+ .44,
743
+ .46,
744
+ .5
745
+ ],
746
+ water: [
747
+ 1.08,
748
+ 1.08,
749
+ 1.04
750
+ ],
751
+ sky: [
752
+ 0,
753
+ 0,
754
+ 0
755
+ ],
756
+ glint: 1,
757
+ caustic: 1,
758
+ floor: .25,
759
+ shadowAlpha: 1,
760
+ glow: .35
655
761
  };
656
- var POND = Object.freeze({
657
- mode: "filter",
658
- background: null,
659
- css: POND_BACKGROUND,
660
- light: false,
661
- tint: WHITE,
662
- saturation: 1,
663
- invert: false
664
- });
665
- var presets = {
666
- pond: { mode: "filter" },
667
- mono: {
668
- mode: "ramp",
669
- colors: [
670
- "#0b0b0b",
671
- "#5c5c5c",
672
- "#f2f2f2"
673
- ]
762
+ var KEYS = [
763
+ {
764
+ at: 0,
765
+ part: "night",
766
+ ...NIGHT
674
767
  },
675
- phosphor: {
676
- mode: "ramp",
677
- colors: [
678
- "#020a04",
679
- "#1b7a35",
680
- "#b6ffbf"
681
- ]
768
+ {
769
+ at: .21,
770
+ part: "blue hour",
771
+ ...BLUE
682
772
  },
683
- amber: {
684
- mode: "ramp",
685
- colors: [
686
- "#0c0600",
687
- "#8a4f00",
688
- "#ffe2a8"
689
- ]
773
+ {
774
+ at: .265,
775
+ part: "golden hour",
776
+ ...GOLDEN
690
777
  },
691
- paper: {
692
- mode: "roles",
693
- background: "#f3efe6",
694
- water: "#b3bcbb",
695
- highlight: "#1d2629",
696
- fish: {
697
- pale: "#4c5759",
698
- ink: "#111416",
699
- accent: "#c8452c",
700
- metallic: "#b5832a"
701
- }
778
+ {
779
+ at: .34,
780
+ part: "morning",
781
+ ...MORNING
702
782
  },
703
- brand: {
704
- mode: "roles",
705
- background: "#14102a",
706
- water: "#3b2f6e",
707
- highlight: "#f1edff",
708
- fish: {
709
- pale: "#c8c2ff",
710
- ink: "#5a4a9a",
711
- accent: "#ff5c8a",
712
- metallic: "#ffc2d6"
713
- }
783
+ {
784
+ at: .5,
785
+ part: "noon",
786
+ ...NOON
787
+ },
788
+ {
789
+ at: .66,
790
+ part: "afternoon",
791
+ ...MORNING
792
+ },
793
+ {
794
+ at: .745,
795
+ part: "golden hour",
796
+ ...GOLDEN
797
+ },
798
+ {
799
+ at: .79,
800
+ part: "blue hour",
801
+ ...BLUE
802
+ },
803
+ {
804
+ at: 1,
805
+ part: "night",
806
+ ...NIGHT
714
807
  }
808
+ ];
809
+ KEYS.slice(0, -1).map(({ at, part }) => ({
810
+ at,
811
+ name: part
812
+ }));
813
+ /** The moon hands over to the sun across [a, b] and back across [1 - b, 1 - a]. */
814
+ var HANDOFF = [.19, .23];
815
+ /**
816
+ * The sun's path is tilted on screen so the noon light still comes from one
817
+ * side: a fish swimming any way but along that diagonal shows a lit flank.
818
+ */
819
+ var PATH_TILT = -.45;
820
+ /** Sun elevation (rad) at sunrise/sunset and at noon; never 0 so it always lights something. */
821
+ var SUN_LOW = .1;
822
+ var SUN_HIGH = 1;
823
+ var MOON_LOW = .25;
824
+ var MOON_HIGH = .95;
825
+ /** Shadow length per unit of cot(elevation), in pond units, and its cap for a low sun. */
826
+ var SHADOW_PER_COT = .16;
827
+ var SHADOW_MAX = .24;
828
+ var TAU$1 = Math.PI * 2;
829
+ /** GLSL smoothstep. */
830
+ var smooth = (a, b, x) => {
831
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
832
+ return t * t * (3 - 2 * t);
715
833
  };
716
- function deepFreeze(value) {
717
- if (value && typeof value === "object") {
718
- for (const v of Object.values(value)) deepFreeze(v);
719
- Object.freeze(value);
720
- }
721
- return value;
722
- }
723
- /** The presets behind the public `koiPalettes`, frozen all the way down. */
724
- var PRESETS = deepFreeze(presets);
725
- function warn(message) {
726
- console.warn(`koi-ascii: ${message}`);
834
+ var mix = (a, b, t) => a + (b - a) * t;
835
+ var mix3 = (a, b, t) => [
836
+ mix(a[0], b[0], t),
837
+ mix(a[1], b[1], t),
838
+ mix(a[2], b[2], t)
839
+ ];
840
+ /**
841
+ * Scratch that azimuth() (x, y, length) and arcDir() (sun, moon) write into, so a frame
842
+ * allocates no temporaries: single-threaded, and each is read straight after its write.
843
+ */
844
+ var AZ = /* @__PURE__ */ new Float64Array(3);
845
+ var SUN_D = /* @__PURE__ */ new Float64Array(3);
846
+ var MOON_D = /* @__PURE__ */ new Float64Array(3);
847
+ /**
848
+ * Direction toward a body, into `out`, on an arc that rises at angle 0 (screen
849
+ * left) and sets at π (screen right), with elevation lo..hi following sin(angle).
850
+ */
851
+ function arcDir(angle, lo, hi, out) {
852
+ const el = mix(lo, hi, Math.max(0, Math.sin(angle)));
853
+ azimuth(angle);
854
+ const h = Math.cos(el) / AZ[2];
855
+ out[0] = AZ[0] * h;
856
+ out[1] = AZ[1] * h;
857
+ out[2] = Math.sin(el);
727
858
  }
728
- /** WCAG 2 relative luminance of an sRGB colour. */
729
- function relativeLuminance([r, g, b]) {
730
- const lin = (c) => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
731
- return .2126 * lin(r) + .7152 * lin(g) + .0722 * lin(b);
859
+ /** A body's azimuth on screen at `angle` (see arcDir), tilted, into AZ; its length is the same before and after. */
860
+ function azimuth(angle) {
861
+ const ax = -Math.cos(angle);
862
+ const ay = -.8 * Math.sin(angle);
863
+ const c = Math.cos(PATH_TILT);
864
+ const s = Math.sin(PATH_TILT);
865
+ AZ[0] = ax * c - ay * s;
866
+ AZ[1] = ax * s + ay * c;
867
+ AZ[2] = Math.hypot(ax, ay) || 1;
732
868
  }
733
- function isLight(color) {
734
- return relativeLuminance(color) > LIGHT_LUMINANCE;
869
+ /** Half-extents of the path reflections follow, as a share of the pond's width and height. */
870
+ var BODY_BAND = [.38, .36];
871
+ /** Superellipse exponent of that path: high enough to hug the edges, low enough to round the corners. */
872
+ var BODY_ROUND = 6;
873
+ /** The moon's radius as a share of the pond's shorter side. */
874
+ var MOON_RADIUS = .065;
875
+ /** The moon fades in after the evening handoff and out before the morning one (D6). */
876
+ var MOON_RISE = [1 - HANDOFF[0], 1 - HANDOFF[0] + .05];
877
+ var MOON_SET = [HANDOFF[0] - .05, HANDOFF[0]];
878
+ /** The sun's glitter patch: its outer edge as a share of the pond's shorter side. */
879
+ var SUN_RADIUS = .1;
880
+ /** The sun fades in after the morning handoff and out before the evening one (D6). */
881
+ var SUN_RISE = [HANDOFF[1], HANDOFF[1] + .05];
882
+ var SUN_SET = [1 - HANDOFF[1] - .05, 1 - HANDOFF[1]];
883
+ /**
884
+ * The sun's path runs at this share of its light's pace, so its visible day
885
+ * (HANDOFF[1] … 1 − HANDOFF[1]) covers exactly the moon's visible night: up
886
+ * the left side, over the top, out near the top-right corner. At the light's
887
+ * own pace it would also run down the right side (P1: the top edge and one side).
888
+ */
889
+ var SUN_PACE = 2 * HANDOFF[0] / (1 - 2 * HANDOFF[1]);
890
+ /**
891
+ * Where a body's reflection sits: toward its azimuth from the pond's centre,
892
+ * out on a rounded rectangle near the edges (half-extents BODY_BAND, as a
893
+ * share of width and height). Every point of it is at least 0.89 of the way
894
+ * out in one axis, so with a radius under 0.12 of the shorter side the disc
895
+ * keeps clear of the middle 40 % of the pond at any aspect (P1).
896
+ */
897
+ function bodyPos(angle) {
898
+ azimuth(angle);
899
+ const x = AZ[0];
900
+ const y = AZ[1];
901
+ const k = ((Math.abs(x) / BODY_BAND[0]) ** BODY_ROUND + (Math.abs(y) / BODY_BAND[1]) ** BODY_ROUND) ** (-1 / BODY_ROUND);
902
+ return [.5 + x * k, .5 + y * k];
735
903
  }
736
- function mix(a, b, t) {
737
- return [
738
- a[0] + (b[0] - a[0]) * t,
739
- a[1] + (b[1] - a[1]) * t,
740
- a[2] + (b[2] - a[2]) * t
741
- ];
904
+ /**
905
+ * The moon's reflection at `phase`, from the phase alone (not the blended
906
+ * light direction, which swings to the sun at the handoff). It follows the
907
+ * moon's own azimuth: up the left side after dusk, over the top through the
908
+ * night, and gone before either handoff starts, so it never jumps.
909
+ */
910
+ function moonAt(phase) {
911
+ const p = (phase % 1 + 1) % 1;
912
+ const alpha = 1 - smooth(MOON_SET[0], MOON_SET[1], p) * (1 - smooth(MOON_RISE[0], MOON_RISE[1], p));
913
+ return {
914
+ pos: bodyPos(TAU$1 * (p - .75)),
915
+ radius: MOON_RADIUS,
916
+ alpha
917
+ };
742
918
  }
743
- function toCss(c) {
744
- return `#${c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("")}`;
919
+ /**
920
+ * The sun's glitter at `phase`, from the phase alone, on the moon's path
921
+ * (see moonAt and SUN_PACE): rises on the left after the morning handoff,
922
+ * over the top at noon, gone near the top-right corner before the evening
923
+ * handoff, so it never jumps and never shares the sky with the moon.
924
+ */
925
+ function sunAt(phase) {
926
+ const p = (phase % 1 + 1) % 1;
927
+ const alpha = smooth(SUN_RISE[0], SUN_RISE[1], p) * (1 - smooth(SUN_SET[0], SUN_SET[1], p));
928
+ return {
929
+ pos: bodyPos(TAU$1 * (.25 + (p - .5) * SUN_PACE)),
930
+ radius: SUN_RADIUS,
931
+ alpha
932
+ };
745
933
  }
934
+ /** Flat water and a graded background at full night with `darkNight` on. */
935
+ var DARK_WATER = .7;
936
+ /** Flat-water density at full night with `darkNight` on: about half the caustics show. */
937
+ var DARK_CAUSTIC = .5;
938
+ /** The blue hours the night fades across: out at dawn, back in at dusk. */
939
+ var NIGHT_DAWN = [.17, .24];
940
+ var NIGHT_DUSK = [.76, .83];
746
941
  /**
747
- * Resolves a palette. Never throws: an invalid field falls back on its own,
748
- * an unknown preset/mode (or anything unreadable) falls back to `pond`, each
749
- * with a `console.warn` (Q8). `undefined`/`null` is `pond`, silently.
942
+ * How much of the night shows at a phase: 1 from the end of the evening blue
943
+ * hour to the start of the morning one, 0 by day, a smooth fade across each
944
+ * blue hour (so golden hour is still pure day).
750
945
  */
751
- function resolvePalette(input, parse) {
752
- if (input === void 0 || input === null) return POND;
753
- try {
754
- return resolve(input, parse);
755
- } catch (err) {
756
- warn(`could not read the palette (${String(err)}); using 'pond'.`);
757
- return POND;
758
- }
946
+ function nightWeight(phase) {
947
+ const p = (phase % 1 + 1) % 1;
948
+ return 1 - smooth(NIGHT_DAWN[0], NIGHT_DAWN[1], p) * (1 - smooth(NIGHT_DUSK[0], NIGHT_DUSK[1], p));
759
949
  }
760
- function resolve(input, parse) {
761
- if (typeof input === "string") {
762
- if (Object.hasOwn(PRESETS, input)) return resolve(PRESETS[input], parse);
763
- warn(`unknown palette '${input}'; using 'pond'.`);
764
- return POND;
765
- }
766
- const p = typeof input === "object" ? input : {};
767
- const mode = p.mode;
768
- if (mode === "ramp") return resolveRamp(p, parse);
769
- if (mode === "roles") return resolveRoles(p, parse);
770
- if (mode === "filter") return resolveFilter(p, parse);
771
- warn(`unknown palette mode '${String(mode)}' (expected 'ramp', 'roles' or 'filter'); using 'pond'.`);
772
- return POND;
950
+ /** Night weight below which no star shows: they arrive after the night and leave before it. */
951
+ var STARS_FROM = .35;
952
+ /**
953
+ * How strongly the reflected stars show at a phase (the `stars` sky
954
+ * feature): `smoothstep(0.35, 1, nightWeight)`, so 0 through the golden
955
+ * hours and the start of each blue hour, 1 at full night.
956
+ */
957
+ function starWeight(phase) {
958
+ return smooth(STARS_FROM, 1, nightWeight(phase));
773
959
  }
774
- /** A colour field: undefined when absent (no warning), null when invalid (warned). */
775
- function color(value, field, parse) {
776
- if (value === void 0) return void 0;
777
- const parsed = typeof value === "string" ? safeParse(value, parse) : null;
778
- if (!parsed) warn(`palette ${field} ${show$1(value)} is not a CSS colour (var() is not supported); ignoring it.`);
779
- return parsed;
960
+ /** The fireflies' dusk window: in after the golden hour, out into the night (never at dawn). */
961
+ var FIREFLIES_IN = [.745, .8];
962
+ var FIREFLIES_OUT = [.88, .95];
963
+ /**
964
+ * How strongly the fireflies show at a phase (the `fireflies` sky feature):
965
+ * 0 until the golden hour ends, 1 through the evening blue hour and early
966
+ * night, 0 again from 0.95 through the night, the morning and the day.
967
+ */
968
+ function fireflyWeight(phase) {
969
+ const p = (phase % 1 + 1) % 1;
970
+ return smooth(FIREFLIES_IN[0], FIREFLIES_IN[1], p) * (1 - smooth(FIREFLIES_OUT[0], FIREFLIES_OUT[1], p));
780
971
  }
781
- function resolveRamp(p, parse) {
782
- const colors = p.colors;
783
- if (!Array.isArray(colors) || colors.length < 2 || colors.length > 5) {
784
- warn(`a ramp palette needs 2 to 5 colors; using 'pond'.`);
785
- return POND;
786
- }
787
- const stops = [];
788
- for (let i = 0; i < colors.length; i++) {
789
- const c = typeof colors[i] === "string" ? safeParse(colors[i], parse) : null;
790
- if (!c) {
791
- warn(`ramp colors[${i}] ${show$1(colors[i])} is not a CSS colour; using 'pond'.`);
792
- return POND;
793
- }
794
- stops.push(c);
795
- }
796
- const background = stops[0];
797
- return {
798
- mode: "ramp",
799
- background,
800
- css: toCss(background),
801
- light: isLight(background),
802
- stops
972
+ /** Lighting at `phase`; without options (or `darkNight` false) exactly the 0.1 lighting. */
973
+ function lightingAt(phase, options = {}) {
974
+ const p = (phase % 1 + 1) % 1;
975
+ let i = 0;
976
+ while (i < KEYS.length - 2 && p >= KEYS[i + 1].at) i++;
977
+ const a = KEYS[i];
978
+ const b = KEYS[i + 1];
979
+ const t = smooth(a.at, b.at, p);
980
+ arcDir(TAU$1 * (p - .25), SUN_LOW, SUN_HIGH, SUN_D);
981
+ arcDir(TAU$1 * (p - .75), MOON_LOW, MOON_HIGH, MOON_D);
982
+ const w = smooth(HANDOFF[0], HANDOFF[1], p) * (1 - smooth(1 - HANDOFF[1], 1 - HANDOFF[0], p));
983
+ const d0 = mix(MOON_D[0], SUN_D[0], w);
984
+ const d1 = mix(MOON_D[1], SUN_D[1], w);
985
+ const d2 = mix(MOON_D[2], SUN_D[2], w);
986
+ const dl = Math.hypot(d0, d1, d2);
987
+ const dir = [
988
+ d0 / dl,
989
+ d1 / dl,
990
+ d2 / dl
991
+ ];
992
+ const horiz = Math.hypot(dir[0], dir[1]);
993
+ const len = Math.min(SHADOW_MAX, SHADOW_PER_COT * horiz / Math.max(dir[2], .001));
994
+ const shadow = horiz > 1e-6 ? [-dir[0] / horiz * len, -dir[1] / horiz * len] : [0, 0];
995
+ const floor = mix(a.floor, b.floor, t);
996
+ const water = [
997
+ Math.max(mix(a.water[0], b.water[0], t), floor),
998
+ Math.max(mix(a.water[1], b.water[1], t), floor),
999
+ Math.max(mix(a.water[2], b.water[2], t), floor)
1000
+ ];
1001
+ const night = options.darkNight ? nightWeight(p) : 0;
1002
+ return {
1003
+ dir,
1004
+ color: mix3(a.color, b.color, t),
1005
+ ambient: mix3(a.ambient, b.ambient, t),
1006
+ water,
1007
+ sky: mix3(a.sky, b.sky, t),
1008
+ glint: mix(a.glint, b.glint, t),
1009
+ caustic: mix(a.caustic, b.caustic, t),
1010
+ floor,
1011
+ shadow,
1012
+ shadowAlpha: mix(a.shadowAlpha, b.shadowAlpha, t),
1013
+ glowStrength: mix(a.glow, b.glow, t),
1014
+ darkWater: night <= 0 ? 1 : mix(1, DARK_WATER, night),
1015
+ darkCaustic: night <= 0 ? 1 : mix(1, DARK_CAUSTIC, night),
1016
+ moon: moonAt(p),
1017
+ sun: sunAt(p)
803
1018
  };
804
1019
  }
805
- /** A value for a warning message; never throws. */
806
- function show$1(value) {
807
- return typeof value === "string" ? JSON.stringify(value) : typeof value === "object" && value ? "object" : String(value);
1020
+ /** Wraps any number into [0, 1). */
1021
+ function wrapPhase(p) {
1022
+ return p - Math.floor(p);
808
1023
  }
809
- function safeParse(css, parse) {
810
- try {
811
- return parse(css);
812
- } catch {
813
- return null;
1024
+ /** Stays at whatever phase it was last set to. */
1025
+ var FixedSource = class {
1026
+ kind = "fixed";
1027
+ phase;
1028
+ constructor(phase) {
1029
+ this.phase = wrapPhase(phase);
814
1030
  }
815
- }
816
- function resolveRoles(p, parse) {
817
- let background = color(p.background, "background", parse);
818
- if (background === void 0) warn(`a roles palette needs a background; using ${POND_BACKGROUND}.`);
819
- background ??= POND_BG;
820
- const light = isLight(background);
821
- const highlight = color(p.highlight, "highlight", parse) ?? (light ? NEAR_BLACK : WHITE);
822
- const toward = (t) => mix(background, highlight, t);
823
- const water = color(p.water, "water", parse) ?? toward(DERIVE.water);
824
- let fish = {};
825
- if (p.fish !== void 0) {
826
- if (p.fish && typeof p.fish === "object") fish = p.fish;
827
- else warn(`palette fish must be an object; deriving the fish colours.`);
1031
+ tick() {
1032
+ return this.phase;
828
1033
  }
829
- const roles = {
830
- background,
831
- water,
832
- highlight,
833
- pale: color(fish.pale, "fish.pale", parse) ?? toward(DERIVE.pale),
834
- ink: color(fish.ink, "fish.ink", parse) ?? toward(DERIVE.ink),
835
- accent: color(fish.accent, "fish.accent", parse) ?? POND_ACCENT,
836
- metallic: color(fish.metallic, "fish.metallic", parse) ?? POND_METALLIC
837
- };
838
- return {
839
- mode: "roles",
840
- background,
841
- css: toCss(background),
842
- light,
843
- roles
844
- };
845
- }
846
- function resolveFilter(p, parse) {
847
- const background = color(p.background, "background", parse) ?? null;
848
- const tint = color(p.tint, "tint", parse) ?? WHITE;
849
- let saturation = 1;
850
- const s = p.saturation;
851
- if (typeof s === "number" && !Number.isNaN(s)) {
852
- saturation = Math.min(1, Math.max(0, s));
853
- if (saturation !== s) warn(`palette saturation ${s} is outside [0, 1]; using ${saturation}.`);
854
- } else if (s !== void 0) warn(`palette saturation must be a number from 0 to 1; using 1.`);
855
- let invert = false;
856
- if (typeof p.invert === "boolean") invert = p.invert;
857
- else if (p.invert !== void 0) warn(`palette invert must be true or false; using false.`);
858
- const light = background ? isLight(background) : false;
859
- if (light && !invert) warn(`a light filter background needs invert: true, or the glyphs barely show.`);
860
- if (!light && invert) warn(`invert: true needs a light filter background, or the glyphs barely show.`);
861
- return {
862
- mode: "filter",
863
- background,
864
- css: background ? toCss(background) : POND_BACKGROUND,
865
- light,
866
- tint,
867
- saturation,
868
- invert
869
- };
870
- }
1034
+ seek(phase) {
1035
+ this.phase = wrapPhase(phase);
1036
+ }
1037
+ };
1038
+ /** Runs through a whole day every `cycle` seconds. */
1039
+ var LoopSource = class {
1040
+ kind = "loop";
1041
+ phase;
1042
+ cycleS;
1043
+ constructor(cycle, phase) {
1044
+ this.cycleS = validCycle(cycle) ?? 600;
1045
+ this.phase = wrapPhase(phase);
1046
+ }
1047
+ get cycle() {
1048
+ return this.cycleS;
1049
+ }
1050
+ /** Ignores lengths outside [MIN_CYCLE_S, MAX_CYCLE_S]; keeps the phase. */
1051
+ set cycle(s) {
1052
+ this.cycleS = validCycle(s) ?? this.cycleS;
1053
+ }
1054
+ tick(dt) {
1055
+ this.phase = wrapPhase(this.phase + dt / this.cycleS);
1056
+ return this.phase;
1057
+ }
1058
+ seek(phase) {
1059
+ this.phase = wrapPhase(phase);
1060
+ }
1061
+ };
871
1062
  /**
872
- * Validates an intensity (Q9, R3): a number in [0, 1]. Out-of-range numbers
873
- * clamp, anything else (NaN, a string, a missing setter argument) keeps
874
- * `fallback`; both with a warning. `optional`: undefined is the omitted
875
- * option, which takes `fallback` silently.
1063
+ * The visitor's local time of day: 06:00 is 0.25 (sunrise), 18:00 is 0.75
1064
+ * (sunset). Fixed hours, no latitude; rendered time and seeks are ignored.
876
1065
  */
877
- function resolveIntensity(value, fallback, optional) {
878
- if (value === void 0 && optional) return fallback;
879
- if (typeof value !== "number" || Number.isNaN(value)) {
880
- warn(`intensity must be a number from 0 to 1; keeping ${fallback}.`);
881
- return fallback;
1066
+ var ClockSource = class {
1067
+ now;
1068
+ kind = "clock";
1069
+ constructor(now = () => /* @__PURE__ */ new Date()) {
1070
+ this.now = now;
882
1071
  }
883
- const clamped = Math.min(1, Math.max(0, value));
884
- if (clamped !== value) warn(`intensity ${value} is outside [0, 1]; using ${clamped}.`);
885
- return clamped;
886
- }
887
- var parserCtx;
1072
+ tick() {
1073
+ const d = this.now();
1074
+ return wrapPhase((d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600 + d.getMilliseconds() / 36e5) / 24);
1075
+ }
1076
+ };
888
1077
  /**
889
- * The browser's own colour parser (Q7): any CSS colour a canvas accepts, in
890
- * sRGB, alpha dropped. `var()` and junk are rejected by `fillStyle`. Needs a
891
- * DOM; call it only when a palette is set, never at import.
1078
+ * The pond's clock: a time source plus a manual override. While the overlay
1079
+ * slider is held the phase follows it; on release the source continues from
1080
+ * there (a loop resumes from the released phase, a slider stays put).
892
1081
  */
893
- var parseCssColor = (css) => {
894
- if (parserCtx === void 0) {
895
- const canvas = document.createElement("canvas");
896
- canvas.width = canvas.height = 1;
897
- parserCtx = canvas.getContext("2d", { willReadFrequently: true });
1082
+ var Sky = class {
1083
+ /** Phase used for the current frame. */
1084
+ phase;
1085
+ held = null;
1086
+ src;
1087
+ constructor(source) {
1088
+ this.src = source;
1089
+ this.phase = source.tick(0);
898
1090
  }
899
- const ctx = parserCtx;
900
- if (!ctx) return null;
901
- ctx.fillStyle = "#000000";
902
- ctx.fillStyle = css;
903
- const a = String(ctx.fillStyle);
904
- ctx.fillStyle = "#ffffff";
905
- ctx.fillStyle = css;
906
- if (String(ctx.fillStyle) !== a) return null;
907
- const hex = /^#([0-9a-f]{6})$/i.exec(a);
908
- if (hex) {
909
- const n = parseInt(hex[1], 16);
910
- return [
911
- (n >> 16 & 255) / 255,
912
- (n >> 8 & 255) / 255,
913
- (n & 255) / 255
914
- ];
1091
+ get source() {
1092
+ return this.src;
1093
+ }
1094
+ /** Swaps the source (setTime); a held phase still wins until release. */
1095
+ setSource(source) {
1096
+ this.src = source;
1097
+ this.phase = this.held ?? source.tick(0);
1098
+ }
1099
+ get holding() {
1100
+ return this.held !== null;
1101
+ }
1102
+ /** Call once per rendered frame with unscaled dt; returns the phase. */
1103
+ update(dt) {
1104
+ this.phase = this.held ?? this.source.tick(dt);
1105
+ return this.phase;
1106
+ }
1107
+ /** Takes over from the source (slider drag); applies immediately. */
1108
+ hold(phase) {
1109
+ this.held = wrapPhase(phase);
1110
+ this.phase = this.held;
1111
+ }
1112
+ release() {
1113
+ if (this.held === null) return;
1114
+ this.source.seek?.(this.held);
1115
+ this.held = null;
915
1116
  }
916
- const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(a);
917
- if (rgba) return [
918
- Number(rgba[1]) / 255,
919
- Number(rgba[2]) / 255,
920
- Number(rgba[3]) / 255
921
- ];
922
- ctx.fillStyle = a.replace(/\s*\/\s*[^/)]+\)$/, ")");
923
- ctx.globalCompositeOperation = "copy";
924
- ctx.fillRect(0, 0, 1, 1);
925
- const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
926
- return [
927
- r / 255,
928
- g / 255,
929
- b / 255
930
- ];
931
1117
  };
1118
+ function validCycle(s) {
1119
+ return Number.isFinite(s) && s >= 1 && s <= 86400 ? s : null;
1120
+ }
1121
+ /** A phase in [0, 1] (1 wraps to 0), or null for anything else. */
1122
+ function validPhase(p) {
1123
+ return typeof p === "number" && p >= 0 && p <= 1 ? wrapPhase(p) : null;
1124
+ }
1125
+ /**
1126
+ * Turns a `time` option into a source. Never throws: an unknown mode is a
1127
+ * loop, a cycle outside [MIN_CYCLE_S, MAX_CYCLE_S] is DEFAULT_CYCLE_S and a
1128
+ * phase outside [0, 1] is 0. A loop without a phase starts from `current`,
1129
+ * so switching to it carries on from the phase on screen.
1130
+ */
1131
+ function timeSource(time, current) {
1132
+ const t = time ?? {};
1133
+ if (t.mode === "clock") return new ClockSource();
1134
+ if (t.mode === "fixed") return new FixedSource(validPhase(t.phase) ?? 0);
1135
+ const phase = t.phase === void 0 ? current : validPhase(t.phase) ?? 0;
1136
+ return new LoopSource(typeof t.cycle === "number" ? t.cycle : NaN, phase);
1137
+ }
1138
+ var slots = /* @__PURE__ */ new WeakMap();
1139
+ /** Called by createKoiPond for every instance. */
1140
+ function register(handle) {
1141
+ const slot = {
1142
+ hooks: {},
1143
+ core: null
1144
+ };
1145
+ slots.set(handle, slot);
1146
+ return slot;
1147
+ }
932
1148
  /** Small seeded PRNG (mulberry32) so a `?seed=` URL reproduces a pond. */
933
1149
  var Rng = class {
934
1150
  s;
@@ -2074,22 +2290,35 @@ function overlaps(a, b) {
2074
2290
  return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);
2075
2291
  }
2076
2292
  var fullscreen_default = "\nvoid main() {\nvec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\ngl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}\n";
2077
- var common_default = "precision highp float;\nprecision highp int;\nprecision highp sampler2D;\n\n\n#define M_WATER 0\n#define M_RIPPLE 1\n#define M_TRAIL 2\n#define M_FIN 3\n#define M_EDGE 4\n#define M_BODY 5\n#define M_EYE 6\n#define M_GLINT 7\n#define M_STAR 8\n#define M_MOON 9\n#define M_FLY 10\n#define M_SUN 11\n\n#define PI 3.14159265\n#define TAU 6.28318531\n\n\nfloat hash12(vec2 p) {\nvec3 p3 = fract(vec3(p.xyx) * 0.1031);\np3 += dot(p3, p3.yzx + 33.33);\nreturn fract((p3.x + p3.y) * p3.z);\n}\n\nvec2 hash22(vec2 p) {\nvec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\np3 += dot(p3, p3.yzx + 33.33);\nreturn fract((p3.xx + p3.yz) * p3.zy);\n}\n\n\nfloat gnoise(vec2 p) {\nvec2 i = floor(p);\nvec2 f = fract(p);\nvec2 u = f * f * (3.0 - 2.0 * f);\nfloat a = dot(hash22(i) * 2.0 - 1.0, f);\nfloat b = dot(hash22(i + vec2(1.0, 0.0)) * 2.0 - 1.0, f - vec2(1.0, 0.0));\nfloat c = dot(hash22(i + vec2(0.0, 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0));\nfloat d = dot(hash22(i + vec2(1.0, 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0));\nreturn mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\nfloat s = 0.0;\nfloat a = 0.5;\nfor (int i = 0; i < 4; i++) {\ns += a * gnoise(p);\np = mat2(1.6, 1.2, -1.2, 1.6) * p;\na *= 0.5;\n}\nreturn s;\n}\n\n#ifdef RIPPLE_PACKED\n\n\nvec2 enc16(float v) {\nfloat x = floor(clamp(v * 0.5 + 0.5, 0.0, 1.0) * 65535.0 + 0.5);\nfloat hi = floor(x / 256.0);\nreturn vec2(hi, x - hi * 256.0) / 255.0;\n}\n\nfloat dec16(vec2 e) {\nvec2 b = floor(e * 255.0 + 0.5);\nreturn (b.x * 256.0 + b.y) / 65535.0 * 2.0 - 1.0;\n}\n#endif\n";
2293
+ var common_default = "precision highp float;\nprecision highp int;\nprecision highp sampler2D;\n\n\n#define M_WATER 0\n#define M_RIPPLE 1\n#define M_TRAIL 2\n#define M_FIN 3\n#define M_EDGE 4\n#define M_BODY 5\n#define M_EYE 6\n#define M_GLINT 7\n#define M_STAR 8\n#define M_MOON 9\n#define M_FLY 10\n#define M_SUN 11\n\n\n\nbool avoidWater(int mat) { return mat <= M_TRAIL || mat == M_GLINT; }\n\n#define PI 3.14159265\n#define TAU 6.28318531\n\n\nfloat hash12(vec2 p) {\nvec3 p3 = fract(vec3(p.xyx) * 0.1031);\np3 += dot(p3, p3.yzx + 33.33);\nreturn fract((p3.x + p3.y) * p3.z);\n}\n\nvec2 hash22(vec2 p) {\nvec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\np3 += dot(p3, p3.yzx + 33.33);\nreturn fract((p3.xx + p3.yz) * p3.zy);\n}\n\n\nfloat gnoise(vec2 p) {\nvec2 i = floor(p);\nvec2 f = fract(p);\nvec2 u = f * f * (3.0 - 2.0 * f);\nfloat a = dot(hash22(i) * 2.0 - 1.0, f);\nfloat b = dot(hash22(i + vec2(1.0, 0.0)) * 2.0 - 1.0, f - vec2(1.0, 0.0));\nfloat c = dot(hash22(i + vec2(0.0, 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0));\nfloat d = dot(hash22(i + vec2(1.0, 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0));\nreturn mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\nfloat s = 0.0;\nfloat a = 0.5;\nfor (int i = 0; i < 4; i++) {\ns += a * gnoise(p);\np = mat2(1.6, 1.2, -1.2, 1.6) * p;\na *= 0.5;\n}\nreturn s;\n}\n\n#ifdef RIPPLE_PACKED\n\n\nvec2 enc16(float v) {\nfloat x = floor(clamp(v * 0.5 + 0.5, 0.0, 1.0) * 65535.0 + 0.5);\nfloat hi = floor(x / 256.0);\nreturn vec2(hi, x - hi * 256.0) / 255.0;\n}\n\nfloat dec16(vec2 e) {\nvec2 b = floor(e * 255.0 + 0.5);\nreturn (b.x * 256.0 + b.y) / 65535.0 * 2.0 - 1.0;\n}\n#endif\n";
2078
2294
  var ripple_default = "\n\nuniform sampler2D uState;\nuniform ivec2 uSize;\nuniform float uDamping;\nuniform vec4 uDrops[MAX_DROPS];\nuniform int uDropCount;\n\nin vec2 vPx;\nout vec4 fragColor;\n\nvec2 state(ivec2 c) {\nc = clamp(c, ivec2(0), uSize - 1);\nvec4 s = texelFetch(uState, c, 0);\n#ifdef RIPPLE_PACKED\nreturn vec2(dec16(s.rg), dec16(s.ba));\n#else\nreturn s.rg;\n#endif\n}\n\nvoid main() {\n\n\nivec2 c = ivec2(vPx);\nvec2 s = state(c);\nfloat lap = state(c + ivec2(1, 0)).x + state(c - ivec2(1, 0)).x\n+ state(c + ivec2(0, 1)).x + state(c - ivec2(0, 1)).x - 4.0 * s.x;\nfloat h = 2.0 * s.x - s.y + 0.3 * lap;\n\n\nvec2 fc = vec2(c) + 0.5;\nfloat edge = min(min(fc.x, fc.y), min(float(uSize.x) - fc.x, float(uSize.y) - fc.y));\nh *= uDamping * mix(0.86, 1.0, smoothstep(0.0, 8.0, edge));\n\nfor (int i = 0; i < MAX_DROPS; i++) {\nif (i >= uDropCount) break;\nvec4 d = uDrops[i];\nfloat x = length(fc - d.xy) / d.z;\nif (x < 1.0) {\nfloat k = 1.0 - x * x;\nh += d.w * k * k;\n}\n}\nh = clamp(h, -1.0, 1.0);\n\n#ifdef RIPPLE_PACKED\nfragColor = vec4(enc16(h), enc16(s.x));\n#else\nfragColor = vec4(h, s.x, 0.0, 1.0);\n#endif\n}\n";
2295
+ var uniforms_default = "\n\n\nuniform float uRipplePx;\nuniform int uFishCount;\nuniform vec2 uCell;\nuniform float uTime;\n\n\n\nuniform vec3 uLightDir;\nuniform vec3 uLightCol;\nuniform vec3 uWaterTint;\nuniform float uCaustic;\nuniform float uCalm;\n\n\n\nuniform vec3 uWaterDim;\nuniform vec3 uWaterLit;\nuniform vec3 uOgonGold;\nuniform vec3 uGlintCol;\nuniform float uRoles;\n\n\nconst float RIPPLE_GAIN = 28.0;\n\n\nconst float REFRACT = 220.0;\n";
2296
+ var light_default = "\n\n\n\n\n\n\nuniform vec3 uAmbient;\nuniform float uFloor;\n\n\nconst float CALM_FLICKER = 0.3;\n\nfloat flicker() { return mix(1.0, CALM_FLICKER, uCalm); }\n\n\n\n\n\nvec3 diffuseDir() {\nreturn normalize(vec3(uLightDir.xy, max(uLightDir.z, 0.5)));\n}\n\n\n\nvec3 lightOn(vec3 n) {\nreturn max(uAmbient + uLightCol * max(dot(n, diffuseDir()), 0.0), vec3(uFloor));\n}\n\n\nvec3 lightHue() {\nreturn uLightCol / max(max(uLightCol.r, max(uLightCol.g, uLightCol.b)), 1e-3);\n}\n\n\n\nvec3 mirrorHue() {\nreturn mix(lightHue(), vec3(1.0), 0.4);\n}\n";
2297
+ var cell_default = "\n\n\n\n\n\nstruct Cell {\nvec3 col;\nfloat dens;\nint mat;\nvec2 dir;\nfloat aux;\nfloat style;\n};\n\n\nvec2 lineDir(vec2 d) {\nvec2 c = d / uCell;\nfloat l = length(c);\nreturn l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\nvec2 cellNormal(vec2 n) {\nvec2 c = n * uCell;\nfloat l = length(c);\nreturn l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\n\nfloat coverage(float d, vec2 n) {\nfloat span = abs(n.x) * uCell.x + abs(n.y) * uCell.y;\nreturn clamp(0.5 - d / span, 0.0, 1.0);\n}\n\nfloat sq(float x) { return x * x; }\n\nbool inBox(vec2 p, vec4 box, float pad) {\nreturn all(greaterThanEqual(p, box.xy - pad)) && all(lessThanEqual(p, box.zw + pad));\n}\n";
2298
+ var ripples_default = "\n\n\n\n\n\nuniform sampler2D uRipple;\nuniform ivec2 uRippleSize;\n\nfloat rippleTexel(ivec2 c) {\nc = clamp(c, ivec2(0), uRippleSize - 1);\n#ifdef RIPPLE_PACKED\nreturn dec16(texelFetch(uRipple, c, 0).rg);\n#else\nreturn texelFetch(uRipple, c, 0).r;\n#endif\n}\n\nfloat rippleAt(vec2 p) {\nvec2 t = p / uRipplePx - 0.5;\nvec2 i = floor(t);\nvec2 f = t - i;\nivec2 c = ivec2(i);\nreturn mix(mix(rippleTexel(c), rippleTexel(c + ivec2(1, 0)), f.x),\nmix(rippleTexel(c + ivec2(0, 1)), rippleTexel(c + ivec2(1, 1)), f.x), f.y);\n}\n";
2299
+ var water_default = "\n\n\n\n\n\n\n\nuniform vec3 uSky;\nuniform float uGlint;\nuniform float uDarkWater;\nuniform float uDarkCaustic;\nuniform float uMoteThin;\nuniform vec3 uRippleCol;\n\n\n\n\n\nconst float GLINT_TILT = 2.0;\n\n\nfloat caustic(vec2 x, float t) {\nvec2 i = floor(x);\nvec2 f = fract(x);\nfloat f1 = 8.0;\nfloat f2 = 8.0;\nfor (int y = -1; y <= 1; y++) {\nfor (int k = -1; k <= 1; k++) {\nvec2 b = vec2(float(k), float(y));\nvec2 o = 0.5 + 0.42 * sin(t + TAU * hash22(i + b));\nfloat d = length(b + o - f);\nif (d < f1) {\nf2 = f1;\nf1 = d;\n} else if (d < f2) {\nf2 = d;\n}\n}\n}\nreturn 1.0 - smoothstep(0.0, 0.22, f2 - f1);\n}\n\nCell water(vec2 p, float h, vec2 g) {\nCell c;\n\nvec2 mp = floor((p + vec2(7.0, -4.0) * uTime) / uCell);\n\nfloat mote = step(mix(0.96, 0.99, uMoteThin), hash12(mp)) * (0.4 + 0.6 * hash12(mp + 17.0));\n\n\n\nfloat shimmer = 0.8 + 0.6 * fbm(p * 0.0016 + vec2(uTime * 0.012, -uTime * 0.008));\nfloat lit = smoothstep(0.45, 0.95, caustic(p * 0.011 + g * 3.0, uTime * 0.45) * shimmer);\nc.mat = M_WATER;\n\nc.dens = max(mote * 0.5, lit * mix(0.6, 1.0, uCaustic));\n\nc.col = mix(uWaterDim, uWaterLit, 0.6 * lit * min(uCaustic, 1.0)) * uWaterTint + uSky;\n\n\nif (uDarkWater < 1.0) c.col *= uDarkWater;\nif (uDarkCaustic < 1.0) c.dens *= uDarkCaustic;\nc.aux = hash12(mp + 3.1);\nc.dir = vec2(1.0, 0.0);\nc.style = 0.0;\n\nfloat rip = smoothstep(0.15, 0.6, length(g) * RIPPLE_GAIN);\nif (rip > 0.25) {\nc.mat = M_RIPPLE;\nc.dens = rip;\nc.col = mix(uWaterLit, uRippleCol, rip) * uWaterTint + uSky;\nc.dir = lineDir(vec2(-g.y, g.x));\nc.aux = h > 0.0 ? 1.0 : 0.0;\n}\n\n\n\n\nvec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\nvec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\nvec2 across = vec2(-az.y, az.x);\nfloat low = 1.0 - uLightDir.z;\nvec2 e = -g * RIPPLE_GAIN * GLINT_TILT - H.xy / H.z;\nfloat ea = dot(e, az);\nfloat ec = dot(e, across);\n\n\nfloat gl = exp(-sq(ea / (0.3 + 0.4 * low)) - sq(ec / (0.3 + 0.8 * low)));\ngl *= smoothstep(0.06, 0.25, length(g) * RIPPLE_GAIN);\n\n\nvec2 r = vec2(dot(p, az), dot(p, across)) / (max(uCell.x, uCell.y) * vec2(1.0 + 5.0 * low, 1.0));\nfloat tw = hash12(floor(r) + floor(uTime * 6.0 * flicker()) * 0.613);\ngl *= uGlint * (0.45 + 1.1 * tw);\nif (gl > 0.45) {\nc.mat = M_GLINT;\nc.dens = min(gl, 1.0);\n\n\nvec3 gc = uRoles > 0.5 ? uGlintCol : mirrorHue();\nc.col = gc * mix(0.8, 1.0, smoothstep(0.45, 1.0, gl));\nc.dir = lineDir(az);\nc.aux = low;\n}\nreturn c;\n}\n";
2300
+ var sky_default = "\n\n\n\n\n\n\nuniform float uStars;\nuniform vec2 uView;\n\n\n\nuniform vec4 uMoon;\n\n\n\nuniform vec4 uSun;\nuniform vec3 uStarCool;\nuniform vec3 uStarWarm;\nuniform vec3 uMoonCol;\nuniform vec3 uSunCol;\n\n\n\nconst float MOON_GAIN = 0.85;\n\n\n\n\nconst float SUN_THIN = 0.5;\nconst vec2 SUN_GAIN = vec2(0.6, 0.9);\n\n\n\nconst float SUN_MIN = 0.45;\nconst float SUN_CORE = 0.115;\n\n\n\n\nvoid stars(vec2 p, vec2 g, inout Cell c) {\nif (uStars < 0.02 || c.mat != M_WATER) return;\nvec2 cell = floor((p + g * REFRACT * 2.0) / uCell);\n\nif (hash12(cell * 0.917 + 41.0) < 0.984) return;\nfloat mag = hash12(cell + 7.7);\nfloat ph = hash12(cell + 2.3) * TAU;\n\n\nfloat tw = 0.75 + 0.25 * sin(uTime * (0.8 + 1.6 * mag) + ph);\ntw += 0.5 * pow(max(sin(uTime * 0.37 + ph * 3.0), 0.0), 40.0);\ntw = mix(tw, 0.8, uCalm);\nfloat v = mag * tw * uStars;\nif (v < 0.15) return;\nc.mat = M_STAR;\nc.dens = v;\nc.aux = hash12(cell + 9.1);\nc.style = 0.0;\n\n\nfloat k = 0.35 + 0.6 * min(v, 1.0);\nvec3 col = mix(uStarCool, uStarWarm, c.aux);\nc.col = uRoles > 0.5 ? mix(uWaterDim, col, k) : col * k;\n}\n\n\n\n\n\nvoid moon(vec2 p, vec2 g, inout Cell c) {\nfloat a = uMoon.w;\nif (a < 0.02) return;\nfloat R = uMoon.z * min(uView.x, uView.y);\n\nvec2 wob = vec2(sin(p.y * 0.21 + uTime * 1.3 * flicker()) * 1.2, 0.0);\nvec2 dq = p + g * REFRACT * 3.0 + wob - uMoon.xy * uView;\nfloat dist = length(dq);\nif (dist > R * 1.4) return;\nvec2 n = dist > 1e-3 ? dq / dist : vec2(1.0, 0.0);\nfloat cv = coverage(dist - R, n);\nif (cv < 0.25) return;\nfloat r = dist / R;\nfloat maria = smoothstep(-0.05, 0.2, fbm(dq / R * 1.7 + 5.0));\nfloat lum = (0.95 - 0.45 * maria) * (1.0 - 0.25 * r * r);\nc.mat = M_MOON;\nc.aux = 0.0;\n\n\nfloat k = (0.35 + 0.5 * lum) * MOON_GAIN * a;\nc.col = uRoles > 0.5 ? mix(uWaterDim, uMoonCol, k) : uMoonCol * k;\nif (length(g) * RIPPLE_GAIN > 0.35) {\nc.style = 2.0;\nc.dens = a;\nc.dir = lineDir(vec2(-g.y, g.x));\nc.col = mix(c.col, uRoles > 0.5 ? uGlintCol : vec3(1.0), 0.3 * a);\n} else if (cv < 0.8) {\nc.style = 1.0;\nc.dens = cv;\nc.dir = cellNormal(n);\n} else {\nc.style = 0.0;\nc.dens = 0.72 * lum * a;\n}\n}\n\n\n\n\n\n\nvoid sun(vec2 p, vec2 g, inout Cell c) {\nfloat a = uSun.w;\nif (a < 0.02 || c.mat == M_GLINT) return;\nfloat R = uSun.z * min(uView.x, uView.y);\nvec2 dq = p + g * REFRACT * 3.0 - uSun.xy * uView;\nfloat r = length(dq) / R;\nif (r > 1.0) return;\nvec2 cell = floor(p / uCell);\n\n\nfloat flash = floor(uTime * 5.0 * flicker());\n\nif (hash12(cell * 1.31 + flash * 0.29 + 3.0) > (1.0 - SUN_THIN) * a) return;\nfloat tw = hash12(cell + flash * 0.613);\ntw = mix(tw, hash12(cell + 0.37), uCalm * 0.7);\nfloat v = (1.0 - smoothstep(SUN_CORE, 1.0, r)) * (0.35 + 0.9 * tw);\nif (v < SUN_MIN) return;\nc.mat = M_SUN;\nc.dens = min(v, 1.0);\nc.aux = 0.0;\nc.style = 0.0;\n\n\nfloat k = mix(SUN_GAIN.x, SUN_GAIN.y, smoothstep(SUN_MIN, 1.0, v));\nc.col = uRoles > 0.5 ? mix(uWaterDim, uSunCol, k) : uSunCol * mirrorHue() * k;\n}\n";
2301
+ var fish_default = "\n\n\n\n\n\nuniform sampler2D uFish;\n\nstruct Hit {\nfloat d;\nfloat u;\nfloat v;\nvec2 n;\n};\n\nvec4 F(int f, int i) { return texelFetch(uFish, ivec2(i, f), 0); }\n\n\n\nHit polyline(int f, int i0, int count, vec2 p) {\nHit h;\nh.d = 1e9;\nh.u = 0.0;\nh.v = 0.0;\nh.n = vec2(1.0, 0.0);\n\n\n\nfloat bu = 0.0, br = 0.0;\nvec2 bdq = vec2(0.0), bab = vec2(0.0), bap = vec2(0.0);\nvec4 A = F(f, i0);\nfor (int k = 0; k < count; k++) {\nvec4 B = F(f, i0 + k + 1);\nvec2 ab = B.xy - A.xy;\nvec2 ap = p - A.xy;\nfloat t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nvec2 dq = ap - ab * t;\nfloat dist = length(dq);\nfloat r = mix(A.z, B.z, t);\nfloat d = dist - r;\nbool w = d < h.d;\nh.d = w ? d : h.d;\nbu = w ? (float(k) + t) / float(count) : bu;\nbr = w ? r : br;\nbdq = w ? dq : bdq;\nbab = w ? ab : bab;\nbap = w ? ap : bap;\nA = B;\n}\n\n\nif (h.d < 1e9) {\nfloat dist = length(bdq);\nh.u = bu;\nfloat side = bab.x * bap.y - bab.y * bap.x < 0.0 ? -1.0 : 1.0;\nh.v = side * dist / max(br, 1e-3);\nh.n = dist > 1e-4 ? bdq / dist : normalize(vec2(-bab.y, bab.x) + 1e-6);\n}\nreturn h;\n}\n\n\nfloat finDist(vec2 p, vec2 a, vec2 b, float r0, float r1, out float t) {\nvec2 ab = b - a;\nvec2 ap = p - a;\nt = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nfloat r = mix(r0, r1, smoothstep(0.0, 0.5, t)) * (1.0 - 0.45 * smoothstep(0.55, 1.0, t));\nreturn length(ap - ab * t) - r;\n}\n\n\n\nfloat tailSD(int f, vec2 q, vec4 fin) {\nHit th = polyline(f, J_BODY - 1, J_TAIL, q);\nvec2 tip = F(f, J_TOTAL - 1).xy;\nvec2 tdir = normalize(tip - F(f, J_TOTAL - 2).xy + 1e-6);\nreturn max(max(th.d, (th.u - 1.0 + fin.w * (1.0 - min(abs(th.v), 1.0))) * fin.z), dot(q - tip, tdir));\n}\n";
2302
+ var markings_default = "\n\n\n\n\n\n\n\nuniform vec3 uKohakuWhite;\nuniform vec3 uHiRed;\nuniform vec3 uSumi;\nuniform vec3 uShowaWhite;\nuniform vec3 uOgonShade;\nuniform vec3 uOgonGlint;\nuniform vec3 uKohakuFin;\nuniform vec3 uKohakuTail;\nuniform vec3 uOgonFin;\nuniform vec3 uOgonTail;\n\n\n\nvec3 kohaku(float u, float v, float seed) {\nfloat n = fbm(vec2(u * 3.0, v * 0.8) + seed)\n+ 0.08\n+ 0.35 * exp(-sq((u - 0.14) / 0.08))\n- 0.45 * v * v\n- 0.7 * smoothstep(0.75, 0.92, u)\n- 0.8 * (1.0 - smoothstep(0.02, 0.07, u));\nreturn mix(uKohakuWhite, uHiRed, smoothstep(-0.02, 0.04, n));\n}\n\n\nvec3 showa(float u, float v, float seed, out float sumiAmt) {\nfloat s = fbm(vec2(u * 3.5 + v * 0.9, v * 1.4) + seed * 1.7) + 0.04;\nfloat r = fbm(vec2(u * 2.6, v * 0.9) + seed * 2.3 + 31.0)\n+ 0.06 - 0.3 * v * v + 0.2 * exp(-sq((u - 0.12) / 0.1));\nfloat sumi = smoothstep(0.0, 0.04, s);\nfloat hi = smoothstep(0.0, 0.04, r);\nsumiAmt = sumi * (1.0 - 0.5 * hi);\nreturn mix(mix(uShowaWhite, uHiRed, hi), uSumi, sumiAmt);\n}\n\n\n\nvec3 ogon(float u, float v, float seed, out float glint) {\nvec2 sc = vec2(u * 26.0, v * 4.0 + 4.0);\nvec2 id = floor(vec2(sc.x + 0.5 * mod(floor(sc.y), 2.0), sc.y));\nfloat ph = hash12(id + seed);\nfloat twinkle = pow(0.5 + 0.5 * sin(uTime * 2.3 * flicker() + ph * TAU + u * 7.0), 14.0) * step(0.55, ph);\nfloat band = exp(-sq((v - 0.35 * sin(uTime * 0.6 + u * 4.0 + seed)) * 2.2));\nglint = max(twinkle, band * 0.6);\nvec3 base = mix(uOgonShade, uOgonGold, 1.0 - abs(v));\nreturn mix(base, uOgonGlint, glint);\n}\n\nvec3 pectoralColor(int variety, float t) {\nif (variety == V_KOHAKU) return uKohakuFin;\nif (variety == V_SHOWA) return mix(uSumi, uShowaWhite, smoothstep(0.15, 0.45, t));\nreturn uOgonFin;\n}\n\nvec3 tailColor(int variety, float u, float v, float seed) {\nif (variety == V_KOHAKU) return uKohakuTail;\nif (variety == V_SHOWA) {\nfloat streak = smoothstep(0.1, 0.2, gnoise(vec2(v * 3.0, u * 1.5) + seed));\nreturn mix(uShowaWhite, uSumi, streak * (1.0 - u * 0.6));\n}\nreturn mix(uOgonGold, uOgonTail, u);\n}\n";
2303
+ var trails_default = "\n\n\n\n\n\nuniform vec3 uSilk;\nuniform vec3 uBubble;\nuniform vec3 uGoldDust;\n\n\nvoid trailSeg(vec2 p, vec4 A, vec4 B, float w0, bool live, int variety,\ninout float best, inout vec2 bestDir, inout int bestVariety) {\nvec2 ab = B.xy - A.xy;\nvec2 ap = p - A.xy;\nfloat t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nfloat dist = length(ap - ab * t);\nfloat age = mix(A.z, B.z, t);\nfloat w = w0 * (0.5 + 1.4 * age) + 0.3 * min(uCell.x, uCell.y);\nfloat I = pow(1.0 - age, 1.3) * (1.0 - smoothstep(w * 0.35, w, dist));\nif (live && I > best) {\nbest = I;\nbestDir = ab;\nbestVariety = variety;\n}\n}\n\n\n\n#if TRAIL_CHUNK % 4 != 0\n#error trails.glsl unrolls by 4\n#endif\n#if TRAIL_CHUNKS * TRAIL_CHUNK > TRAIL_N + TRAIL_CHUNKS - 1\n#error trails.glsl fetches past the fish row\n#endif\n\nvoid trails(vec2 p, inout Cell c) {\nfloat best = 0.0;\nvec2 bestDir = vec2(1.0, 0.0);\nint bestVariety = 0;\n\n\n\n\nfloat cellPad = 0.3 * min(uCell.x, uCell.y);\nfor (int f = 0; f < MAX_FISH; f++) {\nif (f >= uFishCount) break;\nif (!inBox(p, F(f, T_TBOX), cellPad)) continue;\nvec4 P1 = F(f, T_P1);\nint n = int(P1.x + 0.5);\nfloat w0 = P1.y;\nint variety = int(F(f, T_P0).x + 0.5);\n\n\n\n\nfor (int ch = 0; 1 + ch * TRAIL_CHUNK < n; ch++) {\nint k0 = 1 + ch * TRAIL_CHUNK;\nif (!inBox(p, F(f, T_CBOX + ch), cellPad)) continue;\nvec4 A = F(f, T_TRAIL + k0 - 1);\nfor (int j = 0; j < TRAIL_CHUNK; j += 4) {\nint k = k0 + j;\nif (k >= n) break;\n\n\n\nvec4 B0 = F(f, T_TRAIL + k), B1 = F(f, T_TRAIL + k + 1);\nvec4 B2 = F(f, T_TRAIL + k + 2), B3 = F(f, T_TRAIL + k + 3);\ntrailSeg(p, A, B0, w0, true, variety, best, bestDir, bestVariety);\ntrailSeg(p, B0, B1, w0, k + 1 < n, variety, best, bestDir, bestVariety);\ntrailSeg(p, B1, B2, w0, k + 2 < n, variety, best, bestDir, bestVariety);\ntrailSeg(p, B2, B3, w0, k + 3 < n, variety, best, bestDir, bestVariety);\nA = B3;\n}\n}\n}\nif (best < 0.05) return;\n\nvec2 cell = floor(p / uCell);\nvec3 col;\nif (bestVariety == V_KOHAKU) {\n\nbest *= 0.7 + 0.6 * gnoise(p * 0.035 - uTime * 0.5);\ncol = uSilk;\n} else if (bestVariety == V_SHOWA) {\n\nfloat hb = hash12(cell * 1.37 + 5.0);\nbest *= step(0.62, hb) * (0.7 + 0.6 * hb);\ncol = uBubble;\n} else {\n\nfloat tw = hash12(cell + floor(uTime * 7.0 * flicker()) * 0.731);\nbest *= step(0.5, tw) * 1.2;\ncol = uGoldDust;\n}\nif (best < 0.12) return;\nc.mat = M_TRAIL;\nc.dens = min(best, 1.0);\n\nc.col = (bestVariety == V_KOHAKU ? col * (0.6 + 0.4 * smoothstep(0.1, 0.6, best)) : mix(uWaterLit, col, smoothstep(0.1, 0.6, best))) * uWaterTint;\nc.dir = lineDir(bestDir);\nc.aux = 0.0;\nc.style = float(bestVariety);\n}\n";
2304
+ var compose_default = "\n\n\n\n\n\n\n\n\nuniform vec2 uShadow;\nuniform float uShadowAlpha;\nuniform vec3 uEyeCol;\n\n\nuniform vec3 uSumiRim;\nuniform vec3 uAddTint;\n\n\nconst float SPEC_POW = 18.0;\n\nfloat shadow(int f, vec2 p) {\nfloat lift = 1.25 - F(f, T_P0).w;\nvec2 q = p - uShadow * lift;\nfloat blur = max(uCell.x, uCell.y) * (0.5 + 1.5 * lift);\nif (!inBox(q, F(f, T_BBOX), blur)) return 0.0;\nfloat d = min(polyline(f, 0, J_BODY - 1, q).d,\npolyline(f, J_BODY - 1, J_TAIL, q).d + max(uCell.x, uCell.y) * 0.5);\nreturn (1.0 - smoothstep(-blur, blur, d)) * (0.95 - 0.35 * lift) * uShadowAlpha;\n}\n\n\nvec2 refrAt(vec2 x) {\nfloat e = uRipplePx;\nreturn vec2(rippleAt(x + vec2(e, 0.0)) - rippleAt(x - vec2(e, 0.0)),\nrippleAt(x + vec2(0.0, e)) - rippleAt(x - vec2(0.0, e))) / (2.0 * e) * REFRACT;\n}\n\nvoid drawFish(int f, vec2 p, vec2 refr, inout Cell c) {\nvec4 P0 = F(f, T_P0);\nint variety = int(P0.x + 0.5);\nfloat seed = P0.y;\nfloat depth = P0.w;\n\nvec2 q = p + refr * (0.5 + depth);\nif (!inBox(q, F(f, T_BBOX), max(uCell.x, uCell.y) * 1.5)) return;\nfloat fog = 0.55 * depth;\nvec3 fogCol = uWaterDim * uWaterTint;\nfloat fade = 1.0 - 0.3 * depth;\nfloat style = float(variety);\nvec4 fin = F(f, T_FIN);\n\nvec3 flatLight = lightOn(vec3(0.0, 0.0, 1.0));\n\n\n\nfloat reach = max(uCell.x, uCell.y);\nvec2 ex = vec2(uCell.x * 0.5, 0.0);\nvec2 ey = vec2(0.0, uCell.y * 0.5);\n\n\nfor (int s = 0; s < 2; s++) {\nvec4 pf = F(f, T_PEC_L + s);\nfloat t, t2;\nfloat d = finDist(q, pf.xy, pf.zw, fin.x, fin.y, t);\nif (d > reach) continue;\nvec2 gr = vec2(finDist(q + ex, pf.xy, pf.zw, fin.x, fin.y, t2) - finDist(q - ex, pf.xy, pf.zw, fin.x, fin.y, t2),\nfinDist(q + ey, pf.xy, pf.zw, fin.x, fin.y, t2) - finDist(q - ey, pf.xy, pf.zw, fin.x, fin.y, t2)) / uCell;\nvec2 n = normalize(gr + 1e-6);\nfloat cv = coverage(d, n);\nif (cv > 0.25) {\nc.col = mix(pectoralColor(variety, t) * flatLight, fogCol, fog);\nc.aux = 0.0;\nc.style = style;\nif (cv < 0.8) {\nc.mat = M_EDGE;\nc.dens = cv;\nc.dir = cellNormal(n);\n} else {\nc.mat = M_FIN;\nc.col *= 0.75;\nc.dens = 0.6 * fade;\nc.dir = lineDir(q - pf.xy);\n}\n}\n}\n\n\n\nfloat td = tailSD(f, q, fin);\nif (td < reach) {\nvec2 gr = vec2(tailSD(f, q + ex, fin) - tailSD(f, q - ex, fin),\ntailSD(f, q + ey, fin) - tailSD(f, q - ey, fin)) / uCell;\nvec2 n = normalize(gr + 1e-6);\nfloat tcv = coverage(td, n);\nif (tcv > 0.25) {\nHit th = polyline(f, J_BODY - 1, J_TAIL, q);\nc.col = mix(tailColor(variety, th.u, th.v, seed) * flatLight, fogCol, fog);\nc.aux = 0.0;\nc.style = style;\nif (tcv < 0.8) {\nc.mat = M_EDGE;\nc.dens = tcv;\nc.dir = cellNormal(n);\n} else {\nc.mat = M_FIN;\nc.col *= 0.75;\nc.dens = (0.65 - 0.2 * th.u) * fade;\nc.dir = lineDir(q - F(f, J_BODY - 1).xy);\n}\n}\n}\n\n\nHit bh = polyline(f, 0, J_BODY - 1, q);\nfloat bcv = coverage(bh.d, bh.n);\nif (bcv > 0.18) {\nfloat glint = 0.0;\nfloat sumiAmt = 0.0;\nvec3 bc = variety == V_KOHAKU ? kohaku(bh.u, bh.v, seed)\n: variety == V_SHOWA ? showa(bh.u, bh.v, seed, sumiAmt)\n: ogon(bh.u, bh.v, seed, glint);\n\n\nfloat a = min(abs(bh.v), 1.0) * 0.92;\nvec3 N = vec3(bh.n * a, sqrt(1.0 - a * a));\nfloat ndl = dot(N, diffuseDir());\nvec3 lit = lightOn(N);\n\n\n\nvec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\nfloat spec = pow(max(dot(N, H), 0.0), SPEC_POW);\nvec3 hue = lightHue() * length(uLightCol) * 0.82 * uAddTint;\nvec3 hi = variety == V_OGON ? hue * uOgonGold * 0.9 : hue * (variety == V_SHOWA ? 0.32 + 0.25 * sumiAmt : 0.3);\n\nvec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\nfloat rim = a * a * a * max(-dot(bh.n, az), 0.0) * (0.35 + 0.65 * (1.0 - uLightDir.z));\n\n\nfloat cst = smoothstep(0.5, 0.95, caustic(q * 0.014 + refr * 0.012 + seed, uTime * 0.45));\nvec3 caust = uLightCol * uAddTint * cst * uCaustic * 0.4 * max(ndl, 0.0) * (1.0 - depth);\nc.col = mix(bc * lit + hi * spec + uLightCol * uAddTint * rim * 0.6 + caust, fogCol, fog);\n\nfloat shade = mix(0.58, 1.05, smoothstep(-0.2, 0.9, ndl)) + 0.25 * spec;\nc.style = style;\nif (bcv > 0.8) {\n\n\nfloat Yl = dot(bc * bc, vec3(0.2126, 0.7152, 0.0722));\nfloat Ls = 1.16 * pow(Yl, 1.0 / 3.0) - 0.16;\nc.mat = M_BODY;\nc.dens = mix(0.6, 1.0, Ls) * shade * fade;\nc.aux = glint;\n} else {\nc.mat = M_EDGE;\nc.dens = bcv;\nc.dir = cellNormal(bh.n);\nc.aux = 0.0;\nc.col = mix(c.col, mix(uSumiRim * lit, fogCol, fog), 0.65 * sumiAmt);\n}\n}\n\n\nvec4 E = F(f, T_EYE);\n\n\n\n\nvec2 eyeReach = 0.5 * uCell + REFRACT / uRipplePx * abs(0.5 + depth) + 1.0;\nfor (int s = 0; s < 2; s++) {\nvec2 e = s == 0 ? E.xy : E.zw;\nbool hit = length(q - e) < P0.z;\nif (!hit && all(lessThanEqual(abs(p - e), eyeReach))) {\n\n\nvec2 de = abs(p + refrAt(e) * (0.5 + depth) - e);\nhit = de.x < uCell.x * 0.5 && de.y < uCell.y * 0.5;\n}\nif (hit) {\nc.mat = M_EYE;\nc.col = mix(uEyeCol * max(flatLight, vec3(0.75)), fogCol, fog * 0.5);\nc.dens = 1.0;\nc.aux = 0.0;\nc.style = style;\n}\n}\n}\n";
2305
+ var glyphs_default = "\n\n\n\n\nconst int BODY_N = 6;\nconst int BODY_RAMP[BODY_N] = int[BODY_N](G_EQUALS, G_PLUS, G_STAR, G_HASH, G_PERCENT, G_AT);\n\nfloat bayer4(ivec2 c) {\nconst int B[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);\nreturn (float(B[(c.x & 3) + (c.y & 3) * 4]) + 0.5) / 16.0;\n}\n\n\nint lineGlyph(vec2 d, int horizontal, int vertical) {\nfloat a = atan(d.y, d.x);\nif (a < 0.0) a += PI;\nint s = int(floor(a / (PI * 0.25) + 0.5)) % 4;\nif (s == 0) return horizontal;\nif (s == 1) return G_BSLASH;\nif (s == 2) return vertical;\nreturn G_SLASH;\n}\n\n\n\nint edgeGlyph(vec2 n, float cov) {\nint o = int(floor((atan(n.y, n.x) + TAU) / (PI * 0.25) + 0.5)) % 8;\nif (o == 0) return G_RPAREN;\nif (o == 4) return G_LPAREN;\nif (o == 1 || o == 5) return G_SLASH;\nif (o == 3 || o == 7) return G_BSLASH;\nif (o == 2) return cov > 0.5 ? G_UNDERSCORE : G_MINUS;\nreturn cov > 0.5 ? G_MINUS : G_UNDERSCORE;\n}\n\nint pickGlyph(Cell c, ivec2 cell) {\nfloat dens = clamp(c.dens, 0.0, 1.0);\nfloat aux = clamp(c.aux, 0.0, 1.0);\nint style = int(c.style + 0.5);\nif (c.mat == M_WATER) {\nif (dens < 0.12) return G_SPACE;\nif (dens < 0.6) return aux < 0.4 ? G_DOT : aux < 0.65 ? G_COMMA : aux < 0.85 ? G_BACKTICK : G_QUOTE;\nreturn G_COLON;\n}\nif (c.mat == M_RIPPLE) {\nif (dens < 0.45) return G_DOT;\nreturn lineGlyph(c.dir, aux > 0.5 ? G_TILDE : G_MINUS, G_PIPE);\n}\nif (c.mat == M_TRAIL) {\nif (style == V_SHOWA) return dens > 0.55 ? G_O : G_DOT;\nif (style == V_OGON) return dens > 0.65 ? G_STAR : dens > 0.35 ? G_QUOTE : G_DOT;\nif (dens < 0.3) return G_DOT;\nif (dens < 0.55) return G_MINUS;\nreturn lineGlyph(c.dir, G_TILDE, G_COLON);\n}\nif (c.mat == M_FIN) {\nif (dens < 0.3) return G_DOT;\nif (dens < 0.45) return G_QUOTE;\nreturn lineGlyph(c.dir, style == V_OGON ? G_TILDE : G_MINUS, G_PIPE);\n}\nif (c.mat == M_EDGE) return edgeGlyph(c.dir, dens);\nif (c.mat == M_BODY) {\nif (aux > 0.75) return G_STAR;\nfloat x = smoothstep(0.3, 0.95, dens) * float(BODY_N - 1) + (bayer4(cell) - 0.5) * 0.5;\nreturn BODY_RAMP[clamp(int(floor(x + 0.5)), 0, BODY_N - 1)];\n}\nif (c.mat == M_EYE) return G_O;\nif (c.mat == M_STAR) {\n\nif (dens > 0.95) return G_STAR;\nif (dens > 0.65) return G_PLUS;\nif (dens > 0.4) return aux < 0.5 ? G_QUOTE : G_BACKTICK;\nreturn G_DOT;\n}\nif (c.mat == M_MOON) {\nif (style == 2) return lineGlyph(c.dir, G_EQUALS, G_PIPE);\nif (style == 1) return edgeGlyph(c.dir, dens);\nreturn dens < 0.33 ? G_DOT : dens < 0.56 ? G_COLON : dens < 0.67 ? G_O : G_BIG_O;\n}\nif (c.mat == M_SUN) return dens > 0.8 ? G_STAR : dens > 0.6 ? G_PLUS : G_DOT;\nif (c.mat == M_GLINT) {\n\nif (aux > 0.55) return dens > 0.6 ? lineGlyph(c.dir, dens > 0.85 ? G_EQUALS : G_MINUS, G_PIPE) : G_DOT;\nreturn dens > 0.75 ? G_STAR : G_PLUS;\n}\nreturn G_SPACE;\n}\n";
2306
+ var avoid_default = "\n\n\n\n\n\n\n\n\n\n#define AVOID_MAX 16\n\nuniform int uAvoidN;\nuniform vec4 uAvoid[AVOID_MAX];\nuniform vec4 uAvoidR[AVOID_MAX / 4];\nuniform vec4 uAvoidFade;\n\n\nfloat avoidDist(vec2 p) {\nfloat d = 1e9;\n\nfor (int i = 0; i < uAvoidN; i++) {\nvec2 hs = uAvoid[i].zw * 0.5;\nfloat r = min(uAvoidR[i >> 2][i & 3], min(hs.x, hs.y));\nvec2 q = abs(p - uAvoid[i].xy - hs) - hs + r;\nd = min(d, length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r);\n}\nreturn d;\n}\n\n\n\nfloat avoidMask(float d) {\nreturn 1.0 - smoothstep(0.0, max(uAvoidFade.z, 0.5), d);\n}\n\n\n\nfloat avoidThin(float m, int mat) {\nreturn 1.0 - (avoidWater(mat) ? uAvoidFade.x : uAvoidFade.y) * m;\n}\n";
2079
2307
  /** Each chunk's file name and source, in compile order. */
2080
2308
  var sceneChunks = [
2081
- ["uniforms.glsl", "\n\n\nuniform float uRipplePx;\nuniform int uFishCount;\nuniform vec2 uCell;\nuniform float uTime;\n\n\n\nuniform vec3 uLightDir;\nuniform vec3 uLightCol;\nuniform vec3 uWaterTint;\nuniform float uCaustic;\nuniform float uCalm;\n\n\n\nuniform vec3 uWaterDim;\nuniform vec3 uWaterLit;\nuniform vec3 uOgonGold;\nuniform vec3 uGlintCol;\nuniform float uRoles;\n\n\nconst float RIPPLE_GAIN = 28.0;\n\n\nconst float REFRACT = 220.0;\n"],
2082
- ["light.glsl", "\n\n\n\n\n\n\nuniform vec3 uAmbient;\nuniform float uFloor;\n\n\nconst float CALM_FLICKER = 0.3;\n\nfloat flicker() { return mix(1.0, CALM_FLICKER, uCalm); }\n\n\n\n\n\nvec3 diffuseDir() {\nreturn normalize(vec3(uLightDir.xy, max(uLightDir.z, 0.5)));\n}\n\n\n\nvec3 lightOn(vec3 n) {\nreturn max(uAmbient + uLightCol * max(dot(n, diffuseDir()), 0.0), vec3(uFloor));\n}\n\n\nvec3 lightHue() {\nreturn uLightCol / max(max(uLightCol.r, max(uLightCol.g, uLightCol.b)), 1e-3);\n}\n\n\n\nvec3 mirrorHue() {\nreturn mix(lightHue(), vec3(1.0), 0.4);\n}\n"],
2083
- ["cell.glsl", "\n\n\n\n\n\nstruct Cell {\nvec3 col;\nfloat dens;\nint mat;\nvec2 dir;\nfloat aux;\nfloat style;\n};\n\n\nvec2 lineDir(vec2 d) {\nvec2 c = d / uCell;\nfloat l = length(c);\nreturn l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\nvec2 cellNormal(vec2 n) {\nvec2 c = n * uCell;\nfloat l = length(c);\nreturn l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\n\nfloat coverage(float d, vec2 n) {\nfloat span = abs(n.x) * uCell.x + abs(n.y) * uCell.y;\nreturn clamp(0.5 - d / span, 0.0, 1.0);\n}\n\nfloat sq(float x) { return x * x; }\n\nbool inBox(vec2 p, vec4 box, float pad) {\nreturn all(greaterThanEqual(p, box.xy - pad)) && all(lessThanEqual(p, box.zw + pad));\n}\n"],
2084
- ["ripples.glsl", "\n\n\n\n\n\nuniform sampler2D uRipple;\nuniform ivec2 uRippleSize;\n\nfloat rippleTexel(ivec2 c) {\nc = clamp(c, ivec2(0), uRippleSize - 1);\n#ifdef RIPPLE_PACKED\nreturn dec16(texelFetch(uRipple, c, 0).rg);\n#else\nreturn texelFetch(uRipple, c, 0).r;\n#endif\n}\n\nfloat rippleAt(vec2 p) {\nvec2 t = p / uRipplePx - 0.5;\nvec2 i = floor(t);\nvec2 f = t - i;\nivec2 c = ivec2(i);\nreturn mix(mix(rippleTexel(c), rippleTexel(c + ivec2(1, 0)), f.x),\nmix(rippleTexel(c + ivec2(0, 1)), rippleTexel(c + ivec2(1, 1)), f.x), f.y);\n}\n"],
2085
- ["water.glsl", "\n\n\n\n\n\n\n\nuniform vec3 uSky;\nuniform float uGlint;\nuniform float uDarkWater;\nuniform float uDarkCaustic;\nuniform float uMoteThin;\nuniform vec3 uRippleCol;\n\n\n\n\n\nconst float GLINT_TILT = 2.0;\n\n\nfloat caustic(vec2 x, float t) {\nvec2 i = floor(x);\nvec2 f = fract(x);\nfloat f1 = 8.0;\nfloat f2 = 8.0;\nfor (int y = -1; y <= 1; y++) {\nfor (int k = -1; k <= 1; k++) {\nvec2 b = vec2(float(k), float(y));\nvec2 o = 0.5 + 0.42 * sin(t + TAU * hash22(i + b));\nfloat d = length(b + o - f);\nif (d < f1) {\nf2 = f1;\nf1 = d;\n} else if (d < f2) {\nf2 = d;\n}\n}\n}\nreturn 1.0 - smoothstep(0.0, 0.22, f2 - f1);\n}\n\nCell water(vec2 p, float h, vec2 g) {\nCell c;\n\nvec2 mp = floor((p + vec2(7.0, -4.0) * uTime) / uCell);\n\nfloat mote = step(mix(0.96, 0.99, uMoteThin), hash12(mp)) * (0.4 + 0.6 * hash12(mp + 17.0));\n\n\n\nfloat shimmer = 0.8 + 0.6 * fbm(p * 0.0016 + vec2(uTime * 0.012, -uTime * 0.008));\nfloat lit = smoothstep(0.45, 0.95, caustic(p * 0.011 + g * 3.0, uTime * 0.45) * shimmer);\nc.mat = M_WATER;\n\nc.dens = max(mote * 0.5, lit * mix(0.6, 1.0, uCaustic));\n\nc.col = mix(uWaterDim, uWaterLit, 0.6 * lit * min(uCaustic, 1.0)) * uWaterTint + uSky;\n\n\nif (uDarkWater < 1.0) c.col *= uDarkWater;\nif (uDarkCaustic < 1.0) c.dens *= uDarkCaustic;\nc.aux = hash12(mp + 3.1);\nc.dir = vec2(1.0, 0.0);\nc.style = 0.0;\n\nfloat rip = smoothstep(0.15, 0.6, length(g) * RIPPLE_GAIN);\nif (rip > 0.25) {\nc.mat = M_RIPPLE;\nc.dens = rip;\nc.col = mix(uWaterLit, uRippleCol, rip) * uWaterTint + uSky;\nc.dir = lineDir(vec2(-g.y, g.x));\nc.aux = h > 0.0 ? 1.0 : 0.0;\n}\n\n\n\n\nvec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\nvec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\nvec2 across = vec2(-az.y, az.x);\nfloat low = 1.0 - uLightDir.z;\nvec2 e = -g * RIPPLE_GAIN * GLINT_TILT - H.xy / H.z;\nfloat ea = dot(e, az);\nfloat ec = dot(e, across);\n\n\nfloat gl = exp(-sq(ea / (0.3 + 0.4 * low)) - sq(ec / (0.3 + 0.8 * low)));\ngl *= smoothstep(0.06, 0.25, length(g) * RIPPLE_GAIN);\n\n\nvec2 r = vec2(dot(p, az), dot(p, across)) / (max(uCell.x, uCell.y) * vec2(1.0 + 5.0 * low, 1.0));\nfloat tw = hash12(floor(r) + floor(uTime * 6.0 * flicker()) * 0.613);\ngl *= uGlint * (0.45 + 1.1 * tw);\nif (gl > 0.45) {\nc.mat = M_GLINT;\nc.dens = min(gl, 1.0);\n\n\nvec3 gc = uRoles > 0.5 ? uGlintCol : mirrorHue();\nc.col = gc * mix(0.8, 1.0, smoothstep(0.45, 1.0, gl));\nc.dir = lineDir(az);\nc.aux = low;\n}\nreturn c;\n}\n"],
2086
- ["sky.glsl", "\n\n\n\n\n\n\nuniform float uStars;\nuniform vec2 uView;\n\n\n\nuniform vec4 uMoon;\n\n\n\nuniform vec4 uSun;\nuniform vec3 uStarCool;\nuniform vec3 uStarWarm;\nuniform vec3 uMoonCol;\nuniform vec3 uSunCol;\n\n\n\nconst float MOON_GAIN = 0.85;\n\n\n\n\nconst float SUN_THIN = 0.5;\nconst vec2 SUN_GAIN = vec2(0.6, 0.9);\n\n\n\nconst float SUN_MIN = 0.45;\nconst float SUN_CORE = 0.115;\n\n\n\n\nvoid stars(vec2 p, vec2 g, inout Cell c) {\nif (uStars < 0.02 || c.mat != M_WATER) return;\nvec2 cell = floor((p + g * REFRACT * 2.0) / uCell);\n\nif (hash12(cell * 0.917 + 41.0) < 0.984) return;\nfloat mag = hash12(cell + 7.7);\nfloat ph = hash12(cell + 2.3) * TAU;\n\n\nfloat tw = 0.75 + 0.25 * sin(uTime * (0.8 + 1.6 * mag) + ph);\ntw += 0.5 * pow(max(sin(uTime * 0.37 + ph * 3.0), 0.0), 40.0);\ntw = mix(tw, 0.8, uCalm);\nfloat v = mag * tw * uStars;\nif (v < 0.15) return;\nc.mat = M_STAR;\nc.dens = v;\nc.aux = hash12(cell + 9.1);\nc.style = 0.0;\n\n\nfloat k = 0.35 + 0.6 * min(v, 1.0);\nvec3 col = mix(uStarCool, uStarWarm, c.aux);\nc.col = uRoles > 0.5 ? mix(uWaterDim, col, k) : col * k;\n}\n\n\n\n\n\nvoid moon(vec2 p, vec2 g, inout Cell c) {\nfloat a = uMoon.w;\nif (a < 0.02) return;\nfloat R = uMoon.z * min(uView.x, uView.y);\n\nvec2 wob = vec2(sin(p.y * 0.21 + uTime * 1.3 * flicker()) * 1.2, 0.0);\nvec2 dq = p + g * REFRACT * 3.0 + wob - uMoon.xy * uView;\nfloat dist = length(dq);\nif (dist > R * 1.4) return;\nvec2 n = dist > 1e-3 ? dq / dist : vec2(1.0, 0.0);\nfloat cv = coverage(dist - R, n);\nif (cv < 0.25) return;\nfloat r = dist / R;\nfloat maria = smoothstep(-0.05, 0.2, fbm(dq / R * 1.7 + 5.0));\nfloat lum = (0.95 - 0.45 * maria) * (1.0 - 0.25 * r * r);\nc.mat = M_MOON;\nc.aux = 0.0;\n\n\nfloat k = (0.35 + 0.5 * lum) * MOON_GAIN * a;\nc.col = uRoles > 0.5 ? mix(uWaterDim, uMoonCol, k) : uMoonCol * k;\nif (length(g) * RIPPLE_GAIN > 0.35) {\nc.style = 2.0;\nc.dens = a;\nc.dir = lineDir(vec2(-g.y, g.x));\nc.col = mix(c.col, uRoles > 0.5 ? uGlintCol : vec3(1.0), 0.3 * a);\n} else if (cv < 0.8) {\nc.style = 1.0;\nc.dens = cv;\nc.dir = cellNormal(n);\n} else {\nc.style = 0.0;\nc.dens = 0.72 * lum * a;\n}\n}\n\n\n\n\n\n\nvoid sun(vec2 p, vec2 g, inout Cell c) {\nfloat a = uSun.w;\nif (a < 0.02 || c.mat == M_GLINT) return;\nfloat R = uSun.z * min(uView.x, uView.y);\nvec2 dq = p + g * REFRACT * 3.0 - uSun.xy * uView;\nfloat r = length(dq) / R;\nif (r > 1.0) return;\nvec2 cell = floor(p / uCell);\n\n\nfloat flash = floor(uTime * 5.0 * flicker());\n\nif (hash12(cell * 1.31 + flash * 0.29 + 3.0) > (1.0 - SUN_THIN) * a) return;\nfloat tw = hash12(cell + flash * 0.613);\ntw = mix(tw, hash12(cell + 0.37), uCalm * 0.7);\nfloat v = (1.0 - smoothstep(SUN_CORE, 1.0, r)) * (0.35 + 0.9 * tw);\nif (v < SUN_MIN) return;\nc.mat = M_SUN;\nc.dens = min(v, 1.0);\nc.aux = 0.0;\nc.style = 0.0;\n\n\nfloat k = mix(SUN_GAIN.x, SUN_GAIN.y, smoothstep(SUN_MIN, 1.0, v));\nc.col = uRoles > 0.5 ? mix(uWaterDim, uSunCol, k) : uSunCol * mirrorHue() * k;\n}\n"],
2087
- ["fish.glsl", "\n\n\n\n\n\nuniform sampler2D uFish;\n\nstruct Hit {\nfloat d;\nfloat u;\nfloat v;\nvec2 n;\n};\n\nvec4 F(int f, int i) { return texelFetch(uFish, ivec2(i, f), 0); }\n\n\n\nHit polyline(int f, int i0, int count, vec2 p) {\nHit h;\nh.d = 1e9;\nh.u = 0.0;\nh.v = 0.0;\nh.n = vec2(1.0, 0.0);\n\n\n\nfloat bu = 0.0, br = 0.0;\nvec2 bdq = vec2(0.0), bab = vec2(0.0), bap = vec2(0.0);\nvec4 A = F(f, i0);\nfor (int k = 0; k < count; k++) {\nvec4 B = F(f, i0 + k + 1);\nvec2 ab = B.xy - A.xy;\nvec2 ap = p - A.xy;\nfloat t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nvec2 dq = ap - ab * t;\nfloat dist = length(dq);\nfloat r = mix(A.z, B.z, t);\nfloat d = dist - r;\nbool w = d < h.d;\nh.d = w ? d : h.d;\nbu = w ? (float(k) + t) / float(count) : bu;\nbr = w ? r : br;\nbdq = w ? dq : bdq;\nbab = w ? ab : bab;\nbap = w ? ap : bap;\nA = B;\n}\n\n\nif (h.d < 1e9) {\nfloat dist = length(bdq);\nh.u = bu;\nfloat side = bab.x * bap.y - bab.y * bap.x < 0.0 ? -1.0 : 1.0;\nh.v = side * dist / max(br, 1e-3);\nh.n = dist > 1e-4 ? bdq / dist : normalize(vec2(-bab.y, bab.x) + 1e-6);\n}\nreturn h;\n}\n\n\nfloat finDist(vec2 p, vec2 a, vec2 b, float r0, float r1, out float t) {\nvec2 ab = b - a;\nvec2 ap = p - a;\nt = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nfloat r = mix(r0, r1, smoothstep(0.0, 0.5, t)) * (1.0 - 0.45 * smoothstep(0.55, 1.0, t));\nreturn length(ap - ab * t) - r;\n}\n\n\n\nfloat tailSD(int f, vec2 q, vec4 fin) {\nHit th = polyline(f, J_BODY - 1, J_TAIL, q);\nvec2 tip = F(f, J_TOTAL - 1).xy;\nvec2 tdir = normalize(tip - F(f, J_TOTAL - 2).xy + 1e-6);\nreturn max(max(th.d, (th.u - 1.0 + fin.w * (1.0 - min(abs(th.v), 1.0))) * fin.z), dot(q - tip, tdir));\n}\n"],
2088
- ["markings.glsl", "\n\n\n\n\n\n\n\nuniform vec3 uKohakuWhite;\nuniform vec3 uHiRed;\nuniform vec3 uSumi;\nuniform vec3 uShowaWhite;\nuniform vec3 uOgonShade;\nuniform vec3 uOgonGlint;\nuniform vec3 uKohakuFin;\nuniform vec3 uKohakuTail;\nuniform vec3 uOgonFin;\nuniform vec3 uOgonTail;\n\n\n\nvec3 kohaku(float u, float v, float seed) {\nfloat n = fbm(vec2(u * 3.0, v * 0.8) + seed)\n+ 0.08\n+ 0.35 * exp(-sq((u - 0.14) / 0.08))\n- 0.45 * v * v\n- 0.7 * smoothstep(0.75, 0.92, u)\n- 0.8 * (1.0 - smoothstep(0.02, 0.07, u));\nreturn mix(uKohakuWhite, uHiRed, smoothstep(-0.02, 0.04, n));\n}\n\n\nvec3 showa(float u, float v, float seed, out float sumiAmt) {\nfloat s = fbm(vec2(u * 3.5 + v * 0.9, v * 1.4) + seed * 1.7) + 0.04;\nfloat r = fbm(vec2(u * 2.6, v * 0.9) + seed * 2.3 + 31.0)\n+ 0.06 - 0.3 * v * v + 0.2 * exp(-sq((u - 0.12) / 0.1));\nfloat sumi = smoothstep(0.0, 0.04, s);\nfloat hi = smoothstep(0.0, 0.04, r);\nsumiAmt = sumi * (1.0 - 0.5 * hi);\nreturn mix(mix(uShowaWhite, uHiRed, hi), uSumi, sumiAmt);\n}\n\n\n\nvec3 ogon(float u, float v, float seed, out float glint) {\nvec2 sc = vec2(u * 26.0, v * 4.0 + 4.0);\nvec2 id = floor(vec2(sc.x + 0.5 * mod(floor(sc.y), 2.0), sc.y));\nfloat ph = hash12(id + seed);\nfloat twinkle = pow(0.5 + 0.5 * sin(uTime * 2.3 * flicker() + ph * TAU + u * 7.0), 14.0) * step(0.55, ph);\nfloat band = exp(-sq((v - 0.35 * sin(uTime * 0.6 + u * 4.0 + seed)) * 2.2));\nglint = max(twinkle, band * 0.6);\nvec3 base = mix(uOgonShade, uOgonGold, 1.0 - abs(v));\nreturn mix(base, uOgonGlint, glint);\n}\n\nvec3 pectoralColor(int variety, float t) {\nif (variety == V_KOHAKU) return uKohakuFin;\nif (variety == V_SHOWA) return mix(uSumi, uShowaWhite, smoothstep(0.15, 0.45, t));\nreturn uOgonFin;\n}\n\nvec3 tailColor(int variety, float u, float v, float seed) {\nif (variety == V_KOHAKU) return uKohakuTail;\nif (variety == V_SHOWA) {\nfloat streak = smoothstep(0.1, 0.2, gnoise(vec2(v * 3.0, u * 1.5) + seed));\nreturn mix(uShowaWhite, uSumi, streak * (1.0 - u * 0.6));\n}\nreturn mix(uOgonGold, uOgonTail, u);\n}\n"],
2089
- ["trails.glsl", "\n\n\n\n\n\nuniform vec3 uSilk;\nuniform vec3 uBubble;\nuniform vec3 uGoldDust;\n\n\nvoid trailSeg(vec2 p, vec4 A, vec4 B, float w0, bool live, int variety,\ninout float best, inout vec2 bestDir, inout int bestVariety) {\nvec2 ab = B.xy - A.xy;\nvec2 ap = p - A.xy;\nfloat t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\nfloat dist = length(ap - ab * t);\nfloat age = mix(A.z, B.z, t);\nfloat w = w0 * (0.5 + 1.4 * age) + 0.3 * min(uCell.x, uCell.y);\nfloat I = pow(1.0 - age, 1.3) * (1.0 - smoothstep(w * 0.35, w, dist));\nif (live && I > best) {\nbest = I;\nbestDir = ab;\nbestVariety = variety;\n}\n}\n\n\n\n#if TRAIL_CHUNK % 4 != 0\n#error trails.glsl unrolls by 4\n#endif\n#if TRAIL_CHUNKS * TRAIL_CHUNK > TRAIL_N + TRAIL_CHUNKS - 1\n#error trails.glsl fetches past the fish row\n#endif\n\nvoid trails(vec2 p, inout Cell c) {\nfloat best = 0.0;\nvec2 bestDir = vec2(1.0, 0.0);\nint bestVariety = 0;\n\n\n\n\nfloat cellPad = 0.3 * min(uCell.x, uCell.y);\nfor (int f = 0; f < MAX_FISH; f++) {\nif (f >= uFishCount) break;\nif (!inBox(p, F(f, T_TBOX), cellPad)) continue;\nvec4 P1 = F(f, T_P1);\nint n = int(P1.x + 0.5);\nfloat w0 = P1.y;\nint variety = int(F(f, T_P0).x + 0.5);\n\n\n\n\nfor (int ch = 0; 1 + ch * TRAIL_CHUNK < n; ch++) {\nint k0 = 1 + ch * TRAIL_CHUNK;\nif (!inBox(p, F(f, T_CBOX + ch), cellPad)) continue;\nvec4 A = F(f, T_TRAIL + k0 - 1);\nfor (int j = 0; j < TRAIL_CHUNK; j += 4) {\nint k = k0 + j;\nif (k >= n) break;\n\n\n\nvec4 B0 = F(f, T_TRAIL + k), B1 = F(f, T_TRAIL + k + 1);\nvec4 B2 = F(f, T_TRAIL + k + 2), B3 = F(f, T_TRAIL + k + 3);\ntrailSeg(p, A, B0, w0, true, variety, best, bestDir, bestVariety);\ntrailSeg(p, B0, B1, w0, k + 1 < n, variety, best, bestDir, bestVariety);\ntrailSeg(p, B1, B2, w0, k + 2 < n, variety, best, bestDir, bestVariety);\ntrailSeg(p, B2, B3, w0, k + 3 < n, variety, best, bestDir, bestVariety);\nA = B3;\n}\n}\n}\nif (best < 0.05) return;\n\nvec2 cell = floor(p / uCell);\nvec3 col;\nif (bestVariety == V_KOHAKU) {\n\nbest *= 0.7 + 0.6 * gnoise(p * 0.035 - uTime * 0.5);\ncol = uSilk;\n} else if (bestVariety == V_SHOWA) {\n\nfloat hb = hash12(cell * 1.37 + 5.0);\nbest *= step(0.62, hb) * (0.7 + 0.6 * hb);\ncol = uBubble;\n} else {\n\nfloat tw = hash12(cell + floor(uTime * 7.0 * flicker()) * 0.731);\nbest *= step(0.5, tw) * 1.2;\ncol = uGoldDust;\n}\nif (best < 0.12) return;\nc.mat = M_TRAIL;\nc.dens = min(best, 1.0);\n\nc.col = (bestVariety == V_KOHAKU ? col * (0.6 + 0.4 * smoothstep(0.1, 0.6, best)) : mix(uWaterLit, col, smoothstep(0.1, 0.6, best))) * uWaterTint;\nc.dir = lineDir(bestDir);\nc.aux = 0.0;\nc.style = float(bestVariety);\n}\n"],
2090
- ["compose.glsl", "\n\n\n\n\n\n\n\n\nuniform vec2 uShadow;\nuniform float uShadowAlpha;\nuniform vec3 uEyeCol;\n\n\nuniform vec3 uSumiRim;\nuniform vec3 uAddTint;\n\n\nconst float SPEC_POW = 18.0;\n\nfloat shadow(int f, vec2 p) {\nfloat lift = 1.25 - F(f, T_P0).w;\nvec2 q = p - uShadow * lift;\nfloat blur = max(uCell.x, uCell.y) * (0.5 + 1.5 * lift);\nif (!inBox(q, F(f, T_BBOX), blur)) return 0.0;\nfloat d = min(polyline(f, 0, J_BODY - 1, q).d,\npolyline(f, J_BODY - 1, J_TAIL, q).d + max(uCell.x, uCell.y) * 0.5);\nreturn (1.0 - smoothstep(-blur, blur, d)) * (0.95 - 0.35 * lift) * uShadowAlpha;\n}\n\n\nvec2 refrAt(vec2 x) {\nfloat e = uRipplePx;\nreturn vec2(rippleAt(x + vec2(e, 0.0)) - rippleAt(x - vec2(e, 0.0)),\nrippleAt(x + vec2(0.0, e)) - rippleAt(x - vec2(0.0, e))) / (2.0 * e) * REFRACT;\n}\n\nvoid drawFish(int f, vec2 p, vec2 refr, inout Cell c) {\nvec4 P0 = F(f, T_P0);\nint variety = int(P0.x + 0.5);\nfloat seed = P0.y;\nfloat depth = P0.w;\n\nvec2 q = p + refr * (0.5 + depth);\nif (!inBox(q, F(f, T_BBOX), max(uCell.x, uCell.y) * 1.5)) return;\nfloat fog = 0.55 * depth;\nvec3 fogCol = uWaterDim * uWaterTint;\nfloat fade = 1.0 - 0.3 * depth;\nfloat style = float(variety);\nvec4 fin = F(f, T_FIN);\n\nvec3 flatLight = lightOn(vec3(0.0, 0.0, 1.0));\n\n\n\nfloat reach = max(uCell.x, uCell.y);\nvec2 ex = vec2(uCell.x * 0.5, 0.0);\nvec2 ey = vec2(0.0, uCell.y * 0.5);\n\n\nfor (int s = 0; s < 2; s++) {\nvec4 pf = F(f, T_PEC_L + s);\nfloat t, t2;\nfloat d = finDist(q, pf.xy, pf.zw, fin.x, fin.y, t);\nif (d > reach) continue;\nvec2 gr = vec2(finDist(q + ex, pf.xy, pf.zw, fin.x, fin.y, t2) - finDist(q - ex, pf.xy, pf.zw, fin.x, fin.y, t2),\nfinDist(q + ey, pf.xy, pf.zw, fin.x, fin.y, t2) - finDist(q - ey, pf.xy, pf.zw, fin.x, fin.y, t2)) / uCell;\nvec2 n = normalize(gr + 1e-6);\nfloat cv = coverage(d, n);\nif (cv > 0.25) {\nc.col = mix(pectoralColor(variety, t) * flatLight, fogCol, fog);\nc.aux = 0.0;\nc.style = style;\nif (cv < 0.8) {\nc.mat = M_EDGE;\nc.dens = cv;\nc.dir = cellNormal(n);\n} else {\nc.mat = M_FIN;\nc.col *= 0.75;\nc.dens = 0.6 * fade;\nc.dir = lineDir(q - pf.xy);\n}\n}\n}\n\n\n\nfloat td = tailSD(f, q, fin);\nif (td < reach) {\nvec2 gr = vec2(tailSD(f, q + ex, fin) - tailSD(f, q - ex, fin),\ntailSD(f, q + ey, fin) - tailSD(f, q - ey, fin)) / uCell;\nvec2 n = normalize(gr + 1e-6);\nfloat tcv = coverage(td, n);\nif (tcv > 0.25) {\nHit th = polyline(f, J_BODY - 1, J_TAIL, q);\nc.col = mix(tailColor(variety, th.u, th.v, seed) * flatLight, fogCol, fog);\nc.aux = 0.0;\nc.style = style;\nif (tcv < 0.8) {\nc.mat = M_EDGE;\nc.dens = tcv;\nc.dir = cellNormal(n);\n} else {\nc.mat = M_FIN;\nc.col *= 0.75;\nc.dens = (0.65 - 0.2 * th.u) * fade;\nc.dir = lineDir(q - F(f, J_BODY - 1).xy);\n}\n}\n}\n\n\nHit bh = polyline(f, 0, J_BODY - 1, q);\nfloat bcv = coverage(bh.d, bh.n);\nif (bcv > 0.18) {\nfloat glint = 0.0;\nfloat sumiAmt = 0.0;\nvec3 bc = variety == V_KOHAKU ? kohaku(bh.u, bh.v, seed)\n: variety == V_SHOWA ? showa(bh.u, bh.v, seed, sumiAmt)\n: ogon(bh.u, bh.v, seed, glint);\n\n\nfloat a = min(abs(bh.v), 1.0) * 0.92;\nvec3 N = vec3(bh.n * a, sqrt(1.0 - a * a));\nfloat ndl = dot(N, diffuseDir());\nvec3 lit = lightOn(N);\n\n\n\nvec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\nfloat spec = pow(max(dot(N, H), 0.0), SPEC_POW);\nvec3 hue = lightHue() * length(uLightCol) * 0.82 * uAddTint;\nvec3 hi = variety == V_OGON ? hue * uOgonGold * 0.9 : hue * (variety == V_SHOWA ? 0.32 + 0.25 * sumiAmt : 0.3);\n\nvec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\nfloat rim = a * a * a * max(-dot(bh.n, az), 0.0) * (0.35 + 0.65 * (1.0 - uLightDir.z));\n\n\nfloat cst = smoothstep(0.5, 0.95, caustic(q * 0.014 + refr * 0.012 + seed, uTime * 0.45));\nvec3 caust = uLightCol * uAddTint * cst * uCaustic * 0.4 * max(ndl, 0.0) * (1.0 - depth);\nc.col = mix(bc * lit + hi * spec + uLightCol * uAddTint * rim * 0.6 + caust, fogCol, fog);\n\nfloat shade = mix(0.58, 1.05, smoothstep(-0.2, 0.9, ndl)) + 0.25 * spec;\nc.style = style;\nif (bcv > 0.8) {\n\n\nfloat Yl = dot(bc * bc, vec3(0.2126, 0.7152, 0.0722));\nfloat Ls = 1.16 * pow(Yl, 1.0 / 3.0) - 0.16;\nc.mat = M_BODY;\nc.dens = mix(0.6, 1.0, Ls) * shade * fade;\nc.aux = glint;\n} else {\nc.mat = M_EDGE;\nc.dens = bcv;\nc.dir = cellNormal(bh.n);\nc.aux = 0.0;\nc.col = mix(c.col, mix(uSumiRim * lit, fogCol, fog), 0.65 * sumiAmt);\n}\n}\n\n\nvec4 E = F(f, T_EYE);\n\n\n\n\nvec2 eyeReach = 0.5 * uCell + REFRACT / uRipplePx * abs(0.5 + depth) + 1.0;\nfor (int s = 0; s < 2; s++) {\nvec2 e = s == 0 ? E.xy : E.zw;\nbool hit = length(q - e) < P0.z;\nif (!hit && all(lessThanEqual(abs(p - e), eyeReach))) {\n\n\nvec2 de = abs(p + refrAt(e) * (0.5 + depth) - e);\nhit = de.x < uCell.x * 0.5 && de.y < uCell.y * 0.5;\n}\nif (hit) {\nc.mat = M_EYE;\nc.col = mix(uEyeCol * max(flatLight, vec3(0.75)), fogCol, fog * 0.5);\nc.dens = 1.0;\nc.aux = 0.0;\nc.style = style;\n}\n}\n}\n"],
2091
- ["glyphs.glsl", "\n\n\n\n\nconst int BODY_N = 6;\nconst int BODY_RAMP[BODY_N] = int[BODY_N](G_EQUALS, G_PLUS, G_STAR, G_HASH, G_PERCENT, G_AT);\n\nfloat bayer4(ivec2 c) {\nconst int B[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);\nreturn (float(B[(c.x & 3) + (c.y & 3) * 4]) + 0.5) / 16.0;\n}\n\n\nint lineGlyph(vec2 d, int horizontal, int vertical) {\nfloat a = atan(d.y, d.x);\nif (a < 0.0) a += PI;\nint s = int(floor(a / (PI * 0.25) + 0.5)) % 4;\nif (s == 0) return horizontal;\nif (s == 1) return G_BSLASH;\nif (s == 2) return vertical;\nreturn G_SLASH;\n}\n\n\n\nint edgeGlyph(vec2 n, float cov) {\nint o = int(floor((atan(n.y, n.x) + TAU) / (PI * 0.25) + 0.5)) % 8;\nif (o == 0) return G_RPAREN;\nif (o == 4) return G_LPAREN;\nif (o == 1 || o == 5) return G_SLASH;\nif (o == 3 || o == 7) return G_BSLASH;\nif (o == 2) return cov > 0.5 ? G_UNDERSCORE : G_MINUS;\nreturn cov > 0.5 ? G_MINUS : G_UNDERSCORE;\n}\n\nint pickGlyph(Cell c, ivec2 cell) {\nfloat dens = clamp(c.dens, 0.0, 1.0);\nfloat aux = clamp(c.aux, 0.0, 1.0);\nint style = int(c.style + 0.5);\nif (c.mat == M_WATER) {\nif (dens < 0.12) return G_SPACE;\nif (dens < 0.6) return aux < 0.4 ? G_DOT : aux < 0.65 ? G_COMMA : aux < 0.85 ? G_BACKTICK : G_QUOTE;\nreturn G_COLON;\n}\nif (c.mat == M_RIPPLE) {\nif (dens < 0.45) return G_DOT;\nreturn lineGlyph(c.dir, aux > 0.5 ? G_TILDE : G_MINUS, G_PIPE);\n}\nif (c.mat == M_TRAIL) {\nif (style == V_SHOWA) return dens > 0.55 ? G_O : G_DOT;\nif (style == V_OGON) return dens > 0.65 ? G_STAR : dens > 0.35 ? G_QUOTE : G_DOT;\nif (dens < 0.3) return G_DOT;\nif (dens < 0.55) return G_MINUS;\nreturn lineGlyph(c.dir, G_TILDE, G_COLON);\n}\nif (c.mat == M_FIN) {\nif (dens < 0.3) return G_DOT;\nif (dens < 0.45) return G_QUOTE;\nreturn lineGlyph(c.dir, style == V_OGON ? G_TILDE : G_MINUS, G_PIPE);\n}\nif (c.mat == M_EDGE) return edgeGlyph(c.dir, dens);\nif (c.mat == M_BODY) {\nif (aux > 0.75) return G_STAR;\nfloat x = smoothstep(0.3, 0.95, dens) * float(BODY_N - 1) + (bayer4(cell) - 0.5) * 0.5;\nreturn BODY_RAMP[clamp(int(floor(x + 0.5)), 0, BODY_N - 1)];\n}\nif (c.mat == M_EYE) return G_O;\nif (c.mat == M_STAR) {\n\nif (dens > 0.95) return G_STAR;\nif (dens > 0.65) return G_PLUS;\nif (dens > 0.4) return aux < 0.5 ? G_QUOTE : G_BACKTICK;\nreturn G_DOT;\n}\nif (c.mat == M_MOON) {\nif (style == 2) return lineGlyph(c.dir, G_EQUALS, G_PIPE);\nif (style == 1) return edgeGlyph(c.dir, dens);\nreturn dens < 0.33 ? G_DOT : dens < 0.56 ? G_COLON : dens < 0.67 ? G_O : G_BIG_O;\n}\nif (c.mat == M_SUN) return dens > 0.8 ? G_STAR : dens > 0.6 ? G_PLUS : G_DOT;\nif (c.mat == M_GLINT) {\n\nif (aux > 0.55) return dens > 0.6 ? lineGlyph(c.dir, dens > 0.85 ? G_EQUALS : G_MINUS, G_PIPE) : G_DOT;\nreturn dens > 0.75 ? G_STAR : G_PLUS;\n}\nreturn G_SPACE;\n}\n"],
2092
- ["main.frag", "\n\n\n\n\n\n\n\n\n\n\n\n\nuniform int uOrder[MAX_FISH];\nuniform vec2 uOriginCss;\nuniform int uLayer;\nuniform int uFishDraw;\n\nlayout(location = 0) out vec4 oColor;\nlayout(location = 1) out vec4 oInfo;\n\nvoid main() {\nvec2 p = (floor(gl_FragCoord.xy) + 0.5) * uCell + uOriginCss;\n\nfloat e = uRipplePx;\nfloat h = rippleAt(p);\nvec2 g = vec2(rippleAt(p + vec2(e, 0.0)) - rippleAt(p - vec2(e, 0.0)),\nrippleAt(p + vec2(0.0, e)) - rippleAt(p - vec2(0.0, e))) / (2.0 * e);\n\nCell c;\nif (uLayer == 0) {\nc = water(p, h, g);\nstars(p, g, c);\nmoon(p, g, c);\nsun(p, g, c);\n\nfloat sh = 0.0;\n\n\n\nfor (int f = 0; f < uFishCount; f++) {\nsh = max(sh, shadow(f, p));\n}\nc.dens *= clamp(1.0 - 1.4 * sh, 0.0, 1.0);\nc.col *= 1.0 - 0.5 * sh;\n\ntrails(p, c);\n} else {\n\n\nc = Cell(vec3(0.0), 0.0, -1, vec2(1.0, 0.0), 0.0, 0.0);\n}\n\nvec2 refr = g * REFRACT;\nfor (int k = 0; k < MAX_FISH; k++) {\nif (k >= uFishDraw) break;\ndrawFish(uOrder[k], p, refr, c);\n}\n\nif (uLayer == 1 && c.mat < 0) discard;\n\nint glyph = pickGlyph(c, ivec2(gl_FragCoord.xy));\noColor = vec4(c.col, clamp(c.dens, 0.0, 1.0));\noInfo = vec4(float(glyph) / 255.0, float(c.mat) / 255.0, 0.0, 0.0);\n}\n"]
2309
+ ["uniforms.glsl", uniforms_default],
2310
+ ["light.glsl", light_default],
2311
+ ["cell.glsl", cell_default],
2312
+ ["ripples.glsl", ripples_default],
2313
+ ["water.glsl", water_default],
2314
+ ["sky.glsl", sky_default],
2315
+ ["fish.glsl", fish_default],
2316
+ ["markings.glsl", markings_default],
2317
+ ["trails.glsl", trails_default],
2318
+ ["compose.glsl", compose_default],
2319
+ ["glyphs.glsl", glyphs_default],
2320
+ ["avoid.glsl", avoid_default],
2321
+ ["main.frag", "\n\n\n\n\n\n\n\n\n\n\n\n\n\nuniform int uOrder[MAX_FISH];\nuniform vec2 uOriginCss;\nuniform int uLayer;\nuniform int uFishDraw;\n\nlayout(location = 0) out vec4 oColor;\nlayout(location = 1) out vec4 oInfo;\n\nvoid main() {\nvec2 p = (floor(gl_FragCoord.xy) + 0.5) * uCell + uOriginCss;\n\nfloat e = uRipplePx;\nfloat h = rippleAt(p);\nvec2 g = vec2(rippleAt(p + vec2(e, 0.0)) - rippleAt(p - vec2(e, 0.0)),\nrippleAt(p + vec2(0.0, e)) - rippleAt(p - vec2(0.0, e))) / (2.0 * e);\n\nCell c;\nif (uLayer == 0) {\nc = water(p, h, g);\nstars(p, g, c);\nmoon(p, g, c);\nsun(p, g, c);\n\nfloat sh = 0.0;\n\n\n\nfor (int f = 0; f < uFishCount; f++) {\nsh = max(sh, shadow(f, p));\n}\nc.dens *= clamp(1.0 - 1.4 * sh, 0.0, 1.0);\nc.col *= 1.0 - 0.5 * sh;\n\ntrails(p, c);\n} else {\n\n\nc = Cell(vec3(0.0), 0.0, -1, vec2(1.0, 0.0), 0.0, 0.0);\n}\n\nvec2 refr = g * REFRACT;\nfor (int k = 0; k < MAX_FISH; k++) {\nif (k >= uFishDraw) break;\ndrawFish(uOrder[k], p, refr, c);\n}\n\nif (uLayer == 1 && c.mat < 0) discard;\n\n\n\n\nfloat m = 0.0;\nbool outline = false;\nif (uAvoidN > 0) {\nfloat d = avoidDist(p);\nm = avoidMask(d);\nif (m > 0.0) c.dens *= avoidThin(m, c.mat);\n\noutline = uAvoidFade.w > 0.5 && abs(d) < 0.5 * max(uCell.x, uCell.y);\n}\n\nint glyph = pickGlyph(c, ivec2(gl_FragCoord.xy));\nif (outline) {\nc.col = uHiRed;\nglyph = G_DOT;\nm = 0.0;\n}\noColor = vec4(c.col, clamp(c.dens, 0.0, 1.0));\noInfo = vec4(float(glyph) / 255.0, float(c.mat) / 255.0, m, 0.0);\n}\n"]
2093
2322
  ];
2094
2323
  var sceneSrc = sceneChunks.map(([, src]) => src).join("\n");
2095
2324
  /** The line each chunk starts on in sceneSrc, 1-based, so compile errors can name `sky.glsl:42`. */
@@ -2102,13 +2331,13 @@ var sceneLines = sceneChunks.map(([name, src]) => {
2102
2331
  line += src.split("\n").length;
2103
2332
  return start;
2104
2333
  });
2105
- var ascii_default = "\n\n\nuniform sampler2D uScene0;\nuniform sampler2D uScene1;\nuniform sampler2D uAtlas;\nuniform ivec2 uCellPx;\nuniform ivec2 uOrigin;\nuniform ivec2 uGrid;\nuniform int uCanvasH;\nuniform vec3 uBg;\nuniform vec3 uRawBg;\nuniform int uRaw;\n\nuniform int uLook;\nuniform vec3 uOutBg;\nuniform vec3 uStops[5];\nuniform vec3 uStopDeltas[4];\nuniform int uStopCount;\nuniform vec3 uTint;\nuniform float uSaturation;\nuniform int uInvert;\nuniform float uIntensity;\n#ifdef GLOW\nuniform sampler2D uGlow;\nuniform vec2 uGlowScale;\nuniform int uGlowOn;\n#endif\n\nin vec2 vPx;\nout vec4 fragColor;\n\n\n\n\nfloat lum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }\n\n\nvec3 ramp(vec3 pix) {\nfloat t = clamp((lum(pix) - lum(uBg)) / (0.9 - lum(uBg)), 0.0, 1.0);\nfloat x = t * float(uStopCount - 1);\n\n\nfloat fi = min(floor(x), float(uStopCount - 2));\n\n\n\n\nvec3 a = uStops[0], d = uStopDeltas[0];\nif (uStopCount > 2) {\nif (fi >= 1.0) { a = uStops[1]; d = uStopDeltas[1]; }\nif (uStopCount > 3) {\nif (fi >= 2.0) { a = uStops[2]; d = uStopDeltas[2]; }\nif (uStopCount > 4 && fi >= 3.0) { a = uStops[3]; d = uStopDeltas[3]; }\n}\n}\n\n\nreturn a + (x - fi) * d;\n}\n\n\n\nvec3 filtered(vec3 pix) {\nvec3 d = pix - uBg;\nfloat y = lum(d);\nvec3 chroma = (d - vec3(y)) * uSaturation;\n\n\n\n\nfloat tl = lum(uTint);\nif (uInvert == 1) return uOutBg - y * 1.25 * tl * (vec3(1.0 + tl) - uTint) + chroma * 0.9;\nreturn uOutBg + (vec3(y) + chroma) * uTint;\n}\n\nvoid main() {\n\n\nivec2 q = ivec2(int(vPx.x), uCanvasH - 1 - int(vPx.y)) - uOrigin;\n\n\nvec2 cf = floor((vec2(q) + 0.5) / vec2(uCellPx));\nivec2 cell = ivec2(cf);\n\n\nif (any(greaterThanEqual(uvec2(cell), uvec2(uGrid)))) {\nfragColor = vec4(uRaw == 1 ? uRawBg : uOutBg, 1.0);\nreturn;\n}\n\n\nivec2 local = ivec2(vec2(q) - cf * vec2(uCellPx));\nvec4 s0 = texelFetch(uScene0, cell, 0);\nif (uRaw == 1) {\nfragColor = vec4(mix(uRawBg, s0.rgb, s0.a), 1.0);\nreturn;\n}\nint glyph = int(texelFetch(uScene1, cell, 0).r * 255.0 + 0.5);\nfloat ink = texelFetch(uAtlas, ivec2(int(float(glyph) * float(uCellPx.x)) + local.x, local.y), 0).r;\nvec3 bg = uBg;\n#ifdef GLOW\n\n\n\n\n\nif (uGlowOn == 1) bg += textureLod(uGlow, (vec2(q) + 0.5) * uGlowScale, 0.0).rgb;\n#endif\nvec3 c = mix(bg, s0.rgb, ink);\n\n\nif (uLook != 0 || uIntensity < 1.0) {\nif (uLook == 1) c = ramp(c);\nelse if (uLook == 2) c = clamp(filtered(c), 0.0, 1.0);\nif (uIntensity < 1.0) c = mix(uOutBg, c, uIntensity);\n}\nfragColor = vec4(c, 1.0);\n}\n";
2334
+ var ascii_default = "\n\n\nuniform sampler2D uScene0;\nuniform sampler2D uScene1;\nuniform sampler2D uAtlas;\nuniform ivec2 uCellPx;\nuniform ivec2 uOrigin;\nuniform ivec2 uGrid;\nuniform int uCanvasH;\nuniform vec3 uBg;\nuniform vec3 uRawBg;\nuniform int uRaw;\n\nuniform int uLook;\nuniform vec3 uOutBg;\nuniform vec3 uStops[5];\nuniform vec3 uStopDeltas[4];\nuniform int uStopCount;\nuniform vec3 uTint;\nuniform float uSaturation;\nuniform int uInvert;\nuniform float uIntensity;\nuniform vec2 uAvoidDim;\n#ifdef GLOW\nuniform sampler2D uGlow;\nuniform vec2 uGlowScale;\nuniform int uGlowOn;\n#endif\n\nin vec2 vPx;\nout vec4 fragColor;\n\n\n\n\nfloat lum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }\n\n\nvec3 ramp(vec3 pix) {\nfloat t = clamp((lum(pix) - lum(uBg)) / (0.9 - lum(uBg)), 0.0, 1.0);\nfloat x = t * float(uStopCount - 1);\n\n\nfloat fi = min(floor(x), float(uStopCount - 2));\n\n\n\n\nvec3 a = uStops[0], d = uStopDeltas[0];\nif (uStopCount > 2) {\nif (fi >= 1.0) { a = uStops[1]; d = uStopDeltas[1]; }\nif (uStopCount > 3) {\nif (fi >= 2.0) { a = uStops[2]; d = uStopDeltas[2]; }\nif (uStopCount > 4 && fi >= 3.0) { a = uStops[3]; d = uStopDeltas[3]; }\n}\n}\n\n\nreturn a + (x - fi) * d;\n}\n\n\n\nvec3 filtered(vec3 pix) {\nvec3 d = pix - uBg;\nfloat y = lum(d);\nvec3 chroma = (d - vec3(y)) * uSaturation;\n\n\n\n\nfloat tl = lum(uTint);\nif (uInvert == 1) return uOutBg - y * 1.25 * tl * (vec3(1.0 + tl) - uTint) + chroma * 0.9;\nreturn uOutBg + (vec3(y) + chroma) * uTint;\n}\n\nvoid main() {\n\n\nivec2 q = ivec2(int(vPx.x), uCanvasH - 1 - int(vPx.y)) - uOrigin;\n\n\nvec2 cf = floor((vec2(q) + 0.5) / vec2(uCellPx));\nivec2 cell = ivec2(cf);\n\n\nif (any(greaterThanEqual(uvec2(cell), uvec2(uGrid)))) {\nfragColor = vec4(uRaw == 1 ? uRawBg : uOutBg, 1.0);\nreturn;\n}\n\n\nivec2 local = ivec2(vec2(q) - cf * vec2(uCellPx));\nvec4 s0 = texelFetch(uScene0, cell, 0);\nif (uRaw == 1) {\nfragColor = vec4(mix(uRawBg, s0.rgb, s0.a), 1.0);\nreturn;\n}\nvec4 s1 = texelFetch(uScene1, cell, 0);\nint glyph = int(s1.r * 255.0 + 0.5);\nfloat ink = texelFetch(uAtlas, ivec2(int(float(glyph) * float(uCellPx.x)) + local.x, local.y), 0).r;\nvec3 bg = uBg;\n#ifdef GLOW\n\n\n\n\n\nif (uGlowOn == 1) bg += textureLod(uGlow, (vec2(q) + 0.5) * uGlowScale, 0.0).rgb;\n#endif\nvec3 c = mix(bg, s0.rgb, ink);\n\n\nif (uLook != 0 || uIntensity < 1.0) {\nif (uLook == 1) c = ramp(c);\nelse if (uLook == 2) c = clamp(filtered(c), 0.0, 1.0);\nif (uIntensity < 1.0) c = mix(uOutBg, c, uIntensity);\n}\n\n\n\n\nif (uAvoidDim != vec2(0.0) && s1.b > 0.0) {\nint mat = int(s1.g * 255.0 + 0.5);\nc = mix(uOutBg, c, 1.0 - (avoidWater(mat) ? uAvoidDim.x : uAvoidDim.y) * s1.b);\n}\nfragColor = vec4(c, 1.0);\n}\n";
2106
2335
  var pixel_default = "\n\n\n\n\nuniform vec2 uDev;\nout vec2 vPx;\n\nvoid main() {\nvec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\ngl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\nvPx = p * uDev;\n}\n";
2107
2336
  var glow_default = "\n\n\n\n\n\nuniform ivec2 uGrid;\nuniform float uAspect;\nuniform float uGain;\n\nout vec4 oGlow;\n\n\n\n#ifdef EMIT_TEX\n\n\n\nuniform sampler2D uEmit;\n#endif\n\nvoid main() {\nivec2 cell = ivec2(gl_FragCoord.xy);\nvec3 sum = vec3(0.0);\nfloat wsum = 0.0;\nfor (int y = -RY; y <= RY; y++) {\nfor (int x = -RX; x <= RX; x++) {\n\nvec2 d = vec2(float(x) * uAspect, float(y));\nfloat k = exp(-dot(d, d) * 0.6);\nwsum += k;\n#ifdef EMIT_TEX\nvec4 e = texelFetch(uEmit, cell + ivec2(x + RX, y + RY), 0);\nsum += e.rgb * e.a * k;\n#else\nivec2 n = cell + ivec2(x, y);\n\n\nivec2 nc = clamp(n, ivec2(0), uGrid - 1);\nvec4 e = emission(nc);\nif (all(equal(n, nc))) sum += e.rgb * e.a * k;\n#endif\n}\n}\n\n\nvec3 halo = sum * (uGain / wsum);\noGlow = vec4(halo / (1.0 + 2.5 * max(halo.r, max(halo.g, halo.b))), 1.0);\n}\n";
2108
2337
  var emission_default$1 = "\n\n\n\nuniform sampler2D uScene0;\nuniform sampler2D uScene1;\n\n\n\n\n\n\nvec4 emission(ivec2 cell) {\nvec4 s0 = texelFetch(uScene0, cell, 0);\nint mat = int(texelFetch(uScene1, cell, 0).g * 255.0 + 0.5);\nfloat w = mat == M_FLY ? 1.3\n: mat == M_GLINT ? 1.0\n: mat == M_SUN ? 1.2\n: mat == M_EYE ? 0.7\n: mat == M_MOON ? 0.5\n: mat == M_STAR ? 0.35\n: mat == M_RIPPLE ? 0.45\n: mat == M_BODY || mat == M_EDGE || mat == M_FIN ? 0.4\n: 0.0;\nfloat lum = dot(s0.rgb, vec3(0.299, 0.587, 0.114));\nreturn vec4(s0.rgb, w * s0.a * smoothstep(0.3, 0.85, lum));\n}\n";
2109
2338
  var emission_default = "\n\n\n\n\n\nout vec4 oEmit;\n\nvoid main() {\noEmit = emission(ivec2(gl_FragCoord.xy) - ivec2(RX, RY));\n}\n";
2110
2339
  var fly_default$1 = "\n\n\n\nlayout(location = 0) in vec4 aFly;\n\nuniform vec2 uCell;\nuniform vec2 uOriginCss;\nuniform ivec2 uGrid;\nuniform float uFlies;\n\nout float vBright;\n\n\nconst float FLY_MIN = 0.22;\n\nvoid main() {\nfloat v = aFly.z * uFlies;\nvBright = v;\ngl_PointSize = 1.0;\n\nvec2 cell = floor((aFly.xy - uOriginCss) / uCell);\nvec2 ndc = (cell + 0.5) / vec2(uGrid) * 2.0 - 1.0;\n\ngl_Position = v < FLY_MIN ? vec4(2.0, 2.0, 0.0, 1.0) : vec4(ndc, 0.0, 1.0);\n}\n";
2111
- var fly_default = "\n\n\n\n\nuniform vec3 uFlyCol;\nuniform vec3 uWaterDim;\nuniform float uRoles;\n\nin float vBright;\n\nlayout(location = 0) out vec4 oColor;\nlayout(location = 1) out vec4 oInfo;\n\nvoid main() {\nfloat v = min(vBright, 1.0);\nint glyph = v > 0.7 ? G_STAR : v > 0.45 ? G_PLUS : G_DOT;\nfloat k = min(0.45 + 0.6 * v, 1.0);\nvec3 col = uRoles > 0.5 ? mix(uWaterDim, uFlyCol, k) : uFlyCol * k;\noColor = vec4(col, v);\noInfo = vec4(float(glyph) / 255.0, float(M_FLY) / 255.0, 0.0, 0.0);\n}\n";
2340
+ var fly_default = "\n\n\n\n\n\n\n\nuniform vec3 uFlyCol;\nuniform vec3 uWaterDim;\nuniform float uRoles;\nuniform vec2 uCell;\nuniform vec2 uOriginCss;\n\nin float vBright;\n\nlayout(location = 0) out vec4 oColor;\nlayout(location = 1) out vec4 oInfo;\n\nvoid main() {\nfloat v = min(vBright, 1.0);\n\nfloat m = uAvoidN > 0 ? avoidMask(avoidDist((floor(gl_FragCoord.xy) + 0.5) * uCell + uOriginCss)) : 0.0;\n\nfloat vt = m > 0.0 ? v * avoidThin(m, M_FLY) : v;\nint glyph = vt > 0.7 ? G_STAR : vt > 0.45 ? G_PLUS : G_DOT;\nfloat k = min(0.45 + 0.6 * v, 1.0);\nvec3 col = uRoles > 0.5 ? mix(uWaterDim, uFlyCol, k) : uFlyCol * k;\noColor = vec4(col, vt);\noInfo = vec4(float(glyph) / 255.0, float(M_FLY) / 255.0, m, 0.0);\n}\n";
2112
2341
  /** `file:line` for line n of a compiled shader, or just n before the first file. */
2113
2342
  function sourceLine(files, n) {
2114
2343
  let file;
@@ -2377,34 +2606,34 @@ function scale(a, k) {
2377
2606
  * deep water and ripples come from water, highlight and background only (Q11).
2378
2607
  */
2379
2608
  function rolesScene(r) {
2380
- const ripple = mix(r.water, r.highlight, .6);
2609
+ const ripple = mix$1(r.water, r.highlight, .6);
2381
2610
  return {
2382
- uWaterDim: mix(r.background, r.water, .37),
2611
+ uWaterDim: mix$1(r.background, r.water, .37),
2383
2612
  uWaterLit: r.water,
2384
2613
  uRippleCol: ripple,
2385
2614
  uKohakuWhite: r.pale,
2386
2615
  uHiRed: r.accent,
2387
2616
  uSumi: r.ink,
2388
- uShowaWhite: mix(r.pale, r.background, .05),
2617
+ uShowaWhite: mix$1(r.pale, r.background, .05),
2389
2618
  uOgonGold: r.metallic,
2390
2619
  uOgonShade: scale(r.metallic, .8),
2391
- uOgonGlint: mix(r.metallic, r.highlight, .6),
2620
+ uOgonGlint: mix$1(r.metallic, r.highlight, .6),
2392
2621
  uEyeCol: r.highlight,
2393
- uSumiRim: mix(r.ink, r.pale, .7),
2394
- uKohakuFin: mix(r.pale, r.water, .15),
2395
- uKohakuTail: mix(r.pale, r.water, .12),
2396
- uOgonFin: mix(r.metallic, r.highlight, .25),
2397
- uOgonTail: mix(r.metallic, r.highlight, .45),
2622
+ uSumiRim: mix$1(r.ink, r.pale, .7),
2623
+ uKohakuFin: mix$1(r.pale, r.water, .15),
2624
+ uKohakuTail: mix$1(r.pale, r.water, .12),
2625
+ uOgonFin: mix$1(r.metallic, r.highlight, .25),
2626
+ uOgonTail: mix$1(r.metallic, r.highlight, .45),
2398
2627
  uSilk: r.pale,
2399
- uBubble: mix(r.water, r.highlight, .6),
2400
- uGoldDust: mix(r.metallic, r.highlight, .15),
2628
+ uBubble: mix$1(r.water, r.highlight, .6),
2629
+ uGoldDust: mix$1(r.metallic, r.highlight, .15),
2401
2630
  uGlintCol: r.highlight,
2402
2631
  uAddTint: r.highlight,
2403
2632
  uStarCool: r.highlight,
2404
- uStarWarm: mix(r.highlight, r.water, .25),
2405
- uMoonCol: mix(r.highlight, r.pale, .25),
2633
+ uStarWarm: mix$1(r.highlight, r.water, .25),
2634
+ uMoonCol: mix$1(r.highlight, r.pale, .25),
2406
2635
  uSunCol: r.highlight,
2407
- uFlyCol: mix(r.metallic, r.highlight, .32)
2636
+ uFlyCol: mix$1(r.metallic, r.highlight, .32)
2408
2637
  };
2409
2638
  }
2410
2639
  var luma = (c) => .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
@@ -2578,6 +2807,8 @@ function starsAt(phase, sky, light) {
2578
2807
  function firefliesAt(phase, sky) {
2579
2808
  return sky.fireflies ? fireflyWeight(phase) : 0;
2580
2809
  }
2810
+ /** Stands in for mount()'s zone arrays until the first setAvoid. */
2811
+ var NO_ZONES = /* @__PURE__ */ new Float32Array(0);
2581
2812
  /** World px per ripple simulation texel. */
2582
2813
  var RIPPLE_PX = 5;
2583
2814
  var RIPPLE_HZ = 60;
@@ -2622,6 +2853,8 @@ var Renderer = class {
2622
2853
  intensity = 1;
2623
2854
  /** Night-sky features, already resolved; mount() owns them like the palette. */
2624
2855
  sky = DEFAULT_SKY;
2856
+ /** Debug switch: outlines the readability zones (in the palette's hi red) and draws them even at strength 0. */
2857
+ showAvoid = false;
2625
2858
  gl;
2626
2859
  fontFamily;
2627
2860
  vao;
@@ -2642,6 +2875,18 @@ var Renderer = class {
2642
2875
  dropBuf = /* @__PURE__ */ new Float32Array(128);
2643
2876
  stopBuf = /* @__PURE__ */ new Float32Array(15);
2644
2877
  stopDeltaBuf = /* @__PURE__ */ new Float32Array(12);
2878
+ /**
2879
+ * Readability zones for the next frames, as mount() last measured them: x,
2880
+ * y, w, h and a radius each, world px. mount()'s arrays (setAvoid); never
2881
+ * read while avoidCount is 0.
2882
+ */
2883
+ avoidRects = NO_ZONES;
2884
+ avoidRadii = NO_ZONES;
2885
+ avoidCount = 0;
2886
+ /** The readability fade, already resolved; mount() owns it like the palette. */
2887
+ avoidFade = DEFAULT_FADE;
2888
+ /** What avoidFade's strength does to each material (worked out once per setAvoidFade, not per frame). */
2889
+ avoidAmounts = avoidAmounts(DEFAULT_FADE.strength);
2645
2890
  /** Scene colours for the active palette (roles replace the pond's). */
2646
2891
  sceneColors = POND_SCENE;
2647
2892
  uploadedColors = null;
@@ -2726,7 +2971,7 @@ var Renderer = class {
2726
2971
  const glow = emission ? `#define EMIT_TEX\n${glow_default}` : `${emission_default$1}\n${glow_default}`;
2727
2972
  this.glowProg = software ? null : new Program(gl, vertex, fragment(glow), "glow");
2728
2973
  this.asciiProg = new Program(gl, pixelVertex, fragment((software ? "" : "#define GLOW\n") + ascii_default), "ascii");
2729
- this.flyProg = new Program(gl, `#version 300 es\n${common_default}\n${fly_default$1}`, fragment(fly_default), "fly");
2974
+ this.flyProg = new Program(gl, `#version 300 es\n${common_default}\n${fly_default$1}`, fragment(`${avoid_default}\n${fly_default}`), "fly");
2730
2975
  const vao = gl.createVertexArray();
2731
2976
  const flyVao = gl.createVertexArray();
2732
2977
  const flyBuf = gl.createBuffer();
@@ -2768,6 +3013,28 @@ var Renderer = class {
2768
3013
  setSky(sky) {
2769
3014
  this.sky = sky;
2770
3015
  }
3016
+ /**
3017
+ * Sets the readability zones the next render() fades under: `count` zones,
3018
+ * `rects` x, y, w, h and `radii` one per zone, in world px. The arrays are
3019
+ * kept, not copied (mount() refills them before each draw).
3020
+ */
3021
+ setAvoid(rects, radii, count) {
3022
+ this.avoidRects = rects;
3023
+ this.avoidRadii = radii;
3024
+ this.avoidCount = count;
3025
+ }
3026
+ /**
3027
+ * Whether render() draws zones at all: not in the raw view (A14), nor at
3028
+ * strength 0 unless outlined. mount() skips measuring when it doesn't.
3029
+ */
3030
+ get drawsAvoid() {
3031
+ return !this.raw && (this.avoidFade.strength > 0 || this.showAvoid);
3032
+ }
3033
+ /** Sets the readability fade; the next render() draws with it. */
3034
+ setAvoidFade(fade) {
3035
+ this.avoidFade = fade;
3036
+ this.avoidAmounts = avoidAmounts(fade.strength);
3037
+ }
2771
3038
  /** Sizes everything for a drawing buffer of devW × devH device pixels. */
2772
3039
  resize(devW, devH, dpr) {
2773
3040
  const gl = this.gl;
@@ -2850,6 +3117,8 @@ var Renderer = class {
2850
3117
  const sceneLight = this.sceneLight;
2851
3118
  const stars = this.stars;
2852
3119
  const flies = this.flies;
3120
+ const amounts = this.avoidAmounts;
3121
+ const zones = this.drawsAvoid ? this.avoidCount : 0;
2853
3122
  const gl = this.gl;
2854
3123
  const atlas = this.atlas;
2855
3124
  const scene = this.scene;
@@ -2887,7 +3156,8 @@ var Renderer = class {
2887
3156
  for (const [name, c] of Object.entries(colors)) sp.vec3(name, c[0], c[1], c[2]);
2888
3157
  this.uploadedColors = colors;
2889
3158
  }
2890
- sp.float("uRoles", roles ? 1 : 0).texture("uFish", 0, this.fishTex).texture("uRipple", 1, ripple[0].textures[0]).ivec2("uRippleSize", this.simW, this.simH).float("uRipplePx", RIPPLE_PX).int("uFishCount", count).ints("uOrder", pond.order).vec2("uCell", atlas.cellW / this.dpr, atlas.cellH / this.dpr).vec2("uOriginCss", this.originX / this.dpr, this.originY / this.dpr).float("uTime", pond.time % 65536).vec3("uLightDir", light.dir[0], light.dir[1], light.dir[2]).vec3("uLightCol", sceneLight.color[0], sceneLight.color[1], sceneLight.color[2]).vec3("uAmbient", sceneLight.ambient[0], sceneLight.ambient[1], sceneLight.ambient[2]).vec3("uWaterTint", sceneLight.water[0], sceneLight.water[1], sceneLight.water[2]).vec3("uSky", sceneLight.sky[0], sceneLight.sky[1], sceneLight.sky[2]).float("uGlint", light.glint).float("uCaustic", light.caustic).float("uFloor", light.floor).float("uDarkWater", light.darkWater).float("uDarkCaustic", light.darkCaustic).vec2("uShadow", unit * light.shadow[0], unit * light.shadow[1]).float("uShadowAlpha", light.shadowAlpha).float("uCalm", pond.calm ? 1 : 0).float("uStars", stars.stars).float("uMoteThin", stars.moteThin).vec2("uView", this.devW / this.dpr, this.devH / this.dpr).vec4("uMoon", light.moon.pos[0], light.moon.pos[1], light.moon.radius, this.sky.moon ? light.moon.alpha : 0).vec4("uSun", light.sun.pos[0], light.sun.pos[1], light.sun.radius, this.sky.sun ? light.sun.alpha : 0);
3159
+ sp.float("uRoles", roles ? 1 : 0).texture("uFish", 0, this.fishTex).texture("uRipple", 1, ripple[0].textures[0]).ivec2("uRippleSize", this.simW, this.simH).float("uRipplePx", RIPPLE_PX).int("uFishCount", count).ints("uOrder", pond.order).vec2("uCell", atlas.cellW / this.dpr, atlas.cellH / this.dpr).vec2("uOriginCss", this.originX / this.dpr, this.originY / this.dpr).float("uTime", pond.time % 65536).vec3("uLightDir", light.dir[0], light.dir[1], light.dir[2]).vec3("uLightCol", sceneLight.color[0], sceneLight.color[1], sceneLight.color[2]).vec3("uAmbient", sceneLight.ambient[0], sceneLight.ambient[1], sceneLight.ambient[2]).vec3("uWaterTint", sceneLight.water[0], sceneLight.water[1], sceneLight.water[2]).vec3("uSky", sceneLight.sky[0], sceneLight.sky[1], sceneLight.sky[2]).float("uGlint", light.glint).float("uCaustic", light.caustic).float("uFloor", light.floor).float("uDarkWater", light.darkWater).float("uDarkCaustic", light.darkCaustic).vec2("uShadow", unit * light.shadow[0], unit * light.shadow[1]).float("uShadowAlpha", light.shadowAlpha).float("uCalm", pond.calm ? 1 : 0).float("uStars", stars.stars).float("uMoteThin", stars.moteThin).vec2("uView", this.devW / this.dpr, this.devH / this.dpr).vec4("uMoon", light.moon.pos[0], light.moon.pos[1], light.moon.radius, this.sky.moon ? light.moon.alpha : 0).vec4("uSun", light.sun.pos[0], light.sun.pos[1], light.sun.radius, this.sky.sun ? light.sun.alpha : 0).int("uAvoidN", zones);
3160
+ if (zones > 0) this.avoidUniforms(sp, amounts);
2891
3161
  sp.int("uLayer", 0).int("uFishDraw", 0);
2892
3162
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2893
3163
  if (count > 0) {
@@ -2904,7 +3174,8 @@ var Renderer = class {
2904
3174
  for (const [name, c] of Object.entries(colors)) fp.vec3(name, c[0], c[1], c[2]);
2905
3175
  this.flyColors = colors;
2906
3176
  }
2907
- fp.float("uRoles", roles ? 1 : 0).vec2("uCell", atlas.cellW / this.dpr, atlas.cellH / this.dpr).vec2("uOriginCss", this.originX / this.dpr, this.originY / this.dpr).ivec2("uGrid", this.cols, this.rows).float("uFlies", flies);
3177
+ fp.float("uRoles", roles ? 1 : 0).vec2("uCell", atlas.cellW / this.dpr, atlas.cellH / this.dpr).vec2("uOriginCss", this.originX / this.dpr, this.originY / this.dpr).ivec2("uGrid", this.cols, this.rows).float("uFlies", flies).int("uAvoidN", zones);
3178
+ if (zones > 0) this.avoidUniforms(fp, amounts);
2908
3179
  gl.drawArrays(gl.POINTS, 0, 14);
2909
3180
  gl.bindVertexArray(this.vao);
2910
3181
  }
@@ -2939,10 +3210,14 @@ var Renderer = class {
2939
3210
  const look = this.look;
2940
3211
  const filter = palette.mode === "filter" ? palette : null;
2941
3212
  const tint = filter?.tint ?? WHITE;
2942
- const ascii = this.asciiProg.use().texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]).texture("uAtlas", 2, atlas.texture).vec2("uDev", this.devW, this.devH).ivec2("uCellPx", atlas.cellW, atlas.cellH).ivec2("uOrigin", this.originX, this.originY).ivec2("uGrid", this.cols, this.rows).int("uCanvasH", this.devH).vec3("uBg", look.bg[0], look.bg[1], look.bg[2]).vec3("uRawBg", POND_BG[0], POND_BG[1], POND_BG[2]).int("uRaw", this.raw ? 1 : 0).int("uLook", look.look).vec3("uOutBg", look.outBg[0], look.outBg[1], look.outBg[2]).vec3s("uStops", this.stopBuf).vec3s("uStopDeltas", this.stopDeltaBuf).int("uStopCount", palette.mode === "ramp" ? palette.stops.length : 2).vec3("uTint", tint[0], tint[1], tint[2]).float("uSaturation", filter?.saturation ?? 1).int("uInvert", filter?.invert ? 1 : 0).float("uIntensity", this.raw ? 1 : this.intensity);
3213
+ const ascii = this.asciiProg.use().texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]).texture("uAtlas", 2, atlas.texture).vec2("uDev", this.devW, this.devH).ivec2("uCellPx", atlas.cellW, atlas.cellH).ivec2("uOrigin", this.originX, this.originY).ivec2("uGrid", this.cols, this.rows).int("uCanvasH", this.devH).vec3("uBg", look.bg[0], look.bg[1], look.bg[2]).vec3("uRawBg", POND_BG[0], POND_BG[1], POND_BG[2]).int("uRaw", this.raw ? 1 : 0).int("uLook", look.look).vec3("uOutBg", look.outBg[0], look.outBg[1], look.outBg[2]).vec3s("uStops", this.stopBuf).vec3s("uStopDeltas", this.stopDeltaBuf).int("uStopCount", palette.mode === "ramp" ? palette.stops.length : 2).vec3("uTint", tint[0], tint[1], tint[2]).float("uSaturation", filter?.saturation ?? 1).int("uInvert", filter?.invert ? 1 : 0).float("uIntensity", this.raw ? 1 : this.intensity).vec2("uAvoidDim", zones > 0 ? amounts.waterDim : 0, zones > 0 ? amounts.fishDim : 0);
2943
3214
  if (this.glowTarget) ascii.texture("uGlow", 3, this.glowTarget.textures[0]).vec2("uGlowScale", 1 / (this.cols * atlas.cellW), 1 / (this.rows * atlas.cellH)).int("uGlowOn", haloDrawn ? 1 : 0);
2944
3215
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2945
3216
  }
3217
+ /** The zones and their thinning, for the scene and firefly programs (scene/avoid.glsl). */
3218
+ avoidUniforms(prog, amounts) {
3219
+ prog.vec4s("uAvoid", this.avoidRects).vec4s("uAvoidR", this.avoidRadii).vec4("uAvoidFade", amounts.waterThin, amounts.fishThin, this.avoidFade.feather, this.showAvoid ? 1 : 0);
3220
+ }
2946
3221
  /** Moves up to MAX_DROPS pending impulses into the uniform buffer, in sim texels. */
2947
3222
  takeDrops(pond) {
2948
3223
  const drops = pond.drops;
@@ -2980,15 +3255,19 @@ function mount(container, options) {
2980
3255
  const interactive = options.interactive === "canvas" || options.interactive === "none" ? options.interactive : "window";
2981
3256
  const seed = typeof options.seed === "number" && Number.isFinite(options.seed) ? options.seed >>> 0 : Math.random() * 2 ** 32 >>> 0;
2982
3257
  const sky = new Sky(timeSource(options.time, 0));
3258
+ const { background, onError } = options;
2983
3259
  let palette = resolvePalette(options.palette, parseCssColor);
2984
3260
  let intensity = resolveIntensity(options.intensity, 1, true);
2985
3261
  let skyFeatures = resolveSky(options.sky);
3262
+ const zones = new AvoidZones();
3263
+ zones.set(options.avoid, false);
3264
+ let avoidFade = resolveAvoidFade(options.avoidFade);
2986
3265
  let canvas = document.createElement("canvas");
2987
3266
  canvas.setAttribute("aria-hidden", "true");
2988
3267
  canvas.style.display = "block";
2989
3268
  canvas.style.width = "100%";
2990
3269
  canvas.style.height = "100%";
2991
- canvas.style.backgroundColor = options.background || palette.css;
3270
+ canvas.style.backgroundColor = background || palette.css;
2992
3271
  if (interactive === "canvas") canvas.style.touchAction = "none";
2993
3272
  container.append(canvas);
2994
3273
  const listeners = new AbortController();
@@ -3020,6 +3299,7 @@ function mount(container, options) {
3020
3299
  cancelAnimationFrame(raf);
3021
3300
  raf = 0;
3022
3301
  renderer = null;
3302
+ zones.clear();
3023
3303
  if (gl && !gl.isContextLost()) gl.getExtension("WEBGL_lose_context")?.loseContext();
3024
3304
  };
3025
3305
  const fail = (code, message, cause) => {
@@ -3032,12 +3312,12 @@ function mount(container, options) {
3032
3312
  canvas = blank;
3033
3313
  }
3034
3314
  const error = koiPondError(code, message, cause);
3035
- if (!options.onError) {
3315
+ if (!onError) {
3036
3316
  console.error(error);
3037
3317
  return;
3038
3318
  }
3039
3319
  try {
3040
- options.onError(error);
3320
+ onError(error);
3041
3321
  } catch (err) {
3042
3322
  console.error(err);
3043
3323
  }
@@ -3049,7 +3329,7 @@ function mount(container, options) {
3049
3329
  setPalette(next) {
3050
3330
  if (destroyed) return;
3051
3331
  palette = resolvePalette(next, parseCssColor);
3052
- if (!options.background) canvas.style.backgroundColor = palette.css;
3332
+ if (!background) canvas.style.backgroundColor = palette.css;
3053
3333
  renderer?.setLook(palette, intensity);
3054
3334
  },
3055
3335
  setIntensity(next) {
@@ -3062,6 +3342,15 @@ function mount(container, options) {
3062
3342
  skyFeatures = resolveSky(next);
3063
3343
  renderer?.setSky(skyFeatures);
3064
3344
  },
3345
+ setAvoid(list) {
3346
+ if (destroyed || failed) return;
3347
+ zones.set(list);
3348
+ },
3349
+ setAvoidFade(next) {
3350
+ if (destroyed || failed) return;
3351
+ avoidFade = resolveAvoidFade(next, avoidFade);
3352
+ renderer?.setAvoidFade(avoidFade);
3353
+ },
3065
3354
  pause() {
3066
3355
  paused = true;
3067
3356
  schedule();
@@ -3144,6 +3433,7 @@ function mount(container, options) {
3144
3433
  renderer = new Renderer(ctx, fontFamily, software);
3145
3434
  renderer.setLook(palette, intensity);
3146
3435
  renderer.setSky(skyFeatures);
3436
+ renderer.setAvoidFade(avoidFade);
3147
3437
  sizeDirty = true;
3148
3438
  return true;
3149
3439
  } catch (err) {
@@ -3256,6 +3546,7 @@ function mount(container, options) {
3256
3546
  }
3257
3547
  pond.resize(size.w / size.dpr, size.h / size.dpr);
3258
3548
  r.resize(bw, bh, size.dpr * bw / size.w);
3549
+ zones.readRadii();
3259
3550
  };
3260
3551
  /** Applies a pending resize; false while there is nothing to draw into. */
3261
3552
  const prepare = () => {
@@ -3268,8 +3559,26 @@ function mount(container, options) {
3268
3559
  sizeDirty = false;
3269
3560
  applySize(renderer);
3270
3561
  }
3562
+ measureZones(renderer);
3271
3563
  return renderer;
3272
3564
  };
3565
+ /**
3566
+ * Measures the readability zones for the draw about to happen. On every
3567
+ * drawn frame and manual draw (D8), never while the loop is stopped: the
3568
+ * page's elements move without telling anyone (scroll, transforms,
3569
+ * animations), so a fresh read is the only way to be right. No reads
3570
+ * without zones, or when the renderer won't draw them (strength 0 without
3571
+ * the debug outline, the raw view); the next frame that draws them
3572
+ * measures afresh.
3573
+ */
3574
+ const measureZones = (r) => {
3575
+ let count = 0;
3576
+ if (zones.size > 0 && r.drawsAvoid) {
3577
+ const c = canvas.getBoundingClientRect();
3578
+ count = zones.measure(c, window.innerWidth, window.innerHeight, avoidFade.feather);
3579
+ }
3580
+ r.setAvoid(zones.rects, zones.radii, count);
3581
+ };
3273
3582
  /** Simulates `dt` seconds and draws; shared by the frame loop and step(). */
3274
3583
  const advance = (r, dt) => {
3275
3584
  pond.calm = reduceMotion.matches;