fuzzyworld

nordic-skating / local-littlejs / files / src / render / shores.ts

Branch local-littlejs · default branch · all branches

shores.ts

15.3 KB · history · blame

1import * as THREE from 'three'
2import { type Channel, type Lake, channelsAt, mulberry32 } from '../world/lake.js'
3
4/** The shores are made a stretch at a time, this many metres of lake each. */
5const CHUNK = 100
6/** Metres between rows of the land's mesh along the lake. */
7const STEP = 5
8const ROWS = CHUNK / STEP
9/** How far inland each column of a bank's mesh is, metres: fine at the shore, coarse far off. */
10const INLAND = [0, 2, 6, 14, 30, 60, 120, 220, 400]
11/** How far across an island each column of its mesh is, as a fraction of its width. */
12const ACROSS = [0, 0.04, 0.12, 0.25, 0.5, 0.75, 0.88, 0.96, 1]
13/**
14 * How far the land is made in the direction the skater faces, and how far the other way,
15 * metres. The fog ends at 600. Facing across the lake, it is made that far both ways.
16 */
17const AHEAD = 700
18const BEHIND = 100
19/** Stretches made in one frame at most, so that turning round does not make them all at once. */
20const MADE_AT_ONCE = 2
21const TREES_PER_BANK = 60
22const TREES_PER_ISLAND = 50
23/** Reeds on an islet for each square metre of it, and the most on one. */
24const REEDS_PER_M2 = 2.5
25const MOST_REEDS = 260
26/** Reeds on each stretch of a long island. */
27const REEDS_PER_ISLAND = 1400
28
29const snow = new THREE.MeshStandardMaterial({ color: 0xe8eef4, roughness: 0.9 })
30const spruce = new THREE.MeshStandardMaterial({ color: 0x1f3b2c, roughness: 1, flatShading: true })
31// A spruce is a cone standing on its base.
32const cone = new THREE.ConeGeometry(1.7, 8, 6).translate(0, 4, 0)
33// An islet is a low hump of frozen ground, white with frost, with reeds standing in it:
34// dry, straw-coloured, a metre or two tall, and thin.
35const frost = new THREE.MeshStandardMaterial({ color: 0xd9d6c8, roughness: 1 })
36const straw = new THREE.MeshStandardMaterial({ color: 0xb59a62, roughness: 1 })
37const hump = new THREE.SphereGeometry(1, 12, 5, 0, Math.PI * 2, 0, Math.PI / 2).scale(1, 0.14, 1)
38const reed = new THREE.ConeGeometry(0.045, 1, 3).translate(0, 0.5, 0)
39
40interface Chunk {
41 /** Where the chunk's own origin is: across the lake, and along it. */
42 x: number
43 s: number
44 group: THREE.Group
45}
46
47/** The water's edges at `s`: the outer shores, and the island between two channels. */
48interface Shoreline {
49 left: number
50 right: number
51 /** The island's two shores; the same number where there is no island. */
52 islandLeft: number
53 islandRight: number
54 /** Whether the island is a low bank of reeds, as one standing in a channel is, and not wooded land. */
55 reeds?: boolean
56}
57
58function shoreline(channel: Channel, s: number): Shoreline {
59 const channels = channelsAt(channel, s)
60 const first = channels[0]!
61 const last = channels[channels.length - 1]!
62 const left = first.centre(s) - first.halfWidth(s)
63 const right = last.centre(s) + last.halfWidth(s)
64 // The land in the middle is the island between a fork's two channels, or the island
65 // standing in one channel.
66 const within = channels.length === 1 ? first.island(s) : 0
67 if (within > 0) return { left, right, islandLeft: first.centre(s) - within, islandRight: first.centre(s) + within, reeds: true }
68 const islandLeft = first.centre(s) + first.halfWidth(s)
69 const islandRight = last.centre(s) - last.halfWidth(s)
70 // One channel, or two that have not yet drawn apart: no island, and its two shores
71 // are put at one point so that the mesh made for it closes up to nothing.
72 if (islandRight <= islandLeft) {
73 const middle = (islandLeft + islandRight) / 2
74 return { left, right, islandLeft: middle, islandRight: middle }
75 }
76 return { left, right, islandLeft, islandRight }
77}
78
79/**
80 * A strip of land for one stretch, `columns` wide, as a mesh whose vertices are measured
81 * from the stretch's own origin (`x0`, `s0`) so that they stay small numbers. `place`
82 * says where column `col` is at `s`, across and up.
83 */
84function land(columns: number, x0: number, s0: number, place: (s: number, col: number) => [number, number]): THREE.BufferGeometry {
85 // One row more at each end than is drawn. The normals are worked out with them and
86 // they are then left out, so the shading runs unbroken from one stretch into the next.
87 const positions = new Float32Array((ROWS + 3) * columns * 3)
88 let p = 0
89 for (let row = -1; row <= ROWS + 1; row++) {
90 const s = s0 + row * STEP
91 for (let col = 0; col < columns; col++) {
92 const [x, y] = place(s, col)
93 positions[p++] = x - x0
94 positions[p++] = y
95 positions[p++] = -(s - s0)
96 }
97 }
98 const quads = (from: number, to: number): number[] => {
99 const index: number[] = []
100 for (let row = from; row < to; row++) {
101 for (let col = 0; col < columns - 1; col++) {
102 const a = row * columns + col
103 const b = a + 1
104 const c = a + columns
105 const d = c + 1
106 // Columns run left to right; wound so that the land faces up.
107 index.push(a, b, c, b, d, c)
108 }
109 }
110 return index
111 }
112 const geometry = new THREE.BufferGeometry()
113 geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
114 geometry.setIndex(quads(0, ROWS + 2))
115 geometry.computeVertexNormals()
116 geometry.setIndex(quads(1, ROWS + 1))
117 return geometry
118}
119
120/**
121 * How high a strip of land's mesh is at `s` along and `at` across, where `columns` is
122 * what its columns are placed by and `height` the height it was given at a row and a
123 * column. Not the height of the land itself there: the mesh is flat between its rows and
124 * columns and the land curves, so the two differ by a metre or more far inland, where
125 * the columns are far apart. Whatever stands on the land stands on its mesh.
126 */
127function meshHeight(s0: number, s: number, columns: readonly number[], at: number, height: (s: number, column: number) => number): number {
128 let col = 0
129 while (col < columns.length - 2 && columns[col + 1]! < at) col++
130 const across = Math.max(0, Math.min(1, (at - columns[col]!) / (columns[col + 1]! - columns[col]!)))
131 const row = Math.floor((s - s0) / STEP)
132 const along = (s - s0) / STEP - row
133 const atRow = (r: number): number => {
134 const rowS = s0 + r * STEP
135 return height(rowS, columns[col]!) * (1 - across) + height(rowS, columns[col + 1]!) * across
136 }
137 return atRow(row) * (1 - along) + atRow(row + 1) * along
138}
139
140/** How high the island between two channels is at `s`, a fraction `t` of the way across it. */
141function islandHeight(lake: Lake, channel: Channel, s: number, t: number): number {
142 const { islandLeft, islandRight, reeds } = shoreline(channel, s)
143 // An island rises from both its shores, so its height is that of land as far inland
144 // as its nearer shore. A bank of reeds is a quarter as high as wooded land would be.
145 return lake.bankHeight(1, s, Math.min(t, 1 - t) * (islandRight - islandLeft)) * (reeds ? 0.25 : 1)
146}
147
148function trees(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.InstancedMesh {
149 const random = mulberry32(seed ^ Math.imul(index, 0x9e3779b1))
150 const mesh = new THREE.InstancedMesh(cone, spruce, TREES_PER_BANK * 2 + TREES_PER_ISLAND)
151 const at = new THREE.Object3D()
152 let n = 0
153 const plant = (x: number, s: number, ground: number, size: number): void => {
154 // A little into the ground, so that no tree shows its base on a slope.
155 at.position.set(x - x0, ground - 0.3, -(s - s0))
156 at.rotation.y = random() * Math.PI
157 at.scale.set(size, size * (0.8 + random() * 0.5), size)
158 at.updateMatrix()
159 mesh.setMatrixAt(n++, at.matrix)
160 }
161 for (const side of [-1, 1] as const) {
162 for (let i = 0; i < TREES_PER_BANK; i++) {
163 const s = s0 + random() * CHUNK
164 const shore = shoreline(channel, s)
165 // Thick along the shore and thinning out up the hill.
166 const d = 5 + random() ** 2 * 170
167 const ground = meshHeight(s0, s, INLAND, d, (rowS, inland) => lake.bankHeight(side, rowS, inland))
168 plant((side < 0 ? shore.left : shore.right) + side * d, s, ground, 0.7 + random() * 0.9)
169 }
170 }
171 for (let i = 0; i < TREES_PER_ISLAND; i++) {
172 const s = s0 + random() * CHUNK
173 const { islandLeft, islandRight, reeds } = shoreline(channel, s)
174 const width = islandRight - islandLeft
175 const x = islandLeft + 2 + random() * (width - 4)
176 const wooded = width > 8 && !reeds
177 const ground = wooded ? meshHeight(s0, s, ACROSS, (x - islandLeft) / width, (rowS, t) => islandHeight(lake, channel, rowS, t)) : 0
178 // No island here, one too narrow to stand a tree on, or one of reeds: a tree of no size.
179 plant(x, s, ground, wooded ? 0.7 + random() * 0.9 : 0)
180 }
181 return mesh
182}
183
184/** The reeds of one stretch of an island that has them, if there is one in this stretch. */
185function islandReeds(lake: Lake, channel: Channel, seed: number, index: number, x0: number, s0: number): THREE.Object3D[] {
186 let any = false
187 for (let s = s0; s <= s0 + CHUNK && !any; s += STEP) any = shoreline(channel, s).reeds === true
188 if (!any) return []
189 const random = mulberry32(seed ^ Math.imul(index, 0xc2b2ae35))
190 const stand = new THREE.InstancedMesh(reed, straw, REEDS_PER_ISLAND)
191 const at = new THREE.Object3D()
192 for (let n = 0; n < REEDS_PER_ISLAND; n++) {
193 const s = s0 + random() * CHUNK
194 const { islandLeft, islandRight, reeds } = shoreline(channel, s)
195 const width = islandRight - islandLeft
196 // Across all of it but its very edges.
197 const t = 0.06 + 0.88 * random()
198 const here = reeds && width > 1.5
199 at.position.set(
200 islandLeft + t * width - x0,
201 here ? meshHeight(s0, s, ACROSS, t, (rowS, across) => islandHeight(lake, channel, rowS, across)) - 0.05 : 0,
202 -(s - s0),
203 )
204 at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35)
205 const tall = here ? 0.9 + random() * 1.3 : 0
206 at.scale.set(tall > 0 ? 1 : 0, tall, tall > 0 ? 1 : 0)
207 at.updateMatrix()
208 stand.setMatrixAt(n, at.matrix)
209 }
210 return [stand]
211}
212
213/** The islets of one stretch: their humps, and the reeds on them. */
214function islets(channel: Channel, x0: number, s0: number): THREE.Object3D[] {
215 const found = channel.islets(s0, s0 + CHUNK)
216 if (found.length === 0) return []
217 const reeds = found.map((islet) => Math.min(MOST_REEDS, Math.round(REEDS_PER_M2 * Math.PI * islet.radius ** 2)))
218 const humps = new THREE.InstancedMesh(hump, frost, found.length)
219 const stand = new THREE.InstancedMesh(reed, straw, reeds.reduce((a, b) => a + b))
220 const at = new THREE.Object3D()
221 let n = 0
222 for (const [i, islet] of found.entries()) {
223 const random = mulberry32(islet.seed)
224 at.position.set(islet.x - x0, 0, -(islet.s - s0))
225 at.rotation.set(0, 0, 0)
226 at.scale.set(islet.radius, islet.radius, islet.radius)
227 at.updateMatrix()
228 humps.setMatrixAt(i, at.matrix)
229 for (let r = 0; r < reeds[i]!; r++) {
230 // Thickest in the middle; none on the last of the hump, where the ice begins.
231 const out = Math.sqrt(random()) * 0.85
232 const round = random() * Math.PI * 2
233 at.position.set(
234 islet.x - x0 + Math.cos(round) * out * islet.radius,
235 // The hump is part of a sphere, squashed: this is how high it is that far out.
236 0.14 * islet.radius * Math.sqrt(1 - out * out) - 0.05,
237 -(islet.s - s0) + Math.sin(round) * out * islet.radius,
238 )
239 // Each leaning a little its own way.
240 at.rotation.set((random() - 0.5) * 0.35, random() * Math.PI, (random() - 0.5) * 0.35)
241 const tall = 0.9 + random() * 1.1
242 at.scale.set(1, tall, 1)
243 at.updateMatrix()
244 stand.setMatrixAt(n++, at.matrix)
245 }
246 }
247 return [humps, stand]
248}
249
250/**
251 * The land of the lake: both banks, the island wherever it has forked, and the islets. It is made
252 * ahead of the skater and thrown away behind, and each stretch is placed relative to the
253 * skater every frame, so the skater is always at the origin of what is drawn and the lake
254 * can run on without losing precision.
255 */
256export class Shores {
257 readonly group = new THREE.Group()
258 private readonly chunks = new Map<number, Chunk>()
259 /** Whether nothing of this lake has been made yet. */
260 private starting = true
261
262 constructor(private lake: Lake, private seed: number) {}
263
264 /** Be the land of another lake: what was made of the last one is thrown away. */
265 show(lake: Lake, seed: number): void {
266 this.lake = lake
267 this.seed = seed
268 for (const chunk of this.chunks.values()) this.drop(chunk)
269 this.chunks.clear()
270 this.starting = true
271 }
272
273 private drop(chunk: Chunk): void {
274 this.group.remove(chunk.group)
275 for (const child of chunk.group.children) {
276 // The land is this stretch's own; the trees share one cone and one material.
277 if (child instanceof THREE.InstancedMesh) child.dispose()
278 else (child as THREE.Mesh).geometry.dispose()
279 }
280 }
281
282 /**
283 * Make, drop and place the stretches for a skater at `x` across and `s` along, who is
284 * in `channel`. `facing` is 1 for a skater looking down the lake, -1 for one looking
285 * back up it, and between for one looking across.
286 *
287 * A stretch is made once, with whichever channel the skater was in at the time, and
288 * that is safe: a stretch near a fork has both of the fork's channels in it whichever
289 * of them the skater takes, for `FORK_ZONE` metres, and the skater has taken one long
290 * before stretches further off than that are made.
291 */
292 update(x: number, s: number, channel: Channel, facing = 1): void {
293 const first = Math.floor((s - (facing > 0.5 ? BEHIND : AHEAD)) / CHUNK)
294 const last = Math.floor((s + (facing < -0.5 ? BEHIND : AHEAD)) / CHUNK)
295 for (const [index, chunk] of this.chunks) {
296 if (index >= first && index <= last) continue
297 this.drop(chunk)
298 this.chunks.delete(index)
299 }
300 // Nearest first, and only so many in a frame; the rest are for the frames after.
301 const here = Math.floor(s / CHUNK)
302 let made = 0
303 for (let away = 0; made < MADE_AT_ONCE && (here - away >= first || here + away <= last); away++) {
304 for (const index of away === 0 ? [here] : [here + away, here - away]) {
305 if (index < first || index > last || this.chunks.has(index) || made >= MADE_AT_ONCE) continue
306 this.chunks.set(index, this.make(index, channel))
307 // The very first frame makes everything: there is nothing yet to look at meanwhile.
308 if (this.chunks.size > 1 && !this.starting) made++
309 }
310 }
311 this.starting = false
312 for (const chunk of this.chunks.values()) {
313 chunk.group.position.set(chunk.x - x, 0, -(chunk.s - s))
314 }
315 }
316
317 private make(index: number, channel: Channel): Chunk {
318 const { lake } = this
319 const s0 = index * CHUNK
320 const x0 = channel.centre(s0)
321 const group = new THREE.Group()
322 // The left bank's columns run from far inland to the shore, the right bank's from
323 // the shore inland, and the island's from its left shore to its right.
324 const inland = INLAND.length - 1
325 group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => {
326 const d = INLAND[inland - col]!
327 return [shoreline(channel, s).left - d, lake.bankHeight(-1, s, d)]
328 }), snow))
329 group.add(new THREE.Mesh(land(INLAND.length, x0, s0, (s, col) => {
330 const d = INLAND[col]!
331 return [shoreline(channel, s).right + d, lake.bankHeight(1, s, d)]
332 }), snow))
333 group.add(new THREE.Mesh(land(ACROSS.length, x0, s0, (s, col) => {
334 const { islandLeft, islandRight } = shoreline(channel, s)
335 const t = ACROSS[col]!
336 return [islandLeft + t * (islandRight - islandLeft), islandHeight(lake, channel, s, t)]
337 }), snow))
338 group.add(trees(lake, channel, this.seed, index, x0, s0), ...islets(channel, x0, s0), ...islandReeds(lake, channel, this.seed, index, x0, s0))
339 this.group.add(group)
340 return { x: x0, s: s0, group }
341 }
342}