vireuikit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -274,9 +274,46 @@ function neutralSpecies(baseColor) {
274
274
  }
275
275
 
276
276
  // src/medium/params.ts
277
+ var MEDIUM_MAX_LIGHTS = 3;
277
278
  var BASE_COLOR = [0.07, 0.08, 0.1];
279
+ var normalize2 = (v) => {
280
+ const len = Math.hypot(v[0], v[1]) || 1;
281
+ return [v[0] / len, v[1] / len];
282
+ };
283
+ var MEDIUM_DEFAULT_LIGHT_COLOR = [0.78, 0.85, 0.94];
284
+ var MEDIUM_DEFAULT_LIGHTS = [
285
+ {
286
+ position: [0.5, 0],
287
+ direction: [0, 1],
288
+ coneAngle: Math.PI,
289
+ falloff: 16,
290
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
291
+ intensity: 0.28
292
+ },
293
+ {
294
+ position: [0.12, 1.45],
295
+ direction: normalize2([0.38, -1.03]),
296
+ coneAngle: 9 * Math.PI / 180,
297
+ falloff: 0.7,
298
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
299
+ intensity: 1.1
300
+ },
301
+ {
302
+ position: [0.9, 1.4],
303
+ direction: normalize2([-0.4, -0.98]),
304
+ coneAngle: 8 * Math.PI / 180,
305
+ falloff: 0.75,
306
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
307
+ intensity: 0.9
308
+ }
309
+ ];
278
310
  var MEDIUM_DEFAULTS = {
279
- decay: 0.06,
311
+ // A stamped track has to visibly broaden and fade within the reference's "roughly one to two
312
+ // seconds", not the ~11s half-life a 0.06 decay gives — measured against check-medium.mjs's track
313
+ // gate (birth vs +1.5s cross-section width and total density): 1.1 leaves ~30% of the peak at 1s
314
+ // and ~17% at 1.5s, comfortably faded without dying before MacCormack's own diffusion has time to
315
+ // visibly broaden the stamp.
316
+ decay: 1.1,
280
317
  curlFreq: 0.045,
281
318
  curlSpeed: 0.12,
282
319
  advectSpeed: 26,
@@ -285,11 +322,13 @@ var MEDIUM_DEFAULTS = {
285
322
  condensationFloor: 0.12,
286
323
  evaporationRate: 0.2,
287
324
  condensateSettleSpeed: 8,
325
+ condensateSettleWiggle: 0.4,
288
326
  condensateSpreadRate: 0.08,
289
327
  condensateTint: 0.2,
290
328
  condensateGain: 5,
291
329
  condensateColor: [0.94, 0.95, 0.97],
292
330
  channelColors: neutralSpecies(BASE_COLOR),
331
+ channelScatter: [1, 1, 1],
293
332
  baseColor: BASE_COLOR,
294
333
  baseWeight: 0.9,
295
334
  depthFarScale: 1.6,
@@ -298,7 +337,11 @@ var MEDIUM_DEFAULTS = {
298
337
  depthFarWeight: 0.5,
299
338
  gravityVaporDrift: 0.6,
300
339
  gravityBottomBoost: 2,
301
- gravityBottomBoostStart: 0.75
340
+ gravityBottomBoostStart: 0.75,
341
+ lights: MEDIUM_DEFAULT_LIGHTS,
342
+ shadowExtinction: 3,
343
+ trackLightFloor: 0.8,
344
+ trackLayerAmount: 1
302
345
  };
303
346
  function depthFarOffsetPx(offsetFrac, gridWidth, gridHeight) {
304
347
  return [offsetFrac[0] * gridWidth, offsetFrac[1] * gridHeight];
@@ -320,7 +363,7 @@ var MEDIUM_TRACK_PRESETS = {
320
363
  tailDim: 0.55,
321
364
  intensity: 1.15
322
365
  },
323
- beta: {
366
+ electron: {
324
367
  lengthFrac: 0.42,
325
368
  headWidthFrac: 0.018,
326
369
  tailWidthFrac: 0.05,
@@ -328,17 +371,17 @@ var MEDIUM_TRACK_PRESETS = {
328
371
  raggedFreq: 5,
329
372
  settleFrac: 0.05,
330
373
  tailDim: 0.45,
331
- intensity: 0.85
374
+ intensity: 0.6
332
375
  },
333
- natural: {
334
- lengthFrac: 0.13,
335
- headWidthFrac: 0.02,
336
- tailWidthFrac: 0.04,
337
- raggedFrac: 0.02,
338
- raggedFreq: 4,
339
- settleFrac: 0.02,
340
- tailDim: 0.5,
341
- intensity: 0.4
376
+ muon: {
377
+ lengthFrac: 0.7,
378
+ headWidthFrac: 0.012,
379
+ tailWidthFrac: 0.02,
380
+ raggedFrac: 0,
381
+ raggedFreq: 0,
382
+ settleFrac: 0.01,
383
+ tailDim: 0.75,
384
+ intensity: 0.5
342
385
  }
343
386
  };
344
387
  var MEDIUM_DRIFT_TURBULENCE = 0.045;
@@ -347,12 +390,60 @@ var MEDIUM_TURBULENCE_TAU = 2.5;
347
390
  var MEDIUM_EMISSION_BEATS = 4;
348
391
  var MEDIUM_EMISSION_PROBABILITY = 0.55;
349
392
  var MEDIUM_MIN_EMISSION_INTERVAL = 1.2;
350
- var MEDIUM_NATURAL_INTERVAL = 25;
351
- var MEDIUM_NATURAL_JITTER = 35;
393
+ var MEDIUM_NATURAL_INTERVAL = 8;
394
+ var MEDIUM_NATURAL_JITTER = 12;
352
395
  var MEDIUM_SENSITIVE_TOP = 0.55;
353
396
  var MEDIUM_SENSITIVE_BOTTOM = 0.95;
354
- var MEDIUM_TRACK_DEPTH_WIDTH_RANGE = [0.55, 1];
397
+ var MEDIUM_ALPHA_BURST_RAYS = [8, 16];
398
+ var MEDIUM_ALPHA_RAY_LENGTH_SCALE = [0.55, 1];
399
+ var MEDIUM_TRACK_DEPTH_WIDTH_RANGE = [0.55, 1.2];
355
400
  var MEDIUM_TRACK_DEPTH_INTENSITY_RANGE = [0.4, 1];
401
+ var MEDIUM_TRACK_LAYER_LIFE_SECONDS = {
402
+ alpha: 1.8,
403
+ electron: 1.4,
404
+ muon: 2.2
405
+ };
406
+ var MEDIUM_TRACK_LAYER_MAX_TRACKS = 64;
407
+ var MEDIUM_TRACK_LAYER_LOOK = {
408
+ alpha: {
409
+ sigmaFrac: 22e-4,
410
+ endSigmaMul: 1.9,
411
+ broaden: 3.2,
412
+ wiggleFrac: 0,
413
+ beadFrac: 6e-3,
414
+ dropletFrac: 35e-4,
415
+ dropletChance: 0.5,
416
+ startFrac: 0.035,
417
+ gain: 1.8
418
+ },
419
+ electron: {
420
+ sigmaFrac: 9e-4,
421
+ endSigmaMul: 1.3,
422
+ broaden: 6,
423
+ wiggleFrac: 0.012,
424
+ beadFrac: 8e-3,
425
+ dropletFrac: 6e-3,
426
+ dropletChance: 0.55,
427
+ startFrac: 0,
428
+ gain: 2.2
429
+ },
430
+ muon: {
431
+ sigmaFrac: 8e-4,
432
+ endSigmaMul: 1,
433
+ broaden: 5,
434
+ wiggleFrac: 0,
435
+ beadFrac: 7e-3,
436
+ dropletFrac: 5e-3,
437
+ dropletChance: 0.5,
438
+ startFrac: 0,
439
+ gain: 2.2
440
+ }
441
+ };
442
+ var MEDIUM_TRACK_LAYER_BURST_SPREAD = 1.6;
443
+ var MEDIUM_TRACK_LAYER_BURST_ACCENT = 3;
444
+ var MEDIUM_TRACK_LAYER_FADE_GAMMA = 1.6;
445
+ var MEDIUM_TRACK_LAYER_GRAVITY_SAG = 0.018;
446
+ var MEDIUM_TRACK_LAYER_CURL_PUSH = 0.05;
356
447
 
357
448
  // src/medium/noise.ts
358
449
  var VG_VALUE_NOISE = `
@@ -484,6 +575,71 @@ float2 vgCurlVelocity(float2 p, float phase) {
484
575
  float py0 = vgPotential(p - float2(0.0, e), phase);
485
576
  return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
486
577
  }
578
+
579
+ // vgPotential above is periodic in TIME only: the simulation domain is a torus (gl.REPEAT), but
580
+ // the lattice itself never wraps in x/y, so velocity jumps at the domain seam and dye on either
581
+ // side of it is transported differently \u2014 a straight seam, made worse wherever something re-reads
582
+ // the field at a different scale (the far depth plane's tile boundary). vgValueNoise3Seamless
583
+ // wraps ALL THREE lattice axes: xyPeriod/zPeriod fold BOTH neighbors of a cell into their own
584
+ // period before hashing, so the field is EXACTLY the same one period over, in space exactly as it
585
+ // already was in time.
586
+ //
587
+ // The period has to be an integer number of lattice cells, or the wrap would jump instead of
588
+ // closing seamlessly (the same requirement MEDIUM_TIME_OCTAVES/mediumOctavePeriod enforces for
589
+ // time) \u2014 but unlike time, the spatial period depends on the GRID SIZE, which this shader has no
590
+ // notion of. So xyPeriod travels together with a per-octave spatial frequency the CPU computes to
591
+ // make gridSize * frequency land on that exact integer (see computeMediumSpatialPeriods below);
592
+ // both arrive together as one uniform per octave (u_spatialN.xy = frequency, .zw = period),
593
+ // recomputed by web/medium.ts whenever the grid resizes.
594
+ float vgValueNoise3Seamless(float3 p, float2 xyPeriod, float zPeriod) {
595
+ float3 i = floor(p);
596
+ float3 f = fract(p);
597
+ float3 u = f * f * (3.0 - 2.0 * f);
598
+ float x0 = mod(i.x, xyPeriod.x);
599
+ float x1 = mod(i.x + 1.0, xyPeriod.x);
600
+ float y0 = mod(i.y, xyPeriod.y);
601
+ float y1 = mod(i.y + 1.0, xyPeriod.y);
602
+ float z0 = mod(i.z, zPeriod);
603
+ float z1 = mod(i.z + 1.0, zPeriod);
604
+ float n000 = vgHash31(float3(x0, y0, z0));
605
+ float n100 = vgHash31(float3(x1, y0, z0));
606
+ float n010 = vgHash31(float3(x0, y1, z0));
607
+ float n110 = vgHash31(float3(x1, y1, z0));
608
+ float n001 = vgHash31(float3(x0, y0, z1));
609
+ float n101 = vgHash31(float3(x1, y0, z1));
610
+ float n011 = vgHash31(float3(x0, y1, z1));
611
+ float n111 = vgHash31(float3(x1, y1, z1));
612
+ float nx00 = mix(n000, n100, u.x);
613
+ float nx10 = mix(n010, n110, u.x);
614
+ float nx01 = mix(n001, n101, u.x);
615
+ float nx11 = mix(n011, n111, u.x);
616
+ float nxy0 = mix(nx00, nx10, u.y);
617
+ float nxy1 = mix(nx01, nx11, u.y);
618
+ return mix(nxy0, nxy1, u.z) * 2.0 - 1.0;
619
+ }
620
+
621
+ // Same four-octave fbm as vgPotential, but every octave reads the seamless lattice above at its
622
+ // OWN (frequency, period) pair \u2014 sN.xy replaces that octave's compile-time \`scale\` constant (it
623
+ // has to be a uniform now: the adjustment depends on the live grid size), sN.zw is the matching
624
+ // integer period. \`p\` is still curlFreq-scaled exactly as vgPotential's caller scales it \u2014 only
625
+ // the per-octave multiplier changed from a constant to a grid-dependent uniform, so the finite
626
+ // difference in vgCurlVelocitySeamless below needs no epsilon change of its own.
627
+ float vgPotentialSeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
628
+ float v = vgValueNoise3Seamless(float3(p * s0.xy, phase * ${VG_OCT0.rate} + ${VG_OCT0.offset}), s0.zw, ${VG_OCT0.period}) * 0.5;
629
+ v += vgValueNoise3Seamless(float3(p * s1.xy, phase * ${VG_OCT1.rate} + ${VG_OCT1.offset}), s1.zw, ${VG_OCT1.period}) * 0.25;
630
+ v += vgValueNoise3Seamless(float3(p * s2.xy, phase * ${VG_OCT2.rate} + ${VG_OCT2.offset}), s2.zw, ${VG_OCT2.period}) * 0.125;
631
+ float macro = vgValueNoise3Seamless(float3(p * s3.xy, phase * ${VG_OCT3.rate} + ${VG_OCT3.offset}), s3.zw, ${VG_OCT3.period});
632
+ return v * (0.7 + 0.3 * macro);
633
+ }
634
+
635
+ float2 vgCurlVelocitySeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
636
+ float e = 0.6;
637
+ float px1 = vgPotentialSeamless(p + float2(e, 0.0), phase, s0, s1, s2, s3);
638
+ float px0 = vgPotentialSeamless(p - float2(e, 0.0), phase, s0, s1, s2, s3);
639
+ float py1 = vgPotentialSeamless(p + float2(0.0, e), phase, s0, s1, s2, s3);
640
+ float py0 = vgPotentialSeamless(p - float2(0.0, e), phase, s0, s1, s2, s3);
641
+ return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
642
+ }
487
643
  `;
488
644
 
489
645
  // src/medium/seed-shader.ts
@@ -527,15 +683,25 @@ uniform float u_phase;
527
683
  uniform float u_curlFreq;
528
684
  uniform float u_advectSpeed;
529
685
  uniform float u_turbulence;
686
+ // One float4 per MEDIUM_TIME_OCTAVES entry: xy is that octave's seamless spatial frequency, zw its
687
+ // matching integer lattice period \u2014 both grid-size-dependent, so they can't be compile-time
688
+ // constants (see vgPotentialSeamless/computeMediumSpatialPeriods in noise.ts). Recomputed by
689
+ // web/medium.ts's bindVelocityUniforms every frame from the live grid dimensions.
690
+ uniform float4 u_spatial0;
691
+ uniform float4 u_spatial1;
692
+ uniform float4 u_spatial2;
693
+ uniform float4 u_spatial3;
530
694
 
531
695
  ${VG_CURL_NOISE}
532
696
  `;
533
697
  var MEDIUM_ADVECT_VELOCITY = `
534
- float2 vel = vgCurlVelocity(xy * u_curlFreq, u_phase) * u_advectSpeed * u_turbulence;
698
+ float2 curl = vgCurlVelocitySeamless(xy * u_curlFreq, u_phase, u_spatial0, u_spatial1, u_spatial2, u_spatial3);
699
+ float2 vel = curl * u_advectSpeed * u_turbulence;
535
700
  `;
536
701
  var MEDIUM_ADVECT_VELOCITY_SETTLE = `
537
702
  ${MEDIUM_ADVECT_VELOCITY}
538
703
  vel.y += u_settleSpeed;
704
+ vel.x += curl.x * u_advectSpeed * u_settleWiggle;
539
705
  `;
540
706
  var MEDIUM_ADVECT_VELOCITY_VAPOR = `
541
707
  ${MEDIUM_ADVECT_VELOCITY}
@@ -554,6 +720,7 @@ ${MEDIUM_ADVECT_VELOCITY_VAPOR}
554
720
  var MEDIUM_CONDENSATE_FORWARD_SHADER = `
555
721
  ${MEDIUM_ADVECT_UNIFORMS}
556
722
  uniform float u_settleSpeed;
723
+ uniform float u_settleWiggle;
557
724
 
558
725
  half4 main(float2 xy) {
559
726
  ${MEDIUM_ADVECT_VELOCITY_SETTLE}
@@ -593,6 +760,7 @@ ${MEDIUM_MACCORMACK_CORRECT}
593
760
  var MEDIUM_CONDENSATE_CORRECT_SHADER = `
594
761
  ${MEDIUM_ADVECT_UNIFORMS}
595
762
  uniform float u_settleSpeed;
763
+ uniform float u_settleWiggle;
596
764
  uniform shader u_forward;
597
765
 
598
766
  half4 main(float2 xy) {
@@ -692,10 +860,68 @@ ${MEDIUM_ADVECT_VELOCITY}
692
860
  `;
693
861
 
694
862
  // src/medium/composite-shader.ts
863
+ var VG_CUBIC_WEIGHTS = `
864
+ float4 vgCubicWeights(float v) {
865
+ float4 n = float4(1.0, 2.0, 3.0, 4.0) - v;
866
+ float4 s = n * n * n;
867
+ float x = s.x;
868
+ float y = s.y - 4.0 * s.x;
869
+ float z = s.z - 4.0 * s.y + 6.0 * s.x;
870
+ float w = 6.0 - x - y - z;
871
+ return float4(x, y, z, w) * (1.0 / 6.0);
872
+ }
873
+ `;
874
+ function vgBicubicSampler(fnName, textureName) {
875
+ return `
876
+ half4 ${fnName}(float2 px) {
877
+ float2 shifted = px - 0.5;
878
+ float2 fxy = fract(shifted);
879
+ float2 base = shifted - fxy;
880
+ float4 xw = vgCubicWeights(fxy.x);
881
+ float4 yw = vgCubicWeights(fxy.y);
882
+ float2 sx = float2(xw.x + xw.y, xw.z + xw.w);
883
+ float2 sy = float2(yw.x + yw.y, yw.z + yw.w);
884
+ float2 offsetX = float2(base.x - 0.5 + xw.y / sx.x, base.x + 1.5 + xw.w / sx.y);
885
+ float2 offsetY = float2(base.y - 0.5 + yw.y / sy.x, base.y + 1.5 + yw.w / sy.y);
886
+ half4 s00 = ${textureName}.eval(float2(offsetX.x, offsetY.x));
887
+ half4 s10 = ${textureName}.eval(float2(offsetX.y, offsetY.x));
888
+ half4 s01 = ${textureName}.eval(float2(offsetX.x, offsetY.y));
889
+ half4 s11 = ${textureName}.eval(float2(offsetX.y, offsetY.y));
890
+ float wx = sx.x / (sx.x + sx.y);
891
+ float wy = sy.x / (sy.x + sy.y);
892
+ return mix(mix(s11, s01, wx), mix(s10, s00, wx), wy);
893
+ }
894
+ `;
895
+ }
896
+ var VG_LIGHT = `
897
+ float vgLightAmount(float2 xy, float2 lightPosFrac, float2 lightDir, float cosCone, float falloff, float2 resolution, float seed) {
898
+ float2 rel = xy - lightPosFrac * resolution;
899
+ // A cone of 180\xB0 or more is an edge strip: light spreads from a line, not a point.
900
+ if (cosCone <= -0.999) {
901
+ float depth = max(dot(rel, lightDir), 0.0);
902
+ return exp(-(depth / max(resolution.y, 1.0)) * falloff);
903
+ }
904
+ float dist = length(rel);
905
+ float2 dirN = dist > 1e-4 ? rel / dist : lightDir;
906
+ float cosAngle = dot(dirN, lightDir);
907
+ // A soft lobe with half intensity at the cone angle and no boundary at all: a lamp in haze has
908
+ // no drawn edge.
909
+ float lobe = log(0.5) / log(clamp(cosCone, 0.05, 0.999));
910
+ float edge = pow(max(cosAngle, 0.0), lobe);
911
+ float atten = exp(-(dist / max(resolution.y, 1.0)) * falloff);
912
+ if (edge * atten < 0.001) return 0.0;
913
+ // Static shafts inside the beam: brightness varies with the angle around the lamp, so streaks
914
+ // fan out from the source the way light through dusty air does.
915
+ float angle = atan(dirN.x * lightDir.y - dirN.y * lightDir.x, cosAngle);
916
+ float shafts = 0.6 + 0.32 * vgValueNoise(float2(angle * 14.0, seed)) + 0.18 * vgValueNoise(float2(angle * 33.0, seed + 5.3));
917
+ return edge * atten * clamp(shafts, 0.0, 1.0);
918
+ }
919
+ `;
695
920
  var MEDIUM_COMPOSITE_SHADER = `
696
921
  uniform shader u_vapor;
697
922
  uniform shader u_condensate;
698
923
  uniform shader u_track;
924
+ uniform shader u_shadow;
699
925
  uniform float2 u_dyeScale;
700
926
  uniform float3 u_lab0;
701
927
  uniform float3 u_lab1;
@@ -711,27 +937,68 @@ uniform float u_farBlurRadius;
711
937
  uniform float u_farWeight;
712
938
  uniform float u_gravityBoost;
713
939
  uniform float u_gravityBoostStart;
940
+ uniform float3 u_channelScatter;
941
+ uniform float2 u_light0Pos;
942
+ uniform float2 u_light0Dir;
943
+ uniform float u_light0CosCone;
944
+ uniform float u_light0Falloff;
945
+ uniform float3 u_light0Color;
946
+ uniform float u_light0Intensity;
947
+ uniform float2 u_light1Pos;
948
+ uniform float2 u_light1Dir;
949
+ uniform float u_light1CosCone;
950
+ uniform float u_light1Falloff;
951
+ uniform float3 u_light1Color;
952
+ uniform float u_light1Intensity;
953
+ uniform float2 u_light2Pos;
954
+ uniform float2 u_light2Dir;
955
+ uniform float u_light2CosCone;
956
+ uniform float u_light2Falloff;
957
+ uniform float3 u_light2Color;
958
+ uniform float u_light2Intensity;
959
+ uniform float u_trackLightFloor;
714
960
 
715
961
  ${VG_OKLAB_TO_SRGB}
962
+ ${VG_CUBIC_WEIGHTS}
963
+ ${vgBicubicSampler("vgBicubicVapor", "u_vapor")}
964
+ ${vgBicubicSampler("vgBicubicCondensate", "u_condensate")}
965
+ ${VG_VALUE_NOISE}
966
+ ${VG_LIGHT}
716
967
 
717
968
  half4 main(float2 xy) {
718
969
  float2 baseSrc = xy * u_dyeScale;
719
970
 
720
- // Near plane \u2014 sampled exactly as before the depth work; the calibrated look lives here.
721
- half4 vapor = u_vapor.eval(baseSrc);
722
- half4 condensate = u_condensate.eval(baseSrc);
971
+ // Near plane \u2014 the calibrated look lives here. Bicubic (see the file header) on vapor and
972
+ // condensate; track stays a plain bilinear read.
973
+ half4 vapor = vgBicubicVapor(baseSrc);
974
+ half4 condensate = vgBicubicCondensate(baseSrc);
723
975
  half4 track = u_track.eval(baseSrc);
724
976
 
725
- // Far plane \u2014 the identical field, scaled and offset (see the file header). A 4-tap box blur
726
- // (no separate center sample, the cheapest isotropic blur available) supplies aerial perspective's
727
- // blur and, as a side effect of averaging, its lower contrast too.
977
+ // Far plane \u2014 the identical field, scaled and offset (see the file header). An 8-tap ring, not
978
+ // the near plane's \xB1x/\xB1y box: axis-aligned taps read the SAME column/row on both sides of any
979
+ // residual axis-aligned structure (a seam, a resize artifact), which is exactly how a straight
980
+ // line gets doubled into four parallel copies instead of softened away \u2014 measured directly via
981
+ // check-medium.mjs's isotropy gate (see its report). The ring is rotated a half-step (22.5\xB0) off
982
+ // the axes so no tap direction ever coincides with a grid row or column. Radius stays in the same
983
+ // far-plane texture-space unit as before (u_farBlurRadius, grid-px) \u2014 only the tap PATTERN
984
+ // changed, not what it measures in.
728
985
  float2 farSrc = baseSrc * u_farScale + u_farOffset;
729
- float2 blurX = float2(u_farBlurRadius, 0.0);
730
- float2 blurY = float2(0.0, u_farBlurRadius);
731
- half4 vaporFar = (u_vapor.eval(farSrc + blurX) + u_vapor.eval(farSrc - blurX) +
732
- u_vapor.eval(farSrc + blurY) + u_vapor.eval(farSrc - blurY)) * half4(0.25);
733
- half4 condensateFar = (u_condensate.eval(farSrc + blurX) + u_condensate.eval(farSrc - blurX) +
734
- u_condensate.eval(farSrc + blurY) + u_condensate.eval(farSrc - blurY)) * half4(0.25);
986
+ float2 ring0 = float2( 0.9238795, 0.3826834) * u_farBlurRadius;
987
+ float2 ring1 = float2( 0.3826834, 0.9238795) * u_farBlurRadius;
988
+ float2 ring2 = float2(-0.3826834, 0.9238795) * u_farBlurRadius;
989
+ float2 ring3 = float2(-0.9238795, 0.3826834) * u_farBlurRadius;
990
+ float2 ring4 = float2(-0.9238795, -0.3826834) * u_farBlurRadius;
991
+ float2 ring5 = float2(-0.3826834, -0.9238795) * u_farBlurRadius;
992
+ float2 ring6 = float2( 0.3826834, -0.9238795) * u_farBlurRadius;
993
+ float2 ring7 = float2( 0.9238795, -0.3826834) * u_farBlurRadius;
994
+ half4 vaporFar = (u_vapor.eval(farSrc + ring0) + u_vapor.eval(farSrc + ring1) +
995
+ u_vapor.eval(farSrc + ring2) + u_vapor.eval(farSrc + ring3) +
996
+ u_vapor.eval(farSrc + ring4) + u_vapor.eval(farSrc + ring5) +
997
+ u_vapor.eval(farSrc + ring6) + u_vapor.eval(farSrc + ring7)) * half4(0.125);
998
+ half4 condensateFar = (u_condensate.eval(farSrc + ring0) + u_condensate.eval(farSrc + ring1) +
999
+ u_condensate.eval(farSrc + ring2) + u_condensate.eval(farSrc + ring3) +
1000
+ u_condensate.eval(farSrc + ring4) + u_condensate.eval(farSrc + ring5) +
1001
+ u_condensate.eval(farSrc + ring6) + u_condensate.eval(farSrc + ring7)) * half4(0.125);
735
1002
 
736
1003
  // Gravity's "denser layer at the bottom of the chamber" \u2014 a compositing-only boost (see
737
1004
  // MEDIUM_DEFAULTS.gravityBottomBoost): it scales how density already in the conserved buffers is
@@ -764,7 +1031,34 @@ half4 main(float2 xy) {
764
1031
  float total = max(w0 + w1 + w2 + wBg + wCondensate, 1e-4);
765
1032
  float3 mixLab =
766
1033
  (u_labBg * wBg + u_lab0 * w0 + u_lab1 * w1 + u_lab2 * w2 + condensateLab * wCondensate) / total;
767
- float3 rgbLinear = clamp(vgOklabToLinear(mixLab), float3(0.0), float3(1.0));
1034
+ // The dark ambient base \u2014 this is where the OLD mechanism stops (see the file header on
1035
+ // VG_LIGHT): unclamped here, clamped once at the very end, after the lit layer adds in.
1036
+ float3 rgbLinearBase = vgOklabToLinear(mixLab);
1037
+
1038
+ // Spotlights reach this point dimmed by the gas in front of them (shadow-shader.ts).
1039
+ half4 shadow = u_shadow.eval(baseSrc);
1040
+ float light0 = vgLightAmount(xy, u_light0Pos, u_light0Dir, u_light0CosCone, u_light0Falloff, u_resolution, 1.7) * u_light0Intensity;
1041
+ float light1 = vgLightAmount(xy, u_light1Pos, u_light1Dir, u_light1CosCone, u_light1Falloff, u_resolution, 4.1) * u_light1Intensity
1042
+ * float(shadow.r);
1043
+ float light2 = vgLightAmount(xy, u_light2Pos, u_light2Dir, u_light2CosCone, u_light2Falloff, u_resolution, 8.3) * u_light2Intensity
1044
+ * float(shadow.g);
1045
+ float3 illum = u_light0Color * light0 + u_light1Color * light1 + u_light2Color * light2;
1046
+
1047
+ float totalScatter = gasR * u_channelScatter.x + gasG * u_channelScatter.y + gasB * u_channelScatter.z;
1048
+ float fogDensity = totalScatter + wCondensate;
1049
+ float mist = 0.006 + fogDensity * 0.2;
1050
+
1051
+ // TRACKS: lit the same way as the mist, plus a floor so a track already on screen never vanishes
1052
+ // entirely just because it drifted out of a cone (a fresh ionization trail reads brighter than
1053
+ // ambient mist in a real chamber). The floor is added to ITS OWN color, not multiplied into illum:
1054
+ // illum is exactly [0,0,0] in a fully unlit spot (every light's amount is 0 there), so a floor
1055
+ // multiplied by illum would vanish exactly where it exists to help \u2014 it needs a color of its own
1056
+ // (a neutral, self-luminous glow) rather than borrowing whichever light happens to be nearby.
1057
+ float trackDensity = max(float(track.r), max(float(track.g), float(track.b)));
1058
+ float3 trackGlow = (1.0 - exp(-trackDensity * 0.4)) * (illum + float3(u_trackLightFloor)) * 0.08;
1059
+
1060
+ float3 rgbLinear = rgbLinearBase * 0.025 + illum * mist + trackGlow;
1061
+ rgbLinear = float3(1.0) - exp(-rgbLinear * 1.0);
768
1062
  float3 srgb = vgLinearToSrgb(rgbLinear);
769
1063
  return half4(half3(srgb), half(1.0));
770
1064
  }
@@ -782,7 +1076,6 @@ uniform float u_settle;
782
1076
  uniform float u_tailDim;
783
1077
  uniform float u_intensity;
784
1078
  uniform float u_seed;
785
- uniform float3 u_channelMask;
786
1079
 
787
1080
  ${VG_VALUE_NOISE}
788
1081
 
@@ -805,20 +1098,46 @@ half4 main(float2 xy) {
805
1098
  best = max(best, v);
806
1099
  }
807
1100
 
808
- float3 dye = u_channelMask * (best * u_intensity);
1101
+ float3 dye = float3(best * u_intensity);
809
1102
  return half4(half3(dye), half(1.0));
810
1103
  }
811
1104
  `;
812
1105
 
1106
+ // src/medium/shadow-shader.ts
1107
+ var MEDIUM_SHADOW_SHADER = `
1108
+ uniform shader u_vapor;
1109
+ uniform shader u_condensate;
1110
+ uniform float u_condensateGain;
1111
+ uniform float2 u_light1Grid;
1112
+ uniform float2 u_light2Grid;
1113
+ uniform float u_extinction;
1114
+
1115
+ float vgOpticalDepth(float2 p, float2 lightP) {
1116
+ float2 toLight = lightP - p;
1117
+ float optical = 0.0;
1118
+ for (int i = 1; i <= 24; i++) {
1119
+ float2 q = p + toLight * (float(i) / 24.0);
1120
+ float inside = step(0.0, q.x) * step(q.x, u_resolution.x) * step(0.0, q.y) * step(q.y, u_resolution.y);
1121
+ half4 v = u_vapor.eval(q);
1122
+ half4 c = u_condensate.eval(q);
1123
+ optical += inside * (float(v.r + v.g + v.b) + float(c.r) * u_condensateGain);
1124
+ }
1125
+ return optical * length(toLight) / 24.0 / max(u_resolution.y, 1.0);
1126
+ }
1127
+
1128
+ half4 main(float2 xy) {
1129
+ float2 p = float2(xy.x, u_resolution.y - xy.y);
1130
+ float t1 = exp(-vgOpticalDepth(p, u_light1Grid) * u_extinction);
1131
+ float t2 = exp(-vgOpticalDepth(p, u_light2Grid) * u_extinction);
1132
+ return half4(half(t1), half(t2), half(0.0), half(1.0));
1133
+ }
1134
+ `;
1135
+
813
1136
  // src/medium/dynamics.ts
814
1137
  function hash01(n) {
815
1138
  const s = Math.sin(n * 12.9898) * 43758.5453;
816
1139
  return s - Math.floor(s);
817
1140
  }
818
- function channelFromSeed(seed) {
819
- const idx = Math.floor(hash01(seed * 1.37) * 3) % 3;
820
- return idx === 0 ? [1, 0, 0] : idx === 1 ? [0, 1, 0] : [0, 0, 1];
821
- }
822
1141
  function lerp(range, t) {
823
1142
  return range[0] + (range[1] - range[0]) * t;
824
1143
  }
@@ -832,18 +1151,19 @@ function applyTrackDepth(preset, depth) {
832
1151
  intensity: preset.intensity * intensityScale
833
1152
  };
834
1153
  }
835
- function buildEmission(presetName, source, seed, intensityScale) {
1154
+ function buildEmission(presetName, source, seed, intensityScale, angleOverride, lengthScale = 1) {
836
1155
  const preset = MEDIUM_TRACK_PRESETS[presetName];
837
1156
  const depth = hash01(seed * 4.63);
838
1157
  const depthApplied = applyTrackDepth(preset, depth);
839
1158
  return {
840
1159
  ...depthApplied,
1160
+ lengthFrac: depthApplied.lengthFrac * lengthScale,
841
1161
  intensity: depthApplied.intensity * intensityScale,
842
1162
  source,
843
- angle: hash01(seed * 7.31) * Math.PI * 2,
1163
+ angle: angleOverride ?? hash01(seed * 7.31) * Math.PI * 2,
844
1164
  seed,
845
- channel: channelFromSeed(seed),
846
- depth
1165
+ depth,
1166
+ kind: presetName
847
1167
  };
848
1168
  }
849
1169
  function createMediumDynamics() {
@@ -869,8 +1189,15 @@ function createMediumDynamics() {
869
1189
  const eligible = hash01(bar * 0.618) < MEDIUM_EMISSION_PROBABILITY && simTime - lastEmissionAt >= MEDIUM_MIN_EMISSION_INTERVAL;
870
1190
  if (eligible) {
871
1191
  lastEmissionAt = simTime;
872
- const presetName = hash01(bar * 3.14) < 0.55 ? "alpha" : "beta";
873
- emissions.push(buildEmission(presetName, sourcePoint, bar, 0.6 + 0.4 * amp));
1192
+ const rayCount = Math.round(lerp(MEDIUM_ALPHA_BURST_RAYS, hash01(bar * 9.17)));
1193
+ for (let i = 0; i < rayCount; i += 1) {
1194
+ const raySeed = bar + (i + 1) * 0.0137;
1195
+ const angle = (i + hash01(raySeed * 5.23)) / rayCount * Math.PI * 2;
1196
+ const lengthScale = lerp(MEDIUM_ALPHA_RAY_LENGTH_SCALE, hash01(raySeed * 2.91));
1197
+ emissions.push(
1198
+ buildEmission("alpha", sourcePoint, raySeed, 0.6 + 0.4 * amp, angle, lengthScale)
1199
+ );
1200
+ }
874
1201
  }
875
1202
  }
876
1203
  } else if (state === "idle") {
@@ -882,7 +1209,8 @@ function createMediumDynamics() {
882
1209
  hash01(idx * 2.1 + 0.5),
883
1210
  MEDIUM_SENSITIVE_TOP + hash01(idx * 5.9 + 1.7) * (MEDIUM_SENSITIVE_BOTTOM - MEDIUM_SENSITIVE_TOP)
884
1211
  ];
885
- emissions.push(buildEmission("natural", randomSource, idx, 1));
1212
+ const presetName = hash01(idx * 3.14) < 0.5 ? "electron" : "muon";
1213
+ emissions.push(buildEmission(presetName, randomSource, idx, 1));
886
1214
  nextNaturalAt = idleClock + MEDIUM_NATURAL_INTERVAL + hash01(idx * 1.7) * MEDIUM_NATURAL_JITTER;
887
1215
  }
888
1216
  }
@@ -891,21 +1219,73 @@ function createMediumDynamics() {
891
1219
  return { step };
892
1220
  }
893
1221
 
1222
+ // src/medium/light-rig.ts
1223
+ var clamp012 = (v) => Math.min(1, Math.max(0, v));
1224
+ var EDGE_STRIP_CONE_ANGLE = Math.PI - 1e-6;
1225
+ var EDGE_STRIP_TINT_MIX = 0.22;
1226
+ var GEL_LIGHTNESS = 0.7;
1227
+ var LIGHT_HUE_SPREAD = MEDIUM_SPECIES_HUE_SPREAD / 2;
1228
+ function retint(base, hueDeg, chroma, mix2) {
1229
+ const [baseL, a, b] = srgbToOklab(base);
1230
+ const l = baseL + (Math.min(baseL, GEL_LIGHTNESS) - baseL) * mix2;
1231
+ const rad = hueDeg * Math.PI / 180;
1232
+ const targetA = chroma * Math.cos(rad);
1233
+ const targetB = chroma * Math.sin(rad);
1234
+ const mixedA = a + (targetA - a) * mix2;
1235
+ const mixedB = b + (targetB - b) * mix2;
1236
+ let lo = 0;
1237
+ let hi = 1;
1238
+ if (inGamut(oklabToSrgb([l, mixedA, mixedB]))) lo = 1;
1239
+ else {
1240
+ for (let i = 0; i < 12; i += 1) {
1241
+ const mid = (lo + hi) / 2;
1242
+ if (inGamut(oklabToSrgb([l, mixedA * mid, mixedB * mid]))) lo = mid;
1243
+ else hi = mid;
1244
+ }
1245
+ }
1246
+ const rgb = oklabToSrgb([l, mixedA * lo, mixedB * lo]).map(clamp012);
1247
+ const gain = Math.max(...base) / Math.max(...rgb, 1e-6);
1248
+ return rgb.map((v) => clamp012(v * gain));
1249
+ }
1250
+ var inGamut = (rgb) => rgb.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
1251
+ function lightRigForCharacter(character, baseLights = MEDIUM_DEFAULT_LIGHTS) {
1252
+ const spotlightIndices = [];
1253
+ baseLights.forEach((light, i) => {
1254
+ if (light.coneAngle < EDGE_STRIP_CONE_ANGLE) spotlightIndices.push(i);
1255
+ });
1256
+ const spotlightCount = spotlightIndices.length;
1257
+ return baseLights.map((light, i) => {
1258
+ if (light.coneAngle >= EDGE_STRIP_CONE_ANGLE) {
1259
+ return { ...light, color: retint(light.color, character.hue, character.chroma, EDGE_STRIP_TINT_MIX) };
1260
+ }
1261
+ const slot = spotlightIndices.indexOf(i);
1262
+ const hueOffset = spotlightCount > 1 ? LIGHT_HUE_SPREAD * (2 * slot / (spotlightCount - 1) - 1) : 0;
1263
+ return { ...light, color: retint(light.color, character.hue + hueOffset, character.chroma, 1) };
1264
+ });
1265
+ }
1266
+ function lightRigForCover(cover, baseLights = MEDIUM_DEFAULT_LIGHTS) {
1267
+ return lightRigForCharacter(characterOf(cover), baseLights);
1268
+ }
1269
+
894
1270
  exports.ATOM_AT_REST = ATOM_AT_REST;
895
1271
  exports.DEFAULT_GROWTH_TIMING = DEFAULT_GROWTH_TIMING;
896
1272
  exports.LINEAR_TO_LMS = LINEAR_TO_LMS;
897
1273
  exports.LMS_TO_LINEAR = LMS_TO_LINEAR;
898
1274
  exports.LMS_TO_OKLAB = LMS_TO_OKLAB;
1275
+ exports.MEDIUM_ALPHA_BURST_RAYS = MEDIUM_ALPHA_BURST_RAYS;
1276
+ exports.MEDIUM_ALPHA_RAY_LENGTH_SCALE = MEDIUM_ALPHA_RAY_LENGTH_SCALE;
899
1277
  exports.MEDIUM_COMPOSITE_SHADER = MEDIUM_COMPOSITE_SHADER;
900
1278
  exports.MEDIUM_CONDENSATE_CORRECT_SHADER = MEDIUM_CONDENSATE_CORRECT_SHADER;
901
1279
  exports.MEDIUM_CONDENSATE_FORWARD_SHADER = MEDIUM_CONDENSATE_FORWARD_SHADER;
902
1280
  exports.MEDIUM_CONDENSATE_REACT_SHADER = MEDIUM_CONDENSATE_REACT_SHADER;
903
1281
  exports.MEDIUM_DEFAULTS = MEDIUM_DEFAULTS;
904
1282
  exports.MEDIUM_DEFAULT_CELL_PX = MEDIUM_DEFAULT_CELL_PX;
1283
+ exports.MEDIUM_DEFAULT_LIGHTS = MEDIUM_DEFAULT_LIGHTS;
905
1284
  exports.MEDIUM_DRIFT_TURBULENCE = MEDIUM_DRIFT_TURBULENCE;
906
1285
  exports.MEDIUM_EMISSION_BEATS = MEDIUM_EMISSION_BEATS;
907
1286
  exports.MEDIUM_EMISSION_PROBABILITY = MEDIUM_EMISSION_PROBABILITY;
908
1287
  exports.MEDIUM_EMIT_SHADER = MEDIUM_EMIT_SHADER;
1288
+ exports.MEDIUM_MAX_LIGHTS = MEDIUM_MAX_LIGHTS;
909
1289
  exports.MEDIUM_MIN_EMISSION_INTERVAL = MEDIUM_MIN_EMISSION_INTERVAL;
910
1290
  exports.MEDIUM_NATURAL_INTERVAL = MEDIUM_NATURAL_INTERVAL;
911
1291
  exports.MEDIUM_NATURAL_JITTER = MEDIUM_NATURAL_JITTER;
@@ -915,12 +1295,21 @@ exports.MEDIUM_RESAMPLE_SHADER = MEDIUM_RESAMPLE_SHADER;
915
1295
  exports.MEDIUM_SEED_SHADER = MEDIUM_SEED_SHADER;
916
1296
  exports.MEDIUM_SENSITIVE_BOTTOM = MEDIUM_SENSITIVE_BOTTOM;
917
1297
  exports.MEDIUM_SENSITIVE_TOP = MEDIUM_SENSITIVE_TOP;
1298
+ exports.MEDIUM_SHADOW_SHADER = MEDIUM_SHADOW_SHADER;
918
1299
  exports.MEDIUM_SPECIES_HUE_SPREAD = MEDIUM_SPECIES_HUE_SPREAD;
919
1300
  exports.MEDIUM_SPECIES_LIGHTNESS = MEDIUM_SPECIES_LIGHTNESS;
920
1301
  exports.MEDIUM_TIME_PERIOD = MEDIUM_TIME_PERIOD;
921
1302
  exports.MEDIUM_TRACK_ADVECT_SHADER = MEDIUM_TRACK_ADVECT_SHADER;
922
1303
  exports.MEDIUM_TRACK_DEPTH_INTENSITY_RANGE = MEDIUM_TRACK_DEPTH_INTENSITY_RANGE;
923
1304
  exports.MEDIUM_TRACK_DEPTH_WIDTH_RANGE = MEDIUM_TRACK_DEPTH_WIDTH_RANGE;
1305
+ exports.MEDIUM_TRACK_LAYER_BURST_ACCENT = MEDIUM_TRACK_LAYER_BURST_ACCENT;
1306
+ exports.MEDIUM_TRACK_LAYER_BURST_SPREAD = MEDIUM_TRACK_LAYER_BURST_SPREAD;
1307
+ exports.MEDIUM_TRACK_LAYER_CURL_PUSH = MEDIUM_TRACK_LAYER_CURL_PUSH;
1308
+ exports.MEDIUM_TRACK_LAYER_FADE_GAMMA = MEDIUM_TRACK_LAYER_FADE_GAMMA;
1309
+ exports.MEDIUM_TRACK_LAYER_GRAVITY_SAG = MEDIUM_TRACK_LAYER_GRAVITY_SAG;
1310
+ exports.MEDIUM_TRACK_LAYER_LIFE_SECONDS = MEDIUM_TRACK_LAYER_LIFE_SECONDS;
1311
+ exports.MEDIUM_TRACK_LAYER_LOOK = MEDIUM_TRACK_LAYER_LOOK;
1312
+ exports.MEDIUM_TRACK_LAYER_MAX_TRACKS = MEDIUM_TRACK_LAYER_MAX_TRACKS;
924
1313
  exports.MEDIUM_TRACK_PRESETS = MEDIUM_TRACK_PRESETS;
925
1314
  exports.MEDIUM_TURBULENCE_TAU = MEDIUM_TURBULENCE_TAU;
926
1315
  exports.MEDIUM_VAPOR_CORRECT_SHADER = MEDIUM_VAPOR_CORRECT_SHADER;
@@ -941,6 +1330,8 @@ exports.depthFarOffsetPx = depthFarOffsetPx;
941
1330
  exports.growthFrame = growthFrame;
942
1331
  exports.growthInk = growthInk;
943
1332
  exports.growthPhase = growthPhase;
1333
+ exports.lightRigForCharacter = lightRigForCharacter;
1334
+ exports.lightRigForCover = lightRigForCover;
944
1335
  exports.neutralSpecies = neutralSpecies;
945
1336
  exports.oklabToSrgb = oklabToSrgb;
946
1337
  exports.smoothstep = smoothstep;