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:
118
src/write.js
Normal file
118
src/write.js
Normal 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 };
|
||||
Reference in New Issue
Block a user