vireuikit 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -76,6 +76,21 @@ Turbulence and light-track emission in response to playback come from `createMed
76
76
  which is platform-neutral: feed `step()` a playback state, BPM and a deterministic position, and
77
77
  pass the `emissions` it returns on as `MediumFrame.emissions`.
78
78
 
79
+ The backdrop reads as a chamber seen from the side, not a flat field, without a second simulation:
80
+ the composite samples the same vapor/condensate grid twice, once at identity and once at a larger
81
+ scale and a fixed offset (`MEDIUM_DEFAULTS.depthFarScale`/`depthFarOffsetFrac`, the offset as a
82
+ fraction of the grid so it stays clear of the wrap at any grid size) — the far read is both slower
83
+ on screen and finer-grained, since the field's own motion maps to screen motion as `v / scale`. A
84
+ cheap 4-tap blur on that far read (`depthFarBlurRadius`) supplies aerial perspective's softness and
85
+ lower contrast in the same pass, weighted down (`depthFarWeight`) so it never competes with the near
86
+ plane. Gravity is two separate properties: a small downward drift on vapor's own velocity
87
+ (`gravityVaporDrift`, a motion cue only — a uniform drift on this periodic grid cannot itself
88
+ accumulate density) and a compositing-only density boost near the bottom of the frame
89
+ (`gravityBottomBoost`/`gravityBottomBoostStart`, a steady-state property that cannot affect the
90
+ conserved water total) that actually produces the "denser at the bottom" reading.
91
+ Each emitted track also carries a `depth` (0 far … 1 near) that scales its own width and intensity,
92
+ so a track reads thinner, dimmer and softer the farther back it lives.
93
+
79
94
  ## License
80
95
 
81
96
  Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
@@ -104,8 +104,22 @@ var MEDIUM_DEFAULTS = {
104
104
  condensateColor: [0.94, 0.95, 0.97],
105
105
  channelColors: neutralSpecies(BASE_COLOR),
106
106
  baseColor: BASE_COLOR,
107
- baseWeight: 0.9
107
+ baseWeight: 0.9,
108
+ depthFarScale: 1.6,
109
+ depthFarOffsetFrac: [0.47, 0.53],
110
+ depthFarBlurRadius: 5,
111
+ depthFarWeight: 0.5,
112
+ gravityVaporDrift: 0.6,
113
+ gravityBottomBoost: 2,
114
+ gravityBottomBoostStart: 0.75
108
115
  };
116
+ function depthFarOffsetPx(offsetFrac, gridWidth, gridHeight) {
117
+ return [offsetFrac[0] * gridWidth, offsetFrac[1] * gridHeight];
118
+ }
119
+ function wrappedDistanceFromZero(value, period) {
120
+ const wrapped = (value % period + period) % period;
121
+ return Math.min(wrapped, period - wrapped);
122
+ }
109
123
  var MEDIUM_PROBE_FLOOR_PX = 22;
110
124
  var MEDIUM_DEFAULT_CELL_PX = 26;
111
125
  var MEDIUM_TRACK_PRESETS = {
@@ -150,6 +164,8 @@ var MEDIUM_NATURAL_INTERVAL = 25;
150
164
  var MEDIUM_NATURAL_JITTER = 35;
151
165
  var MEDIUM_SENSITIVE_TOP = 0.55;
152
166
  var MEDIUM_SENSITIVE_BOTTOM = 0.95;
167
+ var MEDIUM_TRACK_DEPTH_WIDTH_RANGE = [0.55, 1];
168
+ var MEDIUM_TRACK_DEPTH_INTENSITY_RANGE = [0.4, 1];
153
169
 
154
170
  // src/medium/noise.ts
155
171
  var VG_VALUE_NOISE = `
@@ -334,11 +350,16 @@ var MEDIUM_ADVECT_VELOCITY_SETTLE = `
334
350
  ${MEDIUM_ADVECT_VELOCITY}
335
351
  vel.y += u_settleSpeed;
336
352
  `;
