@aethervtt/shard-terrain 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/biomes.d.ts +1 -0
- package/dist/biomes.js +3 -0
- package/dist/chunk.d.ts +0 -44
- package/dist/chunk.js +2 -127
- package/dist/colliders.d.ts +16 -1
- package/dist/colliders.js +39 -24
- package/dist/components.d.ts +5 -0
- package/dist/components.js +7 -2
- package/dist/cube-sphere.d.ts +50 -0
- package/dist/cube-sphere.js +224 -0
- package/dist/grid-mesh.d.ts +49 -0
- package/dist/grid-mesh.js +208 -0
- package/dist/heightfield/bake.d.ts +120 -0
- package/dist/heightfield/bake.js +494 -0
- package/dist/heightfield/colliders.d.ts +39 -0
- package/dist/heightfield/colliders.js +152 -0
- package/dist/heightfield/component.d.ts +14 -0
- package/dist/heightfield/component.js +59 -0
- package/dist/heightfield/deflate.d.ts +11 -0
- package/dist/heightfield/deflate.js +484 -0
- package/dist/heightfield/heightmap.d.ts +27 -0
- package/dist/heightfield/heightmap.js +160 -0
- package/dist/heightfield/kernel.d.ts +277 -0
- package/dist/heightfield/kernel.js +1033 -0
- package/dist/heightfield/material.d.ts +13 -0
- package/dist/heightfield/material.js +394 -0
- package/dist/heightfield/methods.d.ts +104 -0
- package/dist/heightfield/methods.js +218 -0
- package/dist/heightfield/pack.d.ts +59 -0
- package/dist/heightfield/pack.js +127 -0
- package/dist/heightfield/pages.d.ts +91 -0
- package/dist/heightfield/pages.js +244 -0
- package/dist/heightfield/project.d.ts +40 -0
- package/dist/heightfield/project.js +55 -0
- package/dist/heightfield/queries.d.ts +48 -0
- package/dist/heightfield/queries.js +150 -0
- package/dist/heightfield/render.d.ts +108 -0
- package/dist/heightfield/render.js +727 -0
- package/dist/heightfield/root-grid.d.ts +38 -0
- package/dist/heightfield/root-grid.js +109 -0
- package/dist/heightfield/runtime.d.ts +105 -0
- package/dist/heightfield/runtime.js +312 -0
- package/dist/heightfield/source-asset.d.ts +39 -0
- package/dist/heightfield/source-asset.js +372 -0
- package/dist/heightfield/source.d.ts +151 -0
- package/dist/heightfield/source.js +402 -0
- package/dist/heightfield/stack.d.ts +29 -0
- package/dist/heightfield/stack.js +158 -0
- package/dist/heightfield/system.d.ts +22 -0
- package/dist/heightfield/system.js +165 -0
- package/dist/heightfield/testing.d.ts +317 -0
- package/dist/heightfield/testing.js +441 -0
- package/dist/heightfield/worker.js +47 -0
- package/dist/heights.d.ts +8 -3
- package/dist/heights.js +8 -4
- package/dist/index.d.ts +24 -7
- package/dist/index.js +158 -8
- package/dist/lod.d.ts +27 -0
- package/dist/lod.js +23 -1
- package/dist/material.js +2 -1
- package/dist/methods.js +64 -16
- package/dist/overlays.d.ts +2 -0
- package/dist/overlays.js +48 -6
- package/dist/planet.d.ts +2 -7
- package/dist/planet.js +8 -18
- package/dist/plugin.d.ts +10 -3
- package/dist/plugin.js +48 -8
- package/dist/quadtree.d.ts +61 -39
- package/dist/quadtree.js +44 -208
- package/dist/render.d.ts +7 -1
- package/dist/render.js +8 -6
- package/dist/test-planet.d.ts +39 -0
- package/dist/test-planet.js +40 -4
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +42 -2
- package/package.json +16 -16
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { sha256Hex } from "@aethervtt/shard-assets";
|
|
2
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
3
|
+
import { loadNoiseKernel, NOISE_KERNEL_MODULE } from "@aethervtt/shard-noise";
|
|
4
|
+
import {
|
|
5
|
+
BAKE_VERSION,
|
|
6
|
+
bakeBlock,
|
|
7
|
+
deflate,
|
|
8
|
+
encodePage,
|
|
9
|
+
PAGE,
|
|
10
|
+
pageBytes,
|
|
11
|
+
pageNormal,
|
|
12
|
+
parentPage,
|
|
13
|
+
readPage,
|
|
14
|
+
SIDE
|
|
15
|
+
} from "./kernel.js";
|
|
16
|
+
import { PACK, packOf, readIndex, writePack } from "./pack.js";
|
|
17
|
+
import { mainNoise } from "./stack.js";
|
|
18
|
+
const WORKER = new URL("./worker.js", import.meta.url).href;
|
|
19
|
+
const MANIFEST = "manifest.json";
|
|
20
|
+
const encoder = new TextEncoder();
|
|
21
|
+
const decoder = new TextDecoder();
|
|
22
|
+
async function readManifest(store) {
|
|
23
|
+
const bytes = await store.read(MANIFEST);
|
|
24
|
+
if (!bytes) return void 0;
|
|
25
|
+
try {
|
|
26
|
+
const m = JSON.parse(decoder.decode(bytes));
|
|
27
|
+
return m.format === 1 ? m : void 0;
|
|
28
|
+
} catch {
|
|
29
|
+
return void 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function gridOf(input) {
|
|
33
|
+
const l = input.layout;
|
|
34
|
+
return {
|
|
35
|
+
sizeX: l.sizeX,
|
|
36
|
+
sizeZ: l.sizeZ,
|
|
37
|
+
spacing: l.spacing,
|
|
38
|
+
paintSpacing: l.paintSpacing,
|
|
39
|
+
depth: l.depth,
|
|
40
|
+
block: l.block,
|
|
41
|
+
heightRange: input.source.heightRange
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function rectBox(r, grow) {
|
|
45
|
+
const a = r.rotation * Math.PI / 180;
|
|
46
|
+
const hw = r.size[0] / 2;
|
|
47
|
+
const hd = r.size[1] / 2;
|
|
48
|
+
const ex = Math.abs(Math.cos(a)) * hw + Math.abs(Math.sin(a)) * hd + grow;
|
|
49
|
+
const ez = Math.abs(Math.sin(a)) * hw + Math.abs(Math.cos(a)) * hd + grow;
|
|
50
|
+
return [r.at[0] - ex, r.at[1] - ez, r.at[0] + ex, r.at[1] + ez];
|
|
51
|
+
}
|
|
52
|
+
const overlaps = (a, b) => a[0] <= b[2] && b[0] <= a[2] && a[1] <= b[3] && b[1] <= a[3];
|
|
53
|
+
async function blockKeys(input, stack) {
|
|
54
|
+
const { source, layout } = input;
|
|
55
|
+
const base = JSON.stringify({
|
|
56
|
+
v: BAKE_VERSION,
|
|
57
|
+
grid: gridOf(input),
|
|
58
|
+
seed: source.seed,
|
|
59
|
+
layers: source.layers.map((l) => l.name)
|
|
60
|
+
});
|
|
61
|
+
const parts = [];
|
|
62
|
+
const digest = async (value, extra) => {
|
|
63
|
+
const text = JSON.stringify(value);
|
|
64
|
+
const bytes = encoder.encode(text);
|
|
65
|
+
if (!extra) return sha256Hex(bytes);
|
|
66
|
+
const f = Float64Array.from(extra);
|
|
67
|
+
const all = new Uint8Array(bytes.length + f.byteLength);
|
|
68
|
+
all.set(bytes);
|
|
69
|
+
all.set(new Uint8Array(f.buffer), bytes.length);
|
|
70
|
+
return sha256Hex(all);
|
|
71
|
+
};
|
|
72
|
+
for (let i = 0; i < source.height.length; i++) {
|
|
73
|
+
const l = source.height[i];
|
|
74
|
+
const s = stack.height[i];
|
|
75
|
+
if (l.kind === "noise") {
|
|
76
|
+
parts.push({
|
|
77
|
+
box: l.region ? rectBox(l.region, l.falloff) : void 0,
|
|
78
|
+
digest: await digest(["h", i, l, input.deps[l.noise.path]?.hash ?? ""], void 0)
|
|
79
|
+
});
|
|
80
|
+
} else if (l.kind === "image") {
|
|
81
|
+
parts.push({
|
|
82
|
+
box: rectBox(l, l.falloff),
|
|
83
|
+
digest: await digest(["h", i, l, input.deps[l.image.path]?.hash ?? ""], void 0)
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
const sp = s.spline;
|
|
87
|
+
parts.push({
|
|
88
|
+
box: [sp.minX, sp.minZ, sp.maxX, sp.maxZ],
|
|
89
|
+
digest: await digest(["h", i, l, source.splines[l.spline]], sp.pts)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < source.paint.length; i++) {
|
|
94
|
+
const p = source.paint[i];
|
|
95
|
+
let box;
|
|
96
|
+
const clip = (b) => {
|
|
97
|
+
box = box ? [
|
|
98
|
+
Math.max(box[0], b[0]),
|
|
99
|
+
Math.max(box[1], b[1]),
|
|
100
|
+
Math.min(box[2], b[2]),
|
|
101
|
+
Math.min(box[3], b[3])
|
|
102
|
+
] : b;
|
|
103
|
+
};
|
|
104
|
+
if (p.mask) clip(rectBox(p.mask, p.blend));
|
|
105
|
+
const sp = stack.paint[i].spline;
|
|
106
|
+
if (sp) clip([sp.minX, sp.minZ, sp.maxX, sp.maxZ]);
|
|
107
|
+
parts.push({
|
|
108
|
+
box,
|
|
109
|
+
digest: await digest(
|
|
110
|
+
[
|
|
111
|
+
"p",
|
|
112
|
+
i,
|
|
113
|
+
p,
|
|
114
|
+
p.noise ? input.deps[p.noise.ref.path]?.hash ?? "" : "",
|
|
115
|
+
p.mask ? input.deps[p.mask.ref.path]?.hash ?? "" : "",
|
|
116
|
+
p.spline ? source.splines[p.spline] : null
|
|
117
|
+
],
|
|
118
|
+
sp?.pts
|
|
119
|
+
)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const margin = (2 ** Math.min(layout.blockLevels, layout.depth) + 1) * layout.spacing;
|
|
123
|
+
const size = layout.block * layout.leafSize;
|
|
124
|
+
const keys = [];
|
|
125
|
+
for (let bz = 0; bz < layout.blocksZ; bz++) {
|
|
126
|
+
for (let bx = 0; bx < layout.blocksX; bx++) {
|
|
127
|
+
const box = [
|
|
128
|
+
bx * size - margin,
|
|
129
|
+
bz * size - margin,
|
|
130
|
+
Math.min(layout.sizeX, (bx + 1) * size) + margin,
|
|
131
|
+
Math.min(layout.sizeZ, (bz + 1) * size) + margin
|
|
132
|
+
];
|
|
133
|
+
const touching = parts.filter((p) => !p.box || overlaps(p.box, box)).map((p) => p.digest);
|
|
134
|
+
keys.push(await sha256Hex(encoder.encode(`${base}
|
|
135
|
+
${touching.join("\n")}`)));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return keys;
|
|
139
|
+
}
|
|
140
|
+
class PageReader {
|
|
141
|
+
indexes = /* @__PURE__ */ new Map();
|
|
142
|
+
pages = /* @__PURE__ */ new Map();
|
|
143
|
+
store;
|
|
144
|
+
layout;
|
|
145
|
+
/** Pages this bake made, by "level/x/z". */
|
|
146
|
+
fresh;
|
|
147
|
+
/** Whether the stored packs match this grid (else they're ignored). */
|
|
148
|
+
usePacks;
|
|
149
|
+
constructor(store, layout, fresh, usePacks) {
|
|
150
|
+
this.store = store;
|
|
151
|
+
this.layout = layout;
|
|
152
|
+
this.fresh = fresh;
|
|
153
|
+
this.usePacks = usePacks;
|
|
154
|
+
}
|
|
155
|
+
index(path) {
|
|
156
|
+
let p = this.indexes.get(path);
|
|
157
|
+
if (!p) {
|
|
158
|
+
p = this.usePacks ? this.store.read(path).then((bytes) => bytes ? readIndex(path, bytes) : void 0) : Promise.resolve(void 0);
|
|
159
|
+
this.indexes.set(path, p);
|
|
160
|
+
}
|
|
161
|
+
return p;
|
|
162
|
+
}
|
|
163
|
+
/** Index entry and bytes of a stored page, or undefined. */
|
|
164
|
+
async stored(level, x, z) {
|
|
165
|
+
const depth = this.layout.depth - level;
|
|
166
|
+
const { path, slot } = packOf(depth, x, z);
|
|
167
|
+
const index = await this.index(path);
|
|
168
|
+
const e = index?.entries[slot];
|
|
169
|
+
if (!e || e.length === 0) return void 0;
|
|
170
|
+
const bytes = await this.store.read(path);
|
|
171
|
+
return { entry: e, data: bytes.subarray(e.offset, e.offset + e.length) };
|
|
172
|
+
}
|
|
173
|
+
/** A node's decoded page (fresh first), or null past the terrain's edge or where none is stored. */
|
|
174
|
+
page(level, x, z) {
|
|
175
|
+
const key = `${level}/${x}/${z}`;
|
|
176
|
+
let p = this.pages.get(key);
|
|
177
|
+
if (!p) {
|
|
178
|
+
p = (async () => {
|
|
179
|
+
const n = this.nodes(level);
|
|
180
|
+
if (x < 0 || z < 0 || x >= n[0] || z >= n[1]) return null;
|
|
181
|
+
const f = this.fresh.get(key);
|
|
182
|
+
const data = f ? f.data : (await this.stored(level, x, z))?.data;
|
|
183
|
+
return data ? readPage(data, level === 0, this.layout.cells) : null;
|
|
184
|
+
})();
|
|
185
|
+
this.pages.set(key, p);
|
|
186
|
+
}
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
/** Height range of a node (fresh first). */
|
|
190
|
+
async range(level, x, z) {
|
|
191
|
+
const f = this.fresh.get(`${level}/${x}/${z}`);
|
|
192
|
+
if (f) return [f.min, f.max];
|
|
193
|
+
const s = await this.stored(level, x, z);
|
|
194
|
+
return s ? [s.entry.min, s.entry.max] : void 0;
|
|
195
|
+
}
|
|
196
|
+
nodes(level) {
|
|
197
|
+
return [this.layout.leavesX >> level, this.layout.leavesZ >> level];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function bakeTerrain(input, stack, store, options = {}) {
|
|
201
|
+
const t0 = performance.now();
|
|
202
|
+
const { layout } = input;
|
|
203
|
+
const D = layout.depth;
|
|
204
|
+
const lmax = Math.min(layout.blockLevels, D);
|
|
205
|
+
const previous = await readManifest(store);
|
|
206
|
+
const grid = gridOf(input);
|
|
207
|
+
const compatible = !options.force && previous !== void 0 && previous.bakeVersion === BAKE_VERSION && JSON.stringify(previous.grid) === JSON.stringify(grid);
|
|
208
|
+
const keys = await blockKeys(input, stack);
|
|
209
|
+
const blocks = {};
|
|
210
|
+
const stale = [];
|
|
211
|
+
for (let bz = 0; bz < layout.blocksZ; bz++) {
|
|
212
|
+
for (let bx = 0; bx < layout.blocksX; bx++) {
|
|
213
|
+
const id = `${bx},${bz}`;
|
|
214
|
+
const key = keys[bz * layout.blocksX + bx];
|
|
215
|
+
const old = compatible ? previous.blocks[id] : void 0;
|
|
216
|
+
if (old && old.key === key) blocks[id] = old;
|
|
217
|
+
else stale.push([bx, bz]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const fresh = /* @__PURE__ */ new Map();
|
|
221
|
+
const reader = new PageReader(store, layout, fresh, compatible);
|
|
222
|
+
if (compatible) {
|
|
223
|
+
for (let bz = 0; bz < layout.blocksZ; bz++) {
|
|
224
|
+
for (let bx = 0; bx < layout.blocksX; bx++) {
|
|
225
|
+
const id = `${bx},${bz}`;
|
|
226
|
+
if (!blocks[id]) continue;
|
|
227
|
+
const { path } = packOf(D, bx * layout.block, bz * layout.block);
|
|
228
|
+
if (!await reader.index(path)) {
|
|
229
|
+
delete blocks[id];
|
|
230
|
+
stale.push([bx, bz]);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const kernel = await loadNoiseKernel();
|
|
236
|
+
const workers = options.workers && options.workers.size > 0 ? options.workers : void 0;
|
|
237
|
+
let done = 0;
|
|
238
|
+
let poolMs = 0;
|
|
239
|
+
const outOfRange = [];
|
|
240
|
+
const blockSize = layout.block * layout.leafSize;
|
|
241
|
+
await Promise.all(
|
|
242
|
+
stale.map(async ([bx, bz]) => {
|
|
243
|
+
const result = workers ? await workers.run(
|
|
244
|
+
WORKER,
|
|
245
|
+
"bakeBlock",
|
|
246
|
+
[NOISE_KERNEL_MODULE, kernel.module, stack, layout, bx, bz],
|
|
247
|
+
{ kind: "terrain/bake-block" }
|
|
248
|
+
) : bakeBlock(mainNoise(), stack, layout, bx, bz);
|
|
249
|
+
poolMs += result.ms;
|
|
250
|
+
const errors = new Array(D + 1).fill(0);
|
|
251
|
+
for (const p of result.pages) {
|
|
252
|
+
fresh.set(`${p.level}/${p.x}/${p.z}`, p);
|
|
253
|
+
if (p.level > 0) errors[p.level] = Math.max(errors[p.level], p.error);
|
|
254
|
+
}
|
|
255
|
+
result.above.forEach((e, i) => {
|
|
256
|
+
errors[lmax + 1 + i] = e;
|
|
257
|
+
});
|
|
258
|
+
blocks[`${bx},${bz}`] = {
|
|
259
|
+
key: keys[bz * layout.blocksX + bx],
|
|
260
|
+
errors,
|
|
261
|
+
range: result.range,
|
|
262
|
+
clipped: result.clipped,
|
|
263
|
+
lowest: result.lowest,
|
|
264
|
+
highest: result.highest,
|
|
265
|
+
ms: result.ms
|
|
266
|
+
};
|
|
267
|
+
if (result.clipped > 0) {
|
|
268
|
+
const rect = [
|
|
269
|
+
bx * blockSize,
|
|
270
|
+
bz * blockSize,
|
|
271
|
+
Math.min(layout.sizeX, (bx + 1) * blockSize),
|
|
272
|
+
Math.min(layout.sizeZ, (bz + 1) * blockSize)
|
|
273
|
+
];
|
|
274
|
+
outOfRange.push({
|
|
275
|
+
rect,
|
|
276
|
+
samples: result.clipped,
|
|
277
|
+
lowest: result.lowest,
|
|
278
|
+
highest: result.highest
|
|
279
|
+
});
|
|
280
|
+
options.warn?.(
|
|
281
|
+
new ShardError(
|
|
282
|
+
"terrain/out-of-range",
|
|
283
|
+
`${result.clipped} heights in [${rect.join(", ")}] fall outside heightRange [${input.source.heightRange.join(", ")}] (from ${result.lowest.toFixed(1)} to ${result.highest.toFixed(1)} m) and were clipped`,
|
|
284
|
+
{
|
|
285
|
+
path: "/heightRange",
|
|
286
|
+
hint: "Widen heightRange to cover them (heights are 16-bit over it), or lower the layers that push past it."
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
options.onProgress?.(++done, stale.length);
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
let ancestors = 0;
|
|
295
|
+
const normals = /* @__PURE__ */ new WeakMap();
|
|
296
|
+
const v = new Float64Array(3);
|
|
297
|
+
const unpacked = (page) => {
|
|
298
|
+
let n = normals.get(page);
|
|
299
|
+
if (!n) {
|
|
300
|
+
n = new Float32Array(SIDE * SIDE * 3);
|
|
301
|
+
for (let k = 0; k < SIDE * SIDE; k++) {
|
|
302
|
+
pageNormal(page, k % SIDE, Math.floor(k / SIDE), v);
|
|
303
|
+
n[k * 3] = v[0];
|
|
304
|
+
n[k * 3 + 1] = v[1];
|
|
305
|
+
n[k * 3 + 2] = v[2];
|
|
306
|
+
}
|
|
307
|
+
normals.set(page, n);
|
|
308
|
+
}
|
|
309
|
+
return n;
|
|
310
|
+
};
|
|
311
|
+
for (let L = lmax + 1; L <= D; L++) {
|
|
312
|
+
const dirty = /* @__PURE__ */ new Set();
|
|
313
|
+
for (const [bx, bz] of stale)
|
|
314
|
+
dirty.add(`${bx * layout.block >> L},${bz * layout.block >> L}`);
|
|
315
|
+
for (const id of [...dirty].sort()) {
|
|
316
|
+
const [X, Z] = id.split(",").map(Number);
|
|
317
|
+
const ring = new Array(16).fill(null);
|
|
318
|
+
await Promise.all(
|
|
319
|
+
ring.map(async (_, k) => {
|
|
320
|
+
ring[k] = await reader.page(L - 1, 2 * X + k % 4 - 1, 2 * Z + Math.floor(k / 4) - 1);
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
const children = [ring[5], ring[6], ring[9], ring[10]];
|
|
324
|
+
const normalAt = (gi, gj, out) => {
|
|
325
|
+
let c = gi < 0 ? -1 : gi > 2 * PAGE ? 2 : gi === 2 * PAGE ? 1 : Math.floor(gi / PAGE);
|
|
326
|
+
let r = gj < 0 ? -1 : gj > 2 * PAGE ? 2 : gj === 2 * PAGE ? 1 : Math.floor(gj / PAGE);
|
|
327
|
+
let page = ring[(r + 1) * 4 + c + 1];
|
|
328
|
+
if (!page) {
|
|
329
|
+
gi = Math.min(2 * PAGE, Math.max(0, gi));
|
|
330
|
+
gj = Math.min(2 * PAGE, Math.max(0, gj));
|
|
331
|
+
c = gi === 2 * PAGE ? 1 : Math.floor(gi / PAGE);
|
|
332
|
+
r = gj === 2 * PAGE ? 1 : Math.floor(gj / PAGE);
|
|
333
|
+
page = ring[(r + 1) * 4 + c + 1];
|
|
334
|
+
}
|
|
335
|
+
if (!page) {
|
|
336
|
+
out[0] = 0;
|
|
337
|
+
out[1] = 1;
|
|
338
|
+
out[2] = 0;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const n = unpacked(page);
|
|
342
|
+
const o = ((gj - r * PAGE) * SIDE + gi - c * PAGE) * 3;
|
|
343
|
+
out[0] = n[o];
|
|
344
|
+
out[1] = n[o + 1];
|
|
345
|
+
out[2] = n[o + 2];
|
|
346
|
+
};
|
|
347
|
+
const parent = parentPage(children, layout.cells, normalAt);
|
|
348
|
+
let min = 65535;
|
|
349
|
+
let max = 0;
|
|
350
|
+
for (let c = 0; c < 4; c++) {
|
|
351
|
+
const range = await reader.range(L - 1, 2 * X + (c & 1), 2 * Z + (c >> 1));
|
|
352
|
+
if (!range) continue;
|
|
353
|
+
min = Math.min(min, range[0]);
|
|
354
|
+
max = Math.max(max, range[1]);
|
|
355
|
+
}
|
|
356
|
+
let error = 0;
|
|
357
|
+
const span = 2 ** L / layout.block;
|
|
358
|
+
for (let bz = Z * span; bz < Math.min(layout.blocksZ, (Z + 1) * span); bz++)
|
|
359
|
+
for (let bx = X * span; bx < Math.min(layout.blocksX, (X + 1) * span); bx++)
|
|
360
|
+
error = Math.max(error, blocks[`${bx},${bz}`]?.errors[L] ?? 0);
|
|
361
|
+
fresh.set(`${L}/${X}/${Z}`, {
|
|
362
|
+
level: L,
|
|
363
|
+
x: X,
|
|
364
|
+
z: Z,
|
|
365
|
+
data: deflate(encodePage(parent, false, layout.cells)),
|
|
366
|
+
min,
|
|
367
|
+
max,
|
|
368
|
+
error
|
|
369
|
+
});
|
|
370
|
+
ancestors++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const packs = /* @__PURE__ */ new Map();
|
|
374
|
+
for (const p of fresh.values()) {
|
|
375
|
+
const depth = D - p.level;
|
|
376
|
+
const { path } = packOf(depth, p.x, p.z);
|
|
377
|
+
packs.set(path, { depth, gx: Math.floor(p.x / PACK), gz: Math.floor(p.z / PACK) });
|
|
378
|
+
}
|
|
379
|
+
let bytes = 0;
|
|
380
|
+
for (const [path, { depth, gx, gz }] of [...packs].sort((a, b) => a[0] < b[0] ? -1 : 1)) {
|
|
381
|
+
const level = D - depth;
|
|
382
|
+
const pages = [];
|
|
383
|
+
for (let slot = 0; slot < PACK * PACK; slot++) {
|
|
384
|
+
const x = gx * PACK + slot % PACK;
|
|
385
|
+
const z = gz * PACK + Math.floor(slot / PACK);
|
|
386
|
+
const f = fresh.get(`${level}/${x}/${z}`);
|
|
387
|
+
if (f) pages.push(f);
|
|
388
|
+
else {
|
|
389
|
+
const s = await reader.stored(level, x, z);
|
|
390
|
+
pages.push(s ? { data: s.data, ...s.entry } : null);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const pack = writePack(depth, gx, gz, pages);
|
|
394
|
+
await store.write(path, pack);
|
|
395
|
+
bytes += pack.length;
|
|
396
|
+
}
|
|
397
|
+
const depthErrors = new Array(D + 1).fill(0);
|
|
398
|
+
for (const record of Object.values(blocks)) {
|
|
399
|
+
for (let L = 1; L <= D; L++)
|
|
400
|
+
depthErrors[D - L] = Math.max(depthErrors[D - L], record.errors[L] ?? 0);
|
|
401
|
+
}
|
|
402
|
+
const sorted = {};
|
|
403
|
+
for (let bz = 0; bz < layout.blocksZ; bz++) {
|
|
404
|
+
for (let bx = 0; bx < layout.blocksX; bx++) {
|
|
405
|
+
const record = blocks[`${bx},${bz}`];
|
|
406
|
+
if (record) sorted[`${bx},${bz}`] = record;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const manifest = {
|
|
410
|
+
format: 1,
|
|
411
|
+
bakeVersion: BAKE_VERSION,
|
|
412
|
+
grid,
|
|
413
|
+
blocks: sorted,
|
|
414
|
+
depthErrors,
|
|
415
|
+
sourceHash: input.hash
|
|
416
|
+
};
|
|
417
|
+
await store.write(MANIFEST, encoder.encode(`${JSON.stringify(manifest)}
|
|
418
|
+
`));
|
|
419
|
+
return {
|
|
420
|
+
blocks: layout.blocksX * layout.blocksZ,
|
|
421
|
+
rebaked: stale.length,
|
|
422
|
+
dirtied: stale.map(([x, z]) => `${x},${z}`),
|
|
423
|
+
pages: fresh.size,
|
|
424
|
+
ancestors,
|
|
425
|
+
bytes,
|
|
426
|
+
ms: performance.now() - t0,
|
|
427
|
+
msPerBlock: stale.length > 0 ? poolMs / stale.length : 0,
|
|
428
|
+
outOfRange
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function terrainStats(input, store, manifest) {
|
|
432
|
+
const { layout } = input;
|
|
433
|
+
let pages = 0;
|
|
434
|
+
let rawBytes = 0;
|
|
435
|
+
if (manifest) {
|
|
436
|
+
for (let depth = 0; depth <= layout.depth; depth++) {
|
|
437
|
+
const level = layout.depth - depth;
|
|
438
|
+
const nx = layout.leavesX >> level;
|
|
439
|
+
const nz = layout.leavesZ >> level;
|
|
440
|
+
for (let gz = 0; gz < Math.ceil(nz / PACK); gz++) {
|
|
441
|
+
for (let gx = 0; gx < Math.ceil(nx / PACK); gx++) {
|
|
442
|
+
const path = `d${depth}/${gx}_${gz}.pack`;
|
|
443
|
+
const bytes = await store.read(path);
|
|
444
|
+
if (!bytes) continue;
|
|
445
|
+
for (const e of readIndex(path, bytes).entries) {
|
|
446
|
+
if (e.length === 0) continue;
|
|
447
|
+
pages++;
|
|
448
|
+
rawBytes += pageBytes(level === 0, layout.cells);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const samples = layout.sizeX / layout.spacing * (layout.sizeZ / layout.spacing);
|
|
455
|
+
const bytesOnDisk = await store.size();
|
|
456
|
+
const times = Object.values(manifest?.blocks ?? {}).map((b) => b.ms);
|
|
457
|
+
return {
|
|
458
|
+
baked: manifest !== void 0,
|
|
459
|
+
current: manifest?.sourceHash === input.hash,
|
|
460
|
+
size: [layout.sizeX, layout.sizeZ],
|
|
461
|
+
spacing: layout.spacing,
|
|
462
|
+
roots: layout.rootsX * layout.rootsZ,
|
|
463
|
+
depth: layout.depth,
|
|
464
|
+
blocks: layout.blocksX * layout.blocksZ,
|
|
465
|
+
pages,
|
|
466
|
+
rawBytes,
|
|
467
|
+
bytesOnDisk,
|
|
468
|
+
bytesPerSample: rawBytes / samples,
|
|
469
|
+
diskBytesPerSample: bytesOnDisk / samples,
|
|
470
|
+
msPerBlock: times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0,
|
|
471
|
+
slowestBlockMs: times.length > 0 ? Math.max(...times) : 0
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
async function packHash(files) {
|
|
475
|
+
const paths = [...files.keys()].filter((p) => p !== MANIFEST).sort();
|
|
476
|
+
let size = 0;
|
|
477
|
+
for (const p of paths) size += p.length + files.get(p).length;
|
|
478
|
+
const all = new Uint8Array(size);
|
|
479
|
+
let o = 0;
|
|
480
|
+
for (const p of paths) {
|
|
481
|
+
all.set(encoder.encode(p), o);
|
|
482
|
+
o += p.length;
|
|
483
|
+
all.set(files.get(p), o);
|
|
484
|
+
o += files.get(p).length;
|
|
485
|
+
}
|
|
486
|
+
return sha256Hex(all);
|
|
487
|
+
}
|
|
488
|
+
export {
|
|
489
|
+
bakeTerrain,
|
|
490
|
+
blockKeys,
|
|
491
|
+
packHash,
|
|
492
|
+
readManifest,
|
|
493
|
+
terrainStats
|
|
494
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Entity, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { HeightfieldRuntime } from './runtime.js';
|
|
3
|
+
/** A heightfield collider tile: one leaf page as a fixed body. */
|
|
4
|
+
export interface ColliderTile {
|
|
5
|
+
key: number;
|
|
6
|
+
x: number;
|
|
7
|
+
z: number;
|
|
8
|
+
/** The collider entity, or −1 while not wanted. */
|
|
9
|
+
entity: Entity;
|
|
10
|
+
/** The terrain version its heights are from. */
|
|
11
|
+
version: number;
|
|
12
|
+
used: number;
|
|
13
|
+
/** Heights (m), SIDE × SIDE, row by row along +Z: what Rapier gets. */
|
|
14
|
+
heights: number[];
|
|
15
|
+
}
|
|
16
|
+
/** A terrain's collider tiles, and when the wanted ones are due. */
|
|
17
|
+
export declare class TileSet {
|
|
18
|
+
readonly tiles: Map<number, ColliderTile>;
|
|
19
|
+
/** Wanted keys → the frame each becomes a tile. */
|
|
20
|
+
readonly due: Map<number, number>;
|
|
21
|
+
/** Keys wanted this frame, sorted. */
|
|
22
|
+
wanted: number[];
|
|
23
|
+
/** Tiles made from a page that had arrived, and baked on the main thread at their due frame. */
|
|
24
|
+
fromPages: number;
|
|
25
|
+
bakedInline: number;
|
|
26
|
+
}
|
|
27
|
+
export declare function tilesOf(rt: HeightfieldRuntime): TileSet;
|
|
28
|
+
/** Leaf tiles within reach of the runtime's anchors, as sorted keys. */
|
|
29
|
+
export declare function wantedTiles(rt: HeightfieldRuntime): number[];
|
|
30
|
+
/**
|
|
31
|
+
* Keeps a heightfield's collider tiles around its anchors (spec 0071): a leaf page within reach
|
|
32
|
+
* becomes a fixed body with a Rapier heightfield exactly COLLIDER_DELAY frames after it's wanted,
|
|
33
|
+
* from its page when the read has landed and baked here when it hasn't. Both are the same bytes
|
|
34
|
+
* (canonical samples), so the world never depends on IO timing. Unwanted tiles despawn but stay
|
|
35
|
+
* cached (least recently used out).
|
|
36
|
+
*/
|
|
37
|
+
export declare function updateHeightfieldColliders(world: World, rt: HeightfieldRuntime, frame: number, cacheSize: number): void;
|
|
38
|
+
/** Drops every collider tile (terrain despawned). */
|
|
39
|
+
export declare function clearHeightfieldColliders(world: World, rt: HeightfieldRuntime): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ChildOf, Derived } from "@aethervtt/shard-core";
|
|
2
|
+
import { NavSource } from "@aethervtt/shard-nav";
|
|
3
|
+
import { Collider, RigidBody } from "@aethervtt/shard-physics";
|
|
4
|
+
import { placeInGrid, propagateSubtree, Transform } from "@aethervtt/shard-transform";
|
|
5
|
+
import { COLLIDER_DELAY } from "../colliders.js";
|
|
6
|
+
import { TerrainChunk } from "./component.js";
|
|
7
|
+
import { dequantize, LEAF_SIDE, SIDE } from "./kernel.js";
|
|
8
|
+
import { keyX, keyZ, nodeKey } from "./pages.js";
|
|
9
|
+
import { mainNoise } from "./stack.js";
|
|
10
|
+
class TileSet {
|
|
11
|
+
tiles = /* @__PURE__ */ new Map();
|
|
12
|
+
/** Wanted keys → the frame each becomes a tile. */
|
|
13
|
+
due = /* @__PURE__ */ new Map();
|
|
14
|
+
/** Keys wanted this frame, sorted. */
|
|
15
|
+
wanted = [];
|
|
16
|
+
/** Tiles made from a page that had arrived, and baked on the main thread at their due frame. */
|
|
17
|
+
fromPages = 0;
|
|
18
|
+
bakedInline = 0;
|
|
19
|
+
}
|
|
20
|
+
function tilesOf(rt) {
|
|
21
|
+
let set = rt.parts.get("colliders");
|
|
22
|
+
if (!set) {
|
|
23
|
+
set = new TileSet();
|
|
24
|
+
rt.parts.set("colliders", set);
|
|
25
|
+
}
|
|
26
|
+
return set;
|
|
27
|
+
}
|
|
28
|
+
const wantedKeys = /* @__PURE__ */ new Set();
|
|
29
|
+
function wantedTiles(rt) {
|
|
30
|
+
wantedKeys.clear();
|
|
31
|
+
const layout = rt.layout;
|
|
32
|
+
const size = layout.leafSize;
|
|
33
|
+
const D = layout.depth;
|
|
34
|
+
for (let i = 0; i < rt.anchors; i++) {
|
|
35
|
+
const ax = rt.anchorPos[i * 3];
|
|
36
|
+
const az = rt.anchorPos[i * 3 + 2];
|
|
37
|
+
const r = rt.anchorRadius[i];
|
|
38
|
+
const x0 = Math.max(0, Math.floor((ax - r) / size));
|
|
39
|
+
const x1 = Math.min(layout.leavesX - 1, Math.floor((ax + r) / size));
|
|
40
|
+
const z0 = Math.max(0, Math.floor((az - r) / size));
|
|
41
|
+
const z1 = Math.min(layout.leavesZ - 1, Math.floor((az + r) / size));
|
|
42
|
+
for (let z = z0; z <= z1; z++) {
|
|
43
|
+
for (let x = x0; x <= x1; x++) {
|
|
44
|
+
const dx = Math.max(x * size - ax, 0, ax - (x + 1) * size);
|
|
45
|
+
const dz = Math.max(z * size - az, 0, az - (z + 1) * size);
|
|
46
|
+
if (dx * dx + dz * dz <= r * r) wantedKeys.add(nodeKey(D, x, z));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...wantedKeys].sort((a, b) => a - b);
|
|
51
|
+
}
|
|
52
|
+
function updateHeightfieldColliders(world, rt, frame, cacheSize) {
|
|
53
|
+
const set = tilesOf(rt);
|
|
54
|
+
const pages = rt.pages;
|
|
55
|
+
if (!rt.ready || !pages) return;
|
|
56
|
+
const wanted = wantedTiles(rt);
|
|
57
|
+
set.wanted = wanted;
|
|
58
|
+
const D = rt.layout.depth;
|
|
59
|
+
for (const key of wanted) {
|
|
60
|
+
const tile = set.tiles.get(key);
|
|
61
|
+
if (tile && tile.version === rt.version) {
|
|
62
|
+
tile.used = frame;
|
|
63
|
+
if (tile.entity < 0) spawnTile(world, rt, tile);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let due = set.due.get(key);
|
|
67
|
+
if (due === void 0) {
|
|
68
|
+
due = frame + COLLIDER_DELAY;
|
|
69
|
+
set.due.set(key, due);
|
|
70
|
+
void pages.load(D, keyX(key), keyZ(key)).catch(() => {
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (frame < due) continue;
|
|
74
|
+
set.due.delete(key);
|
|
75
|
+
const cached = pages.get(key);
|
|
76
|
+
const page = cached ?? pages.leafNow(mainNoise(), rt.stack, keyX(key), keyZ(key));
|
|
77
|
+
if (cached) set.fromPages++;
|
|
78
|
+
else set.bakedInline++;
|
|
79
|
+
const heights = new Array(SIDE * SIDE);
|
|
80
|
+
for (let j = 0; j < SIDE; j++) {
|
|
81
|
+
for (let i = 0; i < SIDE; i++)
|
|
82
|
+
heights[j * SIDE + i] = dequantize(page.heights[(j + 1) * LEAF_SIDE + i + 1], rt.lo, rt.hi);
|
|
83
|
+
}
|
|
84
|
+
if (tile) despawnTile(world, tile);
|
|
85
|
+
const next = {
|
|
86
|
+
key,
|
|
87
|
+
x: keyX(key),
|
|
88
|
+
z: keyZ(key),
|
|
89
|
+
entity: -1,
|
|
90
|
+
version: rt.version,
|
|
91
|
+
used: frame,
|
|
92
|
+
heights
|
|
93
|
+
};
|
|
94
|
+
set.tiles.set(key, next);
|
|
95
|
+
spawnTile(world, rt, next);
|
|
96
|
+
}
|
|
97
|
+
const keep = new Set(wanted);
|
|
98
|
+
for (const key of set.due.keys()) if (!keep.has(key)) set.due.delete(key);
|
|
99
|
+
for (const tile of set.tiles.values())
|
|
100
|
+
if (!keep.has(tile.key) && tile.entity >= 0) despawnTile(world, tile);
|
|
101
|
+
if (set.tiles.size > cacheSize) {
|
|
102
|
+
const idle = [...set.tiles.values()].filter((t) => t.entity < 0).sort((a, b) => a.used - b.used || a.key - b.key);
|
|
103
|
+
for (let i = 0; i < idle.length && set.tiles.size > cacheSize; i++)
|
|
104
|
+
set.tiles.delete(idle[i].key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const center = new Float64Array(3);
|
|
108
|
+
function spawnTile(world, rt, tile) {
|
|
109
|
+
const size = rt.layout.leafSize;
|
|
110
|
+
const e = world.spawn(
|
|
111
|
+
[RigidBody, { kind: "fixed" }],
|
|
112
|
+
[
|
|
113
|
+
Collider,
|
|
114
|
+
{
|
|
115
|
+
shape: "heightfield",
|
|
116
|
+
halfExtents: [size / 2, 1, size / 2],
|
|
117
|
+
heightfield: { rows: SIDE, cols: SIDE, heights: tile.heights }
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
TerrainChunk,
|
|
122
|
+
{ terrain: rt.entity, key: `${rt.layout.depth}/${tile.x}/${tile.z}`, kind: "collider" }
|
|
123
|
+
],
|
|
124
|
+
[ChildOf, { parent: rt.entity }],
|
|
125
|
+
NavSource,
|
|
126
|
+
Transform,
|
|
127
|
+
Derived
|
|
128
|
+
);
|
|
129
|
+
center[0] = (tile.x + 0.5) * size;
|
|
130
|
+
center[1] = 0;
|
|
131
|
+
center[2] = (tile.z + 0.5) * size;
|
|
132
|
+
placeInGrid(world, e, rt.entity, center);
|
|
133
|
+
propagateSubtree(world, e);
|
|
134
|
+
tile.entity = e;
|
|
135
|
+
}
|
|
136
|
+
function despawnTile(world, tile) {
|
|
137
|
+
if (world.isAlive(tile.entity)) world.despawn(tile.entity);
|
|
138
|
+
tile.entity = -1;
|
|
139
|
+
}
|
|
140
|
+
function clearHeightfieldColliders(world, rt) {
|
|
141
|
+
const set = tilesOf(rt);
|
|
142
|
+
for (const tile of set.tiles.values()) despawnTile(world, tile);
|
|
143
|
+
set.tiles.clear();
|
|
144
|
+
set.due.clear();
|
|
145
|
+
}
|
|
146
|
+
export {
|
|
147
|
+
TileSet,
|
|
148
|
+
clearHeightfieldColliders,
|
|
149
|
+
tilesOf,
|
|
150
|
+
updateHeightfieldColliders,
|
|
151
|
+
wantedTiles
|
|
152
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const Terrain: import("@aethervtt/shard-core").ComponentDef<{
|
|
2
|
+
readonly source: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"terrain/TerrainSource"> | null, "object">;
|
|
3
|
+
readonly errorPixels: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
4
|
+
readonly vertexPixels: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
5
|
+
readonly colliderRadius: import("@aethervtt/shard-core").FieldType<number, "f32">;
|
|
6
|
+
readonly skirts: import("@aethervtt/shard-core").FieldType<boolean, "u8">;
|
|
7
|
+
readonly residentDepth: import("@aethervtt/shard-core").FieldType<number, "i8">;
|
|
8
|
+
readonly scatter: import("@aethervtt/shard-core").FieldType<import("@aethervtt/shard-core").AssetRef<"scatter/ScatterSet"> | null, "object">;
|
|
9
|
+
}>;
|
|
10
|
+
export declare const TerrainChunk: import("@aethervtt/shard-core").ComponentDef<{
|
|
11
|
+
readonly terrain: import("@aethervtt/shard-core").FieldType<number | null, "f64">;
|
|
12
|
+
readonly key: import("@aethervtt/shard-core").FieldType<string, "object">;
|
|
13
|
+
readonly kind: import("@aethervtt/shard-core").FieldType<"collider" | "render", "u8">;
|
|
14
|
+
}>;
|