tower / files / src / terrain / tile.ts
tile.ts
| 1 | /** |
| 2 | * Terrain tiles: the quadtree scheme and the mesh data for one tile. |
| 3 | * See world-generation.md §6.1. |
| 4 | * |
| 5 | * The unrolled surface is cut into a quadtree. Level 0 is 16 tiles along the axis by |
| 6 | * 10 around it — 33.75 × 31.4 km each — and each level halves them, down to level 10 |
| 7 | * at 33 × 31 m. Every tile is the same grid of vertices, so a near tile has fine |
| 8 | * spacing and a far one coarse. |
| 9 | * |
| 10 | * A tile is built in a worker and handed back as typed arrays. Its vertices are |
| 11 | * already bent onto the cylinder and given **relative to the tile's own centre**, in |
| 12 | * kilometres. That is a precision decision, not a convenience: float32 at 50 km is |
| 13 | * about 6 mm, which is visible jitter on anything small, while relative to a centre |
| 14 | * at most 24 km away it is under a millimetre, and at level 10 it is nothing. |
| 15 | */ |
| 16 | import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' |
| 17 | import type { Runtime, Sample } from './field.js' |
| 18 | |
| 19 | /** |
| 20 | * The deepest anything generated may reach, metres below the datum. Tower's wall is a |
| 21 | * kilometre thick (D-044), and its outer skin sits at that depth: sea floors are cut to |
| 22 | * about -330 m, and nothing — not a basin, not a tile's skirt — may reach the skin, or |
| 23 | * it shows through the hull from outside (D-064). |
| 24 | */ |
| 25 | export const LOWEST_GROUND_M = -950 |
| 26 | |
| 27 | export const ROOTS_ALONG = 16 |
| 28 | export const ROOTS_AROUND = 10 |
| 29 | export const MAX_LEVEL = 10 |
| 30 | |
| 31 | export interface TileKey { level: number; i: number; j: number } |
| 32 | |
| 33 | export const tileKey = (t: TileKey): string => `${t.level}/${t.i}/${t.j}` |
| 34 | |
| 35 | /** Tile size in metres along the axis and around it. */ |
| 36 | export function tileSize(level: number): { along: number; around: number } { |
| 37 | const n = 2 ** level |
| 38 | return { along: LENGTH_M / (ROOTS_ALONG * n), around: CIRCUMFERENCE_M / (ROOTS_AROUND * n) } |
| 39 | } |
| 40 | |
| 41 | /** The tile's centre on the datum cylinder, in kilometres, tower frame. */ |
| 42 | export function tileCentreKm(t: TileKey): [number, number, number] { |
| 43 | const s = tileSize(t.level) |
| 44 | const z = (t.i + 0.5) * s.along |
| 45 | const theta = ((t.j + 0.5) * s.around) / RADIUS_M |
| 46 | const r = RADIUS_M / 1000 |
| 47 | return [r * Math.cos(theta), r * Math.sin(theta), z / 1000] |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * Materials, in the order the shader reads them. Weights, not colours: the fragment |
| 52 | * shader turns them into colour, so a tile carries what the ground *is* and the look |
| 53 | * of it can change without regenerating anything. |
| 54 | */ |
| 55 | export const MATERIALS = [ |
| 56 | 'plain', 'desert', 'forest', 'rock', 'snow', 'water', 'depth', 'bed', 'salt', 'sandstone', 'farm', |
| 57 | ] as const |
| 58 | |
| 59 | /** |
| 60 | * Per water vertex, in this order: depth of water over the ground, main wave amplitude, |
| 61 | * wavelength and direction; flow along z and around; whiteness; ice cover (field.ts). |
| 62 | */ |
| 63 | export const WATER_ATTRS = 8 |
| 64 | |
| 65 | /** |
| 66 | * Per canopy vertex: how high the canopy stands over the ground here, how full the stand |
| 67 | * is, 0..1, and how much of it is conifer. |
| 68 | */ |
| 69 | export const CANOPY_ATTRS = 3 |
| 70 | |
| 71 | export interface TileCanopy { |
| 72 | /** The *ground* vertices, relative to the tile centre, km: the shader lifts them. */ |
| 73 | positions: Float32Array |
| 74 | attrs: Float32Array |
| 75 | indices: Uint16Array |
| 76 | } |
| 77 | |
| 78 | export interface TileWater { |
| 79 | /** Water surface vertices, relative to the tile centre, km: the same grid as the ground. */ |
| 80 | positions: Float32Array |
| 81 | attrs: Float32Array |
| 82 | indices: Uint16Array |
| 83 | } |
| 84 | |
| 85 | export interface TileData { |
| 86 | key: string |
| 87 | /** Vertices relative to the tile centre, km, tower frame. Grid first, then skirts. */ |
| 88 | positions: Float32Array |
| 89 | /** Unit normals, tower frame, as signed bytes: a normal needs no more than 1/127. */ |
| 90 | normals: Int8Array |
| 91 | /** MATERIALS.length weights per vertex, 0..255. */ |
| 92 | materials: Uint8Array |
| 93 | /** The water surfaces on this tile, or null where there is none. */ |
| 94 | water: TileWater | null |
| 95 | /** The forest canopy over this tile, seen from far off, or null where nothing grows. */ |
| 96 | canopy: TileCanopy | null |
| 97 | /** Lowest and highest displayed elevation on the tile, ground or water, metres. */ |
| 98 | minM: number |
| 99 | maxM: number |
| 100 | } |
| 101 | |
| 102 | /** Vertices per side of a tile's grid. */ |
| 103 | export type TileGrid = 33 | 65 |
| 104 | |
| 105 | const smoothstep = (e0: number, e1: number, x: number): number => { |
| 106 | const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0))) |
| 107 | return t * t * (3 - 2 * t) |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Surface material weights from a sample and its slope. Also used by the map, so |
| 112 | * the map and the view cannot disagree about what the ground is. |
| 113 | * |
| 114 | * `out` receives MATERIALS.length values in 0..1. |
| 115 | */ |
| 116 | export function materialsAt(s: Sample, slope: number, out: Float32Array | number[], offset = 0): void { |
| 117 | const shown = Math.max(s.h, s.water) |
| 118 | /** |
| 119 | * Water that is drawn as a surface of its own leaves the ground under it as bed. Water |
| 120 | * too narrow to draw — a river under a coarse tile's vertex spacing — tints the ground |
| 121 | * by how much of it the river covers. |
| 122 | */ |
| 123 | // A narrow river's share of a cell reads far weaker than a line that thin looks to the |
| 124 | // eye: 40 m of river in a 200 m cell is a 20% tint, and it vanished where tiles coarsened. |
| 125 | // The square root keeps a sub-resolution river continuous with the surface drawn nearer. |
| 126 | const water = s.waterMesh ? 0 : Math.min(1, Math.sqrt(s.waterCover) * 1.2) |
| 127 | /** |
| 128 | * Bare rock: high ground in a range, whatever the desert claims as rock (plateau tops, |
| 129 | * cliff bands, a wash's gravel floor), and any steep desert face — a mesa's riser reads |
| 130 | * as rock because it is too steep to hold sand, which is the same reason snow slides off. |
| 131 | */ |
| 132 | const rock = Math.min(1, Math.max( |
| 133 | smoothstep(1300, 1900, s.relief), |
| 134 | Math.max(s.rock, smoothstep(0.45, 0.85, slope) * s.desert))) |
| 135 | // A salt crust is not sand, rock or anything else: it covers them. |
| 136 | const salt = s.salt * (1 - water) |
| 137 | const desert = s.desert * (1 - rock) * (1 - salt) |
| 138 | /** |
| 139 | * Forest floor only where trees actually stand: a meadow inside a forest is grass, and |
| 140 | * the palette's plain ramp is the grass (world-generation.md §4.6). |
| 141 | */ |
| 142 | const canopy = Math.min(1, s.stems / 150) |
| 143 | const forest = s.forest * (1 - rock) * (1 - desert) * (1 - salt) * canopy |
| 144 | const plain = Math.max(0, 1 - rock - desert - forest - salt) |
| 145 | // Snow does not lie on cliffs. Blending it out with slope is what turns a white blob |
| 146 | // into a mountain with rock showing through. |
| 147 | const holds = 1 - smoothstep(0.55, 1.1, slope) |
| 148 | // Grounded sea ice is white whatever its slope: an ice front is ice, not a cliff of rock. |
| 149 | const snow = Math.max((1 - water) * smoothstep(s.snowlineM, s.snowlineM + 550, shown) * holds, s.glacier) |
| 150 | out[offset] = plain |
| 151 | out[offset + 1] = desert |
| 152 | out[offset + 2] = forest |
| 153 | // Rock in a desert is sandstone: warm, and bedded. Rock in a range is grey stone. |
| 154 | const sandstone = rock * (1 - salt) * s.desert |
| 155 | out[offset + 3] = rock * (1 - salt) - sandstone |
| 156 | out[offset + 4] = snow |
| 157 | out[offset + 5] = water |
| 158 | // Water deepens to its darkest over the first 90 m, as the v1 map did. |
| 159 | out[offset + 6] = s.water > s.h ? Math.min(1, (s.water - s.h) / 90) : 0 |
| 160 | out[offset + 7] = s.waterMesh ? 1 : 0 |
| 161 | out[offset + 8] = salt |
| 162 | out[offset + 9] = sandstone |
| 163 | // Fields lie over whatever the ground would otherwise be; the shader draws the crop. |
| 164 | out[offset + 10] = s.farm * (1 - water) * (1 - snow) |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Build one tile. Samples an (N+2)² grid — one ring beyond the edge — so that normals |
| 169 | * at the edge are taken from the real neighbouring ground, and match the tile next |
| 170 | * door instead of creasing along every seam. |
| 171 | */ |
| 172 | export function buildTile(rt: Runtime, t: TileKey, n: TileGrid): TileData { |
| 173 | const size = tileSize(t.level) |
| 174 | const z0 = t.i * size.along |
| 175 | const a0 = t.j * size.around |
| 176 | const dz = size.along / (n - 1) |
| 177 | const da = size.around / (n - 1) |
| 178 | // Shortest wavelength worth computing: twice the vertex spacing. |
| 179 | const detail = 2 * Math.min(dz, da) |
| 180 | const W = n + 2 |
| 181 | |
| 182 | const shown = new Float64Array(W * W) |
| 183 | const mats = new Float32Array(W * W * MATERIALS.length) |
| 184 | // Grounded sea ice (D-093), which stays white however steep its front. |
| 185 | const glacier = new Uint8Array(W * W) |
| 186 | // The water on the tile's own grid: its level (NaN where none is drawn) and attributes. |
| 187 | const wLevel = new Float64Array(n * n).fill(NaN) |
| 188 | const wAttrs = new Float32Array(n * n * WATER_ATTRS) |
| 189 | const wet = new Uint8Array(n * n) |
| 190 | // The canopy over the tile's own grid: how high it stands, and what it is made of. |
| 191 | const cAttrs = new Float32Array(n * n * CANOPY_ATTRS) |
| 192 | let wooded = false |
| 193 | for (let v = 0; v < W; v++) { |
| 194 | for (let u = 0; u < W; u++) { |
| 195 | // z is clamped to the world: the ring beyond a tile at either cap samples the |
| 196 | // edge again rather than the space beyond the end of the cylinder. |
| 197 | const z = Math.min(LENGTH_M, Math.max(0, z0 + (u - 1) * dz)) |
| 198 | const theta = (a0 + (v - 1) * da) / RADIUS_M |
| 199 | const s = rt.sample(theta, z, detail) |
| 200 | const k = v * W + u |
| 201 | // The ground is the ground, under water or not: the water is its own mesh now. |
| 202 | shown[k] = s.h |
| 203 | if (s.waterMesh && u >= 1 && u <= n && v >= 1 && v <= n) { |
| 204 | const w = (v - 1) * n + (u - 1) |
| 205 | wet[w] = 1 |
| 206 | wLevel[w] = s.water |
| 207 | const o = w * WATER_ATTRS |
| 208 | wAttrs[o] = s.water - s.h |
| 209 | if (s.body >= 0) { |
| 210 | // A lake or sea: its waves follow the weather, so the vertex names the body and |
| 211 | // the shader looks its sea state up (sea-state.ts). Negative, so 0 stays "none". |
| 212 | wAttrs[o + 1] = -(s.body + 1) |
| 213 | } else { |
| 214 | // A river's own chop: short and low, and running with it. |
| 215 | wAttrs[o + 1] = 0.04 |
| 216 | wAttrs[o + 2] = 3 |
| 217 | wAttrs[o + 3] = Math.atan2(s.flowA, s.flowZ) |
| 218 | } |
| 219 | wAttrs[o + 4] = s.flowZ |
| 220 | wAttrs[o + 5] = s.flowA |
| 221 | wAttrs[o + 6] = s.foam |
| 222 | wAttrs[o + 7] = s.ice |
| 223 | } |
| 224 | if (s.canopyM > 0 && u >= 1 && u <= n && v >= 1 && v <= n) { |
| 225 | const c = ((v - 1) * n + (u - 1)) * CANOPY_ATTRS |
| 226 | /** |
| 227 | * A rough top. The canopy of a stand is not a sheet at one height — from a |
| 228 | * distance what reads as forest is the broken line of it — so each vertex is |
| 229 | * knocked about by a hash of where it is, which every tile agrees on. |
| 230 | */ |
| 231 | const jitter = 1 + (hash01(z, a0 + (v - 1) * da) - 0.5) * 0.35 |
| 232 | cAttrs[c] = s.canopyM * jitter |
| 233 | cAttrs[c + 1] = Math.min(1, s.stems / 450) |
| 234 | cAttrs[c + 2] = s.forest |
| 235 | wooded = true |
| 236 | } |
| 237 | // Slope is not known until the neighbours are, so snow is thinned by it below. |
| 238 | materialsAt(s, 0, mats, k * MATERIALS.length) |
| 239 | glacier[k] = s.glacier > 0.5 ? 1 : 0 |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | const vertCount = n * n + 4 * n |
| 244 | const positions = new Float32Array(vertCount * 3) |
| 245 | const normals = new Int8Array(vertCount * 3) |
| 246 | const materials = new Uint8Array(vertCount * MATERIALS.length) |
| 247 | const [cx, cy, cz] = tileCentreKm(t) |
| 248 | let minM = Infinity, maxM = -Infinity |
| 249 | |
| 250 | const write = (index: number, u: number, v: number, drop: number): void => { |
| 251 | const k = (v + 1) * W + (u + 1) |
| 252 | const z = Math.min(LENGTH_M, Math.max(0, z0 + u * dz)) |
| 253 | const theta = (a0 + v * da) / RADIUS_M |
| 254 | const cos = Math.cos(theta), sin = Math.sin(theta) |
| 255 | const h = shown[k]! |
| 256 | // A skirt hangs down past any crack, but never through the wall. |
| 257 | const r = (RADIUS_M - Math.max(LOWEST_GROUND_M, h - drop)) / 1000 |
| 258 | positions[3 * index] = r * cos - cx |
| 259 | positions[3 * index + 1] = r * sin - cy |
| 260 | positions[3 * index + 2] = z / 1000 - cz |
| 261 | |
| 262 | // Rise per metre of run along the axis and around it, from the neighbours. |
| 263 | const gz = (shown[k + 1]! - shown[k - 1]!) / (2 * dz) |
| 264 | const ga = (shown[k + W]! - shown[k - W]!) / (2 * da) |
| 265 | // up = -radial; deepward = +z; spinward = (-sin, cos, 0). |
| 266 | let nx = -cos + ga * sin |
| 267 | let ny = -sin - ga * cos |
| 268 | let nz = -gz |
| 269 | const len = Math.hypot(nx, ny, nz) |
| 270 | nx /= len; ny /= len; nz /= len |
| 271 | normals[3 * index] = Math.round(nx * 127) |
| 272 | normals[3 * index + 1] = Math.round(ny * 127) |
| 273 | normals[3 * index + 2] = Math.round(nz * 127) |
| 274 | |
| 275 | // Slope matters for snow, and only now is it known. |
| 276 | const slope = Math.hypot(gz, ga) |
| 277 | const base = k * MATERIALS.length |
| 278 | const holds = glacier[k] ? 1 : 1 - smoothstep(0.55, 1.1, slope) |
| 279 | for (let m = 0; m < MATERIALS.length; m++) { |
| 280 | const w = m === 4 ? mats[base + m]! * holds : mats[base + m]! |
| 281 | materials[index * MATERIALS.length + m] = Math.round(w * 255) |
| 282 | } |
| 283 | if (!drop) { |
| 284 | minM = Math.min(minM, h) |
| 285 | maxM = Math.max(maxM, h) |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | for (let v = 0; v < n; v++) for (let u = 0; u < n; u++) write(v * n + u, u, v, 0) |
| 290 | |
| 291 | /** |
| 292 | * Skirts: a curtain hung from every edge, reaching down past any crack a coarser |
| 293 | * neighbour could open. Deep enough for the height error one level coarser, which |
| 294 | * scales with the tile, and never less than a few metres. |
| 295 | */ |
| 296 | // On steep ground a coarser neighbour's edge can be off by far more than 4% of the |
| 297 | // tile: as much as the steepest step between two vertices here, and then some. A 50° |
| 298 | // gorge wall showed its cracks as a row of dark panels until skirts reached that far. |
| 299 | let steepest = 0 |
| 300 | for (let v = 1; v <= n; v++) { |
| 301 | for (let u = 1; u <= n; u++) { |
| 302 | const k = v * W + u |
| 303 | steepest = Math.max(steepest, Math.abs(shown[k]! - shown[k + 1]!), Math.abs(shown[k]! - shown[k + W]!)) |
| 304 | } |
| 305 | } |
| 306 | const drop = Math.max(8, 0.04 * Math.max(size.along, size.around), 2 * steepest) |
| 307 | let idx = n * n |
| 308 | for (let e = 0; e < n; e++) write(idx++, e, 0, drop) |
| 309 | for (let e = 0; e < n; e++) write(idx++, n - 1, e, drop) |
| 310 | for (let e = 0; e < n; e++) write(idx++, n - 1 - e, n - 1, drop) |
| 311 | for (let e = 0; e < n; e++) write(idx++, 0, n - 1 - e, drop) |
| 312 | |
| 313 | const canopy = wooded ? buildCanopy(n, positions, cAttrs) : null |
| 314 | const water = buildWater(n, z0, a0, dz, da, [cx, cy, cz], wLevel, wAttrs, wet) |
| 315 | if (water) { |
| 316 | for (let w = 0; w < n * n; w++) { |
| 317 | if (Number.isFinite(wLevel[w]!)) { minM = Math.min(minM, wLevel[w]!); maxM = Math.max(maxM, wLevel[w]!) } |
| 318 | } |
| 319 | } |
| 320 | return { key: tileKey(t), positions, normals, materials, water, canopy, minM, maxM } |
| 321 | } |
| 322 | |
| 323 | /** A stable 0..1 from a surface position: the same everywhere that position is asked about. */ |
| 324 | function hash01(z: number, a: number): number { |
| 325 | const x = Math.sin(z * 0.0173 + a * 0.0411) * 43758.5453 |
| 326 | return x - Math.floor(x) |
| 327 | } |
| 328 | |
| 329 | /** |
| 330 | * The canopy over a tile: the *ground* vertices again, with the canopy's height beside |
| 331 | * them. The shader lifts them and drops them back as the eye comes close, where the real |
| 332 | * trees take over (views/forest-view.ts) — so this mesh is the far view of a forest, and |
| 333 | * the ground it is built from is the ground it collapses onto exactly. |
| 334 | */ |
| 335 | function buildCanopy(n: number, ground: Float32Array, attrs: Float32Array): TileCanopy | null { |
| 336 | const positions = Float32Array.from(ground) |
| 337 | const idx: number[] = [] |
| 338 | const has = (k: number): boolean => attrs[k * CANOPY_ATTRS]! > 0.5 |
| 339 | for (let v = 0; v < n - 1; v++) { |
| 340 | for (let u = 0; u < n - 1; u++) { |
| 341 | const a = v * n + u, b = a + 1, c = a + n, d = c + 1 |
| 342 | if (has(a) && has(b) && has(c)) idx.push(a, b, c) |
| 343 | if (has(b) && has(d) && has(c)) idx.push(b, d, c) |
| 344 | } |
| 345 | } |
| 346 | if (!idx.length) return null |
| 347 | return { positions, attrs, indices: new Uint16Array(idx) } |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * The water surface for a tile. Every wet vertex carries its water's level; a dry vertex |
| 352 | * next to one borrows that level, so the surface runs a little way under the bank and the |
| 353 | * depth test draws the shoreline exactly where water meets ground. A triangle is kept if |
| 354 | * all three corners have a level and at least one is wet. |
| 355 | */ |
| 356 | function buildWater( |
| 357 | n: number, z0: number, a0: number, dz: number, da: number, centre: [number, number, number], |
| 358 | level: Float64Array, attrs: Float32Array, wet: Uint8Array, |
| 359 | ): TileWater | null { |
| 360 | if (!wet.some((w) => w)) return null |
| 361 | for (let v = 0; v < n; v++) { |
| 362 | for (let u = 0; u < n; u++) { |
| 363 | const w = v * n + u |
| 364 | if (wet[w]) continue |
| 365 | let best = -Infinity, from = -1 |
| 366 | for (let dv = -1; dv <= 1; dv++) { |
| 367 | for (let du = -1; du <= 1; du++) { |
| 368 | const uu = u + du, vv = v + dv |
| 369 | if (uu < 0 || vv < 0 || uu >= n || vv >= n) continue |
| 370 | const q = vv * n + uu |
| 371 | if (wet[q] && level[q]! > best) { best = level[q]!; from = q } |
| 372 | } |
| 373 | } |
| 374 | if (from < 0) continue |
| 375 | level[w] = best |
| 376 | attrs.copyWithin(w * WATER_ATTRS, from * WATER_ATTRS, (from + 1) * WATER_ATTRS) |
| 377 | attrs[w * WATER_ATTRS] = 0 // no depth on the bank |
| 378 | } |
| 379 | } |
| 380 | const positions = new Float32Array(n * n * 3) |
| 381 | const [cx, cy, cz] = centre |
| 382 | for (let v = 0; v < n; v++) { |
| 383 | const theta = (a0 + v * da) / RADIUS_M |
| 384 | const cos = Math.cos(theta), sin = Math.sin(theta) |
| 385 | for (let u = 0; u < n; u++) { |
| 386 | const w = v * n + u |
| 387 | if (!Number.isFinite(level[w]!)) continue |
| 388 | const r = (RADIUS_M - level[w]!) / 1000 |
| 389 | const z = Math.min(LENGTH_M, Math.max(0, z0 + u * dz)) |
| 390 | positions[3 * w] = r * cos - cx |
| 391 | positions[3 * w + 1] = r * sin - cy |
| 392 | positions[3 * w + 2] = z / 1000 - cz |
| 393 | } |
| 394 | } |
| 395 | const idx: number[] = [] |
| 396 | const has = (w: number): boolean => Number.isFinite(level[w]!) |
| 397 | for (let v = 0; v < n - 1; v++) { |
| 398 | for (let u = 0; u < n - 1; u++) { |
| 399 | const a = v * n + u, b = a + 1, c = a + n, d = c + 1 |
| 400 | if (has(a) && has(b) && has(c) && (wet[a] || wet[b] || wet[c])) idx.push(a, b, c) |
| 401 | if (has(b) && has(d) && has(c) && (wet[b] || wet[d] || wet[c])) idx.push(b, d, c) |
| 402 | } |
| 403 | } |
| 404 | if (!idx.length) return null |
| 405 | return { positions, attrs, indices: new Uint16Array(idx) } |
| 406 | } |
| 407 | |
| 408 | const indexCache = new Map<number, Uint16Array>() |
| 409 | |
| 410 | /** |
| 411 | * Triangle indices for a tile, shared by every tile of the same grid. Wound so the |
| 412 | * face points up — toward the axis — which is the side anyone ever sees. |
| 413 | * |
| 414 | * At vertex (u, v), +u is deepward and +v spinward, and deepward × spinward is up. |
| 415 | * So (u,v) → (u+1,v) → (u,v+1) is counter-clockwise seen from above: a front face. |
| 416 | */ |
| 417 | export function tileIndices(n: TileGrid): Uint16Array { |
| 418 | let idx = indexCache.get(n) |
| 419 | if (idx) return idx |
| 420 | const out: number[] = [] |
| 421 | const at = (u: number, v: number): number => v * n + u |
| 422 | for (let v = 0; v < n - 1; v++) { |
| 423 | for (let u = 0; u < n - 1; u++) { |
| 424 | const a = at(u, v), b = at(u + 1, v), c = at(u, v + 1), d = at(u + 1, v + 1) |
| 425 | out.push(a, b, c, b, d, c) |
| 426 | } |
| 427 | } |
| 428 | // Skirts: each edge vertex joined to its dropped copy, following the edge. |
| 429 | const edge = (e: number): [number, number] => { |
| 430 | // Grid vertex and skirt vertex for step e along the perimeter, in skirt order. |
| 431 | const side = Math.floor(e / n), k = e % n |
| 432 | const grid = side === 0 ? at(k, 0) |
| 433 | : side === 1 ? at(n - 1, k) |
| 434 | : side === 2 ? at(n - 1 - k, n - 1) |
| 435 | : at(0, n - 1 - k) |
| 436 | return [grid, n * n + e] |
| 437 | } |
| 438 | for (let e = 0; e < 4 * n; e++) { |
| 439 | if (e % n === n - 1) continue |
| 440 | const [g0, s0] = edge(e) |
| 441 | const [g1, s1] = edge(e + 1) |
| 442 | // Both windings: a skirt is seen from whichever side the crack is on. |
| 443 | out.push(g0, s0, g1, g1, s0, s1, g0, g1, s0, g1, s1, s0) |
| 444 | } |
| 445 | // 65² + 4·65 vertices fits in 16 bits with room to spare. |
| 446 | idx = new Uint16Array(out) |
| 447 | indexCache.set(n, idx) |
| 448 | return idx |
| 449 | } |