353
+ var MEDIUM_ADVECT_VELOCITY_VAPOR = `
354
+ ${MEDIUM_ADVECT_VELOCITY}
355
+ vel.y += u_vaporDrift;
356
+ `;
337
357
  var MEDIUM_VAPOR_FORWARD_SHADER = `
338
358
  ${MEDIUM_ADVECT_UNIFORMS}
359
+ uniform float u_vaporDrift;
339
360
 
340
361
  half4 main(float2 xy) {
341
- ${MEDIUM_ADVECT_VELOCITY}
362
+ ${MEDIUM_ADVECT_VELOCITY_VAPOR}
342
363
  float2 src = xy - vel * u_dt;
343
364
  return u_dye.eval(float2(src.x, u_dyeSize.y - src.y));
344
365
  }
@@ -374,9 +395,10 @@ var MEDIUM_MACCORMACK_CORRECT = `
374
395
  var MEDIUM_VAPOR_CORRECT_SHADER = `
375
396
  ${MEDIUM_ADVECT_UNIFORMS}
376
397
  uniform shader u_forward;
398
+ uniform float u_vaporDrift;
377
399
 
378
400
  half4 main(float2 xy) {
379
- ${MEDIUM_ADVECT_VELOCITY}
401
+ ${MEDIUM_ADVECT_VELOCITY_VAPOR}
380
402
  ${MEDIUM_MACCORMACK_CORRECT}
381
403
  return limited;
382
404
  }
@@ -496,17 +518,52 @@ uniform float3 u_labCondensate;
496
518
  uniform float u_baseWeight;
497
519
  uniform float u_condensateTint;
498
520
  uniform float u_condensateGain;
521
+ uniform float u_farScale;
522
+ uniform float2 u_farOffset;
523
+ uniform float u_farBlurRadius;
524
+ uniform float u_farWeight;
525
+ uniform float u_gravityBoost;
526
+ uniform float u_gravityBoostStart;
499
527
 
500
528
  ${VG_OKLAB_TO_SRGB}
501
529
 
502
530
  half4 main(float2 xy) {
503
- float2 src = xy * u_dyeScale;
504
- half4 vapor = u_vapor.eval(src);
505
- half4 condensate = u_condensate.eval(src);
506
- half4 track = u_track.eval(src);
507
- float w0 = float(vapor.r) + float(track.r);
508
- float w1 = float(vapor.g) + float(track.g);
509
- float w2 = float(vapor.b) + float(track.b);
531
+ float2 baseSrc = xy * u_dyeScale;
532
+
533
+ // Near plane \u2014 sampled exactly as before the depth work; the calibrated look lives here.
534
+ half4 vapor = u_vapor.eval(baseSrc);
535
+ half4 condensate = u_condensate.eval(baseSrc);
536
+ half4 track = u_track.eval(baseSrc);
537
+
538
+ // Far plane \u2014 the identical field, scaled and offset (see the file header). A 4-tap box blur
539
+ // (no separate center sample, the cheapest isotropic blur available) supplies aerial perspective's
540
+ // blur and, as a side effect of averaging, its lower contrast too.
541
+ float2 farSrc = baseSrc * u_farScale + u_farOffset;
542
+ float2 blurX = float2(u_farBlurRadius, 0.0);
543
+ float2 blurY = float2(0.0, u_farBlurRadius);
544
+ half4 vaporFar = (u_vapor.eval(farSrc + blurX) + u_vapor.eval(farSrc - blurX) +
545
+ u_vapor.eval(farSrc + blurY) + u_vapor.eval(farSrc - blurY)) * half4(0.25);
546
+ half4 condensateFar = (u_condensate.eval(farSrc + blurX) + u_condensate.eval(farSrc - blurX) +
547
+ u_condensate.eval(farSrc + blurY) + u_condensate.eval(farSrc - blurY)) * half4(0.25);
548
+
549
+ // Gravity's "denser layer at the bottom of the chamber" \u2014 a compositing-only boost (see
550
+ // MEDIUM_DEFAULTS.gravityBottomBoost): it scales how density already in the conserved buffers is
551
+ // DISPLAYED, never the buffers themselves, so it can't threaten water conservation. Shared by
552
+ // both planes: the chamber's floor is denser everywhere along its depth, not just up close.
553
+ //
554
+ // Verified with a headless render, not assumed: ramping this with xy.y directly lit up the TOP
555
+ // of the frame, not the bottom (contentTexture's own orientation contract flips y once more
556
+ // between this pass and the final scene \u2014 see the file header) \u2014 hence the 1.0 - below.
557
+ float bottomFrac = 1.0 - xy.y / u_resolution.y;
558
+ float bottomT = smoothstep(u_gravityBoostStart, 1.0, bottomFrac);
559
+ float bottomBoost = 1.0 + u_gravityBoost * bottomT;
560
+
561
+ float gasR = (float(vapor.r) + float(vaporFar.r) * u_farWeight) * bottomBoost;
562
+ float gasG = (float(vapor.g) + float(vaporFar.g) * u_farWeight) * bottomBoost;
563
+ float gasB = (float(vapor.b) + float(vaporFar.b) * u_farWeight) * bottomBoost;
564
+ float w0 = gasR + float(track.r);
565
+ float w1 = gasG + float(track.g);
566
+ float w2 = gasB + float(track.b);
510
567
  float wBg = u_baseWeight;
511
568
 
512
569
  // A faint condensate hint from the LOCAL vapor, not its own color (droplets scatter light
@@ -514,7 +571,8 @@ half4 main(float2 xy) {
514
571
  float vaporLocal = max(w0 + w1 + w2, 1e-4);
515
572
  float3 vaporHue = (u_lab0 * w0 + u_lab1 * w1 + u_lab2 * w2) / vaporLocal;
516
573
  float3 condensateLab = mix(u_labCondensate, vaporHue, u_condensateTint);
517
- float wCondensate = float(condensate.r) * u_condensateGain;
574
+ float wCondensate =
575
+ (float(condensate.r) + float(condensateFar.r) * u_farWeight) * u_condensateGain * bottomBoost;
518
576
 
519
577
  float total = max(w0 + w1 + w2 + wBg + wCondensate, 1e-4);
520
578
  float3 mixLab =
@@ -574,15 +632,31 @@ function channelFromSeed(seed) {
574
632
  const idx = Math.floor(hash01(seed * 1.37) * 3) % 3;
575
633
  return idx === 0 ? [1, 0, 0] : idx === 1 ? [0, 1, 0] : [0, 0, 1];
576
634
  }
635
+ function lerp(range, t) {
636
+ return range[0] + (range[1] - range[0]) * t;
637
+ }
638
+ function applyTrackDepth(preset, depth) {
639
+ const widthScale = lerp(MEDIUM_TRACK_DEPTH_WIDTH_RANGE, depth);
640
+ const intensityScale = lerp(MEDIUM_TRACK_DEPTH_INTENSITY_RANGE, depth);
641
+ return {
642
+ ...preset,
643
+ headWidthFrac: preset.headWidthFrac * widthScale,
644
+ tailWidthFrac: preset.tailWidthFrac * widthScale,
645
+ intensity: preset.intensity * intensityScale
646
+ };
647
+ }
577
648
  function buildEmission(presetName, source, seed, intensityScale) {
578
649
  const preset = MEDIUM_TRACK_PRESETS[presetName];
650
+ const depth = hash01(seed * 4.63);
651
+ const depthApplied = applyTrackDepth(preset, depth);
579
652
  return {
580
- ...preset,
581
- intensity: preset.intensity * intensityScale,
653
+ ...depthApplied,
654
+ intensity: depthApplied.intensity * intensityScale,
582
655
  source,
583
656
  angle: hash01(seed * 7.31) * Math.PI * 2,
584
657
  seed,
585
- channel: channelFromSeed(seed)
658
+ channel: channelFromSeed(seed),
659
+ depth
586
660
  };
587
661
  }
588
662
  function createMediumDynamics() {
@@ -630,6 +704,6 @@ function createMediumDynamics() {
630
704
  return { step };
631
705
  }
632
706
 
633
- export { LINEAR_TO_LMS, LMS_TO_LINEAR, LMS_TO_OKLAB, MEDIUM_COMPOSITE_SHADER, MEDIUM_CONDENSATE_CORRECT_SHADER, MEDIUM_CONDENSATE_FORWARD_SHADER, MEDIUM_CONDENSATE_REACT_SHADER, MEDIUM_DEFAULTS, MEDIUM_DEFAULT_CELL_PX, MEDIUM_DRIFT_TURBULENCE, MEDIUM_EMISSION_BEATS, MEDIUM_EMISSION_PROBABILITY, MEDIUM_EMIT_SHADER, MEDIUM_MIN_EMISSION_INTERVAL, MEDIUM_NATURAL_INTERVAL, MEDIUM_NATURAL_JITTER, MEDIUM_PLAYING_TURBULENCE_RANGE, MEDIUM_PROBE_FLOOR_PX, MEDIUM_RESAMPLE_SHADER, MEDIUM_SEED_SHADER, MEDIUM_SENSITIVE_BOTTOM, MEDIUM_SENSITIVE_TOP, MEDIUM_SPECIES_HUE_SPREAD, MEDIUM_SPECIES_LIGHTNESS, MEDIUM_TIME_PERIOD, MEDIUM_TRACK_ADVECT_SHADER, MEDIUM_TRACK_PRESETS, MEDIUM_TURBULENCE_TAU, MEDIUM_VAPOR_CORRECT_SHADER, MEDIUM_VAPOR_FORWARD_SHADER, MEDIUM_VAPOR_REACT_SHADER, OKLAB_TO_LMS, VG_CURL_NOISE, VG_OKLAB_TO_SRGB, VG_VALUE_NOISE, characterOf, createMediumDynamics, neutralSpecies, oklabToSrgb, speciesFamily, srgbToOklab };
634
- //# sourceMappingURL=chunk-ZFX2MLKK.js.map
635
- //# sourceMappingURL=chunk-ZFX2MLKK.js.map
707
+ export { LINEAR_TO_LMS, LMS_TO_LINEAR, LMS_TO_OKLAB, MEDIUM_COMPOSITE_SHADER, MEDIUM_CONDENSATE_CORRECT_SHADER, MEDIUM_CONDENSATE_FORWARD_SHADER, MEDIUM_CONDENSATE_REACT_SHADER, MEDIUM_DEFAULTS, MEDIUM_DEFAULT_CELL_PX, MEDIUM_DRIFT_TURBULENCE, MEDIUM_EMISSION_BEATS, MEDIUM_EMISSION_PROBABILITY, MEDIUM_EMIT_SHADER, MEDIUM_MIN_EMISSION_INTERVAL, MEDIUM_NATURAL_INTERVAL, MEDIUM_NATURAL_JITTER, MEDIUM_PLAYING_TURBULENCE_RANGE, MEDIUM_PROBE_FLOOR_PX, MEDIUM_RESAMPLE_SHADER, MEDIUM_SEED_SHADER, MEDIUM_SENSITIVE_BOTTOM, MEDIUM_SENSITIVE_TOP, MEDIUM_SPECIES_HUE_SPREAD, MEDIUM_SPECIES_LIGHTNESS, MEDIUM_TIME_PERIOD, MEDIUM_TRACK_ADVECT_SHADER, MEDIUM_TRACK_DEPTH_INTENSITY_RANGE, MEDIUM_TRACK_DEPTH_WIDTH_RANGE, MEDIUM_TRACK_PRESETS, MEDIUM_TURBULENCE_TAU, MEDIUM_VAPOR_CORRECT_SHADER, MEDIUM_VAPOR_FORWARD_SHADER, MEDIUM_VAPOR_REACT_SHADER, OKLAB_TO_LMS, VG_CURL_NOISE, VG_OKLAB_TO_SRGB, VG_VALUE_NOISE, applyTrackDepth, characterOf, createMediumDynamics, depthFarOffsetPx, neutralSpecies, oklabToSrgb, speciesFamily, srgbToOklab, wrappedDistanceFromZero };
708
+ //# sourceMappingURL=chunk-BV5P6MF7.js.map
709
+ //# sourceMappingURL=chunk-BV5P6MF7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/medium/oklab.ts","../src/medium/species.ts","../src/medium/params.ts","../src/medium/noise.ts","../src/medium/seed-shader.ts","../src/medium/resample-shader.ts","../src/medium/advect-shader.ts","../src/medium/composite-shader.ts","../src/medium/emit-shader.ts","../src/medium/dynamics.ts"],"names":[],"mappings":"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","file":"chunk-BV5P6MF7.js","sourcesContent":["// Oklab / linear-sRGB — the shared math for mixing the medium's channels (mix in OKLCH/Oklab, not\n// raw RGB, or saturated diffusion hues collapse into gray). Coefficients: Björn Ottosson,\n// https://bottosson.github.io/posts/oklab/.\n//\n// The numbers live as ONE set: the shader text is assembled from the same matrices the JS side\n// computes with. While the forward direction lived in TS and the inverse was separate literals in\n// the shader, they could drift apart silently, and the medium's palette would shift by a hue\n// between the GPU and any JS-side precomputation.\ntype Mat3 = readonly [\n readonly [number, number, number],\n readonly [number, number, number],\n readonly [number, number, number],\n];\n\nexport const LINEAR_TO_LMS: Mat3 = [\n [0.4122214708, 0.5363325363, 0.0514459929],\n [0.2119034982, 0.6806995451, 0.1073969566],\n [0.0883024619, 0.2817188376, 0.6299787005],\n];\n\nexport const LMS_TO_OKLAB: Mat3 = [\n [0.2104542553, 0.793617785, -0.0040720468],\n [1.9779984951, -2.428592205, 0.4505937099],\n [0.0259040371, 0.7827717662, -0.808675766],\n];\n\nexport const OKLAB_TO_LMS: Mat3 = [\n [1, 0.3963377774, 0.2158037573],\n [1, -0.1055613458, -0.0638541728],\n [1, -0.0894841775, -1.291485548],\n];\n\nexport const LMS_TO_LINEAR: Mat3 = [\n [4.0767416621, -3.3077115913, 0.2309699292],\n [-1.2684380046, 2.6097574011, -0.3413193965],\n [-0.0041960863, -0.7034186147, 1.707614701],\n];\n\n/** GLSL integers aren't floats, so the decimal point is mandatory: `1` won't compile as a float. */\nconst glsl = (v: number) => (Number.isInteger(v) ? v.toFixed(1) : String(v));\nconst row = (m: Mat3, i: number, vars: readonly [string, string, string]) =>\n m[i].map((k, j) => `${glsl(k)} * ${vars[j]}`).join(' + ');\n\nexport const VG_OKLAB_TO_SRGB = `\nfloat3 vgLinearToSrgb(float3 c) {\n float3 lo = c * 12.92;\n float3 hi = 1.055 * pow(max(c, float3(0.0)), float3(1.0 / 2.4)) - 0.055;\n return mix(lo, hi, step(float3(0.0031308), c));\n}\n\nfloat3 vgOklabToLinear(float3 lab) {\n float l_ = ${row(OKLAB_TO_LMS, 0, ['lab.x', 'lab.y', 'lab.z'])};\n float m_ = ${row(OKLAB_TO_LMS, 1, ['lab.x', 'lab.y', 'lab.z'])};\n float s_ = ${row(OKLAB_TO_LMS, 2, ['lab.x', 'lab.y', 'lab.z'])};\n float l = l_ * l_ * l_;\n float m = m_ * m_ * m_;\n float s = s_ * s_ * s_;\n return float3(\n ${row(LMS_TO_LINEAR, 0, ['l', 'm', 's'])},\n ${row(LMS_TO_LINEAR, 1, ['l', 'm', 's'])},\n ${row(LMS_TO_LINEAR, 2, ['l', 'm', 's'])}\n );\n}\n`;\n\nconst apply = (m: Mat3, v: readonly [number, number, number]): [number, number, number] => [\n m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],\n m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],\n m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],\n];\n\nconst srgbChannelToLinear = (c: number) =>\n c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);\nconst linearChannelToSrgb = (c: number) =>\n c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(Math.max(c, 0), 1 / 2.4) - 0.055;\n\nexport function srgbToOklab(rgb: readonly [number, number, number]): readonly [number, number, number] {\n const linear = rgb.map(srgbChannelToLinear) as [number, number, number];\n const lms = apply(LINEAR_TO_LMS, linear);\n return apply(LMS_TO_OKLAB, lms.map((v) => Math.cbrt(Math.max(v, 0))) as [number, number, number]);\n}\n\n/** The inverse direction in JS — the same numbers that go into the shader: only that way does a\n * round-trip test prove the GPU and the CPU compute the same thing. */\nexport function oklabToSrgb(lab: readonly [number, number, number]): readonly [number, number, number] {\n const lms = apply(OKLAB_TO_LMS, lab as [number, number, number]).map((v) => v * v * v) as [\n number,\n number,\n number,\n ];\n return apply(LMS_TO_LINEAR, lms).map(linearChannelToSrgb) as [number, number, number];\n}\n","// Vapor species tones are DERIVED from a character, not picked as three separate colors. The\n// three most common colors of a piece of cover art are not a palette: they land all around the\n// hue wheel, and the species mix with each other too — a mix of two opposing hues passes through\n// gray. A narrow arc around a single hue is the guard against that: mixes inside it simply have\n// nowhere to pass through gray.\n\nimport { oklabToSrgb, srgbToOklab } from './oklab';\nimport type { VireUIKitMediumChannel } from './params';\n\n/** The medium's character: one hue, one chroma, one lightness shared by the whole triad. */\nexport type VireUIKitMediumCharacter = {\n /** Oklch hue, degrees. */\n hue: number;\n /** Oklch chroma. For the initial state, this is the platform tone's own chroma — i.e. the floor. */\n chroma: number;\n /** Oklab lightness, shared across every species: unevenly bright tones read as separate patches\n * rather than one medium. */\n lightness: number;\n};\n\n/**\n * The medium's working lightness. Glass can't be parked on the polarity threshold (0.50…0.62) —\n * the material would flip its polarity every frame there — so the medium stays below that band.\n * The physical picture is the same either way: the chamber is a dark volume that tracks glow\n * inside it.\n */\nexport const MEDIUM_SPECIES_LIGHTNESS = 0.44;\n\n/** Arc between neighboring species, degrees (a 25–40° spread is the target). */\nexport const MEDIUM_SPECIES_HUE_SPREAD = 32;\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v));\n\n/** Hue, chroma and lightness of an sRGB color — the input for building a family around it. */\nexport function characterOf(color: VireUIKitMediumChannel): VireUIKitMediumCharacter {\n const [lightness, a, b] = srgbToOklab(color);\n return {\n hue: (Math.atan2(b, a) * 180) / Math.PI,\n chroma: Math.hypot(a, b),\n lightness,\n };\n}\n\n/**\n * A family of three species: `H − Δ`, `H`, `H + Δ`, sharing lightness and chroma.\n *\n * Cover art with no color needs no special case: on a gray cover, chroma arrives as the floor,\n * and the arc collapses on its own — all three species get the same platform tone. That is the\n * initial state, not a branch written for it.\n */\nexport function speciesFamily(\n character: VireUIKitMediumCharacter,\n spreadDeg = MEDIUM_SPECIES_HUE_SPREAD,\n): readonly [VireUIKitMediumChannel, VireUIKitMediumChannel, VireUIKitMediumChannel] {\n const { hue, chroma, lightness } = character;\n const tone = (deg: number): VireUIKitMediumChannel => {\n const rad = (deg * Math.PI) / 180;\n return oklabToSrgb([lightness, chroma * Math.cos(rad), chroma * Math.sin(rad)]).map(\n clamp01,\n ) as unknown as VireUIKitMediumChannel;\n };\n return [tone(hue - spreadDeg), tone(hue), tone(hue + spreadDeg)];\n}\n\n/**\n * The initial state: with no cover art, there is nowhere to take a color from. The family is\n * derived from the platform's own tone, so there is no hardcoded triad in the code — neither a\n * placeholder nor an example — only the platform tone and the derivation rule.\n */\nexport function neutralSpecies(\n baseColor: VireUIKitMediumChannel,\n): readonly [VireUIKitMediumChannel, VireUIKitMediumChannel, VireUIKitMediumChannel] {\n const { hue, chroma } = characterOf(baseColor);\n return speciesFamily({ hue, chroma, lightness: MEDIUM_SPECIES_LIGHTNESS });\n}\n","import { neutralSpecies } from './species';\n\n// Medium parameters — the single source of truth for the web runtime (`web/medium.ts`) and,\n// later, an Android one: the numbers are one set, the platforms only differ in how they feed them\n// into uniforms.\nexport type VireUIKitMediumChannel = readonly [number, number, number];\n\nexport type VireUIKitMediumParams = {\n /** 1/s — how fast the TRACK density decays per second. Water (vapor+condensate) does not decay:\n * it is a conserved volume, only moving between phases and being transported. */\n decay: number;\n /** Spatial frequency of the curl-noise potential, 1/px of the simulation grid. */\n curlFreq: number;\n /** Drift speed of the noise domain over time, 1/s. */\n curlSpeed: number;\n /** Velocity field scale, grid-px/s — how far the smoke can be carried in a second. */\n advectSpeed: number;\n /** Turbulence gate, 0…1 (playback drives it: playing is alive, paused cools down). The\n * per-frame value is computed by `createMediumDynamics` (`dynamics.ts`) — it arrives here\n * already resolved. */\n turbulence: number;\n /** 1/s — the fraction of vapor EXCESS above `condensationFloor` that turns into condensate every\n * second from background seeding: weak, everywhere, all the time — the chamber is never\n * perfectly clean. */\n condensationRate: number;\n /** Vapor density (in seed-density units, a spot's peak ≈1) below which condensation does not\n * happen at all — only the excess above it condenses. Without a floor, the condensate would\n * exactly retrace the vapor spot's shape (the same outline, or softer from growth blur), and\n * the two phases would have nowhere to visually diverge: vapor is already soft, so the floor\n * concentrates condensate in dense turbulence cores, leaving the sparse vapor around it\n * uncondensed. Keep it NOTICEABLY below the settled mean vapor level (turbulence stirs spots\n * into a nearly flat background of ~0.3 over a few minutes — a known limit of the numerical\n * advection): a floor above that mean eventually stops finding any excess at all, and\n * condensation dies out for good. */\n condensationFloor: number;\n /** 1/s — the fraction of condensate evaporating back into vapor. Larger than the condensation\n * rate: at equilibrium most of the water stays vapor, which is the \"supersaturated\" one. */\n evaporationRate: number;\n /** grid-px/s — condensate drift downward, on top of the shared velocity field: gravity touches\n * droplets and leaves vapor alone. */\n condensateSettleSpeed: number;\n /** 1/s — the rate at which a droplet blends with its grid neighbors. There is no separate age\n * counter: the blend repeats every frame, and whatever has survived more frames ends up more\n * blended with its neighbors on its own — that IS \"growth\". Keep it NOTICEABLY below\n * `evaporationRate`: over a droplet's average lifetime (1/evaporationRate) only a small\n * fraction should get to blend — otherwise the blur outruns evaporation and the whole grid\n * levels into flat fog before a droplet gets a chance to return to vapor (visible by eye: the\n * contrast between \"a patch of droplets\" and the vapor background disappears within seconds). */\n condensateSpreadRate: number;\n /** 0…1 — how much of the local vapor hue shows through in the condensate. Droplets carry no\n * color of their own (droplets scatter light rather than tint it) — this is only a faint hint\n * of the surroundings. */\n condensateTint: number;\n /** Condensate's visibility multiplier in the Oklab mix relative to vapor at equal density —\n * droplets hold a small share of the water's mass, but they are mostly what's visible (as in a\n * real cloud: vapor is transparent, droplets create the visibility). */\n condensateGain: number;\n /** Near-white sRGB tone of droplets with no hue seeding (`condensateTint=0`) — droplets scatter\n * light rather than color it. */\n condensateColor: VireUIKitMediumChannel;\n /** Each vapor species' hue, sRGB 0…1. Derived as a family around one tone (`speciesFamily`),\n * not picked as three separate colors — see `species.ts`. */\n channelColors: readonly [VireUIKitMediumChannel, VireUIKitMediumChannel, VireUIKitMediumChannel];\n /** The platform's neutral tone — what the medium is, alone, with no source playing. */\n baseColor: VireUIKitMediumChannel;\n /** Background weight in the Oklab mix: keeps the platform tone visible even where the smoke is dense. */\n baseWeight: number;\n /** Field-sample scale for the far depth plane, k ≥ 1 (see `MEDIUM_COMPOSITE_SHADER`): the far\n * plane reads the SAME vapor/condensate field as the near plane, at `src * k + offset` instead\n * of `src`. A larger k folds more of the field into the same screen area, so the identical\n * evolving field reads as both finer-grained structure (features occupy fewer screen-px) and,\n * because the field's own motion maps to screen motion as v/k, slower apparent motion — parallax\n * and scale-of-structure from one number, with no second simulation. Measured against\n * `check-medium.mjs`'s parallax gate (a cross-correlation best-shift, not a centroid — see the\n * gate's own comment on why): 1.6 puts the far/near screen-speed ratio at 0.630, matching the\n * naive prediction 1/1.6 = 0.625 almost exactly, comfortably inside a band that reads as depth\n * without the far plane looking frozen. */\n depthFarScale: number;\n /** Offset into the shared field for the far plane, as a FRACTION of the grid's own width/height\n * (per axis) rather than a fixed grid-px value — the field wraps (`gl.REPEAT`), so a fixed\n * grid-px offset's EFFECTIVE (wrapped) distance from zero depends on whatever grid size it\n * happens to run at, and can land close to zero by accident at a size nobody tested against.\n * (This shipped once as `[41, 67]` grid-px: harmless at the 96px grid the depth gates tested\n * against, but 67 wraps to just 5px at the 72px grid height this package's own README uses as\n * its example — the far plane nearly re-read the near one, exactly the echo depth exists to\n * avoid.) A fraction keeps the wrapped distance proportional to the grid at any size — see\n * `depthFarOffsetPx` below and `check-medium.mjs`'s decorrelation gate, which measures this\n * directly at the README's own 128x72. */\n depthFarOffsetFrac: readonly [number, number];\n /** Grid-px tap radius for the far plane's 4-tap box blur. A spatial average cannot raise local\n * variance, so the same blur that softens the far plane's edges also, by construction, lowers\n * its measured contrast — one mechanism for both aerial-perspective cues (no separate contrast\n * knob exists). 0 disables it (the near plane stays sharp). Measured: reading the far plane's\n * own scale/offset with no blur already drops contrast ~6% (a fixed offset can land on a\n * locally denser or sparser patch of a field that isn't spatially uniform); this radius takes\n * the total to 24% — see the aerial-perspective gate, whose two thresholds isolate blur's own\n * share from that baseline. */\n depthFarBlurRadius: number;\n /** The far plane's share of the mix, relative to the near plane's implicit 1 — aerial\n * perspective's other half: even at equal density the far plane must not compete with the near\n * one for attention. */\n depthFarWeight: number;\n /** grid-px/s — a small downward drift added to VAPOR's own velocity, on top of the shared\n * curl-noise field (condensate already settles faster via `condensateSettleSpeed`). An order of\n * magnitude below it so the gas reads as \"barely noticeable\" drift rather than visibly falling.\n * ADDS NO DENSITY: on this periodic (`gl.REPEAT`) grid a uniform drift only ever translates the\n * field and wraps it back in at the top — it cannot accumulate mass anywhere, the way a real\n * floor would. It is a motion cue (which way is down), not a source of \"denser at the bottom\" —\n * that reading comes entirely from `gravityBottomBoost` below. See\n * `check-medium.mjs`'s vapor-drift gate, which checks DIRECTION over a short window rather than\n * a density ratio, for exactly this reason. */\n gravityVaporDrift: number;\n /** Density boost at the very bottom of the frame, added on top of 1 — a COMPOSITING-only effect:\n * it scales how the already-conserved vapor/condensate density is DISPLAYED, never the\n * simulated buffers themselves, so it cannot threaten the water-conservation invariant by\n * construction, and — unlike `gravityVaporDrift` above — this is the mechanism that actually\n * produces \"denser at the bottom\": it is a steady-state property of the compositing math, true\n * from the very first frame, independent of any warmup or mixing time (see\n * `check-medium.mjs`'s boost gate, which measures it with zero simulated steps for exactly that\n * reason). Large in absolute terms because `baseWeight` (0.9) dominates the Oklab mix wherever\n * gas density is thin — a small boost gets diluted into an invisible change in the final color;\n * measured this large to register as a real, visible band against that dilution. */\n gravityBottomBoost: number;\n /** Fraction of frame height (0 top, 1 bottom) where the bottom boost starts ramping in via\n * `smoothstep`. The brief asks for a denser LAYER at the floor of the chamber, not a boost that\n * reaches halfway up the frame — kept in the bottom quarter. */\n gravityBottomBoostStart: number;\n};\n\nconst BASE_COLOR: VireUIKitMediumChannel = [0.07, 0.08, 0.1];\n\nexport const MEDIUM_DEFAULTS: VireUIKitMediumParams = {\n decay: 0.06,\n curlFreq: 0.045,\n curlSpeed: 0.12,\n advectSpeed: 26,\n turbulence: 1,\n condensationRate: 0.18,\n condensationFloor: 0.12,\n evaporationRate: 0.2,\n condensateSettleSpeed: 8,\n condensateSpreadRate: 0.08,\n condensateTint: 0.2,\n condensateGain: 5,\n condensateColor: [0.94, 0.95, 0.97],\n channelColors: neutralSpecies(BASE_COLOR),\n baseColor: BASE_COLOR,\n baseWeight: 0.9,\n depthFarScale: 1.6,\n depthFarOffsetFrac: [0.47, 0.53],\n depthFarBlurRadius: 5,\n depthFarWeight: 0.5,\n gravityVaporDrift: 0.6,\n gravityBottomBoost: 2,\n gravityBottomBoostStart: 0.75,\n};\n\n/**\n * Resolves the far plane's fractional offset (`depthFarOffsetFrac`) into grid-px for a GIVEN grid\n * size — pure and exported so the wrap-safety it exists for is directly testable (see\n * `medium-depth-offset.test.ts`) without spinning up a WebGL2 context. Per-axis: `gridWidth` and\n * `gridHeight` are independent, and a non-square grid (the README's own 128x72 example) shouldn't\n * distort one axis relative to the other.\n */\nexport function depthFarOffsetPx(\n offsetFrac: readonly [number, number],\n gridWidth: number,\n gridHeight: number,\n): readonly [number, number] {\n return [offsetFrac[0] * gridWidth, offsetFrac[1] * gridHeight];\n}\n\n/**\n * How far a value lands from the nearest wrap boundary (0 or `period`) on a periodic axis of the\n * given `period` — the quantity that actually matters for decorrelation, not the raw offset value\n * itself (a raw offset near the period is effectively a tiny one once the field wraps). Exported\n * for the same reason as `depthFarOffsetPx`: a plain number, testable without a GPU.\n */\nexport function wrappedDistanceFromZero(value: number, period: number): number {\n const wrapped = ((value % period) + period) % period;\n return Math.min(wrapped, period - wrapped);\n}\n\n/** The material probe can't see texture finer than this: there is no gain in a denser simulation\n * grid than the probe's own floor. */\nexport const MEDIUM_PROBE_FLOOR_PX = 22;\n\n/** Device-px per simulation grid cell by default — noticeably coarser than the probe floor;\n * sharpening the detail visible right next to the glass is a later increment (emission and\n * palette). */\nexport const MEDIUM_DEFAULT_CELL_PX = 26;\n\n/**\n * The geometry of one track stamp (`emit-shader.ts`), in FRACTIONS of `min(gridW, gridH)` rather\n * than pixels — so a preset doesn't depend on the simulation grid's resolution. Two presets:\n * alpha is a thick, short, straight track; beta is a thin, long, ragged one. The third, natural,\n * is dim and short, for the occasional background track at rest (not tied to cover art).\n */\nexport type VireUIKitMediumTrackPreset = {\n lengthFrac: number;\n headWidthFrac: number;\n tailWidthFrac: number;\n /** Amplitude of the path's sideways noise, as a fraction of `min(gridW, gridH)`; 0 = a straight thread. */\n raggedFrac: number;\n raggedFreq: number;\n /** How far the tail has settled downward, as a fraction of `min(gridW, gridH)`. */\n settleFrac: number;\n /** How much dimmer the tail is than the head, 0…1. */\n tailDim: number;\n intensity: number;\n};\n\nexport const MEDIUM_TRACK_PRESETS: Readonly<\n Record<'alpha' | 'beta' | 'natural', VireUIKitMediumTrackPreset>\n> = {\n alpha: {\n lengthFrac: 0.22,\n headWidthFrac: 0.05,\n tailWidthFrac: 0.12,\n raggedFrac: 0,\n raggedFreq: 0,\n settleFrac: 0.02,\n tailDim: 0.55,\n intensity: 1.15,\n },\n beta: {\n lengthFrac: 0.42,\n headWidthFrac: 0.018,\n tailWidthFrac: 0.05,\n raggedFrac: 0.045,\n raggedFreq: 5,\n settleFrac: 0.05,\n tailDim: 0.45,\n intensity: 0.85,\n },\n natural: {\n lengthFrac: 0.13,\n headWidthFrac: 0.02,\n tailWidthFrac: 0.04,\n raggedFrac: 0.02,\n raggedFreq: 4,\n settleFrac: 0.02,\n tailDim: 0.5,\n intensity: 0.4,\n },\n};\n\n/** Turbulence at rest/paused/with no active track — the \"slow drift\" baseline shared by all three\n * neutral states (they differ from each other in other ways, not in this number). */\n// Rest has to look like it's actually resting: mixing happens over minutes, not seconds. A\n// slower flow also accumulates less numerical diffusion.\nexport const MEDIUM_DRIFT_TURBULENCE = 0.045;\n\n/** Turbulence range during playback: the low end is a quiet stretch of the track, the high end is\n * peak amplitude. */\nexport const MEDIUM_PLAYING_TURBULENCE_RANGE: readonly [number, number] = [0.55, 1.15];\n\n/** Turbulence smoothing time constant, seconds: both ramping up on play and cooling down on pause\n * go through this filter — smooth, never a jump. */\nexport const MEDIUM_TURBULENCE_TAU = 2.5;\n\n/** Emission cadence — once every this many beats, not on every beat: at 120–180 BPM that is\n * 0.5–0.75 Hz, an order of magnitude below the WCAG 2.3.1 photosensitivity ceiling (3 Hz). */\nexport const MEDIUM_EMISSION_BEATS = 4;\n\n/** Fraction of the beats that actually get an emission — breaks up the exact periodicity (a\n * metronome-perfect pulse reads as mechanical) without moving the average rate. */\nexport const MEDIUM_EMISSION_PROBABILITY = 0.55;\n\n/** Emergency floor between two emissions, seconds — a safeguard for the same WCAG 2.3.1\n * requirement, independent of `MEDIUM_EMISSION_BEATS`/BPM: even a mistake in the other constants\n * can't flash faster than this. */\nexport const MEDIUM_MIN_EMISSION_INTERVAL = 1.2;\n\n/** Average interval between solitary \"background emission\" tracks at rest, seconds, plus a random\n * `MEDIUM_NATURAL_JITTER` on top — rare, with no discernible rhythm. */\nexport const MEDIUM_NATURAL_INTERVAL = 25;\nexport const MEDIUM_NATURAL_JITTER = 35;\n\n/** The sensitive layer — fractions of height, top to bottom. Above it there isn't enough\n * supersaturation for a track to appear at all, so background emission is only ever visible here. */\nexport const MEDIUM_SENSITIVE_TOP = 0.55;\nexport const MEDIUM_SENSITIVE_BOTTOM = 0.95;\n\n/** Per-emission depth range applied to a track's width (`headWidthFrac`/`tailWidthFrac`), far…near.\n * The same knob reads as both \"thin\" and \"soft\": a Gaussian stamp's edge steepness scales with its\n * own width (`emit-shader.ts`'s only extent parameter), so shrinking it for a far track thins and\n * softens it in the same stroke — there is no second knob to hang an independent softness number\n * on. Floored at 0.55: below that a far `alpha` track's head narrows past what MacCormack's own\n * numerical smoothing already blurs it to, and the depth cue disappears into that noise floor. */\nexport const MEDIUM_TRACK_DEPTH_WIDTH_RANGE: readonly [number, number] = [0.55, 1];\n\n/** Per-emission depth range applied to a track's intensity, far…near — \"dim\". Floored at 0.4, not\n * lower: a far track still has to clear `MEDIUM_DEFAULTS.condensationFloor`'s excess threshold\n * often enough to read as a track, or depth would look like tracks randomly failing to spawn\n * rather than fading into the distance. */\nexport const MEDIUM_TRACK_DEPTH_INTENSITY_RANGE: readonly [number, number] = [0.4, 1];\n","// Hash + value-noise — the shared noise primitive (track emission also uses it for the tail's\n// ragged spread, `emit-shader.ts`), kept apart from what's curl-specific (the potential, the curl).\nexport const VG_VALUE_NOISE = `\nfloat vgHash21(float2 p) {\n float h = dot(p, float2(127.1, 311.7));\n return fract(sin(h) * 43758.5453123);\n}\n\nfloat vgValueNoise(float2 p) {\n float2 i = floor(p);\n float2 f = fract(p);\n float a = vgHash21(i);\n float b = vgHash21(i + float2(1.0, 0.0));\n float c = vgHash21(i + float2(0.0, 1.0));\n float d = vgHash21(i + float2(1.0, 1.0));\n float2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 2.0 - 1.0;\n}\n`;\n\n/** GLSL float literals need a decimal point (`600` won't compile as a float) — same convention as\n * oklab.ts's `glsl()`. */\nconst glslFloat = (v: number): string => (Number.isInteger(v) ? v.toFixed(1) : String(v));\n\n/** The field's phase wraps every this many PHASE units (curlSpeed·seconds), NOT seconds of real\n * time: `web/medium.ts`'s CPU-side accumulator does `phase += dt * curlSpeed`, so the wall-clock\n * wrap period is `MEDIUM_TIME_PERIOD / curlSpeed` — about 2.3h (8333s) at the shipped default\n * (`curlSpeed = 0.12`), verified against that default in `medium-noise-periodicity.test.ts` so\n * this comment can't silently drift out of sync with it. Chosen so every octave rate below times\n * this period lands on an integer — that integer is the z-lattice period `vgValueNoise3Periodic`\n * wraps on, which is what makes the wrap exact rather than merely small. */\nexport const MEDIUM_TIME_PERIOD = 1000;\n\ntype MediumTimeOctave = {\n /** The z-axis (phase) rate this octave's `vgValueNoise3Periodic` call advances at. */\n rate: number;\n /** Fixed phase offset — decorrelates the octaves (see the comment on `vgPotential` below) and\n * doesn't affect periodicity: shifting a periodic function along its own axis leaves its period\n * unchanged. */\n offset: number;\n /** Spatial frequency multiplier on `p`. */\n scale: number;\n};\n\n/** `vgPotential`'s four fbm octaves — the single source both the GLSL text below and the JS port\n * (`vgPotentialJS`, for tests) derive their numbers from, so the two can never drift apart. */\nexport const MEDIUM_TIME_OCTAVES: readonly MediumTimeOctave[] = [\n { rate: 0.6, offset: 0, scale: 1 },\n { rate: 1.7, offset: 11, scale: 2.03 },\n { rate: 0.9, offset: 37, scale: 4.11 },\n { rate: 0.15, offset: 5, scale: 0.35 },\n];\n\n/** The z-lattice period for one octave's rate — `rate * MEDIUM_TIME_PERIOD`, which has to land on\n * an integer or the phase wrap would jump instead of being seamless (see `MEDIUM_TIME_OCTAVES`).\n * Throws at module load rather than rendering a shader that would silently jitter on every wrap. */\nexport function mediumOctavePeriod(rate: number): number {\n const period = rate * MEDIUM_TIME_PERIOD;\n if (!Number.isInteger(period)) {\n throw new Error(\n `medium noise: octave rate ${rate} * MEDIUM_TIME_PERIOD ${MEDIUM_TIME_PERIOD} = ${period}, not an integer — the phase wrap would not be seamless`,\n );\n }\n return period;\n}\n\nconst glslOctave = (o: MediumTimeOctave) => ({\n rate: glslFloat(o.rate),\n offset: glslFloat(o.offset),\n scale: glslFloat(o.scale),\n period: glslFloat(mediumOctavePeriod(o.rate)),\n});\nconst [VG_OCT0, VG_OCT1, VG_OCT2, VG_OCT3] = MEDIUM_TIME_OCTAVES.map(glslOctave);\n\n// Curl-noise velocity field: the curl of a SCALAR potential is divergence-free by construction,\n// so no Poisson solver is needed here — unlike Stable Fluids, where incompressibility has to be\n// projected explicitly.\nexport const VG_CURL_NOISE = `\n${VG_VALUE_NOISE}\n\nfloat vgHash31(float3 p) {\n float h = dot(p, float3(127.1, 311.7, 74.7));\n return fract(sin(h) * 43758.5453123);\n}\n\n// 3D value-noise: time is a third grid axis, not a coordinate shift. A shift would just SLIDE the\n// pattern (a \"traveling\" look, when motion is meant to read as random), while trilinear\n// interpolation over t CHANGES the pattern itself between grid nodes — nearby moments look alike,\n// distant ones don't, and no repeat ever shows up.\nfloat vgValueNoise3(float3 p) {\n float3 i = floor(p);\n float3 f = fract(p);\n float3 u = f * f * (3.0 - 2.0 * f);\n float n000 = vgHash31(i);\n float n100 = vgHash31(i + float3(1.0, 0.0, 0.0));\n float n010 = vgHash31(i + float3(0.0, 1.0, 0.0));\n float n110 = vgHash31(i + float3(1.0, 1.0, 0.0));\n float n001 = vgHash31(i + float3(0.0, 0.0, 1.0));\n float n101 = vgHash31(i + float3(1.0, 0.0, 1.0));\n float n011 = vgHash31(i + float3(0.0, 1.0, 1.0));\n float n111 = vgHash31(i + float3(1.0, 1.0, 1.0));\n float nx00 = mix(n000, n100, u.x);\n float nx10 = mix(n010, n110, u.x);\n float nx01 = mix(n001, n101, u.x);\n float nx11 = mix(n011, n111, u.x);\n float nxy0 = mix(nx00, nx10, u.y);\n float nxy1 = mix(nx01, nx11, u.y);\n return mix(nxy0, nxy1, u.z) * 2.0 - 1.0;\n}\n\n// Same as vgValueNoise3, but the z axis wraps at \\`period\\`: BOTH z-lattice neighbors (i.z and\n// i.z + 1.0) are folded into [0, period) before hashing, so this is EXACTLY the same function of\n// z and of z + period — not just close. vgPotential below calls this once per octave, each with\n// the period matching its own rate (MEDIUM_TIME_OCTAVES), so a phase that wraps at\n// MEDIUM_TIME_PERIOD reproduces the identical field instead of jumping to a new one.\nfloat vgValueNoise3Periodic(float3 p, float period) {\n float3 i = floor(p);\n float3 f = fract(p);\n float3 u = f * f * (3.0 - 2.0 * f);\n float z0 = mod(i.z, period);\n float z1 = mod(i.z + 1.0, period);\n float n000 = vgHash31(float3(i.xy, z0));\n float n100 = vgHash31(float3(i.xy + float2(1.0, 0.0), z0));\n float n010 = vgHash31(float3(i.xy + float2(0.0, 1.0), z0));\n float n110 = vgHash31(float3(i.xy + float2(1.0, 1.0), z0));\n float n001 = vgHash31(float3(i.xy, z1));\n float n101 = vgHash31(float3(i.xy + float2(1.0, 0.0), z1));\n float n011 = vgHash31(float3(i.xy + float2(0.0, 1.0), z1));\n float n111 = vgHash31(float3(i.xy + float2(1.0, 1.0), z1));\n float nx00 = mix(n000, n100, u.x);\n float nx10 = mix(n010, n110, u.x);\n float nx01 = mix(n001, n101, u.x);\n float nx11 = mix(n011, n111, u.x);\n float nxy0 = mix(nx00, nx10, u.y);\n float nxy1 = mix(nx01, nx11, u.y);\n return mix(nxy0, nxy1, u.z) * 2.0 - 1.0;\n}\n\n// The potential is an fbm of three octaves, each evolving over the field's PHASE at its own speed\n// (unrelated to the others, plus a phase offset): no single phase shift can align them all at\n// once, so no traveling pattern remains. Each octave wraps its own z-lattice at the period that\n// matches its rate (see MEDIUM_TIME_OCTAVES/vgValueNoise3Periodic), so\n// vgPotential(p, phase) === vgPotential(p, phase + ${glslFloat(MEDIUM_TIME_PERIOD)}) exactly — a\n// CPU-side phase accumulator can wrap at that period with no visible jump (see web/medium.ts). The\n// large-scale factor is a slow non-uniformity layered over the sum — without it, a field that's\n// uniform by statistics still reads as a regular cell pattern.\nfloat vgPotential(float2 p, float phase) {\n float v = vgValueNoise3Periodic(float3(p * ${VG_OCT0.scale}, phase * ${VG_OCT0.rate} + ${VG_OCT0.offset}), ${VG_OCT0.period}) * 0.5;\n v += vgValueNoise3Periodic(float3(p * ${VG_OCT1.scale}, phase * ${VG_OCT1.rate} + ${VG_OCT1.offset}), ${VG_OCT1.period}) * 0.25;\n v += vgValueNoise3Periodic(float3(p * ${VG_OCT2.scale}, phase * ${VG_OCT2.rate} + ${VG_OCT2.offset}), ${VG_OCT2.period}) * 0.125;\n float macro = vgValueNoise3Periodic(float3(p * ${VG_OCT3.scale}, phase * ${VG_OCT3.rate} + ${VG_OCT3.offset}), ${VG_OCT3.period});\n return v * (0.7 + 0.3 * macro);\n}\n\n// Velocity = (dPsi/dy, -dPsi/dx) — the curl of a scalar field, solenoidal for any Psi. Central\n// differences instead of an analytic derivative: the potential is a sum of three value-noise\n// calls, and there is no cheaper way to differentiate it by hand than four extra samples.\nfloat2 vgCurlVelocity(float2 p, float phase) {\n float e = 0.6;\n float px1 = vgPotential(p + float2(e, 0.0), phase);\n float px0 = vgPotential(p - float2(e, 0.0), phase);\n float py1 = vgPotential(p + float2(0.0, e), phase);\n float py0 = vgPotential(p - float2(0.0, e), phase);\n return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);\n}\n`;\n\n// --- JS port of the periodic potential, for tests that can't spin up a WebGL2 context ------------\n//\n// The exact same arithmetic as vgHash31/vgValueNoise3Periodic/vgPotential above, so a vitest can\n// prove periodicity and continuity across the phase wrap without a GPU. Kept next to the shader\n// text as the single source of truth for what \"the same constants\" means: both are generated from\n// MEDIUM_TIME_OCTAVES, and `medium-noise-periodicity.test.ts` checks the shader string itself\n// contains these numbers rather than trusting that the two never drifted apart.\n\nfunction hash31JS(x: number, y: number, z: number): number {\n const h = x * 127.1 + y * 311.7 + z * 74.7;\n const s = Math.sin(h) * 43758.5453123;\n return s - Math.floor(s);\n}\n\nconst smoothstep3JS = (f: number): number => f * f * (3 - 2 * f);\n\n/** GLSL's `mod(x, y)` for floats is `x - y * floor(x / y)` — always in `[0, y)`, unlike JS's `%`,\n * which keeps the sign of `x`. Matching it exactly here is the point: this IS the wrap the shader\n * performs. */\nconst glslMod = (x: number, y: number): number => x - y * Math.floor(x / y);\n\n/** Pure-JS port of `vgValueNoise3Periodic`. */\nexport function vgValueNoise3PeriodicJS(px: number, py: number, pz: number, period: number): number {\n const ix = Math.floor(px);\n const iy = Math.floor(py);\n const iz = Math.floor(pz);\n const ux = smoothstep3JS(px - ix);\n const uy = smoothstep3JS(py - iy);\n const uz = smoothstep3JS(pz - iz);\n const z0 = glslMod(iz, period);\n const z1 = glslMod(iz + 1, period);\n const n000 = hash31JS(ix, iy, z0);\n const n100 = hash31JS(ix + 1, iy, z0);\n const n010 = hash31JS(ix, iy + 1, z0);\n const n110 = hash31JS(ix + 1, iy + 1, z0);\n const n001 = hash31JS(ix, iy, z1);\n const n101 = hash31JS(ix + 1, iy, z1);\n const n011 = hash31JS(ix, iy + 1, z1);\n const n111 = hash31JS(ix + 1, iy + 1, z1);\n const nx00 = n000 + (n100 - n000) * ux;\n const nx10 = n010 + (n110 - n010) * ux;\n const nx01 = n001 + (n101 - n001) * ux;\n const nx11 = n011 + (n111 - n011) * ux;\n const nxy0 = nx00 + (nx10 - nx00) * uy;\n const nxy1 = nx01 + (nx11 - nx01) * uy;\n return (nxy0 + (nxy1 - nxy0) * uz) * 2 - 1;\n}\n\n/** Pure-JS port of `vgPotential`, built from the same `MEDIUM_TIME_OCTAVES` table the shader text\n * above is generated from. */\nexport function vgPotentialJS(px: number, py: number, phase: number): number {\n const [o0, o1, o2, o3] = MEDIUM_TIME_OCTAVES;\n let v =\n vgValueNoise3PeriodicJS(px * o0.scale, py * o0.scale, phase * o0.rate + o0.offset, mediumOctavePeriod(o0.rate)) *\n 0.5;\n v +=\n vgValueNoise3PeriodicJS(px * o1.scale, py * o1.scale, phase * o1.rate + o1.offset, mediumOctavePeriod(o1.rate)) *\n 0.25;\n v +=\n vgValueNoise3PeriodicJS(px * o2.scale, py * o2.scale, phase * o2.rate + o2.offset, mediumOctavePeriod(o2.rate)) *\n 0.125;\n const macro = vgValueNoise3PeriodicJS(\n px * o3.scale,\n py * o3.scale,\n phase * o3.rate + o3.offset,\n mediumOctavePeriod(o3.rate),\n );\n return v * (0.7 + 0.3 * macro);\n}\n","// Initial spots for the three GAS channels: three fixed-color channels with initial spots — this\n// is the entire water supply, gas has no other source, it only conserves and mixes through\n// advection. One pass, three channels: r/g/b are each smoke channel's density on its own, not a\n// color — composite-shader.ts paints them with a fixed palette.\nexport const MEDIUM_SEED_SHADER = `\nuniform float2 u_spot0;\nuniform float2 u_spot1;\nuniform float2 u_spot2;\nuniform float u_spotRadius;\n\nfloat vgSpot(float2 p, float2 c, float r) {\n float d = length(p - c) / max(r, 1.0);\n return exp(-d * d * 2.0);\n}\n\nhalf4 main(float2 xy) {\n float3 dye = float3(\n vgSpot(xy, u_spot0, u_spotRadius),\n vgSpot(xy, u_spot1, u_spotRadius),\n vgSpot(xy, u_spot2, u_spotRadius)\n );\n return half4(half3(dye), half(1.0));\n}\n`;\n","// Bilinear resample of one buffer into a different-sized target — used only when the simulation\n// grid changes size (`ensureGrid` in web/medium.ts resizing on a window resize or a device\n// rotation), to carry vapor/condensate/track state across instead of wiping it. A destroy-and-\n// reseed on every resize was the flicker the design forbids: the vapor jumps back to its three\n// initial spots and condensate/tracks blink out.\n//\n// Same flip convention as advect-shader.ts's `.eval()` reads: `xy` is top-down (the shader's own\n// entry coordinate), the source texture is stored bottom-up, so a straight `.eval()` without the\n// flip would resample from the wrong row. `u_src`'s own size (`u_srcSize`) comes from the\n// transpiler's auto-generated uniform for `uniform shader` (see the comment on the cross-sampler\n// in web/medium.ts) — the OLD grid's dimensions, set explicitly since bindTextureAt only binds the\n// sampler itself.\nexport const MEDIUM_RESAMPLE_SHADER = `\nuniform shader u_src;\n\nhalf4 main(float2 xy) {\n float2 uv = xy / u_resolution;\n float2 src = uv * u_srcSize;\n return u_src.eval(float2(src.x, u_srcSize.y - src.y));\n}\n`;\n","import { VG_CURL_NOISE } from './noise';\n\n// MacCormack instead of plain semi-Lagrangian transport: a single backtrace sample is heavily\n// diffusive on a coarse grid — over roughly 15-35s turbulence stirs the vapor species into a flat\n// background, and all the structure the grid exists for disappears. This scheme splits the\n// transport of EACH field (vapor, condensate) into three passes instead of one:\n// forward — the same backtrace sample the old single-pass scheme used (`MEDIUM_*_FORWARD_SHADER`);\n// correct — a reverse backtrace of the already-transported field, a correction for the\n// difference against the original, and a limiter (clamped to the source cell's\n// neighbors) — without the limiter the scheme produces overshoots that weren't in the\n// field, which read as flicker (`MEDIUM_*_CORRECT_SHADER`);\n// react — the phase exchange (condensation/evaporation/growth), applied to the already\n// corrected transport rather than the raw backtrace (`MEDIUM_*_REACT_SHADER`).\n// The vapor<->condensate exchange used to read its partner AT ITS OWN transported point; splitting\n// the passes removes the cause outright: both phases transport on SEPARATE fields with no\n// cross-reads, and the exchange happens AFTER, on values already transported and grid-aligned at\n// the same point xy — so \"how much vapor was lost\" and \"how much condensate was gained\" agree by\n// construction.\n// u_phase replaces the old u_time/u_curlSpeed pair: the CPU accumulates phase += dt * curlSpeed\n// in float64 and wraps it at MEDIUM_TIME_PERIOD (see web/medium.ts and noise.ts's\n// MEDIUM_TIME_OCTAVES) before it ever becomes a float32 uniform. Over hours of playback, a raw\n// u_time kept growing without bound and eventually lost the bits that place a point within its\n// own lattice cell — the visible symptom was the field jittering in place. A side benefit: since\n// the rate is baked into the accumulation rather than multiplied in on every frame, changing\n// curlSpeed at runtime changes the SLOPE going forward instead of rescaling everything already\n// accumulated — no jump.\nconst MEDIUM_ADVECT_UNIFORMS = `\nuniform shader u_dye;\nuniform float u_dt;\nuniform float u_phase;\nuniform float u_curlFreq;\nuniform float u_advectSpeed;\nuniform float u_turbulence;\n\n${VG_CURL_NOISE}\n`;\n\n// Reading our own previous frame, which sits in the texture bottom-to-top: xy runs top-to-bottom,\n// and `.eval()` passes the coordinate straight through as a texture coordinate. Without this flip,\n// every step would turn the field upside down, and the two states would alternate frame to frame —\n// flicker across half the refresh rate. The rule is the same for ANY read of a smoke texture via\n// `.eval()` in this file: composite-shader.ts reads these same buffers for display and has to flip\n// the same way.\nconst MEDIUM_ADVECT_VELOCITY = `\n float2 vel = vgCurlVelocity(xy * u_curlFreq, u_phase) * u_advectSpeed * u_turbulence;\n`;\n\n// Gravity touches only the condensate — an addition on top of the shared field rather than a\n// separate calculation: droplets are carried by the same wind as vapor, plus a downward drift.\nconst MEDIUM_ADVECT_VELOCITY_SETTLE = `\n${MEDIUM_ADVECT_VELOCITY}\n vel.y += u_settleSpeed;\n`;\n\n// The gas itself leans down too, just far less than condensate: \"from the side\" only reads if\n// gravity touches the medium as a whole, not only its droplets. `u_vaporDrift` is an order of\n// magnitude below `u_settleSpeed` (see MEDIUM_DEFAULTS.gravityVaporDrift) — barely noticeable\n// frame to frame, cumulative over the run.\nconst MEDIUM_ADVECT_VELOCITY_VAPOR = `\n${MEDIUM_ADVECT_VELOCITY}\n vel.y += u_vaporDrift;\n`;\n\n// The forward MacCormack half-step — ONE backtrace sample, bit-for-bit what the whole scheme used\n// to do on its own. The result goes to a temporary buffer rather than out; the `correct` pass will\n// read it again to estimate, via a reverse step, how much this single sample smeared.\nexport const MEDIUM_VAPOR_FORWARD_SHADER = `\n${MEDIUM_ADVECT_UNIFORMS}\nuniform float u_vaporDrift;\n\nhalf4 main(float2 xy) {\n${MEDIUM_ADVECT_VELOCITY_VAPOR}\n float2 src = xy - vel * u_dt;\n return u_dye.eval(float2(src.x, u_dyeSize.y - src.y));\n}\n`;\n\nexport const MEDIUM_CONDENSATE_FORWARD_SHADER = `\n${MEDIUM_ADVECT_UNIFORMS}\nuniform float u_settleSpeed;\n\nhalf4 main(float2 xy) {\n${MEDIUM_ADVECT_VELOCITY_SETTLE}\n float2 src = xy - vel * u_dt;\n return u_dye.eval(float2(src.x, u_dyeSize.y - src.y));\n}\n`;\n\n// Reverse half-step + correction + limiter (Selle et al. 2007). The macro expects `vel` already\n// computed (the calling shader sets it to either the forward or the settling version) and two\n// samplers: `u_dye` — the field BEFORE this frame's transport, `u_forward` — the forward pass's\n// result for the same field.\n//\n// Correction: corrected = forward + (field_before - forward_backward)/2 — the standard MacCormack\n// formula, roughly halving one backtrace's numerical diffusion in order of accuracy.\n//\n// The limiter is mandatory: without it the correction isn't LOCALLY conservative and can push a\n// value above every one of the source cell's neighbors — a new extremum that wasn't in the field.\n// On a frame-by-frame sweep that reads as flicker. The clamp is to the range of the four texels\n// neighboring the ORIGINAL field around the point the forward pass came from (not bilinearly\n// blended — exactly the texel, `.eval()` at its precise center, the same technique used for\n// droplet growth in the react shaders below).\nconst MEDIUM_MACCORMACK_CORRECT = `\n float2 srcForward = xy - vel * u_dt;\n float2 srcReverse = xy + vel * u_dt;\n half4 forwardSelf = u_forward.eval(float2(xy.x, u_forwardSize.y - xy.y));\n half4 forwardReverse = u_forward.eval(float2(srcReverse.x, u_forwardSize.y - srcReverse.y));\n half4 prevSelf = u_dye.eval(float2(xy.x, u_dyeSize.y - xy.y));\n half4 corrected = forwardSelf + half4(0.5) * (prevSelf - forwardReverse);\n\n float2 src = float2(srcForward.x, u_dyeSize.y - srcForward.y);\n float2 base = floor(src - 0.5);\n half4 c00 = u_dye.eval(base + float2(0.5, 0.5));\n half4 c10 = u_dye.eval(base + float2(1.5, 0.5));\n half4 c01 = u_dye.eval(base + float2(0.5, 1.5));\n half4 c11 = u_dye.eval(base + float2(1.5, 1.5));\n half4 lo = min(min(c00, c10), min(c01, c11));\n half4 hi = max(max(c00, c10), max(c01, c11));\n half4 limited = clamp(corrected, lo, hi);\n`;\n\nexport const MEDIUM_VAPOR_CORRECT_SHADER = `\n${MEDIUM_ADVECT_UNIFORMS}\nuniform shader u_forward;\nuniform float u_vaporDrift;\n\nhalf4 main(float2 xy) {\n${MEDIUM_ADVECT_VELOCITY_VAPOR}\n${MEDIUM_MACCORMACK_CORRECT}\n return limited;\n}\n`;\n\nexport const MEDIUM_CONDENSATE_CORRECT_SHADER = `\n${MEDIUM_ADVECT_UNIFORMS}\nuniform float u_settleSpeed;\nuniform shader u_forward;\n\nhalf4 main(float2 xy) {\n${MEDIUM_ADVECT_VELOCITY_SETTLE}\n${MEDIUM_MACCORMACK_CORRECT}\n return limited;\n}\n`;\n\n// Water is a conserved volume: the sum of vapor and condensate has no source and no sink, only\n// transport and a transition between phases. `u_totalScale` is not water physics but a method\n// correction (see `web/medium.ts`): transport alone doesn't hold the sum exactly (residual\n// numerical diffusion/dispersion), so the runtime checks the total water (vapor+condensate) once a\n// second against the target and folds a correction into both buffers — the same one for each.\n//\n// The reaction operates on the already CORRECTED (MacCormack + limiter) transport of both phases,\n// not the raw backtrace: `u_transported`/`u_condensateTransported` are the correct pass's output,\n// grid-aligned at the same point xy, so the exchange reads its partner with no backtrace of its\n// own and no desync (see the comment at the top of this file).\n//\n// Vapor carries color (three channels r/g/b, as before).\nexport const MEDIUM_VAPOR_REACT_SHADER = `\nuniform shader u_transported;\nuniform shader u_condensateTransported;\nuniform float u_dt;\nuniform float u_condensationRate;\nuniform float u_condensationFloor;\nuniform float u_evaporationRate;\nuniform float u_totalScale;\n\nhalf4 main(float2 xy) {\n float3 prevVapor = float3(u_transported.eval(float2(xy.x, u_transportedSize.y - xy.y)).rgb);\n float condensateAtXy = float(u_condensateTransported.eval(float2(xy.x, u_condensateTransportedSize.y - xy.y)).r);\n float vaporTotal = prevVapor.r + prevVapor.g + prevVapor.b;\n\n // Only the EXCESS of vapor above the background floor condenses, not any nonzero density —\n // otherwise condensate would exactly retrace the vapor spot's shape (the same outline, or\n // softer from blur), leaving the two phases nowhere to visually diverge: vapor is already soft,\n // and condensate needs to read brighter and sharper than it. The floor concentrates condensate\n // in dense turbulence cores, leaving the sparse vapor around it uncondensed — that's where the\n // contrast comes from.\n float excess = max(0.0, vaporTotal - u_condensationFloor);\n float condensedAmount = excess * (1.0 - exp(-u_condensationRate * u_dt));\n float lossFraction = vaporTotal > 1e-6 ? condensedAmount / vaporTotal : 0.0;\n float3 afterLoss = prevVapor * (1.0 - lossFraction);\n\n float condKeep = exp(-u_evaporationRate * u_dt);\n float returned = condensateAtXy * (1.0 - condKeep);\n\n // Evaporated water is distributed by the CURRENT vapor proportion — the condensation/\n // evaporation cycle doesn't shift the hue, water only oscillates between phases (color belongs\n // to vapor).\n float sumAfterLoss = afterLoss.r + afterLoss.g + afterLoss.b;\n float3 hueShare = sumAfterLoss > 1e-6 ? afterLoss / sumAfterLoss : float3(1.0 / 3.0);\n float3 vapor = (afterLoss + hueShare * returned) * u_totalScale;\n return half4(half3(vapor), half(1.0));\n}\n`;\n\n// Condensate is droplets: a scalar (density lives in .r, g/b unused — it has no color of its own,\n// composite-shader.ts tints it with the local vapor hue). Two processes on top of transport:\n// exchange with vapor (mirrors the exchange in MEDIUM_VAPOR_REACT_SHADER, the same\n// excess/condensedAmount from the same transported vaporTotal) and growth (blending with grid\n// neighbors every frame with no age counter: whatever survived more steps ends up more blended\n// with its neighbors on its own). Settling isn't here — it's a velocity addition in\n// *_FORWARD_SHADER/*_CORRECT_SHADER.\nexport const MEDIUM_CONDENSATE_REACT_SHADER = `\nuniform shader u_transported;\nuniform shader u_vaporTransported;\nuniform shader u_dye;\nuniform float u_dt;\nuniform float u_condensationRate;\nuniform float u_condensationFloor;\nuniform float u_evaporationRate;\nuniform float u_spreadRate;\nuniform float u_totalScale;\n\nhalf4 main(float2 xy) {\n float prevCondensate = float(u_transported.eval(float2(xy.x, u_transportedSize.y - xy.y)).r);\n float3 vaporAtXy = float3(u_vaporTransported.eval(float2(xy.x, u_vaporTransportedSize.y - xy.y)).rgb);\n float vaporTotal = vaporAtXy.r + vaporAtXy.g + vaporAtXy.b;\n\n float excess = max(0.0, vaporTotal - u_condensationFloor);\n float condensed = excess * (1.0 - exp(-u_condensationRate * u_dt));\n float condKeep = exp(-u_evaporationRate * u_dt);\n float afterReaction = prevCondensate * condKeep + condensed;\n\n // Growth is an Euler step separate from transport: the neighbors are sampled at THIS cell's own\n // xy in the ORIGINAL (pre-transport-this-frame) field u_dye, not the already\n // transported/corrected value. On a non-uniform flow, warping compresses the domain in some\n // places and stretches it in others, and blurring at a warped point stacks those distortions —\n // the error accumulates frame over frame (measured: condensate steadily gained about 5% extra\n // water alongside its own mass growth). Right on the grid, with no warp, the same blur is an\n // honest averaging kernel that conserves the sum on a periodic domain.\n float2 selfSrc = float2(xy.x, u_dyeSize.y - xy.y);\n float n0 = float(u_dye.eval(float2(selfSrc.x + 1.0, selfSrc.y)).r);\n float n1 = float(u_dye.eval(float2(selfSrc.x - 1.0, selfSrc.y)).r);\n float n2 = float(u_dye.eval(float2(selfSrc.x, selfSrc.y - 1.0)).r);\n float n3 = float(u_dye.eval(float2(selfSrc.x, selfSrc.y + 1.0)).r);\n float neighborAvg = (n0 + n1 + n2 + n3) * 0.25;\n float spreadWeight = 1.0 - exp(-u_spreadRate * u_dt);\n float grown = mix(afterReaction, neighborAvg, spreadWeight) * u_totalScale;\n\n return half4(half3(grown, 0.0, 0.0), half(1.0));\n}\n`;\n\n// Tracks are not this layer's water: an event stamps a burst (`emit-shader.ts`), and it fades on\n// its own. Decay is a property of THIS layer, so it lives here rather than in the vapor/condensate\n// passes. Track transport stays a single backtrace: it doesn't need MacCormack — tracks aren't\n// conserved, they live for seconds and their own decay extinguishes them before numerical\n// diffusion becomes noticeable.\nexport const MEDIUM_TRACK_ADVECT_SHADER = `\n${MEDIUM_ADVECT_UNIFORMS}\nuniform float u_decay;\n\nhalf4 main(float2 xy) {\n${MEDIUM_ADVECT_VELOCITY}\n float2 back = xy - vel * u_dt;\n half4 prev = u_dye.eval(float2(back.x, u_dyeSize.y - back.y));\n float3 decayed = float3(prev.rgb) * exp(-u_decay * u_dt);\n return half4(half3(decayed), half(1.0));\n}\n`;\n","import { VG_OKLAB_TO_SRGB } from './oklab';\n\n// Composites vapor, condensate and track density into color — DIRECTLY into the buffer the lens\n// samples: this is not a separate pass drawn over the glass, it fills the same contentTexture an\n// ordinary 2D scene would, just from the GPU side.\n//\n// The mix happens in Oklab: two saturated channels averaged in raw RGB collapse into muddy gray;\n// in Oklab, lightness and chroma don't bleed into each other, and the sum stays vivid. The palette\n// arrives already in Oklab (`u_lab0/1/2`, `u_labBg`, `u_labCondensate`) — the forward direction\n// (sRGB → Oklab) is computed once per frame by `srgbToOklab` in `web/medium.ts`, not here per pixel.\n//\n// `u_dyeScale` converts a frame coordinate into SIMULATION-GRID pixels — deliberately coarser than\n// the frame — and only here, once, does the resolution become what the eye actually sees again,\n// through `.eval()`'s bilinear sample. NO extra Y flip here, unlike the grid-to-grid reads in\n// advect-shader.ts: those flip because they read a buffer through `.eval()` while still inside the\n// same top-down \"xy\" frame the buffer was written in. This is the one read that leaves that frame\n// for the screen's own — `xy` here already is screen space (this shader's entry point is\n// transpiled exactly like the lens/surface shaders), so the coordinate handed to `u_vapor` etc.\n// only needs the grid/frame resolution scale, not another flip. Measured with a headless render:\n// adding the extra flip (as a very early version of this file did) made settled condensate drift\n// toward the TOP of the screen instead of the bottom.\n//\n// Three buffers, not one: vapor is conserved, condensate transitions from vapor and back\n// (`MEDIUM_VAPOR_REACT_SHADER`/`MEDIUM_CONDENSATE_REACT_SHADER`, see `advect-shader.ts`), tracks\n// fade out (`MEDIUM_TRACK_ADVECT_SHADER`) — a channel's density for the mix is the sum of vapor and\n// tracks, while condensate mixes separately (it carries its own, near-white, only lightly tinted\n// paint).\n//\n// DEPTH: a far plane is not a second simulation — it is the SAME vapor/condensate buffers, read a\n// second time at a different scale and offset (see MEDIUM_DEFAULTS.depthFarScale/depthFarOffsetFrac\n// — u_farOffset itself arrives already resolved to grid-px, see depthFarOffsetPx in params.ts).\n// Reading the identical evolving field through two different \"lenses\" gives parallax and finer\n// far-plane structure from one number (`depthFarScale`) with no extra grid to step, and a cheap\n// 4-tap blur gives the far plane aerial perspective's blur AND lower contrast from one mechanism\n// (a spatial average cannot raise local variance). Tracks are NOT re-sampled per plane — a track's\n// own depth is baked into its stamped width/intensity at emission time (`dynamics.ts`), not into a\n// second read here.\n//\n// The far plane folds into the SAME weighted-average-in-Oklab this file already used for species:\n// its weights simply add to the near plane's before the one division, exactly the technique species\n// already use to combine three channels without the sum ever pulling toward gray.\nexport const MEDIUM_COMPOSITE_SHADER = `\nuniform shader u_vapor;\nuniform shader u_condensate;\nuniform shader u_track;\nuniform float2 u_dyeScale;\nuniform float3 u_lab0;\nuniform float3 u_lab1;\nuniform float3 u_lab2;\nuniform float3 u_labBg;\nuniform float3 u_labCondensate;\nuniform float u_baseWeight;\nuniform float u_condensateTint;\nuniform float u_condensateGain;\nuniform float u_farScale;\nuniform float2 u_farOffset;\nuniform float u_farBlurRadius;\nuniform float u_farWeight;\nuniform float u_gravityBoost;\nuniform float u_gravityBoostStart;\n\n${VG_OKLAB_TO_SRGB}\n\nhalf4 main(float2 xy) {\n float2 baseSrc = xy * u_dyeScale;\n\n // Near plane — sampled exactly as before the depth work; the calibrated look lives here.\n half4 vapor = u_vapor.eval(baseSrc);\n half4 condensate = u_condensate.eval(baseSrc);\n half4 track = u_track.eval(baseSrc);\n\n // Far plane — the identical field, scaled and offset (see the file header). A 4-tap box blur\n // (no separate center sample, the cheapest isotropic blur available) supplies aerial perspective's\n // blur and, as a side effect of averaging, its lower contrast too.\n float2 farSrc = baseSrc * u_farScale + u_farOffset;\n float2 blurX = float2(u_farBlurRadius, 0.0);\n float2 blurY = float2(0.0, u_farBlurRadius);\n half4 vaporFar = (u_vapor.eval(farSrc + blurX) + u_vapor.eval(farSrc - blurX) +\n u_vapor.eval(farSrc + blurY) + u_vapor.eval(farSrc - blurY)) * half4(0.25);\n half4 condensateFar = (u_condensate.eval(farSrc + blurX) + u_condensate.eval(farSrc - blurX) +\n u_condensate.eval(farSrc + blurY) + u_condensate.eval(farSrc - blurY)) * half4(0.25);\n\n // Gravity's \"denser layer at the bottom of the chamber\" — a compositing-only boost (see\n // MEDIUM_DEFAULTS.gravityBottomBoost): it scales how density already in the conserved buffers is\n // DISPLAYED, never the buffers themselves, so it can't threaten water conservation. Shared by\n // both planes: the chamber's floor is denser everywhere along its depth, not just up close.\n //\n // Verified with a headless render, not assumed: ramping this with xy.y directly lit up the TOP\n // of the frame, not the bottom (contentTexture's own orientation contract flips y once more\n // between this pass and the final scene — see the file header) — hence the 1.0 - below.\n float bottomFrac = 1.0 - xy.y / u_resolution.y;\n float bottomT = smoothstep(u_gravityBoostStart, 1.0, bottomFrac);\n float bottomBoost = 1.0 + u_gravityBoost * bottomT;\n\n float gasR = (float(vapor.r) + float(vaporFar.r) * u_farWeight) * bottomBoost;\n float gasG = (float(vapor.g) + float(vaporFar.g) * u_farWeight) * bottomBoost;\n float gasB = (float(vapor.b) + float(vaporFar.b) * u_farWeight) * bottomBoost;\n float w0 = gasR + float(track.r);\n float w1 = gasG + float(track.g);\n float w2 = gasB + float(track.b);\n float wBg = u_baseWeight;\n\n // A faint condensate hint from the LOCAL vapor, not its own color (droplets scatter light\n // rather than tint it) — a fraction of the way from white to the vapor tone sampled right here.\n float vaporLocal = max(w0 + w1 + w2, 1e-4);\n float3 vaporHue = (u_lab0 * w0 + u_lab1 * w1 + u_lab2 * w2) / vaporLocal;\n float3 condensateLab = mix(u_labCondensate, vaporHue, u_condensateTint);\n float wCondensate =\n (float(condensate.r) + float(condensateFar.r) * u_farWeight) * u_condensateGain * bottomBoost;\n\n float total = max(w0 + w1 + w2 + wBg + wCondensate, 1e-4);\n float3 mixLab =\n (u_labBg * wBg + u_lab0 * w0 + u_lab1 * w1 + u_lab2 * w2 + condensateLab * wCondensate) / total;\n float3 rgbLinear = clamp(vgOklabToLinear(mixLab), float3(0.0), float3(1.0));\n float3 srgb = vgLinearToSrgb(rgbLinear);\n return half4(half3(srgb), half(1.0));\n}\n`;\n","import { VG_VALUE_NOISE } from './noise';\n\n// Stamps a track into the vapor: one pass that stamps a density burst DIRECTLY into the dye buffer\n// — the same one that carries the curl-noise field and that composite-shader.ts reads. From there\n// the track lives and fades through exactly the same code as the gas: advection carries and spins\n// it, exponential decay extinguishes it. It has no position of its own and no lifetime timer here —\n// the track's entire physics is the shape of this ONE stamped frame.\n//\n// The head (s=1, at u_head) is sharp, narrow, bright — fresh ionization. The tail (s=0, at\n// u_source) is wider, dimmer and has settled downward — those droplets had time to grow earlier.\n// The path's noise-driven spread also grows toward the tail and fades toward the head, so the\n// thread stays straight and crisp at its leading end.\nexport const MEDIUM_EMIT_SHADER = `\nuniform float2 u_source;\nuniform float2 u_head;\nuniform float u_headWidth;\nuniform float u_tailWidth;\nuniform float u_raggedAmp;\nuniform float u_raggedFreq;\nuniform float u_settle;\nuniform float u_tailDim;\nuniform float u_intensity;\nuniform float u_seed;\nuniform float3 u_channelMask;\n\n${VG_VALUE_NOISE}\n\nhalf4 main(float2 xy) {\n float2 dir = u_head - u_source;\n float len = max(length(dir), 1.0);\n float2 side = float2(-dir.y, dir.x) / len;\n\n float best = 0.0;\n const int STEPS = 12;\n for (int i = 0; i < STEPS; i++) {\n float s = float(i) / float(STEPS - 1);\n float tailness = 1.0 - s;\n float ragged = u_raggedAmp * tailness * vgValueNoise(float2(s * u_raggedFreq + u_seed, u_seed * 3.7));\n float2 p = mix(u_source, u_head, s) + side * ragged + float2(0.0, u_settle * tailness * tailness);\n float w = max(mix(u_tailWidth, u_headWidth, s), 0.5);\n float amp = mix(u_tailDim, 1.0, s);\n float d = length(xy - p);\n float v = amp * exp(-(d * d) / (2.0 * w * w));\n best = max(best, v);\n }\n\n float3 dye = u_channelMask * (best * u_intensity);\n return half4(half3(dye), half(1.0));\n}\n`;\n","import {\n MEDIUM_DRIFT_TURBULENCE,\n MEDIUM_EMISSION_BEATS,\n MEDIUM_EMISSION_PROBABILITY,\n MEDIUM_MIN_EMISSION_INTERVAL,\n MEDIUM_NATURAL_INTERVAL,\n MEDIUM_NATURAL_JITTER,\n MEDIUM_SENSITIVE_BOTTOM,\n MEDIUM_SENSITIVE_TOP,\n MEDIUM_PLAYING_TURBULENCE_RANGE,\n MEDIUM_TRACK_DEPTH_INTENSITY_RANGE,\n MEDIUM_TRACK_DEPTH_WIDTH_RANGE,\n MEDIUM_TRACK_PRESETS,\n MEDIUM_TURBULENCE_TAU,\n type VireUIKitMediumTrackPreset,\n} from './params';\n\n// Rest-state playback: a chamber with nothing playing isn't empty — each neutral state has its\n// own behavior rather than one shared \"nothing is happening\".\nexport type VireUIKitMediumPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped';\n\nexport type VireUIKitMediumTrackPresetName = keyof typeof MEDIUM_TRACK_PRESETS;\n\n/** The fully resolved geometry of one track stamp — the platform layer (`web/medium.ts`)\n * translates fractions into simulation-grid pixels and binds the uniforms, deciding nothing else:\n * which preset, where, and how bright is entirely this model's call. */\nexport type VireUIKitMediumEmission = VireUIKitMediumTrackPreset & {\n /** 0…1, a fraction of the grid on both axes — the source point (cover art while playing;\n * wherever it happens to land at rest). */\n source: readonly [number, number];\n /** Radians, direction from the source to the head. */\n angle: number;\n seed: number;\n channel: readonly [number, number, number];\n /** 0 (farthest) … 1 (nearest), random within the full range and deterministic from `seed` — which\n * plane the track reads as living on. Already folded into this emission's own `headWidthFrac`/\n * `tailWidthFrac`/`intensity` below (see `buildEmission`); carried here too so a caller inspecting\n * an emission can see why. */\n depth: number;\n};\n\nexport type VireUIKitMediumDynamicsInput = {\n state: VireUIKitMediumPlaybackState;\n /** Beats per minute of the active track; unread in states with no track. */\n bpm: number;\n /** 0…1 — a continuous volume envelope (e.g. from precomputed waveform peaks), NOT the beat itself. */\n amplitude: number;\n /** Seconds, a deterministic playback position (not wall-clock time) — the emission cadence has\n * to be derived from it rather than accumulating its own phase. */\n positionSeconds: number;\n /** 0…1 — the area of the active track's cover art that emission comes from while playing. */\n sourcePoint: readonly [number, number];\n dt: number;\n};\n\nexport type VireUIKitMediumDynamicsFrame = {\n turbulence: number;\n emissions: readonly VireUIKitMediumEmission[];\n};\n\nexport type VireUIKitMediumDynamics = {\n step(input: VireUIKitMediumDynamicsInput): VireUIKitMediumDynamicsFrame;\n};\n\nfunction hash01(n: number): number {\n const s = Math.sin(n * 12.9898) * 43758.5453;\n return s - Math.floor(s);\n}\n\nfunction channelFromSeed(seed: number): readonly [number, number, number] {\n const idx = Math.floor(hash01(seed * 1.37) * 3) % 3;\n return idx === 0 ? [1, 0, 0] : idx === 1 ? [0, 1, 0] : [0, 0, 1];\n}\n\nfunction lerp(range: readonly [number, number], t: number): number {\n return range[0] + (range[1] - range[0]) * t;\n}\n\n/**\n * Scales a track preset's width and intensity by depth (0 far … 1 near) — separated from\n * `buildEmission` below and exported so depth's effect on a preset is directly testable with two\n * plain numbers, independent of how a specific emission's depth got hashed from its seed. At\n * depth=1 this returns the preset's own numbers unchanged (both ranges' upper bound is 1).\n */\nexport function applyTrackDepth(\n preset: VireUIKitMediumTrackPreset,\n depth: number,\n): VireUIKitMediumTrackPreset {\n const widthScale = lerp(MEDIUM_TRACK_DEPTH_WIDTH_RANGE, depth);\n const intensityScale = lerp(MEDIUM_TRACK_DEPTH_INTENSITY_RANGE, depth);\n return {\n ...preset,\n headWidthFrac: preset.headWidthFrac * widthScale,\n tailWidthFrac: preset.tailWidthFrac * widthScale,\n intensity: preset.intensity * intensityScale,\n };\n}\n\nfunction buildEmission(\n presetName: VireUIKitMediumTrackPresetName,\n source: readonly [number, number],\n seed: number,\n intensityScale: number,\n): VireUIKitMediumEmission {\n const preset = MEDIUM_TRACK_PRESETS[presetName];\n // Decorrelated from the channel (seed*1.37) and angle (seed*7.31) hashes below, so a track's\n // depth doesn't covary with its color or direction.\n const depth = hash01(seed * 4.63);\n const depthApplied = applyTrackDepth(preset, depth);\n return {\n ...depthApplied,\n intensity: depthApplied.intensity * intensityScale,\n source,\n angle: hash01(seed * 7.31) * Math.PI * 2,\n seed,\n channel: channelFromSeed(seed),\n depth,\n };\n}\n\n/**\n * Stateful \"playback dynamics\" model: turbulence and emission scheduling per frame. Platform\n * neutral — the caller supplies the clock and the playback position (in a bench, that would be\n * its own render loop); it never reads `performance.now()` or the DOM itself.\n */\nexport function createMediumDynamics(): VireUIKitMediumDynamics {\n let turbulence = MEDIUM_DRIFT_TURBULENCE;\n let lastBar = -1;\n let idleClock = 0;\n let naturalIndex = 0;\n let nextNaturalAt = MEDIUM_NATURAL_INTERVAL * 0.5;\n let simTime = 0;\n let lastEmissionAt = -Infinity;\n\n function step(input: VireUIKitMediumDynamicsInput): VireUIKitMediumDynamicsFrame {\n const { state, bpm, amplitude, positionSeconds, sourcePoint, dt } = input;\n simTime += dt;\n const amp = Math.max(0, Math.min(1, amplitude));\n\n const target =\n state === 'playing'\n ? MEDIUM_PLAYING_TURBULENCE_RANGE[0] +\n (MEDIUM_PLAYING_TURBULENCE_RANGE[1] - MEDIUM_PLAYING_TURBULENCE_RANGE[0]) * amp\n : MEDIUM_DRIFT_TURBULENCE;\n turbulence += (target - turbulence) * (1 - Math.exp(-dt / MEDIUM_TURBULENCE_TAU));\n\n const emissions: VireUIKitMediumEmission[] = [];\n\n // `bar`/`idx` below become `u_seed`, a float32 uniform in emit-shader.ts — but unlike the\n // curl-noise phase (see MEDIUM_TIME_PERIOD in noise.ts), they don't need periodic treatment:\n // `bar` resets with `positionSeconds` on every new track (it counts beats within the CURRENT\n // track, not the session), and `idx` advances only once per natural-emission interval (tens of\n // seconds), reaching at most a few thousand over a multi-hour session — nowhere near where a\n // float32 hash input's precision would matter.\n if (state === 'playing' && bpm > 0) {\n const beats = (positionSeconds * bpm) / 60;\n const bar = Math.floor(beats / MEDIUM_EMISSION_BEATS);\n if (bar !== lastBar) {\n lastBar = bar;\n const eligible =\n hash01(bar * 0.618) < MEDIUM_EMISSION_PROBABILITY &&\n simTime - lastEmissionAt >= MEDIUM_MIN_EMISSION_INTERVAL;\n if (eligible) {\n lastEmissionAt = simTime;\n const presetName: VireUIKitMediumTrackPresetName = hash01(bar * 3.14) < 0.55 ? 'alpha' : 'beta';\n emissions.push(buildEmission(presetName, sourcePoint, bar, 0.6 + 0.4 * amp));\n }\n }\n } else if (state === 'idle') {\n idleClock += dt;\n if (idleClock >= nextNaturalAt) {\n const idx = naturalIndex;\n naturalIndex += 1;\n // There's no source at rest: this is background emission, and its location is random. The\n // sensitive layer bounds it vertically — above it a track simply wouldn't appear.\n const randomSource: readonly [number, number] = [\n hash01(idx * 2.1 + 0.5),\n MEDIUM_SENSITIVE_TOP + hash01(idx * 5.9 + 1.7) * (MEDIUM_SENSITIVE_BOTTOM - MEDIUM_SENSITIVE_TOP),\n ];\n emissions.push(buildEmission('natural', randomSource, idx, 1));\n nextNaturalAt = idleClock + MEDIUM_NATURAL_INTERVAL + hash01(idx * 1.7) * MEDIUM_NATURAL_JITTER;\n }\n }\n\n return { turbulence, emissions };\n }\n\n return { step };\n}\n"]}
@@ -59,8 +59,83 @@ type VireUIKitMediumParams = {
59
59
  baseColor: VireUIKitMediumChannel;
60
60
  /** Background weight in the Oklab mix: keeps the platform tone visible even where the smoke is dense. */
61
61
  baseWeight: number;
62
+ /** Field-sample scale for the far depth plane, k ≥ 1 (see `MEDIUM_COMPOSITE_SHADER`): the far
63
+ * plane reads the SAME vapor/condensate field as the near plane, at `src * k + offset` instead
64
+ * of `src`. A larger k folds more of the field into the same screen area, so the identical
65
+ * evolving field reads as both finer-grained structure (features occupy fewer screen-px) and,
66
+ * because the field's own motion maps to screen motion as v/k, slower apparent motion — parallax
67
+ * and scale-of-structure from one number, with no second simulation. Measured against
68
+ * `check-medium.mjs`'s parallax gate (a cross-correlation best-shift, not a centroid — see the
69
+ * gate's own comment on why): 1.6 puts the far/near screen-speed ratio at 0.630, matching the
70
+ * naive prediction 1/1.6 = 0.625 almost exactly, comfortably inside a band that reads as depth
71
+ * without the far plane looking frozen. */
72
+ depthFarScale: number;
73
+ /** Offset into the shared field for the far plane, as a FRACTION of the grid's own width/height
74
+ * (per axis) rather than a fixed grid-px value — the field wraps (`gl.REPEAT`), so a fixed
75
+ * grid-px offset's EFFECTIVE (wrapped) distance from zero depends on whatever grid size it
76
+ * happens to run at, and can land close to zero by accident at a size nobody tested against.
77
+ * (This shipped once as `[41, 67]` grid-px: harmless at the 96px grid the depth gates tested
78
+ * against, but 67 wraps to just 5px at the 72px grid height this package's own README uses as
79
+ * its example — the far plane nearly re-read the near one, exactly the echo depth exists to
80
+ * avoid.) A fraction keeps the wrapped distance proportional to the grid at any size — see
81
+ * `depthFarOffsetPx` below and `check-medium.mjs`'s decorrelation gate, which measures this
82
+ * directly at the README's own 128x72. */
83
+ depthFarOffsetFrac: readonly [number, number];
84
+ /** Grid-px tap radius for the far plane's 4-tap box blur. A spatial average cannot raise local
85
+ * variance, so the same blur that softens the far plane's edges also, by construction, lowers
86
+ * its measured contrast — one mechanism for both aerial-perspective cues (no separate contrast
87
+ * knob exists). 0 disables it (the near plane stays sharp). Measured: reading the far plane's
88
+ * own scale/offset with no blur already drops contrast ~6% (a fixed offset can land on a
89
+ * locally denser or sparser patch of a field that isn't spatially uniform); this radius takes
90
+ * the total to 24% — see the aerial-perspective gate, whose two thresholds isolate blur's own
91
+ * share from that baseline. */
92
+ depthFarBlurRadius: number;
93
+ /** The far plane's share of the mix, relative to the near plane's implicit 1 — aerial
94
+ * perspective's other half: even at equal density the far plane must not compete with the near
95
+ * one for attention. */
96
+ depthFarWeight: number;
97
+ /** grid-px/s — a small downward drift added to VAPOR's own velocity, on top of the shared
98
+ * curl-noise field (condensate already settles faster via `condensateSettleSpeed`). An order of
99
+ * magnitude below it so the gas reads as "barely noticeable" drift rather than visibly falling.
100
+ * ADDS NO DENSITY: on this periodic (`gl.REPEAT`) grid a uniform drift only ever translates the
101
+ * field and wraps it back in at the top — it cannot accumulate mass anywhere, the way a real
102
+ * floor would. It is a motion cue (which way is down), not a source of "denser at the bottom" —
103
+ * that reading comes entirely from `gravityBottomBoost` below. See
104
+ * `check-medium.mjs`'s vapor-drift gate, which checks DIRECTION over a short window rather than
105
+ * a density ratio, for exactly this reason. */
106
+ gravityVaporDrift: number;
107
+ /** Density boost at the very bottom of the frame, added on top of 1 — a COMPOSITING-only effect:
108
+ * it scales how the already-conserved vapor/condensate density is DISPLAYED, never the
109
+ * simulated buffers themselves, so it cannot threaten the water-conservation invariant by
110
+ * construction, and — unlike `gravityVaporDrift` above — this is the mechanism that actually
111
+ * produces "denser at the bottom": it is a steady-state property of the compositing math, true
112
+ * from the very first frame, independent of any warmup or mixing time (see
113
+ * `check-medium.mjs`'s boost gate, which measures it with zero simulated steps for exactly that
114
+ * reason). Large in absolute terms because `baseWeight` (0.9) dominates the Oklab mix wherever
115
+ * gas density is thin — a small boost gets diluted into an invisible change in the final color;
116
+ * measured this large to register as a real, visible band against that dilution. */
117
+ gravityBottomBoost: number;
118
+ /** Fraction of frame height (0 top, 1 bottom) where the bottom boost starts ramping in via
119
+ * `smoothstep`. The brief asks for a denser LAYER at the floor of the chamber, not a boost that
120
+ * reaches halfway up the frame — kept in the bottom quarter. */
121
+ gravityBottomBoostStart: number;
62
122
  };
