Files
UnrealPrototyping/Tools/Orogen/js/unreal-render.js
T
2026-09-25 17:02:24 +03:00

186 lines
8.8 KiB
JavaScript

// Sampling the planet over a window, for the Unreal landscape export.
//
// The map exports in planet-mesh.js answer one question: "draw the whole planet at width W". Unreal needs
// a different one answered - "what is the ground, in metres, over *this* rectangle of the planet, at
// whatever sample spacing I ask for" - and this module is that question and nothing else. Keeping it
// separate is what lets unreal-export.js know about tiles and weightmaps without planet-mesh.js knowing
// about either.
//
// Heights come back as kilometres through the same fixed -5..6 km ramp `heightmapColor` uses, rather than
// as kilometres written straight into the vertex colours. The ramp is code that is already proven by the
// 16-bit export; the difference here is that the float render target is read as floats instead of being
// quantised to 16 bits. A float32 over 0..1 resolves about 1e-7, which over an 11 km ramp is a millimetre,
// three orders of magnitude finer than the 16-bit PNG's 17 cm - so nothing is lost coming back out, and
// negative values never have to survive a vertex-colour path where three.js colour management could reach
// them.
import * as THREE from 'three';
import { renderer } from './scene.js';
import { state } from './state.js';
import { elevToHeightKm } from './color-map.js';
export const RAMP_MIN_KM = -5;
export const RAMP_SPAN_KM = 11;
/** The ramp `heightmapColor` paints with, as a number rather than a colour. Clamping is a formality:
* elevToHeightKm cannot leave -5..6 by construction. */
function toRamp(elevation) {
const km = elevToHeightKm(elevation);
return Math.max(0, Math.min(1, (km - RAMP_MIN_KM) / RAMP_SPAN_KM));
}
// The same smooth triangle soup the 16-bit heightmap export builds: one triangle per mesh side, with the
// two triangle-centre vertices carrying the average of the three regions they touch, so a cell interpolates
// as a Gouraud gradient rather than reading as a flat hex.
//
// Two differences, both because this is sampled rather than looked at. Nothing is clamped into x in
// [-2, 2]: that clamp squashes the triangles straddling the date line, which is invisible in a whole-planet
// image because the wrapped copy covers it, and is a torn seam in a window that happens to sit there. And
// the caller draws the result three times, a full map apart, so a window crossing the date line sees real
// geometry on both sides instead of the edge of the mesh.
function buildHeightMapMesh(curData) {
const { mesh, r_xyz, t_xyz, r_elevation } = curData;
const { numSides, numTriangles } = mesh;
const PI = Math.PI;
const sx = 2 / PI;
const t_elev = new Float32Array(numTriangles);
const tris = mesh.triangles;
for (let t = 0; t < numTriangles; t++) {
const s0 = 3 * t;
t_elev[t] = (r_elevation[tris[s0]] + r_elevation[tris[s0 + 1]] + r_elevation[tris[s0 + 2]]) / 3;
}
const posArr = new Float32Array(numSides * 18);
const colArr = new Float32Array(numSides * 18);
let triCount = 0;
const emit = (lonA, latA, lonB, latB, lonC, latC, vA, vB, vC) => {
const off = triCount * 9;
posArr[off] = lonA * sx; posArr[off + 1] = latA * sx; posArr[off + 2] = 0;
posArr[off + 3] = lonB * sx; posArr[off + 4] = latB * sx; posArr[off + 5] = 0;
posArr[off + 6] = lonC * sx; posArr[off + 7] = latC * sx; posArr[off + 8] = 0;
colArr[off] = colArr[off + 1] = colArr[off + 2] = vA;
colArr[off + 3] = colArr[off + 4] = colArr[off + 5] = vB;
colArr[off + 6] = colArr[off + 7] = colArr[off + 8] = vC;
triCount++;
};
for (let s = 0; s < numSides; s++) {
const it = mesh.s_inner_t(s);
const ot = mesh.s_outer_t(s);
const br = mesh.s_begin_r(s);
const v0 = toRamp(t_elev[it]);
const v1 = toRamp(t_elev[ot]);
const v2 = toRamp(r_elevation[br]);
const x0 = t_xyz[3 * it], y0 = t_xyz[3 * it + 1], z0 = t_xyz[3 * it + 2];
const x1 = t_xyz[3 * ot], y1 = t_xyz[3 * ot + 1], z1 = t_xyz[3 * ot + 2];
const x2 = r_xyz[3 * br], y2 = r_xyz[3 * br + 1], z2 = r_xyz[3 * br + 2];
let lon0 = Math.atan2(x0, z0), lat0 = Math.asin(Math.max(-1, Math.min(1, y0)));
let lon1 = Math.atan2(x1, z1), lat1 = Math.asin(Math.max(-1, Math.min(1, y1)));
let lon2 = Math.atan2(x2, z2), lat2 = Math.asin(Math.max(-1, Math.min(1, y2)));
if (Math.max(lon0, lon1, lon2) - Math.min(lon0, lon1, lon2) > PI) {
if (lon0 < 0) lon0 += 2 * PI;
if (lon1 < 0) lon1 += 2 * PI;
if (lon2 < 0) lon2 += 2 * PI;
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
emit(lon0 - 2 * PI, lat0, lon1 - 2 * PI, lat1, lon2 - 2 * PI, lat2, v0, v1, v2);
} else {
emit(lon0, lat0, lon1, lat1, lon2, lat2, v0, v1, v2);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(posArr.buffer, 0, triCount * 9), 3));
geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colArr.buffer, 0, triCount * 9), 3));
return new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide }));
}
/**
* Renders a longitude/latitude rectangle of the current planet into kilometres above sea level.
*
* The raster's *pixel centres* span the rectangle exactly - pixel 0 sits on lonMin, pixel width-1 on
* lonMax - because that is the convention the resampler downstream reads it with, so the frustum is
* widened by half a pixel on each side to put them there. Row 0 is the northern edge, as in an image.
*
* @returns {Promise<Float32Array>} width * height kilometres, row-major from the north.
*/
export async function renderHeightWindowKm({ lonMin, lonMax, latMin, latMax, width, height, onProgress }) {
if (!state.curData) throw new Error('no planet loaded to export');
if (width < 2 || height < 2) throw new Error('a height window needs at least 2 x 2 samples');
const sx = 2 / Math.PI;
const mapMesh = buildHeightMapMesh(state.curData);
const offScene = new THREE.Scene();
offScene.background = new THREE.Color(0x000000);
// Three copies, a full map apart, so a window crossing the date line is covered on both sides of it.
for (const shift of [-4, 0, 4]) {
const copy = new THREE.Mesh(mapMesh.geometry, mapMesh.material);
copy.position.x = shift;
offScene.add(copy);
}
const halfU = (lonMax - lonMin) * sx / (width - 1) / 2;
const halfV = (latMax - latMin) * sx / (height - 1) / 2;
const mx0 = lonMin * sx - halfU, mx1 = lonMax * sx + halfU;
const my0 = latMin * sx - halfV, my1 = latMax * sx + halfV;
const out = new Float32Array(width * height);
const step = Math.min(2048, renderer.capabilities.maxTextureSize);
const tilesX = Math.ceil(width / step);
const tilesY = Math.ceil(height / step);
const total = tilesX * tilesY;
let done = 0;
const prevColorSpace = renderer.outputColorSpace;
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
try {
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const px0 = tx * step, py0 = ty * step;
const pw = Math.min(step, width - px0);
const ph = Math.min(step, height - py0);
const cam = new THREE.OrthographicCamera(
mx0 + (mx1 - mx0) * px0 / width,
mx0 + (mx1 - mx0) * (px0 + pw) / width,
my1 - (my1 - my0) * py0 / height,
my1 - (my1 - my0) * (py0 + ph) / height,
0.1, 10);
cam.position.set(0, 0, 5);
cam.lookAt(0, 0, 0);
const target = new THREE.WebGLRenderTarget(pw, ph, { type: THREE.FloatType });
renderer.setRenderTarget(target);
renderer.render(offScene, cam);
const pixels = new Float32Array(pw * ph * 4);
renderer.readRenderTargetPixels(target, 0, 0, pw, ph, pixels);
renderer.setRenderTarget(null);
target.dispose();
for (let y = 0; y < ph; y++) {
const src = (ph - 1 - y) * pw; // the readback is bottom-up
const dst = (py0 + y) * width + px0;
for (let x = 0; x < pw; x++) {
out[dst + x] = pixels[(src + x) * 4] * RAMP_SPAN_KM + RAMP_MIN_KM;
}
}
done++;
if (onProgress) onProgress(done / total, 'Sampling the planet');
await new Promise(r => setTimeout(r, 0));
}
}
} finally {
renderer.outputColorSpace = prevColorSpace;
renderer.setRenderTarget(null);
mapMesh.geometry.dispose();
mapMesh.material.dispose();
}
return out;
}