sindicate 0.1.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.
Files changed (65) hide show
  1. package/README.md +63 -2
  2. package/docs/api/README.md +28 -0
  3. package/docs/api/anim.md +125 -0
  4. package/docs/api/assets.md +208 -0
  5. package/docs/api/collision.md +101 -0
  6. package/docs/api/fbxloader.md +50 -0
  7. package/docs/api/grass.md +103 -0
  8. package/docs/api/input.md +164 -0
  9. package/docs/api/quality.md +159 -0
  10. package/docs/api/renderer.md +115 -0
  11. package/docs/api/retarget.md +149 -0
  12. package/docs/api/shatter.md +121 -0
  13. package/docs/api/utils-decals.md +188 -0
  14. package/docs/api/water.md +143 -0
  15. package/docs/api/wind-weather-uniforms.md +105 -0
  16. package/docs/contracts/render.md +32 -0
  17. package/docs/contracts/time-feel.md +52 -0
  18. package/package.json +1 -1
  19. package/src/core/anim.js +184 -0
  20. package/src/core/assets.js +596 -0
  21. package/src/core/engine.js +284 -0
  22. package/src/core/input/bindings.js +30 -0
  23. package/src/core/input/gamepad.js +92 -0
  24. package/src/core/input/index.js +62 -0
  25. package/src/core/input/keyboard.js +16 -0
  26. package/src/core/input/mouse.js +48 -0
  27. package/src/core/quality.js +5 -1
  28. package/src/core/renderer.js +104 -0
  29. package/src/core/retarget.js +282 -0
  30. package/src/index.js +19 -2
  31. package/src/systems/aim.js +114 -0
  32. package/src/systems/campfire.js +150 -0
  33. package/src/systems/climbing.js +136 -0
  34. package/src/systems/deadeye.js +332 -0
  35. package/src/systems/encounters.js +123 -0
  36. package/src/systems/entity.js +524 -0
  37. package/src/systems/fistCurl.js +38 -0
  38. package/src/systems/footsteps.js +161 -0
  39. package/src/systems/glass.js +67 -0
  40. package/src/systems/herd.js +277 -0
  41. package/src/systems/horse.js +361 -0
  42. package/src/systems/mountedTraveller.js +518 -0
  43. package/src/systems/nav.js +343 -0
  44. package/src/systems/npc.js +880 -0
  45. package/src/systems/pathFollow.js +107 -0
  46. package/src/systems/platform.js +484 -0
  47. package/src/systems/riding.js +580 -0
  48. package/src/systems/seatedRider.js +396 -0
  49. package/src/systems/skybirds.js +129 -0
  50. package/src/systems/trainCamera.js +414 -0
  51. package/src/systems/traveller.js +254 -0
  52. package/src/systems/tumbleweeds.js +92 -0
  53. package/src/systems/vat.js +327 -0
  54. package/src/systems/vfx.js +472 -0
  55. package/src/world/blobShadows.js +109 -0
  56. package/src/world/clouds.js +61 -0
  57. package/src/world/flora.js +87 -0
  58. package/src/world/footprints.js +117 -0
  59. package/src/world/lampField.js +58 -0
  60. package/src/world/lampGlow.js +92 -0
  61. package/src/world/scatter.js +1077 -0
  62. package/src/world/sky.js +363 -0
  63. package/src/world/tileManager.js +197 -0
  64. package/src/world/walkHumps.js +74 -0
  65. package/src/world/weather.js +312 -0
