@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.
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,23 @@
1
+ // The alpine world's daylight: the engine's sun, sky, fill, haze and tone curve, every value this world's.
2
+ // Edit any number here to change the look. Nothing in a package decides it.
3
+ import { Daylight } from "@threenative/core";
4
+ import { ACESFilmicToneMapping, Color, type Object3D, Vector3 } from "three";
5
+
6
+ export function alpineDaylight(follow: Object3D): Daylight {
7
+ return new Daylight({
8
+ follow,
9
+ // Mid-morning sun from the south-east, high enough for short readable shadows under the boulders.
10
+ sunDirection: new Vector3(0.55, 0.62, 0.56),
11
+ sunColor: new Color(0xfff1dc),
12
+ sunIntensity: 3.2,
13
+ shadowExtents: [24, 96, 320],
14
+ sky: { turbidity: 2, rayleigh: 3, mieCoefficient: 0.003, mieDirectionalG: 0.82 },
15
+ fill: { sky: new Color(0xa8c8e8), ground: new Color(0x464937), intensity: 0.62 },
16
+ // Enough air that the 512 m world's far edge fades instead of ending on a hard line.
17
+ haze: { color: new Color(0x9fb4c2), density: 0.0016 },
18
+ exposure: 2 ** -0.38,
19
+ toneMapping: ACESFilmicToneMapping,
20
+ // The box's corners must stay inside the camera's far plane (5000 m in the kit's example scene).
21
+ skySize: 5000,
22
+ });
23
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "base": {
3
+ "id": "rock",
4
+ "tile": 3.8,
5
+ "tint": [1, 1, 1],
6
+ "normal": true,
7
+ "triplanar": true
8
+ },
9
+ "breakup": {
10
+ "scale": 0.05,
11
+ "push": 0.12
12
+ },
13
+ "macro": {
14
+ "scale": 0.006,
15
+ "min": 0.75,
16
+ "max": 1.1
17
+ },
18
+ "layers": [
19
+ {
20
+ "id": "dirt",
21
+ "tile": 3.4,
22
+ "tint": [0.8, 0.72, 0.62],
23
+ "normal": true,
24
+ "mask": "dirt",
25
+ "lo": 0.45,
26
+ "hi": 0.85
27
+ },
28
+ {
29
+ "id": "snow",
30
+ "tile": 4.5,
31
+ "tint": [1, 1, 1],
32
+ "normal": true,
33
+ "mask": "snow",
34
+ "lo": 0.3,
35
+ "hi": 0.7
36
+ }
37
+ ],
38
+ "textures": "textures",
39
+ "sources": {
40
+ "rock": {
41
+ "diff": "rock_boulder_dry/rock_boulder_dry_diff_512.jpg",
42
+ "nrm": "rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg"
43
+ },
44
+ "dirt": {
45
+ "diff": "forest_ground_04/forest_ground_04_diff_1k.jpg",
46
+ "nrm": "forest_ground_04/forest_ground_04_nor_gl_1k.jpg"
47
+ },
48
+ "snow": {
49
+ "diff": "snow_02/snow_02_diff_1k.jpg",
50
+ "nrm": "snow_02/snow_02_nor_gl_1k.jpg"
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,335 @@
1
+ // The alpine 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 boulder.
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
+
28
+ /** Where the game's asset source holds the baked `world/` folder. */
29
+ export const ALPINE_URL = "terrain/alpine/world.json";
30
+
31
+ /**
32
+ * What stops the player, per placed asset, in metres at the placement's scale 1. `null` walks
33
+ * through. A sphere sits on the placement's origin. Change a row to change every copy.
34
+ */
35
+ export const COLLIDERS: Readonly<Record<string, { sphere: number } | null>> = {
36
+ boulder: { sphere: 0.9 },
37
+ };
38
+
39
+ /** How strongly the sky lights the props. The sun stays the dominant light on a boulder. */
40
+ export const PROP_SKY_LIGHT = 0.5;
41
+
42
+ /** Metres around `follow` within which props collide. Beyond that, props only draw. */
43
+ export const PROP_COLLIDER_REACH = 60;
44
+
45
+ export interface IAlpineWorld {
46
+ readonly world: WorldCells;
47
+ /** One heightfield for the whole world, in place before `addAlpine` resolves. */
48
+ readonly ground: RigidBody3D;
49
+ /** The prop colliders near `follow`. They stream with it (see `PROP_COLLIDER_REACH`). */
50
+ readonly colliders: PropColliders;
51
+ /** The heightfield the ground stands on: `heightAt(x, z)` reads the terrain under any point. */
52
+ readonly field: Heightfield;
53
+ /** Every placed prop's record, eight floats each, as the bake wrote it. */
54
+ readonly placements: Float32Array;
55
+ /** The world's extent in metres, as the bake wrote it. */
56
+ readonly extent: IWorldPackage["extent"];
57
+ }
58
+
59
+ /**
60
+ * Stream the alpine world around `follow` (usually the player or the camera) and give it collision.
61
+ * `whileCurrent` stops a departed scene's async load from attaching anything. Its resources are
62
+ * released on cancellation, failure and when the game's entity registry ends this scene.
63
+ */
64
+ export async function addAlpine(
65
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
66
+ follow: Object3D,
67
+ url = ALPINE_URL,
68
+ whileCurrent: () => boolean = () => true,
69
+ ): Promise<IAlpineWorld> {
70
+ const assertCurrent = () => {
71
+ if (whileCurrent()) return;
72
+ const error = new Error("Alpine world: its scene is no longer current.");
73
+ error.name = "AbortError";
74
+ throw error;
75
+ };
76
+ let surface: Material | undefined;
77
+ let releaseLighting: (() => void) | undefined;
78
+ let world: WorldCells | undefined;
79
+ let ground: RigidBody3D | undefined;
80
+ let colliders: PropColliders | undefined;
81
+ let released = false;
82
+ const dispose = () => {
83
+ if (released) return;
84
+ released = true;
85
+ colliders?.detach();
86
+ colliders?.removeFromParent();
87
+ ground?.dispose();
88
+ ground?.object?.removeFromParent();
89
+ world?.dispose();
90
+ surface?.dispose();
91
+ releaseLighting?.();
92
+ };
93
+ try {
94
+ assertCurrent();
95
+ const manifest = (await (await fetchAsset(ctx, url, assertCurrent)).json()) as IWorldPackage;
96
+ assertCurrent();
97
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
98
+ const records = await (
99
+ await fetchAsset(ctx, base + manifest.placements, assertCurrent)
100
+ ).arrayBuffer();
101
+ assertCurrent();
102
+ const [heightmapUrl] = await ctx.assets.resolve(base + manifest.terrain.heightmap);
103
+ assertCurrent();
104
+ if (heightmapUrl === undefined) throw new Error("Alpine world: its heightmap is not served.");
105
+ const heightmap = await loadWorldHeightmap(heightmapUrl);
106
+ assertCurrent();
107
+ surface = await loadTerrainSplat({ assets: ctx.assets, url });
108
+ assertCurrent();
109
+ releaseLighting = await lightProps(ctx, url, manifest, assertCurrent);
110
+ assertCurrent();
111
+ const options: IWorldCellsLoadOptions = {
112
+ url,
113
+ assets: ctx.assets,
114
+ data: { manifest, placements: records, heightmap },
115
+ surface,
116
+ follow,
117
+ ring: 5,
118
+ impostors: false,
119
+ gpuScene: false,
120
+ shadows: { cast: true, receive: true },
121
+ budgets: { residentCells: 64, instances: 40_000, bytes: 64_000_000 },
122
+ terrain: { streamRadius: 6 },
123
+ };
124
+ world = await WorldCells.load(options);
125
+ assertCurrent();
126
+ const field = alpineField(manifest, heightmap);
127
+ ground = groundCollider(ctx, manifest, field);
128
+ const props = new Float32Array(records);
129
+ colliders = new PropColliders(ctx, follow, manifest, props);
130
+ ctx.add(world);
131
+ if (ground.object !== undefined) ctx.add(ground.object);
132
+ ctx.add(colliders);
133
+ ctx.entities.add(`alpine-world.${world.uuid}`, { dispose });
134
+ return { world, ground, colliders, field, placements: props, extent: manifest.extent };
135
+ } catch (error) {
136
+ dispose();
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ // Every prop gets the sky as its own envMap before WorldCells adopts the shared cached models.
142
+ // Without it, a boulder lit by the sun alone draws flat.
143
+ async function lightProps(
144
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
145
+ url: string,
146
+ manifest: IWorldPackage,
147
+ assertCurrent: () => void,
148
+ ): Promise<() => void> {
149
+ const base = url.slice(0, url.lastIndexOf("/") + 1);
150
+ const [skyUrl] = await ctx.assets.resolve(`${base}sky.hdr`);
151
+ assertCurrent();
152
+ if (skyUrl === undefined) throw new Error("Alpine world: 'sky.hdr' is not served.");
153
+ const sky = await new HDRLoader().loadAsync(skyUrl);
154
+ const originals = new Map<MeshStandardMaterial, { envMap: Texture | null; intensity: number }>();
155
+ const dispose = () => {
156
+ for (const [material, original] of originals) {
157
+ if (material.envMap !== sky) continue;
158
+ material.envMap = original.envMap;
159
+ material.envMapIntensity = original.intensity;
160
+ }
161
+ sky.dispose();
162
+ };
163
+ try {
164
+ assertCurrent();
165
+ sky.mapping = EquirectangularReflectionMapping;
166
+ for (const asset of Object.values(manifest.assets)) {
167
+ const model = await ctx.assets.model<{ scene: Object3D }>(base + asset.glb);
168
+ assertCurrent();
169
+ model.scene.traverse((object) => {
170
+ const mesh = object as Mesh;
171
+ for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[])
172
+ if (!originals.has(material))
173
+ originals.set(material, {
174
+ envMap: material.envMap,
175
+ intensity: material.envMapIntensity,
176
+ });
177
+ dressProp(mesh, sky);
178
+ });
179
+ }
180
+ return dispose;
181
+ } catch (error) {
182
+ dispose();
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ /** One prop mesh: the sky as its envMap. */
188
+ function dressProp(mesh: Mesh, sky: Texture): void {
189
+ for (const material of [mesh.material ?? []].flat() as MeshStandardMaterial[]) {
190
+ material.envMap = sky;
191
+ material.envMapIntensity = PROP_SKY_LIGHT;
192
+ }
193
+ }
194
+
195
+ // The streamed terrain tiles arrive over several frames. A player spawned before its tile would
196
+ // fall through. One heightfield for the whole package avoids that, at a few hundred kilobytes.
197
+ /** The kit's one heightfield: the collider stands on it. */
198
+ function alpineField(manifest: IWorldPackage, heightmap: Uint16Array): Heightfield {
199
+ const { extent, terrain } = manifest;
200
+ return Heightfield.fromSampler({
201
+ columns: terrain.columns,
202
+ rows: terrain.rows,
203
+ width: extent.sizeX,
204
+ depth: extent.sizeZ,
205
+ origin: { x: extent.minX + extent.sizeX / 2, z: extent.minZ + extent.sizeZ / 2 },
206
+ sampleHeight: heightSamplerFromHeightmap(terrain, extent, heightmap),
207
+ });
208
+ }
209
+
210
+ function groundCollider(
211
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
212
+ manifest: IWorldPackage,
213
+ field: Heightfield,
214
+ ): RigidBody3D {
215
+ const { extent } = manifest;
216
+ const anchor = new Object3D();
217
+ anchor.position.set(extent.minX + extent.sizeX / 2, 0, extent.minZ + extent.sizeZ / 2);
218
+ return new RigidBody3D({
219
+ object: anchor,
220
+ physics: ctx.physics,
221
+ type: "fixed",
222
+ entity: "terrain",
223
+ shape: CollisionShape3D.heightfield(field.rows, field.columns, field.toColliderHeights(), {
224
+ x: extent.sizeX,
225
+ y: 1,
226
+ z: extent.sizeZ,
227
+ }),
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Fixed bodies for the props within `PROP_COLLIDER_REACH` of `follow`, rebuilt when it has moved a few
233
+ * metres. Thousands of resident bodies cost Rapier about 0.5 s per 8 s (measured). A hundred do not.
234
+ */
235
+ export class PropColliders extends Object3D implements IComputeDriven {
236
+ readonly warmupNodes: readonly unknown[] = [];
237
+ readonly processCadence = "render" as const;
238
+ readonly #live = new Map<number, RigidBody3D>();
239
+ readonly #runs: { asset: string; offset: number; count: number }[] = [];
240
+ readonly #at = { x: Number.NaN, z: Number.NaN };
241
+ readonly #followPosition = new Vector3();
242
+ #released = false;
243
+
244
+ constructor(
245
+ private readonly ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
246
+ private readonly follow: Object3D,
247
+ manifest: IWorldPackage,
248
+ private readonly records: Float32Array,
249
+ ) {
250
+ super();
251
+ this.name = "alpine-prop-colliders";
252
+ for (const cell of manifest.cells)
253
+ for (const run of cell.runs) if (COLLIDERS[run.asset]) this.#runs.push(run);
254
+ try {
255
+ this.#rebuild();
256
+ } catch (error) {
257
+ this.detach();
258
+ throw error;
259
+ }
260
+ }
261
+
262
+ /** How many prop bodies exist right now. */
263
+ get active(): number {
264
+ return this.#live.size;
265
+ }
266
+
267
+ get released(): boolean {
268
+ return this.#released;
269
+ }
270
+
271
+ attachRenderer(): void {}
272
+
273
+ process(): void {
274
+ if (this.#released) return;
275
+ const p = this.follow.getWorldPosition(this.#followPosition);
276
+ if (Math.hypot(p.x - this.#at.x, p.z - this.#at.z) > 4 || Number.isNaN(this.#at.x))
277
+ this.#rebuild();
278
+ }
279
+
280
+ detach(): void {
281
+ for (const body of this.#live.values()) body.dispose();
282
+ this.#live.clear();
283
+ this.#released = true;
284
+ }
285
+
286
+ #rebuild(): void {
287
+ const { x: fx, z: fz } = this.follow.getWorldPosition(this.#followPosition);
288
+ this.#at.x = fx;
289
+ this.#at.z = fz;
290
+ const keep = new Set<number>();
291
+ for (const run of this.#runs) {
292
+ const shape = COLLIDERS[run.asset];
293
+ if (!shape) continue;
294
+ for (let i = run.offset; i < run.offset + run.count; i++) {
295
+ const x = this.records[i * 8] as number;
296
+ const z = this.records[i * 8 + 2] as number;
297
+ if (Math.hypot(x - fx, z - fz) > PROP_COLLIDER_REACH) continue;
298
+ keep.add(i);
299
+ if (this.#live.has(i)) continue;
300
+ const collider = CollisionShape3D.sphere(
301
+ shape.sphere * (this.records[i * 8 + 7] as number),
302
+ );
303
+ this.#live.set(
304
+ i,
305
+ new RigidBody3D({
306
+ physics: this.ctx.physics,
307
+ type: "fixed",
308
+ entity: `${run.asset}.${String(i)}`,
309
+ position: { x, y: this.records[i * 8 + 1] as number, z },
310
+ shape: collider,
311
+ }),
312
+ );
313
+ }
314
+ }
315
+ for (const [i, body] of this.#live)
316
+ if (!keep.has(i)) {
317
+ body.dispose();
318
+ this.#live.delete(i);
319
+ }
320
+ }
321
+ }
322
+
323
+ async function fetchAsset(
324
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
325
+ url: string,
326
+ assertCurrent: () => void,
327
+ ): Promise<Response> {
328
+ const [resolved] = await ctx.assets.resolve(url);
329
+ assertCurrent();
330
+ if (resolved === undefined) throw new Error(`Alpine world: '${url}' is not served.`);
331
+ const response = await fetch(resolved);
332
+ assertCurrent();
333
+ if (!response.ok) throw new Error(`Alpine world: '${url}' answered ${String(response.status)}.`);
334
+ return response;
335
+ }
@@ -0,0 +1,37 @@
1
+ # Coastal starter kit
2
+
3
+ A 512 m coastline on a real survey (USGS 1 m DEM, Colorado), with hydraulic and thermal erosion,
4
+ rock on the steep ground, sand on the low ground, an ocean at 1.5 m and fir stands kept out of the
5
+ water. 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 rock and sand surfaces, the ocean and the stands. |
10
+ | `assets.json` | The fir model, its LOD distance and draw distance, and its bounds. |
11
+ | `surface.json` | The ground: the sand base, the rock layer, 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 firs 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
+ | `sea.ts` | The sea: the installed spectral simulation drives a lit water surface at the level the bake wrote. |
16
+
17
+ Art is CC0 (Poly Haven); provenance is in `@threenative/terrain/starter-assets/credits.json`.
18
+
19
+ ## Substitution
20
+
21
+ The preview's coastal stands use its licensed spruce models, which cannot ship in a kit. This kit
22
+ plants the CC0 fir from the forest kit in their place, with the same count and spacing. The
23
+ ocean is written to `water.json` as the one entry in `lakes`, with no `at`: the game draws it.
24
+
25
+ ## Status
26
+
27
+ The kit bakes, streams and draws its sea. The kit ships no scene: the game writes one. The sea is a
28
+ lit surface at the baked level: the simulation's waves, the surf from the bake's shoreline, and the depth
29
+ tint. Its colours and sea state are in `sea.ts`. The close-up view is named `boulders` in the shared captures, although
30
+ it frames a fir.
31
+
32
+ ## Steps
33
+
34
+ 1. Copy `node_modules/@threenative/terrain/starter/coastal` to `src/terrain/coastal`.
35
+ 2. Run `node src/terrain/coastal/bake.mjs --out assets/terrain/coastal` (needs `@threenative/terrain`
36
+ as a devDependency). The same recipe always writes the same bytes.
37
+ 3. In the scene, call `addCoastal(ctx, follow)` for the world and `coastalDaylight(follow)` for the light.
@@ -0,0 +1,10 @@
1
+ {
2
+ "fir": {
3
+ "near": "fir_tree_01/fir-b-lod2.glb",
4
+ "maxDistance": 600,
5
+ "bounds": {
6
+ "min": [-2.869, 0, -3.619],
7
+ "max": [2.763, 14.078, 2.465]
8
+ }
9
+ }
10
+ }
@@ -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/coastal/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
+ );