@adatechnology/game-fluids-three 0.1.8 → 0.2.1

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.js CHANGED
@@ -2,6 +2,10 @@ var __defProp = Object.defineProperty;
2
2
  var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
3
  var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
4
 
5
+ // src/renderer.ts
6
+ import { createVfx } from "@adatechnology/game-vfx";
7
+ import { createVfxRenderer } from "@adatechnology/game-vfx-three";
8
+
5
9
  // src/clip.ts
6
10
  var sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
7
11
  var cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
@@ -360,207 +364,65 @@ var MaterialCache = class {
360
364
  }
361
365
  };
362
366
 
363
- // src/particles.ts
364
- var VERTEX = (
365
- /* glsl */
366
- `
367
- attribute float aSize;
368
- attribute vec4 aColor;
369
- attribute float aSoft;
370
- uniform float uScale;
371
- varying vec4 vColor;
372
- varying float vSoft;
373
- void main() {
374
- vColor = aColor;
375
- vSoft = aSoft;
376
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
377
- gl_PointSize = aSize * uScale / max(0.1, -mv.z);
378
- gl_Position = projectionMatrix * mv;
379
- }
380
- `
381
- );
382
- var FRAGMENT = (
383
- /* glsl */
384
- `
385
- varying vec4 vColor;
386
- varying float vSoft;
387
- void main() {
388
- float d = length(gl_PointCoord - vec2(0.5)) * 2.0;
389
- if (d > 1.0 || vColor.a <= 0.001) discard;
390
- float edge = mix(smoothstep(1.0, 0.8, d), 1.0 - d, vSoft);
391
- gl_FragColor = vec4(vColor.rgb, vColor.a * edge);
392
- }
393
- `
394
- );
395
- var ParticlePool = class {
396
- constructor(THREE, capacity, pointScale) {
397
- __publicField(this, "THREE", THREE);
398
- __publicField(this, "points");
399
- __publicField(this, "geometry");
400
- __publicField(this, "material");
401
- __publicField(this, "capacity");
402
- __publicField(this, "next", 0);
403
- __publicField(this, "pos");
404
- __publicField(this, "size");
405
- __publicField(this, "color");
406
- __publicField(this, "soft");
407
- __publicField(this, "vel");
408
- __publicField(this, "life");
409
- __publicField(this, "maxLife");
410
- __publicField(this, "size0");
411
- __publicField(this, "size1");
412
- __publicField(this, "alpha");
413
- __publicField(this, "floorY");
414
- __publicField(this, "kind");
415
- __publicField(this, "rgb");
367
+ // src/quality.ts
368
+ var PROFILES = {
369
+ low: { bodySegments: 10, streamSamples: 8, streamSides: 4, thinDrops: 4, stainSubdiv: 1, maxStains: 24, maxStreams: 12, maxParticles: 96, steamRate: 4, splashDroplets: 5 },
370
+ medium: { bodySegments: 14, streamSamples: 12, streamSides: 5, thinDrops: 6, stainSubdiv: 2, maxStains: 48, maxStreams: 24, maxParticles: 256, steamRate: 8, splashDroplets: 9 },
371
+ high: { bodySegments: 20, streamSamples: 20, streamSides: 8, thinDrops: 8, stainSubdiv: 4, maxStains: 96, maxStreams: 48, maxParticles: 512, steamRate: 14, splashDroplets: 14 }
372
+ };
373
+ var profileOf = (q) => PROFILES[q ?? "medium"] ?? PROFILES.medium;
374
+
375
+ // src/sprites.ts
376
+ var FluidSprites = class {
377
+ constructor(vfx, owner) {
378
+ __publicField(this, "vfx", vfx);
379
+ __publicField(this, "owner", owner);
416
380
  __publicField(this, "rng", new Rng(1234567));
417
- __publicField(this, "alive", 0);
418
- this.capacity = capacity;
419
- this.pos = new Float32Array(capacity * 3);
420
- this.size = new Float32Array(capacity);
421
- this.color = new Float32Array(capacity * 4);
422
- this.soft = new Float32Array(capacity);
423
- this.vel = new Float32Array(capacity * 3);
424
- this.life = new Float32Array(capacity);
425
- this.maxLife = new Float32Array(capacity);
426
- this.size0 = new Float32Array(capacity);
427
- this.size1 = new Float32Array(capacity);
428
- this.alpha = new Float32Array(capacity);
429
- this.floorY = new Float32Array(capacity);
430
- this.kind = new Uint8Array(capacity);
431
- this.rgb = new Float32Array(capacity * 3);
432
- this.life.fill(0);
433
- const T = THREE;
434
- this.geometry = new T.BufferGeometry();
435
- const attr = (array, n) => {
436
- const a = new T.BufferAttribute(array, n);
437
- a.setUsage(T.DynamicDrawUsage);
438
- return a;
439
- };
440
- this.geometry.setAttribute("position", attr(this.pos, 3));
441
- this.geometry.setAttribute("aSize", attr(this.size, 1));
442
- this.geometry.setAttribute("aColor", attr(this.color, 4));
443
- this.geometry.setAttribute("aSoft", attr(this.soft, 1));
444
- this.material = new T.ShaderMaterial({
445
- vertexShader: VERTEX,
446
- fragmentShader: FRAGMENT,
447
- uniforms: { uScale: { value: pointScale } },
448
- transparent: true,
449
- depthWrite: false
450
- });
451
- this.points = new T.Points(this.geometry, this.material);
452
- this.points.frustumCulled = false;
453
- this.points.renderOrder = 4;
454
- this.points.name = "fluid_particles";
455
- }
456
- get count() {
457
- return this.alive;
381
+ __publicField(this, "thrown", 0);
458
382
  }
459
- slot() {
460
- const i = this.next;
461
- this.next = (this.next + 1) % this.capacity;
462
- return i;
463
- }
464
- setColor(i, hex) {
465
- this.rgb[i * 3] = (hex >> 16 & 255) / 255;
466
- this.rgb[i * 3 + 1] = (hex >> 8 & 255) / 255;
467
- this.rgb[i * 3 + 2] = (hex & 255) / 255;
468
- }
469
- /** Droplets thrown up by an impact; `floorY` is the height they stop at. */
383
+ /** Droplets thrown up by an impact; they stop at `point[1]`. */
470
384
  splash(point, intensity, hex, opacity, count) {
471
- const rng = new Rng(hashPoint(point[0], point[1], point[2]) ^ this.next);
385
+ const rng = new Rng(hashPoint(point[0], point[1], point[2]) ^ this.thrown);
472
386
  const n = Math.max(2, Math.round(count * (0.35 + 0.65 * clamp(intensity, 0, 1))));
387
+ this.thrown += n;
473
388
  for (let k = 0; k < n; k++) {
474
- const i = this.slot();
475
389
  const a = rng.next() * Math.PI * 2;
476
390
  const h = (0.25 + rng.next() * 0.9) * (0.4 + intensity);
477
391
  const up = (0.7 + rng.next() * 1.5) * (0.5 + intensity);
478
- this.pos.set([point[0], point[1] + 0.01, point[2]], i * 3);
479
- this.vel.set([Math.cos(a) * h, up, Math.sin(a) * h], i * 3);
480
- this.life[i] = this.maxLife[i] = 0.45 + rng.next() * 0.45;
481
- this.size0[i] = 0.012 + rng.next() * 0.014;
482
- this.size1[i] = this.size0[i] * 0.7;
483
- this.alpha[i] = clamp(opacity + 0.2, 0.35, 1);
484
- this.soft[i] = 0;
485
- this.kind[i] = 0;
486
- this.floorY[i] = point[1];
487
- this.setColor(i, hex);
392
+ const life = 0.45 + rng.next() * 0.45;
393
+ const size = 0.012 + rng.next() * 0.014;
394
+ this.vfx.spawn("drips", [point[0], point[1] + 0.01, point[2]], [Math.cos(a) * h, up, Math.sin(a) * h], {
395
+ color: hex,
396
+ life: life / DRIP_LIFE,
397
+ size: size / DRIP_SIZE,
398
+ alpha: clamp(opacity + 0.2, 0.35, 1),
399
+ floor: point[1]
400
+ });
488
401
  }
489
402
  }
490
403
  /** One puff of steam rising from `point`. */
491
404
  steam(point) {
492
- const i = this.slot();
493
405
  const r = this.rng;
494
- this.pos.set([point[0] + (r.next() - 0.5) * 0.04, point[1], point[2] + (r.next() - 0.5) * 0.04], i * 3);
495
- this.vel.set([(r.next() - 0.5) * 0.06, 0.22 + r.next() * 0.12, (r.next() - 0.5) * 0.06], i * 3);
496
- this.life[i] = this.maxLife[i] = 1.2 + r.next() * 0.6;
497
- this.size0[i] = 0.03;
498
- this.size1[i] = 0.1;
499
- this.alpha[i] = 0.24;
500
- this.soft[i] = 1;
501
- this.kind[i] = 1;
502
- this.floorY[i] = -1e9;
503
- this.setColor(i, 16054527);
504
- }
505
- update(dt, gravity) {
506
- let alive = 0;
507
- for (let i = 0; i < this.capacity; i++) {
508
- if (this.life[i] <= 0) {
509
- this.size[i] = 0;
510
- this.color[i * 4 + 3] = 0;
511
- continue;
512
- }
513
- this.life[i] -= dt;
514
- const k = 1 - this.life[i] / this.maxLife[i];
515
- if (this.life[i] <= 0) {
516
- this.size[i] = 0;
517
- this.color[i * 4 + 3] = 0;
518
- continue;
519
- }
520
- alive++;
521
- if (this.kind[i] === 0) {
522
- this.vel[i * 3 + 1] -= gravity * dt;
523
- } else {
524
- this.vel[i * 3] += Math.sin((k + i) * 7) * 0.02 * dt;
525
- }
526
- this.pos[i * 3] += this.vel[i * 3] * dt;
527
- this.pos[i * 3 + 1] += this.vel[i * 3 + 1] * dt;
528
- this.pos[i * 3 + 2] += this.vel[i * 3 + 2] * dt;
529
- if (this.kind[i] === 0 && this.pos[i * 3 + 1] < this.floorY[i]) {
530
- this.life[i] = 0;
531
- alive--;
532
- this.size[i] = 0;
533
- this.color[i * 4 + 3] = 0;
534
- continue;
535
- }
536
- this.size[i] = this.size0[i] + (this.size1[i] - this.size0[i]) * k;
537
- const fade = this.kind[i] === 1 ? Math.min(1, k * 4) * (1 - k) : 1 - k * k;
538
- this.color[i * 4] = this.rgb[i * 3];
539
- this.color[i * 4 + 1] = this.rgb[i * 3 + 1];
540
- this.color[i * 4 + 2] = this.rgb[i * 3 + 2];
541
- this.color[i * 4 + 3] = this.alpha[i] * fade;
542
- }
543
- this.alive = alive;
544
- for (const name of ["position", "aSize", "aColor", "aSoft"]) this.geometry.getAttribute(name).needsUpdate = true;
545
- }
546
- clear() {
547
- this.life.fill(0);
548
- this.alive = 0;
549
- this.update(0, 0);
406
+ this.vfx.spawn("steam", [point[0] + (r.next() - 0.5) * 0.04, point[1], point[2] + (r.next() - 0.5) * 0.04], [(r.next() - 0.5) * 0.06, 0.22 + r.next() * 0.12, (r.next() - 0.5) * 0.06], {
407
+ color: STEAM_COLOR,
408
+ life: (1.2 + r.next() * 0.6) / STEAM_LIFE,
409
+ size: STEAM_SIZE_FACTOR,
410
+ alpha: STEAM_ALPHA,
411
+ rise: STEAM_RISE
412
+ });
550
413
  }
551
- dispose() {
552
- this.geometry.dispose();
553
- this.material.dispose();
414
+ /** The sprites alive in the world (a shared world counts everyone's). */
415
+ get count() {
416
+ return this.vfx.sprites().live;
554
417
  }
555
418
  };
556
-
557
- // src/quality.ts
558
- var PROFILES = {
559
- low: { bodySegments: 10, streamSamples: 8, streamSides: 4, thinDrops: 4, stainSubdiv: 1, maxStains: 24, maxStreams: 12, maxParticles: 96, steamRate: 4, splashDroplets: 5 },
560
- medium: { bodySegments: 14, streamSamples: 12, streamSides: 5, thinDrops: 6, stainSubdiv: 2, maxStains: 48, maxStreams: 24, maxParticles: 256, steamRate: 8, splashDroplets: 9 },
561
- high: { bodySegments: 20, streamSamples: 20, streamSides: 8, thinDrops: 8, stainSubdiv: 4, maxStains: 96, maxStreams: 48, maxParticles: 512, steamRate: 14, splashDroplets: 14 }
562
- };
563
- var profileOf = (q) => PROFILES[q ?? "medium"] ?? PROFILES.medium;
419
+ var DRIP_LIFE = 2.5;
420
+ var DRIP_SIZE = 0.03;
421
+ var STEAM_LIFE = 1.5;
422
+ var STEAM_COLOR = 16054527;
423
+ var STEAM_SIZE_FACTOR = 0.2;
424
+ var STEAM_ALPHA = 0.24 / 0.35;
425
+ var STEAM_RISE = 0.3;
564
426
 
565
427
  // src/stains.ts
566
428
  import { STAIN_SAMPLES } from "@adatechnology/game-fluids";
@@ -650,7 +512,7 @@ var StainVisual = class {
650
512
  const mean = stain.front.reduce((s, v) => s + v, 0) / stain.front.length;
651
513
  const wet = clamp(stain.wetness, 0, 1);
652
514
  const soakRatio = stain.soaked / (stain.soaked + stain.liters + 1e-6);
653
- this.tint.setHex(stainColorOf(def));
515
+ this.tint.setHex(stain.tint?.dry ?? stainColorOf(def));
654
516
  const dark = 1 - 0.3 * wet - 0.2 * soakRatio;
655
517
  const markAlpha = def.leavesMark ? 0.34 : 0.1;
656
518
  const aMid = (markAlpha + (0.62 - markAlpha) * wet) * (0.75 + 0.25 * Math.min(1, stain.liters * 20 + soakRatio));
@@ -660,7 +522,7 @@ var StainVisual = class {
660
522
  const showInner = wr > 0.05 && wet > 0.03;
661
523
  this.inner.mesh.visible = showInner;
662
524
  if (showInner) {
663
- this.tint.setHex(def.color);
525
+ this.tint.setHex(stain.tint?.fresh ?? def.color);
664
526
  const shift = stain.flow ? -mean * 0.1 * (1 - wr) : 0;
665
527
  const centre = stain.flow ? [stain.center[0] + stain.flow[0] * shift, stain.center[1] + stain.flow[1] * shift, stain.center[2] + stain.flow[2] * shift] : stain.center;
666
528
  const a = clamp(def.opacity, 0.2, 0.85) * wet * 0.8;
@@ -905,6 +767,7 @@ var StreamVisual = class {
905
767
  };
906
768
 
907
769
  // src/renderer.ts
770
+ var rendererCount = 0;
908
771
  function createFluidRenderer(THREE, options) {
909
772
  const { scene, fluids } = options;
910
773
  const quality = options.quality ?? "medium";
@@ -913,8 +776,9 @@ function createFluidRenderer(THREE, options) {
913
776
  const root = new THREE.Group();
914
777
  root.name = "fluid_renderer";
915
778
  scene.add(root);
916
- const particles = new ParticlePool(THREE, profile.maxParticles, options.pointScale ?? 600);
917
- root.add(particles.points);
779
+ const vfx = options.vfx ?? createVfx({ profile: quality, gravity: fluids.gravity, sprites: { capacity: profile.maxParticles } });
780
+ const ownVfx = options.vfx ? void 0 : createVfxRenderer(THREE, { scene, camera: options.camera ?? new THREE.PerspectiveCamera(), vfx, pointScale: options.pointScale ?? 600 });
781
+ const particles = new FluidSprites(vfx, `fluids-${++rendererCount}`);
918
782
  const dropGeometry = new THREE.SphereGeometry(1, 8, 6);
919
783
  const bodies = /* @__PURE__ */ new Map();
920
784
  const streamsActive = /* @__PURE__ */ new Map();
@@ -1023,7 +887,7 @@ function createFluidRenderer(THREE, options) {
1023
887
  for (const body of bodies.values()) body.update(dt, fluids, materials, profile, particles);
1024
888
  syncStreams(dt);
1025
889
  syncStains();
1026
- particles.update(dt, fluids.gravity);
890
+ ownVfx?.update(dt);
1027
891
  },
1028
892
  clear() {
1029
893
  for (const v of [...streamsActive.values()]) releaseStream(v);
@@ -1032,7 +896,8 @@ function createFluidRenderer(THREE, options) {
1032
896
  stainsActive.delete(id);
1033
897
  stainsFree.push(v);
1034
898
  }
1035
- particles.clear();
899
+ if (ownVfx) vfx.clear();
900
+ else vfx.sprites().clear();
1036
901
  },
1037
902
  dispose() {
1038
903
  if (disposed) return;
@@ -1046,7 +911,7 @@ function createFluidRenderer(THREE, options) {
1046
911
  stainsActive.clear();
1047
912
  streamsFree.length = 0;
1048
913
  stainsFree.length = 0;
1049
- particles.dispose();
914
+ ownVfx?.dispose();
1050
915
  dropGeometry.dispose();
1051
916
  materials.dispose();
1052
917
  scene.remove(root);
@@ -1074,8 +939,777 @@ function createFluidRenderer(THREE, options) {
1074
939
  };
1075
940
  return renderer;
1076
941
  }
942
+
943
+ // src/film/manager.ts
944
+ import { FILM_PROFILES, filmProfileOf, layoutFromSurfaces } from "@adatechnology/game-fluids";
945
+
946
+ // src/film/grid.ts
947
+ import { createFilm } from "@adatechnology/game-fluids";
948
+
949
+ // src/film/drops.ts
950
+ var METRES_PER_MM = 13e-4;
951
+ var STRETCH_PER_SPEED = 0.35;
952
+ var MAX_STRETCH = 3.5;
953
+ var BEAD_HANG = 12e-4;
954
+ var BEAD_SCALE = 11e-4;
955
+ function createFilmDrops(THREE, capacity, profile) {
956
+ const material = new THREE.MeshStandardMaterial({ color: profile.dropColor, roughness: 0.12, metalness: 0.15, transparent: true, opacity: profile.dropOpacity });
957
+ const geometry = new THREE.IcosahedronGeometry(1, 1);
958
+ const drops = new THREE.InstancedMesh(geometry, material, capacity);
959
+ drops.frustumCulled = false;
960
+ drops.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
961
+ drops.name = "film_drops";
962
+ const beadGeometry = new THREE.SphereGeometry(1, 10, 8);
963
+ const bead = new THREE.Mesh(beadGeometry, material);
964
+ bead.visible = false;
965
+ bead.name = "film_bead";
966
+ const hidden = new THREE.Matrix4().makeScale(0, 0, 0);
967
+ for (let i = 0; i < capacity; i++) drops.setMatrixAt(i, hidden);
968
+ const up = new THREE.Vector3(0, 1, 0);
969
+ const direction = new THREE.Vector3();
970
+ const location = new THREE.Vector3();
971
+ const scale = new THREE.Vector3();
972
+ const rotation = new THREE.Quaternion();
973
+ const matrix = new THREE.Matrix4();
974
+ return {
975
+ drops,
976
+ bead,
977
+ setProfile(next) {
978
+ material.color.setHex(next.dropColor);
979
+ material.opacity = next.dropOpacity;
980
+ },
981
+ sync(film) {
982
+ const { active, position, velocity, radius } = film.drops;
983
+ let drawn = 0;
984
+ for (let i = 0; i < capacity; i++) {
985
+ if (!active[i]) {
986
+ drops.setMatrixAt(i, hidden);
987
+ continue;
988
+ }
989
+ const o = i * 3;
990
+ const speed = Math.hypot(velocity[o], velocity[o + 1], velocity[o + 2]);
991
+ direction.set(velocity[o], velocity[o + 1], velocity[o + 2]).normalize();
992
+ rotation.setFromUnitVectors(up, direction);
993
+ const size = radius[i] * METRES_PER_MM;
994
+ matrix.compose(location.set(position[o], position[o + 1], position[o + 2]), rotation, scale.set(size, size * (1 + Math.min(MAX_STRETCH, speed * STRETCH_PER_SPEED)), size));
995
+ drops.setMatrixAt(i, matrix);
996
+ drawn++;
997
+ }
998
+ drops.instanceMatrix.needsUpdate = true;
999
+ const state = film.bead;
1000
+ bead.visible = state.visible;
1001
+ if (state.visible) {
1002
+ const r = state.radius;
1003
+ bead.position.set(state.position[0], state.position[1] - r * BEAD_HANG, state.position[2]);
1004
+ bead.scale.set(r * BEAD_SCALE, r * BEAD_SCALE * (1 + state.swell * 0.7), r * BEAD_SCALE);
1005
+ }
1006
+ return drawn;
1007
+ },
1008
+ dispose() {
1009
+ geometry.dispose();
1010
+ beadGeometry.dispose();
1011
+ material.dispose();
1012
+ }
1013
+ };
1014
+ }
1015
+
1016
+ // src/film/shader.ts
1017
+ var FILM_VERTEX = `attribute vec3 aB;attribute vec3 aT;varying vec2 vUv;varying vec3 vPos;varying vec3 vN;varying vec3 vB;varying vec3 vT;
1018
+ void main(){vUv=uv;vPos=position;vN=normal;vB=aB;vT=aT;gl_Position=projectionMatrix*viewMatrix*vec4(position,1.);}`;
1019
+ var FILM_FRAGMENT = `precision highp float;
1020
+ uniform sampler2D tF;uniform vec2 tx;uniform vec2 uGrid;uniform float uSpan;uniform float uT;uniform int uFl;uniform vec3 cThin,cThick,cDry,cDryThin,uBase;uniform float opK,F0,nrm,dbg;
1021
+ uniform vec3 camP,lampP,flashP,flashD;varying vec2 vUv;varying vec3 vPos;varying vec3 vN;varying vec3 vB;varying vec3 vT;
1022
+ float hsh(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
1023
+ vec2 hsh2(vec2 p){return vec2(hsh(p),hsh(p+17.7));}
1024
+ float nz(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);return mix(mix(hsh(i),hsh(i+vec2(1,0)),f.x),mix(hsh(i+vec2(0,1)),hsh(i+vec2(1,1)),f.x),f.y);}
1025
+ float vor(vec2 p){vec2 i=floor(p),f=fract(p);float d1=8.,d2=8.;for(int y=-1;y<=1;y++)for(int x=-1;x<=1;x++){vec2 o=vec2(float(x),float(y));vec2 r=o+hsh2(i+o)-f;float d=dot(r,r);if(d<d1){d2=d1;d1=d;}else if(d<d2)d2=d;}return sqrt(d2)-sqrt(d1);}
1026
+ float hs(vec2 u){float r=texture2D(tF,u).r;return r*r*6.;}
1027
+ void main(){
1028
+ vec4 S=texture2D(tF,vUv);vec2 q=tx*.5;float h=.25*(hs(vUv+q)+hs(vUv-q)+hs(vUv+vec2(q.x,-q.y))+hs(vUv+vec2(-q.x,q.y)));float dry=S.g;float wg=S.b;
1029
+ vec2 o1=vec2(tx.x*1.5,0.),o2=vec2(0.,tx.y*1.5);
1030
+ float hl=hs(vUv-o1),hr=hs(vUv+o1),hd=hs(vUv-o2),hu=hs(vUv+o2);
1031
+ vec2 g=vec2(hr-hl,hu-hd)/37.5;
1032
+ vec2 cellp=vUv*uGrid;
1033
+ float n1=nz(cellp*2.3),n2=nz(cellp*7.1);
1034
+ float hn=h+(n1-.5)*.045+(n2-.5)*.02;
1035
+ float m=smoothstep(.035,.08,hn);
1036
+ if(uFl==4){float wa=.25*(texture2D(tF,vUv+q).b+texture2D(tF,vUv-q).b+texture2D(tF,vUv+vec2(q.x,-q.y)).b+texture2D(tF,vUv+vec2(-q.x,q.y)).b);m=smoothstep(.35,.75,wa+(n1-.5)*.25)*smoothstep(.0,.012,h);wg=smoothstep(.2,.8,wa+(n1-.5)*.2);}
1037
+ vec2 p=vec2(dot(vPos,normalize(vT)),vUv.y*uSpan);
1038
+ vec3 base=uBase;
1039
+ vec3 T=normalize(vT),Bv=normalize(vB),N0=normalize(vN);
1040
+ vec3 nt=vec3(-g*nrm*m,1.);
1041
+ float dryM=smoothstep(.5,.95,dry);
1042
+ float wet=(1.-dryM)*m;
1043
+ // bile bubbles
1044
+ float bub=0.;
1045
+ if(uFl==2){vec2 bp=cellp/2.6;vec2 bi=floor(bp);vec2 bf=fract(bp)-.5;float rnd=hsh(bi);
1046
+ if(rnd>.5&&h>1.1){float t=fract(uT*(.18+.25*hsh(bi+3.1))+rnd*7.);float rad=.42*smoothstep(0.,.8,t);float d=length(bf-(hsh2(bi+9.)-.5)*.2);
1047
+ if(t<.86&&d<rad){float z=sqrt(max(0.,rad*rad-d*d));nt.xy+=(bf/max(rad,.01))*2.2*(1.-dryM);bub=z/rad;}
1048
+ else if(t>=.86){float rr=.42*1.15;float ring=1.-smoothstep(0.,.07,abs(d-rr*(.9+(t-.86)*.8)));nt.xy-=normalize(bf+.0001)*ring*.8*(1.-dryM);bub=-ring;}}}
1049
+ if(uFl==4){vec2 wp=p;nt.xy+=.05*m*vec2(nz(wp*9.+vec2(uT*1.7,0.))-.5,nz(wp*9.+vec2(0.,uT*1.4)+7.)-.5)*2.;}
1050
+ vec3 Nf=normalize(T*nt.x+Bv*nt.y+N0*nt.z);
1051
+ // albedo
1052
+ float th=smoothstep(.1,1.7,h);
1053
+ vec3 fc=mix(cThin,cThick,th);
1054
+ float op=clamp(1.-exp(-h*opK),0.,1.);
1055
+ vec3 alb;
1056
+ if(uFl==4){alb=base*mix(vec3(1.),cThin*1.15,clamp(h*.9,0.,1.));}
1057
+ else{alb=mix(base*mix(vec3(1.),fc*1.6,clamp(h*2.,0.,1.)),fc,op);}
1058
+ if(uFl==2&&bub>0.)alb=mix(alb,cThin*1.25,bub*.5);
1059
+ // dried look: matte, darker rim, cracks where it was thick
1060
+ float rim=smoothstep(.01,.05,length(g))*(1.-smoothstep(.4,1.,h));
1061
+ vec3 dc=mix(cDryThin,cDry,smoothstep(.06,.8,h))*(1.-.42*rim);
1062
+ float crk=(1.-smoothstep(.015,.05,vor(p*(uFl==2?34.:52.))))*smoothstep(.9,1.6,h);
1063
+ dc=mix(dc,base*.55,crk*.75);
1064
+ if(uFl!=3&&uFl!=4)alb=mix(alb,mix(base,dc,clamp(.4+h*1.4,0.,1.)),dryM);
1065
+ // gasoline darkens the ground while wet
1066
+ float gw=0.;if(uFl==4){gw=wg*(1.-m*.0);base*=mix(1.,.45,gw);alb=mix(base,alb,m);}
1067
+ vec3 col0=mix(base,alb*(uFl<2?mix(.62,1.,dryM):1.),m);
1068
+ // lighting
1069
+ vec3 V=normalize(camP-vPos);
1070
+ vec3 Lv=lampP-vPos;float dl=length(Lv);vec3 L=Lv/dl;float att=2.2/(1.+dl*dl*.12);
1071
+ vec3 lampC=vec3(1.,.88,.7);vec3 moonD=normalize(vec3(-3.,5.,4.));vec3 moonC=vec3(.11,.14,.19);vec3 amb=vec3(.14,.15,.17);
1072
+ vec3 Fv=flashP-vPos;float fd=length(Fv);vec3 FL=Fv/fd;float cone=smoothstep(.86,.97,dot(-FL,flashD));float fatt=cone*1.1/(1.+fd*fd*.6);
1073
+ vec3 dif=col0*(amb+lampC*att*max(dot(Nf,L),0.)+moonC*max(dot(Nf,moonD),0.)+vec3(.95,.97,1.)*fatt*max(dot(Nf,FL),0.));
1074
+ float wv=max(wet,gw*.7);
1075
+ float shin=mix(30.,320.,wv);
1076
+ vec3 Hh=normalize(L+V);vec3 Hf=normalize(FL+V);
1077
+ float sp=pow(max(dot(Nf,Hh),0.),shin)*shin*.011*att;float spf=pow(max(dot(Nf,Hf),0.),shin)*shin*.008*fatt;
1078
+ vec3 R=reflect(-V,Nf);
1079
+ vec3 env=mix(vec3(.03,.035,.04),vec3(.15,.17,.21),clamp(R.y*1.4,0.,1.));
1080
+ env+=lampC*(pow(max(dot(R,L),0.),500.)*7.+pow(max(dot(R,L),0.),40.)*.6)*att;
1081
+ float fr=F0+(1.-F0)*pow(1.-max(dot(Nf,V),0.),5.);
1082
+ vec3 col=dif*(1.-fr*wv*.7)+(env*min(1.,fr*(uFl==4?1.6:3.4))+min(lampC*sp+vec3(.9,.95,1.)*spf,vec3(.85)))*wv;
1083
+ if(uFl==3){float film=(1.-smoothstep(.04,.55,h))*m;vec3 ir=.5+.5*cos(6.283*(h*7.+n1*.5+uT*.03+vec3(0.,.33,.67)));col+=ir*film*(.10+.5*fr)*(.6+att*.4);}
1084
+ if(uFl==4){vec2 wp=p;float film=m*(.4+.6*(1.-smoothstep(.02,.2,h)));vec3 ir=.5+.5*cos(6.283*(nz(wp*6.)*1.4+uT*.02+vec3(0.,.33,.67)));col+=ir*film*.05*(.3+fr*3.);}
1085
+ if(dbg>.5){float k=clamp(h/3.,0.,1.);vec3 hc=mix(vec3(.05,.1,.5),vec3(1.,.9,.2),k);hc=mix(hc,vec3(1.,.2,.1),step(.66,k));col=mix(base*.25,mix(hc,vec3(.6),dryM*.7),step(.004,h));}
1086
+ float cover=max(m,gw*.55);
1087
+ gl_FragColor=vec4(pow(max(col,vec3(0.)),vec3(2.2)),clamp(cover,0.,1.));
1088
+ #include <colorspace_fragment>
1089
+ }`;
1090
+
1091
+ // src/film/material.ts
1092
+ var LOOK_INDEX = { blood: 0, bile: 2, oil: 3, fuel: 4 };
1093
+ var DEFAULT_SPAN = 4.95;
1094
+ var rgb = (THREE, target, hex) => {
1095
+ target.set((hex >> 16 & 255) / 255, (hex >> 8 & 255) / 255, (hex & 255) / 255);
1096
+ };
1097
+ function createFilmMaterial(THREE, texture, width, height, span = DEFAULT_SPAN) {
1098
+ const V3 = THREE.Vector3;
1099
+ const uniforms = {
1100
+ tF: { value: texture },
1101
+ tx: { value: new THREE.Vector2(1 / width, 1 / height) },
1102
+ uGrid: { value: new THREE.Vector2(width, height) },
1103
+ uSpan: { value: span },
1104
+ uT: { value: 0 },
1105
+ uFl: { value: 0 },
1106
+ cThin: { value: new V3() },
1107
+ cThick: { value: new V3() },
1108
+ cDry: { value: new V3() },
1109
+ cDryThin: { value: new V3() },
1110
+ uBase: { value: new V3(0.4, 0.4, 0.38) },
1111
+ opK: { value: 3 },
1112
+ F0: { value: 0.03 },
1113
+ nrm: { value: 16 },
1114
+ camP: { value: new V3() },
1115
+ lampP: { value: new V3() },
1116
+ flashP: { value: new V3() },
1117
+ flashD: { value: new V3(0, 0, -1) },
1118
+ dbg: { value: 0 }
1119
+ };
1120
+ const material = new THREE.ShaderMaterial({
1121
+ uniforms,
1122
+ vertexShader: FILM_VERTEX,
1123
+ fragmentShader: FILM_FRAGMENT,
1124
+ transparent: true,
1125
+ depthWrite: false,
1126
+ // the layout's axes may be either handed (the page's strips were all built facing out)
1127
+ side: THREE.DoubleSide,
1128
+ polygonOffset: true,
1129
+ polygonOffsetFactor: -2,
1130
+ polygonOffsetUnits: -2
1131
+ });
1132
+ return {
1133
+ material,
1134
+ setProfile(profile) {
1135
+ uniforms.uFl.value = LOOK_INDEX[profile.look];
1136
+ rgb(THREE, uniforms.cThin.value, profile.thin);
1137
+ rgb(THREE, uniforms.cThick.value, profile.thick);
1138
+ rgb(THREE, uniforms.cDry.value, profile.dry);
1139
+ rgb(THREE, uniforms.cDryThin.value, profile.dryThin);
1140
+ uniforms.opK.value = profile.opacityRate;
1141
+ uniforms.F0.value = profile.reflectance;
1142
+ uniforms.nrm.value = profile.normalScale;
1143
+ },
1144
+ dispose() {
1145
+ material.dispose();
1146
+ }
1147
+ };
1148
+ }
1149
+
1150
+ // src/film/strips.ts
1151
+ var SEGMENTS_ACROSS = 8;
1152
+ var SEGMENTS_ALONG = 8;
1153
+ function createStripGeometry(THREE, layout, region) {
1154
+ const { width, height, cellSize } = layout;
1155
+ const alongSegments = region.rowCount <= 2 ? 1 : SEGMENTS_ALONG;
1156
+ const position = [];
1157
+ const normal = [];
1158
+ const uv = [];
1159
+ const down = [];
1160
+ const across = [];
1161
+ const index = [];
1162
+ for (let j = 0; j <= alongSegments; j++) {
1163
+ const t = j / alongSegments;
1164
+ for (let i = 0; i <= SEGMENTS_ACROSS; i++) {
1165
+ const u = i / SEGMENTS_ACROSS;
1166
+ for (let k = 0; k < 3; k++) position.push(region.origin[k] + region.columnAxis[k] * u * width * cellSize + region.rowAxis[k] * t * region.rowCount * cellSize);
1167
+ normal.push(region.normal[0], region.normal[1], region.normal[2]);
1168
+ down.push(region.rowAxis[0], region.rowAxis[1], region.rowAxis[2]);
1169
+ across.push(region.columnAxis[0], region.columnAxis[1], region.columnAxis[2]);
1170
+ uv.push(u, (region.firstRow + t * region.rowCount) / height);
1171
+ }
1172
+ }
1173
+ for (let j = 0; j < alongSegments; j++) {
1174
+ for (let i = 0; i < SEGMENTS_ACROSS; i++) {
1175
+ const a = j * (SEGMENTS_ACROSS + 1) + i;
1176
+ const b = a + 1;
1177
+ const c = a + SEGMENTS_ACROSS + 1;
1178
+ index.push(a, c, b, b, c, c + 1);
1179
+ }
1180
+ }
1181
+ const geometry = new THREE.BufferGeometry();
1182
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(position, 3));
1183
+ geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normal, 3));
1184
+ geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
1185
+ geometry.setAttribute("aB", new THREE.Float32BufferAttribute(down, 3));
1186
+ geometry.setAttribute("aT", new THREE.Float32BufferAttribute(across, 3));
1187
+ geometry.setIndex(index);
1188
+ return geometry;
1189
+ }
1190
+
1191
+ // src/film/grid.ts
1192
+ var MAX_THICKNESS = 6;
1193
+ var CHANNEL = 255;
1194
+ var VISIBLE_THICKNESS = 0.035;
1195
+ var DEFAULT_BASE = 6710881;
1196
+ var MIN_CHANGE = 2;
1197
+ var CHANGE_FRACTION = 5e-3;
1198
+ var LAMP_OFFSET = [0.9, 2.9, 0.4];
1199
+ var FilmGrid = class {
1200
+ constructor(THREE, options) {
1201
+ __publicField(this, "THREE", THREE);
1202
+ __publicField(this, "film");
1203
+ __publicField(this, "group");
1204
+ __publicField(this, "texture");
1205
+ __publicField(this, "center");
1206
+ __publicField(this, "tint");
1207
+ __publicField(this, "data");
1208
+ /** Seconds the grid has been without anything landing, flowing or drying. */
1209
+ __publicField(this, "idle", 0);
1210
+ /** Set once the whole texture went to the GPU (the partial uploads need it there first). */
1211
+ __publicField(this, "uploaded", false);
1212
+ __publicField(this, "strips", []);
1213
+ __publicField(this, "material");
1214
+ __publicField(this, "dropsView");
1215
+ __publicField(this, "previous", null);
1216
+ __publicField(this, "pending", null);
1217
+ __publicField(this, "launched", 0);
1218
+ __publicField(this, "lastWet", 0);
1219
+ const { layout, profile } = options;
1220
+ this.center = options.center;
1221
+ this.tint = options.tint;
1222
+ this.film = createFilm({ layout, profile, random: options.random, ...options.stepRate ? { stepRate: options.stepRate } : {}, ...options.maxSteps ? { maxSteps: options.maxSteps } : {} });
1223
+ this.data = new Uint8Array(new ArrayBuffer(layout.width * layout.height * 4));
1224
+ this.texture = new THREE.DataTexture(this.data, layout.width, layout.height, THREE.RGBAFormat);
1225
+ this.texture.magFilter = THREE.LinearFilter;
1226
+ this.texture.minFilter = THREE.LinearFilter;
1227
+ this.texture.needsUpdate = true;
1228
+ this.texture.onUpdate = () => {
1229
+ this.uploaded = true;
1230
+ };
1231
+ this.material = createFilmMaterial(THREE, this.texture, layout.width, layout.height, layout.height * layout.cellSize);
1232
+ this.material.setProfile(profile);
1233
+ this.group = new THREE.Group();
1234
+ this.group.name = "film_grid";
1235
+ for (const region of layout.regions) {
1236
+ const mesh = new THREE.Mesh(createStripGeometry(THREE, layout, region), this.material.material);
1237
+ mesh.frustumCulled = false;
1238
+ mesh.renderOrder = 3;
1239
+ mesh.name = `film_${region.id}`;
1240
+ this.strips.push(mesh);
1241
+ this.group.add(mesh);
1242
+ }
1243
+ this.dropsView = createFilmDrops(THREE, layout.rules.dropCapacity, profile);
1244
+ this.group.add(this.dropsView.drops, this.dropsView.bead);
1245
+ const uniforms = this.material.material.uniforms;
1246
+ const base = options.base ?? DEFAULT_BASE;
1247
+ uniforms.uBase.value.set((base >> 16 & 255) / 255, (base >> 8 & 255) / 255, (base & 255) / 255);
1248
+ uniforms.lampP.value.set(this.center[0] + LAMP_OFFSET[0], this.center[1] + LAMP_OFFSET[1], this.center[2] + LAMP_OFFSET[2]);
1249
+ this.launched = this.film.drops.launched;
1250
+ }
1251
+ get profile() {
1252
+ return this.film.profile;
1253
+ }
1254
+ setProfile(profile) {
1255
+ this.film.profile = profile;
1256
+ this.material.setProfile(profile);
1257
+ this.dropsView.setProfile(profile);
1258
+ }
1259
+ /** Where the film's light and eye are (the page's `camP`, `flashP` and `flashD`). */
1260
+ setView(camera, forward, time) {
1261
+ const uniforms = this.material.material.uniforms;
1262
+ uniforms.camP.value.set(camera[0], camera[1], camera[2]);
1263
+ uniforms.flashP.value.set(camera[0] + 0.12, camera[1] - 0.15, camera[2]);
1264
+ uniforms.flashD.value.set(forward[0], forward[1], forward[2]);
1265
+ uniforms.uT.value = time;
1266
+ }
1267
+ /** Steps the film one frame and refreshes the texture cells that may have changed. Returns the rectangle to upload, or null. */
1268
+ update(dt, dryingSeconds) {
1269
+ this.film.update({ dt, dryingSeconds });
1270
+ const bounds = this.film.simulator.bounds;
1271
+ const launched = this.film.drops.launched;
1272
+ const busy = launched !== this.launched || this.film.drip !== void 0 || this.film.sources.length > 0 || this.film.drags.some((drag) => !drag.done) || this.dropsInFlight();
1273
+ this.launched = launched;
1274
+ const current = bounds.lastRow < 0 ? null : { firstRow: bounds.firstRow, lastRow: bounds.lastRow, firstColumn: bounds.firstColumn, lastColumn: bounds.lastColumn };
1275
+ const rect = this.union(this.previous, current);
1276
+ this.previous = current;
1277
+ const wet = current ? this.film.wetCells() : 0;
1278
+ const moved = Math.abs(wet - this.lastWet) > Math.max(MIN_CHANGE, wet * CHANGE_FRACTION);
1279
+ this.idle = busy || moved ? 0 : this.idle + dt;
1280
+ if (moved || busy) this.lastWet = wet;
1281
+ if (!rect) return this.takePending();
1282
+ this.encode(rect);
1283
+ this.pending = this.union(this.pending, rect);
1284
+ return this.takePending();
1285
+ }
1286
+ takePending() {
1287
+ const rect = this.pending;
1288
+ this.pending = null;
1289
+ return rect;
1290
+ }
1291
+ /** The mean dryness of the cells that are wet (1 when nothing is). */
1292
+ dryness() {
1293
+ const { thickness, dryness } = this.film.field;
1294
+ let wet = 0;
1295
+ let dry = 0;
1296
+ for (let i = 0; i < thickness.length; i++) {
1297
+ if (thickness[i] > VISIBLE_THICKNESS) {
1298
+ wet++;
1299
+ dry += dryness[i];
1300
+ }
1301
+ }
1302
+ return wet === 0 ? 1 : dry / wet;
1303
+ }
1304
+ /** Draws the drops and the bead; answers how many drops are in flight. */
1305
+ syncDrops() {
1306
+ return this.dropsView.sync(this.film);
1307
+ }
1308
+ dropsInFlight() {
1309
+ const { active } = this.film.drops;
1310
+ for (let i = 0; i < active.length; i++) if (active[i]) return true;
1311
+ return false;
1312
+ }
1313
+ /** Writes the cells of the texture from the field, as the page's `upload` does (F360). */
1314
+ encode(rect) {
1315
+ const { thickness, dryness, dampness, width } = this.film.field;
1316
+ const data = this.data;
1317
+ for (let row = rect.firstRow; row <= rect.lastRow; row++) {
1318
+ for (let column = rect.firstColumn; column <= rect.lastColumn; column++) {
1319
+ const cell = row * width + column;
1320
+ const j = cell * 4;
1321
+ const h = thickness[cell];
1322
+ data[j] = h > 0 ? Math.min(CHANNEL, Math.sqrt(Math.min(MAX_THICKNESS, h) / MAX_THICKNESS) * CHANNEL + 0.5) : 0;
1323
+ data[j + 1] = dryness[cell] * CHANNEL;
1324
+ data[j + 2] = dampness[cell] * CHANNEL;
1325
+ data[j + 3] = CHANNEL;
1326
+ }
1327
+ }
1328
+ }
1329
+ /** Encodes the whole field (a grid made from a state, or after `clear`). */
1330
+ encodeAll() {
1331
+ this.encode({ firstRow: 0, lastRow: this.film.field.height - 1, firstColumn: 0, lastColumn: this.film.field.width - 1 });
1332
+ this.texture.needsUpdate = true;
1333
+ this.uploaded = false;
1334
+ }
1335
+ union(a, b) {
1336
+ if (!a) return b ? { ...b } : null;
1337
+ if (!b) return { ...a };
1338
+ return { firstRow: Math.min(a.firstRow, b.firstRow), lastRow: Math.max(a.lastRow, b.lastRow), firstColumn: Math.min(a.firstColumn, b.firstColumn), lastColumn: Math.max(a.lastColumn, b.lastColumn) };
1339
+ }
1340
+ dispose() {
1341
+ for (const mesh of this.strips) mesh.geometry.dispose();
1342
+ this.material.dispose();
1343
+ this.dropsView.dispose();
1344
+ this.texture.dispose();
1345
+ }
1346
+ };
1347
+
1348
+ // src/film/manager.ts
1349
+ var DEFAULT_MAX_GRIDS = 6;
1350
+ var DEFAULT_RANGE = 30;
1351
+ var DEFAULT_BUDGET_MS = 1.2;
1352
+ var DEFAULT_SETTLE_SECONDS = 20;
1353
+ var DEFAULT_MAX_STAGING = 16;
1354
+ var DEFAULT_GRID_METRES = 2;
1355
+ var DEFAULT_CELL = 0.0125;
1356
+ var BAKE_RANGE_FACTOR = 1.25;
1357
+ var EDGE_MARGIN = 0.35;
1358
+ var FLOOR_REACH = 0.3;
1359
+ var DROP_HEIGHT = 2.5;
1360
+ var DEFAULT_DROP_RADIUS = 3;
1361
+ var FALL_SPEED = 2;
1362
+ var VISIBLE_THICKNESS2 = 0.035;
1363
+ var DEFAULT_HALF = 50;
1364
+ var FREE_AFTER_EMPTY = 2;
1365
+ var THICK_FACTOR = 0.55;
1366
+ var sameTint = (a, b) => a?.fresh === b?.fresh && a?.dry === b?.dry;
1367
+ var scaleColor = (hex, factor) => Math.round((hex >> 16 & 255) * factor) << 16 | Math.round((hex >> 8 & 255) * factor) << 8 | Math.round((hex & 255) * factor);
1368
+ function tinted(profile, tint) {
1369
+ return { ...profile, thin: tint.fresh, thick: scaleColor(tint.fresh, THICK_FACTOR), dry: tint.dry, dryThin: tint.dry, dropColor: tint.fresh };
1370
+ }
1371
+ var distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
1372
+ var FilmManager = class {
1373
+ constructor(THREE, options) {
1374
+ __publicField(this, "THREE", THREE);
1375
+ __publicField(this, "options", options);
1376
+ __publicField(this, "group");
1377
+ __publicField(this, "grids", []);
1378
+ __publicField(this, "profiles");
1379
+ __publicField(this, "staging", /* @__PURE__ */ new Map());
1380
+ __publicField(this, "now");
1381
+ __publicField(this, "rain", 0);
1382
+ __publicField(this, "time", 0);
1383
+ __publicField(this, "bakedCount", 0);
1384
+ __publicField(this, "last", { grids: 0, drops: 0, wetCells: 0, simMs: 0, starved: 0, baked: 0, uploadedTexels: 0 });
1385
+ __publicField(this, "empties", /* @__PURE__ */ new Map());
1386
+ this.group = new THREE.Group();
1387
+ this.group.name = "film_manager";
1388
+ options.scene.add(this.group);
1389
+ this.profiles = options.profiles ?? Object.fromEntries(FILM_PROFILES.map((profile) => [profile.id, profile]));
1390
+ this.now = options.now ?? (() => typeof performance === "undefined" ? Date.now() : performance.now());
1391
+ }
1392
+ get gridCount() {
1393
+ return this.grids.length;
1394
+ }
1395
+ /** The grids, for a host that wants to look at them (a bench, a test). */
1396
+ list() {
1397
+ return this.grids;
1398
+ }
1399
+ setWeather(weather) {
1400
+ this.rain = Math.min(1, Math.max(0, weather.rain));
1401
+ for (const grid of this.grids) grid.film.setWeather({ rain: this.rain });
1402
+ }
1403
+ /** Throws a drop of a liquid: it lands in the grid under it (made on the spot) and leaves the impact splash. Null when it is out of range or has no surface. */
1404
+ launch(request) {
1405
+ const grid = this.gridFor(request.fluid, request.point, request.surfaces, request.tint);
1406
+ if (!grid) return null;
1407
+ const velocity = request.velocity ?? [0, -FALL_SPEED, 0];
1408
+ grid.film.launch(request.point, velocity, request.radius ?? DEFAULT_DROP_RADIUS);
1409
+ return grid;
1410
+ }
1411
+ /** A drip: a bead swells at `point`, detaches and falls every `every` seconds on average. Replaces the grid's drip. */
1412
+ drip(spec) {
1413
+ const grid = this.gridFor(spec.fluid, spec.point, spec.surfaces, spec.tint);
1414
+ if (!grid) return null;
1415
+ grid.film.drip = { position: [spec.point[0], spec.point[1], spec.point[2]], every: spec.every, base: spec.every, elapsed: 0, radius: spec.radius ?? DEFAULT_DROP_RADIUS };
1416
+ return grid;
1417
+ }
1418
+ /** The grid of a liquid that covers `point`, made when there is none. Null when `point` is out of range or has no surface under it. */
1419
+ gridFor(fluid, point, surfaces, tint) {
1420
+ const viewer = this.viewer();
1421
+ if (viewer && distance(viewer.position, point) > (this.options.range ?? DEFAULT_RANGE)) return null;
1422
+ const existing = this.grids.find((grid2) => grid2.profile.id === fluid && sameTint(grid2.tint, tint) && this.covers(grid2, point));
1423
+ if (existing) return existing;
1424
+ const base = this.profiles[fluid] ?? filmProfileOf(fluid);
1425
+ if (!base) return null;
1426
+ const profile = tint ? tinted(base, tint) : base;
1427
+ const found = surfaces ?? this.surfacesAt(point);
1428
+ if (!found) return null;
1429
+ const layout = layoutFromSurfaces({ ...found, center: point, columns: this.columns(), rows: this.columns(), cellSize: this.options.cellSize ?? DEFAULT_CELL });
1430
+ if (this.grids.length >= (this.options.maxGrids ?? DEFAULT_MAX_GRIDS)) this.evict(point);
1431
+ const grid = new FilmGrid(this.THREE, { layout, profile, random: this.options.random, center: point, ...tint ? { tint } : {}, ...this.options.base === void 0 ? {} : { base: this.options.base } });
1432
+ grid.film.setWeather({ rain: this.rain });
1433
+ this.grids.push(grid);
1434
+ this.group.add(grid.group);
1435
+ return grid;
1436
+ }
1437
+ /** One frame: simulates the grids near the player inside the budget, uploads what changed, bakes the ones that are far or still. */
1438
+ update(dt) {
1439
+ this.time += dt;
1440
+ const viewer = this.viewer();
1441
+ const range = this.options.range ?? DEFAULT_RANGE;
1442
+ const budget = this.options.budgetMs ?? DEFAULT_BUDGET_MS;
1443
+ const drying = dt * (this.options.dryingSpeed ?? 1);
1444
+ const order = [...this.grids].sort((a, b) => viewer ? distance(viewer.position, a.center) - distance(viewer.position, b.center) : 0);
1445
+ const start = this.now();
1446
+ let starved = 0;
1447
+ let uploaded = 0;
1448
+ for (const grid of order) {
1449
+ if (viewer && distance(viewer.position, grid.center) > range * BAKE_RANGE_FACTOR) {
1450
+ this.release(grid, "range");
1451
+ continue;
1452
+ }
1453
+ if (this.now() - start > budget) {
1454
+ starved++;
1455
+ continue;
1456
+ }
1457
+ if (viewer) grid.setView(viewer.position, viewer.forward, this.time);
1458
+ const rect = grid.update(dt, drying);
1459
+ grid.syncDrops();
1460
+ if (rect) uploaded += this.upload(grid, rect);
1461
+ if (this.emptyFor(grid, dt) || grid.idle > (this.options.settleSeconds ?? DEFAULT_SETTLE_SECONDS)) this.release(grid, "settled");
1462
+ }
1463
+ let drops = 0;
1464
+ let wet = 0;
1465
+ for (const grid of this.grids) {
1466
+ const { active } = grid.film.drops;
1467
+ for (let i = 0; i < active.length; i++) drops += active[i];
1468
+ wet += grid.film.wetCells();
1469
+ }
1470
+ this.last = { grids: this.grids.length, drops, wetCells: wet, simMs: this.now() - start, starved, baked: this.bakedCount, uploadedTexels: uploaded };
1471
+ return this.last;
1472
+ }
1473
+ stats() {
1474
+ return this.last;
1475
+ }
1476
+ /** Frees every grid. `bake` leaves each one's film as a stain first. */
1477
+ clear(bake = false) {
1478
+ for (const grid of [...this.grids]) {
1479
+ if (bake) this.release(grid, "clear");
1480
+ else this.free(grid);
1481
+ }
1482
+ }
1483
+ dispose() {
1484
+ this.clear();
1485
+ for (const texture of this.staging.values()) texture.dispose();
1486
+ this.staging.clear();
1487
+ this.options.scene.remove(this.group);
1488
+ }
1489
+ // ---- internals ----
1490
+ columns() {
1491
+ return Math.round((this.options.gridMetres ?? DEFAULT_GRID_METRES) / (this.options.cellSize ?? DEFAULT_CELL));
1492
+ }
1493
+ viewer() {
1494
+ if (this.options.viewer) return this.options.viewer();
1495
+ const camera = this.options.camera;
1496
+ if (!camera) return null;
1497
+ const forward = new this.THREE.Vector3();
1498
+ camera.getWorldDirection(forward);
1499
+ return { position: [camera.position.x, camera.position.y, camera.position.z], forward: [forward.x, forward.y, forward.z] };
1500
+ }
1501
+ surfacesAt(point) {
1502
+ if (this.options.surfaceAt) return this.options.surfaceAt(point);
1503
+ const y = this.options.groundAt?.(point[0], point[2]) ?? 0;
1504
+ return { floor: { id: "film_ground", point: [point[0], y, point[2]], normal: [0, 1, 0], halfSize: [DEFAULT_HALF, DEFAULT_HALF], porosity: 0 } };
1505
+ }
1506
+ /** Whether `point` is over the grid, away from its edge, so a splash there does not fall off the strip. */
1507
+ covers(grid, point) {
1508
+ const layout = grid.film.layout;
1509
+ const wide = layout.width * layout.cellSize;
1510
+ for (const region of layout.regions) {
1511
+ const local = [point[0] - region.origin[0], point[1] - region.origin[1], point[2] - region.origin[2]];
1512
+ const above = local[0] * region.normal[0] + local[1] * region.normal[1] + local[2] * region.normal[2];
1513
+ if (above < -FLOOR_REACH || above > DROP_HEIGHT) continue;
1514
+ const sideways = local[0] * region.columnAxis[0] + local[1] * region.columnAxis[1] + local[2] * region.columnAxis[2];
1515
+ const down = local[0] * region.rowAxis[0] + local[1] * region.rowAxis[1] + local[2] * region.rowAxis[2];
1516
+ const length = region.rowCount * layout.cellSize;
1517
+ if (sideways > EDGE_MARGIN && sideways < wide - EDGE_MARGIN && down > EDGE_MARGIN && down < length - EDGE_MARGIN) return true;
1518
+ }
1519
+ return false;
1520
+ }
1521
+ /** The grid squeezed out for a new one at `point`: the one that has been still the longest, then the farthest from the player. */
1522
+ evict(point) {
1523
+ const viewer = this.viewer();
1524
+ const score = (grid) => grid.idle * 10 + (viewer ? distance(viewer.position, grid.center) : distance(point, grid.center));
1525
+ const victim = [...this.grids].sort((a, b) => score(b) - score(a))[0];
1526
+ if (victim) this.release(victim, "budget");
1527
+ }
1528
+ /** A grid that has nothing wet for two seconds goes without a trace. */
1529
+ emptyFor(grid, dt) {
1530
+ if (grid.film.wetCells() > 0 || grid.dropsInFlight() || grid.film.drip || grid.film.sources.length > 0) {
1531
+ this.empties.delete(grid);
1532
+ return false;
1533
+ }
1534
+ const seconds = (this.empties.get(grid) ?? 0) + dt;
1535
+ this.empties.set(grid, seconds);
1536
+ return seconds > FREE_AFTER_EMPTY;
1537
+ }
1538
+ free(grid) {
1539
+ const at = this.grids.indexOf(grid);
1540
+ if (at < 0) return;
1541
+ this.grids.splice(at, 1);
1542
+ this.empties.delete(grid);
1543
+ this.group.remove(grid.group);
1544
+ grid.dispose();
1545
+ }
1546
+ /** Bakes the film of a grid into the host's stain decals and frees the grid. */
1547
+ release(grid, reason) {
1548
+ const bake = this.measure(grid, reason);
1549
+ this.free(grid);
1550
+ if (!bake) return;
1551
+ this.bakedCount++;
1552
+ this.options.fluids?.splash(bake.fluid, bake.point, bake.liters, void 0, { tint: bake.tint });
1553
+ this.options.onBake?.(bake);
1554
+ }
1555
+ /** The film's centre of volume, its litres (millimetres of film times cell area) and the radius of the wet cells. */
1556
+ measure(grid, reason) {
1557
+ const { thickness } = grid.film.field;
1558
+ const { width, cellSize } = grid.film.layout;
1559
+ let volume = 0;
1560
+ let x = 0;
1561
+ let y = 0;
1562
+ let z = 0;
1563
+ const wet = [];
1564
+ for (let i = 0; i < thickness.length; i++) {
1565
+ const h = thickness[i];
1566
+ if (h <= VISIBLE_THICKNESS2) continue;
1567
+ const at = grid.film.layout.cellToWorld(Math.floor(i / width) + 0.5, i % width + 0.5);
1568
+ volume += h;
1569
+ x += at[0] * h;
1570
+ y += at[1] * h;
1571
+ z += at[2] * h;
1572
+ wet.push(i);
1573
+ }
1574
+ if (volume === 0) return null;
1575
+ const point = [x / volume, y / volume, z / volume];
1576
+ let radius = 0;
1577
+ for (const i of wet) radius = Math.max(radius, distance(grid.film.layout.cellToWorld(Math.floor(i / width) + 0.5, i % width + 0.5), point));
1578
+ const profile = grid.profile;
1579
+ return { fluid: profile.id, point, liters: volume * cellSize * cellSize, tint: grid.tint ?? { fresh: profile.thin, dry: profile.dry }, radius, reason };
1580
+ }
1581
+ /** Copies the dirty rectangle to the GPU texture; answers the texels written. */
1582
+ upload(grid, rect) {
1583
+ const gpu = this.options.gpu;
1584
+ const width = rect.lastColumn - rect.firstColumn + 1;
1585
+ const height = rect.lastRow - rect.firstRow + 1;
1586
+ const whole = grid.film.layout.width * grid.film.layout.height;
1587
+ if (!gpu || !grid.uploaded || width * height >= whole * 0.6) {
1588
+ grid.texture.needsUpdate = true;
1589
+ return whole;
1590
+ }
1591
+ const staging = this.stagingFor(width, height);
1592
+ const target = staging.image.data;
1593
+ const stride = grid.film.layout.width * 4;
1594
+ for (let row = 0; row < height; row++) {
1595
+ const from = (rect.firstRow + row) * stride + rect.firstColumn * 4;
1596
+ target.set(grid.data.subarray(from, from + width * 4), row * width * 4);
1597
+ }
1598
+ staging.needsUpdate = true;
1599
+ gpu.copyTextureToTexture(new this.THREE.Vector2(rect.firstColumn, rect.firstRow), staging, grid.texture);
1600
+ return width * height;
1601
+ }
1602
+ stagingFor(width, height) {
1603
+ const key = `${width}x${height}`;
1604
+ let texture = this.staging.get(key);
1605
+ if (!texture) {
1606
+ if (this.staging.size >= DEFAULT_MAX_STAGING) {
1607
+ const oldest = this.staging.keys().next().value;
1608
+ this.staging.get(oldest)?.dispose();
1609
+ this.staging.delete(oldest);
1610
+ }
1611
+ texture = new this.THREE.DataTexture(new Uint8Array(new ArrayBuffer(width * height * 4)), width, height, this.THREE.RGBAFormat);
1612
+ texture.magFilter = this.THREE.LinearFilter;
1613
+ texture.minFilter = this.THREE.LinearFilter;
1614
+ this.staging.set(key, texture);
1615
+ }
1616
+ return texture;
1617
+ }
1618
+ };
1619
+ function createFilmManager(THREE, options) {
1620
+ return new FilmManager(THREE, options);
1621
+ }
1622
+
1623
+ // src/film/bridge.ts
1624
+ import { FILM_PROFILES as FILM_PROFILES2 } from "@adatechnology/game-fluids";
1625
+ var DEFAULT_DROP_LITERS = 15e-5;
1626
+ var DEFAULT_DROP_RADIUS2 = 3.2;
1627
+ var MAX_DROPS_PER_FRAME = 6;
1628
+ var RADIUS_SPREAD = 0.3;
1629
+ var RADIUS_BASE = 0.85;
1630
+ var DEFAULT_SPLAT_SPEED = 2.5;
1631
+ var filmLiquids = () => Object.fromEntries(FILM_PROFILES2.map((profile) => [profile.id, profile.id]));
1632
+ function createFilmBridge(options) {
1633
+ const { fluids, manager, random } = options;
1634
+ const liquids = options.liquids ?? filmLiquids();
1635
+ const dropLiters = options.dropLiters ?? DEFAULT_DROP_LITERS;
1636
+ const leaks = /* @__PURE__ */ new Map();
1637
+ const offLeak = fluids.on("leak", (event) => {
1638
+ if (event.type !== "leak") return;
1639
+ const fluid = liquids[event.liquid];
1640
+ if (fluid) leaks.set(event.stream, { fluid, owed: 0 });
1641
+ });
1642
+ const offEnd = fluids.on("stream_end", (event) => {
1643
+ if (event.type === "stream_end") leaks.delete(event.stream);
1644
+ });
1645
+ const surfaces = (point) => options.surfaceAt?.(point) ?? void 0;
1646
+ return {
1647
+ get leaks() {
1648
+ return leaks.size;
1649
+ },
1650
+ splat(splat) {
1651
+ const count = splat.count ?? 1;
1652
+ const speed = splat.speed ?? DEFAULT_SPLAT_SPEED;
1653
+ const direction = splat.direction ?? [0, -1, 0];
1654
+ const length = Math.hypot(direction[0], direction[1], direction[2]) || 1;
1655
+ const velocity = [direction[0] / length * speed, direction[1] / length * speed, direction[2] / length * speed];
1656
+ let landed = 0;
1657
+ for (let i = 0; i < count; i++) {
1658
+ const grid = manager.launch({
1659
+ fluid: splat.fluid,
1660
+ point: splat.point,
1661
+ velocity,
1662
+ radius: (splat.radius ?? DEFAULT_DROP_RADIUS2) * (RADIUS_BASE + random() * RADIUS_SPREAD),
1663
+ ...splat.surfaces ? { surfaces: splat.surfaces } : {},
1664
+ ...splat.tint ? { tint: splat.tint } : {}
1665
+ });
1666
+ if (grid) landed++;
1667
+ }
1668
+ return landed;
1669
+ },
1670
+ setWeather: (weather) => manager.setWeather(weather),
1671
+ update(dt) {
1672
+ if (leaks.size === 0) return;
1673
+ const streams = new Map(fluids.streams().map((stream) => [stream.id, stream]));
1674
+ for (const [id, leak] of leaks) {
1675
+ const stream = streams.get(id);
1676
+ if (!stream) continue;
1677
+ leak.owed += stream.rate * dt / dropLiters;
1678
+ let thrown = 0;
1679
+ while (leak.owed >= 1 && thrown < MAX_DROPS_PER_FRAME) {
1680
+ leak.owed -= 1;
1681
+ thrown++;
1682
+ manager.launch({
1683
+ fluid: leak.fluid,
1684
+ point: stream.origin,
1685
+ velocity: stream.velocity,
1686
+ radius: DEFAULT_DROP_RADIUS2 * (RADIUS_BASE + random() * RADIUS_SPREAD),
1687
+ ...surfaces(stream.origin) ? { surfaces: surfaces(stream.origin) } : {}
1688
+ });
1689
+ }
1690
+ if (thrown === MAX_DROPS_PER_FRAME) leak.owed = 0;
1691
+ }
1692
+ },
1693
+ dispose() {
1694
+ offLeak();
1695
+ offEnd();
1696
+ leaks.clear();
1697
+ }
1698
+ };
1699
+ }
1077
1700
  export {
1701
+ FILM_FRAGMENT,
1702
+ FILM_VERTEX,
1703
+ FilmGrid,
1704
+ FilmManager,
1705
+ LOOK_INDEX,
1706
+ createFilmBridge,
1707
+ createFilmDrops,
1708
+ createFilmManager,
1709
+ createFilmMaterial,
1078
1710
  createFluidRenderer,
1079
- smoothOutline
1711
+ createStripGeometry,
1712
+ smoothOutline,
1713
+ tinted
1080
1714
  };
1081
1715
  //# sourceMappingURL=index.js.map