@@ -0,0 +1,87 @@
1
+ // Countryside placement fields — CPU port of braffolk/fable5-world-demo's
2
+ // Scatter.ts placement RULES. His pipeline is a boot-time GPU compute pass, but
3
+ // every rule in it is a pure deterministic function of hashed (x,z) + smooth
4
+ // field samples, so it ports straight onto our manifest placementFields:
5
+ //
6
+ // clumpField : parent-clump thickets (clustered Poisson / light competition) —
7
+ // trees gather in copses with real gaps, not uniform speckle
8
+ // moistureAt : analytic moisture — desert-low; the creek corridor reads lusher,
9
+ // everything else parched (species density keys off it)
10
+ // forestness : always 0 — no forest on the Dustwater plain (name/signature kept
11
+ // for the field consumers)
12
+ //
13
+ // All gates hash SPACE (pcg2d over quantized x,z), never the rng stream, so the
14
+ // mulberry32(1337) placement determinism is untouched.
15
+ // Terrain queries are GAME data — register before building placements:
16
+ // setFloraTerrain({ heightAt, riverInfo, fbm, LAKES })
17
+ let TERRAIN = { heightAt: () => 0, riverInfo: () => null, fbm: () => 0, LAKES: [] };
18
+ export function setFloraTerrain(t) { TERRAIN = { ...TERRAIN, ...t }; }
19
+ import { dist2D } from '../core/utils.js';
20
+
21
+ const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
22
+
23
+ // integer 2D hash (braffolk's pcg2d) — stable at any cell magnitude, unlike
24
+ // sin-based hashes which band at 4-digit coordinates
25
+ function pcg2d(ix, iz, salt) {
26
+ const M = 1664525, C = 1013904223;
27
+ let a = (ix + 40000 + (salt & 0x3fff)) >>> 0;
28
+ let b = (iz + 40000 + ((salt >> 14) & 0x3fff)) >>> 0;
29
+ a = (Math.imul(a, M) + C) >>> 0;
30
+ b = (Math.imul(b, M) + C) >>> 0;
31
+ a = (a + Math.imul(b, M)) >>> 0;
32
+ b = (b + Math.imul(a, M)) >>> 0;
33
+ a = (a ^ (a >>> 16)) >>> 0;
34
+ b = (b ^ (b >>> 16)) >>> 0;
35
+ a = (a + Math.imul(b, M)) >>> 0;
36
+ b = (b + Math.imul(a, M)) >>> 0;
37
+ a = (a ^ (a >>> 16)) >>> 0;
38
+ b = (b ^ (b >>> 16)) >>> 0;
39
+ return [(a & 0xffffff) / 16777216, (b & 0xffffff) / 16777216];
40
+ }
41
+
42
+ /** spatial probability gate: 0..1, deterministic per ~0.5 m cell */
43
+ export const hashXZ = (x, z, salt) => pcg2d(Math.floor(x * 2), Math.floor(z * 2), salt)[0];
44
+
45
+ // Parent clump field: hashed parent points on a coarse grid, weight = max kernel
46
+ // over the 3×3 neighbourhood — ~1 at clump hearts, 0 in the gaps between. The
47
+ // SAME salt is shared by trees and understory so ferns gather under the same
48
+ // thickets whose trees they shade beneath (braffolk's canopy proxy).
49
+ const PARENT_CELL = 26;
50
+ const PARENT_PROB = 0.62;
51
+ export const TREE_SALT = 0x51f3;
52
+
53
+ export function clumpField(x, z, salt = TREE_SALT) {
54
+ const bx = Math.floor(x / PARENT_CELL), bz = Math.floor(z / PARENT_CELL);
55
+ let w = 0;
56
+ for (let dz = -1; dz <= 1; dz++) {
57
+ for (let dx = -1; dx <= 1; dx++) {
58
+ const cx = bx + dx + 8192, cz = bz + dz + 8192;
59
+ if (pcg2d(cx, cz, salt ^ 0x9e3779)[0] >= PARENT_PROB) continue;
60
+ const [h0, h1] = pcg2d(cx, cz, salt);
61
+ const px = (cx - 8192 + 0.15 + h0 * 0.7) * PARENT_CELL;
62
+ const pz = (cz - 8192 + 0.15 + h1 * 0.7) * PARENT_CELL;
63
+ const r = PARENT_CELL * (h0 * 0.55 + 0.5);
64
+ const k = 1 - sstep(r * 0.22, r, Math.hypot(x - px, z - pz));
65
+ if (k > w) w = k;
66
+ }
67
+ }
68
+ return w;
69
+ }
70
+
71
+ /** analytic moisture 0..1 — desert-low: faint drift, a lusher corridor along the creek */
72
+ export function moistureAt(x, z) {
73
+ let m = 0.12 + TERRAIN.fbm(x + 191, z - 87, 3, 2, 0.5, 0.012) * 0.1;
74
+ const ri = TERRAIN.riverInfo(x, z);
75
+ if (ri.dist < ri.w + 30) m += 0.35 * (1 - Math.max(0, ri.dist - ri.w) / 30);
76
+ for (const L of TERRAIN.LAKES) {
77
+ const d = dist2D(x, z, L.x, L.z) - L.r;
78
+ if (d < 22) m += 0.35 * (1 - Math.max(0, d) / 22);
79
+ }
80
+ m -= Math.max(0, (TERRAIN.heightAt(x, z) - 12) * 0.012);
81
+ return Math.min(1, Math.max(0, m));
82
+ }
83
+
84
+ /** no forest on the desert plain — always 0 (signature kept for the field consumers) */
85
+ export function forestness(x, z) {
86
+ return 0;
87
+ }
@@ -0,0 +1,117 @@
1
+ // Footprints in the snow — a resident pool of ground decals stamped on the player's
2
+ // stride and faded out over time in the shader (snow doesn't spring back like grass, so
3
+ // prints LINGER then melt away). Mirrors the grass-trample idea — a timestamped trail —
4
+ // but snow is TERRAIN, not instanced blades, so prints are decal quads laid on the ground.
5
+ //
6
+ // Fixed-size ring buffer of instances → resident-for-life, never added/removed at runtime
7
+ // (the WebGPU skinned/mesh-churn rule): only per-instance matrices + a spawn-time attribute
8
+ // are rewritten as feet land. One InstancedMesh = one draw call for the whole trail.
9
+ import * as THREE from 'three/webgpu';
10
+ import { attribute, uniform, float, uv, smoothstep, oneMinus, step, max, vec3 } from 'three/tsl';
11
+
12
+
13
+ const N = 96; // pool size — must outlast LIFE worth of strides so prints don't self-evict early
14
+ const STRIDE = 0.9; // metres between footfalls (walk cadence)
15
+ const LATERAL = 0.13; // left/right offset of each print from the centre line
16
+ const HOLD = 5; // seconds a print stays at full strength
17
+ const LIFE = 14; // seconds until fully melted (fade runs HOLD..LIFE)
18
+ const SNOW_MIN = 0.5; // snowFactor threshold — only leave prints on actual snow
19
+ const STRENGTH = 0.55; // max decal darkness (a clear cool depression, not a faint smudge)
20
+
21
+ export class Footprints {
22
+ constructor(scene, { heightAt = () => 0, snowFactor = () => 0 } = {}) {
23
+ this._heightAt = heightAt; this._snowFactor = snowFactor;
24
+ this.scene = scene;
25
+ this._now = 0; this._head = 0; this._dist = 0; this._foot = 1;
26
+ this._lastX = null; this._lastZ = null; this._moving = false;
27
+ this.uNow = uniform(0);
28
+
29
+ const geo = new THREE.PlaneGeometry(0.26, 0.44).rotateX(-Math.PI / 2); // lay flat; local +Z = print length
30
+ const spawn = new Float32Array(N).fill(-1000); // all "born" long ago → invisible
31
+ geo.setAttribute('aSpawn', new THREE.InstancedBufferAttribute(spawn, 1));
32
+ this._spawnAttr = geo.getAttribute('aSpawn');
33
+
34
+ this.mesh = new THREE.InstancedMesh(geo, this._makeMaterial(), N);
35
+ this.mesh.frustumCulled = false;
36
+ this.mesh.castShadow = false; this.mesh.receiveShadow = false;
37
+ this.mesh.renderOrder = 2; // draw over the terrain
38
+ this.mesh.name = 'footprints';
39
+ const m = new THREE.Matrix4().makeScale(0, 0, 0);
40
+ for (let i = 0; i < N; i++) this.mesh.setMatrixAt(i, m);
41
+ this.mesh.instanceMatrix.needsUpdate = true;
42
+ scene.add(this.mesh);
43
+
44
+ this._m = new THREE.Matrix4(); this._q = new THREE.Quaternion();
45
+ this._p = new THREE.Vector3(); this._sv = new THREE.Vector3(); this._up = new THREE.Vector3(0, 1, 0);
46
+ }
47
+
48
+ _makeMaterial() {
49
+ const mat = new THREE.MeshBasicNodeMaterial({ transparent: true, depthWrite: false });
50
+ mat.polygonOffset = true; mat.polygonOffsetFactor = -4; mat.polygonOffsetUnits = -4;
51
+ const age = this.uNow.sub(attribute('aSpawn', 'float'));
52
+ const alive = step(float(0), age).mul(oneMinus(step(float(LIFE), age))); // 1 while 0<=age<=LIFE
53
+ const fade = oneMinus(smoothstep(float(HOLD), float(LIFE), age)); // 1 → 0 over HOLD..LIFE
54
+ // procedural two-lobe boot sole (ball + heel) — soft falloff from UV, no texture asset
55
+ const p = uv();
56
+ const lobe = (cx, cy, rx, ry) => {
57
+ const dx = p.x.sub(float(cx)).div(float(rx));
58
+ const dy = p.y.sub(float(cy)).div(float(ry));
59
+ const d = dx.mul(dx).add(dy.mul(dy)).sqrt();
60
+ return oneMinus(smoothstep(float(0.45), float(1.0), d));
61
+ };
62
+ const shape = max(lobe(0.5, 0.40, 0.26, 0.34), lobe(0.5, 0.74, 0.17, 0.19));
63
+ mat.colorNode = vec3(0.10, 0.13, 0.20); // cool pressed-snow shadow (reads grey-blue over white)
64
+ mat.opacityNode = shape.mul(fade).mul(alive).mul(float(STRENGTH));
65
+ return mat;
66
+ }
67
+
68
+ // Stamp one print centred at (x,z), long axis along yaw, scaled by `scale`.
69
+ _stamp(x, z, yaw, scale) {
70
+ const i = this._head % N;
71
+ this._q.setFromAxisAngle(this._up, yaw);
72
+ this._p.set(x, this._heightAt(x, z) + 0.03, z);
73
+ this._m.compose(this._p, this._q, this._sv.set(scale, scale, scale));
74
+ this.mesh.setMatrixAt(i, this._m);
75
+ this._spawnAttr.array[i] = this._now;
76
+ this.mesh.instanceMatrix.needsUpdate = true;
77
+ this._spawnAttr.needsUpdate = true;
78
+ this._head++;
79
+ }
80
+
81
+ // Called each frame with the active ground-walker. `grounded` gates prints (none airborne);
82
+ // `scale`/`strideMul` let a mounted horse leave bigger, wider-spaced hoof marks.
83
+ step(dt, x, z, grounded, { scale = 1, strideMul = 1, lateral = LATERAL } = {}) {
84
+ this._now += dt;
85
+ this.uNow.value = this._now;
86
+ if (!grounded) { this._lastX = null; this._moving = false; return; } // reset stride on takeoff
87
+ if (this._lastX === null) { this._lastX = x; this._lastZ = z; return; }
88
+ const dx = x - this._lastX, dz = z - this._lastZ;
89
+ const moved = Math.hypot(dx, dz);
90
+ this._lastX = x; this._lastZ = z;
91
+ const onSnow = this._snowFactor(x, z, this._heightAt(x, z)) >= SNOW_MIN;
92
+
93
+ if (moved < 0.004) { // effectively at rest this frame
94
+ if (this._moving && onSnow) { // JUST stopped → plant a standing pair (both feet)
95
+ const yaw = this._lastYaw ?? 0, nx = Math.sin(yaw), nz = Math.cos(yaw);
96
+ const sx = -nz, sz = nx; // perpendicular
97
+ this._stamp(x + sx * lateral, z + sz * lateral, yaw, scale);
98
+ this._stamp(x - sx * lateral, z - sz * lateral, yaw, scale);
99
+ }
100
+ this._moving = false;
101
+ return;
102
+ }
103
+ this._moving = true;
104
+ const nx = dx / moved, nz = dz / moved; // unit travel direction
105
+ const yaw = Math.atan2(nx, nz); // align print length to travel
106
+ this._lastYaw = yaw;
107
+ const px = -nz, pz = nx; // perpendicular (left/right offset)
108
+ this._dist += moved;
109
+ const stride = STRIDE * strideMul;
110
+ while (this._dist >= stride) {
111
+ this._dist -= stride;
112
+ if (!onSnow) { this._foot = -this._foot; continue; }
113
+ this._stamp(x + px * lateral * this._foot, z + pz * lateral * this._foot, yaw, scale);
114
+ this._foot = -this._foot;
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,58 @@
1
+ // THE LAMP FIELD — one cheap lighting system for the whole open world, inside AND out.
2
+ //
3
+ // The problem it solves: a WebGPU forward renderer shades EVERY resident light on EVERY fragment and
4
+ // recompiles the pipeline whenever a light is added/removed (the multi-second "night freeze"), so real
5
+ // THREE lights are capped at ~3 for the entire world. lampGlow.js sidestepped that with fake additive
6
+ // glow — but that only paints a FLOOR decal; walls, props and faces stay dark. A saloon at night is too
7
+ // dark to read cards.
8
+ //
9
+ // The fix: feed the NEAREST FIXED-N lamps to the player into a fixed-size uniform array each frame
10
+ // (UniformArrayNode.updateType === RENDER repacks it automatically — no needsUpdate, no recompile), and
11
+ // ADD each lamp's real half-lambert N·L with distance falloff into the world/character materials'
12
+ // emissiveNode. Fixed N is baked into the shader graph => ONE pipeline forever, no light cap, constant
13
+ // per-fragment cost no matter how many lamps exist across how many towns. Real light on walls + faces,
14
+ // the SAME term inside and out. Pure forward TSL — works on the WebGL2 fallback too. See src/core/
15
+ // renderer.js, world.js (the lamp sort it feeds off), lampGlow.js (kept as the visible flame bulb).
16
+ import * as THREE from 'three/webgpu';
17
+ import { Fn, vec3, uniform, uniformArray, positionWorld, normalWorld } from 'three/tsl';
18
+
19
+ export const LAMP_N = 12; // nearest lamps shaded per fragment (fixed => one pipeline)
20
+ const WARM = [1.0, 0.62, 0.30]; // lamp colour (warm oil-flame), scaled by each lamp's intensity
21
+
22
+ // xyz = world position of the flame, w = 1 / range. Parked far below the world until fed.
23
+ const uLampPos = uniformArray(Array.from({ length: LAMP_N }, () => new THREE.Vector4(0, -9999, 0, 1)));
24
+ // rgb = colour * intensity, a unused.
25
+ const uLampCol = uniformArray(Array.from({ length: LAMP_N }, () => new THREE.Vector4(0, 0, 0, 0)));
26
+ export const uLampNight = uniform(0); // 0 by day … 1 at night (fed = lampGlow.k), fades the whole term
27
+
28
+ // per-fragment lamp contribution, tinted by the surface albedo `alb` and gated by night. Returns a colour
29
+ // to ADD in emissiveNode (added after the sun/hemi BRDF = genuine additive fill).
30
+ export const lampLight = /*@__PURE__*/ Fn(([alb]) => {
31
+ const acc = vec3(0).toVar();
32
+ for (let i = 0; i < LAMP_N; i++) {
33
+ const lp = uLampPos.element(i);
34
+ const lc = uLampCol.element(i);
35
+ const toL = lp.xyz.sub(positionWorld);
36
+ const d = toL.length();
37
+ const L = toL.div(d.max(0.0001));
38
+ const atten = d.mul(lp.w).oneMinus().max(0); // 1 at the flame, 0 at range (w = 1/range)
39
+ const ndl = normalWorld.dot(L).mul(0.5).add(0.5); // HALF-lambert: soft fill, faces never go pure black
40
+ acc.addAssign(lc.xyz.mul(atten.mul(atten)).mul(ndl)); // quadratic falloff → pops in invisibly at the N-set edge
41
+ }
42
+ return acc.mul(alb).mul(uLampNight);
43
+ });
44
+
45
+ // CPU: pack the nearest lamps into the uniform arrays. `sorted` is world.js's _lampSorted:
46
+ // [{ p: { x, y, z, I?, range?, lift? } }, ...] nearest-first. Cheap — mutate in place, auto-repacked.
47
+ export function refreshLampField(sorted) {
48
+ for (let i = 0; i < LAMP_N; i++) {
49
+ const s = sorted && sorted[i];
50
+ if (s && s.p) {
51
+ const p = s.p, I = p.I ?? 1;
52
+ uLampPos.array[i].set(p.x, (p.y ?? 0) + (p.lift ?? 0), p.z, 1 / (p.range ?? 10));
53
+ uLampCol.array[i].set(WARM[0] * I, WARM[1] * I, WARM[2] * I, 0);
54
+ } else {
55
+ uLampCol.array[i].set(0, 0, 0, 0); // empty slot: zero colour = contributes nothing
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,92 @@
1
+ // FAKE LIGHT, SO WE CAN AFFORD A LIT TOWN.
2
+ //
3
+ // Real lights are the scarcest thing in this renderer: WebGPU's forward loop evaluates EVERY
4
+ // resident light on EVERY fragment, even one sitting at intensity 0 — which is why world.js
5
+ // keeps a FIXED pool of 2–6 point lights and slides them to whichever posts are nearest, and
6
+ // why the count may never change at runtime (a new light = a shader recompile = the multi-second
7
+ // night freeze this codebase keeps warning about). Six lights over a whole town is a lit lamp
8
+ // here and there and a dark street everywhere else.
9
+ //
10
+ // So light the street WITHOUT lights. Two pieces of geometry per lamp, both unlit, additive and
11
+ // depth-write-off, so they cost a draw call each for the WHOLE TOWN and no shading at all:
12
+ //
13
+ // FLAME a small additive ball at the wick. It reads as the lamp being ON from any angle —
14
+ // no billboarding shader needed, because a sphere looks the same from everywhere.
15
+ // POOL a flat disc of light on the dirt beneath, with a soft radial falloff painted into a
16
+ // texture. This is the bit that actually makes a street look lit: light ON something.
17
+ //
18
+ // Both fade in with the night (opacity is one shared uniform per material — free to animate),
19
+ // so they cost nothing by day. The real light pool still rides along for the nearest few posts,
20
+ // which is what gives genuine shading on the player and the boardwalk; this fills in the other
21
+ // forty. Unlimited lamps, zero recompiles.
22
+ import * as THREE from 'three/webgpu';
23
+
24
+ // A radial falloff, painted once into a 128² canvas. Smooth, warm at the centre, gone at the rim.
25
+ function glowTexture() {
26
+ const c = document.createElement('canvas');
27
+ c.width = c.height = 128;
28
+ const g = c.getContext('2d');
29
+ const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64);
30
+ grad.addColorStop(0.0, 'rgba(255, 216, 150, 1.0)');
31
+ grad.addColorStop(0.35, 'rgba(255, 190, 110, 0.55)');
32
+ grad.addColorStop(0.7, 'rgba(255, 170, 90, 0.16)');
33
+ grad.addColorStop(1.0, 'rgba(255, 160, 80, 0.0)');
34
+ g.fillStyle = grad;
35
+ g.fillRect(0, 0, 128, 128);
36
+ const t = new THREE.CanvasTexture(c);
37
+ t.colorSpace = THREE.SRGBColorSpace;
38
+ return t;
39
+ }
40
+
41
+ export class LampGlow {
42
+ // lamps: [{ x, y, z, ground }] — y is the flame, ground is the dirt under it
43
+ constructor(scene, lamps, { poolR = 5.6, flameR = 0.18 } = {}) {
44
+ this.lamps = lamps;
45
+ this.k = 0; // 0 by day … 1 at night (eased)
46
+ if (!lamps.length) return;
47
+
48
+ const tex = glowTexture();
49
+ // THE POOL on the ground. One InstancedMesh for every lamp in the world = ONE draw call.
50
+ this.poolMat = new THREE.MeshBasicMaterial({
51
+ map: tex, transparent: true, opacity: 0, depthWrite: false,
52
+ blending: THREE.AdditiveBlending, side: THREE.DoubleSide, fog: false, toneMapped: false,
53
+ });
54
+ const poolGeo = new THREE.PlaneGeometry(poolR * 2, poolR * 2).rotateX(-Math.PI / 2);
55
+ this.pool = new THREE.InstancedMesh(poolGeo, this.poolMat, lamps.length);
56
+ // THE FLAME at the wick — a ball, so it needs no billboarding to face you.
57
+ this.flameMat = new THREE.MeshBasicMaterial({
58
+ color: 0xffcf8a, transparent: true, opacity: 0, depthWrite: false,
59
+ blending: THREE.AdditiveBlending, fog: false, toneMapped: false,
60
+ });
61
+ this.flame = new THREE.InstancedMesh(new THREE.SphereGeometry(flameR, 8, 6), this.flameMat, lamps.length);
62
+
63
+ const m = new THREE.Matrix4();
64
+ const q = new THREE.Quaternion();
65
+ const s = new THREE.Vector3(1, 1, 1);
66
+ lamps.forEach((l, i) => {
67
+ // the pool sits a whisker above the dirt, or it z-fights the ground it is painted on
68
+ this.pool.setMatrixAt(i, m.compose(new THREE.Vector3(l.x, (l.ground ?? l.y - 2) + 0.03, l.z), q, s));
69
+ this.flame.setMatrixAt(i, m.compose(new THREE.Vector3(l.x, l.y, l.z), q, s));
70
+ });
71
+ for (const im of [this.pool, this.flame]) {
72
+ im.instanceMatrix.needsUpdate = true;
73
+ im.frustumCulled = false; // the town is one instanced mesh; culling it all-or-nothing is worse
74
+ im.castShadow = im.receiveShadow = false;
75
+ im.matrixAutoUpdate = false;
76
+ im.renderOrder = 3; // after the world, so the additive pass lands on top of it
77
+ scene.add(im);
78
+ }
79
+ console.log(`[lamps] ${lamps.length} glow lamps (2 draw calls, no lights)`);
80
+ }
81
+
82
+ // hour → lit. Lamps strike at 19.25 (~7:15pm, Nick — 18 lit them while it was still full daylight),
83
+ // out by 6.5 — the same window world.js lights its real pool on.
84
+ update(dt, hour) {
85
+ if (!this.pool) return;
86
+ const night = (hour >= 19.25 || hour < 6.5) ? 1 : 0;
87
+ this.k += (night - this.k) * Math.min(1, dt * 1.5);
88
+ if (this.k < 0.002 && this.poolMat.opacity === 0) return;
89
+ this.poolMat.opacity = this.k * 0.95;
90
+ this.flameMat.opacity = this.k;
91
+ }
92
+ }