koi-ascii 0.3.1 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +396 -163
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -463,7 +463,7 @@ console.log(await page.evaluate(() => koi.help()));
463
463
  | `setTime` | `mode?`, `phase?`, `cycle?`, `hold?` | Time of day; `hold` keeps the phase until a call with `hold: false`. |
464
464
  | `setDayPart` | `part` | Jumps to a lighting keyframe (`night`, `blue-hour-am`, … `noon` …). |
465
465
  | `getPerf` | | fps, 1% low, frame / CPU / GPU ms (GPU `null` without the timer extension), canvas size, GPU string, software flag, seed. |
466
- | `profile` | `frames?` (400) | GPU µs per pass (`scene`, `glow`, `ascii`, `fly`; `frame` is all of them), median and p90, from timer queries around each draw over `frames` redraws of the paused frame. `{ gpuTimer: false }` without the timer extension. |
466
+ | `profile` | `frames?` (400) | GPU µs per pass (`scene`, `emission`, `glow`, `ascii`, `fly`; `frame` is all of them), median and p90, from timer queries around each draw over `frames` redraws of the paused frame. `{ gpuTimer: false }` without the timer extension. |
467
467
  | `setRender` | `view?`, `glow?` | `final` / `raw` view and the glow halo. The raw view ignores the palette and intensity. |
468
468
  | `setPalette` | `palette` | Switches to a preset (`pond`, `mono`, `phosphor`, `amber`, `paper`, `brand`). |
469
469
  | `setIntensity` | `intensity` | 0 background only … 1 full pond. |
package/dist/index.js CHANGED
@@ -304,30 +304,33 @@ var mix3 = (a, b, t) => [
304
304
  mix$1(a[2], b[2], t)
305
305
  ];
306
306
  /**
307
- * Direction toward a body on an arc that rises at angle 0 (screen left) and
308
- * sets at π (screen right), with elevation lo..hi following sin(angle).
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
309
  */
310
- function arcDir(angle, lo, hi) {
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) {
311
318
  const el = mix$1(lo, hi, Math.max(0, Math.sin(angle)));
312
- const { x, y, length } = azimuth(angle);
313
- const h = Math.cos(el) / length;
314
- return [
315
- x * h,
316
- y * h,
317
- Math.sin(el)
318
- ];
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);
319
324
  }
320
- /** A body's azimuth on screen at `angle` (see arcDir), tilted; its length is the same before and after. */
325
+ /** A body's azimuth on screen at `angle` (see arcDir), tilted, into AZ; its length is the same before and after. */
321
326
  function azimuth(angle) {
322
327
  const ax = -Math.cos(angle);
323
328
  const ay = -.8 * Math.sin(angle);
324
329
  const c = Math.cos(PATH_TILT);
325
330
  const s = Math.sin(PATH_TILT);
326
- return {
327
- x: ax * c - ay * s,
328
- y: ax * s + ay * c,
329
- length: Math.hypot(ax, ay) || 1
330
- };
331
+ AZ[0] = ax * c - ay * s;
332
+ AZ[1] = ax * s + ay * c;
333
+ AZ[2] = Math.hypot(ax, ay) || 1;
331
334
  }
332
335
  /** Half-extents of the path reflections follow, as a share of the pond's width and height. */
333
336
  var BODY_BAND = [.38, .36];
@@ -358,7 +361,9 @@ var SUN_PACE = 2 * HANDOFF[0] / (1 - 2 * HANDOFF[1]);
358
361
  * keeps clear of the middle 40 % of the pond at any aspect (P1).
359
362
  */
360
363
  function bodyPos(angle) {
361
- const { x, y } = azimuth(angle);
364
+ azimuth(angle);
365
+ const x = AZ[0];
366
+ const y = AZ[1];
362
367
  const k = ((Math.abs(x) / BODY_BAND[0]) ** BODY_ROUND + (Math.abs(y) / BODY_BAND[1]) ** BODY_ROUND) ** (-1 / BODY_ROUND);
363
368
  return [.5 + x * k, .5 + y * k];
364
369
  }
@@ -438,19 +443,28 @@ function lightingAt(phase, options = {}) {
438
443
  const a = KEYS[i];
439
444
  const b = KEYS[i + 1];
440
445
  const t = smooth(a.at, b.at, p);
441
- const sun = arcDir(TAU$1 * (p - .25), SUN_LOW, SUN_HIGH);
442
- const d = mix3(arcDir(TAU$1 * (p - .75), MOON_LOW, MOON_HIGH), sun, smooth(HANDOFF[0], HANDOFF[1], p) * (1 - smooth(1 - HANDOFF[1], 1 - HANDOFF[0], p)));
443
- const dl = Math.hypot(d[0], d[1], d[2]);
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);
444
453
  const dir = [
445
- d[0] / dl,
446
- d[1] / dl,
447
- d[2] / dl
454
+ d0 / dl,
455
+ d1 / dl,
456
+ d2 / dl
448
457
  ];
449
458
  const horiz = Math.hypot(dir[0], dir[1]);
450
459
  const len = Math.min(SHADOW_MAX, SHADOW_PER_COT * horiz / Math.max(dir[2], .001));
451
460
  const shadow = horiz > 1e-6 ? [-dir[0] / horiz * len, -dir[1] / horiz * len] : [0, 0];
452
461
  const floor = mix$1(a.floor, b.floor, t);
453
- const water = mix3(a.water, b.water, t).map((v) => Math.max(v, floor));
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)
466
+ ];
467
+ const night = options.darkNight ? nightWeight(p) : 0;
454
468
  return {
455
469
  dir,
456
470
  color: mix3(a.color, b.color, t),
@@ -463,21 +477,12 @@ function lightingAt(phase, options = {}) {
463
477
  shadow,
464
478
  shadowAlpha: mix$1(a.shadowAlpha, b.shadowAlpha, t),
465
479
  glowStrength: mix$1(a.glow, b.glow, t),
466
- ...darkNight(options.darkNight ? nightWeight(p) : 0),
480
+ darkWater: night <= 0 ? 1 : mix$1(1, DARK_WATER, night),
481
+ darkCaustic: night <= 0 ? 1 : mix$1(1, DARK_CAUSTIC, night),
467
482
  moon: moonAt(p),
468
483
  sun: sunAt(p)
469
484
  };
470
485
  }
471
- function darkNight(night) {
472
- if (night <= 0) return {
473
- darkWater: 1,
474
- darkCaustic: 1
475
- };
476
- return {
477
- darkWater: mix$1(1, DARK_WATER, night),
478
- darkCaustic: mix$1(1, DARK_CAUSTIC, night)
479
- };
480
- }
481
486
  /** Wraps any number into [0, 1). */
482
487
  function wrapPhase(p) {
483
488
  return p - Math.floor(p);
@@ -1024,6 +1029,8 @@ function layoutDefines() {
1024
1029
  J_TAIL: 6,
1025
1030
  J_TOTAL: 22,
1026
1031
  TRAIL_N: 40,
1032
+ TRAIL_CHUNK: 8,
1033
+ TRAIL_CHUNKS: 5,
1027
1034
  T_PEC_L: 22,
1028
1035
  T_PEC_R: 23,
1029
1036
  T_FIN: 24,
@@ -1033,6 +1040,7 @@ function layoutDefines() {
1033
1040
  T_TBOX: 28,
1034
1041
  T_P1: 29,
1035
1042
  T_TRAIL: 30,
1043
+ T_CBOX: 70,
1036
1044
  MAX_DROPS: 32
1037
1045
  }).map(([k, v]) => `#define ${k} ${v}\n`).join("");
1038
1046
  }
@@ -1061,24 +1069,56 @@ function profile(u) {
1061
1069
  }
1062
1070
  return PROFILE[PROFILE.length - 1][1];
1063
1071
  }
1072
+ /** profile() at each body joint, u = i / (J_BODY - 1); the same for every fish and frame. */
1073
+ var JOINT_PROFILE = Float64Array.from({ length: 16 }, (_, i) => profile(i / 15));
1074
+ var PEDUNCLE_PROFILE = profile(1);
1064
1075
  var clamp = (x, lo, hi) => x < lo ? lo : x > hi ? hi : x;
1065
1076
  var lerp = (a, b, t) => a + (b - a) * t;
1066
1077
  function smoothstep(e0, e1, x) {
1067
1078
  const t = clamp((x - e0) / (e1 - e0), 0, 1);
1068
1079
  return t * t * (3 - 2 * t);
1069
1080
  }
1081
+ /** How far tail joint J_BODY + n has spread from the peduncle width to the full tail width. */
1082
+ var TAIL_SPREAD = Float64Array.from({ length: 6 }, (_, n) => Math.pow(smoothstep(0, .85, (n + 1) / 6), .75));
1070
1083
  function wrapAngle(a) {
1071
1084
  a = (a + Math.PI) % TAU;
1072
1085
  return (a < 0 ? a + TAU : a) - Math.PI;
1073
1086
  }
1074
1087
  /** Frame-rate independent smoothing factor. */
1075
1088
  var ease = (rate, dt) => 1 - Math.exp(-rate * dt);