63
123
  declare const MEDIUM_DEFAULTS: VireUIKitMediumParams;
124
+ /**
125
+ * Resolves the far plane's fractional offset (`depthFarOffsetFrac`) into grid-px for a GIVEN grid
126
+ * size — pure and exported so the wrap-safety it exists for is directly testable (see
127
+ * `medium-depth-offset.test.ts`) without spinning up a WebGL2 context. Per-axis: `gridWidth` and
128
+ * `gridHeight` are independent, and a non-square grid (the README's own 128x72 example) shouldn't
129
+ * distort one axis relative to the other.
130
+ */
131
+ declare function depthFarOffsetPx(offsetFrac: readonly [number, number], gridWidth: number, gridHeight: number): readonly [number, number];
132
+ /**
133
+ * How far a value lands from the nearest wrap boundary (0 or `period`) on a periodic axis of the
134
+ * given `period` — the quantity that actually matters for decorrelation, not the raw offset value
135
+ * itself (a raw offset near the period is effectively a tiny one once the field wraps). Exported
136
+ * for the same reason as `depthFarOffsetPx`: a plain number, testable without a GPU.
137
+ */
138
+ declare function wrappedDistanceFromZero(value: number, period: number): number;
64
139
  /** The material probe can't see texture finer than this: there is no gain in a denser simulation
65
140
  * grid than the probe's own floor. */
