@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.
- 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,727 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChildOf,
|
|
3
|
+
Derived,
|
|
4
|
+
defineSystem
|
|
5
|
+
} from "@aethervtt/shard-core";
|
|
6
|
+
import { Mesh } from "@aethervtt/shard-mesh";
|
|
7
|
+
import {
|
|
8
|
+
Camera3d,
|
|
9
|
+
Cameras,
|
|
10
|
+
DebugOverlays,
|
|
11
|
+
GpuAssetsResource,
|
|
12
|
+
InstanceData,
|
|
13
|
+
isOverlayOn,
|
|
14
|
+
MaterialAsset,
|
|
15
|
+
Materials,
|
|
16
|
+
Mesh3d,
|
|
17
|
+
Meshes,
|
|
18
|
+
MeshMaterial,
|
|
19
|
+
Visibility
|
|
20
|
+
} from "@aethervtt/shard-render";
|
|
21
|
+
import { Time } from "@aethervtt/shard-runtime";
|
|
22
|
+
import { Texture, Textures, toHalf } from "@aethervtt/shard-texture";
|
|
23
|
+
import {
|
|
24
|
+
GlobalTransform,
|
|
25
|
+
placeInGrid,
|
|
26
|
+
propagateSubtree,
|
|
27
|
+
Transform
|
|
28
|
+
} from "@aethervtt/shard-transform";
|
|
29
|
+
import { TerrainBudget } from "../components.js";
|
|
30
|
+
import { chunkIndices, chunkLayout } from "../grid-mesh.js";
|
|
31
|
+
import { TerrainWorld } from "../heights.js";
|
|
32
|
+
import { adaptLodBias } from "../lod.js";
|
|
33
|
+
import { TEXTURE_PERIOD } from "../material.js";
|
|
34
|
+
import { NODE_READY, nearAnchor, selectNodes } from "../quadtree.js";
|
|
35
|
+
import { pickCamera } from "../render.js";
|
|
36
|
+
import { createView, perspectiveView } from "../view.js";
|
|
37
|
+
import { TerrainChunk } from "./component.js";
|
|
38
|
+
import { LEAF_SIDE, PAGE } from "./kernel.js";
|
|
39
|
+
import { CHUNK_TABLE_WIDTH, TerrainSurfaceMaterial } from "./material.js";
|
|
40
|
+
import { keyDepth, keyX, keyZ, nodeKey } from "./pages.js";
|
|
41
|
+
const NONE = -1;
|
|
42
|
+
const ATLAS = 2048;
|
|
43
|
+
const PER_ROW = Math.floor(ATLAS / LEAF_SIDE);
|
|
44
|
+
const PER_LAYER = PER_ROW * PER_ROW;
|
|
45
|
+
const IDLE_FRAMES = 60;
|
|
46
|
+
const FADE = 0.5;
|
|
47
|
+
const MAX_READS = 32;
|
|
48
|
+
const HeightfieldDebug = {
|
|
49
|
+
/** 0 off, 1 layers, 2 seams, 3 levels, 4 normals, 5 pages. */
|
|
50
|
+
mode: 0
|
|
51
|
+
};
|
|
52
|
+
class HeightfieldRender {
|
|
53
|
+
material;
|
|
54
|
+
materialRef;
|
|
55
|
+
capacity = 0;
|
|
56
|
+
layers = 0;
|
|
57
|
+
pages;
|
|
58
|
+
pagesRef;
|
|
59
|
+
/** The pool's GPU textures this frame (looked up in First). */
|
|
60
|
+
gpuPages;
|
|
61
|
+
gpuControl;
|
|
62
|
+
control;
|
|
63
|
+
controlRef;
|
|
64
|
+
chunksTexture;
|
|
65
|
+
chunksRef;
|
|
66
|
+
layerRef;
|
|
67
|
+
layerHash = "";
|
|
68
|
+
/** Per slot: node key (−1 free), frame last drawn, pinned. */
|
|
69
|
+
slotKey = new Float64Array(0);
|
|
70
|
+
slotUsed = new Uint32Array(0);
|
|
71
|
+
slotPinned = new Uint8Array(0);
|
|
72
|
+
keySlot = /* @__PURE__ */ new Map();
|
|
73
|
+
/** Chunk table bytes (RGBA8 per slot: lock bits, quadrant mask, fade, depth). */
|
|
74
|
+
table = new Uint8Array(0);
|
|
75
|
+
tableDirty = true;
|
|
76
|
+
chunks = [];
|
|
77
|
+
/** Pages read and waiting to upload, in arrival order. */
|
|
78
|
+
arrived = [];
|
|
79
|
+
/** Keys read or waiting, so a node is asked for once. */
|
|
80
|
+
pending = /* @__PURE__ */ new Set();
|
|
81
|
+
meshes = /* @__PURE__ */ new Map();
|
|
82
|
+
view = createView();
|
|
83
|
+
/** The selecting camera in the origin frame (xyz), pixels per radian / errorPixels (w). */
|
|
84
|
+
camera = new Float32Array(4);
|
|
85
|
+
hasCamera = false;
|
|
86
|
+
/** The walk stamp last frame (what was on screen then). */
|
|
87
|
+
lastStamp = 0;
|
|
88
|
+
/** The terrain version the pool's pages are from. */
|
|
89
|
+
version = 0;
|
|
90
|
+
/** The grid the pool's pages are for. */
|
|
91
|
+
layout;
|
|
92
|
+
/** The terrain version whose coarse levels went to the pool. */
|
|
93
|
+
residentVersion = -1;
|
|
94
|
+
stats = { uploaded: 0, evicted: 0, uploadedLastFrame: 0, requested: 0, skippedFull: 0 };
|
|
95
|
+
constructor(world) {
|
|
96
|
+
this.material = new MaterialAsset(
|
|
97
|
+
{ roughness: 0.9, baseColor: [1, 1, 1, 1] },
|
|
98
|
+
TerrainSurfaceMaterial
|
|
99
|
+
);
|
|
100
|
+
this.materialRef = world.resource(Materials).add(this.material, "terrain/heightfield");
|
|
101
|
+
}
|
|
102
|
+
/** The pool slot holding a node's page, or −1. */
|
|
103
|
+
slotOf(key) {
|
|
104
|
+
return this.keySlot.get(key) ?? NONE;
|
|
105
|
+
}
|
|
106
|
+
get used() {
|
|
107
|
+
return this.keySlot.size;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function heightfieldRenderOf(world, rt) {
|
|
111
|
+
let r = rt.parts.get("render");
|
|
112
|
+
if (!r) {
|
|
113
|
+
r = new HeightfieldRender(world);
|
|
114
|
+
rt.parts.set("render", r);
|
|
115
|
+
const render = r;
|
|
116
|
+
rt.parts.set("ready", (key) => render.slotOf(key));
|
|
117
|
+
}
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
function ensurePool(world, rt, r, pages) {
|
|
121
|
+
const layout = rt.layout;
|
|
122
|
+
if (r.capacity >= pages && r.pages) return;
|
|
123
|
+
const capacity = Math.max(pages, r.capacity);
|
|
124
|
+
const layers = Math.max(2, Math.ceil(capacity / PER_LAYER));
|
|
125
|
+
const textures = world.initResource(Textures);
|
|
126
|
+
const cside = layout.cells + 1;
|
|
127
|
+
const grow = (old, n, fill) => {
|
|
128
|
+
const next = new old.constructor(n);
|
|
129
|
+
next.fill(fill);
|
|
130
|
+
next.set(old);
|
|
131
|
+
return next;
|
|
132
|
+
};
|
|
133
|
+
r.slotKey = grow(r.slotKey, capacity, NONE);
|
|
134
|
+
r.slotUsed = grow(r.slotUsed, capacity, 0);
|
|
135
|
+
r.slotPinned = grow(r.slotPinned, capacity, 0);
|
|
136
|
+
r.table = grow(r.table, CHUNK_TABLE_WIDTH * Math.ceil(capacity / CHUNK_TABLE_WIDTH) * 4, 0);
|
|
137
|
+
r.tableDirty = true;
|
|
138
|
+
if (layers !== r.layers || !r.pages) {
|
|
139
|
+
for (const ref of [r.pagesRef, r.controlRef]) if (ref?.guid) textures.delete(ref.guid);
|
|
140
|
+
r.pages = Texture.gpu({ width: ATLAS, height: ATLAS, layers, format: "rgba8unorm" });
|
|
141
|
+
r.control = Texture.gpu({
|
|
142
|
+
width: PER_ROW * cside,
|
|
143
|
+
height: PER_ROW * cside,
|
|
144
|
+
layers,
|
|
145
|
+
format: "rgba8unorm"
|
|
146
|
+
});
|
|
147
|
+
r.pagesRef = textures.add(r.pages, "terrain/heightfield-pages");
|
|
148
|
+
r.controlRef = textures.add(r.control, "terrain/heightfield-control");
|
|
149
|
+
r.layers = layers;
|
|
150
|
+
for (const key of [...r.keySlot.keys()]) dropSlot(rt, r, r.keySlot.get(key));
|
|
151
|
+
}
|
|
152
|
+
if (r.chunksRef?.guid) textures.delete(r.chunksRef.guid);
|
|
153
|
+
r.chunksTexture = Texture.gpu({
|
|
154
|
+
width: CHUNK_TABLE_WIDTH,
|
|
155
|
+
height: Math.max(2, Math.ceil(capacity / CHUNK_TABLE_WIDTH)),
|
|
156
|
+
format: "rgba8unorm"
|
|
157
|
+
});
|
|
158
|
+
r.chunksRef = textures.add(r.chunksTexture, "terrain/heightfield-chunks");
|
|
159
|
+
r.capacity = capacity;
|
|
160
|
+
const v = r.material.value;
|
|
161
|
+
v.pages = r.pagesRef;
|
|
162
|
+
v.control = r.controlRef;
|
|
163
|
+
v.chunks = r.chunksRef;
|
|
164
|
+
r.material.version++;
|
|
165
|
+
}
|
|
166
|
+
function dropSlot(rt, r, slot) {
|
|
167
|
+
const key = r.slotKey[slot];
|
|
168
|
+
if (key < 0) return;
|
|
169
|
+
r.keySlot.delete(key);
|
|
170
|
+
r.slotKey[slot] = NONE;
|
|
171
|
+
r.slotPinned[slot] = 0;
|
|
172
|
+
const n = rt.nodeOf(keyDepth(key), keyX(key), keyZ(key));
|
|
173
|
+
if (n >= 0) {
|
|
174
|
+
rt.tree.flags[n] &= ~NODE_READY;
|
|
175
|
+
rt.tree.slot[n] = NONE;
|
|
176
|
+
}
|
|
177
|
+
const chunk = r.chunks[slot];
|
|
178
|
+
if (chunk) chunk.key = NONE;
|
|
179
|
+
}
|
|
180
|
+
function acquireSlot(rt, r, frame, needed) {
|
|
181
|
+
let best = NONE;
|
|
182
|
+
let bestUsed = Infinity;
|
|
183
|
+
for (let s = 0; s < r.capacity; s++) {
|
|
184
|
+
if (r.slotKey[s] < 0) return s;
|
|
185
|
+
if (r.slotPinned[s]) continue;
|
|
186
|
+
const used = r.slotUsed[s];
|
|
187
|
+
if (used >= frame) continue;
|
|
188
|
+
if (!needed && used > frame - IDLE_FRAMES) continue;
|
|
189
|
+
const key = r.slotKey[s];
|
|
190
|
+
const n = rt.nodeOf(keyDepth(key), keyX(key), keyZ(key));
|
|
191
|
+
if (n >= 0 && rt.tree.neededAt[n] >= frame) continue;
|
|
192
|
+
if (used < bestUsed) {
|
|
193
|
+
bestUsed = used;
|
|
194
|
+
best = s;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (best !== NONE) {
|
|
198
|
+
dropSlot(rt, r, best);
|
|
199
|
+
r.stats.evicted++;
|
|
200
|
+
}
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
203
|
+
function writeSlot(gpu, rt, r, slot, page) {
|
|
204
|
+
const layout = rt.layout;
|
|
205
|
+
const cside = layout.cells + 1;
|
|
206
|
+
const layer = Math.floor(slot / PER_LAYER);
|
|
207
|
+
const cell = slot % PER_LAYER;
|
|
208
|
+
const col = cell % PER_ROW;
|
|
209
|
+
const row = Math.floor(cell / PER_ROW);
|
|
210
|
+
const queue = gpu.device.queue;
|
|
211
|
+
const pages = r.gpuPages;
|
|
212
|
+
const control = r.gpuControl;
|
|
213
|
+
queue.writeTexture(
|
|
214
|
+
{ texture: pages, origin: { x: col * LEAF_SIDE, y: row * LEAF_SIDE, z: layer } },
|
|
215
|
+
page.texels,
|
|
216
|
+
{ bytesPerRow: LEAF_SIDE * 4, rowsPerImage: LEAF_SIDE },
|
|
217
|
+
{ width: LEAF_SIDE, height: LEAF_SIDE, depthOrArrayLayers: 1 }
|
|
218
|
+
);
|
|
219
|
+
queue.writeTexture(
|
|
220
|
+
{ texture: control, origin: { x: col * cside, y: row * cside, z: layer } },
|
|
221
|
+
page.control,
|
|
222
|
+
{ bytesPerRow: cside * 4, rowsPerImage: cside },
|
|
223
|
+
{ width: cside, height: cside, depthOrArrayLayers: 1 }
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const uploadHeightfieldPages = defineSystem({
|
|
227
|
+
name: "terrain/heightfield-upload",
|
|
228
|
+
description: "Uploads streamed heightfield pages into the GPU page pool, at most TerrainBudget.pagesPerFrame a frame, evicting the least recently drawn pages that are not needed now.",
|
|
229
|
+
run: (_s, world) => {
|
|
230
|
+
const assets = world.tryResource(GpuAssetsResource);
|
|
231
|
+
const state = world.tryResource(TerrainWorld);
|
|
232
|
+
if (!assets || !state) return;
|
|
233
|
+
const budget = world.resource(TerrainBudget);
|
|
234
|
+
const frame = state.frame;
|
|
235
|
+
for (const rt of state.heightfields.values()) {
|
|
236
|
+
const r = rt.parts.get("render");
|
|
237
|
+
if (!r || !rt.layout || r.arrived.length === 0) {
|
|
238
|
+
if (r) r.stats.uploadedLastFrame = 0;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
ensurePool(world, rt, r, budget.pages);
|
|
242
|
+
const gpuPages = assets.texture(r.pages)?.texture;
|
|
243
|
+
const gpuControl = assets.texture(r.control)?.texture;
|
|
244
|
+
if (!gpuPages || !gpuControl) continue;
|
|
245
|
+
r.gpuPages = gpuPages;
|
|
246
|
+
r.gpuControl = gpuControl;
|
|
247
|
+
r.arrived.sort(
|
|
248
|
+
(a, b) => (b.depth <= rt.residentDepth ? 1 : 0) - (a.depth <= rt.residentDepth ? 1 : 0)
|
|
249
|
+
);
|
|
250
|
+
let uploaded = 0;
|
|
251
|
+
let i = 0;
|
|
252
|
+
for (; i < r.arrived.length; i++) {
|
|
253
|
+
const page = r.arrived[i];
|
|
254
|
+
const pinned = page.depth <= rt.residentDepth;
|
|
255
|
+
if (!pinned && uploaded >= budget.pagesPerFrame) break;
|
|
256
|
+
r.pending.delete(page.key);
|
|
257
|
+
if (!page.texels) continue;
|
|
258
|
+
let slot = r.slotOf(page.key);
|
|
259
|
+
if (slot === NONE) {
|
|
260
|
+
const n2 = rt.nodeOf(page.depth, page.x, page.z);
|
|
261
|
+
const needed = pinned || n2 >= 0 && rt.tree.neededAt[n2] >= frame - 1;
|
|
262
|
+
slot = acquireSlot(rt, r, frame, needed);
|
|
263
|
+
if (slot === NONE) {
|
|
264
|
+
r.stats.skippedFull++;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
r.slotKey[slot] = page.key;
|
|
268
|
+
r.keySlot.set(page.key, slot);
|
|
269
|
+
}
|
|
270
|
+
r.slotPinned[slot] = pinned ? 1 : 0;
|
|
271
|
+
r.slotUsed[slot] = frame;
|
|
272
|
+
writeSlot(assets.gpu, rt, r, slot, page);
|
|
273
|
+
page.texels = void 0;
|
|
274
|
+
if (!pinned) uploaded++;
|
|
275
|
+
r.stats.uploaded++;
|
|
276
|
+
const n = rt.nodeOf(page.depth, page.x, page.z);
|
|
277
|
+
if (n >= 0) {
|
|
278
|
+
rt.tree.flags[n] |= NODE_READY;
|
|
279
|
+
rt.tree.slot[n] = slot;
|
|
280
|
+
rt.tree.gen[n] = rt.version;
|
|
281
|
+
if (!(rt.tree.flags[n] & 2)) rt.refreshBounds(n);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
r.arrived.splice(0, i);
|
|
285
|
+
r.stats.uploadedLastFrame = uploaded;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
const camPos = new Float64Array(3);
|
|
290
|
+
const right = new Float64Array(3);
|
|
291
|
+
const up = new Float64Array(3);
|
|
292
|
+
const forward = new Float64Array(3);
|
|
293
|
+
const g = new Float64Array(2);
|
|
294
|
+
function normalize3(v) {
|
|
295
|
+
const l = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) || 1;
|
|
296
|
+
v[0] = v[0] / l;
|
|
297
|
+
v[1] = v[1] / l;
|
|
298
|
+
v[2] = v[2] / l;
|
|
299
|
+
}
|
|
300
|
+
function updateView(world, rt, r, camera) {
|
|
301
|
+
const m = world.get(camera, GlobalTransform).matrix;
|
|
302
|
+
const cam = world.get(camera, Camera3d);
|
|
303
|
+
const data = world.tryResource(Cameras)?.get(camera);
|
|
304
|
+
const height = data && data.height > 1 ? data.displayHeight / data.pixelRatio : 720;
|
|
305
|
+
const aspect = data && data.height > 1 ? data.width / data.height : 16 / 9;
|
|
306
|
+
const fovY = cam.fovY * Math.PI / 180;
|
|
307
|
+
rt.frame.pointToPlanet(m[3], m[7], m[11], camPos);
|
|
308
|
+
rt.frame.vectorToPlanet(m[0], m[4], m[8], right);
|
|
309
|
+
rt.frame.vectorToPlanet(m[1], m[5], m[9], up);
|
|
310
|
+
rt.frame.vectorToPlanet(-m[2], -m[6], -m[10], forward);
|
|
311
|
+
normalize3(right);
|
|
312
|
+
normalize3(up);
|
|
313
|
+
normalize3(forward);
|
|
314
|
+
perspectiveView(r.view, camPos, right, up, forward, fovY, aspect, height);
|
|
315
|
+
r.camera[0] = m[3];
|
|
316
|
+
r.camera[1] = m[7];
|
|
317
|
+
r.camera[2] = m[11];
|
|
318
|
+
r.camera[3] = r.view.pixelsPerRadian / (rt.settings.errorPixels * rt.lodBias);
|
|
319
|
+
}
|
|
320
|
+
function selectionParams(rt) {
|
|
321
|
+
const D = rt.layout.depth;
|
|
322
|
+
return {
|
|
323
|
+
maxDepth: D,
|
|
324
|
+
errors: rt.errors,
|
|
325
|
+
errorPixels: rt.settings.errorPixels * rt.lodBias,
|
|
326
|
+
occluder: 0,
|
|
327
|
+
colliderDepth: D,
|
|
328
|
+
anchorPos: rt.anchorPos,
|
|
329
|
+
anchorRadius: rt.anchorRadius,
|
|
330
|
+
anchors: rt.anchors
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
let wantKeys = new Float64Array(256);
|
|
334
|
+
let wantPriority = new Float32Array(256);
|
|
335
|
+
function requestPages(rt, r) {
|
|
336
|
+
const sel = rt.selection;
|
|
337
|
+
const tree = rt.tree;
|
|
338
|
+
const pages = rt.pages;
|
|
339
|
+
let count = 0;
|
|
340
|
+
for (let i = 0; i < sel.requestedCount; i++) {
|
|
341
|
+
const n = sel.requested[i];
|
|
342
|
+
rt.grid.globalOf(tree, n, g);
|
|
343
|
+
const depth = tree.depth[n];
|
|
344
|
+
const key = nodeKey(depth, g[0], g[1]);
|
|
345
|
+
const slot = r.slotOf(key);
|
|
346
|
+
if (slot !== NONE) {
|
|
347
|
+
tree.flags[n] |= NODE_READY;
|
|
348
|
+
tree.slot[n] = slot;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (r.pending.has(key)) continue;
|
|
352
|
+
if (count === wantKeys.length) {
|
|
353
|
+
const k = new Float64Array(count * 2);
|
|
354
|
+
k.set(wantKeys);
|
|
355
|
+
wantKeys = k;
|
|
356
|
+
const p = new Float32Array(count * 2);
|
|
357
|
+
p.set(wantPriority);
|
|
358
|
+
wantPriority = p;
|
|
359
|
+
}
|
|
360
|
+
wantKeys[count] = key;
|
|
361
|
+
wantPriority[count] = tree.priority[n];
|
|
362
|
+
count++;
|
|
363
|
+
}
|
|
364
|
+
let inflight = pages.pendingReads;
|
|
365
|
+
while (inflight < MAX_READS && count > 0) {
|
|
366
|
+
let best = 0;
|
|
367
|
+
for (let k = 1; k < count; k++) if (wantPriority[k] > wantPriority[best]) best = k;
|
|
368
|
+
const key = wantKeys[best];
|
|
369
|
+
wantKeys[best] = wantKeys[count - 1];
|
|
370
|
+
wantPriority[best] = wantPriority[count - 1];
|
|
371
|
+
count--;
|
|
372
|
+
r.pending.add(key);
|
|
373
|
+
r.stats.requested++;
|
|
374
|
+
inflight++;
|
|
375
|
+
const version = rt.version;
|
|
376
|
+
void pages.load(keyDepth(key), keyX(key), keyZ(key)).then(
|
|
377
|
+
(page) => {
|
|
378
|
+
if (page && rt.version === version) r.arrived.push(page);
|
|
379
|
+
else r.pending.delete(key);
|
|
380
|
+
},
|
|
381
|
+
() => r.pending.delete(key)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function meshFor(world, rt, r, variant) {
|
|
386
|
+
let ref = r.meshes.get(variant);
|
|
387
|
+
if (ref) return ref;
|
|
388
|
+
const n = PAGE + 1;
|
|
389
|
+
const layout = chunkLayout(n);
|
|
390
|
+
const positions = new Float32Array(layout.vertexCount * 3);
|
|
391
|
+
for (let j = 0; j < n; j++) {
|
|
392
|
+
for (let i = 0; i < n; i++) {
|
|
393
|
+
const v = layout.index[i + j * n];
|
|
394
|
+
positions[v * 3] = i;
|
|
395
|
+
positions[v * 3 + 2] = j;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
for (let k = 0; k < layout.ring; k++) {
|
|
399
|
+
const v = layout.surface + k;
|
|
400
|
+
positions[v * 3] = layout.ringPoints[k * 2];
|
|
401
|
+
positions[v * 3 + 1] = 1;
|
|
402
|
+
positions[v * 3 + 2] = layout.ringPoints[k * 2 + 1];
|
|
403
|
+
}
|
|
404
|
+
const normals = new Float32Array(layout.vertexCount * 3);
|
|
405
|
+
for (let v = 0; v < layout.vertexCount; v++) normals[v * 3 + 1] = 1;
|
|
406
|
+
const indices = chunkIndices(n, variant & 15, variant >> 4, rt.settings.skirts, "anti");
|
|
407
|
+
const mesh = Mesh.create({
|
|
408
|
+
positions,
|
|
409
|
+
normals,
|
|
410
|
+
uvs: new Float32Array(layout.vertexCount * 2),
|
|
411
|
+
indices
|
|
412
|
+
});
|
|
413
|
+
const skirt2 = rt.rawErrors[0] * 2 + rt.spacing(0);
|
|
414
|
+
const b = mesh.bounds;
|
|
415
|
+
b[0] = -PAGE / 2;
|
|
416
|
+
b[1] = rt.lo - skirt2;
|
|
417
|
+
b[2] = -PAGE / 2;
|
|
418
|
+
b[3] = PAGE / 2;
|
|
419
|
+
b[4] = rt.hi;
|
|
420
|
+
b[5] = PAGE / 2;
|
|
421
|
+
ref = world.resource(Meshes).add(mesh, `terrain/heightfield/${variant}`);
|
|
422
|
+
r.meshes.set(variant, ref);
|
|
423
|
+
return ref;
|
|
424
|
+
}
|
|
425
|
+
const center = new Float64Array(3);
|
|
426
|
+
function chunkFor(world, rt, r, slot) {
|
|
427
|
+
let c = r.chunks[slot];
|
|
428
|
+
if (c) return c;
|
|
429
|
+
const entity = world.spawn(
|
|
430
|
+
[Mesh3d, { mesh: meshFor(world, rt, r, 15) }],
|
|
431
|
+
[MeshMaterial, { material: r.materialRef }],
|
|
432
|
+
[TerrainChunk, { terrain: rt.entity, key: "", kind: "render" }],
|
|
433
|
+
[ChildOf, { parent: rt.entity }],
|
|
434
|
+
[Visibility, { mode: "hidden" }],
|
|
435
|
+
[InstanceData, { x: 0, y: slot }],
|
|
436
|
+
Transform,
|
|
437
|
+
Derived
|
|
438
|
+
);
|
|
439
|
+
c = { entity, key: NONE, variant: 15, shown: false, fade: 0, used: 0 };
|
|
440
|
+
r.chunks[slot] = c;
|
|
441
|
+
return c;
|
|
442
|
+
}
|
|
443
|
+
function setShown(world, c, shown) {
|
|
444
|
+
if (c.shown === shown) return;
|
|
445
|
+
c.shown = shown;
|
|
446
|
+
world.set(c.entity, Visibility, { mode: shown ? "inherit" : "hidden" });
|
|
447
|
+
}
|
|
448
|
+
function showSelected(world, rt, r, frame, params) {
|
|
449
|
+
const dt = world.tryResource(Time)?.delta ?? 1 / 60;
|
|
450
|
+
const tree = rt.tree;
|
|
451
|
+
const sel = rt.selection;
|
|
452
|
+
for (let i = 0; i < sel.renderedCount; i++) {
|
|
453
|
+
const n = sel.rendered[i];
|
|
454
|
+
const slot = tree.slot[n];
|
|
455
|
+
if (slot < 0 || slot >= r.capacity) continue;
|
|
456
|
+
rt.grid.globalOf(tree, n, g);
|
|
457
|
+
const depth = tree.depth[n];
|
|
458
|
+
const key = nodeKey(depth, g[0], g[1]);
|
|
459
|
+
if (r.slotKey[slot] !== key) continue;
|
|
460
|
+
r.slotUsed[slot] = frame;
|
|
461
|
+
const c = chunkFor(world, rt, r, slot);
|
|
462
|
+
c.used = frame;
|
|
463
|
+
if (c.key !== key) {
|
|
464
|
+
const size = rt.nodeSize(depth);
|
|
465
|
+
center[0] = (g[0] + 0.5) * size;
|
|
466
|
+
center[1] = 0;
|
|
467
|
+
center[2] = (g[1] + 0.5) * size;
|
|
468
|
+
placeInGrid(world, c.entity, rt.entity, center);
|
|
469
|
+
const s = size / PAGE;
|
|
470
|
+
world.set(c.entity, Transform, { ...world.get(c.entity, Transform), scale: [s, 1, s] });
|
|
471
|
+
propagateSubtree(world, c.entity);
|
|
472
|
+
world.set(c.entity, TerrainChunk, {
|
|
473
|
+
terrain: rt.entity,
|
|
474
|
+
key: `${depth}/${g[0]}/${g[1]}`,
|
|
475
|
+
kind: "render"
|
|
476
|
+
});
|
|
477
|
+
c.key = key;
|
|
478
|
+
setShown(world, c, false);
|
|
479
|
+
}
|
|
480
|
+
let fade = c.fade;
|
|
481
|
+
if (!c.shown) {
|
|
482
|
+
const parent = tree.parent[n];
|
|
483
|
+
fade = parent !== NONE && tree.rendered[parent] === r.lastStamp ? 1 : 0;
|
|
484
|
+
} else if (fade > 0) fade = Math.max(0, fade - dt / FADE);
|
|
485
|
+
const mask = tree.mask[n];
|
|
486
|
+
let bits = 0;
|
|
487
|
+
let stitch = 0;
|
|
488
|
+
for (let e = 0; e < 4; e++) {
|
|
489
|
+
const lock = tree.locks[n * 4 + e];
|
|
490
|
+
bits |= (lock < 0 ? 0 : lock === 0 ? 1 : 2) << e * 2;
|
|
491
|
+
if (lock === 1) stitch |= 1 << e;
|
|
492
|
+
}
|
|
493
|
+
const variant = mask + 16 * stitch;
|
|
494
|
+
if (variant !== c.variant) {
|
|
495
|
+
c.variant = variant;
|
|
496
|
+
world.set(c.entity, Mesh3d, {
|
|
497
|
+
...world.get(c.entity, Mesh3d),
|
|
498
|
+
mesh: meshFor(world, rt, r, variant)
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
setShown(world, c, true);
|
|
502
|
+
c.fade = fade;
|
|
503
|
+
const o = slot * 4;
|
|
504
|
+
const fb = Math.round(fade * 255);
|
|
505
|
+
const anchored = depth === rt.layout.depth && nearAnchor(tree, n, params) ? 128 : 0;
|
|
506
|
+
const db = depth | anchored;
|
|
507
|
+
if (r.table[o] !== bits || r.table[o + 1] !== mask || r.table[o + 2] !== fb || r.table[o + 3] !== db) {
|
|
508
|
+
r.table[o] = bits;
|
|
509
|
+
r.table[o + 1] = mask;
|
|
510
|
+
r.table[o + 2] = fb;
|
|
511
|
+
r.table[o + 3] = db;
|
|
512
|
+
r.tableDirty = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
r.lastStamp = sel.stamp;
|
|
516
|
+
for (const c of r.chunks) if (c?.shown && c.used !== frame) setShown(world, c, false);
|
|
517
|
+
}
|
|
518
|
+
function hideAll(world, r) {
|
|
519
|
+
for (const c of r.chunks) if (c?.shown) setShown(world, c, false);
|
|
520
|
+
}
|
|
521
|
+
const CHUNK_TRIANGLES = 2 * PAGE * PAGE;
|
|
522
|
+
const selectHeightfields = defineSystem({
|
|
523
|
+
name: "terrain/heightfield-select",
|
|
524
|
+
description: "Selects each Terrain\u2019s chunks for the camera (screen-space error, frustum culling, 2:1 balance on the root grid), requests their pages, and shows exactly the selected ones.",
|
|
525
|
+
setup: (world) => ({ cameras: world.query({ with: [Camera3d, GlobalTransform] }) }),
|
|
526
|
+
run: (s, world) => {
|
|
527
|
+
if (!world.tryResource(GpuAssetsResource)) return;
|
|
528
|
+
const state = world.tryResource(TerrainWorld);
|
|
529
|
+
if (!state) return;
|
|
530
|
+
const budget = world.resource(TerrainBudget);
|
|
531
|
+
const frame = state.frame;
|
|
532
|
+
const picked = pickCamera(s.cameras);
|
|
533
|
+
for (const rt of state.heightfields.values()) {
|
|
534
|
+
if (!rt.ready || !rt.streaming || !world.isAlive(rt.entity)) continue;
|
|
535
|
+
let r = heightfieldRenderOf(world, rt);
|
|
536
|
+
if (r.layout !== void 0 && r.layout !== rt.layout) {
|
|
537
|
+
cleanupHeightfieldRender(world, rt);
|
|
538
|
+
r = heightfieldRenderOf(world, rt);
|
|
539
|
+
}
|
|
540
|
+
r.layout = rt.layout;
|
|
541
|
+
ensurePool(world, rt, r, budget.pages);
|
|
542
|
+
if (r.version !== rt.version) reloadPool(rt, r);
|
|
543
|
+
updateMaterial(world, rt, r);
|
|
544
|
+
if (!picked) {
|
|
545
|
+
r.hasCamera = false;
|
|
546
|
+
hideAll(world, r);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
r.hasCamera = true;
|
|
550
|
+
updateView(world, rt, r, picked.entity);
|
|
551
|
+
const params = selectionParams(rt);
|
|
552
|
+
selectNodes(rt.tree, r.view, params, frame, rt.selection);
|
|
553
|
+
rt.lodBias = adaptLodBias(
|
|
554
|
+
rt.lodBias,
|
|
555
|
+
rt.selection.renderedCount * CHUNK_TRIANGLES,
|
|
556
|
+
budget.triangles
|
|
557
|
+
);
|
|
558
|
+
requestPages(rt, r);
|
|
559
|
+
showSelected(world, rt, r, frame, params);
|
|
560
|
+
writeTable(world, r);
|
|
561
|
+
if (frame % 120 === 0) {
|
|
562
|
+
rt.tree.prune(frame - 600, (n) => {
|
|
563
|
+
rt.tree.slot[n] = NONE;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
function reloadPool(rt, r) {
|
|
570
|
+
r.version = rt.version;
|
|
571
|
+
r.arrived.length = 0;
|
|
572
|
+
r.pending.clear();
|
|
573
|
+
const version = rt.version;
|
|
574
|
+
const pages = rt.pages;
|
|
575
|
+
const keys = [...r.keySlot.keys()].sort((a, b) => keyDepth(a) - keyDepth(b));
|
|
576
|
+
for (const key of keys) {
|
|
577
|
+
r.pending.add(key);
|
|
578
|
+
void pages.load(keyDepth(key), keyX(key), keyZ(key)).then(
|
|
579
|
+
(page) => {
|
|
580
|
+
if (page && rt.version === version) r.arrived.push(page);
|
|
581
|
+
},
|
|
582
|
+
() => r.pending.delete(key)
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
enqueueResident(rt, r);
|
|
586
|
+
}
|
|
587
|
+
function enqueueResident(rt, r) {
|
|
588
|
+
for (const page of rt.pages.pages()) {
|
|
589
|
+
if (page.depth > rt.residentDepth || r.pending.has(page.key) || r.slotOf(page.key) !== NONE)
|
|
590
|
+
continue;
|
|
591
|
+
if (!page.texels) continue;
|
|
592
|
+
r.pending.add(page.key);
|
|
593
|
+
r.arrived.push(page);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function writeTable(world, r) {
|
|
597
|
+
if (!r.tableDirty || !r.chunksTexture) return;
|
|
598
|
+
const assets = world.resource(GpuAssetsResource);
|
|
599
|
+
const tex = assets.texture(r.chunksTexture)?.texture;
|
|
600
|
+
if (!tex) return;
|
|
601
|
+
const rows = Math.ceil(r.table.length / 4 / CHUNK_TABLE_WIDTH);
|
|
602
|
+
assets.gpu.device.queue.writeTexture(
|
|
603
|
+
{ texture: tex },
|
|
604
|
+
r.table,
|
|
605
|
+
{ bytesPerRow: CHUNK_TABLE_WIDTH * 4, rowsPerImage: rows },
|
|
606
|
+
{ width: CHUNK_TABLE_WIDTH, height: rows, depthOrArrayLayers: 1 }
|
|
607
|
+
);
|
|
608
|
+
r.tableDirty = false;
|
|
609
|
+
}
|
|
610
|
+
const origin = new Float64Array(3);
|
|
611
|
+
const ERRORS = ["errors0", "errors1", "errors2"];
|
|
612
|
+
const SKIRTS = ["skirts0", "skirts1", "skirts2"];
|
|
613
|
+
function set4(v, key, x, y, z, w) {
|
|
614
|
+
let a = v[key];
|
|
615
|
+
if (!Array.isArray(a) || a.length !== 4) {
|
|
616
|
+
a = [0, 0, 0, 0];
|
|
617
|
+
v[key] = a;
|
|
618
|
+
}
|
|
619
|
+
a[0] = x;
|
|
620
|
+
a[1] = y;
|
|
621
|
+
a[2] = z;
|
|
622
|
+
a[3] = w;
|
|
623
|
+
}
|
|
624
|
+
const wrap = (x, period) => (x % period + period) % period;
|
|
625
|
+
function skirt(rt, depth) {
|
|
626
|
+
return depth <= rt.layout.depth ? 2 * (rt.rawErrors[depth] ?? 0) + rt.spacing(depth) : 0;
|
|
627
|
+
}
|
|
628
|
+
function debugMode(world) {
|
|
629
|
+
const o = world.tryResource(DebugOverlays);
|
|
630
|
+
if (o && isOverlayOn(o, "terrain-lod")) return 3;
|
|
631
|
+
if (o && isOverlayOn(o, "terrain-pages")) return 5;
|
|
632
|
+
return HeightfieldDebug.mode;
|
|
633
|
+
}
|
|
634
|
+
function updateMaterial(world, rt, r) {
|
|
635
|
+
const asset = rt.asset;
|
|
636
|
+
const source = asset.source;
|
|
637
|
+
const layout = rt.layout;
|
|
638
|
+
const v = r.material.value;
|
|
639
|
+
const hash = `${asset.hash}:${asset.version}`;
|
|
640
|
+
if (hash !== r.layerHash) {
|
|
641
|
+
r.layerHash = hash;
|
|
642
|
+
const count = Math.max(1, source.layers.length);
|
|
643
|
+
const data = new Float32Array(2 * count * 4);
|
|
644
|
+
source.layers.forEach((l, i) => {
|
|
645
|
+
const o = i * 8;
|
|
646
|
+
data[o] = l.albedo;
|
|
647
|
+
data[o + 1] = l.normal;
|
|
648
|
+
data[o + 2] = l.orm;
|
|
649
|
+
data[o + 3] = TEXTURE_PERIOD / Math.max(1, Math.round(TEXTURE_PERIOD / l.scale));
|
|
650
|
+
data[o + 4] = l.tint[0];
|
|
651
|
+
data[o + 5] = l.tint[1];
|
|
652
|
+
data[o + 6] = l.tint[2];
|
|
653
|
+
data[o + 7] = l.triplanar ? 1 : 0;
|
|
654
|
+
});
|
|
655
|
+
const textures = world.initResource(Textures);
|
|
656
|
+
if (r.layerRef?.guid) textures.delete(r.layerRef.guid);
|
|
657
|
+
const tex = Texture.create({
|
|
658
|
+
width: 2,
|
|
659
|
+
height: count,
|
|
660
|
+
format: "rgba16float",
|
|
661
|
+
usage: "data",
|
|
662
|
+
mips: [new Uint8Array(toHalf(data).buffer)]
|
|
663
|
+
});
|
|
664
|
+
r.layerRef = textures.add(tex, "terrain/heightfield-layers");
|
|
665
|
+
const deps = asset.deps;
|
|
666
|
+
const ref = (t) => t && deps[t.path] ? { type: "Texture", guid: deps[t.path].guid, path: t.path } : null;
|
|
667
|
+
v.layerTable = r.layerRef;
|
|
668
|
+
v.albedoArray = ref(source.textures.albedo);
|
|
669
|
+
v.ormArray = ref(source.textures.orm);
|
|
670
|
+
r.material.version++;
|
|
671
|
+
}
|
|
672
|
+
const toTerrain = rt.frame.toPlanet;
|
|
673
|
+
rt.frame.pointToOrigin(0, 0, 0, origin);
|
|
674
|
+
const period = TEXTURE_PERIOD;
|
|
675
|
+
set4(v, "center", origin[0], origin[1], origin[2], 0);
|
|
676
|
+
set4(v, "rot0", toTerrain[0], toTerrain[1], toTerrain[2], wrap(toTerrain[3], period));
|
|
677
|
+
set4(v, "rot1", toTerrain[4], toTerrain[5], toTerrain[6], wrap(toTerrain[7], period));
|
|
678
|
+
set4(v, "rot2", toTerrain[8], toTerrain[9], toTerrain[10], wrap(toTerrain[11], period));
|
|
679
|
+
set4(v, "camera", r.camera[0], r.camera[1], r.camera[2], r.camera[3]);
|
|
680
|
+
set4(v, "pool", PER_ROW, PER_LAYER, ATLAS, layout.cells + 1);
|
|
681
|
+
set4(v, "range", rt.lo, rt.hi, layout.cells, Math.max(1, source.layers.length));
|
|
682
|
+
const e = rt.errors;
|
|
683
|
+
for (let k = 0; k < 3; k++) {
|
|
684
|
+
set4(v, ERRORS[k], e[k * 4] ?? 0, e[k * 4 + 1] ?? 0, e[k * 4 + 2] ?? 0, e[k * 4 + 3] ?? 0);
|
|
685
|
+
set4(
|
|
686
|
+
v,
|
|
687
|
+
SKIRTS[k],
|
|
688
|
+
skirt(rt, k * 4),
|
|
689
|
+
skirt(rt, k * 4 + 1),
|
|
690
|
+
skirt(rt, k * 4 + 2),
|
|
691
|
+
skirt(rt, k * 4 + 3)
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
const debug = debugMode(world);
|
|
695
|
+
const hasAlbedo = v.albedoArray ? 1 : 0;
|
|
696
|
+
const hasOrm = v.ormArray ? 1 : 0;
|
|
697
|
+
const dbg = v.debug;
|
|
698
|
+
if (!dbg || dbg[0] !== debug || dbg[1] !== hasAlbedo || dbg[2] !== hasOrm) {
|
|
699
|
+
v.debug = [debug, hasAlbedo, hasOrm, TEXTURE_PERIOD];
|
|
700
|
+
r.material.version++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function cleanupHeightfieldRender(world, rt) {
|
|
704
|
+
const r = rt.parts.get("render");
|
|
705
|
+
if (!r) return;
|
|
706
|
+
for (const c of r.chunks) if (c && world.isAlive(c.entity)) world.despawn(c.entity);
|
|
707
|
+
const textures = world.tryResource(Textures);
|
|
708
|
+
for (const ref of [r.pagesRef, r.controlRef, r.chunksRef, r.layerRef])
|
|
709
|
+
if (ref?.guid) textures?.delete(ref.guid);
|
|
710
|
+
const meshes = world.tryResource(Meshes);
|
|
711
|
+
for (const ref of r.meshes.values()) if (ref.guid) meshes?.delete(ref.guid);
|
|
712
|
+
if (r.materialRef.guid) world.tryResource(Materials)?.delete(r.materialRef.guid);
|
|
713
|
+
rt.parts.delete("render");
|
|
714
|
+
rt.parts.delete("ready");
|
|
715
|
+
}
|
|
716
|
+
export {
|
|
717
|
+
ATLAS,
|
|
718
|
+
HeightfieldDebug,
|
|
719
|
+
HeightfieldRender,
|
|
720
|
+
PER_LAYER,
|
|
721
|
+
PER_ROW,
|
|
722
|
+
cleanupHeightfieldRender,
|
|
723
|
+
enqueueResident,
|
|
724
|
+
heightfieldRenderOf,
|
|
725
|
+
selectHeightfields,
|
|
726
|
+
uploadHeightfieldPages
|
|
727
|
+
};
|