paperlab 0.7.1 → 0.8.1

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 (37) hide show
  1. package/README.md +57 -28
  2. package/dist/{FxPostPass-M66QMVVC.js → FxPostPass-ACFSHY3S.js} +24 -6
  3. package/dist/FxPostPass-ACFSHY3S.js.map +1 -0
  4. package/dist/chunk-BIFUJCUA.js +34 -0
  5. package/dist/chunk-BIFUJCUA.js.map +1 -0
  6. package/dist/{chunk-NTDVOOBA.js → chunk-LUKNPQ3S.js} +385 -165
  7. package/dist/chunk-LUKNPQ3S.js.map +1 -0
  8. package/dist/{chunk-GB6BMHC3.js → chunk-S74YZAXE.js} +7 -19
  9. package/dist/chunk-S74YZAXE.js.map +1 -0
  10. package/dist/{damageContract-DPF1YFLZ.d.cts → damageContract-9RC4egJg.d.cts} +31 -3
  11. package/dist/{damageContract-DPF1YFLZ.d.ts → damageContract-9RC4egJg.d.ts} +31 -3
  12. package/dist/fx.cjs +336 -107
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +113 -89
  15. package/dist/fx.d.ts +113 -89
  16. package/dist/fx.js +293 -63
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +422 -193
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +13 -5
  21. package/dist/index.d.ts +13 -5
  22. package/dist/index.js +21 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-C5PqY0Q5.d.cts → slots-DbJQfwVd.d.cts} +13 -1
  25. package/dist/{slots-C5PqY0Q5.d.ts → slots-DbJQfwVd.d.ts} +13 -1
  26. package/dist/stage.cjs +417 -201
  27. package/dist/stage.cjs.map +1 -1
  28. package/dist/stage.d.cts +1 -1
  29. package/dist/stage.d.ts +1 -1
  30. package/dist/stage.js +12 -12
  31. package/dist/stage.js.map +1 -1
  32. package/package.json +5 -5
  33. package/dist/FxPostPass-M66QMVVC.js.map +0 -1
  34. package/dist/chunk-7TLIWPGM.js +0 -36
  35. package/dist/chunk-7TLIWPGM.js.map +0 -1
  36. package/dist/chunk-GB6BMHC3.js.map +0 -1
  37. package/dist/chunk-NTDVOOBA.js.map +0 -1