66
141
  declare const MEDIUM_PROBE_FLOOR_PX = 22;
@@ -115,6 +190,18 @@ declare const MEDIUM_NATURAL_JITTER = 35;
115
190
  * supersaturation for a track to appear at all, so background emission is only ever visible here. */
116
191
  declare const MEDIUM_SENSITIVE_TOP = 0.55;
117
192
  declare const MEDIUM_SENSITIVE_BOTTOM = 0.95;
193
+ /** Per-emission depth range applied to a track's width (`headWidthFrac`/`tailWidthFrac`), far…near.
194
+ * The same knob reads as both "thin" and "soft": a Gaussian stamp's edge steepness scales with its
195
+ * own width (`emit-shader.ts`'s only extent parameter), so shrinking it for a far track thins and
196
+ * softens it in the same stroke — there is no second knob to hang an independent softness number
197
+ * on. Floored at 0.55: below that a far `alpha` track's head narrows past what MacCormack's own
198
+ * numerical smoothing already blurs it to, and the depth cue disappears into that noise floor. */
199
+ declare const MEDIUM_TRACK_DEPTH_WIDTH_RANGE: readonly [number, number];
200
+ /** Per-emission depth range applied to a track's intensity, far…near — "dim". Floored at 0.4, not
201
+ * lower: a far track still has to clear `MEDIUM_DEFAULTS.condensationFloor`'s excess threshold
202
+ * often enough to read as a track, or depth would look like tracks randomly failing to spawn
203
+ * rather than fading into the distance. */
204
+ declare const MEDIUM_TRACK_DEPTH_INTENSITY_RANGE: readonly [number, number];
118
205
 
