@vitreajs/vitrea 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ var WGSL_ANALYSIS_PASS = `struct AnalysisUniforms {
17
17
  @group(0) @binding(3) var<storage, read_write> stats : array<f32>;
18
18
 
19
19
  var<workgroup> partialLum : array<f32, ${ANALYSIS_WORKGROUP}>;
20
+ var<workgroup> partialTone : array<f32, ${ANALYSIS_WORKGROUP}>;
20
21
  var<workgroup> partialSq : array<f32, ${ANALYSIS_WORKGROUP}>;
21
22
  var<workgroup> partialEdge : array<f32, ${ANALYSIS_WORKGROUP}>;
22
23
 
@@ -32,6 +33,7 @@ fn lum_at(uv : vec2f) -> f32 {
32
33
  fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
33
34
  let total = u32(au.grid.x * au.grid.y);
34
35
  var sumLum = 0.0;
36
+ var sumTone = 0.0;
35
37
  var sumSq = 0.0;
36
38
  var sumEdge = 0.0;
37
39
  var n = 0.0;
@@ -47,7 +49,15 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
47
49
  let dx = lum_at(uv + vec2f(au.texel.x, 0.0)) - lum_at(uv - vec2f(au.texel.x, 0.0));
48
50
  let dy = lum_at(uv + vec2f(0.0, au.texel.y)) - lum_at(uv - vec2f(0.0, au.texel.y));
49
51
 
52
+ // The published luminance accumulates ENCODED \u2014 the W9 model (claims
53
+ // 5.31): the reference's tone responses track the encoded-space mean of a
54
+ // structured backdrop, not its linear mean. The host decodes the mean once
55
+ // on readback, so every consumer downstream still speaks linear light.
56
+ // The linear sum stays, because the variance fold below needs a mean in
57
+ // the same space as its squares; variance and edge density stay linear \u2014
58
+ // nothing tonal reads them.
50
59
  sumLum = sumLum + c;
60
+ sumTone = sumTone + linear_to_srgb(vec3f(c, c, c)).x;
51
61
  sumSq = sumSq + c * c;
52
62
  sumEdge = sumEdge + length(vec2f(dx, dy)) * 0.5;
53
63
  n = n + 1.0;
@@ -56,6 +66,7 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
56
66
  }
57
67
 
58
68
  partialLum[lane] = sumLum;
69
+ partialTone[lane] = sumTone;
59
70
  partialSq[lane] = sumSq;
60
71
  partialEdge[lane] = sumEdge;
61
72
  workgroupBarrier();
@@ -66,6 +77,7 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
66
77
  if (stride == 0u) { break; }
67
78
  if (lane < stride) {
68
79
  partialLum[lane] = partialLum[lane] + partialLum[lane + stride];
80
+ partialTone[lane] = partialTone[lane] + partialTone[lane + stride];
69
81
  partialSq[lane] = partialSq[lane] + partialSq[lane + stride];
70
82
  partialEdge[lane] = partialEdge[lane] + partialEdge[lane + stride];
71
83
  }
@@ -76,7 +88,8 @@ fn cs_analysis(@builtin(local_invocation_index) lane : u32) {
76
88
  if (lane == 0u) {
77
89
  let count = max(f32(total), 1.0);
78
90
  let mean = partialLum[0] / count;
79
- stats[0] = mean;
91
+ // stats[0] is the ENCODED-space mean; the host decodes it once (W9).
92
+ stats[0] = partialTone[0] / count;
80
93
  stats[1] = max(partialSq[0] / count - mean * mean, 0.0);
81
94
  stats[2] = partialEdge[0] / count;
82
95
  stats[3] = count;
@@ -233,7 +246,12 @@ var DEFAULT_MATERIAL_PROFILE = {
233
246
  * the same 44 px span, which is the lerp-versus-multiply question C9a
234
247
  * recorded and nobody has acted on. Claims §5.13.
235
248
  */
236
- blurSigma: 3,
249
+ // 1.25 since W11c G1 (claims §5.41): the reference's interior over
250
+ // structured content is a sharp component near σ 1.25 plus a heavy one
251
+ // the scatter facet now supplies (`sizeScatterFloor`); the cascade's 3
252
+ // (claims §5.16) was the one Gaussian that best split the difference on
253
+ // a bed with no pitch axis to tell the two apart.
254
+ blurSigma: 1.25,
237
255
  tint: srgbToLinear(SRGB_WHITE_TINT),
238
256
  tintAlpha: 0.46,
239
257
  rimWidth: 1.5,
@@ -330,6 +348,8 @@ var DEFAULT_MATERIAL_PROFILE = {
330
348
  */
331
349
  sizeSpanMin: 32,
332
350
  sizeSpanMax: 96,
351
+ // The inner shadow's depth gain since W12 G2 (the lens reads its own law
352
+ // below); the value is W2's, unchanged.
333
353
  lensSizeGainMax: 2.6,
334
354
  /*
335
355
  * MEASURED (W2), each with its own status — see the fields' notes.
@@ -405,41 +425,93 @@ var DEFAULT_MATERIAL_PROFILE = {
405
425
  * on. The grid 1.0/1.4/2.2 is flat to 0.2%. Carrying a fitted-looking 1.4 whose
406
426
  * measurement no longer exists would be the worse of the two errors.
407
427
  */
408
- sizeScatterGainMax: 1,
428
+ // MEASURED (W11c G1, claims §5.41): the scatter facet off the identity, on
429
+ // its own curve — see `MaterialProfile.sizeScatterFloor`. The paragraph
430
+ // above records why the identity was the right answer while `blurSigma`
431
+ // was 3 and the bed had no pitch axis; the W9 probe's pitch axis is what
432
+ // identified the two-component interior this expresses.
433
+ sizeScatterGainMax: 8,
434
+ sizeScatterFloor: 0.4,
435
+ sizeScatterSpanMax: 256,
436
+ // The 1x three FITTED in the renderer (W13 G1's third sweep, 44 points over
437
+ // the calibration bed: `results/2026-09-03-w13-ramp/g1/sweep-3/g1-sweep-3.md`
438
+ // §3); the 2x three PROVISIONAL still, because at them the excursion is
439
+ // bit-exactly zero on the whole bed and a sweep cannot fit what does not
440
+ // move (§4). The thin anchor 0.72 is above G0's read-off of 0.637–0.642: the
441
+ // grid runs 0.60 / 0.64 / 0.68 and the refinement 0.72 / 0.76, and 0.72 is an
442
+ // interior optimum of both the interior objective and the interior gap. The
443
+ // thick anchor 0.52 is G0's `rrect-md` reading and is above that cell's deep
444
+ // sharp share of 0.481, where the joint thick fit of 0.47 would have clamped
445
+ // it to nothing. The 2x pair is G0's 2x readings, every one of which is BELOW
446
+ // its cell's deep value, so at dpr 2 the excursion is zero on this whole bed
447
+ // — the law evaluating to no excursion, and the 2x gap located in the deep
448
+ // value instead (claims §5.64 §4). See
449
+ // `MaterialProfile.sizeScatterRampStartThin1x`.
450
+ sizeScatterRampStartThin1x: 0.72,
451
+ sizeScatterRampStartThick1x: 0.52,
452
+ sizeScatterRampStartFar1x: 0.2,
453
+ sizeScatterRampStartThin2x: 0.46,
454
+ sizeScatterRampStartThick2x: 0.17,
455
+ sizeScatterRampStartFar2x: 0.15,
456
+ sizeScatterRampReach1xPx: 80,
457
+ sizeScatterRampReach2xPx: 100,
409
458
  sizeOcclusionGain: 0.05,
410
459
  sizeShadowGainMax: 1,
411
- lensBodyLodPerPx: 0.16,
412
- lensRimLodBias: 2.5,
460
+ /*
461
+ * The lens (W12 G2, claims §5.51; Decision Log 3 of the W12 spec).
462
+ *
463
+ * MEASURED from the reference's own layer tree (§5.50): the inner refraction
464
+ * amount and height laws, verified on spans 32 / 44 / 48 … 112 / 128 / 160
465
+ * and the nested base at 130, and the ovalization's knee (0 through span 64,
466
+ * 0.5 from 72). FITTED on the pixels (§5.51, a 2-D band renderer validated on
467
+ * this renderer's own capture): the gain, the extent and the exponent, on
468
+ * `rrect-md` + `-ml` at 1x pitches 16 and 32 with `rrect-lg` held out at both
469
+ * scales; the ovalization's effective value on the box-inscribed ellipse.
470
+ * Predicted before the landing capture: checkerboard SSIM 1x md / ml / lg
471
+ * 0.954 / 0.931 / 0.929 → 0.970 / 0.946 / 0.942, 2x 0.939 / 0.902 / 0.901 →
472
+ * 0.950 / 0.918 / 0.918, the capsule 0.977 → 0.985.
473
+ *
474
+ * What W11c G2 recorded here — 1.6 lens depths on the square profile, fitted
475
+ * per depth shell — was the band's mean, not its shape (§5.48, §5.49); it is
476
+ * kept in git history and in claims §5.43.
477
+ */
478
+ lensRefractionGain: 0.745,
479
+ lensHeightPerSpan: 0.25,
480
+ lensHeightMax: 20,
481
+ lensAmountPerSpan: 0.8,
482
+ lensAmountMax: 60,
483
+ lensThicknessReference: 8,
484
+ lensExtentGain: 1.337,
485
+ lensProfileExponent: 3.69,
486
+ // 0.8 by eye (W12 Decision Log 6). The pixels preferred 0.6 by a hair — every
487
+ // texture row that can see ω scores 0.001–0.002 lower at 0.8 (claims §5.54) —
488
+ // but the field's measured tilt sits at 0.8–1.0 (§5.49 §3) and the user read
489
+ // the 0.8 sheet as much closer to macOS; the eye overrides a margin that small,
490
+ // and §5.54 §1's rows are the recorded cost.
491
+ lensOvalization: 0.8,
492
+ lensOvalizationSpanMin: 64,
493
+ lensOvalizationSpanMax: 72,
413
494
  reducedTransparencyFrost: 1.75,
414
495
  increasedOcclusionLift: INCREASED_OCCLUSION_LIFT,
415
496
  strongBorderRim: { rimWidth: 2, rimAlpha: 0.95 },
416
497
  reducedTintAdaptation: 0.35,
417
- // ADVISORY (W3). The span 0.02 … 0.65 covers most of the canonical backdrop
418
- // range (0.003 … 0.891 linear), and the two ends are deliberately symmetric —
419
- // 0.45 of the seed's brightness at the dark end, 0.45 of the way to white at
420
- // the bright end — so the seed itself sits mid-range and the excursion reads
421
- // as glass rather than as two different colours.
422
498
  /*
423
- * FITTED (2026-08-31, active bed) — and the fit is that THE CURVE IS THE
424
- * IDENTITY. `floor` → 1 and `ceilMix` → 0 make `tintTone` return the seed
425
- * unchanged at every backdrop, which leaves `low` and `high` describing
426
- * nothing at all; they keep their provisional values so a bed that can
427
- * identify them has somewhere to land, exactly as `adaptiveTint` keeps two
428
- * equal ends.
499
+ * MEASURED (W10, 2026-09-02) — per-pixel least squares of the reference's
500
+ * tinted pixel against its own untinted pixel on the five W9-probe tinted
501
+ * checkerboard cells (pitch 4…64 px, 17 700 px): shade = 0.5289 + 0.4886·u,
502
+ * RMS 0.0035. Out of sample on the canonical bed the orange cells fit at
503
+ * RMS 0.003 with zero bias; blue sits 0.011 darker (the second hue's
504
+ * residual, recorded and unmodelled). The light end extrapolates past 1 and
505
+ * is clamped in `tintShade`. Claims §5.36.
429
506
  *
430
- * Monotone and uncontested over floor ∈ {0.45, 0.7, 0.85, 1} × ceilMix ∈
431
- * {0, 0.2, 0.45}: objective 0.26523 → 0.12676, a 2.09× spread, with ΔE
432
- * (0.01331 → 0.00831) and SSIM (0.9749 → 0.9788) agreeing. The reference
433
- * says the same thing directly — on three of the five calibration backdrops
434
- * its tinted interior is the declared seed EXACTLY in linear light, at a
435
- * per-channel standard deviation of 0.000. Apple's tint is the author's
436
- * colour, not a range of tones mapped to it; S219's wording described the
437
- * inactive material's residue. Claims §5.13.
507
+ * §5.13's earlier "the curve is the identity" fit was made on the material's
508
+ * MEAN over solid backdrops, where u is either ~0.97 (shade 1.0) or the
509
+ * collapse has already folded the shade out — the identity was the law's
510
+ * two endpoints, seen without anything between them.
438
511
  */
439
- tintToneFloor: 1,
440
- tintToneCeilMix: 0,
441
- tintToneLow: 0.02,
442
- tintToneHigh: 0.65,
512
+ tintShadeDark: 0.5289,
513
+ tintShadeLight: 1.0175,
514
+ tintShadeStrength: 1,
443
515
  /*
444
516
  * MEASURED (W7), against the settled apple-macos-26.5 bed, on the light and
445
517
  * dark standard profiles jointly and at both scales.
@@ -472,9 +544,21 @@ var DEFAULT_MATERIAL_PROFILE = {
472
544
  * is a VALIDATION cell and was not fitted to: the calibration set predicts
473
545
  * 0.34 there and the cell reads 0.356.
474
546
  */
547
+ /*
548
+ * RE-SCOPED (W9, claims §5.33). The four mix constants now own TEXTURE
549
+ * COLLAPSE alone — the interior mean moved to the response law below — and
550
+ * their band contracts to the one domain the collapse is real in: full at
551
+ * `dark-solid` (0.0117) for thin surfaces including the 44 px capsule
552
+ * (byte-identical collapse, arg 0.0164 ≤ low with margin), zero by
553
+ * `mid-dark-solid` (0.0595), where the reference's small surface keeps a
554
+ * textured body at 0.4561 and the old band's partial collapse (k = 0.81)
555
+ * was the measured 0.1375-vs-0.4561 overshoot. `impulse__rrect-md`
556
+ * (arg 0.0539) keeps k ≈ 0.003 against the old band's 0.008 — the same
557
+ * unadapted render that cell validated.
558
+ */
475
559
  backdropToneMax: 1,
476
- backdropToneLow: 0.03,
477
- backdropToneHigh: 0.14,
560
+ backdropToneLow: 0.02,
561
+ backdropToneHigh: 0.055,
478
562
  /*
479
563
  * REFITTED 0.09 → 0.13 (2026-08-31, active bed). The law's SHAPE is untouched
480
564
  * (Decision Log 13 stands; the two-axis rework is next wave) and `max`, `low`
@@ -493,8 +577,26 @@ var DEFAULT_MATERIAL_PROFILE = {
493
577
  * estimator's algebra assumes the two colour schemes share one tint alpha,
494
578
  * and this profile pair does not (0.46 against 0.97) — recorded in §5.13 as
495
579
  * the reason it is no longer the primary evidence for this constant.
580
+ *
581
+ * RE-SCOPED 0.13 → 0.05 with the band above (W9): the bias's one remaining
582
+ * job is keeping the thick surface's collapse argument above `high` at the
583
+ * dark anchor (0.0117 + 0.05 = 0.0617 > 0.055) while the thin capsule's
584
+ * stays below `low` — the near-binary size snap the probe measured
585
+ * (claims §5.33). The smooth size trend on structured backdrops, which the
586
+ * old wide band tried and failed to carry, belongs to the response law's
587
+ * thickness axis now.
496
588
  */
497
- backdropToneSizeBias: 0.13,
589
+ backdropToneSizeBias: 0.05,
590
+ /*
591
+ * MEASURED (W9 probe, claims §5.30–§5.33): the anchors are the probe bed's
592
+ * settled reference levels, frequency-settled over seven attested runs,
593
+ * under the probe's own native-mask interior. Thick rows pool the 96 px
594
+ * and 160 px rrects (±0.012). Not tuned; re-measured only by a new probe.
595
+ */
596
+ backdropToneAnchorX: [0.1104, 0.2706, 0.9505],
597
+ backdropToneResponseThin: [0.0126, 0.4561, 0.9713],
598
+ backdropToneResponseThick: [0.4953, 0.5744, 0.9358],
599
+ backdropToneResponseStrength: 1,
498
600
  /*
499
601
  * FITTED (recalibration cascade, 2026-08-31). W8's geometry SURVIVES the fit
500
602
  * unchanged — `sigmaPx` 15.55, `offsetPx` 7.95, `spreadPx` 3.10 — and only the
@@ -516,14 +618,18 @@ var DEFAULT_MATERIAL_PROFILE = {
516
618
  * instrument's independent shadow axis agrees, so the geometry stands on those
517
619
  * two and this objective is not asked to re-decide it.
518
620
  *
519
- * `reducedTransparencyOcclusion` 0.70 is sharp where the amplitude is flat: a
520
- * 3.83× spread over 0.45…0.95 with a clear minimum. It also reconciles two
621
+ * `reducedTransparencyOcclusion` 0.70 was sharp where the amplitude was flat: a
622
+ * 3.83× spread over 0.45…0.95 with a clear minimum. It also reconciled two
521
623
  * routes — 0.285 × 0.70 = 0.1995 against the reference's directly measured
522
624
  * reduce-transparency amplitude of 0.203 at a 44 px span. W8's 0.566 was the
523
- * ratio of the reference's two amplitudes; this is the ratio that makes
625
+ * ratio of the reference's two amplitudes; that was the ratio that made
524
626
  * vitrea's shadow match the reference's under the preference, and the two
525
- * differ because the base amplitude is a compromise across scenes whose spread
526
- * is much wider in the standard profile than under reduced transparency.
627
+ * differed because the base amplitude is a compromise across scenes whose
628
+ * spread is much wider in the standard profile than under reduced transparency.
629
+ * **W14 G1 re-forms the constant as the LEVEL those routes were reconciling
630
+ * to** — 0.197 absolute, replacing both regimes rather than scaling them — for
631
+ * the reason its doc comment gives: a ratio and a level stopped being the same
632
+ * thing when the single amplitude became six unequal anchors.
527
633
  *
528
634
  * Extracted from the active bed's native fixtures directly —
529
635
  * `results/2026-08-31-active-bed-stage0.json` measured the gap, these numbers
@@ -562,13 +668,33 @@ var DEFAULT_MATERIAL_PROFILE = {
562
668
  * 44 px span — the dark material's shadow is nearly invisible) and lands as a
563
669
  * profile patch, not as a branch here. Both are stated in the claims doc as the
564
670
  * open question the cascade's fit inherits.
671
+ *
672
+ * **W14 G1 answers that open question and replaces `occlusion`.** "Over the
673
+ * flat near-white `light-solid` backdrop the same fit reads 0.123 rather than
674
+ * 0.33 … it is not a function of the backdrop's luminance" was read on a bed
675
+ * that had no backdrop between `hc-text` (linear 0.74) and `light-solid`
676
+ * (0.891): it IS a function of the backdrop's luminance, and the whole factor
677
+ * of 2.6 happens inside the gap the bed cannot see (claims §5.62 §5). The
678
+ * single amplitude becomes six anchors on two regimes — three in backdrop
679
+ * luminance below the knee, three in span above it — plus the lift's four
680
+ * constants. The three lengths are untouched; G0 re-read them free on both
681
+ * terms and they came back at W8's values.
565
682
  */
566
683
  outerShadow: {
567
684
  offsetPx: 7.95,
568
685
  sigmaPx: 15.55,
569
686
  spreadPx: 3.1,
570
- occlusion: 0.285,
571
- reducedTransparencyOcclusion: 0.7,
687
+ thinOcclusionDark: 0,
688
+ thinOcclusionMid: 0.33,
689
+ thinOcclusionBright: 0.127,
690
+ thickOcclusionAt96: 0.37,
691
+ thickOcclusionAt128: 0.448,
692
+ thickOcclusionAt160: 0.479,
693
+ liftAmplitude: 0.01,
694
+ liftSpanMin: 64,
695
+ liftSpanFull: 118,
696
+ liftBlurSigmaCss: 40,
697
+ reducedTransparencyOcclusion: 0.197,
572
698
  sizeGain: 0
573
699
  },
574
700
  lightDirection: [-0.3714, -0.9285],
@@ -587,16 +713,50 @@ var SIZE_SPAN_MIN = DEFAULT_MATERIAL_PROFILE.sizeSpanMin;
587
713
  var SIZE_SPAN_MAX = DEFAULT_MATERIAL_PROFILE.sizeSpanMax;
588
714
  var LENS_SIZE_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.lensSizeGainMax;
589
715
  var SIZE_SCATTER_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.sizeScatterGainMax;
716
+ var SIZE_SCATTER_FLOOR = DEFAULT_MATERIAL_PROFILE.sizeScatterFloor;
717
+ var SIZE_SCATTER_SPAN_MAX = DEFAULT_MATERIAL_PROFILE.sizeScatterSpanMax;
718
+ var SIZE_SCATTER_RAMP_START_THIN_1X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartThin1x;
719
+ var SIZE_SCATTER_RAMP_START_THICK_1X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartThick1x;
720
+ var SIZE_SCATTER_RAMP_START_THIN_2X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartThin2x;
721
+ var SIZE_SCATTER_RAMP_START_THICK_2X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartThick2x;
722
+ var SIZE_SCATTER_RAMP_START_FAR_1X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartFar1x;
723
+ var SIZE_SCATTER_RAMP_START_FAR_2X = DEFAULT_MATERIAL_PROFILE.sizeScatterRampStartFar2x;
724
+ var SIZE_SCATTER_RAMP_REACH_1X_PX = DEFAULT_MATERIAL_PROFILE.sizeScatterRampReach1xPx;
725
+ var SIZE_SCATTER_RAMP_REACH_2X_PX = DEFAULT_MATERIAL_PROFILE.sizeScatterRampReach2xPx;
590
726
  var SIZE_OCCLUSION_GAIN = DEFAULT_MATERIAL_PROFILE.sizeOcclusionGain;
591
727
  var SIZE_SHADOW_GAIN_MAX = DEFAULT_MATERIAL_PROFILE.sizeShadowGainMax;
592
- var LENS_BODY_LOD_PER_PX = DEFAULT_MATERIAL_PROFILE.lensBodyLodPerPx;
593
- var LENS_RIM_LOD_BIAS = DEFAULT_MATERIAL_PROFILE.lensRimLodBias;
728
+ var LENS_REFRACTION_GAIN = DEFAULT_MATERIAL_PROFILE.lensRefractionGain;
729
+ var LENS_HEIGHT_PER_SPAN = DEFAULT_MATERIAL_PROFILE.lensHeightPerSpan;
730
+ var LENS_HEIGHT_MAX = DEFAULT_MATERIAL_PROFILE.lensHeightMax;
731
+ var LENS_AMOUNT_PER_SPAN = DEFAULT_MATERIAL_PROFILE.lensAmountPerSpan;
732
+ var LENS_AMOUNT_MAX = DEFAULT_MATERIAL_PROFILE.lensAmountMax;
733
+ var LENS_THICKNESS_REFERENCE = DEFAULT_MATERIAL_PROFILE.lensThicknessReference;
734
+ var LENS_EXTENT_GAIN = DEFAULT_MATERIAL_PROFILE.lensExtentGain;
735
+ var LENS_PROFILE_EXPONENT = DEFAULT_MATERIAL_PROFILE.lensProfileExponent;
736
+ var LENS_OVALIZATION = DEFAULT_MATERIAL_PROFILE.lensOvalization;
594
737
  var BACKDROP_TONE_MAX = DEFAULT_MATERIAL_PROFILE.backdropToneMax;
595
738
  var BACKDROP_TONE_LOW = DEFAULT_MATERIAL_PROFILE.backdropToneLow;
596
739
  var BACKDROP_TONE_HIGH = DEFAULT_MATERIAL_PROFILE.backdropToneHigh;
597
740
  var BACKDROP_TONE_SIZE_BIAS = DEFAULT_MATERIAL_PROFILE.backdropToneSizeBias;
741
+ DEFAULT_MATERIAL_PROFILE.backdropToneAnchorX;
742
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseThin;
743
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseThick;
744
+ DEFAULT_MATERIAL_PROFILE.backdropToneResponseStrength;
598
745
  var OUTER_SHADOW = DEFAULT_MATERIAL_PROFILE.outerShadow;
746
+ var RETIRED_OUTER_SHADOW_LEAVES = {
747
+ occlusion: "the six amplitude anchors (thinOcclusionDark, thinOcclusionMid, thinOcclusionBright, thickOcclusionAt96, thickOcclusionAt128, thickOcclusionAt160) and liftAmplitude for the second term"
748
+ };
749
+ function rejectRetiredOuterShadowLeaves(patch) {
750
+ if (patch === void 0)
751
+ return;
752
+ for (const [leaf, replacement] of Object.entries(RETIRED_OUTER_SHADOW_LEAVES)) {
753
+ if (!(leaf in patch))
754
+ continue;
755
+ throw new Error(`outerShadow.${leaf} was retired by W14 G1 (claims \xA75.62) and is replaced by ${replacement}. Applying this patch would have rendered the default shadow while recording itself as configured. It is refused rather than mapped: a single span-flat amplitude is the material the measurement retired.`);
756
+ }
757
+ }
599
758
  function withMaterialOverrides(base, patch) {
759
+ rejectRetiredOuterShadowLeaves(patch.outerShadow);
600
760
  const optics = {};
601
761
  for (const variant of MATERIAL_VARIANTS) {
602
762
  optics[variant] = { ...base.optics[variant], ...patch.optics?.[variant] };
@@ -616,22 +776,44 @@ function withMaterialOverrides(base, patch) {
616
776
  sizeSpanMax: patch.sizeSpanMax ?? base.sizeSpanMax,
617
777
  lensSizeGainMax: patch.lensSizeGainMax ?? base.lensSizeGainMax,
618
778
  sizeScatterGainMax: patch.sizeScatterGainMax ?? base.sizeScatterGainMax,
779
+ sizeScatterFloor: patch.sizeScatterFloor ?? base.sizeScatterFloor,
780
+ sizeScatterSpanMax: patch.sizeScatterSpanMax ?? base.sizeScatterSpanMax,
781
+ sizeScatterRampStartThin1x: patch.sizeScatterRampStartThin1x ?? base.sizeScatterRampStartThin1x,
782
+ sizeScatterRampStartThick1x: patch.sizeScatterRampStartThick1x ?? base.sizeScatterRampStartThick1x,
783
+ sizeScatterRampStartThin2x: patch.sizeScatterRampStartThin2x ?? base.sizeScatterRampStartThin2x,
784
+ sizeScatterRampStartThick2x: patch.sizeScatterRampStartThick2x ?? base.sizeScatterRampStartThick2x,
785
+ sizeScatterRampStartFar1x: patch.sizeScatterRampStartFar1x ?? base.sizeScatterRampStartFar1x,
786
+ sizeScatterRampStartFar2x: patch.sizeScatterRampStartFar2x ?? base.sizeScatterRampStartFar2x,
787
+ sizeScatterRampReach1xPx: patch.sizeScatterRampReach1xPx ?? base.sizeScatterRampReach1xPx,
788
+ sizeScatterRampReach2xPx: patch.sizeScatterRampReach2xPx ?? base.sizeScatterRampReach2xPx,
619
789
  sizeOcclusionGain: patch.sizeOcclusionGain ?? base.sizeOcclusionGain,
620
790
  sizeShadowGainMax: patch.sizeShadowGainMax ?? base.sizeShadowGainMax,
621
- lensBodyLodPerPx: patch.lensBodyLodPerPx ?? base.lensBodyLodPerPx,
622
- lensRimLodBias: patch.lensRimLodBias ?? base.lensRimLodBias,
791
+ lensRefractionGain: patch.lensRefractionGain ?? base.lensRefractionGain,
792
+ lensHeightPerSpan: patch.lensHeightPerSpan ?? base.lensHeightPerSpan,
793
+ lensHeightMax: patch.lensHeightMax ?? base.lensHeightMax,
794
+ lensAmountPerSpan: patch.lensAmountPerSpan ?? base.lensAmountPerSpan,
795
+ lensAmountMax: patch.lensAmountMax ?? base.lensAmountMax,
796
+ lensThicknessReference: patch.lensThicknessReference ?? base.lensThicknessReference,
797
+ lensExtentGain: patch.lensExtentGain ?? base.lensExtentGain,
798
+ lensProfileExponent: patch.lensProfileExponent ?? base.lensProfileExponent,
799
+ lensOvalization: patch.lensOvalization ?? base.lensOvalization,
800
+ lensOvalizationSpanMin: patch.lensOvalizationSpanMin ?? base.lensOvalizationSpanMin,
801
+ lensOvalizationSpanMax: patch.lensOvalizationSpanMax ?? base.lensOvalizationSpanMax,
623
802
  reducedTransparencyFrost: patch.reducedTransparencyFrost ?? base.reducedTransparencyFrost,
624
803
  increasedOcclusionLift: patch.increasedOcclusionLift ?? base.increasedOcclusionLift,
625
804
  strongBorderRim: { ...base.strongBorderRim, ...patch.strongBorderRim },
626
805
  reducedTintAdaptation: patch.reducedTintAdaptation ?? base.reducedTintAdaptation,
627
- tintToneFloor: patch.tintToneFloor ?? base.tintToneFloor,
628
- tintToneCeilMix: patch.tintToneCeilMix ?? base.tintToneCeilMix,
629
- tintToneLow: patch.tintToneLow ?? base.tintToneLow,
630
- tintToneHigh: patch.tintToneHigh ?? base.tintToneHigh,
806
+ tintShadeDark: patch.tintShadeDark ?? base.tintShadeDark,
807
+ tintShadeLight: patch.tintShadeLight ?? base.tintShadeLight,
808
+ tintShadeStrength: patch.tintShadeStrength ?? base.tintShadeStrength,
631
809
  backdropToneMax: patch.backdropToneMax ?? base.backdropToneMax,
632
810
  backdropToneLow: patch.backdropToneLow ?? base.backdropToneLow,
633
811
  backdropToneHigh: patch.backdropToneHigh ?? base.backdropToneHigh,
634
812
  backdropToneSizeBias: patch.backdropToneSizeBias ?? base.backdropToneSizeBias,
813
+ backdropToneAnchorX: patch.backdropToneAnchorX ?? base.backdropToneAnchorX,
814
+ backdropToneResponseThin: patch.backdropToneResponseThin ?? base.backdropToneResponseThin,
815
+ backdropToneResponseThick: patch.backdropToneResponseThick ?? base.backdropToneResponseThick,
816
+ backdropToneResponseStrength: patch.backdropToneResponseStrength ?? base.backdropToneResponseStrength,
635
817
  outerShadow: { ...base.outerShadow, ...patch.outerShadow },
636
818
  lightDirection: patch.lightDirection ?? base.lightDirection,
637
819
  sweepBandRadians: patch.sweepBandRadians ?? base.sweepBandRadians,
@@ -677,28 +859,29 @@ function tintToneAdaptation(policy, profile = DEFAULT_MATERIAL_PROFILE) {
677
859
  return 0;
678
860
  }
679
861
  }
680
- function tintTone(seed, backdropLuminance, toneAdaptation, profile = DEFAULT_MATERIAL_PROFILE) {
681
- const t = smoothstep(profile.tintToneLow, profile.tintToneHigh, backdropLuminance);
682
- const k = Math.min(1, Math.max(0, toneAdaptation));
862
+ function tintShade(materialLuminance, grip, profile = DEFAULT_MATERIAL_PROFILE) {
863
+ const u = Math.min(1, Math.max(0, materialLuminance));
864
+ const shade = Math.min(1, Math.max(0, profile.tintShadeDark + (profile.tintShadeLight - profile.tintShadeDark) * u));
865
+ const k = Math.min(1, Math.max(0, grip));
866
+ return 1 + (shade - 1) * k;
867
+ }
868
+ function tintShadeLayer(seed, materialLuminance, grip, profile = DEFAULT_MATERIAL_PROFILE) {
869
+ const shade = tintShade(materialLuminance, grip, profile);
870
+ return [seed[0] * shade, seed[1] * shade, seed[2] * shade];
871
+ }
872
+ function tintedMaterialColour(material, tint, grip, profile = DEFAULT_MATERIAL_PROFILE) {
873
+ if (tint === void 0 || tint.strength <= 0)
874
+ return material;
875
+ const s = Math.min(1, Math.max(0, tint.strength));
876
+ const layer = tintShadeLayer(tint.color, relativeLuminance(material), grip, profile);
877
+ const clamp01 = (value) => Math.min(1, Math.max(0, value));
683
878
  const channel = (index) => {
684
- const s = seed[index];
685
- const low = s * profile.tintToneFloor;
686
- const high = s + (1 - s) * profile.tintToneCeilMix;
687
- return s + (low + (high - low) * t - s) * k;
879
+ const from = linearToSrgbChannel(clamp01(material[index]));
880
+ const to = linearToSrgbChannel(clamp01(layer[index]));
881
+ return srgbToLinearChannel(from + (to - from) * s);
688
882
  };
689
883
  return [channel(0), channel(1), channel(2)];
690
884
  }
691
- function tintedTintColour(neutral, tint, backdropLuminance, toneAdaptation, profile = DEFAULT_MATERIAL_PROFILE) {
692
- if (tint === void 0)
693
- return neutral;
694
- const tone = tintTone(tint.color, backdropLuminance, toneAdaptation, profile);
695
- const k = Math.min(1, Math.max(0, tint.strength));
696
- return [
697
- neutral[0] + (tone[0] - neutral[0]) * k,
698
- neutral[1] + (tone[1] - neutral[1]) * k,
699
- neutral[2] + (tone[2] - neutral[2]) * k
700
- ];
701
- }
702
885
  function backdropToneAdaptation(backdropLuminance, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
703
886
  const x = backdropLuminance + profile.backdropToneSizeBias * Math.min(1, Math.max(0, thickness));
704
887
  const span = Math.max(profile.backdropToneHigh - profile.backdropToneLow, 1e-6);
@@ -748,11 +931,102 @@ function lensSizeGain(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
748
931
  function lensSizeGainFromThickness(thickness, profile = DEFAULT_MATERIAL_PROFILE) {
749
932
  return 1 + (profile.lensSizeGainMax - 1) * thickness;
750
933
  }
751
- function sizeScatterSigma(sigmaPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
752
- return sizeScatterSigmaAt(sigmaPx, sizeThickness(spanPx, profile), profile);
934
+ function shadowDepthPx(thicknessPx, spanPx, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
935
+ return Math.min(Math.max(thicknessPx, 0) * lensSizeGainFromThickness(thickness, profile), spanPx * 0.5);
753
936
  }
754
- function sizeScatterSigmaAt(sigmaPx, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
755
- return sigmaPx * (1 + (profile.sizeScatterGainMax - 1) * thickness);
937
+ function lensHeightBasePx(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
938
+ return Math.min(profile.lensHeightPerSpan * Math.max(spanPx, 0), profile.lensHeightMax);
939
+ }
940
+ function lensAmountBasePx(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
941
+ return Math.min(profile.lensAmountPerSpan * Math.max(spanPx, 0), profile.lensAmountMax);
942
+ }
943
+ function lensOvalizationAt(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
944
+ return profile.lensOvalization * smoothstep(profile.lensOvalizationSpanMin, profile.lensOvalizationSpanMax, spanPx);
945
+ }
946
+ function lensMagnitudePx(thicknessPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE, fold = 1) {
947
+ const thick = Math.max(thicknessPx, 0);
948
+ if (thick <= 0)
949
+ return 0;
950
+ const scale = thick / profile.lensThicknessReference;
951
+ const unclamped = thick + (lensHeightBasePx(spanPx, profile) * scale - thick) * fold;
952
+ const depth = lensDepthPx(thicknessPx, spanPx, profile, fold);
953
+ const amount = thick + (lensAmountBasePx(spanPx, profile) * scale - thick) * fold;
954
+ return profile.lensRefractionGain * amount * (unclamped > 0 ? depth / unclamped : 0);
955
+ }
956
+ function lensExtentPx(thicknessPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE, fold = 1) {
957
+ return profile.lensExtentGain * lensDepthPx(thicknessPx, spanPx, profile, fold);
958
+ }
959
+ function lensDirection(normal, offset, half, ovalization) {
960
+ const a = Math.max(half[0], 1e-6);
961
+ const b = Math.max(half[1], 1e-6);
962
+ const ex = offset[0] / (a * a);
963
+ const ey = offset[1] / (b * b);
964
+ const r = Math.max(Math.hypot(offset[0] / a, offset[1] / b), 1e-6);
965
+ const scale = Math.min(a, b) / r;
966
+ const gx = (1 - ovalization) * normal[0] + ovalization * ex * scale;
967
+ const gy = (1 - ovalization) * normal[1] + ovalization * ey * scale;
968
+ const len = Math.hypot(gx, gy);
969
+ return len > 1e-9 ? [gx / len, gy / len] : [normal[0], normal[1]];
970
+ }
971
+ function sizeScatterSigma(sigmaPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE, devicePixelRatio = 1, extentsCssPx) {
972
+ return sizeScatterSigmaAt(sigmaPx, scatterThickness(spanPx, 1, profile, devicePixelRatio, extentsCssPx), profile);
973
+ }
974
+ function scatterRampStart(devicePixelRatio, profile = DEFAULT_MATERIAL_PROFILE, spanPx = 0) {
975
+ const thin = rampAtScale(profile.sizeScatterRampStartThin1x, profile.sizeScatterRampStartThin2x, devicePixelRatio);
976
+ const thick = rampAtScale(profile.sizeScatterRampStartThick1x, profile.sizeScatterRampStartThick2x, devicePixelRatio);
977
+ const far = rampAtScale(profile.sizeScatterRampStartFar1x, profile.sizeScatterRampStartFar2x, devicePixelRatio);
978
+ const decline = smoothstep(profile.sizeSpanMax, profile.sizeScatterSpanMax, spanPx);
979
+ return thin + (thick - thin) * sizeThickness(spanPx, profile) + (far - thick) * decline;
980
+ }
981
+ function scatterRampReachDevicePx(devicePixelRatio, profile = DEFAULT_MATERIAL_PROFILE) {
982
+ return rampAtScale(profile.sizeScatterRampReach1xPx, profile.sizeScatterRampReach2xPx, devicePixelRatio);
983
+ }
984
+ function rampAtScale(at1x, at2x, devicePixelRatio) {
985
+ const t = devicePixelRatio - 1;
986
+ if (t <= 0)
987
+ return at1x;
988
+ if (t >= 1)
989
+ return at2x;
990
+ return at1x + (at2x - at1x) * t;
991
+ }
992
+ function scatterDeepThickness(spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
993
+ const floor = clampUnit(profile.sizeScatterFloor);
994
+ return floor + (1 - floor) * smoothstep(profile.sizeSpanMin, profile.sizeScatterSpanMax, spanPx);
995
+ }
996
+ function scatterSharpShare(uDevicePx, devicePixelRatio, profile = DEFAULT_MATERIAL_PROFILE, spanPx = 0) {
997
+ const deepSharp = 1 - scatterDeepThickness(spanPx, profile);
998
+ const start = scatterRampStart(devicePixelRatio, profile, spanPx);
999
+ const reach = Math.max(scatterRampReachDevicePx(devicePixelRatio, profile), 1e-6);
1000
+ const excursion = Math.max(start - deepSharp, 0) * Math.max(1 - Math.max(uDevicePx, 0) / reach, 0);
1001
+ return clampUnit(deepSharp + excursion);
1002
+ }
1003
+ function scatterThickness(spanPx, fold, profile = DEFAULT_MATERIAL_PROFILE, devicePixelRatio = 1, extentsCssPx) {
1004
+ const floor = clampUnit(profile.sizeScatterFloor);
1005
+ const mean = scatterRampAreaMean(spanPx, profile, devicePixelRatio, extentsCssPx);
1006
+ return clampUnit(floor + (mean - floor) * fold);
1007
+ }
1008
+ function scatterRampAreaMean(spanPx, profile = DEFAULT_MATERIAL_PROFILE, devicePixelRatio = 1, extentsCssPx) {
1009
+ const deep = scatterDeepThickness(spanPx, profile);
1010
+ const amplitude = Math.max(scatterRampStart(devicePixelRatio, profile, spanPx) - (1 - deep), 0);
1011
+ if (amplitude <= 0)
1012
+ return clampUnit(deep);
1013
+ const width = Math.max(extentsCssPx?.[0] ?? spanPx, 0);
1014
+ const height = Math.max(extentsCssPx?.[1] ?? spanPx, 0);
1015
+ const area = width * height;
1016
+ if (area <= 0)
1017
+ return clampUnit(deep - amplitude);
1018
+ const reach = Math.max(scatterRampReachDevicePx(devicePixelRatio, profile) / Math.max(devicePixelRatio, 1e-3), 1e-6);
1019
+ const perimeter = 2 * (width + height);
1020
+ const limit = Math.min(reach, Math.min(width, height) / 2);
1021
+ const triangleMean = (perimeter * limit - 4 * limit * limit - perimeter * limit * limit / (2 * reach) + 8 * limit * limit * limit / (3 * reach)) / area;
1022
+ return clampUnit(deep - amplitude * triangleMean);
1023
+ }
1024
+ function sizeScatterSigmaAt(sigmaPx, scatter, profile = DEFAULT_MATERIAL_PROFILE) {
1025
+ const mix = clampUnit(scatter);
1026
+ return sigmaPx * (1 + (profile.sizeScatterGainMax - 1) * mix);
1027
+ }
1028
+ function clampUnit(value) {
1029
+ return Math.min(1, Math.max(0, value));
756
1030
  }
757
1031
  function sizeOcclusionAlpha(alpha, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
758
1032
  return sizeOcclusionAlphaAt(alpha, sizeThickness(spanPx, profile), profile);
@@ -772,15 +1046,79 @@ function sizeOuterShadowOcclusion(occlusion, spanPx, profile = DEFAULT_MATERIAL_
772
1046
  function sizeOuterShadowOcclusionAt(occlusion, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
773
1047
  return Math.min(1, occlusion + profile.outerShadow.sizeGain * thickness * (1 - occlusion));
774
1048
  }
1049
+ var OUTER_SHADOW_THIN_L = {
1050
+ /** At and below this backdrop luminance the black term is `thinOcclusionDark`. */
1051
+ inert: 0.02,
1052
+ /** From here the mid plateau holds. */
1053
+ midFrom: 0.06,
1054
+ /** To here — `hc-text`'s own linear luminance. */
1055
+ midTo: 0.74,
1056
+ /** `light-solid`'s linear luminance, where 0.127 was measured; held above. */
1057
+ bright: 0.891
1058
+ };
1059
+ var OUTER_SHADOW_UNMEASURED_BACKDROP_LUMINANCE = 0.3;
1060
+ function outerShadowThinOcclusion(backdropLuminance, shadow) {
1061
+ const l = backdropLuminance ?? OUTER_SHADOW_UNMEASURED_BACKDROP_LUMINANCE;
1062
+ const { inert, midFrom, midTo, bright } = OUTER_SHADOW_THIN_L;
1063
+ if (l <= inert)
1064
+ return shadow.thinOcclusionDark;
1065
+ if (l < midFrom) {
1066
+ const t2 = (l - inert) / (midFrom - inert);
1067
+ const s = t2 * t2 * (3 - 2 * t2);
1068
+ return shadow.thinOcclusionDark + (shadow.thinOcclusionMid - shadow.thinOcclusionDark) * s;
1069
+ }
1070
+ if (l <= midTo)
1071
+ return shadow.thinOcclusionMid;
1072
+ if (l >= bright)
1073
+ return shadow.thinOcclusionBright;
1074
+ const t = (l - midTo) / (bright - midTo);
1075
+ return shadow.thinOcclusionMid + (shadow.thinOcclusionBright - shadow.thinOcclusionMid) * t;
1076
+ }
1077
+ var OUTER_SHADOW_THICK_SPANS = [96, 128, 160];
1078
+ function outerShadowThickOcclusion(spanPx, shadow) {
1079
+ const [s0, s1, s2] = OUTER_SHADOW_THICK_SPANS;
1080
+ const y0 = shadow.thickOcclusionAt96;
1081
+ const y1 = shadow.thickOcclusionAt128;
1082
+ const y2 = shadow.thickOcclusionAt160;
1083
+ if (spanPx <= s0)
1084
+ return y0;
1085
+ if (spanPx >= s2)
1086
+ return y2;
1087
+ if (spanPx <= s1)
1088
+ return y0 + (y1 - y0) * (spanPx - s0) / (s1 - s0);
1089
+ return y1 + (y2 - y1) * (spanPx - s1) / (s2 - s1);
1090
+ }
1091
+ function outerShadowOcclusionAt(shadow, backdropLuminance, spanPx, thickness, profile = DEFAULT_MATERIAL_PROFILE) {
1092
+ const thin = outerShadowThinOcclusion(backdropLuminance, shadow);
1093
+ const thick = outerShadowThickOcclusion(spanPx, shadow);
1094
+ const k = Math.min(1, Math.max(0, thickness));
1095
+ const blend = k * k * (3 - 2 * k);
1096
+ return sizeOuterShadowOcclusionAt(thin + (thick - thin) * blend, thickness, profile);
1097
+ }
1098
+ function outerShadowLiftRise(spanPx, shadow) {
1099
+ return smoothstep(shadow.liftSpanMin, shadow.liftSpanFull, spanPx);
1100
+ }
775
1101
  function outerShadowUnderPolicy(policy, profile = DEFAULT_MATERIAL_PROFILE) {
776
1102
  const shadow = profile.outerShadow;
777
1103
  if (policy.glass === "none" || policy.frost === "none")
778
- return { ...shadow, occlusion: 0 };
1104
+ return flatOuterShadow(shadow, 0);
779
1105
  if (policy.frost === "increased") {
780
- return { ...shadow, occlusion: shadow.occlusion * shadow.reducedTransparencyOcclusion };
1106
+ return flatOuterShadow(shadow, shadow.reducedTransparencyOcclusion);
781
1107
  }
782
1108
  return shadow;
783
1109
  }
1110
+ function flatOuterShadow(shadow, amplitude) {
1111
+ return {
1112
+ ...shadow,
1113
+ thinOcclusionDark: amplitude,
1114
+ thinOcclusionMid: amplitude,
1115
+ thinOcclusionBright: amplitude,
1116
+ thickOcclusionAt96: amplitude,
1117
+ thickOcclusionAt128: amplitude,
1118
+ thickOcclusionAt160: amplitude,
1119
+ liftAmplitude: 0
1120
+ };
1121
+ }
784
1122
  var SRGB_ENCODING_EXPONENT = 2.4;
785
1123
  function outerShadowFalloff(signedDistancePx, sigmaPx) {
786
1124
  const x = -signedDistancePx / Math.max(sigmaPx, 1e-4);
@@ -790,8 +1128,8 @@ function outerShadowAlpha(occlusion) {
790
1128
  const occ = Math.min(1, Math.max(0, occlusion));
791
1129
  return 1 - Math.pow(1 - occ, 1 / SRGB_ENCODING_EXPONENT);
792
1130
  }
793
- function outerShadowReachPx(shadow) {
794
- const alpha = outerShadowAlpha(shadow.occlusion);
1131
+ function outerShadowReachPx(shadow, occlusion) {
1132
+ const alpha = outerShadowAlpha(occlusion);
795
1133
  if (!(alpha > 0))
796
1134
  return 0;
797
1135
  const cutoff = 1 / 255 / alpha;
@@ -809,12 +1147,18 @@ function outerShadowReachPx(shadow) {
809
1147
  }
810
1148
  return Math.max(0, hi + Math.abs(shadow.offsetPx) + shadow.spreadPx);
811
1149
  }
812
- function lensDepthPx(thicknessPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE) {
813
- const gain = lensSizeGain(spanPx, profile);
814
- return Math.min(Math.max(thicknessPx, 0) * gain, spanPx * 0.5);
1150
+ function lensDepthPx(thicknessPx, spanPx, profile = DEFAULT_MATERIAL_PROFILE, fold = 1) {
1151
+ const thick = Math.max(thicknessPx, 0);
1152
+ const scale = thick / profile.lensThicknessReference;
1153
+ const depth = thick + (lensHeightBasePx(spanPx, profile) * scale - thick) * fold;
1154
+ return Math.min(Math.max(depth, 0), spanPx * 0.5);
815
1155
  }
816
- function bodyLod(lensDepth, maxLod, profile = DEFAULT_MATERIAL_PROFILE) {
817
- return Math.min(Math.max(lensDepth * profile.lensBodyLodPerPx, 0), maxLod);
1156
+ function lensDisplacementPx(depthPx, spanPx, thicknessPx, refractionScale, profile = DEFAULT_MATERIAL_PROFILE, fold = 1) {
1157
+ const extent = lensExtentPx(thicknessPx, spanPx, profile, fold);
1158
+ if (extent <= 0)
1159
+ return 0;
1160
+ const t = Math.max(0, 1 - Math.max(depthPx, 0) / extent);
1161
+ return lensMagnitudePx(thicknessPx, spanPx, profile, fold) * Math.pow(t, profile.lensProfileExponent) * refractionScale;
818
1162
  }
819
1163
 
820
1164
  // ../renderer-webgpu/dist/analysis.js
@@ -826,7 +1170,11 @@ var ZERO_STATS = {
826
1170
  };
827
1171
  function statsFromBuffer(values) {
828
1172
  return {
829
- luminance: values[0] ?? 0,
1173
+ // The reduction publishes the ENCODED-space mean (the W9 model, claims
1174
+ // §5.31) and this is its one decode: every consumer downstream — the
1175
+ // drivers, `adaptiveTint`'s fitted thresholds — still speaks linear light,
1176
+ // and a solid backdrop reads identically under either convention.
1177
+ luminance: srgbToLinearChannel(values[0] ?? 0),
830
1178
  variance: values[1] ?? 0,
831
1179
  edgeDensity: values[2] ?? 0,
832
1180
  sampleCount: values[3] ?? 0
@@ -1300,6 +1648,44 @@ function createAppTextureProvider(options) {
1300
1648
  };
1301
1649
  }
1302
1650
 
1651
+ // ../renderer-webgpu/dist/backdrop-fit.js
1652
+ var IDENTITY_FIT = [1, 1, 0, 0];
1653
+ function isUsablePlacement(placement) {
1654
+ return placement !== void 0 && Number.isFinite(placement.x) && Number.isFinite(placement.y) && placement.width > 0 && placement.height > 0;
1655
+ }
1656
+ function coverFit(sourceWidth, sourceHeight, viewportWidthCss, viewportHeightCss) {
1657
+ const viewportAspect = viewportHeightCss > 0 ? viewportWidthCss / viewportHeightCss : 1;
1658
+ const sourceAspect = sourceHeight > 0 ? sourceWidth / sourceHeight : 1;
1659
+ if (sourceAspect > viewportAspect) {
1660
+ const scaleX = viewportAspect / sourceAspect;
1661
+ return [scaleX, 1, (1 - scaleX) / 2, 0];
1662
+ }
1663
+ const scaleY = sourceAspect / viewportAspect;
1664
+ return [1, scaleY, 0, (1 - scaleY) / 2];
1665
+ }
1666
+ function placementFit(placement, viewportWidthCss, viewportHeightCss) {
1667
+ return [
1668
+ viewportWidthCss / placement.width,
1669
+ viewportHeightCss / placement.height,
1670
+ -placement.x / placement.width,
1671
+ -placement.y / placement.height
1672
+ ];
1673
+ }
1674
+ function backdropFit(sourceWidth, sourceHeight, placement, viewportWidthCss, viewportHeightCss) {
1675
+ return isUsablePlacement(placement) ? placementFit(placement, viewportWidthCss, viewportHeightCss) : coverFit(sourceWidth, sourceHeight, viewportWidthCss, viewportHeightCss);
1676
+ }
1677
+ function texelsPerCssPx(sourceWidth, sourceHeight, placement, viewportWidthCss, viewportHeightCss) {
1678
+ if (isUsablePlacement(placement)) {
1679
+ return sourceWidth > 0 ? sourceWidth / placement.width : 1;
1680
+ }
1681
+ return viewportWidthCss > 0 && viewportHeightCss > 0 ? Math.max(sourceWidth / viewportWidthCss, sourceHeight / viewportHeightCss) : 1;
1682
+ }
1683
+ function samePlacement(a, b) {
1684
+ if (a === void 0 || b === void 0)
1685
+ return a === b;
1686
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
1687
+ }
1688
+
1303
1689
  // ../renderer-webgpu/dist/device.js
1304
1690
  function createDeviceHost(options = {}) {
1305
1691
  let status = {
@@ -1610,7 +1996,8 @@ var poolKey = {
1610
1996
  backdropBodyScratch: (sourceId) => `backdrop:${sourceId}:body-scratch`,
1611
1997
  backdropUpload: (sourceId) => `backdrop:${sourceId}:upload`,
1612
1998
  groupField: (groupId) => `group:${groupId}:field`,
1613
- groupAux: (groupId) => `group:${groupId}:aux`
1999
+ groupAux: (groupId) => `group:${groupId}:aux`,
2000
+ groupAux2: (groupId) => `group:${groupId}:aux2`
1614
2001
  };
1615
2002
 
1616
2003
  // ../renderer-webgpu/dist/gpu-context.js
@@ -1767,7 +2154,7 @@ function resolveSurfaces(group, family, profile = DEFAULT_MATERIAL_PROFILE, poli
1767
2154
  centre: [fieldSource.channels.center[0], fieldSource.channels.center[1]],
1768
2155
  channels,
1769
2156
  spanPx,
1770
- lensDepthPx: Math.min(Math.max(shape.channels.thickness, 0) * lensSizeGainFromThickness(thickness, profile), spanPx * 0.5),
2157
+ lensDepthPx: lensDepthPx(shape.channels.thickness, spanPx, profile, profile.refractionScale[accessibilityRefractionCap(policy)]),
1771
2158
  sizeThickness: thickness,
1772
2159
  tintStrength: Math.min(1, Math.max(0, surface.tint?.strength ?? 0))
1773
2160
  };
@@ -1832,9 +2219,9 @@ function packInstances(surfaces, origin, into) {
1832
2219
  data[o + 11] = s.shape.channels.thickness;
1833
2220
  data[o + 12] = s.channels.press;
1834
2221
  data[o + 13] = s.channels.glow;
1835
- data[o + 14] = s.lensDepthPx * Math.min(1, Math.max(0, s.channels.lensStrength));
2222
+ data[o + 14] = Math.max(s.shape.channels.thickness, 0) * Math.min(1, Math.max(0, s.channels.lensStrength));
1836
2223
  data[o + 15] = s.tintStrength;
1837
- data[o + 16] = s.sizeThickness;
2224
+ data[o + 16] = s.spanPx;
1838
2225
  data[o + 17] = 0;
1839
2226
  }
1840
2227
  return { data, count: surfaces.length };
@@ -2110,9 +2497,9 @@ var WGSL_INSTANCE_STRUCT = `struct Instance {
2110
2497
  thick : f32, // 44 material thickness, CSS px
2111
2498
  press : f32, // 48 interaction channel value, 0..1
2112
2499
  glow : f32, // 52 interaction channel value, 0..1
2113
- lensDepth : f32, // 56 CPU-resolved lens depth in CSS px (material.ts), already scaled by the lensStrength channel
2500
+ lensThick : f32, // 56 authored thickness in CSS px times the lensStrength channel (W12 G2): both depths are evaluated from it per pixel
2114
2501
  tintK : f32, // 60 author tint strength, 0..1 \u2014 the seed itself is a group uniform
2115
- sizeK : f32, // 64 the size law's thickness factor, 0..1 \u2014 material.ts's sizeThickness(span)
2502
+ span : f32, // 64 the surface's shorter extent, CSS px \u2014 the size law's input, both curves (W11c)
2116
2503
  _pad : f32, // 68 stride padding: vec2f aligns the struct to 8, so 72 is the next legal size
2117
2504
  };`;
2118
2505
  var WGSL_FIELD_SAMPLE = `struct FieldSample {
@@ -2278,7 +2665,8 @@ var WGSL_FIELD_PASS = `struct FieldUniforms {
2278
2665
  // field but its members are not one size, and carrying them per pixel is what
2279
2666
  // lets a 40 px button and a 320 px platter share a field pass and still lens by
2280
2667
  // their own depth \u2014 parent acceptance #2 inside a GlassEffectContainer.
2281
- // aux = (lensDepthPx, glow, sizeK, tintStrength)
2668
+ // aux = (lensThickPx, glow, spanPx, tintStrength)
2669
+ // aux2 = (p \u2212 centre, half.x, half.y) \u2014 W12 G2, group-local CSS px
2282
2670
  //
2283
2671
  // The third and fourth slots both changed hands, and neither of the values they
2284
2672
  // used to carry was ever read by a fragment stage. 'thick' left because
@@ -2292,23 +2680,45 @@ var WGSL_FIELD_PASS = `struct FieldUniforms {
2292
2680
  // because a group is one field pass and one optics pass while its members are
2293
2681
  // not one thing:
2294
2682
  //
2295
- // 'sizeK' \u2014 the size law's thickness factor (W2). A 40 px button and a
2296
- // 320 px platter in one container lens, scatter, occlude and
2297
- // shadow by their own size.
2683
+ // 'spanPx' \u2014 the size law's input (W2): the surface's shorter extent in
2684
+ // CSS px. A 40 px button and a 320 px platter in one container
2685
+ // lens, scatter, occlude and shadow by their own size. Until
2686
+ // W11c this slot carried the folded thickness factor itself;
2687
+ // the scatter facet now rides a curve with a different band
2688
+ // top, so the span travels and each consumer evaluates its own
2689
+ // curve from it with the bands and the fold as uniforms.
2298
2690
  // 'tintStrength' \u2014 the author tint's strength (W3). One emphasised control
2299
2691
  // among plain ones is the composition Apple's guidance
2300
2692
  // describes; the seed itself is a group uniform.
2301
2693
  //
2302
2694
  // They compose rather than compete: the size law moves what the material does to
2303
2695
  // the backdrop, the tint moves what colour it does it in.
2696
+ //
2697
+ // One consequence of carrying the span rather than the thickness, stated so it
2698
+ // is not mistaken for a drift: the union below blends 'aux' linearly between two
2699
+ // members, so a pixel in the neck between a button and a platter now blends
2700
+ // their SPANS and evaluates the curves on the blend, where it used to blend the
2701
+ // evaluated thicknesses. Inside either member the two are identical; only the
2702
+ // smooth-min neck differs, and nothing measured claims a value there.
2703
+ //
2704
+ // 'aux2' (W12 G2) carries the pixel's offset from the owning surface's centre
2705
+ // and that surface's half-extents, which
2706
+ // the optics pass needs for the lens's direction: the reference displaces along
2707
+ // the gradient of its SDF blended toward the oval inscribed in the shape's box
2708
+ // (claims \xA75.50's 'gradientOvalization'), and an oval is a property of the box,
2709
+ // not of the distance field. Blended by the same weight as 'aux' in a neck, so
2710
+ // two merged members share one oval there \u2014 a value nothing measured either.
2711
+ // For a concentric child the box is the parent's (the child is the parent's
2712
+ // level set), which is the same centre and a slightly larger oval.
2304
2713
 
2305
2714
  @group(0) @binding(0) var<uniform> fu : FieldUniforms;
2306
2715
  @group(0) @binding(1) var<storage, read> instances : array<Instance>;
2307
2716
 
2308
2717
  struct Member {
2309
- d : f32,
2310
- g : vec2f,
2311
- aux : vec4f,
2718
+ d : f32,
2719
+ g : vec2f,
2720
+ aux : vec4f,
2721
+ aux2 : vec4f,
2312
2722
  };
2313
2723
 
2314
2724
  fn eval_instance(i : u32, p : vec2f) -> Member {
@@ -2321,13 +2731,17 @@ fn eval_instance(i : u32, p : vec2f) -> Member {
2321
2731
  var m : Member;
2322
2732
  m.d = f.d + s.inset;
2323
2733
  m.g = f.g;
2324
- m.aux = vec4f(s.lensDepth, s.glow, s.sizeK, s.tintK);
2734
+ m.aux = vec4f(s.lensThick, s.glow, s.span, s.tintK);
2735
+ // The pixel relative to the surface's centre, and the half-extents: what the
2736
+ // oval's gradient needs, written here where the group-local position is exact.
2737
+ m.aux2 = vec4f(p - s.centre, s.half);
2325
2738
  return m;
2326
2739
  }
2327
2740
 
2328
2741
  struct FieldOut {
2329
2742
  @location(0) field : vec4f,
2330
2743
  @location(1) aux : vec4f,
2744
+ @location(2) aux2 : vec4f,
2331
2745
  };
2332
2746
 
2333
2747
  @fragment
@@ -2345,6 +2759,7 @@ fn fs_field(in : FullscreenOut) -> FieldOut {
2345
2759
  // coverage downstream is not reliably zero.
2346
2760
  out.field = vec4f(65000.0, 0.0, -1.0, 0.0);
2347
2761
  out.aux = vec4f(0.0);
2762
+ out.aux2 = vec4f(0.0);
2348
2763
  return out;
2349
2764
  }
2350
2765
 
@@ -2357,6 +2772,7 @@ fn fs_field(in : FullscreenOut) -> FieldOut {
2357
2772
  acc.d = blend.x;
2358
2773
  acc.g = mix(s.g, acc.g, h);
2359
2774
  acc.aux = mix(s.aux, acc.aux, h);
2775
+ acc.aux2 = mix(s.aux2, acc.aux2, h);
2360
2776
  nearest = min(nearest, s.d);
2361
2777
  }
2362
2778
  // One clamp at the end of the fold, so |union - min| <= maxBulge holds for any
@@ -2369,6 +2785,7 @@ fn fs_field(in : FullscreenOut) -> FieldOut {
2369
2785
 
2370
2786
  out.field = vec4f(acc.d, normal.x, normal.y, coverage);
2371
2787
  out.aux = acc.aux;
2788
+ out.aux2 = acc.aux2;
2372
2789
  return out;
2373
2790
  }`;
2374
2791
  function fieldPassSource(family) {
@@ -2409,7 +2826,10 @@ var WGSL_HIGHLIGHT_PASS = `struct HighlightUniforms {
2409
2826
  /// accessibility refraction cap, and the resolved strength \u2014 zero where there
2410
2827
  /// is no measured backdrop tone, which stands the fade down
2411
2828
  toneAdapt : vec4f,
2412
- /// the backdrop source's own average luminance (x); yzw unused
2829
+ /// the backdrop source's own average luminance (x), and the size law's band
2830
+ /// and fold \u2014 sizeSpanMin (y), sizeSpanMax (z), the accessibility fold (w) \u2014
2831
+ /// so this pass evaluates the thickness curve off the per-pixel span exactly
2832
+ /// as the optics pass does (W11c: the span rides the field, not the factor)
2413
2833
  toneLevel : vec4f,
2414
2834
  /// this pass's uv into the FIELD texture's uv: scale (xy), offset (zw).
2415
2835
  ///
@@ -2489,7 +2909,14 @@ fn fs_highlight(in : FullscreenOut) -> @location(0) vec4f {
2489
2909
  * around a surface the optics pass had made invisible \u2014 measured at 60 pixels
2490
2910
  * past 2/255, peaking at 97/255, on exactly that cell.
2491
2911
  */
2492
- let sizeK = clamp(aux.z, 0.0, 1.0);
2912
+ // The thickness curve off the per-pixel span, written out as the optics pass
2913
+ // writes it, so the two passes fade on one curve and not on two copies.
2914
+ let spanT = clamp(
2915
+ (aux.z - hu.toneLevel.y) / max(hu.toneLevel.z - hu.toneLevel.y, 1e-6),
2916
+ 0.0,
2917
+ 1.0,
2918
+ );
2919
+ let sizeK = clamp(spanT * spanT * (3.0 - 2.0 * spanT) * hu.toneLevel.w, 0.0, 1.0);
2493
2920
  var toneAdapt = 0.0;
2494
2921
  if (hu.toneAdapt.w > 0.0) {
2495
2922
  let toneX = hu.toneLevel.x + hu.toneAdapt.z * sizeK;
@@ -2514,7 +2941,9 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
2514
2941
  screen : vec4f,
2515
2942
  /// backdrop uv transform on viewport-normalised coords: scale (xy), offset (zw)
2516
2943
  fit : vec4f,
2517
- /// refractionScale, bodyLodPerPx, rimLodBias, chainMaxLod
2944
+ /// refractionScale, lensRefractionGain (W12 G2: the gain on the reference's
2945
+ /// amount law), the inner shadow's depth gain (W2's lensSizeGainMax, which the
2946
+ /// shadow keeps), chainMaxLod
2518
2947
  lens : vec4f,
2519
2948
  /// fixed tint colour, linear light (xyz), tint alpha (w)
2520
2949
  tint : vec4f,
@@ -2522,7 +2951,10 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
2522
2951
  adapt : vec4f,
2523
2952
  /// author tint seed, linear light (xyz), tone adaptation under the contrast regime (w)
2524
2953
  seed : vec4f,
2525
- /// tintToneFloor, tintToneCeilMix, tintToneLow, tintToneHigh
2954
+ /// the author tint's shade law (W10): tintShadeDark, tintShadeLight,
2955
+ /// tintShadeStrength (the profile's provenance gate); and (w) the size law's
2956
+ /// accessibility fold \u2014 the refraction ladder read at the preference's cap,
2957
+ /// which every facet's span-dependent rise is multiplied by (W11c)
2526
2958
  tone : vec4f,
2527
2959
  /// rimWidthPx, rimAlpha, specularPower, specularGain
2528
2960
  rim : vec4f,
@@ -2542,18 +2974,68 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
2542
2974
  toneAdapt : vec4f,
2543
2975
  /// the backdrop source's own average colour, linear (xyz), and its luminance (w)
2544
2976
  toneColour : vec4f,
2545
- /// the outer shadow (W8): its compositing-space alpha before the size law (x),
2546
- /// the blur's sigma in group-local CSS px (y), the silhouette's outward spread
2547
- /// in the same units (z), and the downward offset expressed in field-texture UV
2548
- /// (w) \u2014 a UV so the offset silhouette is one texture read rather than a
2549
- /// gradient extrapolation, which is exact only on a straight edge and a capsule
2550
- /// is mostly not one
2977
+ /// the outer shadow (W8, amplitude re-read by W14 G1): the THIN regime's peak
2978
+ /// LINEAR occlusion, already resolved at this group's backdrop luminance and
2979
+ /// already folded under the accessibility policy (x) \u2014 a linear occlusion and
2980
+ /// no longer a compositing alpha, because the shader now blends two regimes
2981
+ /// before it converts; the blur's sigma in group-local CSS px (y), the
2982
+ /// silhouette's outward spread in the same units (z), and the downward offset
2983
+ /// expressed in field-texture UV (w) \u2014 a UV so the offset silhouette is one
2984
+ /// texture read rather than a gradient extrapolation, which is exact only on a
2985
+ /// straight edge and a capsule is mostly not one
2551
2986
  shadow : vec4f,
2552
2987
  /// the outer shadow's size-law gain (x), read against the casting surface's own
2553
2988
  /// thickness, and the field rect's height in CSS px (y) \u2014 the conversion the
2554
- /// shadow's shift needs when it lands outside the texture; z and w are the
2989
+ /// shadow's shift needs when it lands outside the texture; the body depth
2990
+ /// ramp's THICK start (z, W13 G1's third form \u2014 the thin end is scatter.y and
2991
+ /// this pass mixes the two by the pixel's own sizeThickness), and (w) the
2555
2992
  /// padding a uniform's vec4 alignment requires
2556
2993
  shadowSize : vec4f,
2994
+ /// the backdrop tone response's anchors (W9): the three solid anchors'
2995
+ /// ENCODED-space means (xyz), and the backdrop's linear-space mean (w) \u2014 the
2996
+ /// quantity the response solve composites against
2997
+ toneAnchor : vec4f,
2998
+ /// the response law's thin row: the reference's settled interior levels at the
2999
+ /// three anchors for a surface of sizeThickness 0 (xyz); w is the law's
3000
+ /// per-profile authority \u2014 0 on dark profiles, whose response is unmeasured
3001
+ toneRowThin : vec4f,
3002
+ /// the thick row (sizeThickness saturated), same layout
3003
+ toneRowThick : vec4f,
3004
+ /// the size law's bands: the scatter facet's floor (x) and the depth ramp's
3005
+ /// THIN start s0 at the ratio this group draws at (y, W13 G1 \u2014 the CPU has
3006
+ /// already interpolated it between the profile's 1x and 2x anchors; the thick
3007
+ /// end is shadowSize.z), and the
3008
+ /// thickness curve's sizeSpanMin (z) and sizeSpanMax (w); the fold both
3009
+ /// multiply by is tone.w. The scatter span curve's own band top is lensOval.w,
3010
+ /// and it starts at the same z.
3011
+ scatter : vec4f,
3012
+ /// the lens law (W12 G2): the reference's height law (lensHeightPerSpan,
3013
+ /// lensHeightMax) and amount law (lensAmountPerSpan, lensAmountMax)
3014
+ lensLaw : vec4f,
3015
+ /// the lens profile (W12 G2): lensExtentGain, lensProfileExponent,
3016
+ /// lensOvalization, lensThicknessReference
3017
+ lensShape : vec4f,
3018
+ /// the ovalization's knee (W12 G2): spanMin (x), spanMax (y); the body depth
3019
+ /// ramp's reach in group-local CSS px (z, W13 G1) \u2014 the profile names it in
3020
+ /// device px and the CPU divides by the ratio, so that this shader can read
3021
+ /// the field's own CSS-px depth without a second conversion; the scatter span
3022
+ /// curve's band top sizeScatterSpanMax (w, W11c), which W13 G1 keeps
3023
+ /// underneath the ramp as its deep value
3024
+ lensOval : vec4f,
3025
+ /// the outer shadow's THICK regime (W14 G1): the composite's peak linear
3026
+ /// occlusion at casting spans 96, 128 and 160 CSS px (xyz), already folded
3027
+ /// under the accessibility policy, piecewise-linear between and held outside;
3028
+ /// and (w) the body depth ramp's FAR start (W13 G1's fourth form) \u2014 the start
3029
+ /// at span >= sizeScatterSpanMax, which the ramp declines to from the thick
3030
+ /// end along the scatter span curve, in the slot W14 left free
3031
+ shadowThick : vec4f,
3032
+ /// the lift (W14 G1), the shadow's second term and GPU-tier only: its peak
3033
+ /// amplitude in LINEAR light as a fraction of the blurred backdrop's own
3034
+ /// luminance (x), the span rise's foot and top in CSS px (yz), and the chain
3035
+ /// LOD whose blur is the profile's liftBlurSigmaCss \u2014 resolved on the CPU,
3036
+ /// which is the only place the CSS-px-to-texel conversion is knowable, and
3037
+ /// already clamped to chainMaxLod (w)
3038
+ shadowLift : vec4f,
2557
3039
  };
2558
3040
 
2559
3041
  @group(0) @binding(0) var<uniform> ou : OpticsUniforms;
@@ -2563,6 +3045,45 @@ var WGSL_OPTICS_PASS = `struct OpticsUniforms {
2563
3045
  @group(0) @binding(4) var backdropChain : texture_2d<f32>;
2564
3046
  @group(0) @binding(5) var backdropBody : texture_2d<f32>;
2565
3047
  @group(0) @binding(6) var fieldSampler : sampler;
3048
+ /// The owning surface's box per pixel (W12 G2): the pixel's offset from its
3049
+ /// centre (xy) and its half-extents (zw), group-local CSS px \u2014 the lens's oval.
3050
+ @group(0) @binding(7) var aux2Texture : texture_2d<f32>;
3051
+
3052
+ /// One encoded sRGB channel from a linear one \u2014 the space the backdrop tone
3053
+ /// response's anchors live in (W9). Mirrors material.ts's 'linearToSrgbChannel'.
3054
+ fn srgb_encode(c : f32) -> f32 {
3055
+ let x = clamp(c, 0.0, 1.0);
3056
+ return select(1.055 * pow(x, 1.0 / 2.4) - 0.055, x * 12.92, x <= 0.0031308);
3057
+ }
3058
+
3059
+ /// The backdrop tone response R(encodedInput, sizeK) (W9): monotone
3060
+ /// (Fritsch\u2013Carlson) interpolation through the three anchors, clamped to their
3061
+ /// span; smoothstep between the thin and thick rows. Mirrors material.ts's
3062
+ /// 'backdropToneResponse' term for term \u2014 the constants are authored there.
3063
+ fn tone_response(x : f32, sizeK : f32) -> f32 {
3064
+ let f = sizeK * sizeK * (3.0 - 2.0 * sizeK);
3065
+ let ys = mix(ou.toneRowThin.xyz, ou.toneRowThick.xyz, vec3f(f));
3066
+ let xs = ou.toneAnchor.xyz;
3067
+ let xc = clamp(x, xs.x, xs.z);
3068
+ let h0 = max(xs.y - xs.x, 1e-4);
3069
+ let h1 = max(xs.z - xs.y, 1e-4);
3070
+ let d0 = (ys.y - ys.x) / h0;
3071
+ let d1 = (ys.z - ys.y) / h1;
3072
+ // The interior slope: the harmonic mean where the secants agree, 0 across a
3073
+ // sign change \u2014 what keeps the curve monotone between monotone anchors.
3074
+ var m1 = 0.0;
3075
+ if (d0 * d1 > 0.0) { m1 = 2.0 * d0 * d1 / (d0 + d1); }
3076
+ var h = h0; var t = (xc - xs.x) / h0;
3077
+ var y0 = ys.x; var y1 = ys.y; var s0 = d0; var s1 = m1;
3078
+ if (xc > xs.y) {
3079
+ h = h1; t = (xc - xs.y) / h1;
3080
+ y0 = ys.y; y1 = ys.z; s0 = m1; s1 = d1;
3081
+ }
3082
+ return y0 * (1.0 + 2.0 * t) * (1.0 - t) * (1.0 - t)
3083
+ + s0 * h * t * (1.0 - t) * (1.0 - t)
3084
+ + y1 * t * t * (3.0 - 2.0 * t)
3085
+ + s1 * h * t * t * (t - 1.0);
3086
+ }
2566
3087
 
2567
3088
  /// Rim proximity: 1 exactly on the contour, falling to 0 by 'width' on either
2568
3089
  /// side. Symmetric, so the rim is a band on the boundary rather than a plateau
@@ -2582,7 +3103,33 @@ fn outer_shadow_falloff(signedDistance : f32, sigma : f32) -> f32 {
2582
3103
  return 0.5 * (1.0 + tanh(0.7978845608028654 * (x + 0.044715 * x * x * x)));
2583
3104
  }
2584
3105
 
2585
- /// The outer shadow's alpha at this pixel (W8).
3106
+ /// What one pixel's outer shadow is made of: the compositing-space alpha the
3107
+ /// black term composites at, the falloff BOTH terms ride, and the span of the
3108
+ /// surface that cast it \u2014 which the lift's own rise is read from.
3109
+ ///
3110
+ /// One struct rather than three calls because there is one falloff (W14 G0,
3111
+ /// claims \xA75.62 \xA74): the black term's free fit and the lift's free fit return
3112
+ /// the same sigma, offset and spread, so evaluating a second geometry for the
3113
+ /// second term would be asserting a difference the measurement denies.
3114
+ struct ShadowSample {
3115
+ alpha : f32,
3116
+ falloff : f32,
3117
+ castSpanCss : f32,
3118
+ };
3119
+
3120
+ /// The composite's peak linear occlusion above the knee, at a casting span \u2014
3121
+ /// piecewise linear through the three measured anchors (96 / 128 / 160 CSS px)
3122
+ /// and held flat outside them. Mirrors material.ts's
3123
+ /// 'outerShadowThickOcclusion'.
3124
+ fn outer_shadow_thick(spanCss : f32) -> f32 {
3125
+ let y = ou.shadowThick.xyz;
3126
+ if (spanCss <= 96.0) { return y.x; }
3127
+ if (spanCss >= 160.0) { return y.z; }
3128
+ if (spanCss <= 128.0) { return y.x + (y.y - y.x) * (spanCss - 96.0) / 32.0; }
3129
+ return y.y + (y.z - y.y) * (spanCss - 128.0) / 32.0;
3130
+ }
3131
+
3132
+ /// The outer shadow's alpha at this pixel (W8, amplitude W14 G1).
2586
3133
  ///
2587
3134
  /// The shadow is the group's OWN field, translated down by 'shadow.w' in field
2588
3135
  /// UV and outset by 'shadow.z', then blurred. Reading the offset silhouette from
@@ -2593,9 +3140,13 @@ fn outer_shadow_falloff(signedDistance : f32, sigma : f32) -> f32 {
2593
3140
  /// The value is an ALPHA on pure black, so what it composites to is the backdrop
2594
3141
  /// times '1 - alpha' \u2014 multiplicative occlusion, and exactly zero over black,
2595
3142
  /// with no branch for it.
2596
- fn outer_shadow_alpha(uv : vec2f, upsampled : f32, fieldSize : vec2f) -> f32 {
2597
- if (ou.shadow.x <= 0.0) {
2598
- return 0.0;
3143
+ fn outer_shadow(uv : vec2f, upsampled : f32, fieldSize : vec2f) -> ShadowSample {
3144
+ var out : ShadowSample;
3145
+ out.alpha = 0.0;
3146
+ out.falloff = 0.0;
3147
+ out.castSpanCss = 0.0;
3148
+ if (ou.shadow.x <= 0.0 && ou.shadowThick.x <= 0.0) {
3149
+ return out;
2599
3150
  }
2600
3151
  /*
2601
3152
  * The shift can leave the field texture, and clamping alone reads a lie there.
@@ -2631,19 +3182,78 @@ fn outer_shadow_alpha(uv : vec2f, upsampled : f32, fieldSize : vec2f) -> f32 {
2631
3182
  }
2632
3183
 
2633
3184
  // The size law reaches the amplitude and nothing else \u2014 the reference's three
2634
- // lengths are span-invariant across 32\u2026160 px. The gain rides the thickness of
2635
- // the surface that CAST the shadow, which is the one read at the offset
2636
- // position rather than the one under the pixel being shaded.
3185
+ // lengths are span-invariant across 32\u2026160 px. Every span-keyed quantity here
3186
+ // rides the thickness and the span of the surface that CAST the shadow, which
3187
+ // is the one read at the offset position rather than the one under the pixel
3188
+ // being shaded.
2637
3189
  //
2638
- // Applied on '1 - alpha' rather than on the alpha: the gain is a fraction of
2639
- // the remaining transparency in LINEAR light ('sizeOuterShadowOcclusionAt'),
2640
- // and (1 - occ) is exactly what raising to 1/2.4 turns into (1 - alpha), so the
2641
- // two forms are the same law and neither needs the other's space.
2642
- let sizeK = clamp(shadowAux.z, 0.0, 1.0);
2643
- let sizeFold = pow(max(1.0 - ou.shadowSize.x * sizeK, 0.0), 1.0 / 2.4);
2644
- let alpha = 1.0 - (1.0 - ou.shadow.x) * sizeFold;
2645
-
2646
- return alpha * outer_shadow_falloff(shadowField.x + clampedOffCss - ou.shadow.z, ou.shadow.y);
3190
+ // The thickness curve off the casting surface's span (W11c: the span rides
3191
+ // the field), written as the body's own is below.
3192
+ let castT = clamp(
3193
+ (shadowAux.z - ou.scatter.z) / max(ou.scatter.w - ou.scatter.z, 1e-6),
3194
+ 0.0,
3195
+ 1.0,
3196
+ );
3197
+ let sizeK = clamp(castT * castT * (3.0 - 2.0 * castT) * clamp(ou.tone.w, 0.0, 1.0), 0.0, 1.0);
3198
+
3199
+ // The two regimes and the knee between them (W14 G1). The thin amplitude is
3200
+ // resolved on the CPU because its key \u2014 the backdrop's luminance \u2014 is one
3201
+ // number for the whole group; the thick one is a span law and has to be per
3202
+ // pixel. The blend is the smoothstep of 'sizeK', which is exactly the curve
3203
+ // 'tone_response' blends its own thin and thick rows across: one knee for the
3204
+ // face and the shadow, which is the charter's third binding rule read from
3205
+ // the other side.
3206
+ let blend = sizeK * sizeK * (3.0 - 2.0 * sizeK);
3207
+ let regime = mix(ou.shadow.x, outer_shadow_thick(shadowAux.z), blend);
3208
+ // The size gain, on the relative form 'sizeOuterShadowOcclusionAt' uses, in
3209
+ // LINEAR light; then the one conversion to the canvas's compositing space,
3210
+ // which is where the black term is actually composited.
3211
+ let occ = clamp(regime + ou.shadowSize.x * sizeK * (1.0 - regime), 0.0, 1.0);
3212
+ out.alpha = 1.0 - pow(1.0 - occ, 1.0 / 2.4);
3213
+ out.falloff = outer_shadow_falloff(shadowField.x + clampedOffCss - ou.shadow.z, ou.shadow.y);
3214
+ out.castSpanCss = shadowAux.z;
3215
+ return out;
3216
+ }
3217
+
3218
+ /// The lift (W14 G1) \u2014 the shadow's second term, in LINEAR light, premultiplied
3219
+ /// by nothing yet: a blurred copy of the backdrop's own light, at an amplitude
3220
+ /// that is zero below the knee and saturating above it, on the SAME falloff the
3221
+ /// black term rides.
3222
+ ///
3223
+ /// 'V' is the backdrop chain read at the pixel's OWN position \u2014 the copy is of
3224
+ /// the backdrop beneath the shadow, not of the backdrop under the surface \u2014 at
3225
+ /// the LOD whose blur is the profile's 40 CSS px. The chain's uv transform is on
3226
+ /// viewport-normalised coordinates, so every pixel this pass draws has a valid
3227
+ /// sample: unlike the field texture, which the offset shift can walk off the top
3228
+ /// of, the chain covers the whole viewport and there is nothing to reconstruct.
3229
+ ///
3230
+ /// Zero over black by construction (V = 0), zero below the knee by the rise, and
3231
+ /// zero where the group has no backdrop to copy \u2014 so 'dark-solid', 'impulse',
3232
+ /// every thin cell and every unsampled group stay byte-for-byte what they were.
3233
+ fn outer_shadow_lift(viewport01 : vec2f, shadow : ShadowSample) -> vec3f {
3234
+ if (ou.flags.x <= 0.5 || ou.shadowLift.x <= 0.0 || shadow.falloff <= 0.0) {
3235
+ return vec3f(0.0);
3236
+ }
3237
+ // Written out rather than through WGSL's 'smoothstep', which is undefined
3238
+ // where the two edges coincide \u2014 a profile is entitled to set the rise's foot
3239
+ // and top to one number, and material.ts's own 'smoothstep' degrades that to a
3240
+ // step rather than to a NaN. Same rule as the tone band's divide above.
3241
+ let riseT = clamp(
3242
+ (shadow.castSpanCss - ou.shadowLift.y) / max(ou.shadowLift.z - ou.shadowLift.y, 1e-6),
3243
+ 0.0,
3244
+ 1.0,
3245
+ );
3246
+ let rise = riseT * riseT * (3.0 - 2.0 * riseT);
3247
+ if (rise <= 0.0) {
3248
+ return vec3f(0.0);
3249
+ }
3250
+ let uv = clamp(viewport01 * ou.fit.xy + ou.fit.zw, vec2f(0.0), vec2f(1.0));
3251
+ let chainSample = textureSampleLevel(backdropChain, backdropSampler, uv, ou.shadowLift.w);
3252
+ // Premultiplied linear in, straight colour out \u2014 the same unpremultiply the
3253
+ // body's own samples take, for the same reason: a partially transparent
3254
+ // backdrop must not darken what the material adds back.
3255
+ let v = chainSample.rgb / max(chainSample.a, 1e-6);
3256
+ return v * (ou.shadowLift.x * rise * shadow.falloff);
2647
3257
  }
2648
3258
 
2649
3259
  @fragment
@@ -2655,13 +3265,16 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2655
3265
  // contour to the coarse grid and the rim would step along it.
2656
3266
  var field : vec4f;
2657
3267
  var aux : vec4f;
3268
+ var aux2 : vec4f;
2658
3269
  if (ou.flags.w > 0.5) {
2659
3270
  field = textureSampleLevel(fieldTexture, fieldSampler, in.uv, 0.0);
2660
3271
  aux = textureSampleLevel(auxTexture, fieldSampler, in.uv, 0.0);
3272
+ aux2 = textureSampleLevel(aux2Texture, fieldSampler, in.uv, 0.0);
2661
3273
  } else {
2662
3274
  let texel = vec2i(in.uv * ou.flags.yz);
2663
3275
  field = textureLoad(fieldTexture, texel, 0);
2664
3276
  aux = textureLoad(auxTexture, texel, 0);
3277
+ aux2 = textureLoad(aux2Texture, texel, 0);
2665
3278
  }
2666
3279
 
2667
3280
  let d = field.x;
@@ -2677,61 +3290,202 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2677
3290
  * is clipped out of its own border box, and the two meet across the coverage
2678
3291
  * ramp with no seam because it is one expression.
2679
3292
  */
2680
- let shadowAlpha = outer_shadow_alpha(in.uv, ou.flags.w, ou.flags.yz);
3293
+ let shadowSample = outer_shadow(in.uv, ou.flags.w, ou.flags.yz);
3294
+ let shadowAlpha = shadowSample.alpha * shadowSample.falloff;
3295
+ let viewport01 = in.position.xy / ou.screen.xy;
3296
+ // Resolved once, for both sides of the coverage ramp: the lift fills exactly
3297
+ // what the surface's coverage leaves, on the same rule and in the same
3298
+ // expression as the black term's alpha at the bottom of this function, so the
3299
+ // two meet across the ramp with no seam. At full coverage it is not computed
3300
+ // at all and the chain read is not taken.
3301
+ var liftEncoded = vec3f(0.0);
3302
+ if (coverage < 1.0) {
3303
+ let lift = outer_shadow_lift(viewport01, shadowSample);
3304
+ liftEncoded = min(linear_to_srgb(max(lift, vec3f(0.0))), vec3f(shadowAlpha));
3305
+ }
2681
3306
  if (coverage <= 0.0) {
2682
- return vec4f(0.0, 0.0, 0.0, shadowAlpha);
3307
+ /*
3308
+ * The lift (W14 G1) makes this a premultiplied COMPOSITE rather than an
3309
+ * alpha on black: the compositor produces 'page\xB7(1 \u2212 shadowAlpha) + lift',
3310
+ * which is the reference's own two-term form outside the coverage.
3311
+ *
3312
+ * The added light is computed in linear light and encoded on its own before
3313
+ * it is emitted, because it IS its own light \u2014 over the checkerboard's black
3314
+ * squares, where the multiply is inert, what the reference adds is exactly
3315
+ * this term and nothing else, and encoding it alone reproduces G0's measured
3316
+ * +0.048 encoded at span 160 from its +0.0038 linear (claims \xA75.62 \xA72, \xA73).
3317
+ * Where the backdrop is bright the encoding of a sum and the sum of the
3318
+ * encodings differ, and this term is the second: the residual is bounded by
3319
+ * the term's own size and is well inside the bed's \xB14/255, on the same
3320
+ * honest-floor argument 'outerShadowAlpha' makes for the black term's space.
3321
+ *
3322
+ * A premultiplied layer may not carry a channel above its own alpha, so the
3323
+ * emitted light is capped at 'shadowAlpha'. At the profile's constants the
3324
+ * cap is inert everywhere on the falloff: both terms are proportional to the
3325
+ * same F, and the lift's encoded value stays at about a sixth of the black
3326
+ * term's alpha from the deepest ring out to where the shadow stops moving a
3327
+ * code. It is here so that a swept amplitude produces a valid layer rather
3328
+ * than an implementation-defined one.
3329
+ */
3330
+ return vec4f(liftEncoded, shadowAlpha);
2683
3331
  }
2684
3332
 
2685
- let viewport01 = in.position.xy / ou.screen.xy;
2686
3333
  let viewportCss = ou.screen.xy * ou.screen.z;
2687
3334
 
2688
- // Per-pixel, unioned through the field pass. See the module note.
2689
- let lensDepth = max(aux.x, 1e-4);
2690
- // The size law's thickness factor for whichever surface owns this pixel, 0..1.
2691
- // Zero on anything at or below the profile's 'sizeSpanMin', and every term
2692
- // below multiplies by it \u2014 so a small control takes the pre-law path exactly.
2693
- let sizeK = clamp(aux.z, 0.0, 1.0);
2694
- // '-d' is depth inside the surface, so the profile runs 1 at the contour to 0
2695
- // at 'lensDepth' inward, and the square makes the falloff read as curvature
2696
- // rather than as a linear ramp.
2697
- let depth = clamp(-d / lensDepth, 0.0, 1.0);
2698
- let profile = (1.0 - depth) * (1.0 - depth);
2699
-
2700
- let displaceCss = -normal * lensDepth * profile * ou.lens.x;
3335
+ // Per-pixel, unioned through the field pass. See the module note. 'aux.x' is
3336
+ // the authored thickness times the lensStrength channel (W12 G2); the lens's
3337
+ // depth and the inner shadow's are both evaluated from it and the span below.
3338
+ let lensThick = max(aux.x, 0.0);
3339
+ // The thickness curve off the span of whichever surface owns this pixel
3340
+ // (W11c). 'sizeK' is the thickness factor, 0..1: zero on anything at or below
3341
+ // the profile's 'sizeSpanMin', saturated at 'sizeSpanMax', folded under the
3342
+ // preference \u2014 the occlusion, the inner shadow and the tone response multiply
3343
+ // by it, so a small control takes the pre-law path exactly. Written out as a
3344
+ // smoothstep with a guarded denominator, so a profile that collapses the band
3345
+ // degrades to a step rather than to NaN.
3346
+ let span = aux.z;
3347
+ let fold = clamp(ou.tone.w, 0.0, 1.0);
3348
+ let thickT = clamp((span - ou.scatter.z) / max(ou.scatter.w - ou.scatter.z, 1e-6), 0.0, 1.0);
3349
+ // The unfolded curve, which the depth ramp's start grades along below, and the
3350
+ // folded factor every other facet multiplies by. One smoothstep, two readings.
3351
+ let sizeThick = thickT * thickT * (3.0 - 2.0 * thickT);
3352
+ let sizeK = clamp(sizeThick * fold, 0.0, 1.0);
3353
+ /*
3354
+ * The body's mix is the span curve W11c fitted with a ramp in DEPTH riding on
3355
+ * top of it near the contour (W13 G1, from the measurement of claims \xA75.61 \xA72
3356
+ * and the re-forming its first runtime sweep forced):
3357
+ *
3358
+ * kDeep = floor + (1 - floor) * smoothstep(sizeSpanMin, sizeScatterSpanMax, span)
3359
+ * s0 = startThin + (startThick - startThin) * sizeThickness(span)
3360
+ * + (startFar - startThick) * smoothstep(sizeSpanMax, sizeScatterSpanMax, span)
3361
+ * s(u) = (1 - kDeep) + max(0, s0 - (1 - kDeep)) * max(0, 1 - u / U)
3362
+ * k(u) = 1 - s(u)
3363
+ *
3364
+ * The first form of the ramp replaced the span curve outright and its sweep
3365
+ * measured what that cost: the ramp's own projection onto one number per
3366
+ * surface runs 0.43-0.56 where the curve it replaced runs 0.41-1.00, so the
3367
+ * family is nearly span-flat where the bed is strongly span-graded and no
3368
+ * point in 81 reached the wave's stops. So the curve stays as the deep value
3369
+ * and the ramp is what it always measured as - a near-contour excursion.
3370
+ *
3371
+ * The SECOND form gave that excursion one start per scale, and its own sweep
3372
+ * refuted that arithmetically (claims \xA75.64 \xA72): the smallest cell's span is
3373
+ * exactly sizeSpanMin, so its deep sharp share is exactly 1 - floor and no
3374
+ * start at or below it can touch that cell, while the next span up only
3375
+ * improves BELOW a start lower still. So the start grades with span too, and
3376
+ * along the material's own thin/thick curve rather than a new one - the same
3377
+ * smoothstep 'sizeK' is built from above, unfolded, because s0 is a share the
3378
+ * reference has and not a rise a preference removes. The fold below still
3379
+ * applies once, on the composed mix.
3380
+ *
3381
+ * The THIRD form stopped there and its holdout failed on one row for the
3382
+ * form's own arithmetic (claims \xA75.67 \xA74): sizeThickness saturates at
3383
+ * sizeSpanMax, so every thick span got the same start while the reference's
3384
+ * start keeps FALLING across the thick spans, and because kDeep keeps rising
3385
+ * to sizeScatterSpanMax the excursion grew with span where the reference's
3386
+ * shrinks. So the FOURTH form declines the start past the knee along the
3387
+ * scatter span curve itself \u2014 the same smoothstep 'deepT' is built from, on
3388
+ * the band above the thickness knee \u2014 from the thick anchor to startFar at
3389
+ * sizeScatterSpanMax. Same curve read twice, no new span statistic.
3390
+ *
3391
+ * '-d' is the depth, in group-local CSS px and unclamped - the same quantity
3392
+ * the lens's 'lensT' and the inner shadow's 'shadowT' are evaluated from,
3393
+ * except that those two divide it by a depth of their own while this reads it
3394
+ * absolutely, because the reach measured as a LENGTH rather than as a fraction
3395
+ * of the span. The reach reaches this shader already divided by the device
3396
+ * ratio ('lensOval.z'), so the ratio 'u / U' is the same number in CSS px as
3397
+ * it is in the device px the profile names.
3398
+ *
3399
+ * 'max(s0 - sDeep, 0)' rather than a signed difference: the excursion is the
3400
+ * band the reference has ABOVE the body, and a span whose deep sharp share
3401
+ * already exceeds s0 has nothing to add rather than something to subtract.
3402
+ *
3403
+ * The fold keeps its W11c semantics on the composed law: the floor is the
3404
+ * frost the material has at any size and is not folded, everything above it -
3405
+ * the span curve's rise and the ramp's excursion alike - is depth and is, so
3406
+ * fold 1 renders the law and fold 0 sits at the floor exactly.
3407
+ */
3408
+ let scatterFloor = clamp(ou.scatter.x, 0.0, 1.0);
3409
+ let deepT = clamp((span - ou.scatter.z) / max(ou.lensOval.w - ou.scatter.z, 1e-6), 0.0, 1.0);
3410
+ let kDeep = scatterFloor + (1.0 - scatterFloor) * deepT * deepT * (3.0 - 2.0 * deepT);
3411
+ let sDeep = 1.0 - kDeep;
3412
+ let rampT = max(1.0 - max(-d, 0.0) / max(ou.lensOval.z, 1e-6), 0.0);
3413
+ let farT = clamp((span - ou.scatter.w) / max(ou.lensOval.w - ou.scatter.w, 1e-6), 0.0, 1.0);
3414
+ let farS = farT * farT * (3.0 - 2.0 * farT);
3415
+ let rampStart = ou.scatter.y + (ou.shadowSize.z - ou.scatter.y) * sizeThick
3416
+ + (ou.shadowThick.w - ou.shadowSize.z) * farS;
3417
+ let sharpShare = clamp(sDeep + max(rampStart - sDeep, 0.0) * rampT, 0.0, 1.0);
3418
+ let kScatter = clamp(scatterFloor + ((1.0 - sharpShare) - scatterFloor) * fold, 0.0, 1.0);
3419
+ // The inner shadow's depth and profile: W2's law, byte for byte \u2014 the
3420
+ // thickness times the size gain (folded through 'sizeK'), clamped to the
3421
+ // shorter half extent, and a square on it. The lens ran on this until W12 G2;
3422
+ // the occlusion keeps it, because nothing measured it as wrong, and every
3423
+ // solid-backdrop cell renders exactly as it did.
3424
+ let shadowLensDepth = max(min(lensThick * (1.0 + (ou.lens.z - 1.0) * sizeK), span * 0.5), 1e-4);
3425
+ let shadowT = clamp(-d / shadowLensDepth, 0.0, 1.0);
3426
+ let shadowProfile = (1.0 - shadowT) * (1.0 - shadowT);
3427
+
3428
+ // The lens (W12 G2, claims \xA75.51) \u2014 see the module note. The reference's
3429
+ // height and amount laws off the span, scaled by the thickness over the
3430
+ // reference's unit, folded like the W2 law (at fold 0 the depth is the
3431
+ // authored thickness and nothing more), and clamped to the shorter half
3432
+ // extent with the magnitude clamped by the same ratio.
3433
+ let thickScale = lensThick / max(ou.lensShape.w, 1e-4);
3434
+ let heightBase = min(ou.lensLaw.x * span, ou.lensLaw.y);
3435
+ let amountBase = min(ou.lensLaw.z * span, ou.lensLaw.w);
3436
+ let depthUnclamped = mix(lensThick, heightBase * thickScale, fold);
3437
+ let lensDepth = clamp(depthUnclamped, 0.0, span * 0.5);
3438
+ let clampRatio = select(0.0, lensDepth / depthUnclamped, depthUnclamped > 1e-6);
3439
+ let magnitude = ou.lens.y * mix(lensThick, amountBase * thickScale, fold) * clampRatio;
3440
+ let extent = max(ou.lensShape.x * lensDepth, 1e-4);
3441
+ // One steep power over the extent; '-d' is depth inside the surface.
3442
+ let lensT = max(1.0 - max(-d, 0.0) / extent, 0.0);
3443
+ let displacementCss = magnitude * pow(lensT, ou.lensShape.y) * ou.lens.x;
3444
+
3445
+ // The direction: the field's gradient blended toward the oval inscribed in
3446
+ // the surface's box (the reference's 'gradientOvalization'), on from the
3447
+ // knee, with the magnitude fixed \u2014 which tilts the displacement toward the
3448
+ // edge's midpoint and magnifies the band along the edge.
3449
+ let ovalT = clamp((span - ou.lensOval.x) / max(ou.lensOval.y - ou.lensOval.x, 1e-6), 0.0, 1.0);
3450
+ let omega = ou.lensShape.z * ovalT * ovalT * (3.0 - 2.0 * ovalT);
3451
+ let halfExt = max(aux2.zw, vec2f(1e-4));
3452
+ let rel = aux2.xy;
3453
+ let unitR = max(length(rel / halfExt), 1e-6);
3454
+ let ovalGrad = (rel / (halfExt * halfExt)) * (min(halfExt.x, halfExt.y) / unitR);
3455
+ let blended = (1.0 - omega) * normal + omega * ovalGrad;
3456
+ let blendLen = length(blended);
3457
+ let direction = select(normal, blended / blendLen, blendLen > 1e-6);
3458
+ let displaceCss = -direction * displacementCss;
2701
3459
  let refracted01 = viewport01 + displaceCss / viewportCss;
2702
3460
 
2703
- let straightUv = clamp(viewport01 * ou.fit.xy + ou.fit.zw, vec2f(0.0), vec2f(1.0));
2704
3461
  let refractedUv = clamp(refracted01 * ou.fit.xy + ou.fit.zw, vec2f(0.0), vec2f(1.0));
2705
3462
 
2706
- let bodyLod = clamp(lensDepth * ou.lens.y, 0.0, ou.lens.w);
2707
- let lod = clamp(bodyLod - ou.lens.z * profile, 0.0, ou.lens.w);
2708
-
2709
3463
  /*
2710
3464
  * The scattering facet of the size law: "a softer scattering of light".
2711
3465
  *
2712
3466
  * The body texture is one blur for the whole backdrop source \u2014 it is built once
2713
3467
  * per source per frame, so it cannot be per-surface. The chain beside it can:
2714
3468
  * 'size.w' is the chain level whose blur already matches that body, so
2715
- * 'size.w + log2(scatterGain)' is the level whose blur is 'scatterGain' times
2716
- * wider, and lerping the body toward it by 'sizeK' widens the kernel with the
2717
- * surface. At sizeK = 0 the weight is zero and the body sample stands alone,
2718
- * which is what makes the whole facet inert on a small control.
3469
+ * 'size.w + log2(sizeScatterGainMax)' is the level whose blur is the gain times
3470
+ * wider \u2014 the HEAVY component, at one fixed level \u2014 and lerping the body toward
3471
+ * it by 'kScatter' is what the reference's interior measures (W11c, claims
3472
+ * \xA75.41): a sharp component near the body's \u03C3 and a heavy one near \u03C3 10, mixed
3473
+ * by a share that is \u2248 0.4 on a small control and rises with the span. The
3474
+ * mix, not the level, is what the span moves.
2719
3475
  */
2720
- let scatterGain = 1.0 + (ou.size.x - 1.0) * sizeK;
2721
- let scatterLod = clamp(ou.size.w + log2(max(scatterGain, 1e-4)), 0.0, ou.lens.w);
3476
+ let scatterLod = clamp(ou.size.w + log2(max(ou.size.x, 1e-4)), 0.0, ou.lens.w);
2722
3477
 
2723
3478
  var backdrop = vec3f(0.0);
2724
3479
  if (ou.flags.x > 0.5) {
2725
- let lensSample = textureSampleLevel(backdropChain, backdropSampler, refractedUv, lod);
2726
- let bodySample = textureSampleLevel(backdropBody, backdropSampler, straightUv, 0.0);
2727
- let scatterSample = textureSampleLevel(backdropChain, backdropSampler, straightUv, scatterLod);
3480
+ // Both components at the refracted position (W11c G2): the band and the
3481
+ // interior are one body, and the displacement alone is the lens.
3482
+ let bodySample = textureSampleLevel(backdropBody, backdropSampler, refractedUv, 0.0);
3483
+ let scatterSample = textureSampleLevel(backdropChain, backdropSampler, refractedUv, scatterLod);
2728
3484
  // Premultiplied linear in, straight colour out: the material composites over
2729
3485
  // whatever is behind it, so a partially transparent backdrop must not darken
2730
3486
  // the glass.
2731
- let lensColour = lensSample.rgb / max(lensSample.a, 1e-6);
2732
3487
  let scatterColour = scatterSample.rgb / max(scatterSample.a, 1e-6);
2733
- let bodyColour = mix(bodySample.rgb / max(bodySample.a, 1e-6), scatterColour, sizeK);
2734
- backdrop = mix(bodyColour, lensColour, profile);
3488
+ backdrop = mix(bodySample.rgb / max(bodySample.a, 1e-6), scatterColour, kScatter);
2735
3489
  }
2736
3490
 
2737
3491
  // Adaptive tint. 'adapt.w' is the strength the accessibility policy and the
@@ -2739,8 +3493,6 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2739
3493
  // is what a 'hint' or 'none' group gets.
2740
3494
  let neutral = mix(ou.tint.rgb, ou.adapt.rgb, ou.adapt.w);
2741
3495
 
2742
- let backdropLuma = dot(backdrop, vec3f(0.2126, 0.7152, 0.0722));
2743
-
2744
3496
  /*
2745
3497
  * Backdrop tone adaptation (W7) \u2014 step two of the composition contract, between
2746
3498
  * the colour scheme's neutral and the author's tint.
@@ -2785,57 +3537,148 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2785
3537
  * and 'adaptedTintAlpha' in material.ts.
2786
3538
  */
2787
3539
  let sizedAlpha = ou.tint.w + ou.size.y * sizeK * (1.0 - ou.tint.w);
2788
- let adaptedAlpha = sizedAlpha + toneAdapt * (1.0 - sizedAlpha);
2789
- var adapted = neutral;
3540
+
3541
+ /*
3542
+ * The backdrop tone response solve (W9) \u2014 the law that owns the interior
3543
+ * MEAN, where the collapse below owns texture and nothing else (claims
3544
+ * \xA75.33). The composite under this mechanism reduces exactly to
3545
+ * mean = (1 \u2212 k)\xB7M\u2080 + k\xB7toneLuma with M\u2080 = (1 \u2212 \u03B1)\xB7bgLinear + \u03B1\xB7L(neutral),
3546
+ * so the neutral's tone is solved in closed form: shift its luma so the
3547
+ * post-collapse mean lands on R(encodedInput, sizeK), the reference's own
3548
+ * measured response. Chroma is untouched \u2014 the shift is achromatic \u2014 and
3549
+ * the author tint still displaces the result per the composition contract.
3550
+ *
3551
+ * Three stand-downs, each measured rather than defensive: the whole axis is
3552
+ * off where no backdrop tone was measured (same gate as the collapse); the
3553
+ * solve's authority fades to zero below the dark anchor, where the only
3554
+ * evidence is the impulse cell the collapse constants were fitted on; and
3555
+ * at k \u2192 1 the collapse owns the pixel outright, so the solve's
3556
+ * extrapolation is never evaluated against a vanishing (1 \u2212 k).
3557
+ */
3558
+ var solvedNeutral = neutral;
3559
+ var solvedAlpha = sizedAlpha;
3560
+ if (ou.flags.x > 0.5 && ou.toneAdapt.w > 0.0 && ou.toneRowThin.w > 0.0 &&
3561
+ sizedAlpha > 1e-3 && toneAdapt < 0.995) {
3562
+ let encodedInput = srgb_encode(ou.toneColour.w);
3563
+ let anchor = max(ou.toneAnchor.x, 1e-4);
3564
+ let authority =
3565
+ smoothstep(anchor * 0.5, anchor, encodedInput) * clamp(ou.toneRowThin.w, 0.0, 1.0);
3566
+ if (authority > 0.0) {
3567
+ let response = tone_response(encodedInput, sizeK);
3568
+ // The collapse's mean pull is toward L(toneColour.rgb) \u2014 the LINEAR
3569
+ // mean, which toneAnchor.w carries \u2014 not toward the encoded level.
3570
+ let preCollapse = (response - toneAdapt * ou.toneAnchor.w) / (1.0 - toneAdapt);
3571
+ let neutralLuma = dot(neutral, vec3f(0.2126, 0.7152, 0.0722));
3572
+ let nominal = (1.0 - sizedAlpha) * ou.toneAnchor.w + sizedAlpha * neutralLuma;
3573
+ let shift = (preCollapse - nominal) / sizedAlpha * authority * ou.toneAdapt.w;
3574
+ solvedNeutral = clamp(neutral + vec3f(shift), vec3f(0.0), vec3f(1.0));
3575
+ /*
3576
+ * The light attractor needs OPACITY. The light scheme's neutral is
3577
+ * already at white, so an upward shift clamps to nothing \u2014 and the
3578
+ * reference's light-adapted state is the material gone opaque-bright,
3579
+ * the same "an adapting material stops transmitting" the collapse's
3580
+ * alpha half was measured on. Whatever the clamp truncated is carried
3581
+ * by the alpha, solved against the same composite and folded by the
3582
+ * same authority. One-sided by design: darkward opacity is the
3583
+ * collapse's own axis with its own fitted constants.
3584
+ */
3585
+ let solvedLuma = dot(solvedNeutral, vec3f(0.2126, 0.7152, 0.0722));
3586
+ let achieved = (1.0 - sizedAlpha) * ou.toneAnchor.w + sizedAlpha * solvedLuma;
3587
+ if (preCollapse > achieved + 1e-4 && solvedLuma > ou.toneAnchor.w + 1e-3) {
3588
+ let alphaTarget = clamp(
3589
+ (preCollapse - ou.toneAnchor.w) / (solvedLuma - ou.toneAnchor.w),
3590
+ sizedAlpha,
3591
+ 1.0,
3592
+ );
3593
+ solvedAlpha = mix(sizedAlpha, alphaTarget, authority * ou.toneAdapt.w);
3594
+ }
3595
+ }
3596
+ }
3597
+
3598
+ let adaptedAlpha = solvedAlpha + toneAdapt * (1.0 - solvedAlpha);
3599
+ var adapted = solvedNeutral;
2790
3600
  if (toneAdapt > 0.0 && adaptedAlpha > 0.0) {
2791
3601
  adapted =
2792
- (neutral * ((1.0 - toneAdapt) * sizedAlpha) + ou.toneColour.rgb * toneAdapt) / adaptedAlpha;
3602
+ (solvedNeutral * ((1.0 - toneAdapt) * solvedAlpha) + ou.toneColour.rgb * toneAdapt) /
3603
+ adaptedAlpha;
2793
3604
  }
2794
3605
 
2795
- // The author tint. 'aux.w' is the per-pixel strength, unioned in the field pass,
2796
- // so a toolbar can carry one tinted control among plain ones; the seed is a
2797
- // group uniform. At strength 0 this is the identity and the material is the one
2798
- // the calibration bed measures, byte for byte.
2799
- //
2800
- // Apple's mechanism, not a fill: the seed is the middle of a range of tones
2801
- // 'mapped to content brightness underneath'. 'backdrop' is what this pixel is
2802
- // actually looking through \u2014 lens-displaced, LOD-sampled, already the thing the
2803
- // material transmits \u2014 so the tone is taken against the same light the tint is
2804
- // about to be mixed into, and a tinted surface over a dark backdrop settles to a
2805
- // shade of the author's colour rather than sitting on it as paint.
2806
- //
2807
- // That 'backdrop' is also where the size law's scattering has already been
2808
- // applied (above), and that is the right order rather than a coincidence: a
2809
- // large surface diffuses more of what is behind it, so the tone the tint maps
2810
- // to should be the diffused light, not the sharp light nobody sees through it.
2811
- //
2812
- // It displaces the ADAPTED neutral, not the raw one: a full-strength tint is its
2813
- // own adaptation and replaces this axis outright, while a partial one moves with
2814
- // it. That order is the wave's composition contract, in one line.
2815
- var tintColour = adapted;
2816
- if (aux.w > 0.0) {
2817
- let t = smoothstep(ou.tone.z, ou.tone.w, backdropLuma);
2818
- let low = ou.seed.rgb * ou.tone.x;
2819
- let high = ou.seed.rgb + (vec3f(1.0) - ou.seed.rgb) * ou.tone.y;
2820
- // 'seed.w' is the contrast regime's grip on the excursion, never on the hue:
2821
- // at 0 the material shows the author's colour flat and stops responding.
2822
- let tone = mix(ou.seed.rgb, mix(low, high, t), clamp(ou.seed.w, 0.0, 1.0));
2823
- tintColour = mix(adapted, tone, clamp(aux.w, 0.0, 1.0));
3606
+ // The material, untinted: the adapted neutral over what this pixel looks
3607
+ // through, at the material's own occlusion. That alpha is what reduced
3608
+ // transparency lifts and what the size law thickens ("a larger size is more
3609
+ // opaque" is a statement about how much material there is), and the backdrop
3610
+ // adaptation moved it above. The author's colour never touches it \u2014 but not
3611
+ // for the reason the composition contract first gave (claims \xA75.36).
3612
+ var colour = mix(backdrop, adapted, adaptedAlpha);
3613
+ /*
3614
+ * How much of this pixel the SURFACE owns, as the canvas will composite it.
3615
+ *
3616
+ * With a backdrop the composite above is the whole pixel \u2014 the pass sampled
3617
+ * what is behind the surface and mixed it in, so the output is opaque inside
3618
+ * the contour and the page beneath the canvas never shows. With no backdrop
3619
+ * the composite is not here to make: a 'css-backdrop' group's blurred
3620
+ * backdrop is a DOM proxy under this canvas, a 'none' group's is the page
3621
+ * itself, and the browser is the compositor. So the material leaves as a
3622
+ * LAYER \u2014 the adapted colour at the material's own alpha, premultiplied on
3623
+ * the way out \u2014 and every term below that would have shaped the composite
3624
+ * shapes the layer instead, in the form that composites to the same thing.
3625
+ *
3626
+ * Before W11a this path mixed the material over a black backdrop and wrote
3627
+ * it opaque: a nested surface over glass rendered as a flat 0.468 where the
3628
+ * reference reads 0.89 (claims \xA75.38 \xA75). The rows that floored were that
3629
+ * grey. The pair the layer is written at is the host's
3630
+ * ('GroupRenderInput.unsampledMaterial'): the browser composites this canvas
3631
+ * in encoded sRGB, so the alpha is the CSS tier's, not the linear profile's.
3632
+ */
3633
+ var bodyAlpha = 1.0;
3634
+ if (ou.flags.x <= 0.5) {
3635
+ colour = adapted;
3636
+ bodyAlpha = adaptedAlpha;
2824
3637
  }
2825
3638
 
2826
- // The tint layer's ALPHA is untouched by the author's colour. It is the
2827
- // material's occlusion \u2014 what reduced transparency lifts, and the axis the
2828
- // system's own Clear-to-Tinted preference runs on \u2014 and an author choosing a
2829
- // colour does not get to move it.
3639
+ // The author tint (W10). 'aux.w' is the per-pixel strength, unioned in the
3640
+ // field pass, so a toolbar can carry one tinted control among plain ones; the
3641
+ // seed is a group uniform. At strength 0 this is the identity and the material
3642
+ // is the one the calibration bed measures, byte for byte.
2830
3643
  //
2831
- // The size law does move it, and the two do not collide: "a larger size is more
2832
- // opaque, a smaller size is clearer" is a statement about how much material
2833
- // there is, not about what colour it is. A fraction of whatever transparency
2834
- // the resolved alpha still has, for the reason 'increasedOcclusionLift' is a
2835
- // fraction \u2014 see material.ts.
3644
+ // Apple's mechanism, measured per pixel: the tinted material is an OPAQUE
3645
+ // layer of the seed at a shade that is linear in the luminance the untinted
3646
+ // material shows at this pixel \u2014 about half the seed's light over black
3647
+ // content, the seed itself over white, the seed's chromaticity intact
3648
+ // throughout. That layer composites over the material at the AUTHOR's
3649
+ // opacity in the encoded space, which is what a CALayer with 'opacity' does
3650
+ // and how the reference's half-strength cell measures. So a tinted surface
3651
+ // over a checkerboard shows the checker as light and dark ORANGE, not as
3652
+ // orange glass with the checker behind it \u2014 the material's own alpha is not
3653
+ // what a tinted surface shows.
2836
3654
  //
2837
- // The backdrop adaptation moves it, above, and the author's colour does not.
2838
- var colour = mix(backdrop, tintColour, adaptedAlpha);
3655
+ // 'seed.w' is the contrast regime's grip on the excursion, never on the hue;
3656
+ // 'tone.z' is the profile's provenance gate (the dark scheme renders the pure
3657
+ // seed); and '1 \u2212 toneAdapt' folds the shade out where the collapse has made
3658
+ // the material a dark body, where the reference renders the pure seed too.
3659
+ // At zero grip the layer is the bare seed \u2014 the author's colour, flat.
3660
+ if (aux.w > 0.0) {
3661
+ // The untinted material's luminance at this pixel. Over a backdrop that is
3662
+ // the composite; as a layer it is the layer over the tone the host measured
3663
+ // for the group \u2014 zero where nothing was measured, the same reference-level
3664
+ // convention the CSS tier's 'materialLuminance' takes (within its 0.02).
3665
+ let u = bodyAlpha * dot(colour, vec3f(0.2126, 0.7152, 0.0722)) + (1.0 - bodyAlpha) * ou.toneColour.w;
3666
+ let grip = clamp(ou.seed.w, 0.0, 1.0) * clamp(ou.tone.z, 0.0, 1.0) * (1.0 - toneAdapt);
3667
+ let shade = mix(1.0, clamp(mix(ou.tone.x, ou.tone.y, clamp(u, 0.0, 1.0)), 0.0, 1.0), grip);
3668
+ let layer = ou.seed.rgb * shade;
3669
+ let s = clamp(aux.w, 0.0, 1.0);
3670
+ let encodedMaterial = linear_to_srgb(clamp(colour, vec3f(0.0), vec3f(1.0)));
3671
+ let encodedLayer = linear_to_srgb(layer);
3672
+ if (ou.flags.x > 0.5) {
3673
+ colour = srgb_to_linear(mix(encodedMaterial, encodedLayer, vec3f(s)));
3674
+ } else {
3675
+ // The opaque layer at the author's opacity over the material's own layer,
3676
+ // premultiplied: the fold 'tintedCssOptics' makes into one rgba.
3677
+ let premultiplied = (1.0 - s) * bodyAlpha * encodedMaterial + s * encodedLayer;
3678
+ bodyAlpha = 1.0 - (1.0 - s) * (1.0 - bodyAlpha);
3679
+ colour = srgb_to_linear(premultiplied / max(bodyAlpha, 1e-6));
3680
+ }
3681
+ }
2839
3682
 
2840
3683
  /*
2841
3684
  * Everything below is the surface's own APPEARANCE \u2014 the marks that say a
@@ -2861,21 +3704,51 @@ fn fs_optics(in : FullscreenOut) -> @location(0) vec4f {
2861
3704
  // strongest, which is what makes a thicker surface read as heavier \u2014 and the
2862
3705
  // shadow facet of the size law deepens it further with the span.
2863
3706
  let shadowDepth = ou.light.z * (1.0 + (ou.size.z - 1.0) * sizeK);
2864
- colour = colour * (1.0 - profile * shadowDepth * ou.light.w * present);
3707
+ // A multiplicative occlusion of the whole composite. As a layer that is the
3708
+ // colour scaled and the alpha raised \u2014 (k\xB7a\xB7c, 1 \u2212 k\xB7(1 \u2212 a)) composites to
3709
+ // k times what (a\xB7c, a) would \u2014 and at bodyAlpha 1 it is the plain product.
3710
+ let shadowKeep = 1.0 - shadowProfile * shadowDepth * ou.light.w * present;
3711
+ let shadowedAlpha = 1.0 - shadowKeep * (1.0 - bodyAlpha);
3712
+ colour = colour * (shadowKeep * bodyAlpha / max(shadowedAlpha, 1e-6));
3713
+ bodyAlpha = shadowedAlpha;
2865
3714
 
2866
3715
  // Rim and specular from the gradient. The rim is unlit ambient edge brightness;
2867
3716
  // the specular term is the same edge lit from 'light.xy'.
2868
3717
  let rw = rim_weight(d, ou.rim.x);
2869
3718
  let facing = dot(normal, ou.light.xy);
2870
3719
  let spec = pow(clamp(facing, 0.0, 1.0), max(ou.rim.z, 1e-3)) * ou.rim.w;
2871
- colour = colour + vec3f(rw * (ou.rim.y + spec) * present);
3720
+ let rim = rw * (ou.rim.y + spec) * present;
3721
+ if (ou.flags.x > 0.5) {
3722
+ colour = colour + vec3f(rim);
3723
+ } else {
3724
+ // Added light has no premultiplied form of its own \u2014 a canvas colour may
3725
+ // not exceed its alpha \u2014 so the layer carries the light in its opacity:
3726
+ // (a\xB7c + rim, a + rim) composites to dst\xB7(1 \u2212 a) + a\xB7c + rim \u2212 rim\xB7dst,
3727
+ // the additive term short of rim \xD7 dst. Exact wherever the layer is
3728
+ // opaque (an author tint at full strength, or a lit contour pixel whose
3729
+ // alpha the rim fills), and never further off than the rim times what
3730
+ // shows through \u2014 where the mix form ("white over the layer at the rim's
3731
+ // weight") is short by the rim times the whole composite, which halved
3732
+ // the rim on a tinted panel.
3733
+ let carried = clamp(bodyAlpha + rim, 0.0, 1.0);
3734
+ colour = min((colour * bodyAlpha + vec3f(rim)) / max(carried, 1e-6), vec3f(1.0));
3735
+ bodyAlpha = carried;
3736
+ }
2872
3737
 
2873
3738
  // The material over its own shadow over the page, in one premultiplied vector:
2874
- // the colour is the surface's, weighted by its coverage, and the alpha is what
2875
- // the two of them together leave the page \u2014 'coverage' from the surface, and
2876
- // the shadow filling whatever transparency is left.
2877
- let body = encode_output(max(colour, vec3f(0.0)), coverage);
2878
- return vec4f(body.rgb, body.a + shadowAlpha * (1.0 - body.a));
3739
+ // the colour is the surface's, weighted by its coverage and by however much of
3740
+ // the pixel the layer owns, and the alpha is what the two of them together
3741
+ // leave the page. The shadow fills only what the surface's COVERAGE leaves \u2014
3742
+ // clipped out of the silhouette exactly as a 'box-shadow' is clipped out of
3743
+ // its border box \u2014 never what the layer's own transparency leaves: a
3744
+ // translucent surface shows the page through it, not its own shadow. (With
3745
+ // an opaque body the two are the same quantity, which is how the shadow was
3746
+ // first written and why the difference only surfaced with the layer form.)
3747
+ let body = encode_output(max(colour, vec3f(0.0)), coverage * bodyAlpha);
3748
+ return vec4f(
3749
+ body.rgb + liftEncoded * (1.0 - coverage),
3750
+ body.a + shadowAlpha * (1.0 - coverage),
3751
+ );
2879
3752
  }`;
2880
3753
 
2881
3754
  // ../renderer-webgpu/dist/wgsl/prelude.js
@@ -3003,7 +3876,11 @@ function createPassRunner(context) {
3003
3876
  fragment: {
3004
3877
  module,
3005
3878
  entryPoint: "fs_field",
3006
- targets: [{ format: WORKING_TEXTURE_FORMAT }, { format: WORKING_TEXTURE_FORMAT }]
3879
+ targets: [
3880
+ { format: WORKING_TEXTURE_FORMAT },
3881
+ { format: WORKING_TEXTURE_FORMAT },
3882
+ { format: WORKING_TEXTURE_FORMAT }
3883
+ ]
3007
3884
  },
3008
3885
  primitive: { topology: "triangle-list" }
3009
3886
  };
@@ -3067,6 +3944,13 @@ function createPassRunner(context) {
3067
3944
  usage: fieldUsage(),
3068
3945
  label: `vitrea:group:${args.groupId}:aux`
3069
3946
  });
3947
+ const aux2 = pool.acquire(poolKey.groupAux2(args.groupId), {
3948
+ width,
3949
+ height,
3950
+ format: WORKING_TEXTURE_FORMAT,
3951
+ usage: fieldUsage(),
3952
+ label: `vitrea:group:${args.groupId}:aux2`
3953
+ });
3070
3954
  const slot = uniformSlot(`field:${args.groupId}`, 12);
3071
3955
  slot.data[0] = rectWidth;
3072
3956
  slot.data[1] = rectHeight;
@@ -3100,6 +3984,12 @@ function createPassRunner(context) {
3100
3984
  loadOp: "clear",
3101
3985
  storeOp: "store",
3102
3986
  clearValue: { r: 0, g: 0, b: 0, a: 0 }
3987
+ },
3988
+ {
3989
+ view: aux2.createView(),
3990
+ loadOp: "clear",
3991
+ storeOp: "store",
3992
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
3103
3993
  }
3104
3994
  ]
3105
3995
  });
@@ -3116,13 +4006,14 @@ function createPassRunner(context) {
3116
4006
  return {
3117
4007
  field,
3118
4008
  aux,
4009
+ aux2,
3119
4010
  width,
3120
4011
  height,
3121
4012
  upsampled: width !== rectWidth || height !== rectHeight
3122
4013
  };
3123
4014
  },
3124
4015
  opticsPass(encoder, args) {
3125
- const slot = uniformSlot(`optics:${args.groupId}`, 60);
4016
+ const slot = uniformSlot(`optics:${args.groupId}`, 96);
3126
4017
  const d = slot.data;
3127
4018
  d[0] = args.viewportDevice[0];
3128
4019
  d[1] = args.viewportDevice[1];
@@ -3133,8 +4024,8 @@ function createPassRunner(context) {
3133
4024
  d[6] = args.fit[2];
3134
4025
  d[7] = args.fit[3];
3135
4026
  d[8] = args.refractionScale;
3136
- d[9] = args.bodyLodPerPx;
3137
- d[10] = args.rimLodBias;
4027
+ d[9] = args.lensRefractionGain;
4028
+ d[10] = args.shadowDepthSizeGainMax;
3138
4029
  d[11] = args.chainMaxLod;
3139
4030
  d[12] = args.tint[0];
3140
4031
  d[13] = args.tint[1];
@@ -3148,10 +4039,10 @@ function createPassRunner(context) {
3148
4039
  d[21] = args.tintSeed[1];
3149
4040
  d[22] = args.tintSeed[2];
3150
4041
  d[23] = args.tintToneAdaptation;
3151
- d[24] = args.tintTone[0];
3152
- d[25] = args.tintTone[1];
3153
- d[26] = args.tintTone[2];
3154
- d[27] = Math.max(args.tintTone[3], args.tintTone[2] + 1e-4);
4042
+ d[24] = args.tintShade[0];
4043
+ d[25] = args.tintShade[1];
4044
+ d[26] = args.tintShade[2];
4045
+ d[27] = args.sizeFold;
3155
4046
  d[28] = args.rimWidth;
3156
4047
  d[29] = args.rimAlpha;
3157
4048
  d[30] = args.specularPower;
@@ -3182,8 +4073,44 @@ function createPassRunner(context) {
3182
4073
  d[55] = args.outerShadow[3];
3183
4074
  d[56] = args.outerShadowSizeGain;
3184
4075
  d[57] = args.outerShadowRectCssHeight;
3185
- d[58] = 0;
4076
+ d[58] = args.sizeScatterRampStartThick;
3186
4077
  d[59] = 0;
4078
+ d[60] = args.backdropToneAnchorX[0];
4079
+ d[61] = args.backdropToneAnchorX[1];
4080
+ d[62] = args.backdropToneAnchorX[2];
4081
+ d[63] = args.backdropToneLinearMean;
4082
+ d[64] = args.backdropToneResponseThin[0];
4083
+ d[65] = args.backdropToneResponseThin[1];
4084
+ d[66] = args.backdropToneResponseThin[2];
4085
+ d[67] = args.backdropToneResponseStrength;
4086
+ d[68] = args.backdropToneResponseThick[0];
4087
+ d[69] = args.backdropToneResponseThick[1];
4088
+ d[70] = args.backdropToneResponseThick[2];
4089
+ d[71] = 0;
4090
+ d[72] = args.sizeScatterFloor;
4091
+ d[73] = args.sizeScatterRampStartThin;
4092
+ d[74] = args.sizeSpanMin;
4093
+ d[75] = args.sizeSpanMax;
4094
+ d[76] = args.lensHeightPerSpan;
4095
+ d[77] = args.lensHeightMax;
4096
+ d[78] = args.lensAmountPerSpan;
4097
+ d[79] = args.lensAmountMax;
4098
+ d[80] = args.lensExtentGain;
4099
+ d[81] = args.lensProfileExponent;
4100
+ d[82] = args.lensOvalization;
4101
+ d[83] = args.lensThicknessReference;
4102
+ d[84] = args.lensOvalizationSpanMin;
4103
+ d[85] = args.lensOvalizationSpanMax;
4104
+ d[86] = args.sizeScatterRampReachCssPx;
4105
+ d[87] = args.sizeScatterSpanMax;
4106
+ d[88] = args.outerShadowThick[0];
4107
+ d[89] = args.outerShadowThick[1];
4108
+ d[90] = args.outerShadowThick[2];
4109
+ d[91] = args.sizeScatterRampStartFar;
4110
+ d[92] = args.outerShadowLift[0];
4111
+ d[93] = args.outerShadowLift[1];
4112
+ d[94] = args.outerShadowLift[2];
4113
+ d[95] = args.outerShadowLift[3];
3187
4114
  slot.write();
3188
4115
  const chain = args.backdrop?.chain ?? placeholderView;
3189
4116
  const body = args.backdrop?.body ?? placeholderView;
@@ -3206,7 +4133,8 @@ function createPassRunner(context) {
3206
4133
  { binding: 5, resource: body },
3207
4134
  // Linear, no mips: the field targets have one level, and this is only
3208
4135
  // read at all when the governor shrank them below the group's rect.
3209
- { binding: 6, resource: context.flatSampler }
4136
+ { binding: 6, resource: context.flatSampler },
4137
+ { binding: 7, resource: args.fields.aux2.createView() }
3210
4138
  ]
3211
4139
  }));
3212
4140
  pass.draw(3);
@@ -3240,9 +4168,9 @@ function createPassRunner(context) {
3240
4168
  d[22] = args.backdropTone[2];
3241
4169
  d[23] = args.backdropTone[3];
3242
4170
  d[24] = args.backdropToneLevel;
3243
- d[25] = 0;
3244
- d[26] = 0;
3245
- d[27] = 0;
4171
+ d[25] = args.sizeSpanMin;
4172
+ d[26] = args.sizeSpanMax;
4173
+ d[27] = args.sizeFold;
3246
4174
  const fieldRect = args.fieldRectDevice;
3247
4175
  d[28] = args.rectDevice.width / Math.max(fieldRect.width, 1e-6);
3248
4176
  d[29] = args.rectDevice.height / Math.max(fieldRect.height, 1e-6);
@@ -3289,6 +4217,7 @@ function createPassRunner(context) {
3289
4217
  forget(groupId) {
3290
4218
  pool.release(poolKey.groupField(groupId));
3291
4219
  pool.release(poolKey.groupAux(groupId));
4220
+ pool.release(poolKey.groupAux2(groupId));
3292
4221
  storages.get(groupId)?.destroy();
3293
4222
  storages.delete(groupId);
3294
4223
  for (const key of [`field:${groupId}`, `optics:${groupId}`, `highlight:${groupId}`]) {
@@ -3433,6 +4362,9 @@ function createRebuildLedger() {
3433
4362
 
3434
4363
  // ../renderer-webgpu/dist/pyramid.js
3435
4364
  var chainUsage = () => GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING;
4365
+ var densityOf = (source, request) => texelsPerCssPx(source.width, source.height, request.placement, request.viewportCss[0], request.viewportCss[1]);
4366
+ var same = (next, existing) => Math.abs(next - existing) <= 1e-6 * Math.max(1, Math.abs(existing));
4367
+ var sameBody = (existing, request) => same(densityOf({ width: existing.sourceWidth, height: existing.sourceHeight }, request), existing.texelsPerCss) && same(request.bodySigmaCss, existing.bodySigmaCss);
3436
4368
  function createPyramidStore(context) {
3437
4369
  const { device, pool, cache } = context;
3438
4370
  const resources = /* @__PURE__ */ new Map();
@@ -3508,7 +4440,7 @@ function createPyramidStore(context) {
3508
4440
  return target;
3509
4441
  };
3510
4442
  const mipView = (texture, level) => texture.createView({ baseMipLevel: level, mipLevelCount: 1, dimension: "2d" });
3511
- function allocate(sourceId, plan, bodyLevel, sizeEpoch, builtEpoch, bodySigmaTexels) {
4443
+ function allocate(sourceId, plan, bodyLevel, sizeEpoch, builtEpoch, bodySigmaTexels, density, bodySigmaCss) {
3512
4444
  const existing = resources.get(sourceId);
3513
4445
  const bodyWidth = (plan.levels[bodyLevel] ?? plan.levels[0]).width;
3514
4446
  const bodyHeight = (plan.levels[bodyLevel] ?? plan.levels[0]).height;
@@ -3546,7 +4478,11 @@ function createPyramidStore(context) {
3546
4478
  stats,
3547
4479
  sizeEpoch,
3548
4480
  builtEpoch,
3549
- bodySigmaTexels
4481
+ bodySigmaTexels,
4482
+ texelsPerCss: density.texelsPerCss,
4483
+ sourceWidth: density.sourceWidth,
4484
+ sourceHeight: density.sourceHeight,
4485
+ bodySigmaCss
3550
4486
  };
3551
4487
  resources.set(sourceId, next);
3552
4488
  return next;
@@ -3734,7 +4670,7 @@ function createPyramidStore(context) {
3734
4670
  },
3735
4671
  build(request, provider, encoder) {
3736
4672
  const existing = liveResources(request.sourceId);
3737
- if (existing !== void 0 && existing.builtEpoch >= request.epoch && !provider.isDirty()) {
4673
+ if (existing !== void 0 && existing.builtEpoch >= request.epoch && !provider.isDirty() && sameBody(existing, request)) {
3738
4674
  ledger.recordClean();
3739
4675
  return { status: "clean", resources: existing };
3740
4676
  }
@@ -3752,12 +4688,11 @@ function createPyramidStore(context) {
3752
4688
  }
3753
4689
  pendingRelease.push(provider);
3754
4690
  const plan = planPyramid(frame.width, frame.height, request.resolution);
3755
- const [viewportW, viewportH] = request.viewportCss;
3756
- const cover = viewportW > 0 && viewportH > 0 ? Math.max(frame.width / viewportW, frame.height / viewportH) : 1;
4691
+ const texelsPerCss = densityOf(frame, request);
3757
4692
  const planScale = frame.width > 0 ? plan.width / frame.width : 1;
3758
- const bodySigmaTexels = request.bodySigmaCss * cover * planScale;
4693
+ const bodySigmaTexels = request.bodySigmaCss * texelsPerCss * planScale;
3759
4694
  const bodyPlan = bodyBlurPlan(bodySigmaTexels, plan);
3760
- const target = allocate(request.sourceId, plan, bodyPlan.level, frame.sizeEpoch, request.epoch, bodySigmaTexels);
4695
+ const target = allocate(request.sourceId, plan, bodyPlan.level, frame.sizeEpoch, request.epoch, bodySigmaTexels, { texelsPerCss, sourceWidth: frame.width, sourceHeight: frame.height }, request.bodySigmaCss);
3761
4696
  runImport(encoder, request.sourceId, frame, target.chain);
3762
4697
  runChain(encoder, request.sourceId, plan, target.chain);
3763
4698
  runBodyBlur(encoder, request.sourceId, plan, target.chain, target.body, bodyPlan.level, bodyPlan.residualSigmaTexels);
@@ -3873,8 +4808,11 @@ var RENDERER_PASS_IDS = [
3873
4808
  OPTICS_PASS_ID,
3874
4809
  HIGHLIGHT_PASS_ID
3875
4810
  ];
4811
+ var STANDALONE_RESOLUTION = { scale: 1, maxDimension: 2048 };
3876
4812
  function createWebGPURenderer(options = {}) {
3877
4813
  const providers = /* @__PURE__ */ new Map();
4814
+ const placements = /* @__PURE__ */ new Map();
4815
+ const lastResolution = /* @__PURE__ */ new Map();
3878
4816
  const groups = /* @__PURE__ */ new Map();
3879
4817
  const adaptation = /* @__PURE__ */ new Map();
3880
4818
  const lastReadbackAt = /* @__PURE__ */ new Map();
@@ -3963,18 +4901,48 @@ function createWebGPURenderer(options = {}) {
3963
4901
  }
3964
4902
  return state;
3965
4903
  };
3966
- const coverFit = (sourceWidth, sourceHeight) => {
3967
- const viewportAspect = viewport.heightCss > 0 ? viewport.widthCss / viewport.heightCss : 1;
3968
- const sourceAspect = sourceHeight > 0 ? sourceWidth / sourceHeight : 1;
3969
- if (sourceAspect > viewportAspect) {
3970
- const scaleX = viewportAspect / sourceAspect;
3971
- return [scaleX, 1, (1 - scaleX) / 2, 0];
4904
+ const fitFor = (sourceId, pyramid) => {
4905
+ const placement = placements.get(sourceId);
4906
+ return isUsablePlacement(placement) ? placementFit(placement, viewport.widthCss, viewport.heightCss) : coverFit(pyramid.plan.width, pyramid.plan.height, viewport.widthCss, viewport.heightCss);
4907
+ };
4908
+ const liftChainLod = (sigmaCss, pyramid) => {
4909
+ if (pyramid === void 0 || !(sigmaCss > 0))
4910
+ return 0;
4911
+ const planScale = pyramid.sourceWidth > 0 ? pyramid.plan.width / pyramid.sourceWidth : 1;
4912
+ const sigmaTexels = sigmaCss * pyramid.texelsPerCss * planScale;
4913
+ return Math.min(chainLodForSigma(sigmaTexels), pyramid.plan.maxLod);
4914
+ };
4915
+ const staleBodyRebuilds = (explicit, pyramids) => {
4916
+ const requests = [];
4917
+ const named = new Set(explicit.map((request) => request.sourceId));
4918
+ for (const sourceId of providers.keys()) {
4919
+ if (named.has(sourceId))
4920
+ continue;
4921
+ const existing = pyramids.resources(sourceId);
4922
+ if (existing === void 0)
4923
+ continue;
4924
+ const next = texelsPerCssPx(existing.sourceWidth, existing.sourceHeight, placements.get(sourceId), viewport.widthCss, viewport.heightCss);
4925
+ const sameDensity = Math.abs(next - existing.texelsPerCss) <= 1e-6 * Math.max(1, existing.texelsPerCss);
4926
+ const sigmaCss = bodySigmaCssFor(sourceId);
4927
+ const sameSigma = Math.abs(sigmaCss - existing.bodySigmaCss) <= 1e-6 * Math.max(1, existing.bodySigmaCss);
4928
+ if (sameDensity && sameSigma)
4929
+ continue;
4930
+ requests.push({
4931
+ sourceId,
4932
+ epoch: existing.builtEpoch,
4933
+ resolution: lastResolution.get(sourceId) ?? STANDALONE_RESOLUTION,
4934
+ groupIds: [...groups.values()].filter((entry) => entry.input.backdropSourceId === sourceId).map((entry) => entry.input.groupId)
4935
+ });
3972
4936
  }
3973
- const scaleY = sourceAspect / viewportAspect;
3974
- return [1, scaleY, 0, (1 - scaleY) / 2];
4937
+ return requests;
3975
4938
  };
3976
4939
  const unionOf = (input) => input.union ?? DEFAULT_GROUP_UNION;
3977
4940
  const variantOf = (input) => input.variant ?? "regular";
4941
+ const sourceVariant = (sourceId) => {
4942
+ const input = [...groups.values()].find((entry) => entry.input.backdropSourceId === sourceId)?.input;
4943
+ return input === void 0 ? "regular" : variantOf(input);
4944
+ };
4945
+ const bodySigmaCssFor = (sourceId) => opticsUnderPolicy(material.optics[sourceVariant(sourceId)], accessibility, material).blurSigma;
3978
4946
  const groupTintSeed = (input) => input.surfaces.find((surface) => surface.fieldReferenceOnly !== true && (surface.tint?.strength ?? 0) > 0)?.tint?.color;
3979
4947
  const stateOf = (input, resolution) => {
3980
4948
  const resolved = resolution?.groups.find((group) => group.groupId === input.groupId);
@@ -3987,8 +4955,12 @@ function createWebGPURenderer(options = {}) {
3987
4955
  };
3988
4956
  };
3989
4957
  function rebuildRequests(explicit) {
3990
- if (explicit !== void 0)
3991
- return explicit;
4958
+ const base = explicit ?? standaloneRequests();
4959
+ const { store: pyramids } = ensureContext();
4960
+ const stale = staleBodyRebuilds(base, pyramids);
4961
+ return stale.length === 0 ? base : [...base, ...stale];
4962
+ }
4963
+ function standaloneRequests() {
3992
4964
  const sampled = /* @__PURE__ */ new Set();
3993
4965
  for (const entry of groups.values()) {
3994
4966
  const id = entry.input.backdropSourceId;
@@ -4006,7 +4978,7 @@ function createWebGPURenderer(options = {}) {
4006
4978
  // keeping the books, so a monotonically rising number is the honest
4007
4979
  // stand-in and it keeps `build`'s clean-skip from firing spuriously.
4008
4980
  epoch: framesDrawn + 1,
4009
- resolution: { scale: 1, maxDimension: 2048 },
4981
+ resolution: STANDALONE_RESOLUTION,
4010
4982
  groupIds: [...groups.values()].filter((entry) => entry.input.backdropSourceId === sourceId).map((entry) => entry.input.groupId)
4011
4983
  });
4012
4984
  }
@@ -4022,14 +4994,18 @@ function createWebGPURenderer(options = {}) {
4022
4994
  unbuilt.push(request.sourceId);
4023
4995
  continue;
4024
4996
  }
4025
- const variant = variantOf([...groups.values()].find((entry) => entry.input.backdropSourceId === request.sourceId)?.input ?? { });
4026
- const optics = opticsUnderPolicy(material.optics[variant], accessibility, material);
4997
+ lastResolution.set(request.sourceId, request.resolution);
4998
+ const placement = placements.get(request.sourceId);
4027
4999
  const outcome = pyramids.build({
4028
5000
  sourceId: request.sourceId,
4029
5001
  epoch: request.epoch,
4030
5002
  resolution: request.resolution,
4031
- bodySigmaCss: optics.blurSigma,
4032
- viewportCss: [viewport.widthCss, viewport.heightCss]
5003
+ // The body's sharp width in CSS px at every device scale (W13 Decision
5004
+ // Log 8); the heavy component rides the same conversion — its level
5005
+ // is this one plus log2(sizeScatterGainMax) octaves.
5006
+ bodySigmaCss: bodySigmaCssFor(request.sourceId),
5007
+ viewportCss: [viewport.widthCss, viewport.heightCss],
5008
+ ...isUsablePlacement(placement) ? { placement } : {}
4033
5009
  }, provider, encoder);
4034
5010
  if (outcome.status === "built")
4035
5011
  built += 1;
@@ -4078,11 +5054,12 @@ function createWebGPURenderer(options = {}) {
4078
5054
  }
4079
5055
  const union = unionOf(input);
4080
5056
  const shadow = outerShadowUnderPolicy(policy, material);
4081
- let reachOcclusion = shadow.occlusion;
5057
+ const shadowBackdropLuminance = input.backdropTone === void 0 ? void 0 : input.backdropToneLevel ?? relativeLuminance(input.backdropTone);
5058
+ let reachOcclusion = outerShadowThinOcclusion(shadowBackdropLuminance, shadow);
4082
5059
  for (const surface of surfaces) {
4083
- reachOcclusion = Math.max(reachOcclusion, sizeOuterShadowOcclusionAt(shadow.occlusion, surface.sizeThickness, material));
5060
+ reachOcclusion = Math.max(reachOcclusion, outerShadowOcclusionAt(shadow, shadowBackdropLuminance, surface.spanPx, surface.sizeThickness, material));
4084
5061
  }
4085
- const shadowReachPx = outerShadowReachPx({ ...shadow, occlusion: reachOcclusion });
5062
+ const shadowReachPx = outerShadowReachPx(shadow, reachOcclusion);
4086
5063
  const snapped = snapRectToDevicePixels(groupFieldRect(surfaces, union, void 0, shadowReachPx), dpr);
4087
5064
  const rectDevice = clipFieldRectToCanvas(snapped, dpr, viewportDevice);
4088
5065
  if (rectDevice === void 0)
@@ -4107,19 +5084,24 @@ function createWebGPURenderer(options = {}) {
4107
5084
  });
4108
5085
  const state = stateOf(input, resolution);
4109
5086
  const variant = variantOf(input);
4110
- const optics = opticsUnderPolicy(material.optics[variant], policy, material);
5087
+ const sourceId = input.backdropSourceId;
5088
+ const pyramid = sourceId === void 0 ? void 0 : pyramids.resources(sourceId);
5089
+ const adapt = sourceId === void 0 ? void 0 : adaptationFor(sourceId).values;
5090
+ const nominalOptics = material.optics[variant];
5091
+ const optics = opticsUnderPolicy(pyramid === void 0 && input.unsampledMaterial !== void 0 ? {
5092
+ ...nominalOptics,
5093
+ tint: input.unsampledMaterial.tint,
5094
+ tintAlpha: input.unsampledMaterial.tintAlpha
5095
+ } : nominalOptics, policy, material);
4111
5096
  const backdropTone = [
4112
5097
  material.backdropToneLow,
4113
5098
  material.backdropToneHigh,
4114
5099
  backdropToneSizeBiasUnderPolicy(policy, material),
4115
5100
  input.backdropTone === void 0 ? 0 : backdropToneUnderPolicy(policy, material) * material.backdropToneMax
4116
5101
  ];
4117
- const backdropToneLevel = input.backdropTone === void 0 ? 0 : relativeLuminance(input.backdropTone);
5102
+ const backdropToneLevel = shadowBackdropLuminance ?? 0;
4118
5103
  const refraction = effectiveRefraction(accessibilityRefractionCap(policy), state.refraction);
4119
5104
  const refractionScale = material.refractionScale[refraction];
4120
- const sourceId = input.backdropSourceId;
4121
- const pyramid = sourceId === void 0 ? void 0 : pyramids.resources(sourceId);
4122
- const adapt = sourceId === void 0 ? void 0 : adaptationFor(sourceId).values;
4123
5105
  passes.opticsPass(encoder, {
4124
5106
  groupId: input.groupId,
4125
5107
  target: active.optics,
@@ -4129,10 +5111,22 @@ function createWebGPURenderer(options = {}) {
4129
5111
  viewportDevice,
4130
5112
  cssPerDevice,
4131
5113
  coverageRampCss: cssPerDevice,
4132
- fit: pyramid === void 0 ? [1, 1, 0, 0] : coverFit(pyramid.plan.width, pyramid.plan.height),
5114
+ fit: pyramid === void 0 || sourceId === void 0 ? [1, 1, 0, 0] : fitFor(sourceId, pyramid),
4133
5115
  refractionScale,
4134
- bodyLodPerPx: material.lensBodyLodPerPx,
4135
- rimLodBias: material.lensRimLodBias,
5116
+ lensRefractionGain: material.lensRefractionGain,
5117
+ // The lens law (W12 G2) and the inner shadow's own depth gain, both
5118
+ // evaluated per pixel in the shader from the thickness and the span.
5119
+ lensHeightPerSpan: material.lensHeightPerSpan,
5120
+ lensHeightMax: material.lensHeightMax,
5121
+ lensAmountPerSpan: material.lensAmountPerSpan,
5122
+ lensAmountMax: material.lensAmountMax,
5123
+ lensThicknessReference: material.lensThicknessReference,
5124
+ lensExtentGain: material.lensExtentGain,
5125
+ lensProfileExponent: material.lensProfileExponent,
5126
+ lensOvalization: material.lensOvalization,
5127
+ lensOvalizationSpanMin: material.lensOvalizationSpanMin,
5128
+ lensOvalizationSpanMax: material.lensOvalizationSpanMax,
5129
+ shadowDepthSizeGainMax: material.lensSizeGainMax,
4136
5130
  chainMaxLod: pyramid?.plan.maxLod ?? 0,
4137
5131
  tint: optics.tint,
4138
5132
  tintAlpha: optics.tintAlpha,
@@ -4152,12 +5146,7 @@ function createWebGPURenderer(options = {}) {
4152
5146
  */
4153
5147
  tintSeed: (policy.glass === "none" ? void 0 : groupTintSeed(input)) ?? optics.tint,
4154
5148
  tintToneAdaptation: tintToneAdaptation(policy, material),
4155
- tintTone: [
4156
- material.tintToneFloor,
4157
- material.tintToneCeilMix,
4158
- material.tintToneLow,
4159
- material.tintToneHigh
4160
- ],
5149
+ tintShade: [material.tintShadeDark, material.tintShadeLight, material.tintShadeStrength],
4161
5150
  rimWidth: optics.rimWidth,
4162
5151
  rimAlpha: optics.rimAlpha,
4163
5152
  specularPower: optics.specularPower,
@@ -4174,6 +5163,28 @@ function createWebGPURenderer(options = {}) {
4174
5163
  sizeOcclusionGain: material.sizeOcclusionGain,
4175
5164
  sizeShadowGainMax: material.sizeShadowGainMax,
4176
5165
  bodyChainLod: chainLodForSigma(pyramid?.bodySigmaTexels ?? 0),
5166
+ // The size law's curves (W11c): the shader evaluates the thickness and
5167
+ // the scatter mix per pixel from the span the field carries, under the
5168
+ // same fold `sizeThicknessUnderPolicy` applies on the CPU.
5169
+ sizeSpanMin: material.sizeSpanMin,
5170
+ sizeSpanMax: material.sizeSpanMax,
5171
+ sizeScatterFloor: material.sizeScatterFloor,
5172
+ sizeScatterSpanMax: material.sizeScatterSpanMax,
5173
+ // The body's depth ramp (W13 G1, claims §5.61 §2, §5.64 §5), resolved
5174
+ // from the viewport's own ratio: the profile anchors the start's thin
5175
+ // and thick ends and the reach at dpr 1 and dpr 2, and the CPU
5176
+ // interpolates all three in dpr. The start's SPAN grading stays in the
5177
+ // shader, because a group's members are not one size and the span
5178
+ // arrives per pixel on the field's own channel — so the two anchors go
5179
+ // over and `sizeThickness` is evaluated there. The reach is handed over
5180
+ // in CSS px because the field's depth is in CSS px.
5181
+ sizeScatterRampStartThin: scatterRampStart(dpr, material, 0),
5182
+ sizeScatterRampStartThick: scatterRampStart(dpr, material, material.sizeSpanMax),
5183
+ // The fourth form's far end, at the scatter span curve's top; the shader
5184
+ // declines from the thick end to it along that curve.
5185
+ sizeScatterRampStartFar: scatterRampStart(dpr, material, material.sizeScatterSpanMax),
5186
+ sizeScatterRampReachCssPx: scatterRampReachDevicePx(dpr, material) / dpr,
5187
+ sizeFold: material.refractionScale[accessibilityRefractionCap(policy)],
4177
5188
  backdropTone,
4178
5189
  backdropToneColour: [
4179
5190
  input.backdropTone?.[0] ?? 0,
@@ -4181,8 +5192,31 @@ function createWebGPURenderer(options = {}) {
4181
5192
  input.backdropTone?.[2] ?? 0,
4182
5193
  backdropToneLevel
4183
5194
  ],
5195
+ // The response law's anchors (W9) and the linear backdrop mean its
5196
+ // solve composites against. The shader re-derives the encoded input
5197
+ // from `backdropToneLevel`, so the fourth slot carries the linear
5198
+ // mean — the one quantity the solve needs that the tone colour does
5199
+ // not already hold.
5200
+ backdropToneAnchorX: material.backdropToneAnchorX,
5201
+ backdropToneResponseThin: material.backdropToneResponseThin,
5202
+ backdropToneResponseThick: material.backdropToneResponseThick,
5203
+ // The response law rides only the UN-DEGRADED regime, for the same
5204
+ // provenance reason the dark profiles set its strength to 0: the
5205
+ // anchors are standard-light-reference measurements, and the
5206
+ // accessibility references behave differently (nearly opaque, flat in
5207
+ // span — W2). Measured before this gate existed: reduced-transparency
5208
+ // photo ΔE p95 0.007 → 0.048 and cross-tier ratio 0.99 → 0.86. Where
5209
+ // any policy fold touches the tone axis, the solve stands down and the
5210
+ // collapse alone runs, exactly the pre-W9 behaviour those profiles
5211
+ // were fitted on.
5212
+ backdropToneResponseStrength: backdropToneUnderPolicy(policy, material) >= 0.999 ? material.backdropToneResponseStrength : 0,
5213
+ backdropToneLinearMean: input.backdropToneLinearLuminance ?? backdropToneLevel,
4184
5214
  outerShadow: [
4185
- outerShadowAlpha(shadow.occlusion),
5215
+ // The thin regime's LINEAR occlusion at this group's backdrop (W14
5216
+ // G1). The conversion to the compositing space moved into the shader
5217
+ // with the regimes: it is not linear, so converting one regime here
5218
+ // and blending in the shader would not be the same law.
5219
+ outerShadowThinOcclusion(shadowBackdropLuminance, shadow),
4186
5220
  shadow.sigmaPx,
4187
5221
  shadow.spreadPx,
4188
5222
  // The offset in field-texture UV. The field spans exactly this rect, so
@@ -4190,6 +5224,19 @@ function createWebGPURenderer(options = {}) {
4190
5224
  // scaled the rasterisation.
4191
5225
  shadow.offsetPx / Math.max(rectDevice.height * cssPerDevice, 1e-6)
4192
5226
  ],
5227
+ outerShadowThick: [
5228
+ shadow.thickOcclusionAt96,
5229
+ shadow.thickOcclusionAt128,
5230
+ shadow.thickOcclusionAt160
5231
+ ],
5232
+ outerShadowLift: [
5233
+ // The lift needs a chain to copy; without one there is nothing to add,
5234
+ // and `hasBackdrop` in the shader says the same thing a second time.
5235
+ pyramid === void 0 || policy.glass === "none" ? 0 : shadow.liftAmplitude,
5236
+ shadow.liftSpanMin,
5237
+ shadow.liftSpanFull,
5238
+ liftChainLod(shadow.liftBlurSigmaCss, pyramid)
5239
+ ],
4193
5240
  outerShadowSizeGain: shadow.sizeGain,
4194
5241
  outerShadowRectCssHeight: rectDevice.height * cssPerDevice,
4195
5242
  backdrop: pyramid === void 0 || policy.glass === "none" ? void 0 : {
@@ -4219,9 +5266,13 @@ function createWebGPURenderer(options = {}) {
4219
5266
  glowGain: material.glowGain,
4220
5267
  colour: optics.highlight,
4221
5268
  // The same four numbers the optics pass took, so the two passes fade
4222
- // the surface on one curve rather than on two copies of it.
5269
+ // the surface on one curve rather than on two copies of it — and the
5270
+ // same band and fold, since the curve is evaluated off the span.
4223
5271
  backdropTone,
4224
- backdropToneLevel
5272
+ backdropToneLevel,
5273
+ sizeSpanMin: material.sizeSpanMin,
5274
+ sizeSpanMax: material.sizeSpanMax,
5275
+ sizeFold: material.refractionScale[accessibilityRefractionCap(policy)]
4225
5276
  });
4226
5277
  }
4227
5278
  drawn += 1;
@@ -4286,6 +5337,8 @@ function createWebGPURenderer(options = {}) {
4286
5337
  providers.set(provider.id, provider);
4287
5338
  },
4288
5339
  unregisterBackdrop(sourceId) {
5340
+ placements.delete(sourceId);
5341
+ lastResolution.delete(sourceId);
4289
5342
  const provider = providers.get(sourceId);
4290
5343
  if (provider === void 0)
4291
5344
  return;
@@ -4298,6 +5351,17 @@ function createWebGPURenderer(options = {}) {
4298
5351
  backdrop(sourceId) {
4299
5352
  return providers.get(sourceId);
4300
5353
  },
5354
+ setBackdropPlacement(sourceId, placement) {
5355
+ if (samePlacement(placements.get(sourceId), placement))
5356
+ return;
5357
+ if (placement === void 0)
5358
+ placements.delete(sourceId);
5359
+ else
5360
+ placements.set(sourceId, { ...placement });
5361
+ },
5362
+ backdropPlacement(sourceId) {
5363
+ return placements.get(sourceId);
5364
+ },
4301
5365
  setViewport(next) {
4302
5366
  const changed = next.widthCss !== viewport.widthCss || next.heightCss !== viewport.heightCss || next.devicePixelRatio !== viewport.devicePixelRatio;
4303
5367
  viewport = next;
@@ -4454,6 +5518,6 @@ function createWebGPURenderer(options = {}) {
4454
5518
  };
4455
5519
  }
4456
5520
 
4457
- export { ADAPTIVE_LUMINANCE_HIGH, ADAPTIVE_LUMINANCE_LOW, ADAPTIVE_TINT_DARK, ADAPTIVE_TINT_LIGHT, ANALYSIS_DISPATCH, ANALYSIS_GRID, ANALYSIS_PASS_ID, ANALYSIS_STATS_FLOATS, ANALYSIS_TARGET_EXTENT, ANALYSIS_WORKGROUP, BACKDROP_ALPHA_MODES, BACKDROP_COLOR_SPACES, BACKDROP_KINDS, BACKDROP_PASS_ID, BACKDROP_TONE_HIGH, BACKDROP_TONE_LOW, BACKDROP_TONE_MAX, BACKDROP_TONE_SIZE_BIAS, CANVAS_FORMAT, CHAIN_SIGMA_AT_LEVEL_1, CROSS_CHECK_SHAPE_FLOATS, CROSS_CHECK_WORKGROUP, DEFAULT_MATERIAL_PROFILE, FAMILY_C_CROSS_CHECK, FIELD_PASS_ID, GOVERNOR_LADDER, HIGHLIGHT_PASS_ID, IDLE_CHANNELS, INCREASED_OCCLUSION_LIFT, INSTANCE_BYTES, INSTANCE_FLOATS, LENS_BODY_LOD_PER_PX, LENS_RIM_LOD_BIAS, LENS_SIZE_GAIN_MAX, LUMINANCE_WEIGHTS, MATERIAL_OPTICS, MATERIAL_VARIANTS, MAX_CHAIN_LEVELS, MIN_LEVEL_EXTENT, NOMINAL_GOVERNOR, NOMINAL_MATERIAL_POLICY, OPTICS_PASS_ID, OUTER_SHADOW, OUTPUT_TEXTURE_FORMAT, PASS_LABEL, REFRACTION_LADDER, REFRACTION_SCALE, RENDERER_ERROR_CODES, RENDERER_PASS_IDS, RendererError, SIZE_OCCLUSION_GAIN, SIZE_SCATTER_GAIN_MAX, SIZE_SHADOW_GAIN_MAX, SIZE_SPAN_MAX, SIZE_SPAN_MIN, SRGB_ENCODING_EXPONENT, SUPPORTED_APP_TEXTURE_FORMATS, WGSL_ANALYSIS_PASS, WGSL_CROSS_CHECK_PASS, WGSL_DOWNSAMPLE_PASS, WGSL_FIELD_KERNELS, WGSL_FIELD_PASS, WGSL_FIELD_SAMPLE, WGSL_HIGHLIGHT_PASS, WGSL_IMPORT_PASS, WGSL_INSTANCE_STRUCT, WGSL_OPTICS_PASS, WGSL_PRELUDE, WGSL_RSUPN_GRAD, WGSL_RSUP_GRAD, WGSL_SMOOTH_UNION, WORKING_TEXTURE_FORMAT, ZERO_STATS, accessibilityRefractionCap, adaptationStrength, adaptedTintAlpha, adaptedTintColour, adaptiveTint, allShaderSource, alphaNormalisationMode, analysisModule, backdropToneAdaptation, backdropToneSizeBiasUnderPolicy, backdropToneUnderPolicy, bodyBlurPlan, bodyLod, chainModule, clipFieldRectToCanvas, createAdaptationState, createAppTextureProvider, createCopyProvider, createDeviceHost, createGovernor, createGpuContext, createGradientProvider, createPassRunner, createPipelineCache, createPyramidStore, createRebuildLedger, createStorageSlot, createTexturePool, createTimingCollector, createUniformSlot, createVideoProvider, createWebGPURenderer, crossCheckKernelModule, displayP3ToSrgbLinear, effectiveRefraction, encodeOutput, encodeOutputBytes, fieldModule, fieldPassSource, groupFieldRect, highlightModule, importColorMatrix, importModule, importPassSource, lensDepthPx, lensSizeGain, lensSizeGainFromThickness, linearGradientStops, linearToSrgb, linearToSrgbChannel, occlusionAlphaUnderPolicy, opticsModule, opticsUnderPolicy, outerShadowAlpha, outerShadowFalloff, outerShadowReachPx, outerShadowUnderPolicy, packInstances, pipelineKey, planPyramid, poolKey, readbackDue, refractionRank, relativeLuminance, rendererError, resolveSurfaces, sizeOcclusionAlpha, sizeOcclusionAlphaAt, sizeOuterShadowOcclusion, sizeOuterShadowOcclusionAt, sizeScatterSigma, sizeScatterSigmaAt, sizeShadowDepth, sizeShadowDepthAt, sizeThickness, sizeThicknessUnderPolicy, snapRectToDevicePixels, srgbToLinear, srgbToLinearChannel, statsFromBuffer, supportsTimestamps, tintTone, tintToneAdaptation, tintedTintColour, validateAppTexture, withMaterialOverrides };
4458
- //# sourceMappingURL=dist-RQ4ZMA3D.js.map
4459
- //# sourceMappingURL=dist-RQ4ZMA3D.js.map
5521
+ export { ADAPTIVE_LUMINANCE_HIGH, ADAPTIVE_LUMINANCE_LOW, ADAPTIVE_TINT_DARK, ADAPTIVE_TINT_LIGHT, ANALYSIS_DISPATCH, ANALYSIS_GRID, ANALYSIS_PASS_ID, ANALYSIS_STATS_FLOATS, ANALYSIS_TARGET_EXTENT, ANALYSIS_WORKGROUP, BACKDROP_ALPHA_MODES, BACKDROP_COLOR_SPACES, BACKDROP_KINDS, BACKDROP_PASS_ID, BACKDROP_TONE_HIGH, BACKDROP_TONE_LOW, BACKDROP_TONE_MAX, BACKDROP_TONE_SIZE_BIAS, CANVAS_FORMAT, CHAIN_SIGMA_AT_LEVEL_1, CROSS_CHECK_SHAPE_FLOATS, CROSS_CHECK_WORKGROUP, DEFAULT_MATERIAL_PROFILE, FAMILY_C_CROSS_CHECK, FIELD_PASS_ID, GOVERNOR_LADDER, HIGHLIGHT_PASS_ID, IDENTITY_FIT, IDLE_CHANNELS, INCREASED_OCCLUSION_LIFT, INSTANCE_BYTES, INSTANCE_FLOATS, LENS_AMOUNT_MAX, LENS_AMOUNT_PER_SPAN, LENS_EXTENT_GAIN, LENS_HEIGHT_MAX, LENS_HEIGHT_PER_SPAN, LENS_OVALIZATION, LENS_PROFILE_EXPONENT, LENS_REFRACTION_GAIN, LENS_SIZE_GAIN_MAX, LENS_THICKNESS_REFERENCE, LUMINANCE_WEIGHTS, MATERIAL_OPTICS, MATERIAL_VARIANTS, MAX_CHAIN_LEVELS, MIN_LEVEL_EXTENT, NOMINAL_GOVERNOR, NOMINAL_MATERIAL_POLICY, OPTICS_PASS_ID, OUTER_SHADOW, OUTER_SHADOW_THICK_SPANS, OUTER_SHADOW_THIN_L, OUTER_SHADOW_UNMEASURED_BACKDROP_LUMINANCE, OUTPUT_TEXTURE_FORMAT, PASS_LABEL, REFRACTION_LADDER, REFRACTION_SCALE, RENDERER_ERROR_CODES, RENDERER_PASS_IDS, RendererError, SIZE_OCCLUSION_GAIN, SIZE_SCATTER_FLOOR, SIZE_SCATTER_GAIN_MAX, SIZE_SCATTER_RAMP_REACH_1X_PX, SIZE_SCATTER_RAMP_REACH_2X_PX, SIZE_SCATTER_RAMP_START_FAR_1X, SIZE_SCATTER_RAMP_START_FAR_2X, SIZE_SCATTER_RAMP_START_THICK_1X, SIZE_SCATTER_RAMP_START_THICK_2X, SIZE_SCATTER_RAMP_START_THIN_1X, SIZE_SCATTER_RAMP_START_THIN_2X, SIZE_SCATTER_SPAN_MAX, SIZE_SHADOW_GAIN_MAX, SIZE_SPAN_MAX, SIZE_SPAN_MIN, SRGB_ENCODING_EXPONENT, SUPPORTED_APP_TEXTURE_FORMATS, WGSL_ANALYSIS_PASS, WGSL_CROSS_CHECK_PASS, WGSL_DOWNSAMPLE_PASS, WGSL_FIELD_KERNELS, WGSL_FIELD_PASS, WGSL_FIELD_SAMPLE, WGSL_HIGHLIGHT_PASS, WGSL_IMPORT_PASS, WGSL_INSTANCE_STRUCT, WGSL_OPTICS_PASS, WGSL_PRELUDE, WGSL_RSUPN_GRAD, WGSL_RSUP_GRAD, WGSL_SMOOTH_UNION, WORKING_TEXTURE_FORMAT, ZERO_STATS, accessibilityRefractionCap, adaptationStrength, adaptedTintAlpha, adaptedTintColour, adaptiveTint, allShaderSource, alphaNormalisationMode, analysisModule, backdropFit, backdropToneAdaptation, backdropToneSizeBiasUnderPolicy, backdropToneUnderPolicy, bodyBlurPlan, chainModule, clipFieldRectToCanvas, coverFit, createAdaptationState, createAppTextureProvider, createCopyProvider, createDeviceHost, createGovernor, createGpuContext, createGradientProvider, createPassRunner, createPipelineCache, createPyramidStore, createRebuildLedger, createStorageSlot, createTexturePool, createTimingCollector, createUniformSlot, createVideoProvider, createWebGPURenderer, crossCheckKernelModule, displayP3ToSrgbLinear, effectiveRefraction, encodeOutput, encodeOutputBytes, fieldModule, fieldPassSource, groupFieldRect, highlightModule, importColorMatrix, importModule, importPassSource, isUsablePlacement, lensAmountBasePx, lensDepthPx, lensDirection, lensDisplacementPx, lensExtentPx, lensHeightBasePx, lensMagnitudePx, lensOvalizationAt, lensSizeGain, lensSizeGainFromThickness, linearGradientStops, linearToSrgb, linearToSrgbChannel, occlusionAlphaUnderPolicy, opticsModule, opticsUnderPolicy, outerShadowAlpha, outerShadowFalloff, outerShadowLiftRise, outerShadowOcclusionAt, outerShadowReachPx, outerShadowThickOcclusion, outerShadowThinOcclusion, outerShadowUnderPolicy, packInstances, pipelineKey, placementFit, planPyramid, poolKey, readbackDue, refractionRank, relativeLuminance, rendererError, resolveSurfaces, samePlacement, scatterDeepThickness, scatterRampAreaMean, scatterRampReachDevicePx, scatterRampStart, scatterSharpShare, scatterThickness, shadowDepthPx, sizeOcclusionAlpha, sizeOcclusionAlphaAt, sizeOuterShadowOcclusion, sizeOuterShadowOcclusionAt, sizeScatterSigma, sizeScatterSigmaAt, sizeShadowDepth, sizeShadowDepthAt, sizeThickness, sizeThicknessUnderPolicy, snapRectToDevicePixels, srgbToLinear, srgbToLinearChannel, statsFromBuffer, supportsTimestamps, texelsPerCssPx, tintShade, tintShadeLayer, tintToneAdaptation, tintedMaterialColour, validateAppTexture, withMaterialOverrides };
5522
+ //# sourceMappingURL=dist-RGWZAZ6J.js.map
5523
+ //# sourceMappingURL=dist-RGWZAZ6J.js.map