@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,402 @@
|
|
|
1
|
+
import { isPlainObject, pointer, ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { BLOCK, PAGE } from "./kernel.js";
|
|
3
|
+
const MAX_MATERIAL_LAYERS = 32;
|
|
4
|
+
const MIN_SIZE = 256;
|
|
5
|
+
const MAX_SIZE = 65536;
|
|
6
|
+
const MIN_SPACING = 0.25;
|
|
7
|
+
const MAX_SPACING = 4;
|
|
8
|
+
const MAX_ROOTS = 64;
|
|
9
|
+
const MAX_DEPTH = 10;
|
|
10
|
+
const LAYER_TINTS = [
|
|
11
|
+
[0.16, 0.3, 0.08],
|
|
12
|
+
[0.32, 0.3, 0.28],
|
|
13
|
+
[0.45, 0.4, 0.33],
|
|
14
|
+
[0.7, 0.62, 0.42],
|
|
15
|
+
[0.9, 0.92, 0.95],
|
|
16
|
+
[0.25, 0.17, 0.1],
|
|
17
|
+
[0.12, 0.2, 0.12],
|
|
18
|
+
[0.5, 0.25, 0.15]
|
|
19
|
+
];
|
|
20
|
+
const shape = (path, message, hint) => new ShardError("terrain/bad-source", message, { path, ...hint ? { hint } : {} });
|
|
21
|
+
function num(v, path, what) {
|
|
22
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw shape(path, `${what} must be a number`);
|
|
23
|
+
return v;
|
|
24
|
+
}
|
|
25
|
+
function opt(v, fallback, path, what) {
|
|
26
|
+
return v === void 0 ? fallback : num(v, path, what);
|
|
27
|
+
}
|
|
28
|
+
function pair(v, path, what) {
|
|
29
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
30
|
+
throw shape(path, `${what} must be [a, b]`, "Two numbers, e.g. [0, 140].");
|
|
31
|
+
return [num(v[0], pointer(path, 0), what), num(v[1], pointer(path, 1), what)];
|
|
32
|
+
}
|
|
33
|
+
function ref(v, path, what) {
|
|
34
|
+
if (typeof v === "string" && v !== "") return { path: v };
|
|
35
|
+
if (isPlainObject(v) && typeof v.path === "string" && v.path !== "") {
|
|
36
|
+
return typeof v.guid === "string" ? { path: v.path, guid: v.guid } : { path: v.path };
|
|
37
|
+
}
|
|
38
|
+
throw shape(path, `${what} must be { "path": "\u2026" }`, 'e.g. { "path": "noise/hills.noise.json" }');
|
|
39
|
+
}
|
|
40
|
+
function blendOf(v, fallback, path) {
|
|
41
|
+
if (v === void 0) return fallback;
|
|
42
|
+
if (v === "add" || v === "max" || v === "min" || v === "replace") return v;
|
|
43
|
+
throw shape(path, `blend must be "add", "max", "min" or "replace"`);
|
|
44
|
+
}
|
|
45
|
+
function rectOf(o, path) {
|
|
46
|
+
const size = pair(o.size, pointer(path, "size"), "size");
|
|
47
|
+
if (!(size[0] > 0 && size[1] > 0)) throw shape(pointer(path, "size"), "size must be positive");
|
|
48
|
+
return {
|
|
49
|
+
at: pair(o.at, pointer(path, "at"), "at"),
|
|
50
|
+
size,
|
|
51
|
+
rotation: opt(o.rotation, 0, pointer(path, "rotation"), "rotation")
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function known(o, keys, path) {
|
|
55
|
+
for (const k of Object.keys(o)) {
|
|
56
|
+
if (k === "$schema" || keys.includes(k)) continue;
|
|
57
|
+
throw shape(pointer(path, k), `Unknown field "${k}"`, `Fields here: ${keys.join(", ")}.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseTerrainSource(json) {
|
|
61
|
+
if (!isPlainObject(json)) throw shape("", "A terrain file is a JSON object");
|
|
62
|
+
known(
|
|
63
|
+
json,
|
|
64
|
+
[
|
|
65
|
+
"size",
|
|
66
|
+
"spacing",
|
|
67
|
+
"paintSpacing",
|
|
68
|
+
"heightRange",
|
|
69
|
+
"seed",
|
|
70
|
+
"splines",
|
|
71
|
+
"height",
|
|
72
|
+
"layers",
|
|
73
|
+
"textures",
|
|
74
|
+
"paint"
|
|
75
|
+
],
|
|
76
|
+
""
|
|
77
|
+
);
|
|
78
|
+
const size = pair(json.size, "/size", "size");
|
|
79
|
+
const spacing = num(json.spacing, "/spacing", "spacing");
|
|
80
|
+
if (!(spacing >= MIN_SPACING && spacing <= MAX_SPACING)) {
|
|
81
|
+
throw new ShardError(
|
|
82
|
+
"terrain/bad-spacing",
|
|
83
|
+
`spacing ${spacing} m is outside ${MIN_SPACING}\u2013${MAX_SPACING} m`,
|
|
84
|
+
{
|
|
85
|
+
path: "/spacing",
|
|
86
|
+
hint: "Sample spacing is the leaf vertex spacing: 1 m for most open worlds, 0.5 m for detailed ground."
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const paintSpacing = opt(json.paintSpacing, spacing * 2, "/paintSpacing", "paintSpacing");
|
|
91
|
+
const step = paintSpacing / spacing;
|
|
92
|
+
if (!(step === 1 || step === 2 || step === 4)) {
|
|
93
|
+
throw new ShardError(
|
|
94
|
+
"terrain/bad-spacing",
|
|
95
|
+
`paintSpacing ${paintSpacing} m isn't 1, 2 or 4 times spacing (${spacing} m)`,
|
|
96
|
+
{
|
|
97
|
+
path: "/paintSpacing",
|
|
98
|
+
hint: "The default, twice spacing, quarters control memory and keeps 8 m roads crisp."
|
|
99
|
+
}
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const heightRange = pair(json.heightRange, "/heightRange", "heightRange");
|
|
103
|
+
if (!(heightRange[1] > heightRange[0]))
|
|
104
|
+
throw shape("/heightRange", "heightRange must be [lowest, highest] with lowest < highest");
|
|
105
|
+
const seed = opt(json.seed, 0, "/seed", "seed") >>> 0;
|
|
106
|
+
const splines = {};
|
|
107
|
+
if (json.splines !== void 0) {
|
|
108
|
+
if (!isPlainObject(json.splines))
|
|
109
|
+
throw shape(
|
|
110
|
+
"/splines",
|
|
111
|
+
"splines is an object of named splines",
|
|
112
|
+
'e.g. { "north-road": { \u2026 } }'
|
|
113
|
+
);
|
|
114
|
+
for (const [name, value] of Object.entries(json.splines)) {
|
|
115
|
+
const path = pointer("/splines", name);
|
|
116
|
+
if (!isPlainObject(value)) throw shape(path, "A spline is an object");
|
|
117
|
+
known(value, ["points", "width", "falloff"], path);
|
|
118
|
+
if (!Array.isArray(value.points) || value.points.length < 2)
|
|
119
|
+
throw shape(pointer(path, "points"), "A spline needs at least two points");
|
|
120
|
+
const points = value.points.map((p, i) => {
|
|
121
|
+
const pp = pointer(pointer(path, "points"), i);
|
|
122
|
+
if (!Array.isArray(p) || p.length !== 3)
|
|
123
|
+
throw shape(pp, "A point is [x, y, z]", 'y may be "ground": the height below at x, z.');
|
|
124
|
+
const y = p[1] === "ground" ? "ground" : num(p[1], pointer(pp, 1), "y");
|
|
125
|
+
return [num(p[0], pointer(pp, 0), "x"), y, num(p[2], pointer(pp, 2), "z")];
|
|
126
|
+
});
|
|
127
|
+
const width = num(value.width, pointer(path, "width"), "width");
|
|
128
|
+
if (!(width > 0)) throw shape(pointer(path, "width"), "width must be positive");
|
|
129
|
+
const falloff = opt(value.falloff, 0, pointer(path, "falloff"), "falloff");
|
|
130
|
+
if (falloff < 0) throw shape(pointer(path, "falloff"), "falloff must be \u2265 0");
|
|
131
|
+
splines[name] = { points, width, falloff };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const splineName = (v, path) => {
|
|
135
|
+
if (typeof v !== "string" || v === "") throw shape(path, "A spline is named by a string");
|
|
136
|
+
if (!splines[v]) {
|
|
137
|
+
throw new ShardError("terrain/unknown-spline", `No spline named "${v}"`, {
|
|
138
|
+
path,
|
|
139
|
+
hint: `Define it under "splines". Known: ${Object.keys(splines).join(", ") || "(none)"}.`
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return v;
|
|
143
|
+
};
|
|
144
|
+
if (!Array.isArray(json.height))
|
|
145
|
+
throw shape(
|
|
146
|
+
"/height",
|
|
147
|
+
"height is a list of layers",
|
|
148
|
+
'e.g. [{ "noise": { "path": "\u2026" }, "scale": 200 }]'
|
|
149
|
+
);
|
|
150
|
+
const height = json.height.map((value, i) => {
|
|
151
|
+
const path = pointer("/height", i);
|
|
152
|
+
if (!isPlainObject(value)) throw shape(path, "A height layer is an object");
|
|
153
|
+
const kinds = ["noise", "image", "spline"].filter((k) => value[k] !== void 0);
|
|
154
|
+
if (kinds.length !== 1) {
|
|
155
|
+
throw shape(
|
|
156
|
+
path,
|
|
157
|
+
'A height layer has exactly one of "noise", "image" or "spline"',
|
|
158
|
+
'Noise: { "noise": { "path": \u2026 }, "scale": 200 }. Image: { "image": { "path": \u2026 }, "at", "size", "range" }. Spline: { "spline": "road", "mode": "flatten" }.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const falloff = opt(value.falloff, 0, pointer(path, "falloff"), "falloff");
|
|
162
|
+
if (falloff < 0) throw shape(pointer(path, "falloff"), "falloff must be \u2265 0");
|
|
163
|
+
if (value.noise !== void 0) {
|
|
164
|
+
known(value, ["noise", "scale", "offset", "blend", "falloff", "at", "size", "rotation"], path);
|
|
165
|
+
const region = value.at !== void 0 || value.size !== void 0 ? rectOf(value, path) : null;
|
|
166
|
+
return {
|
|
167
|
+
kind: "noise",
|
|
168
|
+
noise: ref(value.noise, pointer(path, "noise"), "noise"),
|
|
169
|
+
scale: opt(value.scale, 1, pointer(path, "scale"), "scale"),
|
|
170
|
+
offset: opt(value.offset, 0, pointer(path, "offset"), "offset"),
|
|
171
|
+
blend: blendOf(value.blend, "add", pointer(path, "blend")),
|
|
172
|
+
falloff,
|
|
173
|
+
region
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (value.image !== void 0) {
|
|
177
|
+
known(value, ["image", "at", "size", "rotation", "range", "blend", "falloff"], path);
|
|
178
|
+
return {
|
|
179
|
+
kind: "image",
|
|
180
|
+
image: ref(value.image, pointer(path, "image"), "image"),
|
|
181
|
+
range: pair(value.range, pointer(path, "range"), "range"),
|
|
182
|
+
blend: blendOf(value.blend, "replace", pointer(path, "blend")),
|
|
183
|
+
falloff,
|
|
184
|
+
...rectOf(value, path)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
known(value, ["spline", "mode", "offset", "falloff"], path);
|
|
188
|
+
const name = splineName(value.spline, pointer(path, "spline"));
|
|
189
|
+
const mode = value.mode ?? "flatten";
|
|
190
|
+
if (mode !== "flatten" && mode !== "raise" && mode !== "carve")
|
|
191
|
+
throw shape(pointer(path, "mode"), 'mode must be "flatten", "raise" or "carve"');
|
|
192
|
+
return {
|
|
193
|
+
kind: "spline",
|
|
194
|
+
spline: name,
|
|
195
|
+
mode,
|
|
196
|
+
offset: opt(value.offset, 0, pointer(path, "offset"), "offset"),
|
|
197
|
+
falloff: value.falloff === void 0 ? splines[name].falloff : falloff
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
const layerList = json.layers ?? [];
|
|
201
|
+
if (!Array.isArray(layerList)) throw shape("/layers", "layers is a list of material layers");
|
|
202
|
+
if (layerList.length > MAX_MATERIAL_LAYERS) {
|
|
203
|
+
throw new ShardError(
|
|
204
|
+
"terrain/too-many-layers",
|
|
205
|
+
`${layerList.length} material layers; a terrain has at most ${MAX_MATERIAL_LAYERS}`,
|
|
206
|
+
{
|
|
207
|
+
path: "/layers",
|
|
208
|
+
hint: "Merge layers that look alike, or share texture array layers between them."
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const layers = layerList.map((value, i) => {
|
|
213
|
+
const path = pointer("/layers", i);
|
|
214
|
+
if (!isPlainObject(value)) throw shape(path, "A material layer is an object");
|
|
215
|
+
known(value, ["name", "albedo", "normal", "orm", "scale", "triplanar", "tint"], path);
|
|
216
|
+
if (typeof value.name !== "string" || value.name === "")
|
|
217
|
+
throw shape(pointer(path, "name"), "A material layer needs a name");
|
|
218
|
+
const albedo = opt(value.albedo, i, pointer(path, "albedo"), "albedo");
|
|
219
|
+
const scale = opt(value.scale, 4, pointer(path, "scale"), "scale");
|
|
220
|
+
if (!(scale > 0)) throw shape(pointer(path, "scale"), "scale must be positive");
|
|
221
|
+
let tint = LAYER_TINTS[i % LAYER_TINTS.length];
|
|
222
|
+
if (value.tint !== void 0) {
|
|
223
|
+
const tp = pointer(path, "tint");
|
|
224
|
+
if (!Array.isArray(value.tint) || value.tint.length !== 3)
|
|
225
|
+
throw shape(tp, "tint is [r, g, b], linear, 0\u20131");
|
|
226
|
+
tint = [
|
|
227
|
+
num(value.tint[0], tp, "tint"),
|
|
228
|
+
num(value.tint[1], tp, "tint"),
|
|
229
|
+
num(value.tint[2], tp, "tint")
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
name: value.name,
|
|
234
|
+
albedo,
|
|
235
|
+
normal: opt(value.normal, albedo, pointer(path, "normal"), "normal"),
|
|
236
|
+
orm: opt(value.orm, albedo, pointer(path, "orm"), "orm"),
|
|
237
|
+
scale,
|
|
238
|
+
triplanar: value.triplanar === true,
|
|
239
|
+
tint
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
const names = new Map(layers.map((l, i) => [l.name, i]));
|
|
243
|
+
if (names.size !== layers.length) throw shape("/layers", "Material layer names must be unique");
|
|
244
|
+
const textures = { albedo: null, normal: null, orm: null };
|
|
245
|
+
if (json.textures !== void 0) {
|
|
246
|
+
if (!isPlainObject(json.textures)) throw shape("/textures", "textures is an object");
|
|
247
|
+
known(json.textures, ["albedo", "normal", "orm"], "/textures");
|
|
248
|
+
for (const k of ["albedo", "normal", "orm"]) {
|
|
249
|
+
const v = json.textures[k];
|
|
250
|
+
if (v !== void 0) textures[k] = ref(v, pointer("/textures", k), k);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const paintList = json.paint ?? [];
|
|
254
|
+
if (!Array.isArray(paintList)) throw shape("/paint", "paint is a list of paint layers");
|
|
255
|
+
const paint = paintList.map((value, i) => {
|
|
256
|
+
const path = pointer("/paint", i);
|
|
257
|
+
if (!isPlainObject(value)) throw shape(path, "A paint layer is an object");
|
|
258
|
+
known(value, ["layer", "height", "slope", "noise", "mask", "spline", "blend"], path);
|
|
259
|
+
if (typeof value.layer !== "string" || !names.has(value.layer)) {
|
|
260
|
+
throw new ShardError(
|
|
261
|
+
"terrain/unknown-layer",
|
|
262
|
+
`No material layer named ${JSON.stringify(value.layer)}`,
|
|
263
|
+
{
|
|
264
|
+
path: pointer(path, "layer"),
|
|
265
|
+
hint: `Paint names one of "layers": ${[...names.keys()].join(", ") || "(none defined)"}.`
|
|
266
|
+
}
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
let noise = null;
|
|
270
|
+
if (value.noise !== void 0) {
|
|
271
|
+
const np = pointer(path, "noise");
|
|
272
|
+
if (!isPlainObject(value.noise)) throw shape(np, 'noise is { "path": \u2026, "above": 0.2 }');
|
|
273
|
+
noise = {
|
|
274
|
+
ref: ref(value.noise, np, "noise"),
|
|
275
|
+
above: opt(value.noise.above, 0, pointer(np, "above"), "above")
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
let mask = null;
|
|
279
|
+
if (value.mask !== void 0) {
|
|
280
|
+
const mp = pointer(path, "mask");
|
|
281
|
+
if (!isPlainObject(value.mask))
|
|
282
|
+
throw shape(mp, 'mask is { "path": \u2026, "at": [x, z], "size": [w, d] }');
|
|
283
|
+
mask = { ref: ref(value.mask, mp, "mask"), ...rectOf(value.mask, mp) };
|
|
284
|
+
}
|
|
285
|
+
const blend = opt(value.blend, 0, pointer(path, "blend"), "blend");
|
|
286
|
+
if (blend < 0) throw shape(pointer(path, "blend"), "blend must be \u2265 0");
|
|
287
|
+
return {
|
|
288
|
+
layer: value.layer,
|
|
289
|
+
height: value.height === void 0 ? null : pair(value.height, pointer(path, "height"), "height"),
|
|
290
|
+
slope: value.slope === void 0 ? null : pair(value.slope, pointer(path, "slope"), "slope"),
|
|
291
|
+
noise,
|
|
292
|
+
mask,
|
|
293
|
+
spline: value.spline === void 0 ? null : splineName(value.spline, pointer(path, "spline")),
|
|
294
|
+
blend
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
const source = {
|
|
298
|
+
size,
|
|
299
|
+
spacing,
|
|
300
|
+
paintSpacing,
|
|
301
|
+
heightRange,
|
|
302
|
+
seed,
|
|
303
|
+
splines,
|
|
304
|
+
height,
|
|
305
|
+
layers,
|
|
306
|
+
textures,
|
|
307
|
+
paint
|
|
308
|
+
};
|
|
309
|
+
terrainLayout(source);
|
|
310
|
+
return source;
|
|
311
|
+
}
|
|
312
|
+
function terrainLayout(source, block = BLOCK) {
|
|
313
|
+
const [sizeX, sizeZ] = source.size;
|
|
314
|
+
const spacing = source.spacing;
|
|
315
|
+
const leafSize = PAGE * spacing;
|
|
316
|
+
for (const [i, side] of [sizeX, sizeZ].entries()) {
|
|
317
|
+
if (!(side >= MIN_SIZE && side <= MAX_SIZE)) {
|
|
318
|
+
throw new ShardError(
|
|
319
|
+
"terrain/bad-size",
|
|
320
|
+
`A side of ${side} m is outside ${MIN_SIZE} m \u2013 ${MAX_SIZE / 1024} km`,
|
|
321
|
+
{
|
|
322
|
+
path: pointer("/size", i),
|
|
323
|
+
hint: "Terrains run from 256 m to 64 km a side. Bigger worlds are planets (0043)."
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const leavesX = sizeX / leafSize;
|
|
329
|
+
const leavesZ = sizeZ / leafSize;
|
|
330
|
+
const hint = `Sides are whole numbers of roots: ${leafSize} m \xD7 2^k (each leaf is ${PAGE} samples of ${spacing} m), at most ${MAX_ROOTS} roots, e.g. ${leafSize * 32} m.`;
|
|
331
|
+
if (!Number.isInteger(leavesX) || !Number.isInteger(leavesZ)) {
|
|
332
|
+
throw new ShardError(
|
|
333
|
+
"terrain/bad-size",
|
|
334
|
+
`size ${sizeX} \xD7 ${sizeZ} m isn't a whole number of ${leafSize} m leaves`,
|
|
335
|
+
{ path: "/size", hint }
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
let depth = 0;
|
|
339
|
+
while (depth <= MAX_DEPTH) {
|
|
340
|
+
const n = 2 ** depth;
|
|
341
|
+
if (leavesX % n !== 0 || leavesZ % n !== 0) {
|
|
342
|
+
depth = MAX_DEPTH + 1;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
if (leavesX / n * (leavesZ / n) <= MAX_ROOTS) break;
|
|
346
|
+
depth++;
|
|
347
|
+
}
|
|
348
|
+
if (depth > MAX_DEPTH) {
|
|
349
|
+
throw new ShardError(
|
|
350
|
+
"terrain/bad-size",
|
|
351
|
+
`size ${sizeX} \xD7 ${sizeZ} m isn't a whole number of roots (at most ${MAX_ROOTS} of them)`,
|
|
352
|
+
{ path: "/size", hint }
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const paintStep = source.paintSpacing / spacing;
|
|
356
|
+
return {
|
|
357
|
+
sizeX,
|
|
358
|
+
sizeZ,
|
|
359
|
+
spacing,
|
|
360
|
+
paintSpacing: source.paintSpacing,
|
|
361
|
+
paintStep,
|
|
362
|
+
cells: PAGE / paintStep,
|
|
363
|
+
depth,
|
|
364
|
+
rootsX: leavesX / 2 ** depth,
|
|
365
|
+
rootsZ: leavesZ / 2 ** depth,
|
|
366
|
+
rootSize: leafSize * 2 ** depth,
|
|
367
|
+
leafSize,
|
|
368
|
+
leavesX,
|
|
369
|
+
leavesZ,
|
|
370
|
+
block,
|
|
371
|
+
blocksX: Math.ceil(leavesX / block),
|
|
372
|
+
blocksZ: Math.ceil(leavesZ / block),
|
|
373
|
+
blockLevels: Math.log2(block)
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function sourceDependencies(source) {
|
|
377
|
+
const noise = /* @__PURE__ */ new Set();
|
|
378
|
+
const heightmaps = /* @__PURE__ */ new Set();
|
|
379
|
+
for (const l of source.height) {
|
|
380
|
+
if (l.kind === "noise") noise.add(l.noise.path);
|
|
381
|
+
if (l.kind === "image") heightmaps.add(l.image.path);
|
|
382
|
+
}
|
|
383
|
+
for (const p of source.paint) {
|
|
384
|
+
if (p.noise) noise.add(p.noise.ref.path);
|
|
385
|
+
if (p.mask) heightmaps.add(p.mask.ref.path);
|
|
386
|
+
}
|
|
387
|
+
const textures = [source.textures.albedo, source.textures.normal, source.textures.orm].filter((t) => t !== null).map((t) => t.path);
|
|
388
|
+
return { noise: [...noise], heightmaps: [...heightmaps], textures };
|
|
389
|
+
}
|
|
390
|
+
export {
|
|
391
|
+
LAYER_TINTS,
|
|
392
|
+
MAX_DEPTH,
|
|
393
|
+
MAX_MATERIAL_LAYERS,
|
|
394
|
+
MAX_ROOTS,
|
|
395
|
+
MAX_SIZE,
|
|
396
|
+
MAX_SPACING,
|
|
397
|
+
MIN_SIZE,
|
|
398
|
+
MIN_SPACING,
|
|
399
|
+
parseTerrainSource,
|
|
400
|
+
sourceDependencies,
|
|
401
|
+
terrainLayout
|
|
402
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type NoiseGraph } from '@aethervtt/shard-noise';
|
|
2
|
+
import { type NoiseAccess, type Stack, type StackMap, type StackRect } from './kernel.js';
|
|
3
|
+
import type { SourceRect, SourceSpline, TerrainSource } from './source.js';
|
|
4
|
+
/** What compiling a source needs: its assets, loaded. */
|
|
5
|
+
export interface StackAssets {
|
|
6
|
+
noise(path: string): NoiseGraph;
|
|
7
|
+
heightmap(path: string): StackMap;
|
|
8
|
+
}
|
|
9
|
+
/** This thread's noise kernel, as the bake kernel reads it (it must be loaded). */
|
|
10
|
+
export declare function mainNoise(): NoiseAccess;
|
|
11
|
+
export declare function rectOf(r: SourceRect): StackRect;
|
|
12
|
+
/**
|
|
13
|
+
* A spline's centerline: uniform Catmull-Rom through its points (ends repeated), in pieces of at
|
|
14
|
+
* most `step` metres, heights interpolated the same way. `heights` holds each point's y (its
|
|
15
|
+
* "ground" resolved).
|
|
16
|
+
*/
|
|
17
|
+
export declare function tessellate(spline: SourceSpline, heights: ArrayLike<number>, step: number): number[];
|
|
18
|
+
/**
|
|
19
|
+
* Metres between a spline's tessellated points: a metre (or the spacing, if coarser). A road bend
|
|
20
|
+
* of 100 m radius then strays from its chords by about a millimetre.
|
|
21
|
+
*/
|
|
22
|
+
export declare function splineStep(spacing: number): number;
|
|
23
|
+
/**
|
|
24
|
+
* Compiles a validated source and its loaded assets into what the bake kernel reads (spec 0071):
|
|
25
|
+
* noise programs, heightmaps, rectangles, and splines tessellated with their "ground" points
|
|
26
|
+
* resolved against the layers below the layer using them. Structured-cloneable, so it goes to pool
|
|
27
|
+
* workers as is. Needs the noise kernel loaded (splines on "ground" sample the stack).
|
|
28
|
+
*/
|
|
29
|
+
export declare function compileStack(source: TerrainSource, assets: StackAssets, noise?: NoiseAccess): Stack;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { computeOrigins, evalProgram, noiseKernel } from "@aethervtt/shard-noise";
|
|
3
|
+
import {
|
|
4
|
+
BLEND_ADD,
|
|
5
|
+
BLEND_MAX,
|
|
6
|
+
BLEND_MIN,
|
|
7
|
+
BLEND_REPLACE,
|
|
8
|
+
buildSpline,
|
|
9
|
+
evalHeightPoints,
|
|
10
|
+
LAYER_IMAGE,
|
|
11
|
+
LAYER_NOISE,
|
|
12
|
+
LAYER_SPLINE,
|
|
13
|
+
MODE_CARVE,
|
|
14
|
+
MODE_FLATTEN,
|
|
15
|
+
MODE_RAISE
|
|
16
|
+
} from "./kernel.js";
|
|
17
|
+
function mainNoise() {
|
|
18
|
+
return {
|
|
19
|
+
state: noiseKernel().state,
|
|
20
|
+
computeOrigins,
|
|
21
|
+
evalProgram
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const BLENDS = { add: BLEND_ADD, max: BLEND_MAX, min: BLEND_MIN, replace: BLEND_REPLACE };
|
|
25
|
+
const MODES = { flatten: MODE_FLATTEN, raise: MODE_RAISE, carve: MODE_CARVE };
|
|
26
|
+
function rectOf(r) {
|
|
27
|
+
const a = r.rotation * Math.PI / 180;
|
|
28
|
+
return {
|
|
29
|
+
cx: r.at[0],
|
|
30
|
+
cz: r.at[1],
|
|
31
|
+
hw: r.size[0] / 2,
|
|
32
|
+
hd: r.size[1] / 2,
|
|
33
|
+
cos: Math.cos(a),
|
|
34
|
+
sin: Math.sin(a)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function tessellate(spline, heights, step) {
|
|
38
|
+
const p = spline.points;
|
|
39
|
+
const n = p.length;
|
|
40
|
+
const out = [];
|
|
41
|
+
const at = (i) => {
|
|
42
|
+
const k = Math.min(n - 1, Math.max(0, i));
|
|
43
|
+
return [p[k][0], heights[k], p[k][2]];
|
|
44
|
+
};
|
|
45
|
+
for (let s = 0; s + 1 < n; s++) {
|
|
46
|
+
const p0 = at(s - 1);
|
|
47
|
+
const p1 = at(s);
|
|
48
|
+
const p2 = at(s + 1);
|
|
49
|
+
const p3 = at(s + 2);
|
|
50
|
+
const len = Math.hypot(p2[0] - p1[0], p2[2] - p1[2]);
|
|
51
|
+
const pieces = Math.max(1, Math.ceil(len / step));
|
|
52
|
+
for (let k = s === 0 ? 0 : 1; k <= pieces; k++) {
|
|
53
|
+
const t = k / pieces;
|
|
54
|
+
const t2 = t * t;
|
|
55
|
+
const t3 = t2 * t;
|
|
56
|
+
for (let c = 0; c < 3; c++) {
|
|
57
|
+
out.push(
|
|
58
|
+
0.5 * (2 * p1[c] + (-p0[c] + p2[c]) * t + (2 * p0[c] - 5 * p1[c] + 4 * p2[c] - p3[c]) * t2 + (-p0[c] + 3 * p1[c] - 3 * p2[c] + p3[c]) * t3)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function splineStep(spacing) {
|
|
66
|
+
return Math.max(1, spacing);
|
|
67
|
+
}
|
|
68
|
+
function compileStack(source, assets, noise = mainNoise()) {
|
|
69
|
+
const height = [];
|
|
70
|
+
const stack = {
|
|
71
|
+
spacing: source.spacing,
|
|
72
|
+
seed: source.seed,
|
|
73
|
+
lo: source.heightRange[0],
|
|
74
|
+
hi: source.heightRange[1],
|
|
75
|
+
layerCount: Math.max(1, source.layers.length),
|
|
76
|
+
height,
|
|
77
|
+
paint: []
|
|
78
|
+
};
|
|
79
|
+
const step = splineStep(source.spacing);
|
|
80
|
+
const splineFor = (name, reach, ground) => {
|
|
81
|
+
const s = source.splines[name];
|
|
82
|
+
if (!s) {
|
|
83
|
+
throw new ShardError("terrain/unknown-spline", `No spline named "${name}"`, {
|
|
84
|
+
hint: 'Define it under "splines".'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const ys = new Float64Array(s.points.length);
|
|
88
|
+
const xz = [];
|
|
89
|
+
const groundAt = [];
|
|
90
|
+
s.points.forEach((p, i) => {
|
|
91
|
+
if (p[1] === "ground") {
|
|
92
|
+
xz.push(p[0], p[2]);
|
|
93
|
+
groundAt.push(i);
|
|
94
|
+
} else ys[i] = p[1];
|
|
95
|
+
});
|
|
96
|
+
if (groundAt.length > 0 && ground >= 0) {
|
|
97
|
+
const out = new Float64Array(groundAt.length);
|
|
98
|
+
evalHeightPoints(noise, stack, xz, groundAt.length, ground, out);
|
|
99
|
+
groundAt.forEach((i, k) => {
|
|
100
|
+
ys[i] = out[k];
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return buildSpline(tessellate(s, ys, step), s.width, s.falloff, reach(s));
|
|
104
|
+
};
|
|
105
|
+
source.height.forEach((l, index) => {
|
|
106
|
+
if (l.kind === "noise") {
|
|
107
|
+
height.push({
|
|
108
|
+
kind: LAYER_NOISE,
|
|
109
|
+
program: assets.noise(l.noise.path).program,
|
|
110
|
+
scale: l.scale,
|
|
111
|
+
offset: l.offset,
|
|
112
|
+
blend: BLENDS[l.blend],
|
|
113
|
+
rect: l.region ? rectOf(l.region) : null,
|
|
114
|
+
falloff: l.falloff
|
|
115
|
+
});
|
|
116
|
+
} else if (l.kind === "image") {
|
|
117
|
+
height.push({
|
|
118
|
+
kind: LAYER_IMAGE,
|
|
119
|
+
map: assets.heightmap(l.image.path),
|
|
120
|
+
rect: rectOf(l),
|
|
121
|
+
lo: l.range[0],
|
|
122
|
+
hi: l.range[1],
|
|
123
|
+
blend: BLENDS[l.blend],
|
|
124
|
+
falloff: l.falloff
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
const spline = splineFor(l.spline, (s) => s.width / 2 + l.falloff, index);
|
|
128
|
+
height.push({
|
|
129
|
+
kind: LAYER_SPLINE,
|
|
130
|
+
spline,
|
|
131
|
+
mode: MODES[l.mode],
|
|
132
|
+
offset: l.offset,
|
|
133
|
+
falloff: l.falloff
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const names = new Map(source.layers.map((l, i) => [l.name, i]));
|
|
138
|
+
for (const p of source.paint) {
|
|
139
|
+
stack.paint.push({
|
|
140
|
+
layer: names.get(p.layer) ?? 0,
|
|
141
|
+
height: p.height,
|
|
142
|
+
slope: p.slope,
|
|
143
|
+
noise: p.noise ? { program: assets.noise(p.noise.ref.path).program, above: p.noise.above } : null,
|
|
144
|
+
mask: p.mask ? { map: assets.heightmap(p.mask.ref.path), rect: rectOf(p.mask) } : null,
|
|
145
|
+
// Paint reads where the spline is, not its heights.
|
|
146
|
+
spline: p.spline ? splineFor(p.spline, (s) => s.width / 2 + p.blend, -1) : null,
|
|
147
|
+
blend: p.blend
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return stack;
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
compileStack,
|
|
154
|
+
mainNoise,
|
|
155
|
+
rectOf,
|
|
156
|
+
splineStep,
|
|
157
|
+
tessellate
|
|
158
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { App } from '@aethervtt/shard-runtime';
|
|
3
|
+
import { type AnchorQueries } from '../colliders.js';
|
|
4
|
+
import { HeightfieldRuntime } from './runtime.js';
|
|
5
|
+
/**
|
|
6
|
+
* Anchors on a heightfield this frame, in its frame: every TerrainAnchor (unless disabled),
|
|
7
|
+
* character, awake dynamic body and NavAgent within reach of the terrain's ground.
|
|
8
|
+
*/
|
|
9
|
+
export declare function gatherHeightfieldAnchors(_world: World, rt: HeightfieldRuntime, q: AnchorQueries): void;
|
|
10
|
+
/** Per-frame hooks other modules add (colliders, navigation, scatter). */
|
|
11
|
+
export declare const heightfieldUpdates: ((world: World, rt: HeightfieldRuntime, frame: number) => void)[];
|
|
12
|
+
/**
|
|
13
|
+
* Keeps a runtime per Terrain (spec 0071): reads its settings, resolves and compiles its source,
|
|
14
|
+
* checks and bakes its packs, tracks its frame against the floating origin, and gathers anchors.
|
|
15
|
+
* Runs with or without a GPU; colliders and the render side build on it.
|
|
16
|
+
*/
|
|
17
|
+
export declare const updateHeightfields: import("@aethervtt/shard-core").SystemDef<{
|
|
18
|
+
terrains: import("@aethervtt/shard-core").Query;
|
|
19
|
+
anchors: AnchorQueries;
|
|
20
|
+
}>;
|
|
21
|
+
/** Registers the heightfield shaders, its render feature, and its uniform node (GPU up). */
|
|
22
|
+
export declare function registerHeightfield(app: App): void;
|