@threenative/terrain 0.0.0-stage → 0.1.2

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.
Files changed (125) hide show
  1. package/AGENT_GUIDE.md +194 -0
  2. package/LICENSE +21 -0
  3. package/README.md +71 -2
  4. package/THIRD_PARTY_NOTICES.md +38 -0
  5. package/dist/editor/index.d.ts +231 -0
  6. package/dist/editor/index.js +5624 -0
  7. package/dist/editor/server.d.ts +3 -0
  8. package/dist/editor/server.js +3688 -0
  9. package/dist/editor/worker.d.ts +2 -0
  10. package/dist/editor/worker.js +2212 -0
  11. package/dist/export.d.ts +54 -0
  12. package/dist/export.js +297 -0
  13. package/dist/index.d.ts +335 -0
  14. package/dist/index.js +2927 -0
  15. package/dist/server-ByM2hmYJ.d.ts +630 -0
  16. package/dist/terrain-gPnkCU6S.d.ts +141 -0
  17. package/dist/three.d.ts +14 -0
  18. package/dist/three.js +40 -0
  19. package/dist/types-Ant8-Axc.d.ts +486 -0
  20. package/package.json +86 -3
  21. package/starter/alpine/README.md +29 -0
  22. package/starter/alpine/assets.json +12 -0
  23. package/starter/alpine/bake.mjs +118 -0
  24. package/starter/alpine/recipe.json +66167 -0
  25. package/starter/alpine/sky.ts +23 -0
  26. package/starter/alpine/surface.json +53 -0
  27. package/starter/alpine/world.ts +335 -0
  28. package/starter/coastal/README.md +37 -0
  29. package/starter/coastal/assets.json +10 -0
  30. package/starter/coastal/bake.mjs +118 -0
  31. package/starter/coastal/recipe.json +66161 -0
  32. package/starter/coastal/sea.ts +327 -0
  33. package/starter/coastal/sky.ts +24 -0
  34. package/starter/coastal/surface.json +41 -0
  35. package/starter/coastal/world.ts +386 -0
  36. package/starter/desert/README.md +35 -0
  37. package/starter/desert/assets.json +12 -0
  38. package/starter/desert/bake.mjs +118 -0
  39. package/starter/desert/recipe.json +66156 -0
  40. package/starter/desert/sky.ts +23 -0
  41. package/starter/desert/surface.json +54 -0
  42. package/starter/desert/world.ts +335 -0
  43. package/starter/forest/README.md +46 -0
  44. package/starter/forest/assets.json +32 -0
  45. package/starter/forest/bake.mjs +118 -0
  46. package/starter/forest/recipe.json +4586 -0
  47. package/starter/forest/sky.ts +27 -0
  48. package/starter/forest/surface.json +79 -0
  49. package/starter/forest/water.ts +266 -0
  50. package/starter/forest/world.ts +413 -0
  51. package/starter/tundra/README.md +29 -0
  52. package/starter/tundra/assets.json +12 -0
  53. package/starter/tundra/bake.mjs +118 -0
  54. package/starter/tundra/recipe.json +66325 -0
  55. package/starter/tundra/sky.ts +27 -0
  56. package/starter/tundra/surface.json +54 -0
  57. package/starter/tundra/water.ts +313 -0
  58. package/starter/tundra/world.ts +366 -0
  59. package/starter-assets/bark_brown_02/bark_brown_02_diff_1k.jpg +0 -0
  60. package/starter-assets/bark_brown_02/bark_brown_02_nor_gl_1k.jpg +0 -0
  61. package/starter-assets/bark_brown_02/bark_brown_02_rough_1k.jpg +0 -0
  62. package/starter-assets/boulder_01/boulder-mid.glb +0 -0
  63. package/starter-assets/boulder_01/boulder-near.glb +0 -0
  64. package/starter-assets/cliff_side/cliff_side_ao_1k.jpg +0 -0
  65. package/starter-assets/cliff_side/cliff_side_diff_1k.jpg +0 -0
  66. package/starter-assets/cliff_side/cliff_side_nor_gl_1k.jpg +0 -0
  67. package/starter-assets/cliff_side/cliff_side_rough_1k.jpg +0 -0
  68. package/starter-assets/credits.json +1022 -0
  69. package/starter-assets/fern_02/fern_02_alpha_512.png +0 -0
  70. package/starter-assets/fern_02/fern_02_diff_512.jpg +0 -0
  71. package/starter-assets/fern_02/fern_02_nor_gl_512.jpg +0 -0
  72. package/starter-assets/fir_tree_01/fir-b-lod1.glb +0 -0
  73. package/starter-assets/fir_tree_01/fir-b-lod2.glb +0 -0
  74. package/starter-assets/fir_tree_01/fir-c-lod1.glb +0 -0
  75. package/starter-assets/fir_tree_01/fir-c-lod2.glb +0 -0
  76. package/starter-assets/fir_tree_01/fir_tree_01_twig_arm_1k.jpg +0 -0
  77. package/starter-assets/fir_tree_01/fir_tree_01_twig_diff_1k.jpg +0 -0
  78. package/starter-assets/fir_tree_01/fir_tree_01_twig_nor_gl_1k.jpg +0 -0
  79. package/starter-assets/forest_ground_04/forest_ground_04_ao_1k.jpg +0 -0
  80. package/starter-assets/forest_ground_04/forest_ground_04_diff_1k.jpg +0 -0
  81. package/starter-assets/forest_ground_04/forest_ground_04_nor_gl_1k.jpg +0 -0
  82. package/starter-assets/forest_ground_04/forest_ground_04_rough_1k.jpg +0 -0
  83. package/starter-assets/forest_leaves_02/forest_leaves_02_arm_512.jpg +0 -0
  84. package/starter-assets/forest_leaves_02/forest_leaves_02_diff_512.jpg +0 -0
  85. package/starter-assets/forest_leaves_02/forest_leaves_02_nor_gl_512.jpg +0 -0
  86. package/starter-assets/grass_medium_02/grass_medium_02_alpha_512.png +0 -0
  87. package/starter-assets/grass_medium_02/grass_medium_02_diff_512.jpg +0 -0
  88. package/starter-assets/grass_medium_02/grass_medium_02_nor_gl_512.jpg +0 -0
  89. package/starter-assets/hdri/kloofendal_48d_partly_cloudy_1k.hdr +0 -0
  90. package/starter-assets/leafy_grass/leafy_grass_ao_1k.jpg +0 -0
  91. package/starter-assets/leafy_grass/leafy_grass_diff_1k.jpg +0 -0
  92. package/starter-assets/leafy_grass/leafy_grass_nor_gl_1k.jpg +0 -0
  93. package/starter-assets/leafy_grass/leafy_grass_rough_1k.jpg +0 -0
  94. package/starter-assets/lichen_rock/lichen_rock_arm_512.jpg +0 -0
  95. package/starter-assets/lichen_rock/lichen_rock_diff_512.jpg +0 -0
  96. package/starter-assets/lichen_rock/lichen_rock_nor_gl_512.jpg +0 -0
  97. package/starter-assets/mossy_rock/mossy_rock_ao_1k.jpg +0 -0
  98. package/starter-assets/mossy_rock/mossy_rock_diff_1k.jpg +0 -0
  99. package/starter-assets/mossy_rock/mossy_rock_nor_gl_1k.jpg +0 -0
  100. package/starter-assets/mossy_rock/mossy_rock_rough_1k.jpg +0 -0
  101. package/starter-assets/needle-atlas.png +0 -0
  102. package/starter-assets/needle-surface.png +0 -0
  103. package/starter-assets/river_small_rocks/river_small_rocks_arm_512.jpg +0 -0
  104. package/starter-assets/river_small_rocks/river_small_rocks_diff_512.jpg +0 -0
  105. package/starter-assets/river_small_rocks/river_small_rocks_nor_gl_512.jpg +0 -0
  106. package/starter-assets/rock_boulder_dry/rock_boulder_dry_arm_512.jpg +0 -0
  107. package/starter-assets/rock_boulder_dry/rock_boulder_dry_diff_512.jpg +0 -0
  108. package/starter-assets/rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg +0 -0
  109. package/starter-assets/rocks/boulder-mid.glb +0 -0
  110. package/starter-assets/rocks/boulder-near.glb +0 -0
  111. package/starter-assets/rocks/boulder-prep.json +37 -0
  112. package/starter-assets/rocks/rock01-mid.glb +0 -0
  113. package/starter-assets/rocks/rock01-near.glb +0 -0
  114. package/starter-assets/rocks/rock01-prep.json +37 -0
  115. package/starter-assets/rocks/rock04-mid.glb +0 -0
  116. package/starter-assets/rocks/rock04-near.glb +0 -0
  117. package/starter-assets/rocks/rock04-prep.json +37 -0
  118. package/starter-assets/sand_01/sand_01_ao_1k.jpg +0 -0
  119. package/starter-assets/sand_01/sand_01_diff_1k.jpg +0 -0
  120. package/starter-assets/sand_01/sand_01_nor_gl_1k.jpg +0 -0
  121. package/starter-assets/sand_01/sand_01_rough_1k.jpg +0 -0
  122. package/starter-assets/snow_02/snow_02_ao_1k.jpg +0 -0
  123. package/starter-assets/snow_02/snow_02_diff_1k.jpg +0 -0
  124. package/starter-assets/snow_02/snow_02_nor_gl_1k.jpg +0 -0
  125. package/starter-assets/snow_02/snow_02_rough_1k.jpg +0 -0
