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