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.
- package/README.md +63 -2
- package/docs/api/README.md +28 -0
- package/docs/api/anim.md +125 -0
- package/docs/api/assets.md +208 -0
- package/docs/api/collision.md +101 -0
- package/docs/api/fbxloader.md +50 -0
- package/docs/api/grass.md +103 -0
- package/docs/api/input.md +164 -0
- package/docs/api/quality.md +159 -0
- package/docs/api/renderer.md +115 -0
- package/docs/api/retarget.md +149 -0
- package/docs/api/shatter.md +121 -0
- package/docs/api/utils-decals.md +188 -0
- package/docs/api/water.md +143 -0
- package/docs/api/wind-weather-uniforms.md +105 -0
- package/docs/contracts/render.md +32 -0
- package/docs/contracts/time-feel.md +52 -0
- package/package.json +1 -1
- package/src/core/anim.js +184 -0
- package/src/core/assets.js +596 -0
- package/src/core/engine.js +284 -0
- package/src/core/input/bindings.js +30 -0
- package/src/core/input/gamepad.js +92 -0
- package/src/core/input/index.js +62 -0
- package/src/core/input/keyboard.js +16 -0
- package/src/core/input/mouse.js +48 -0
- package/src/core/quality.js +5 -1
- package/src/core/renderer.js +104 -0
- package/src/core/retarget.js +282 -0
- package/src/index.js +19 -2
- package/src/systems/aim.js +114 -0
- package/src/systems/campfire.js +150 -0
- package/src/systems/climbing.js +136 -0
- package/src/systems/deadeye.js +332 -0
- package/src/systems/encounters.js +123 -0
- package/src/systems/entity.js +524 -0
- package/src/systems/fistCurl.js +38 -0
- package/src/systems/footsteps.js +161 -0
- package/src/systems/glass.js +67 -0
- package/src/systems/herd.js +277 -0
- package/src/systems/horse.js +361 -0
- package/src/systems/mountedTraveller.js +518 -0
- package/src/systems/nav.js +343 -0
- package/src/systems/npc.js +880 -0
- package/src/systems/pathFollow.js +107 -0
- package/src/systems/platform.js +484 -0
- package/src/systems/riding.js +580 -0
- package/src/systems/seatedRider.js +396 -0
- package/src/systems/skybirds.js +129 -0
- package/src/systems/trainCamera.js +414 -0
- package/src/systems/traveller.js +254 -0
- package/src/systems/tumbleweeds.js +92 -0
- package/src/systems/vat.js +327 -0
- package/src/systems/vfx.js +472 -0
- package/src/world/blobShadows.js +109 -0
- package/src/world/clouds.js +61 -0
- package/src/world/flora.js +87 -0
- package/src/world/footprints.js +117 -0
- package/src/world/lampField.js +58 -0
- package/src/world/lampGlow.js +92 -0
- package/src/world/scatter.js +1077 -0
- package/src/world/sky.js +363 -0
- package/src/world/tileManager.js +197 -0
- package/src/world/walkHumps.js +74 -0
- 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
|
+
}
|