tower / files / src / terrain / palette.ts
palette.ts
| 1 | /** |
| 2 | * What the ground looks like, from what it is. See world-generation.md §6.2. |
| 3 | * |
| 4 | * Tiles carry material weights, not colours (tile.ts). This turns weights and an |
| 5 | * elevation into colour, twice over from one table: in TypeScript for the map, and as |
| 6 | * GLSL for the 3D views — so the map and the view are the same colours by |
| 7 | * construction rather than by two lists kept in step by hand. |
| 8 | * |
| 9 | * The ramps are v1's hypsometric tints, sRGB 0..255 against elevation in metres. |
| 10 | */ |
| 11 | type Ramp = readonly (readonly [number, readonly [number, number, number]])[] |
| 12 | |
| 13 | export const RAMPS: Record<'plain' | 'desert' | 'forest' | 'rock' | 'sandstone', Ramp> = { |
| 14 | plain: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]], |
| 15 | [700, [140, 138, 106]], [1600, [146, 138, 120]]], |
| 16 | desert: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]], |
| 17 | [1400, [148, 120, 86]]], |
| 18 | forest: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]], |
| 19 | rock: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]], |
| 20 | [2900, [166, 162, 156]]], |
| 21 | /** Desert rock: the sandstones and mudstones a mesa is cut from, warm and reddened. */ |
| 22 | sandstone: [[400, [190, 132, 94]], [800, [176, 114, 78]], [1200, [160, 100, 70]], |
| 23 | [1800, [146, 96, 72]]], |
| 24 | } |
| 25 | export const SNOW = [247, 249, 253] as const |
| 26 | /** Salt: white, but a dirtier and warmer white than snow (world-generation.md §4.5). */ |
| 27 | export const SALT = [232, 227, 213] as const |
| 28 | /** |
| 29 | * Sea ice with snow on it (D-090): white, but a bluer, colder white than snow on land, and |
| 30 | * greyer — it is lit by dusk, and wind-packed rather than fresh. |
| 31 | */ |
| 32 | export const ICE = [214, 224, 232] as const |
| 33 | /** Shallow water, and how much darker it gets at full depth. */ |
| 34 | export const WATER = [58, 96, 132] as const |
| 35 | export const WATER_DEEPEN = [20, 26, 24] as const |
| 36 | |
| 37 | export function ramp(r: Ramp, h: number, out: number[]): void { |
| 38 | let c = r[0]![1] |
| 39 | if (h > r[0]![0]) { |
| 40 | c = r[r.length - 1]![1] |
| 41 | for (let i = 1; i < r.length; i++) { |
| 42 | if (h <= r[i]![0]) { |
| 43 | const t = (h - r[i - 1]![0]) / (r[i]![0] - r[i - 1]![0]) |
| 44 | const a = r[i - 1]![1], b = r[i]![1] |
| 45 | out[0] = a[0] + (b[0] - a[0]) * t |
| 46 | out[1] = a[1] + (b[1] - a[1]) * t |
| 47 | out[2] = a[2] + (b[2] - a[2]) * t |
| 48 | return |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | out[0] = c[0]; out[1] = c[1]; out[2] = c[2] |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * sRGB 0..255 for a material mix at an elevation. `m` is tile.ts MATERIALS order: |
| 57 | * plain, desert, forest, rock, snow, water, depth, bed, salt, sandstone. |
| 58 | */ |
| 59 | export function colourOf(m: ArrayLike<number>, h: number, out: number[]): void { |
| 60 | const tmp = [0, 0, 0] |
| 61 | let r = 0, g = 0, b = 0, sum = 0 |
| 62 | const names = ['plain', 'desert', 'forest', 'rock'] as const |
| 63 | for (let i = 0; i < 4; i++) { |
| 64 | const w = m[i]! |
| 65 | if (w <= 0) continue |
| 66 | ramp(RAMPS[names[i]!], h, tmp) |
| 67 | r += tmp[0]! * w; g += tmp[1]! * w; b += tmp[2]! * w |
| 68 | sum += w |
| 69 | } |
| 70 | const sandstone = m[9] ?? 0 |
| 71 | if (sandstone > 0) { |
| 72 | ramp(RAMPS.sandstone, h, tmp) |
| 73 | r += tmp[0]! * sandstone; g += tmp[1]! * sandstone; b += tmp[2]! * sandstone |
| 74 | sum += sandstone |
| 75 | } |
| 76 | if (sum > 0) { r /= sum; g /= sum; b /= sum } |
| 77 | const salt = m[8] ?? 0 |
| 78 | r += (SALT[0] - r) * salt; g += (SALT[1] - g) * salt; b += (SALT[2] - b) * salt |
| 79 | const snow = m[4]! |
| 80 | r += (SNOW[0] - r) * snow; g += (SNOW[1] - g) * snow; b += (SNOW[2] - b) * snow |
| 81 | const water = m[5]!, d = m[6]! |
| 82 | r += (WATER[0] - WATER_DEEPEN[0] * d - r) * water |
| 83 | g += (WATER[1] - WATER_DEEPEN[1] * d - g) * water |
| 84 | b += (WATER[2] - WATER_DEEPEN[2] * d - b) * water |
| 85 | out[0] = r; out[1] = g; out[2] = b |
| 86 | } |
| 87 | |
| 88 | const f = (x: number): string => x.toFixed(4) |
| 89 | const vec = (c: readonly number[]): string => `vec3(${f(c[0]! / 255)}, ${f(c[1]! / 255)}, ${f(c[2]! / 255)})` |
| 90 | |
| 91 | /** A ramp as a GLSL function of elevation in metres, returning sRGB 0..1. */ |
| 92 | function glslRamp(name: string, r: Ramp): string { |
| 93 | const lines = [`vec3 ${name}(float h) {`, ` vec3 c = ${vec(r[0]![1])};`] |
| 94 | for (let i = 1; i < r.length; i++) { |
| 95 | const [h0] = r[i - 1]!, [h1, c1] = r[i]! |
| 96 | lines.push(` c = mix(c, ${vec(c1)}, clamp((h - ${f(h0)}) / ${f(h1 - h0)}, 0.0, 1.0));`) |
| 97 | } |
| 98 | lines.push(' return c;', '}') |
| 99 | return lines.join('\n') |
| 100 | } |
| 101 | |
| 102 | /** The palette as GLSL: `groundColour(matA, matB, extra, h)`, `extra` = (salt, sandstone). */ |
| 103 | export const PALETTE_GLSL = [ |
| 104 | glslRamp('rampPlain', RAMPS.plain), |
| 105 | glslRamp('rampDesert', RAMPS.desert), |
| 106 | glslRamp('rampForest', RAMPS.forest), |
| 107 | glslRamp('rampRock', RAMPS.rock), |
| 108 | glslRamp('rampSandstone', RAMPS.sandstone), |
| 109 | `vec3 groundColour(vec4 a, vec4 b, vec2 extra, float h) { |
| 110 | float sum = max(a.x + a.y + a.z + a.w + extra.y, 1e-3); |
| 111 | vec3 c = (rampPlain(h) * a.x + rampDesert(h) * a.y + rampForest(h) * a.z + rampRock(h) * a.w |
| 112 | + rampSandstone(h) * extra.y) / sum; |
| 113 | c = mix(c, ${vec(SALT)}, extra.x); |
| 114 | c = mix(c, ${vec(SNOW)}, b.x); |
| 115 | vec3 water = ${vec(WATER)} - ${vec(WATER_DEEPEN)} * b.z; |
| 116 | return mix(c, water, b.y); |
| 117 | }`, |
| 118 | ].join('\n\n') |