// 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} 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; }