From f8d137c119a1e04c9f16278f23a85860a4a47f52 Mon Sep 17 00:00:00 2001 From: Maikel Brons Date: Sun, 13 Jul 2025 18:35:33 +0200 Subject: [PATCH] First draft for dealing with large charsets --- package.json | 1 + src/genFont.ts | 226 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 210 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index f8e859d..f1c24bf 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "typescript": "^5.4.5" }, "dependencies": { + "canvas": "^3.1.2", "chalk": "^5.3.0", "fs-extra": "^11.2.0", "msdf-bmfont-xml": "https://github.com/soimy/msdf-bmfont-xml.git#5a2495a14a1ebd3170d49350f450b6e3f531a941", diff --git a/src/genFont.ts b/src/genFont.ts index e3010ab..2222960 100644 --- a/src/genFont.ts +++ b/src/genFont.ts @@ -20,6 +20,7 @@ import path from 'path'; import chalk from 'chalk'; import { fileURLToPath } from 'url' import generateBMFont from 'msdf-bmfont-xml'; +import { createCanvas, loadImage } from 'canvas'; let fontSrcDir: string = ''; let fontDstDir: string = ''; @@ -52,8 +53,10 @@ export interface SdfFontInfo { fieldType: 'ssdf' | 'msdf'; fontPath: string; jsonPath: string; - pngPath: string; + pngPath: string; // Single stitched atlas path + originalPngPaths?: string[]; // Original multi-page paths (for debugging) dstDir: string; + stitched: boolean; // Whether atlas was stitched from multiple pages } type FontOptions = { @@ -64,6 +67,7 @@ type FontOptions = { pot: boolean; fontSize: number; distanceRange: number; + textureSize?: [number, number]; charset?: string; } @@ -100,6 +104,7 @@ export async function genFont(fontFileName: string, fieldType: 'ssdf' | 'msdf'): const font_size = overrides[fontNameNoExt]?.[fieldType]?.fontSize || 42; const distance_range = overrides[fontNameNoExt]?.[fieldType]?.distanceRange || 4; + const texture_size = overrides[fontNameNoExt]?.[fieldType]?.textureSize || [512, 512]; let options: FontOptions = { fieldType: bmfont_field_type, @@ -109,6 +114,7 @@ export async function genFont(fontFileName: string, fieldType: 'ssdf' | 'msdf'): pot: true, fontSize: font_size, distanceRange: distance_range, + textureSize: texture_size, } if (fs.existsSync(charsetPath)) { @@ -126,21 +132,23 @@ export async function genFont(fontFileName: string, fieldType: 'ssdf' | 'msdf'): options['charset'] = charset } - await generateFont(fontPath, fontDstDir, fontNameNoExt, fieldType, options) + const result = await generateFont(fontPath, fontDstDir, fontNameNoExt, fieldType, options) const info: SdfFontInfo = { fontName: fontNameNoExt, fieldType, jsonPath: path.join(fontDstDir, `${fontNameNoExt}.${fieldType}.json`), - pngPath: path.join(fontDstDir, `${fontNameNoExt}.${fieldType}.png`), + pngPath: result.pngPath, + originalPngPaths: result.originalPaths, fontPath, dstDir: fontDstDir, + stitched: result.stitched, }; return info; } -const generateFont = (fontSrcPath: string, fontDestPath: string, fontName: string, fieldType: string, options: FontOptions): Promise => { +const generateFont = (fontSrcPath: string, fontDestPath: string, fontName: string, fieldType: string, options: FontOptions): Promise<{ stitched: boolean; pngPath: string; originalPaths?: string[] }> => { return new Promise((resolve, reject) => { if (!fs.existsSync(fontDestPath)) { fs.mkdirSync(fontDestPath, { recursive: true }) @@ -148,28 +156,212 @@ const generateFont = (fontSrcPath: string, fontDestPath: string, fontName: strin generateBMFont( fontSrcPath, options, - (err, textures, font) => { + async (err, textures, font) => { if (err) { console.error(err) reject(err) } else { - textures.forEach((texture: any) => { - try { - fs.writeFileSync(path.resolve(fontDestPath, `${fontName}.${fieldType}.png`), texture.texture) - } catch (e) { - console.error(e) - reject(e) - } - }) try { - fs.writeFileSync(path.resolve(fontDestPath, `${fontName}.${fieldType}.json`), font.data) - resolve() + const originalPaths: string[] = []; + const tempPaths: string[] = []; + + // Save all original textures + textures.forEach((texture: any, index: number) => { + const filename = textures.length > 1 + ? `${fontName}.${fieldType}.${index}.png` + : `${fontName}.${fieldType}.png`; + const fullPath = path.resolve(fontDestPath, filename); + fs.writeFileSync(fullPath, texture.texture); + originalPaths.push(fullPath); + if (textures.length > 1) { + tempPaths.push(fullPath); + } + }); + + // Save original JSON file + const jsonPath = path.resolve(fontDestPath, `${fontName}.${fieldType}.json`); + fs.writeFileSync(jsonPath, font.data); + + if (textures.length > 1) { + console.log(chalk.yellow(`Found ${textures.length} pages, stitching into single atlas...`)); + + // Stitch textures into single atlas + const stitchResult = await stitchTextures( + tempPaths, + jsonPath, + fontDestPath, + fontName, + fieldType + ); + + // Write updated JSON with stitched coordinates + fs.writeFileSync(jsonPath, JSON.stringify(stitchResult.updatedFontData, null, 2)); + + // Clean up temporary multi-page files + tempPaths.forEach(tempPath => { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + }); + + console.log(chalk.green(`✓ Atlas stitched successfully: ${path.basename(stitchResult.stitchedImagePath)}`)); + + resolve({ + stitched: true, + pngPath: stitchResult.stitchedImagePath, + originalPaths: originalPaths + }); + } else { + console.log(chalk.green(`✓ Single page - no stitching needed`)); + resolve({ + stitched: false, + pngPath: originalPaths[0]!, + originalPaths: originalPaths + }); + } } catch (e) { - console.error(err) - reject(e) + console.error('Error during font generation or stitching:', e); + reject(e); } } } ) }) } + +interface StitchResult { + stitchedImagePath: string; + updatedFontData: any; +} + +interface TextureLayout { + width: number; + height: number; + positions: Array<{ x: number; y: number; width: number; height: number }>; +} + +/** + * Calculate optimal layout for stitching multiple texture pages + */ +function calculateOptimalLayout(textures: any[]): TextureLayout { + // Add padding between atlas pages to prevent bleeding artifacts + const PADDING = 2; + + if (textures.length === 1) { + return { + width: textures[0].width || 512, + height: textures[0].height || 512, + positions: [{ x: 0, y: 0, width: textures[0].width || 512, height: textures[0].height || 512 }] + }; + } + + const textureWidth = 256; // Assume standard texture size + const textureHeight = 256; + + const totalWidth = textures.length * textureWidth + (textures.length - 1) * PADDING; + const positions = textures.map((_, index) => ({ + x: index * (textureWidth + PADDING), + y: 0, + width: textureWidth, + height: textureHeight + })); + + return { + width: totalWidth, + height: textureHeight, + positions + }; +} + +/** + * Stitch multiple texture pages into a single atlas + */ +async function stitchTextures( + texturePaths: string[], + fontDataPath: string, + outputPath: string, + fontName: string, + fieldType: string +): Promise { + console.log(chalk.yellow(`Stitching ${texturePaths.length} texture pages into single atlas...`)); + + // Load the font data to get texture dimensions and character info + const fontData = JSON.parse(fs.readFileSync(fontDataPath, 'utf8')); + + // Load all texture images + const images = await Promise.all( + texturePaths.map(async (texturePath) => { + try { + return await loadImage(texturePath); + } catch (error) { + console.error(`Failed to load texture: ${texturePath}`, error); + throw error; + } + }) + ); + + // Calculate layout + const layout = calculateOptimalLayout( + images.map(img => ({ width: img.width, height: img.height })) + ); + + // Create large canvas with extra padding + const canvas = createCanvas(layout.width, layout.height); + const ctx = canvas.getContext('2d'); + + // Clear canvas with transparent background + ctx.clearRect(0, 0, layout.width, layout.height); + + // Draw each texture to the large canvas with proper positioning + images.forEach((image, pageIndex) => { + const position = layout.positions[pageIndex]; + if (position) { + ctx.drawImage(image, position.x, position.y, image.width, image.height); + } + }); + + // Save stitched image + const buffer = canvas.toBuffer('image/png'); + const stitchedImagePath = path.resolve(outputPath, `${fontName}.${fieldType}.png`); + fs.writeFileSync(stitchedImagePath, buffer); + + // Update character coordinates in font data + const updatedFontData = updateCharacterCoordinates(fontData, layout); + + // Update pages array to single page + updatedFontData.pages = [`${fontName}.${fieldType}.png`]; + updatedFontData.common.pages = 1; + + return { + stitchedImagePath, + updatedFontData + }; +} + +/** + * Update character coordinates after stitching + */ +function updateCharacterCoordinates(fontData: any, layout: TextureLayout): any { + const updatedData = { ...fontData }; + + // Update character positions based on their original page + updatedData.chars = fontData.chars.map((char: any) => { + const pagePosition = layout.positions[char.page]; + if (!pagePosition) { + console.warn(`No position found for page ${char.page}, using original coordinates`); + return { ...char, page: 0 }; + } + return { + ...char, + x: char.x + pagePosition.x, + y: char.y + pagePosition.y, + page: 0 // All characters are now on page 0 + }; + }); + + // Update texture dimensions + updatedData.common.scaleW = layout.width; + updatedData.common.scaleH = layout.height; + + return updatedData; +}