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.
@@ -87,9 +87,46 @@ function neutralSpecies(baseColor) {
87
87
  }
88
88
 
89
89
  // src/medium/params.ts
90
+ var MEDIUM_MAX_LIGHTS = 3;
90
91
  var BASE_COLOR = [0.07, 0.08, 0.1];
92
+ var normalize2 = (v) => {
93
+ const len = Math.hypot(v[0], v[1]) || 1;
94
+ return [v[0] / len, v[1] / len];
95
+ };
96
+ var MEDIUM_DEFAULT_LIGHT_COLOR = [0.78, 0.85, 0.94];
97
+ var MEDIUM_DEFAULT_LIGHTS = [
98
+ {
99
+ position: [0.5, 0],
100
+ direction: [0, 1],
101
+ coneAngle: Math.PI,
102
+ falloff: 16,
103
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
104
+ intensity: 0.28
105
+ },
106
+ {
107
+ position: [0.12, 1.45],
108
+ direction: normalize2([0.38, -1.03]),
109
+ coneAngle: 9 * Math.PI / 180,
110
+ falloff: 0.7,
111
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
112
+ intensity: 1.1
113
+ },
114
+ {
115
+ position: [0.9, 1.4],
116
+ direction: normalize2([-0.4, -0.98]),
117
+ coneAngle: 8 * Math.PI / 180,
118
+ falloff: 0.75,
119
+ color: MEDIUM_DEFAULT_LIGHT_COLOR,
120
+ intensity: 0.9
121
+ }
122
+ ];
91
123
  var MEDIUM_DEFAULTS = {
92
- decay: 0.06,
124
+ // A stamped track has to visibly broaden and fade within the reference's "roughly one to two
125
+ // seconds", not the ~11s half-life a 0.06 decay gives — measured against check-medium.mjs's track
126
+ // gate (birth vs +1.5s cross-section width and total density): 1.1 leaves ~30% of the peak at 1s
127
+ // and ~17% at 1.5s, comfortably faded without dying before MacCormack's own diffusion has time to
128
+ // visibly broaden the stamp.
129
+ decay: 1.1,
93
130
  curlFreq: 0.045,
94
131
  curlSpeed: 0.12,
95
132
  advectSpeed: 26,
@@ -98,11 +135,13 @@ var MEDIUM_DEFAULTS = {
98
135
  condensationFloor: 0.12,
99
136
  evaporationRate: 0.2,
100
137
  condensateSettleSpeed: 8,
138
+ condensateSettleWiggle: 0.4,
101
139
  condensateSpreadRate: 0.08,
102
140
  condensateTint: 0.2,
103
141
  condensateGain: 5,
104
142
  condensateColor: [0.94, 0.95, 0.97],
105
143
  channelColors: neutralSpecies(BASE_COLOR),
144
+ channelScatter: [1, 1, 1],
106
145
  baseColor: BASE_COLOR,
107
146
  baseWeight: 0.9,
108
147
  depthFarScale: 1.6,
@@ -111,7 +150,11 @@ var MEDIUM_DEFAULTS = {
111
150
  depthFarWeight: 0.5,
112
151
  gravityVaporDrift: 0.6,
113
152
  gravityBottomBoost: 2,
114
- gravityBottomBoostStart: 0.75
153
+ gravityBottomBoostStart: 0.75,
154
+ lights: MEDIUM_DEFAULT_LIGHTS,
155
+ shadowExtinction: 3,
156
+ trackLightFloor: 0.8,
157
+ trackLayerAmount: 1
115
158
  };
116
159
  function depthFarOffsetPx(offsetFrac, gridWidth, gridHeight) {
117
160
  return [offsetFrac[0] * gridWidth, offsetFrac[1] * gridHeight];
@@ -133,7 +176,7 @@ var MEDIUM_TRACK_PRESETS = {
133
176
  tailDim: 0.55,
134
177
  intensity: 1.15
135
178
  },
136
- beta: {
179
+ electron: {
137
180
  lengthFrac: 0.42,
138
181
  headWidthFrac: 0.018,
139
182
  tailWidthFrac: 0.05,
@@ -141,17 +184,17 @@ var MEDIUM_TRACK_PRESETS = {
141
184
  raggedFreq: 5,
142
185
  settleFrac: 0.05,
143
186
  tailDim: 0.45,
144
- intensity: 0.85
187
+ intensity: 0.6
145
188
  },
146
- natural: {
147
- lengthFrac: 0.13,
148
- headWidthFrac: 0.02,
149
- tailWidthFrac: 0.04,
150
- raggedFrac: 0.02,
151
- raggedFreq: 4,
152
- settleFrac: 0.02,
153
- tailDim: 0.5,
154
- intensity: 0.4
189
+ muon: {
190
+ lengthFrac: 0.7,
191
+ headWidthFrac: 0.012,
192
+ tailWidthFrac: 0.02,
193
+ raggedFrac: 0,
194
+ raggedFreq: 0,
195
+ settleFrac: 0.01,
196
+ tailDim: 0.75,
197
+ intensity: 0.5
155
198
  }
156
199
  };
157
200
  var MEDIUM_DRIFT_TURBULENCE = 0.045;
@@ -160,12 +203,60 @@ var MEDIUM_TURBULENCE_TAU = 2.5;
160
203
  var MEDIUM_EMISSION_BEATS = 4;
161
204
  var MEDIUM_EMISSION_PROBABILITY = 0.55;
162
205
  var MEDIUM_MIN_EMISSION_INTERVAL = 1.2;
163
- var MEDIUM_NATURAL_INTERVAL = 25;
164
- var MEDIUM_NATURAL_JITTER = 35;
206
+ var MEDIUM_NATURAL_INTERVAL = 8;
207
+ var MEDIUM_NATURAL_JITTER = 12;
165
208
  var MEDIUM_SENSITIVE_TOP = 0.55;
166
209
  var MEDIUM_SENSITIVE_BOTTOM = 0.95;
167
- var MEDIUM_TRACK_DEPTH_WIDTH_RANGE = [0.55, 1];
210
+ var MEDIUM_ALPHA_BURST_RAYS = [8, 16];
211
+ var MEDIUM_ALPHA_RAY_LENGTH_SCALE = [0.55, 1];
212
+ var MEDIUM_TRACK_DEPTH_WIDTH_RANGE = [0.55, 1.2];
168
213
  var MEDIUM_TRACK_DEPTH_INTENSITY_RANGE = [0.4, 1];
214
+ var MEDIUM_TRACK_LAYER_LIFE_SECONDS = {
215
+ alpha: 1.8,
216
+ electron: 1.4,
217
+ muon: 2.2
218
+ };
219
+ var MEDIUM_TRACK_LAYER_MAX_TRACKS = 64;
220
+ var MEDIUM_TRACK_LAYER_LOOK = {
221
+ alpha: {
222
+ sigmaFrac: 22e-4,
223
+ endSigmaMul: 1.9,
224
+ broaden: 3.2,
225
+ wiggleFrac: 0,
226
+ beadFrac: 6e-3,
227
+ dropletFrac: 35e-4,
228
+ dropletChance: 0.5,
229
+ startFrac: 0.035,
230
+ gain: 1.8
231
+ },
232
+ electron: {
233
+ sigmaFrac: 9e-4,
234
+ endSigmaMul: 1.3,
235
+ broaden: 6,
236
+ wiggleFrac: 0.012,
237
+ beadFrac: 8e-3,
238
+ dropletFrac: 6e-3,
239
+ dropletChance: 0.55,
240
+ startFrac: 0,
241
+ gain: 2.2
242
+ },
243
+ muon: {
244
+ sigmaFrac: 8e-4,
245
+ endSigmaMul: 1,
246
+ broaden: 5,
247
+ wiggleFrac: 0,
248
+ beadFrac: 7e-3,
249
+ dropletFrac: 5e-3,
250
+ dropletChance: 0.5,
251
+ startFrac: 0,
252
+ gain: 2.2
253
+ }
254
+ };
255
+ var MEDIUM_TRACK_LAYER_BURST_SPREAD = 1.6;
256
+ var MEDIUM_TRACK_LAYER_BURST_ACCENT = 3;
257
+ var MEDIUM_TRACK_LAYER_FADE_GAMMA = 1.6;
258
+ var MEDIUM_TRACK_LAYER_GRAVITY_SAG = 0.018;
259
+ var MEDIUM_TRACK_LAYER_CURL_PUSH = 0.05;
169
260
 
170
261
  // src/medium/noise.ts
171
262
  var VG_VALUE_NOISE = `
@@ -297,7 +388,135 @@ float2 vgCurlVelocity(float2 p, float phase) {
297
388
  float py0 = vgPotential(p - float2(0.0, e), phase);
298
389
  return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
299
390
  }
391
+
392
+ // vgPotential above is periodic in TIME only: the simulation domain is a torus (gl.REPEAT), but
393
+ // the lattice itself never wraps in x/y, so velocity jumps at the domain seam and dye on either
394
+ // side of it is transported differently \u2014 a straight seam, made worse wherever something re-reads
395
+ // the field at a different scale (the far depth plane's tile boundary). vgValueNoise3Seamless
396
+ // wraps ALL THREE lattice axes: xyPeriod/zPeriod fold BOTH neighbors of a cell into their own
397
+ // period before hashing, so the field is EXACTLY the same one period over, in space exactly as it
398
+ // already was in time.
399
+ //
400
+ // The period has to be an integer number of lattice cells, or the wrap would jump instead of
401
+ // closing seamlessly (the same requirement MEDIUM_TIME_OCTAVES/mediumOctavePeriod enforces for
402
+ // time) \u2014 but unlike time, the spatial period depends on the GRID SIZE, which this shader has no
403
+ // notion of. So xyPeriod travels together with a per-octave spatial frequency the CPU computes to
404
+ // make gridSize * frequency land on that exact integer (see computeMediumSpatialPeriods below);
405
+ // both arrive together as one uniform per octave (u_spatialN.xy = frequency, .zw = period),
406
+ // recomputed by web/medium.ts whenever the grid resizes.
407
+ float vgValueNoise3Seamless(float3 p, float2 xyPeriod, float zPeriod) {
408
+ float3 i = floor(p);
409
+ float3 f = fract(p);
410
+ float3 u = f * f * (3.0 - 2.0 * f);
411
+ float x0 = mod(i.x, xyPeriod.x);
412
+ float x1 = mod(i.x + 1.0, xyPeriod.x);
413
+ float y0 = mod(i.y, xyPeriod.y);
414
+ float y1 = mod(i.y + 1.0, xyPeriod.y);
415
+ float z0 = mod(i.z, zPeriod);
416
+ float z1 = mod(i.z + 1.0, zPeriod);
417
+ float n000 = vgHash31(float3(x0, y0, z0));
418
+ float n100 = vgHash31(float3(x1, y0, z0));
419
+ float n010 = vgHash31(float3(x0, y1, z0));
420
+ float n110 = vgHash31(float3(x1, y1, z0));
421
+ float n001 = vgHash31(float3(x0, y0, z1));
422
+ float n101 = vgHash31(float3(x1, y0, z1));
423
+ float n011 = vgHash31(float3(x0, y1, z1));
424
+ float n111 = vgHash31(float3(x1, y1, z1));
425
+ float nx00 = mix(n000, n100, u.x);
426
+ float nx10 = mix(n010, n110, u.x);
427
+ float nx01 = mix(n001, n101, u.x);
428
+ float nx11 = mix(n011, n111, u.x);
429
+ float nxy0 = mix(nx00, nx10, u.y);
430
+ float nxy1 = mix(nx01, nx11, u.y);
431
+ return mix(nxy0, nxy1, u.z) * 2.0 - 1.0;
432
+ }
433
+
434
+ // Same four-octave fbm as vgPotential, but every octave reads the seamless lattice above at its
435
+ // OWN (frequency, period) pair \u2014 sN.xy replaces that octave's compile-time \`scale\` constant (it
436
+ // has to be a uniform now: the adjustment depends on the live grid size), sN.zw is the matching
437
+ // integer period. \`p\` is still curlFreq-scaled exactly as vgPotential's caller scales it \u2014 only
438
+ // the per-octave multiplier changed from a constant to a grid-dependent uniform, so the finite
439
+ // difference in vgCurlVelocitySeamless below needs no epsilon change of its own.
440
+ float vgPotentialSeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
441
+ float v = vgValueNoise3Seamless(float3(p * s0.xy, phase * ${VG_OCT0.rate} + ${VG_OCT0.offset}), s0.zw, ${VG_OCT0.period}) * 0.5;
442
+ v += vgValueNoise3Seamless(float3(p * s1.xy, phase * ${VG_OCT1.rate} + ${VG_OCT1.offset}), s1.zw, ${VG_OCT1.period}) * 0.25;
443
+ v += vgValueNoise3Seamless(float3(p * s2.xy, phase * ${VG_OCT2.rate} + ${VG_OCT2.offset}), s2.zw, ${VG_OCT2.period}) * 0.125;
444
+ float macro = vgValueNoise3Seamless(float3(p * s3.xy, phase * ${VG_OCT3.rate} + ${VG_OCT3.offset}), s3.zw, ${VG_OCT3.period});
445
+ return v * (0.7 + 0.3 * macro);
446
+ }
447
+
448
+ float2 vgCurlVelocitySeamless(float2 p, float phase, float4 s0, float4 s1, float4 s2, float4 s3) {
449
+ float e = 0.6;
450
+ float px1 = vgPotentialSeamless(p + float2(e, 0.0), phase, s0, s1, s2, s3);
451
+ float px0 = vgPotentialSeamless(p - float2(e, 0.0), phase, s0, s1, s2, s3);
452
+ float py1 = vgPotentialSeamless(p + float2(0.0, e), phase, s0, s1, s2, s3);
453
+ float py0 = vgPotentialSeamless(p - float2(0.0, e), phase, s0, s1, s2, s3);
454
+ return float2(py1 - py0, -(px1 - px0)) / (2.0 * e);
455
+ }
300
456
  `;
457
+ function hash31JS(x, y, z) {
458
+ const h = x * 127.1 + y * 311.7 + z * 74.7;
459
+ const s = Math.sin(h) * 43758.5453123;
460
+ return s - Math.floor(s);
461
+ }
462
+ var smoothstep3JS = (f) => f * f * (3 - 2 * f);
463
+ var glslMod = (x, y) => x - y * Math.floor(x / y);
464
+ function vgValueNoise3PeriodicJS(px, py, pz, period) {
465
+ const ix = Math.floor(px);
466
+ const iy = Math.floor(py);
467
+ const iz = Math.floor(pz);
468
+ const ux = smoothstep3JS(px - ix);
469
+ const uy = smoothstep3JS(py - iy);
470
+ const uz = smoothstep3JS(pz - iz);
471
+ const z0 = glslMod(iz, period);
472
+ const z1 = glslMod(iz + 1, period);
473
+ const n000 = hash31JS(ix, iy, z0);
474
+ const n100 = hash31JS(ix + 1, iy, z0);
475
+ const n010 = hash31JS(ix, iy + 1, z0);
476
+ const n110 = hash31JS(ix + 1, iy + 1, z0);
477
+ const n001 = hash31JS(ix, iy, z1);
478
+ const n101 = hash31JS(ix + 1, iy, z1);
479
+ const n011 = hash31JS(ix, iy + 1, z1);
480
+ const n111 = hash31JS(ix + 1, iy + 1, z1);
481
+ const nx00 = n000 + (n100 - n000) * ux;
482
+ const nx10 = n010 + (n110 - n010) * ux;
483
+ const nx01 = n001 + (n101 - n001) * ux;
484
+ const nx11 = n011 + (n111 - n011) * ux;
485
+ const nxy0 = nx00 + (nx10 - nx00) * uy;
486
+ const nxy1 = nx01 + (nx11 - nx01) * uy;
487
+ return (nxy0 + (nxy1 - nxy0) * uz) * 2 - 1;
488
+ }
489
+ function vgPotentialJS(px, py, phase) {
490
+ const [o0, o1, o2, o3] = MEDIUM_TIME_OCTAVES;
491
+ let v = vgValueNoise3PeriodicJS(px * o0.scale, py * o0.scale, phase * o0.rate + o0.offset, mediumOctavePeriod(o0.rate)) * 0.5;
492
+ v += vgValueNoise3PeriodicJS(px * o1.scale, py * o1.scale, phase * o1.rate + o1.offset, mediumOctavePeriod(o1.rate)) * 0.25;
493
+ v += vgValueNoise3PeriodicJS(px * o2.scale, py * o2.scale, phase * o2.rate + o2.offset, mediumOctavePeriod(o2.rate)) * 0.125;
494
+ const macro = vgValueNoise3PeriodicJS(
495
+ px * o3.scale,
496
+ py * o3.scale,
497
+ phase * o3.rate + o3.offset,
498
+ mediumOctavePeriod(o3.rate)
499
+ );
500
+ return v * (0.7 + 0.3 * macro);
501
+ }
502
+ var MIN_SPATIAL_PERIOD = 2;
503
+ function computeMediumSpatialPeriods(gridWidth, gridHeight, curlFreq) {
504
+ const freq = Math.max(curlFreq, 1e-6);
505
+ return MEDIUM_TIME_OCTAVES.map((o) => {
506
+ const rawX = gridWidth * freq * o.scale;
507
+ const rawY = gridHeight * freq * o.scale;
508
+ const minRaw = Math.min(rawX, rawY);
509
+ const scaleUp = minRaw > 0 && minRaw < MIN_SPATIAL_PERIOD ? MIN_SPATIAL_PERIOD / minRaw : 1;
510
+ const periodX = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawX * scaleUp));
511
+ const periodY = Math.max(MIN_SPATIAL_PERIOD, Math.round(rawY * scaleUp));
512
+ return {
513
+ freqX: periodX / (gridWidth * freq),
514
+ freqY: periodY / (gridHeight * freq),
515
+ periodX,
516
+ periodY
517
+ };
518
+ });
519
+ }
301
520
 
302
521
  // src/medium/seed-shader.ts
303
522
  var MEDIUM_SEED_SHADER = `
@@ -340,15 +559,25 @@ uniform float u_phase;
340
559
  uniform float u_curlFreq;
341
560
  uniform float u_advectSpeed;
342
561
  uniform float u_turbulence;
562
+ // One float4 per MEDIUM_TIME_OCTAVES entry: xy is that octave's seamless spatial frequency, zw its
563
+ // matching integer lattice period \u2014 both grid-size-dependent, so they can't be compile-time
564
+ // constants (see vgPotentialSeamless/computeMediumSpatialPeriods in noise.ts). Recomputed by
565
+ // web/medium.ts's bindVelocityUniforms every frame from the live grid dimensions.
566
+ uniform float4 u_spatial0;
567
+ uniform float4 u_spatial1;
568
+ uniform float4 u_spatial2;
569
+ uniform float4 u_spatial3;
343
570
 
344
571
  ${VG_CURL_NOISE}
345
572
  `;
346
573
  var MEDIUM_ADVECT_VELOCITY = `
347
- float2 vel = vgCurlVelocity(xy * u_curlFreq, u_phase) * u_advectSpeed * u_turbulence;
574
+ float2 curl = vgCurlVelocitySeamless(xy * u_curlFreq, u_phase, u_spatial0, u_spatial1, u_spatial2, u_spatial3);
575
+ float2 vel = curl * u_advectSpeed * u_turbulence;
348
576
  `;
349
577
  var MEDIUM_ADVECT_VELOCITY_SETTLE = `
350
578
  ${MEDIUM_ADVECT_VELOCITY}
351
579
  vel.y += u_settleSpeed;
580
+ vel.x += curl.x * u_advectSpeed * u_settleWiggle;
352
581
  `;
353
582
  var MEDIUM_ADVECT_VELOCITY_VAPOR = `
354
583
  ${MEDIUM_ADVECT_VELOCITY}
@@ -367,6 +596,7 @@ ${MEDIUM_ADVECT_VELOCITY_VAPOR}
367
596
  var MEDIUM_CONDENSATE_FORWARD_SHADER = `
368
597
  ${MEDIUM_ADVECT_UNIFORMS}
369
598
  uniform float u_settleSpeed;
599
+ uniform float u_settleWiggle;
370
600
 
371
601
  half4 main(float2 xy) {
372
602
  ${MEDIUM_ADVECT_VELOCITY_SETTLE}
@@ -406,6 +636,7 @@ ${MEDIUM_MACCORMACK_CORRECT}
406
636
  var MEDIUM_CONDENSATE_CORRECT_SHADER = `
407
637
  ${MEDIUM_ADVECT_UNIFORMS}
408
638
  uniform float u_settleSpeed;
639
+ uniform float u_settleWiggle;
409
640
  uniform shader u_forward;
410
641
 
411
642
  half4 main(float2 xy) {
@@ -505,10 +736,68 @@ ${MEDIUM_ADVECT_VELOCITY}
505
736
  `;
506
737
 
507
738
  // src/medium/composite-shader.ts
739
+ var VG_CUBIC_WEIGHTS = `
740
+ float4 vgCubicWeights(float v) {
741
+ float4 n = float4(1.0, 2.0, 3.0, 4.0) - v;
742
+ float4 s = n * n * n;
743
+ float x = s.x;
744
+ float y = s.y - 4.0 * s.x;
745
+ float z = s.z - 4.0 * s.y + 6.0 * s.x;
746
+ float w = 6.0 - x - y - z;
747
+ return float4(x, y, z, w) * (1.0 / 6.0);
748
+ }
749
+ `;
750
+ function vgBicubicSampler(fnName, textureName) {
751
+ return `
752
+ half4 ${fnName}(float2 px) {
753
+ float2 shifted = px - 0.5;
754
+ float2 fxy = fract(shifted);
755
+ float2 base = shifted - fxy;
756
+ float4 xw = vgCubicWeights(fxy.x);
757
+ float4 yw = vgCubicWeights(fxy.y);
758
+ float2 sx = float2(xw.x + xw.y, xw.z + xw.w);
759
+ float2 sy = float2(yw.x + yw.y, yw.z + yw.w);
760
+ float2 offsetX = float2(base.x - 0.5 + xw.y / sx.x, base.x + 1.5 + xw.w / sx.y);
761
+ float2 offsetY = float2(base.y - 0.5 + yw.y / sy.x, base.y + 1.5 + yw.w / sy.y);
762
+ half4 s00 = ${textureName}.eval(float2(offsetX.x, offsetY.x));
763
+ half4 s10 = ${textureName}.eval(float2(offsetX.y, offsetY.x));
764
+ half4 s01 = ${textureName}.eval(float2(offsetX.x, offsetY.y));
765
+ half4 s11 = ${textureName}.eval(float2(offsetX.y, offsetY.y));
766
+ float wx = sx.x / (sx.x + sx.y);
767
+ float wy = sy.x / (sy.x + sy.y);
768
+ return mix(mix(s11, s01, wx), mix(s10, s00, wx), wy);
769
+ }
770
+ `;
771
+ }
772
+ var VG_LIGHT = `
773
+ float vgLightAmount(float2 xy, float2 lightPosFrac, float2 lightDir, float cosCone, float falloff, float2 resolution, float seed) {
774
+ float2 rel = xy - lightPosFrac * resolution;
775
+ // A cone of 180\xB0 or more is an edge strip: light spreads from a line, not a point.
776
+ if (cosCone <= -0.999) {
777
+ float depth = max(dot(rel, lightDir), 0.0);
778
+ return exp(-(depth / max(resolution.y, 1.0)) * falloff);
779
+ }
780
+ float dist = length(rel);
781
+ float2 dirN = dist > 1e-4 ? rel / dist : lightDir;
782
+ float cosAngle = dot(dirN, lightDir);
783
+ // A soft lobe with half intensity at the cone angle and no boundary at all: a lamp in haze has
784
+ // no drawn edge.
785
+ float lobe = log(0.5) / log(clamp(cosCone, 0.05, 0.999));
786
+ float edge = pow(max(cosAngle, 0.0), lobe);
787
+ float atten = exp(-(dist / max(resolution.y, 1.0)) * falloff);
788
+ if (edge * atten < 0.001) return 0.0;
789
+ // Static shafts inside the beam: brightness varies with the angle around the lamp, so streaks
790
+ // fan out from the source the way light through dusty air does.
791
+ float angle = atan(dirN.x * lightDir.y - dirN.y * lightDir.x, cosAngle);
792
+ float shafts = 0.6 + 0.32 * vgValueNoise(float2(angle * 14.0, seed)) + 0.18 * vgValueNoise(float2(angle * 33.0, seed + 5.3));
793
+ return edge * atten * clamp(shafts, 0.0, 1.0);
794
+ }
795
+ `;
508
796
  var MEDIUM_COMPOSITE_SHADER = `
509
797
  uniform shader u_vapor;
510
798
  uniform shader u_condensate;
511
799
  uniform shader u_track;
800
+ uniform shader u_shadow;
512
801
  uniform float2 u_dyeScale;
513
802
  uniform float3 u_lab0;
514
803
  uniform float3 u_lab1;
@@ -524,27 +813,68 @@ uniform float u_farBlurRadius;
524
813
  uniform float u_farWeight;
525
814
  uniform float u_gravityBoost;
526
815
  uniform float u_gravityBoostStart;
816
+ uniform float3 u_channelScatter;
817
+ uniform float2 u_light0Pos;
818
+ uniform float2 u_light0Dir;
819
+ uniform float u_light0CosCone;
820
+ uniform float u_light0Falloff;
821
+ uniform float3 u_light0Color;
822
+ uniform float u_light0Intensity;
823
+ uniform float2 u_light1Pos;
824
+ uniform float2 u_light1Dir;
825
+ uniform float u_light1CosCone;
826
+ uniform float u_light1Falloff;
827
+ uniform float3 u_light1Color;
828
+ uniform float u_light1Intensity;
829
+ uniform float2 u_light2Pos;
830
+ uniform float2 u_light2Dir;
831
+ uniform float u_light2CosCone;
832
+ uniform float u_light2Falloff;
833
+ uniform float3 u_light2Color;
834
+ uniform float u_light2Intensity;
835
+ uniform float u_trackLightFloor;
527
836
 
528
837
  ${VG_OKLAB_TO_SRGB}
838
+ ${VG_CUBIC_WEIGHTS}
839
+ ${vgBicubicSampler("vgBicubicVapor", "u_vapor")}
840
+ ${vgBicubicSampler("vgBicubicCondensate", "u_condensate")}
841
+ ${VG_VALUE_NOISE}
842
+ ${VG_LIGHT}
529
843
 
530
844
  half4 main(float2 xy) {
531
845
  float2 baseSrc = xy * u_dyeScale;
532
846
 
533
- // Near plane \u2014 sampled exactly as before the depth work; the calibrated look lives here.
534
- half4 vapor = u_vapor.eval(baseSrc);
535
- half4 condensate = u_condensate.eval(baseSrc);
847
+ // Near plane \u2014 the calibrated look lives here. Bicubic (see the file header) on vapor and
848
+ // condensate; track stays a plain bilinear read.
849
+ half4 vapor = vgBicubicVapor(baseSrc);
850
+ half4 condensate = vgBicubicCondensate(baseSrc);
536
851
  half4 track = u_track.eval(baseSrc);
537
852
 
538
- // Far plane \u2014 the identical field, scaled and offset (see the file header). A 4-tap box blur
539
- // (no separate center sample, the cheapest isotropic blur available) supplies aerial perspective's
540
- // blur and, as a side effect of averaging, its lower contrast too.
853
+ // Far plane \u2014 the identical field, scaled and offset (see the file header). An 8-tap ring, not
854
+ // the near plane's \xB1x/\xB1y box: axis-aligned taps read the SAME column/row on both sides of any
855
+ // residual axis-aligned structure (a seam, a resize artifact), which is exactly how a straight
856
+ // line gets doubled into four parallel copies instead of softened away \u2014 measured directly via
857
+ // check-medium.mjs's isotropy gate (see its report). The ring is rotated a half-step (22.5\xB0) off
858
+ // the axes so no tap direction ever coincides with a grid row or column. Radius stays in the same
859
+ // far-plane texture-space unit as before (u_farBlurRadius, grid-px) \u2014 only the tap PATTERN
860
+ // changed, not what it measures in.
541
861
  float2 farSrc = baseSrc * u_farScale + u_farOffset;
542
- float2 blurX = float2(u_farBlurRadius, 0.0);
543
- float2 blurY = float2(0.0, u_farBlurRadius);
544
- half4 vaporFar = (u_vapor.eval(farSrc + blurX) + u_vapor.eval(farSrc - blurX) +
545
- u_vapor.eval(farSrc + blurY) + u_vapor.eval(farSrc - blurY)) * half4(0.25);
546
- half4 condensateFar = (u_condensate.eval(farSrc + blurX) + u_condensate.eval(farSrc - blurX) +
547
- u_condensate.eval(farSrc + blurY) + u_condensate.eval(farSrc - blurY)) * half4(0.25);
862
+ float2 ring0 = float2( 0.9238795, 0.3826834) * u_farBlurRadius;
863
+ float2 ring1 = float2( 0.3826834, 0.9238795) * u_farBlurRadius;
864
+ float2 ring2 = float2(-0.3826834, 0.9238795) * u_farBlurRadius;
865
+ float2 ring3 = float2(-0.9238795, 0.3826834) * u_farBlurRadius;
866
+ float2 ring4 = float2(-0.9238795, -0.3826834) * u_farBlurRadius;
867
+ float2 ring5 = float2(-0.3826834, -0.9238795) * u_farBlurRadius;
868
+ float2 ring6 = float2( 0.3826834, -0.9238795) * u_farBlurRadius;
869
+ float2 ring7 = float2( 0.9238795, -0.3826834) * u_farBlurRadius;
870
+ half4 vaporFar = (u_vapor.eval(farSrc + ring0) + u_vapor.eval(farSrc + ring1) +
871
+ u_vapor.eval(farSrc + ring2) + u_vapor.eval(farSrc + ring3) +
872
+ u_vapor.eval(farSrc + ring4) + u_vapor.eval(farSrc + ring5) +
873
+ u_vapor.eval(farSrc + ring6) + u_vapor.eval(farSrc + ring7)) * half4(0.125);
874
+ half4 condensateFar = (u_condensate.eval(farSrc + ring0) + u_condensate.eval(farSrc + ring1) +
875
+ u_condensate.eval(farSrc + ring2) + u_condensate.eval(farSrc + ring3) +
876
+ u_condensate.eval(farSrc + ring4) + u_condensate.eval(farSrc + ring5) +
877
+ u_condensate.eval(farSrc + ring6) + u_condensate.eval(farSrc + ring7)) * half4(0.125);
548
878
 
549
879
  // Gravity's "denser layer at the bottom of the chamber" \u2014 a compositing-only boost (see
550
880
  // MEDIUM_DEFAULTS.gravityBottomBoost): it scales how density already in the conserved buffers is
@@ -577,7 +907,34 @@ half4 main(float2 xy) {
577
907
  float total = max(w0 + w1 + w2 + wBg + wCondensate, 1e-4);
578
908
  float3 mixLab =
579
909
  (u_labBg * wBg + u_lab0 * w0 + u_lab1 * w1 + u_lab2 * w2 + condensateLab * wCondensate) / total;
580
- float3 rgbLinear = clamp(vgOklabToLinear(mixLab), float3(0.0), float3(1.0));
910
+ // The dark ambient base \u2014 this is where the OLD mechanism stops (see the file header on
911
+ // VG_LIGHT): unclamped here, clamped once at the very end, after the lit layer adds in.
912
+ float3 rgbLinearBase = vgOklabToLinear(mixLab);
913
+
914
+ // Spotlights reach this point dimmed by the gas in front of them (shadow-shader.ts).
915
+ half4 shadow = u_shadow.eval(baseSrc);
916
+ float light0 = vgLightAmount(xy, u_light0Pos, u_light0Dir, u_light0CosCone, u_light0Falloff, u_resolution, 1.7) * u_light0Intensity;
917
+ float light1 = vgLightAmount(xy, u_light1Pos, u_light1Dir, u_light1CosCone, u_light1Falloff, u_resolution, 4.1) * u_light1Intensity
918
+ * float(shadow.r);
919
+ float light2 = vgLightAmount(xy, u_light2Pos, u_light2Dir, u_light2CosCone, u_light2Falloff, u_resolution, 8.3) * u_light2Intensity
920
+ * float(shadow.g);
921
+ float3 illum = u_light0Color * light0 + u_light1Color * light1 + u_light2Color * light2;
922
+
923
+ float totalScatter = gasR * u_channelScatter.x + gasG * u_channelScatter.y + gasB * u_channelScatter.z;
924
+ float fogDensity = totalScatter + wCondensate;
925
+ float mist = 0.006 + fogDensity * 0.2;
926
+
927
+ // TRACKS: lit the same way as the mist, plus a floor so a track already on screen never vanishes
928
+ // entirely just because it drifted out of a cone (a fresh ionization trail reads brighter than
929
+ // ambient mist in a real chamber). The floor is added to ITS OWN color, not multiplied into illum:
930
+ // illum is exactly [0,0,0] in a fully unlit spot (every light's amount is 0 there), so a floor
931
+ // multiplied by illum would vanish exactly where it exists to help \u2014 it needs a color of its own
932
+ // (a neutral, self-luminous glow) rather than borrowing whichever light happens to be nearby.
933
+ float trackDensity = max(float(track.r), max(float(track.g), float(track.b)));
934
+ float3 trackGlow = (1.0 - exp(-trackDensity * 0.4)) * (illum + float3(u_trackLightFloor)) * 0.08;
935
+
936
+ float3 rgbLinear = rgbLinearBase * 0.025 + illum * mist + trackGlow;
937
+ rgbLinear = float3(1.0) - exp(-rgbLinear * 1.0);
581
938
  float3 srgb = vgLinearToSrgb(rgbLinear);
582
939
  return half4(half3(srgb), half(1.0));
583
940
  }
@@ -595,7 +952,6 @@ uniform float u_settle;
595
952
  uniform float u_tailDim;
596
953
  uniform float u_intensity;
597
954
  uniform float u_seed;
598
- uniform float3 u_channelMask;
599
955
 
600
956
  ${VG_VALUE_NOISE}
601
957
 
@@ -618,20 +974,46 @@ half4 main(float2 xy) {
618
974
  best = max(best, v);
619
975
  }
620
976
 
621
- float3 dye = u_channelMask * (best * u_intensity);
977
+ float3 dye = float3(best * u_intensity);
622
978
  return half4(half3(dye), half(1.0));
623
979
  }
624
980
  `;
625
981
 
982
+ // src/medium/shadow-shader.ts
983
+ var MEDIUM_SHADOW_SHADER = `
984
+ uniform shader u_vapor;
985
+ uniform shader u_condensate;
986
+ uniform float u_condensateGain;
987
+ uniform float2 u_light1Grid;
988
+ uniform float2 u_light2Grid;
989
+ uniform float u_extinction;
990
+
991
+ float vgOpticalDepth(float2 p, float2 lightP) {
992
+ float2 toLight = lightP - p;
993
+ float optical = 0.0;
994
+ for (int i = 1; i <= 24; i++) {
995
+ float2 q = p + toLight * (float(i) / 24.0);
996
+ float inside = step(0.0, q.x) * step(q.x, u_resolution.x) * step(0.0, q.y) * step(q.y, u_resolution.y);
997
+ half4 v = u_vapor.eval(q);
998
+ half4 c = u_condensate.eval(q);
999
+ optical += inside * (float(v.r + v.g + v.b) + float(c.r) * u_condensateGain);
1000
+ }
1001
+ return optical * length(toLight) / 24.0 / max(u_resolution.y, 1.0);
1002
+ }
1003
+
1004
+ half4 main(float2 xy) {
1005
+ float2 p = float2(xy.x, u_resolution.y - xy.y);
1006
+ float t1 = exp(-vgOpticalDepth(p, u_light1Grid) * u_extinction);
1007
+ float t2 = exp(-vgOpticalDepth(p, u_light2Grid) * u_extinction);
1008
+ return half4(half(t1), half(t2), half(0.0), half(1.0));
1009
+ }
1010
+ `;
1011
+
626
1012
  // src/medium/dynamics.ts
627
1013
  function hash01(n) {
628
1014
  const s = Math.sin(n * 12.9898) * 43758.5453;
629
1015
  return s - Math.floor(s);
630
1016
  }
631
- function channelFromSeed(seed) {
632
- const idx = Math.floor(hash01(seed * 1.37) * 3) % 3;
633
- return idx === 0 ? [1, 0, 0] : idx === 1 ? [0, 1, 0] : [0, 0, 1];
634
- }
635
1017
  function lerp(range, t) {
636
1018
  return range[0] + (range[1] - range[0]) * t;
637
1019
  }
@@ -645,18 +1027,19 @@ function applyTrackDepth(preset, depth) {
645
1027
  intensity: preset.intensity * intensityScale
646
1028
  };
647
1029
  }
648
- function buildEmission(presetName, source, seed, intensityScale) {
1030
+ function buildEmission(presetName, source, seed, intensityScale, angleOverride, lengthScale = 1) {
649
1031
  const preset = MEDIUM_TRACK_PRESETS[presetName];
650
1032
  const depth = hash01(seed * 4.63);
651
1033
  const depthApplied = applyTrackDepth(preset, depth);
652
1034
  return {
653
1035
  ...depthApplied,
1036
+ lengthFrac: depthApplied.lengthFrac * lengthScale,
654
1037
  intensity: depthApplied.intensity * intensityScale,
655
1038
  source,
656
- angle: hash01(seed * 7.31) * Math.PI * 2,
1039
+ angle: angleOverride ?? hash01(seed * 7.31) * Math.PI * 2,
657
1040
  seed,
658
- channel: channelFromSeed(seed),
659
- depth
1041
+ depth,
1042
+ kind: presetName
660
1043
  };
661
1044
  }
662
1045
  function createMediumDynamics() {
@@ -682,8 +1065,15 @@ function createMediumDynamics() {
682
1065
  const eligible = hash01(bar * 0.618) < MEDIUM_EMISSION_PROBABILITY && simTime - lastEmissionAt >= MEDIUM_MIN_EMISSION_INTERVAL;
683
1066
  if (eligible) {
684
1067
  lastEmissionAt = simTime;
685
- const presetName = hash01(bar * 3.14) < 0.55 ? "alpha" : "beta";
686
- emissions.push(buildEmission(presetName, sourcePoint, bar, 0.6 + 0.4 * amp));
1068
+ const rayCount = Math.round(lerp(MEDIUM_ALPHA_BURST_RAYS, hash01(bar * 9.17)));
1069
+ for (let i = 0; i < rayCount; i += 1) {
1070
+ const raySeed = bar + (i + 1) * 0.0137;
1071
+ const angle = (i + hash01(raySeed * 5.23)) / rayCount * Math.PI * 2;
1072
+ const lengthScale = lerp(MEDIUM_ALPHA_RAY_LENGTH_SCALE, hash01(raySeed * 2.91));
1073
+ emissions.push(
1074
+ buildEmission("alpha", sourcePoint, raySeed, 0.6 + 0.4 * amp, angle, lengthScale)
1075
+ );
1076
+ }
687
1077
  }
688
1078
  }
689
1079
  } else if (state === "idle") {
@@ -695,7 +1085,8 @@ function createMediumDynamics() {
695
1085
  hash01(idx * 2.1 + 0.5),
696
1086
  MEDIUM_SENSITIVE_TOP + hash01(idx * 5.9 + 1.7) * (MEDIUM_SENSITIVE_BOTTOM - MEDIUM_SENSITIVE_TOP)
697
1087
  ];
698
- emissions.push(buildEmission("natural", randomSource, idx, 1));
1088
+ const presetName = hash01(idx * 3.14) < 0.5 ? "electron" : "muon";
1089
+ emissions.push(buildEmission(presetName, randomSource, idx, 1));
699
1090
  nextNaturalAt = idleClock + MEDIUM_NATURAL_INTERVAL + hash01(idx * 1.7) * MEDIUM_NATURAL_JITTER;
700
1091
  }
701
1092
  }
@@ -704,6 +1095,54 @@ function createMediumDynamics() {
704
1095
  return { step };
705
1096
  }
706
1097
 
707
- export { LINEAR_TO_LMS, LMS_TO_LINEAR, LMS_TO_OKLAB, MEDIUM_COMPOSITE_SHADER, MEDIUM_CONDENSATE_CORRECT_SHADER, MEDIUM_CONDENSATE_FORWARD_SHADER, MEDIUM_CONDENSATE_REACT_SHADER, MEDIUM_DEFAULTS, MEDIUM_DEFAULT_CELL_PX, MEDIUM_DRIFT_TURBULENCE, MEDIUM_EMISSION_BEATS, MEDIUM_EMISSION_PROBABILITY, MEDIUM_EMIT_SHADER, MEDIUM_MIN_EMISSION_INTERVAL, MEDIUM_NATURAL_INTERVAL, MEDIUM_NATURAL_JITTER, MEDIUM_PLAYING_TURBULENCE_RANGE, MEDIUM_PROBE_FLOOR_PX, MEDIUM_RESAMPLE_SHADER, MEDIUM_SEED_SHADER, MEDIUM_SENSITIVE_BOTTOM, MEDIUM_SENSITIVE_TOP, MEDIUM_SPECIES_HUE_SPREAD, MEDIUM_SPECIES_LIGHTNESS, MEDIUM_TIME_PERIOD, MEDIUM_TRACK_ADVECT_SHADER, MEDIUM_TRACK_DEPTH_INTENSITY_RANGE, MEDIUM_TRACK_DEPTH_WIDTH_RANGE, MEDIUM_TRACK_PRESETS, MEDIUM_TURBULENCE_TAU, MEDIUM_VAPOR_CORRECT_SHADER, MEDIUM_VAPOR_FORWARD_SHADER, MEDIUM_VAPOR_REACT_SHADER, OKLAB_TO_LMS, VG_CURL_NOISE, VG_OKLAB_TO_SRGB, VG_VALUE_NOISE, applyTrackDepth, characterOf, createMediumDynamics, depthFarOffsetPx, neutralSpecies, oklabToSrgb, speciesFamily, srgbToOklab, wrappedDistanceFromZero };
708
- //# sourceMappingURL=chunk-BV5P6MF7.js.map
709
- //# sourceMappingURL=chunk-BV5P6MF7.js.map
1098
+ // src/medium/light-rig.ts
1099
+ var clamp012 = (v) => Math.min(1, Math.max(0, v));
1100
+ var EDGE_STRIP_CONE_ANGLE = Math.PI - 1e-6;
1101
+ var EDGE_STRIP_TINT_MIX = 0.22;
1102
+ var GEL_LIGHTNESS = 0.7;
1103
+ var LIGHT_HUE_SPREAD = MEDIUM_SPECIES_HUE_SPREAD / 2;
1104
+ function retint(base, hueDeg, chroma, mix) {
1105
+ const [baseL, a, b] = srgbToOklab(base);
1106
+ const l = baseL + (Math.min(baseL, GEL_LIGHTNESS) - baseL) * mix;
1107
+ const rad = hueDeg * Math.PI / 180;
1108
+ const targetA = chroma * Math.cos(rad);
1109
+ const targetB = chroma * Math.sin(rad);
1110
+ const mixedA = a + (targetA - a) * mix;
1111
+ const mixedB = b + (targetB - b) * mix;
1112
+ let lo = 0;
1113
+ let hi = 1;
1114
+ if (inGamut(oklabToSrgb([l, mixedA, mixedB]))) lo = 1;
1115
+ else {
1116
+ for (let i = 0; i < 12; i += 1) {
1117
+ const mid = (lo + hi) / 2;
1118
+ if (inGamut(oklabToSrgb([l, mixedA * mid, mixedB * mid]))) lo = mid;
1119
+ else hi = mid;
1120
+ }
1121
+ }
1122
+ const rgb = oklabToSrgb([l, mixedA * lo, mixedB * lo]).map(clamp012);
1123
+ const gain = Math.max(...base) / Math.max(...rgb, 1e-6);
1124
+ return rgb.map((v) => clamp012(v * gain));
1125
+ }
1126
+ var inGamut = (rgb) => rgb.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
1127
+ function lightRigForCharacter(character, baseLights = MEDIUM_DEFAULT_LIGHTS) {
1128
+ const spotlightIndices = [];
1129
+ baseLights.forEach((light, i) => {
1130
+ if (light.coneAngle < EDGE_STRIP_CONE_ANGLE) spotlightIndices.push(i);
1131
+ });
1132
+ const spotlightCount = spotlightIndices.length;
1133
+ return baseLights.map((light, i) => {
1134
+ if (light.coneAngle >= EDGE_STRIP_CONE_ANGLE) {
1135
+ return { ...light, color: retint(light.color, character.hue, character.chroma, EDGE_STRIP_TINT_MIX) };
1136
+ }
1137
+ const slot = spotlightIndices.indexOf(i);
1138
+ const hueOffset = spotlightCount > 1 ? LIGHT_HUE_SPREAD * (2 * slot / (spotlightCount - 1) - 1) : 0;
1139
+ return { ...light, color: retint(light.color, character.hue + hueOffset, character.chroma, 1) };
1140
+ });
1141
+ }
1142
+ function lightRigForCover(cover, baseLights = MEDIUM_DEFAULT_LIGHTS) {
1143
+ return lightRigForCharacter(characterOf(cover), baseLights);
1144
+ }
1145
+
1146
+ export { LINEAR_TO_LMS, LMS_TO_LINEAR, LMS_TO_OKLAB, MEDIUM_ALPHA_BURST_RAYS, MEDIUM_ALPHA_RAY_LENGTH_SCALE, MEDIUM_COMPOSITE_SHADER, MEDIUM_CONDENSATE_CORRECT_SHADER, MEDIUM_CONDENSATE_FORWARD_SHADER, MEDIUM_CONDENSATE_REACT_SHADER, MEDIUM_DEFAULTS, MEDIUM_DEFAULT_CELL_PX, MEDIUM_DEFAULT_LIGHTS, MEDIUM_DRIFT_TURBULENCE, MEDIUM_EMISSION_BEATS, MEDIUM_EMISSION_PROBABILITY, MEDIUM_EMIT_SHADER, MEDIUM_MAX_LIGHTS, 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_SHADOW_SHADER, 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_LAYER_BURST_ACCENT, MEDIUM_TRACK_LAYER_BURST_SPREAD, MEDIUM_TRACK_LAYER_CURL_PUSH, MEDIUM_TRACK_LAYER_FADE_GAMMA, MEDIUM_TRACK_LAYER_GRAVITY_SAG, MEDIUM_TRACK_LAYER_LIFE_SECONDS, MEDIUM_TRACK_LAYER_LOOK, MEDIUM_TRACK_LAYER_MAX_TRACKS, 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, lightRigForCharacter, lightRigForCover, neutralSpecies, oklabToSrgb, speciesFamily, srgbToOklab, vgPotentialJS, wrappedDistanceFromZero };
1147
+ //# sourceMappingURL=chunk-VY22O2II.js.map
1148
+ //# sourceMappingURL=chunk-VY22O2II.js.map