@threenative/terrain 0.0.0-stage → 0.1.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/AGENT_GUIDE.md +194 -0
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/dist/editor/index.d.ts +231 -0
- package/dist/editor/index.js +5624 -0
- package/dist/editor/server.d.ts +3 -0
- package/dist/editor/server.js +3688 -0
- package/dist/editor/worker.d.ts +2 -0
- package/dist/editor/worker.js +2212 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.js +297 -0
- package/dist/index.d.ts +335 -0
- package/dist/index.js +2927 -0
- package/dist/server-ByM2hmYJ.d.ts +630 -0
- package/dist/terrain-gPnkCU6S.d.ts +141 -0
- package/dist/three.d.ts +14 -0
- package/dist/three.js +40 -0
- package/dist/types-Ant8-Axc.d.ts +486 -0
- package/package.json +86 -3
- package/starter/alpine/README.md +29 -0
- package/starter/alpine/assets.json +12 -0
- package/starter/alpine/bake.mjs +118 -0
- package/starter/alpine/recipe.json +66167 -0
- package/starter/alpine/sky.ts +23 -0
- package/starter/alpine/surface.json +53 -0
- package/starter/alpine/world.ts +335 -0
- package/starter/coastal/README.md +37 -0
- package/starter/coastal/assets.json +10 -0
- package/starter/coastal/bake.mjs +118 -0
- package/starter/coastal/recipe.json +66161 -0
- package/starter/coastal/sea.ts +327 -0
- package/starter/coastal/sky.ts +24 -0
- package/starter/coastal/surface.json +41 -0
- package/starter/coastal/world.ts +386 -0
- package/starter/desert/README.md +35 -0
- package/starter/desert/assets.json +12 -0
- package/starter/desert/bake.mjs +118 -0
- package/starter/desert/recipe.json +66156 -0
- package/starter/desert/sky.ts +23 -0
- package/starter/desert/surface.json +54 -0
- package/starter/desert/world.ts +335 -0
- package/starter/forest/README.md +46 -0
- package/starter/forest/assets.json +32 -0
- package/starter/forest/bake.mjs +118 -0
- package/starter/forest/recipe.json +4586 -0
- package/starter/forest/sky.ts +27 -0
- package/starter/forest/surface.json +79 -0
- package/starter/forest/water.ts +266 -0
- package/starter/forest/world.ts +413 -0
- package/starter/tundra/README.md +29 -0
- package/starter/tundra/assets.json +12 -0
- package/starter/tundra/bake.mjs +118 -0
- package/starter/tundra/recipe.json +66325 -0
- package/starter/tundra/sky.ts +27 -0
- package/starter/tundra/surface.json +54 -0
- package/starter/tundra/water.ts +313 -0
- package/starter/tundra/world.ts +366 -0
- package/starter-assets/bark_brown_02/bark_brown_02_diff_1k.jpg +0 -0
- package/starter-assets/bark_brown_02/bark_brown_02_nor_gl_1k.jpg +0 -0
- package/starter-assets/bark_brown_02/bark_brown_02_rough_1k.jpg +0 -0
- package/starter-assets/boulder_01/boulder-mid.glb +0 -0
- package/starter-assets/boulder_01/boulder-near.glb +0 -0
- package/starter-assets/cliff_side/cliff_side_ao_1k.jpg +0 -0
- package/starter-assets/cliff_side/cliff_side_diff_1k.jpg +0 -0
- package/starter-assets/cliff_side/cliff_side_nor_gl_1k.jpg +0 -0
- package/starter-assets/cliff_side/cliff_side_rough_1k.jpg +0 -0
- package/starter-assets/credits.json +1022 -0
- package/starter-assets/fern_02/fern_02_alpha_512.png +0 -0
- package/starter-assets/fern_02/fern_02_diff_512.jpg +0 -0
- package/starter-assets/fern_02/fern_02_nor_gl_512.jpg +0 -0
- package/starter-assets/fir_tree_01/fir-b-lod1.glb +0 -0
- package/starter-assets/fir_tree_01/fir-b-lod2.glb +0 -0
- package/starter-assets/fir_tree_01/fir-c-lod1.glb +0 -0
- package/starter-assets/fir_tree_01/fir-c-lod2.glb +0 -0
- package/starter-assets/fir_tree_01/fir_tree_01_twig_arm_1k.jpg +0 -0
- package/starter-assets/fir_tree_01/fir_tree_01_twig_diff_1k.jpg +0 -0
- package/starter-assets/fir_tree_01/fir_tree_01_twig_nor_gl_1k.jpg +0 -0
- package/starter-assets/forest_ground_04/forest_ground_04_ao_1k.jpg +0 -0
- package/starter-assets/forest_ground_04/forest_ground_04_diff_1k.jpg +0 -0
- package/starter-assets/forest_ground_04/forest_ground_04_nor_gl_1k.jpg +0 -0
- package/starter-assets/forest_ground_04/forest_ground_04_rough_1k.jpg +0 -0
- package/starter-assets/forest_leaves_02/forest_leaves_02_arm_512.jpg +0 -0
- package/starter-assets/forest_leaves_02/forest_leaves_02_diff_512.jpg +0 -0
- package/starter-assets/forest_leaves_02/forest_leaves_02_nor_gl_512.jpg +0 -0
- package/starter-assets/grass_medium_02/grass_medium_02_alpha_512.png +0 -0
- package/starter-assets/grass_medium_02/grass_medium_02_diff_512.jpg +0 -0
- package/starter-assets/grass_medium_02/grass_medium_02_nor_gl_512.jpg +0 -0
- package/starter-assets/hdri/kloofendal_48d_partly_cloudy_1k.hdr +0 -0
- package/starter-assets/leafy_grass/leafy_grass_ao_1k.jpg +0 -0
- package/starter-assets/leafy_grass/leafy_grass_diff_1k.jpg +0 -0
- package/starter-assets/leafy_grass/leafy_grass_nor_gl_1k.jpg +0 -0
- package/starter-assets/leafy_grass/leafy_grass_rough_1k.jpg +0 -0
- package/starter-assets/lichen_rock/lichen_rock_arm_512.jpg +0 -0
- package/starter-assets/lichen_rock/lichen_rock_diff_512.jpg +0 -0
- package/starter-assets/lichen_rock/lichen_rock_nor_gl_512.jpg +0 -0
- package/starter-assets/mossy_rock/mossy_rock_ao_1k.jpg +0 -0
- package/starter-assets/mossy_rock/mossy_rock_diff_1k.jpg +0 -0
- package/starter-assets/mossy_rock/mossy_rock_nor_gl_1k.jpg +0 -0
- package/starter-assets/mossy_rock/mossy_rock_rough_1k.jpg +0 -0
- package/starter-assets/needle-atlas.png +0 -0
- package/starter-assets/needle-surface.png +0 -0
- package/starter-assets/river_small_rocks/river_small_rocks_arm_512.jpg +0 -0
- package/starter-assets/river_small_rocks/river_small_rocks_diff_512.jpg +0 -0
- package/starter-assets/river_small_rocks/river_small_rocks_nor_gl_512.jpg +0 -0
- package/starter-assets/rock_boulder_dry/rock_boulder_dry_arm_512.jpg +0 -0
- package/starter-assets/rock_boulder_dry/rock_boulder_dry_diff_512.jpg +0 -0
- package/starter-assets/rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg +0 -0
- package/starter-assets/rocks/boulder-mid.glb +0 -0
- package/starter-assets/rocks/boulder-near.glb +0 -0
- package/starter-assets/rocks/boulder-prep.json +37 -0
- package/starter-assets/rocks/rock01-mid.glb +0 -0
- package/starter-assets/rocks/rock01-near.glb +0 -0
- package/starter-assets/rocks/rock01-prep.json +37 -0
- package/starter-assets/rocks/rock04-mid.glb +0 -0
- package/starter-assets/rocks/rock04-near.glb +0 -0
- package/starter-assets/rocks/rock04-prep.json +37 -0
- package/starter-assets/sand_01/sand_01_ao_1k.jpg +0 -0
- package/starter-assets/sand_01/sand_01_diff_1k.jpg +0 -0
- package/starter-assets/sand_01/sand_01_nor_gl_1k.jpg +0 -0
- package/starter-assets/sand_01/sand_01_rough_1k.jpg +0 -0
- package/starter-assets/snow_02/snow_02_ao_1k.jpg +0 -0
- package/starter-assets/snow_02/snow_02_diff_1k.jpg +0 -0
- package/starter-assets/snow_02/snow_02_nor_gl_1k.jpg +0 -0
- package/starter-assets/snow_02/snow_02_rough_1k.jpg +0 -0
|
@@ -0,0 +1,2212 @@
|
|
|
1
|
+
// src/core/math.ts
|
|
2
|
+
var clamp = (x, a = 0, b = 1) => Math.max(a, Math.min(b, x));
|
|
3
|
+
var lerp = (a, b, t) => a + (b - a) * t;
|
|
4
|
+
var smoothstep = (a, b, x) => {
|
|
5
|
+
if (a === b) return x >= b ? 1 : 0;
|
|
6
|
+
const t = clamp((x - a) / (b - a));
|
|
7
|
+
return t * t * (3 - 2 * t);
|
|
8
|
+
};
|
|
9
|
+
var hashString = (text) => {
|
|
10
|
+
let h = 2166136261;
|
|
11
|
+
for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
|
|
12
|
+
return h >>> 0;
|
|
13
|
+
};
|
|
14
|
+
function random(seed) {
|
|
15
|
+
let a = seed >>> 0;
|
|
16
|
+
return () => {
|
|
17
|
+
a |= 0;
|
|
18
|
+
a = a + 1831565813 | 0;
|
|
19
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
20
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
21
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
var hash2 = (x, z, seed) => {
|
|
25
|
+
let n = Math.imul(x | 0, 374761393) ^ Math.imul(z | 0, 668265263) ^ seed;
|
|
26
|
+
n = Math.imul(n ^ n >>> 13, 1274126177);
|
|
27
|
+
return (n ^ n >>> 16) >>> 0;
|
|
28
|
+
};
|
|
29
|
+
var diagonalGradient = 0.7071;
|
|
30
|
+
var grad = (h, x, z) => {
|
|
31
|
+
switch (h & 7) {
|
|
32
|
+
case 0:
|
|
33
|
+
return x;
|
|
34
|
+
case 1:
|
|
35
|
+
return -x;
|
|
36
|
+
case 2:
|
|
37
|
+
return z;
|
|
38
|
+
case 3:
|
|
39
|
+
return -z;
|
|
40
|
+
case 4:
|
|
41
|
+
return (x + z) * diagonalGradient;
|
|
42
|
+
case 5:
|
|
43
|
+
return (x - z) * diagonalGradient;
|
|
44
|
+
case 6:
|
|
45
|
+
return (-x + z) * diagonalGradient;
|
|
46
|
+
default:
|
|
47
|
+
return (-x - z) * diagonalGradient;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function noise2(x, z, seed = 1) {
|
|
51
|
+
const ix = Math.floor(x);
|
|
52
|
+
const iz = Math.floor(z);
|
|
53
|
+
const fx = x - ix;
|
|
54
|
+
const fz = z - iz;
|
|
55
|
+
const u = fx * fx * fx * (fx * (fx * 6 - 15) + 10);
|
|
56
|
+
const v = fz * fz * fz * (fz * (fz * 6 - 15) + 10);
|
|
57
|
+
return lerp(
|
|
58
|
+
lerp(grad(hash2(ix, iz, seed), fx, fz), grad(hash2(ix + 1, iz, seed), fx - 1, fz), u),
|
|
59
|
+
lerp(
|
|
60
|
+
grad(hash2(ix, iz + 1, seed), fx, fz - 1),
|
|
61
|
+
grad(hash2(ix + 1, iz + 1, seed), fx - 1, fz - 1),
|
|
62
|
+
u
|
|
63
|
+
),
|
|
64
|
+
v
|
|
65
|
+
) * 1.65;
|
|
66
|
+
}
|
|
67
|
+
function fbm(inputX, inputZ, { seed = 1, octaves = 5, persistence = 0.5, lacunarity = 2, mode = "fbm" } = {}) {
|
|
68
|
+
let x = inputX;
|
|
69
|
+
let z = inputZ;
|
|
70
|
+
let value = 0;
|
|
71
|
+
let amplitude = 1;
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (let i = 0; i < octaves; i += 1) {
|
|
74
|
+
let n = noise2(x + 13.17, z - 8.71, seed + i * 71);
|
|
75
|
+
if (mode === "ridged") n = (1 - Math.abs(n)) ** 2 * 2 - 1;
|
|
76
|
+
if (mode === "billow") n = Math.abs(n) * 2 - 1;
|
|
77
|
+
value += n * amplitude;
|
|
78
|
+
total += amplitude;
|
|
79
|
+
amplitude *= persistence;
|
|
80
|
+
x *= lacunarity;
|
|
81
|
+
z *= lacunarity;
|
|
82
|
+
}
|
|
83
|
+
return total ? value / total : 0;
|
|
84
|
+
}
|
|
85
|
+
var world = (grid, x) => x / (grid.resolution - 1) * grid.size - grid.size / 2;
|
|
86
|
+
function sampleGrid(a, width, height, u, v) {
|
|
87
|
+
const gx = clamp(u, 0, 1) * (width - 1);
|
|
88
|
+
const gz = clamp(v, 0, 1) * (height - 1);
|
|
89
|
+
const x = Math.min(width - 2, Math.floor(gx));
|
|
90
|
+
const z = Math.min(height - 2, Math.floor(gz));
|
|
91
|
+
const tx = gx - x;
|
|
92
|
+
const tz = gz - z;
|
|
93
|
+
return lerp(
|
|
94
|
+
lerp(a[z * width + x], a[z * width + x + 1], tx),
|
|
95
|
+
lerp(a[(z + 1) * width + x], a[(z + 1) * width + x + 1], tx),
|
|
96
|
+
tz
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
var sampleHeight = (grid, x, z) => sampleGrid(
|
|
100
|
+
grid.height,
|
|
101
|
+
grid.resolution,
|
|
102
|
+
grid.resolution,
|
|
103
|
+
x / grid.size + 0.5,
|
|
104
|
+
z / grid.size + 0.5
|
|
105
|
+
);
|
|
106
|
+
function gradientAt(grid, x, z) {
|
|
107
|
+
const d = grid.size / (grid.resolution - 1);
|
|
108
|
+
return [
|
|
109
|
+
(sampleHeight(grid, x + d, z) - sampleHeight(grid, x - d, z)) / (Math.min(grid.size / 2, x + d) - Math.max(-grid.size / 2, x - d) || d),
|
|
110
|
+
(sampleHeight(grid, x, z + d) - sampleHeight(grid, x, z - d)) / (Math.min(grid.size / 2, z + d) - Math.max(-grid.size / 2, z - d) || d)
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
function slopeAtIndex(grid, i) {
|
|
114
|
+
const n = grid.resolution;
|
|
115
|
+
const x = i % n;
|
|
116
|
+
const z = Math.floor(i / n);
|
|
117
|
+
const dx = x > 0 && x < n - 1 ? 2 : 1;
|
|
118
|
+
const dz = z > 0 && z < n - 1 ? 2 : 1;
|
|
119
|
+
const cell = grid.size / (n - 1);
|
|
120
|
+
const gx = (grid.height[z * n + Math.min(x + 1, n - 1)] - grid.height[z * n + Math.max(x - 1, 0)]) / (dx * cell);
|
|
121
|
+
const gz = (grid.height[Math.min(z + 1, n - 1) * n + x] - grid.height[Math.max(z - 1, 0) * n + x]) / (dz * cell);
|
|
122
|
+
return Math.atan(Math.hypot(gx, gz)) * (180 / Math.PI);
|
|
123
|
+
}
|
|
124
|
+
function falloff(distance, radius, softness = 0.6) {
|
|
125
|
+
const d = distance / radius;
|
|
126
|
+
return d >= 1 ? 0 : 1 - smoothstep(1 - clamp(softness, 1e-5, 1), 1, d);
|
|
127
|
+
}
|
|
128
|
+
function splinePoints(points, step = 3, smooth = true) {
|
|
129
|
+
const out = [];
|
|
130
|
+
for (let k = 0; k < points.length - 1; k += 1) {
|
|
131
|
+
const a = points[Math.max(0, k - 1)];
|
|
132
|
+
const b = points[k];
|
|
133
|
+
const c = points[k + 1];
|
|
134
|
+
const d = points[Math.min(points.length - 1, k + 2)];
|
|
135
|
+
const count = Math.max(
|
|
136
|
+
1,
|
|
137
|
+
Math.min(512, Math.ceil(Math.hypot(c[0] - b[0], c[2] - b[2]) / step))
|
|
138
|
+
);
|
|
139
|
+
for (let j = 0; j < count; j += 1) {
|
|
140
|
+
const t = j / count;
|
|
141
|
+
const t2 = t * t;
|
|
142
|
+
const t3 = t2 * t;
|
|
143
|
+
const cat = (dim) => 0.5 * (2 * b[dim] + (-a[dim] + c[dim]) * t + (2 * a[dim] - 5 * b[dim] + 4 * c[dim] - d[dim]) * t2 + (-a[dim] + 3 * b[dim] - 3 * c[dim] + d[dim]) * t3);
|
|
144
|
+
out.push([
|
|
145
|
+
smooth ? cat(0) : lerp(b[0], c[0], t),
|
|
146
|
+
lerp(b[1], c[1], t),
|
|
147
|
+
smooth ? cat(2) : lerp(b[2], c[2], t)
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const last = points[points.length - 1];
|
|
152
|
+
out.push([last[0], last[1], last[2]]);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/core/erosion.ts
|
|
157
|
+
function thermal(height, n, size, { iterations = 20, talus = 32, rate = 0.22 } = {}, observations) {
|
|
158
|
+
const h = height.slice();
|
|
159
|
+
const delta = new Float64Array(h.length);
|
|
160
|
+
const limit = Math.tan(talus * Math.PI / 180) * (size / (n - 1));
|
|
161
|
+
for (let k = 0; k < iterations; k += 1) {
|
|
162
|
+
delta.fill(0);
|
|
163
|
+
for (let z = 0; z < n; z += 1) {
|
|
164
|
+
for (let x = 0; x < n; x += 1) {
|
|
165
|
+
const i = z * n + x;
|
|
166
|
+
let best = -1;
|
|
167
|
+
let diff = limit;
|
|
168
|
+
for (const j of [
|
|
169
|
+
x > 0 ? i - 1 : -1,
|
|
170
|
+
x < n - 1 ? i + 1 : -1,
|
|
171
|
+
z > 0 ? i - n : -1,
|
|
172
|
+
z < n - 1 ? i + n : -1
|
|
173
|
+
]) {
|
|
174
|
+
if (j >= 0 && h[i] - h[j] > diff) {
|
|
175
|
+
diff = h[i] - h[j];
|
|
176
|
+
best = j;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (best >= 0) {
|
|
180
|
+
const transfer = (diff - limit) * rate;
|
|
181
|
+
delta[i] = delta[i] - transfer;
|
|
182
|
+
delta[best] = delta[best] + transfer;
|
|
183
|
+
if (observations)
|
|
184
|
+
observations.talus[best] = observations.talus[best] + transfer;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (let i = 0; i < h.length; i += 1) h[i] = h[i] + delta[i];
|
|
189
|
+
}
|
|
190
|
+
return h;
|
|
191
|
+
}
|
|
192
|
+
var brushes = /* @__PURE__ */ new Map();
|
|
193
|
+
function brush(radius) {
|
|
194
|
+
let cells = brushes.get(radius);
|
|
195
|
+
if (!cells) {
|
|
196
|
+
cells = [];
|
|
197
|
+
let total = 0;
|
|
198
|
+
for (let dz = -radius; dz <= radius; dz += 1) {
|
|
199
|
+
for (let dx = -radius; dx <= radius; dx += 1) {
|
|
200
|
+
const d = Math.hypot(dx, dz);
|
|
201
|
+
if (d > radius) continue;
|
|
202
|
+
const weight = 1 - d / (radius + 1);
|
|
203
|
+
cells.push({ dx, dz, weight });
|
|
204
|
+
total += weight;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const c of cells) c.weight /= total;
|
|
208
|
+
brushes.set(radius, cells);
|
|
209
|
+
}
|
|
210
|
+
return cells;
|
|
211
|
+
}
|
|
212
|
+
function hydraulic(height, n, size, {
|
|
213
|
+
// Roughly one droplet per cell. Incision saturates near here: more droplets keep cutting the
|
|
214
|
+
// same drainage lines over, which deepens nothing and spends the bake time.
|
|
215
|
+
droplets = n * n,
|
|
216
|
+
// A droplet has to be able to cross the world, so its step budget scales with the grid: a
|
|
217
|
+
// fixed 40 barely leaves a 257 world while a 65 world would walk off it in a few steps.
|
|
218
|
+
maxSteps = Math.round(Math.min(64, Math.max(24, n / 4))),
|
|
219
|
+
inertia = 0.2,
|
|
220
|
+
capacity = 4,
|
|
221
|
+
// Each step takes a small bite of the sediment the droplet can carry. A big bite carves
|
|
222
|
+
// fewer, deeper dimples that stand proud of their neighbours as spikes; many shallow bites
|
|
223
|
+
// over the same lines incise the same drainage with a smooth surface.
|
|
224
|
+
erosion = 0.03,
|
|
225
|
+
deposition = 0.3,
|
|
226
|
+
evaporation = 0.025,
|
|
227
|
+
seed = 1,
|
|
228
|
+
brushRadius = 3
|
|
229
|
+
} = {}, observations) {
|
|
230
|
+
const h = Float64Array.from(height);
|
|
231
|
+
const rnd = random(seed);
|
|
232
|
+
const cell = size / (n - 1);
|
|
233
|
+
const cells = brush(brushRadius);
|
|
234
|
+
const spread = (x, z, amount, floor = Number.NEGATIVE_INFINITY) => {
|
|
235
|
+
let total = 0;
|
|
236
|
+
for (const c of cells) {
|
|
237
|
+
const ix = x + c.dx;
|
|
238
|
+
const iz = z + c.dz;
|
|
239
|
+
if (ix >= 0 && ix < n && iz >= 0 && iz < n) total += c.weight;
|
|
240
|
+
}
|
|
241
|
+
if (total <= 0) return 0;
|
|
242
|
+
let moved = 0;
|
|
243
|
+
for (const c of cells) {
|
|
244
|
+
const ix = x + c.dx;
|
|
245
|
+
const iz = z + c.dz;
|
|
246
|
+
if (ix < 0 || ix >= n || iz < 0 || iz >= n) continue;
|
|
247
|
+
const i = iz * n + ix;
|
|
248
|
+
const share = amount * c.weight / total;
|
|
249
|
+
const change = amount < 0 ? -Math.min(-share, Math.max(0, h[i] - floor)) : share;
|
|
250
|
+
h[i] = h[i] + change;
|
|
251
|
+
moved += change;
|
|
252
|
+
if (observations && amount > 0)
|
|
253
|
+
observations.deposition[i] = observations.deposition[i] + amount * c.weight / total;
|
|
254
|
+
}
|
|
255
|
+
return moved;
|
|
256
|
+
};
|
|
257
|
+
const get = (x, z) => {
|
|
258
|
+
const ix = Math.min(n - 2, Math.floor(x));
|
|
259
|
+
const iz = Math.min(n - 2, Math.floor(z));
|
|
260
|
+
const tx = x - ix;
|
|
261
|
+
const tz = z - iz;
|
|
262
|
+
const i = iz * n + ix;
|
|
263
|
+
return {
|
|
264
|
+
value: lerp(
|
|
265
|
+
lerp(h[i], h[i + 1], tx),
|
|
266
|
+
lerp(h[i + n], h[i + n + 1], tx),
|
|
267
|
+
tz
|
|
268
|
+
),
|
|
269
|
+
dx: lerp(
|
|
270
|
+
h[i + 1] - h[i],
|
|
271
|
+
h[i + n + 1] - h[i + n],
|
|
272
|
+
tz
|
|
273
|
+
) / cell,
|
|
274
|
+
dz: lerp(
|
|
275
|
+
h[i + n] - h[i],
|
|
276
|
+
h[i + n + 1] - h[i + 1],
|
|
277
|
+
tx
|
|
278
|
+
) / cell,
|
|
279
|
+
ix,
|
|
280
|
+
iz
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
for (let k = 0; k < droplets; k += 1) {
|
|
284
|
+
let x = rnd() * (n - 1 - 1e-3);
|
|
285
|
+
let z = rnd() * (n - 1 - 1e-3);
|
|
286
|
+
let dx = 0;
|
|
287
|
+
let dz = 0;
|
|
288
|
+
let water = 1;
|
|
289
|
+
let speed = 1;
|
|
290
|
+
let sediment = 0;
|
|
291
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
292
|
+
const old = get(x, z);
|
|
293
|
+
if (observations) {
|
|
294
|
+
const tx = x - old.ix;
|
|
295
|
+
const tz = z - old.iz;
|
|
296
|
+
const i = old.iz * n + old.ix;
|
|
297
|
+
for (let oz = 0; oz < 2; oz++) {
|
|
298
|
+
for (let ox = 0; ox < 2; ox++) {
|
|
299
|
+
const index = i + oz * n + ox;
|
|
300
|
+
const weight = (ox ? tx : 1 - tx) * (oz ? tz : 1 - tz);
|
|
301
|
+
observations.flow[index] = observations.flow[index] + water * weight;
|
|
302
|
+
observations.sediment[index] = observations.sediment[index] + sediment * weight;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
dx = dx * inertia - old.dx * (1 - inertia);
|
|
307
|
+
dz = dz * inertia - old.dz * (1 - inertia);
|
|
308
|
+
let length = Math.hypot(dx, dz);
|
|
309
|
+
if (length < 1e-9) {
|
|
310
|
+
const angle = rnd() * Math.PI * 2;
|
|
311
|
+
dx = Math.cos(angle);
|
|
312
|
+
dz = Math.sin(angle);
|
|
313
|
+
length = 1;
|
|
314
|
+
}
|
|
315
|
+
dx /= length;
|
|
316
|
+
dz /= length;
|
|
317
|
+
const nx = x + dx;
|
|
318
|
+
const nz = z + dz;
|
|
319
|
+
if (nx < 0 || nx >= n - 1 || nz < 0 || nz >= n - 1) {
|
|
320
|
+
sediment = 0;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
const next = get(nx, nz);
|
|
324
|
+
const dh = next.value - old.value;
|
|
325
|
+
const cap = Math.max(-dh, 5e-3 * cell) * speed * water * capacity;
|
|
326
|
+
if (dh > 0 || sediment > cap) {
|
|
327
|
+
const amount = dh > 0 ? Math.min(dh, sediment) : (sediment - cap) * deposition;
|
|
328
|
+
spread(old.ix, old.iz, amount);
|
|
329
|
+
sediment -= amount;
|
|
330
|
+
} else {
|
|
331
|
+
const amount = Math.max(0, Math.min((cap - sediment) * erosion, -dh));
|
|
332
|
+
sediment -= spread(old.ix, old.iz, -amount, next.value);
|
|
333
|
+
}
|
|
334
|
+
speed = Math.sqrt(Math.max(0.01, speed * speed - dh * 3));
|
|
335
|
+
water *= 1 - evaporation;
|
|
336
|
+
x = nx;
|
|
337
|
+
z = nz;
|
|
338
|
+
if (water < 0.02) break;
|
|
339
|
+
}
|
|
340
|
+
if (sediment > 0 && water < 0.02) {
|
|
341
|
+
const end = get(clamp(x, 0, n - 1.001), clamp(z, 0, n - 1.001));
|
|
342
|
+
spread(end.ix, end.iz, sediment);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return Float32Array.from(h);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/core/masks.ts
|
|
349
|
+
var MATERIAL_IDS = Object.freeze([
|
|
350
|
+
"grass",
|
|
351
|
+
"dirt",
|
|
352
|
+
"rock",
|
|
353
|
+
"snow",
|
|
354
|
+
"sand",
|
|
355
|
+
"mud",
|
|
356
|
+
"road",
|
|
357
|
+
"moss"
|
|
358
|
+
]);
|
|
359
|
+
var range = (v, min, max, fade = 0) => fade > 0 ? smoothstep(min - fade, min, v) * (1 - smoothstep(max, max + fade, v)) : Number(v >= min && v <= max);
|
|
360
|
+
function compileMask(mask, s) {
|
|
361
|
+
if (!mask || mask.type === "all") return () => 1;
|
|
362
|
+
switch (mask.type) {
|
|
363
|
+
case "none":
|
|
364
|
+
return () => 0;
|
|
365
|
+
case "circle": {
|
|
366
|
+
const centre = mask.at;
|
|
367
|
+
return (i, x, z) => falloff(
|
|
368
|
+
Math.hypot(x - centre[0], z - centre[1]),
|
|
369
|
+
mask.radius,
|
|
370
|
+
mask.falloff ?? 0.6
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
case "rectangle": {
|
|
374
|
+
const centre = mask.at;
|
|
375
|
+
const rad = (mask.rotation ?? 0) * Math.PI / 180;
|
|
376
|
+
const c = Math.cos(rad);
|
|
377
|
+
const sn = Math.sin(rad);
|
|
378
|
+
const size = Array.isArray(mask.size) ? mask.size : [mask.size, mask.size];
|
|
379
|
+
return (i, x, z) => {
|
|
380
|
+
const dx = x - centre[0];
|
|
381
|
+
const dz = z - centre[1];
|
|
382
|
+
return falloff(
|
|
383
|
+
Math.max(
|
|
384
|
+
Math.abs(dx * c + dz * sn) / (size[0] / 2),
|
|
385
|
+
Math.abs(-dx * sn + dz * c) / (size[1] / 2)
|
|
386
|
+
),
|
|
387
|
+
1,
|
|
388
|
+
mask.falloff ?? 0.2
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
case "height":
|
|
393
|
+
return (i) => range(s.height[i], mask.min ?? -1e9, mask.max ?? 1e9, mask.fade);
|
|
394
|
+
case "slope":
|
|
395
|
+
return (i) => range(slopeAtIndex(s, i), mask.min ?? 0, mask.max ?? 90, mask.fade);
|
|
396
|
+
case "noise":
|
|
397
|
+
return (i, x, z) => smoothstep(
|
|
398
|
+
(mask.threshold ?? 0.5) - (mask.fade ?? 0.15),
|
|
399
|
+
(mask.threshold ?? 0.5) + (mask.fade ?? 0.15),
|
|
400
|
+
noise2(x / mask.scale, z / mask.scale, mask.seed ?? s.seed) * 0.5 + 0.5
|
|
401
|
+
);
|
|
402
|
+
case "biome": {
|
|
403
|
+
const field = s.biomes[mask.name];
|
|
404
|
+
return (i) => field?.[i] ?? 0;
|
|
405
|
+
}
|
|
406
|
+
case "material": {
|
|
407
|
+
const channel = MATERIAL_IDS.indexOf(mask.name);
|
|
408
|
+
if (channel < 0) throw Error(`Unknown material: ${String(mask.name)}`);
|
|
409
|
+
return (i) => s.splat[i * 8 + channel];
|
|
410
|
+
}
|
|
411
|
+
case "not": {
|
|
412
|
+
const inner = compileMask(mask.mask, s);
|
|
413
|
+
return (i, x, z) => 1 - inner(i, x, z);
|
|
414
|
+
}
|
|
415
|
+
case "and": {
|
|
416
|
+
const inner = (mask.masks ?? []).map((entry) => compileMask(entry, s));
|
|
417
|
+
return (i, x, z) => inner.reduce((value, sample) => value * sample(i, x, z), 1);
|
|
418
|
+
}
|
|
419
|
+
case "or": {
|
|
420
|
+
const inner = (mask.masks ?? []).map((entry) => compileMask(entry, s));
|
|
421
|
+
return (i, x, z) => 1 - inner.reduce((value, sample) => value * (1 - sample(i, x, z)), 1);
|
|
422
|
+
}
|
|
423
|
+
default:
|
|
424
|
+
throw Error(`Unknown mask: ${String(mask.type)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
function rasterMask(mask, s) {
|
|
428
|
+
if (!mask || mask.type === "all") return null;
|
|
429
|
+
const out = new Float32Array(s.height.length);
|
|
430
|
+
const sample = compileMask(mask, s);
|
|
431
|
+
for (let z = 0; z < s.resolution; z += 1) {
|
|
432
|
+
for (let x = 0; x < s.resolution; x += 1) {
|
|
433
|
+
const i = z * s.resolution + x;
|
|
434
|
+
out[i] = clamp(sample(i, world(s, x), world(s, z)));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
function paintWeights(s, i, channel, weight) {
|
|
440
|
+
const w = clamp(weight);
|
|
441
|
+
const base = i * 8;
|
|
442
|
+
for (let c = 0; c < 8; c += 1)
|
|
443
|
+
s.splat[base + c] = s.splat[base + c] * (1 - w) + (c === channel ? w : 0);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/core/operations.ts
|
|
447
|
+
var OPERATION_TYPES = Object.freeze([
|
|
448
|
+
"noise",
|
|
449
|
+
"sculpt",
|
|
450
|
+
"smooth",
|
|
451
|
+
"flatten",
|
|
452
|
+
"ramp",
|
|
453
|
+
"stamp",
|
|
454
|
+
"erode",
|
|
455
|
+
"terrace",
|
|
456
|
+
"materials",
|
|
457
|
+
"paint",
|
|
458
|
+
"biome",
|
|
459
|
+
"scatter",
|
|
460
|
+
"clear",
|
|
461
|
+
"road",
|
|
462
|
+
"river",
|
|
463
|
+
"water",
|
|
464
|
+
"heightmap",
|
|
465
|
+
"paste"
|
|
466
|
+
]);
|
|
467
|
+
function createState(config, resolution = config.resolution) {
|
|
468
|
+
const height = new Float32Array(resolution * resolution);
|
|
469
|
+
const splat = new Float32Array(height.length * 8);
|
|
470
|
+
for (let i = 0; i < height.length; i += 1) splat[i * 8] = 1;
|
|
471
|
+
return {
|
|
472
|
+
size: config.size,
|
|
473
|
+
resolution,
|
|
474
|
+
seed: config.seed,
|
|
475
|
+
height,
|
|
476
|
+
splat,
|
|
477
|
+
biomes: {},
|
|
478
|
+
scatterRules: [],
|
|
479
|
+
waterRules: [],
|
|
480
|
+
rivers: [],
|
|
481
|
+
instances: [],
|
|
482
|
+
waters: [],
|
|
483
|
+
diagnostics: []
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function brushCenters(p, seed) {
|
|
487
|
+
const source = p.points ?? (p.at ? [p.at] : null);
|
|
488
|
+
if (!source) return null;
|
|
489
|
+
const radius = p.radius ?? 30;
|
|
490
|
+
const first = source[0];
|
|
491
|
+
if (!first) return null;
|
|
492
|
+
const out = [[first[0], first[1]]];
|
|
493
|
+
const step = Math.max(0.1, radius * 2 * (p.spacing ?? 0.15));
|
|
494
|
+
for (let k = 1; k < source.length; k += 1) {
|
|
495
|
+
const a = source[k - 1];
|
|
496
|
+
const b = source[k];
|
|
497
|
+
const count = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step));
|
|
498
|
+
for (let j = 1; j <= count; j += 1)
|
|
499
|
+
out.push([lerp(a[0], b[0], j / count), lerp(a[1], b[1], j / count)]);
|
|
500
|
+
}
|
|
501
|
+
if (p.jitter) {
|
|
502
|
+
const rnd = random(seed);
|
|
503
|
+
for (const centre of out) {
|
|
504
|
+
const angle = rnd() * Math.PI * 2;
|
|
505
|
+
const offset = Math.sqrt(rnd()) * p.jitter * radius;
|
|
506
|
+
centre[0] += Math.cos(angle) * offset;
|
|
507
|
+
centre[1] += Math.sin(angle) * offset;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
function brushWeights(s, p, mask, seed) {
|
|
513
|
+
const centers = brushCenters(p, seed);
|
|
514
|
+
if (!centers) return mask ?? new Float32Array(s.height.length).fill(1);
|
|
515
|
+
const n = s.resolution;
|
|
516
|
+
const r = p.radius ?? 30;
|
|
517
|
+
const cell = s.size / (n - 1);
|
|
518
|
+
const weights = new Float32Array(n * n);
|
|
519
|
+
const angle = (p.rotation ?? 0) * Math.PI / 180;
|
|
520
|
+
const cos = Math.cos(angle);
|
|
521
|
+
const sin = Math.sin(angle);
|
|
522
|
+
const bound = r * (p.shape === "square" ? Math.SQRT2 : 1);
|
|
523
|
+
for (const [cx, cz] of centers) {
|
|
524
|
+
const x0 = clamp(Math.floor((cx - bound + s.size / 2) / cell), 0, n - 1);
|
|
525
|
+
const x1 = clamp(Math.ceil((cx + bound + s.size / 2) / cell), 0, n - 1);
|
|
526
|
+
const z0 = clamp(Math.floor((cz - bound + s.size / 2) / cell), 0, n - 1);
|
|
527
|
+
const z1 = clamp(Math.ceil((cz + bound + s.size / 2) / cell), 0, n - 1);
|
|
528
|
+
for (let z = z0; z <= z1; z += 1) {
|
|
529
|
+
for (let x = x0; x <= x1; x += 1) {
|
|
530
|
+
const dx = world(s, x) - cx;
|
|
531
|
+
const dz = world(s, z) - cz;
|
|
532
|
+
const distance = p.shape === "square" ? Math.max(Math.abs(dx * cos + dz * sin), Math.abs(-dx * sin + dz * cos)) : Math.hypot(dx, dz);
|
|
533
|
+
const i = z * n + x;
|
|
534
|
+
weights[i] = Math.max(
|
|
535
|
+
weights[i],
|
|
536
|
+
falloff(distance, r, p.falloff ?? 0.6) * (mask?.[i] ?? 1)
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return weights;
|
|
542
|
+
}
|
|
543
|
+
function followsTerrain(source, followTerrain) {
|
|
544
|
+
return followTerrain ?? source.some((point) => !Number.isFinite(point[1]));
|
|
545
|
+
}
|
|
546
|
+
function gradeToGround(s, points, p) {
|
|
547
|
+
const at = (k) => points[k];
|
|
548
|
+
const run = (k) => {
|
|
549
|
+
const a = at(k - 1);
|
|
550
|
+
const b = at(k);
|
|
551
|
+
return Math.hypot(b[0] - a[0], b[2] - a[2]);
|
|
552
|
+
};
|
|
553
|
+
const bed = points.map(([x, , z]) => sampleHeight(s, x, z));
|
|
554
|
+
let spacing = 0;
|
|
555
|
+
for (let k = 1; k < points.length && spacing === 0; k += 1) spacing = run(k);
|
|
556
|
+
spacing ||= 3;
|
|
557
|
+
const window = Math.max(1, Math.round(Math.max(8, p.width ?? 12) / spacing));
|
|
558
|
+
const profile = bed.slice();
|
|
559
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
560
|
+
const next = profile.slice();
|
|
561
|
+
for (let k = 0; k < profile.length; k += 1) {
|
|
562
|
+
let sum = 0;
|
|
563
|
+
let count = 0;
|
|
564
|
+
for (let j = Math.max(0, k - window); j <= Math.min(profile.length - 1, k + window); j += 1) {
|
|
565
|
+
sum += profile[j];
|
|
566
|
+
count += 1;
|
|
567
|
+
}
|
|
568
|
+
next[k] = sum / count;
|
|
569
|
+
}
|
|
570
|
+
for (let k = 0; k < profile.length; k += 1) profile[k] = next[k];
|
|
571
|
+
}
|
|
572
|
+
const maxCut = p.maxCut ?? 2.5;
|
|
573
|
+
const maxFill = p.maxFill ?? 1.5;
|
|
574
|
+
const grade2 = p.maxGrade ?? 0.12;
|
|
575
|
+
const limit = (k) => grade2 * Math.max(0.5, run(k));
|
|
576
|
+
for (let round = 0; round < 16; round += 1) {
|
|
577
|
+
let moved = 0;
|
|
578
|
+
for (let k = 1; k < profile.length; k += 1) {
|
|
579
|
+
const y = clamp(
|
|
580
|
+
profile[k],
|
|
581
|
+
profile[k - 1] - limit(k),
|
|
582
|
+
profile[k - 1] + limit(k)
|
|
583
|
+
);
|
|
584
|
+
moved += Math.abs(y - profile[k]);
|
|
585
|
+
profile[k] = y;
|
|
586
|
+
}
|
|
587
|
+
for (let k = profile.length - 2; k >= 0; k -= 1) {
|
|
588
|
+
const y = clamp(
|
|
589
|
+
profile[k],
|
|
590
|
+
profile[k + 1] - limit(k + 1),
|
|
591
|
+
profile[k + 1] + limit(k + 1)
|
|
592
|
+
);
|
|
593
|
+
moved += Math.abs(y - profile[k]);
|
|
594
|
+
profile[k] = y;
|
|
595
|
+
}
|
|
596
|
+
if (moved < 1e-4) break;
|
|
597
|
+
}
|
|
598
|
+
for (let k = 0; k < profile.length; k += 1)
|
|
599
|
+
profile[k] = clamp(
|
|
600
|
+
profile[k],
|
|
601
|
+
bed[k] - maxCut,
|
|
602
|
+
bed[k] + maxFill
|
|
603
|
+
);
|
|
604
|
+
for (let k = 0; k < points.length; k += 1) at(k)[1] = profile[k];
|
|
605
|
+
}
|
|
606
|
+
function applySpline(s, layer, mask) {
|
|
607
|
+
const p = layer.params;
|
|
608
|
+
const river = layer.type === "river";
|
|
609
|
+
const ramp = layer.type === "ramp";
|
|
610
|
+
const seed = p.seed ?? s.seed;
|
|
611
|
+
const raw = ramp ? [p.from, p.to] : p.points ?? [];
|
|
612
|
+
const follows = followsTerrain(raw, p.followTerrain);
|
|
613
|
+
const source = raw.map(
|
|
614
|
+
([x, y, z]) => [x, Number.isFinite(y) ? y : 0, z]
|
|
615
|
+
);
|
|
616
|
+
const points = splinePoints(
|
|
617
|
+
source,
|
|
618
|
+
Math.max(1, (p.width ?? 12) * 0.22),
|
|
619
|
+
!ramp && p.smooth !== false
|
|
620
|
+
);
|
|
621
|
+
if (follows) gradeToGround(s, points, p);
|
|
622
|
+
const half = (p.width ?? 12) / 2;
|
|
623
|
+
const shoulder = p.shoulder ?? half;
|
|
624
|
+
const extent = half + shoulder;
|
|
625
|
+
const depth = p.depth ?? 4;
|
|
626
|
+
const alpha = layer.opacity ?? 1;
|
|
627
|
+
const n = s.resolution;
|
|
628
|
+
const cell = s.size / (n - 1);
|
|
629
|
+
const dist = new Float32Array(n * n).fill(Number.POSITIVE_INFINITY);
|
|
630
|
+
const target = new Float32Array(n * n);
|
|
631
|
+
const bed = new Float32Array(n * n);
|
|
632
|
+
const floor = river ? 1.5 * depth : 0;
|
|
633
|
+
let reach = extent;
|
|
634
|
+
const raster = () => {
|
|
635
|
+
dist.fill(Number.POSITIVE_INFINITY);
|
|
636
|
+
for (let k = 1; k < points.length; k += 1) {
|
|
637
|
+
const a = points[k - 1];
|
|
638
|
+
const b = points[k];
|
|
639
|
+
const dx = b[0] - a[0];
|
|
640
|
+
const dz = b[2] - a[2];
|
|
641
|
+
const den = dx * dx + dz * dz;
|
|
642
|
+
if (den < 1e-12) continue;
|
|
643
|
+
const xmin = clamp(Math.floor((Math.min(a[0], b[0]) - reach + s.size / 2) / cell), 0, n - 1);
|
|
644
|
+
const xmax = clamp(Math.ceil((Math.max(a[0], b[0]) + reach + s.size / 2) / cell), 0, n - 1);
|
|
645
|
+
const zmin = clamp(Math.floor((Math.min(a[2], b[2]) - reach + s.size / 2) / cell), 0, n - 1);
|
|
646
|
+
const zmax = clamp(Math.ceil((Math.max(a[2], b[2]) + reach + s.size / 2) / cell), 0, n - 1);
|
|
647
|
+
for (let z = zmin; z <= zmax; z += 1) {
|
|
648
|
+
for (let x = xmin; x <= xmax; x += 1) {
|
|
649
|
+
const wx = world(s, x);
|
|
650
|
+
const wz = world(s, z);
|
|
651
|
+
const t = clamp(((wx - a[0]) * dx + (wz - a[2]) * dz) / den);
|
|
652
|
+
const d = Math.hypot(wx - a[0] - t * dx, wz - a[2] - t * dz);
|
|
653
|
+
const i = z * n + x;
|
|
654
|
+
if (d < dist[i]) {
|
|
655
|
+
dist[i] = d;
|
|
656
|
+
target[i] = lerp(a[1], b[1], t);
|
|
657
|
+
bed[i] = s.height[i];
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
raster();
|
|
664
|
+
let worst = 0;
|
|
665
|
+
for (let i = 0; i < dist.length; i += 1)
|
|
666
|
+
if (dist[i] <= extent)
|
|
667
|
+
worst = Math.max(worst, Math.abs(target[i] - bed[i]));
|
|
668
|
+
if (floor + 1.5 * worst > 0.05) {
|
|
669
|
+
reach = extent + floor + 1.5 * worst;
|
|
670
|
+
raster();
|
|
671
|
+
}
|
|
672
|
+
const material = MATERIAL_IDS.indexOf(p.material ?? (river ? "mud" : "road"));
|
|
673
|
+
for (let i = 0; i < s.height.length; i += 1) {
|
|
674
|
+
const d = dist[i];
|
|
675
|
+
if (d > reach) continue;
|
|
676
|
+
const pre = bed[i];
|
|
677
|
+
const edge = Math.min(reach, extent + floor + 1.5 * Math.abs(target[i] - pre));
|
|
678
|
+
const blend = 1 - smoothstep(half, edge, d);
|
|
679
|
+
const w = blend * blend * (mask?.[i] ?? 1) * alpha;
|
|
680
|
+
let y = target[i];
|
|
681
|
+
if (river) {
|
|
682
|
+
const bowl = 1 - 0.35 * clamp(d / half) ** 2;
|
|
683
|
+
const bars = 1 + 0.22 * noise2(world(s, i % n) / (half * 8), world(s, Math.floor(i / n)) / (half * 8), seed + 7);
|
|
684
|
+
y = Math.min(pre, y - depth * bowl * bars * blend);
|
|
685
|
+
}
|
|
686
|
+
s.height[i] = lerp(pre, y, w);
|
|
687
|
+
if (material >= 0) paintWeights(s, i, material, w * (river ? 0.85 : 1));
|
|
688
|
+
}
|
|
689
|
+
if (river && p.water !== false)
|
|
690
|
+
s.rivers.push({
|
|
691
|
+
id: layer.id,
|
|
692
|
+
points,
|
|
693
|
+
width: (p.width ?? 12) * (p.waterWidth ?? 0.76),
|
|
694
|
+
color: p.color,
|
|
695
|
+
opacity: alpha
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function applyOperation(s, layer) {
|
|
699
|
+
const { type, params: p } = layer;
|
|
700
|
+
const opacity = layer.opacity ?? 1;
|
|
701
|
+
if (layer.enabled === false || opacity <= 0) return;
|
|
702
|
+
const mask = rasterMask(layer.mask, s);
|
|
703
|
+
const weight = (i) => (mask?.[i] ?? 1) * opacity;
|
|
704
|
+
const n = s.resolution;
|
|
705
|
+
const seed = p.seed ?? s.seed;
|
|
706
|
+
switch (type) {
|
|
707
|
+
case "noise": {
|
|
708
|
+
const amplitude = p.amplitude ?? 35;
|
|
709
|
+
const scale = p.scale ?? 100;
|
|
710
|
+
const warp = p.warp ?? 20;
|
|
711
|
+
for (let z = 0; z < n; z += 1) {
|
|
712
|
+
for (let x = 0; x < n; x += 1) {
|
|
713
|
+
const i = z * n + x;
|
|
714
|
+
const wx = world(s, x);
|
|
715
|
+
const wz = world(s, z);
|
|
716
|
+
const ox = noise2(wx / (scale * 1.7) + 17, wz / (scale * 1.7) - 19, seed + 29) * warp;
|
|
717
|
+
const oz = noise2(wx / (scale * 1.7) - 27, wz / (scale * 1.7) + 12, seed + 31) * warp;
|
|
718
|
+
let value = (p.base ?? 0) + fbm((wx + ox) / scale, (wz + oz) / scale, {
|
|
719
|
+
seed,
|
|
720
|
+
octaves: p.octaves,
|
|
721
|
+
persistence: p.persistence,
|
|
722
|
+
lacunarity: p.lacunarity,
|
|
723
|
+
mode: p.mode
|
|
724
|
+
}) * amplitude;
|
|
725
|
+
if (p.island) {
|
|
726
|
+
const r = Math.hypot(wx / (s.size * 0.59), wz / (s.size * 0.59));
|
|
727
|
+
value = value * (1 - smoothstep(0.45, 1, r)) - (p.coastDepth ?? 15) * smoothstep(0.6, 1, r);
|
|
728
|
+
}
|
|
729
|
+
s.height[i] = p.blend === "replace" ? lerp(s.height[i], value, weight(i)) : s.height[i] + value * weight(i);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
case "sculpt":
|
|
735
|
+
case "flatten":
|
|
736
|
+
case "smooth":
|
|
737
|
+
case "paint": {
|
|
738
|
+
const weights = brushWeights(s, p, mask, seed);
|
|
739
|
+
const w = (i) => (weights?.[i] ?? 1) * opacity;
|
|
740
|
+
if (type === "sculpt")
|
|
741
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
742
|
+
s.height[i] = s.height[i] + (p.strength ?? 5) * w(i);
|
|
743
|
+
if (type === "flatten") {
|
|
744
|
+
const level = "height" in p ? p.height : void 0;
|
|
745
|
+
if (typeof level !== "number" || !Number.isFinite(level)) {
|
|
746
|
+
const covered = brushWeights(s, p, mask, seed);
|
|
747
|
+
let sum = 0;
|
|
748
|
+
let total = 0;
|
|
749
|
+
for (let i = 0; i < s.height.length; i += 1) {
|
|
750
|
+
const share = covered[i] * opacity;
|
|
751
|
+
sum += s.height[i] * share;
|
|
752
|
+
total += share;
|
|
753
|
+
}
|
|
754
|
+
const target = total > 0 ? sum / total : 0;
|
|
755
|
+
let delta = 0;
|
|
756
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
757
|
+
if (covered[i] > 0)
|
|
758
|
+
delta = Math.max(delta, Math.abs(s.height[i] - target));
|
|
759
|
+
const graded = brushWeights(
|
|
760
|
+
s,
|
|
761
|
+
{ ...p, radius: (p.radius ?? 30) + 1.5 * delta },
|
|
762
|
+
mask,
|
|
763
|
+
seed
|
|
764
|
+
);
|
|
765
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
766
|
+
s.height[i] = lerp(
|
|
767
|
+
s.height[i],
|
|
768
|
+
target,
|
|
769
|
+
graded[i] * opacity * clamp(p.strength ?? 1)
|
|
770
|
+
);
|
|
771
|
+
} else
|
|
772
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
773
|
+
s.height[i] = lerp(s.height[i], level, w(i) * clamp(p.strength ?? 1));
|
|
774
|
+
}
|
|
775
|
+
if (type === "paint") {
|
|
776
|
+
const channel = MATERIAL_IDS.indexOf(("material" in p ? p.material : void 0) ?? "dirt");
|
|
777
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
778
|
+
paintWeights(s, i, channel, w(i) * clamp(p.strength ?? 1));
|
|
779
|
+
}
|
|
780
|
+
if (type === "smooth")
|
|
781
|
+
for (let k = 0; k < (("iterations" in p ? p.iterations : void 0) ?? 3); k += 1) {
|
|
782
|
+
const old = s.height.slice();
|
|
783
|
+
for (let z = 0; z < n; z += 1) {
|
|
784
|
+
for (let x = 0; x < n; x += 1) {
|
|
785
|
+
const i = z * n + x;
|
|
786
|
+
if (w(i) === 0) continue;
|
|
787
|
+
let total = 0;
|
|
788
|
+
let count = 0;
|
|
789
|
+
for (let dz = -1; dz <= 1; dz += 1) {
|
|
790
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
791
|
+
const xx = x + dx;
|
|
792
|
+
const zz = z + dz;
|
|
793
|
+
if (xx >= 0 && xx < n && zz >= 0 && zz < n) {
|
|
794
|
+
total += old[zz * n + xx];
|
|
795
|
+
count += 1;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
s.height[i] = lerp(old[i], total / count, w(i) * clamp(p.strength ?? 0.65));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
case "stamp":
|
|
806
|
+
case "paste": {
|
|
807
|
+
const at = p.at ?? [0, 0];
|
|
808
|
+
const radius = p.radius ?? (p.size ? typeof p.size === "number" ? p.size / 2 : p.size.map((v) => v / 2) : 60);
|
|
809
|
+
const extents = Array.isArray(radius) ? [radius[0] / 1, radius[1] / 1] : [radius / 1, radius / 1];
|
|
810
|
+
const rot = (p.rotation ?? 0) * Math.PI / 180;
|
|
811
|
+
const c = Math.cos(rot);
|
|
812
|
+
const sn = Math.sin(rot);
|
|
813
|
+
const shape = ("shape" in p ? p.shape : void 0) ?? "mountain";
|
|
814
|
+
for (let z = 0; z < n; z += 1) {
|
|
815
|
+
for (let x = 0; x < n; x += 1) {
|
|
816
|
+
const i = z * n + x;
|
|
817
|
+
const dx = world(s, x) - at[0];
|
|
818
|
+
const dz = world(s, z) - at[1];
|
|
819
|
+
const u = (dx * c + dz * sn) / extents[0] * (p.mirrorX ? -1 : 1);
|
|
820
|
+
const v = (-dx * sn + dz * c) / extents[1] * (p.mirrorZ ? -1 : 1);
|
|
821
|
+
const d = Math.hypot(u, v);
|
|
822
|
+
let profile;
|
|
823
|
+
let w = 1;
|
|
824
|
+
if (p.data) {
|
|
825
|
+
if (Math.abs(u) > 1 || Math.abs(v) > 1) continue;
|
|
826
|
+
const source = p.data;
|
|
827
|
+
profile = sampleGrid(
|
|
828
|
+
source.values,
|
|
829
|
+
source.width,
|
|
830
|
+
source.height,
|
|
831
|
+
u * 0.5 + 0.5,
|
|
832
|
+
v * 0.5 + 0.5
|
|
833
|
+
);
|
|
834
|
+
w = falloff(Math.max(Math.abs(u), Math.abs(v)), 1, p.falloff ?? 0.12);
|
|
835
|
+
} else {
|
|
836
|
+
if (d >= 1) continue;
|
|
837
|
+
const rough = 1 + (("roughness" in p ? p.roughness : void 0) ?? 0.13) * fbm(u * 5, v * 5, { seed, octaves: 3 });
|
|
838
|
+
if (shape === "crater")
|
|
839
|
+
profile = (Math.exp(-(((d - 0.67) / 0.14) ** 2)) * 0.48 - Math.exp(-((d / 0.49) ** 4)) * 0.6) * (1 - smoothstep(0.85, 1, d));
|
|
840
|
+
else if (shape === "ridge")
|
|
841
|
+
profile = Math.max(0, 1 - Math.abs(v)) ** 1.7 * (1 - smoothstep(0.35, 1, Math.abs(u))) * rough;
|
|
842
|
+
else if (shape === "mesa") profile = (1 - smoothstep(0.5, 1, d)) * rough;
|
|
843
|
+
else if (shape === "valley") profile = -(1 - smoothstep(0, 1, d)) * rough;
|
|
844
|
+
else if (shape === "dune") profile = Math.max(0, 1 - d) ** 1.2 * (1 + 0.25 * u) * rough;
|
|
845
|
+
else profile = Math.max(0, 1 - d) ** 1.65 * rough;
|
|
846
|
+
profile *= ("amplitude" in p ? p.amplitude : void 0) ?? 45;
|
|
847
|
+
w = falloff(d, 1, p.falloff ?? 0);
|
|
848
|
+
}
|
|
849
|
+
const alpha = weight(i) * w;
|
|
850
|
+
const blend = p.blend ?? (type === "paste" ? "replace" : "add");
|
|
851
|
+
const height = profile * (p.scale ?? 1) + (p.offset ?? 0);
|
|
852
|
+
const value = blend === "replace" ? height : blend === "max" ? Math.max(s.height[i], height) : blend === "min" ? Math.min(s.height[i], height) : s.height[i] + height;
|
|
853
|
+
s.height[i] = lerp(s.height[i], value, alpha);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
case "erode": {
|
|
859
|
+
const observations = {
|
|
860
|
+
flow: new Float32Array(s.height.length),
|
|
861
|
+
sediment: new Float32Array(s.height.length),
|
|
862
|
+
deposition: new Float32Array(s.height.length),
|
|
863
|
+
talus: new Float32Array(s.height.length)
|
|
864
|
+
};
|
|
865
|
+
s.erosion ??= structuredClone(observations);
|
|
866
|
+
const eroded = p.method === "hydraulic" ? hydraulic(
|
|
867
|
+
s.height,
|
|
868
|
+
n,
|
|
869
|
+
s.size,
|
|
870
|
+
{
|
|
871
|
+
seed,
|
|
872
|
+
droplets: p.droplets,
|
|
873
|
+
maxSteps: p.maxSteps,
|
|
874
|
+
inertia: p.inertia,
|
|
875
|
+
capacity: p.capacity,
|
|
876
|
+
erosion: p.erosion,
|
|
877
|
+
deposition: p.deposition,
|
|
878
|
+
evaporation: p.evaporation
|
|
879
|
+
},
|
|
880
|
+
observations
|
|
881
|
+
) : thermal(s.height, n, s.size, p, observations);
|
|
882
|
+
let influence = mask;
|
|
883
|
+
if (p.at || p.points) influence = brushWeights(s, p, mask, seed);
|
|
884
|
+
for (let i = 0; i < s.height.length; i += 1) {
|
|
885
|
+
const alpha = (influence?.[i] ?? 1) * opacity * (p.strength ?? 1);
|
|
886
|
+
s.height[i] = lerp(s.height[i], eroded[i], alpha);
|
|
887
|
+
for (const key of ["flow", "sediment", "deposition", "talus"])
|
|
888
|
+
s.erosion[key][i] = s.erosion[key][i] + observations[key][i] * alpha;
|
|
889
|
+
}
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
case "terrace": {
|
|
893
|
+
const step = p.step ?? 8;
|
|
894
|
+
const softness = p.softness ?? 0.18;
|
|
895
|
+
for (let i = 0; i < s.height.length; i += 1) {
|
|
896
|
+
const v = (s.height[i] - (p.offset ?? 0)) / step;
|
|
897
|
+
const base = Math.floor(v);
|
|
898
|
+
const f = v - base;
|
|
899
|
+
const target = (base + smoothstep(0.5 - softness * 0.5, 0.5 + softness * 0.5, f)) * step + (p.offset ?? 0);
|
|
900
|
+
s.height[i] = lerp(s.height[i], target, weight(i) * (p.strength ?? 1));
|
|
901
|
+
}
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
case "materials": {
|
|
905
|
+
if (p.reset !== false)
|
|
906
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
907
|
+
paintWeights(s, i, MATERIAL_IDS.indexOf(p.base ?? "grass"), weight(i));
|
|
908
|
+
for (const rule of p.rules ?? []) {
|
|
909
|
+
const ruleMask = rasterMask(rule.mask, s);
|
|
910
|
+
const channel = MATERIAL_IDS.indexOf(rule.material);
|
|
911
|
+
for (let i = 0; i < s.height.length; i += 1)
|
|
912
|
+
paintWeights(s, i, channel, (ruleMask?.[i] ?? 1) * weight(i) * (rule.strength ?? 1));
|
|
913
|
+
}
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case "biome": {
|
|
917
|
+
const name = p.name ?? "forest";
|
|
918
|
+
s.biomes[name] ??= new Float32Array(s.height.length);
|
|
919
|
+
const values = s.biomes[name];
|
|
920
|
+
const weights = brushWeights(s, p, mask, seed);
|
|
921
|
+
for (let i = 0; i < values.length; i += 1)
|
|
922
|
+
values[i] = lerp(
|
|
923
|
+
values[i],
|
|
924
|
+
p.value ?? 1,
|
|
925
|
+
(weights?.[i] ?? 1) * opacity * (p.strength ?? 1)
|
|
926
|
+
);
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
case "scatter":
|
|
930
|
+
s.scatterRules.push({ id: layer.id, ...p, mask: layer.mask, opacity, clear: [] });
|
|
931
|
+
break;
|
|
932
|
+
case "clear": {
|
|
933
|
+
if (p.target === "scatter" || p.target === "all") {
|
|
934
|
+
for (const rule of s.scatterRules)
|
|
935
|
+
if (!p.asset || rule.asset === p.asset)
|
|
936
|
+
rule.clear.push({ mask: layer.mask ?? { type: "all" }, opacity });
|
|
937
|
+
}
|
|
938
|
+
if (p.target === "biome" || p.target === "all")
|
|
939
|
+
for (const [name, values] of Object.entries(s.biomes)) {
|
|
940
|
+
if (p.name && p.name !== name) continue;
|
|
941
|
+
for (let i = 0; i < values.length; i += 1)
|
|
942
|
+
values[i] = values[i] * (1 - weight(i));
|
|
943
|
+
}
|
|
944
|
+
if (p.target === "paint" || p.target === "all")
|
|
945
|
+
for (let i = 0; i < s.height.length; i += 1) paintWeights(s, i, 0, weight(i));
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
case "road":
|
|
949
|
+
case "river":
|
|
950
|
+
case "ramp":
|
|
951
|
+
applySpline(s, layer, mask);
|
|
952
|
+
break;
|
|
953
|
+
case "water":
|
|
954
|
+
s.waterRules.push({ id: layer.id, ...p, mask: layer.mask, opacity });
|
|
955
|
+
break;
|
|
956
|
+
case "heightmap": {
|
|
957
|
+
if (p.at !== void 0 || p.size !== void 0 || p.rotation !== void 0 || p.falloff !== void 0) {
|
|
958
|
+
applyOperation(s, {
|
|
959
|
+
...layer,
|
|
960
|
+
type: "paste",
|
|
961
|
+
params: { ...p, size: p.size ?? s.size, falloff: p.falloff ?? 0 }
|
|
962
|
+
});
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
const data = p.data;
|
|
966
|
+
for (let z = 0; z < n; z += 1) {
|
|
967
|
+
for (let x = 0; x < n; x += 1) {
|
|
968
|
+
const i = z * n + x;
|
|
969
|
+
const value = sampleGrid(data.values, data.width, data.height, x / (n - 1), z / (n - 1)) * (p.scale ?? 1) + (p.offset ?? 0);
|
|
970
|
+
s.height[i] = p.blend === "add" ? s.height[i] + value * weight(i) : lerp(s.height[i], value, weight(i));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
default:
|
|
976
|
+
throw Error(`Unsupported operation: ${String(layer.type)}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function finalizeState(s) {
|
|
980
|
+
const n = s.resolution;
|
|
981
|
+
const cell = s.size / (n - 1);
|
|
982
|
+
s.waters = [];
|
|
983
|
+
s.instances = [];
|
|
984
|
+
for (const water of s.waterRules) {
|
|
985
|
+
const mask = new Uint8Array(n * n);
|
|
986
|
+
const queue = new Int32Array(n * n);
|
|
987
|
+
const allowed = compileMask(water.mask, s);
|
|
988
|
+
const level = water.level ?? 0;
|
|
989
|
+
const at = water.at ?? [0, 0];
|
|
990
|
+
const radius = water.radius ?? s.size;
|
|
991
|
+
let front = 0;
|
|
992
|
+
let back = 0;
|
|
993
|
+
const eligible = (i) => {
|
|
994
|
+
const x = world(s, i % n);
|
|
995
|
+
const z = world(s, Math.floor(i / n));
|
|
996
|
+
return s.height[i] < level && Math.hypot(x - at[0], z - at[1]) <= radius && allowed(i, x, z) > 0.05;
|
|
997
|
+
};
|
|
998
|
+
const push = (i) => {
|
|
999
|
+
if (i >= 0 && i < n * n && !mask[i] && eligible(i)) {
|
|
1000
|
+
mask[i] = 1;
|
|
1001
|
+
queue[back] = i;
|
|
1002
|
+
back += 1;
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
if (water.kind === "ocean") {
|
|
1006
|
+
for (let k = 0; k < n; k += 1) {
|
|
1007
|
+
push(k);
|
|
1008
|
+
push((n - 1) * n + k);
|
|
1009
|
+
push(k * n);
|
|
1010
|
+
push(k * n + n - 1);
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
const x = clamp(Math.round((at[0] + s.size / 2) / cell), 0, n - 1);
|
|
1014
|
+
const z = clamp(Math.round((at[1] + s.size / 2) / cell), 0, n - 1);
|
|
1015
|
+
push(z * n + x);
|
|
1016
|
+
}
|
|
1017
|
+
while (front < back) {
|
|
1018
|
+
const i = queue[front];
|
|
1019
|
+
front += 1;
|
|
1020
|
+
const x = i % n;
|
|
1021
|
+
const z = Math.floor(i / n);
|
|
1022
|
+
if (x > 0) push(i - 1);
|
|
1023
|
+
if (x < n - 1) push(i + 1);
|
|
1024
|
+
if (z > 0) push(i - n);
|
|
1025
|
+
if (z < n - 1) push(i + n);
|
|
1026
|
+
}
|
|
1027
|
+
s.waters.push({ ...water, level, mask });
|
|
1028
|
+
if (!back)
|
|
1029
|
+
s.diagnostics.push(
|
|
1030
|
+
`Water '${water.id}' has no flooded cells; its seed may be above the water level.`
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
const wet = (i, x, z, h) => {
|
|
1034
|
+
if (s.waters.some((water) => water.mask[i])) return true;
|
|
1035
|
+
for (const river of s.rivers) {
|
|
1036
|
+
const half = river.width / 2;
|
|
1037
|
+
for (let k = 1; k < river.points.length; k += 1) {
|
|
1038
|
+
const a = river.points[k - 1];
|
|
1039
|
+
const b = river.points[k];
|
|
1040
|
+
if (x < Math.min(a[0], b[0]) - half || x > Math.max(a[0], b[0]) + half || z < Math.min(a[2], b[2]) - half || z > Math.max(a[2], b[2]) + half)
|
|
1041
|
+
continue;
|
|
1042
|
+
const dx = b[0] - a[0];
|
|
1043
|
+
const dz = b[2] - a[2];
|
|
1044
|
+
const t = clamp(((x - a[0]) * dx + (z - a[2]) * dz) / (dx * dx + dz * dz || 1));
|
|
1045
|
+
if (Math.hypot(x - a[0] - t * dx, z - a[2] - t * dz) < half && h <= lerp(a[1], b[1], t) + 0.2)
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return false;
|
|
1050
|
+
};
|
|
1051
|
+
for (const rule of s.scatterRules) {
|
|
1052
|
+
const count = Math.round((rule.count ?? 300) * (rule.opacity ?? 1));
|
|
1053
|
+
const seed = (rule.seed ?? s.seed ^ hashString(rule.id)) >>> 0;
|
|
1054
|
+
const rnd = random(seed);
|
|
1055
|
+
const accept = compileMask(rule.mask, s);
|
|
1056
|
+
const clears = rule.clear.map((entry) => ({
|
|
1057
|
+
fn: compileMask(entry.mask, s),
|
|
1058
|
+
opacity: entry.opacity
|
|
1059
|
+
}));
|
|
1060
|
+
const distance = rule.minDistance ?? 4;
|
|
1061
|
+
const gridSize = Math.max(distance, 0.1);
|
|
1062
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1063
|
+
const accepted = [];
|
|
1064
|
+
const maxAttempts = count * 40;
|
|
1065
|
+
const scale = rule.scale ?? [0.8, 1.3];
|
|
1066
|
+
for (let attempt = 0; attempt < maxAttempts && accepted.length < count; attempt += 1) {
|
|
1067
|
+
const x = (rnd() - 0.5) * s.size;
|
|
1068
|
+
const z = (rnd() - 0.5) * s.size;
|
|
1069
|
+
const acceptance = rnd();
|
|
1070
|
+
const scaleRandom = rnd();
|
|
1071
|
+
const yaw = rnd() * Math.PI * 2;
|
|
1072
|
+
const ix = clamp(Math.round((x / s.size + 0.5) * (n - 1)), 0, n - 1);
|
|
1073
|
+
const iz = clamp(Math.round((z / s.size + 0.5) * (n - 1)), 0, n - 1);
|
|
1074
|
+
const i = iz * n + ix;
|
|
1075
|
+
let w = accept(i, x, z);
|
|
1076
|
+
for (const clear of clears) w *= 1 - clear.fn(i, x, z) * clear.opacity;
|
|
1077
|
+
if (acceptance > w) continue;
|
|
1078
|
+
const h = sampleHeight(s, x, z);
|
|
1079
|
+
if (rule.avoidWater !== false && wet(i, x, z, h)) continue;
|
|
1080
|
+
if (h < (rule.minHeight ?? -1e9) || h > (rule.maxHeight ?? 1e9)) continue;
|
|
1081
|
+
const g = gradientAt(s, x, z);
|
|
1082
|
+
const slope = Math.atan(Math.hypot(g[0], g[1])) * (180 / Math.PI);
|
|
1083
|
+
if (slope < (rule.minSlope ?? 0) || slope > (rule.maxSlope ?? 90)) continue;
|
|
1084
|
+
const gx = Math.floor(x / gridSize);
|
|
1085
|
+
const gz = Math.floor(z / gridSize);
|
|
1086
|
+
let blocked = false;
|
|
1087
|
+
if (distance > 0)
|
|
1088
|
+
for (let dz = -1; dz <= 1 && !blocked; dz += 1) {
|
|
1089
|
+
for (let dx = -1; dx <= 1 && !blocked; dx += 1) {
|
|
1090
|
+
for (const j of buckets.get(`${gx + dx},${gz + dz}`) ?? []) {
|
|
1091
|
+
const other = accepted[j];
|
|
1092
|
+
if (Math.hypot(other.position[0] - x, other.position[2] - z) < distance) {
|
|
1093
|
+
blocked = true;
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (blocked) continue;
|
|
1100
|
+
const index = accepted.length;
|
|
1101
|
+
const itemScale = Array.isArray(scale) ? lerp(scale[0], scale[1], scaleRandom) : scale;
|
|
1102
|
+
const normal = [-g[0], 1, -g[1]];
|
|
1103
|
+
const length = Math.hypot(normal[0], normal[1], normal[2]);
|
|
1104
|
+
accepted.push({
|
|
1105
|
+
id: `${rule.id}:candidate:${seed}:${attempt}`,
|
|
1106
|
+
layer: rule.id,
|
|
1107
|
+
asset: rule.asset ?? "pine",
|
|
1108
|
+
position: [x, h + (rule.offsetY ?? 0), z],
|
|
1109
|
+
rotation: yaw,
|
|
1110
|
+
scale: itemScale,
|
|
1111
|
+
normal: [normal[0] / length, normal[1] / length, normal[2] / length],
|
|
1112
|
+
alignToNormal: rule.alignToNormal ?? false
|
|
1113
|
+
});
|
|
1114
|
+
const key = `${gx},${gz}`;
|
|
1115
|
+
const bucket = buckets.get(key) ?? [];
|
|
1116
|
+
bucket.push(index);
|
|
1117
|
+
buckets.set(key, bucket);
|
|
1118
|
+
}
|
|
1119
|
+
s.instances.push(...accepted);
|
|
1120
|
+
if (accepted.length < count)
|
|
1121
|
+
s.diagnostics.push(
|
|
1122
|
+
`Scatter '${rule.id}': placed ${accepted.length}/${count}; masks or spacing exhausted the attempt budget.`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return s;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/core/validation.ts
|
|
1129
|
+
var RESOLUTIONS = Object.freeze([17, 33, 65, 129, 257, 513, 1025]);
|
|
1130
|
+
var brush2 = [
|
|
1131
|
+
"at",
|
|
1132
|
+
"points",
|
|
1133
|
+
"radius",
|
|
1134
|
+
"strength",
|
|
1135
|
+
"falloff",
|
|
1136
|
+
"shape",
|
|
1137
|
+
"rotation",
|
|
1138
|
+
"spacing",
|
|
1139
|
+
"jitter"
|
|
1140
|
+
];
|
|
1141
|
+
var grade = ["followTerrain", "maxGrade", "maxCut", "maxFill"];
|
|
1142
|
+
var PARAMS = Object.freeze({
|
|
1143
|
+
noise: [
|
|
1144
|
+
"amplitude",
|
|
1145
|
+
"scale",
|
|
1146
|
+
"octaves",
|
|
1147
|
+
"persistence",
|
|
1148
|
+
"lacunarity",
|
|
1149
|
+
"mode",
|
|
1150
|
+
"warp",
|
|
1151
|
+
"base",
|
|
1152
|
+
"blend",
|
|
1153
|
+
"island",
|
|
1154
|
+
"coastDepth"
|
|
1155
|
+
],
|
|
1156
|
+
sculpt: brush2,
|
|
1157
|
+
smooth: [...brush2, "iterations"],
|
|
1158
|
+
flatten: [...brush2, "height"],
|
|
1159
|
+
paint: [...brush2, "material"],
|
|
1160
|
+
stamp: [
|
|
1161
|
+
"at",
|
|
1162
|
+
"radius",
|
|
1163
|
+
"size",
|
|
1164
|
+
"amplitude",
|
|
1165
|
+
"shape",
|
|
1166
|
+
"rotation",
|
|
1167
|
+
"mirrorX",
|
|
1168
|
+
"mirrorZ",
|
|
1169
|
+
"blend",
|
|
1170
|
+
"roughness",
|
|
1171
|
+
"scale",
|
|
1172
|
+
"offset",
|
|
1173
|
+
"data",
|
|
1174
|
+
"falloff"
|
|
1175
|
+
],
|
|
1176
|
+
paste: [
|
|
1177
|
+
"at",
|
|
1178
|
+
"size",
|
|
1179
|
+
"radius",
|
|
1180
|
+
"rotation",
|
|
1181
|
+
"mirrorX",
|
|
1182
|
+
"mirrorZ",
|
|
1183
|
+
"blend",
|
|
1184
|
+
"offset",
|
|
1185
|
+
"scale",
|
|
1186
|
+
"data",
|
|
1187
|
+
"falloff"
|
|
1188
|
+
],
|
|
1189
|
+
erode: [
|
|
1190
|
+
...brush2,
|
|
1191
|
+
"method",
|
|
1192
|
+
"iterations",
|
|
1193
|
+
"talus",
|
|
1194
|
+
"rate",
|
|
1195
|
+
"droplets",
|
|
1196
|
+
"maxSteps",
|
|
1197
|
+
"inertia",
|
|
1198
|
+
"capacity",
|
|
1199
|
+
"erosion",
|
|
1200
|
+
"deposition",
|
|
1201
|
+
"evaporation"
|
|
1202
|
+
],
|
|
1203
|
+
terrace: ["step", "softness", "strength", "offset"],
|
|
1204
|
+
materials: ["base", "rules", "reset"],
|
|
1205
|
+
biome: [...brush2, "name", "value"],
|
|
1206
|
+
scatter: [
|
|
1207
|
+
"asset",
|
|
1208
|
+
"count",
|
|
1209
|
+
"minDistance",
|
|
1210
|
+
"scale",
|
|
1211
|
+
"minHeight",
|
|
1212
|
+
"maxHeight",
|
|
1213
|
+
"minSlope",
|
|
1214
|
+
"maxSlope",
|
|
1215
|
+
"avoidWater",
|
|
1216
|
+
"offsetY",
|
|
1217
|
+
"alignToNormal"
|
|
1218
|
+
],
|
|
1219
|
+
clear: ["target", "asset", "name"],
|
|
1220
|
+
road: ["points", "width", "shoulder", "material", "smooth", ...grade],
|
|
1221
|
+
river: [
|
|
1222
|
+
"points",
|
|
1223
|
+
"width",
|
|
1224
|
+
"shoulder",
|
|
1225
|
+
"depth",
|
|
1226
|
+
"material",
|
|
1227
|
+
"smooth",
|
|
1228
|
+
"water",
|
|
1229
|
+
"waterWidth",
|
|
1230
|
+
"color",
|
|
1231
|
+
"enforceDownhill",
|
|
1232
|
+
...grade
|
|
1233
|
+
],
|
|
1234
|
+
ramp: ["from", "to", "width", "shoulder", "material", ...grade],
|
|
1235
|
+
water: ["kind", "at", "radius", "level", "color"],
|
|
1236
|
+
heightmap: ["data", "scale", "offset", "blend", "at", "size", "rotation", "falloff"]
|
|
1237
|
+
});
|
|
1238
|
+
function fail(message) {
|
|
1239
|
+
throw new TypeError(message);
|
|
1240
|
+
}
|
|
1241
|
+
function finiteTree(value, path = "value", depth = 0) {
|
|
1242
|
+
if (depth > 32) fail(`${path}: nesting is too deep`);
|
|
1243
|
+
if (typeof value === "number" && !Number.isFinite(value)) fail(`${path} must be finite`);
|
|
1244
|
+
if (value === void 0 || typeof value === "function" || typeof value === "bigint" || typeof value === "symbol")
|
|
1245
|
+
fail(`${path} is not JSON-serializable`);
|
|
1246
|
+
if (value && typeof value === "object") {
|
|
1247
|
+
if (ArrayBuffer.isView(value)) fail(`${path}: use a plain array in recipes`);
|
|
1248
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1249
|
+
if (["__proto__", "prototype", "constructor"].includes(key))
|
|
1250
|
+
fail(`${path}: unsafe object key`);
|
|
1251
|
+
finiteTree(entry, `${path}.${key}`, depth + 1);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
var num = (v, name, min = -1e9, max = 1e9, integer = false) => {
|
|
1256
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || v > max || integer && !Number.isInteger(v))
|
|
1257
|
+
fail(`${name} must be ${integer ? "an integer" : "finite"} between ${min} and ${max}`);
|
|
1258
|
+
};
|
|
1259
|
+
var vec = (v, name, n) => {
|
|
1260
|
+
if (!Array.isArray(v) || v.length !== n) fail(`${name} must contain ${n} numbers`);
|
|
1261
|
+
for (const x of v) num(x, name);
|
|
1262
|
+
};
|
|
1263
|
+
var identifier = (v, name) => {
|
|
1264
|
+
if (typeof v !== "string" || v.length === 0 || v.length > 100 || ["__proto__", "prototype", "constructor"].includes(v))
|
|
1265
|
+
fail(`${name} must be a nonempty safe string, at most 100 characters`);
|
|
1266
|
+
};
|
|
1267
|
+
var enumeration = (v, name, choices) => {
|
|
1268
|
+
if (!choices.includes(v)) fail(`${name}: expected ${choices.join(", ")}`);
|
|
1269
|
+
};
|
|
1270
|
+
function validateConfig(config) {
|
|
1271
|
+
num(config.size, "size", 1, 1e5);
|
|
1272
|
+
if (!RESOLUTIONS.includes(config.resolution))
|
|
1273
|
+
fail(`resolution must be one of ${RESOLUTIONS.join(", ")}`);
|
|
1274
|
+
num(config.seed, "seed", 0, 4294967295, true);
|
|
1275
|
+
}
|
|
1276
|
+
function validateMask(m, depth = 0) {
|
|
1277
|
+
if (!m || typeof m !== "object" || depth > 20) fail("Invalid mask");
|
|
1278
|
+
enumeration(m.type, "mask.type", [
|
|
1279
|
+
"all",
|
|
1280
|
+
"none",
|
|
1281
|
+
"circle",
|
|
1282
|
+
"rectangle",
|
|
1283
|
+
"height",
|
|
1284
|
+
"slope",
|
|
1285
|
+
"noise",
|
|
1286
|
+
"biome",
|
|
1287
|
+
"material",
|
|
1288
|
+
"and",
|
|
1289
|
+
"or",
|
|
1290
|
+
"not"
|
|
1291
|
+
]);
|
|
1292
|
+
const allowed = {
|
|
1293
|
+
all: [],
|
|
1294
|
+
none: [],
|
|
1295
|
+
circle: ["at", "radius", "falloff"],
|
|
1296
|
+
rectangle: ["at", "size", "rotation", "falloff"],
|
|
1297
|
+
height: ["min", "max", "fade"],
|
|
1298
|
+
slope: ["min", "max", "fade"],
|
|
1299
|
+
noise: ["scale", "threshold", "seed", "fade"],
|
|
1300
|
+
biome: ["name"],
|
|
1301
|
+
material: ["name"],
|
|
1302
|
+
and: ["masks"],
|
|
1303
|
+
or: ["masks"],
|
|
1304
|
+
not: ["mask"]
|
|
1305
|
+
};
|
|
1306
|
+
for (const key of Object.keys(m))
|
|
1307
|
+
if (key !== "type" && !allowed[m.type].includes(key)) fail(`Unknown mask field '${key}'`);
|
|
1308
|
+
if (m.rotation !== void 0) num(m.rotation, "mask.rotation");
|
|
1309
|
+
if (m.seed !== void 0) num(m.seed, "mask.seed", 0, 4294967295, true);
|
|
1310
|
+
if (m.type === "circle") {
|
|
1311
|
+
vec(m.at, "mask.at", 2);
|
|
1312
|
+
num(m.radius, "mask.radius", 1e-3, 1e6);
|
|
1313
|
+
}
|
|
1314
|
+
if (m.type === "rectangle") {
|
|
1315
|
+
vec(m.at, "mask.at", 2);
|
|
1316
|
+
const size = m.size;
|
|
1317
|
+
if (Array.isArray(size)) {
|
|
1318
|
+
vec(size, "mask.size", 2);
|
|
1319
|
+
for (const value of size) num(value, "mask.size", 1e-3, 1e6);
|
|
1320
|
+
} else num(size, "mask.size", 1e-3, 1e6);
|
|
1321
|
+
}
|
|
1322
|
+
if (["height", "slope"].includes(m.type)) {
|
|
1323
|
+
if (m.min !== void 0) num(m.min, "mask.min");
|
|
1324
|
+
if (m.max !== void 0) num(m.max, "mask.max");
|
|
1325
|
+
if ((m.min ?? -1e9) > (m.max ?? 1e9)) fail("mask.min must not exceed mask.max");
|
|
1326
|
+
if (m.fade !== void 0) num(m.fade, "mask.fade", 0, 1e6);
|
|
1327
|
+
}
|
|
1328
|
+
if (m.type === "noise") {
|
|
1329
|
+
num(m.scale, "mask.scale", 1e-3, 1e6);
|
|
1330
|
+
if (m.threshold !== void 0) num(m.threshold, "mask.threshold", 0, 1);
|
|
1331
|
+
if (m.fade !== void 0) num(m.fade, "mask.fade", 1e-6, 1);
|
|
1332
|
+
}
|
|
1333
|
+
if (m.type === "biome") identifier(m.name, "mask.name");
|
|
1334
|
+
if (m.type === "material") enumeration(m.name, "mask.material", MATERIAL_IDS);
|
|
1335
|
+
if (m.type === "not") validateMask(m.mask, depth + 1);
|
|
1336
|
+
if (["and", "or"].includes(m.type)) {
|
|
1337
|
+
if (!Array.isArray(m.masks) || m.masks.length > 32)
|
|
1338
|
+
fail("mask.masks must be an array of at most 32 masks");
|
|
1339
|
+
for (const entry of m.masks) validateMask(entry, depth + 1);
|
|
1340
|
+
}
|
|
1341
|
+
if (m.falloff !== void 0) num(m.falloff, "mask.falloff", 0, 1);
|
|
1342
|
+
}
|
|
1343
|
+
function splinePoint(v, name, follows) {
|
|
1344
|
+
if (!Array.isArray(v) || v.length !== 3) fail(`${name} must contain 3 numbers`);
|
|
1345
|
+
num(v[0], name);
|
|
1346
|
+
num(v[2], name);
|
|
1347
|
+
const y = v[1];
|
|
1348
|
+
if (y === null || y === void 0) {
|
|
1349
|
+
if (follows) return;
|
|
1350
|
+
fail(`${name} must contain 3 numbers`);
|
|
1351
|
+
}
|
|
1352
|
+
if (typeof y !== "number") fail(`${name} must contain 3 numbers`);
|
|
1353
|
+
if (!Number.isFinite(y) && !follows) fail(`${name} elevation must be finite`);
|
|
1354
|
+
}
|
|
1355
|
+
function layerFollowsTerrain(layer) {
|
|
1356
|
+
if (!layer || typeof layer !== "object" || !["road", "river", "ramp"].includes(layer.type))
|
|
1357
|
+
return false;
|
|
1358
|
+
const params = layer.params ?? {};
|
|
1359
|
+
const source = layer.type === "ramp" ? [params.from, params.to] : params.points;
|
|
1360
|
+
if (!Array.isArray(source)) return false;
|
|
1361
|
+
return followsTerrain(source, params.followTerrain);
|
|
1362
|
+
}
|
|
1363
|
+
function omittedElevations(layer, follows) {
|
|
1364
|
+
if (!follows) return layer;
|
|
1365
|
+
const params = layer.params ?? {};
|
|
1366
|
+
const zero = (p) => Array.isArray(p) && p.length === 3 ? [p[0], 0, p[2]] : p;
|
|
1367
|
+
const points = params.points;
|
|
1368
|
+
const fixes = {
|
|
1369
|
+
...Array.isArray(points) ? { points: points.map(zero) } : {},
|
|
1370
|
+
...Array.isArray(params.from) ? { from: zero(params.from) } : {},
|
|
1371
|
+
...Array.isArray(params.to) ? { to: zero(params.to) } : {}
|
|
1372
|
+
};
|
|
1373
|
+
return Object.keys(fixes).length ? { ...layer, params: { ...params, ...fixes } } : layer;
|
|
1374
|
+
}
|
|
1375
|
+
function validateLayer(layer) {
|
|
1376
|
+
const follows = layerFollowsTerrain(layer);
|
|
1377
|
+
finiteTree(omittedElevations(layer, follows), "layer");
|
|
1378
|
+
if (!layer || typeof layer !== "object") fail("layer must be an object");
|
|
1379
|
+
for (const k of Object.keys(layer))
|
|
1380
|
+
if (!["id", "name", "type", "params", "enabled", "opacity", "mask"].includes(k))
|
|
1381
|
+
fail(`Unknown layer field '${k}'`);
|
|
1382
|
+
identifier(layer.id, "layer.id");
|
|
1383
|
+
enumeration(layer.type, "operation", OPERATION_TYPES);
|
|
1384
|
+
if (layer.name !== void 0) identifier(layer.name, "layer.name");
|
|
1385
|
+
if (layer.enabled !== void 0 && typeof layer.enabled !== "boolean")
|
|
1386
|
+
fail("enabled must be boolean");
|
|
1387
|
+
if (layer.opacity !== void 0) num(layer.opacity, "opacity", 0, 1);
|
|
1388
|
+
if (layer.mask) validateMask(layer.mask);
|
|
1389
|
+
const params = layer.params ?? {};
|
|
1390
|
+
if (!params || Array.isArray(params) || typeof params !== "object")
|
|
1391
|
+
fail("params must be an object");
|
|
1392
|
+
for (const k of Object.keys(params))
|
|
1393
|
+
if (!PARAMS[layer.type].includes(k) && k !== "seed")
|
|
1394
|
+
fail(`Unknown ${layer.type} parameter '${k}'`);
|
|
1395
|
+
for (const key of [
|
|
1396
|
+
"island",
|
|
1397
|
+
"mirrorX",
|
|
1398
|
+
"mirrorZ",
|
|
1399
|
+
"reset",
|
|
1400
|
+
"avoidWater",
|
|
1401
|
+
"alignToNormal",
|
|
1402
|
+
"smooth",
|
|
1403
|
+
"water",
|
|
1404
|
+
"enforceDownhill",
|
|
1405
|
+
"followTerrain"
|
|
1406
|
+
])
|
|
1407
|
+
if (params[key] !== void 0 && typeof params[key] !== "boolean")
|
|
1408
|
+
fail(`${key} must be boolean`);
|
|
1409
|
+
if (params.seed !== void 0) num(params.seed, "seed", 0, 4294967295, true);
|
|
1410
|
+
if (params.at !== void 0) vec(params.at, "at", 2);
|
|
1411
|
+
if (params.from !== void 0) splinePoint(params.from, "from", follows);
|
|
1412
|
+
if (params.to !== void 0) splinePoint(params.to, "to", follows);
|
|
1413
|
+
const points = params.points;
|
|
1414
|
+
const isSpline = ["road", "river"].includes(layer.type);
|
|
1415
|
+
if (points) {
|
|
1416
|
+
if (!Array.isArray(points) || !points.length || points.length > 8192)
|
|
1417
|
+
fail("points must have 1..8192 entries");
|
|
1418
|
+
const dimensions = isSpline ? 3 : 2;
|
|
1419
|
+
for (const point of points)
|
|
1420
|
+
if (isSpline) splinePoint(point, "point", follows);
|
|
1421
|
+
else vec(point, "point", dimensions);
|
|
1422
|
+
}
|
|
1423
|
+
if (Array.isArray(points) && !isSpline) {
|
|
1424
|
+
const stroke = points;
|
|
1425
|
+
const spacing = Math.max(
|
|
1426
|
+
0.1,
|
|
1427
|
+
(params.radius ?? 30) * 2 * (params.spacing ?? 0.15)
|
|
1428
|
+
);
|
|
1429
|
+
let samples = 1;
|
|
1430
|
+
for (let k = 1; k < stroke.length; k += 1) {
|
|
1431
|
+
const next = stroke[k];
|
|
1432
|
+
const previous = stroke[k - 1];
|
|
1433
|
+
samples += Math.max(
|
|
1434
|
+
1,
|
|
1435
|
+
Math.ceil(
|
|
1436
|
+
Math.hypot(
|
|
1437
|
+
next[0] - previous[0],
|
|
1438
|
+
next[1] - previous[1]
|
|
1439
|
+
) / spacing
|
|
1440
|
+
)
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
if (samples > 65536)
|
|
1444
|
+
fail(
|
|
1445
|
+
"Brush interpolation exceeds 65536 samples; increase radius/spacing or shorten the stroke"
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (isSpline && (!Array.isArray(points) || points.length < 2))
|
|
1449
|
+
fail(`${layer.type} requires at least two [x,y,z] points`);
|
|
1450
|
+
if (layer.type === "ramp" && (!params.from || !params.to)) fail("ramp requires from and to");
|
|
1451
|
+
for (const key of ["radius", "size"]) {
|
|
1452
|
+
const value = params[key];
|
|
1453
|
+
if (value === void 0) continue;
|
|
1454
|
+
if (Array.isArray(value)) {
|
|
1455
|
+
vec(value, key, 2);
|
|
1456
|
+
for (const entry of value) num(entry, key, 1e-3, 1e6);
|
|
1457
|
+
} else num(value, key, 1e-3, 1e6);
|
|
1458
|
+
}
|
|
1459
|
+
if (Array.isArray(params.radius) && !["stamp", "paste"].includes(layer.type))
|
|
1460
|
+
fail("Brush radius must be one number");
|
|
1461
|
+
for (const key of [
|
|
1462
|
+
"amplitude",
|
|
1463
|
+
"height",
|
|
1464
|
+
"base",
|
|
1465
|
+
"offset",
|
|
1466
|
+
"offsetY",
|
|
1467
|
+
"minHeight",
|
|
1468
|
+
"maxHeight",
|
|
1469
|
+
"level",
|
|
1470
|
+
"rotation"
|
|
1471
|
+
])
|
|
1472
|
+
if (params[key] !== void 0 && !(key === "base" && layer.type === "materials")) {
|
|
1473
|
+
if (params[key] !== null || key !== "height") num(params[key], key);
|
|
1474
|
+
}
|
|
1475
|
+
for (const key of ["width", "step"])
|
|
1476
|
+
if (params[key] !== void 0) num(params[key], key, 1e-3, 1e6);
|
|
1477
|
+
for (const key of [
|
|
1478
|
+
"warp",
|
|
1479
|
+
"shoulder",
|
|
1480
|
+
"roughness",
|
|
1481
|
+
"coastDepth",
|
|
1482
|
+
"minDistance",
|
|
1483
|
+
"depth",
|
|
1484
|
+
"capacity",
|
|
1485
|
+
"maxCut",
|
|
1486
|
+
"maxFill"
|
|
1487
|
+
])
|
|
1488
|
+
if (params[key] !== void 0) num(params[key], key, 0, 1e6);
|
|
1489
|
+
for (const key of [
|
|
1490
|
+
"falloff",
|
|
1491
|
+
"jitter",
|
|
1492
|
+
"softness",
|
|
1493
|
+
"inertia",
|
|
1494
|
+
"erosion",
|
|
1495
|
+
"deposition",
|
|
1496
|
+
"evaporation",
|
|
1497
|
+
"rate",
|
|
1498
|
+
"value",
|
|
1499
|
+
"waterWidth",
|
|
1500
|
+
"maxGrade"
|
|
1501
|
+
])
|
|
1502
|
+
if (params[key] !== void 0) num(params[key], key, 0, 1);
|
|
1503
|
+
if (params.spacing !== void 0) num(params.spacing, "spacing", 0.01, 2);
|
|
1504
|
+
if (params.strength !== void 0)
|
|
1505
|
+
num(
|
|
1506
|
+
params.strength,
|
|
1507
|
+
"strength",
|
|
1508
|
+
layer.type === "sculpt" ? -1e4 : 0,
|
|
1509
|
+
layer.type === "sculpt" ? 1e4 : 1
|
|
1510
|
+
);
|
|
1511
|
+
if (params.scale !== void 0) {
|
|
1512
|
+
const scale = params.scale;
|
|
1513
|
+
if (layer.type === "scatter") {
|
|
1514
|
+
if (Array.isArray(scale)) {
|
|
1515
|
+
vec(scale, "scale", 2);
|
|
1516
|
+
if (scale[0] > scale[1]) fail("scale range is inverted");
|
|
1517
|
+
for (const entry of scale) num(entry, "scale", 1e-3, 1e4);
|
|
1518
|
+
} else num(scale, "scale", 1e-3, 1e4);
|
|
1519
|
+
} else num(scale, "scale", 1e-3, 1e6);
|
|
1520
|
+
}
|
|
1521
|
+
for (const [key, max] of [
|
|
1522
|
+
["octaves", 10],
|
|
1523
|
+
["iterations", 200],
|
|
1524
|
+
["droplets", 2e5],
|
|
1525
|
+
["maxSteps", 128],
|
|
1526
|
+
["count", 2e4]
|
|
1527
|
+
])
|
|
1528
|
+
if (params[key] !== void 0) num(params[key], key, key === "count" ? 0 : 1, max, true);
|
|
1529
|
+
if (params.persistence !== void 0) num(params.persistence, "persistence", 0.01, 1);
|
|
1530
|
+
if (params.lacunarity !== void 0) num(params.lacunarity, "lacunarity", 1, 4);
|
|
1531
|
+
for (const key of ["minSlope", "maxSlope", "talus"])
|
|
1532
|
+
if (params[key] !== void 0) num(params[key], key, 0, key === "talus" ? 89.9 : 90);
|
|
1533
|
+
if (params.minHeight !== void 0 && params.maxHeight !== void 0 && params.minHeight > params.maxHeight)
|
|
1534
|
+
fail("height range is inverted");
|
|
1535
|
+
if (params.minSlope !== void 0 && params.maxSlope !== void 0 && params.minSlope > params.maxSlope)
|
|
1536
|
+
fail("slope range is inverted");
|
|
1537
|
+
if (params.material !== void 0) enumeration(params.material, "material", MATERIAL_IDS);
|
|
1538
|
+
if (layer.type === "materials") {
|
|
1539
|
+
if (params.base !== void 0) enumeration(params.base, "base", MATERIAL_IDS);
|
|
1540
|
+
const rules = params.rules;
|
|
1541
|
+
if (!Array.isArray(rules) || rules.length > 64) fail("materials requires a rules array");
|
|
1542
|
+
for (const rule of rules) {
|
|
1543
|
+
enumeration(rule.material, "rule.material", MATERIAL_IDS);
|
|
1544
|
+
if (rule.mask) validateMask(rule.mask);
|
|
1545
|
+
if (rule.strength !== void 0) num(rule.strength, "rule.strength", 0, 1);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (params.method !== void 0) enumeration(params.method, "method", ["thermal", "hydraulic"]);
|
|
1549
|
+
if (params.mode !== void 0) enumeration(params.mode, "mode", ["fbm", "ridged", "billow"]);
|
|
1550
|
+
if (params.shape !== void 0)
|
|
1551
|
+
enumeration(
|
|
1552
|
+
params.shape,
|
|
1553
|
+
"shape",
|
|
1554
|
+
["stamp"].includes(layer.type) ? ["mountain", "crater", "ridge", "valley", "mesa", "dune"] : ["circle", "square"]
|
|
1555
|
+
);
|
|
1556
|
+
if (params.blend !== void 0)
|
|
1557
|
+
enumeration(
|
|
1558
|
+
params.blend,
|
|
1559
|
+
"blend",
|
|
1560
|
+
["noise", "heightmap"].includes(layer.type) ? ["add", "replace"] : ["add", "replace", "min", "max"]
|
|
1561
|
+
);
|
|
1562
|
+
if (layer.type === "clear")
|
|
1563
|
+
enumeration(params.target, "clear.target", ["scatter", "paint", "biome", "all"]);
|
|
1564
|
+
if (layer.type === "water") enumeration(params.kind ?? "lake", "water.kind", ["lake", "ocean"]);
|
|
1565
|
+
if (layer.type === "river" && params.enforceDownhill && Array.isArray(points)) {
|
|
1566
|
+
const river = points;
|
|
1567
|
+
for (let i = 1; i < river.length; i += 1) {
|
|
1568
|
+
const next = river[i];
|
|
1569
|
+
const previous = river[i - 1];
|
|
1570
|
+
if (next[1] > previous[1])
|
|
1571
|
+
fail("River control-point elevations must be non-increasing");
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (params.name !== void 0) identifier(params.name, "name");
|
|
1575
|
+
if (params.asset !== void 0) identifier(params.asset, "asset");
|
|
1576
|
+
if (["heightmap", "paste"].includes(layer.type) && !params.data)
|
|
1577
|
+
fail(`${layer.type} requires heightmap data`);
|
|
1578
|
+
const data = params.data;
|
|
1579
|
+
if (data) {
|
|
1580
|
+
num(data.width, "data.width", 2, 4097, true);
|
|
1581
|
+
num(data.height, "data.height", 2, 4097, true);
|
|
1582
|
+
if (!Array.isArray(data.values) || data.values.length !== data.width * data.height)
|
|
1583
|
+
fail("heightmap dimensions do not match values");
|
|
1584
|
+
for (const value of data.values)
|
|
1585
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1586
|
+
fail("data.values must contain only finite numbers");
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function validateDocument(doc) {
|
|
1590
|
+
finiteTree(
|
|
1591
|
+
Array.isArray(doc.layers) ? {
|
|
1592
|
+
...doc,
|
|
1593
|
+
layers: doc.layers.map((layer) => omittedElevations(layer, layerFollowsTerrain(layer)))
|
|
1594
|
+
} : doc,
|
|
1595
|
+
"recipe"
|
|
1596
|
+
);
|
|
1597
|
+
if (doc.version !== 1) fail("Unsupported recipe version; expected 1");
|
|
1598
|
+
validateConfig(doc.config);
|
|
1599
|
+
if (!Array.isArray(doc.layers) || doc.layers.length > 512)
|
|
1600
|
+
fail("layers must be an array of at most 512 layers");
|
|
1601
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1602
|
+
for (const layer of doc.layers) {
|
|
1603
|
+
validateLayer(layer);
|
|
1604
|
+
if (ids.has(layer.id)) fail(`Duplicate layer ID: ${layer.id}`);
|
|
1605
|
+
ids.add(layer.id);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/core/terrain.ts
|
|
1610
|
+
var TerrainEvaluator = class {
|
|
1611
|
+
cache = /* @__PURE__ */ new Map();
|
|
1612
|
+
bytes = 0;
|
|
1613
|
+
limit;
|
|
1614
|
+
constructor({ cacheMB = 48 } = {}) {
|
|
1615
|
+
if (!Number.isFinite(cacheMB) || cacheMB < 0)
|
|
1616
|
+
throw new RangeError("cacheMB must be finite and nonnegative");
|
|
1617
|
+
this.limit = cacheMB * 1024 * 1024;
|
|
1618
|
+
}
|
|
1619
|
+
clear() {
|
|
1620
|
+
this.cache.clear();
|
|
1621
|
+
this.bytes = 0;
|
|
1622
|
+
}
|
|
1623
|
+
evaluate(doc, { resolution = doc.config.resolution, signal, onProgress } = {}) {
|
|
1624
|
+
validateDocument(doc);
|
|
1625
|
+
validateConfig({ ...doc.config, resolution });
|
|
1626
|
+
const keys = [];
|
|
1627
|
+
let key = JSON.stringify({ ...doc.config, resolution });
|
|
1628
|
+
for (const layer of doc.layers) {
|
|
1629
|
+
key += `|${JSON.stringify(layer)}`;
|
|
1630
|
+
keys.push(key);
|
|
1631
|
+
}
|
|
1632
|
+
let state;
|
|
1633
|
+
let start = 0;
|
|
1634
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
1635
|
+
const k = keys[i];
|
|
1636
|
+
const hit = this.cache.get(k);
|
|
1637
|
+
if (hit) {
|
|
1638
|
+
state = structuredClone(hit.state);
|
|
1639
|
+
start = i + 1;
|
|
1640
|
+
this.cache.delete(k);
|
|
1641
|
+
this.cache.set(k, hit);
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
state ??= createState(doc.config, resolution);
|
|
1646
|
+
for (let i = start; i < doc.layers.length; i++) {
|
|
1647
|
+
signal?.throwIfAborted();
|
|
1648
|
+
const layer = doc.layers[i];
|
|
1649
|
+
applyOperation(state, layer);
|
|
1650
|
+
onProgress?.({ index: i, total: doc.layers.length, id: layer.id });
|
|
1651
|
+
const bytes = state.height.byteLength + state.splat.byteLength + Object.values(state.erosion ?? {}).reduce((n, a) => n + a.byteLength, 0) + Object.values(state.biomes).reduce((n, a) => n + a.byteLength, 0);
|
|
1652
|
+
if (bytes <= this.limit) {
|
|
1653
|
+
while (this.bytes + bytes > this.limit && this.cache.size) {
|
|
1654
|
+
const first = this.cache.entries().next().value;
|
|
1655
|
+
if (!first) break;
|
|
1656
|
+
this.bytes -= first[1].bytes;
|
|
1657
|
+
this.cache.delete(first[0]);
|
|
1658
|
+
}
|
|
1659
|
+
this.cache.set(keys[i], { state: structuredClone(state), bytes });
|
|
1660
|
+
this.bytes += bytes;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
signal?.throwIfAborted();
|
|
1664
|
+
if (!state.height.every(Number.isFinite))
|
|
1665
|
+
throw new Error("A terrain operation produced a non-finite height");
|
|
1666
|
+
return finalizeState(state);
|
|
1667
|
+
}
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
// src/core/bake.ts
|
|
1671
|
+
var toLinear = (color) => color <= 0.04045 ? color / 12.92 : ((color + 0.055) / 1.055) ** 2.4;
|
|
1672
|
+
function bakeMesh(state, {
|
|
1673
|
+
step = 1,
|
|
1674
|
+
x = 0,
|
|
1675
|
+
z = 0,
|
|
1676
|
+
cells = state.resolution - 1,
|
|
1677
|
+
skirtDepth = 0,
|
|
1678
|
+
palette
|
|
1679
|
+
} = {}) {
|
|
1680
|
+
if (!Number.isFinite(skirtDepth) || skirtDepth < 0)
|
|
1681
|
+
throw new RangeError("skirtDepth must be finite and nonnegative");
|
|
1682
|
+
const n = state.resolution;
|
|
1683
|
+
if (!Number.isInteger(n) || n < 2 || !Number.isFinite(state.size) || state.size <= 0)
|
|
1684
|
+
throw new RangeError("Invalid heightfield extent");
|
|
1685
|
+
if (!Number.isInteger(step) || step < 1 || (step & step - 1) !== 0 || cells % step !== 0)
|
|
1686
|
+
throw new RangeError("step must be a power of two dividing chunk cells");
|
|
1687
|
+
if (![x, z, cells].every(Number.isInteger) || x < 0 || z < 0 || cells < 1 || x + cells >= n || z + cells >= n)
|
|
1688
|
+
throw new RangeError("Chunk outside heightfield");
|
|
1689
|
+
if (state.height.length !== n * n || !state.height.every(Number.isFinite))
|
|
1690
|
+
throw new RangeError("Invalid heightfield samples");
|
|
1691
|
+
if (palette && (palette.length !== 8 || Array.from(palette).some(
|
|
1692
|
+
(rgb) => !Array.isArray(rgb) || rgb.length !== 3 || Array.from(rgb).some((value) => !Number.isFinite(value) || value < 0 || value > 1)
|
|
1693
|
+
)))
|
|
1694
|
+
throw new RangeError("palette requires eight finite sRGB triples in 0..1");
|
|
1695
|
+
if (palette && (state.splat.length !== n * n * 8 || !state.splat.every(Number.isFinite)))
|
|
1696
|
+
throw new RangeError("Invalid splat samples");
|
|
1697
|
+
const side = cells / step + 1;
|
|
1698
|
+
const base = side * side;
|
|
1699
|
+
const ring = [];
|
|
1700
|
+
if (skirtDepth > 0) {
|
|
1701
|
+
for (let k2 = 0; k2 < side - 1; k2++) ring.push(k2);
|
|
1702
|
+
for (let k2 = 0; k2 < side - 1; k2++) ring.push(k2 * side + side - 1);
|
|
1703
|
+
for (let k2 = side - 1; k2 > 0; k2--) ring.push((side - 1) * side + k2);
|
|
1704
|
+
for (let k2 = side - 1; k2 > 0; k2--) ring.push(k2 * side);
|
|
1705
|
+
}
|
|
1706
|
+
const count = base + ring.length;
|
|
1707
|
+
const positions = new Float32Array(count * 3);
|
|
1708
|
+
const normals = new Float32Array(count * 3);
|
|
1709
|
+
const uvs = new Float32Array(count * 2);
|
|
1710
|
+
const indices = new Uint32Array((side - 1) ** 2 * 6 + ring.length * 6);
|
|
1711
|
+
const colors = palette ? new Float32Array(count * 3) : void 0;
|
|
1712
|
+
const cell = state.size / (n - 1);
|
|
1713
|
+
for (let zz = 0; zz < side; zz++)
|
|
1714
|
+
for (let xx = 0; xx < side; xx++) {
|
|
1715
|
+
const gx = x + xx * step;
|
|
1716
|
+
const gz = z + zz * step;
|
|
1717
|
+
const i = gz * n + gx;
|
|
1718
|
+
const j = zz * side + xx;
|
|
1719
|
+
const dx = (state.height[gz * n + Math.min(n - 1, gx + 1)] - state.height[gz * n + Math.max(0, gx - 1)]) / ((gx > 0 && gx < n - 1 ? 2 : 1) * cell);
|
|
1720
|
+
const dz = (state.height[Math.min(n - 1, gz + 1) * n + gx] - state.height[Math.max(0, gz - 1) * n + gx]) / ((gz > 0 && gz < n - 1 ? 2 : 1) * cell);
|
|
1721
|
+
const length = Math.hypot(dx, 1, dz);
|
|
1722
|
+
positions.set([world(state, gx), state.height[i], world(state, gz)], j * 3);
|
|
1723
|
+
normals.set([-dx / length, 1 / length, -dz / length], j * 3);
|
|
1724
|
+
if (colors && palette)
|
|
1725
|
+
for (let k2 = 0; k2 < 3; k2++) {
|
|
1726
|
+
let color = 0;
|
|
1727
|
+
for (let c = 0; c < 8; c++)
|
|
1728
|
+
color += toLinear(palette[c]?.[k2]) * state.splat[i * 8 + c];
|
|
1729
|
+
colors[j * 3 + k2] = color;
|
|
1730
|
+
}
|
|
1731
|
+
uvs.set([gx / (n - 1), 1 - gz / (n - 1)], j * 2);
|
|
1732
|
+
}
|
|
1733
|
+
let k = 0;
|
|
1734
|
+
for (let zz = 0; zz < side - 1; zz++)
|
|
1735
|
+
for (let xx = 0; xx < side - 1; xx++) {
|
|
1736
|
+
const a = zz * side + xx;
|
|
1737
|
+
const b = a + 1;
|
|
1738
|
+
const c = a + side;
|
|
1739
|
+
const d = c + 1;
|
|
1740
|
+
indices.set([a, c, b, b, c, d], k);
|
|
1741
|
+
k += 6;
|
|
1742
|
+
}
|
|
1743
|
+
for (let j = 0; j < ring.length; j++) {
|
|
1744
|
+
const top = ring[j];
|
|
1745
|
+
const bottom = base + j;
|
|
1746
|
+
positions.set(positions.subarray(top * 3, top * 3 + 3), bottom * 3);
|
|
1747
|
+
positions[bottom * 3 + 1] = positions[bottom * 3 + 1] - skirtDepth;
|
|
1748
|
+
normals.set(normals.subarray(top * 3, top * 3 + 3), bottom * 3);
|
|
1749
|
+
colors?.set(colors.subarray(top * 3, top * 3 + 3), bottom * 3);
|
|
1750
|
+
uvs.set(uvs.subarray(top * 2, top * 2 + 2), bottom * 2);
|
|
1751
|
+
const next = (j + 1) % ring.length;
|
|
1752
|
+
indices.set([top, ring[next], bottom, ring[next], base + next, bottom], k);
|
|
1753
|
+
k += 6;
|
|
1754
|
+
}
|
|
1755
|
+
return {
|
|
1756
|
+
name: `terrain_${x}_${z}_step${step}`,
|
|
1757
|
+
positions,
|
|
1758
|
+
normals,
|
|
1759
|
+
colors,
|
|
1760
|
+
uvs,
|
|
1761
|
+
indices,
|
|
1762
|
+
side,
|
|
1763
|
+
topVertexCount: base,
|
|
1764
|
+
bounds: { x, z, cells, step },
|
|
1765
|
+
skirtDepth
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
function bakeTerrain(state, {
|
|
1769
|
+
chunkCells = Math.min(64, state.resolution - 1),
|
|
1770
|
+
lodSteps = [1, 2, 4],
|
|
1771
|
+
skirtDepth = 6,
|
|
1772
|
+
palette
|
|
1773
|
+
} = {}) {
|
|
1774
|
+
if (!Number.isInteger(chunkCells) || chunkCells < 1 || (state.resolution - 1) % chunkCells !== 0)
|
|
1775
|
+
throw new RangeError("chunkCells must divide resolution - 1");
|
|
1776
|
+
if (!lodSteps.length) throw new RangeError("lodSteps must not be empty");
|
|
1777
|
+
const lods = lodSteps.map((step) => {
|
|
1778
|
+
const chunks = [];
|
|
1779
|
+
for (let z = 0; z < state.resolution - 1; z += chunkCells)
|
|
1780
|
+
for (let x = 0; x < state.resolution - 1; x += chunkCells)
|
|
1781
|
+
chunks.push(bakeMesh(state, { x, z, cells: chunkCells, step, skirtDepth, palette }));
|
|
1782
|
+
return { step, chunks };
|
|
1783
|
+
});
|
|
1784
|
+
return {
|
|
1785
|
+
version: 1,
|
|
1786
|
+
size: state.size,
|
|
1787
|
+
resolution: state.resolution,
|
|
1788
|
+
lods,
|
|
1789
|
+
collision: {
|
|
1790
|
+
type: "heightfield",
|
|
1791
|
+
size: state.size,
|
|
1792
|
+
resolution: state.resolution,
|
|
1793
|
+
cellSize: state.size / (state.resolution - 1),
|
|
1794
|
+
origin: [-state.size / 2, 0, -state.size / 2],
|
|
1795
|
+
heights: state.height.slice()
|
|
1796
|
+
},
|
|
1797
|
+
instances: structuredClone(state.instances),
|
|
1798
|
+
materialChannels: [...MATERIAL_IDS]
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
function validateBakedMesh(mesh) {
|
|
1802
|
+
if (![mesh.positions, mesh.normals, mesh.uvs, ...mesh.colors ? [mesh.colors] : []].every(
|
|
1803
|
+
(array) => array instanceof Float32Array
|
|
1804
|
+
) || !(mesh.indices instanceof Uint32Array))
|
|
1805
|
+
throw new TypeError("Baked mesh requires Float32 attributes and Uint32 indices");
|
|
1806
|
+
const count = mesh.positions.length / 3;
|
|
1807
|
+
if (!count || !Number.isInteger(count) || mesh.normals.length !== count * 3 || mesh.uvs.length !== count * 2 || mesh.colors && mesh.colors.length !== count * 3 || !mesh.indices.length || mesh.indices.length % 3)
|
|
1808
|
+
throw new RangeError("Baked mesh attribute counts do not agree");
|
|
1809
|
+
for (const array of [
|
|
1810
|
+
mesh.positions,
|
|
1811
|
+
mesh.normals,
|
|
1812
|
+
mesh.uvs,
|
|
1813
|
+
...mesh.colors ? [mesh.colors] : []
|
|
1814
|
+
])
|
|
1815
|
+
if (!array.every(Number.isFinite)) throw new RangeError("Baked mesh attributes must be finite");
|
|
1816
|
+
if (mesh.indices.some((index) => index >= count))
|
|
1817
|
+
throw new RangeError("Baked mesh index outside vertex range");
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/core/io.ts
|
|
1821
|
+
var encoder = new TextEncoder();
|
|
1822
|
+
new TextDecoder();
|
|
1823
|
+
var crcTable = Uint32Array.from({ length: 256 }, (_, initial) => {
|
|
1824
|
+
let n = initial;
|
|
1825
|
+
for (let k = 0; k < 8; k++) n = n & 1 ? 3988292384 ^ n >>> 1 : n >>> 1;
|
|
1826
|
+
return n >>> 0;
|
|
1827
|
+
});
|
|
1828
|
+
var crc32 = (bytes) => {
|
|
1829
|
+
let c = 4294967295;
|
|
1830
|
+
for (const b of bytes) c = crcTable[(c ^ b) & 255] ^ c >>> 8;
|
|
1831
|
+
return (c ^ 4294967295) >>> 0;
|
|
1832
|
+
};
|
|
1833
|
+
var join = (arrays) => {
|
|
1834
|
+
const out = new Uint8Array(arrays.reduce((n, a) => n + a.length, 0));
|
|
1835
|
+
let offset = 0;
|
|
1836
|
+
for (const a of arrays) {
|
|
1837
|
+
out.set(a, offset);
|
|
1838
|
+
offset += a.length;
|
|
1839
|
+
}
|
|
1840
|
+
return out;
|
|
1841
|
+
};
|
|
1842
|
+
function heightRange(values, { min, max } = {}) {
|
|
1843
|
+
for (const v of values) if (!Number.isFinite(v)) throw TypeError("Height data must be finite");
|
|
1844
|
+
if (min === void 0 || max === void 0) {
|
|
1845
|
+
let lo = Number.POSITIVE_INFINITY;
|
|
1846
|
+
let hi = Number.NEGATIVE_INFINITY;
|
|
1847
|
+
for (const v of values) {
|
|
1848
|
+
if (!Number.isFinite(v)) throw TypeError("Height data must be finite");
|
|
1849
|
+
lo = Math.min(lo, v);
|
|
1850
|
+
hi = Math.max(hi, v);
|
|
1851
|
+
}
|
|
1852
|
+
min ??= lo;
|
|
1853
|
+
max ??= hi > lo ? hi : lo + 1;
|
|
1854
|
+
}
|
|
1855
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
|
|
1856
|
+
throw RangeError("Height range must be finite and max > min");
|
|
1857
|
+
return { min, max };
|
|
1858
|
+
}
|
|
1859
|
+
function encodeRAW16(values, options = {}) {
|
|
1860
|
+
const { min, max } = heightRange(values, options);
|
|
1861
|
+
const bytes = new Uint8Array(values.length * 2);
|
|
1862
|
+
const view = new DataView(bytes.buffer);
|
|
1863
|
+
for (let i = 0; i < values.length; i++)
|
|
1864
|
+
view.setUint16(
|
|
1865
|
+
i * 2,
|
|
1866
|
+
Math.round(clamp((values[i] - min) / (max - min)) * 65535),
|
|
1867
|
+
options.littleEndian !== false
|
|
1868
|
+
);
|
|
1869
|
+
return bytes;
|
|
1870
|
+
}
|
|
1871
|
+
function chunk(type, content) {
|
|
1872
|
+
const bytes = new Uint8Array(content.length + 12);
|
|
1873
|
+
const view = new DataView(bytes.buffer);
|
|
1874
|
+
view.setUint32(0, content.length);
|
|
1875
|
+
bytes.set(encoder.encode(type), 4);
|
|
1876
|
+
bytes.set(content, 8);
|
|
1877
|
+
view.setUint32(bytes.length - 4, crc32(bytes.subarray(4, bytes.length - 4)));
|
|
1878
|
+
return bytes;
|
|
1879
|
+
}
|
|
1880
|
+
async function compress(bytes) {
|
|
1881
|
+
if (typeof CompressionStream === "undefined")
|
|
1882
|
+
throw Error("PNG export requires CompressionStream (modern browser or Node 22+)");
|
|
1883
|
+
return new Uint8Array(
|
|
1884
|
+
await new Response(
|
|
1885
|
+
new Blob([new Uint8Array(bytes)]).stream().pipeThrough(new CompressionStream("deflate"))
|
|
1886
|
+
).arrayBuffer()
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
async function encodePNG(width, height, bytes, bitDepth, colorType, metadata) {
|
|
1890
|
+
const channels = colorType === 6 ? 4 : 1;
|
|
1891
|
+
const rowBytes = width * channels * (bitDepth / 8);
|
|
1892
|
+
if (bytes.length !== rowBytes * height) throw RangeError("PNG pixel byte count mismatch");
|
|
1893
|
+
const raw = new Uint8Array((rowBytes + 1) * height);
|
|
1894
|
+
for (let y = 0; y < height; y++)
|
|
1895
|
+
raw.set(bytes.subarray(y * rowBytes, (y + 1) * rowBytes), y * (rowBytes + 1) + 1);
|
|
1896
|
+
const ihdr = new Uint8Array(13);
|
|
1897
|
+
const v = new DataView(ihdr.buffer);
|
|
1898
|
+
v.setUint32(0, width);
|
|
1899
|
+
v.setUint32(4, height);
|
|
1900
|
+
ihdr[8] = bitDepth;
|
|
1901
|
+
ihdr[9] = colorType;
|
|
1902
|
+
const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr)];
|
|
1903
|
+
if (metadata)
|
|
1904
|
+
parts.push(chunk("tEXt", encoder.encode(`StrataHeightRange\0${JSON.stringify(metadata)}`)));
|
|
1905
|
+
parts.push(chunk("IDAT", await compress(raw)), chunk("IEND", new Uint8Array()));
|
|
1906
|
+
return join(parts);
|
|
1907
|
+
}
|
|
1908
|
+
async function encodeHeightPNG(state, options = {}) {
|
|
1909
|
+
const values = state.height;
|
|
1910
|
+
const range2 = heightRange(values, options);
|
|
1911
|
+
const bytes = encodeRAW16(values, { ...range2, littleEndian: false });
|
|
1912
|
+
return encodePNG(state.resolution, state.resolution, bytes, 16, 0, range2);
|
|
1913
|
+
}
|
|
1914
|
+
async function encodeSplatPNGs(state) {
|
|
1915
|
+
const maps = [];
|
|
1916
|
+
for (let pack = 0; pack < 2; pack++) {
|
|
1917
|
+
const bytes = new Uint8Array(state.height.length * 4);
|
|
1918
|
+
for (let i = 0; i < state.height.length; i++)
|
|
1919
|
+
for (let c = 0; c < 4; c++)
|
|
1920
|
+
bytes[i * 4 + c] = Math.round(clamp(state.splat[i * 8 + pack * 4 + c]) * 255);
|
|
1921
|
+
maps.push({
|
|
1922
|
+
channels: MATERIAL_IDS.slice(pack * 4, pack * 4 + 4),
|
|
1923
|
+
bytes: await encodePNG(state.resolution, state.resolution, bytes, 8, 6)
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
return maps;
|
|
1927
|
+
}
|
|
1928
|
+
function encodeGLB(input) {
|
|
1929
|
+
const meshes = Array.isArray(input) ? input : [input];
|
|
1930
|
+
if (!meshes.length) throw new RangeError("GLB requires at least one baked mesh");
|
|
1931
|
+
for (const mesh of meshes) validateBakedMesh(mesh);
|
|
1932
|
+
const chunks = [];
|
|
1933
|
+
const bufferViews = [];
|
|
1934
|
+
const accessors = [];
|
|
1935
|
+
const gltfMeshes = [];
|
|
1936
|
+
let offset = 0;
|
|
1937
|
+
const add = (array, type, componentType, target, minMax = false) => {
|
|
1938
|
+
const aligned = offset + 3 & -4;
|
|
1939
|
+
if (aligned > offset) {
|
|
1940
|
+
chunks.push(new Uint8Array(aligned - offset));
|
|
1941
|
+
offset = aligned;
|
|
1942
|
+
}
|
|
1943
|
+
const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength).slice();
|
|
1944
|
+
const view = bufferViews.length;
|
|
1945
|
+
bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: bytes.length, target });
|
|
1946
|
+
chunks.push(bytes);
|
|
1947
|
+
offset += bytes.length;
|
|
1948
|
+
const size = type === "VEC3" ? 3 : type === "VEC2" ? 2 : 1;
|
|
1949
|
+
const accessor = {
|
|
1950
|
+
bufferView: view,
|
|
1951
|
+
componentType,
|
|
1952
|
+
count: array.length / size,
|
|
1953
|
+
type
|
|
1954
|
+
};
|
|
1955
|
+
if (minMax) {
|
|
1956
|
+
accessor.min = Array(size).fill(Number.POSITIVE_INFINITY);
|
|
1957
|
+
accessor.max = Array(size).fill(Number.NEGATIVE_INFINITY);
|
|
1958
|
+
for (let i = 0; i < array.length; i++) {
|
|
1959
|
+
const c = i % size;
|
|
1960
|
+
accessor.min[c] = Math.min(accessor.min[c], array[i]);
|
|
1961
|
+
accessor.max[c] = Math.max(accessor.max[c], array[i]);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
accessors.push(accessor);
|
|
1965
|
+
return accessors.length - 1;
|
|
1966
|
+
};
|
|
1967
|
+
for (const m of meshes) {
|
|
1968
|
+
const attributes = {
|
|
1969
|
+
POSITION: add(m.positions, "VEC3", 5126, 34962, true),
|
|
1970
|
+
NORMAL: add(m.normals, "VEC3", 5126, 34962),
|
|
1971
|
+
TEXCOORD_0: add(m.uvs, "VEC2", 5126, 34962)
|
|
1972
|
+
};
|
|
1973
|
+
if (m.colors) attributes.COLOR_0 = add(m.colors, "VEC3", 5126, 34962);
|
|
1974
|
+
gltfMeshes.push({
|
|
1975
|
+
name: m.name ?? "Terrain",
|
|
1976
|
+
primitives: [{ attributes, indices: add(m.indices, "SCALAR", 5125, 34963), mode: 4 }]
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
const binary = join(chunks);
|
|
1980
|
+
const doc = {
|
|
1981
|
+
asset: { version: "2.0", generator: "Strata Terrain 0.1.0" },
|
|
1982
|
+
scene: 0,
|
|
1983
|
+
scenes: [{ nodes: meshes.map((_, i) => i) }],
|
|
1984
|
+
nodes: meshes.map((m, i) => ({ mesh: i, name: m.name ?? `Terrain ${i}` })),
|
|
1985
|
+
meshes: gltfMeshes,
|
|
1986
|
+
buffers: [{ byteLength: binary.length }],
|
|
1987
|
+
bufferViews,
|
|
1988
|
+
accessors
|
|
1989
|
+
};
|
|
1990
|
+
const json2 = encoder.encode(JSON.stringify(doc));
|
|
1991
|
+
const jsonLength = json2.length + 3 & -4;
|
|
1992
|
+
const binLength = binary.length + 3 & -4;
|
|
1993
|
+
const out = new Uint8Array(12 + 8 + jsonLength + 8 + binLength);
|
|
1994
|
+
const v = new DataView(out.buffer);
|
|
1995
|
+
v.setUint32(0, 1179937895, true);
|
|
1996
|
+
v.setUint32(4, 2, true);
|
|
1997
|
+
v.setUint32(8, out.length, true);
|
|
1998
|
+
v.setUint32(12, jsonLength, true);
|
|
1999
|
+
v.setUint32(16, 1313821514, true);
|
|
2000
|
+
out.fill(32, 20, 20 + jsonLength);
|
|
2001
|
+
out.set(json2, 20);
|
|
2002
|
+
const start = 20 + jsonLength;
|
|
2003
|
+
v.setUint32(start, binLength, true);
|
|
2004
|
+
v.setUint32(start + 4, 5130562, true);
|
|
2005
|
+
out.set(binary, start + 8);
|
|
2006
|
+
return out;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// src/core/export.ts
|
|
2010
|
+
var enc = new TextEncoder();
|
|
2011
|
+
var table = Uint32Array.from({ length: 256 }, (_, initial) => {
|
|
2012
|
+
let n = initial;
|
|
2013
|
+
for (let k = 0; k < 8; k++) n = n & 1 ? 3988292384 ^ n >>> 1 : n >>> 1;
|
|
2014
|
+
return n >>> 0;
|
|
2015
|
+
});
|
|
2016
|
+
function crc322(b) {
|
|
2017
|
+
let c = 4294967295;
|
|
2018
|
+
for (const v of b) c = table[(c ^ v) & 255] ^ c >>> 8;
|
|
2019
|
+
return (c ^ 4294967295) >>> 0;
|
|
2020
|
+
}
|
|
2021
|
+
function concat(parts) {
|
|
2022
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
2023
|
+
let o = 0;
|
|
2024
|
+
for (const p of parts) {
|
|
2025
|
+
out.set(p, o);
|
|
2026
|
+
o += p.length;
|
|
2027
|
+
}
|
|
2028
|
+
return out;
|
|
2029
|
+
}
|
|
2030
|
+
function encodeZIP(files) {
|
|
2031
|
+
if (files.length > 65535) throw RangeError("Too many ZIP entries");
|
|
2032
|
+
const parts = [];
|
|
2033
|
+
const central = [];
|
|
2034
|
+
const names = /* @__PURE__ */ new Set();
|
|
2035
|
+
let offset = 0;
|
|
2036
|
+
for (const { name, bytes } of files) {
|
|
2037
|
+
if (!/^[a-zA-Z0-9_.\/-]+$/.test(name) || name.startsWith("/") || name.split("/").includes("..") || names.has(name))
|
|
2038
|
+
throw Error("Unsafe or duplicate ZIP filename");
|
|
2039
|
+
if (!(bytes instanceof Uint8Array)) throw TypeError("ZIP bytes must be Uint8Array");
|
|
2040
|
+
names.add(name);
|
|
2041
|
+
const nb = enc.encode(name);
|
|
2042
|
+
const crc = crc322(bytes);
|
|
2043
|
+
const h = new Uint8Array(30 + nb.length);
|
|
2044
|
+
const v = new DataView(h.buffer);
|
|
2045
|
+
v.setUint32(0, 67324752, true);
|
|
2046
|
+
v.setUint16(4, 20, true);
|
|
2047
|
+
v.setUint16(12, 33, true);
|
|
2048
|
+
v.setUint32(14, crc, true);
|
|
2049
|
+
v.setUint32(18, bytes.length, true);
|
|
2050
|
+
v.setUint32(22, bytes.length, true);
|
|
2051
|
+
v.setUint16(26, nb.length, true);
|
|
2052
|
+
h.set(nb, 30);
|
|
2053
|
+
parts.push(h, bytes);
|
|
2054
|
+
const c = new Uint8Array(46 + nb.length);
|
|
2055
|
+
const cv = new DataView(c.buffer);
|
|
2056
|
+
cv.setUint32(0, 33639248, true);
|
|
2057
|
+
cv.setUint16(4, 20, true);
|
|
2058
|
+
cv.setUint16(6, 20, true);
|
|
2059
|
+
cv.setUint16(14, 33, true);
|
|
2060
|
+
cv.setUint32(16, crc, true);
|
|
2061
|
+
cv.setUint32(20, bytes.length, true);
|
|
2062
|
+
cv.setUint32(24, bytes.length, true);
|
|
2063
|
+
cv.setUint16(28, nb.length, true);
|
|
2064
|
+
cv.setUint32(42, offset, true);
|
|
2065
|
+
c.set(nb, 46);
|
|
2066
|
+
central.push(c);
|
|
2067
|
+
offset += h.length + bytes.length;
|
|
2068
|
+
if (offset > 4294967295) throw RangeError("ZIP64 is not supported");
|
|
2069
|
+
}
|
|
2070
|
+
const directory = concat(central);
|
|
2071
|
+
const end = new Uint8Array(22);
|
|
2072
|
+
const e = new DataView(end.buffer);
|
|
2073
|
+
e.setUint32(0, 101010256, true);
|
|
2074
|
+
e.setUint16(8, files.length, true);
|
|
2075
|
+
e.setUint16(10, files.length, true);
|
|
2076
|
+
e.setUint32(12, directory.length, true);
|
|
2077
|
+
e.setUint32(16, offset, true);
|
|
2078
|
+
return concat([...parts, directory, end]);
|
|
2079
|
+
}
|
|
2080
|
+
var json = (name, data) => ({
|
|
2081
|
+
name,
|
|
2082
|
+
bytes: enc.encode(JSON.stringify(data, null, 2))
|
|
2083
|
+
});
|
|
2084
|
+
async function makeExport(state, recipe, kind) {
|
|
2085
|
+
const result = (name, type, bytes) => ({
|
|
2086
|
+
name,
|
|
2087
|
+
type,
|
|
2088
|
+
bytes
|
|
2089
|
+
});
|
|
2090
|
+
if (kind === "project")
|
|
2091
|
+
return result("terrain-recipe.json", "application/json", json("", recipe).bytes);
|
|
2092
|
+
if (kind === "png")
|
|
2093
|
+
return result("terrain-height16.png", "image/png", await encodeHeightPNG(state));
|
|
2094
|
+
if (kind === "glb") return result("terrain.glb", "model/gltf-binary", encodeGLB(bakeMesh(state)));
|
|
2095
|
+
let min = Number.POSITIVE_INFINITY;
|
|
2096
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
2097
|
+
for (const h of state.height) {
|
|
2098
|
+
min = Math.min(min, h);
|
|
2099
|
+
max = Math.max(max, h);
|
|
2100
|
+
}
|
|
2101
|
+
if (max === min) max = min + 1;
|
|
2102
|
+
const range2 = {
|
|
2103
|
+
resolution: state.resolution,
|
|
2104
|
+
size: state.size,
|
|
2105
|
+
min,
|
|
2106
|
+
max,
|
|
2107
|
+
littleEndian: true,
|
|
2108
|
+
units: "metres",
|
|
2109
|
+
rowOrder: "z increases from -size/2 to +size/2",
|
|
2110
|
+
columnOrder: "x increases from -size/2 to +size/2"
|
|
2111
|
+
};
|
|
2112
|
+
if (kind === "raw")
|
|
2113
|
+
return result(
|
|
2114
|
+
"terrain-raw16.zip",
|
|
2115
|
+
"application/zip",
|
|
2116
|
+
encodeZIP([
|
|
2117
|
+
{ name: "height.raw", bytes: encodeRAW16(state.height, { min, max }) },
|
|
2118
|
+
json("heightfield.json", range2)
|
|
2119
|
+
])
|
|
2120
|
+
);
|
|
2121
|
+
const splats = await encodeSplatPNGs(state);
|
|
2122
|
+
const splatFiles = splats.map((s, i) => ({ name: `splat-${i}.png`, bytes: s.bytes }));
|
|
2123
|
+
if (kind === "splat")
|
|
2124
|
+
return result(
|
|
2125
|
+
"terrain-splatmaps.zip",
|
|
2126
|
+
"application/zip",
|
|
2127
|
+
encodeZIP([
|
|
2128
|
+
...splatFiles,
|
|
2129
|
+
json(
|
|
2130
|
+
"channels.json",
|
|
2131
|
+
splats.map((s, i) => ({ file: `splat-${i}.png`, rgba: s.channels }))
|
|
2132
|
+
)
|
|
2133
|
+
])
|
|
2134
|
+
);
|
|
2135
|
+
if (kind !== "runtime") throw Error(`Unknown export '${kind}'`);
|
|
2136
|
+
const baked = bakeTerrain(state);
|
|
2137
|
+
const collision = new Uint8Array(state.height.length * 4);
|
|
2138
|
+
const dv = new DataView(collision.buffer);
|
|
2139
|
+
state.height.forEach((h, i) => dv.setFloat32(i * 4, h, true));
|
|
2140
|
+
const manifest = {
|
|
2141
|
+
version: 1,
|
|
2142
|
+
size: state.size,
|
|
2143
|
+
resolution: state.resolution,
|
|
2144
|
+
units: "metres",
|
|
2145
|
+
axis: "Y up",
|
|
2146
|
+
lods: baked.lods.map((l) => ({
|
|
2147
|
+
step: l.step,
|
|
2148
|
+
file: `lod-${l.step}.glb`,
|
|
2149
|
+
chunks: l.chunks.map((c) => ({ name: c.name, ...c.bounds, skirtDepth: c.skirtDepth }))
|
|
2150
|
+
})),
|
|
2151
|
+
collision: {
|
|
2152
|
+
format: "float32-le",
|
|
2153
|
+
file: "collision.f32le",
|
|
2154
|
+
resolution: state.resolution,
|
|
2155
|
+
cellSize: baked.collision.cellSize,
|
|
2156
|
+
origin: baked.collision.origin
|
|
2157
|
+
},
|
|
2158
|
+
heightRange: range2,
|
|
2159
|
+
splats: splats.map((s, i) => ({ file: `splat-${i}.png`, rgba: s.channels })),
|
|
2160
|
+
placements: "placements.json",
|
|
2161
|
+
water: "water.json",
|
|
2162
|
+
limitations: [
|
|
2163
|
+
"Static LODs; runtime selection and streaming not provided",
|
|
2164
|
+
"GLBs contain terrain only; supply assets for placement IDs",
|
|
2165
|
+
"No navigation mesh"
|
|
2166
|
+
]
|
|
2167
|
+
};
|
|
2168
|
+
const water = state.waters.map((w, i) => {
|
|
2169
|
+
const { mask, ...rest } = w;
|
|
2170
|
+
return { ...rest, maskFile: `water-${i}.mask8` };
|
|
2171
|
+
});
|
|
2172
|
+
const files = [
|
|
2173
|
+
json("manifest.json", manifest),
|
|
2174
|
+
json("recipe.json", recipe),
|
|
2175
|
+
json("placements.json", baked.instances),
|
|
2176
|
+
json("water.json", { bodies: water, rivers: state.rivers }),
|
|
2177
|
+
{ name: "collision.f32le", bytes: collision },
|
|
2178
|
+
{ name: "height16.png", bytes: await encodeHeightPNG(state) },
|
|
2179
|
+
...splatFiles,
|
|
2180
|
+
...baked.lods.map((l) => ({ name: `lod-${l.step}.glb`, bytes: encodeGLB(l.chunks) }))
|
|
2181
|
+
];
|
|
2182
|
+
for (let i = 0; i < water.length; i++)
|
|
2183
|
+
files.push({
|
|
2184
|
+
name: water[i]?.maskFile,
|
|
2185
|
+
bytes: new Uint8Array(state.waters[i]?.mask ?? [])
|
|
2186
|
+
});
|
|
2187
|
+
return result("terrain-runtime.zip", "application/zip", encodeZIP(files));
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// src/editor/worker.js
|
|
2191
|
+
var evaluator = new TerrainEvaluator({ cacheMB: 48 });
|
|
2192
|
+
self.onmessage = async ({ data }) => {
|
|
2193
|
+
const { id, type, recipe, resolution, kind } = data;
|
|
2194
|
+
try {
|
|
2195
|
+
validateDocument(recipe);
|
|
2196
|
+
const start = performance.now();
|
|
2197
|
+
const state = evaluator.evaluate(recipe, {
|
|
2198
|
+
resolution,
|
|
2199
|
+
onProgress: (p) => self.postMessage({ id, type: "progress", progress: p })
|
|
2200
|
+
});
|
|
2201
|
+
if (type === "export") {
|
|
2202
|
+
const output = await makeExport(state, recipe, kind);
|
|
2203
|
+
self.postMessage({ id, type: "exported", output }, [output.bytes.buffer]);
|
|
2204
|
+
} else self.postMessage({ id, type: "evaluated", state, ms: performance.now() - start });
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
self.postMessage({
|
|
2207
|
+
id,
|
|
2208
|
+
type: "error",
|
|
2209
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
};
|