sindicate 0.1.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.
@@ -0,0 +1,85 @@
1
+ // Off-main-thread per-tile collision BVH builder.
2
+ //
3
+ // The tile streamer used to bake + merge + build a MeshBVH synchronously in
4
+ // World.update() — ~30–80ms PER TILE, which is the constant town/exploration
5
+ // stutter (the `world` phase in the freeze log). That work is pure geometry math
6
+ // (no DOM, no renderer), so it runs here in a Web Worker instead.
7
+ //
8
+ // Protocol: main thread posts { id, parts } where each part is
9
+ // { positions:Float32Array, index:Uint16/32Array|null, matrices:Float32Array(count*16), count }
10
+ // (positions/index are the source collider geometry; matrices are the WORLD
11
+ // transforms of every instance). We bake every instance into one big position
12
+ // buffer, build a MeshBVH, serialize it, and post { id, ok, serialized, position }
13
+ // back with everything transferred (zero-copy). The main thread deserializes into
14
+ // a real MeshBVH that still answers .shapecast()/.raycastFirst() — identical
15
+ // collision, just built off the hot path. See collision.js buildBVHAsync.
16
+ //
17
+ // IMPORTANT: plain 'three' here, NOT 'three/webgpu' — the webgpu entry pulls in
18
+ // renderer/DOM code that has no `document`/`window` in a worker. three-mesh-bvh is
19
+ // pure math and worker-safe.
20
+ import { BufferGeometry, BufferAttribute, Matrix4 } from 'three';
21
+ import { MeshBVH } from 'three-mesh-bvh';
22
+
23
+ const _m = new Matrix4();
24
+
25
+ // Bake every instance of every part into ONE non-indexed, position-only buffer
26
+ // (mirrors collision.js buildBVH), then build a BVH over it. Inlined matrix×point
27
+ // math + a single preallocated array — no per-instance geometry allocations.
28
+ function buildFromParts(parts) {
29
+ const bases = [];
30
+ let totalFloats = 0;
31
+ for (const p of parts) {
32
+ if (!p.positions || !p.count) continue;
33
+ let pos = p.positions;
34
+ if (p.index) {
35
+ const idx = p.index;
36
+ const out = new Float32Array(idx.length * 3);
37
+ for (let k = 0; k < idx.length; k++) {
38
+ const v = idx[k] * 3;
39
+ out[k * 3] = pos[v]; out[k * 3 + 1] = pos[v + 1]; out[k * 3 + 2] = pos[v + 2];
40
+ }
41
+ pos = out; // expanded to non-indexed
42
+ }
43
+ bases.push({ pos, count: p.count, matrices: p.matrices });
44
+ totalFloats += pos.length * p.count;
45
+ }
46
+ if (!totalFloats) return null;
47
+
48
+ const merged = new Float32Array(totalFloats);
49
+ let off = 0;
50
+ for (const b of bases) {
51
+ const pos = b.pos, vn = pos.length / 3;
52
+ for (let i = 0; i < b.count; i++) {
53
+ _m.fromArray(b.matrices, i * 16);
54
+ const e = _m.elements;
55
+ const e0 = e[0], e1 = e[1], e2 = e[2], e4 = e[4], e5 = e[5], e6 = e[6],
56
+ e8 = e[8], e9 = e[9], e10 = e[10], e12 = e[12], e13 = e[13], e14 = e[14];
57
+ for (let v = 0; v < vn; v++) {
58
+ const x = pos[v * 3], y = pos[v * 3 + 1], z = pos[v * 3 + 2];
59
+ merged[off++] = e0 * x + e4 * y + e8 * z + e12;
60
+ merged[off++] = e1 * x + e5 * y + e9 * z + e13;
61
+ merged[off++] = e2 * x + e6 * y + e10 * z + e14;
62
+ }
63
+ }
64
+ }
65
+
66
+ const geom = new BufferGeometry();
67
+ geom.setAttribute('position', new BufferAttribute(merged, 3));
68
+ const bvh = new MeshBVH(geom); // creates a sequential index internally
69
+ return { bvh, position: merged };
70
+ }
71
+
72
+ self.onmessage = (e) => {
73
+ const { id, parts } = e.data;
74
+ try {
75
+ const res = buildFromParts(parts);
76
+ if (!res) { self.postMessage({ id, ok: false, empty: true }); return; }
77
+ const serialized = MeshBVH.serialize(res.bvh, { cloneBuffers: false });
78
+ const transfer = [res.position.buffer, ...serialized.roots];
79
+ if (serialized.index) transfer.push(serialized.index.buffer);
80
+ if (serialized.indirectBuffer) transfer.push(serialized.indirectBuffer.buffer);
81
+ self.postMessage({ id, ok: true, serialized, position: res.position }, transfer);
82
+ } catch (err) {
83
+ self.postMessage({ id, ok: false, error: String(err && err.message || err) });
84
+ }
85
+ };
@@ -0,0 +1,174 @@
1
+ // Unified mesh collision (Phase 1: buildings) built on three-mesh-bvh.
2
+ //
3
+ // We bake the position-only geometry of static colliders into ONE merged
4
+ // BufferGeometry (world transforms applied) and build a BVH over it. The player
5
+ // capsule is then swept against that BVH each frame (see world.moveCapsule),
6
+ // which makes stairs/walls/slopes "just work" as real triangles — no per-object
7
+ // 2D colliders or heightfield hacks. The merged mesh is never rendered.
8
+ //
9
+ // three-mesh-bvh is geometry/math only (no renderer calls), so it is fine under
10
+ // the three/webgpu renderer; we feed it plain BufferGeometry data.
11
+ import * as THREE from 'three/webgpu';
12
+ import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
13
+ import { MeshBVH } from 'three-mesh-bvh';
14
+
15
+ // Merge every mesh under `objs` (world matrices baked, position attribute only)
16
+ // and return a MeshBVH over the result — or null if there's no geometry.
17
+ // objs: Object3Ds (regular + instanced meshes) baked at their current matrices.
18
+ // instanceGroups: { geometry, matrices(Float32Array), count } baked from explicit
19
+ // LOD0 geometry + full instance set (used for distance-LOD scatters like rocks,
20
+ // so collision uses LOD0 — never the far LOD — across all instances).
21
+ export function buildBVH(objs = [], instanceGroups = []) {
22
+ const geos = [];
23
+ const world = new THREE.Matrix4();
24
+ const inst = new THREE.Matrix4();
25
+ const addAt = (base, worldMat) => {
26
+ if (!base?.attributes?.position) return;
27
+ let g = base.clone();
28
+ for (const name of Object.keys(g.attributes)) {
29
+ if (name !== 'position') g.deleteAttribute(name);
30
+ }
31
+ g.applyMatrix4(worldMat);
32
+ if (g.index) g = g.toNonIndexed(); // normalize so merge doesn't mismatch
33
+ geos.push(g);
34
+ };
35
+ for (const o of objs) {
36
+ if (!o) continue;
37
+ o.updateMatrixWorld(true);
38
+ o.traverse((m) => {
39
+ if (!m.isMesh || !m.geometry?.attributes?.position) return;
40
+ // MOVING PARTS OPT OUT. The BVH is baked once and frozen, so anything that later moves
41
+ // would still be collided at the pose it had at boot — the door would swing open in front
42
+ // of you while its shut-pose triangles still stood in the doorway. The county's door leaves
43
+ // ride a BatchedMesh at the scene root, which this walk never reaches, so the flag is belt
44
+ // and braces; doors collide via the switchable slab in world.collide() (town.js doorsAt).
45
+ if (m.userData?.noBVH) return;
46
+ if (m.isInstancedMesh) {
47
+ for (let i = 0; i < m.count; i++) {
48
+ m.getMatrixAt(i, inst);
49
+ world.multiplyMatrices(m.matrixWorld, inst);
50
+ addAt(m.geometry, world);
51
+ }
52
+ } else {
53
+ addAt(m.geometry, m.matrixWorld);
54
+ }
55
+ });
56
+ }
57
+ for (const grp of instanceGroups) {
58
+ for (let i = 0; i < grp.count; i++) {
59
+ world.fromArray(grp.matrices, i * 16);
60
+ addAt(grp.geometry, world);
61
+ }
62
+ }
63
+ if (!geos.length) return null;
64
+ const merged = BufferGeometryUtils.mergeGeometries(geos, false);
65
+ if (!merged) return null;
66
+ return new MeshBVH(merged);
67
+ }
68
+
69
+ // ---- off-main-thread variant (see bvhWorker.js) -------------------------------
70
+ // Same result as buildBVH(), but the bake+merge+BVH (the ~30–80ms cost) runs in a
71
+ // Web Worker. Used for streamed tiles so collision never blocks the frame. The
72
+ // returned MeshBVH supports .shapecast()/.raycastFirst() identically.
73
+ let _worker = null, _workerBroken = false;
74
+ const _jobs = new Map();
75
+ let _nextJob = 1;
76
+
77
+ function _getWorker() {
78
+ if (_worker || _workerBroken) return _worker;
79
+ try {
80
+ _worker = new Worker(new URL('./bvhWorker.js', import.meta.url), { type: 'module' });
81
+ _worker.onmessage = (e) => {
82
+ const job = _jobs.get(e.data.id);
83
+ if (!job) return;
84
+ _jobs.delete(e.data.id);
85
+ if (!e.data.ok) { job.resolve(null); return; }
86
+ try {
87
+ const geom = new THREE.BufferGeometry();
88
+ geom.setAttribute('position', new THREE.BufferAttribute(e.data.position, 3));
89
+ const bvh = MeshBVH.deserialize(e.data.serialized, geom, { setIndex: true, indirect: Boolean(e.data.serialized.indirectBuffer) });
90
+ job.resolve(bvh);
91
+ } catch (err) { console.warn('[bvh] deserialize failed', err); job.resolve(null); }
92
+ };
93
+ _worker.onerror = (err) => {
94
+ console.warn('[bvh] worker error — falling back to sync builds', err?.message || err);
95
+ _workerBroken = true;
96
+ for (const job of _jobs.values()) job.resolve(null); // let callers fall back
97
+ _jobs.clear();
98
+ };
99
+ } catch (e) {
100
+ console.warn('[bvh] worker unavailable — sync builds', e);
101
+ _workerBroken = true;
102
+ }
103
+ return _worker;
104
+ }
105
+
106
+ // Gather serializable collider data WITHOUT mutating/neutering the live geometry:
107
+ // copy each source position/index, and the WORLD matrix of every instance.
108
+ function extractParts(objs, instanceGroups) {
109
+ const parts = [];
110
+ const inst = new THREE.Matrix4(), world = new THREE.Matrix4();
111
+ const copyPos = (g) => { const a = g.attributes.position.array; return new Float32Array(a); };
112
+ const copyIdx = (g) => (g.index ? new (g.index.array.constructor)(g.index.array) : null);
113
+ for (const o of objs) {
114
+ if (!o) continue;
115
+ o.updateMatrixWorld(true);
116
+ o.traverse((m) => {
117
+ if (!m.isMesh || !m.geometry?.attributes?.position) return;
118
+ // MOVING PARTS OPT OUT. The BVH is baked once and frozen, so anything that later moves
119
+ // would still be collided at the pose it had at boot — the door would swing open in front
120
+ // of you while its shut-pose triangles still stood in the doorway. The county's door leaves
121
+ // ride a BatchedMesh at the scene root, which this walk never reaches, so the flag is belt
122
+ // and braces; doors collide via the switchable slab in world.collide() (town.js doorsAt).
123
+ if (m.userData?.noBVH) return;
124
+ if (m.isInstancedMesh) {
125
+ if (!m.count) return;
126
+ const mats = new Float32Array(m.count * 16);
127
+ for (let i = 0; i < m.count; i++) { m.getMatrixAt(i, inst); world.multiplyMatrices(m.matrixWorld, inst); world.toArray(mats, i * 16); }
128
+ parts.push({ positions: copyPos(m.geometry), index: copyIdx(m.geometry), matrices: mats, count: m.count });
129
+ } else {
130
+ const mats = new Float32Array(16); m.matrixWorld.toArray(mats, 0);
131
+ parts.push({ positions: copyPos(m.geometry), index: copyIdx(m.geometry), matrices: mats, count: 1 });
132
+ }
133
+ });
134
+ }
135
+ for (const grp of instanceGroups) {
136
+ if (!grp?.geometry?.attributes?.position || !grp.count) continue;
137
+ const mats = new Float32Array(grp.matrices.subarray(0, grp.count * 16));
138
+ parts.push({ positions: copyPos(grp.geometry), index: copyIdx(grp.geometry), matrices: mats, count: grp.count });
139
+ }
140
+ return parts;
141
+ }
142
+
143
+ export async function buildBVHAsync(objs = [], instanceGroups = []) {
144
+ if (_workerBroken) return buildBVH(objs, instanceGroups);
145
+ const w = _getWorker();
146
+ if (!w) return buildBVH(objs, instanceGroups);
147
+ const parts = extractParts(objs, instanceGroups);
148
+ if (!parts.length) return null;
149
+ try {
150
+ const bvh = await new Promise((resolve) => {
151
+ const id = _nextJob++;
152
+ _jobs.set(id, { resolve });
153
+ const transfer = [];
154
+ for (const p of parts) { transfer.push(p.positions.buffer, p.matrices.buffer); if (p.index) transfer.push(p.index.buffer); }
155
+ w.postMessage({ id, parts }, transfer);
156
+ });
157
+ // worker died mid-job (resolved null via onerror) → rebuild synchronously so the tile still collides
158
+ if (bvh === null && _workerBroken) return buildBVH(objs, instanceGroups);
159
+ return bvh;
160
+ } catch (e) {
161
+ _workerBroken = true;
162
+ return buildBVH(objs, instanceGroups);
163
+ }
164
+ }
165
+
166
+ // Optional dev visualization: a wireframe mesh of the merged collision geometry,
167
+ // so the collider can be eyeballed without trusting the renderer's BVH helper.
168
+ export function colliderWireframe(bvh) {
169
+ if (!bvh) return null;
170
+ const mat = new THREE.MeshBasicMaterial({ color: 0x00ff88, wireframe: true, transparent: true, opacity: 0.4, depthTest: false });
171
+ const mesh = new THREE.Mesh(bvh.geometry, mat);
172
+ mesh.renderOrder = 999;
173
+ return mesh;
174
+ }
@@ -0,0 +1,347 @@
1
+ // Streaming grass — Option A: lightweight procedural blades (≈6 triangles each,
2
+ // vertex-colour gradient) as instanced chunks with GPU wind in a TSL node material.
3
+ // A small ring of tiles is generated on demand around the player and disposed when
4
+ // far, so only a few thousand blades exist/render. Blades also flatten under nearby
5
+ // entities (player + NPCs/enemies) and slowly spring back — a timestamped trail of
6
+ // recent positions drives the recovery, all evaluated on the GPU.
7
+ import * as THREE from 'three/webgpu';
8
+ import {
9
+ Fn, positionLocal, instanceIndex, time, sin, vec3, vec2, hash, float, mix,
10
+ positionWorld, cameraPosition, smoothstep, oneMinus, attribute, uniform, uniformArray,
11
+ Loop, If, step, max, sqrt, length, texture, normalize, dot, normalLocal, transformNormalToView,
12
+ } from 'three/tsl';
13
+ import { uWet, uCover, uSunDir, uWindDir, uWindStr } from './weatherUniforms.js';
14
+ import { waterNoiseTexture } from './waterNoise.js';
15
+ import { mulberry32 } from '../core/utils.js';
16
+
17
+ // Trample trail split so other entities can't evict the player's path: slots
18
+ // 0..TRAIL_P-1 are a dedicated player trail (≈3s of recovery), the rest hold the
19
+ // current positions of nearby NPCs/enemies.
20
+ const TRAIL_P = 20;
21
+ const TRAIL_E = 8;
22
+ const TRAIL = TRAIL_P + TRAIL_E;
23
+ const TRAMPLE_R = 0.9; // metres a sample flattens around it
24
+ const PLAYER_GATE = 36; // only blades within this of the player run the trample loop
25
+
26
+ // A CLUMP of blades per instance (braffolk: clumps read denser than single blades at the
27
+ // same triangle budget). Each blade: 3 paired levels + a POINTED single-vertex tip, a 2×
28
+ // stronger built-in bend, and "rounded cross-section" normals (left/right verts rotated
29
+ // ±38° around the blade's up axis) so the two sides of a blade light differently.
30
+ function makeClumpGeometry() {
31
+ const BLADES = 4, baseHalf = 0.05;
32
+ const pos = [], nrm = [], col = [], idx = [];
33
+ const base = new THREE.Color(0x3d5f24), tip = new THREE.Color(0x8ab551), c = new THREE.Color();
34
+ const rng = mulberry32(9182736);
35
+ let v = 0;
36
+ for (let b = 0; b < BLADES; b++) {
37
+ const yaw = rng() * 6.2832;
38
+ const cy = Math.cos(yaw), sy = Math.sin(yaw);
39
+ const ox = (rng() - 0.5) * 0.22, oz = (rng() - 0.5) * 0.22; // radial offset in the clump
40
+ const hgt = 0.75 + rng() * 0.5; // per-blade height
41
+ const leanX = (rng() - 0.5) * 0.16, leanZ = (rng() - 0.5) * 0.16;
42
+ const put = (lx, y, lz, nx, nz, t) => {
43
+ // rotate blade-local → clump-local, add offset + lean
44
+ const wx = lx * cy - lz * sy + ox + leanX * y;
45
+ const wz = lx * sy + lz * cy + oz + leanZ * y;
46
+ pos.push(wx, y * hgt, wz);
47
+ const wnx = nx * cy - nz * sy, wnz = nx * sy + nz * cy;
48
+ nrm.push(wnx * 0.75, 1.05, wnz * 0.75); // mostly UP (≈45°) — noon sun must still light the sward
49
+ c.copy(base).lerp(tip, t);
50
+ col.push(c.r, c.g, c.b);
51
+ };
52
+ const N38 = Math.sin(0.66), NC = Math.cos(0.66); // ±38° rounded cross-section
53
+ for (let i = 0; i <= 2; i++) {
54
+ const y = i / 3;
55
+ const hw = baseHalf * (1 - y * 0.7);
56
+ const zb = 0.28 * y * y; // 2× the old bend
57
+ put(-hw, y, zb, -N38, NC, y);
58
+ put(hw, y, zb, N38, NC, y);
59
+ }
60
+ put(0, 1, 0.28, 0, 1, 1); // pointed tip
61
+ const B = v;
62
+ for (let i = 0; i < 2; i++) { const a = B + i * 2; idx.push(a, a + 1, a + 3, a, a + 3, a + 2); }
63
+ idx.push(B + 4, B + 5, B + 6); // tip triangle
64
+ v += 7;
65
+ }
66
+ const g = new THREE.BufferGeometry();
67
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
68
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
69
+ g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
70
+ g.setIndex(idx);
71
+ g.computeBoundingSphere();
72
+ return g;
73
+ }
74
+
75
+ export class GrassField {
76
+ constructor(scene, opts) {
77
+ this.scene = scene;
78
+ this.accept = opts.accept;
79
+ this.heightAt = opts.heightAt;
80
+ this.tile = opts.tile ?? 16;
81
+ this.spacing = opts.spacing ?? 0.135;
82
+ this.jitter = opts.jitter ?? 0.11;
83
+ this.sMin = opts.sMin ?? 0.22;
84
+ this.sMax = opts.sMax ?? 0.40;
85
+ this.showR = opts.showR ?? 60;
86
+ this.hideR = opts.hideR ?? 72;
87
+ this.maxBuildsPerUpdate = 1; // >1 stacks multi-ms tile builds into one frame — visible gallop hitches
88
+
89
+ // trample trail (world x, z, timestamp) — old/unwritten slots sit far in the past
90
+ this._ring = Array.from({ length: TRAIL }, () => new THREE.Vector3(0, 0, -1000));
91
+ this.uTrail = uniformArray(this._ring, 'vec3');
92
+ this.uNow = uniform(0);
93
+ this.uPlayer = uniform(new THREE.Vector3());
94
+ this._pHead = 0; this._eHead = 0; this._now = 0; this._sampleT = 0;
95
+
96
+ this.geo = makeClumpGeometry();
97
+ this.mat = this._makeMaterial();
98
+ this.tiles = new Map();
99
+
100
+ // RESIDENT mesh pool: grass tiles never create meshes at runtime. r184 WebGPU pays
101
+ // ~3 pipeline-cache entries per new InstancedMesh OBJECT, and adding a mesh to the
102
+ // scene re-records the render bundle — per-tile mesh churn while moving was most of
103
+ // the remaining travel freezes (geo:+3 / render 45-60ms records) after the scatter
104
+ // residents landed. A fixed pool of capacity-sized meshes is built here at boot
105
+ // (their pipelines compile in the boot warm); a streamed-in tile checks one out and
106
+ // rewrites its instance buffers, a streamed-out tile returns it (visible=false pool
107
+ // slots skip the render-list walk). Per-tile bounding spheres keep frustum culling.
108
+ this.tileCap = Math.ceil((this.tile / this.spacing + 2) ** 2); // worst-case clumps in one tile
109
+ // tiles live while their centre is within hideR of the player → pool bound + margin
110
+ this.poolN = Math.ceil(Math.PI * this.hideR * this.hideR / (this.tile * this.tile)) + 12;
111
+ this._pool = [];
112
+ for (let i = 0; i < this.poolN; i++) this._pool.push(this._makePoolMesh());
113
+ }
114
+
115
+ _makePoolMesh() {
116
+ const geo = this.geo.clone();
117
+ geo.setAttribute('aBase', new THREE.InstancedBufferAttribute(new Float32Array(this.tileCap * 2), 2));
118
+ geo.setAttribute('aTNorm', new THREE.InstancedBufferAttribute(new Float32Array(this.tileCap * 3), 3));
119
+ const im = new THREE.InstancedMesh(geo, this.mat, this.tileCap); // instanceMatrix born zero-filled (warm-flash safe)
120
+ im.count = 0;
121
+ im.visible = false;
122
+ im.castShadow = false; im.receiveShadow = true;
123
+ im.name = 'scatter:grass'; // 'grass' is in the BVH EXCLUDE regex — never collides
124
+ im.frustumCulled = true;
125
+ im.boundingSphere = new THREE.Sphere(); // set per checkout to the tile's bounds
126
+ im.matrixAutoUpdate = false; im.updateMatrix();
127
+ this.scene.add(im);
128
+ return im;
129
+ }
130
+
131
+ _makeMaterial() {
132
+ const mat = new THREE.MeshStandardNodeMaterial({ vertexColors: true, roughness: 1, metalness: 0 });
133
+ mat.side = THREE.DoubleSide;
134
+ const aBase = attribute('aBase', 'vec2'); // this blade's world x,z
135
+ const noiseTex = waterNoiseTexture();
136
+
137
+ // wind — travelling gust fronts (braffolk): a tileable noise field advected downwind,
138
+ // squared so lulls rest and fronts SWEEP across whole meadows; a small per-blade sine
139
+ // jiggle keeps neighbours desynchronised inside a front. One vertex texture fetch.
140
+ const phase = hash(instanceIndex).mul(6.2832);
141
+ const bend = positionLocal.y.mul(positionLocal.y);
142
+ // fronts advect DOWNWIND on the shared global wind (world/wind.js), and the blades lean the way it
143
+ // blows — strength = the local gust front × the global wind strength, so a calm hour barely stirs and
144
+ // a gust sweeps the whole meadow over. (Was a fixed vec2(4.2,3.4) front + a fixed +X/+Z lean.)
145
+ const gust = texture(noiseTex, aBase.sub(uWindDir.mul(5.4).mul(time)).mul(1 / 42)).r;
146
+ const gustStr = gust.mul(gust).mul(0.5).add(0.04).mul(uWindStr.mul(1.3).add(0.28));
147
+ const jig = sin(time.mul(2.4).add(phase)).mul(0.05).add(sin(time.mul(3.9).add(phase.mul(1.7))).mul(0.028));
148
+ const lean = float(0.95).mul(gustStr);
149
+ const swayX = uWindDir.x.mul(lean).add(jig).mul(bend);
150
+ const swayZ = uWindDir.y.mul(lean).add(jig.mul(0.7)).mul(bend);
151
+
152
+ // trample — only evaluated for blades near the player (cost bounded). Each trail
153
+ // sample flattens nearby blades; flatten holds ~1s then eases out by ~3s. Loop/If
154
+ // require a Fn() stack context, so the whole position calc lives inside one.
155
+ const uTrail = this.uTrail, uNow = this.uNow, uPlayer = this.uPlayer;
156
+ mat.positionNode = Fn(() => {
157
+ // strongest trample influence on this blade (0 upright .. 1 fully flattened),
158
+ // plus the unit direction of that strongest sample (so the tip droops the right way)
159
+ const flat = float(0).toVar();
160
+ const dir = vec2(0, 0).toVar();
161
+ const playerD = length(aBase.sub(vec2(uPlayer.x, uPlayer.z)));
162
+ If(playerD.lessThan(float(PLAYER_GATE)), () => {
163
+ Loop(TRAIL, ({ i }) => {
164
+ const s = uTrail.element(i);
165
+ const age = uNow.sub(s.z);
166
+ const dx = aBase.x.sub(s.x), dz = aBase.y.sub(s.y);
167
+ const d = max(sqrt(dx.mul(dx).add(dz.mul(dz))), float(0.001));
168
+ const ageGate = step(float(0), age).mul(oneMinus(step(float(3), age)));
169
+ const distGate = oneMinus(smoothstep(float(TRAMPLE_R * 0.6), float(TRAMPLE_R), d));
170
+ const recover = oneMinus(smoothstep(float(1), float(3), age)); // 1 hold → 0 risen
171
+ const f = ageGate.mul(distGate).mul(recover);
172
+ const stronger = step(flat, f); // 1 when this sample beats the best so far
173
+ dir.assign(dir.mul(oneMinus(stronger)).add(vec2(dx, dz).div(d).mul(stronger))); // adopt its unit dir
174
+ flat.assign(max(flat, f));
175
+ });
176
+ });
177
+ const flatten = flat.clamp(0, 1);
178
+ const droop = flatten.mul(bend); // bend the upper blade, planted base
179
+ const newY = positionLocal.y.mul(oneMinus(flatten.mul(0.85))); // shorten toward the ground
180
+ // tip droops outward by ~its own height (bounded — no summed push) so it lies over
181
+ const px = dir.x.mul(droop).mul(0.7);
182
+ const pz = dir.y.mul(droop).mul(0.7);
183
+ const w = oneMinus(flatten); // trampled blades stop swaying
184
+ const sx = swayX.mul(w), sz = swayZ.mul(w);
185
+ // cantilever: a bent blade gets SHORTER, not longer — dip the tip by the lean²
186
+ const dip = sx.mul(sx).add(sz.mul(sz)).mul(0.3);
187
+ return vec3(positionLocal.x.add(sx).add(px), newY.sub(dip), positionLocal.z.add(sz).add(pz));
188
+ })();
189
+
190
+ const camDist = positionWorld.sub(cameraPosition).length();
191
+
192
+ // far blades light like the hillside they grow on: blend the blade's rounded normal
193
+ // toward the baked TERRAIN normal with distance (braffolk) — kills far-field sparkle
194
+ // and makes meadows follow the sun across slopes. Instance matrices are composed in
195
+ // world space (mesh at origin), so instance-transformed normalLocal IS world space.
196
+ const aTN = attribute('aTNorm', 'vec3');
197
+ const nBlend = smoothstep(float(7), float(26), camDist).mul(0.6).add(0.3);
198
+ mat.normalNode = transformNormalToView(normalize(mix(normalLocal, aTN, nBlend)));
199
+
200
+ // colour: dryness patches + root AO + light-through-the-blade, then weather on top
201
+ mat.colorNode = Fn(() => {
202
+ // broad noise patches drift meadows green → straw gold (braffolk's patch dryness)
203
+ const dry = smoothstep(float(0.48), float(0.8), texture(noiseTex, aBase.mul(1 / 52)).g).mul(0.65);
204
+ const graded = attribute('color', 'vec3');
205
+ const dried = mix(graded, graded.mul(vec3(1.45, 1.2, 0.5)), dry);
206
+ // roots sit in each other's shadow — cheap AO keeps dense clumps from reading flat
207
+ const ao = positionLocal.y.mul(0.35).add(0.72).clamp(0.72, 1);
208
+ // sun/moon shining THROUGH blades when looking toward the light (translucency)
209
+ const vDir = normalize(positionWorld.sub(cameraPosition));
210
+ const back = dot(vDir, uSunDir).clamp(0, 1).pow(4).mul(positionLocal.y).mul(0.4);
211
+ const lit = dried.mul(ao).add(vec3(0.5, 0.55, 0.2).mul(back));
212
+ const c = lit.mul(mix(float(1.0), float(0.7), uWet));
213
+ const tip = smoothstep(float(0.1), float(0.55), positionLocal.y);
214
+ return mix(c, vec3(0.95, 0.96, 1.0), uCover.mul(tip));
215
+ })();
216
+
217
+ // dissolve toward the render edge (fragment stage — no circular dependency)
218
+ mat.opacityNode = oneMinus(smoothstep(float(this.showR - 14), float(this.showR - 3), camDist));
219
+ mat.alphaHash = true;
220
+ return mat;
221
+ }
222
+
223
+ // Feed recent entity positions each frame; sampled into the trail at intervals so
224
+ // it spans a few seconds (drives the spring-back). Pass player first.
225
+ tickTrample(dt, sources) {
226
+ this._now += dt;
227
+ this.uNow.value = this._now;
228
+ if (sources.length) this.uPlayer.value.set(sources[0].x, 0, sources[0].z);
229
+ this._sampleT -= dt;
230
+ if (this._sampleT > 0) return;
231
+ this._sampleT = 0.15;
232
+ // player → dedicated trail slots (0..TRAIL_P-1) so it isn't evicted by others
233
+ if (sources.length) {
234
+ this._ring[this._pHead % TRAIL_P].set(sources[0].x, sources[0].z, this._now);
235
+ this._pHead++;
236
+ }
237
+ // nearby NPCs/enemies → the entity slots (TRAIL_P..TRAIL-1)
238
+ for (let i = 1; i < sources.length; i++) {
239
+ this._ring[TRAIL_P + (this._eHead % TRAIL_E)].set(sources[i].x, sources[i].z, this._now);
240
+ this._eHead++;
241
+ }
242
+ }
243
+
244
+ update(px, pz) {
245
+ const T = this.tile, hide2 = this.hideR * this.hideR, show2 = this.showR * this.showR;
246
+ for (const [k, t] of this.tiles) {
247
+ const dx = t.cx - px, dz = t.cz - pz;
248
+ if (dx * dx + dz * dz > hide2) {
249
+ // return the mesh to the pool — no scene.remove (render-bundle re-record), no dispose
250
+ if (t.mesh) { t.mesh.visible = false; t.mesh.count = 0; this._pool.push(t.mesh); }
251
+ this.tiles.delete(k);
252
+ }
253
+ }
254
+ const ctx = Math.round(px / T), ctz = Math.round(pz / T), span = Math.ceil(this.showR / T) + 1;
255
+ let budget = this.maxBuildsPerUpdate;
256
+ for (let i = -span; i <= span && budget > 0; i++) {
257
+ for (let j = -span; j <= span && budget > 0; j++) {
258
+ const tx = ctx + i, tz = ctz + j, cx = tx * T, cz = tz * T;
259
+ const dx = cx - px, dz = cz - pz;
260
+ if (dx * dx + dz * dz > show2) continue;
261
+ const key = tx * 100003 + tz;
262
+ if (this.tiles.has(key)) continue;
263
+ this._buildTile(tx, tz, cx, cz, key);
264
+ budget--;
265
+ }
266
+ }
267
+ }
268
+
269
+ _buildTile(tx, tz, cx, cz, key) {
270
+ const rng = mulberry32(((tx * 73856093) ^ (tz * 19349663)) >>> 0);
271
+ const half = this.tile / 2;
272
+ // Coarse height grid + bilinear interp: heightAt (fbm + pads + road scan) per blade
273
+ // was ~14k full samples per tile — the gallop-streaming hitch. 17×17 real samples
274
+ // cover the tile; cells whose corners disagree by >0.35 m (river banks, cliff
275
+ // benches) fall back to exact heightAt so blades never float on rough ground.
276
+ const HG = 16, gstep = this.tile / HG, gx0 = cx - half, gz0 = cz - half;
277
+ const hg = new Float32Array((HG + 1) * (HG + 1));
278
+ for (let gi = 0; gi <= HG; gi++) {
279
+ for (let gj = 0; gj <= HG; gj++) hg[gi * (HG + 1) + gj] = this.heightAt(gx0 + gi * gstep, gz0 + gj * gstep);
280
+ }
281
+ const hAt = (x, z) => {
282
+ let fx = (x - gx0) / gstep, fz = (z - gz0) / gstep;
283
+ fx = fx < 0 ? 0 : fx > HG ? HG : fx; fz = fz < 0 ? 0 : fz > HG ? HG : fz;
284
+ const i0 = Math.min(HG - 1, fx | 0), j0 = Math.min(HG - 1, fz | 0);
285
+ const tx2 = fx - i0, tz2 = fz - j0, r0 = i0 * (HG + 1) + j0;
286
+ const h00 = hg[r0], h01 = hg[r0 + 1], h10 = hg[r0 + HG + 1], h11 = hg[r0 + HG + 2];
287
+ const steep = Math.max(h00, h01, h10, h11) - Math.min(h00, h01, h10, h11) > 0.35;
288
+ if (steep) return this.heightAt(x, z);
289
+ return (h00 * (1 - tx2) + h10 * tx2) * (1 - tz2) + (h01 * (1 - tx2) + h11 * tx2) * tz2;
290
+ };
291
+ // per-grid-node terrain normals from the height grid (finite differences — no extra
292
+ // heightAt calls); each clump takes its nearest node's normal for the far-lighting blend
293
+ const tn = new Float32Array((HG + 1) * (HG + 1) * 3);
294
+ for (let gi = 0; gi <= HG; gi++) {
295
+ for (let gj = 0; gj <= HG; gj++) {
296
+ const iA = Math.max(0, gi - 1), iB = Math.min(HG, gi + 1);
297
+ const jA = Math.max(0, gj - 1), jB = Math.min(HG, gj + 1);
298
+ const dx = (hg[iB * (HG + 1) + gj] - hg[iA * (HG + 1) + gj]) / ((iB - iA) * gstep);
299
+ const dz = (hg[gi * (HG + 1) + jB] - hg[gi * (HG + 1) + jA]) / ((jB - jA) * gstep);
300
+ const o = (gi * (HG + 1) + gj) * 3, inl = 1 / Math.hypot(dx, 1, dz);
301
+ tn[o] = -dx * inl; tn[o + 1] = inl; tn[o + 2] = -dz * inl;
302
+ }
303
+ }
304
+ // flat number array (x,y,z,ry,s,nx,ny,nz per clump) — an object per blade was megabytes
305
+ // of short-lived GC churn per tile, felt as periodic pauses while streaming at speed
306
+ const data = [];
307
+ let yMin = Infinity, yMax = -Infinity;
308
+ for (let x = cx - half; x < cx + half; x += this.spacing) {
309
+ for (let z = cz - half; z < cz + half; z += this.spacing) {
310
+ const jx = x + (rng() - 0.5) * this.jitter, jz = z + (rng() - 0.5) * this.jitter;
311
+ const ry = rng() * 6.2832, s = this.sMin + rng() * (this.sMax - this.sMin);
312
+ if (!this.accept(jx, jz)) continue;
313
+ const gi = Math.max(0, Math.min(HG, Math.round((jx - gx0) / gstep)));
314
+ const gj = Math.max(0, Math.min(HG, Math.round((jz - gz0) / gstep)));
315
+ const ni = (gi * (HG + 1) + gj) * 3;
316
+ const y = hAt(jx, jz) - 0.02;
317
+ if (y < yMin) yMin = y; if (y > yMax) yMax = y;
318
+ data.push(jx, y, jz, ry, s, tn[ni], tn[ni + 1], tn[ni + 2]);
319
+ }
320
+ }
321
+ let n = data.length / 8;
322
+ if (!n) { this.tiles.set(key, { mesh: null, cx, cz }); return; }
323
+ // check a RESIDENT mesh out of the pool and rewrite its buffers — never create one here.
324
+ // Pool dry (transient burst) → leave the tile unbuilt; update() naturally retries it.
325
+ const im = this._pool.pop();
326
+ if (!im) return;
327
+ if (n > this.tileCap) { console.error(`[grass] tile overflow (${n} > cap ${this.tileCap}) — clumps dropped`); n = this.tileCap; }
328
+ const baseA = im.geometry.getAttribute('aBase'), tnAt = im.geometry.getAttribute('aTNorm');
329
+ const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), up = new THREE.Vector3(0, 1, 0), p = new THREE.Vector3(), sc = new THREE.Vector3();
330
+ for (let i = 0; i < n; i++) {
331
+ const o = i * 8;
332
+ baseA.array[i * 2] = data[o]; baseA.array[i * 2 + 1] = data[o + 2];
333
+ tnAt.array[i * 3] = data[o + 5]; tnAt.array[i * 3 + 1] = data[o + 6]; tnAt.array[i * 3 + 2] = data[o + 7];
334
+ q.setFromAxisAngle(up, data[o + 3]);
335
+ m4.compose(p.set(data[o], data[o + 1], data[o + 2]), q, sc.set(data[o + 4], data[o + 4], data[o + 4]));
336
+ im.setMatrixAt(i, m4);
337
+ }
338
+ im.count = n;
339
+ im.instanceMatrix.needsUpdate = true;
340
+ baseA.needsUpdate = true; tnAt.needsUpdate = true;
341
+ // conservative tile sphere: half-diagonal + terrain span + blade height/sway headroom
342
+ im.boundingSphere.center.set(cx, (yMin + yMax) / 2, cz);
343
+ im.boundingSphere.radius = Math.hypot(half * 1.42, (yMax - yMin) / 2 + 1.5);
344
+ im.visible = true;
345
+ this.tiles.set(key, { mesh: im, cx, cz });
346
+ }
347
+ }