@aethervtt/shard-terrain 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/biomes.d.ts +1 -0
  2. package/dist/biomes.js +3 -0
  3. package/dist/chunk.d.ts +0 -44
  4. package/dist/chunk.js +2 -127
  5. package/dist/colliders.d.ts +16 -1
  6. package/dist/colliders.js +39 -24
  7. package/dist/components.d.ts +5 -0
  8. package/dist/components.js +7 -2
  9. package/dist/cube-sphere.d.ts +50 -0
  10. package/dist/cube-sphere.js +224 -0
  11. package/dist/grid-mesh.d.ts +49 -0
  12. package/dist/grid-mesh.js +208 -0
  13. package/dist/heightfield/bake.d.ts +120 -0
  14. package/dist/heightfield/bake.js +494 -0
  15. package/dist/heightfield/colliders.d.ts +39 -0
  16. package/dist/heightfield/colliders.js +152 -0
  17. package/dist/heightfield/component.d.ts +14 -0
  18. package/dist/heightfield/component.js +59 -0
  19. package/dist/heightfield/deflate.d.ts +11 -0
  20. package/dist/heightfield/deflate.js +484 -0
  21. package/dist/heightfield/heightmap.d.ts +27 -0
  22. package/dist/heightfield/heightmap.js +160 -0
  23. package/dist/heightfield/kernel.d.ts +277 -0
  24. package/dist/heightfield/kernel.js +1033 -0
  25. package/dist/heightfield/material.d.ts +13 -0
  26. package/dist/heightfield/material.js +394 -0
  27. package/dist/heightfield/methods.d.ts +104 -0
  28. package/dist/heightfield/methods.js +218 -0
  29. package/dist/heightfield/pack.d.ts +59 -0
  30. package/dist/heightfield/pack.js +127 -0
  31. package/dist/heightfield/pages.d.ts +91 -0
  32. package/dist/heightfield/pages.js +244 -0
  33. package/dist/heightfield/project.d.ts +40 -0
  34. package/dist/heightfield/project.js +55 -0
  35. package/dist/heightfield/queries.d.ts +48 -0
  36. package/dist/heightfield/queries.js +150 -0
  37. package/dist/heightfield/render.d.ts +108 -0
  38. package/dist/heightfield/render.js +727 -0
  39. package/dist/heightfield/root-grid.d.ts +38 -0
  40. package/dist/heightfield/root-grid.js +109 -0
  41. package/dist/heightfield/runtime.d.ts +105 -0
  42. package/dist/heightfield/runtime.js +312 -0
  43. package/dist/heightfield/source-asset.d.ts +39 -0
  44. package/dist/heightfield/source-asset.js +372 -0
  45. package/dist/heightfield/source.d.ts +151 -0
  46. package/dist/heightfield/source.js +402 -0
  47. package/dist/heightfield/stack.d.ts +29 -0
  48. package/dist/heightfield/stack.js +158 -0
  49. package/dist/heightfield/system.d.ts +22 -0
  50. package/dist/heightfield/system.js +165 -0
  51. package/dist/heightfield/testing.d.ts +317 -0
  52. package/dist/heightfield/testing.js +441 -0
  53. package/dist/heightfield/worker.js +47 -0
  54. package/dist/heights.d.ts +8 -3
  55. package/dist/heights.js +8 -4
  56. package/dist/index.d.ts +24 -7
  57. package/dist/index.js +158 -8
  58. package/dist/lod.d.ts +27 -0
  59. package/dist/lod.js +23 -1
  60. package/dist/material.js +2 -1
  61. package/dist/methods.js +64 -16
  62. package/dist/overlays.d.ts +2 -0
  63. package/dist/overlays.js +48 -6
  64. package/dist/planet.d.ts +2 -7
  65. package/dist/planet.js +8 -18
  66. package/dist/plugin.d.ts +10 -3
  67. package/dist/plugin.js +48 -8
  68. package/dist/quadtree.d.ts +61 -39
  69. package/dist/quadtree.js +44 -208
  70. package/dist/render.d.ts +7 -1
  71. package/dist/render.js +8 -6
  72. package/dist/test-planet.d.ts +39 -0
  73. package/dist/test-planet.js +40 -4
  74. package/dist/testing.d.ts +2 -0
  75. package/dist/testing.js +42 -2
  76. package/package.json +16 -16