@@ -0,0 +1,413 @@
1
+ // The forest starter world, as the game that copied it draws and collides with it.
2
+ //
3
+ // `bake.mjs` wrote a world package (heightmap, splat masks, placements, CC0 models) into `world/`;
4
+ // the engine's `WorldCells` streams it, `loadTerrainSplat` textures its ground, and everything
5
+ // 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 IWorldPackage,
10
+ WorldCells,
11
+ heightSamplerFromHeightmap,
12
+ loadTerrainSplat,
13
+ loadWorldHeightmap,
14
+ } from "@threenative/core/world";
15
+ import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics";
16
+ import {
17
+ type Box3,
18
+ type BufferGeometry,
19
+ Color,
20
+ EquirectangularReflectionMapping,
21
+ type Material,
22
+ type Mesh,
23
+ type MeshStandardMaterial,
24
+ Object3D,
25
+ type Texture,
26
+ Vector3,
27
+ } from "three";
28
+ import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
29
+ import { type IForestWater, type IForestWaterFile, addForestWater } from "./water.js";
30
+
31
+ /** Where the game's asset source holds the baked `world/` folder. */
32
+ export const FOREST_URL = "terrain/forest/world.json";
33
+
34
+ /**
35
+ * What stops the player, per placed asset, in metres at the placement's scale 1. `null` walks
36
+ * through. A capsule stands on the placement's origin. Change a row to change every copy.
37
+ */
38
+ export const COLLIDERS: Readonly<
39
+ Record<
40
+ string,
41
+ { capsule: readonly [halfHeight: number, radius: number] } | { sphere: number } | null
42
+ >
43
+ > = {
44
+ fir: { capsule: [3, 0.35] },
45
+ "fir-c": { capsule: [3, 0.35] },
46
+ boulder: { sphere: 0.9 },
47
+ };
48
+
49
+ /** How strongly the sky lights the props; the sun stays the dominant light on a crown. */
50
+ export const PROP_SKY_LIGHT = 0.65;
51
+ /** Multiplies every boulder's albedo: the CC0 scan photographed pink-orange, the forest's stone is cool grey. */
52
+ export const BOULDER_TINT = new Color(0.6, 0.74, 0.86);
53
+
54
+ export interface IForestWorld {
55
+ readonly world: WorldCells;
56
+ /** One heightfield for the whole world, in place before `addForest` resolves. */
57
+ readonly ground: RigidBody3D;
58
+ /** The prop colliders near `follow`; they stream with it (see `PROP_COLLIDER_REACH`). */
59
+ readonly colliders: PropColliders;
60
+ /** The lake and river the bake wrote, drawn beside the ground (see `water.ts`). */
61
+ readonly water: IForestWater;
62
+ }
63
+
64
+ /** Metres around `follow` within which props collide; beyond it they only draw. */
65
+ export const PROP_COLLIDER_REACH = 60;
66
+
67
+ /**
68
+ * Stream the forest around `follow` (usually the player or the camera) and give it collision.
69
+ * `whileCurrent` prevents a departed scene's async load from attaching anything; its resources
70
+ * are released on cancellation, failure and when the game's entity registry ends this scene.
71
+ */
72
+ export async function addForest(
73
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
74
+ follow: Object3D,
75
+ url = FOREST_URL,
76
+ whileCurrent: () => boolean = () => true,
77
+ ): Promise<IForestWorld> {
78
+ const assertCurrent = () => {
79
+ if (whileCurrent()) return;
80
+ const error = new Error("Forest world: its scene is no longer current.");
81
+ error.name = "AbortError";
82
+ throw error;
83
+ };
84
+ let surface: Material | undefined;
85
+ let releaseLighting: (() => void) | undefined;
86
+ let world: WorldCells | undefined;
87
+ let ground: RigidBody3D | undefined;
88
+ let colliders: PropColliders | undefined;
89
+ let water: IForestWater | undefined;
90
+ let released = false;
91
+ const dispose = () => {
92
+ if (released) return;
93
+ released = true;
94
+ colliders?.detach();
95
+ colliders?.removeFromParent();
96
+ water?.dispose();
97
+ ground?.dispose();
98
+ ground?.object?.removeFromParent();
99
+ world?.dispose();
100
+ surface?.dispose();
101
+ releaseLighting?.();
102
+ };
103
+ try {
104
+ assertCurrent();
105
+ const manifest = (await (await fetchAsset(ctx, url, assertCurrent)).json()) as IWorldPackage;
106
+ assertCurrent();
107
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
108
+ const records = await (
109
+ await fetchAsset(ctx, base + manifest.placements, assertCurrent)
110
+ ).arrayBuffer();
111
+ assertCurrent();
112
+ const [heightmapUrl] = await ctx.assets.resolve(base + manifest.terrain.heightmap);
113
+ assertCurrent();
114
+ if (heightmapUrl === undefined) throw new Error("Forest world: its heightmap is not served.");
115
+ const heightmap = await loadWorldHeightmap(heightmapUrl);
116
+ assertCurrent();
117
+ surface = await loadTerrainSplat({ assets: ctx.assets, url });
118
+ assertCurrent();
119
+ releaseLighting = await lightProps(ctx, url, manifest, assertCurrent);
120
+ assertCurrent();
121
+ world = await WorldCells.load({
122
+ url,
123
+ assets: ctx.assets,
124
+ data: { manifest, placements: records, heightmap },
125
+ surface,
126
+ follow,
127
+ ring: 5,
128
+ // Far firs draw as engine-baked impostors; the authored model stays the shadow caster.
129
+ impostors: true,
130
+ // The GPU-culled path drops the near firs from the main pass while their shadows still draw
131
+ // (measured in a fresh game, 2026-10-05); the CPU path draws them. Engine finding, PRD-466.
132
+ gpuScene: false,
133
+ // Trees and rocks shade the ground and each other; impostors keep the authored model as the caster.
134
+ shadows: { cast: true, receive: true },
135
+ budgets: { residentCells: 64, instances: 40_000, bytes: 64_000_000 },
136
+ terrain: { streamRadius: 6 },
137
+ });
138
+ assertCurrent();
139
+ const field = forestField(manifest, heightmap);
140
+ ground = groundCollider(ctx, manifest, field);
141
+ const waterFile = (await (
142
+ await fetchAsset(ctx, `${base}water.json`, assertCurrent)
143
+ ).json()) as IForestWaterFile;
144
+ assertCurrent();
145
+ const forestWater = addForestWater(ctx, waterFile, field);
146
+ water = forestWater;
147
+ colliders = new PropColliders(ctx, follow, manifest, new Float32Array(records));
148
+ ctx.add(world);
149
+ if (ground.object !== undefined) ctx.add(ground.object);
150
+ ctx.add(colliders);
151
+ ctx.entities.add(`forest-world.${world.uuid}`, { dispose });
152
+ return { world, ground, colliders, water: forestWater };
153
+ } catch (error) {
154
+ dispose();
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ // Cutout foliage with no environment draws flat and dark (the engine says TN_UNLIT_FOLIAGE): give
160
+ // every prop the sky as its own envMap before WorldCells adopts the shared cached models. Foliage
161
+ // cards also get normals bent out from the crown's axis, so a crown shades as one soft volume
162
+ // instead of a stack of flat cards, and lose the glossy specular glTF exporters give them.
163
+ async function lightProps(
164
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
165
+ url: string,
166
+ manifest: IWorldPackage,
167
+ assertCurrent: () => void,
168
+ ): Promise<() => void> {
169
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
170
+ const [skyUrl] = await ctx.assets.resolve(`${base}sky.hdr`);
171
+ assertCurrent();
172
+ if (skyUrl === undefined) throw new Error("Forest world: 'sky.hdr' is not served.");
173
+ const sky = await new HDRLoader().loadAsync(skyUrl);
174
+ const originals = new Map<
175
+ MeshStandardMaterial,
176
+ { color: Color; envMap: Texture | null; intensity: number }
177
+ >();
178
+ const dispose = () => {
179
+ for (const [material, original] of originals) {
180
+ if (material.envMap !== sky) continue;
181
+ material.envMap = original.envMap;
182
+ material.envMapIntensity = original.intensity;
183
+ material.color.copy(original.color);
184
+ }
185
+ sky.dispose();
186
+ };
187
+ try {
188
+ assertCurrent();
189
+ sky.mapping = EquirectangularReflectionMapping;
190
+ // Every LOD of an asset, not only its nearest: a far boulder left undressed shows its raw scan.
191
+ const models = Object.entries(manifest.assets).flatMap(([id, asset]) =>
192
+ [asset.glb, ...(asset.lods ?? []).map((lod) => lod.glb)].map((glb) => ({ id, glb })),
193
+ );
194
+ for (const { id, glb } of models) {
195
+ const model = await ctx.assets.model<{ scene: Object3D }>(base + glb);
196
+ assertCurrent();
197
+ model.scene.traverse((object) => {
198
+ const mesh = object as Mesh;
199
+ for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[])
200
+ if (!originals.has(material))
201
+ originals.set(material, {
202
+ color: material.color.clone(),
203
+ envMap: material.envMap,
204
+ intensity: material.envMapIntensity,
205
+ });
206
+ dressProp(mesh, sky, id.startsWith("boulder") ? BOULDER_TINT : undefined, originals);
207
+ });
208
+ }
209
+ return dispose;
210
+ } catch (error) {
211
+ dispose();
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ /** One prop mesh: the sky as its envMap, and matte, crown-shaded foliage. */
217
+ function dressProp(
218
+ mesh: Mesh,
219
+ sky: Texture,
220
+ tint: Color | undefined,
221
+ originals: ReadonlyMap<MeshStandardMaterial, { color: Color }>,
222
+ ): void {
223
+ const surfaces = [mesh.material ?? []].flat() as MeshStandardMaterial[];
224
+ const foliage = surfaces.some((material) => material.alphaTest > 0);
225
+ for (const material of surfaces) {
226
+ material.envMap = sky;
227
+ material.envMapIntensity = foliage ? FOLIAGE_SKY_LIGHT : PROP_SKY_LIGHT;
228
+ const original = originals.get(material);
229
+ if (tint !== undefined && original !== undefined)
230
+ material.color.copy(original.color).multiply(tint);
231
+ if (material.alphaTest <= 0) continue;
232
+ material.roughness = 0.85;
233
+ (material as MeshStandardMaterial & { specularIntensity?: number }).specularIntensity = 0.15;
234
+ }
235
+ if (foliage && mesh.isMesh) bendCrownNormals(mesh.geometry, CROWN_NORMAL_BEND);
236
+ }
237
+
238
+ /** How strongly the sky lights foliage, which the sun alone leaves near-black from most sides. */
239
+ export const FOLIAGE_SKY_LIGHT = 2.2;
240
+ /** 0 keeps each card's own normal, 1 points every normal straight out from the crown's axis. */
241
+ export const CROWN_NORMAL_BEND = 0.7;
242
+
243
+ function bendCrownNormals(geometry: BufferGeometry, bend: number): void {
244
+ const position = geometry.getAttribute("position");
245
+ const normal = geometry.getAttribute("normal");
246
+ if (position === undefined || normal === undefined) return;
247
+ geometry.computeBoundingBox();
248
+ const box = geometry.boundingBox as Box3;
249
+ const midY = (box.min.y + box.max.y) / 2;
250
+ const out = new Vector3();
251
+ const own = new Vector3();
252
+ for (let i = 0; i < position.count; i++) {
253
+ out.set(position.getX(i), (position.getY(i) - midY) * 0.5, position.getZ(i)).normalize();
254
+ own.fromBufferAttribute(normal, i).lerp(out, bend).normalize();
255
+ normal.setXYZ(i, own.x, own.y, own.z);
256
+ }
257
+ normal.needsUpdate = true;
258
+ }
259
+
260
+ // The streamed terrain tiles arrive over several frames; a player spawned before its tile would fall
261
+ // through. One heightfield for the whole package (a few hundred kilobytes of samples) avoids that.
262
+ /** The kit's one heightfield: the collider stands on it and the water's shore reads it. */
263
+ function forestField(manifest: IWorldPackage, heightmap: Uint16Array): Heightfield {
264
+ const { extent, terrain } = manifest;
265
+ return Heightfield.fromSampler({
266
+ columns: terrain.columns,
267
+ rows: terrain.rows,
268
+ width: extent.sizeX,
269
+ depth: extent.sizeZ,
270
+ origin: { x: extent.minX + extent.sizeX / 2, z: extent.minZ + extent.sizeZ / 2 },
271
+ sampleHeight: heightSamplerFromHeightmap(terrain, extent, heightmap),
272
+ });
273
+ }
274
+
275
+ function groundCollider(
276
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
277
+ manifest: IWorldPackage,
278
+ field: Heightfield,
279
+ ): RigidBody3D {
280
+ const { extent } = manifest;
281
+ const anchor = new Object3D();
282
+ anchor.position.set(extent.minX + extent.sizeX / 2, 0, extent.minZ + extent.sizeZ / 2);
283
+ return new RigidBody3D({
284
+ object: anchor,
285
+ physics: ctx.physics,
286
+ type: "fixed",
287
+ entity: "terrain",
288
+ shape: CollisionShape3D.heightfield(field.rows, field.columns, field.toColliderHeights(), {
289
+ x: extent.sizeX,
290
+ y: 1,
291
+ z: extent.sizeZ,
292
+ }),
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Fixed bodies for the props within `PROP_COLLIDER_REACH` of `follow`, rebuilt when it has moved a
298
+ * few metres. Thousands of resident bodies cost Rapier ~0.5 s per 8 s (measured); a hundred do not.
299
+ */
300
+ export class PropColliders extends Object3D implements IComputeDriven {
301
+ readonly warmupNodes: readonly unknown[] = [];
302
+ readonly processCadence = "render" as const;
303
+ readonly #live = new Map<number, RigidBody3D>();
304
+ readonly #runs: { asset: string; offset: number; count: number }[] = [];
305
+ readonly #at = { x: Number.NaN, z: Number.NaN };
306
+ readonly #followPosition = new Vector3();
307
+ #released = false;
308
+
309
+ constructor(
310
+ private readonly ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
311
+ private readonly follow: Object3D,
312
+ manifest: IWorldPackage,
313
+ private readonly records: Float32Array,
314
+ ) {
315
+ super();
316
+ this.name = "forest-prop-colliders";
317
+ for (const cell of manifest.cells)
318
+ for (const run of cell.runs) if (COLLIDERS[run.asset]) this.#runs.push(run);
319
+ try {
320
+ this.#rebuild();
321
+ } catch (error) {
322
+ this.detach();
323
+ throw error;
324
+ }
325
+ }
326
+
327
+ /** How many prop bodies exist right now. */
328
+ get active(): number {
329
+ return this.#live.size;
330
+ }
331
+
332
+ get released(): boolean {
333
+ return this.#released;
334
+ }
335
+
336
+ attachRenderer(): void {}
337
+
338
+ process(): void {
339
+ if (this.#released) return;
340
+ const p = this.follow.getWorldPosition(this.#followPosition);
341
+ if (Math.hypot(p.x - this.#at.x, p.z - this.#at.z) > 4 || Number.isNaN(this.#at.x))
342
+ this.#rebuild();
343
+ }
344
+
345
+ detach(): void {
346
+ for (const body of this.#live.values()) body.dispose();
347
+ this.#live.clear();
348
+ this.#released = true;
349
+ }
350
+
351
+ #rebuild(): void {
352
+ const { x: fx, z: fz } = this.follow.getWorldPosition(this.#followPosition);
353
+ this.#at.x = fx;
354
+ this.#at.z = fz;
355
+ const keep = new Set<number>();
356
+ for (const run of this.#runs) {
357
+ const shape = COLLIDERS[run.asset];
358
+ if (!shape) continue;
359
+ for (let i = run.offset; i < run.offset + run.count; i++) {
360
+ const x = this.records[i * 8] as number;
361
+ const z = this.records[i * 8 + 2] as number;
362
+ if (Math.hypot(x - fx, z - fz) > PROP_COLLIDER_REACH) continue;
363
+ keep.add(i);
364
+ if (this.#live.has(i)) continue;
365
+ const { lift, collider } = colliderAt(shape, this.records[i * 8 + 7] as number);
366
+ this.#live.set(
367
+ i,
368
+ new RigidBody3D({
369
+ physics: this.ctx.physics,
370
+ type: "fixed",
371
+ entity: `${run.asset}.${String(i)}`,
372
+ position: { x, y: (this.records[i * 8 + 1] as number) + lift, z },
373
+ shape: collider,
374
+ }),
375
+ );
376
+ }
377
+ }
378
+ for (const [i, body] of this.#live)
379
+ if (!keep.has(i)) {
380
+ body.dispose();
381
+ this.#live.delete(i);
382
+ }
383
+ }
384
+ }
385
+
386
+ /** One table row at one placement's scale; a capsule stands on the origin, a sphere sits on it. */
387
+ function colliderAt(
388
+ shape: NonNullable<(typeof COLLIDERS)[string]>,
389
+ scale: number,
390
+ ): { lift: number; collider: CollisionShape3D } {
391
+ if ("sphere" in shape)
392
+ return { lift: 0, collider: CollisionShape3D.sphere(shape.sphere * scale) };
393
+ const [halfHeight, radius] = shape.capsule;
394
+ return {
395
+ lift: (halfHeight + radius) * scale,
396
+ collider: CollisionShape3D.capsule(halfHeight * scale, radius * scale),
397
+ };
398
+ }
399
+
400
+ async function fetchAsset(
401
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
402
+ path: string,
403
+ assertCurrent: () => void,
404
+ ): Promise<Response> {
405
+ const candidates = await ctx.assets.resolve(path);
406
+ assertCurrent();
407
+ for (const candidate of candidates) {
408
+ const response = await fetch(candidate);
409
+ assertCurrent();
410
+ if (response.ok) return response;
411
+ }
412
+ throw new Error(`Forest world: '${path}' is not served.`);
413
+ }
@@ -0,0 +1,29 @@
1
+ # Tundra starter kit
2
+
3
+ A 512 m low-relief tundra on a real survey (USGS 1 m DEM, Colorado), with hydraulic and thermal
4
+ erosion, two meltwater rivers, two kettle lakes, moss ground with snow and rock, and boulder
5
+ erratics. It is game 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 kettle beds, the two rivers, the two lakes, the surfaces and the erratics. |
10
+ | `assets.json` | The boulder model, its LOD distance and draw distance, and its bounds. |
11
+ | `surface.json` | The ground: the moss base, the snow and rock layers, tiles, tints and blend thresholds. |
12
+ | `bake.mjs` | Writes the world package and `world/water.json` (the rivers and lakes) 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. It also lights the lakes' mirror. |
15
+ | `water.ts` | The kettle lakes and meltwater rivers, drawn with a pale ice tint on the lakes. |
16
+
17
+ Art is CC0 (Poly Haven); provenance is in `@threenative/terrain/starter-assets/credits.json`.
18
+
19
+ ## Status
20
+
21
+ The kit bakes, streams and draws its water. The kit ships no scene: the game writes one. The terrain is
22
+ gentle, so the scene picks its spawn from the loaded heightfield and keeps it off the lake shores.
23
+
24
+ ## Steps
25
+
26
+ 1. Copy `node_modules/@threenative/terrain/starter/tundra` to `src/terrain/tundra`.
27
+ 2. Run `node src/terrain/tundra/bake.mjs --out assets/terrain/tundra` (needs `@threenative/terrain`
28
+ as a devDependency). The same recipe always writes the same bytes.
29
+ 3. In the scene, call `addTundra(ctx, follow)` for the world and `tundraDaylight(follow)` for the light.
@@ -0,0 +1,12 @@
1
+ {
2
+ "boulder": {
3
+ "near": "boulder_01/boulder-near.glb",
4
+ "mid": "boulder_01/boulder-mid.glb",
5
+ "midDistance": 35,
6
+ "maxDistance": 300,
7
+ "bounds": {
8
+ "min": [-0.716, 0, -0.991],
9
+ "max": [0.557, 1.004, 0.841]
10
+ }
11
+ }
12
+ }
@@ -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/tundra/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
+ );