vireuikit 0.2.0 → 0.3.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 +22 -10
- package/dist/{chunk-BV5P6MF7.js → chunk-2AFVQ65S.js} +164 -16
- package/dist/chunk-2AFVQ65S.js.map +1 -0
- package/dist/{dynamics-DelasYER.d.cts → dynamics-7Bq3LLds.d.cts} +13 -1
- package/dist/{dynamics-DelasYER.d.ts → dynamics-7Bq3LLds.d.ts} +13 -1
- package/dist/index.cjs +143 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web/index.cjs +293 -138
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.d.cts +1 -1
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +14 -7
- package/dist/web/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-BV5P6MF7.js.map +0 -1
package/README.md
CHANGED
|
@@ -80,16 +80,28 @@ The backdrop reads as a chamber seen from the side, not a flat field, without a
|
|
|
80
80
|
the composite samples the same vapor/condensate grid twice, once at identity and once at a larger
|
|
81
81
|
scale and a fixed offset (`MEDIUM_DEFAULTS.depthFarScale`/`depthFarOffsetFrac`, the offset as a
|
|
82
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`.
|
|
84
|
-
|
|
85
|
-
lower contrast in the same pass, weighted down (`depthFarWeight`) so it never competes
|
|
86
|
-
plane
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
83
|
+
on screen and finer-grained, since the field's own motion maps to screen motion as `v / scale`. An
|
|
84
|
+
8-tap rotated ring blur on that far read (`depthFarBlurRadius`) supplies aerial perspective's
|
|
85
|
+
softness and lower contrast in the same pass, weighted down (`depthFarWeight`) so it never competes
|
|
86
|
+
with the near plane; the ring (not an axis-aligned box) keeps that blur from turning a residual
|
|
87
|
+
seam or resize artifact into parallel straight lines. Gravity is two separate properties: a small
|
|
88
|
+
downward drift on vapor's own velocity (`gravityVaporDrift`, a motion cue only — a uniform drift on
|
|
89
|
+
this periodic grid cannot itself accumulate density) and a compositing-only density boost near the
|
|
90
|
+
bottom of the frame (`gravityBottomBoost`/`gravityBottomBoostStart`, a steady-state property that
|
|
91
|
+
cannot affect the conserved water total) that actually produces the "denser at the bottom" reading.
|
|
92
|
+
Condensate settling adds a second, always-on share of the ambient curl field's own sideways push
|
|
93
|
+
(`condensateSettleWiggle`) so a droplet falling at a constant speed doesn't trace a dead-straight
|
|
94
|
+
vertical line when turbulence is otherwise low. Each emitted track also carries a `depth` (0 far …
|
|
95
|
+
1 near) that scales its own width and intensity, so a track reads thinner, dimmer and softer the
|
|
96
|
+
farther back it lives.
|
|
97
|
+
|
|
98
|
+
The curl-noise potential itself is periodic over the simulation grid in space, the same way it
|
|
99
|
+
already was in time: each octave's spatial frequency is adjusted, per grid size, to the nearest
|
|
100
|
+
value that makes a full grid width (or height) an exact integer number of lattice cells
|
|
101
|
+
(`computeMediumSpatialPeriods`) — so velocity never jumps at the domain's own wrap, and the far
|
|
102
|
+
plane's tile boundary reads as an ordinary point in the field rather than a seam. The near plane's
|
|
103
|
+
vapor and condensate reads use cubic B-spline reconstruction (4 bilinear taps) instead of plain
|
|
104
|
+
bilinear, so the simulation grid's own cells don't read as squares at typical (coarse) grid sizes.
|
|
93
105
|
|
|
94
106
|
## License
|
|
95
107
|
|
|
@@ -98,6 +98,7 @@ var MEDIUM_DEFAULTS = {
|
|
|
98
98
|
condensationFloor: 0.12,
|
|
99
99
|
evaporationRate: 0.2,
|
|
100
100
|
condensateSettleSpeed: 8,
|
|
101
|
+
condensateSettleWiggle: 0.4,
|
|
101
102
|
condensateSpreadRate: 0.08,
|
|
102
103
|
condensateTint: 0.2,
|
|
103
104
|
condensateGain: 5,
|
|
@@ -297,7 +298,90 @@ float2 vgCurlVelocity(float2 p, float phase) {
|
|
|
297
298
|
float py0 = vgPotential(p - float2(0.0, e), phase);
|
|
298
299
|
return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
|
|
299
300
|
}
|
|
301
|
+
|
|
302
|
+
// vgPotential above is periodic in TIME only: the simulation domain is a torus (gl.REPEAT), but
|
|
303
|
+
// the lattice itself never wraps in x/y, so velocity jumps at the domain seam and dye on either
|
|
304
|
+
// side of it is transported differently \u2014 a straight seam, made worse wherever something re-reads
|
|
305
|
+
// the field at a different scale (the far depth plane's tile boundary). vgValueNoise3Seamless
|
|
306
|
+
// wraps ALL THREE lattice axes: xyPeriod/zPeriod fold BOTH neighbors of a cell into their own
|
|
307
|
+
// period before hashing, so the field is EXACTLY the same one period over, in space exactly as it
|
|
308
|
+
// already was in time.
|
|
309
|
+
//
|
|
310
|
+
// The period has to be an integer number of lattice cells, or the wrap would jump instead of
|
|
311
|
+
// closing seamlessly (the same requirement MEDIUM_TIME_OCTAVES/mediumOctavePeriod enforces for
|
|
312
|
+
// time) \u2014 but unlike time, the spatial period depends on the GRID SIZE, which this shader has no
|
|
313
|
+
// notion of. So xyPeriod travels together with a per-octave spatial frequency the CPU computes to
|
|
314
|
+
// make gridSize * frequency land on that exact integer (see computeMediumSpatialPeriods below);
|
|
315
|
+
// both arrive together as one uniform per octave (u_spatialN.xy = frequency, .zw = period),
|
|
316
|
+
// recomputed by web/medium.ts whenever the grid resizes.
|
|
317
|
+
float vgValueNoise3Seamless(float3 p, float2 xyPeriod, float zPeriod) {
|
|
318
|
+
float3 i = floor(p);
|
|
319
|
+
float3 f = fract(p);
|
|
320
|
+
float3 u = f * f * (3.0 - 2.0 * f);
|
|
321
|
+
float x0 = mod(i.x, xyPeriod.x);
|
|
322
|
+
float x1 = mod(i.x + 1.0, xyPeriod.x);
|
|
323
|
+
float y0 = mod(i.y, xyPeriod.y);
|
|
324
|
+
float y1 = mod(i.y + 1.0, xyPeriod.y);
|
|
325
|
+
float z0 = mod(i.z, zPeriod);
|
|
326
|
+
float z1 = mod(i.z + 1.0, zPeriod);
|
|
327
|
+
float n000 = vgHash31(float3(x0, y0, z0));
|
|
328
|
+
float n100 = vgHash31(float3(x1, y0, z0));
|
|
329
|
+
float n010 = vgHash31(float3(x0, y1, z0));
|
|
330
|
+
float n110 = vgHash31(float3(x1, y1, z0));
|
|
331
|
+
float n001 = vgHash31(float3(x0, y0, z1));
|
|
332
|
+
float n101 = vgHash31(float3(x1, y0, z1));
|
|
333
|
+
float n011 = vgHash31(float3(x0, y1, z1));
|
|
334
|
+
float n111 = vgHash31(float3(x1, y1, z1));
|
|
335
|
+
float nx00 = mix(n000, n100, u.x);
|
|
336
|
+
float nx10 = mix(n010, n110, u.x);
|
|
337
|
+
float nx01 = mix(n001, n101, u.x);
|
|
338
|
+
float nx11 = mix(n011, n111, u.x);
|
|
339
|
+
float nxy0 = mix(nx00, nx10, u.y);
|
|
340
|
+
float nxy1 = mix(nx01, nx11, u.y);
|
|
341
|
+
return mix(nxy0, nxy1, u.z) * 2.0 - 1.0;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Same four-octave fbm as vgPotential, but every octave reads the seamless lattice above at its
|
|
345
|
+
// OWN (frequency, period) pair \u2014 sN.xy replaces that octave's compile-time \`scale\` constant (it
|
|
346
|
+
// has to be a uniform now: the adjustment depends on the live grid size), sN.zw is the matching
|
|
347
|
+
// integer period. \`p\` is still curlFreq-scaled exactly as vgPotential's caller scales it \u2014 only
|
|
348
|
+
// the per-octave multiplier changed from a constant to a grid-dependent uniform, so the finite
|
|
349
|
+
// difference in vgCurlVelocitySeamless below needs no epsilon change of its own.
|
|
350
|
+
float vgPotentialSeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
|
|
351
|
+
float v = vgValueNoise3Seamless(float3(p * s0.xy, phase * ${VG_OCT0.rate} + ${VG_OCT0.offset}), s0.zw, ${VG_OCT0.period}) * 0.5;
|
|
352
|
+
v += vgValueNoise3Seamless(float3(p * s1.xy, phase * ${VG_OCT1.rate} + ${VG_OCT1.offset}), s1.zw, ${VG_OCT1.period}) * 0.25;
|
|
353
|
+
v += vgValueNoise3Seamless(float3(p * s2.xy, phase * ${VG_OCT2.rate} + ${VG_OCT2.offset}), s2.zw, ${VG_OCT2.period}) * 0.125;
|
|
354
|
+
float macro = vgValueNoise3Seamless(float3(p * s3.xy, phase * ${VG_OCT3.rate} + ${VG_OCT3.offset}), s3.zw, ${VG_OCT3.period});
|
|
355
|
+
return v * (0.7 + 0.3 * macro);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
float2 vgCurlVelocitySeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
|
|
359
|
+
float e = 0.6;
|
|
360
|
+
float px1 = vgPotentialSeamless(p + float2(e, 0.0), phase, s0, s1, s2, s3);
|
|
361
|
+
float px0 = vgPotentialSeamless(p - float2(e, 0.0), phase, s0, s1, s2, s3);
|
|
362
|
+
float py1 = vgPotentialSeamless(p + float2(0.0, e), phase, s0, s1, s2, s3);
|
|
363
|
+
float py0 = vgPotentialSeamless(p - float2(0.0, e), phase, s0, s1, s2, s3);
|
|
364
|
+
return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
|
|
365
|
+
}
|
|
300
366
|
`;
|
|
367
|
+
var MIN_SPATIAL_PERIOD = 2;
|
|
368
|
+
function computeMediumSpatialPeriods(gridWidth, gridHeight, curlFreq) {
|
|
369
|
+
const freq = Math.max(curlFreq, 1e-6);
|
|
370
|
+
return MEDIUM_TIME_OCTAVES.map((o) => {
|
|
371
|
+
const rawX = gridWidth * freq * o.scale;
|
|
372
|
+
const rawY = gridHeight * freq * o.scale;
|
|
373
|
+
const minRaw = Math.min(rawX, rawY);
|
|
374
|
+
const scaleUp = minRaw > 0 && minRaw < MIN_SPATIAL_PERIOD ? MIN_SPATIAL_PERIOD / minRaw : 1;
|
|
375
|
+
const periodX = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawX * scaleUp));
|
|
376
|
+
const periodY = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawY * scaleUp));
|
|
377
|
+
return {
|
|
378
|
+
freqX: periodX / (gridWidth * freq),
|
|
379
|
+
freqY: periodY / (gridHeight * freq),
|
|
380
|
+
periodX,
|
|
381
|
+
periodY
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
}
|
|
301
385
|
|
|
302
386
|
// src/medium/seed-shader.ts
|
|
303
387
|
var MEDIUM_SEED_SHADER = `
|
|
@@ -340,15 +424,25 @@ uniform float u_phase;
|
|
|
340
424
|
uniform float u_curlFreq;
|
|
341
425
|
uniform float u_advectSpeed;
|
|
342
426
|
uniform float u_turbulence;
|
|
427
|
+
// One float4 per MEDIUM_TIME_OCTAVES entry: xy is that octave's seamless spatial frequency, zw its
|
|
428
|
+
// matching integer lattice period \u2014 both grid-size-dependent, so they can't be compile-time
|
|
429
|
+
// constants (see vgPotentialSeamless/computeMediumSpatialPeriods in noise.ts). Recomputed by
|
|
430
|
+
// web/medium.ts's bindVelocityUniforms every frame from the live grid dimensions.
|
|
431
|
+
uniform float4 u_spatial0;
|
|
432
|
+
uniform float4 u_spatial1;
|
|
433
|
+
uniform float4 u_spatial2;
|
|
434
|
+
uniform float4 u_spatial3;
|
|
343
435
|
|
|
344
436
|
${VG_CURL_NOISE}
|
|
345
437
|
`;
|
|
346
438
|
var MEDIUM_ADVECT_VELOCITY = `
|
|
347
|
-
float2
|
|
439
|
+
float2 curl = vgCurlVelocitySeamless(xy * u_curlFreq, u_phase, u_spatial0, u_spatial1, u_spatial2, u_spatial3);
|
|
440
|
+
float2 vel = curl * u_advectSpeed * u_turbulence;
|
|
348
441
|
`;
|
|
349
442
|
var MEDIUM_ADVECT_VELOCITY_SETTLE = `
|
|
350
443
|
${MEDIUM_ADVECT_VELOCITY}
|
|
351
444
|
vel.y += u_settleSpeed;
|
|
445
|
+
vel.x += curl.x * u_advectSpeed * u_settleWiggle;
|
|
352
446
|
`;
|
|
353
447
|
var MEDIUM_ADVECT_VELOCITY_VAPOR = `
|
|
354
448
|
${MEDIUM_ADVECT_VELOCITY}
|
|
@@ -367,6 +461,7 @@ ${MEDIUM_ADVECT_VELOCITY_VAPOR}
|
|
|
367
461
|
var MEDIUM_CONDENSATE_FORWARD_SHADER = `
|
|
368
462
|
${MEDIUM_ADVECT_UNIFORMS}
|
|
369
463
|
uniform float u_settleSpeed;
|
|
464
|
+
uniform float u_settleWiggle;
|
|
370
465
|
|
|
371
466
|
half4 main(float2 xy) {
|
|
372
467
|
${MEDIUM_ADVECT_VELOCITY_SETTLE}
|
|
@@ -406,6 +501,7 @@ ${MEDIUM_MACCORMACK_CORRECT}
|
|
|
406
501
|
var MEDIUM_CONDENSATE_CORRECT_SHADER = `
|
|
407
502
|
${MEDIUM_ADVECT_UNIFORMS}
|
|
408
503
|
uniform float u_settleSpeed;
|
|
504
|
+
uniform float u_settleWiggle;
|
|
409
505
|
uniform shader u_forward;
|
|
410
506
|
|
|
411
507
|
half4 main(float2 xy) {
|
|
@@ -505,6 +601,39 @@ ${MEDIUM_ADVECT_VELOCITY}
|
|
|
505
601
|
`;
|
|
506
602
|
|
|
507
603
|
// src/medium/composite-shader.ts
|
|
604
|
+
var VG_CUBIC_WEIGHTS = `
|
|
605
|
+
float4 vgCubicWeights(float v) {
|
|
606
|
+
float4 n = float4(1.0, 2.0, 3.0, 4.0) - v;
|
|
607
|
+
float4 s = n * n * n;
|
|
608
|
+
float x = s.x;
|
|
609
|
+
float y = s.y - 4.0 * s.x;
|
|
610
|
+
float z = s.z - 4.0 * s.y + 6.0 * s.x;
|
|
611
|
+
float w = 6.0 - x - y - z;
|
|
612
|
+
return float4(x, y, z, w) * (1.0 / 6.0);
|
|
613
|
+
}
|
|
614
|
+
`;
|
|
615
|
+
function vgBicubicSampler(fnName, textureName) {
|
|
616
|
+
return `
|
|
617
|
+
half4 ${fnName}(float2 px) {
|
|
618
|
+
float2 shifted = px - 0.5;
|
|
619
|
+
float2 fxy = fract(shifted);
|
|
620
|
+
float2 base = shifted - fxy;
|
|
621
|
+
float4 xw = vgCubicWeights(fxy.x);
|
|
622
|
+
float4 yw = vgCubicWeights(fxy.y);
|
|
623
|
+
float2 sx = float2(xw.x + xw.y, xw.z + xw.w);
|
|
624
|
+
float2 sy = float2(yw.x + yw.y, yw.z + yw.w);
|
|
625
|
+
float2 offsetX = float2(base.x - 0.5 + xw.y / sx.x, base.x + 1.5 + xw.w / sx.y);
|
|
626
|
+
float2 offsetY = float2(base.y - 0.5 + yw.y / sy.x, base.y + 1.5 + yw.w / sy.y);
|
|
627
|
+
half4 s00 = ${textureName}.eval(float2(offsetX.x, offsetY.x));
|
|
628
|
+
half4 s10 = ${textureName}.eval(float2(offsetX.y, offsetY.x));
|
|
629
|
+
half4 s01 = ${textureName}.eval(float2(offsetX.x, offsetY.y));
|
|
630
|
+
half4 s11 = ${textureName}.eval(float2(offsetX.y, offsetY.y));
|
|
631
|
+
float wx = sx.x / (sx.x + sx.y);
|
|
632
|
+
float wy = sy.x / (sy.x + sy.y);
|
|
633
|
+
return mix(mix(s11, s01, wx), mix(s10, s00, wx), wy);
|
|
634
|
+
}
|
|
635
|
+
`;
|
|
636
|
+
}
|
|
508
637
|
var MEDIUM_COMPOSITE_SHADER = `
|
|
509
638
|
uniform shader u_vapor;
|
|
510
639
|
uniform shader u_condensate;
|
|
@@ -526,25 +655,44 @@ uniform float u_gravityBoost;
|
|
|
526
655
|
uniform float u_gravityBoostStart;
|
|
527
656
|
|
|
528
657
|
${VG_OKLAB_TO_SRGB}
|
|
658
|
+
${VG_CUBIC_WEIGHTS}
|
|
659
|
+
${vgBicubicSampler("vgBicubicVapor", "u_vapor")}
|
|
660
|
+
${vgBicubicSampler("vgBicubicCondensate", "u_condensate")}
|
|
529
661
|
|
|
530
662
|
half4 main(float2 xy) {
|
|
531
663
|
float2 baseSrc = xy * u_dyeScale;
|
|
532
664
|
|
|
533
|
-
// Near plane \u2014
|
|
534
|
-
|
|
535
|
-
half4
|
|
665
|
+
// Near plane \u2014 the calibrated look lives here. Bicubic (see the file header) on vapor and
|
|
666
|
+
// condensate; track stays a plain bilinear read.
|
|
667
|
+
half4 vapor = vgBicubicVapor(baseSrc);
|
|
668
|
+
half4 condensate = vgBicubicCondensate(baseSrc);
|
|
536
669
|
half4 track = u_track.eval(baseSrc);
|
|
537
670
|
|
|
538
|
-
// Far plane \u2014 the identical field, scaled and offset (see the file header).
|
|
539
|
-
//
|
|
540
|
-
//
|
|
671
|
+
// Far plane \u2014 the identical field, scaled and offset (see the file header). An 8-tap ring, not
|
|
672
|
+
// the near plane's \xB1x/\xB1y box: axis-aligned taps read the SAME column/row on both sides of any
|
|
673
|
+
// residual axis-aligned structure (a seam, a resize artifact), which is exactly how a straight
|
|
674
|
+
// line gets doubled into four parallel copies instead of softened away \u2014 measured directly via
|
|
675
|
+
// check-medium.mjs's isotropy gate (see its report). The ring is rotated a half-step (22.5\xB0) off
|
|
676
|
+
// the axes so no tap direction ever coincides with a grid row or column. Radius stays in the same
|
|
677
|
+
// far-plane texture-space unit as before (u_farBlurRadius, grid-px) \u2014 only the tap PATTERN
|
|
678
|
+
// changed, not what it measures in.
|
|
541
679
|
float2 farSrc = baseSrc * u_farScale + u_farOffset;
|
|
542
|
-
float2
|
|
543
|
-
float2
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
680
|
+
float2 ring0 = float2( 0.9238795, 0.3826834) * u_farBlurRadius;
|
|
681
|
+
float2 ring1 = float2( 0.3826834, 0.9238795) * u_farBlurRadius;
|
|
682
|
+
float2 ring2 = float2(-0.3826834, 0.9238795) * u_farBlurRadius;
|
|
683
|
+
float2 ring3 = float2(-0.9238795, 0.3826834) * u_farBlurRadius;
|
|
684
|
+
float2 ring4 = float2(-0.9238795, -0.3826834) * u_farBlurRadius;
|
|
685
|
+
float2 ring5 = float2(-0.3826834, -0.9238795) * u_farBlurRadius;
|
|
686
|
+
float2 ring6 = float2( 0.3826834, -0.9238795) * u_farBlurRadius;
|
|
687
|
+
float2 ring7 = float2( 0.9238795, -0.3826834) * u_farBlurRadius;
|
|
688
|
+
half4 vaporFar = (u_vapor.eval(farSrc + ring0) + u_vapor.eval(farSrc + ring1) +
|
|
689
|
+
u_vapor.eval(farSrc + ring2) + u_vapor.eval(farSrc + ring3) +
|
|
690
|
+
u_vapor.eval(farSrc + ring4) + u_vapor.eval(farSrc + ring5) +
|
|
691
|
+
u_vapor.eval(farSrc + ring6) + u_vapor.eval(farSrc + ring7)) * half4(0.125);
|
|
692
|
+
half4 condensateFar = (u_condensate.eval(farSrc + ring0) + u_condensate.eval(farSrc + ring1) +
|
|
693
|
+
u_condensate.eval(farSrc + ring2) + u_condensate.eval(farSrc + ring3) +
|
|
694
|
+
u_condensate.eval(farSrc + ring4) + u_condensate.eval(farSrc + ring5) +
|
|
695
|
+
u_condensate.eval(farSrc + ring6) + u_condensate.eval(farSrc + ring7)) * half4(0.125);
|
|
548
696
|
|
|
549
697
|
// Gravity's "denser layer at the bottom of the chamber" \u2014 a compositing-only boost (see
|
|
550
698
|
// MEDIUM_DEFAULTS.gravityBottomBoost): it scales how density already in the conserved buffers is
|
|
@@ -704,6 +852,6 @@ function createMediumDynamics() {
|
|
|
704
852
|
return { step };
|
|
705
853
|
}
|
|
706
854
|
|
|
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-
|
|
709
|
-
//# sourceMappingURL=chunk-
|
|
855
|
+
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, computeMediumSpatialPeriods, createMediumDynamics, depthFarOffsetPx, neutralSpecies, oklabToSrgb, speciesFamily, srgbToOklab, wrappedDistanceFromZero };
|
|
856
|
+
//# sourceMappingURL=chunk-2AFVQ65S.js.map
|
|
857
|
+
//# sourceMappingURL=chunk-2AFVQ65S.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-2AFVQ65S.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 /** Multiplier on the ambient curl field's OWN x-component, added to settling UNCONDITIONALLY\n * (not gated by `turbulence` the way the shared velocity field is) — see\n * MEDIUM_ADVECT_VELOCITY_SETTLE in advect-shader.ts. Without it, a droplet falling at a constant\n * `condensateSettleSpeed` with only a turbulence-gated sideways push traces a near-straight\n * vertical line whenever turbulence is low (rest/paused: 0.045, over 20x below the gated push\n * needed to bend the fall) — many droplets in nearby columns then read as parallel vertical\n * streaks, one of the axis-aligned artifacts `check-medium.mjs`'s isotropy gate measures. Reuses\n * the SAME seamless curl field `vel` already reads rather than a second noise system, so it adds\n * no new seam of its own. Calibrated against that gate at the product's default grid (1920x952,\n * 74x37) — see the gate's own report for the measured ratio at this value. */\n condensateSettleWiggle: 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 radius of the far plane's 8-tap ring blur, rotated so no tap pair lies on a grid\n * axis. 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 condensateSettleWiggle: 0.4,\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// vgPotential above is periodic in TIME only: the simulation domain is a torus (gl.REPEAT), but\n// the lattice itself never wraps in x/y, so velocity jumps at the domain seam and dye on either\n// side of it is transported differently — a straight seam, made worse wherever something re-reads\n// the field at a different scale (the far depth plane's tile boundary). vgValueNoise3Seamless\n// wraps ALL THREE lattice axes: xyPeriod/zPeriod fold BOTH neighbors of a cell into their own\n// period before hashing, so the field is EXACTLY the same one period over, in space exactly as it\n// already was in time.\n//\n// The period has to be an integer number of lattice cells, or the wrap would jump instead of\n// closing seamlessly (the same requirement MEDIUM_TIME_OCTAVES/mediumOctavePeriod enforces for\n// time) — but unlike time, the spatial period depends on the GRID SIZE, which this shader has no\n// notion of. So xyPeriod travels together with a per-octave spatial frequency the CPU computes to\n// make gridSize * frequency land on that exact integer (see computeMediumSpatialPeriods below);\n// both arrive together as one uniform per octave (u_spatialN.xy = frequency, .zw = period),\n// recomputed by web/medium.ts whenever the grid resizes.\nfloat vgValueNoise3Seamless(float3 p, float2 xyPeriod, float zPeriod) {\n float3 i = floor(p);\n float3 f = fract(p);\n float3 u = f * f * (3.0 - 2.0 * f);\n float x0 = mod(i.x, xyPeriod.x);\n float x1 = mod(i.x + 1.0, xyPeriod.x);\n float y0 = mod(i.y, xyPeriod.y);\n float y1 = mod(i.y + 1.0, xyPeriod.y);\n float z0 = mod(i.z, zPeriod);\n float z1 = mod(i.z + 1.0, zPeriod);\n float n000 = vgHash31(float3(x0, y0, z0));\n float n100 = vgHash31(float3(x1, y0, z0));\n float n010 = vgHash31(float3(x0, y1, z0));\n float n110 = vgHash31(float3(x1, y1, z0));\n float n001 = vgHash31(float3(x0, y0, z1));\n float n101 = vgHash31(float3(x1, y0, z1));\n float n011 = vgHash31(float3(x0, y1, z1));\n float n111 = vgHash31(float3(x1, y1, 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// Same four-octave fbm as vgPotential, but every octave reads the seamless lattice above at its\n// OWN (frequency, period) pair — sN.xy replaces that octave's compile-time \\`scale\\` constant (it\n// has to be a uniform now: the adjustment depends on the live grid size), sN.zw is the matching\n// integer period. \\`p\\` is still curlFreq-scaled exactly as vgPotential's caller scales it — only\n// the per-octave multiplier changed from a constant to a grid-dependent uniform, so the finite\n// difference in vgCurlVelocitySeamless below needs no epsilon change of its own.\nfloat vgPotentialSeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {\n float v = vgValueNoise3Seamless(float3(p * s0.xy, phase * ${VG_OCT0.rate} + ${VG_OCT0.offset}), s0.zw, ${VG_OCT0.period}) * 0.5;\n v += vgValueNoise3Seamless(float3(p * s1.xy, phase * ${VG_OCT1.rate} + ${VG_OCT1.offset}), s1.zw, ${VG_OCT1.period}) * 0.25;\n v += vgValueNoise3Seamless(float3(p * s2.xy, phase * ${VG_OCT2.rate} + ${VG_OCT2.offset}), s2.zw, ${VG_OCT2.period}) * 0.125;\n float macro = vgValueNoise3Seamless(float3(p * s3.xy, phase * ${VG_OCT3.rate} + ${VG_OCT3.offset}), s3.zw, ${VG_OCT3.period});\n return v * (0.7 + 0.3 * macro);\n}\n\nfloat2 vgCurlVelocitySeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {\n float e = 0.6;\n float px1 = vgPotentialSeamless(p + float2(e, 0.0), phase, s0, s1, s2, s3);\n float px0 = vgPotentialSeamless(p - float2(e, 0.0), phase, s0, s1, s2, s3);\n float py1 = vgPotentialSeamless(p + float2(0.0, e), phase, s0, s1, s2, s3);\n float py0 = vgPotentialSeamless(p - float2(0.0, e), phase, s0, s1, s2, s3);\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\n// --- JS port of the seamless (space + time periodic) potential, and the CPU-side period math -----\n\n/** Pure-JS port of `vgValueNoise3Seamless`. */\nexport function vgValueNoise3SeamlessJS(\n px: number,\n py: number,\n pz: number,\n xPeriod: number,\n yPeriod: number,\n zPeriod: number,\n): 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 x0 = glslMod(ix, xPeriod);\n const x1 = glslMod(ix + 1, xPeriod);\n const y0 = glslMod(iy, yPeriod);\n const y1 = glslMod(iy + 1, yPeriod);\n const z0 = glslMod(iz, zPeriod);\n const z1 = glslMod(iz + 1, zPeriod);\n const n000 = hash31JS(x0, y0, z0);\n const n100 = hash31JS(x1, y0, z0);\n const n010 = hash31JS(x0, y1, z0);\n const n110 = hash31JS(x1, y1, z0);\n const n001 = hash31JS(x0, y0, z1);\n const n101 = hash31JS(x1, y0, z1);\n const n011 = hash31JS(x0, y1, z1);\n const n111 = hash31JS(x1, y1, 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/** One octave's seamless spatial setup: `freq` is what replaces that octave's compile-time\n * `scale` constant, adjusted off `curlFreq * scale` to the nearest value making `gridSize * freq`\n * land on the integer `period` — the same requirement `mediumOctavePeriod` enforces on the time\n * axis, just recomputed per grid size instead of fixed at module load. */\nexport type MediumSpatialOctave = {\n freqX: number;\n freqY: number;\n periodX: number;\n periodY: number;\n};\n\n/** The macro octave (`MEDIUM_TIME_OCTAVES[3]`, scale 0.35 — \"a slow non-uniformity layered over\n * the sum\") would round to a period of 0 or 1 lattice cell at this package's default grid\n * (74x37 at the shipped `MEDIUM_DEFAULT_CELL_PX`): a period of 1 collapses the octave to a\n * SPATIAL CONSTANT (every integer x hashes to the same node), trading one axis-aligned artifact\n * for another — a flat band instead of a seam. Flooring keeps at least one full cycle across the\n * grid, at the cost of the octave being coarser than its own frequency would imply at very small\n * grids. */\nconst MIN_SPATIAL_PERIOD = 2;\n\n/**\n * Computes, for every entry of `MEDIUM_TIME_OCTAVES`, the seamless (frequency, period) pair the\n * shader's `u_spatialN` uniforms need — pure and exported so the wrap-safety it exists for is\n * directly testable without a GPU (`medium-noise-spatial-periodicity.test.ts`), the same reasoning\n * as `depthFarOffsetPx`. Depends on the LIVE grid size (unlike the time octaves' periods, fixed at\n * module load): `web/medium.ts` recomputes this every frame from the current grid dimensions, since\n * a resize changes what \"an integer number of cells across the grid\" even means.\n *\n * `rawX`/`rawY` (the UNROUNDED cell counts, `gridSize * curlFreq * scale`) keep the grid's own\n * aspect ratio exactly — at this package's default 74x37 (2:1), `rawY` is always `rawX / 2`. Simply\n * flooring EACH axis's independently-rounded count at `MIN_SPATIAL_PERIOD` breaks that: the macro\n * octave's raw counts (1.17, 0.58) both round to 1 and both get floored to the SAME value (2), so\n * `freqY` (divided by the smaller `gridHeight`) comes out exactly 2x `freqX` — a genuinely\n * ANISOTROPIC lattice, not a seamless-but-otherwise-unchanged one. Measured directly against\n * `check-medium.mjs`'s isotropy gate: this asymmetry, not the seam it replaced, was the dominant\n * source of axis-aligned gradient energy — worse than the pre-fix (non-periodic, but isotropic)\n * noise. The fix scales BOTH raw counts by the SAME factor (enough to bring the smaller one up to\n * the floor) before rounding, so a floored octave stays isotropic (both axes land on the same\n * frequency, up to independent rounding of the now-larger counts) instead of stretching one axis.\n */\nexport function computeMediumSpatialPeriods(\n gridWidth: number,\n gridHeight: number,\n curlFreq: number,\n): readonly MediumSpatialOctave[] {\n // A zero frequency would divide by zero below and hand the shader Infinity.\n const freq = Math.max(curlFreq, 1e-6);\n return MEDIUM_TIME_OCTAVES.map((o) => {\n const rawX = gridWidth * freq * o.scale;\n const rawY = gridHeight * freq * o.scale;\n const minRaw = Math.min(rawX, rawY);\n const scaleUp = minRaw > 0 && minRaw < MIN_SPATIAL_PERIOD ? MIN_SPATIAL_PERIOD / minRaw : 1;\n const periodX = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawX * scaleUp));\n const periodY = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawY * scaleUp));\n return {\n freqX: periodX / (gridWidth * freq),\n freqY: periodY / (gridHeight * freq),\n periodX,\n periodY,\n };\n });\n}\n\n/** Pure-JS port of `vgPotentialSeamless`, built from `computeMediumSpatialPeriods`'s output the\n * same way `vgPotentialJS` is built from `MEDIUM_TIME_OCTAVES` directly. */\nexport function vgPotentialSeamlessJS(\n px: number,\n py: number,\n phase: number,\n spatial: readonly MediumSpatialOctave[],\n): number {\n const [o0, o1, o2, o3] = MEDIUM_TIME_OCTAVES;\n const [s0, s1, s2, s3] = spatial;\n let v =\n vgValueNoise3SeamlessJS(\n px * s0.freqX,\n py * s0.freqY,\n phase * o0.rate + o0.offset,\n s0.periodX,\n s0.periodY,\n mediumOctavePeriod(o0.rate),\n ) * 0.5;\n v +=\n vgValueNoise3SeamlessJS(\n px * s1.freqX,\n py * s1.freqY,\n phase * o1.rate + o1.offset,\n s1.periodX,\n s1.periodY,\n mediumOctavePeriod(o1.rate),\n ) * 0.25;\n v +=\n vgValueNoise3SeamlessJS(\n px * s2.freqX,\n py * s2.freqY,\n phase * o2.rate + o2.offset,\n s2.periodX,\n s2.periodY,\n mediumOctavePeriod(o2.rate),\n ) * 0.125;\n const macro = vgValueNoise3SeamlessJS(\n px * s3.freqX,\n py * s3.freqY,\n phase * o3.rate + o3.offset,\n s3.periodX,\n s3.periodY,\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// One float4 per MEDIUM_TIME_OCTAVES entry: xy is that octave's seamless spatial frequency, zw its\n// matching integer lattice period — both grid-size-dependent, so they can't be compile-time\n// constants (see vgPotentialSeamless/computeMediumSpatialPeriods in noise.ts). Recomputed by\n// web/medium.ts's bindVelocityUniforms every frame from the live grid dimensions.\nuniform float4 u_spatial0;\nuniform float4 u_spatial1;\nuniform float4 u_spatial2;\nuniform float4 u_spatial3;\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.\n//\n// `curl` is kept as its own variable (not folded straight into `vel`) so MEDIUM_ADVECT_VELOCITY_SETTLE\n// below can reuse its x component directly, at full (untouched by `u_turbulence`) strength.\nconst MEDIUM_ADVECT_VELOCITY = `\n float2 curl = vgCurlVelocitySeamless(xy * u_curlFreq, u_phase, u_spatial0, u_spatial1, u_spatial2, u_spatial3);\n float2 vel = curl * 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.\n//\n// `u_settleWiggle` adds a SECOND, unconditional share of the ambient curl's own sideways push —\n// unlike `vel`'s turbulence-gated share above, this one doesn't vanish when turbulence is low. A\n// constant per-cell downward speed with no lateral term traces a dead-straight vertical line: at\n// rest (turbulence ~0.045) the turbulence-gated curl is over an order of magnitude below\n// `u_settleSpeed` and does nothing to break that line up, which is exactly the artifact the\n// isotropy gate exists to catch (see check-medium.mjs and MEDIUM_DEFAULTS.condensateSettleWiggle's\n// own comment for the calibration). Reusing `curl` needs no new noise field and is periodic for\n// free — it is the same seamless field `vel` already reads.\nconst MEDIUM_ADVECT_VELOCITY_SETTLE = `\n${MEDIUM_ADVECT_VELOCITY}\n vel.y += u_settleSpeed;\n vel.x += curl.x * u_advectSpeed * u_settleWiggle;\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;\nuniform float u_settleWiggle;\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 float u_settleWiggle;\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 an 8-tap\n// ring 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.\n// Cubic B-spline reconstruction from 4 bilinear taps (Sigg & Hadwiger 2005) — the standard trick\n// that gets a smooth surface out of the grid's own bilinear filtering instead of 16 point samples.\n// Needed because check-medium.mjs's isotropy gate measures it directly: at the product's default\n// coarse grid (74x37 at 1920x952), plain bilinear plus this shader's own nonlinear Oklab mix shows\n// the grid's cells as squares with staircase edges (see the gate's report for the measured ratio).\n// `vgCubicWeights` computes the four B-spline basis weights at fractional position `v`; each of\n// the two `vgBicubicN` functions below is the SAME sampling formula against a DIFFERENT texture —\n// the transpiler's `.eval()` rewrite (vireglass's targets/glsl.ts) matches call sites by the\n// literal declared uniform name, not by a function parameter, so a texture can't be passed in as an\n// argument; the formula is instead generated once per texture name by `vgBicubicSampler` in JS,\n// the same way noise.ts's octave text is generated from MEDIUM_TIME_OCTAVES rather than retyped.\n// Applied to vapor and condensate (the two fields that make up the visible \"gas\" body); NOT to the\n// far plane, which is already blurred (a spatial average already hides blockiness on its own), or\n// to tracks (thin, high-contrast, already-sharp stamps — bicubic softening would blur the \"fresh\n// ionization\" look the head/tail intensity split exists for, at a cost with no matching benefit).\nconst VG_CUBIC_WEIGHTS = `\nfloat4 vgCubicWeights(float v) {\n float4 n = float4(1.0, 2.0, 3.0, 4.0) - v;\n float4 s = n * n * n;\n float x = s.x;\n float y = s.y - 4.0 * s.x;\n float z = s.z - 4.0 * s.y + 6.0 * s.x;\n float w = 6.0 - x - y - z;\n return float4(x, y, z, w) * (1.0 / 6.0);\n}\n`;\n\nfunction vgBicubicSampler(fnName: string, textureName: string): string {\n return `\nhalf4 ${fnName}(float2 px) {\n float2 shifted = px - 0.5;\n float2 fxy = fract(shifted);\n float2 base = shifted - fxy;\n float4 xw = vgCubicWeights(fxy.x);\n float4 yw = vgCubicWeights(fxy.y);\n float2 sx = float2(xw.x + xw.y, xw.z + xw.w);\n float2 sy = float2(yw.x + yw.y, yw.z + yw.w);\n float2 offsetX = float2(base.x - 0.5 + xw.y / sx.x, base.x + 1.5 + xw.w / sx.y);\n float2 offsetY = float2(base.y - 0.5 + yw.y / sy.x, base.y + 1.5 + yw.w / sy.y);\n half4 s00 = ${textureName}.eval(float2(offsetX.x, offsetY.x));\n half4 s10 = ${textureName}.eval(float2(offsetX.y, offsetY.x));\n half4 s01 = ${textureName}.eval(float2(offsetX.x, offsetY.y));\n half4 s11 = ${textureName}.eval(float2(offsetX.y, offsetY.y));\n float wx = sx.x / (sx.x + sx.y);\n float wy = sy.x / (sy.x + sy.y);\n return mix(mix(s11, s01, wx), mix(s10, s00, wx), wy);\n}\n`;\n}\n\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${VG_CUBIC_WEIGHTS}\n${vgBicubicSampler('vgBicubicVapor', 'u_vapor')}\n${vgBicubicSampler('vgBicubicCondensate', 'u_condensate')}\n\nhalf4 main(float2 xy) {\n float2 baseSrc = xy * u_dyeScale;\n\n // Near plane — the calibrated look lives here. Bicubic (see the file header) on vapor and\n // condensate; track stays a plain bilinear read.\n half4 vapor = vgBicubicVapor(baseSrc);\n half4 condensate = vgBicubicCondensate(baseSrc);\n half4 track = u_track.eval(baseSrc);\n\n // Far plane — the identical field, scaled and offset (see the file header). An 8-tap ring, not\n // the near plane's ±x/±y box: axis-aligned taps read the SAME column/row on both sides of any\n // residual axis-aligned structure (a seam, a resize artifact), which is exactly how a straight\n // line gets doubled into four parallel copies instead of softened away — measured directly via\n // check-medium.mjs's isotropy gate (see its report). The ring is rotated a half-step (22.5°) off\n // the axes so no tap direction ever coincides with a grid row or column. Radius stays in the same\n // far-plane texture-space unit as before (u_farBlurRadius, grid-px) — only the tap PATTERN\n // changed, not what it measures in.\n float2 farSrc = baseSrc * u_farScale + u_farOffset;\n float2 ring0 = float2( 0.9238795, 0.3826834) * u_farBlurRadius;\n float2 ring1 = float2( 0.3826834, 0.9238795) * u_farBlurRadius;\n float2 ring2 = float2(-0.3826834, 0.9238795) * u_farBlurRadius;\n float2 ring3 = float2(-0.9238795, 0.3826834) * u_farBlurRadius;\n float2 ring4 = float2(-0.9238795, -0.3826834) * u_farBlurRadius;\n float2 ring5 = float2(-0.3826834, -0.9238795) * u_farBlurRadius;\n float2 ring6 = float2( 0.3826834, -0.9238795) * u_farBlurRadius;\n float2 ring7 = float2( 0.9238795, -0.3826834) * u_farBlurRadius;\n half4 vaporFar = (u_vapor.eval(farSrc + ring0) + u_vapor.eval(farSrc + ring1) +\n u_vapor.eval(farSrc + ring2) + u_vapor.eval(farSrc + ring3) +\n u_vapor.eval(farSrc + ring4) + u_vapor.eval(farSrc + ring5) +\n u_vapor.eval(farSrc + ring6) + u_vapor.eval(farSrc + ring7)) * half4(0.125);\n half4 condensateFar = (u_condensate.eval(farSrc + ring0) + u_condensate.eval(farSrc + ring1) +\n u_condensate.eval(farSrc + ring2) + u_condensate.eval(farSrc + ring3) +\n u_condensate.eval(farSrc + ring4) + u_condensate.eval(farSrc + ring5) +\n u_condensate.eval(farSrc + ring6) + u_condensate.eval(farSrc + ring7)) * half4(0.125);\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"]}
|
|
@@ -33,6 +33,17 @@ type VireUIKitMediumParams = {
|
|
|
33
33
|
/** grid-px/s — condensate drift downward, on top of the shared velocity field: gravity touches
|
|
34
34
|
* droplets and leaves vapor alone. */
|
|
35
35
|
condensateSettleSpeed: number;
|
|
36
|
+
/** Multiplier on the ambient curl field's OWN x-component, added to settling UNCONDITIONALLY
|
|
37
|
+
* (not gated by `turbulence` the way the shared velocity field is) — see
|
|
38
|
+
* MEDIUM_ADVECT_VELOCITY_SETTLE in advect-shader.ts. Without it, a droplet falling at a constant
|
|
39
|
+
* `condensateSettleSpeed` with only a turbulence-gated sideways push traces a near-straight
|
|
40
|
+
* vertical line whenever turbulence is low (rest/paused: 0.045, over 20x below the gated push
|
|
41
|
+
* needed to bend the fall) — many droplets in nearby columns then read as parallel vertical
|
|
42
|
+
* streaks, one of the axis-aligned artifacts `check-medium.mjs`'s isotropy gate measures. Reuses
|
|
43
|
+
* the SAME seamless curl field `vel` already reads rather than a second noise system, so it adds
|
|
44
|
+
* no new seam of its own. Calibrated against that gate at the product's default grid (1920x952,
|
|
45
|
+
* 74x37) — see the gate's own report for the measured ratio at this value. */
|
|
46
|
+
condensateSettleWiggle: number;
|
|
36
47
|
/** 1/s — the rate at which a droplet blends with its grid neighbors. There is no separate age
|
|
37
48
|
* counter: the blend repeats every frame, and whatever has survived more frames ends up more
|
|
38
49
|
* blended with its neighbors on its own — that IS "growth". Keep it NOTICEABLY below
|
|
@@ -81,7 +92,8 @@ type VireUIKitMediumParams = {
|
|
|
81
92
|
* `depthFarOffsetPx` below and `check-medium.mjs`'s decorrelation gate, which measures this
|
|
82
93
|
* directly at the README's own 128x72. */
|
|
83
94
|
depthFarOffsetFrac: readonly [number, number];
|
|
84
|
-
/** Grid-px
|
|
95
|
+
/** Grid-px radius of the far plane's 8-tap ring blur, rotated so no tap pair lies on a grid
|
|
96
|
+
* axis. A spatial average cannot raise local
|
|
85
97
|
* variance, so the same blur that softens the far plane's edges also, by construction, lowers
|
|
86
98
|
* its measured contrast — one mechanism for both aerial-perspective cues (no separate contrast
|
|
87
99
|
* knob exists). 0 disables it (the near plane stays sharp). Measured: reading the far plane's
|
|
@@ -33,6 +33,17 @@ type VireUIKitMediumParams = {
|
|
|
33
33
|
/** grid-px/s — condensate drift downward, on top of the shared velocity field: gravity touches
|
|
34
34
|
* droplets and leaves vapor alone. */
|
|
35
35
|
condensateSettleSpeed: number;
|
|
36
|
+
/** Multiplier on the ambient curl field's OWN x-component, added to settling UNCONDITIONALLY
|
|
37
|
+
* (not gated by `turbulence` the way the shared velocity field is) — see
|
|
38
|
+
* MEDIUM_ADVECT_VELOCITY_SETTLE in advect-shader.ts. Without it, a droplet falling at a constant
|
|
39
|
+
* `condensateSettleSpeed` with only a turbulence-gated sideways push traces a near-straight
|
|
40
|
+
* vertical line whenever turbulence is low (rest/paused: 0.045, over 20x below the gated push
|
|
41
|
+
* needed to bend the fall) — many droplets in nearby columns then read as parallel vertical
|
|
42
|
+
* streaks, one of the axis-aligned artifacts `check-medium.mjs`'s isotropy gate measures. Reuses
|
|
43
|
+
* the SAME seamless curl field `vel` already reads rather than a second noise system, so it adds
|
|
44
|
+
* no new seam of its own. Calibrated against that gate at the product's default grid (1920x952,
|
|
45
|
+
* 74x37) — see the gate's own report for the measured ratio at this value. */
|
|
46
|
+
condensateSettleWiggle: number;
|
|
36
47
|
/** 1/s — the rate at which a droplet blends with its grid neighbors. There is no separate age
|
|
37
48
|
* counter: the blend repeats every frame, and whatever has survived more frames ends up more
|
|
38
49
|
* blended with its neighbors on its own — that IS "growth". Keep it NOTICEABLY below
|
|
@@ -81,7 +92,8 @@ type VireUIKitMediumParams = {
|
|
|
81
92
|
* `depthFarOffsetPx` below and `check-medium.mjs`'s decorrelation gate, which measures this
|
|
82
93
|
* directly at the README's own 128x72. */
|
|
83
94
|
depthFarOffsetFrac: readonly [number, number];
|
|
84
|
-
/** Grid-px
|
|
95
|
+
/** Grid-px radius of the far plane's 8-tap ring blur, rotated so no tap pair lies on a grid
|
|
96
|
+
* axis. A spatial average cannot raise local
|
|
85
97
|
* variance, so the same blur that softens the far plane's edges also, by construction, lowers
|
|
86
98
|
* its measured contrast — one mechanism for both aerial-perspective cues (no separate contrast
|
|
87
99
|
* knob exists). 0 disables it (the near plane stays sharp). Measured: reading the far plane's
|