package/dist/fx.cjs CHANGED
@@ -41,32 +41,24 @@ var init_damageContract = __esm({
41
41
  "src/surface/damageContract.ts"() {
42
42
  "use strict";
43
43
  DAMAGE_LOOK_DEFAULTS = {
44
- // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
45
- // it was tuned in — a control at its limit is a report that something
46
- // underneath is wrong, which in this case was a fire nothing could see.
47
- emberWidth: 0.9,
48
- emberIntensity: 1.45,
44
+ emberWidth: 1.25,
45
+ emberIntensity: 1.05,
49
46
  emberCoverage: 0.6,
50
47
  emberFlicker: 1.65,
51
48
  emberGlow: 1.25,
52
49
  sparkle: 0.5,
53
- lipWidth: 1.2,
54
- // Was 1.5, its slider's ceiling, which made the ash lip brighter than the
55
- // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
56
- // the sheet, not a highlight drawn on it.
57
- lipBrightness: 0.85,
58
- // Was 1, also a ceiling. At full warmth the char is milk chocolate —
59
- // closer to cardboard than to charcoal (§5). Burnt paper keeps a little
60
- // warmth in the plates and reads near black in a frame with a fire in it.
61
- charWarmth: 0.3,
62
- // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
63
- // at full strength the char reads as a mosaic rather than as broken plates.
64
- charCracks: 0.55,
65
- scorchReach: 30,
66
- scorchDarkness: 1.17,
67
- fingers: 1.25,
68
- edgeWave: 8.5,
69
- edgeBite: 3.6
50
+ // As wide as the char band: the lip has to be seen. At
51
+ // 0.95 mm it was a hairline tracing the edge.
52
+ lipWidth: 3.5,
53
+ charWidth: 3.5,
54
+ lipBrightness: 0.76,
55
+ charWarmth: 0.32,
56
+ charCracks: 0.45,
57
+ scorchReach: 9,
58
+ scorchDarkness: 0.86,
59
+ fingers: 1.95,
60
+ edgeWave: 13,
61
+ edgeBite: 6
70
62
  };
71
63
  DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
72
64
  }
@@ -613,27 +605,15 @@ var init_emission = __esm({
613
605
  FIRE_GLOW = [4, 8];
614
606
  FIRE_HEAT_SCALE = 0.65;
615
607
  FIRE_SOOT_SCALE = 3;
616
- FX_BLOOM = 0.55;
608
+ FX_BLOOM = 1.25;
617
609
  FIRE_CONTRAST = 0.9;
618
610
  FIRE_OPACITY = 5;
619
611
  FIRE_THIN = 0.55;
620
612
  FIRE_ZONES = {
621
- root: { color: "#3b6bff", amount: 0, reach: 0.5 },
622
- // glow 6 (was 4.6): once the core became the hottest few percent of a
623
- // flame instead of its whole root, 4.6 no longer cleared the bloom
624
- // threshold bloom's share of the peak frame fell to 0.48%, under the
625
- // budget. Brighter keeps the same small area: 1.47% bloom, near-white 0.8%.
626
- // Starting it lower (from 0.45) bloomed 6.4% but put near-white back at 4.7%.
627
- core: { color: "#fff7d4", glow: 6, from: 0.55 },
628
- // Saturated, because the tone curve takes saturation away from anything
629
- // above paper white: #ffdd7c at this glow came out pale yellow (s ~0.4),
630
- // and measured 7.5% yellow against Flame_base.png's 29%.
631
- body: { color: "#ffcf3a", glow: 1.3 },
632
- // to: 0.4, measured against Flame_base.png with bloom off (bloom's halo
633
- // counts as flame in any diff): near-white / pale / yellow / orange came out
634
- // 0.7 / 21 / 34 / 44% against the reference's 0.9 / 17 / 29 / 43. At 0.32
635
- // the body took half the flame and it was 51% yellow; at 0.45, 64% orange.
636
- tip: { color: "#ff9e2c", glow: 0.9, from: 0.08, to: 0.4, softness: 0.15, tearing: 0.35 }
613
+ root: { color: "#3b6bff", amount: 0.01, reach: 0.39 },
614
+ core: { color: "#fff7d4", glow: 2.1, from: 0.63 },
615
+ body: { color: "#ffcf3a", glow: 0.8 },
616
+ tip: { color: "#ff9e2c", glow: 0.21, from: 0, to: 0.5, softness: 0.02, tearing: 0.64 }
637
617
  };
638
618
  }
639
619
  });
@@ -711,16 +691,16 @@ function flameAnchors(field, locate, max, out, time = field.time) {
711
691
  const rx = at.x;
712
692
  const ry = at.y;
713
693
  const rz = at.z;
714
- const toward = locate(u + gx / gl / last, v + gy / gl / last);
694
+ const toward2 = locate(u + gx / gl / last, v + gy / gl / last);
715
695
  let upper = 0;
716
696
  scratchA.x = 0;
717
697
  scratchA.y = 0;
718
698
  scratchA.z = 0;
719
- if (toward) {
720
- const l = Math.hypot(toward.x - rx, toward.y - ry, toward.z - rz) || 1;
721
- scratchA.x = (toward.x - rx) / l;
722
- scratchA.y = (toward.y - ry) / l;
723
- scratchA.z = (toward.z - rz) / l;
699
+ if (toward2) {
700
+ const l = Math.hypot(toward2.x - rx, toward2.y - ry, toward2.z - rz) || 1;
701
+ scratchA.x = (toward2.x - rx) / l;
702
+ scratchA.y = (toward2.y - ry) / l;
703
+ scratchA.z = (toward2.z - rz) / l;
724
704
  upper = scratchA.y;
725
705
  }
726
706
  const heat = data[cell * 4 + HEAT];
@@ -866,7 +846,7 @@ void mainUv(inout vec2 uv) {
866
846
  }
867
847
 
868
848
  // The grade: the whole frame warms as the fire grows \u2014 a few percent, no
869
- // more (spec \xA77). A multiply, so black stays black.
849
+ // more. A multiply, so black stays black.
870
850
  void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
871
851
  outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);
872
852
  }
@@ -976,12 +956,14 @@ function FxPostPass({
976
956
  threshold = FX_BLOOM_THRESHOLD,
977
957
  field,
978
958
  locate,
979
- haze: hazeOverride
959
+ haze: hazeOverride,
960
+ focus = 0
980
961
  }) {
981
962
  const { bloomScale, haze: tierHaze } = fxQualityFor(quality);
982
963
  const hazePx = hazeOverride ?? tierHaze;
983
964
  const haze = (0, import_react2.useMemo)(() => new HazeGradeEffect(), []);
984
965
  const anchors = (0, import_react2.useRef)([]);
966
+ const focusTarget = (0, import_react2.useRef)(new THREE3.Vector3());
985
967
  (0, import_fiber2.useFrame)(({ camera }) => {
986
968
  if (!field || !locate) {
987
969
  haze.count = 0;
@@ -996,6 +978,14 @@ function FxPostPass({
996
978
  top.set(a.x, a.y + a.height, a.z).project(camera);
997
979
  sources[i].set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat);
998
980
  }
981
+ if (focus > 0 && n > 0) {
982
+ focusTarget.current.set(0, 0, 0);
983
+ for (let i = 0; i < n; i++) {
984
+ const a = anchors.current[i];
985
+ focusTarget.current.add(root.set(a.x, a.y, a.z));
986
+ }
987
+ focusTarget.current.multiplyScalar(1 / n);
988
+ }
999
989
  haze.count = n;
1000
990
  haze.time = field.time;
1001
991
  haze.amount = hazePx;
@@ -1013,6 +1003,14 @@ function FxPostPass({
1013
1003
  }
1014
1004
  ) : null,
1015
1005
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("primitive", { object: haze }),
1006
+ focus > 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1007
+ import_postprocessing2.DepthOfField,
1008
+ {
1009
+ target: focusTarget.current,
1010
+ focalLength: 0.06 - 0.045 * focus,
1011
+ bokehScale: 1 + 3 * focus
1012
+ }
1013
+ ) : null,
1016
1014
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_postprocessing2.ToneMapping, { mode: modes[film] })
1017
1015
  ] });
