@call-me-sensei/toonlab 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +131 -0
- package/README.md +73 -0
- package/package.json +3 -2
- package/src/debrisgen/debrisPresets.js +7 -0
- package/src/environment/environmentPresets.js +19 -0
- package/src/environment/environmentSettings.js +10 -0
- package/src/environment/environmentTextureResolver.js +3 -0
- package/src/environment/index.js +1 -0
- package/src/index.js +7 -0
- package/src/post/postProcessing.js +28 -0
- package/src/rockgen/rockgenPresets.js +8 -0
- package/src/rockgen/rockgenSettings.js +47 -0
- package/src/shaders-tsl/environment.js +60 -0
- package/src/shaders-tsl/water.js +54 -1
- package/src/sky/stylizedSky.js +49 -1
- package/src/stylizedTerrain.js +601 -0
- package/src/stylizedWorld.js +500 -0
- package/src/vegetation/index.js +2 -0
- package/src/vegetation/scatter.js +297 -0
- package/src/vegetation/stylizedFlowers.js +48 -1
- package/src/vegetation/stylizedForest.js +448 -0
- package/src/vegetation/stylizedGrass.js +50 -2
- package/src/vegetation/stylizedTree.js +71 -2
- package/src/vegetation/stylizedTreeFoliage.js +11 -2
- package/src/water/waterScenePasses.js +5 -1
- package/src/water/waterSettings.js +10 -0
- package/src/water/waterSurface.js +27 -0
- package/src/worldCollision.js +94 -0
- package/src/worldMinimap.js +152 -0
- package/src/worldPresets.js +122 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
|
|
3
|
+
// Seeded stylized-terrain generator. Import from '@call-me-sensei/toonlab'.
|
|
4
|
+
//
|
|
5
|
+
// The randomization contract: ANY seed yields a valid, playable world.
|
|
6
|
+
// Nothing in here is tuned to one seed — the pieces that were hand-picked
|
|
7
|
+
// in early demos are all derived at generation time:
|
|
8
|
+
//
|
|
9
|
+
// - The waterline is solved from the height histogram: `waterCoverage:
|
|
10
|
+
// 0.35` puts 35% of the playable area under water for any seed, instead
|
|
11
|
+
// of tuning noise amplitudes against a fixed water level.
|
|
12
|
+
// - Paint bands (rock-by-height, snow line, golden fields) are fractions
|
|
13
|
+
// of the observed relief, not absolute meters.
|
|
14
|
+
// - The spawn is found by probing (walkable slope, near a shore, open
|
|
15
|
+
// sightlines), deterministically per seed, then the meadow around it is
|
|
16
|
+
// flattened.
|
|
17
|
+
// - Degenerate seeds (no acceptable spawn) re-roll a derived sub-seed;
|
|
18
|
+
// the same input seed always resolves to the same world.
|
|
19
|
+
//
|
|
20
|
+
// Dimension knobs:
|
|
21
|
+
// - `size` — playable extent in meters: a number (square) or `{ x, z }`
|
|
22
|
+
// (rectangular). The mesh extends to 2× into a mountain rim.
|
|
23
|
+
// - `height` (H) — mountain-range amplitude override in meters.
|
|
24
|
+
// - `depth` (D) — how far basins dip below the ground datum, in meters
|
|
25
|
+
// (deep lakes / room under the waterline). True underground structure
|
|
26
|
+
// (caves, overhangs) is beyond a heightfield — that needs volumetric
|
|
27
|
+
// terrain and is tracked separately.
|
|
28
|
+
// - `floatingIslands` — `true` or `{ count, minAltitude, maxAltitude,
|
|
29
|
+
// minRadius, maxRadius }`: seeded sky islands (meadow tops, tapered
|
|
30
|
+
// rock undersides), returned in `islands`; each carries
|
|
31
|
+
// `topAt(worldX, worldZ)` so hosts can stand trees or characters on it.
|
|
32
|
+
// - `sinkholes` — `true` or `{ count, minRadius, maxRadius, depth }`:
|
|
33
|
+
// seeded karst dolines, sheer-walled pits carved into the land; one cut
|
|
34
|
+
// below the waterline floods into a cenote pool. Returned in
|
|
35
|
+
// `sinkholes`. The spawn probe never lands in one.
|
|
36
|
+
//
|
|
37
|
+
// const terrain = createStylizedTerrain({
|
|
38
|
+
// seed: 42,
|
|
39
|
+
// size: { x: 1200, z: 800 },
|
|
40
|
+
// archetype: 'terracedKarst', // 'lakeland' | 'alpine' | 'rollingPlains' | 'archipelago'
|
|
41
|
+
// waterCoverage: 0.35, // ← "more water" is one knob
|
|
42
|
+
// height: 220, // ← taller mountain range
|
|
43
|
+
// depth: 60, // ← deeper basins
|
|
44
|
+
// floatingIslands: { count: 4 },
|
|
45
|
+
// });
|
|
46
|
+
// const world = await createStylizedWorld({
|
|
47
|
+
// terrain: { heightAt: terrain.heightAt, root: terrain.root, size: terrain.meshExtent },
|
|
48
|
+
// water: { level: terrain.waterLevel },
|
|
49
|
+
// });
|
|
50
|
+
// character.position.copy(terrain.spawn);
|
|
51
|
+
|
|
52
|
+
// Morphology + paint bundles. Amplitudes are meters; band values are
|
|
53
|
+
// fractions of the archetype's observed land relief above the waterline.
|
|
54
|
+
const TERRAIN_ARCHETYPES = new Map([
|
|
55
|
+
['terracedKarst', {
|
|
56
|
+
continent: { amp: 160, bias: -0.535, freq: 0.0011 },
|
|
57
|
+
label: 'Terraced Karst',
|
|
58
|
+
mountains: { amp: 170, freq: 0.00076, mask: [0.47, 0.6], ridgeExp: 1.8, ridgeFreq: 0.007 },
|
|
59
|
+
rim: { base: 55, ridged: 120 },
|
|
60
|
+
rolling: { amp: 18, freq: 0.0036 },
|
|
61
|
+
terraces: { blendOff: [0.8, 1.0], sharpness: 5, step: 20 },
|
|
62
|
+
waterCoverage: 0.14,
|
|
63
|
+
}],
|
|
64
|
+
['lakeland', {
|
|
65
|
+
continent: { amp: 70, bias: -0.5, freq: 0.0013 },
|
|
66
|
+
label: 'Lakeland',
|
|
67
|
+
mountains: { amp: 60, freq: 0.0009, mask: [0.55, 0.7], ridgeExp: 1.6, ridgeFreq: 0.008 },
|
|
68
|
+
rim: { base: 40, ridged: 70 },
|
|
69
|
+
rolling: { amp: 14, freq: 0.004 },
|
|
70
|
+
terraces: { blendOff: [0.75, 0.95], sharpness: 4, step: 12 },
|
|
71
|
+
waterCoverage: 0.4,
|
|
72
|
+
}],
|
|
73
|
+
['alpine', {
|
|
74
|
+
continent: { amp: 130, bias: -0.5, freq: 0.001 },
|
|
75
|
+
label: 'Alpine',
|
|
76
|
+
mountains: { amp: 260, freq: 0.0007, mask: [0.4, 0.56], ridgeExp: 1.6, ridgeFreq: 0.006 },
|
|
77
|
+
rim: { base: 80, ridged: 160 },
|
|
78
|
+
rolling: { amp: 24, freq: 0.0034 },
|
|
79
|
+
terraces: { blendOff: [0.55, 0.75], sharpness: 4, step: 30 },
|
|
80
|
+
waterCoverage: 0.08,
|
|
81
|
+
}],
|
|
82
|
+
['rollingPlains', {
|
|
83
|
+
continent: { amp: 46, bias: -0.46, freq: 0.0012 },
|
|
84
|
+
label: 'Rolling Plains',
|
|
85
|
+
mountains: { amp: 34, freq: 0.0009, mask: [0.6, 0.75], ridgeExp: 1.5, ridgeFreq: 0.008 },
|
|
86
|
+
rim: { base: 36, ridged: 60 },
|
|
87
|
+
rolling: { amp: 20, freq: 0.0038 },
|
|
88
|
+
terraces: null,
|
|
89
|
+
waterCoverage: 0.12,
|
|
90
|
+
}],
|
|
91
|
+
['archipelago', {
|
|
92
|
+
continent: { amp: 110, bias: -0.62, freq: 0.0014 },
|
|
93
|
+
label: 'Archipelago',
|
|
94
|
+
mountains: { amp: 90, freq: 0.001, mask: [0.5, 0.66], ridgeExp: 1.7, ridgeFreq: 0.008 },
|
|
95
|
+
rim: { base: 40, ridged: 90 },
|
|
96
|
+
rolling: { amp: 16, freq: 0.004 },
|
|
97
|
+
terraces: { blendOff: [0.7, 0.92], sharpness: 4, step: 14 },
|
|
98
|
+
waterCoverage: 0.55,
|
|
99
|
+
}],
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** Lists terrain archetypes as `{ id, label }` (for HUDs / docs). */
|
|
103
|
+
export function getTerrainArchetypeOptions() {
|
|
104
|
+
return Array.from(TERRAIN_ARCHETYPES.entries()).map(([id, a]) => ({ id, label: a.label }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const DEFAULT_PALETTE = {
|
|
108
|
+
golden: 0xd2b24c,
|
|
109
|
+
haze: 0xa9c6e8,
|
|
110
|
+
meadow: 0x64ad48,
|
|
111
|
+
rock: 0xa7b7c6,
|
|
112
|
+
sand: 0xe2d49a,
|
|
113
|
+
snow: 0xe2eaf2,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function smoothstep(edge0, edge1, value) {
|
|
117
|
+
const t = Math.min(Math.max((value - edge0) / (edge1 - edge0), 0), 1);
|
|
118
|
+
return t * t * (3 - 2 * t);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Terracing is the signature of the karst look: quantize height into steps
|
|
122
|
+
// with steep eased walls, so hills become flat-topped mesas.
|
|
123
|
+
function terrace(y, step, sharpness) {
|
|
124
|
+
const t = y / step;
|
|
125
|
+
const base = Math.floor(t);
|
|
126
|
+
const f = t - base;
|
|
127
|
+
const eased = f ** sharpness / (f ** sharpness + (1 - f) ** sharpness);
|
|
128
|
+
return (base + eased) * step;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Seeded value-noise kit; every stream hangs off the resolved seed so the
|
|
132
|
+
// same seed is the same world, bit for bit.
|
|
133
|
+
function createNoiseKit(seed) {
|
|
134
|
+
const hashCell = (ix, iz) => {
|
|
135
|
+
let h = (ix * 374761393 + iz * 668265263 + seed * 971) | 0;
|
|
136
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
137
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
138
|
+
};
|
|
139
|
+
const valueNoise = (x, z) => {
|
|
140
|
+
const ix = Math.floor(x);
|
|
141
|
+
const iz = Math.floor(z);
|
|
142
|
+
const fx = x - ix;
|
|
143
|
+
const fz = z - iz;
|
|
144
|
+
const sx = fx * fx * (3 - 2 * fx);
|
|
145
|
+
const sz = fz * fz * (3 - 2 * fz);
|
|
146
|
+
const a = hashCell(ix, iz);
|
|
147
|
+
const b = hashCell(ix + 1, iz);
|
|
148
|
+
const c = hashCell(ix, iz + 1);
|
|
149
|
+
const d = hashCell(ix + 1, iz + 1);
|
|
150
|
+
return a + (b - a) * sx + (c - a) * sz + (a - b - c + d) * sx * sz;
|
|
151
|
+
};
|
|
152
|
+
const fbm = (x, z, octaves = 4) => {
|
|
153
|
+
let amplitude = 0.5;
|
|
154
|
+
let frequency = 1;
|
|
155
|
+
let sum = 0;
|
|
156
|
+
let norm = 0;
|
|
157
|
+
for (let i = 0; i < octaves; i += 1) {
|
|
158
|
+
sum += amplitude * valueNoise(x * frequency, z * frequency);
|
|
159
|
+
norm += amplitude;
|
|
160
|
+
amplitude *= 0.5;
|
|
161
|
+
frequency *= 2;
|
|
162
|
+
}
|
|
163
|
+
return sum / norm;
|
|
164
|
+
};
|
|
165
|
+
return { fbm, hashCell, valueNoise };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Tiling value-noise mottling multiplied under the vertex-color biomes so
|
|
169
|
+
// grass and sand read as ground instead of flat paint.
|
|
170
|
+
function createGroundDetailTexture(kit, repeatX, repeatZ) {
|
|
171
|
+
if (typeof document === 'undefined') return null;
|
|
172
|
+
const size = 256;
|
|
173
|
+
const canvas = document.createElement('canvas');
|
|
174
|
+
canvas.width = size;
|
|
175
|
+
canvas.height = size;
|
|
176
|
+
const ctx = canvas.getContext('2d');
|
|
177
|
+
const image = ctx.createImageData(size, size);
|
|
178
|
+
for (let py = 0; py < size; py += 1) {
|
|
179
|
+
for (let px = 0; px < size; px += 1) {
|
|
180
|
+
const n1 = kit.valueNoise((px % size) * 0.09, (py % size) * 0.09);
|
|
181
|
+
const n2 = kit.valueNoise((px % size) * 0.23 + 37, (py % size) * 0.23 + 59);
|
|
182
|
+
const shade = 215 + (n1 * 0.65 + n2 * 0.35) * 40;
|
|
183
|
+
const i = (py * size + px) * 4;
|
|
184
|
+
image.data[i] = shade;
|
|
185
|
+
image.data[i + 1] = shade;
|
|
186
|
+
image.data[i + 2] = shade;
|
|
187
|
+
image.data[i + 3] = 255;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
ctx.putImageData(image, 0, 0);
|
|
191
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
192
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
193
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
194
|
+
texture.repeat.set(repeatX, repeatZ);
|
|
195
|
+
return texture;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Generates a seeded stylized heightfield world: morphology from a named
|
|
200
|
+
* archetype, waterline solved from `waterCoverage`, spawn found by probing,
|
|
201
|
+
* biome paint in relief-relative bands, optional floating islands. Returns
|
|
202
|
+
* `{ archetype, dispose, heightAt, islands, mesh, meshExtent, resolvedSeed,
|
|
203
|
+
* root, size, spawn, stats, waterLevel }`.
|
|
204
|
+
*/
|
|
205
|
+
export function createStylizedTerrain({
|
|
206
|
+
seed = 1,
|
|
207
|
+
size = 1000,
|
|
208
|
+
archetype = 'terracedKarst',
|
|
209
|
+
waterCoverage = null, // 0..0.6; default from the archetype
|
|
210
|
+
height = null, // H: mountain amplitude override (m)
|
|
211
|
+
depth = null, // D: basin depth below the ground datum (m)
|
|
212
|
+
floatingIslands = false,
|
|
213
|
+
sinkholes = false, // true | { count, minRadius, maxRadius, depth }
|
|
214
|
+
palette = {},
|
|
215
|
+
segments = 384,
|
|
216
|
+
detailTexture = true,
|
|
217
|
+
maxAttempts = 4,
|
|
218
|
+
} = {}) {
|
|
219
|
+
const base = TERRAIN_ARCHETYPES.get(archetype);
|
|
220
|
+
if (!base) {
|
|
221
|
+
throw new Error(`Unknown terrain archetype "${archetype}" (have: ${[...TERRAIN_ARCHETYPES.keys()].join(', ')}).`);
|
|
222
|
+
}
|
|
223
|
+
const spec = Number.isFinite(height)
|
|
224
|
+
? { ...base, mountains: { ...base.mountains, amp: Math.max(height, 1) } }
|
|
225
|
+
: base;
|
|
226
|
+
const coverage = Math.min(Math.max(
|
|
227
|
+
Number.isFinite(waterCoverage) ? waterCoverage : spec.waterCoverage, 0), 0.6);
|
|
228
|
+
const sizeX = Number.isFinite(size?.x) ? size.x : Number(size) || 1000;
|
|
229
|
+
const sizeZ = Number.isFinite(size?.z) ? size.z : sizeX;
|
|
230
|
+
const halfX = sizeX / 2;
|
|
231
|
+
const halfZ = sizeZ / 2;
|
|
232
|
+
const meshExtent = { x: sizeX * 2, z: sizeZ * 2 };
|
|
233
|
+
// Normalized elliptical distance: 1 at the playable edge, rim beyond.
|
|
234
|
+
const edgeDistance = (x, z) => Math.hypot(x / halfX, z / halfZ);
|
|
235
|
+
// D: scale the below-datum range. The archetype's nominal basin depth is
|
|
236
|
+
// continent.amp × |bias|; `depth` remaps it.
|
|
237
|
+
const nominalDepth = spec.continent.amp * Math.abs(spec.continent.bias);
|
|
238
|
+
const depthScale = Number.isFinite(depth) ? Math.max(depth, 1) / nominalDepth : 1;
|
|
239
|
+
|
|
240
|
+
// ---- attempt loop: raw morphology → waterline → spawn; re-roll a
|
|
241
|
+
// derived sub-seed if no acceptable spawn exists. ----
|
|
242
|
+
let attempt = 0;
|
|
243
|
+
let resolvedSeed = (Number(seed) >>> 0) || 1;
|
|
244
|
+
let kit;
|
|
245
|
+
let rawHeight;
|
|
246
|
+
let waterLevel;
|
|
247
|
+
let spawn = null;
|
|
248
|
+
let landPeak = 1;
|
|
249
|
+
let holes = [];
|
|
250
|
+
|
|
251
|
+
while (attempt < maxAttempts) {
|
|
252
|
+
kit = createNoiseKit(resolvedSeed);
|
|
253
|
+
const { fbm } = kit;
|
|
254
|
+
const { continent, mountains, rolling, rim, terraces } = spec;
|
|
255
|
+
|
|
256
|
+
// Sinkholes (karst dolines): seeded pits with sheer carved walls — the
|
|
257
|
+
// heightfield's version of underground structure. One that cuts below
|
|
258
|
+
// the waterline floods into a cenote pool. The slope paint and cliff
|
|
259
|
+
// decorators pick their walls up automatically. True caves/overhangs
|
|
260
|
+
// need volumetric terrain and are tracked separately.
|
|
261
|
+
holes = [];
|
|
262
|
+
if (sinkholes) {
|
|
263
|
+
const ho = typeof sinkholes === 'object' ? sinkholes : {};
|
|
264
|
+
const count = Math.max(1, Math.round(ho.count ?? 3));
|
|
265
|
+
const minR = ho.minRadius ?? 26;
|
|
266
|
+
const maxR = ho.maxRadius ?? 52;
|
|
267
|
+
for (let i = 0; i < count; i += 1) {
|
|
268
|
+
holes.push({
|
|
269
|
+
depth: ho.depth ?? 34 + kit.hashCell(i * 19 + 3, i * 7 + 29) * 26,
|
|
270
|
+
radius: minR + (maxR - minR) * kit.hashCell(i * 11 + 1, i * 5 + 17),
|
|
271
|
+
x: (kit.hashCell(i * 13 + 7, i * 3 + 2) - 0.5) * sizeX * 0.72,
|
|
272
|
+
z: (kit.hashCell(i * 29 + 5, i * 23 + 11) - 0.5) * sizeZ * 0.72,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
rawHeight = (x, z) => {
|
|
278
|
+
const cont = (fbm(x * continent.freq, z * continent.freq, 4) + continent.bias) * continent.amp;
|
|
279
|
+
const roll = fbm(x * rolling.freq, z * rolling.freq, 4) * rolling.amp;
|
|
280
|
+
const mask = smoothstep(mountains.mask[0], mountains.mask[1],
|
|
281
|
+
fbm(x * mountains.freq + 11, z * mountains.freq + 7, 3));
|
|
282
|
+
const ridged = (1 - Math.abs(2 * fbm(x * mountains.ridgeFreq + 31, z * mountains.ridgeFreq + 17, 4) - 1))
|
|
283
|
+
** mountains.ridgeExp;
|
|
284
|
+
let y = cont + roll + mask * ridged * mountains.amp;
|
|
285
|
+
if (y < 0) y *= depthScale; // D: basins scale independently of peaks
|
|
286
|
+
if (terraces && y > 2) {
|
|
287
|
+
const blend = 1 - smoothstep(
|
|
288
|
+
mountains.amp * terraces.blendOff[0],
|
|
289
|
+
mountains.amp * terraces.blendOff[1],
|
|
290
|
+
y,
|
|
291
|
+
);
|
|
292
|
+
y = y * (1 - blend) + terrace(y, terraces.step, terraces.sharpness) * blend;
|
|
293
|
+
}
|
|
294
|
+
const rimBlend = smoothstep(1.3, 1.9, edgeDistance(x, z));
|
|
295
|
+
y += rimBlend * (rim.base + ridged * rim.ridged);
|
|
296
|
+
for (const hole of holes) {
|
|
297
|
+
const d = Math.hypot(x - hole.x, z - hole.z);
|
|
298
|
+
if (d < hole.radius * 1.05) {
|
|
299
|
+
y -= hole.depth * smoothstep(hole.radius, hole.radius * 0.45, d);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return y;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Waterline: the requested coverage percentile of the playable-area
|
|
306
|
+
// height histogram. Works for any seed by construction.
|
|
307
|
+
const samples = [];
|
|
308
|
+
for (let gx = 0; gx <= 80; gx += 1) {
|
|
309
|
+
for (let gz = 0; gz <= 80; gz += 1) {
|
|
310
|
+
samples.push(rawHeight(-halfX + (gx / 80) * sizeX, -halfZ + (gz / 80) * sizeZ));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
samples.sort((a, b) => a - b);
|
|
314
|
+
waterLevel = samples[Math.min(Math.floor(samples.length * coverage), samples.length - 1)];
|
|
315
|
+
landPeak = Math.max(samples[Math.floor(samples.length * 0.995)] - waterLevel, 1);
|
|
316
|
+
|
|
317
|
+
spawn = probeSpawn(rawHeight, waterLevel, halfX, halfZ, { holes });
|
|
318
|
+
if (spawn) break;
|
|
319
|
+
attempt += 1;
|
|
320
|
+
resolvedSeed = (Math.imul(resolvedSeed, 2654435761) + 101) >>> 0;
|
|
321
|
+
}
|
|
322
|
+
if (!spawn) {
|
|
323
|
+
// Every candidate failed even after re-rolls — take the least-bad site
|
|
324
|
+
// rather than throwing: a mediocre spawn beats no world.
|
|
325
|
+
spawn = probeSpawn(rawHeight, waterLevel, halfX, halfZ, { holes, relaxed: true })
|
|
326
|
+
?? { openness: 0, x: 0, y: rawHeight(0, 0), z: 0 };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Final height function: raw + a flattened spawn meadow.
|
|
330
|
+
const spawnHeight = Math.max(rawHeight(spawn.x, spawn.z), waterLevel + 3);
|
|
331
|
+
const flattenFar = Math.min(sizeX, sizeZ) * 0.12;
|
|
332
|
+
const flattenNear = flattenFar / 3;
|
|
333
|
+
const heightAt = (x, z) => {
|
|
334
|
+
const y = rawHeight(x, z);
|
|
335
|
+
const flatten = smoothstep(flattenFar, flattenNear, Math.hypot(x - spawn.x, z - spawn.z));
|
|
336
|
+
return y * (1 - flatten) + spawnHeight * flatten;
|
|
337
|
+
};
|
|
338
|
+
spawn.y = heightAt(spawn.x, spawn.z);
|
|
339
|
+
|
|
340
|
+
// ---- mesh + paint (bands are fractions of the observed relief) ----
|
|
341
|
+
const colors = {
|
|
342
|
+
golden: new THREE.Color(palette.golden ?? DEFAULT_PALETTE.golden),
|
|
343
|
+
haze: new THREE.Color(palette.haze ?? DEFAULT_PALETTE.haze),
|
|
344
|
+
meadow: new THREE.Color(palette.meadow ?? DEFAULT_PALETTE.meadow),
|
|
345
|
+
rock: new THREE.Color(palette.rock ?? DEFAULT_PALETTE.rock),
|
|
346
|
+
sand: new THREE.Color(palette.sand ?? DEFAULT_PALETTE.sand),
|
|
347
|
+
snow: new THREE.Color(palette.snow ?? DEFAULT_PALETTE.snow),
|
|
348
|
+
};
|
|
349
|
+
const rockBand = [waterLevel + landPeak * 0.5, waterLevel + landPeak * 0.66];
|
|
350
|
+
const snowBand = [waterLevel + landPeak * 0.95, waterLevel + landPeak * 1.15];
|
|
351
|
+
const goldTop = waterLevel + landPeak * 0.42;
|
|
352
|
+
const { fbm } = kit;
|
|
353
|
+
const goldenField = (x, z) => smoothstep(0.54, 0.68, fbm(x * 0.003 + 91, z * 0.003 + 43, 3));
|
|
354
|
+
|
|
355
|
+
const paintVertex = (color, x, y, z, grade) => {
|
|
356
|
+
const rockiness = Math.max(smoothstep(0.5, 0.9, grade), smoothstep(rockBand[0], rockBand[1], y));
|
|
357
|
+
color.copy(colors.meadow)
|
|
358
|
+
// Golden fields on flat lowlands only: loose gates leak gold up cliff
|
|
359
|
+
// triangles as sawtooth wedges.
|
|
360
|
+
.lerp(colors.golden, goldenField(x, z) * 0.85
|
|
361
|
+
* (1 - smoothstep(goldTop * 0.72, goldTop, y)) * (1 - smoothstep(0.16, 0.32, grade)))
|
|
362
|
+
.lerp(colors.rock, rockiness)
|
|
363
|
+
.lerp(colors.snow, smoothstep(snowBand[0], snowBand[1], y));
|
|
364
|
+
// Low-frequency strata + drift: frequencies stay above the vertex
|
|
365
|
+
// spacing (finer bands alias into zigzag triangles on walls); fine
|
|
366
|
+
// rock detail belongs to the triplanar stone texture.
|
|
367
|
+
if (rockiness > 0.02) {
|
|
368
|
+
const strata = 0.95 + 0.1 * fbm(y * 0.1 + 17, (x + z) * 0.004, 2);
|
|
369
|
+
const drift = 0.95 + 0.1 * fbm(x * 0.008 + 5, z * 0.008 + 9, 2);
|
|
370
|
+
color.lerp(color.clone().multiplyScalar(strata * drift), rockiness);
|
|
371
|
+
}
|
|
372
|
+
color
|
|
373
|
+
.lerp(colors.sand, smoothstep(waterLevel + 2.5, waterLevel + 0.3, y))
|
|
374
|
+
// Baked atmospheric perspective: the rim reads as hazy blue peaks.
|
|
375
|
+
.lerp(colors.haze, smoothstep(1.4, 2.1, edgeDistance(x, z)) * 0.45);
|
|
376
|
+
return color;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const geometry = new THREE.PlaneGeometry(meshExtent.x, meshExtent.z, segments, segments);
|
|
380
|
+
geometry.rotateX(-Math.PI / 2);
|
|
381
|
+
const positions = geometry.attributes.position;
|
|
382
|
+
for (let i = 0; i < positions.count; i += 1) {
|
|
383
|
+
positions.setY(i, heightAt(positions.getX(i), positions.getZ(i)));
|
|
384
|
+
}
|
|
385
|
+
geometry.computeVertexNormals();
|
|
386
|
+
|
|
387
|
+
const colorAttr = new Float32Array(positions.count * 3);
|
|
388
|
+
const color = new THREE.Color();
|
|
389
|
+
for (let i = 0; i < positions.count; i += 1) {
|
|
390
|
+
const x = positions.getX(i);
|
|
391
|
+
const z = positions.getZ(i);
|
|
392
|
+
// Analytic slope: sharper than smoothed normals, so terrace walls
|
|
393
|
+
// reliably classify as cliff.
|
|
394
|
+
const e = 4;
|
|
395
|
+
const grade = Math.hypot(
|
|
396
|
+
heightAt(x + e, z) - heightAt(x - e, z),
|
|
397
|
+
heightAt(x, z + e) - heightAt(x, z - e),
|
|
398
|
+
) / (2 * e);
|
|
399
|
+
paintVertex(color, x, positions.getY(i), z, grade);
|
|
400
|
+
colorAttr.set([color.r, color.g, color.b], i * 3);
|
|
401
|
+
}
|
|
402
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(colorAttr, 3));
|
|
403
|
+
|
|
404
|
+
const material = new THREE.MeshStandardMaterial({
|
|
405
|
+
map: detailTexture
|
|
406
|
+
? createGroundDetailTexture(kit, Math.round(meshExtent.x / 11), Math.round(meshExtent.z / 11))
|
|
407
|
+
: null,
|
|
408
|
+
vertexColors: true,
|
|
409
|
+
});
|
|
410
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
411
|
+
mesh.castShadow = true; // cliff walls shadow their own valleys
|
|
412
|
+
mesh.receiveShadow = true;
|
|
413
|
+
// Displaced world-scale geometry misjudges its bounding volume — never cull.
|
|
414
|
+
mesh.frustumCulled = false;
|
|
415
|
+
mesh.name = 'terrain';
|
|
416
|
+
const root = new THREE.Group();
|
|
417
|
+
root.name = 'StylizedTerrain';
|
|
418
|
+
root.add(mesh);
|
|
419
|
+
|
|
420
|
+
// ---- floating islands (optional): meadow-topped sky rocks with tapered
|
|
421
|
+
// undersides. Decorative by default — returned so hosts can scatter
|
|
422
|
+
// trees on them, hang waterfalls, or add collision. ----
|
|
423
|
+
const islands = [];
|
|
424
|
+
const islandDisposables = [];
|
|
425
|
+
if (floatingIslands) {
|
|
426
|
+
const opts = typeof floatingIslands === 'object' ? floatingIslands : {};
|
|
427
|
+
const count = Math.max(1, Math.round(opts.count ?? 3));
|
|
428
|
+
const minAlt = opts.minAltitude ?? waterLevel + landPeak * 1.1;
|
|
429
|
+
const maxAlt = opts.maxAltitude ?? waterLevel + landPeak * 1.7;
|
|
430
|
+
const minR = opts.minRadius ?? 26;
|
|
431
|
+
const maxR = opts.maxRadius ?? 70;
|
|
432
|
+
for (let i = 0; i < count; i += 1) {
|
|
433
|
+
const r1 = kit.hashCell(i * 53 + 5, i * 31 + 9);
|
|
434
|
+
const r2 = kit.hashCell(i * 97 + 3, i * 17 + 21);
|
|
435
|
+
const r3 = kit.hashCell(i * 13 + 41, i * 71 + 2);
|
|
436
|
+
const radius = minR + (maxR - minR) * r3;
|
|
437
|
+
const island = {
|
|
438
|
+
radius,
|
|
439
|
+
x: (r1 - 0.5) * sizeX * 0.9,
|
|
440
|
+
y: minAlt + (maxAlt - minAlt) * kit.hashCell(i * 7 + 1, i * 3 + 8),
|
|
441
|
+
z: (r2 - 0.5) * sizeZ * 0.9,
|
|
442
|
+
};
|
|
443
|
+
const built = buildFloatingIsland(kit, island, i, colors, paintVertex, waterLevel, landPeak);
|
|
444
|
+
root.add(built.mesh);
|
|
445
|
+
islandDisposables.push(built);
|
|
446
|
+
islands.push(island);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
archetype,
|
|
452
|
+
heightAt,
|
|
453
|
+
islands,
|
|
454
|
+
mesh,
|
|
455
|
+
meshExtent,
|
|
456
|
+
resolvedSeed,
|
|
457
|
+
root,
|
|
458
|
+
sinkholes: holes,
|
|
459
|
+
size: { x: sizeX, z: sizeZ },
|
|
460
|
+
spawn,
|
|
461
|
+
stats: { attempts: attempt + 1, landPeak, waterLevel },
|
|
462
|
+
waterLevel,
|
|
463
|
+
dispose() {
|
|
464
|
+
geometry.dispose();
|
|
465
|
+
material.map?.dispose();
|
|
466
|
+
material.dispose();
|
|
467
|
+
for (const built of islandDisposables) built.dispose();
|
|
468
|
+
root.parent?.remove(root);
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// One floating island: a displaced top disc (meadow) and a deeper tapered
|
|
474
|
+
// bottom disc (rock) sharing their rim ring, so the silhouette closes.
|
|
475
|
+
function buildFloatingIsland(kit, island, index, colors, paintVertex, waterLevel, landPeak) {
|
|
476
|
+
const segments = 40;
|
|
477
|
+
const rings = 12;
|
|
478
|
+
const { radius } = island;
|
|
479
|
+
const topAmp = radius * 0.18;
|
|
480
|
+
const bottomDepth = radius * (0.7 + kit.hashCell(index * 3 + 2, index * 11 + 6) * 0.5);
|
|
481
|
+
const positions = [];
|
|
482
|
+
const colorList = [];
|
|
483
|
+
const indexList = [];
|
|
484
|
+
const color = new THREE.Color();
|
|
485
|
+
|
|
486
|
+
// Local top-surface height; also exposed as island.topAt(worldX, worldZ)
|
|
487
|
+
// so hosts can stand trees, props, or a character on the island.
|
|
488
|
+
const topOffset = (x, z) => {
|
|
489
|
+
const t = Math.min(Math.hypot(x, z) / radius, 1);
|
|
490
|
+
const rimFalloff = 1 - t * t;
|
|
491
|
+
return (kit.fbm(x * 0.05 + index * 7, z * 0.05 + index * 13, 3) - 0.3) * topAmp * 2 * rimFalloff
|
|
492
|
+
+ topAmp * 0.4 * rimFalloff;
|
|
493
|
+
};
|
|
494
|
+
island.topAt = (worldX, worldZ) => island.y + topOffset(worldX - island.x, worldZ - island.z);
|
|
495
|
+
|
|
496
|
+
const ringVertex = (side, ring, seg) => {
|
|
497
|
+
const t = ring / rings; // 0 center → 1 rim
|
|
498
|
+
const angle = (seg / segments) * Math.PI * 2;
|
|
499
|
+
const r = t * radius;
|
|
500
|
+
const x = Math.cos(angle) * r;
|
|
501
|
+
const z = Math.sin(angle) * r;
|
|
502
|
+
let y;
|
|
503
|
+
if (side === 'top') {
|
|
504
|
+
y = topOffset(x, z);
|
|
505
|
+
} else {
|
|
506
|
+
const taper = (1 - t) ** 0.75;
|
|
507
|
+
y = -bottomDepth * taper * (0.65 + kit.fbm(x * 0.06 + 51, z * 0.06 + 77, 3) * 0.7);
|
|
508
|
+
}
|
|
509
|
+
positions.push(island.x + x, island.y + y, island.z + z);
|
|
510
|
+
if (side === 'top') {
|
|
511
|
+
// Reuse the terrain paint at a meadow-friendly virtual altitude so
|
|
512
|
+
// island tops match the ground biome exactly.
|
|
513
|
+
paintVertex(color, x + island.x, waterLevel + landPeak * 0.15, z + island.z, t * 0.35);
|
|
514
|
+
} else {
|
|
515
|
+
color.copy(colors.rock).multiplyScalar(0.82 + kit.fbm(x * 0.1, z * 0.1, 2) * 0.25);
|
|
516
|
+
}
|
|
517
|
+
colorList.push(color.r, color.g, color.b);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const sideOffsets = {};
|
|
521
|
+
for (const side of ['top', 'bottom']) {
|
|
522
|
+
sideOffsets[side] = positions.length / 3;
|
|
523
|
+
for (let ring = 0; ring <= rings; ring += 1) {
|
|
524
|
+
for (let seg = 0; seg < segments; seg += 1) ringVertex(side, ring, seg);
|
|
525
|
+
}
|
|
526
|
+
const start = sideOffsets[side];
|
|
527
|
+
for (let ring = 0; ring < rings; ring += 1) {
|
|
528
|
+
for (let seg = 0; seg < segments; seg += 1) {
|
|
529
|
+
const a = start + ring * segments + seg;
|
|
530
|
+
const b = start + ring * segments + ((seg + 1) % segments);
|
|
531
|
+
const c = start + (ring + 1) * segments + seg;
|
|
532
|
+
const d = start + (ring + 1) * segments + ((seg + 1) % segments);
|
|
533
|
+
if (side === 'top') indexList.push(a, c, b, b, c, d);
|
|
534
|
+
else indexList.push(a, b, c, b, d, c);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const geometry = new THREE.BufferGeometry();
|
|
540
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
|
|
541
|
+
geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colorList), 3));
|
|
542
|
+
geometry.setIndex(indexList);
|
|
543
|
+
geometry.computeVertexNormals();
|
|
544
|
+
const material = new THREE.MeshStandardMaterial({ vertexColors: true });
|
|
545
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
546
|
+
mesh.castShadow = true;
|
|
547
|
+
mesh.receiveShadow = true;
|
|
548
|
+
mesh.frustumCulled = false; // large displaced silhouette, same rule as terrain
|
|
549
|
+
mesh.name = `FloatingIsland${index}`;
|
|
550
|
+
return { dispose: () => { geometry.dispose(); material.dispose(); }, mesh };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Deterministic spawn search: walkable slope, above the waterline, near a
|
|
554
|
+
// shore, and open sightlines (no tall wall close by in any direction).
|
|
555
|
+
function probeSpawn(rawHeight, waterLevel, halfX, halfZ, { holes = [], relaxed = false } = {}) {
|
|
556
|
+
const maxRX = halfX * 0.85;
|
|
557
|
+
const maxRZ = halfZ * 0.85;
|
|
558
|
+
const wallLimit = relaxed ? 45 : 27;
|
|
559
|
+
let best = null;
|
|
560
|
+
for (let x = -maxRX; x <= maxRX; x += 20) {
|
|
561
|
+
for (let z = -maxRZ; z <= maxRZ; z += 20) {
|
|
562
|
+
// Never spawn inside (or on the lip of) a sinkhole.
|
|
563
|
+
if (holes.some((h) => Math.hypot(x - h.x, z - h.z) < h.radius * 1.4)) continue;
|
|
564
|
+
const y = rawHeight(x, z);
|
|
565
|
+
if (y < waterLevel + 1.2 || y > waterLevel + 22) continue;
|
|
566
|
+
const e = 4;
|
|
567
|
+
const grade = Math.hypot(
|
|
568
|
+
rawHeight(x + e, z) - rawHeight(x - e, z),
|
|
569
|
+
rawHeight(x, z + e) - rawHeight(x, z - e),
|
|
570
|
+
) / (2 * e);
|
|
571
|
+
if (grade > (relaxed ? 0.4 : 0.22)) continue;
|
|
572
|
+
// Shore distance: probe rings outward until a sample dips underwater.
|
|
573
|
+
let shore = Infinity;
|
|
574
|
+
outer: for (let r = 15; r <= 90; r += 15) {
|
|
575
|
+
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
576
|
+
if (rawHeight(x + Math.cos(a) * r, z + Math.sin(a) * r) < waterLevel) {
|
|
577
|
+
shore = r;
|
|
578
|
+
break outer;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!relaxed && shore > 90) continue;
|
|
583
|
+
// Openness: penalize tall walls within 150 m along 8 rays.
|
|
584
|
+
let openness = 0;
|
|
585
|
+
for (let a = 0; a < Math.PI * 2; a += Math.PI / 4) {
|
|
586
|
+
let rayClear = 1;
|
|
587
|
+
for (let r = 40; r <= 150; r += 25) {
|
|
588
|
+
if (rawHeight(x + Math.cos(a) * r, z + Math.sin(a) * r) - y > wallLimit) {
|
|
589
|
+
rayClear = 0;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
openness += rayClear;
|
|
594
|
+
}
|
|
595
|
+
if (!relaxed && openness < 6) continue;
|
|
596
|
+
const score = openness * 10 - Math.abs(shore - 35) * 0.1 - grade * 20;
|
|
597
|
+
if (!best || score > best.score) best = { openness, score, x, y, z };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return best;
|
|
601
|
+
}
|