@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,27 @@
1
+ // The tundra 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
+ import { SKY_REFLECTION_LAYER } from "./water.js";
6
+
7
+ export function tundraDaylight(follow: Object3D): Daylight {
8
+ const daylight = new Daylight({
9
+ follow,
10
+ // Mid-morning sun from the south-east, high enough for short readable shadows under the boulders.
11
+ sunDirection: new Vector3(0.55, 0.62, 0.56),
12
+ sunColor: new Color(0xfff1dc),
13
+ sunIntensity: 3.2,
14
+ shadowExtents: [24, 96, 320],
15
+ sky: { turbidity: 2, rayleigh: 3, mieCoefficient: 0.003, mieDirectionalG: 0.82 },
16
+ fill: { sky: new Color(0xa8c8e8), ground: new Color(0x464937), intensity: 0.62 },
17
+ // Enough air that the 512 m world's far edge fades instead of ending on a hard line.
18
+ haze: { color: new Color(0x9fb4c2), density: 0.0016 },
19
+ exposure: 2 ** -0.38,
20
+ toneMapping: ACESFilmicToneMapping,
21
+ // The box's corners must stay inside the camera's far plane (5000 m in the kit's example scene).
22
+ skySize: 5000,
23
+ });
24
+ // The kettles' mirror shows the sky only on this layer (see `water.ts`), so the sky joins it.
25
+ daylight.sky.layers.enable(SKY_REFLECTION_LAYER);
26
+ return daylight;
27
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "base": {
3
+ "id": "moss",
4
+ "tile": 3.6,
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": "snow",
21
+ "tile": 4.5,
22
+ "tint": [1, 1, 1],
23
+ "normal": true,
24
+ "mask": "snow",
25
+ "lo": 0.3,
26
+ "hi": 0.7
27
+ },
28
+ {
29
+ "id": "rock",
30
+ "tile": 3.8,
31
+ "tint": [1, 1, 1],
32
+ "normal": true,
33
+ "triplanar": true,
34
+ "mask": "rock",
35
+ "lo": 0.25,
36
+ "hi": 0.75
37
+ }
38
+ ],
39
+ "textures": "textures",
40
+ "sources": {
41
+ "moss": {
42
+ "diff": "mossy_rock/mossy_rock_diff_1k.jpg",
43
+ "nrm": "mossy_rock/mossy_rock_nor_gl_1k.jpg"
44
+ },
45
+ "snow": {
46
+ "diff": "snow_02/snow_02_diff_1k.jpg",
47
+ "nrm": "snow_02/snow_02_nor_gl_1k.jpg"
48
+ },
49
+ "rock": {
50
+ "diff": "rock_boulder_dry/rock_boulder_dry_diff_512.jpg",
51
+ "nrm": "rock_boulder_dry/rock_boulder_dry_nor_gl_512.jpg"
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,313 @@
1
+ // The tundra's lake and river, drawn from the water the bake wrote to `world/water.json`. The lake covers
2
+ // the ground that its level floods from the lake's centre, read from the same heightfield the physics
3
+ // stands on, so the water sits where the bake floods it. WaterSurface3D mirrors the sky in the lake and
4
+ // shows the bed through it, and the water thins to nothing where the ground rises through the level: that
5
+ // is the shore. A river follows the ground, so it is a ribbon the game shades itself. Colours, depths and
6
+ // the shore are this file's to change: the kit owns its look.
7
+ import type { ICtx } from "@threenative/core";
8
+ import { WaterSurface3D } from "@threenative/core";
9
+ import type { Heightfield } from "@threenative/core/world";
10
+ import type { IPhysicsContext } from "@threenative/physics";
11
+ import {
12
+ BufferAttribute,
13
+ BufferGeometry,
14
+ Color,
15
+ DoubleSide,
16
+ type Material,
17
+ Mesh,
18
+ MeshStandardMaterial,
19
+ } from "three";
20
+ import {
21
+ attribute,
22
+ cameraPosition,
23
+ clamp,
24
+ dot,
25
+ float,
26
+ mix,
27
+ normalize,
28
+ positionWorld,
29
+ pow,
30
+ smoothstep,
31
+ vec3,
32
+ } from "three/tsl";
33
+ import { MeshBasicNodeMaterial } from "three/webgpu";
34
+
35
+ /** The generator's own water, written beside the world by `bake.mjs`. */
36
+ export interface ITundraWaterFile {
37
+ readonly lakes: readonly ILake[];
38
+ readonly rivers: readonly IRiver[];
39
+ }
40
+
41
+ interface ILake {
42
+ readonly id: string;
43
+ readonly at: readonly [number, number];
44
+ readonly radius: number;
45
+ readonly level: number;
46
+ }
47
+
48
+ interface IRiver {
49
+ readonly id: string;
50
+ readonly width: number;
51
+ readonly points: readonly (readonly [number, number, number])[];
52
+ }
53
+
54
+ export interface ITundraWater {
55
+ readonly lakes: number;
56
+ readonly rivers: number;
57
+ dispose(): void;
58
+ }
59
+
60
+ /** The layer the sky sits on, so the lake's mirror can show it (see `sky.ts`). */
61
+ export const SKY_REFLECTION_LAYER = 3;
62
+ /** The ribbon sits this far above its profile, clear of the carved bed. */
63
+ const RIVER_LIFT = 0.05;
64
+ /**
65
+ * Metres of water over the bed at which the lake is fully opaque. The shore is wide enough to hide the
66
+ * flood grid's stepped outline, which reads as a jagged edge when the fade is narrow.
67
+ */
68
+ const SHORE_DEPTH = 0.8;
69
+ /**
70
+ * The colour the mirrored sky takes at grazing angles, in linear light. The kettles are frozen, so the tint
71
+ * is pale ice; the mirror keeps the sky's gradient.
72
+ */
73
+ const SHEEN = new Color(0xeaf4f8);
74
+ /** The ice's own colour: a pale, milky blue-white. The bed is hidden under it, as frozen water hides it. */
75
+ const BODY = new Color(0xbcd6e0);
76
+ /** Meltwater: a blue-grey that reads as running water rather than a dark road. */
77
+ const RIVER = new Color(0x9fb9c4);
78
+
79
+ /** The heightfield's sample grid in world metres: one node per column and row, the first at (x0, z0). */
80
+ function gridOf(field: Heightfield): { stepX: number; stepZ: number; x0: number; z0: number } {
81
+ return {
82
+ stepX: field.width / (field.columns - 1),
83
+ stepZ: field.depth / (field.rows - 1),
84
+ x0: field.origin.x - field.width / 2,
85
+ z0: field.origin.z - field.depth / 2,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * The grid nodes the lake covers: the nodes below its level that a flood from its centre reaches inside
91
+ * its radius. This reproduces the generator's own flood on the heightfield the physics stands on.
92
+ */
93
+ function floodedNodes(field: Heightfield, lake: ILake): Uint8Array {
94
+ const { columns, rows } = field;
95
+ const { stepX, stepZ, x0, z0 } = gridOf(field);
96
+ const [cx, cz] = lake.at;
97
+ const worldX = (node: number): number => x0 + (node % columns) * stepX;
98
+ const worldZ = (node: number): number => z0 + Math.floor(node / columns) * stepZ;
99
+ const below = (node: number): boolean => field.heightAt(worldX(node), worldZ(node)) < lake.level;
100
+ const within = (node: number): boolean =>
101
+ Math.hypot(worldX(node) - cx, worldZ(node) - cz) <= lake.radius;
102
+ const start = Math.round((cz - z0) / stepZ) * columns + Math.round((cx - x0) / stepX);
103
+ const flooded = new Uint8Array(columns * rows);
104
+ const seen = new Uint8Array(columns * rows);
105
+ const queue: number[] = [start];
106
+ seen[start] = 1;
107
+ for (let head = 0; head < queue.length; head += 1) {
108
+ const node = queue[head];
109
+ if (node === undefined || !below(node)) continue;
110
+ flooded[node] = 1;
111
+ const i = node % columns;
112
+ const j = Math.floor(node / columns);
113
+ const neighbours = [
114
+ i > 0 ? node - 1 : -1,
115
+ i < columns - 1 ? node + 1 : -1,
116
+ j > 0 ? node - columns : -1,
117
+ j < rows - 1 ? node + columns : -1,
118
+ ];
119
+ for (const next of neighbours) {
120
+ if (next < 0 || seen[next] === 1 || !within(next)) continue;
121
+ seen[next] = 1;
122
+ queue.push(next);
123
+ }
124
+ }
125
+ return flooded;
126
+ }
127
+
128
+ /**
129
+ * The lake's surface: a level sheet over every grid cell that touches a flooded node. The sheet runs a
130
+ * cell past the flood, so the shore is where `lakeMaterial` thins the water, not the sheet's edge.
131
+ */
132
+ /**
133
+ * The lake's surface, cut where the water level crosses the ground. Each flooded grid cell is two triangles,
134
+ * and each triangle keeps only the part where the ground is under the level. The shoreline therefore follows
135
+ * the terrain's contour, not the grid's steps.
136
+ */
137
+ function lakeGeometry(field: Heightfield, lake: ILake, flooded: Uint8Array): BufferGeometry {
138
+ const { columns, rows } = field;
139
+ const { stepX, stepZ, x0, z0 } = gridOf(field);
140
+ const positions: number[] = [];
141
+ const indices: number[] = [];
142
+ // Each vertex's distance from the kettle's centre, in radii: the sheet fades out toward its circular edge.
143
+ const radial: number[] = [];
144
+ const corner = (node: number): ICorner => {
145
+ const x = x0 + (node % columns) * stepX;
146
+ const z = z0 + Math.floor(node / columns) * stepZ;
147
+ return { x, z, h: field.heightAt(x, z) };
148
+ };
149
+ const emit = (polygon: readonly { readonly x: number; readonly z: number }[]): void => {
150
+ const first = positions.length / 3;
151
+ for (const point of polygon) {
152
+ positions.push(point.x, lake.level, point.z);
153
+ radial.push(Math.hypot(point.x - lake.at[0], point.z - lake.at[1]) / lake.radius);
154
+ }
155
+ for (let k = 1; k + 1 < polygon.length; k += 1) indices.push(first, first + k, first + k + 1);
156
+ };
157
+ for (let j = 0; j < rows - 1; j += 1)
158
+ for (let i = 0; i < columns - 1; i += 1) {
159
+ const a = j * columns + i;
160
+ const b = a + 1;
161
+ const c = a + columns;
162
+ const d = c + 1;
163
+ if (!(flooded[a] || flooded[b] || flooded[c] || flooded[d])) continue;
164
+ for (const triangle of [
165
+ [a, c, b],
166
+ [b, c, d],
167
+ ]) {
168
+ const clipped = clipBelow(triangle.map(corner), lake.level);
169
+ if (clipped.length >= 3) emit(clipped);
170
+ }
171
+ }
172
+ const geometry = new BufferGeometry();
173
+ geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
174
+ geometry.setAttribute("radial", new BufferAttribute(new Float32Array(radial), 1));
175
+ geometry.setIndex(indices);
176
+ return geometry;
177
+ }
178
+
179
+ /** A grid corner: its place on the ground and the terrain height there. */
180
+ interface ICorner {
181
+ readonly x: number;
182
+ readonly z: number;
183
+ readonly h: number;
184
+ }
185
+
186
+ /**
187
+ * The part of a triangle where the ground is under `level`, by Sutherland–Hodgman on the height difference.
188
+ * The ground is linear across a triangle, so the crossing points lie on the level's contour.
189
+ */
190
+ function clipBelow(
191
+ corners: readonly ICorner[],
192
+ level: number,
193
+ ): readonly { readonly x: number; readonly z: number }[] {
194
+ const out: { x: number; z: number }[] = [];
195
+ for (let k = 0; k < corners.length; k += 1) {
196
+ const p = corners[k];
197
+ const q = corners[(k + 1) % corners.length];
198
+ if (p === undefined || q === undefined) continue;
199
+ const fp = level - p.h;
200
+ const fq = level - q.h;
201
+ if (fp >= 0) out.push({ x: p.x, z: p.z });
202
+ if (fp * fq < 0) {
203
+ const t = fp / (fp - fq);
204
+ out.push({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t });
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * The lake's surface: the sky mirrored in it at grazing angles, the bed seen through it where it is
212
+ * shallow, and the body's own colour where it is deep. Schlick at water's 1.333 index, so the water at
213
+ * your feet is clear and the far bank is a mirror. Where the water is thinner than SHORE_DEPTH it fades
214
+ * to nothing, because the ground has risen through the level there.
215
+ */
216
+ function lakeMaterial(surface: WaterSurface3D): MeshBasicNodeMaterial {
217
+ const view = normalize(cameraPosition.sub(positionWorld));
218
+ const facing = clamp(dot(vec3(0, 1, 0), view), float(0), float(1));
219
+ const fresnel = float(0.02).add(pow(float(1).sub(facing), 5).mul(0.98));
220
+ const thickness = surface.thicknessAt();
221
+ const body = vec3(BODY.r, BODY.g, BODY.b);
222
+ // Ice hides the bed: the body colour takes over from the refraction within a fraction of a metre.
223
+ const submerged = mix(
224
+ surface.refractionAt(),
225
+ body,
226
+ smoothstep(float(0.1), float(0.6), thickness),
227
+ );
228
+ const material = new MeshBasicNodeMaterial({ transparent: true, side: DoubleSide });
229
+ // The HDR mirror is compressed, then tinted: its horizon is neutral, the water should not be.
230
+ const mirror = surface.reflectionAt();
231
+ const compressed = mirror.div(mirror.add(vec3(1, 1, 1)));
232
+ material.colorNode = mix(submerged, compressed.mul(vec3(SHEEN.r, SHEEN.g, SHEEN.b)), fresnel);
233
+ // The sheet ends at the kettle's circle, where the ground beyond is still under the level. It fades there.
234
+ const edge = float(1).sub(smoothstep(float(0.8), float(1), attribute("radial", "float")));
235
+ material.opacityNode = smoothstep(float(0), float(SHORE_DEPTH), thickness).mul(edge);
236
+ return material;
237
+ }
238
+
239
+ /** A ribbon along the river's profile: each point's left and right edge across the course. */
240
+ function riverGeometry(river: IRiver): BufferGeometry {
241
+ const half = river.width / 2;
242
+ const positions: number[] = [];
243
+ const indices: number[] = [];
244
+ river.points.forEach(([x, y, z], i) => {
245
+ const before = river.points[Math.max(0, i - 1)] ?? [x, y, z];
246
+ const after = river.points[Math.min(river.points.length - 1, i + 1)] ?? [x, y, z];
247
+ const tx = after[0] - before[0];
248
+ const tz = after[2] - before[2];
249
+ const length = Math.hypot(tx, tz) || 1;
250
+ const sx = (-tz / length) * half;
251
+ const sz = (tx / length) * half;
252
+ positions.push(x + sx, y + RIVER_LIFT, z + sz, x - sx, y + RIVER_LIFT, z - sz);
253
+ if (i > 0) {
254
+ const a = 2 * (i - 1);
255
+ const b = 2 * i;
256
+ indices.push(a, b, a + 1, b, b + 1, a + 1);
257
+ }
258
+ });
259
+ const geometry = new BufferGeometry();
260
+ geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3));
261
+ geometry.setIndex(indices);
262
+ return geometry;
263
+ }
264
+
265
+ /**
266
+ * Adds the lake and river the bake wrote. `field` is the ground the props and the colliders stand on.
267
+ * The caller owns the returned handle: its `dispose` removes both meshes and releases each surface.
268
+ */
269
+ export function addTundraWater(
270
+ ctx: ICtx<Record<string, unknown>, IPhysicsContext>,
271
+ water: ITundraWaterFile,
272
+ field: Heightfield,
273
+ ): ITundraWater {
274
+ const surfaces: WaterSurface3D[] = [];
275
+ const meshes: Mesh[] = [];
276
+ for (const lake of water.lakes) {
277
+ const surface = new WaterSurface3D({
278
+ level: lake.level,
279
+ maxThickness: 4,
280
+ // The mirror holds only the sky, which barely changes: redrawing it every fourth frame keeps the
281
+ // water's look and takes the second world draw off most frames (the lake's cost in the ground view).
282
+ reflection: { resolutionScale: 0.5, layers: 1 << SKY_REFLECTION_LAYER, refreshInterval: 4 },
283
+ });
284
+ surfaces.push(surface);
285
+ meshes.push(
286
+ new Mesh(lakeGeometry(field, lake, floodedNodes(field, lake)), lakeMaterial(surface)),
287
+ );
288
+ }
289
+ for (const river of water.rivers) {
290
+ const material = new MeshStandardMaterial({
291
+ color: RIVER,
292
+ roughness: 0.18,
293
+ metalness: 0,
294
+ transparent: true,
295
+ opacity: 0.9,
296
+ side: DoubleSide,
297
+ });
298
+ meshes.push(new Mesh(riverGeometry(river), material));
299
+ }
300
+ for (const mesh of meshes) ctx.add(mesh);
301
+ return {
302
+ lakes: water.lakes.length,
303
+ rivers: water.rivers.length,
304
+ dispose() {
305
+ for (const mesh of meshes) {
306
+ mesh.removeFromParent();
307
+ mesh.geometry.dispose();
308
+ (mesh.material as Material).dispose();
309
+ }
310
+ for (const surface of surfaces) surface.dispose();
311
+ },
312
+ };
313
+ }