fuzzyworld

tower / files / src / terrain / map-image.ts

map-image.ts

5.1 KB · history · blame

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 */
11import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
12import type { Runtime } from './field.js'
13import { cylPoint, fbm } from './noise.js'
14import { ICE, colourOf } from './palette.js'
15import { CROPS, SOIL, fieldAt } from './farms.js'
16import { MATERIALS, materialsAt } from './tile.js'
17
18const 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 */
24export 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}