paperlab 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +7 -5
  2. package/dist/{FxPostPass-M66QMVVC.js → FxPostPass-V6YDK4H2.js} +23 -5
  3. package/dist/FxPostPass-V6YDK4H2.js.map +1 -0
  4. package/dist/chunk-232SU3AQ.js +34 -0
  5. package/dist/chunk-232SU3AQ.js.map +1 -0
  6. package/dist/{chunk-GB6BMHC3.js → chunk-7WOGU2D4.js} +7 -19
  7. package/dist/{chunk-GB6BMHC3.js.map → chunk-7WOGU2D4.js.map} +1 -1
  8. package/dist/{chunk-NTDVOOBA.js → chunk-SVWWC5UL.js} +372 -151
  9. package/dist/chunk-SVWWC5UL.js.map +1 -0
  10. package/dist/{damageContract-DPF1YFLZ.d.cts → damageContract-nF1fgK7P.d.cts} +31 -3
  11. package/dist/{damageContract-DPF1YFLZ.d.ts → damageContract-nF1fgK7P.d.ts} +31 -3
  12. package/dist/fx.cjs +320 -91
  13. package/dist/fx.cjs.map +1 -1
  14. package/dist/fx.d.cts +81 -55
  15. package/dist/fx.d.ts +81 -55
  16. package/dist/fx.js +278 -48
  17. package/dist/fx.js.map +1 -1
  18. package/dist/index.cjs +402 -172
  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 +17 -4
  23. package/dist/index.js.map +1 -1
  24. package/dist/{slots-C5PqY0Q5.d.cts → slots-D_EE8GXn.d.cts} +12 -0
  25. package/dist/{slots-C5PqY0Q5.d.ts → slots-D_EE8GXn.d.ts} +12 -0
  26. package/dist/stage.cjs +396 -179
  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 +2 -2
  31. package/package.json +1 -1
  32. package/dist/FxPostPass-M66QMVVC.js.map +0 -1
  33. package/dist/chunk-7TLIWPGM.js +0 -36
  34. package/dist/chunk-7TLIWPGM.js.map +0 -1
  35. package/dist/chunk-NTDVOOBA.js.map +0 -1
@@ -69,6 +69,29 @@ interface DamageSource {
69
69
  * Anything left out takes {@link DAMAGE_LOOK_DEFAULTS}.
70
70
  */
71
71
  readonly look?: DamageLook;
72
+ /**
73
+ * The light the damage gives off, as the room around the sheet should feel
74
+ * it. Presentation only; the physics never sees it. Omitted, the room is
75
+ * lit exactly as its lighting says.
76
+ */
77
+ readonly firelight?: DamageFirelight;
78
+ }
79
+ /**
80
+ * What a burning sheet does to the light around it.
81
+ *
82
+ * A fire big enough to see by is the key light while it burns, and a room's
83
+ * own light yields to it — a sheet burning under an unchanged studio key
84
+ * looks like a flame pasted onto a photograph. The source says by how much,
85
+ * because only the source knows how big its fire is; the lighting does the
86
+ * dimming, because only the lighting knows what its lights are.
87
+ */
88
+ interface DamageFirelight {
89
+ /**
90
+ * How much of the room's own light is left, 0..1; omitted means 1. The key,
91
+ * the ambient fill and the studio light are all scaled by it, every frame,
92
+ * without rebuilding anything.
93
+ */
94
+ readonly room?: number;
72
95
  }
73
96
  /**
74
97
  * The knobs on what a burn looks like, in the units a person tunes by —
@@ -87,8 +110,10 @@ interface DamageLook {
87
110
  emberGlow?: number;
88
111
  /** Specks of glowing fibre along the edge, 0..2. */
89
112
  sparkle?: number;
90
- /** The pale ash lip's widest point, mm. */
113
+ /** How wide the pale ash lip is, from the cut out to the ember line, mm. */
91
114
  lipWidth?: number;
115
+ /** How wide the black char band is, past the ember line, mm. */
116
+ charWidth?: number;
92
117
  /** How pale the ash lip is, × the sampled grey. */