1089
+ /**
1090
+ * Math.hypot(x, y) to the bit, but inlinable: V8 never inlines its builtin, so
1091
+ * each call boxes both arguments and the result as heap numbers. For two
1092
+ * arguments the builtin's normalised Kahan sum comes down to this. The NaN case
1093
+ * returns ax + ay, not the global NaN: that load never runs, so TurboFan leaves
1094
+ * it untyped and would box every inlined call's result.
1095
+ */
1096
+ function hypot(x, y) {
1097
+ const ax = Math.abs(x);
1098
+ const ay = Math.abs(y);
1099
+ if (ax === Infinity || ay === Infinity) return Infinity;
1100
+ if (ax !== ax || ay !== ay) return ax + ay;
1101
+ const hi = ax > ay ? ax : ay;
1102
+ if (hi === 0) return 0;
1103
+ const q = (ax > ay ? ay : ax) / hi;
1104
+ return Math.sqrt(1 + q * q) * hi;
1105
+ }
1106
+ /**
1107
+ * Age at which writeTrail ends a trail. Ages only grow along it, so a segment
1108
+ * cut off joins samples at least this old, where trails.glsl's I is at most
1109
+ * 0.15^1.3 < 0.085; even times the largest variety factor, 1.3 (gnoise stays
1110
+ * within [-1, 1]), that is under the 0.12 a trail cell needs. So the strongest
1111
+ * segment of any cell drawn is kept, and is still the first that strong.
1112
+ */
1113
+ var TRAIL_CUT = .85;
1076
1114
  var Koi = class {
1077
1115
  variety;
1078
1116
  /** Small offset into pattern noise space; gives each fish its own markings. */
1079
1117
  patternSeed;
1080
1118
  rng;
1081
1119
  noiseSeed;
1120
+ /** cos and sin of +maxBend, then of -maxBend: body joints from 0, tail joints from 4. */
1121
+ bendTrig;
1082
1122
  length = 1;
1083
1123
  x = 0;
1084
1124
  y = 0;
@@ -1106,6 +1146,8 @@ var Koi = class {
1106
1146
  nextRipple = 0;
1107
1147
  nextGulp = 0;
1108
1148
  posed = false;
1149
+ /** sampleBody's result: x, y, radius, then the backward axis bx, by. Each call overwrites it. */
1150
+ sample = /* @__PURE__ */ new Float64Array(5);
1109
1151
  /** Spine centreline that follows the head's path: x, y per joint. */
1110
1152
  spine = /* @__PURE__ */ new Float32Array(44);
1111
1153
  /** Joints with the swimming wave applied: x, y, radius. */
@@ -1119,8 +1161,10 @@ var Koi = class {
1119
1161
  eyeRadius = 1;
1120
1162
  /** Bounds of body and fins: min.xy, max.xy. */
1121
1163
  bbox = /* @__PURE__ */ new Float32Array(4);
1122
- /** Bounds of the trail written by the last writeTrail call. */
1164
+ /** Bounds of the trail written by the last writeTrail call: the union of trailChunks. */
1123
1165
  trailBox = /* @__PURE__ */ new Float32Array(4);
1166
+ /** Bounds of each chunk of that trail: min.xy, max.xy per chunk (see writeTrail). */
1167
+ trailChunks = /* @__PURE__ */ new Float32Array(20);
1124
1168
  /** Ring buffer of trail samples: x, y, time. */
1125
1169
  trail = /* @__PURE__ */ new Float32Array(117);
1126
1170
  trailHead = 0;
@@ -1128,6 +1172,12 @@ var Koi = class {
1128
1172
  nextTrail = 0;
1129
1173
  constructor(variety, seed, world) {
1130
1174
  this.variety = variety;
1175
+ this.bendTrig = Float64Array.from([variety.bodyBend, variety.tailBend].flatMap((m) => [
1176
+ Math.cos(m),
1177
+ Math.sin(m),
1178
+ Math.cos(-m),
1179
+ Math.sin(-m)
1180
+ ]));
1131
1181
  this.rng = new Rng(seed);
1132
1182
  this.noiseSeed = this.rng.range(0, 100);
1133
1183
  this.patternSeed = this.rng.range(0, 40);
@@ -1219,11 +1269,11 @@ var Koi = class {
1219
1269
  const t = w.time;
1220
1270
  const ptr = w.pointer;
1221
1271
  const ptrActive = (ptr.inside || ptr.linger) && t - ptr.lastMove < v.attention;
1222
- const pd = ptrActive ? Math.hypot(ptr.x - this.x, ptr.y - this.y) : Infinity;
1272
+ const pd = ptrActive ? hypot(ptr.x - this.x, ptr.y - this.y) : Infinity;
1223
1273
  for (const tap of w.taps) {
1224
1274
  if (tap.t <= this.lastTap) continue;
1225
1275
  this.lastTap = tap.t;
1226
- const d = Math.hypot(tap.x - this.x, tap.y - this.y);
1276
+ const d = hypot(tap.x - this.x, tap.y - this.y);
1227
1277
  if (!w.calm && d < w.unit * .55 * v.skittish) this.startle(tap.x, tap.y, t, w);
1228
1278
  else if (v.roams && d < w.unit * 1.5) {
1229
1279
  this.poi = {
@@ -1266,12 +1316,12 @@ var Koi = class {
1266
1316
  else if (ax > w.width - margin) sx -= (ax - (w.width - margin)) / margin * 3;
1267
1317
  if (ay < margin) sy += (margin - ay) / margin * 3;
1268
1318
  else if (ay > w.height - margin) sy -= (ay - (w.height - margin)) / margin * 3;
1269
- const wallK = Math.hypot(sx - sx0, sy - sy0);
1319
+ const wallK = hypot(sx - sx0, sy - sy0);
1270
1320
  for (const o of w.fish) {
1271
1321
  if (o === this) continue;
1272
1322
  const dx = this.x - o.x;
1273
1323
  const dy = this.y - o.y;
1274
- const d = Math.hypot(dx, dy);
1324
+ const d = hypot(dx, dy);
1275
1325
  const range = (L + o.length) * .5;
1276
1326
  const dz = Math.abs(this.depth - o.depth);
1277
1327
  if (d > .001 && d < range && dz < .3) {
@@ -1310,7 +1360,7 @@ var Koi = class {
1310
1360
  y: w.height * this.rng.range(.15, .85)
1311
1361
  });
1312
1362
  let poi = pick();
1313
- for (let k = 0; k < 6 && Math.hypot(poi.x - this.x, poi.y - this.y) < .35 * w.unit; k++) poi = pick();
1363
+ for (let k = 0; k < 6 && hypot(poi.x - this.x, poi.y - this.y) < .35 * w.unit; k++) poi = pick();
1314
1364
  this.poi = poi;
1315
1365
  this.poiUntil = t + this.rng.range(8, 14);
1316
1366
  }
@@ -1325,7 +1375,7 @@ var Koi = class {
1325
1375
  if (tw !== 0) {
1326
1376
  const dx = tx - this.x;
1327
1377
  const dy = ty - this.y;
1328
- td = Math.hypot(dx, dy);
1378
+ td = hypot(dx, dy);
1329
1379
  if (td > .001) {
1330
1380
  const near = tw > 0 ? smoothstep(L * .25, L * .6, td) : 1;
1331
1381
  sx += dx / td * tw * 2.2 * near;
@@ -1409,7 +1459,7 @@ var Koi = class {
1409
1459
  startle(x, y, t, w) {
1410
1460
  const dx = this.x - x;
1411
1461
  const dy = this.y - y;
1412
- const d = Math.hypot(dx, dy) || 1;
1462
+ const d = hypot(dx, dy) || 1;
1413
1463
  this.fleeX = dx / d;
1414
1464
  this.fleeY = dy / d;
1415
1465
  this.mode = "flee";
@@ -1437,7 +1487,7 @@ var Koi = class {
1437
1487
  const maxBend = i < 16 ? v.bodyBend : v.tailBend;
1438
1488
  let dx = s[i * 2] - s[i * 2 - 2];
1439
1489
  let dy = s[i * 2 + 1] - s[i * 2 - 1];
1440
- const d = Math.hypot(dx, dy);
1490
+ const d = hypot(dx, dy);
1441
1491
  if (d < 1e-6) {
1442
1492
  dx = px;
1443
1493
  dy = py;
@@ -1445,11 +1495,19 @@ var Koi = class {
1445
1495
  dx /= d;
1446
1496
  dy /= d;
1447
1497
  }
1448
- const bend = Math.atan2(px * dy - py * dx, px * dx + py * dy);
1449
- if (Math.abs(bend) > maxBend) {
1450
- const a = Math.sign(bend) * maxBend;
1451
- const c = Math.cos(a);
1452
- const sn = Math.sin(a);
1498
+ const cross = px * dy - py * dx;
1499
+ const dot = px * dx + py * dy;
1500
+ const t = i < 16 ? 0 : 4;
1501
+ const past = Math.abs(cross) * this.bendTrig[t] - dot * this.bendTrig[t + 1];
1502
+ let side = 0;
1503
+ if (past > 1e-9 && cross !== 0) side = cross > 0 ? 1 : -1;
1504
+ else if (past >= -1e-9) {
1505
+ const bend = Math.atan2(cross, dot);
1506
+ if (Math.abs(bend) > maxBend) side = Math.sign(bend);
1507
+ }
1508
+ if (side !== 0) {
1509
+ const c = this.bendTrig[side > 0 ? t : t + 2];
1510
+ const sn = this.bendTrig[side > 0 ? t + 1 : t + 3];
1453
1511
  dx = px * c - py * sn;
1454
1512
  dy = px * sn + py * c;
1455
1513
  }
@@ -1467,26 +1525,25 @@ var Koi = class {
1467
1525
  const tailLen = L * v.tailLength;
1468
1526
  const total = L + tailLen;
1469
1527
  const maxR = v.radius * L;
1470
- const pedR = maxR * profile(1);
1528
+ const pedR = maxR * PEDUNCLE_PROFILE;
1471
1529
  for (let i = 0; i < 22; i++) {
1472
1530
  const i0 = Math.max(i - 1, 0);
1473
1531
  const i1 = Math.min(i + 1, 21);
1474
1532
  let tx = s[i1 * 2] - s[i0 * 2];
1475
1533
  let ty = s[i1 * 2 + 1] - s[i0 * 2 + 1];
1476
- const tl = Math.hypot(tx, ty) || 1;
1534
+ const tl = hypot(tx, ty) || 1;
1477
1535
  tx /= tl;
1478
1536
  ty /= tl;
1479
1537
  let a;
1480
1538
  let r;
1481
1539
  let lag = 0;
1482
1540
  if (i < 16) {
1483
- const u = i / 15;
1484
- a = u * L / total;
1485
- r = maxR * profile(u);
1541
+ a = i / 15 * L / total;
1542
+ r = maxR * JOINT_PROFILE[i];
1486
1543
  } else {
1487
1544
  const k = (i - 16 + 1) / 6;
1488
1545
  a = (L + k * tailLen) / total;
1489
- r = pedR + (maxR * v.tailSpread - pedR) * Math.pow(smoothstep(0, .85, k), .75);
1546
+ r = pedR + (maxR * v.tailSpread - pedR) * TAIL_SPREAD[i - 16];
1490
1547
  lag = k * v.tailBend * 1.6;
1491
1548
  }
1492
1549
  const off = this.amp * (.1 + .9 * a * a) * Math.sin(TAU * v.waveK * a - this.phase - lag);
@@ -1501,15 +1558,22 @@ var Koi = class {
1501
1558
  this.pecMax = v.finWidth * L;
1502
1559
  this.pecRoot = this.pecMax * .4;
1503
1560
  const root = this.sampleBody(.2);
1561
+ const rootX = root[0];
1562
+ const rootY = root[1];
1563
+ const rootR = root[2];
1564
+ const rootBx = root[3];
1565
+ const rootBy = root[4];
1566
+ const rootNx = -rootBy;
1567
+ const rootNy = rootBx;
1504
1568
  for (let side = -1; side <= 1; side += 2) {
1505
1569
  const out = side > 0 ? this.pecL : this.pecR;
1506
1570
  const ang = sweep + slow * .35 * Math.sin(t * 3.4 + (side > 0 ? 0 : Math.PI));
1507
1571
  const c = Math.cos(ang);
1508
1572
  const sn = Math.sin(ang) * side;
1509
- const dx = root.bx * c + root.nx * sn;
1510
- const dy = root.by * c + root.ny * sn;
1511
- const rx = root.x + root.nx * side * root.r * .85;
1512
- const ry = root.y + root.ny * side * root.r * .85;
1573
+ const dx = rootBx * c + rootNx * sn;
1574
+ const dy = rootBy * c + rootNy * sn;
1575
+ const rx = rootX + rootNx * side * rootR * .85;
1576
+ const ry = rootY + rootNy * side * rootR * .85;
1513
1577
  let tipX = rx + dx * finLen;
1514
1578
  let tipY = ry + dy * finLen;
1515
1579
  if (this.posed) {
@@ -1518,7 +1582,7 @@ var Koi = class {
1518
1582
  tipY = lerp(out[3], tipY, k);
1519
1583
  const lx = tipX - rx;
1520
1584
  const ly = tipY - ry;
1521
- const ll = Math.hypot(lx, ly) || 1;
1585
+ const ll = hypot(lx, ly) || 1;
1522
1586
  tipX = rx + lx / ll * finLen;
1523
1587
  tipY = ry + ly / ll * finLen;
1524
1588
  }
@@ -1528,10 +1592,15 @@ var Koi = class {
1528
1592
  out[3] = tipY;
1529
1593
  }
1530
1594
  const head = this.sampleBody(.075);
1531
- this.eyeRadius = Math.max(head.r * .17, 1);
1595
+ const headX = head[0];
1596
+ const headY = head[1];
1597
+ const headR = head[2];
1598
+ const headNx = -head[4];
1599
+ const headNy = head[3];
1600
+ this.eyeRadius = Math.max(headR * .17, 1);
1532
1601
  for (let side = 1, o = 0; side >= -1; side -= 2, o += 2) {
1533
- this.eyes[o] = head.x + head.nx * side * head.r * .55;
1534
- this.eyes[o + 1] = head.y + head.ny * side * head.r * .55;
1602
+ this.eyes[o] = headX + headNx * side * headR * .55;
1603
+ this.eyes[o + 1] = headY + headNy * side * headR * .55;
1535
1604
  }
1536
1605
  let minX = Infinity;
1537
1606
  let minY = Infinity;
@@ -1544,11 +1613,14 @@ var Koi = class {
1544
1613
  maxX = Math.max(maxX, j[i * 3] + r);
1545
1614
  maxY = Math.max(maxY, j[i * 3 + 1] + r);
1546
1615
  }
1547
- for (const fin of [this.pecL, this.pecR]) for (let k = 0; k < 4; k += 2) {
1548
- minX = Math.min(minX, fin[k] - this.pecMax);
1549
- minY = Math.min(minY, fin[k + 1] - this.pecMax);
1550
- maxX = Math.max(maxX, fin[k] + this.pecMax);
1551
- maxY = Math.max(maxY, fin[k + 1] + this.pecMax);
1616
+ for (let f = 0; f < 2; f++) {
1617
+ const fin = f ? this.pecR : this.pecL;
1618
+ for (let k = 0; k < 4; k += 2) {
1619
+ minX = Math.min(minX, fin[k] - this.pecMax);
1620
+ minY = Math.min(minY, fin[k + 1] - this.pecMax);
1621
+ maxX = Math.max(maxX, fin[k] + this.pecMax);
1622
+ maxY = Math.max(maxY, fin[k + 1] + this.pecMax);
1623
+ }
1552
1624
  }
1553
1625
  this.bbox[0] = minX;
1554
1626
  this.bbox[1] = minY;
@@ -1556,7 +1628,10 @@ var Koi = class {
1556
1628
  this.bbox[3] = maxY;
1557
1629
  this.posed = true;
1558
1630
  }
1559
- /** Point on the rendered body centreline at u (0 nose .. 1 peduncle). */
1631
+ /**
1632
+ * Point on the rendered body centreline at u (0 nose .. 1 peduncle), with
1633
+ * its radius and backward (head → tail) axis, written into `sample`.
1634
+ */
1560
1635
  sampleBody(u) {
1561
1636
  const j = this.joints;
1562
1637
  const f = u * 15;
@@ -1566,20 +1641,16 @@ var Koi = class {
1566
1641
  const b = Math.min(i + 2, 15);
1567
1642
  let bx = j[b * 3] - j[a * 3];
1568
1643
  let by = j[b * 3 + 1] - j[a * 3 + 1];
1569
- const bl = Math.hypot(bx, by) || 1;
1644
+ const bl = hypot(bx, by) || 1;
1570
1645
  bx /= bl;
1571
1646
  by /= bl;
1572
- return {
1573
- x: lerp(j[i * 3], j[(i + 1) * 3], k),
1574
- y: lerp(j[i * 3 + 1], j[(i + 1) * 3 + 1], k),
1575
- r: lerp(j[i * 3 + 2], j[(i + 1) * 3 + 2], k),
1576
- /** backward (head → tail) axis */
1577
- bx,
1578
- by,
1579
- /** fish's left in y-down screen space */
1580
- nx: -by,
1581
- ny: bx
1582
- };
1647
+ const o = this.sample;
1648
+ o[0] = lerp(j[i * 3], j[(i + 1) * 3], k);
1649
+ o[1] = lerp(j[i * 3 + 1], j[(i + 1) * 3 + 1], k);
1650
+ o[2] = lerp(j[i * 3 + 2], j[(i + 1) * 3 + 2], k);
1651
+ o[3] = bx;
1652
+ o[4] = by;
1653
+ return o;
1583
1654
  }
1584
1655
  /**
1585
1656
  * Where the trail is laid: 35% of the way from the spine's tail tip to the
@@ -1604,37 +1675,65 @@ var Koi = class {
1604
1675
  }
1605
1676
  /**
1606
1677
  * Writes trail samples newest-first as texels (x, y, age, 0) starting at
1607
- * `base`, beginning with the live tail-tip position. Returns the count.
1678
+ * `base`, beginning with the live tail-tip position and ending with the
1679
+ * first sample TRAIL_CUT old. Returns the count.
1680
+ * Chunk c of trailChunks bounds segments 1 + c * TRAIL_CHUNK to
1681
+ * (c + 1) * TRAIL_CHUNK (segment k joins samples k - 1 and k), grown by the
1682
+ * width they draw at the chunk's oldest sample (trails.glsl trailSeg, less
1683
+ * its cell term, which the shader adds) and 1 px for rounding; a chunk past
1684
+ * the last segment is empty. trailBox is their union, empty with no segments.
1608
1685
  */
1609
1686
  writeTrail(out, base, t) {
1610
1687
  const cap = 39;
1611
1688
  const life = this.variety.trailTime;
1612
- let [x, y] = this.trailPoint();
1689
+ const [x, y] = this.trailPoint();
1613
1690
  out[base] = x;
1614
1691
  out[base + 1] = y;
1615
1692
  out[base + 2] = 0;
1616
1693
  out[base + 3] = 0;
1617
- let minX = x;
1618
- let minY = y;
1619
- let maxX = x;
1620
- let maxY = y;
1621
1694
  let n = 1;
1622
1695
  for (let k = 0; k < this.trailLen; k++) {
1623
1696
  const idx = (this.trailHead - 1 - k + cap) % cap;
1624
1697
  const age = (t - this.trail[idx * 3 + 2]) / life;
1625
- x = this.trail[idx * 3];
1626
- y = this.trail[idx * 3 + 1];
1627
1698
  const o = base + n * 4;
1628
- out[o] = x;
1629
- out[o + 1] = y;
1699
+ out[o] = this.trail[idx * 3];
1700
+ out[o + 1] = this.trail[idx * 3 + 1];
1630
1701
  out[o + 2] = Math.min(age, 1);
1631
1702
  out[o + 3] = 0;
1632
- minX = Math.min(minX, x);
1633
- minY = Math.min(minY, y);
1634
- maxX = Math.max(maxX, x);
1635
- maxY = Math.max(maxY, y);
1636
1703
  n++;
1637
- if (age >= 1) break;
1704
+ if (age >= TRAIL_CUT) break;
1705
+ }
1706
+ const boxes = this.trailChunks;
1707
+ const w0 = this.variety.trailWidth * this.length;
1708
+ let minX = 1e9;
1709
+ let minY = 1e9;
1710
+ let maxX = -1e9;
1711
+ let maxY = -1e9;
1712
+ for (let c = 0; c < 5; c++) {
1713
+ const first = c * 8;
1714
+ const last = first + 1 < n ? Math.min(first + 8, n - 1) : -1;
1715
+ let x0 = 1e9;
1716
+ let y0 = 1e9;
1717
+ let x1 = -1e9;
1718
+ let y1 = -1e9;
1719
+ let maxAge = 0;
1720
+ for (let i = first; i <= last; i++) {
1721
+ const o = base + i * 4;
1722
+ x0 = Math.min(x0, out[o]);
1723
+ y0 = Math.min(y0, out[o + 1]);
1724
+ x1 = Math.max(x1, out[o]);
1725
+ y1 = Math.max(y1, out[o + 1]);
1726
+ maxAge = Math.max(maxAge, out[o + 2]);
1727
+ }
1728
+ const g = last < 0 ? 0 : w0 * (.5 + 1.4 * maxAge) + 1;
1729
+ boxes[c * 4] = x0 - g;
1730
+ boxes[c * 4 + 1] = y0 - g;
1731
+ boxes[c * 4 + 2] = x1 + g;
1732
+ boxes[c * 4 + 3] = y1 + g;
1733
+ minX = Math.min(minX, boxes[c * 4]);
1734
+ minY = Math.min(minY, boxes[c * 4 + 1]);
1735
+ maxX = Math.max(maxX, boxes[c * 4 + 2]);
1736
+ maxY = Math.max(maxY, boxes[c * 4 + 3]);
1638
1737
  }
1639
1738
  this.trailBox[0] = minX;
1640
1739
  this.trailBox[1] = minY;
@@ -1807,7 +1906,7 @@ var Pond = class {
1807
1906
  /** Ripple impulses waiting for the next simulation step. */
1808
1907
  drops = [];
1809
1908
  /** Fish data texture contents, see koi/layout.ts. */
1810
- data = /* @__PURE__ */ new Float32Array(1120);
1909
+ data = /* @__PURE__ */ new Float32Array(1200);
1811
1910
  /** Fish rows in draw order, deepest first. */
1812
1911
  order = /* @__PURE__ */ new Int32Array(4);
1813
1912
  /** Seeded from the pond's seed; simulated whether or not they show (the sky decides that). */
@@ -1921,7 +2020,7 @@ var Pond = class {
1921
2020
  const n = Math.min(this.fish.length, 4);
1922
2021
  for (let f = 0; f < n; f++) {
1923
2022
  const k = this.fish[f];
1924
- const row = f * 70 * 4;
2023
+ const row = f * 75 * 4;
1925
2024
  for (let i = 0; i < 22; i++) {
1926
2025
  const o = row + i * 4;
1927
2026
  d[o] = k.joints[i * 3];
@@ -1931,28 +2030,26 @@ var Pond = class {
1931
2030
  }
1932
2031
  d.set(k.pecL, row + 88);
1933
2032
  d.set(k.pecR, row + 92);
1934
- d.set([
1935
- k.pecRoot,
1936
- k.pecMax,
1937
- k.length * k.variety.tailLength,
1938
- k.variety.tailFork
1939
- ], row + 96);
2033
+ const fin = row + 96;
2034
+ d[fin] = k.pecRoot;
2035
+ d[fin + 1] = k.pecMax;
2036
+ d[fin + 2] = k.length * k.variety.tailLength;
2037
+ d[fin + 3] = k.variety.tailFork;
1940
2038
  d.set(k.eyes, row + 100);
1941
- d.set([
1942
- k.variety.id,
1943
- k.patternSeed,
1944
- k.eyeRadius,
1945
- k.depth
1946
- ], row + 104);
2039
+ const p0 = row + 104;
2040
+ d[p0] = k.variety.id;
2041
+ d[p0 + 1] = k.patternSeed;
2042
+ d[p0 + 2] = k.eyeRadius;
2043
+ d[p0 + 3] = k.depth;
1947
2044
  d.set(k.bbox, row + 108);
1948
2045
  const count = k.writeTrail(d, row + 120, this.time);
1949
2046
  d.set(k.trailBox, row + 112);
1950
- d.set([
1951
- count,
1952
- k.variety.trailWidth * k.length,
1953
- 0,
1954
- 0
1955
- ], row + 116);
2047
+ d.set(k.trailChunks, row + 280);
2048
+ const p1 = row + 116;
2049
+ d[p1] = count;
2050
+ d[p1 + 1] = k.variety.trailWidth * k.length;
2051
+ d[p1 + 2] = 0;
2052
+ d[p1 + 3] = 0;
1956
2053
  }
1957
2054
  if (this.orderN !== n) {
1958
2055
  const idx = Array.from({ length: n }, (_, i) => i).sort((a, b) => this.fish[b].depth - this.fish[a].depth);
@@ -1976,22 +2073,56 @@ var Pond = class {
1976
2073
  function overlaps(a, b) {
1977
2074
  return !(a[2] < b[0] || b[2] < a[0] || a[3] < b[1] || b[3] < a[1]);
1978
2075
  }
1979
- var fullscreen_default = "// One oversized triangle covering the viewport; no vertex buffers needed.\nvoid main() {\n vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}\n";
1980
- var common_default = "precision highp float;\nprecision highp int;\nprecision highp sampler2D;\n\n// Glyph classes written by the scene pass and read by the ASCII pass.\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 // a reflected star (sky feature `stars`)\n#define M_MOON 9 // the moon's reflection (sky feature `moon`)\n#define M_FLY 10 // a firefly, over everything (sky feature `fireflies`; fly.frag)\n#define M_SUN 11 // the sun's glitter (sky feature `sun`)\n\n#define PI 3.14159265\n#define TAU 6.28318531\n\n// Hash without sine (Dave Hoskins).\nfloat hash12(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nvec2 hash22(vec2 p) {\n vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\n// Gradient noise, roughly [-0.7, 0.7].\nfloat gnoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = dot(hash22(i) * 2.0 - 1.0, f);\n float b = dot(hash22(i + vec2(1.0, 0.0)) * 2.0 - 1.0, f - vec2(1.0, 0.0));\n float c = dot(hash22(i + vec2(0.0, 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0));\n float d = dot(hash22(i + vec2(1.0, 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float s = 0.0;\n float a = 0.5;\n for (int i = 0; i < 4; i++) {\n s += a * gnoise(p);\n p = mat2(1.6, 1.2, -1.2, 1.6) * p;\n a *= 0.5;\n }\n return s;\n}\n\n#ifdef RIPPLE_PACKED\n// Ripple state packed into RGBA8 when float render targets are unavailable:\n// each of (height, previous height) in [-1, 1] uses two bytes.\nvec2 enc16(float v) {\n float x = floor(clamp(v * 0.5 + 0.5, 0.0, 1.0) * 65535.0 + 0.5);\n float hi = floor(x / 256.0);\n return vec2(hi, x - hi * 256.0) / 255.0;\n}\n\nfloat dec16(vec2 e) {\n vec2 b = floor(e * 255.0 + 0.5);\n return (b.x * 256.0 + b.y) / 65535.0 * 2.0 - 1.0;\n}\n#endif\n";
1981
- var ripple_default = "// One step of the surface wave equation. State: (height, previous height).\n\nuniform sampler2D uState;\nuniform ivec2 uSize;\nuniform float uDamping;\nuniform vec4 uDrops[MAX_DROPS]; // xy centre and z radius in texels, w impulse\nuniform int uDropCount;\n\nout vec4 fragColor;\n\nvec2 state(ivec2 c) {\n c = clamp(c, ivec2(0), uSize - 1);\n vec4 s = texelFetch(uState, c, 0);\n#ifdef RIPPLE_PACKED\n return vec2(dec16(s.rg), dec16(s.ba));\n#else\n return s.rg;\n#endif\n}\n\nvoid main() {\n ivec2 c = ivec2(gl_FragCoord.xy);\n vec2 s = state(c);\n float 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;\n float h = 2.0 * s.x - s.y + 0.3 * lap;\n\n // Soak up waves near the borders instead of reflecting them.\n vec2 fc = gl_FragCoord.xy;\n float edge = min(min(fc.x, fc.y), min(float(uSize.x) - fc.x, float(uSize.y) - fc.y));\n h *= uDamping * mix(0.86, 1.0, smoothstep(0.0, 8.0, edge));\n\n for (int i = 0; i < MAX_DROPS; i++) {\n if (i >= uDropCount) break;\n vec4 d = uDrops[i];\n float x = length(fc - d.xy) / d.z;\n if (x < 1.0) {\n float k = 1.0 - x * x;\n h += d.w * k * k;\n }\n }\n h = clamp(h, -1.0, 1.0);\n\n#ifdef RIPPLE_PACKED\n fragColor = vec4(enc16(h), enc16(s.x));\n#else\n fragColor = vec4(h, s.x, 0.0, 1.0);\n#endif\n}\n";
1982
- var scene_default = "// Scene pass: one fragment per character cell. Decides what the cell shows\n// (water, ripple, trail, fin, body outline, body, eye), its colour and which\n// glyph draws it. The ASCII pass only copies that glyph from the atlas.\n// World space is CSS pixels with y pointing down; cell (0, 0) is top-left.\n\nuniform sampler2D uFish;\nuniform sampler2D uRipple;\nuniform ivec2 uRippleSize;\nuniform float uRipplePx;\nuniform int uFishCount;\nuniform int uOrder[MAX_FISH];\nuniform vec2 uCell;\nuniform vec2 uOriginCss; // world position of the grid's top-left corner\nuniform float uTime;\n// Time-of-day lighting, derived on the CPU from the phase (lighting.ts).\n// Light space: x right, y down the screen, z up toward the viewer.\nuniform vec3 uLightDir; // unit, toward the sun or moon\nuniform vec3 uLightCol; // direct light, intensity included\nuniform vec3 uAmbient; // sky light on the fish\nuniform vec3 uWaterTint; // water, ripples and trails\nuniform vec3 uSky; // sky reflection added to lit water\nuniform float uGlint;\nuniform float uCaustic;\nuniform float uFloor; // minimum light per channel on fish (night legibility)\nuniform float uDarkWater; // darker night: flat water colour ×, 1 when off or by day\nuniform float uDarkCaustic; // darker night: flat water density × (fewer caustics), 1 when off or by day\nuniform vec2 uShadow; // shadow offset, px, away from the light\nuniform float uShadowAlpha;\nuniform float uCalm; // 1 under reduced motion: flicker slows further\nuniform float uStars; // reflected stars (sky feature stars): 0 none … 1 full night\nuniform float uMoteThin; // share of motes taken away (light palettes with stars); 0 keeps every mote\nuniform vec2 uView; // the pond's size, CSS px\n// The moon's reflection (sky feature moon; lighting.ts moonAt): xy centre as\n// a share of uView, y down; z radius as a share of its shorter side; w alpha, 0 off.\nuniform vec4 uMoon;\n// The sun's glitter (sky feature sun; lighting.ts sunAt), as uMoon; z is the\n// patch's outer edge.\nuniform vec4 uSun;\n\nlayout(location = 0) out vec4 oColor; // rgb colour, a density (for the raw view)\nlayout(location = 1) out vec4 oInfo; // r glyph index, g cell class\n\n// Colours, set by the renderer (look.ts): today's pond, or the palette's\n// roles (Q1). Outside roles mode they hold the pond's original constants.\nuniform vec3 uWaterDim;\nuniform vec3 uWaterLit;\nuniform vec3 uRippleCol;\nuniform vec3 uKohakuWhite;\nuniform vec3 uHiRed;\nuniform vec3 uSumi;\nuniform vec3 uShowaWhite;\nuniform vec3 uOgonGold;\nuniform vec3 uOgonShade; // Ogon's flank\nuniform vec3 uOgonGlint;\nuniform vec3 uEyeCol;\n// Light rim drawn over sumi so the Showa's black flank keeps its silhouette.\nuniform vec3 uSumiRim;\nuniform vec3 uKohakuFin;\nuniform vec3 uKohakuTail;\nuniform vec3 uOgonFin;\nuniform vec3 uOgonTail; // tail tip; the root is uOgonGold\nuniform vec3 uSilk;\nuniform vec3 uBubble;\nuniform vec3 uGoldDust;\nuniform vec3 uGlintCol; // water glints in roles mode (the highlight)\nuniform vec3 uAddTint; // multiplies light added on a fish: specular, rim, caustics\nuniform float uRoles; // 1 in roles mode: glints take uGlintCol, not the light's hue\nuniform vec3 uStarCool; // reflected stars, coolest …\nuniform vec3 uStarWarm; // … to warmest\nuniform vec3 uMoonCol; // the moon's reflection at full strength\nuniform vec3 uSunCol; // the sun's glitter at full strength\n\n// Ripple slope (height per px) that reads as a full-strength crest.\nconst float RIPPLE_GAIN = 28.0;\n// Refraction offset (px) per unit of surface slope.\nconst float REFRACT = 220.0;\n// Surface tilt (normal.xy / normal.z) per unit of RIPPLE_GAIN-scaled slope.\n// A full crest tilts ~63°, so a sun ~6° above the horizon is mirrored\n// (half vector ~42° off vertical) at about half a crest; noon needs only\n// ~13°, the rings' soft flanks.\nconst float GLINT_TILT = 2.0;\n// Flicker rate under reduced motion, on top of the pond's own 0.75 slowdown.\nconst float CALM_FLICKER = 0.3;\n// The moon's brightness at full night (D7): dimmer than full strength, so it\n// reads as a reflection rather than an icon.\nconst float MOON_GAIN = 0.85;\n// The sun's glitter, half the prototype's density (D8): the share of would-be\n// flashes dropped each flash, and the brightness of the faintest and brightest,\n// below the Ogon's glints.\nconst float SUN_THIN = 0.5;\nconst vec2 SUN_GAIN = vec2(0.6, 0.9);\n// Glitter strength below which a cell shows plain water, and the share of the\n// patch's radius at its full-strength core.\nconst float SUN_MIN = 0.45;\nconst float SUN_CORE = 0.115;\n// Body specular: broad, so the highlight runs along the back.\nconst float SPEC_POW = 18.0;\n\nfloat flicker() { return mix(1.0, CALM_FLICKER, uCalm); }\n\n// Seen from above, a fish is mostly back: a sun near the horizon would barely\n// reach it. Diffuse light and glyph weight therefore use the light's azimuth\n// at no less than ~30° elevation; the highlight and glints keep the true\n// direction, so they still slide to the lit edge as the sun sinks.\nvec3 diffuseDir() {\n return normalize(vec3(uLightDir.xy, max(uLightDir.z, 0.5)));\n}\n\n// Light falling on a surface with unit normal n: sky + sun/moon, never below\n// the night floor (per channel, which also neutralises the moon's blue).\nvec3 lightOn(vec3 n) {\n return max(uAmbient + uLightCol * max(dot(n, diffuseDir()), 0.0), vec3(uFloor));\n}\n\n// The light's own hue at full brightness, for glints and highlights.\nvec3 lightHue() {\n return uLightCol / max(max(uLightCol.r, max(uLightCol.g, uLightCol.b)), 1e-3);\n}\n\n// The sun or moon mirrored on the water (glints, the sun's glitter): the\n// light's hue, part way to white.\nvec3 mirrorHue() {\n return mix(lightHue(), vec3(1.0), 0.4);\n}\n\nstruct Cell {\n vec3 col;\n float dens;\n int mat;\n vec2 dir; // cell space: outward normal for edges, line direction otherwise\n float aux;\n float style;\n};\n\nstruct Hit {\n float d; // signed distance, px\n float u; // 0..1 along the polyline\n float v; // -1..1 across it\n vec2 n; // outward normal, world\n};\n\nvec4 F(int f, int i) { return texelFetch(uFish, ivec2(i, f), 0); }\n\n// Directions are converted to cell space so glyph slopes match on screen.\nvec2 lineDir(vec2 d) {\n vec2 c = d / uCell;\n float l = length(c);\n return l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\nvec2 cellNormal(vec2 n) {\n vec2 c = n * uCell;\n float l = length(c);\n return l > 1e-6 ? c / l : vec2(1.0, 0.0);\n}\n\n// Fraction of the cell covered, estimated from the distance along the normal.\nfloat coverage(float d, vec2 n) {\n float span = abs(n.x) * uCell.x + abs(n.y) * uCell.y;\n return clamp(0.5 - d / span, 0.0, 1.0);\n}\n\nfloat sq(float x) { return x * x; }\n\n// ---------------------------------------------------------------- glyphs\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) {\n const int B[16] = int[16](0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5);\n return (float(B[(c.x & 3) + (c.y & 3) * 4]) + 0.5) / 16.0;\n}\n\n// Sign-free line direction (y down) → horizontal, '\\', vertical or '/'.\nint lineGlyph(vec2 d, int horizontal, int vertical) {\n float a = atan(d.y, d.x);\n if (a < 0.0) a += PI;\n int s = int(floor(a / (PI * 0.25) + 0.5)) % 4;\n if (s == 0) return horizontal;\n if (s == 1) return G_BSLASH;\n if (s == 2) return vertical;\n return G_SLASH;\n}\n\n// Outward normal (y down) of a silhouette → outline glyph. `cov` says whether\n// the boundary sits in the upper or lower half of the cell.\nint edgeGlyph(vec2 n, float cov) {\n int o = int(floor((atan(n.y, n.x) + TAU) / (PI * 0.25) + 0.5)) % 8;\n if (o == 0) return G_RPAREN;\n if (o == 4) return G_LPAREN;\n if (o == 1 || o == 5) return G_SLASH;\n if (o == 3 || o == 7) return G_BSLASH;\n if (o == 2) return cov > 0.5 ? G_UNDERSCORE : G_MINUS; // bottom edge\n return cov > 0.5 ? G_MINUS : G_UNDERSCORE; // top edge\n}\n\nbool inBox(vec2 p, vec4 box, float pad) {\n return all(greaterThanEqual(p, box.xy - pad)) && all(lessThanEqual(p, box.zw + pad));\n}\n\n// ---------------------------------------------------------------- ripples\n\nfloat rippleTexel(ivec2 c) {\n c = clamp(c, ivec2(0), uRippleSize - 1);\n#ifdef RIPPLE_PACKED\n return dec16(texelFetch(uRipple, c, 0).rg);\n#else\n return texelFetch(uRipple, c, 0).r;\n#endif\n}\n\nfloat rippleAt(vec2 p) {\n vec2 t = p / uRipplePx - 0.5;\n vec2 i = floor(t);\n vec2 f = t - i;\n ivec2 c = ivec2(i);\n return mix(mix(rippleTexel(c), rippleTexel(c + ivec2(1, 0)), f.x),\n mix(rippleTexel(c + ivec2(0, 1)), rippleTexel(c + ivec2(1, 1)), f.x), f.y);\n}\n\n// ---------------------------------------------------------------- water\n\n// Voronoi edge network, animated: bright where two cells meet.\nfloat caustic(vec2 x, float t) {\n vec2 i = floor(x);\n vec2 f = fract(x);\n float f1 = 8.0;\n float f2 = 8.0;\n for (int y = -1; y <= 1; y++) {\n for (int k = -1; k <= 1; k++) {\n vec2 b = vec2(float(k), float(y));\n vec2 o = 0.5 + 0.42 * sin(t + TAU * hash22(i + b));\n float d = length(b + o - f);\n if (d < f1) {\n f2 = f1;\n f1 = d;\n } else if (d < f2) {\n f2 = d;\n }\n }\n }\n return 1.0 - smoothstep(0.0, 0.22, f2 - f1);\n}\n\nCell water(vec2 p, float h, vec2 g) {\n Cell c;\n // Motes drifting with a slow current.\n vec2 mp = floor((p + vec2(7.0, -4.0) * uTime) / uCell);\n // On a light palette at night, a quarter of them, so the stars read (D10).\n float mote = step(mix(0.96, 0.99, uMoteThin), hash12(mp)) * (0.4 + 0.6 * hash12(mp + 17.0));\n // Caustic light on the pond floor, bent a little by the surface. fbm spans\n // only about ±0.3 across a screen, so shimmer stays within ~[0.62, 0.98];\n // the smoothstep's low edge must stay below that range's top.\n float shimmer = 0.8 + 0.6 * fbm(p * 0.0016 + vec2(uTime * 0.012, -uTime * 0.008));\n float lit = smoothstep(0.45, 0.95, caustic(p * 0.011 + g * 3.0, uTime * 0.45) * shimmer);\n c.mat = M_WATER;\n // Fewer floor caustics as the light weakens; motes stay.\n c.dens = max(mote * 0.5, lit * mix(0.6, 1.0, uCaustic));\n // Kept dimmer than ripples and trails, which share the light glyphs.\n c.col = mix(uWaterDim, uWaterLit, 0.6 * lit * min(uCaustic, 1.0)) * uWaterTint + uSky;\n // The darker night (sky feature darkNight). Each is skipped at 1, so\n // without it the water is exactly 0.2's (D4).\n if (uDarkWater < 1.0) c.col *= uDarkWater;\n if (uDarkCaustic < 1.0) c.dens *= uDarkCaustic;\n c.aux = hash12(mp + 3.1);\n c.dir = vec2(1.0, 0.0);\n c.style = 0.0;\n\n float rip = smoothstep(0.15, 0.6, length(g) * RIPPLE_GAIN);\n if (rip > 0.25) {\n c.mat = M_RIPPLE;\n c.dens = rip;\n c.col = mix(uWaterLit, uRippleCol, rip) * uWaterTint + uSky;\n c.dir = lineDir(vec2(-g.y, g.x)); // along the crest\n c.aux = h > 0.0 ? 1.0 : 0.0;\n }\n\n // Glints: the cell mirrors the sun/moon to the viewer (looking straight\n // down) when its surface tilt matches the half vector's. Flat water never\n // glints; only ripples do.\n vec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\n vec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\n vec2 across = vec2(-az.y, az.x);\n float low = 1.0 - uLightDir.z; // ~0.1 at noon, ~0.9 for a setting sun\n vec2 e = -g * RIPPLE_GAIN * GLINT_TILT - H.xy / H.z;\n float ea = dot(e, az);\n float ec = dot(e, across);\n // A low light is mirrored by a wider spread of slopes across its direction,\n // so glints run further along the crests that face it.\n float gl = exp(-sq(ea / (0.3 + 0.4 * low)) - sq(ec / (0.3 + 0.8 * low)));\n gl *= smoothstep(0.06, 0.25, length(g) * RIPPLE_GAIN);\n // Twinkle on a grid stretched along the light: with a low sun neighbouring\n // cells in that direction flash together, so glints read as long dashes.\n vec2 r = vec2(dot(p, az), dot(p, across)) / (max(uCell.x, uCell.y) * vec2(1.0 + 5.0 * low, 1.0));\n float tw = hash12(floor(r) + floor(uTime * 6.0 * flicker()) * 0.613);\n gl *= uGlint * (0.45 + 1.1 * tw);\n if (gl > 0.45) {\n c.mat = M_GLINT;\n c.dens = min(gl, 1.0);\n // Full brightness in the light's hue whatever the hour: a mirror image of\n // the sun or moon, not lit water.\n vec3 gc = uRoles > 0.5 ? uGlintCol : mirrorHue();\n c.col = gc * mix(0.8, 1.0, smoothstep(0.45, 1.0, gl));\n c.dir = lineDir(az);\n c.aux = low;\n }\n return c;\n}\n\n// A sparse field of reflected stars on flat water: fixed to the screen (the\n// sky does not drift with the current, unlike the motes), but ripples shift\n// which cell each star lands in, so a passing ring makes them jump.\nvoid stars(vec2 p, vec2 g, inout Cell c) {\n if (uStars < 0.02 || c.mat != M_WATER) return;\n vec2 cell = floor((p + g * REFRACT * 2.0) / uCell);\n // 1.6 % of cells; the faintest drop out below, leaving about 1.4 %.\n if (hash12(cell * 0.917 + 41.0) < 0.984) return;\n float mag = hash12(cell + 7.7);\n float ph = hash12(cell + 2.3) * TAU;\n // Twinkle: slow breathing plus a rare sharp scintillation. Calm holds\n // each star still at about its average, so the field looks the same.\n float tw = 0.75 + 0.25 * sin(uTime * (0.8 + 1.6 * mag) + ph);\n tw += 0.5 * pow(max(sin(uTime * 0.37 + ph * 3.0), 0.0), 40.0);\n tw = mix(tw, 0.8, uCalm);\n float v = mag * tw * uStars;\n if (v < 0.15) return;\n c.mat = M_STAR;\n c.dens = v;\n c.aux = hash12(cell + 9.1);\n c.style = 0.0;\n // Brighter stars stand out more. The pond dims toward black; roles fade\n // toward the deep water, so on a light palette a faint star is light grey.\n float k = 0.35 + 0.6 * min(v, 1.0);\n vec3 col = mix(uStarCool, uStarWarm, c.aux);\n c.col = uRoles > 0.5 ? mix(uWaterDim, col, k) : col * k;\n}\n\n// The moon's reflection: a full disc with maria and a drawn rim. Ripple\n// slopes shift where each cell looks, so a passing ring tears it into\n// shards; steep cells inside become bright crest dashes. Drawn over the\n// water and stars, under everything else.\nvoid moon(vec2 p, vec2 g, inout Cell c) {\n float a = uMoon.w;\n if (a < 0.02) return;\n float R = uMoon.z * min(uView.x, uView.y);\n // A slow sideways wobble, as on moving water; slower under reduced motion.\n vec2 wob = vec2(sin(p.y * 0.21 + uTime * 1.3 * flicker()) * 1.2, 0.0);\n vec2 dq = p + g * REFRACT * 3.0 + wob - uMoon.xy * uView;\n float dist = length(dq);\n if (dist > R * 1.4) return;\n vec2 n = dist > 1e-3 ? dq / dist : vec2(1.0, 0.0);\n float cv = coverage(dist - R, n);\n if (cv < 0.25) return;\n float r = dist / R;\n float maria = smoothstep(-0.05, 0.2, fbm(dq / R * 1.7 + 5.0));\n float lum = (0.95 - 0.45 * maria) * (1.0 - 0.25 * r * r);\n c.mat = M_MOON;\n c.aux = 0.0;\n // The pond dims toward black; roles fade toward the deep water, so on a\n // light palette the moon is an ink drawing that fades out to the water.\n float k = (0.35 + 0.5 * lum) * MOON_GAIN * a;\n c.col = uRoles > 0.5 ? mix(uWaterDim, uMoonCol, k) : uMoonCol * k;\n if (length(g) * RIPPLE_GAIN > 0.35) {\n c.style = 2.0; // a shard along the crest\n c.dens = a;\n c.dir = lineDir(vec2(-g.y, g.x));\n c.col = mix(c.col, uRoles > 0.5 ? uGlintCol : vec3(1.0), 0.3 * a);\n } else if (cv < 0.8) {\n c.style = 1.0; // rim\n c.dens = cv;\n c.dir = cellNormal(n);\n } else {\n c.style = 0.0; // face: o and : mostly, O only at the brightest centre\n c.dens = 0.72 * lum * a;\n }\n}\n\n// The sun's reflection by day: a patch of glitter, densest at the centre,\n// that flashes cell by cell. Ripple slopes shift where each cell looks, so a\n// ring spreads it. Gold at noon, amber with a low sun, never as bright as an\n// Ogon's glint. Drawn over the water and stars, under everything else; the\n// water's own glints keep their cells.\nvoid sun(vec2 p, vec2 g, inout Cell c) {\n float a = uSun.w;\n if (a < 0.02 || c.mat == M_GLINT) return;\n float R = uSun.z * min(uView.x, uView.y);\n vec2 dq = p + g * REFRACT * 3.0 - uSun.xy * uView;\n float r = length(dq) / R;\n if (r > 1.0) return;\n vec2 cell = floor(p / uCell);\n // Flashes 5 times a second; reduced motion slows that and holds most of\n // each cell's brightness still.\n float flash = floor(uTime * 5.0 * flicker());\n // Rising and setting, the patch thins out rather than shrinking.\n if (hash12(cell * 1.31 + flash * 0.29 + 3.0) > (1.0 - SUN_THIN) * a) return;\n float tw = hash12(cell + flash * 0.613);\n tw = mix(tw, hash12(cell + 0.37), uCalm * 0.7);\n float v = (1.0 - smoothstep(SUN_CORE, 1.0, r)) * (0.35 + 0.9 * tw);\n if (v < SUN_MIN) return;\n c.mat = M_SUN;\n c.dens = min(v, 1.0);\n c.aux = 0.0;\n c.style = 0.0;\n // The pond tints its gold by the light's hue, so it deepens with a low sun;\n // roles take the highlight and fade toward the deep water.\n float k = mix(SUN_GAIN.x, SUN_GAIN.y, smoothstep(SUN_MIN, 1.0, v));\n c.col = uRoles > 0.5 ? mix(uWaterDim, uSunCol, k) : uSunCol * mirrorHue() * k;\n}\n\n// ---------------------------------------------------------------- fish geometry\n\n// Closest point on the joint polyline [i0, i0 + count] with linearly\n// interpolated radius: a chain of tapered capsules.\nHit polyline(int f, int i0, int count, vec2 p) {\n Hit h;\n h.d = 1e9;\n h.u = 0.0;\n h.v = 0.0;\n h.n = vec2(1.0, 0.0);\n vec4 A = F(f, i0);\n for (int k = 0; k < count; k++) {\n vec4 B = F(f, i0 + k + 1);\n vec2 ab = B.xy - A.xy;\n vec2 ap = p - A.xy;\n float t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\n vec2 dq = ap - ab * t;\n float dist = length(dq);\n float r = mix(A.z, B.z, t);\n float d = dist - r;\n if (d < h.d) {\n h.d = d;\n h.u = (float(k) + t) / float(count);\n float side = ab.x * ap.y - ab.y * ap.x < 0.0 ? -1.0 : 1.0;\n h.v = side * dist / max(r, 1e-3);\n h.n = dist > 1e-4 ? dq / dist : normalize(vec2(-ab.y, ab.x) + 1e-6);\n }\n A = B;\n }\n return h;\n}\n\n// Pectoral fin: a blade that widens from the root and rounds off at the tip.\nfloat finDist(vec2 p, vec2 a, vec2 b, float r0, float r1, out float t) {\n vec2 ab = b - a;\n vec2 ap = p - a;\n t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\n float r = mix(r0, r1, smoothstep(0.0, 0.5, t)) * (1.0 - 0.45 * smoothstep(0.55, 1.0, t));\n return length(ap - ab * t) - r;\n}\n\n// Caudal fin: the tail polyline with a forked notch cut into its end and the\n// tip capped flat, so the rim is a closed outline.\nfloat tailSD(int f, vec2 q, vec4 fin) {\n Hit th = polyline(f, J_BODY - 1, J_TAIL, q);\n vec2 tip = F(f, J_TOTAL - 1).xy;\n vec2 tdir = normalize(tip - F(f, J_TOTAL - 2).xy + 1e-6);\n return 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\n// ---------------------------------------------------------------- markings\n\n// Kohaku: porcelain white with large hi (red) patches over the back, a red\n// head patch, clean white nose and tail root.\nvec3 kohaku(float u, float v, float seed) {\n float 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));\n return mix(uKohakuWhite, uHiRed, smoothstep(-0.02, 0.04, n));\n}\n\n// Showa: sumi (black) bands wrapping the body, big red patches, some white.\nvec3 showa(float u, float v, float seed, out float sumiAmt) {\n float s = fbm(vec2(u * 3.5 + v * 0.9, v * 1.4) + seed * 1.7) + 0.04;\n float 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));\n float sumi = smoothstep(0.0, 0.04, s);\n float hi = smoothstep(0.0, 0.04, r);\n sumiAmt = sumi * (1.0 - 0.5 * hi);\n return mix(mix(uShowaWhite, uHiRed, hi), uSumi, sumiAmt);\n}\n\n// Ogon: metallic gold; individual scales catch the light and a soft\n// highlight band rolls along the flank.\nvec3 ogon(float u, float v, float seed, out float glint) {\n vec2 sc = vec2(u * 26.0, v * 4.0 + 4.0);\n vec2 id = floor(vec2(sc.x + 0.5 * mod(floor(sc.y), 2.0), sc.y));\n float ph = hash12(id + seed);\n float twinkle = pow(0.5 + 0.5 * sin(uTime * 2.3 * flicker() + ph * TAU + u * 7.0), 14.0) * step(0.55, ph);\n float band = exp(-sq((v - 0.35 * sin(uTime * 0.6 + u * 4.0 + seed)) * 2.2));\n glint = max(twinkle, band * 0.6);\n vec3 base = mix(uOgonShade, uOgonGold, 1.0 - abs(v));\n return mix(base, uOgonGlint, glint);\n}\n\nvec3 pectoralColor(int variety, float t) {\n if (variety == V_KOHAKU) return uKohakuFin;\n if (variety == V_SHOWA) return mix(uSumi, uShowaWhite, smoothstep(0.15, 0.45, t)); // motoguro\n return uOgonFin;\n}\n\nvec3 tailColor(int variety, float u, float v, float seed) {\n if (variety == V_KOHAKU) return uKohakuTail;\n if (variety == V_SHOWA) {\n float streak = smoothstep(0.1, 0.2, gnoise(vec2(v * 3.0, u * 1.5) + seed));\n return mix(uShowaWhite, uSumi, streak * (1.0 - u * 0.6));\n }\n return mix(uOgonGold, uOgonTail, u);\n}\n\n// ---------------------------------------------------------------- composition\n\nfloat shadow(int f, vec2 p) {\n float lift = 1.25 - F(f, T_P0).w;\n vec2 q = p - uShadow * lift;\n float blur = max(uCell.x, uCell.y) * (0.5 + 1.5 * lift);\n if (!inBox(q, F(f, T_BBOX), blur)) return 0.0;\n float d = min(polyline(f, 0, J_BODY - 1, q).d,\n polyline(f, J_BODY - 1, J_TAIL, q).d + max(uCell.x, uCell.y) * 0.5);\n return (1.0 - smoothstep(-blur, blur, d)) * (0.95 - 0.35 * lift) * uShadowAlpha;\n}\n\nvoid trails(vec2 p, inout Cell c) {\n float best = 0.0;\n vec2 bestDir = vec2(1.0, 0.0);\n int bestVariety = 0;\n for (int f = 0; f < MAX_FISH; f++) {\n if (f >= uFishCount) break;\n vec4 P1 = F(f, T_P1);\n int n = int(P1.x + 0.5);\n float w0 = P1.y;\n if (n < 2 || !inBox(p, F(f, T_TBOX), w0 * 2.5 + uCell.y)) continue;\n int variety = int(F(f, T_P0).x + 0.5);\n vec4 A = F(f, T_TRAIL);\n for (int k = 1; k < TRAIL_N; k++) {\n if (k >= n) break;\n vec4 B = F(f, T_TRAIL + k);\n vec2 ab = B.xy - A.xy;\n vec2 ap = p - A.xy;\n float t = clamp(dot(ap, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);\n float dist = length(ap - ab * t);\n float age = mix(A.z, B.z, t);\n float w = w0 * (0.5 + 1.4 * age) + 0.3 * min(uCell.x, uCell.y);\n float I = pow(1.0 - age, 1.3) * (1.0 - smoothstep(w * 0.35, w, dist));\n if (I > best) {\n best = I;\n bestDir = ab;\n bestVariety = variety;\n }\n A = B;\n }\n }\n if (best < 0.05) return;\n\n vec2 cell = floor(p / uCell);\n vec3 col;\n if (bestVariety == V_KOHAKU) {\n // Silk: a smooth warm-white ribbon that frays as it fades.\n best *= 0.7 + 0.6 * gnoise(p * 0.035 - uTime * 0.5);\n col = uSilk;\n } else if (bestVariety == V_SHOWA) {\n // Bubbles: sparse dots that stay put while the trail fades.\n float hb = hash12(cell * 1.37 + 5.0);\n best *= step(0.62, hb) * (0.7 + 0.6 * hb);\n col = uBubble;\n } else {\n // Gold dust that twinkles.\n float tw = hash12(cell + floor(uTime * 7.0 * flicker()) * 0.731);\n best *= step(0.5, tw) * 1.2;\n col = uGoldDust;\n }\n if (best < 0.12) return;\n c.mat = M_TRAIL;\n c.dens = min(best, 1.0);\n // Silk keeps its warm hue and only dims as it fades; the others fade into the water.\n c.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;\n c.dir = lineDir(bestDir);\n c.aux = 0.0;\n c.style = float(bestVariety);\n}\n\n// Surface refraction at one point, as main() takes it per cell.\nvec2 refrAt(vec2 x) {\n float e = uRipplePx;\n return vec2(rippleAt(x + vec2(e, 0.0)) - rippleAt(x - vec2(e, 0.0)),\n rippleAt(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) {\n vec4 P0 = F(f, T_P0);\n int variety = int(P0.x + 0.5);\n float seed = P0.y;\n float depth = P0.w;\n // Deeper fish are displaced more by the moving surface.\n vec2 q = p + refr * (0.5 + depth);\n if (!inBox(q, F(f, T_BBOX), max(uCell.x, uCell.y) * 1.5)) return;\n float fog = 0.55 * depth;\n vec3 fogCol = uWaterDim * uWaterTint;\n float fade = 1.0 - 0.3 * depth;\n float style = float(variety);\n vec4 fin = F(f, T_FIN);\n // Fins are thin and lie nearly flat: lit as if facing straight up.\n vec3 flatLight = lightOn(vec3(0.0, 0.0, 1.0));\n\n // Fin normals come from a numeric gradient over half a cell, so outline\n // glyphs follow the fin's actual rim (fork and tip included).\n float reach = max(uCell.x, uCell.y);\n vec2 ex = vec2(uCell.x * 0.5, 0.0);\n vec2 ey = vec2(0.0, uCell.y * 0.5);\n\n // Pectoral fins sit beneath the body: outline on the rim, rays inside.\n for (int s = 0; s < 2; s++) {\n vec4 pf = F(f, T_PEC_L + s);\n float t, t2;\n float d = finDist(q, pf.xy, pf.zw, fin.x, fin.y, t);\n if (d > reach) continue;\n vec2 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),\n finDist(q + ey, pf.xy, pf.zw, fin.x, fin.y, t2) - finDist(q - ey, pf.xy, pf.zw, fin.x, fin.y, t2)) / uCell;\n vec2 n = normalize(gr + 1e-6);\n float cv = coverage(d, n);\n if (cv > 0.25) {\n c.col = mix(pectoralColor(variety, t) * flatLight, fogCol, fog);\n c.aux = 0.0;\n c.style = style;\n if (cv < 0.8) {\n c.mat = M_EDGE;\n c.dens = cv;\n c.dir = cellNormal(n);\n } else {\n c.mat = M_FIN;\n c.col *= 0.75;\n c.dens = 0.6 * fade;\n c.dir = lineDir(q - pf.xy);\n }\n }\n }\n\n // Caudal fin with a forked notch: outline on the rim, rays fanning out from\n // the peduncle inside.\n float td = tailSD(f, q, fin);\n if (td < reach) {\n vec2 gr = vec2(tailSD(f, q + ex, fin) - tailSD(f, q - ex, fin),\n tailSD(f, q + ey, fin) - tailSD(f, q - ey, fin)) / uCell;\n vec2 n = normalize(gr + 1e-6);\n float tcv = coverage(td, n);\n if (tcv > 0.25) {\n Hit th = polyline(f, J_BODY - 1, J_TAIL, q);\n c.col = mix(tailColor(variety, th.u, th.v, seed) * flatLight, fogCol, fog);\n c.aux = 0.0;\n c.style = style;\n if (tcv < 0.8) {\n c.mat = M_EDGE;\n c.dens = tcv;\n c.dir = cellNormal(n);\n } else {\n c.mat = M_FIN;\n c.col *= 0.75;\n c.dens = (0.65 - 0.2 * th.u) * fade;\n c.dir = lineDir(q - F(f, J_BODY - 1).xy);\n }\n }\n }\n\n // Body: dense fill inside, outline glyphs along the silhouette.\n Hit bh = polyline(f, 0, J_BODY - 1, q);\n float bcv = coverage(bh.d, bh.n);\n if (bcv > 0.18) {\n float glint = 0.0;\n float sumiAmt = 0.0;\n vec3 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 // The body as a cylinder seen from above: |v| = 1 at the silhouette.\n // 0.92 keeps the edge from turning fully sideways (a koi's back is broad).\n float a = min(abs(bh.v), 1.0) * 0.92;\n vec3 N = vec3(bh.n * a, sqrt(1.0 - a * a));\n float ndl = dot(N, diffuseDir());\n vec3 lit = lightOn(N);\n // Highlight along the back where the normal meets the half vector; it\n // slides across the back as the light moves. Sumi is lacquer-glossy and\n // ogon metallic (tinted gold).\n vec3 H = normalize(uLightDir + vec3(0.0, 0.0, 1.0));\n float spec = pow(max(dot(N, H), 0.0), SPEC_POW);\n vec3 hue = lightHue() * length(uLightCol) * 0.82 * uAddTint; // ~1 at noon, dim at night\n vec3 hi = variety == V_OGON ? hue * uOgonGold * 0.9 : hue * (variety == V_SHOWA ? 0.32 + 0.25 * sumiAmt : 0.3);\n // Rim on the flank away from the light, lit by the light wrapping round.\n vec2 az = normalize(uLightDir.xy + vec2(1e-5, 0.0));\n float rim = a * a * a * max(-dot(bh.n, az), 0.0) * (0.35 + 0.65 * (1.0 - uLightDir.z));\n // Caustics on the back, only for cells this fish covers. Shallower fish\n // catch sharper, brighter ones; the pattern slides as the fish swims.\n float cst = smoothstep(0.5, 0.95, caustic(q * 0.014 + refr * 0.012 + seed, uTime * 0.45));\n vec3 caust = uLightCol * uAddTint * cst * uCaustic * 0.4 * max(ndl, 0.0) * (1.0 - depth);\n c.col = mix(bc * lit + hi * spec + uLightCol * uAddTint * rim * 0.6 + caust, fogCol, fog);\n // Glyph weight follows the light too, so the lit flank is heavier.\n float shade = mix(0.58, 1.05, smoothstep(-0.2, 0.9, ndl)) + 0.25 * spec;\n c.style = style;\n if (bcv > 0.8) {\n // Glyph weight follows perceived lightness, so markings read as texture\n // and not only as colour: white scales are heavier than sumi.\n float Yl = dot(bc * bc, vec3(0.2126, 0.7152, 0.0722));\n float Ls = 1.16 * pow(Yl, 1.0 / 3.0) - 0.16;\n c.mat = M_BODY;\n c.dens = mix(0.6, 1.0, Ls) * shade * fade;\n c.aux = glint;\n } else {\n c.mat = M_EDGE;\n c.dens = bcv;\n c.dir = cellNormal(bh.n);\n c.aux = 0.0;\n c.col = mix(c.col, mix(uSumiRim * lit, fogCol, fog), 0.65 * sumiAmt);\n }\n }\n\n // Eyes: always claim the cell nearest their centre so they never vanish.\n vec4 E = F(f, T_EYE);\n for (int s = 0; s < 2; s++) {\n vec2 e = s == 0 ? E.xy : E.zw;\n // Refraction taken at the eye, not per cell: adjacent cells refract by different\n // amounts, so per-cell offsets can leave the eye with no cell (or two).\n vec2 de = abs(p + refrAt(e) * (0.5 + depth) - e);\n if (length(q - e) < P0.z || (de.x < uCell.x * 0.5 && de.y < uCell.y * 0.5)) {\n c.mat = M_EYE;\n c.col = mix(uEyeCol * max(flatLight, vec3(0.75)), fogCol, fog * 0.5);\n c.dens = 1.0;\n c.aux = 0.0;\n c.style = style;\n }\n }\n}\n\nint pickGlyph(Cell c, ivec2 cell) {\n float dens = clamp(c.dens, 0.0, 1.0);\n float aux = clamp(c.aux, 0.0, 1.0);\n int style = int(c.style + 0.5);\n if (c.mat == M_WATER) {\n if (dens < 0.12) return G_SPACE;\n if (dens < 0.6) return aux < 0.4 ? G_DOT : aux < 0.65 ? G_COMMA : aux < 0.85 ? G_BACKTICK : G_QUOTE;\n return G_COLON;\n }\n if (c.mat == M_RIPPLE) {\n if (dens < 0.45) return G_DOT;\n return lineGlyph(c.dir, aux > 0.5 ? G_TILDE : G_MINUS, G_PIPE);\n }\n if (c.mat == M_TRAIL) {\n if (style == V_SHOWA) return dens > 0.55 ? G_O : G_DOT;\n if (style == V_OGON) return dens > 0.65 ? G_STAR : dens > 0.35 ? G_QUOTE : G_DOT;\n if (dens < 0.3) return G_DOT;\n if (dens < 0.55) return G_MINUS;\n return lineGlyph(c.dir, G_TILDE, G_COLON);\n }\n if (c.mat == M_FIN) {\n if (dens < 0.3) return G_DOT;\n if (dens < 0.45) return G_QUOTE;\n return lineGlyph(c.dir, style == V_OGON ? G_TILDE : G_MINUS, G_PIPE);\n }\n if (c.mat == M_EDGE) return edgeGlyph(c.dir, dens);\n if (c.mat == M_BODY) {\n if (aux > 0.75) return G_STAR;\n float x = smoothstep(0.3, 0.95, dens) * float(BODY_N - 1) + (bayer4(cell) - 0.5) * 0.5;\n return BODY_RAMP[clamp(int(floor(x + 0.5)), 0, BODY_N - 1)];\n }\n if (c.mat == M_EYE) return G_O;\n if (c.mat == M_STAR) {\n // Mostly small points; '*' only for the brightest, mostly at a scintillation.\n if (dens > 0.95) return G_STAR;\n if (dens > 0.65) return G_PLUS;\n if (dens > 0.4) return aux < 0.5 ? G_QUOTE : G_BACKTICK;\n return G_DOT;\n }\n if (c.mat == M_MOON) {\n if (style == 2) return lineGlyph(c.dir, G_EQUALS, G_PIPE);\n if (style == 1) return edgeGlyph(c.dir, dens);\n return dens < 0.33 ? G_DOT : dens < 0.56 ? G_COLON : dens < 0.67 ? G_O : G_BIG_O;\n }\n if (c.mat == M_SUN) return dens > 0.8 ? G_STAR : dens > 0.6 ? G_PLUS : G_DOT;\n if (c.mat == M_GLINT) {\n // High light: star bursts. Low light: dashes along its direction.\n if (aux > 0.55) return dens > 0.6 ? lineGlyph(c.dir, dens > 0.85 ? G_EQUALS : G_MINUS, G_PIPE) : G_DOT;\n return dens > 0.75 ? G_STAR : G_PLUS;\n }\n return G_SPACE;\n}\n\nvoid main() {\n vec2 p = (floor(gl_FragCoord.xy) + 0.5) * uCell + uOriginCss;\n\n float e = uRipplePx;\n float h = rippleAt(p);\n vec2 g = vec2(rippleAt(p + vec2(e, 0.0)) - rippleAt(p - vec2(e, 0.0)),\n rippleAt(p + vec2(0.0, e)) - rippleAt(p - vec2(0.0, e))) / (2.0 * e);\n\n Cell c = water(p, h, g);\n stars(p, g, c);\n moon(p, g, c);\n sun(p, g, c);\n\n float sh = 0.0;\n for (int f = 0; f < MAX_FISH; f++) {\n if (f >= uFishCount) break;\n sh = max(sh, shadow(f, p));\n }\n c.dens *= clamp(1.0 - 1.4 * sh, 0.0, 1.0);\n c.col *= 1.0 - 0.5 * sh;\n\n trails(p, c);\n\n vec2 refr = g * REFRACT;\n for (int k = 0; k < MAX_FISH; k++) {\n if (k >= uFishCount) break;\n drawFish(uOrder[k], p, refr, c);\n }\n\n int glyph = pickGlyph(c, ivec2(gl_FragCoord.xy));\n oColor = vec4(c.col, clamp(c.dens, 0.0, 1.0));\n oInfo = vec4(float(glyph) / 255.0, float(c.mat) / 255.0, 0.0, 0.0);\n}\n";
1983
- var ascii_default = "// ASCII pass: each output pixel finds its character cell and copies the\n// cell's glyph (picked by the scene pass) texel-for-texel from the atlas.\n\nuniform sampler2D uScene0;\nuniform sampler2D uScene1;\nuniform sampler2D uAtlas;\nuniform ivec2 uCellPx;\nuniform ivec2 uOrigin; // grid top-left, device px from the canvas top-left\nuniform ivec2 uGrid; // cols, rows\nuniform int uCanvasH;\nuniform vec3 uBg; // what glyphs are composited over: pond graded by the time of day, or the roles background\nuniform vec3 uRawBg; // the raw view keeps the plain background\nuniform int uRaw;\n// Palette (look.ts). Outside #ifdef GLOW, so the software path has it too.\nuniform int uLook; // 0 as composited, 1 ramp, 2 filter\nuniform vec3 uOutBg; // the background as drawn; intensity fades toward it\nuniform vec3 uStops[5]; // ramp, background first\nuniform int uStopCount; // 2..5, evenly spaced\nuniform vec3 uTint; // filter: multiplies the pond's colours\nuniform float uSaturation; // filter: 0 grey … 1 original\nuniform int uInvert; // filter: dark glyphs, hues kept\nuniform float uIntensity; // 0 only the background … 1 the full pond\n#ifdef GLOW\nuniform sampler2D uGlow; // cell-resolution halo, linear filtered (see glow.frag)\nuniform vec2 uGlowScale; // grid px → glow texture coordinates\n#endif\n\nout vec4 fragColor;\n\n// Rec.601 luma on sRGB values: what the ramp and filter were tuned with (from\n// the prototype). look.ts greys lighting with Rec.709 and palette.ts tests\n// \"light\" with WCAG luminance (Q13); each suits its use, so they differ.\nfloat lum(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); }\n\n// Ramp: brightness above the pond's background picks a colour along the stops.\nvec3 ramp(vec3 pix) {\n float t = clamp((lum(pix) - lum(uBg)) / (0.9 - lum(uBg)), 0.0, 1.0);\n float x = t * float(uStopCount - 1);\n int i = min(int(x), uStopCount - 2);\n return mix(uStops[i], uStops[i + 1], x - float(i));\n}\n\n// Filter: the pond's signal above its own background, laid over the new one\n// with its hues (scaled by saturation) kept.\nvec3 filtered(vec3 pix) {\n vec3 d = pix - uBg;\n float y = lum(d);\n vec3 chroma = (d - vec3(y)) * uSaturation;\n // Inverted: the signal darkens the background. The tint steers the ink's\n // hue (channels the tint lacks are taken away harder; white = even) and its\n // brightness scales the ink, so a grey tint softens it as it dims glyphs\n // when not inverted.\n float tl = lum(uTint);\n if (uInvert == 1) return uOutBg - y * 1.25 * tl * (vec3(1.0 + tl) - uTint) + chroma * 0.9;\n return uOutBg + (vec3(y) + chroma) * uTint;\n}\n\nvoid main() {\n ivec2 q = ivec2(int(gl_FragCoord.x), uCanvasH - 1 - int(gl_FragCoord.y)) - uOrigin;\n if (any(lessThan(q, ivec2(0))) || any(greaterThanEqual(q, uGrid * uCellPx))) {\n fragColor = vec4(uRaw == 1 ? uRawBg : uOutBg, 1.0);\n return;\n }\n ivec2 cell = q / uCellPx;\n ivec2 local = q - cell * uCellPx;\n vec4 s0 = texelFetch(uScene0, cell, 0);\n if (uRaw == 1) {\n fragColor = vec4(mix(uRawBg, s0.rgb, s0.a), 1.0);\n return;\n }\n int glyph = int(texelFetch(uScene1, cell, 0).r * 255.0 + 0.5);\n float ink = texelFetch(uAtlas, ivec2(glyph * uCellPx.x + local.x, local.y), 0).r;\n vec3 bg = uBg;\n#ifdef GLOW\n // The halo lights only the background between strokes; ink keeps the\n // glyph's own colour, so shapes stay crisp under the glow.\n bg += texture(uGlow, (vec2(q) + 0.5) * uGlowScale).rgb;\n#endif\n vec3 c = mix(bg, s0.rgb, ink);\n if (uLook == 1) c = ramp(c);\n else if (uLook == 2) c = clamp(filtered(c), 0.0, 1.0);\n if (uIntensity < 1.0) c = mix(uOutBg, c, uIntensity);\n fragColor = vec4(c, 1.0);\n}\n";
1984
- var glow_default = "// Glow pass: one fragment per character cell. Gathers the light the\n// neighbouring cells give off (bright glyph colours, weighted by class) with a\n// small Gaussian, so the ASCII pass can read a soft halo with one linear\n// texture fetch. Cells are about twice as tall as wide, so the kernel spans\n// twice as many columns as rows to stay round on screen.\n\nuniform sampler2D uScene0;\nuniform sampler2D uScene1;\nuniform ivec2 uGrid; // cols, rows\nuniform float uAspect; // cell width / cell height\nuniform float uGain; // time-of-day glow strength × overall gain\n\nout vec4 oGlow;\n\n#define RX 4\n#define RY 2\n\n// Light a cell gives off: only its brightest colours, scaled by how much ink\n// the glyph puts down, with glints and eyes counting most. Plain water gives\n// none, so the halo marks highlights rather than lifting the whole pond.\nvec3 emission(ivec2 cell) {\n vec4 s0 = texelFetch(uScene0, cell, 0);\n int mat = int(texelFetch(uScene1, cell, 0).g * 255.0 + 0.5);\n float 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;\n float lum = dot(s0.rgb, vec3(0.299, 0.587, 0.114));\n return s0.rgb * (w * s0.a * smoothstep(0.3, 0.85, lum));\n}\n\nvoid main() {\n ivec2 cell = ivec2(gl_FragCoord.xy);\n vec3 sum = vec3(0.0);\n float wsum = 0.0;\n for (int y = -RY; y <= RY; y++) {\n for (int x = -RX; x <= RX; x++) {\n // Distance in cell heights; sigma ≈ one row.\n vec2 d = vec2(float(x) * uAspect, float(y));\n float k = exp(-dot(d, d) * 0.6);\n wsum += k;\n ivec2 n = cell + ivec2(x, y);\n if (any(lessThan(n, ivec2(0))) || any(greaterThanEqual(n, uGrid))) continue;\n sum += emission(n) * k;\n }\n }\n // Soft knee: dense glints (a dusk crest) saturate toward ~0.4 instead of\n // lighting the gaps between strokes as bright as the strokes themselves.\n vec3 halo = sum * (uGain / wsum);\n oGlow = vec4(halo / (1.0 + 2.5 * max(halo.r, max(halo.g, halo.b))), 1.0);\n}\n";
1985
- var fly_default$1 = "// Fireflies: one point per firefly, drawn into the scene target after the\n// scene pass (one fragment per character cell), so each covers exactly the\n// cell its centre falls in. State comes packed from the CPU (fireflies.ts).\n\nlayout(location = 0) in vec4 aFly; // x, y (CSS px, y down), flash 0…1, seed\n\nuniform vec2 uCell; // cell size, CSS px\nuniform vec2 uOriginCss; // world position of the grid's top-left corner\nuniform ivec2 uGrid; // cols, rows\nuniform float uFlies; // the dusk window (lighting.ts fireflyWeight), 0 … 1\n\nout float vBright;\n\n// Dimmer than this, a firefly draws nothing: it is between blinks.\nconst float FLY_MIN = 0.22;\n\nvoid main() {\n float v = aFly.z * uFlies;\n vBright = v;\n gl_PointSize = 1.0;\n // Cell rows run down from the top of the pond, as in scene.frag.\n vec2 cell = floor((aFly.xy - uOriginCss) / uCell);\n vec2 ndc = (cell + 0.5) / vec2(uGrid) * 2.0 - 1.0;\n // Too faint: outside the clip volume, so the point is dropped.\n gl_Position = v < FLY_MIN ? vec4(2.0, 2.0, 0.0, 1.0) : vec4(ndc, 0.0, 1.0);\n}\n";
1986
- var fly_default = "// A firefly's cell: its glyph by brightness (. + *) and its colour, written\n// over whatever the scene pass put there (fish included). Blending is off.\n// Its halo comes from the glow pass (M_FLY), which light palettes skip, so\n// there it is the glyph alone, ink like the rest of the pond.\n\nuniform vec3 uFlyCol; // look.ts: the pond's yellow-green, or the palette's metallic toward the highlight\nuniform vec3 uWaterDim; // roles fade toward the deep water, not toward black\nuniform float uRoles;\n\nin float vBright;\n\nlayout(location = 0) out vec4 oColor; // as scene.frag: rgb colour, a density\nlayout(location = 1) out vec4 oInfo; // r glyph index, g cell class\n\nvoid main() {\n float v = min(vBright, 1.0);\n int glyph = v > 0.7 ? G_STAR : v > 0.45 ? G_PLUS : G_DOT;\n float k = min(0.45 + 0.6 * v, 1.0);\n vec3 col = uRoles > 0.5 ? mix(uWaterDim, uFlyCol, k) : uFlyCol * k;\n oColor = vec4(col, v);\n oInfo = vec4(float(glyph) / 255.0, float(M_FLY) / 255.0, 0.0, 0.0);\n}\n";
1987
- function compile(gl, type, source, label) {
2076
+ 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";
2078
+ 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";
2079
+ /** Each chunk's file name and source, in compile order. */
2080
+ 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"]
2093
+ ];
2094
+ var sceneSrc = sceneChunks.map(([, src]) => src).join("\n");
2095
+ /** The line each chunk starts on in sceneSrc, 1-based, so compile errors can name `sky.glsl:42`. */
2096
+ var line = 1;
2097
+ var sceneLines = sceneChunks.map(([name, src]) => {
2098
+ const start = {
2099
+ name,
2100
+ line
2101
+ };
2102
+ line += src.split("\n").length;
2103
+ return start;
2104
+ });
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";
2106
+ 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
+ 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
+ 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
+ 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
+ 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";
2112
+ /** `file:line` for line n of a compiled shader, or just n before the first file. */
2113
+ function sourceLine(files, n) {
2114
+ let file;
2115
+ for (const f of files) if (f.line <= n) file = f;
2116
+ return file ? `${file.name}:${n - file.line + 1}` : String(n);
2117
+ }
2118
+ function compile(gl, type, source, label, files = []) {
1988
2119
  const shader = gl.createShader(type);
1989
2120
  if (!shader) throw new Error(`${label}: could not create shader`);
1990
2121
  gl.shaderSource(shader, source);
1991
2122
  gl.compileShader(shader);
1992
2123
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) {
1993
- const log = gl.getShaderInfoLog(shader) ?? "";
1994
- const numbered = source.split("\n").map((l, i) => `${String(i + 1).padStart(4)} ${l}`).join("\n");
2124
+ const log = (gl.getShaderInfoLog(shader) ?? "").replace(/\b0:(\d+)\b/g, (_, n) => sourceLine(files, Number(n)));
2125
+ const numbered = source.split("\n").map((l, i) => `${sourceLine(files, i + 1).padStart(files.length ? 18 : 4)} ${l}`).join("\n");
1995
2126
  gl.deleteShader(shader);
1996
2127
  throw new Error(`${label}: shader compile failed\n${log}\n${numbered}`);
1997
2128
  }
@@ -2002,10 +2133,11 @@ var Program = class {
2002
2133
  handle;
2003
2134
  gl;
2004
2135
  locs = /* @__PURE__ */ new Map();
2005
- constructor(gl, vertex, fragment, label) {
2136
+ /** `fragmentFiles`: where each file starts in `fragment`, for compile errors. */
2137
+ constructor(gl, vertex, fragment, label, fragmentFiles = []) {
2006
2138
  this.gl = gl;
2007
2139
  const vs = compile(gl, gl.VERTEX_SHADER, vertex, `${label}.vert`);
2008
- const fs = compile(gl, gl.FRAGMENT_SHADER, fragment, `${label}.frag`);
2140
+ const fs = compile(gl, gl.FRAGMENT_SHADER, fragment, `${label}.frag`, fragmentFiles);
2009
2141
  const program = gl.createProgram();
2010
2142
  if (!program) throw new Error(`${label}: could not create program`);
2011
2143
  gl.attachShader(program, vs);
@@ -2114,7 +2246,7 @@ function deleteTarget(gl, t) {
2114
2246
  for (const tex of t.textures) gl.deleteTexture(tex);
2115
2247
  }
2116
2248
  var OGON_GOLD = POND_METALLIC;
2117
- /** Today's pond, exactly as the constants scene.frag had before palettes (A3). */
2249
+ /** Today's pond, exactly as the constants the scene shader had before palettes (A3). */
2118
2250
  var pondScene = {
2119
2251
  uWaterDim: [
2120
2252
  .1,
@@ -2339,6 +2471,19 @@ function asciiLook(p, light) {
2339
2471
  outBg: !p.background ? pondBg : p.light ? p.background : graded(p.background, light)
2340
2472
  };
2341
2473
  }
2474
+ /**
2475
+ * The ramp's stops (5 vec3s, zero past the last and for other modes) and each
2476
+ * stop's step to the next (4 vec3s) for ascii.frag's ramp(). It adds f × step
2477
+ * to a stop in one fused multiply-add, which is how radeonsi runs mix(stop,
2478
+ * next, f). The step is the difference of the float32 stops, taken in double
2479
+ * and stored as float32: the GPU's own float32 subtraction, bit for bit, so
2480
+ * the colours are the same.
2481
+ */
2482
+ function rampUniforms(p, stops, deltas) {
2483
+ stops.fill(0);
2484
+ if (p.mode === "ramp") p.stops.forEach((c, i) => stops.set(c, i * 3));
2485
+ for (let i = 0; i < deltas.length; i++) deltas[i] = stops[i + 3] - stops[i];
2486
+ }
2342
2487
  /** The feature names, in the order the docs and the demo list them. */
2343
2488
  var SKY_FEATURES = [
2344
2489
  "stars",
@@ -2445,15 +2590,20 @@ var FONT_MAX = 22;
2445
2590
  var FONT_MIN_DEVICE = 11;
2446
2591
  /** Halo brightness at glowStrength 1; kept low so glyphs never wash into blobs. */
2447
2592
  var GLOW_GAIN = 2.2;
2593
+ /** Halo blur reach in cells either side: columns, rows (RX, RY in glow.frag). */
2594
+ var GLOW_RX = 4;
2595
+ var GLOW_RY = 2;
2448
2596
  /** Firefly window weight below which no point is drawn (fly.vert's FLY_MIN then hides each one anyway). */
2449
2597
  var FLIES_MIN = .02;
2450
2598
  /**
2451
2599
  * Four passes per frame:
2452
2600
  * 1. ripple — wave equation on a coarse grid (ping-pong)
2453
- * 2. scene — one fragment per character cell → colour and glyph; then the
2601
+ * 2. scene — one fragment per character cell → colour and glyph, in two
2602
+ * draws (the water and all on it, then the fish); then the
2454
2603
  * fireflies as one point each into the same cells, over the fish
2455
- * 3. glow — one fragment per cell → blurred halo of the bright cells
2456
- * (skipped on software renderers: no target, no program)
2604
+ * 3. glow — one fragment per cell → blurred halo of the bright cells,
2605
+ * from each cell's emission, stored first where float targets
2606
+ * exist (skipped on software renderers: no target, no program)
2457
2607
  * 4. ascii — per screen pixel, copy the cell's glyph from the atlas
2458
2608
  */
2459
2609
  var Renderer = class {
@@ -2481,6 +2631,9 @@ var Renderer = class {
2481
2631
  sceneProg;
2482
2632
  asciiProg;
2483
2633
  glowProg;
2634
+ /** Each cell's emission for the glow pass; null without float32 targets (glow works it out per tap). */
2635
+ emissionProg;
2636
+ emissionFormat;
2484
2637
  flyProg;
2485
2638
  /** Vertex array and buffer for the fireflies: one vec4 per point (fireflies.ts). */
2486
2639
  flyVao;
@@ -2488,14 +2641,30 @@ var Renderer = class {
2488
2641
  fishTex;
2489
2642
  dropBuf = /* @__PURE__ */ new Float32Array(128);
2490
2643
  stopBuf = /* @__PURE__ */ new Float32Array(15);
2644
+ stopDeltaBuf = /* @__PURE__ */ new Float32Array(12);
2491
2645
  /** Scene colours for the active palette (roles replace the pond's). */
2492
2646
  sceneColors = POND_SCENE;
2493
2647
  uploadedColors = null;
2494
2648
  flyColors = null;
2649
+ /**
2650
+ * What render() derives besides `light`, and the phase, palette and sky it
2651
+ * last derived them from: pure functions of those three, so a frame that
2652
+ * repeats all three (a fixed time) reuses them.
2653
+ */
2654
+ sceneLight = this.light;
2655
+ stars = starsAt(0, DEFAULT_SKY, false);
2656
+ flies = 0;
2657
+ look = asciiLook(POND, this.light);
2658
+ litPhase = NaN;
2659
+ litPalette = null;
2660
+ litSky = null;
2495
2661
  atlas = null;
2496
2662
  ripple = null;
2497
2663
  scene = null;
2498
2664
  glowTarget = null;
2665
+ /** glowTarget holds a drawn halo (false once it is cleared, or new and zero-filled). */
2666
+ haloInTarget = false;
2667
+ emissionTarget = null;
2499
2668
  rippleClock = 0;
2500
2669
  devW = 1;
2501
2670
  devH = 1;
@@ -2529,13 +2698,34 @@ var Renderer = class {
2529
2698
  format: gl.RGBA,
2530
2699
  type: gl.UNSIGNED_BYTE
2531
2700
  } : half;
2532
- const defines = layoutDefines() + glyphDefines() + varietyDefines() + (packed ? "#define RIPPLE_PACKED\n" : "");
2701
+ this.emissionFormat = {
2702
+ internal: gl.RGBA32F,
2703
+ format: gl.RGBA,
2704
+ type: gl.FLOAT
2705
+ };
2706
+ let emission = false;
2707
+ if (!software && gl.getExtension("EXT_color_buffer_float")) {
2708
+ const probe = createTarget(gl, 4, 4, [this.emissionFormat]);
2709
+ if (probe) {
2710
+ deleteTarget(gl, probe);
2711
+ emission = true;
2712
+ }
2713
+ }
2714
+ const defines = layoutDefines() + glyphDefines() + varietyDefines() + `#define RX ${GLOW_RX}\n#define RY ${GLOW_RY}\n` + (packed ? "#define RIPPLE_PACKED\n" : "");
2533
2715
  const fragment = (src) => `#version 300 es\n${defines}${common_default}\n${src}`;
2534
2716
  const vertex = `#version 300 es\n${fullscreen_default}`;
2535
- this.rippleProg = new Program(gl, vertex, fragment(ripple_default), "ripple");
2536
- this.sceneProg = new Program(gl, vertex, fragment(scene_default), "scene");
2537
- this.glowProg = software ? null : new Program(gl, vertex, fragment(glow_default), "glow");
2538
- this.asciiProg = new Program(gl, vertex, fragment((software ? "" : "#define GLOW\n") + ascii_default), "ascii");
2717
+ const pixelVertex = `#version 300 es\n${pixel_default}`;
2718
+ this.rippleProg = new Program(gl, pixelVertex, fragment(ripple_default), "ripple");
2719
+ const sceneAt = fragment("").split("\n").length;
2720
+ const sceneFiles = sceneLines.map(({ name, line }) => ({
2721
+ name,
2722
+ line: line + sceneAt - 1
2723
+ }));
2724
+ this.sceneProg = new Program(gl, vertex, fragment(sceneSrc), "scene", sceneFiles);
2725
+ this.emissionProg = emission ? new Program(gl, vertex, fragment(`${emission_default$1}\n${emission_default}`), "emission") : null;
2726
+ const glow = emission ? `#define EMIT_TEX\n${glow_default}` : `${emission_default$1}\n${glow_default}`;
2727
+ this.glowProg = software ? null : new Program(gl, vertex, fragment(glow), "glow");
2728
+ this.asciiProg = new Program(gl, pixelVertex, fragment((software ? "" : "#define GLOW\n") + ascii_default), "ascii");
2539
2729
  this.flyProg = new Program(gl, `#version 300 es\n${common_default}\n${fly_default$1}`, fragment(fly_default), "fly");
2540
2730
  const vao = gl.createVertexArray();
2541
2731
  const flyVao = gl.createVertexArray();
@@ -2551,7 +2741,7 @@ var Renderer = class {
2551
2741
  gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0);
2552
2742
  gl.bindVertexArray(null);
2553
2743
  gl.bindBuffer(gl.ARRAY_BUFFER, null);
2554
- this.fishTex = createTexture(gl, 70, 4, {
2744
+ this.fishTex = createTexture(gl, 75, 4, {
2555
2745
  internal: gl.RGBA32F,
2556
2746
  format: gl.RGBA,
2557
2747
  type: gl.FLOAT
@@ -2572,8 +2762,7 @@ var Renderer = class {
2572
2762
  this.palette = palette;
2573
2763
  this.intensity = intensity;
2574
2764
  this.sceneColors = palette.mode === "roles" ? rolesScene(palette.roles) : POND_SCENE;
2575
- this.stopBuf.fill(0);
2576
- if (palette.mode === "ramp") palette.stops.forEach((c, i) => this.stopBuf.set(c, i * 3));
2765
+ rampUniforms(palette, this.stopBuf, this.stopDeltaBuf);
2577
2766
  }
2578
2767
  /** Sets the night-sky features; the next render() draws with them. */
2579
2768
  setSky(sky) {
@@ -2607,10 +2796,17 @@ var Renderer = class {
2607
2796
  this.scene = createTarget(gl, cols, rows, [rgba8, rgba8]);
2608
2797
  if (!this.scene) throw new Error("Scene render target is not supported");
2609
2798
  if (this.glowTarget) deleteTarget(gl, this.glowTarget);
2799
+ if (this.emissionTarget) deleteTarget(gl, this.emissionTarget);
2610
2800
  this.glowTarget = null;
2801
+ this.emissionTarget = null;
2611
2802
  if (this.glowProg) {
2612
2803
  this.glowTarget = createTarget(gl, cols, rows, [rgba8], gl.LINEAR);
2613
2804
  if (!this.glowTarget) throw new Error("Glow render target is not supported");
2805
+ this.haloInTarget = false;
2806
+ }
2807
+ if (this.emissionProg) {
2808
+ this.emissionTarget = createTarget(gl, cols + 8, rows + 4, [this.emissionFormat]);
2809
+ if (!this.emissionTarget) throw new Error("Emission render target is not supported");
2614
2810
  }
2615
2811
  this.cols = cols;
2616
2812
  this.rows = rows;
@@ -2638,12 +2834,22 @@ var Renderer = class {
2638
2834
  render(pond, dt, phase) {
2639
2835
  this.phase = phase;
2640
2836
  const palette = this.raw ? POND : this.palette;
2641
- const light = this.light = lightingAt(phase, { darkNight: this.sky.darkNight && !palette.light });
2837
+ if (phase !== this.litPhase || palette !== this.litPalette || this.sky !== this.litSky) {
2838
+ this.light = lightingAt(phase, { darkNight: this.sky.darkNight && !palette.light });
2839
+ this.sceneLight = palette.mode === "roles" ? brightnessOnly(this.light) : this.light;
2840
+ this.stars = starsAt(phase, this.sky, palette.light);
2841
+ this.flies = firefliesAt(phase, this.sky);
2842
+ this.look = asciiLook(palette, this.light);
2843
+ this.litPhase = phase;
2844
+ this.litPalette = palette;
2845
+ this.litSky = this.sky;
2846
+ }
2847
+ const light = this.light;
2642
2848
  const roles = palette.mode === "roles";
2643
2849
  const colors = this.raw ? POND_SCENE : this.sceneColors;
2644
- const sceneLight = roles ? brightnessOnly(light) : light;
2645
- const stars = starsAt(phase, this.sky, palette.light);
2646
- const flies = firefliesAt(phase, this.sky);
2850
+ const sceneLight = this.sceneLight;
2851
+ const stars = this.stars;
2852
+ const flies = this.flies;
2647
2853
  const gl = this.gl;
2648
2854
  const atlas = this.atlas;
2649
2855
  const scene = this.scene;
@@ -2656,22 +2862,23 @@ var Renderer = class {
2656
2862
  this.rippleClock -= steps / RIPPLE_HZ;
2657
2863
  steps = Math.min(steps, 3);
2658
2864
  const rp = this.rippleProg.use();
2659
- rp.ivec2("uSize", this.simW, this.simH).float("uDamping", RIPPLE_DAMPING);
2660
- let ripple = this.ripple;
2865
+ rp.vec2("uDev", this.simW, this.simH).ivec2("uSize", this.simW, this.simH).float("uDamping", RIPPLE_DAMPING);
2866
+ const ripple = this.ripple;
2661
2867
  for (let s = 0; s < steps; s++) {
2662
- const [src, dst] = ripple;
2868
+ const src = ripple[0];
2869
+ const dst = ripple[1];
2663
2870
  gl.bindFramebuffer(gl.FRAMEBUFFER, dst.fbo);
2664
2871
  gl.viewport(0, 0, this.simW, this.simH);
2665
2872
  rp.texture("uState", 0, src.textures[0]);
2666
2873
  rp.int("uDropCount", s === 0 ? this.takeDrops(pond) : 0).vec4s("uDrops", this.dropBuf);
2667
2874
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2668
- ripple = [dst, src];
2875
+ ripple[0] = dst;
2876
+ ripple[1] = src;
2669
2877
  }
2670
- this.ripple = ripple;
2671
2878
  const count = pond.pack();
2672
2879
  gl.activeTexture(gl.TEXTURE0);
2673
2880
  gl.bindTexture(gl.TEXTURE_2D, this.fishTex);
2674
- gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 70, 4, gl.RGBA, gl.FLOAT, pond.data);
2881
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 75, 4, gl.RGBA, gl.FLOAT, pond.data);
2675
2882
  gl.bindFramebuffer(gl.FRAMEBUFFER, scene.fbo);
2676
2883
  gl.viewport(0, 0, this.cols, this.rows);
2677
2884
  const unit = pond.unit;
@@ -2680,8 +2887,13 @@ var Renderer = class {
2680
2887
  for (const [name, c] of Object.entries(colors)) sp.vec3(name, c[0], c[1], c[2]);
2681
2888
  this.uploadedColors = colors;
2682
2889
  }
2683
- 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).vec3("uLightCol", ...sceneLight.color).vec3("uAmbient", ...sceneLight.ambient).vec3("uWaterTint", ...sceneLight.water).vec3("uSky", ...sceneLight.sky).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);
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);
2891
+ sp.int("uLayer", 0).int("uFishDraw", 0);
2684
2892
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2893
+ if (count > 0) {
2894
+ sp.int("uLayer", 1).int("uFishDraw", count);
2895
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
2896
+ }
2685
2897
  if (flies >= FLIES_MIN) {
2686
2898
  gl.bindBuffer(gl.ARRAY_BUFFER, this.flyBuf);
2687
2899
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, pond.packFireflies());
@@ -2697,33 +2909,54 @@ var Renderer = class {
2697
2909
  gl.bindVertexArray(this.vao);
2698
2910
  }
2699
2911
  const glowProg = this.raw ? null : this.glowProg;
2912
+ const haloDrawn = !!(glowProg && this.glowTarget) && this.glow && !palette.light;
2700
2913
  if (glowProg && this.glowTarget && (!this.glow || palette.light)) {
2701
- gl.bindFramebuffer(gl.FRAMEBUFFER, this.glowTarget.fbo);
2702
- gl.clearColor(0, 0, 0, 0);
2703
- gl.clear(gl.COLOR_BUFFER_BIT);
2914
+ if (this.haloInTarget) {
2915
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.glowTarget.fbo);
2916
+ gl.clearColor(0, 0, 0, 0);
2917
+ gl.clear(gl.COLOR_BUFFER_BIT);
2918
+ this.haloInTarget = false;
2919
+ }
2704
2920
  } else if (glowProg && this.glowTarget) {
2921
+ const emission = this.emissionTarget;
2922
+ if (this.emissionProg && emission) {
2923
+ gl.bindFramebuffer(gl.FRAMEBUFFER, emission.fbo);
2924
+ gl.viewport(GLOW_RX, GLOW_RY, this.cols, this.rows);
2925
+ this.emissionProg.use().texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]);
2926
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
2927
+ gl.viewport(0, 0, this.cols, this.rows);
2928
+ }
2705
2929
  gl.bindFramebuffer(gl.FRAMEBUFFER, this.glowTarget.fbo);
2706
- glowProg.use().texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]).ivec2("uGrid", this.cols, this.rows).float("uAspect", atlas.cellW / atlas.cellH).float("uGain", light.glowStrength * GLOW_GAIN);
2930
+ glowProg.use();
2931
+ if (emission) glowProg.texture("uEmit", 0, emission.textures[0]);
2932
+ else glowProg.texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]);
2933
+ glowProg.ivec2("uGrid", this.cols, this.rows).float("uAspect", atlas.cellW / atlas.cellH).float("uGain", light.glowStrength * GLOW_GAIN);
2707
2934
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2935
+ this.haloInTarget = true;
2708
2936
  }
2709
2937
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
2710
2938
  gl.viewport(0, 0, this.devW, this.devH);
2711
- const look = asciiLook(palette, light);
2939
+ const look = this.look;
2712
2940
  const filter = palette.mode === "filter" ? palette : null;
2713
- const ascii = this.asciiProg.use().texture("uScene0", 0, scene.textures[0]).texture("uScene1", 1, scene.textures[1]).texture("uAtlas", 2, atlas.texture).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).vec3("uRawBg", ...POND_BG).int("uRaw", this.raw ? 1 : 0).int("uLook", look.look).vec3("uOutBg", ...look.outBg).vec3s("uStops", this.stopBuf).int("uStopCount", palette.mode === "ramp" ? palette.stops.length : 2).vec3("uTint", ...filter?.tint ?? WHITE).float("uSaturation", filter?.saturation ?? 1).int("uInvert", filter?.invert ? 1 : 0).float("uIntensity", this.raw ? 1 : this.intensity);
2714
- if (this.glowTarget) ascii.texture("uGlow", 3, this.glowTarget.textures[0]).vec2("uGlowScale", 1 / (this.cols * atlas.cellW), 1 / (this.rows * atlas.cellH));
2941
+ 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);
2943
+ 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);
2715
2944
  gl.drawArrays(gl.TRIANGLES, 0, 3);
2716
2945
  }
2717
2946
  /** Moves up to MAX_DROPS pending impulses into the uniform buffer, in sim texels. */
2718
2947
  takeDrops(pond) {
2719
- const batch = pond.drops.splice(0, 32);
2720
- batch.forEach((d, i) => {
2948
+ const drops = pond.drops;
2949
+ const n = Math.min(drops.length, 32);
2950
+ for (let i = 0; i < n; i++) {
2951
+ const d = drops[i];
2721
2952
  this.dropBuf[i * 4] = d.x / RIPPLE_PX;
2722
2953
  this.dropBuf[i * 4 + 1] = d.y / RIPPLE_PX;
2723
2954
  this.dropBuf[i * 4 + 2] = Math.max(d.r / RIPPLE_PX, 1.5);
2724
2955
  this.dropBuf[i * 4 + 3] = d.a;
2725
- });
2726
- return batch.length;
2956
+ }
2957
+ drops.copyWithin(0, n);
2958
+ drops.length -= n;
2959
+ return n;
2727
2960
  }
2728
2961
  };
2729
2962
  var DEFAULT_FONT_FAMILY = "\"JetBrains Mono\", ui-monospace, Menlo, Consolas, monospace";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koi-ascii",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "An ASCII-art koi pond for page backgrounds. WebGL2, no dependencies.",
5
5
  "keywords": [
6
6
  "koi",
@@ -48,6 +48,9 @@
48
48
  "shot": "node --experimental-strip-types scripts/shot.mjs",
49
49
  "shots": "node --experimental-strip-types scripts/shots.mjs",
50
50
  "perf": "node --experimental-strip-types scripts/perf.mjs",
51
+ "batch": "node --experimental-strip-types scripts/batch.mjs",
52
+ "cpu": "node --experimental-strip-types scripts/cpu.mjs",
53
+ "startup": "node --experimental-strip-types scripts/startup.mjs",
51
54
  "release:check": "node --experimental-strip-types scripts/release-check.mjs"
52
55
  },
53
56
  "devDependencies": {