1018
1016
  }
@@ -1051,6 +1049,7 @@ __export(fx_exports, {
1051
1049
  FIELD_SIZE: () => FIELD_SIZE,
1052
1050
  FIRE_GLOW: () => FIRE_GLOW,
1053
1051
  FIRE_HEAT_SCALE: () => FIRE_HEAT_SCALE,
1052
+ FIRE_LIGHT_GAIN: () => FIRE_LIGHT_GAIN,
1054
1053
  FIRE_SOOT_SCALE: () => FIRE_SOOT_SCALE,
1055
1054
  FIRE_ZONES: () => FIRE_ZONES,
1056
1055
  FIXED_DT: () => FIXED_DT,
@@ -1109,7 +1108,7 @@ var particlePresets = {
1109
1108
  * caught the bug in the version before this one. That version said "the hot
1110
1109
  * end is now ~5.6, well over the bloom threshold" — and 5.6 was an ABSOLUTE
1111
1110
  * luminance, while paper white under `window` is 1.6. So the hottest spark
1112
- * in the frame was **3.5× paper, under the spec's own 4× floor**, and its
1111
+ * in the frame was **3.5× paper, under the 4× floor**, and its
1113
1112
  * own comment said otherwise. A number that cannot be compared to anything
1114
1113
  * is a number nobody can check.
1115
1114
  *
@@ -1129,12 +1128,12 @@ var particlePresets = {
1129
1128
  drag: 0.8,
1130
1129
  jitter: 1.5,
1131
1130
  windCatch: 0.8,
1132
- // 0.3-1 mm of core (§5). A sheet is one world unit across, 210 mm, so
1131
+ // 0.3-1 mm of core. A sheet is one world unit across, 210 mm, so
1133
1132
  // 0.0035 is 0.7 mm. It was 0.005 — a full millimetre wide before the
1134
1133
  // streak stretched it, which is where "wide bars" started.
1135
1134
  size: [35e-4, 12e-4],
1136
1135
  color: [
1137
- // 6× paper white: inside §4.3's 4–8 band, so it blooms.
1136
+ // 6× paper white: inside the 4–8× band, so it blooms.
1138
1137
  emitHex("#FFC271", 6),
1139
1138
  // 0.7× — UNDER the bloom threshold on purpose, so a dying spark stops
1140
1139
  // glowing rather than merely fading.
@@ -1152,7 +1151,13 @@ var particlePresets = {
1152
1151
  * simulation, on purpose; see the fx plan.
1153
1152
  */
1154
1153
  smoke: {
1155
- life: [1.8, 3.5],
1154
+ // Shorter, and smaller below: these are the thread beside the
1155
+ // simulator's smoke, and the simulator's is gone within a second or two
1156
+ // of the last flame. Left to live three and a half seconds and grow to
1157
+ // seven centimetres, the last few of them overlapped into a flat wash
1158
+ // over the hole with nothing left to move them — the one moment of a
1159
+ // burn where the smoke looked wrong.
1160
+ life: [1.4, 2.6],
1156
1161
  speed: [0.05, 0.15],
1157
1162
  direction: [0, 1, 0],
1158
1163
  spread: 0.3,
@@ -1160,13 +1165,13 @@ var particlePresets = {
1160
1165
  drag: 0.9,
1161
1166
  jitter: 0.3,
1162
1167
  windCatch: 1,
1163
- size: [0.05, 0.35],
1168
+ size: [0.05, 0.18],
1164
1169
  // #6B6560 grey-brown, linear, and thin — the background stays clear.
1165
1170
  color: [
1166
1171
  [0.147, 0.13, 0.117],
1167
1172
  [0.2, 0.19, 0.18]
1168
1173
  ],
1169
- alpha: [0.08, 0],
1174
+ alpha: [0.07, 0],
1170
1175
  spin: 0.4,
1171
1176
  blend: "normal",
1172
1177
  shape: "soft",
@@ -1178,7 +1183,9 @@ var particlePresets = {
1178
1183
  * burn that proves paper was there.
1179
1184
  */
1180
1185
  ash: {
1181
- life: [2.5, 5],
1186
+ // A second or two: ash that hung in the air for five read as grey
1187
+ // confetti over the hole, dust or stars rather than something falling.
1188
+ life: [1.2, 2.6],
1182
1189
  // Thrown UP by the heat it came off, before it is heavier than the air.
1183
1190
  // It used to leave at almost nothing (0.02–0.1) with a lift of −0.35, so
1184
1191
  // every flake began falling the moment it was born — straight through the
@@ -1186,17 +1193,21 @@ var particlePresets = {
1186
1193
  speed: [0.14, 0.3],
1187
1194
  direction: [0, 1, 0],
1188
1195
  spread: 0.55,
1189
- lift: -0.22,
1196
+ // Heavier than the air once the throw is spent: most flakes tumble down,
1197
+ // only the lightest ride the heat up a little first.
1198
+ lift: -0.42,
1190
1199
  drag: 1.6,
1191
1200
  jitter: 1.2,
1192
1201
  windCatch: 1,
1193
- // 5–10 mm across (§8.3). A sheet is 210 mm, so 0.03 is 6.3 mm. They were
1202
+ // 5–10 mm across. A sheet is 210 mm, so 0.03 is 6.3 mm. They were
1194
1203
  // 3.8 mm and there were far too many of them: a shower of specks rather
1195
1204
  // than a few flakes you would notice.
1196
1205
  size: [0.03, 0.026],
1206
+ // Charred paper, near black: under a bright key the old value read as
1207
+ // pale grey against the black stage, which is the confetti.
1197
1208
  color: [
1198
- [0.07, 0.065, 0.06],
1199
- [0.12, 0.115, 0.11]
1209
+ [0.04, 0.037, 0.034],
1210
+ [0.07, 0.066, 0.062]
1200
1211
  ],
1201
1212
  alpha: [0.95, 0],
1202
1213
  spin: 4,
@@ -1421,13 +1432,14 @@ var ParticlePool = class {
1421
1432
  // src/fx/fire.ts
1422
1433
  init_field();
1423
1434
  var fireEmitterDefaults = {
1424
- embers: 0.35,
1425
- smoke: 0,
1426
- ash: 0.12,
1435
+ embers: 0.39,
1436
+ smoke: 0.135,
1437
+ ash: 0.36,
1427
1438
  seed: 7,
1428
1439
  caps: {}
1429
1440
  };
1430
1441
  var PER_UPDATE = 24;
1442
+ var CRACKLE_SPARKS = 0.06;
1431
1443
  var FireEmitter = class {
1432
1444
  field;
1433
1445
  pool;
@@ -1464,7 +1476,10 @@ var FireEmitter = class {
1464
1476
  heat /= front;
1465
1477
  const struggle = 0.35 + 1.6 * Math.max(0, 0.7 - heat) + air * 0.8;
1466
1478
  const oxygen = 1 + air * 1.5;
1467
- this.emberDebt = Math.min(this.emberDebt + front * o.embers * oxygen * dt, PER_UPDATE);
1479
+ this.emberDebt = Math.min(
1480
+ this.emberDebt + front * o.embers * oxygen * dt + field.lastStats.charred * o.embers * CRACKLE_SPARKS,
1481
+ PER_UPDATE
1482
+ );
1468
1483
  this.smokeDebt = Math.min(this.smokeDebt + front * o.smoke * struggle * dt, PER_UPDATE);
1469
1484
  while (this.emberDebt >= 1) {
1470
1485
  this.emberDebt -= 1;
@@ -1596,7 +1611,7 @@ void main() {
1596
1611
  // It used to be flat-topped across (smoothstep from 0.35, so the middle
1597
1612
  // seventy per cent of the width was all at full brightness) and to keep a
1598
1613
  // quarter of that brightness all the way to the end of the quad. Both
1599
- // together drew a wide bar with a squared-off end, which is what the review
1614
+ // together drew a wide bar with a squared-off end, which is what a visual review
1600
1615
  // saw up close. A spark is a point of light smeared by its own motion: it
1601
1616
  // is brightest on its centre line and it runs out.
1602
1617
  float across = abs(vQuad.x);
@@ -1716,14 +1731,14 @@ void main() {
1716
1731
  float edge = 0.62 + 0.3 * flNoise(vec2(a * 1.6 + vSeed * 31.0, vSeed * 7.0));
1717
1732
  float mask = 1.0 - smoothstep(edge - 0.06, edge, r);
1718
1733
  if (mask <= 0.0) discard;
1719
- // Char in the middle, pale brittle ash at the edge (\xA78.3) \u2014 in patches
1734
+ // Char in the middle, pale brittle ash at the edge \u2014 in patches
1720
1735
  // along it, not all the way round: a flake is a few pixels across, and a
1721
1736
  // continuous pale rim round a dark middle drew it as a hollow ring.
1722
1737
  float rim = smoothstep(edge - 0.32, edge - 0.04, r);
1723
1738
  float patches = smoothstep(0.45, 0.75, flNoise(vec2(a * 2.2 + vSeed * 17.0, vSeed * 13.0)));
1724
1739
  float grain = flNoise(vQuad * 3.0 + vSeed * 23.0);
1725
1740
  vec3 charC = vec3(0.05, 0.043, 0.038) * (0.7 + 0.6 * grain);
1726
- // #A49E9D, the spec's own sample of ash (\xA78.3), in linear.
1741
+ // #A49E9D, sampled from the reference ash, in linear.
1727
1742
  vec3 ashC = vec3(0.372, 0.344, 0.340);
1728
1743
  // Pale ash with char under it, not char with a pale rim.
1729
1744
  //
@@ -1830,7 +1845,7 @@ function warnOnce(cause) {
1830
1845
  if (warned) return;
1831
1846
  warned = true;
1832
1847
  console.warn(
1833
- "[paperlab/fx] Rendering without FxPost \u2014 fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
1848
+ "[paperlab/fx] Rendering without FxPost, so fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
1834
1849
  ...cause === void 0 ? [] : [cause]
1835
1850
  );
1836
1851
  }
@@ -1885,7 +1900,7 @@ var Afterglow = class {
1885
1900
  }
1886
1901
  /**
1887
1902
  * Advance by `dt`, after the field has stepped. `blow` is the breath on the
1888
- * sheet, 0..1: it flares what is left (§10.6, "on smoulder: beads flare,
1903
+ * sheet, 0..1: it flares what is left ("on smoulder: beads flare,
1889
1904
  * then fade").
1890
1905
  */
1891
1906
  step(dt, blow = 0) {
@@ -2814,10 +2829,17 @@ void main() {
2814
2829
  float rootMask = smoothstep(mix(0.4, 0.02, uRootReach), mix(0.6, 0.12, uRootReach), fuel) * (1.0 - smoothstep(0.25, 0.6, t));
2815
2830
  fire += uRootColor * rootMask * uRootAmount * uPaperWhite * uGlow * 0.6;
2816
2831
 
2817
- // Smoke: warm grey-brown (a cool grey over cream reads lavender), lit by
2818
- // the fire under it.
2832
+ // Smoke: grey-brown, warm (a cool grey over cream reads lavender), and LIT
2833
+ // BY THE FIRE UNDER IT rather than by nothing.
2834
+ //
2835
+ // The base used to be bright enough to read on its own \u2014 beige \u2014 which is
2836
+ // right while flames are lighting it and wrong the moment they stop: the
2837
+ // last of the smoke hung over a black stage as flat beige slabs, the same
2838
+ // colour everywhere, which reads as a filled shape rather than as smoke.
2839
+ // Dim at the base and warm where the fire is: smoke by
2840
+ // the flames looks as it did, smoke after them goes to a dim grey.
2819
2841
  float smokeAlpha = 1.0 - exp(-smoke * uSmokeDensity);
2820
- vec3 smokeColor = vec3(0.16, 0.13, 0.10) + vec3(0.22, 0.09, 0.02) * smoothstep(0.15, 0.9, t);
2842
+ vec3 smokeColor = vec3(0.075, 0.068, 0.062) + vec3(0.3, 0.14, 0.045) * smoothstep(0.12, 0.9, t);
2821
2843
  float alpha = smokeAlpha + fireAlpha - smokeAlpha * fireAlpha;
2822
2844
  // No square edge: the domain fades out before its borders.
2823
2845
  float edge = smoothstep(0.0, 0.06, vUv.x) * smoothstep(1.0, 0.94, vUv.x) * smoothstep(0.0, 0.03, vUv.y) * smoothstep(1.0, 0.9, vUv.y);
@@ -3203,7 +3225,7 @@ var fireFluidDefaults = {
3203
3225
  smoke: 0.7,
3204
3226
  // Was 5 — a full unit a second, 210 mm/s, of gas pushed sideways out of the
3205
3227
  // rim. Against a buoyancy of 1.7 units/s² that is what rolled the flames
3206
- // into mushroom caps: the grey spirals over the text that the review called
3228
+ // into mushroom caps: the grey spirals over the text that a visual review called
3207
3229
  // the most synthetic thing in the frame. A flame leaves paper going UP.
3208
3230
  radialImpulse: 0.8,
3209
3231
  // In world units a second now (see `solverUniforms`): 1.4 is ~0.3 m/s, the
@@ -3236,7 +3258,7 @@ var fireFluidDefaults = {
3236
3258
  // reached the text (~150 mm), 1.6 ~100 mm, 2.1 short licks (~60 mm);
3237
3259
  // Hero.png's tallest is ~70 mm.
3238
3260
  cooling: 1.8,
3239
- // Was 1.4, which made a thick grey column, then 0.5. Noor's direction is
3261
+ // Was 1.4, which made a thick grey column, then 0.5. The direction is
3240
3262
  // light smoke, and with MacCormack advection keeping the smoke's fine
3241
3263
  // structure it needs less of it to read: at 0.5 it veiled the upper sheet.
3242
3264
  smokeProduction: 0.25,
@@ -3617,56 +3639,262 @@ function FxFireFluid({
3617
3639
  var THREE8 = __toESM(require("three"), 1);
3618
3640
  var import_fiber6 = require("@react-three/fiber");
3619
3641
  var import_react7 = require("react");
3642
+
3643
+ // src/fx/fireState.ts
3620
3644
  init_flames();
3621
- var import_jsx_runtime7 = require("react/jsx-runtime");
3622
- function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN }) {
3623
- const front = (0, import_react7.useRef)(null);
3624
- const through = (0, import_react7.useRef)(null);
3625
- const anchors = (0, import_react7.useRef)([]);
3626
- (0, import_fiber6.useFrame)(({ camera }) => {
3627
- const a = front.current;
3628
- const b = through.current;
3629
- if (!a || !b) return;
3630
- const n = flameAnchors(field, locate, 32, anchors.current);
3631
- if (n === 0) {
3632
- a.intensity = 0;
3633
- b.intensity = 0;
3634
- return;
3635
- }
3645
+ var FLAMES = 32;
3646
+ var FIRE_CLUSTERS = 4;
3647
+ var CLUSTER_REACH = 25 / 210;
3648
+ var FireState = class {
3649
+ constructor(field, locate) {
3650
+ this.field = field;
3651
+ this.locate = locate;
3652
+ }
3653
+ field;
3654
+ locate;
3655
+ /** The flames it was taken over. Only the first {@link count} are current. */
3656
+ anchors = [];
3657
+ count = 0;
3658
+ /** Where the fire is, as a whole: its flames' mean. Zero with none. */
3659
+ x = 0;
3660
+ y = 0;
3661
+ z = 0;
3662
+ /** Its flames' mean height. */
3663
+ height = 0;
3664
+ /** Its flames' mean puff. */
3665
+ flicker = 0;
3666
+ /** Where it is gathered. Only the first {@link clusterCount} are current. */
3667
+ clusters = [];
3668
+ clusterCount = 0;
3669
+ frame = Number.NaN;
3670
+ order = [];
3671
+ root = new Float64Array(FIRE_CLUSTERS * 3);
3672
+ weight = new Float64Array(FIRE_CLUSTERS);
3673
+ /**
3674
+ * Bring it up to date for `frame` — any number that is the same for every
3675
+ * reader in one frame and different in the next; R3F's `clock.elapsedTime`
3676
+ * is the one in hand. Free the second time it is asked in a frame.
3677
+ */
3678
+ update(frame) {
3679
+ if (frame === this.frame) return this;
3680
+ this.frame = frame;
3681
+ const { field, anchors } = this;
3682
+ const n = flameAnchors(field, this.locate, FLAMES, anchors);
3683
+ this.count = n;
3636
3684
  let x = 0;
3637
3685
  let y = 0;
3638
3686
  let z = 0;
3639
3687
  let h = 0;
3640
3688
  let flicker = 0;
3641
3689
  for (let i = 0; i < n; i++) {
3642
- const f = anchors.current[i];
3690
+ const f = anchors[i];
3643
3691
  x += f.x;
3644
3692
  y += f.y;
3645
3693
  z += f.z;
3646
3694
  h += f.height;
3647
3695
  flicker += flamePuff(f.seed, field.time);
3648
3696
  }
3649
- x /= n;
3650
- y /= n;
3651
- z /= n;
3652
- h /= n;
3653
- flicker /= n;
3654
- const toCamera = new THREE8.Vector3(camera.position.x - x, 0, camera.position.z - z);
3655
- toCamera.normalize().multiplyScalar(0.035);
3656
- const lift = h * 0.5;
3657
- a.position.set(x + toCamera.x, y + lift, z + toCamera.z);
3658
- b.position.set(x - toCamera.x, y + lift, z - toCamera.z);
3659
- const level = field.lastStats.front * gain * flicker;
3660
- a.intensity = level;
3661
- b.intensity = level * 0.35;
3697
+ if (n > 0) {
3698
+ x /= n;
3699
+ y /= n;
3700
+ z /= n;
3701
+ h /= n;
3702
+ flicker /= n;
3703
+ }
3704
+ this.x = x;
3705
+ this.y = y;
3706
+ this.z = z;
3707
+ this.height = h;
3708
+ this.flicker = flicker;
3709
+ this.gather();
3710
+ return this;
3711
+ }
3712
+ /**
3713
+ * Group the flames into at most {@link FIRE_CLUSTERS} clusters.
3714
+ *
3715
+ * Tallest first, so each cluster is named by its tallest tongue and stands
3716
+ * where that one stands: a flame joins the nearest cluster within
3717
+ * {@link CLUSTER_REACH} of it, or starts one while there is room, or joins
3718
+ * the nearest once there is not. Pure arithmetic over the flames, so the
3719
+ * same burn gathers the same way every time.
3720
+ */
3721
+ gather() {
3722
+ const anchors = this.anchors;
3723
+ const clusters = this.clusters;
3724
+ const order = this.order;
3725
+ const root2 = this.root;
3726
+ const weight = this.weight;
3727
+ const n = this.count;
3728
+ order.length = 0;
3729
+ for (let i = 0; i < n; i++) order.push(i);
3730
+ order.sort((i, j) => anchors[j].height - anchors[i].height || i - j);
3731
+ let k = 0;
3732
+ for (const i of order) {
3733
+ const f = anchors[i];
3734
+ let best = -1;
3735
+ let near = Number.POSITIVE_INFINITY;
3736
+ for (let c = 0; c < k; c++) {
3737
+ const d = Math.hypot(f.x - root2[c * 3], f.y - root2[c * 3 + 1], f.z - root2[c * 3 + 2]);
3738
+ if (d < near) {
3739
+ near = d;
3740
+ best = c;
3741
+ }
3742
+ }
3743
+ if (best < 0 || near > CLUSTER_REACH && k < FIRE_CLUSTERS) {
3744
+ best = k++;
3745
+ root2[best * 3] = f.x;
3746
+ root2[best * 3 + 1] = f.y;
3747
+ root2[best * 3 + 2] = f.z;
3748
+ weight[best] = 0;
3749
+ let cluster2 = clusters[best];
3750
+ if (!cluster2) {
3751
+ cluster2 = { x: 0, y: 0, z: 0, height: 0, tallest: 0, flicker: 0, heat: 0, count: 0 };
3752
+ clusters[best] = cluster2;
3753
+ }
3754
+ cluster2.x = 0;
3755
+ cluster2.y = 0;
3756
+ cluster2.z = 0;
3757
+ cluster2.height = 0;
3758
+ cluster2.tallest = f.height;
3759
+ cluster2.flicker = 0;
3760
+ cluster2.heat = 0;
3761
+ cluster2.count = 0;
3762
+ }
3763
+ const cluster = clusters[best];
3764
+ const w = f.height;
3765
+ cluster.x += f.x * w;
3766
+ cluster.y += f.y * w;
3767
+ cluster.z += f.z * w;
3768
+ weight[best] = weight[best] + w;
3769
+ cluster.height += f.height;
3770
+ cluster.flicker += flamePuff(f.seed, this.field.time);
3771
+ cluster.heat += f.heat;
3772
+ cluster.count++;
3773
+ }
3774
+ for (let c = 0; c < k; c++) {
3775
+ const cluster = clusters[c];
3776
+ const w = weight[c];
3777
+ if (w > 0) {
3778
+ cluster.x /= w;
3779
+ cluster.y /= w;
3780
+ cluster.z /= w;
3781
+ } else {
3782
+ cluster.x = root2[c * 3];
3783
+ cluster.y = root2[c * 3 + 1];
3784
+ cluster.z = root2[c * 3 + 2];
3785
+ }
3786
+ cluster.height /= cluster.count;
3787
+ cluster.flicker /= cluster.count;
3788
+ cluster.heat /= cluster.count;
3789
+ }
3790
+ this.clusterCount = k;
3791
+ }
3792
+ };
3793
+ var shared = /* @__PURE__ */ new WeakMap();
3794
+ function fireStateOf(field, locate) {
3795
+ let byLocator = shared.get(field);
3796
+ if (!byLocator) {
3797
+ byLocator = /* @__PURE__ */ new WeakMap();
3798
+ shared.set(field, byLocator);
3799
+ }
3800
+ let state = byLocator.get(locate);
3801
+ if (!state) {
3802
+ state = new FireState(field, locate);
3803
+ byLocator.set(locate, state);
3804
+ }
3805
+ return state;
3806
+ }
3807
+
3808
+ // src/fx/FxFireLight.tsx
3809
+ var import_jsx_runtime7 = require("react/jsx-runtime");
3810
+ function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN, shadows = false }) {
3811
+ const front = (0, import_react7.useRef)([]);
3812
+ const through = (0, import_react7.useRef)(null);
3813
+ const spot = (0, import_react7.useRef)(null);
3814
+ (0, import_fiber6.useFrame)(({ camera, clock }) => {
3815
+ const b = through.current;
3816
+ if (!b) return;
3817
+ const fire = fireStateOf(field, locate).update(clock.elapsedTime);
3818
+ const level = field.lastStats.front * gain;
3819
+ let total = 0;
3820
+ let brightest = -1;
3821
+ let most = 0;
3822
+ for (let c = 0; c < fire.clusterCount; c++) {
3823
+ const cluster = fire.clusters[c];
3824
+ const share = cluster.count * cluster.height;
3825
+ total += share;
3826
+ if (share > most) {
3827
+ most = share;
3828
+ brightest = c;
3829
+ }
3830
+ }
3831
+ const s = shadows ? spot.current : null;
3832
+ if (s) s.intensity = 0;
3833
+ for (let i = 0; i < FIRE_CLUSTERS; i++) {
3834
+ const light = front.current[i];
3835
+ if (!light) continue;
3836
+ const cluster = i < fire.clusterCount ? fire.clusters[i] : null;
3837
+ if (!cluster || !(total > 0)) {
3838
+ light.intensity = 0;
3839
+ continue;
3840
+ }
3841
+ toward.set(camera.position.x - cluster.x, 0, camera.position.z - cluster.z).normalize().multiplyScalar(0.035);
3842
+ light.position.set(cluster.x + toward.x, cluster.y + cluster.height * 0.5, cluster.z + toward.z);
3843
+ light.intensity = level * cluster.flicker * (cluster.count * cluster.height / total);
3844
+ light.color.copy(COOLING).lerp(FIRE_COLOR, Math.min(1, Math.max(0, cluster.heat)));
3845
+ if (s && i === brightest) {
3846
+ s.position.copy(light.position);
3847
+ s.target.position.set(cluster.x, cluster.y, cluster.z);
3848
+ s.target.updateMatrixWorld();
3849
+ s.intensity = light.intensity;
3850
+ s.color.copy(light.color);
3851
+ light.intensity = 0;
3852
+ }
3853
+ }
3854
+ if (fire.count === 0) {
3855
+ b.intensity = 0;
3856
+ return;
3857
+ }
3858
+ toward.set(camera.position.x - fire.x, 0, camera.position.z - fire.z).normalize().multiplyScalar(0.035);
3859
+ b.position.set(fire.x - toward.x, fire.y + fire.height * 0.5, fire.z - toward.z);
3860
+ b.intensity = level * fire.flicker * 0.35;
3662
3861
  });
3663
3862
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
3664
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("pointLight", { ref: front, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 }),
3863
+ Array.from({ length: FIRE_CLUSTERS }, (_, i) => /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3864
+ "pointLight",
3865
+ {
3866
+ ref: (light) => {
3867
+ front.current[i] = light;
3868
+ },
3869
+ color: FIRE_COLOR,
3870
+ intensity: 0,
3871
+ decay: 0.9,
3872
+ distance: 0
3873
+ },
3874
+ i
3875
+ )),
3876
+ shadows && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
3877
+ "spotLight",
3878
+ {
3879
+ ref: spot,
3880
+ color: FIRE_COLOR,
3881
+ intensity: 0,
3882
+ decay: 0.9,
3883
+ distance: 0,
3884
+ angle: 1.3,
3885
+ penumbra: 0.8,
3886
+ castShadow: true,
3887
+ "shadow-mapSize": [512, 512],
3888
+ "shadow-bias": -4e-4
3889
+ }
3890
+ ),
3665
3891
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("pointLight", { ref: through, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 })
3666
3892
  ] });
3667
3893
  }
3894
+ var toward = new THREE8.Vector3();
3668
3895
  var FIRE_COLOR = new THREE8.Color("#ffa24c");
3669
- var FIRE_LIGHT_GAIN = 42;
3896
+ var COOLING = new THREE8.Color("#ff8a33");
3897
+ var FIRE_LIGHT_GAIN = 26;
3670
3898
 
3671
3899
  // src/fx/FxMatchFlame.tsx
3672
3900
  var THREE9 = __toESM(require("three"), 1);
@@ -3814,7 +4042,7 @@ var FxAudio = class {
3814
4042
  constructor(options) {
3815
4043
  const { quality = "auto", context, volume = 0.8 } = options;
3816
4044
  this.quality = typeof quality === "string" ? fxQualityFor(quality) : quality;
3817
- if (!context) throw new Error("FxAudio needs a context \u2014 see createAudioContext()");
4045
+ if (!context) throw new Error("FxAudio needs a context. See createAudioContext()");
3818
4046
  this.ctx = context;
3819
4047
  this.master = this.ctx.createGain();
3820
4048
  this.master.gain.value = volume;
@@ -4074,18 +4302,18 @@ var FireSound = class {
4074
4302
  }
4075
4303
  /**
4076
4304
  * A match struck: the scratch of the head across the box, then the hiss of
4077
- * it flaring as the head burns off (spec §10.2). Two bursts of the shared
4305
+ * it flaring as the head burns off. Two bursts of the shared
4078
4306
  * noise, one bright and short, one breathier and longer.
4079
4307
  */
4080
4308
  strike() {
4081
4309
  this.burst("fire-strike", 0.9, { type: "bandpass", frequency: 3200, q: 1.1 }, 4e-3, 0.07, 0.8);
4082
4310
  this.burst("fire-flare", 0.8, { type: "highpass", frequency: 1800, q: 0.7 }, 0.03, 0.45, 0.45, 0.05);
4083
4311
  }
4084
- /** Blown out: a soft, low breath of noise (spec §10.6). */
4312
+ /** Blown out: a soft, low breath of noise. */
4085
4313
  puff() {
4086
4314
  this.burst("fire-puff", 0.6, { type: "lowpass", frequency: 520, q: 0.6 }, 0.02, 0.28, 0.55);
4087
4315
  }
4088
- /** One ember popping in the air — a tiny click on the frame it flashes (spec §8.1). */
4316
+ /** One ember popping in the air — a tiny click on the frame it flashes. */
4089
4317
  pop() {
4090
4318
  const f = 3e3 + this.next() * 3e3;
4091
4319
  this.burst(
@@ -4230,6 +4458,7 @@ init_quality();
4230
4458
  FIELD_SIZE,
4231
4459
  FIRE_GLOW,
4232
4460
  FIRE_HEAT_SCALE,
4461
+ FIRE_LIGHT_GAIN,
4233
4462
  FIRE_SOOT_SCALE,
4234
4463
  FIRE_ZONES,
4235
4464
  FIXED_DT,