119
206
  type VireUIKitMediumPlaybackState = 'idle' | 'playing' | 'paused' | 'stopped';
120
207
  type VireUIKitMediumTrackPresetName = keyof typeof MEDIUM_TRACK_PRESETS;
@@ -129,6 +216,11 @@ type VireUIKitMediumEmission = VireUIKitMediumTrackPreset & {
129
216
  angle: number;
130
217
  seed: number;
131
218
  channel: readonly [number, number, number];
219
+ /** 0 (farthest) … 1 (nearest), random within the full range and deterministic from `seed` — which
220
+ * plane the track reads as living on. Already folded into this emission's own `headWidthFrac`/
221
+ * `tailWidthFrac`/`intensity` below (see `buildEmission`); carried here too so a caller inspecting
222
+ * an emission can see why. */
223
+ depth: number;
132
224
  };
133
225
  type VireUIKitMediumDynamicsInput = {
134
226
  state: VireUIKitMediumPlaybackState;
@@ -150,6 +242,13 @@ type VireUIKitMediumDynamicsFrame = {
150
242
  type VireUIKitMediumDynamics = {
151
243
  step(input: VireUIKitMediumDynamicsInput): VireUIKitMediumDynamicsFrame;
152
244
  };
245
+ /**
246
+ * Scales a track preset's width and intensity by depth (0 far … 1 near) — separated from
247
+ * `buildEmission` below and exported so depth's effect on a preset is directly testable with two
248
+ * plain numbers, independent of how a specific emission's depth got hashed from its seed. At
249
+ * depth=1 this returns the preset's own numbers unchanged (both ranges' upper bound is 1).
250
+ */
251
+ declare function applyTrackDepth(preset: VireUIKitMediumTrackPreset, depth: number): VireUIKitMediumTrackPreset;
153
252
  /**
154
253
  * Stateful "playback dynamics" model: turbulence and emission scheduling per frame. Platform
155
254
  * neutral — the caller supplies the clock and the playback position (in a bench, that would be
@@ -157,4 +256,4 @@ type VireUIKitMediumDynamics = {
157
256
  */
158
257
  declare function createMediumDynamics(): VireUIKitMediumDynamics;
159
258
 
160
- export { MEDIUM_DEFAULTS as M, type VireUIKitMediumChannel as V, MEDIUM_DEFAULT_CELL_PX as a, MEDIUM_DRIFT_TURBULENCE as b, MEDIUM_EMISSION_BEATS as c, MEDIUM_EMISSION_PROBABILITY as d, MEDIUM_MIN_EMISSION_INTERVAL as e, MEDIUM_NATURAL_INTERVAL as f, MEDIUM_NATURAL_JITTER as g, MEDIUM_PLAYING_TURBULENCE_RANGE as h, MEDIUM_PROBE_FLOOR_PX as i, MEDIUM_SENSITIVE_BOTTOM as j, MEDIUM_SENSITIVE_TOP as k, MEDIUM_TRACK_PRESETS as l, MEDIUM_TURBULENCE_TAU as m, type VireUIKitMediumDynamics as n, type VireUIKitMediumDynamicsFrame as o, type VireUIKitMediumDynamicsInput as p, type VireUIKitMediumEmission as q, type VireUIKitMediumParams as r, type VireUIKitMediumPlaybackState as s, type VireUIKitMediumTrackPreset as t, type VireUIKitMediumTrackPresetName as u, createMediumDynamics as v };
259
+ export { wrappedDistanceFromZero as A, MEDIUM_DEFAULTS as M, type VireUIKitMediumChannel as V, MEDIUM_DEFAULT_CELL_PX as a, MEDIUM_DRIFT_TURBULENCE as b, MEDIUM_EMISSION_BEATS as c, MEDIUM_EMISSION_PROBABILITY as d, MEDIUM_MIN_EMISSION_INTERVAL as e, MEDIUM_NATURAL_INTERVAL as f, MEDIUM_NATURAL_JITTER as g, MEDIUM_PLAYING_TURBULENCE_RANGE as h, MEDIUM_PROBE_FLOOR_PX as i, MEDIUM_SENSITIVE_BOTTOM as j, MEDIUM_SENSITIVE_TOP as k, MEDIUM_TRACK_DEPTH_INTENSITY_RANGE as l, MEDIUM_TRACK_DEPTH_WIDTH_RANGE as m, MEDIUM_TRACK_PRESETS as n, MEDIUM_TURBULENCE_TAU as o, type VireUIKitMediumDynamics as p, type VireUIKitMediumDynamicsFrame as q, type VireUIKitMediumDynamicsInput as r, type VireUIKitMediumEmission as s, type VireUIKitMediumParams as t, type VireUIKitMediumPlaybackState as u, type VireUIKitMediumTrackPreset as v, type VireUIKitMediumTrackPresetName as w, applyTrackDepth as x, createMediumDynamics as y, depthFarOffsetPx as z };