Tooling
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user