@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,386 @@
|
|
|
1
|
+
// The coastal starter world, as the game that copied it draws and collides with it.
|
|
2
|
+
//
|
|
3
|
+
// `bake.mjs` wrote a world package into `world/`: heightmap, splat masks, placements and the CC0 fir.
|
|
4
|
+
// The engine's `WorldCells` streams it. `loadTerrainSplat` textures its ground.
|
|
5
|
+
// Everything that decides how the world looks, or what stops the player, is in this folder for the game to edit.
|
|
6
|
+
import type { IComputeDriven, ICtx } from "@threenative/core";
|
|
7
|
+
import {
|
|
8
|
+
Heightfield,
|
|
9
|
+
type IWorldCellsLoadOptions,
|
|
10
|
+
type IWorldPackage,
|
|
11
|
+
WorldCells,
|
|
12
|
+
heightSamplerFromHeightmap,
|
|
13
|
+
loadTerrainSplat,
|
|
14
|
+
loadWorldHeightmap,
|
|
15
|
+
} from "@threenative/core/world";
|
|
16
|
+
import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics";
|
|
17
|
+
import {
|
|
18
|
+
EquirectangularReflectionMapping,
|
|
19
|
+
type Material,
|
|
20
|
+
type Mesh,
|
|
21
|
+
type MeshStandardMaterial,
|
|
22
|
+
Object3D,
|
|
23
|
+
type Texture,
|
|
24
|
+
Vector3,
|
|
25
|
+
} from "three";
|
|
26
|
+
import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
|
|
27
|
+
import { type ICoastalSea, type ICoastalSeaData, addCoastalSea } from "./sea.js";
|
|
28
|
+
|
|
29
|
+
/** Where the game's asset source holds the baked `world/` folder. */
|
|
30
|
+
export const COASTAL_URL = "terrain/coastal/world.json";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What stops the player, per placed asset, in metres at the placement's scale 1. `null` walks
|
|
34
|
+
* through. A sphere sits on the placement's origin. Change a row to change every copy.
|
|
35
|
+
*/
|
|
36
|
+
export const COLLIDERS: Readonly<Record<string, { sphere: number } | null>> = {
|
|
37
|
+
fir: { sphere: 0.6 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** How strongly the sky lights the props. The sun stays the dominant light on a fir. */
|
|
41
|
+
export const PROP_SKY_LIGHT = 0.5;
|
|
42
|
+
|
|
43
|
+
/** Metres around `follow` within which props collide. Beyond that, props only draw. */
|
|
44
|
+
export const PROP_COLLIDER_REACH = 60;
|
|
45
|
+
|
|
46
|
+
export interface ICoastalWorld {
|
|
47
|
+
readonly world: WorldCells;
|
|
48
|
+
/** One heightfield for the whole world, in place before `addCoastal` resolves. */
|
|
49
|
+
readonly ground: RigidBody3D;
|
|
50
|
+
/** The prop colliders near `follow`. They stream with it (see `PROP_COLLIDER_REACH`). */
|
|
51
|
+
readonly colliders: PropColliders;
|
|
52
|
+
/** The heightfield the ground stands on: `heightAt(x, z)` reads the terrain under any point. */
|
|
53
|
+
readonly field: Heightfield;
|
|
54
|
+
/** Every placed prop's record, eight floats each, as the bake wrote it. */
|
|
55
|
+
readonly placements: Float32Array;
|
|
56
|
+
/** The world's extent in metres, as the bake wrote it. */
|
|
57
|
+
readonly extent: IWorldPackage["extent"];
|
|
58
|
+
/** The sea's level in metres, as the bake wrote it. The scene keeps its spawn and its walk above it. */
|
|
59
|
+
readonly seaLevel: number;
|
|
60
|
+
/** The sea in the scene. `dispose` removes its mesh and releases its water surface. */
|
|
61
|
+
readonly sea: ICoastalSea;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What `water.json` holds for the coastal kit: the sea, as one lake with a level. */
|
|
65
|
+
interface ICoastalWaterFile {
|
|
66
|
+
readonly lakes: readonly {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly radius: number;
|
|
69
|
+
readonly level: number;
|
|
70
|
+
}[];
|
|
71
|
+
readonly rivers: readonly unknown[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stream the coastal world around `follow` (usually the player or the camera) and give it collision.
|
|
76
|
+
* `whileCurrent` stops a departed scene's async load from attaching anything. Its resources are
|
|
77
|
+
* released on cancellation, failure and when the game's entity registry ends this scene.
|
|
78
|
+
*/
|
|
79
|
+
export async function addCoastal(
|
|
80
|
+
ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
|
|
81
|
+
follow: Object3D,
|
|
82
|
+
url = COASTAL_URL,
|
|
83
|
+
whileCurrent: () => boolean = () => true,
|
|
84
|
+
): Promise<ICoastalWorld> {
|
|
85
|
+
const assertCurrent = () => {
|
|
86
|
+
if (whileCurrent()) return;
|
|
87
|
+
const error = new Error("Coastal world: its scene is no longer current.");
|
|
88
|
+
error.name = "AbortError";
|
|
89
|
+
throw error;
|
|
90
|
+
};
|
|
91
|
+
let surface: Material | undefined;
|
|
92
|
+
let releaseLighting: (() => void) | undefined;
|
|
93
|
+
let world: WorldCells | undefined;
|
|
94
|
+
let ground: RigidBody3D | undefined;
|
|
95
|
+
let colliders: PropColliders | undefined;
|
|
96
|
+
let sea: ICoastalSea | undefined;
|
|
97
|
+
let released = false;
|
|
98
|
+
const dispose = () => {
|
|
99
|
+
if (released) return;
|
|
100
|
+
released = true;
|
|
101
|
+
colliders?.detach();
|
|
102
|
+
colliders?.removeFromParent();
|
|
103
|
+
ground?.dispose();
|
|
104
|
+
ground?.object?.removeFromParent();
|
|
105
|
+
sea?.dispose();
|
|
106
|
+
world?.dispose();
|
|
107
|
+
surface?.dispose();
|
|
108
|
+
releaseLighting?.();
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
assertCurrent();
|
|
112
|
+
const manifest = (await (await fetchAsset(ctx, url, assertCurrent)).json()) as IWorldPackage;
|
|
113
|
+
assertCurrent();
|
|
114
|
+
const base = url.slice(0, url.lastIndexOf("/") + 1);
|
|
115
|
+
const records = await (
|
|
116
|
+
await fetchAsset(ctx, base + manifest.placements, assertCurrent)
|
|
117
|
+
).arrayBuffer();
|
|
118
|
+
assertCurrent();
|
|
119
|
+
const [heightmapUrl] = await ctx.assets.resolve(base + manifest.terrain.heightmap);
|
|
120
|
+
assertCurrent();
|
|
121
|
+
if (heightmapUrl === undefined) throw new Error("Coastal world: its heightmap is not served.");
|
|
122
|
+
const heightmap = await loadWorldHeightmap(heightmapUrl);
|
|
123
|
+
assertCurrent();
|
|
124
|
+
surface = await loadTerrainSplat({ assets: ctx.assets, url });
|
|
125
|
+
assertCurrent();
|
|
126
|
+
releaseLighting = await lightProps(ctx, url, manifest, assertCurrent);
|
|
127
|
+
assertCurrent();
|
|
128
|
+
const options: IWorldCellsLoadOptions = {
|
|
129
|
+
url,
|
|
130
|
+
assets: ctx.assets,
|
|
131
|
+
data: { manifest, placements: records, heightmap },
|
|
132
|
+
surface,
|
|
133
|
+
follow,
|
|
134
|
+
ring: 5,
|
|
135
|
+
impostors: false,
|
|
136
|
+
gpuScene: false,
|
|
137
|
+
shadows: { cast: true, receive: true },
|
|
138
|
+
budgets: { residentCells: 64, instances: 40_000, bytes: 64_000_000 },
|
|
139
|
+
terrain: { streamRadius: 6 },
|
|
140
|
+
};
|
|
141
|
+
world = await WorldCells.load(options);
|
|
142
|
+
assertCurrent();
|
|
143
|
+
const field = coastalField(manifest, heightmap);
|
|
144
|
+
ground = groundCollider(ctx, manifest, field);
|
|
145
|
+
const waterFile = (await (
|
|
146
|
+
await fetchAsset(ctx, `${base}water.json`, assertCurrent)
|
|
147
|
+
).json()) as ICoastalWaterFile;
|
|
148
|
+
assertCurrent();
|
|
149
|
+
const seaLevel = waterFile.lakes[0]?.level;
|
|
150
|
+
if (seaLevel === undefined) throw new Error("Coastal world: water.json names no sea level.");
|
|
151
|
+
const ocean = addCoastalSea(ctx, coastalSeaData(manifest, heightmap, seaLevel));
|
|
152
|
+
sea = ocean;
|
|
153
|
+
const props = new Float32Array(records);
|
|
154
|
+
colliders = new PropColliders(ctx, follow, manifest, props);
|
|
155
|
+
ctx.add(world);
|
|
156
|
+
if (ground.object !== undefined) ctx.add(ground.object);
|
|
157
|
+
ctx.add(colliders);
|
|
158
|
+
ctx.entities.add(`coastal-world.${world.uuid}`, { dispose });
|
|
159
|
+
return {
|
|
160
|
+
world,
|
|
161
|
+
ground,
|
|
162
|
+
colliders,
|
|
163
|
+
field,
|
|
164
|
+
placements: props,
|
|
165
|
+
extent: manifest.extent,
|
|
166
|
+
seaLevel,
|
|
167
|
+
sea: ocean,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
dispose();
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Every prop gets the sky as its own envMap before WorldCells adopts the shared cached models.
|
|
176
|
+
// Without it, a fir lit by the sun alone draws flat.
|
|
177
|
+
async function lightProps(
|
|
178
|
+
ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
|
|
179
|
+
url: string,
|
|
180
|
+
manifest: IWorldPackage,
|
|
181
|
+
assertCurrent: () => void,
|
|
182
|
+
): Promise<() => void> {
|
|
183
|
+
const base = url.slice(0, url.lastIndexOf("/") + 1);
|
|
184
|
+
const [skyUrl] = await ctx.assets.resolve(`${base}sky.hdr`);
|
|
185
|
+
assertCurrent();
|
|
186
|
+
if (skyUrl === undefined) throw new Error("Coastal world: 'sky.hdr' is not served.");
|
|
187
|
+
const sky = await new HDRLoader().loadAsync(skyUrl);
|
|
188
|
+
const originals = new Map<MeshStandardMaterial, { envMap: Texture | null; intensity: number }>();
|
|
189
|
+
const dispose = () => {
|
|
190
|
+
for (const [material, original] of originals) {
|
|
191
|
+
if (material.envMap !== sky) continue;
|
|
192
|
+
material.envMap = original.envMap;
|
|
193
|
+
material.envMapIntensity = original.intensity;
|
|
194
|
+
}
|
|
195
|
+
sky.dispose();
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
assertCurrent();
|
|
199
|
+
sky.mapping = EquirectangularReflectionMapping;
|
|
200
|
+
for (const asset of Object.values(manifest.assets)) {
|
|
201
|
+
const model = await ctx.assets.model<{ scene: Object3D }>(base + asset.glb);
|
|
202
|
+
assertCurrent();
|
|
203
|
+
model.scene.traverse((object) => {
|
|
204
|
+
const mesh = object as Mesh;
|
|
205
|
+
for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[])
|
|
206
|
+
if (!originals.has(material))
|
|
207
|
+
originals.set(material, {
|
|
208
|
+
envMap: material.envMap,
|
|
209
|
+
intensity: material.envMapIntensity,
|
|
210
|
+
});
|
|
211
|
+
dressProp(mesh, sky);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return dispose;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
dispose();
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** One prop mesh: the sky as its envMap. */
|
|
222
|
+
function dressProp(mesh: Mesh, sky: Texture): void {
|
|
223
|
+
for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[]) {
|
|
224
|
+
material.envMap = sky;
|
|
225
|
+
material.envMapIntensity = PROP_SKY_LIGHT;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The streamed terrain tiles arrive over several frames. A player spawned before its tile would
|
|
230
|
+
// fall through. One heightfield for the whole package avoids that, at a few hundred kilobytes.
|
|
231
|
+
/** The kit's one heightfield: the collider stands on it. */
|
|
232
|
+
function coastalField(manifest: IWorldPackage, heightmap: Uint16Array): Heightfield {
|
|
233
|
+
const { extent, terrain } = manifest;
|
|
234
|
+
return Heightfield.fromSampler({
|
|
235
|
+
columns: terrain.columns,
|
|
236
|
+
rows: terrain.rows,
|
|
237
|
+
width: extent.sizeX,
|
|
238
|
+
depth: extent.sizeZ,
|
|
239
|
+
origin: { x: extent.minX + extent.sizeX / 2, z: extent.minZ + extent.sizeZ / 2 },
|
|
240
|
+
sampleHeight: heightSamplerFromHeightmap(terrain, extent, heightmap),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function groundCollider(
|
|
245
|
+
ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
|
|
246
|
+
manifest: IWorldPackage,
|
|
247
|
+
field: Heightfield,
|
|
248
|
+
): RigidBody3D {
|
|
249
|
+
const { extent } = manifest;
|
|
250
|
+
const anchor = new Object3D();
|
|
251
|
+
anchor.position.set(extent.minX + extent.sizeX / 2, 0, extent.minZ + extent.sizeZ / 2);
|
|
252
|
+
return new RigidBody3D({
|
|
253
|
+
object: anchor,
|
|
254
|
+
physics: ctx.physics,
|
|
255
|
+
type: "fixed",
|
|
256
|
+
entity: "terrain",
|
|
257
|
+
shape: CollisionShape3D.heightfield(field.rows, field.columns, field.toColliderHeights(), {
|
|
258
|
+
x: extent.sizeX,
|
|
259
|
+
y: 1,
|
|
260
|
+
z: extent.sizeZ,
|
|
261
|
+
}),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fixed bodies for the props within `PROP_COLLIDER_REACH` of `follow`, rebuilt when it has moved a few
|
|
267
|
+
* metres. Thousands of resident bodies cost Rapier about 0.5 s per 8 s (measured). A hundred do not.
|
|
268
|
+
*/
|
|
269
|
+
export class PropColliders extends Object3D implements IComputeDriven {
|
|
270
|
+
readonly warmupNodes: readonly unknown[] = [];
|
|
271
|
+
readonly processCadence = "render" as const;
|
|
272
|
+
readonly #live = new Map<number, RigidBody3D>();
|
|
273
|
+
readonly #runs: { asset: string; offset: number; count: number }[] = [];
|
|
274
|
+
readonly #at = { x: Number.NaN, z: Number.NaN };
|
|
275
|
+
readonly #followPosition = new Vector3();
|
|
276
|
+
#released = false;
|
|
277
|
+
|
|
278
|
+
constructor(
|
|
279
|
+
private readonly ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
|
|
280
|
+
private readonly follow: Object3D,
|
|
281
|
+
manifest: IWorldPackage,
|
|
282
|
+
private readonly records: Float32Array,
|
|
283
|
+
) {
|
|
284
|
+
super();
|
|
285
|
+
this.name = "coastal-prop-colliders";
|
|
286
|
+
for (const cell of manifest.cells)
|
|
287
|
+
for (const run of cell.runs) if (COLLIDERS[run.asset]) this.#runs.push(run);
|
|
288
|
+
try {
|
|
289
|
+
this.#rebuild();
|
|
290
|
+
} catch (error) {
|
|
291
|
+
this.detach();
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** How many prop bodies exist right now. */
|
|
297
|
+
get active(): number {
|
|
298
|
+
return this.#live.size;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
get released(): boolean {
|
|
302
|
+
return this.#released;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
attachRenderer(): void {}
|
|
306
|
+
|
|
307
|
+
process(): void {
|
|
308
|
+
if (this.#released) return;
|
|
309
|
+
const p = this.follow.getWorldPosition(this.#followPosition);
|
|
310
|
+
if (Math.hypot(p.x - this.#at.x, p.z - this.#at.z) > 4 || Number.isNaN(this.#at.x))
|
|
311
|
+
this.#rebuild();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
detach(): void {
|
|
315
|
+
for (const body of this.#live.values()) body.dispose();
|
|
316
|
+
this.#live.clear();
|
|
317
|
+
this.#released = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#rebuild(): void {
|
|
321
|
+
const { x: fx, z: fz } = this.follow.getWorldPosition(this.#followPosition);
|
|
322
|
+
this.#at.x = fx;
|
|
323
|
+
this.#at.z = fz;
|
|
324
|
+
const keep = new Set<number>();
|
|
325
|
+
for (const run of this.#runs) {
|
|
326
|
+
const shape = COLLIDERS[run.asset];
|
|
327
|
+
if (!shape) continue;
|
|
328
|
+
for (let i = run.offset; i < run.offset + run.count; i++) {
|
|
329
|
+
const x = this.records[i * 8] as number;
|
|
330
|
+
const z = this.records[i * 8 + 2] as number;
|
|
331
|
+
if (Math.hypot(x - fx, z - fz) > PROP_COLLIDER_REACH) continue;
|
|
332
|
+
keep.add(i);
|
|
333
|
+
if (this.#live.has(i)) continue;
|
|
334
|
+
const collider = CollisionShape3D.sphere(
|
|
335
|
+
shape.sphere * (this.records[i * 8 + 7] as number),
|
|
336
|
+
);
|
|
337
|
+
this.#live.set(
|
|
338
|
+
i,
|
|
339
|
+
new RigidBody3D({
|
|
340
|
+
physics: this.ctx.physics,
|
|
341
|
+
type: "fixed",
|
|
342
|
+
entity: `${run.asset}.${String(i)}`,
|
|
343
|
+
position: { x, y: this.records[i * 8 + 1] as number, z },
|
|
344
|
+
shape: collider,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
for (const [i, body] of this.#live)
|
|
350
|
+
if (!keep.has(i)) {
|
|
351
|
+
body.dispose();
|
|
352
|
+
this.#live.delete(i);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The bake's heights on the world's grid, in metres, row-major by Z then X. The sea's shoreline, surf and
|
|
359
|
+
* depth tint are read from these, so they match the terrain the player stands on.
|
|
360
|
+
*/
|
|
361
|
+
function coastalSeaData(
|
|
362
|
+
manifest: IWorldPackage,
|
|
363
|
+
heightmap: Uint16Array,
|
|
364
|
+
seaLevel: number,
|
|
365
|
+
): ICoastalSeaData {
|
|
366
|
+
const { terrain, extent } = manifest;
|
|
367
|
+
const heights = Float32Array.from(
|
|
368
|
+
heightmap,
|
|
369
|
+
(value) => terrain.heightMin + (value / 65535) * (terrain.heightMax - terrain.heightMin),
|
|
370
|
+
);
|
|
371
|
+
return { heights, resolution: terrain.columns, size: extent.sizeX, waterLevel: seaLevel };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function fetchAsset(
|
|
375
|
+
ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
|
|
376
|
+
url: string,
|
|
377
|
+
assertCurrent: () => void,
|
|
378
|
+
): Promise<Response> {
|
|
379
|
+
const [resolved] = await ctx.assets.resolve(url);
|
|
380
|
+
assertCurrent();
|
|
381
|
+
if (resolved === undefined) throw new Error(`Coastal world: '${url}' is not served.`);
|
|
382
|
+
const response = await fetch(resolved);
|
|
383
|
+
assertCurrent();
|
|
384
|
+
if (!response.ok) throw new Error(`Coastal world: '${url}' answered ${String(response.status)}.`);
|
|
385
|
+
return response;
|
|
386
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Desert starter kit (placeholder scatter)
|
|
2
|
+
|
|
3
|
+
A 512 m desert on a real survey (USGS 1 m DEM, Colorado), with hydraulic and thermal erosion, sand
|
|
4
|
+
on the low ground, dirt patches, rock on the steep ground and a scatter of 140 props. It is game
|
|
5
|
+
source: once copied, every file is yours to edit.
|
|
6
|
+
|
|
7
|
+
| File | What it decides |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `recipe.json` | The terrain: the survey heightmap, erosion, the sand, dirt and rock surfaces, and the scatter. |
|
|
10
|
+
| `assets.json` | The boulder model, its LOD distance and draw distance, and its bounds. |
|
|
11
|
+
| `surface.json` | The ground: the sand base, the dirt and rock layers, tiles, tints and blend thresholds. |
|
|
12
|
+
| `bake.mjs` | Writes the world package and `world/water.json` from the three files above. |
|
|
13
|
+
| `world.ts` | The runtime: streams the baked world, one heightfield for the ground, sphere colliders for the boulders near the player, and the sky as the props' light. |
|
|
14
|
+
| `sky.ts` | The daylight: the sun, sky, fill, haze, exposure and tone curve. |
|
|
15
|
+
|
|
16
|
+
Art is CC0 (Poly Haven); provenance is in `@threenative/terrain/starter-assets/credits.json`.
|
|
17
|
+
|
|
18
|
+
## Placeholder
|
|
19
|
+
|
|
20
|
+
The preview's desert scrub is drawn from generated cards, and the starter assets do not ship a CC0
|
|
21
|
+
shrub model. Until one is added, this kit scatters the CC0 boulder in the scrub's place, with the
|
|
22
|
+
same count, spacing and slope limits. The result does not look like the preview's desert. Replace the
|
|
23
|
+
`scrub` entry in `recipe.json` and the boulder row in `assets.json` with a CC0 shrub model to fix it.
|
|
24
|
+
|
|
25
|
+
## Status
|
|
26
|
+
|
|
27
|
+
The kit bakes and streams. The kit ships no scene: the game writes one. The placeholder boulders are
|
|
28
|
+
colliders like any other prop, and the scene picks its spawn from the loaded heightfield.
|
|
29
|
+
|
|
30
|
+
## Steps
|
|
31
|
+
|
|
32
|
+
1. Copy `node_modules/@threenative/terrain/starter/desert` to `src/terrain/desert`.
|
|
33
|
+
2. Run `node src/terrain/desert/bake.mjs --out assets/terrain/desert` (needs `@threenative/terrain`
|
|
34
|
+
as a devDependency). The same recipe always writes the same bytes.
|
|
35
|
+
3. In the scene, call `addDesert(ctx, follow)` for the world and `desertDaylight(follow)` for the light.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Bakes this folder's recipe into a self-contained world package. Run it once from your game
|
|
2
|
+
// project, then serve the folder it writes:
|
|
3
|
+
// node src/terrain/desert/bake.mjs [--assets <dir>] [--out <dir>]
|
|
4
|
+
// The engine's `WorldCells` streams `world/world.json` and `loadTerrainSplat` textures its ground;
|
|
5
|
+
// the game never evaluates terrain at runtime.
|
|
6
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { Terrain, applyPlacementOverrides, bakeWorldPackage } from "@threenative/terrain";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** `--key value` after the script; `--assets` and `--out` default as noted below. */
|
|
14
|
+
function flag(name, fallback) {
|
|
15
|
+
const at = process.argv.indexOf(`--${name}`);
|
|
16
|
+
return at === -1 ? fallback : resolve(process.argv[at + 1] ?? "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The installed kit ships its models and textures beside the package manifest, so a copied kit
|
|
20
|
+
// finds them without asking the game for a path. `--assets` overrides that for a local checkout.
|
|
21
|
+
const installed = dirname(fileURLToPath(import.meta.resolve("@threenative/terrain/package.json")));
|
|
22
|
+
const assetsDir = flag("assets", join(installed, "starter-assets"));
|
|
23
|
+
const outDir = flag("out", join(here, "world"));
|
|
24
|
+
|
|
25
|
+
const recipe = JSON.parse(await readFile(join(here, "recipe.json"), "utf8"));
|
|
26
|
+
const assetTable = JSON.parse(await readFile(join(here, "assets.json"), "utf8"));
|
|
27
|
+
const surface = JSON.parse(await readFile(join(here, "surface.json"), "utf8"));
|
|
28
|
+
|
|
29
|
+
const state = applyPlacementOverrides(
|
|
30
|
+
Terrain.fromJSON(recipe.recipe).evaluate(),
|
|
31
|
+
recipe.placementOverrides ?? {},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const assets = Object.fromEntries(
|
|
35
|
+
Object.entries(assetTable).map(([id, spec]) => [
|
|
36
|
+
id,
|
|
37
|
+
{
|
|
38
|
+
bounds: spec.bounds,
|
|
39
|
+
glb: `models/${spec.near.split("/").at(-1)}`,
|
|
40
|
+
...(spec.mid
|
|
41
|
+
? { lods: [{ distance: spec.midDistance, glb: `models/${spec.mid.split("/").at(-1)}` }] }
|
|
42
|
+
: {}),
|
|
43
|
+
maxDistance: spec.maxDistance,
|
|
44
|
+
},
|
|
45
|
+
]),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const started = performance.now();
|
|
49
|
+
const { manifest, splat, files } = bakeWorldPackage(state, {
|
|
50
|
+
assets,
|
|
51
|
+
layers: { table: "terrain-table.json" },
|
|
52
|
+
});
|
|
53
|
+
const written = new Map();
|
|
54
|
+
|
|
55
|
+
async function write(relative, bytes) {
|
|
56
|
+
await mkdir(join(outDir, dirname(relative)), { recursive: true });
|
|
57
|
+
await writeFile(join(outDir, relative), bytes);
|
|
58
|
+
written.set(relative, bytes.length ?? bytes.byteLength);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function copy(from, to) {
|
|
62
|
+
await mkdir(join(outDir, dirname(to)), { recursive: true });
|
|
63
|
+
await copyFile(join(assetsDir, from), join(outDir, to));
|
|
64
|
+
written.set(to, (await readFile(join(outDir, to))).byteLength);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await write("world.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
68
|
+
// The lake and river the generator evaluated, so the game draws the water the bake shaped.
|
|
69
|
+
const round = (value) => Math.round(value * 1_000) / 1_000;
|
|
70
|
+
await write(
|
|
71
|
+
"water.json",
|
|
72
|
+
`${JSON.stringify(
|
|
73
|
+
{
|
|
74
|
+
lakes: state.waters.map((lake) => ({
|
|
75
|
+
id: lake.id,
|
|
76
|
+
at: lake.at,
|
|
77
|
+
radius: lake.radius,
|
|
78
|
+
level: lake.level,
|
|
79
|
+
})),
|
|
80
|
+
rivers: state.rivers.map((river) => ({
|
|
81
|
+
id: river.id,
|
|
82
|
+
width: round(river.width),
|
|
83
|
+
points: river.points.map((point) => point.map(round)),
|
|
84
|
+
})),
|
|
85
|
+
},
|
|
86
|
+
null,
|
|
87
|
+
2,
|
|
88
|
+
)}\n`,
|
|
89
|
+
);
|
|
90
|
+
for (const [name, bytes] of Object.entries(files)) await write(name, bytes);
|
|
91
|
+
for (const spec of Object.values(assetTable)) {
|
|
92
|
+
await copy(spec.near, `models/${spec.near.split("/").at(-1)}`);
|
|
93
|
+
if (spec.mid) await copy(spec.mid, `models/${spec.mid.split("/").at(-1)}`);
|
|
94
|
+
}
|
|
95
|
+
// The sky the props are lit by (CC0 Poly Haven Kloofendal); world.ts gives it to their materials.
|
|
96
|
+
await copy("hdri/kloofendal_48d_partly_cloudy_1k.hdr", "sky.hdr");
|
|
97
|
+
for (const layer of [surface.base, ...surface.layers]) {
|
|
98
|
+
const source = surface.sources[layer.id];
|
|
99
|
+
await copy(source.diff, `${surface.textures}/${layer.id}_diff.jpg`);
|
|
100
|
+
await copy(source.nrm, `${surface.textures}/${layer.id}_nrm.jpg`);
|
|
101
|
+
}
|
|
102
|
+
// `sources` is the bake's own lookup: the runtime reads only base/layers/splat/textures.
|
|
103
|
+
await write(
|
|
104
|
+
"terrain-table.json",
|
|
105
|
+
`${JSON.stringify({ ...surface, splat, textures: "textures" }, null, 2)}\n`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const perAsset = {};
|
|
109
|
+
for (const cell of manifest.cells)
|
|
110
|
+
for (const run of cell.runs) perAsset[run.asset] = (perAsset[run.asset] ?? 0) + run.count;
|
|
111
|
+
const total = [...written.values()].reduce((sum, bytes) => sum + bytes, 0);
|
|
112
|
+
console.log(
|
|
113
|
+
`${Object.entries(perAsset)
|
|
114
|
+
.map(([asset, count]) => `${asset} ${String(count)}`)
|
|
115
|
+
.join(", ")} placements; ${[...written]
|
|
116
|
+
.map(([name, bytes]) => `${name} ${String(bytes)} B`)
|
|
117
|
+
.join(", ")}; ${String(total)} B total; ${Math.round(performance.now() - started)} ms`,
|
|
118
|
+
);
|