This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+578
View File
@@ -0,0 +1,578 @@
// Exporting a planet as Unreal landscape tiles.
//
// Unreal's landscape importer will not take either of Orogen's two existing exports. The preview is a
// picture. The heightmap is one flat 8192 x 4096 PNG with no scale attached to it - the ramp is absolute,
// so the shades mean metres, but nothing in the file says how wide the planet is, and without that there is
// no answer to "how many metres is a pixel". What the importer wants is per-tile 16-bit greyscale PNGs at
// exactly 255*N+1 vertices, one Landscape actor each, plus a separate 8-bit weightmap per paint layer, at
// the sample spacing the game actually uses. This module writes that, straight out of the planet.
//
// Three things about it are the whole design.
//
// **The scale is an input, not a guess.** A sphere mesh has no metres on it; `planet_circumference_km` is
// what turns the window's degrees into ground. It is asked for rather than derived because it cannot be
// derived, and because it is the number that decides how much land a window holds. The report prints what
// the choice bought, including the east-west stretch at the window's edges, so a window that does not fit
// on the planet says so instead of quietly producing 30 km of ground on a 31.8 km world.
//
// **The window is sampled once, then cut.** The planet is rendered into one float raster over the window,
// and every tile is resampled out of that raster by its *global* vertex position. This is what closes the
// seams: a vertex column shared by two neighbours is computed from the same source coordinates twice and
// comes out bit-identical, so nothing has to blend or stitch. Rendering each tile under its own camera
// would have been one step shorter and would have put a rasteriser's floating-point luck on every seam,
// where one 16-bit step is 11 cm of crack.
//
// **The intermediate is float and it is the window.** The old path read a whole-planet 16-bit PNG and spent
// its resolution on the whole planet; this spends all of it on the window, and carries kilometres as
// float32 rather than quantised to a -5000..6000 m ramp. Both matter less than they sound, because the
// sphere mesh only resolves a couple of hundred metres and no amount of sampling invents what is not there
// - the detail below that is still the Go generator's job. What they do buy is that nothing downstream has
// to know a magic number.
//
// The resampler is the Catmull-Rom from generate_region_tiles.py, clamped to its two central taps for the
// same reason: a plain cubic overshoots at a step, the steps here are coastlines, and unclamped every shore
// gets a raised lip on the land side and a trench on the sea side.
import { renderHeightWindowKm } from './unreal-render.js';
import { encodeGray16, encodeGray8 } from './png-write.js';
// One vertex of the neighbours on every side, sampled before the paint layers are derived and thrown away
// after. The layers read slope, a one-sided difference at an array edge is not what the neighbouring tile
// computes for that same vertex, and without this every tile boundary is a one-vertex line of different
// paint. One vertex is all a central difference needs.
const LAYER_MARGIN = 1;
// Pixels of the intermediate raster rendered beyond the window on each side, so the resampler's outer taps
// and the layer margin read real ground instead of a clamped edge. Catmull-Rom reaches two pixels.
const RASTER_PAD = 4;
export const LAYER_NAMES = ['Base_Layer', 'Layer_02', 'Layer_03']; // rock, meadow, high rock
// The defaults are a window that actually fits on this project's planet, which is a smaller one than it
// looks. Planet.json is 100 km round, so the whole globe is 3183 km2 of surface; the 936 km2 square the
// numpy region pipeline cuts is 29% of it, and that is why that window reads as 110 degrees on a side and
// stretches by three quarters at its edge. Four tiles by two is 20.4 x 10.2 km, 208 km2, and about 5%
// stretch at the edge - a window a sphere this size can actually hold flat.
export const DEFAULTS = {
level: '/Game/Maps/L_Region',
planet_circumference_km: 100,
centre: { lon_deg: 0, lat_deg: 0 },
tiles: { columns: 4, rows: 2, vertices: 2551 },
quad_cm: 200,
sea_level_m: 0,
spawn_pad_m: 150,
streaming_grid_components: 5,
elevation_m: { min: -1024, max: 6144 },
sea_scale: 0.17,
source_metres_per_pixel: 8,
layers: {
rock_slope_start: 0.55,
rock_slope_full: 1.05,
high_altitude_start_m: 1400,
high_altitude_full_m: 2000,
breakup_m: 18,
breakup_cells: 24,
breakup_seed: 7,
},
};
// ── Geometry ────────────────────────────────────────────────────────────────────────────────────────
/**
* Everything that follows from the options, with nothing rendered yet. Cheap, so the UI can call it on
* every keystroke to show what a setting buys - this is the export's equivalent of `--scout`.
*/
export function planRegion(opts) {
const o = mergeDefaults(opts);
const vertices = o.tiles.vertices;
if ((vertices - 1) % 255 !== 0) {
throw new Error(`tiles.vertices must be 255 * N + 1 (2551, 2041, 1021 ...), not ${vertices}`);
}
const quadsPerTile = vertices - 1;
const quadM = o.quad_cm / 100;
const quadsX = o.tiles.columns * quadsPerTile;
const quadsY = o.tiles.rows * quadsPerTile;
const widthM = quadsX * quadM;
const heightM = quadsY * quadM;
const radiusM = o.planet_circumference_km * 1000 / (2 * Math.PI);
const centreLat = o.centre.lat_deg * Math.PI / 180;
const centreLon = o.centre.lon_deg * Math.PI / 180;
const latSpan = heightM / radiusM;
// Cosine-corrected at the centre latitude, so ground metres are right there rather than only at the
// equator. A flat reading of an equirectangular map stretches east-west by 1/cos(latitude); this puts
// the error at zero in the middle of the window and splits it between the north and south edges.
const lonSpan = widthM / (radiusM * Math.cos(centreLat));
const latMin = centreLat - latSpan / 2;
const latMax = centreLat + latSpan / 2;
const innerW = Math.max(2, Math.round(widthM / o.source_metres_per_pixel) + 1);
const innerH = Math.max(2, Math.round(heightM / o.source_metres_per_pixel) + 1);
// What the flat reading costs at the window's edges: 1 at the centre latitude by construction.
const stretchAt = lat => Math.cos(centreLat) / Math.cos(Math.max(-1.55, Math.min(1.55, lat)));
const warnings = [];
if (latSpan >= Math.PI) {
warnings.push(`the window is ${(latSpan * 180 / Math.PI).toFixed(0)} degrees of latitude tall, which `
+ `is more than the planet has. Raise planet_circumference_km or use fewer tiles.`);
}
if (lonSpan >= 2 * Math.PI) {
warnings.push(`the window wraps the planet more than once at this latitude. Raise `
+ `planet_circumference_km or use fewer tiles.`);
}
if (Math.abs(latMax) > 1.4 || Math.abs(latMin) > 1.4) {
warnings.push('the window reaches past 80 degrees of latitude, where an equirectangular reading '
+ 'stretches without bound. Move the centre towards the equator.');
}
const worstStretch = Math.max(stretchAt(latMin), stretchAt(latMax));
if (worstStretch > 1.1 && warnings.length === 0) {
warnings.push(`the ground is stretched east-west by up to ${((worstStretch - 1) * 100).toFixed(1)}% `
+ 'at the window\'s edge. A window this tall on a planet this small cannot avoid it; a bigger '
+ 'planet_circumference_km or fewer rows would.');
}
return {
options: o,
quadsPerTile, quadM, quadsX, quadsY, widthM, heightM,
areaKm2: widthM * heightM / 1e6,
tileSideM: quadsPerTile * quadM,
tileCount: o.tiles.columns * o.tiles.rows,
componentsPerTile: (quadsPerTile / 255) ** 2,
radiusM, centreLat, centreLon, latSpan, lonSpan, latMin, latMax,
lonMin: centreLon - lonSpan / 2,
lonMax: centreLon + lonSpan / 2,
innerW, innerH,
rasterW: innerW + 2 * RASTER_PAD,
rasterH: innerH + 2 * RASTER_PAD,
metresPerPixel: widthM / (innerW - 1),
stretchNorth: stretchAt(latMax),
stretchSouth: stretchAt(latMin),
warnings,
};
}
function mergeDefaults(opts) {
const o = { ...DEFAULTS, ...(opts || {}) };
o.centre = { ...DEFAULTS.centre, ...(opts && opts.centre) };
o.tiles = { ...DEFAULTS.tiles, ...(opts && opts.tiles) };
o.elevation_m = { ...DEFAULTS.elevation_m, ...(opts && opts.elevation_m) };
o.layers = { ...DEFAULTS.layers, ...(opts && opts.layers) };
return o;
}
// ── Resampling ──────────────────────────────────────────────────────────────────────────────────────
/** Catmull-Rom weights for taps at -1, 0, +1, +2. */
function cubicWeights(t) {
const t2 = t * t, t3 = t2 * t;
return [
-0.5 * t3 + t2 - 0.5 * t,
1.5 * t3 - 2.5 * t2 + 1.0,
-1.5 * t3 + 2.0 * t2 + 0.5 * t,
0.5 * t3 - 0.5 * t2,
];
}
/**
* One separable pass of clamped Catmull-Rom along x: `src` is srcW wide and `rows` tall, `coords` are
* float source columns. Held between the two central taps, which is what stops it ringing at a coastline.
*/
function resampleX(src, srcW, rows, coords) {
const outW = coords.length;
const out = new Float32Array(rows * outW);
const clampIdx = i => (i < 0 ? 0 : i >= srcW ? srcW - 1 : i);
for (let o = 0; o < outW; o++) {
const c = coords[o];
const i = Math.floor(c);
const w = cubicWeights(c - i);
const i0 = clampIdx(i - 1), i1 = clampIdx(i), i2 = clampIdx(i + 1), i3 = clampIdx(i + 2);
for (let r = 0; r < rows; r++) {
const base = r * srcW;
const a = src[base + i0], b = src[base + i1], c2 = src[base + i2], d = src[base + i3];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[r * outW + o] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
/** The same along y: `src` is width wide and srcH tall, `coords` are float source rows. */
function resampleY(src, width, srcH, coords) {
const outH = coords.length;
const out = new Float32Array(outH * width);
const clampIdx = j => (j < 0 ? 0 : j >= srcH ? srcH - 1 : j);
for (let o = 0; o < outH; o++) {
const c = coords[o];
const j = Math.floor(c);
const w = cubicWeights(c - j);
const r0 = clampIdx(j - 1) * width, r1 = clampIdx(j) * width;
const r2 = clampIdx(j + 1) * width, r3 = clampIdx(j + 2) * width;
const dst = o * width;
for (let x = 0; x < width; x++) {
const a = src[r0 + x], b = src[r1 + x], c2 = src[r2 + x], d = src[r3 + x];
let v = a * w[0] + b * w[1] + c2 * w[2] + d * w[3];
const lo = b < c2 ? b : c2, hi = b < c2 ? c2 : b;
out[dst + x] = v < lo ? lo : v > hi ? hi : v;
}
}
return out;
}
// ── Break-up noise ──────────────────────────────────────────────────────────────────────────────────
//
// Value-noise fBm on a periodic lattice, sampled at *global* window coordinates so a tile boundary is not
// a discontinuity in the paint. The lattice values come from a hash of (seed, octave, cell) rather than
// from a stream of random numbers, which is what makes a single tile computable without generating the
// ones before it. This is the same shape as heightmap_noise.fbm_at but not the same numbers: numpy's PCG64
// stream cannot be reproduced here, and it does not need to be - the two pipelines are alternatives, never
// mixed, and this noise only decides where a paint boundary wobbles.
function hash01(seed, octave, cells, i, j) {
let h = (seed ^ Math.imul(octave + 1, 0x9E3779B1)) >>> 0;
h = Math.imul(h ^ Math.imul(i, 0x27D4EB2D), 0x165667B1);
h = Math.imul(h ^ Math.imul(j, 0x85EBCA77), 0xC2B2AE3D);
h = Math.imul(h ^ cells, 0x27D4EB2F);
h ^= h >>> 15; h = Math.imul(h, 0x2545F491); h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
const smoothstep = t => t * t * (3 - 2 * t);
function fbmAt(u, v, seed, baseCells, octaves = 4, gain = 0.5) {
let total = 0, amplitude = 1, cells = baseCells, norm = 0;
for (let o = 0; o < octaves; o++) {
const su = u * cells, sv = v * cells;
const i0 = Math.floor(su), j0 = Math.floor(sv);
const tu = smoothstep(su - i0), tv = smoothstep(sv - j0);
const ia = ((i0 % cells) + cells) % cells, ja = ((j0 % cells) + cells) % cells;
const ib = (ia + 1) % cells, jb = (ja + 1) % cells;
const top = hash01(seed, o, cells, ia, ja) * (1 - tu) + hash01(seed, o, cells, ib, ja) * tu;
const bottom = hash01(seed, o, cells, ia, jb) * (1 - tu) + hash01(seed, o, cells, ib, jb) * tu;
total += (top * (1 - tv) + bottom * tv) * amplitude;
norm += amplitude;
amplitude *= gain;
cells *= 2;
}
return total / norm;
}
// ── One tile ────────────────────────────────────────────────────────────────────────────────────────
/** Global vertex indices along one axis for a tile, with `margin` extra on each side. */
function tileVertices(plan, tile, margin) {
const n = plan.options.tiles.vertices + 2 * margin;
const out = new Float64Array(n);
for (let k = 0; k < n; k++) out[k] = tile * plan.quadsPerTile + (k - margin);
return out;
}
/** Global vertex indices to raster pixel coordinates. RASTER_PAD is where the window's first vertex sits. */
function toRasterCoords(plan, vertices, axis) {
const inner = axis === 0 ? plan.innerW : plan.innerH;
const quads = axis === 0 ? plan.quadsX : plan.quadsY;
const out = new Float64Array(vertices.length);
for (let k = 0; k < vertices.length; k++) {
out[k] = RASTER_PAD + vertices[k] * (inner - 1) / quads;
}
return out;
}
/** One tile's height in metres, sampled out of the window raster by global position. */
function tileMetres(plan, raster, tx, ty, margin) {
const vx = tileVertices(plan, tx, margin);
const vy = tileVertices(plan, ty, margin);
const sx = toRasterCoords(plan, vx, 0);
const sy = toRasterCoords(plan, vy, 1);
// Only the raster rows this tile reaches, so a tile costs a band rather than the whole window.
const row0 = Math.max(0, Math.floor(sy[0]) - 1);
const row1 = Math.min(plan.rasterH, Math.floor(sy[sy.length - 1]) + 3);
const bandRows = row1 - row0;
const band = raster.subarray(row0 * plan.rasterW, row1 * plan.rasterW);
const afterX = resampleX(band, plan.rasterW, bandRows, sx);
const shifted = new Float64Array(sy.length);
for (let k = 0; k < sy.length; k++) shifted[k] = sy[k] - row0;
const km = resampleY(afterX, sx.length, bandRows, shifted);
const out = new Float32Array(km.length);
const seaScale = plan.options.sea_scale;
for (let k = 0; k < km.length; k++) {
let m = km[k] * 1000;
if (m < 0) m *= seaScale;
out[k] = m;
}
return { metres: out, vx, vy, width: sx.length, height: sy.length };
}
/**
* The flat disc at the centre of the *window* for the player starts, blended over a second radius. It is
* computed from global position, so where it crosses a tile boundary the two tiles agree on it.
*/
function applySpawnPad(plan, tile, padMetres) {
const radius = plan.options.spawn_pad_m;
if (radius <= 0) return;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
for (let j = 0; j < height; j++) {
const dy = (vy[j] - plan.quadsY / 2) * quadM;
for (let i = 0; i < width; i++) {
const dx = (vx[i] - plan.quadsX / 2) * quadM;
const dist = Math.hypot(dx, dy);
const t = Math.max(0, Math.min(1, 1 - (dist - radius) / radius));
if (t <= 0) continue;
const w = smoothstep(t);
const at = j * width + i;
metres[at] = metres[at] * (1 - w) + padMetres * w;
}
}
}
/**
* The pack's three paint layers from height and slope: meadow everywhere, rock by slope, high rock by
* altitude, with an fBm break-up so neither boundary is a contour line. There is no erosion on this path,
* so unlike L_World's version there is no wear, curvature or deposit term. `tile` carries LAYER_MARGIN
* vertices of its neighbours on every side; the layers are computed over the lot and the margin cropped at
* the end, so the slope at a tile's edge is the central difference its neighbour computes there too.
*/
function deriveLayers(plan, tile) {
const rules = plan.options.layers;
const { metres, vx, vy, width, height } = tile;
const quadM = plan.quadM;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
// Both axes divided by the *longer* one, so the noise stays square on the ground and, because neither
// coordinate then exceeds 1, it never repeats across the window.
const span = Math.max(plan.quadsX, plan.quadsY);
const breakupM = rules.breakup_m;
const slopeBreakupScale = breakupM ? 0.12 / breakupM : 0;
const layers = {};
for (const name of LAYER_NAMES) layers[name] = new Uint8Array(outW * outH);
for (let j = margin; j < height - margin; j++) {
for (let i = margin; i < width - margin; i++) {
const at = j * width + i;
// Central differences, which is why the margin is here.
const gx = (metres[at + 1] - metres[at - 1]) / (2 * quadM);
const gy = (metres[at + width] - metres[at - width]) / (2 * quadM);
const slope = Math.hypot(gx, gy);
const noise = fbmAt(vx[i] / span, vy[j] / span, rules.breakup_seed | 0, rules.breakup_cells | 0);
const breakup = (noise - 0.5) * 2 * breakupM;
let rock = smoothstep(Math.max(0, Math.min(1,
(slope + breakup * slopeBreakupScale - rules.rock_slope_start)
/ (rules.rock_slope_full - rules.rock_slope_start))));
let high = smoothstep(Math.max(0, Math.min(1,
(metres[at] + breakup - rules.high_altitude_start_m)
/ (rules.high_altitude_full_m - rules.high_altitude_start_m))));
high = high * (1 - rock * 0.5);
const meadow = Math.max(0, Math.min(1, 1 - rock - high));
const total = Math.max(meadow + rock + high, 1e-6);
const out = (j - margin) * outW + (i - margin);
layers.Base_Layer[out] = Math.round(rock / total * 255);
layers.Layer_02[out] = Math.round(meadow / total * 255);
layers.Layer_03[out] = Math.round(high / total * 255);
}
}
return { layers, width: outW, height: outH };
}
/** Metres to the 16-bit code the manifest's elevation_m range defines. */
function encodeHeights(plan, tile) {
const { metres, width, height } = tile;
const margin = LAYER_MARGIN;
const outW = width - 2 * margin, outH = height - 2 * margin;
const lo = plan.options.elevation_m.min, hi = plan.options.elevation_m.max;
const span = hi - lo;
const out = new Uint16Array(outW * outH);
let clipped = 0;
// Measured over the cropped tile, not over `metres`, which still carries the margin ring. On an outside
// tile that ring is sampled beyond the window, so a range taken across it can quote ground that is not
// in the world - and this number is what the manifest prints as "the ground came out X..Y m".
let minM = Infinity, maxM = -Infinity;
for (let j = 0; j < outH; j++) {
for (let i = 0; i < outW; i++) {
const m = metres[(j + margin) * width + (i + margin)];
if (m < minM) minM = m;
if (m > maxM) maxM = m;
if (m < lo || m > hi) clipped++;
const bounded = m < lo ? lo : m > hi ? hi : m;
const v = Math.round((bounded - lo) / span * 65535);
out[j * outW + i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
}
}
return { heights: out, width: outW, height: outH, clipped: clipped / (outW * outH), minM, maxM };
}
// ── Writing ─────────────────────────────────────────────────────────────────────────────────────────
async function writeBlob(dirHandle, name, blob) {
const handle = await dirHandle.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
}
async function fileExists(dirHandle, name) {
try {
await dirHandle.getFileHandle(name);
return true;
} catch {
return false; // NotFoundError, and anything else here means we cannot claim it is there
}
}
function tileName(plan, tx, ty) {
const level = plan.options.level.split('/').pop();
return `${level}_x${tx}_y${ty}`;
}
/**
* The manifest, in the shape RawContent/World/Region.json has - so the Unreal side reads this export with
* region_manifest.py exactly as it reads a hand-written one, and nothing downstream needs to know which
* tool cut the tiles.
*
* The `source` block is kept, and made honest. generate_region_tiles.py is what reads it, and it will not
* run against these tiles because they are already there; what it records is where the ground came from
* and at what scale, which used to be a number somebody chose and wrote in a comment.
*/
function regionManifest(plan, meta) {
const o = plan.options;
const deg = r => +(r * 180 / Math.PI).toFixed(6);
return {
_comment: 'Written by World Orogen\'s Unreal landscape export. The tiles in RegionTiles/ are a '
+ 'product of this file and the planet named below; generate_region_tiles.py is not in this '
+ 'path and does not need to run. Every key is explained in Scripts/Authoring/region_manifest.py.',
level: o.level,
_comment_tiles: `${o.tiles.columns} x ${o.tiles.rows} landscapes of ${o.tiles.vertices} vertices. `
+ `${plan.quadsPerTile} quads is ${plan.quadsPerTile / 255} x 255, so the engine gives each tile `
+ `${plan.componentsPerTile} components of 255 quads: ${plan.tileCount * plan.componentsPerTile} `
+ `over the window. Neighbours share their edge vertices, so the grid is ${plan.quadsX + 1} x `
+ `${plan.quadsY + 1} vertices, ${(plan.widthM / 1000).toFixed(2)} x `
+ `${(plan.heightM / 1000).toFixed(2)} km, ${plan.areaKm2.toFixed(0)} km2 of map.`,
tiles: { ...o.tiles },
quad_cm: o.quad_cm,
sea_level_m: o.sea_level_m,
spawn_pad_m: o.spawn_pad_m,
streaming_grid_components: o.streaming_grid_components,
elevation_m: { ...o.elevation_m },
_comment_elevation: `The ground came out ${meta.minM.toFixed(0)}..${meta.maxM.toFixed(0)} m, which `
+ `uses ${(meta.rampUsed * 100).toFixed(0)}% of the 16-bit ramp at `
+ `${((o.elevation_m.max - o.elevation_m.min) / 65535 * 100).toFixed(1)} cm a step. `
+ `${meta.clipped === 0 ? 'Nothing clips.' : (meta.clipped * 100).toFixed(3) + '% of vertices clip - widen elevation_m.'}`,
source: {
_comment: 'Rendered directly out of World Orogen rather than cut from a PNG, so the scale is '
+ 'recorded rather than chosen after the fact. metres_per_pixel is what one pixel of the '
+ 'intermediate float raster was worth; the tiles themselves are at quad_cm.',
kind: 'orogen_render',
planet: meta.planetCode || null,
planet_circumference_km: o.planet_circumference_km,
centre: { lon_deg: o.centre.lon_deg, lat_deg: o.centre.lat_deg },
window_deg: {
lon_min: deg(plan.lonMin), lon_max: deg(plan.lonMax),
lat_min: deg(plan.latMin), lat_max: deg(plan.latMax),
},
projection: 'equirectangular, cosine-corrected at the centre latitude',
east_west_stretch: { north: +plan.stretchNorth.toFixed(4), south: +plan.stretchSouth.toFixed(4) },
metres_per_pixel: +plan.metresPerPixel.toFixed(6),
// The window in pixels of the intermediate raster, so region_manifest.py's metres_per_pixel()
// has the same shape of answer here as it does for a manifest that names a PNG. The raster is
// rendered `pad` pixels wider on every side than the window, for the resampler's outer taps.
window: { x: RASTER_PAD, y: RASTER_PAD, width: plan.innerW, height: plan.innerH },
raster: { width: plan.rasterW, height: plan.rasterH, pad: RASTER_PAD },
elevation_m: { min: -5000, max: 6000 },
sea_scale: o.sea_scale,
exported: new Date().toISOString(),
},
layers: { ...o.layers },
};
}
/**
* Renders the window and writes the whole tile set, plus Region.json, into a directory the user picks.
*
* `dirHandle` should be the project's RawContent/World: the tiles go into RegionTiles/ beneath it and the
* manifest beside it, which is the layout create_region_world.py already reads. One tile is held in memory
* at a time; the window raster is the only large allocation and it is float32 over the window, not the
* planet.
*/
export async function exportUnrealRegion(opts, dirHandle, onProgress = () => {}) {
const plan = planRegion(opts);
const o = plan.options;
onProgress(0.02, 'Sampling the planet');
const raster = await renderHeightWindowKm({
lonMin: plan.lonMin - RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
lonMax: plan.lonMax + RASTER_PAD * plan.lonSpan / (plan.innerW - 1),
latMin: plan.latMin - RASTER_PAD * plan.latSpan / (plan.innerH - 1),
latMax: plan.latMax + RASTER_PAD * plan.latSpan / (plan.innerH - 1),
width: plan.rasterW,
height: plan.rasterH,
onProgress: (f, label) => onProgress(0.02 + f * 0.18, label),
});
// The pad's height is read at the window's exact centre, once, so every tile it touches lifts to the
// same level. Never below the sea: a pad in the water is not a place to stand.
const cx = RASTER_PAD + (plan.innerW - 1) / 2;
const cy = RASTER_PAD + (plan.innerH - 1) / 2;
const centreKm = resampleY(
resampleX(raster, plan.rasterW, plan.rasterH, Float64Array.from([cx])),
1, plan.rasterH, Float64Array.from([cy]))[0];
let padMetres = centreKm * 1000;
if (padMetres < 0) padMetres *= o.sea_scale;
padMetres = Math.max(padMetres, o.sea_level_m + 30);
const tilesDir = await dirHandle.getDirectoryHandle('RegionTiles', { create: true });
const meta = { minM: Infinity, maxM: -Infinity, clipped: 0, planetCode: opts && opts.planet_code };
const total = plan.tileCount;
let done = 0;
for (let ty = 0; ty < o.tiles.rows; ty++) {
for (let tx = 0; tx < o.tiles.columns; tx++) {
const name = tileName(plan, tx, ty);
onProgress(0.2 + done / total * 0.8, `${name} (${done + 1}/${total})`);
const tile = tileMetres(plan, raster, tx, ty, LAYER_MARGIN);
applySpawnPad(plan, tile, padMetres);
const { heights, width, height, clipped, minM, maxM } = encodeHeights(plan, tile);
if (minM < meta.minM) meta.minM = minM;
if (maxM > meta.maxM) meta.maxM = maxM;
meta.clipped += clipped / total;
await writeBlob(tilesDir, `${name}_Height.png`, await encodeGray16(width, height, heights));
const derived = deriveLayers(plan, tile);
for (const layerName of LAYER_NAMES) {
await writeBlob(tilesDir, `${name}_${layerName}.png`,
await encodeGray8(derived.width, derived.height, derived.layers[layerName]));
}
done++;
await new Promise(r => setTimeout(r, 0));
}
}
meta.rampUsed = (meta.maxM - meta.minM) / (o.elevation_m.max - o.elevation_m.min);
const manifest = regionManifest(plan, meta);
// An existing Region.json is never replaced. The one in this project is hand-written and most of it is
// commentary explaining why each number is what it is; a generated file would throw all of that away,
// and the same rule already governs the terrain studio, which saves by patching a legend's *text* so its
// reasoning survives. The tiles are the product here - the manifest is a description of what was cut -
// so the generated one lands beside it under a name of its own and the caller is told which it got.
const manifestName = (await fileExists(dirHandle, 'Region.json'))
? 'Region.generated.json' : 'Region.json';
await writeBlob(dirHandle, manifestName,
new Blob([JSON.stringify(manifest, null, 2) + '\n'], { type: 'application/json' }));
onProgress(1, 'Done');
return { plan, meta, manifest, manifestName };
}