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
package/dist/index.cjs CHANGED
@@ -1562,8 +1562,23 @@ var backdropSchema = import_zod15.z.object({
1562
1562
  /** Out of focus, for the same reason. */
1563
1563
  blur: import_zod15.z.number().min(0).max(1).default(0.2)
1564
1564
  });
1565
+ var floorSchema = import_zod15.z.object({
1566
+ enabled: import_zod15.z.boolean().default(false),
1567
+ /** Dark and matte: it is the ground, not the subject. */
1568
+ color: import_zod15.z.string().default("#1a1917").describe("color"),
1569
+ /**
1570
+ * Where it lies, in the sheet's own space. The contact shadow falls here
1571
+ * too, and a simulated sheet stops here — one floor, one height, so paper
1572
+ * cannot land through the thing its shadow is on.
1573
+ */
1574
+ y: import_zod15.z.number().min(-4).max(0).default(-1.05),
1575
+ /** How rough it is, 0 mirror to 1 chalk. Slate, by default. */
1576
+ roughness: import_zod15.z.number().min(0).max(1).default(0.92)
1577
+ });
1565
1578
  var sceneSchema = import_zod15.z.object({
1566
1579
  lighting: import_zod15.z.enum(lightingNames).default("studio"),
1580
+ /** The ground under the sheet — see `floorSchema`. Off unless asked for. */
1581
+ floor: floorSchema.prefault({}),
1567
1582
  /** What is behind the sheet. Unset leaves the canvas alone. */
1568
1583
  backdrop: backdropSchema.optional(),
1569
1584
  /**
@@ -3795,6 +3810,16 @@ var ClothSim = class {
3795
3810
  * cannot do this job: it moves paper, and a uniformly curled sheet in still
3796
3811
  * air has no business going anywhere.
3797
3812
  */
3813
+ /**
3814
+ * An acceleration on each particle from whatever is in the air around the
3815
+ * sheet, xyz — the fire's updraft, so far (`DamageCoupling`). Not the wind:
3816
+ * the wind is one thing blowing on the whole sheet, and this is local, a
3817
+ * hot column over a burning rim and nothing a hand's width away.
3818
+ *
3819
+ * An acceleration rather than a force, so it lifts a soaked sheet no
3820
+ * differently from a dry one: hot air does not know what paper weighs.
3821
+ */
3822
+ draught;
3798
3823
  restBend;
3799
3824
  /** For each bend spring, the particle between its ends; -1 for every other kind. */
3800
3825
  constraintMiddle;
@@ -3881,6 +3906,7 @@ var ClothSim = class {
3881
3906
  this.constraintStiffness = new Float64Array(m).fill(1);
3882
3907
  this.broken = new Uint8Array(m);
3883
3908
  this.restBend = new Float64Array(m);
3909
+ this.draught = new Float64Array(this.count * 3);
3884
3910
  this.constraintMiddle = new Int32Array(m).fill(-1);
3885
3911
  for (let k2 = 0; k2 < m; k2++) {
3886
3912
  const a = links[k2 * 3];
@@ -4219,9 +4245,9 @@ var ClothSim = class {
4219
4245
  this.prev[i3] = x2;
4220
4246
  this.prev[i3 + 1] = y2;
4221
4247
  this.prev[i3 + 2] = z26;
4222
- p2[i3] = x2 + vx + ax * im * dt2;
4223
- p2[i3 + 1] = y2 + vy + (ay * im - gravity * 3.2) * dt2;
4224
- p2[i3 + 2] = z26 + vz + az * im * dt2;
4248
+ p2[i3] = x2 + vx + (ax * im + this.draught[i3]) * dt2;
4249
+ p2[i3 + 1] = y2 + vy + (ay * im - gravity * 3.2 + this.draught[i3 + 1]) * dt2;
4250
+ p2[i3 + 2] = z26 + vz + (az * im + this.draught[i3 + 2]) * dt2;
4225
4251
  maxTravel = Math.max(maxTravel, vx * vx + vy * vy + vz * vz);
4226
4252
  }
4227
4253
  for (let k2 = 0; k2 < this.grabbed.length; k2++) {
@@ -4320,40 +4346,40 @@ var ClothSim = class {
4320
4346
  };
4321
4347
 
4322
4348
  // src/surface/damageContract.ts
4349
+ function roomLight(source) {
4350
+ const room = source?.firelight?.room;
4351
+ if (room === void 0 || !(room >= 0)) return 1;
4352
+ return Math.min(1, room);
4353
+ }
4323
4354
  var DAMAGE_LOOK_DEFAULTS = {
4324
- // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
4325
- // it was tuned in — a control at its limit is a report that something
4326
- // underneath is wrong, which in this case was a fire nothing could see.
4327
- emberWidth: 0.9,
4328
- emberIntensity: 1.45,
4355
+ emberWidth: 1.25,
4356
+ emberIntensity: 1.05,
4329
4357
  emberCoverage: 0.6,
4330
4358
  emberFlicker: 1.65,
4331
4359
  emberGlow: 1.25,
4332
4360
  sparkle: 0.5,
4333
- lipWidth: 1.2,
4334
- // Was 1.5, its slider's ceiling, which made the ash lip brighter than the
4335
- // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
4336
- // the sheet, not a highlight drawn on it.
4337
- lipBrightness: 0.85,
4338
- // Was 1, also a ceiling. At full warmth the char is milk chocolate —
4339
- // closer to cardboard than to charcoal (§5). Burnt paper keeps a little
4340
- // warmth in the plates and reads near black in a frame with a fire in it.
4341
- charWarmth: 0.3,
4342
- // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
4343
- // at full strength the char reads as a mosaic rather than as broken plates.
4344
- charCracks: 0.55,
4345
- scorchReach: 30,
4346
- scorchDarkness: 1.17,
4347
- fingers: 1.25,
4348
- edgeWave: 8.5,
4349
- edgeBite: 3.6
4361
+ // As wide as the char band (Noor, 2026-09-13): the lip has to be seen. At
4362
+ // 0.95 mm it was a hairline tracing the edge.
4363
+ lipWidth: 3.5,
4364
+ charWidth: 3.5,
4365
+ lipBrightness: 0.76,
4366
+ charWarmth: 0.32,
4367
+ charCracks: 0.45,
4368
+ scorchReach: 9,
4369
+ scorchDarkness: 0.86,
4370
+ fingers: 1.95,
4371
+ edgeWave: 13,
4372
+ edgeBite: 6
4350
4373
  };
4351
4374
  var DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
4352
4375
 
4353
4376
  // src/physics/damage.ts
4354
- var { char: CHAR, saturation: SATURATION, presence: PRESENCE } = DAMAGE_CHANNELS;
4377
+ var { char: CHAR, saturation: SATURATION, heat: HEAT, presence: PRESENCE } = DAMAGE_CHANNELS;
4355
4378
  var GONE_BELOW = 128;
4356
4379
  var CHAR_SHRINK = 0.12;
4380
+ var ACROSS_SHRINK = 0.55;
4381
+ var UPDRAFT = 1.1;
4382
+ var UPDRAFT_OUT = 0.35;
4357
4383
  var CHAR_CURL = 6;
4358
4384
  var FRONT_GRADIENT = 16 * 16;
4359
4385
  var WET_MASS = 2;
@@ -4386,9 +4412,12 @@ var DamageCoupling = class {
4386
4412
  /** Union-find over the particles, reused on every read. */
4387
4413
  parent;
4388
4414
  /**
4389
- * Per bend spring, how squarely it lies ACROSS the burn front, 0..1 — and
4415
+ * Per spring, how squarely it lies ACROSS the burn front, 0..1 — and
4390
4416
  * remembered once it has been.
4391
4417
  *
4418
+ * It decides two things: how far the spring shrinks ({@link ACROSS_SHRINK})
4419
+ * and, for a bend spring, which way the paper rolls ({@link CHAR_CURL}).
4420
+ *
4392
4421
  * A burnt edge rolls about an axis along the front, like a scroll; it does
4393
4422
  * not dish into a bowl. Measured with the curl in every bend spring alike: a
4394
4423
  * burnt band settled into a saddle, its corners forward and its middle BACK
@@ -4401,7 +4430,7 @@ var DamageCoupling = class {
4401
4430
  * gone by, the char behind it is uniform and says nothing about direction,
4402
4431
  * but the paper stays rolled the way it rolled.
4403
4432
  */
4404
- curlWeight;
4433
+ acrossFront;
4405
4434
  /** The mesh's index as it was built — what {@link tear} hides triangles from and mends them back to. */
4406
4435
  original = null;
4407
4436
  /** Per triangle of that index: torn, and hidden until the history is rewound. */
@@ -4416,7 +4445,7 @@ var DamageCoupling = class {
4416
4445
  this.gone = new Uint8Array(sim.count);
4417
4446
  this.piece = new Int32Array(sim.count);
4418
4447
  this.parent = new Int32Array(sim.count);
4419
- this.curlWeight = new Float32Array(sim.constraintCount);
4448
+ this.acrossFront = new Float32Array(sim.constraintCount);
4420
4449
  }
4421
4450
  /**
4422
4451
  * Bring the sim's levers up to date with `source`. Call before stepping.
@@ -4428,7 +4457,7 @@ var DamageCoupling = class {
4428
4457
  const next = source ?? null;
4429
4458
  if (next === this.source && (next === null || next.version === this.version)) return false;
4430
4459
  if (next !== this.source) {
4431
- this.curlWeight.fill(0);
4460
+ this.acrossFront.fill(0);
4432
4461
  if (next && this.holed) {
4433
4462
  this.rewind();
4434
4463
  this.gone.fill(0);
@@ -4573,8 +4602,10 @@ var DamageCoupling = class {
4573
4602
  }
4574
4603
  }
4575
4604
  read(pixels) {
4576
- const { sim, gone, particleTexel, curlWeight, size } = this;
4605
+ const { sim, gone, particleTexel, acrossFront, size } = this;
4577
4606
  const {
4607
+ draught,
4608
+ cols,
4578
4609
  invMass,
4579
4610
  restLength,
4580
4611
  naturalLength,
@@ -4594,6 +4625,12 @@ var DamageCoupling = class {
4594
4625
  if (isGone) holed = true;
4595
4626
  gone[i] = isGone ? 1 : 0;
4596
4627
  invMass[i] = isGone ? 0 : 1 / (1 + WET_MASS * pixels[t + SATURATION] / 255);
4628
+ const heat = isGone ? 0 : pixels[t + HEAT] / 255;
4629
+ const lift = heat > 0.15 ? (heat - 0.15) / 0.85 : 0;
4630
+ const i3 = i * 3;
4631
+ draught[i3] = 0;
4632
+ draught[i3 + 1] = UPDRAFT * lift;
4633
+ draught[i3 + 2] = UPDRAFT * UPDRAFT_OUT * lift;
4597
4634
  }
4598
4635
  if (rewound) this.rewind();
4599
4636
  this.holed = holed;
@@ -4614,20 +4651,25 @@ var DamageCoupling = class {
4614
4651
  samples = 3;
4615
4652
  }
4616
4653
  const char = charSum / (samples * 255);
4617
- restLength[k2] = naturalLength[k2] * (1 - CHAR_SHRINK * char);
4654
+ const t = particleTexel[m >= 0 ? m : a];
4655
+ const x2 = t % size;
4656
+ const y2 = t / size | 0;
4657
+ const gx = pixels[(y2 * size + Math.min(size - 1, x2 + 2)) * 4 + CHAR] - pixels[(y2 * size + Math.max(0, x2 - 2)) * 4 + CHAR];
4658
+ const gy = pixels[(Math.min(size - 1, y2 + 2) * size + x2) * 4 + CHAR] - pixels[(Math.max(0, y2 - 2) * size + x2) * 4 + CHAR];
4659
+ const steep = gx * gx + gy * gy;
4660
+ if (steep > FRONT_GRADIENT) {
4661
+ const dc = b2 % cols - a % cols;
4662
+ const dr = (b2 / cols | 0) - (a / cols | 0);
4663
+ const along = dc * gx - dr * gy;
4664
+ const span = dc * dc + dr * dr || 1;
4665
+ const across2 = along * along / (span * steep);
4666
+ if (across2 > acrossFront[k2]) acrossFront[k2] = across2;
4667
+ }
4668
+ const across = acrossFront[k2];
4669
+ restLength[k2] = naturalLength[k2] * (1 - CHAR_SHRINK * char * (1 - (1 - ACROSS_SHRINK) * across));
4618
4670
  if (constraintKind[k2] === 2) {
4619
- const t = particleTexel[m];
4620
- const x2 = t % size;
4621
- const y2 = t / size | 0;
4622
- const gx = pixels[(y2 * size + Math.min(size - 1, x2 + 2)) * 4 + CHAR] - pixels[(y2 * size + Math.max(0, x2 - 2)) * 4 + CHAR];
4623
- const gy = pixels[(Math.min(size - 1, y2 + 2) * size + x2) * 4 + CHAR] - pixels[(Math.max(0, y2 - 2) * size + x2) * 4 + CHAR];
4624
- const steep = gx * gx + gy * gy;
4625
- if (steep > FRONT_GRADIENT) {
4626
- const across = (b2 - a === 2 ? gx * gx : gy * gy) / steep;
4627
- if (across > curlWeight[k2]) curlWeight[k2] = across;
4628
- }
4629
4671
  const span = naturalLength[k2];
4630
- restBend[k2] = CHAR_CURL * char * curlWeight[k2] * span * span / 8;
4672
+ restBend[k2] = CHAR_CURL * char * across * span * span / 8;
4631
4673
  }
4632
4674
  }
4633
4675
  this.findPieces();
@@ -4712,19 +4754,20 @@ var DamageCoupling = class {
4712
4754
  */
4713
4755
  rewind() {
4714
4756
  this.sim.restore();
4715
- this.curlWeight.fill(0);
4757
+ this.acrossFront.fill(0);
4716
4758
  this.torn.fill(0);
4717
4759
  this.mend = true;
4718
4760
  }
4719
4761
  reset() {
4720
4762
  const { sim } = this;
4721
4763
  if (this.holed) this.rewind();
4764
+ sim.draught.fill(0);
4722
4765
  sim.invMass.fill(1);
4723
4766
  sim.restBend.fill(0);
4724
4767
  sim.broken.fill(0);
4725
4768
  sim.restLength.set(sim.naturalLength);
4726
4769
  this.gone.fill(0);
4727
- this.curlWeight.fill(0);
4770
+ this.acrossFront.fill(0);
4728
4771
  this.followers = [];
4729
4772
  this.holed = false;
4730
4773
  this.version = -1;
@@ -6098,6 +6141,82 @@ function p({
6098
6141
  }
6099
6142
  var j2 = r.forwardRef(p);
6100
6143
 
6144
+ // src/surface/cutDistance.ts
6145
+ var CUT_REACH_MM = 16;
6146
+ var SPECK_TEXELS = 3;
6147
+ var GONE_BELOW2 = 128;
6148
+ var scratch = null;
6149
+ function cutDistance(pixels, size, texelMm, out) {
6150
+ const n = size * size;
6151
+ if (!scratch || scratch.size !== size) {
6152
+ scratch = { size, dist: new Float32Array(n), kept: new Uint8Array(n), stack: new Int32Array(n) };
6153
+ }
6154
+ const { dist, kept, stack } = scratch;
6155
+ const gone = (i) => pixels[i * 4 + 3] < GONE_BELOW2;
6156
+ kept.fill(0);
6157
+ const seen = dist;
6158
+ seen.fill(0);
6159
+ for (let start = 0; start < n; start++) {
6160
+ if (seen[start] || !gone(start)) continue;
6161
+ let top = 0;
6162
+ let count = 0;
6163
+ stack[top++] = start;
6164
+ seen[start] = 1;
6165
+ const first = top;
6166
+ let head = 0;
6167
+ while (head < top) {
6168
+ const i = stack[head++];
6169
+ count++;
6170
+ const x2 = i % size;
6171
+ const y2 = i / size | 0;
6172
+ const next = [
6173
+ x2 > 0 ? i - 1 : -1,
6174
+ x2 < size - 1 ? i + 1 : -1,
6175
+ y2 > 0 ? i - size : -1,
6176
+ y2 < size - 1 ? i + size : -1
6177
+ ];
6178
+ for (const j3 of next) {
6179
+ if (j3 < 0 || seen[j3] || !gone(j3)) continue;
6180
+ seen[j3] = 1;
6181
+ stack[top++] = j3;
6182
+ }
6183
+ }
6184
+ if (count > SPECK_TEXELS) for (let k2 = first - 1; k2 < top; k2++) kept[stack[k2]] = 1;
6185
+ }
6186
+ const [dx, dy] = texelMm;
6187
+ const dd = Math.hypot(dx, dy);
6188
+ for (let i = 0; i < n; i++) dist[i] = kept[i] ? 0 : Number.POSITIVE_INFINITY;
6189
+ for (let y2 = 0; y2 < size; y2++) {
6190
+ for (let x2 = 0; x2 < size; x2++) {
6191
+ const i = y2 * size + x2;
6192
+ let v2 = dist[i];
6193
+ if (v2 === 0) continue;
6194
+ if (x2 > 0) v2 = Math.min(v2, dist[i - 1] + dx);
6195
+ if (y2 > 0) {
6196
+ v2 = Math.min(v2, dist[i - size] + dy);
6197
+ if (x2 > 0) v2 = Math.min(v2, dist[i - size - 1] + dd);
6198
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i - size + 1] + dd);
6199
+ }
6200
+ dist[i] = v2;
6201
+ }
6202
+ }
6203
+ for (let y2 = size - 1; y2 >= 0; y2--) {
6204
+ for (let x2 = size - 1; x2 >= 0; x2--) {
6205
+ const i = y2 * size + x2;
6206
+ let v2 = dist[i];
6207
+ if (v2 === 0) continue;
6208
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i + 1] + dx);
6209
+ if (y2 < size - 1) {
6210
+ v2 = Math.min(v2, dist[i + size] + dy);
6211
+ if (x2 < size - 1) v2 = Math.min(v2, dist[i + size + 1] + dd);
6212
+ if (x2 > 0) v2 = Math.min(v2, dist[i + size - 1] + dd);
6213
+ }
6214
+ dist[i] = v2;
6215
+ }
6216
+ }
6217
+ for (let i = 0; i < n; i++) out[i] = Math.round(Math.min(dist[i], CUT_REACH_MM) / CUT_REACH_MM * 255);
6218
+ }
6219
+
6101
6220
  // src/surface/compose.ts
6102
6221
  var THREE5 = __toESM(require("three"), 1);
6103
6222
 
@@ -6727,7 +6846,7 @@ var DAMAGE_CHUNK = (
6727
6846
  `
6728
6847
  uniform sampler2D uDamage;
6729
6848
  uniform float uDamageDetail;
6730
- // (edgeWave mm, edgeBite mm, sparkle, \u2013) \u2014 see DamageLook.
6849
+ // (edgeWave mm, edgeBite mm, sparkle, charWidth mm) \u2014 see DamageLook.
6731
6850
  uniform vec4 uLook3;
6732
6851
 
6733
6852
  // The damage grid's texels sit ON the sheet \u2014 texel x at u = x / (N - 1), its
@@ -6864,6 +6983,8 @@ var DAMAGE_SHADE_CHUNK = (
6864
6983
  /* glsl */
6865
6984
  `
6866
6985
  uniform float uDamageTime;
6986
+ // How far each texel is from the cut, as a fraction of PL_CUT_REACH \u2014 see cutDistance.
6987
+ uniform sampler2D uDamageEdge;
6867
6988
  // The look, packed \u2014 see DamageLook for what each number means.
6868
6989
  uniform vec4 uLook0; // emberWidth mm, emberIntensity, emberCoverage, emberFlicker
6869
6990
  uniform vec4 uLook1; // emberGlow, lipWidth mm, lipBrightness, charWarmth
@@ -6873,6 +6994,10 @@ vec3 plDamageLight;
6873
6994
 
6874
6995
  // World units per millimetre: a default sheet is one unit across, and A4 is 210 mm.
6875
6996
  const float PL_MM = 1.0 / 210.0;
6997
+ // How far out a burnt edge's zones can be measured from the cut, mm: past
6998
+ // the widest lip, ember line, char and scorch the look allows by default.
6999
+ // The same number cutDistance encodes with, so the two cannot drift.
7000
+ const float PL_CUT_REACH = ${CUT_REACH_MM.toFixed(1)};
6876
7001
 
6877
7002
  vec3 plLinear(vec3 srgb) {
6878
7003
  return pow(srgb, vec3(2.2));
@@ -6929,7 +7054,12 @@ vec3 plScorchTint(float t) {
6929
7054
  vec3 c3 = plLinear(vec3(0.541, 0.310, 0.141)); // #8A4F24
6930
7055
  vec3 c4 = plLinear(vec3(0.471, 0.275, 0.149)); // #784626
6931
7056
  vec3 c5 = plLinear(vec3(0.380, 0.216, 0.133)); // #613722
6932
- vec3 c6 = plLinear(vec3(0.310, 0.200, 0.137)); // #4F3323
7057
+ // The dark end hands over to the char's black: a nearly neutral umber, not
7058
+ // the saturated brown (#4F3323) it used to stop at. Beside black char that
7059
+ // brown showed through wherever the band thinned, and turned the black
7060
+ // brown \u2014 measured, the cold char's saturation went from 0.43 to 0.11 with
7061
+ // the scorch's darkening switched off.
7062
+ vec3 c6 = plLinear(vec3(0.169, 0.133, 0.110)); // #2B221C
6933
7063
  float s = clamp(t, 0.0, 1.0) * 6.0;
6934
7064
  // Each stop is blended with a SMOOTH step, not a linear one.
6935
7065
  //
@@ -6980,32 +7110,6 @@ void plDamage(inout vec4 color, inout float roughness) {
6980
7110
  vec2 perWorld = (vec2(textureSize(uDamage, 0)) - 1.0) / uSheetSize;
6981
7111
  vec2 gradP = vec2(e.a - w.a, n.a - s.a) * 0.5 * perWorld;
6982
7112
  vec2 gradC = vec2(e.r - w.r, n.r - s.r) * 0.5 * perWorld;
6983
- // Millimetres from the cut, on the paper side \u2014 measured against the field
6984
- // the cut is DRAWN from (softened, frayed, warped), through its own screen
6985
- // derivatives. It used to divide by the slope of the raw texels, and since
6986
- // paper burns through within a texel that slope is a cliff while the drawn
6987
- // edge is spread over millimetres: a point well into the char computed as a
6988
- // hair from the cut, and the ember line drew there \u2014 found by the gate.
6989
- // The slope, though, is the SMOOTH presence's. The fray's fibre noise
6990
- // changes pixel to pixel, and its slope is a cliff everywhere it touches:
6991
- // divided by that, a point deep in the char came out a hair from the cut
6992
- // and the ember glow drew there \u2014 the gate's stray pixels. The fray still
6993
- // moves where the edge is; it just cannot fake how steep it is.
6994
- float smoothA = plDamageSmooth;
6995
- vec2 cutSlope = vec2(dFdx(smoothA), dFdy(smoothA));
6996
- float pxFromCut = (d.a - 0.5) / max(length(cutSlope), 1e-4);
6997
- // Millimetres per pixel ALONG the way to the cut, not across the screen's
6998
- // x: a sheet leaning away is foreshortened vertically, so above and below a
6999
- // hole a pixel covers more paper than beside it, and a scale taken from x
7000
- // alone drew the ember zone too many pixels deep on the top and bottom rims.
7001
- vec2 toCutPx = cutSlope / max(length(cutSlope), 1e-6);
7002
- float mmPerPx = length(dFdx(-vViewPosition) * toCutPx.x + dFdy(-vViewPosition) * toCutPx.y) / PL_MM;
7003
- float mm = pxFromCut * mmPerPx;
7004
- // About a pixel of antialiasing, never more: where the warp makes the
7005
- // estimate jump between neighbours, fwidth(mm) is millimetres wide, and the
7006
- // ember's soft tail \u2014 faint, but multiplied by its light \u2014 drew stray glow
7007
- // well out into the char.
7008
- float aa = min(fwidth(mm), 1.5 * mmPerPx);
7009
7113
  // Scorch reaches ahead of the char, and further UP than anywhere else:
7010
7114
  // the char a centimetre below this point, and the char a few millimetres
7011
7115
  // around it, each lent to the scorch at a discount (\xA74.2, \xA74.5).
@@ -7019,38 +7123,61 @@ void plDamage(inout vec4 color, inout float roughness) {
7019
7123
  charWide += texture2D(uDamage, uv + vec2(cos(a), sin(a)) * 7.0 * mmUv).r;
7020
7124
  }
7021
7125
  charWide /= 8.0;
7022
- // Only paper that has a CUT beside it has an edge: a half-eaten cell out
7023
- // in the middle of the sheet reads as "half a presence" too, and without
7024
- // this the ember line and the ash lip drew on it \u2014 islands of glow on
7025
- // paper, the painted glow by another route.
7026
- // Eased in, not switched: a hard threshold on the neighbouring texels drew
7027
- // the grid back into the ember glow as small squares.
7028
- float cutNear = 1.0 - smoothstep(0.3, 0.62, min(min(e.a, w.a), min(n.a, s.a)));
7029
- // And the cut has to really be there. \`mm\` extrapolates this pixel's
7030
- // slope to half presence, and the uneven edge's warp bends the field inside
7031
- // those few millimetres \u2014 measured: with the warp off the stray ember light
7032
- // the gate finds all but vanishes. So step across the PAPER toward where the
7033
- // cut should be, half a millimetre past it but never beyond the ember
7034
- // zone's own outer edge (the lip and the widest bead \u2014 2.5 mm at the
7035
- // spec's numbers, \xA75) plus that half, and read what the cut is drawn from.
7036
- // Still paper: this is not an edge, however close the slope said it was.
7037
- vec2 toCutPaper = -(dFdx(vPaperUv) * toCutPx.x + dFdy(vPaperUv) * toCutPx.y);
7038
- float reachPx = min(max(mm, 0.0) + 0.5, uLook1.y + uLook0.x + 0.5) / max(mmPerPx, 1e-6);
7039
- float pastA = plDrawnPresenceAt(vPaperUv + toCutPaper * reachPx);
7040
- // And a hole, not a pinhole: the fray opens specks of void finer than a
7041
- // pixel in part-burnt paper, which the check above would take for the
7042
- // edge. The void has to go on for another half millimetre.
7043
- float beyondA = plDrawnPresenceAt(vPaperUv + toCutPaper * (reachPx + 0.5 / max(mmPerPx, 1e-6)));
7044
- cutNear *= (1.0 - smoothstep(0.42, 0.55, pastA)) * (1.0 - smoothstep(0.42, 0.55, beyondA));
7126
+ // \u2500\u2500 The cut, measured \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7127
+ // How far this point is from the cut, in millimetres, out to PL_CUT_REACH \u2014
7128
+ // the one measure every zone of a burnt edge is laid out on: the ash lip,
7129
+ // the ember line, the char and the scorch (Ember_line annotated). Worked
7130
+ // out on the CPU once per change of the field (cutDistance), where a speck
7131
+ // of burnt-through paper can be told from a cut: measured here per pixel,
7132
+ // every speck near the rim grew its own lip, char and scorch, and the bands
7133
+ // came apart into blotches.
7134
+ //
7135
+ // Burnt AWAY, not charred: the black and the lip belong to paper beside a
7136
+ // cut, so a burn toasts before it blackens.
7137
+ //
7138
+ // Read through the same warp as the cut, so the zones follow the edge that
7139
+ // is drawn. Bilinear between a gone texel and the paper beside it, the
7140
+ // drawn edge reads half a texel, which comes back off.
7141
+ vec2 edgeTexel = 1.0 / vec2(textureSize(uDamageEdge, 0));
7142
+ vec2 edgeWorld = uSheetSize / (vec2(textureSize(uDamageEdge, 0)) - 1.0);
7143
+ float edgeRaw = texture2D(uDamageEdge, uv).r;
7144
+ float edgeHalf = 0.5 * min(edgeWorld.x, edgeWorld.y) / PL_MM;
7145
+ float xCut = max(edgeRaw * PL_CUT_REACH - edgeHalf, 0.0);
7146
+ // A pixel or so of antialiasing, measured on the same measure.
7147
+ float aa = clamp(fwidth(xCut), 0.05, 1.0);
7148
+ // Which way the cut lies, from how the distance falls \u2014 and along the edge,
7149
+ // across that. In the sheet's own space, so noise laid along it follows the
7150
+ // rim wherever the rim goes.
7151
+ vec2 toward = -vec2(
7152
+ (texture2D(uDamageEdge, uv + vec2(edgeTexel.x, 0.0)).r - texture2D(uDamageEdge, uv - vec2(edgeTexel.x, 0.0)).r) / edgeWorld.x,
7153
+ (texture2D(uDamageEdge, uv + vec2(0.0, edgeTexel.y)).r - texture2D(uDamageEdge, uv - vec2(0.0, edgeTexel.y)).r) / edgeWorld.y
7154
+ );
7155
+ vec2 tangentR = length(toward) > 1e-4 ? normalize(vec2(-toward.y, toward.x)) : vec2(1.0, 0.0);
7156
+ // Paper with a cut within reach, eased out toward the reach.
7157
+ float cutZone = 1.0 - smoothstep(PL_CUT_REACH - 2.0, PL_CUT_REACH - 0.5, xCut);
7045
7158
 
7046
7159
  // An untouched texel is left exactly as it was \u2014 the identity the whole
7047
7160
  // seam is built on. Nothing here has happened to this paper.
7048
- if (d.r <= 0.0 && d.g <= 0.0 && d.a >= 1.0 && charBelow <= 0.0 && charWide <= 0.0 && e.a >= 1.0 && w.a >= 1.0 && n.a >= 1.0 && s.a >= 1.0) {
7161
+ if (d.r <= 0.0 && d.g <= 0.0 && d.a >= 1.0 && charBelow <= 0.0 && charWide <= 0.0 && edgeRaw >= 1.0 && e.a >= 1.0 && w.a >= 1.0 && n.a >= 1.0 && s.a >= 1.0) {
7049
7162
  return;
7050
7163
  }
7051
7164
 
7052
7165
  vec2 p = plLocal();
7053
7166
 
7167
+ // The zones, out from the cut: the ash lip, the ember line on its outer
7168
+ // edge, the char, then the scorch \u2014 each as wide as the look says, and
7169
+ // uneven along the rim so none of them is a ribbon. The lip's unevenness
7170
+ // stays inside the zone the ember gate allows it (lip plus bead).
7171
+ float lipEdge = uLook1.y * mix(0.7, 1.1, plFbm(vec2(dot(p, tangentR) / (5.0 * PL_MM), 1.7)));
7172
+ float charEnd = lipEdge + uLook0.x + uLook3.w * mix(0.8, 1.2, plFbm(vec2(dot(p, tangentR) / (7.0 * PL_MM), 4.3)));
7173
+ // Further above the burn than below it \u2014 hot gas rises and cooks the paper
7174
+ // over it (\xA74.5) \u2014 out to the scorch's full reach straight up, and about
7175
+ // half of it below and beside.
7176
+ vec2 upMm = normalize(upUv * uSheetSize);
7177
+ float above = length(toward) > 1e-3 ? max(0.0, dot(-normalize(toward), upMm)) : 0.0;
7178
+ float scorchEnd = charEnd + uLook2.y * mix(0.45, 1.0, above)
7179
+ + (plFbm(vec2(dot(p, tangentR) / (4.0 * PL_MM), 3.1)) - 0.5) * uLook2.w * 2.5;
7180
+
7054
7181
  // Wet paper is darker and smoother: water fills the gaps between fibres
7055
7182
  // that scatter light, which is both effects from one cause.
7056
7183
  color.rgb *= 1.0 - 0.38 * d.g;
@@ -7102,27 +7229,64 @@ void plDamage(inout vec4 color, inout float roughness) {
7102
7229
  // being finer than a level it can only ever move a value into the
7103
7230
  // neighbouring one.
7104
7231
  c = clamp(c + (plNoise(p * 900.0) - 0.5) * (1.5 / 255.0), 0.0, 1.0);
7232
+ // The colour INSIDE the scorch reads a smoothed c: the same char and the
7233
+ // same reaches, without the fingers. The fingers belong to the front's
7234
+ // silhouette (lead, below); carried into the ramp as well, a strong fingers
7235
+ // setting swung the scorch between tan and near-black umber a few
7236
+ // millimetres apart \u2014 a leopard print, not a toast.
7237
+ float cSmooth = 1.0 - (1.0 - clamp(mix(d.r, soft, 0.65), 0.0, 1.0)) * (1.0 - clamp(0.62 * charBelow * 0.75, 0.0, 1.0)) * (1.0 - clamp(0.4 * charWide * 0.75, 0.0, 1.0));
7238
+ cSmooth = clamp(cSmooth + ((plNoise(p * 300.0) - 0.5) * 0.05 + (plNoise(p * 90.0) - 0.5) * 0.04) * smoothstep(0.0, 0.1, cSmooth), 0.0, 1.0);
7105
7239
  // A steep leading edge \u2014 paper to straw in a sliver \u2014 then the long ramp.
7106
- float lead = smoothstep(0.02, 0.05, c);
7107
- float ramp = smoothstep(0.05, 0.7, c);
7108
- color.rgb *= mix(vec3(1.0), pow(plScorchTint(ramp), vec3(uLook2.z)), lead);
7240
+ // The front's teeth from the noisy c; filled in behind them from the smooth
7241
+ // one, so a dip in the noise cannot open a patch of clean paper inside it.
7242
+ float lead = max(smoothstep(0.02, 0.05, c), smoothstep(0.1, 0.2, cSmooth));
7243
+ // The browns are done by 0.45, where the char's umber hand-over takes them:
7244
+ // the ramp used to run on to 0.7, over the char, and most of what read as
7245
+ // "char" was this ramp's darkest brown.
7246
+ float ramp = smoothstep(0.05, 0.45, cSmooth);
7247
+ // That toast is for paper with no cut near it \u2014 the first stage of a burn,
7248
+ // before anything has gone through.
7249
+ color.rgb *= mix(vec3(1.0), pow(plScorchTint(ramp), vec3(uLook2.z)), lead * (1.0 - cutZone));
7250
+ // Round a cut, the scorch is a band of its own: dark, only a little lighter
7251
+ // than the char it leads from, a shade warmer toward its outer edge, then a
7252
+ // steep leading edge into clean paper (Ember_line annotated, zone 5: "steep
7253
+ // leading edge \xB7 albedo only"). A black band beside a pale tan one was the
7254
+ // contrast that read as wrong.
7255
+ float scorchT = smoothstep(charEnd, scorchEnd, xCut);
7256
+ vec3 scorchTint = mix(plLinear(vec3(0.24, 0.17, 0.12)), plLinear(vec3(0.42, 0.28, 0.17)), scorchT) / plLinear(vec3(0.90, 0.885, 0.855));
7257
+ float scorchZone = (1.0 - smoothstep(scorchEnd - 0.35, scorchEnd + 0.35, xCut)) * cutZone;
7258
+ color.rgb *= mix(vec3(1.0), pow(min(scorchTint, vec3(1.0)), vec3(uLook2.z)), scorchZone);
7109
7259
 
7110
7260
  // \u2500\u2500 Char \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7111
7261
  // A wide, smooth hand-over from scorch to char \u2014 the gradient is the point.
7112
- float charZone = smoothstep(0.5, 0.9, c);
7262
+ // From 0.3: the field carries char in about one texel beside the cut, so
7263
+ // past a couple of millimetres c is mostly the scorch borrowed from below,
7264
+ // and a zone that began at 0.5 was hardly ever char at all.
7265
+ // Away from a cut, c only makes char where it is really high: at 0.3\u20130.6
7266
+ // the finger noise pushed it across the line all through the scorch, and
7267
+ // every crossing was a black island in the orange \u2014 a leopard print.
7268
+ // Round a cut, the band from the lip out to charEnd; away from one, only
7269
+ // where the toast has gone all the way to char.
7270
+ float charBand = (1.0 - smoothstep(charEnd - 0.5, charEnd + 0.5, xCut)) * cutZone;
7271
+ float charZone = max(smoothstep(0.6, 0.85, cSmooth) * (1.0 - cutZone), charBand);
7113
7272
  vec2 cells = plVoronoi(p / (3.6 * PL_MM));
7114
7273
  // Sparse: a real crack network is broken, not a tiled floor \u2014 most cell
7115
7274
  // borders never split.
7116
7275
  float crack = (1.0 - smoothstep(0.008, 0.03, cells.y - cells.x)) * smoothstep(0.45, 0.65, plNoise(p / (7.0 * PL_MM)));
7117
7276
  float plate = plHash(floor(p / (3.6 * PL_MM)) + 3.7);
7118
- // Dark orange where the char meets the scorch, deep brown toward the cut \u2014
7119
- // warm all the way, and fading smoothly into the scorch's lighter browns
7120
- // rather than stopping at a grey band.
7121
- // Warm (dark orange \u2192 deep brown) or, turned down, the sampled greys.
7122
- vec3 body = mix(plLinear(vec3(0.255, 0.239, 0.227)), plLinear(vec3(0.165, 0.086, 0.043)), uLook1.w);
7123
- vec3 under = mix(plLinear(vec3(0.275, 0.169, 0.090)), plLinear(vec3(0.42, 0.18, 0.06)), uLook1.w);
7277
+ // Black. Real char is a near-neutral black \u2014 (22, 20, 18) on the cold
7278
+ // macro references, about 11% of the paper's value. The warm brown this
7279
+ // used to be was Hero.png's char LIT BY ITS FLAMES, painted into the paper,
7280
+ // which is why ours stayed orange on a frame with no fire in it. Warmth on
7281
+ // char comes from the fire's light now, and goes when the fire does.
7282
+ // charWarmth adds only a trace of brown to the black, and a warmer umber
7283
+ // where the char hands over to the scorch.
7284
+ vec3 body = mix(plLinear(vec3(0.086, 0.078, 0.071)), plLinear(vec3(0.11, 0.08, 0.06)), uLook1.w);
7285
+ vec3 under = mix(plLinear(vec3(0.20, 0.15, 0.11)), plLinear(vec3(0.28, 0.16, 0.08)), uLook1.w);
7124
7286
  vec3 cracks = plLinear(vec3(0.039, 0.024, 0.016)); // #0A0604
7125
- vec3 charColor = mix(under, body, smoothstep(0.66, 1.0, c));
7287
+ // Black through the band; the umber only in its outer millimetre.
7288
+ float toUnder = mix(1.0 - smoothstep(0.45, 0.8, cSmooth), smoothstep(charEnd - 1.2, charEnd + 0.3, xCut), cutZone);
7289
+ vec3 charColor = mix(body, under, toUnder);
7126
7290
  // Mottled at the plate scale and finer, and darker than the sampled body
7127
7291
  // under a bright key \u2014 the reference's char reads near black in the frame.
7128
7292
  charColor *= (0.75 + 0.35 * plate) * (0.8 + 0.4 * plNoise(p * 260.0));
@@ -7134,42 +7298,42 @@ void plDamage(inout vec4 color, inout float roughness) {
7134
7298
  plHeight += charZone * ((plNoise(p * 380.0) - 0.5) * 0.00008 + (plate - 0.5) * 0.00018 - crack * 0.00016);
7135
7299
 
7136
7300
  // \u2500\u2500 Ash lip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7137
- // Inside the spec's zones (\xA75): the lip 0.5\u20131.5 mm, then the ember line
7138
- // 0.3\u20131 mm \u2014 2.5 mm from the cut at most, which the gate holds it to.
7139
- // Along the rim. Both the lip and the ember line below are laid out on this,
7140
- // and unlike the scorch fingers above it is safe to sample noise in: it is
7141
- // used ONLY within a couple of millimetres of the cut, where the presence
7142
- // gradient is the actual edge and points somewhere real. The fingers'
7143
- // mistake was carrying the same idea out into flat char, where the gradient
7144
- // is rounding error and a noise sampled in its frame draws the contours.
7145
- vec2 tangentP = length(gradP) > 1e-4 ? normalize(vec2(-gradP.y, gradP.x)) : vec2(1.0, 0.0);
7146
-
7147
- float lipWidth = mix(0.4, max(uLook1.y, 0.41), plFbm(p / (2.5 * PL_MM)));
7148
- // Broken, not a hem. Ash flakes off a cooling edge in pieces, so the lip
7149
- // comes and goes along the rim and wanders in width where it is there. It
7150
- // used to be a continuous band of even width with a highlight down the
7151
- // middle of it, which read as a neon tube round the hole \u2014 the single most
7152
- // synthetic thing at wide view.
7153
- float lipAlong = dot(p, tangentP) / (3.2 * PL_MM);
7154
- float lipBreak = smoothstep(0.34, 0.56, plFbm(vec2(lipAlong, 0.9)));
7155
- float lip = (1.0 - smoothstep(lipWidth - aa, lipWidth + aa, mm)) * step(d.a, 0.999) * cutNear * lipBreak;
7156
- vec3 ash = plLinear(mix(vec3(0.525, 0.498, 0.490), vec3(0.741, 0.725, 0.725), plNoise(p * 520.0) * 0.6 + plNoise(p * 90.0) * 0.4)) * uLook1.z;
7301
+ // A crust as wide as the char band, from the cut to lipEdge \u2014 the pale
7302
+ // thing that makes a hole read as a hole on a black stage (Ember_line
7303
+ // annotated, zone 2). It used to be held to about a millimetre, which at a
7304
+ // reading distance is a hairline tracing the edge, not ash.
7305
+ //
7306
+ // Broken, not a hem: ash flakes off a cooling edge in pieces, so the lip
7307
+ // comes and goes along the rim and wanders in width. Mostly there, though \u2014
7308
+ // it has to be seen.
7309
+ float lipAlong = dot(p, tangentR) / (3.2 * PL_MM);
7310
+ float lipBreak = smoothstep(0.1, 0.3, plFbm(vec2(lipAlong, 0.9)));
7311
+ float lip = (1.0 - smoothstep(lipEdge - aa, lipEdge + aa, xCut)) * cutZone * lipBreak;
7312
+ // Pale: the reference's #A49E9D up to a near-white grey, so it reads white
7313
+ // beside the char. Still matte and still dimmer than the paper.
7314
+ vec3 ash = plLinear(mix(vec3(0.643, 0.620, 0.616), vec3(0.84, 0.82, 0.81), plNoise(p * 520.0) * 0.6 + plNoise(p * 90.0) * 0.4)) * uLook1.z;
7157
7315
  color.rgb = mix(color.rgb, ash, lip);
7158
7316
  roughness = mix(roughness, 0.99, lip);
7159
- // Barely raised. THIS is where the highlight came from: a 0.7 mm ridge
7160
- // running round the hole is a cylinder, and a cylinder under a key light
7161
- // has a specular line down it however rough the surface is. Ash is a
7162
- // fragile crust a fraction of a millimetre proud of the paper, not a bead
7163
- // of solder \u2014 and the shape of it belongs to the broken edge above, not to
7164
- // a smooth ramp.
7165
- plHeight += lip * (1.0 - mm / max(lipWidth, 0.1)) * 0.00018;
7317
+ // And the crust the ragged edge is made of. The cut is frayed at fibre
7318
+ // scale, and the fray opened specks of void all through the first few
7319
+ // millimetres \u2014 exactly where the lip lies \u2014 so at a reading distance the
7320
+ // lip was grey specks in black. Paper inside the fray's band is kept, as
7321
+ // ash, instead of cut away: the lip overhangs the hole a little, broken
7322
+ // where the lip is broken, the way the reference's crust does.
7323
+ float crust = lipBreak * cutZone * smoothstep(0.18, 0.3, d.a) * (1.0 - step(0.5, d.a));
7324
+ color.rgb = mix(color.rgb, ash, crust);
7325
+ roughness = mix(roughness, 0.99, crust);
7326
+ // Raised, and crumbly rather than rounded: a smooth ridge round the hole is
7327
+ // a cylinder, and a cylinder under a key light has a specular line down it
7328
+ // however rough it is \u2014 the neon tube this once was.
7329
+ plHeight += lip * (0.55 + 0.45 * plNoise(p * 700.0)) * 0.00016;
7166
7330
 
7167
7331
  // \u2500\u2500 The ember line \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7168
7332
  // Beads along the cut: position along the edge, crawling on the burn's
7169
7333
  // clock, with a flicker of their own. Only where it is hot \u2014 and as the
7170
7334
  // heat goes the threshold rises, so the line breaks into fewer, dimmer
7171
7335
  // beads and they go out one by one.
7172
- float s1 = dot(p, tangentP) / (2.4 * PL_MM) - uDamageTime * 0.55 * uLook0.w;
7336
+ float s1 = dot(p, tangentR) / (2.4 * PL_MM) - uDamageTime * 0.55 * uLook0.w;
7173
7337
  float bead = plNoise(vec2(s1, 0.37)) * 0.7 + plNoise(vec2(s1 * 2.3, 5.1)) * 0.3;
7174
7338
  float flicker = 0.62 + 0.38 * plNoise(vec2(floor(s1) * 7.13, uDamageTime * 9.0 * uLook0.w));
7175
7339
  // Down to a smoulder's last flicker: a spent edge keeps a little heat in
@@ -7184,7 +7348,8 @@ void plDamage(inout vec4 color, inout float roughness) {
7184
7348
  float lit = smoothstep(mix(0.82, 0.45, hot) - shift, mix(0.9, 0.58, hot) - shift, bead);
7185
7349
  // Each bead tapers at its ends \u2014 a bead, not a dash cut from a strip.
7186
7350
  float width = max(mix(0.4, max(uLook0.x, 0.41), plNoise(vec2(s1 * 0.7, 9.0))) * (0.35 + 0.65 * lit), 1.4 * aa);
7187
- float band = smoothstep(lipWidth - aa, lipWidth + aa, mm) * (1.0 - smoothstep(lipWidth + width - aa, lipWidth + width + aa, mm)) * cutNear;
7351
+ // On the lip's outer edge, between the ash and the char.
7352
+ float band = smoothstep(lipEdge - aa, lipEdge + aa, xCut) * (1.0 - smoothstep(lipEdge + width - aa, lipEdge + width + aa, xCut)) * cutZone;
7188
7353
  // Mostly orange: a bead is red at its ends and yellow-white only at its
7189
7354
  // hottest core, which is what Ember_line.png shows. Mapped low and pushed
7190
7355
  // high by a power, so white is the exception rather than the line.
@@ -7204,13 +7369,15 @@ void plDamage(inout vec4 color, inout float roughness) {
7204
7369
  // edge is hot, and inside the zone the beads are held to \u2014 the lip plus
7205
7370
  // the widest bead, measured from the cut \u2014 so the glow never reaches out
7206
7371
  // over paper the gate says heat must not light.
7207
- float zoneEnd = uLook1.y + uLook0.x;
7208
- float seam = smoothstep(lipWidth - aa, lipWidth + aa, mm) * (1.0 - smoothstep(zoneEnd - 0.6, zoneEnd, mm)) * cutNear;
7372
+ float zoneEnd = lipEdge + uLook0.x;
7373
+ float seam = smoothstep(lipEdge - aa, lipEdge + aa, xCut) * (1.0 - smoothstep(zoneEnd - 0.6, zoneEnd, xCut)) * cutZone;
7209
7374
  float patch_ = smoothstep(0.35, 0.72, plFbm(p / (4.0 * PL_MM) + vec2(uDamageTime * 0.15 * uLook0.w, 0.0)));
7210
7375
  plDamageLight += vec3(0.55, 0.06, 0.01) * seam * patch_ * hot * uLook1.x;
7211
7376
  float speck = step(0.985, plHash(floor(p / (0.35 * PL_MM)) + floor(uDamageTime * 8.0 * uLook0.w)));
7212
7377
  plDamageLight += plEmberRamp(0.9) * 1.6 * speck * seam * hot * uLook3.z;
7213
7378
 
7379
+ // The crust stays, however the fray cut it.
7380
+ d.a = mix(d.a, max(d.a, 0.5), step(0.5, crust));
7214
7381
  plDamageCut(color, d);
7215
7382
  }
7216
7383
  `
@@ -7321,6 +7488,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7321
7488
  chunks.push(DAMAGE_CHUNK, DAMAGE_SHADE_CHUNK);
7322
7489
  calls.push("plDamage(csm_DiffuseColor, csm_Roughness);");
7323
7490
  uniforms.uDamage = { value: null };
7491
+ uniforms.uDamageEdge = { value: null };
7324
7492
  uniforms.uDamageDetail = { value: 1 };
7325
7493
  uniforms.uDamageTime = { value: 0 };
7326
7494
  const look = DAMAGE_LOOK_DEFAULTS;
@@ -7333,7 +7501,7 @@ function composeSurface(surface, stock, thickness, maps = { hasFrontMap: false,
7333
7501
  uniforms.uLook2 = {
7334
7502
  value: new THREE5.Vector4(look.charCracks, look.scorchReach, look.scorchDarkness, look.fingers)
7335
7503
  };
7336
- uniforms.uLook3 = { value: new THREE5.Vector4(look.edgeWave, look.edgeBite, look.sparkle, 0) };
7504
+ uniforms.uLook3 = { value: new THREE5.Vector4(look.edgeWave, look.edgeBite, look.sparkle, look.charWidth) };
7337
7505
  }
7338
7506
  const cutting = [];
7339
7507
  const cuts = [];
@@ -7437,35 +7605,65 @@ function useLightRig(own) {
7437
7605
  var THREE6 = __toESM(require("three"), 1);
7438
7606
  var import_fiber = require("@react-three/fiber");
7439
7607
  var import_react4 = require("react");
7440
- function useDamageTexture(source) {
7441
- const texture = (0, import_react4.useMemo)(() => {
7608
+ var MM_PER_WORLD = 210;
7609
+ function useDamageTexture(source, sheet2 = { width: 1, height: 1.4 }) {
7610
+ const { width, height } = sheet2;
7611
+ const textures = (0, import_react4.useMemo)(() => {
7442
7612
  if (!source) return null;
7443
- const t = new THREE6.DataTexture(
7613
+ const damage = new THREE6.DataTexture(
7444
7614
  source.pixels,
7445
7615
  source.size,
7446
7616
  source.size,
7447
7617
  THREE6.RGBAFormat,
7448
7618
  THREE6.UnsignedByteType
7449
7619
  );
7450
- t.magFilter = THREE6.LinearFilter;
7451
- t.minFilter = THREE6.LinearFilter;
7452
- t.wrapS = THREE6.ClampToEdgeWrapping;
7453
- t.wrapT = THREE6.ClampToEdgeWrapping;
7454
- t.generateMipmaps = false;
7455
- t.colorSpace = THREE6.NoColorSpace;
7456
- t.flipY = false;
7457
- t.userData.version = source.version;
7458
- t.needsUpdate = true;
7459
- return t;
7460
- }, [source]);
7461
- (0, import_react4.useEffect)(() => () => texture?.dispose(), [texture]);
7620
+ const edge = new THREE6.DataTexture(
7621
+ new Uint8Array(source.size * source.size),
7622
+ source.size,
7623
+ source.size,
7624
+ THREE6.RedFormat,
7625
+ THREE6.UnsignedByteType
7626
+ );
7627
+ for (const t of [damage, edge]) {
7628
+ t.magFilter = THREE6.LinearFilter;
7629
+ t.minFilter = THREE6.LinearFilter;
7630
+ t.wrapS = THREE6.ClampToEdgeWrapping;
7631
+ t.wrapT = THREE6.ClampToEdgeWrapping;
7632
+ t.generateMipmaps = false;
7633
+ t.colorSpace = THREE6.NoColorSpace;
7634
+ t.flipY = false;
7635
+ t.unpackAlignment = 1;
7636
+ }
7637
+ measureEdge(source, edge, width, height);
7638
+ damage.userData.version = source.version;
7639
+ damage.needsUpdate = true;
7640
+ return { damage, edge };
7641
+ }, [source, width, height]);
7642
+ (0, import_react4.useEffect)(
7643
+ () => () => {
7644
+ textures?.damage.dispose();
7645
+ textures?.edge.dispose();
7646
+ },
7647
+ [textures]
7648
+ );
7462
7649
  (0, import_fiber.useFrame)(() => {
7463
- if (!source || !texture) return;
7464
- if (texture.userData.version === source.version) return;
7465
- texture.userData.version = source.version;
7466
- texture.needsUpdate = true;
7650
+ if (!source || !textures) return;
7651
+ if (textures.damage.userData.version === source.version) return;
7652
+ textures.damage.userData.version = source.version;
7653
+ textures.damage.needsUpdate = true;
7654
+ measureEdge(source, textures.edge, width, height);
7467
7655
  });
7468
- return texture;
7656
+ return textures;
7657
+ }
7658
+ function measureEdge(source, edge, width, height) {
7659
+ const last = Math.max(1, source.size - 1);
7660
+ cutDistance(
7661
+ source.pixels,
7662
+ source.size,
7663
+ [width * MM_PER_WORLD / last, height * MM_PER_WORLD / last],
7664
+ edge.image.data
7665
+ );
7666
+ edge.needsUpdate = true;
7469
7667
  }
7470
7668
 
7471
7669
  // src/surface/PaperMaterial.tsx
@@ -7482,7 +7680,7 @@ function PaperMaterial({
7482
7680
  damage
7483
7681
  }) {
7484
7682
  const rig = useLightRig(lighting);
7485
- const damageTexture = useDamageTexture(damage);
7683
+ const damageTextures = useDamageTexture(damage, sheet2);
7486
7684
  const composed = composeSurface(
7487
7685
  surface,
7488
7686
  stock,
@@ -7490,7 +7688,7 @@ function PaperMaterial({
7490
7688
  {
7491
7689
  hasFrontMap: Boolean(texture),
7492
7690
  hasBackMap: Boolean(backTexture),
7493
- hasDamage: Boolean(damageTexture)
7691
+ hasDamage: Boolean(damageTextures)
7494
7692
  },
7495
7693
  sheet2,
7496
7694
  rig,
@@ -7499,7 +7697,7 @@ function PaperMaterial({
7499
7697
  const bound = (0, import_react5.useMemo)(() => composed.uniforms, [composed.structureKey]);
7500
7698
  (0, import_react5.useEffect)(() => {
7501
7699
  for (const [key, uniform] of Object.entries(composed.uniforms)) {
7502
- if (!bound[key] || key === "uFrontMap" || key === "uBackMap" || key === "uDamage" || key === "uDamageDetail" || key === "uDamageTime" || key.startsWith("uLook")) {
7700
+ if (!bound[key] || key === "uFrontMap" || key === "uBackMap" || key === "uDamage" || key === "uDamageEdge" || key === "uDamageDetail" || key === "uDamageTime" || key.startsWith("uLook")) {
7503
7701
  continue;
7504
7702
  }
7505
7703
  if (bound[key].value instanceof THREE7.Color && uniform.value instanceof THREE7.Color) {
@@ -7513,8 +7711,9 @@ function PaperMaterial({
7513
7711
  (0, import_react5.useEffect)(() => {
7514
7712
  if (bound.uFrontMap) bound.uFrontMap.value = texture;
7515
7713
  if (bound.uBackMap) bound.uBackMap.value = backTexture ?? null;
7516
- if (bound.uDamage) bound.uDamage.value = damageTexture;
7517
- }, [bound, texture, backTexture, damageTexture]);
7714
+ if (bound.uDamage) bound.uDamage.value = damageTextures?.damage ?? null;
7715
+ if (bound.uDamageEdge) bound.uDamageEdge.value = damageTextures?.edge ?? null;
7716
+ }, [bound, texture, backTexture, damageTextures]);
7518
7717
  (0, import_fiber2.useFrame)((state) => {
7519
7718
  if (!bound.uDamageDetail) return;
7520
7719
  if (bound.uDamageTime) {
@@ -7547,7 +7746,7 @@ function PaperMaterial({
7547
7746
  v2(look.edgeWave, d.edgeWave),
7548
7747
  v2(look.edgeBite, d.edgeBite),
7549
7748
  v2(look.sparkle, d.sparkle),
7550
- 0
7749
+ v2(look.charWidth, d.charWidth)
7551
7750
  );
7552
7751
  }
7553
7752
  const detail = damage?.detail ?? 1;
@@ -8783,7 +8982,8 @@ function PaperLighting({
8783
8982
  reducedMotion,
8784
8983
  shadowMapSize,
8785
8984
  contactShadow = true,
8786
- environment = true
8985
+ environment = true,
8986
+ damage
8787
8987
  }) {
8788
8988
  const p2 = (0, import_react9.useMemo)(() => rig ?? resolveLighting(preset, light), [rig, preset, light]);
8789
8989
  const mapSize = shadowMapSize ?? p2.shadow.mapSize;
@@ -8817,6 +9017,20 @@ function PaperLighting({
8817
9017
  driftRef.current += delta * p2.gobo.drift;
8818
9018
  goboMap.offset.set(driftRef.current, driftRef.current * 0.6);
8819
9019
  });
9020
+ const keyIntensity = p2.gobo && goboMap ? p2.key.intensity * 3.2 : p2.key.intensity;
9021
+ const keyRef = (0, import_react9.useRef)(null);
9022
+ const ambientRef = (0, import_react9.useRef)(null);
9023
+ const hemisphereRef = (0, import_react9.useRef)(null);
9024
+ const roomRef = (0, import_react9.useRef)(1);
9025
+ (0, import_fiber4.useFrame)(() => {
9026
+ const room = roomLight(damage);
9027
+ if (room === 1 && roomRef.current === 1) return;
9028
+ roomRef.current = room;
9029
+ if (keyRef.current) keyRef.current.intensity = keyIntensity * room;
9030
+ if (ambientRef.current) ambientRef.current.intensity = p2.ambient * room;
9031
+ if (hemisphereRef.current) hemisphereRef.current.intensity = p2.studio * HEMISPHERE_STAND_IN * room;
9032
+ if (p2.studio > 0 && environment) scene.environmentIntensity = p2.studio * room;
9033
+ });
8820
9034
  return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
8821
9035
  p2.studio > 0 && (environment ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(StudioLight, { rig: p2 }) : (
8822
9036
  // The studio light degrades rather than disappearing. A hemisphere
@@ -8827,19 +9041,21 @@ function PaperLighting({
8827
9041
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
8828
9042
  "hemisphereLight",
8829
9043
  {
9044
+ ref: hemisphereRef,
8830
9045
  color: p2.sky.horizon,
8831
9046
  groundColor: p2.sky.ground,
8832
9047
  intensity: p2.studio * HEMISPHERE_STAND_IN
8833
9048
  }
8834
9049
  )
8835
9050
  )),
8836
- /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("ambientLight", { intensity: p2.ambient }),
9051
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("ambientLight", { ref: ambientRef, intensity: p2.ambient }),
8837
9052
  p2.gobo && goboMap ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
8838
9053
  "spotLight",
8839
9054
  {
9055
+ ref: keyRef,
8840
9056
  position: p2.key.position,
8841
9057
  color: p2.key.color,
8842
- intensity: p2.key.intensity * 3.2,
9058
+ intensity: keyIntensity,
8843
9059
  angle: p2.gobo.angle,
8844
9060
  penumbra: 0.5,
8845
9061
  decay: 0,
@@ -8852,9 +9068,10 @@ function PaperLighting({
8852
9068
  ) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
8853
9069
  "directionalLight",
8854
9070
  {
9071
+ ref: keyRef,
8855
9072
  position: p2.key.position,
8856
9073
  color: p2.key.color,
8857
- intensity: p2.key.intensity,
9074
+ intensity: keyIntensity,
8858
9075
  castShadow,
8859
9076
  "shadow-mapSize": [mapSize || 1, mapSize || 1],
8860
9077
  "shadow-radius": p2.shadow.radius,
@@ -8968,11 +9185,24 @@ var Paper = (0, import_react12.forwardRef)(function Paper2({ children, className
8968
9185
  {
8969
9186
  preset: config.scene.lighting,
8970
9187
  light: config.scene.light,
8971
- floor: -1.05,
9188
+ floor: config.scene.floor.enabled ? config.scene.floor.y : -1.05,
8972
9189
  scale: 8,
8973
- reducedMotion: meshProps.reducedMotion
9190
+ reducedMotion: meshProps.reducedMotion,
9191
+ damage: meshProps.damage
8974
9192
  }
8975
9193
  ),
9194
+ config.scene.floor.enabled && // Dark, matte and large enough to leave no edge in frame: what the
9195
+ // fire's light pools on, and what the paper it cuts loose lands on.
9196
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("mesh", { "rotation-x": -Math.PI / 2, position: [0, config.scene.floor.y, 0], receiveShadow: true, children: [
9197
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("planeGeometry", { args: [40, 40] }),
9198
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(
9199
+ "meshStandardMaterial",
9200
+ {
9201
+ color: config.scene.floor.color,
9202
+ roughness: config.scene.floor.roughness
9203
+ }
9204
+ )
9205
+ ] }),
8976
9206
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(PaperMesh, { ref, ...meshProps }),
8977
9207
  children,
8978
9208
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ReleaseContextOnUnmount, {})