tower / commit
feat(terrain): author the geography as data and render it
content/geography.json | 879 ++++++++++++++++++++++++++++++++++++ public/terrain-color.png | Bin 0 -> 1163745 bytes public/terrain-corridor.png | Bin 0 -> 1105895 bytes public/terrain-height.png | Bin 0 -> 2204780 bytes public/world.json | 1047 +++++++++++++++++++++++++++++++++++++++++++ tools/noise.mjs | 68 +++ tools/png.mjs | 87 ++++ tools/terrain.mjs | 688 ++++++++++++++++++++++++++++ 8 files changed, 2769 insertions(+) diff --git a/content/geography.json b/content/geography.json new file mode 100644 index 0000000..4dcf263 --- /dev/null +++ b/content/geography.json @@ -0,0 +1,879 @@ +{ + "version": 1, + "$comment": [ + "Authored geography for Tower. This file is the SOURCE OF TRUTH for terrain.", + "`npm run terrain` reads it and generates the height map, the colour map, and a", + "seed world.json of places for the app.", + "", + "Coordinates are canon (design.md §1.7): theta in radians around the cylinder,", + "z in metres from the sun cap. Elevation is metres ABOVE the base shell radius —", + "i.e. inward, toward the axis — because in Tower 'up' means closer to the spin", + "axis. All relief is engineered; a bare cylinder is an equipotential with no", + "slope anywhere (design.md §1.9.3).", + "", + "All names are PROVISIONAL. Every feature carries \"provisionalName\": true until", + "the author replaces it." + ], + "datum": { + "radiusM": 50000, + "lengthM": 540000, + "circumferenceM": 314159.27, + "primeMeridian": "theta = 0 runs through the capital" + }, + "compass": { + "sunward": "-z, toward the sun cap", + "deepward": "+z, toward the far cap", + "spinward": "+theta, the direction of rotation", + "antispinward": "-theta — everything that falls leans this way (§1.9.2), so a waterfall is a compass" + }, + "render": { + "gridWidth": 2048, + "gridHeight": 1192, + "metresPerPixel": 263.7, + "maxElevationM": 5000, + "mapThetaOrigin": 3.14159265, + "$mapThetaOriginNote": "Where the unrolled map is cut. With no slats there is no empty theta to hide the seam in (D-013), so the cut goes opposite the capital — the antimeridian principle, applied to habitation instead of geology.", + "snowlineM": 3000, + "hillshade": { + "azimuthDeg": 315, + "altitudeDeg": 42, + "strength": 0.55 + }, + "corridor": { + "$comment": "A close view of the developed region. At world scale the canyon is six pixels wide.", + "width": 2048, + "height": 1020, + "zFrom": 40000, + "zTo": 305000, + "thetaFrom": -0.6, + "thetaTo": 0.72 + } + }, + "hydrology": { + "$comment": "The loop of D-018. Sized so the catchment supports the flow at a plausible rainfall.", + "meanFlowM3S": 392, + "catchmentKM2": 9800, + "impliedPrecipitationMMYr": 1262, + "sourceElevationM": 1900, + "mouthElevationM": 160, + "liftHeadM": 1780, + "pumpPowerW": 6850000000.0, + "pumpPowerPercentOfBudget": 0.0126, + "closure": "made-weather", + "closureNote": "Tower evaporates The Deep and snows it onto the range; the tarns are the top of the loop. Four rivers descend, three are defeated by the ridges and gather along their front, and all of it leaves through The Narrows as one river.", + "trunk": "the-long-water", + "tributaries": [ + "westwall-water", + "sisters-water", + "eastwall-water" + ] + }, + "features": [ + { + "id": "the-plain", + "name": "The Open Ground", + "provisionalName": true, + "kind": "region", + "biome": "plain", + "geometry": { + "type": "whole-surface" + }, + "relief": { + "op": "base", + "baseM": 60, + "noiseAmpM": 260, + "noiseScaleM": 78000, + "octaves": 5, + "detailAmpM": 45, + "detailScaleM": 12000 + }, + "notes": "Everything outside the developed corridor. Deliberately gentle — this is shaped ground that nobody has bothered to make dramatic." + }, + { + "id": "sunward-sea", + "name": "The Sunward Sea", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "band", + "ring": true, + "zFrom": 0, + "zTo": 42000 + }, + "waterLevelM": 90, + "relief": { + "op": "flood", + "depthM": 420, + "edgeM": 16000, + "warpM": 6000 + }, + "notes": "At the foot of the sun cap. Gravity on a cap is tangent to it, so water cannot rest there — it runs to the rim and pools. This ocean is not designed; it is what happens." + }, + { + "id": "deepward-sea", + "name": "The Deepward Sea", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "band", + "ring": true, + "zFrom": 496000, + "zTo": 540000 + }, + "waterLevelM": 90, + "relief": { + "op": "flood", + "depthM": 420, + "edgeM": 16000, + "warpM": 6000 + }, + "notes": "The same at the far cap, and the larger of the two. A 314 km shoreline with a 50 km cliff behind it, and the only windows in the world at its foot (§1.4)." + }, + { + "id": "snowcrown", + "name": "Snowcrown", + "provisionalName": true, + "kind": "mountain", + "biome": "alpine", + "geometry": { + "type": "point", + "theta": 0.3, + "z": 88000 + }, + "elevationM": 4300, + "relief": { + "op": "dome", + "radiusThetaM": 30000, + "radiusZM": 33000, + "heightM": 4300, + "falloff": 1.7, + "ridgedAmp": 0.55, + "ridgedScaleM": 15000, + "warpM": 9000 + } + }, + { + "id": "the-sisters", + "name": "The Sisters", + "provisionalName": true, + "kind": "mountain", + "biome": "alpine", + "geometry": { + "type": "point", + "theta": -0.52, + "z": 94000 + }, + "elevationM": 3650, + "relief": { + "op": "dome", + "radiusThetaM": 26000, + "radiusZM": 28000, + "heightM": 3650, + "falloff": 1.7, + "ridgedAmp": 0.55, + "ridgedScaleM": 15000, + "warpM": 9000 + } + }, + { + "id": "westwall", + "name": "Westwall", + "provisionalName": true, + "kind": "mountain", + "biome": "alpine", + "geometry": { + "type": "point", + "theta": -1.1, + "z": 86000 + }, + "elevationM": 3400, + "relief": { + "op": "dome", + "radiusThetaM": 24000, + "radiusZM": 26000, + "heightM": 3400, + "falloff": 1.7, + "ridgedAmp": 0.55, + "ridgedScaleM": 15000, + "warpM": 9000 + } + }, + { + "id": "eastwall", + "name": "Eastwall", + "provisionalName": true, + "kind": "mountain", + "biome": "alpine", + "geometry": { + "type": "point", + "theta": 0.98, + "z": 92000 + }, + "elevationM": 3550, + "relief": { + "op": "dome", + "radiusThetaM": 25000, + "radiusZM": 27000, + "heightM": 3550, + "falloff": 1.7, + "ridgedAmp": 0.55, + "ridgedScaleM": 15000, + "warpM": 9000 + } + }, + { + "id": "hollow-tarn", + "name": "Hollow Tarn", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "ellipse", + "theta": 0.22, + "z": 100000, + "radiusThetaM": 2000, + "radiusZM": 2400 + }, + "relief": { + "op": "tarn", + "depthM": 190, + "rimM": 120, + "fillFraction": 0.6 + }, + "notes": "The Long Water's source and the top of the world's water loop. Every litre in Tower passes through here." + }, + { + "id": "sisters-tarn", + "name": "Sisters Tarn", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "ellipse", + "theta": -0.47, + "z": 104000, + "radiusThetaM": 2000, + "radiusZM": 2400 + }, + "relief": { + "op": "tarn", + "depthM": 190, + "rimM": 120, + "fillFraction": 0.6 + }, + "notes": "Largest of the high lakes. A ski settlement stands on its deepward shore." + }, + { + "id": "west-tarn", + "name": "Cold Tarn", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "ellipse", + "theta": -1.05, + "z": 98000, + "radiusThetaM": 2000, + "radiusZM": 2400 + }, + "relief": { + "op": "tarn", + "depthM": 190, + "rimM": 120, + "fillFraction": 0.6 + } + }, + { + "id": "east-tarn", + "name": "Long Tarn", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "ellipse", + "theta": 0.94, + "z": 103000, + "radiusThetaM": 2000, + "radiusZM": 2400 + }, + "relief": { + "op": "tarn", + "depthM": 190, + "rimM": 120, + "fillFraction": 0.6 + } + }, + { + "id": "the-dry-reach", + "name": "The Dry Reach", + "provisionalName": true, + "kind": "desert", + "biome": "desert", + "geometry": { + "type": "band", + "ring": true, + "zFrom": 148000, + "zTo": 218000 + }, + "relief": { + "op": "plateau", + "heightM": 780, + "edgeM": 20000, + "noiseAmpM": 90, + "noiseScaleM": 9000, + "warpM": 13000, + "warpScaleM": 40000 + }, + "notes": "Rings the world. On a cylinder you cannot go around a band — a ringed desert is an absolute barrier, which no Earth desert ever is. 70 km deep, 22,000 km², 13% of the world. Dry because Tower withholds rain here, not because terrain shades it." + }, + { + "id": "first-ridge", + "name": "First Ridge", + "provisionalName": true, + "kind": "region", + "biome": "desert", + "geometry": { + "type": "band", + "ring": true, + "z": 176000 + }, + "relief": { + "op": "ridge", + "heightM": 260, + "widthZM": 7000, + "warpM": 5000 + }, + "notes": "Rings the world with its two siblings: three concentric walls across Tower's waist. Every drainage off the range is turned by them. Only one river was here before they were raised and only that one still cuts through — The Narrows is the single breach in all three." + }, + { + "id": "second-ridge", + "name": "Second Ridge", + "provisionalName": true, + "kind": "region", + "biome": "desert", + "geometry": { + "type": "band", + "ring": true, + "z": 190000 + }, + "relief": { + "op": "ridge", + "heightM": 300, + "widthZM": 6500, + "warpM": 5000 + } + }, + { + "id": "third-ridge", + "name": "Third Ridge", + "provisionalName": true, + "kind": "region", + "biome": "desert", + "geometry": { + "type": "band", + "ring": true, + "z": 203000 + }, + "relief": { + "op": "ridge", + "heightM": 240, + "widthZM": 7500, + "warpM": 5000 + } + }, + { + "id": "westwall-water", + "name": "Westwall Water", + "provisionalName": true, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + -1.06, + 104000 + ], + [ + -1.02, + 118000 + ], + [ + -0.92, + 132000 + ], + [ + -0.8, + 144000 + ], + [ + -0.66, + 152000 + ], + [ + -0.52, + 155500 + ], + [ + -0.4, + 158500 + ], + [ + -0.28, + 157500 + ], + [ + -0.16, + 160000 + ], + [ + -0.06, + 161000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "profileM": [ + 1980, + 1560, + 1180, + 930, + 830, + 790, + 762, + 752, + 730, + 715, + 700 + ], + "relief": { + "op": "carve", + "reaches": [ + { + "toIndex": 99, + "valleyWidthM": 2600, + "channelWidthM": 280, + "depthM": 220, + "wallExponent": 1.3, + "shoulderM": 2600 + } + ] + }, + "notes": "Turned by First Ridge and forced to run spinward along its front, gathering the others." + }, + { + "id": "sisters-water", + "name": "Sisters Water", + "provisionalName": true, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + -0.47, + 110000 + ], + [ + -0.45, + 124000 + ], + [ + -0.4, + 138000 + ], + [ + -0.33, + 148000 + ], + [ + -0.24, + 154000 + ], + [ + -0.14, + 157500 + ], + [ + -0.04, + 160000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "profileM": [ + 1900, + 1500, + 1180, + 950, + 835, + 780, + 735, + 700 + ], + "relief": { + "op": "carve", + "reaches": [ + { + "toIndex": 99, + "valleyWidthM": 2600, + "channelWidthM": 280, + "depthM": 220, + "wallExponent": 1.3, + "shoulderM": 2600 + } + ] + } + }, + { + "id": "eastwall-water", + "name": "Eastwall Water", + "provisionalName": true, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.95, + 108000 + ], + [ + 0.92, + 122000 + ], + [ + 0.86, + 134000 + ], + [ + 0.78, + 145000 + ], + [ + 0.66, + 152000 + ], + [ + 0.54, + 155000 + ], + [ + 0.42, + 158000 + ], + [ + 0.3, + 157000 + ], + [ + 0.2, + 159500 + ], + [ + 0.12, + 161000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "profileM": [ + 1960, + 1580, + 1250, + 970, + 850, + 805, + 775, + 762, + 738, + 716, + 700 + ], + "relief": { + "op": "carve", + "reaches": [ + { + "toIndex": 99, + "valleyWidthM": 2600, + "channelWidthM": 280, + "depthM": 220, + "wallExponent": 1.3, + "shoulderM": 2600 + } + ] + }, + "notes": "Runs antispinward along the ridge front. It and Westwall Water meet head-on at the gap." + }, + { + "id": "the-long-water", + "name": "The Long Water", + "provisionalName": true, + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.22, + 100000 + ], + [ + 0.195, + 112000 + ], + [ + 0.17, + 126000 + ], + [ + 0.14, + 140000 + ], + [ + 0.1, + 152000 + ], + [ + 0.062, + 162000 + ], + [ + 0.03, + 170000 + ], + [ + -0.01, + 178000 + ], + [ + 0.015, + 186000 + ], + [ + -0.02, + 194000 + ], + [ + 0.005, + 201000 + ], + [ + -0.015, + 208000 + ], + [ + 0.01, + 216000 + ], + [ + 0.048, + 226000 + ], + [ + 0.086, + 236000 + ], + [ + 0.12, + 246000 + ] + ] + }, + "profileM": [ + 1900, + 1560, + 1240, + 940, + 800, + 700, + 640, + 580, + 520, + 460, + 415, + 370, + 320, + 250, + 180, + 124 + ], + "relief": { + "op": "carve", + "reaches": [ + { + "$reach": "mountain valley", + "toIndex": 6, + "valleyWidthM": 4200, + "channelWidthM": 280, + "depthM": 300, + "wallExponent": 1.25, + "shoulderM": 3500 + }, + { + "$reach": "The Narrows", + "toIndex": 12, + "valleyWidthM": 3200, + "channelWidthM": 260, + "depthM": 480, + "wallExponent": 0.5, + "shoulderM": 1100 + }, + { + "$reach": "forest reach", + "toIndex": 17, + "valleyWidthM": 5200, + "channelWidthM": 340, + "depthM": 80, + "wallExponent": 1.7, + "shoulderM": 6500 + } + ] + }, + "notes": "Trunk of the whole system. Above the gap it is one of four; below it carries all of them — about 392 m³/s off a 9,800 km² range. Zero Coriolis on its axial reaches (§1.9.1); the tributaries running along the ridge front are the only circumferential water in the world, and they weigh fractionally more or less for it (§1.9.2)." + }, + { + "id": "the-narrows", + "name": "The Narrows", + "provisionalName": true, + "kind": "canyon", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.03, + 170000 + ], + [ + -0.01, + 178000 + ], + [ + 0.015, + 186000 + ], + [ + -0.02, + 194000 + ], + [ + 0.005, + 201000 + ], + [ + -0.015, + 208000 + ] + ] + }, + "depthM": 420, + "lengthM": 40000, + "notes": "The only breach in all three ridges, and therefore the only route along the axis of the world. 40 km long, rim ~1,000 m, up to 480 m deep. Whoever holds The Narrows holds Tower. Antecedent, exactly like the Yakima crossing the Manastash and Umtanum." + }, + { + "id": "greatwood", + "name": "Greatwood", + "provisionalName": true, + "kind": "forest", + "biome": "forest", + "geometry": { + "type": "band", + "ring": true, + "zFrom": 214000, + "zTo": 430000 + }, + "relief": { + "op": "plateau", + "heightM": 250, + "edgeM": 30000, + "noiseAmpM": 70, + "noiseScaleM": 15000, + "warpM": 18000, + "warpScaleM": 55000 + }, + "notes": "A forest belt ringing the world deepward of the desert. Tower rains here on purpose." + }, + { + "id": "the-deep", + "name": "The Deep", + "provisionalName": true, + "kind": "lake", + "biome": "water", + "geometry": { + "type": "rect", + "zFrom": 248000, + "zTo": 402000, + "thetaFrom": 0.06, + "thetaTo": 0.7 + }, + "waterLevelM": 120, + "relief": { + "op": "flood", + "depthM": 330, + "edgeM": 11000, + "warpM": 7000, + "warpScaleM": 34000 + }, + "notes": "Long down the axis and narrow around it, which is the whole point: you cross it the SHORT way, around theta, and the far shore rides 2.6 km above your eye. 154 km long, 32 km wide. Fed by The Long Water at its sunward head. Its heat capacity is the world's flywheel (§1.9.4)." + }, + { + "id": "the-capital", + "name": "The Capital", + "provisionalName": true, + "kind": "city", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 270000 + }, + "elevationM": 175, + "notes": "z = 270 km, the axial midpoint — the only centre a cylinder has. On The Deep's antispinward shore, so the city looks across 32 km of water at a far shore standing 2.6 km up in its sky. Deepward of The Narrows: it sits behind the world's only gate." + }, + { + "id": "the-spindle", + "name": "The Spindle", + "provisionalName": true, + "kind": "tower", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 271500 + }, + "elevationM": 175, + "heightM": 3200, + "notes": "The tall tower. At 3.2 km its top sits in 0.94 g and its sway period differs from its base. Height is a free choice — see the open question in design.md §1.11." + }, + { + "id": "sisters-station", + "name": "Sisters Station", + "provisionalName": true, + "kind": "city", + "geometry": { + "type": "point", + "theta": -0.47, + "z": 110500 + }, + "elevationM": 2100, + "notes": "Ski settlement above Sisters Tarn. Runs facing antispinward are the fast ones: a skier descending against the spin loses up to 8.6% of their weight at speed, and the effect grows as they accelerate. Prograde runs grip harder instead. Every slope in Tower has a handedness; no Earth mountain does." + }, + { + "id": "hollow-station", + "name": "Hollow Station", + "provisionalName": true, + "kind": "city", + "geometry": { + "type": "point", + "theta": 0.235, + "z": 102000 + }, + "elevationM": 2100 + } + ] +} diff --git a/public/terrain-color.png b/public/terrain-color.png new file mode 100644 index 0000000..48b3614 Binary files /dev/null and b/public/terrain-color.png differ diff --git a/public/terrain-corridor.png b/public/terrain-corridor.png new file mode 100644 index 0000000..4bf40fd Binary files /dev/null and b/public/terrain-corridor.png differ diff --git a/public/terrain-height.png b/public/terrain-height.png new file mode 100644 index 0000000..42d862d Binary files /dev/null and b/public/terrain-height.png differ diff --git a/public/world.json b/public/world.json new file mode 100644 index 0000000..93d62e2 --- /dev/null +++ b/public/world.json @@ -0,0 +1,1047 @@ +{ + "version": 1, + "places": [ + { + "id": "sunward-sea", + "name": "The Sunward Sea", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + 0, + 0 + ], + [ + 6.283185306179586, + 0 + ], + [ + 6.283185306179586, + 42000 + ], + [ + 0, + 42000 + ] + ] + }, + "blurb": "" + }, + { + "id": "deepward-sea", + "name": "The Deepward Sea", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + 0, + 496000 + ], + [ + 6.283185306179586, + 496000 + ], + [ + 6.283185306179586, + 540000 + ], + [ + 0, + 540000 + ] + ] + }, + "blurb": "" + }, + { + "id": "snowcrown", + "name": "Snowcrown", + "kind": "mountain", + "geometry": { + "type": "point", + "theta": 0.3, + "z": 88000 + }, + "blurb": "" + }, + { + "id": "the-sisters", + "name": "The Sisters", + "kind": "mountain", + "geometry": { + "type": "point", + "theta": -0.52, + "z": 94000 + }, + "blurb": "" + }, + { + "id": "westwall", + "name": "Westwall", + "kind": "mountain", + "geometry": { + "type": "point", + "theta": -1.1, + "z": 86000 + }, + "blurb": "" + }, + { + "id": "eastwall", + "name": "Eastwall", + "kind": "mountain", + "geometry": { + "type": "point", + "theta": 0.98, + "z": 92000 + }, + "blurb": "" + }, + { + "id": "hollow-tarn", + "name": "Hollow Tarn", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + 0.26, + 100000 + ], + [ + 0.25923141121612925, + 100468.21677283871 + ], + [ + 0.2569551813004515, + 100918.44023767622 + ], + [ + 0.2532587844921018, + 101333.36855924704 + ], + [ + 0.2482842712474619, + 101697.05627484771 + ], + [ + 0.2422228093207841, + 101995.52706952611 + ], + [ + 0.2353073372946036, + 102217.31087802708 + ], + [ + 0.22780361288064513, + 102353.88467296775 + ], + [ + 0.22, + 102400 + ], + [ + 0.21219638711935487, + 102353.88467296775 + ], + [ + 0.2046926627053964, + 102217.31087802708 + ], + [ + 0.19777719067921593, + 101995.52706952611 + ], + [ + 0.19171572875253812, + 101697.05627484771 + ], + [ + 0.18674121550789818, + 101333.36855924704 + ], + [ + 0.18304481869954853, + 100918.44023767622 + ], + [ + 0.18076858878387078, + 100468.21677283871 + ], + [ + 0.18, + 100000 + ], + [ + 0.18076858878387078, + 99531.78322716129 + ], + [ + 0.18304481869954853, + 99081.55976232378 + ], + [ + 0.18674121550789818, + 98666.63144075296 + ], + [ + 0.1917157287525381, + 98302.94372515229 + ], + [ + 0.19777719067921593, + 98004.47293047389 + ], + [ + 0.20469266270539638, + 97782.68912197292 + ], + [ + 0.21219638711935485, + 97646.11532703225 + ], + [ + 0.22, + 97600 + ], + [ + 0.22780361288064513, + 97646.11532703225 + ], + [ + 0.2353073372946036, + 97782.68912197292 + ], + [ + 0.24222280932078408, + 98004.47293047389 + ], + [ + 0.24828427124746189, + 98302.94372515229 + ], + [ + 0.2532587844921018, + 98666.63144075296 + ], + [ + 0.2569551813004515, + 99081.55976232378 + ], + [ + 0.2592314112161292, + 99531.78322716129 + ] + ] + }, + "blurb": "" + }, + { + "id": "sisters-tarn", + "name": "Sisters Tarn", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + -0.43, + 104000 + ], + [ + -0.43076858878387075, + 104468.21677283871 + ], + [ + -0.4330448186995485, + 104918.44023767622 + ], + [ + -0.4367412155078982, + 105333.36855924704 + ], + [ + -0.44171572875253806, + 105697.05627484771 + ], + [ + -0.4477771906792159, + 105995.52706952611 + ], + [ + -0.4546926627053964, + 106217.31087802708 + ], + [ + -0.46219638711935485, + 106353.88467296775 + ], + [ + -0.47, + 106400 + ], + [ + -0.4778036128806451, + 106353.88467296775 + ], + [ + -0.48530733729460357, + 106217.31087802708 + ], + [ + -0.49222280932078405, + 105995.52706952611 + ], + [ + -0.4982842712474619, + 105697.05627484771 + ], + [ + -0.5032587844921018, + 105333.36855924704 + ], + [ + -0.5069551813004515, + 104918.44023767622 + ], + [ + -0.5092314112161291, + 104468.21677283871 + ], + [ + -0.51, + 104000 + ], + [ + -0.5092314112161291, + 103531.78322716129 + ], + [ + -0.5069551813004515, + 103081.55976232378 + ], + [ + -0.5032587844921018, + 102666.63144075296 + ], + [ + -0.4982842712474619, + 102302.94372515229 + ], + [ + -0.49222280932078405, + 102004.47293047389 + ], + [ + -0.48530733729460357, + 101782.68912197292 + ], + [ + -0.4778036128806451, + 101646.11532703225 + ], + [ + -0.47, + 101600 + ], + [ + -0.46219638711935485, + 101646.11532703225 + ], + [ + -0.4546926627053964, + 101782.68912197292 + ], + [ + -0.4477771906792159, + 102004.47293047389 + ], + [ + -0.44171572875253806, + 102302.94372515229 + ], + [ + -0.4367412155078982, + 102666.63144075296 + ], + [ + -0.43304481869954853, + 103081.55976232378 + ], + [ + -0.43076858878387075, + 103531.78322716129 + ] + ] + }, + "blurb": "" + }, + { + "id": "west-tarn", + "name": "Cold Tarn", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + -1.01, + 98000 + ], + [ + -1.0107685887838709, + 98468.21677283871 + ], + [ + -1.0130448186995487, + 98918.44023767622 + ], + [ + -1.0167412155078983, + 99333.36855924704 + ], + [ + -1.0217157287525382, + 99697.05627484771 + ], + [ + -1.027777190679216, + 99995.52706952611 + ], + [ + -1.0346926627053965, + 100217.31087802708 + ], + [ + -1.042196387119355, + 100353.88467296775 + ], + [ + -1.05, + 100400 + ], + [ + -1.0578036128806452, + 100353.88467296775 + ], + [ + -1.0653073372946036, + 100217.31087802708 + ], + [ + -1.0722228093207842, + 99995.52706952611 + ], + [ + -1.0782842712474618, + 99697.05627484771 + ], + [ + -1.0832587844921018, + 99333.36855924704 + ], + [ + -1.0869551813004514, + 98918.44023767622 + ], + [ + -1.0892314112161292, + 98468.21677283871 + ], + [ + -1.09, + 98000 + ], + [ + -1.0892314112161292, + 97531.78322716129 + ], + [ + -1.0869551813004514, + 97081.55976232378 + ], + [ + -1.0832587844921018, + 96666.63144075296 + ], + [ + -1.0782842712474618, + 96302.94372515229 + ], + [ + -1.0722228093207842, + 96004.47293047389 + ], + [ + -1.0653073372946036, + 95782.68912197292 + ], + [ + -1.0578036128806452, + 95646.11532703225 + ], + [ + -1.05, + 95600 + ], + [ + -1.042196387119355, + 95646.11532703225 + ], + [ + -1.0346926627053965, + 95782.68912197292 + ], + [ + -1.027777190679216, + 96004.47293047389 + ], + [ + -1.0217157287525382, + 96302.94372515229 + ], + [ + -1.0167412155078983, + 96666.63144075296 + ], + [ + -1.0130448186995487, + 97081.55976232378 + ], + [ + -1.0107685887838709, + 97531.78322716129 + ] + ] + }, + "blurb": "" + }, + { + "id": "east-tarn", + "name": "Long Tarn", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + 0.98, + 103000 + ], + [ + 0.9792314112161291, + 103468.21677283871 + ], + [ + 0.9769551813004514, + 103918.44023767622 + ], + [ + 0.9732587844921018, + 104333.36855924704 + ], + [ + 0.9682842712474619, + 104697.05627484771 + ], + [ + 0.9622228093207841, + 104995.52706952611 + ], + [ + 0.9553073372946035, + 105217.31087802708 + ], + [ + 0.9478036128806451, + 105353.88467296775 + ], + [ + 0.94, + 105400 + ], + [ + 0.9321963871193548, + 105353.88467296775 + ], + [ + 0.9246926627053964, + 105217.31087802708 + ], + [ + 0.9177771906792158, + 104995.52706952611 + ], + [ + 0.911715728752538, + 104697.05627484771 + ], + [ + 0.9067412155078981, + 104333.36855924704 + ], + [ + 0.9030448186995484, + 103918.44023767622 + ], + [ + 0.9007685887838708, + 103468.21677283871 + ], + [ + 0.8999999999999999, + 103000 + ], + [ + 0.9007685887838708, + 102531.78322716129 + ], + [ + 0.9030448186995484, + 102081.55976232378 + ], + [ + 0.9067412155078981, + 101666.63144075296 + ], + [ + 0.911715728752538, + 101302.94372515229 + ], + [ + 0.9177771906792158, + 101004.47293047389 + ], + [ + 0.9246926627053963, + 100782.68912197292 + ], + [ + 0.9321963871193548, + 100646.11532703225 + ], + [ + 0.94, + 100600 + ], + [ + 0.9478036128806451, + 100646.11532703225 + ], + [ + 0.9553073372946036, + 100782.68912197292 + ], + [ + 0.962222809320784, + 101004.47293047389 + ], + [ + 0.9682842712474619, + 101302.94372515229 + ], + [ + 0.9732587844921018, + 101666.63144075296 + ], + [ + 0.9769551813004514, + 102081.55976232378 + ], + [ + 0.9792314112161291, + 102531.78322716129 + ] + ] + }, + "blurb": "" + }, + { + "id": "the-dry-reach", + "name": "The Dry Reach", + "kind": "desert", + "geometry": { + "type": "polygon", + "points": [ + [ + 0, + 148000 + ], + [ + 6.283185306179586, + 148000 + ], + [ + 6.283185306179586, + 218000 + ], + [ + 0, + 218000 + ] + ] + }, + "blurb": "" + }, + { + "id": "westwall-water", + "name": "Westwall Water", + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + -1.06, + 104000 + ], + [ + -1.02, + 118000 + ], + [ + -0.92, + 132000 + ], + [ + -0.8, + 144000 + ], + [ + -0.66, + 152000 + ], + [ + -0.52, + 155500 + ], + [ + -0.4, + 158500 + ], + [ + -0.28, + 157500 + ], + [ + -0.16, + 160000 + ], + [ + -0.06, + 161000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "blurb": "" + }, + { + "id": "sisters-water", + "name": "Sisters Water", + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + -0.47, + 110000 + ], + [ + -0.45, + 124000 + ], + [ + -0.4, + 138000 + ], + [ + -0.33, + 148000 + ], + [ + -0.24, + 154000 + ], + [ + -0.14, + 157500 + ], + [ + -0.04, + 160000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "blurb": "" + }, + { + "id": "eastwall-water", + "name": "Eastwall Water", + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.95, + 108000 + ], + [ + 0.92, + 122000 + ], + [ + 0.86, + 134000 + ], + [ + 0.78, + 145000 + ], + [ + 0.66, + 152000 + ], + [ + 0.54, + 155000 + ], + [ + 0.42, + 158000 + ], + [ + 0.3, + 157000 + ], + [ + 0.2, + 159500 + ], + [ + 0.12, + 161000 + ], + [ + 0.062, + 162000 + ] + ] + }, + "blurb": "" + }, + { + "id": "the-long-water", + "name": "The Long Water", + "kind": "river", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.22, + 100000 + ], + [ + 0.195, + 112000 + ], + [ + 0.17, + 126000 + ], + [ + 0.14, + 140000 + ], + [ + 0.1, + 152000 + ], + [ + 0.062, + 162000 + ], + [ + 0.03, + 170000 + ], + [ + -0.01, + 178000 + ], + [ + 0.015, + 186000 + ], + [ + -0.02, + 194000 + ], + [ + 0.005, + 201000 + ], + [ + -0.015, + 208000 + ], + [ + 0.01, + 216000 + ], + [ + 0.048, + 226000 + ], + [ + 0.086, + 236000 + ], + [ + 0.12, + 246000 + ] + ] + }, + "blurb": "" + }, + { + "id": "the-narrows", + "name": "The Narrows", + "kind": "canyon", + "geometry": { + "type": "polyline", + "points": [ + [ + 0.03, + 170000 + ], + [ + -0.01, + 178000 + ], + [ + 0.015, + 186000 + ], + [ + -0.02, + 194000 + ], + [ + 0.005, + 201000 + ], + [ + -0.015, + 208000 + ] + ] + }, + "blurb": "" + }, + { + "id": "greatwood", + "name": "Greatwood", + "kind": "forest", + "geometry": { + "type": "polygon", + "points": [ + [ + 0, + 214000 + ], + [ + 6.283185306179586, + 214000 + ], + [ + 6.283185306179586, + 430000 + ], + [ + 0, + 430000 + ] + ] + }, + "blurb": "" + }, + { + "id": "the-deep", + "name": "The Deep", + "kind": "lake", + "geometry": { + "type": "polygon", + "points": [ + [ + 0.06, + 248000 + ], + [ + 0.7, + 248000 + ], + [ + 0.7, + 402000 + ], + [ + 0.06, + 402000 + ] + ] + }, + "blurb": "" + }, + { + "id": "the-capital", + "name": "The Capital", + "kind": "city", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 270000 + }, + "blurb": "" + }, + { + "id": "the-spindle", + "name": "The Spindle", + "kind": "tower", + "geometry": { + "type": "point", + "theta": 0.02, + "z": 271500 + }, + "blurb": "" + }, + { + "id": "sisters-station", + "name": "Sisters Station", + "kind": "city", + "geometry": { + "type": "point", + "theta": -0.47, + "z": 110500 + }, + "blurb": "" + }, + { + "id": "hollow-station", + "name": "Hollow Station", + "kind": "city", + "geometry": { + "type": "point", + "theta": 0.235, + "z": 102000 + }, + "blurb": "" + } + ] +} diff --git a/tools/noise.mjs b/tools/noise.mjs new file mode 100644 index 0000000..daccacf --- /dev/null +++ b/tools/noise.mjs @@ -0,0 +1,68 @@ +/** + * Deterministic value noise with fBm, wrapping on the y (theta) axis. + * + * The map is a cylinder: y wraps and x does not. Noise that ignores that leaves a + * visible seam down the map, so the lattice wraps at a caller-supplied period. + */ + +/** + * 32-bit integer hash. Uses Math.imul throughout: the obvious float version + * silently loses precision on large products and leaves axis-aligned streaks + * across the terrain. + */ +function hash(x, y, seed) { + let h = Math.imul(x | 0, 374761393) ^ Math.imul(y | 0, 668265263) ^ Math.imul(seed | 0, 1274126177) + h = Math.imul(h ^ (h >>> 15), 2246822519) + h = Math.imul(h ^ (h >>> 13), 3266489917) + return ((h ^ (h >>> 16)) >>> 0) / 4294967295 +} + +const smooth = (t) => t * t * (3 - 2 * t) + +/** Value noise at (x, y) on a lattice, wrapping y every `wrapY` cells. */ +function value(x, y, seed, wrapY) { + const xi = Math.floor(x) + const yi = Math.floor(y) + const xf = smooth(x - xi) + const yf = smooth(y - yi) + const wrap = (v) => (wrapY > 0 ? ((v % wrapY) + wrapY) % wrapY : v) + const y0 = wrap(yi) + const y1 = wrap(yi + 1) + const a = hash(xi, y0, seed) + const b = hash(xi + 1, y0, seed) + const c = hash(xi, y1, seed) + const d = hash(xi + 1, y1, seed) + return (a + (b - a) * xf) * (1 - yf) + (c + (d - c) * xf) * yf +} + +/** Fractal Brownian motion in [0,1]. `wrapY` is the lattice period on the y axis. */ +export function fbm(x, y, { octaves = 4, seed = 1, wrapY = 0, lacunarity = 2, gain = 0.5 } = {}) { + let sum = 0 + let amp = 1 + let norm = 0 + let freq = 1 + for (let o = 0; o < octaves; o++) { + sum += value(x * freq, y * freq, seed + o * 101, wrapY * freq) * amp + norm += amp + amp *= gain + freq *= lacunarity + } + return sum / norm +} + +/** Ridged fBm — absolute-valued and inverted, which makes mountain crests. */ +export function ridged(x, y, opts = {}) { + const { octaves = 4 } = opts + let sum = 0 + let amp = 1 + let norm = 0 + let freq = 1 + for (let o = 0; o < octaves; o++) { + const v = 1 - Math.abs(value(x * freq, y * freq, (opts.seed ?? 1) + o * 211, (opts.wrapY ?? 0) * freq) * 2 - 1) + sum += v * v * amp + norm += amp + amp *= 0.5 + freq *= 2 + } + return sum / norm +} diff --git a/tools/png.mjs b/tools/png.mjs new file mode 100644 index 0000000..b4af6e8 --- /dev/null +++ b/tools/png.mjs @@ -0,0 +1,87 @@ +/** + * Minimal PNG encoder. Node's zlib is the only dependency. + * + * Supports what the terrain tool needs: 16-bit greyscale (height data, where 8 bits + * would quantise a 5,000 m range into 20 m steps) and 8-bit RGB (the colour map). + * Scanlines use the Up filter, which suits smooth terrain and costs three lines. + */ +import { deflateSync } from 'node:zlib' + +const CRC_TABLE = (() => { + const t = new Int32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + t[n] = c + } + return t +})() + +function crc32(buf) { + let c = -1 + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) + return (c ^ -1) >>> 0 +} + +function chunk(type, data) { + const out = Buffer.alloc(data.length + 12) + out.writeUInt32BE(data.length, 0) + out.write(type, 4, 'ascii') + data.copy(out, 8) + const body = out.subarray(4, 8 + data.length) + out.writeUInt32BE(crc32(body), 8 + data.length) + return out +} + +/** Apply the Up filter in place, row by row, and prefix each row with its filter byte. */ +function filterRows(raw, width, height, bytesPerPixel) { + const stride = width * bytesPerPixel + const out = Buffer.alloc((stride + 1) * height) + for (let y = 0; y < height; y++) { + out[y * (stride + 1)] = 2 // Up + const row = y * stride + const prev = (y - 1) * stride + const dst = y * (stride + 1) + 1 + for (let x = 0; x < stride; x++) { + const up = y === 0 ? 0 : raw[prev + x] + out[dst + x] = (raw[row + x] - up) & 0xff + } + } + return out +} + +function encode(width, height, colorType, bitDepth, raw, bytesPerPixel, level) { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0) + ihdr.writeUInt32BE(height, 4) + ihdr[8] = bitDepth + ihdr[9] = colorType + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(filterRows(raw, width, height, bytesPerPixel), { level })), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +/** + * 16-bit greyscale from normalised values in [0,1]. Level 9 is nearly free here: + * smooth terrain under the Up filter is almost all near-zero deltas. + */ +export function encodeGray16(width, height, values, level = 9) { + const raw = Buffer.alloc(width * height * 2) + for (let i = 0; i < width * height; i++) { + const v = Math.max(0, Math.min(1, values[i])) + raw.writeUInt16BE(Math.round(v * 65535), i * 2) + } + return encode(width, height, 0, 16, raw, 2, level) +} + +/** + * 8-bit RGB from an interleaved Uint8Array of length width*height*3. + * Level 6 by default: hillshade noise compresses poorly, and level 9 spends + * several seconds per map for a couple of percent. Wrong trade for a dev tool. + */ +export function encodeRGB(width, height, rgb, level = 6) { + return encode(width, height, 2, 8, Buffer.from(rgb.buffer, rgb.byteOffset, rgb.length), 3, level) +} diff --git a/tools/terrain.mjs b/tools/terrain.mjs new file mode 100644 index 0000000..b63e391 --- /dev/null +++ b/tools/terrain.mjs @@ -0,0 +1,688 @@ +/** + * Terrain generator. `npm run terrain` + * + * Reads content/geography.json — the authored source of truth — and writes: + * public/terrain-height.png 16-bit greyscale, 0..maxElevationM + * public/terrain-color.png 8-bit RGB, hypsometric tint + hillshade + * public/world.json seed places for the app + * + * The map is the cylinder unrolled: x is z (sun cap at the left), y is theta. + * y wraps; x does not. Every distance below is in metres, converted to pixels only + * at the edges, because the canon coordinates are metres (design.md §1.7). + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { encodeGray16, encodeRGB } from './png.mjs' +import { fbm, ridged } from './noise.mjs' + +// ------------------------------------------------------------------- progress +const T0 = Date.now() +const secs = (from) => `${((Date.now() - from) / 1000).toFixed(1)}s` +const mb = (n) => `${(n / 1048576).toFixed(1)} MB` +/** Print `label` padded out to a timing column, so the log reads as a table. */ +function line(indent, label, right = '') { + const pad = Math.max(1, 58 - indent * 2 - label.length - right.length) + console.log(' '.repeat(indent * 2) + label + ' '.repeat(pad) + right) +} +/** Run `fn`, timing it and printing one row. */ +function step(indent, label, fn, extra = () => '') { + const t = Date.now() + const out = fn() + line(indent, label, `${extra(out)}${secs(t)}`.trim()) + return out +} + +const geo = JSON.parse(readFileSync('content/geography.json', 'utf8')) +console.log(`\nTower terrain — content/geography.json, ${geo.features.length} features\n`) +const { radiusM: R, lengthM: L, circumferenceM: C } = geo.datum +const BIOME = { plain: 0, desert: 1, forest: 2, alpine: 3, water: 4 } +const ORIGIN = geo.render.mapThetaOrigin ?? 0 + +// Mutable view state. Every op reads these, so the same generator renders the +// whole world or a close crop of the corridor without duplicating any terrain code. +let W, H, MPP_X, MPP_Y, VZ0, VY0 +let height, biome, water, biomeStrength + +const px2z = (x) => VZ0 + (x + 0.5) * MPP_X +const px2y = (y) => VY0 + (y + 0.5) * MPP_Y +/** Canon theta -> map y, honouring where the map is cut. */ +const theta2y = (t) => { + const d = (((t - ORIGIN) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI) + return d * R +} + +/** + * Noise sample coordinates at a given feature size, with the theta axis snapped to + * a whole number of lattice cells. The circumference is not a round multiple of any + * useful scale, so an unsnapped lattice tears at the seam and streaks the terrain. + * Snapping makes the y frequency differ from x by a few percent, which is invisible. + */ +function nc(z, y, scaleM) { + const cells = Math.max(1, Math.round(C / scaleM)) + return { x: z / scaleM, y: (y / C) * cells, wrapY: cells } +} + +/** + * Domain warp: offset a sample point by low-frequency noise before evaluating a + * mask. Without it every region reads as the rectangle or ellipse it was declared + * as; with it the edges wander like real ones. + */ +function warp(z, y, ampM, scaleM, seed) { + if (!ampM) return [z, y] + const s = nc(z, y, scaleM) + const wz = fbm(s.x, s.y, { octaves: 3, wrapY: s.wrapY, seed }) - 0.5 + const wy = fbm(s.x + 37.1, s.y + 13.7, { octaves: 3, wrapY: s.wrapY, seed: seed + 500 }) - 0.5 + return [z + wz * 2 * ampM, y + wy * 2 * ampM] +} +/** Shortest distance along the wrapping theta axis. */ +const dy = (a, b) => { + let d = Math.abs(a - b) % C + return d > C / 2 ? C - d : d +} +const smoothstep = (e0, e1, x) => { + const t = Math.max(0, Math.min(1, (x - e0) / (e1 - e0))) + return t * t * (3 - 2 * t) +} + +// ---------------------------------------------------------------- relief ops + +function opBase(f) { + const r = f.relief + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const z = px2z(x) + const wy = px2y(y) + // Broad rolling relief, plus a finer layer so the ground has texture at + // reading scale. Still gentle: this is engineered ground nobody dramatised. + const sb = nc(z, wy, r.noiseScaleM) + const sf = nc(z, wy, r.detailScaleM) + const sg = nc(z, wy, 3400) + const broad = fbm(sb.x, sb.y, { octaves: r.octaves ?? 5, seed: 7, wrapY: sb.wrapY }) + const fine = fbm(sf.x, sf.y, { octaves: 4, seed: 313, wrapY: sf.wrapY }) + const grain = ridged(sg.x, sg.y, { octaves: 3, seed: 517, wrapY: sg.wrapY }) + height[y * W + x] = + r.baseM + (broad - 0.45) * 2 * r.noiseAmpM + (fine - 0.5) * 2 * r.detailAmpM + + (grain - 0.5) * 26 + } + } +} + +/** Raised block with soft edges — the desert plateau and the forest shelf. */ +/** Theta mask for a footprint, or a flat 1 for a band that rings the world. */ +function thetaMask(g, r, wyw) { + if (g.ring) return 1 + const span = dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) + const mid = theta2y((g.thetaFrom + g.thetaTo) / 2) + return smoothstep(span / 2, span / 2 - r.edgeM, dy(wyw, mid)) +} + +function opPlateau(f) { + const g = f.geometry + const r = f.relief + for (let y = 0; y < H; y++) { + const wy = px2y(y) + for (let x = 0; x < W; x++) { + // Reject on unwarped z first: the warp costs two noise evaluations, and most + // pixels are nowhere near this footprint. + const zr = px2z(x) + if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue + const [z, wyw] = warp(zr, wy, r.warpM, r.warpScaleM ?? 40000, 61) + const inTheta = thetaMask(g, r, wyw) + if (inTheta <= 0) continue + const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom, z) * (1 - smoothstep(g.zTo, g.zTo + r.edgeM, z)) + const m = inTheta * inZ + if (m <= 0) continue + const sn = nc(z, wy, r.noiseScaleM) + const n = fbm(sn.x, sn.y, { octaves: 4, seed: 23, wrapY: sn.wrapY }) + const i = y * W + x + height[i] += m * (r.heightM + (n - 0.5) * 2 * r.noiseAmpM) + // Claim the pixel only if this region's mask is the strongest here, with a + // noise dither so neighbouring regions meet on a ragged line, not a ruler. + const claim = m + (n - 0.5) * 0.5 + if (f.biome && claim > biomeStrength[i] && claim > 0.25) { + biomeStrength[i] = claim + biome[i] = BIOME[f.biome] + } + } + } +} + +/** A ridge crossing the theta axis — what the canyon cuts through. */ +function opRidge(f) { + const g = f.geometry + const r = f.relief + for (let y = 0; y < H; y++) { + const inTheta = g.ring + ? 1 + : smoothstep(dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2, + dy(theta2y(g.thetaFrom), theta2y(g.thetaTo)) / 2 - 12000, + dy(px2y(y), theta2y((g.thetaFrom + g.thetaTo) / 2))) + if (inTheta <= 0) continue + const reach = r.widthZM * 2 + (r.warpM ?? 0) + for (let x = 0; x < W; x++) { + if (Math.abs(px2z(x) - g.z) > reach) continue + const [zw] = warp(px2z(x), px2y(y), r.warpM, 30000, 77) + const d = Math.abs(zw - g.z) + if (d > r.widthZM * 2) continue + const bump = Math.exp(-(d * d) / (2 * (r.widthZM / 2) ** 2)) + const sr = nc(zw, px2y(y), 5000) + const n = fbm(sr.x, sr.y, { octaves: 3, seed: 41, wrapY: sr.wrapY }) + height[y * W + x] += inTheta * bump * r.heightM * (0.75 + 0.5 * n) + } + } +} + +/** The mountain: a dome with ridged noise laid over it. */ +function opDome(f) { + const g = f.geometry + const r = f.relief + const cy = theta2y(g.theta) + // Build the massif into a scratch buffer first: ridged noise overshoots by an + // amount that depends on the noise, so the only way to land the summit on the + // declared 4,300 m is to normalise after the fact. + const tmp = new Float64Array(W * H) + let maxH = 0 + for (let y = 0; y < H; y++) { + const ddy = dy(px2y(y), cy) / r.radiusThetaM + if (ddy > 1.6) continue + for (let x = 0; x < W; x++) { + if (Math.abs(px2z(x) - g.z) > r.radiusZM + (r.warpM ?? 0)) continue + const [zw, yw] = warp(px2z(x), px2y(y), r.warpM, 26000, 131) + const ddx = (zw - g.z) / r.radiusZM + const dd = Math.hypot(ddx, dy(yw, cy) / r.radiusThetaM) + if (dd >= 1) continue + const dome = Math.pow(Math.cos((dd * Math.PI) / 2), r.falloff) + const sd = nc(zw, yw, r.ridgedScaleM) + const rg = ridged(sd.x, sd.y, { octaves: 6, seed: 91, wrapY: sd.wrapY }) + // Ridges bite hardest on the flanks and least at the summit, which is what + // stops the massif reading as a smooth blister. + const flank = Math.sin(dd * Math.PI) * 0.8 + 0.2 + const h = dome * (1 - r.ridgedAmp * flank + r.ridgedAmp * flank * rg * 1.9) + tmp[y * W + x] = h + if (h > maxH) maxH = h + } + } + if (maxH <= 0) return + const scale = r.heightM / maxH + for (let i = 0; i < tmp.length; i++) { + if (tmp[i] <= 0) continue + const h = tmp[i] * scale + height[i] += h + // Only the high ground is bare alpine rock; the skirts stay vegetated. + if (h > 1600) { + biome[i] = BIOME.alpine + biomeStrength[i] = 2 + } + } +} + +/** A lake basin, plus its still water surface. */ +function opBasin(f) { + const g = f.geometry + const r = f.relief + const cy = theta2y(g.theta) + for (let y = 0; y < H; y++) { + const ddy = dy(px2y(y), cy) / (g.radiusThetaM + r.edgeM) + if (ddy > 1.3) continue + for (let x = 0; x < W; x++) { + const ddx = (px2z(x) - g.z) / (g.radiusZM + r.edgeM) + const d = Math.hypot(ddx, ddy) + if (d >= 1) continue + const i = y * W + x + height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.6) + if (height[i] < f.waterLevelM) { + water[i] = f.waterLevelM + biome[i] = BIOME.water + } + } + } +} + +/** + * Carve a river valley along a polyline, interpolating the long profile. + * Reaches let one river be a broad mountain valley, then a slot canyon, then a + * shallow floodplain — which is the whole shape of this watercourse. + */ +/** Catmull-Rom through the authored waypoints, so the river curves instead of zigzagging. */ +function smoothPolyline(points, profile, perSegment = 14) { + const at = (i) => points[Math.max(0, Math.min(points.length - 1, i))] + const pAt = (i) => profile[Math.max(0, Math.min(profile.length - 1, i))] + const out = [] + for (let i = 0; i < points.length - 1; i++) { + const p0 = at(i - 1) + const p1 = at(i) + const p2 = at(i + 1) + const p3 = at(i + 2) + for (let s = 0; s < perSegment; s++) { + const u = s / perSegment + const u2 = u * u + const u3 = u2 * u + const c = (a, b, cc, d) => + 0.5 * (2 * b + (cc - a) * u + (2 * a - 5 * b + 4 * cc - d) * u2 + (-a + 3 * b - 3 * cc + d) * u3) + out.push({ + y: c(p0.y, p1.y, p2.y, p3.y), + z: c(p0.z, p1.z, p2.z, p3.z), + elev: pAt(i) + (pAt(i + 1) - pAt(i)) * u, + src: i + u, + }) + } + } + const last = points[points.length - 1] + out.push({ y: last.y, z: last.z, elev: profile[profile.length - 1], src: points.length - 1 }) + return out +} + +function opCarve(f) { + const raw = f.geometry.points.map(([t, z]) => ({ y: theta2y(t), z })) + const pts = smoothPolyline(raw, f.profileM) + const reaches = f.relief.reaches + const KEYS = ['valleyWidthM', 'channelWidthM', 'depthM', 'wallExponent', 'shoulderM'] + const BLEND = 2.2 // waypoints over which one reach becomes the next + + /** + * Reach parameters at a fractional position along the river, blended across + * boundaries. Switching reaches abruptly steps the valley from 5 km wide to 18 km + * in a single pixel column, which reads as a wall across the map. + */ + function reachAt(sf) { + let idx = reaches.findIndex((r) => sf < r.toIndex) + if (idx < 0) idx = reaches.length - 1 + const cur = reaches[idx] + const out = {} + const edge = cur.toIndex + const next = reaches[idx + 1] + if (next && sf > edge - BLEND) { + const t = smoothstep(edge - BLEND, edge + BLEND, sf) + for (const k of KEYS) out[k] = cur[k] + (next[k] - cur[k]) * t + return out + } + const prev = reaches[idx - 1] + if (prev && sf < prev.toIndex + BLEND) { + const t = smoothstep(prev.toIndex - BLEND, prev.toIndex + BLEND, sf) + for (const k of KEYS) out[k] = prev[k] + (cur[k] - prev[k]) * t + return out + } + for (const k of KEYS) out[k] = cur[k] + return out + } + + // Precompute segments once, with their theta unwrapped and their influence + // radius, then bin them into a coarse grid. Testing every segment against every + // pixel is ~3 billion distance tests for four rivers at world resolution; the + // grid turns it into a handful per pixel. + const segs = [] + for (let s = 0; s < pts.length - 1; s++) { + const a = pts[s] + const b = pts[s + 1] + let by = b.y + if (Math.abs(by - a.y) > C / 2) by += by > a.y ? -C : C + const rr = reachAt(a.src) + segs.push({ a, by, infl: rr.valleyWidthM / 2 + rr.shoulderM }) + } + + const CELL = 4000 + const NZ = Math.ceil(L / CELL) + const NY = Math.ceil(C / CELL) + const grid = new Map() + const wrapCell = (cy) => ((cy % NY) + NY) % NY + for (let s = 0; s < segs.length; s++) { + const { a, by, infl } = segs[s] + const z0 = Math.min(a.z, pts[s + 1].z) - infl + const z1 = Math.max(a.z, pts[s + 1].z) + infl + const y0 = Math.min(a.y, by) - infl + const y1 = Math.max(a.y, by) + infl + for (let cz = Math.floor(z0 / CELL); cz <= Math.floor(z1 / CELL); cz++) { + if (cz < 0 || cz >= NZ) continue + for (let cy = Math.floor(y0 / CELL); cy <= Math.floor(y1 / CELL); cy++) { + const key = cz * NY + wrapCell(cy) + let list = grid.get(key) + if (!list) grid.set(key, (list = [])) + list.push(s) + } + } + } + + for (let y = 0; y < H; y++) { + const wy = px2y(y) + const cy = wrapCell(Math.floor(wy / CELL)) + for (let x = 0; x < W; x++) { + const wz = px2z(x) + const list = grid.get(Math.floor(wz / CELL) * NY + cy) + if (!list) continue + let best = Infinity + let bestElev = 0 + let bestSrc = 0 + for (const si of list) { + const { a, by } = segs[si] + const b = pts[si + 1] + let py = wy + if (Math.abs(py - a.y) > C / 2) py += py > a.y ? -C : C + const vz = b.z - a.z + const vy = by - a.y + const tt = Math.max(0, Math.min(1, ((wz - a.z) * vz + (py - a.y) * vy) / (vz * vz + vy * vy || 1))) + const d = Math.hypot(wz - (a.z + vz * tt), py - (a.y + vy * tt)) + if (d < best) { + best = d + bestElev = a.elev + (b.elev - a.elev) * tt + bestSrc = a.src + } + } + if (best === Infinity) continue + const reach = reachAt(bestSrc) + const half = reach.valleyWidthM / 2 + const shoulder = reach.shoulderM + if (best > half + shoulder) continue + const i = y * W + x + const chan = reach.channelWidthM / 2 + const u = Math.max(0, Math.min(1, (best - chan) / Math.max(1, half - chan))) + const rim = bestElev + Math.pow(u, reach.wallExponent) * reach.depthM + const k = 1 - smoothstep(half, half + shoulder, best) + height[i] = Math.min(height[i], rim * k + height[i] * (1 - k)) + if (best < chan) { + water[i] = bestElev + 2 + biome[i] = BIOME.water + biomeStrength[i] = 3 + } + } + } +} + +/** + * Carve a footprint down and fill it to a water level. Handles the axial sea (a + * trough long in z and narrow in theta) and the cap seas (bands ringing the world). + */ +function opFlood(f) { + const g = f.geometry + const r = f.relief + for (let y = 0; y < H; y++) { + const wy = px2y(y) + for (let x = 0; x < W; x++) { + const zr = px2z(x) + if (zr < g.zFrom - r.edgeM - (r.warpM ?? 0) || zr > g.zTo + r.edgeM + (r.warpM ?? 0)) continue + const [z, wyw] = warp(zr, wy, r.warpM ?? 0, r.warpScaleM ?? 30000, 197) + const inTheta = thetaMask(g, r, wyw) + if (inTheta <= 0) continue + const inZ = smoothstep(g.zFrom - r.edgeM, g.zFrom + r.edgeM * 0.2, z) * + (1 - smoothstep(g.zTo - r.edgeM * 0.2, g.zTo + r.edgeM, z)) + const m = inTheta * inZ + if (m <= 0.001) continue + const i = y * W + x + height[i] -= r.depthM * Math.pow(m, 0.7) + if (height[i] < f.waterLevelM && m > 0.05) { + water[i] = f.waterLevelM + biome[i] = BIOME.water + biomeStrength[i] = 3 + } + } + } +} + +/** + * A mountain tarn. Floods relative to the ground it finds rather than to an + * absolute level, so a lake can be dropped anywhere on a range without first + * knowing how high that spot came out. + */ +function opTarn(f) { + const g = f.geometry + const r = f.relief + const cy = theta2y(g.theta) + const rz = g.radiusZM + const rt = g.radiusThetaM + const inside = [] + + // Carve the bowl and raise a moraine rim around it. + for (let y = 0; y < H; y++) { + const ddy = dy(px2y(y), cy) / rt + if (ddy > 1.05) continue + for (let x = 0; x < W; x++) { + const d = Math.hypot((px2z(x) - g.z) / rz, ddy) + if (d >= 1) continue + const i = y * W + x + height[i] -= r.depthM * Math.pow(Math.cos((d * Math.PI) / 2), 1.2) + if (d > 0.68) height[i] += (r.rimM ?? r.depthM * 0.5) * Math.sin(((d - 0.68) / 0.32) * Math.PI) + if (d < 0.85) inside.push(i) + } + } + if (!inside.length) return + + // Fill from the bowl floor upward. Taking the level from the rim instead lets the + // water spill out downhill and leaves a half-moon rather than a lake. + let floor = Infinity + for (const i of inside) if (height[i] < floor) floor = height[i] + const level = floor + r.depthM * (r.fillFraction ?? 0.55) + for (const i of inside) { + if (height[i] < level) { + water[i] = level + biome[i] = BIOME.water + biomeStrength[i] = 3 + } + } + f.$computedLevelM = Math.round(level) +} + +const OPS = { base: opBase, plateau: opPlateau, ridge: opRidge, dome: opDome, + basin: opBasin, carve: opCarve, flood: opFlood, tarn: opTarn } + +/** Build the height/biome/water fields for one rectangular view of the surface. */ +function generate(view, label, verbose) { + W = view.width + H = view.height + VZ0 = view.zFrom + VY0 = view.yFrom + MPP_X = (view.zTo - view.zFrom) / W + MPP_Y = (view.yTo - view.yFrom) / H + height = new Float64Array(W * H) + biome = new Uint8Array(W * H) + water = new Float64Array(W * H) + biomeStrength = new Float64Array(W * H) + + line(1, `${label} ${W} x ${H}`, `${MPP_X.toFixed(0)} m/px`) + const started = Date.now() + const shaped = [] + for (const f of geo.features) { + if (!f.relief) continue + const op = OPS[f.relief.op] + if (!op) throw new Error(`unknown relief op "${f.relief.op}" on ${f.id}`) + // Capture W/H/etc, which the ops read from module state. + const w = W, h = H, mx = MPP_X, my = MPP_Y, vz = VZ0, vy = VY0 + const run = () => { + W = w; H = h; MPP_X = mx; MPP_Y = my; VZ0 = vz; VY0 = vy + op(f) + } + if (verbose) step(3, `${f.relief.op.padEnd(9)} ${f.name}`, run) + else run() + shaped.push(f.id) + } + W = view.width; H = view.height + line(2, `relief ${shaped.length} features`, secs(started)) + return { width: W, height: H, field: height, biome, water, mppX: MPP_X, mppY: MPP_Y } +} + +// ------------------------------------------------------------------- rendering + +const MAXE = geo.render.maxElevationM +const SNOW = geo.render.snowlineM + +const RAMP = { + 0: [[-40, [104, 118, 88]], [120, [118, 130, 92]], [340, [132, 138, 98]], + [700, [140, 138, 106]], [1600, [146, 138, 120]]], + 1: [[400, [198, 178, 132]], [700, [186, 160, 112]], [1000, [166, 138, 96]], + [1400, [148, 120, 86]]], + 2: [[80, [52, 86, 54]], [260, [62, 100, 58]], [500, [76, 108, 64]], [1000, [98, 114, 76]]], + 3: [[600, [104, 106, 96]], [1400, [124, 122, 114]], [2200, [146, 142, 136]], + [2900, [166, 162, 156]]], + 4: [[0, [40, 84, 128]], [5000, [58, 108, 150]]], +} + +function tint(b, h) { + const stops = RAMP[b] + if (h <= stops[0][0]) return stops[0][1] + for (let i = 1; i < stops.length; i++) { + if (h <= stops[i][0]) { + const t = (h - stops[i - 1][0]) / (stops[i][0] - stops[i - 1][0]) + return stops[i - 1][1].map((c, k) => c + (stops[i][1][k] - c) * t) + } + } + return stops[stops.length - 1][1] +} + +/** + * Hillshade. The light direction is a cartographic convention, not Tower's real + * lighting — Tower's sun is a luminous line down the axis, so true shading there + * is soft and nearly shadowless. This is for reading the map, not for realism. + */ +function colorise(g) { + const { azimuthDeg, altitudeDeg, strength } = geo.render.hillshade + const az = (azimuthDeg * Math.PI) / 180 + const alt = (altitudeDeg * Math.PI) / 180 + const lx = Math.cos(alt) * Math.sin(az) + const ly = Math.cos(alt) * Math.cos(az) + const lz = Math.sin(alt) + const { width: w, height: h, field, biome: bio, water: wat, mppX, mppY } = g + const rgb = new Uint8Array(w * h * 3) + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x + const nx = (field[y * w + Math.max(0, x - 1)] - field[y * w + Math.min(w - 1, x + 1)]) / (2 * mppX) + const ny = (field[((y - 1 + h) % h) * w + x] - field[((y + 1) % h) * w + x]) / (2 * mppY) + const len = Math.hypot(nx, ny, 1) + let shade = (nx * lx + ny * ly + lz) / len + shade = 1 - strength + strength * Math.max(0, shade) * 1.35 + + let c = tint(bio[i], field[i]) + if (wat[i] > 0) { + const d = Math.min(1, Math.max(0, wat[i] - field[i]) / 90) + c = [58 - 20 * d, 96 - 26 * d, 132 - 24 * d] + shade = 0.93 + 0.07 * shade + } else { + // A little per-pixel variation so large regions do not read as flat paint. + const sj = nc(px2z(x), px2y(y), 5200) + const j = fbm(sj.x, sj.y, { octaves: 3, seed: 733, wrapY: sj.wrapY }) + const k = 0.9 + 0.2 * j + c = c.map((v) => v * k) + if (field[i] > SNOW) { + // Snow does not lie on cliffs. Blending it out with slope is what turns a + // white blob into a mountain with rock showing through. + const slope = Math.hypot(nx, ny) + const holds = 1 - Math.min(1, Math.max(0, (slope - 0.55) / 0.55)) + const t = Math.min(1, (field[i] - SNOW) / 550) * holds + c = c.map((v, q) => v + ([247, 249, 253][q] - v) * t) + } + } + rgb[i * 3] = Math.max(0, Math.min(255, c[0] * shade)) + rgb[i * 3 + 1] = Math.max(0, Math.min(255, c[1] * shade)) + rgb[i * 3 + 2] = Math.max(0, Math.min(255, c[2] * shade)) + } + } + return rgb +} + +// --------------------------------------------------------------------- outputs + +const worldView = { + width: geo.render.gridWidth, height: geo.render.gridHeight, + zFrom: 0, zTo: L, yFrom: 0, yTo: C, +} +const world = generate(worldView, 'world', !process.argv.includes('--quiet')) +const worldRGB = step(2, 'colourise', () => colorise(world)) + +let peak = 0 +let low = Infinity +for (const v of world.field) { + if (v > peak) peak = v + if (v < low) low = v +} + +// A close view of the developed corridor. At world scale the canyon is six pixels +// wide, which is not enough to judge anything by. +const crop = geo.render.corridor +const cropView = { + width: crop.width, height: crop.height, + zFrom: crop.zFrom, zTo: crop.zTo, + yFrom: theta2y(crop.thetaFrom), yTo: theta2y(crop.thetaFrom) + (crop.thetaTo - crop.thetaFrom) * R, +} +const corridor = generate(cropView, 'corridor', false) +const corridorRGB = step(2, 'colourise', () => colorise(corridor)) + +line(1, 'encode') +const write = (name, make) => + step(2, name, () => { + const buf = make() + writeFileSync(`public/${name}`, buf) + return buf + }, (b) => `${mb(b.length).padEnd(10)}`) + +const norm = new Float64Array(world.field.length) +for (let i = 0; i < norm.length; i++) norm[i] = Math.max(0, world.field[i]) / MAXE +write('terrain-height.png', () => encodeGray16(world.width, world.height, norm)) +write('terrain-color.png', () => encodeRGB(world.width, world.height, worldRGB)) +write('terrain-corridor.png', () => encodeRGB(corridor.width, corridor.height, corridorRGB)) + +// -------------------------------------------------------- seed places for the app + +const KIND = { mountain: 'mountain', river: 'river', canyon: 'canyon', forest: 'forest', + desert: 'desert', lake: 'lake', city: 'city', tower: 'tower', region: 'region' } + +function toPlaceGeometry(g) { + if (g.type === 'point') return { type: 'point', theta: g.theta, z: g.z } + if (g.type === 'polyline') return { type: 'polyline', points: g.points } + if (g.type === 'ellipse') { + const pts = [] + for (let i = 0; i < 32; i++) { + const a = (i / 32) * 2 * Math.PI + pts.push([g.theta + (g.radiusThetaM / R) * Math.cos(a), g.z + g.radiusZM * Math.sin(a)]) + } + return { type: 'polygon', points: pts } + } + if (g.type === 'rect') { + return { type: 'polygon', points: [ + [g.thetaFrom, g.zFrom], [g.thetaTo, g.zFrom], [g.thetaTo, g.zTo], [g.thetaFrom, g.zTo]] } + } + // A band rings the world, so as a place it is a polygon spanning all of theta. + if (g.type === 'band') { + const T = 2 * Math.PI - 1e-9 + return { type: 'polygon', points: [ + [0, g.zFrom], [T, g.zFrom], [T, g.zTo], [0, g.zTo]] } + } + throw new Error(`cannot convert geometry ${g.type}`) +} + +const places = geo.features + // Ridges are bands with no z extent — relief only, not places on a map. + .filter((f) => f.geometry.type !== 'whole-surface' && f.relief?.op !== 'ridge') + .map((f) => ({ + id: f.id, name: f.name, kind: KIND[f.kind] ?? 'landmark', + geometry: toPlaceGeometry(f.geometry), blurb: '', + })) + +const target = 'public/world.json' +const existing = existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : { places: [] } +const authored = existing.places?.filter((p) => !places.some((q) => q.id === p.id)) ?? [] +if (authored.length && !process.argv.includes('--force')) { + console.warn(`\n ! ${target} holds ${authored.length} place(s) not in geography.json.`) + console.warn(' Not overwriting. Re-run with --force to replace them.\n') +} else { + writeFileSync(target, JSON.stringify({ version: 1, places }, null, 2) + '\n') +} + +const probeArg = process.argv.indexOf('--probe') +if (probeArg >= 0) { + const theta = Number(process.argv[probeArg + 1] ?? 0) + const row = Math.round((theta2y(theta) / C) * world.height) % world.height + console.log(`\n elevation profile along theta = ${theta} (row ${row})`) + let prev = null + for (let x = 0; x < world.width; x += 4) { + const z = (x + 0.5) * world.mppX + const h = world.field[row * world.width + x] + const jump = prev === null ? 0 : h - prev + if (Math.abs(jump) > 60) console.log(` z ${(z / 1000).toFixed(1)} km ${h.toFixed(0)} m JUMP ${jump.toFixed(0)} m`) + prev = h + } + console.log('') +} + +line(1, `seed ${places.length} places`, target) +console.log('') +line(1, 'elevation', `${low.toFixed(0)} m .. ${peak.toFixed(0)} m`) +line(1, 'done', secs(T0)) +console.log('')