tower / files / src / terrain / map-image.ts
map-image.ts
| 1 | /** |
| 2 | * The map's picture of the world, rendered from the features. See world-generation.md §9. |
| 3 | * |
| 4 | * The map still draws an image under its vectors — but it is made here, live, from the |
| 5 | * same elevation function and the same palette as the 3D views, not painted ahead of |
| 6 | * time. Rows are rendered in bands so the pool can split the work. |
| 7 | * |
| 8 | * The hillshade is a cartographic convention, not Tower's lighting: Tower's sun is a |
| 9 | * line down the axis, which lights nearly shadowlessly. This is for reading the map. |
| 10 | */ |
| 11 | import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' |
| 12 | import type { Runtime } from './field.js' |
| 13 | import { cylPoint, fbm } from './noise.js' |
| 14 | import { ICE, colourOf } from './palette.js' |
| 15 | import { CROPS, SOIL, fieldAt } from './farms.js' |
| 16 | import { MATERIALS, materialsAt } from './tile.js' |
| 17 | |
| 18 | const HILLSHADE = { azimuthDeg: 315, altitudeDeg: 42, strength: 0.55 } |
| 19 | |
| 20 | /** |
| 21 | * RGBA for rows [y0, y1) of a width × height image of the unrolled surface. Row 0 is |
| 22 | * `thetaOrigin`, where the map is cut (D-013); column 0 is the sun cap. |
| 23 | */ |
| 24 | export function renderMapRows( |
| 25 | rt: Runtime, width: number, height: number, y0: number, y1: number, thetaOrigin: number, |
| 26 | ): Uint8ClampedArray { |
| 27 | const mppX = LENGTH_M / width |
| 28 | const mppY = CIRCUMFERENCE_M / height |
| 29 | const detail = 2 * Math.min(mppX, mppY) |
| 30 | const rows = y1 - y0 + 2 |
| 31 | const shown = new Float64Array(rows * width) |
| 32 | const mats = new Float32Array((y1 - y0) * width * MATERIALS.length) |
| 33 | // Sea ice (D-090) is not a material of the ground: it lies on the water, and covers it. |
| 34 | const ice = new Float32Array((y1 - y0) * width) |
| 35 | const seed = rt.world.seed |
| 36 | |
| 37 | for (let r = 0; r < rows; r++) { |
| 38 | const y = (((y0 - 1 + r) % height) + height) % height |
| 39 | const theta = thetaOrigin + ((y + 0.5) * mppY) / RADIUS_M |
| 40 | for (let x = 0; x < width; x++) { |
| 41 | const s = rt.sample(theta, (x + 0.5) * mppX, detail) |
| 42 | shown[r * width + x] = Math.max(s.h, s.water) |
| 43 | if (r >= 1 && r <= y1 - y0) { |
| 44 | const o = ((r - 1) * width + x) * MATERIALS.length |
| 45 | materialsAt(s, 0, mats, o) |
| 46 | // The map has no water surfaces to draw over the ground: the water is the colour. |
| 47 | if (s.waterMesh) { mats[o + 5] = 1; mats[o + 7] = 0 } |
| 48 | ice[(r - 1) * width + x] = s.ice |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | const az = (HILLSHADE.azimuthDeg * Math.PI) / 180 |
| 54 | const alt = (HILLSHADE.altitudeDeg * Math.PI) / 180 |
| 55 | const lx = Math.cos(alt) * Math.sin(az) |
| 56 | const ly = Math.cos(alt) * Math.cos(az) |
| 57 | const lz = Math.sin(alt) |
| 58 | const out = new Uint8ClampedArray((y1 - y0) * width * 4) |
| 59 | const colour = [0, 0, 0] |
| 60 | const m = new Float32Array(MATERIALS.length) |
| 61 | |
| 62 | for (let r = 1; r <= y1 - y0; r++) { |
| 63 | const y = y0 + r - 1 |
| 64 | const theta = thetaOrigin + ((y + 0.5) * mppY) / RADIUS_M |
| 65 | for (let x = 0; x < width; x++) { |
| 66 | const k = r * width + x |
| 67 | const h = shown[k]! |
| 68 | const nx = (shown[r * width + Math.max(0, x - 1)]! - shown[r * width + Math.min(width - 1, x + 1)]!) / (2 * mppX) |
| 69 | const ny = (shown[k - width]! - shown[k + width]!) / (2 * mppY) |
| 70 | const slope = Math.hypot(nx, ny) |
| 71 | const base = (r - 1) * width + x |
| 72 | for (let i = 0; i < MATERIALS.length; i++) m[i] = mats[base * MATERIALS.length + i]! |
| 73 | m[4] = m[4]! * (1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55))) |
| 74 | colourOf(m, h, colour) |
| 75 | // Farmland (D-095): each field the colour its crop averages to, rows and soil and bloom. |
| 76 | const f = m[10]! |
| 77 | if (f > 0) { |
| 78 | const crop = CROPS[fieldAt((x + 0.5) * mppX, ((y + 0.5) * mppY) % CIRCUMFERENCE_M + thetaOrigin * RADIUS_M).crop]! |
| 79 | const plant = crop.rowM > 0 ? 0.66 : 1 |
| 80 | for (let k = 0; k < 3; k++) { |
| 81 | const own = crop.leaf[k]! + (crop.bloom[k]! - crop.leaf[k]!) * crop.cover |
| 82 | const field = SOIL[k]! + (own - SOIL[k]!) * plant |
| 83 | colour[k] = colour[k]! + (field - colour[k]!) * f |
| 84 | } |
| 85 | } |
| 86 | const c = ice[base]! |
| 87 | if (c > 0) { |
| 88 | colour[0] = colour[0]! + (ICE[0] - colour[0]!) * c |
| 89 | colour[1] = colour[1]! + (ICE[1] - colour[1]!) * c |
| 90 | colour[2] = colour[2]! + (ICE[2] - colour[2]!) * c |
| 91 | } |
| 92 | |
| 93 | let shade = (nx * lx + ny * ly + lz) / Math.hypot(nx, ny, 1) |
| 94 | shade = 1 - HILLSHADE.strength + HILLSHADE.strength * Math.max(0, shade) * 1.35 |
| 95 | if (m[5]! > 0.5) { |
| 96 | shade = 0.93 + 0.07 * shade |
| 97 | } else { |
| 98 | /** |
| 99 | * Two layers of variation, because one was not enough to stop a region reading |
| 100 | * as flat paint (D-052). The broad one dims and brightens; the fine one mottles, |
| 101 | * pushing the channels apart so ground reads as patchy cover over soil. |
| 102 | */ |
| 103 | const p = cylPoint(theta, (x + 0.5) * mppX) |
| 104 | const j = 0.5 + 0.4 * fbm(seed ^ 733, p, 5200, 3, detail) |
| 105 | const mo = 0.4 * fbm(seed ^ 941, p, 1150, 4, detail) * 0.13 |
| 106 | const kk = 0.9 + 0.2 * j |
| 107 | colour[0] = colour[0]! * (kk - mo * 0.55) |
| 108 | colour[1] = colour[1]! * (kk + mo) |
| 109 | colour[2] = colour[2]! * (kk - mo * 0.25) |
| 110 | } |
| 111 | const o = base * 4 |
| 112 | out[o] = colour[0]! * shade |
| 113 | out[o + 1] = colour[1]! * shade |
| 114 | out[o + 2] = colour[2]! * shade |
| 115 | out[o + 3] = 255 |
| 116 | } |
| 117 | } |
| 118 | return out |
| 119 | } |