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.
- package/README.md +20 -0
- package/docs/contracts/README.md +22 -0
- package/package.json +38 -0
- package/src/core/decalField.js +151 -0
- package/src/core/quality.js +109 -0
- package/src/core/utils.js +48 -0
- package/src/index.js +31 -0
- package/src/systems/shatter.js +94 -0
- package/src/vendor/FBXLoader.js +4586 -0
- package/src/world/bvhWorker.js +85 -0
- package/src/world/collision.js +174 -0
- package/src/world/grass.js +347 -0
- package/src/world/water.js +227 -0
- package/src/world/waterNoise.js +47 -0
- package/src/world/weatherUniforms.js +24 -0
- package/src/world/wind.js +40 -0
|
@@ -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
|
+
}
|