@@ -0,0 +1,244 @@
1
+ import {
2
+ bakeLeaf,
3
+ LEAF_SIDE,
4
+ leafNormals,
5
+ pageTexels,
6
+ readPage,
7
+ SIDE
8
+ } from "./kernel.js";
9
+ import {
10
+ PACK_INDEX_BYTES,
11
+ packOf,
12
+ readIndex
13
+ } from "./pack.js";
14
+ const WORKER = new URL("./worker.js", import.meta.url).href;
15
+ function nodeKey(depth, x, z) {
16
+ return depth * 2 ** 40 + z * 2 ** 20 + x;
17
+ }
18
+ function keyDepth(key) {
19
+ return Math.floor(key / 2 ** 40);
20
+ }
21
+ function keyX(key) {
22
+ return key % 2 ** 20;
23
+ }
24
+ function keyZ(key) {
25
+ return Math.floor(key / 2 ** 20) % 2 ** 20;
26
+ }
27
+ class PageStore {
28
+ store;
29
+ layout;
30
+ lo;
31
+ hi;
32
+ workers;
33
+ /** Extra milliseconds before every read resolves (tests: a slow disk). */
34
+ delayMs = 0;
35
+ /** Pages kept decoded on the CPU, besides pinned ones. */
36
+ capacity = 512;
37
+ indexes = /* @__PURE__ */ new Map();
38
+ pendingIndexes = /* @__PURE__ */ new Map();
39
+ inflight = /* @__PURE__ */ new Map();
40
+ /** Decoded pages by key, in use order (Map insertion order: oldest first). */
41
+ cache = /* @__PURE__ */ new Map();
42
+ pinned = /* @__PURE__ */ new Set();
43
+ stats = { reads: 0, bytesRead: 0, inflated: 0, bakedInline: 0 };
44
+ constructor(store, layout, lo, hi, workers) {
45
+ this.store = store;
46
+ this.layout = layout;
47
+ this.lo = lo;
48
+ this.hi = hi;
49
+ this.workers = workers;
50
+ }
51
+ /** Nodes per side at a depth. */
52
+ nodesAt(depth) {
53
+ const level = this.layout.depth - depth;
54
+ return [this.layout.leavesX >> level, this.layout.leavesZ >> level];
55
+ }
56
+ /** A node's index entry, if its pack's index is loaded. */
57
+ entry(depth, x, z) {
58
+ const { path, slot } = packOf(depth, x, z);
59
+ const index = this.indexes.get(path);
60
+ const e = index?.entries[slot];
61
+ return e && e.length > 0 ? e : void 0;
62
+ }
63
+ /** Loads the index of the pack holding a node. */
64
+ index(depth, x, z) {
65
+ const { path } = packOf(depth, x, z);
66
+ const have = this.indexes.get(path);
67
+ if (have !== void 0) return Promise.resolve(have);
68
+ let p = this.pendingIndexes.get(path);
69
+ if (!p) {
70
+ p = this.store.readRange(path, 0, PACK_INDEX_BYTES).then(
71
+ (bytes) => {
72
+ const index = bytes.length >= PACK_INDEX_BYTES ? readIndex(path, bytes) : null;
73
+ this.indexes.set(path, index);
74
+ this.pendingIndexes.delete(path);
75
+ return index;
76
+ },
77
+ () => {
78
+ this.indexes.set(path, null);
79
+ this.pendingIndexes.delete(path);
80
+ return null;
81
+ }
82
+ );
83
+ this.pendingIndexes.set(path, p);
84
+ }
85
+ return p;
86
+ }
87
+ /** Whether a node's pack index is loaded (or known missing). */
88
+ hasIndex(depth, x, z) {
89
+ return this.indexes.has(packOf(depth, x, z).path);
90
+ }
91
+ /** A decoded page in the CPU cache (marked used), or undefined. */
92
+ get(key) {
93
+ const p = this.cache.get(key);
94
+ if (p && !this.pinned.has(key)) {
95
+ this.cache.delete(key);
96
+ this.cache.set(key, p);
97
+ }
98
+ return p;
99
+ }
100
+ /** Whether a read for this page is under way. */
101
+ loading(key) {
102
+ return this.inflight.has(key);
103
+ }
104
+ /** Keeps a page decoded for the store's life (coarse levels). */
105
+ pin(key) {
106
+ this.pinned.add(key);
107
+ }
108
+ /**
109
+ * Reads, inflates and decodes a page (once, however many ask), into the CPU cache. Undefined
110
+ * past the terrain's edge or when the pack doesn't have it.
111
+ */
112
+ load(depth, x, z) {
113
+ const key = nodeKey(depth, x, z);
114
+ const cached = this.cache.get(key);
115
+ if (cached) return Promise.resolve(cached);
116
+ let p = this.inflight.get(key);
117
+ if (p) return p;
118
+ p = this.read(depth, x, z, key).finally(() => this.inflight.delete(key));
119
+ this.inflight.set(key, p);
120
+ return p;
121
+ }
122
+ async read(depth, x, z, key) {
123
+ const [nx, nz] = this.nodesAt(depth);
124
+ if (x < 0 || z < 0 || x >= nx || z >= nz) return void 0;
125
+ await this.index(depth, x, z);
126
+ const e = this.entry(depth, x, z);
127
+ if (!e) return void 0;
128
+ const { path } = packOf(depth, x, z);
129
+ const packed = await this.store.readRange(path, e.offset, e.length);
130
+ this.stats.reads++;
131
+ this.stats.bytesRead += packed.length;
132
+ if (this.delayMs > 0) await new Promise((r) => setTimeout(r, this.delayMs));
133
+ const leaf = depth === this.layout.depth;
134
+ const decoded = this.workers && this.workers.size > 0 ? await this.workers.run(
135
+ WORKER,
136
+ "loadPage",
137
+ [packed, leaf, this.layout.cells, this.lo, this.hi, this.layout.spacing],
138
+ {
139
+ kind: "terrain/inflate"
140
+ }
141
+ ) : decodeInline(packed, leaf, this.layout.cells, this.lo, this.hi, this.layout.spacing);
142
+ this.stats.inflated++;
143
+ const page = {
144
+ key,
145
+ depth,
146
+ x,
147
+ z,
148
+ leaf,
149
+ heights: decoded.heights,
150
+ normals: decoded.normals,
151
+ control: decoded.control,
152
+ texels: decoded.texels,
153
+ min: e.min,
154
+ max: e.max,
155
+ error: e.error
156
+ };
157
+ this.put(page);
158
+ return page;
159
+ }
160
+ /** Adds a page to the CPU cache, dropping the least recently used unpinned ones over capacity. */
161
+ put(page) {
162
+ this.cache.delete(page.key);
163
+ this.cache.set(page.key, page);
164
+ let over = this.cache.size - this.pinned.size - this.capacity;
165
+ if (over <= 0) return;
166
+ for (const key of this.cache.keys()) {
167
+ if (over <= 0) break;
168
+ if (this.pinned.has(key)) continue;
169
+ this.cache.delete(key);
170
+ over--;
171
+ }
172
+ }
173
+ /**
174
+ * A leaf page now: from the cache, else baked on this thread from the stack (exactly the pack's
175
+ * bytes, spec 0071). Colliders use it on the frame a tile is due, so when a read lands never
176
+ * changes the world.
177
+ */
178
+ leafNow(noise, stack, x, z) {
179
+ const depth = this.layout.depth;
180
+ const key = nodeKey(depth, x, z);
181
+ const cached = this.get(key);
182
+ if (cached) return cached;
183
+ const baked = bakeLeaf(noise, stack, this.layout, x, z);
184
+ this.stats.bakedInline++;
185
+ const page = {
186
+ key,
187
+ depth,
188
+ x,
189
+ z,
190
+ leaf: true,
191
+ heights: baked.page.heights,
192
+ normals: leafNormals(
193
+ baked.page.heights,
194
+ this.lo,
195
+ this.hi,
196
+ this.layout.spacing,
197
+ new Uint8Array(SIDE * SIDE * 2)
198
+ ),
199
+ control: baked.page.control,
200
+ texels: void 0,
201
+ min: baked.min,
202
+ max: baked.max,
203
+ error: 0
204
+ };
205
+ this.put(page);
206
+ return page;
207
+ }
208
+ /** Every decoded page (pinned and cached). */
209
+ pages() {
210
+ return this.cache.values();
211
+ }
212
+ get cached() {
213
+ return this.cache.size;
214
+ }
215
+ get pendingReads() {
216
+ return this.inflight.size;
217
+ }
218
+ /** Forgets every index and page (after a rebake). Reads under way finish into the new cache. */
219
+ clear() {
220
+ this.indexes.clear();
221
+ this.cache.clear();
222
+ this.pinned.clear();
223
+ }
224
+ }
225
+ function decodeInline(packed, leaf, cells, lo, hi, spacing) {
226
+ const page = readPage(packed, leaf, cells);
227
+ const normals = page.normals ?? leafNormals(page.heights, lo, hi, spacing, new Uint8Array(SIDE * SIDE * 2));
228
+ const texels = pageTexels(
229
+ { heights: page.heights, normals, control: page.control },
230
+ leaf,
231
+ lo,
232
+ hi,
233
+ spacing,
234
+ new Uint8Array(LEAF_SIDE * LEAF_SIDE * 4)
235
+ );
236
+ return { heights: page.heights, normals, control: page.control, texels };
237
+ }
238
+ export {
239
+ PageStore,
240
+ keyDepth,
241
+ keyX,
242
+ keyZ,
243
+ nodeKey
244
+ };
@@ -0,0 +1,40 @@
1
+ import type { AssetServer } from '@aethervtt/shard-assets';
2
+ import { ShardError, type World } from '@aethervtt/shard-core';
3
+ import type { PlatformFileSystem, Workers } from '@aethervtt/shard-platform';
4
+ import { type BakeReport } from './bake.js';
5
+ import type { Stack } from './kernel.js';
6
+ import { type PackStore } from './pack.js';
7
+ import { TerrainSourceAsset } from './source-asset.js';
8
+ /** Where a terrain source's packs live, under the project: `.shard/cache/terrain/<guid>`. */
9
+ export declare function terrainCacheDir(guid: string): string;
10
+ /**
11
+ * The bake kernel's stack for a loaded source, from the noise graphs and heightmaps loaded in the
12
+ * world's stores. Throws `terrain/not-ready` while one isn't loaded.
13
+ */
14
+ export declare function stackFor(world: World, asset: TerrainSourceAsset): Stack;
15
+ /** Whether a terrain's packs match its source (a bake would rebake nothing it can't see). */
16
+ export declare function bakeIsCurrent(store: PackStore, asset: TerrainSourceAsset): Promise<boolean>;
17
+ /** What baking needs of a host: its file service and, where it has one, its worker pool. */
18
+ export interface BakeHost {
19
+ readonly fs: PlatformFileSystem;
20
+ readonly workers?: Workers | undefined;
21
+ }
22
+ export interface ProjectBake {
23
+ path: string;
24
+ guid: string;
25
+ report: BakeReport;
26
+ }
27
+ /**
28
+ * Bakes a project's terrain sources into `.shard/cache/terrain/` (`shard import`, `shard terrain
29
+ * bake`): each one's assets loaded through the server, then an incremental bake (all of it with
30
+ * `force`). `only` limits it to sources whose path ends with it. Sources already current are
31
+ * skipped unless forced.
32
+ */
33
+ export declare function bakeProjectTerrains(server: AssetServer, world: World, fs: PlatformFileSystem, options?: {
34
+ force?: boolean;
35
+ only?: string;
36
+ workers?: Workers;
37
+ warn?: (problem: ShardError, path: string) => void;
38
+ /** Bake even when the packs already match the source. */
39
+ always?: boolean;
40
+ }): Promise<ProjectBake[]>;
@@ -0,0 +1,55 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { NoiseGraphs } from "@aethervtt/shard-noise";
3
+ import { bakeTerrain, readManifest } from "./bake.js";
4
+ import { Heightmaps } from "./heightmap.js";
5
+ import { fsPackStore } from "./pack.js";
6
+ import { TerrainSourceAsset } from "./source-asset.js";
7
+ import { compileStack } from "./stack.js";
8
+ function terrainCacheDir(guid) {
9
+ return `.shard/cache/terrain/${guid.replace(/[^a-zA-Z0-9._-]/g, "-")}`;
10
+ }
11
+ function stackFor(world, asset) {
12
+ const need = (path, item) => {
13
+ if (item === void 0) {
14
+ throw new ShardError("terrain/not-ready", `The terrain is waiting for ${path}`, {
15
+ path,
16
+ hint: "Its assets load asynchronously; step a frame (or await the loads) first."
17
+ });
18
+ }
19
+ return item;
20
+ };
21
+ const guid = (path) => ({ guid: asset.deps[path]?.guid });
22
+ return compileStack(asset.source, {
23
+ noise: (path) => need(path, world.tryResource(NoiseGraphs)?.get(guid(path))),
24
+ heightmap: (path) => need(path, world.tryResource(Heightmaps)?.get(guid(path)))
25
+ });
26
+ }
27
+ async function bakeIsCurrent(store, asset) {
28
+ const manifest = await readManifest(store);
29
+ return manifest?.sourceHash === asset.hash;
30
+ }
31
+ async function bakeProjectTerrains(server, world, fs, options = {}) {
32
+ const out = [];
33
+ for (const entry of server.all("terrain/TerrainSource")) {
34
+ if (options.only && !entry.path.endsWith(options.only)) continue;
35
+ await server.load(entry.guid);
36
+ const asset = server.item(entry.guid);
37
+ if (!(asset instanceof TerrainSourceAsset)) continue;
38
+ const store = fsPackStore(fs, terrainCacheDir(entry.guid));
39
+ if (!options.force && !options.always && await bakeIsCurrent(store, asset)) continue;
40
+ const stack = stackFor(world, asset);
41
+ const report = await bakeTerrain(asset, stack, store, {
42
+ force: options.force === true,
43
+ ...options.workers ? { workers: options.workers } : {},
44
+ warn: (problem) => options.warn?.(problem, entry.path)
45
+ });
46
+ out.push({ path: entry.path, guid: entry.guid, report });
47
+ }
48
+ return out;
49
+ }
50
+ export {
51
+ bakeIsCurrent,
52
+ bakeProjectTerrains,
53
+ stackFor,
54
+ terrainCacheDir
55
+ };
@@ -0,0 +1,48 @@
1
+ import { type Entity, type World } from '@aethervtt/shard-core';
2
+ import { type LoadedPage } from './pages.js';
3
+ import type { HeightfieldRuntime } from './runtime.js';
4
+ /** What `terrainHeightAt` found. */
5
+ export interface TerrainHeight {
6
+ /** Metres, in the terrain's frame (its corner at 0). */
7
+ height: number;
8
+ /** The depth of the page it came from (the leaf depth is exact). */
9
+ depth: number;
10
+ /** Whether that's the leaf depth: the collider's own surface. */
11
+ exact: boolean;
12
+ }
13
+ /** The heightfield runtime of a Terrain entity, ready for queries, or a `terrain/*` error. */
14
+ export declare function heightfieldRuntime(world: World, terrain: Entity): HeightfieldRuntime;
15
+ /** The finest page held on the CPU that covers (x, z), deepest first. */
16
+ export declare function finestPage(rt: HeightfieldRuntime, x: number, z: number): LoadedPage | undefined;
17
+ /**
18
+ * The page's surface at (x, z): its cell's triangle split along (i+1, j)–(i, j+1), as the mesh and
19
+ * Rapier's heightfield are.
20
+ */
21
+ export declare function pageHeight(rt: HeightfieldRuntime, page: LoadedPage, x: number, z: number): number;
22
+ /**
23
+ * The ground's height at terrain point (x, z) (spec 0071): sync, from the finest page held on the
24
+ * CPU there (the coarse levels always are), with the depth it used. `loadTerrainRegion` first makes
25
+ * it exact (the leaf depth, the colliders' surface) away from anchors. Throws
26
+ * `terrain/out-of-bounds` off the terrain.
27
+ */
28
+ export declare function terrainHeightAt(world: World, terrain: Entity, x: number, z: number, out?: TerrainHeight): TerrainHeight;
29
+ /**
30
+ * Loads and pins the leaf pages over `rect` ([x0, z0, x1, z1] metres in the terrain's frame), so
31
+ * `terrainHeightAt` there is exact: gameplay that needs the colliders' heights away from anchors
32
+ * (spawn points, say). Resolves with the pages pinned.
33
+ */
34
+ export declare function loadTerrainRegion(world: World, terrain: Entity, rect: readonly [number, number, number, number]): Promise<number>;
35
+ /** Everything about the ground at a point: what `terrain.sample` reports. */
36
+ export interface HeightfieldSample {
37
+ height: number;
38
+ /** Unit normal in the terrain's frame. */
39
+ normal: [number, number, number];
40
+ /** Degrees from flat. */
41
+ slope: number;
42
+ /** The two heaviest material layers (names) and the second's share of their weight. */
43
+ layers: [string, string];
44
+ share: number;
45
+ depth: number;
46
+ }
47
+ /** The ground at (x, z) from the finest page held (leaf pages after `loadTerrainRegion`). */
48
+ export declare function heightfieldSample(rt: HeightfieldRuntime, x: number, z: number): HeightfieldSample;
@@ -0,0 +1,150 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { TerrainWorld } from "../heights.js";
3
+ import { dequantize, LEAF_SIDE, PAGE, SIDE } from "./kernel.js";
4
+ import { nodeKey } from "./pages.js";
5
+ function heightfieldRuntime(world, terrain) {
6
+ const rt = world.tryResource(TerrainWorld)?.heightfields.get(terrain);
7
+ if (!rt) {
8
+ throw new ShardError("terrain/not-a-terrain", `Entity ${terrain} has no terrain/Terrain`, {
9
+ hint: "Add terrain/Terrain (with a Grid) to the entity, and the terrain plugin to the app."
10
+ });
11
+ }
12
+ if (rt.problem) throw rt.problem;
13
+ if (!rt.ready || !rt.pages) {
14
+ throw new ShardError(
15
+ "terrain/not-ready",
16
+ `Terrain ${terrain} is waiting for ${rt.waiting ?? "its source"}`,
17
+ {
18
+ hint: "Its source and assets load asynchronously; step a frame (or await them) first."
19
+ }
20
+ );
21
+ }
22
+ return rt;
23
+ }
24
+ function finestPage(rt, x, z) {
25
+ const layout = rt.layout;
26
+ if (x < 0 || z < 0 || x > layout.sizeX || z > layout.sizeZ) return void 0;
27
+ for (let depth = layout.depth; depth >= 0; depth--) {
28
+ const size = rt.nodeSize(depth);
29
+ const [nx, nz] = rt.pages.nodesAt(depth);
30
+ const px = Math.min(nx - 1, Math.floor(x / size));
31
+ const pz = Math.min(nz - 1, Math.floor(z / size));
32
+ const page = rt.pages.get(nodeKey(depth, px, pz));
33
+ if (page) return page;
34
+ }
35
+ return void 0;
36
+ }
37
+ function sample(rt, page, i, j) {
38
+ const side = page.leaf ? LEAF_SIDE : SIDE;
39
+ const o = page.leaf ? 1 : 0;
40
+ return dequantize(page.heights[(j + o) * side + i + o], rt.lo, rt.hi);
41
+ }
42
+ function pageHeight(rt, page, x, z) {
43
+ const size = rt.nodeSize(page.depth);
44
+ const fx = (x - page.x * size) / size * PAGE;
45
+ const fz = (z - page.z * size) / size * PAGE;
46
+ const i = Math.min(PAGE - 1, Math.max(0, Math.floor(fx)));
47
+ const j = Math.min(PAGE - 1, Math.max(0, Math.floor(fz)));
48
+ const u = Math.min(1, Math.max(0, fx - i));
49
+ const v = Math.min(1, Math.max(0, fz - j));
50
+ const h00 = sample(rt, page, i, j);
51
+ const h10 = sample(rt, page, i + 1, j);
52
+ const h01 = sample(rt, page, i, j + 1);
53
+ const h11 = sample(rt, page, i + 1, j + 1);
54
+ return u + v <= 1 ? h00 + u * (h10 - h00) + v * (h01 - h00) : h11 + (1 - u) * (h01 - h11) + (1 - v) * (h10 - h11);
55
+ }
56
+ function terrainHeightAt(world, terrain, x, z, out = { height: 0, depth: 0, exact: false }) {
57
+ const rt = heightfieldRuntime(world, terrain);
58
+ const page = finestPage(rt, x, z);
59
+ if (!page) {
60
+ const l = rt.layout;
61
+ if (x < 0 || z < 0 || x > l.sizeX || z > l.sizeZ) {
62
+ throw new ShardError(
63
+ "terrain/out-of-bounds",
64
+ `(${x}, ${z}) is off the terrain (0\u2013${l.sizeX} \xD7 0\u2013${l.sizeZ} m)`,
65
+ {
66
+ hint: "Points are metres from the terrain\u2019s corner in its frame."
67
+ }
68
+ );
69
+ }
70
+ throw new ShardError("terrain/not-ready", "The terrain\u2019s coarse levels are still loading", {
71
+ hint: "Step a few frames, or await loadTerrainRegion first."
72
+ });
73
+ }
74
+ out.height = pageHeight(rt, page, x, z);
75
+ out.depth = page.depth;
76
+ out.exact = page.depth === rt.layout.depth;
77
+ return out;
78
+ }
79
+ async function loadTerrainRegion(world, terrain, rect) {
80
+ const rt = heightfieldRuntime(world, terrain);
81
+ const layout = rt.layout;
82
+ const size = layout.leafSize;
83
+ const D = layout.depth;
84
+ const x0 = Math.max(0, Math.floor(rect[0] / size));
85
+ const z0 = Math.max(0, Math.floor(rect[1] / size));
86
+ const x1 = Math.min(layout.leavesX - 1, Math.floor(rect[2] / size));
87
+ const z1 = Math.min(layout.leavesZ - 1, Math.floor(rect[3] / size));
88
+ const loads = [];
89
+ for (let z = z0; z <= z1; z++) {
90
+ for (let x = x0; x <= x1; x++) {
91
+ rt.pages.pin(nodeKey(D, x, z));
92
+ loads.push(rt.pages.load(D, x, z));
93
+ }
94
+ }
95
+ await Promise.all(loads);
96
+ return loads.length;
97
+ }
98
+ function heightfieldSample(rt, x, z) {
99
+ const page = finestPage(rt, x, z);
100
+ if (!page) {
101
+ throw new ShardError("terrain/out-of-bounds", `(${x}, ${z}) is off the terrain`, {
102
+ hint: "Points are metres from the terrain\u2019s corner in its frame."
103
+ });
104
+ }
105
+ const size = rt.nodeSize(page.depth);
106
+ const fx = (x - page.x * size) / size * PAGE;
107
+ const fz = (z - page.z * size) / size * PAGE;
108
+ const i = Math.min(PAGE, Math.max(0, Math.round(fx)));
109
+ const j = Math.min(PAGE, Math.max(0, Math.round(fz)));
110
+ const spacing = size / PAGE;
111
+ const ci = Math.min(PAGE - 1, Math.max(0, Math.floor(fx)));
112
+ const cj = Math.min(PAGE - 1, Math.max(0, Math.floor(fz)));
113
+ const u = fx - ci;
114
+ const v = fz - cj;
115
+ let dx;
116
+ let dz;
117
+ if (u + v <= 1) {
118
+ dx = (sample(rt, page, ci + 1, cj) - sample(rt, page, ci, cj)) / spacing;
119
+ dz = (sample(rt, page, ci, cj + 1) - sample(rt, page, ci, cj)) / spacing;
120
+ } else {
121
+ dx = (sample(rt, page, ci + 1, cj + 1) - sample(rt, page, ci, cj + 1)) / spacing;
122
+ dz = (sample(rt, page, ci + 1, cj + 1) - sample(rt, page, ci + 1, cj)) / spacing;
123
+ }
124
+ const l = Math.sqrt(dx * dx + 1 + dz * dz);
125
+ const normal = [-dx / l, 1 / l, -dz / l];
126
+ const cells = rt.layout.cells;
127
+ const k = PAGE / cells;
128
+ const ci2 = Math.min(cells, Math.max(0, Math.round(i / k)));
129
+ const cj2 = Math.min(cells, Math.max(0, Math.round(j / k)));
130
+ const o = (cj2 * (cells + 1) + ci2) * 4;
131
+ const names = rt.asset.source.layers.map((layer) => layer.name);
132
+ const a = page.control[o];
133
+ const b = page.control[o + 1];
134
+ return {
135
+ height: pageHeight(rt, page, x, z),
136
+ normal,
137
+ slope: Math.acos(Math.min(1, normal[1])) * 180 / Math.PI,
138
+ layers: [names[a] ?? "", names[b] ?? ""],
139
+ share: page.control[o + 2] / 255,
140
+ depth: page.depth
141
+ };
142
+ }
143
+ export {
144
+ finestPage,
145
+ heightfieldRuntime,
146
+ heightfieldSample,
147
+ loadTerrainRegion,
148
+ pageHeight,
149
+ terrainHeightAt
150
+ };
@@ -0,0 +1,108 @@
1
+ import { type AssetRef, type Entity, type World } from '@aethervtt/shard-core';
2
+ import { MaterialAsset } from '@aethervtt/shard-render';
3
+ import { Texture } from '@aethervtt/shard-texture';
4
+ import { type LoadedPage } from './pages.js';
5
+ import type { HeightfieldRuntime } from './runtime.js';
6
+ /** Atlas side of the page pool (texels): within WebGL2's floor of 2048. */
7
+ export declare const ATLAS = 2048;
8
+ /** Pages per atlas row, and per layer. */
9
+ export declare const PER_ROW: number;
10
+ export declare const PER_LAYER: number;
11
+ /** Debug shading and test hooks for heightfields. */
12
+ export declare const HeightfieldDebug: {
13
+ /** 0 off, 1 layers, 2 seams, 3 levels, 4 normals, 5 pages. */
14
+ mode: number;
15
+ };
16
+ /** The chunk drawing one pool slot. */
17
+ interface ChunkEntity {
18
+ entity: Entity;
19
+ /** The node it's placed for (nodeKey), or −1. */
20
+ key: number;
21
+ /** Index set drawn: quadrant mask + 16 × stitched edges. */
22
+ variant: number;
23
+ shown: boolean;
24
+ fade: number;
25
+ used: number;
26
+ }
27
+ /**
28
+ * Render-side state of one heightfield (spec 0071): the GPU page pool (two texture arrays: page
29
+ * texels and control texels, `TerrainBudget.pages` slots, least recently used out, coarse levels
30
+ * pinned), the chunk table, reads in flight and pages waiting to upload, and one chunk entity per
31
+ * slot drawing the shared grid mesh with its index set.
32
+ */
33
+ export declare class HeightfieldRender {
34
+ readonly material: MaterialAsset;
35
+ readonly materialRef: AssetRef<'Material'>;
36
+ capacity: number;
37
+ layers: number;
38
+ pages: Texture | undefined;
39
+ pagesRef: AssetRef | undefined;
40
+ /** The pool's GPU textures this frame (looked up in First). */
41
+ gpuPages: GPUTexture | undefined;
42
+ gpuControl: GPUTexture | undefined;
43
+ control: Texture | undefined;
44
+ controlRef: AssetRef | undefined;
45
+ chunksTexture: Texture | undefined;
46
+ chunksRef: AssetRef | undefined;
47
+ layerRef: AssetRef | undefined;
48
+ layerHash: string;
49
+ /** Per slot: node key (−1 free), frame last drawn, pinned. */
50
+ slotKey: Float64Array<ArrayBuffer>;
51
+ slotUsed: Uint32Array<ArrayBuffer>;
52
+ slotPinned: Uint8Array<ArrayBuffer>;
53
+ readonly keySlot: Map<number, number>;
54
+ /** Chunk table bytes (RGBA8 per slot: lock bits, quadrant mask, fade, depth). */
55
+ table: Uint8Array<ArrayBuffer>;
56
+ tableDirty: boolean;
57
+ chunks: (ChunkEntity | undefined)[];
58
+ /** Pages read and waiting to upload, in arrival order. */
59
+ arrived: LoadedPage[];
60
+ /** Keys read or waiting, so a node is asked for once. */
61
+ readonly pending: Set<number>;
62
+ readonly meshes: Map<number, AssetRef<"Mesh">>;
63
+ readonly view: import("..").SelectionView;
64
+ /** The selecting camera in the origin frame (xyz), pixels per radian / errorPixels (w). */
65
+ readonly camera: Float32Array<ArrayBuffer>;
66
+ hasCamera: boolean;
67
+ /** The walk stamp last frame (what was on screen then). */
68
+ lastStamp: number;
69
+ /** The terrain version the pool's pages are from. */
70
+ version: number;
71
+ /** The grid the pool's pages are for. */
72
+ layout: object | undefined;
73
+ /** The terrain version whose coarse levels went to the pool. */
74
+ residentVersion: number;
75
+ stats: {
76
+ uploaded: number;
77
+ evicted: number;
78
+ uploadedLastFrame: number;
79
+ requested: number;
80
+ skippedFull: number;
81
+ };
82
+ constructor(world: World);
83
+ /** The pool slot holding a node's page, or −1. */
84
+ slotOf(key: number): number;
85
+ get used(): number;
86
+ }
87
+ export declare function heightfieldRenderOf(world: World, rt: HeightfieldRuntime): HeightfieldRender;
88
+ /**
89
+ * Uploads pages that arrived into the pool (spec 0071), in `First`: pinned coarse levels first and
90
+ * all at once, then in arrival order (reads start highest projected error first), at most
91
+ * `TerrainBudget.pagesPerFrame` a frame. A node becomes drawable the frame its page lands. Writes
92
+ * go through the GPU queue, so 0055's upload accounting counts them.
93
+ */
94
+ export declare const uploadHeightfieldPages: import("@aethervtt/shard-core").SystemDef<undefined>;
95
+ /**
96
+ * Chooses each heightfield's nodes for the selecting camera (spec 0071, 0043's selection on the
97
+ * root grid), reads the pages they need (highest projected error first), and shows exactly the
98
+ * selected chunks. Pages arrive asynchronously and upload in `First`; a split waits for its
99
+ * children's pages, drawing its own quadrants meanwhile.
100
+ */
101
+ export declare const selectHeightfields: import("@aethervtt/shard-core").SystemDef<{
102
+ cameras: import("@aethervtt/shard-core").Query;
103
+ }>;
104
+ /** The coarse levels, pinned in the CPU cache, go to the pool once streaming starts. */
105
+ export declare function enqueueResident(rt: HeightfieldRuntime, r: HeightfieldRender): void;
106
+ /** Despawns a heightfield's chunk entities and frees its textures, meshes and material. */
107
+ export declare function cleanupHeightfieldRender(world: World, rt: HeightfieldRuntime): void;
108
+ export {};