93
118
  lipBrightness?: number;
94
119
  /** 0 is grey char, 1 is dark orange to deep brown. */
@@ -107,8 +132,11 @@ interface DamageLook {
107
132
  edgeBite?: number;
108
133
  }
109
134
  /**
110
- * What every burn is drawn with unless told otherwise — the combination Noor
111
- * tuned in the lab's sidebar on 2026-09-12, which is the look this ships.
135
+ * What every burn is drawn with unless told otherwise — Noor's tune in the
136
+ * lab's sidebar on 2026-09-13, and the source of truth for how a burn looks:
137
+ * the lab starts from it, `/hands` inherits it, and a user changes it through
138
+ * `look`. The values it replaced, and why each of those had moved, are in the
139
+ * history of this file.
112
140
  */
113
141
  declare const DAMAGE_LOOK_DEFAULTS: Required<DamageLook>;
114
142
  /**
@@ -69,6 +69,29 @@ interface DamageSource {
69
69
  * Anything left out takes {@link DAMAGE_LOOK_DEFAULTS}.
70
70
  */
71
71
  readonly look?: DamageLook;
72
+ /**
73
+ * The light the damage gives off, as the room around the sheet should feel
74
+ * it. Presentation only; the physics never sees it. Omitted, the room is
75
+ * lit exactly as its lighting says.
76
+ */
77
+ readonly firelight?: DamageFirelight;
78
+ }
79
+ /**
80
+ * What a burning sheet does to the light around it.
81
+ *
82
+ * A fire big enough to see by is the key light while it burns, and a room's
83
+ * own light yields to it — a sheet burning under an unchanged studio key
84
+ * looks like a flame pasted onto a photograph. The source says by how much,
85
+ * because only the source knows how big its fire is; the lighting does the
86
+ * dimming, because only the lighting knows what its lights are.
87
+ */
88
+ interface DamageFirelight {
89
+ /**
90
+ * How much of the room's own light is left, 0..1; omitted means 1. The key,
91
+ * the ambient fill and the studio light are all scaled by it, every frame,
92
+ * without rebuilding anything.
93
+ */
94
+ readonly room?: number;
72
95
  }
73
96
  /**
74
97
  * The knobs on what a burn looks like, in the units a person tunes by —
@@ -87,8 +110,10 @@ interface DamageLook {
87
110
  emberGlow?: number;
88
111
  /** Specks of glowing fibre along the edge, 0..2. */
89
112
  sparkle?: number;
90
- /** The pale ash lip's widest point, mm. */
113
+ /** How wide the pale ash lip is, from the cut out to the ember line, mm. */
91
114
  lipWidth?: number;
115
+ /** How wide the black char band is, past the ember line, mm. */
116
+ charWidth?: number;
92
117
  /** How pale the ash lip is, × the sampled grey. */
93
118
  lipBrightness?: number;
94
119
  /** 0 is grey char, 1 is dark orange to deep brown. */
@@ -107,8 +132,11 @@ interface DamageLook {
107
132
  edgeBite?: number;
108
133
  }
109
134
  /**
110
- * What every burn is drawn with unless told otherwise — the combination Noor
111
- * tuned in the lab's sidebar on 2026-09-12, which is the look this ships.
135
+ * What every burn is drawn with unless told otherwise — Noor's tune in the
136
+ * lab's sidebar on 2026-09-13, and the source of truth for how a burn looks:
137
+ * the lab starts from it, `/hands` inherits it, and a user changes it through
138
+ * `look`. The values it replaced, and why each of those had moved, are in the
139
+ * history of this file.
112
140
  */
113
141
  declare const DAMAGE_LOOK_DEFAULTS: Required<DamageLook>;
114
142
  /**
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 (Noor, 2026-09-13): 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];
@@ -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,
@@ -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 Noor found the smoke wrong (2026-09-13).
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,7 +1193,9 @@ 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,
@@ -1194,9 +1203,11 @@ var particlePresets = {
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;
@@ -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
+ // (Noor, 2026-09-13). 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);
@@ -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);
@@ -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,