281 lines
5.1 KiB
JavaScript
281 lines
5.1 KiB
JavaScript
// Simple Node.js (ESM) script to bake a grayscale heightmap PNG
|
|
// from the OBJ terrain model: resources/models/Terrain003_1K.obj
|
|
//
|
|
// Usage (from repo root, after installing pngjs):
|
|
// npm install pngjs
|
|
// node tools/generate_terrain_heightmap.js
|
|
//
|
|
// It will write a PNG into: resources/textures/heightmap/Terrain003_1K_Height512.png
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { PNG } from "pngjs";
|
|
|
|
|
|
const ROOT_DIR = path.dirname( path.dirname( new URL( import.meta.url ).pathname ) );
|
|
|
|
const OBJ_PATH = path.join( ROOT_DIR, "resources", "models", "Terrain003_1K.obj" );
|
|
|
|
const OUT_PATH = path.join( ROOT_DIR, "resources", "textures", "heightmap", "Terrain003_1K_Height512.png" );
|
|
|
|
const RESOLUTION = 512;
|
|
|
|
|
|
function parseObjVertices( text ) {
|
|
|
|
const lines = text.split( /\r?\n/ );
|
|
|
|
const vertices = [];
|
|
|
|
for ( let i = 0; i < lines.length; i++ ) {
|
|
|
|
const line = lines[ i ].trim();
|
|
|
|
if ( line.length === 0 || line.charAt( 0 ) === "#" ) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
const parts = line.split( /\s+/ );
|
|
|
|
if ( parts[ 0 ] === "v" && parts.length >= 4 ) {
|
|
|
|
const x = parseFloat( parts[ 1 ] );
|
|
const y = parseFloat( parts[ 2 ] );
|
|
const z = parseFloat( parts[ 3 ] );
|
|
|
|
if ( !isNaN( x ) && !isNaN( y ) && !isNaN( z ) ) {
|
|
|
|
vertices.push( { x, y, z } );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return vertices;
|
|
|
|
}
|
|
|
|
|
|
function buildHeightmapFromVertices( vertices, resolution ) {
|
|
|
|
if ( vertices.length === 0 ) {
|
|
|
|
throw new Error( "No vertices parsed from OBJ." );
|
|
|
|
}
|
|
|
|
let minX = Infinity, maxX = -Infinity;
|
|
let minY = Infinity, maxY = -Infinity;
|
|
let minZ = Infinity, maxZ = -Infinity;
|
|
|
|
for ( const v of vertices ) {
|
|
|
|
if ( v.x < minX ) minX = v.x;
|
|
if ( v.x > maxX ) maxX = v.x;
|
|
if ( v.y < minY ) minY = v.y;
|
|
if ( v.y > maxY ) maxY = v.y;
|
|
if ( v.z < minZ ) minZ = v.z;
|
|
if ( v.z > maxZ ) maxZ = v.z;
|
|
|
|
}
|
|
|
|
const spanX = maxX - minX || 1;
|
|
const spanZ = maxZ - minZ || 1;
|
|
|
|
const width = resolution;
|
|
const height= resolution;
|
|
|
|
const heights = new Float32Array( width * height );
|
|
const counts = new Uint32Array( width * height );
|
|
|
|
for ( const v of vertices ) {
|
|
|
|
const uNorm = ( v.x - minX ) / spanX;
|
|
const vNorm = ( v.z - minZ ) / spanZ;
|
|
|
|
let ix = Math.round( uNorm * ( width - 1 ) );
|
|
let iy = Math.round( vNorm * ( height - 1 ) );
|
|
|
|
if ( ix < 0 ) ix = 0;
|
|
if ( ix >= width ) ix = width - 1;
|
|
if ( iy < 0 ) iy = 0;
|
|
if ( iy >= height ) iy = height - 1;
|
|
|
|
const idx = iy * width + ix;
|
|
|
|
heights[ idx ] += v.y;
|
|
counts[ idx ]++;
|
|
|
|
}
|
|
|
|
for ( let i = 0; i < heights.length; i++ ) {
|
|
|
|
if ( counts[ i ] > 0 ) {
|
|
|
|
heights[ i ] /= counts[ i ];
|
|
|
|
} else {
|
|
|
|
heights[ i ] = minY;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const tmp = new Float32Array( width * height );
|
|
|
|
for ( let pass = 0; pass < 2; pass++ ) {
|
|
|
|
for ( let y = 0; y < height; y++ ) {
|
|
|
|
for ( let x = 0; x < width; x++ ) {
|
|
|
|
let sum = 0;
|
|
let weight = 0;
|
|
|
|
for ( let dy = -1; dy <= 1; dy++ ) {
|
|
|
|
for ( let dx = -1; dx <= 1; dx++ ) {
|
|
|
|
const nx = x + dx;
|
|
const ny = y + dy;
|
|
|
|
if ( nx < 0 || ny < 0 || nx >= width || ny >= height ) {
|
|
continue;
|
|
}
|
|
|
|
const nIdx = ny * width + nx;
|
|
|
|
sum += heights[ nIdx ];
|
|
weight += 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const idx = y * width + x;
|
|
|
|
tmp[ idx ] = weight > 0 ? sum / weight : heights[ idx ];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for ( let i = 0; i < heights.length; i++ ) {
|
|
|
|
heights[ i ] = tmp[ i ];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let outMin = Infinity;
|
|
let outMax = -Infinity;
|
|
|
|
for ( let i = 0; i < heights.length; i++ ) {
|
|
|
|
const h = heights[ i ];
|
|
|
|
if ( h < outMin ) outMin = h;
|
|
if ( h > outMax ) outMax = h;
|
|
|
|
}
|
|
|
|
const range = outMax - outMin || 1;
|
|
|
|
const normalized = new Float32Array( width * height );
|
|
|
|
for ( let i = 0; i < heights.length; i++ ) {
|
|
|
|
normalized[ i ] = ( heights[ i ] - outMin ) / range;
|
|
|
|
}
|
|
|
|
return { width, height, data: normalized };
|
|
|
|
}
|
|
|
|
|
|
function writeHeightmapPng( heightmap, outPath ) {
|
|
|
|
const { width, height, data } = heightmap;
|
|
|
|
const png = new PNG( { width, height } );
|
|
|
|
for ( let y = 0; y < height; y++ ) {
|
|
|
|
for ( let x = 0; x < width; x++ ) {
|
|
|
|
const idx = y * width + x;
|
|
|
|
const v = Math.max( 0, Math.min( 1, data[ idx ] ) );
|
|
|
|
const c = Math.round( v * 255 );
|
|
|
|
const o = idx * 4;
|
|
|
|
png.data[ o + 0 ] = c;
|
|
png.data[ o + 1 ] = c;
|
|
png.data[ o + 2 ] = c;
|
|
png.data[ o + 3 ] = 255;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return new Promise( function( resolve, reject ) {
|
|
|
|
const stream = fs.createWriteStream( outPath );
|
|
|
|
stream.on( "finish", resolve );
|
|
stream.on( "error", reject );
|
|
|
|
png.pack().pipe( stream );
|
|
|
|
} );
|
|
|
|
}
|
|
|
|
|
|
async function main( ) {
|
|
|
|
try {
|
|
|
|
console.log( "Reading OBJ:", OBJ_PATH );
|
|
|
|
const text = fs.readFileSync( OBJ_PATH, "utf8" );
|
|
|
|
const vertices = parseObjVertices( text );
|
|
|
|
console.log( "Parsed vertices:", vertices.length );
|
|
|
|
const heightmap = buildHeightmapFromVertices( vertices, RESOLUTION );
|
|
|
|
console.log( "Heightmap built:", heightmap.width, "x", heightmap.height );
|
|
|
|
await writeHeightmapPng( heightmap, OUT_PATH );
|
|
|
|
console.log( "Heightmap written to:", OUT_PATH );
|
|
|
|
} catch ( err ) {
|
|
|
|
console.error( "Failed to generate heightmap:", err );
|
|
|
|
process.exitCode = 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
if ( import.meta.url === `file://${ process.argv[ 1 ] }` ) {
|
|
|
|
main( );
|
|
|
|
}
|