feat(assets): use single texture atlas instead of loading all images individually
This commit is contained in:
@@ -2,14 +2,56 @@ import { defineNuxtModule, useLogger } from "@nuxt/kit";
|
||||
import { readdir, readFile, writeFile } from "fs/promises";
|
||||
import { join, relative, parse } from "path";
|
||||
import { existsSync, watch } from "fs";
|
||||
import sharp from "sharp";
|
||||
|
||||
type AssetsTree = {
|
||||
[key: string]: string | AssetsTree;
|
||||
type AtlasRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ImageTree = {
|
||||
[key: string]: AtlasRect | ImageTree;
|
||||
};
|
||||
|
||||
type ModelTree = {
|
||||
[key: string]: string | ModelTree;
|
||||
};
|
||||
|
||||
type ImageData = {
|
||||
path: string;
|
||||
buffer: Buffer;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = [".png", ".webp"];
|
||||
const MODELS_EXTENSIONS = [".gltf"];
|
||||
const ASSETS_EXTENSIONS = [...IMAGE_EXTENSIONS, ...MODELS_EXTENSIONS];
|
||||
const MODEL_EXTENSIONS = [".gltf"];
|
||||
const MAX_WIDTH = 1024;
|
||||
|
||||
const toCamelCase = (str: string) =>
|
||||
str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replaceAll(".", "_");
|
||||
|
||||
const scanByExt = async (
|
||||
dir: string,
|
||||
extensions: string[],
|
||||
): Promise<string[]> => {
|
||||
if (!existsSync(dir)) return [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...(await scanByExt(fullPath, extensions)));
|
||||
} else if (extensions.includes(parse(fullPath).ext.toLowerCase())) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: {
|
||||
@@ -19,125 +61,237 @@ export default defineNuxtModule({
|
||||
defaults: {},
|
||||
async setup(_, nuxt) {
|
||||
const logger = useLogger("asset-generator");
|
||||
const publicDir = join(nuxt.options.rootDir, "public/nds");
|
||||
const templateFile = join(
|
||||
nuxt.options.rootDir,
|
||||
"app/composables/useAssets.ts.in",
|
||||
);
|
||||
const outputFile = join(
|
||||
nuxt.options.rootDir,
|
||||
"app/composables/useAssets.ts",
|
||||
);
|
||||
const rootDir = nuxt.options.rootDir;
|
||||
const imagesDir = join(rootDir, "public/nds/images");
|
||||
const modelsDir = join(rootDir, "public/nds/models");
|
||||
const templateFile = join(rootDir, "app/composables/useAssets.ts.in");
|
||||
const outputFile = join(rootDir, "app/composables/useAssets.ts");
|
||||
const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
|
||||
|
||||
const hasExt = (filename: string, exts: string[]): boolean => {
|
||||
const ext = parse(filename).ext.toLowerCase();
|
||||
return exts.includes(ext);
|
||||
const loadImages = async (
|
||||
paths: string[],
|
||||
): Promise<Map<string, ImageData>> => {
|
||||
const images = new Map<string, ImageData>();
|
||||
|
||||
await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
try {
|
||||
const [metadata, buffer] = await Promise.all([
|
||||
sharp(path).metadata(),
|
||||
sharp(path).png().toBuffer(),
|
||||
]);
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
images.set(path, {
|
||||
path,
|
||||
buffer,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`Failed to load ${path}, skipping...`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
const scanDirectory = async (dir: string): Promise<string[]> => {
|
||||
const assets: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const packImages = (images: ImageData[]) => {
|
||||
const sorted = images.toSorted((a, b) => b.height - a.height);
|
||||
const rects = new Map<string, AtlasRect>();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let rowHeight = 0;
|
||||
let maxWidth = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const assetFiles = await scanDirectory(fullPath);
|
||||
assets.push(...assetFiles);
|
||||
} else if (hasExt(fullPath, ASSETS_EXTENSIONS)) {
|
||||
assets.push(fullPath);
|
||||
for (const img of sorted) {
|
||||
if (x + img.width > MAX_WIDTH) {
|
||||
x = 0;
|
||||
y += rowHeight;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
rects.set(img.path, {
|
||||
x,
|
||||
y,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
});
|
||||
|
||||
x += img.width;
|
||||
rowHeight = Math.max(rowHeight, img.height);
|
||||
maxWidth = Math.max(maxWidth, x);
|
||||
}
|
||||
|
||||
return assets;
|
||||
return { rects, width: maxWidth, height: y + rowHeight };
|
||||
};
|
||||
|
||||
const toCamelCase = (str: string): string => {
|
||||
return str
|
||||
.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
|
||||
.replaceAll(".", "_");
|
||||
};
|
||||
const buildImageTree = (
|
||||
imagePaths: string[],
|
||||
rects: Map<string, AtlasRect>,
|
||||
): ImageTree => {
|
||||
const tree: ImageTree = {};
|
||||
|
||||
const buildAssetsTree = (assets: string[], baseDir: string): AssetsTree => {
|
||||
const tree: AssetsTree = {};
|
||||
for (const path of imagePaths) {
|
||||
const parts = relative(imagesDir, path).split("/");
|
||||
const fileName = parse(parts.at(-1)!).name;
|
||||
|
||||
for (const assetPath of assets) {
|
||||
const relativePath = relative(baseDir, assetPath);
|
||||
const parts = relativePath.split("/");
|
||||
const filename = parse(parts[parts.length - 1]!).name;
|
||||
|
||||
let current = tree;
|
||||
// start at 1 to skip images/, models/
|
||||
for (let i = 1; i < parts.length - 1; i += 1) {
|
||||
const key = toCamelCase(parts[i]!);
|
||||
current[key] ??= {};
|
||||
current = current[key] as AssetsTree;
|
||||
let node = tree;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const key = toCamelCase(part);
|
||||
node[key] ??= {};
|
||||
node = node[key] as ImageTree;
|
||||
}
|
||||
|
||||
current[toCamelCase(filename)] = `/${relativePath}`;
|
||||
const rect = rects.get(path);
|
||||
if (rect) node[toCamelCase(fileName)] = rect;
|
||||
}
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
const generatorFn = (filePath: string): string => {
|
||||
if (hasExt(filePath, IMAGE_EXTENSIONS)) return "createImage";
|
||||
if (hasExt(filePath, MODELS_EXTENSIONS)) return "createModel";
|
||||
throw new Error(`No matching generator for '${filePath}'`);
|
||||
const buildModelTree = (modelPaths: string[]): ModelTree => {
|
||||
const tree: ModelTree = {};
|
||||
|
||||
for (const path of modelPaths) {
|
||||
const parts = relative(modelsDir, path).split("/");
|
||||
const fileName = parse(parts.at(-1)!).name;
|
||||
|
||||
let node = tree;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const key = toCamelCase(part);
|
||||
node[key] ??= {};
|
||||
node = node[key] as ModelTree;
|
||||
}
|
||||
|
||||
node[toCamelCase(fileName)] =
|
||||
`/nds/models/${relative(modelsDir, path)}`;
|
||||
}
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
const generateAssetsObject = (tree: AssetsTree, indent = 0): string => {
|
||||
const spaces = " ".repeat(indent);
|
||||
const generateImageCode = (tree: ImageTree, indent = 0): string => {
|
||||
const entries = Object.entries(tree);
|
||||
if (!entries.length) return "{}";
|
||||
|
||||
const lines = entries.map(([key, value]) =>
|
||||
typeof value === "string"
|
||||
? `${spaces} ${key}: ${generatorFn(value)}("/nds${value}"),`
|
||||
: `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`,
|
||||
);
|
||||
const sp = " ".repeat(indent);
|
||||
const lines = entries.map(([key, value]) => {
|
||||
if (value && typeof value === "object" && "x" in value) {
|
||||
const { x, y, width, height } = value;
|
||||
return `${sp} ${key}: {
|
||||
${sp} draw: (ctx, x, y, opts?) => drawAtlasImage(ctx, [${x}, ${y}, ${width}, ${height}], [x, y, ${width}, ${height}], opts),
|
||||
${sp} rect: { x: ${x}, y: ${y}, width: ${width}, height: ${height} },
|
||||
${sp} }`;
|
||||
}
|
||||
return `${sp} ${key}: ${generateImageCode(value, indent + 1)}`;
|
||||
});
|
||||
|
||||
return `{\n${lines.join("\n")}\n${spaces}}`;
|
||||
return `{\n${lines.join(",\n")}\n${sp}}`;
|
||||
};
|
||||
|
||||
const generateAssetsFile = async () => {
|
||||
const generateModelCode = (tree: ModelTree, indent = 0): string => {
|
||||
const entries = Object.entries(tree);
|
||||
if (!entries.length) return "{}";
|
||||
|
||||
const sp = " ".repeat(indent);
|
||||
const lines = entries.map(([key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
return `${sp} ${key}: createModel("${value}")`;
|
||||
}
|
||||
return `${sp} ${key}: ${generateModelCode(value, indent + 1)}`;
|
||||
});
|
||||
|
||||
return `{\n${lines.join(",\n")},\n${sp}}`;
|
||||
};
|
||||
|
||||
const generateAssets = async () => {
|
||||
try {
|
||||
if (!existsSync(publicDir)) {
|
||||
logger.warn("No public directory found");
|
||||
return;
|
||||
const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS);
|
||||
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
|
||||
|
||||
const totalAssets = (imagePaths.length > 0 ? 1 : 0) + modelPaths.length;
|
||||
|
||||
let imageCode = "{}";
|
||||
let atlasWidth = 0;
|
||||
let atlasHeight = 0;
|
||||
|
||||
if (imagePaths.length > 0) {
|
||||
const imageMap = await loadImages(imagePaths);
|
||||
const images = Array.from(imageMap.values());
|
||||
|
||||
const { rects, width, height } = packImages(images);
|
||||
atlasWidth = width;
|
||||
atlasHeight = height;
|
||||
|
||||
const atlasBuffer = await sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
},
|
||||
})
|
||||
.composite(
|
||||
images.map((img) => {
|
||||
const rect = rects.get(img.path)!;
|
||||
return { input: img.buffer, left: rect.x, top: rect.y };
|
||||
}),
|
||||
)
|
||||
.webp({ lossless: true })
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(atlasOutputPath, atlasBuffer);
|
||||
|
||||
const imageTree = buildImageTree(imagePaths, rects);
|
||||
imageCode = generateImageCode(imageTree);
|
||||
}
|
||||
|
||||
const assets = await scanDirectory(publicDir);
|
||||
const assetsTree = buildAssetsTree(assets, publicDir);
|
||||
const assetsObject = generateAssetsObject(assetsTree);
|
||||
const modelTree = buildModelTree(modelPaths);
|
||||
const modelCode = generateModelCode(modelTree);
|
||||
|
||||
const template = await readFile(templateFile, "utf-8");
|
||||
const fileContent = template
|
||||
.replace("{{TOTAL}}", assets.length.toString())
|
||||
.replace("{{ASSETS}}", assetsObject);
|
||||
const code = template
|
||||
.replace("{{TOTAL}}", totalAssets.toString())
|
||||
.replace("{{IMAGES}}", imageCode)
|
||||
.replace("{{MODELS}}", modelCode);
|
||||
|
||||
await writeFile(outputFile, fileContent, "utf-8");
|
||||
logger.success(`Generated useAssets.ts with ${assets.length} assets`);
|
||||
await writeFile(outputFile, code, "utf-8");
|
||||
logger.success(
|
||||
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}) and ${modelPaths.length} models`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error generating assets file:", error);
|
||||
logger.error("Error generating assets:", error);
|
||||
}
|
||||
};
|
||||
|
||||
nuxt.hook("build:before", async () => {
|
||||
await generateAssetsFile();
|
||||
});
|
||||
nuxt.hook("build:before", generateAssets);
|
||||
|
||||
if (nuxt.options.dev) {
|
||||
nuxt.hook("ready", () => {
|
||||
watch(publicDir, { recursive: true }, async (_, filePath) => {
|
||||
if (filePath && hasExt(filePath, ASSETS_EXTENSIONS)) {
|
||||
logger.info(`Detected change: ${filePath}`);
|
||||
await generateAssetsFile();
|
||||
const watchAndRegenerate = (
|
||||
dir: string,
|
||||
extensions: string[],
|
||||
type: string,
|
||||
) => {
|
||||
watch(dir, { recursive: true }, (_, filePath) => {
|
||||
if (
|
||||
filePath &&
|
||||
extensions.includes(parse(filePath).ext.toLowerCase())
|
||||
) {
|
||||
logger.info(`Detected ${type} change: ${filePath}`);
|
||||
generateAssets();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watch(templateFile, async () => {
|
||||
nuxt.hook("ready", () => {
|
||||
watchAndRegenerate(imagesDir, IMAGE_EXTENSIONS, "image");
|
||||
watchAndRegenerate(modelsDir, MODEL_EXTENSIONS, "model");
|
||||
watch(templateFile, () => {
|
||||
logger.info("Template file changed");
|
||||
await generateAssetsFile();
|
||||
generateAssets();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user