Add atlas composers and output writers

composeAtlas blits placed tile pixels into bounds-sized RGBA + L8 PNGs,
synthesizing L8=0 planes for unpaired diffuse tiles. buildAtlasJson
emits the metadata with deterministic ID-sorted tile arrays and applies
the optional blocks_sight regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-21 22:45:54 +02:00
parent 52689e9a9c
commit 589bdaef10
2 changed files with 184 additions and 0 deletions

118
src/write.js Normal file
View File

@@ -0,0 +1,118 @@
// src/write.js
// Compose placed tiles into output diffuse + height PNGs, serialize atlas.json.
// Reads multi-format inputs (PNG/WebP/JPG) via jimp; writes deterministic PNGs via pngjs.
const fs = require('node:fs');
const path = require('node:path');
const { PNG } = require('pngjs');
const Jimp = require('jimp');
// Load a diffuse image as a Jimp instance, resized (cover-style) to (w, h)
// if its native dims do not match. Returns { width, height, data } where
// data is a Buffer in RGBA8888 layout matching pngjs.PNG.data shape.
async function loadDiffuseImage(filePath, targetW, targetH) {
const img = await Jimp.read(filePath);
if (img.bitmap.width !== targetW || img.bitmap.height !== targetH) {
img.resize(targetW, targetH, Jimp.RESIZE_BICUBIC);
}
return {
width: img.bitmap.width,
height: img.bitmap.height,
data: Buffer.from(img.bitmap.data), // RGBA8888 already
};
}
// Load a height image and convert to L8 layout (in RGBA buffer slot 0 = the L
// value, repeated across G/B for downstream uniformity). Resized to (w, h)
// if mismatched.
async function loadHeightImageOrSynth(filePath, w, h) {
if (filePath && fs.existsSync(filePath)) {
const img = await Jimp.read(filePath);
if (img.bitmap.width !== w || img.bitmap.height !== h) {
img.resize(w, h, Jimp.RESIZE_BICUBIC);
}
// Jimp gives RGBA; collapse to grayscale via R-channel (heightmaps are
// assumed to be encoded uniformly in R or grayscale-promoted).
return {
width: w,
height: h,
data: Buffer.from(img.bitmap.data),
};
}
// Synth L8=0 plane in RGBA layout
const buf = Buffer.alloc(w * h * 4);
for (let i = 3; i < buf.length; i += 4) buf[i] = 255; // alpha
return { width: w, height: h, data: buf };
}
// Returns the dimensions of a source image without fully decoding the pixels.
// Used by the bake orchestrator's auto-tile-size detection (Task 11 extension).
async function probeImageDims(filePath) {
const img = await Jimp.read(filePath);
return { width: img.bitmap.width, height: img.bitmap.height };
}
async function composeAtlas(placedTiles, boundsW, boundsH) {
const diffuse = new PNG({ width: boundsW, height: boundsH });
const height = new PNG({ width: boundsW, height: boundsH });
diffuse.data.fill(0);
height.data.fill(0);
// Empty pixels are alpha=0 in diffuse (transparent black). Height-empty
// is fully opaque alpha=255 since L8 maps care about R-channel only.
for (let i = 3; i < height.data.length; i += 4) height.data[i] = 255;
for (const tile of placedTiles) {
const d = await loadDiffuseImage(tile.diffusePath, tile.w, tile.h);
const h = await loadHeightImageOrSynth(tile.heightPath, tile.w, tile.h);
for (let y = 0; y < tile.h; y++) {
for (let x = 0; x < tile.w; x++) {
const srcIdx = (y * tile.w + x) * 4;
const dstIdx = ((tile.y + y) * boundsW + (tile.x + x)) * 4;
diffuse.data[dstIdx + 0] = d.data[srcIdx + 0];
diffuse.data[dstIdx + 1] = d.data[srcIdx + 1];
diffuse.data[dstIdx + 2] = d.data[srcIdx + 2];
diffuse.data[dstIdx + 3] = d.data[srcIdx + 3];
// Height: take R-channel of source as L, repeat across RGB
height.data[dstIdx + 0] = h.data[srcIdx + 0];
height.data[dstIdx + 1] = h.data[srcIdx + 0];
height.data[dstIdx + 2] = h.data[srcIdx + 0];
height.data[dstIdx + 3] = 255;
}
}
}
return { diffuse, height };
}
function buildAtlasJson(atlasId, atlasVersion, tileSize, boundsW, boundsH, placedTiles, blocksSightPattern) {
const tiles = placedTiles
.slice()
.sort((a, b) => a.id - b.id)
.map(t => {
const entry = {
id: t.id,
name: t.name,
uv: [t.x, t.y, t.w, t.h],
};
if (blocksSightPattern && blocksSightPattern.test(t.name)) {
entry.blocks_sight = true;
}
return entry;
});
return {
atlas_id: atlasId,
atlas_version: atlasVersion,
atlas_size_px: [boundsW, boundsH],
tile_size_px: tileSize,
tiles,
};
}
function writeOutputs(outDir, diffusePng, heightPng, atlasJson, lockJson) {
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'tiles.diffuse.atlas.png'), PNG.sync.write(diffusePng));
fs.writeFileSync(path.join(outDir, 'tiles.height.atlas.png'), PNG.sync.write(heightPng));
fs.writeFileSync(path.join(outDir, 'tiles.atlas.json'), JSON.stringify(atlasJson, null, 2) + '\n');
fs.writeFileSync(path.join(outDir, 'tiles.atlas.lock.json'), JSON.stringify(lockJson, null, 2) + '\n');
}
module.exports = { loadDiffuseImage, loadHeightImageOrSynth, probeImageDims, composeAtlas, buildAtlasJson, writeOutputs };