diff --git a/Editor/Inspectors/PSXUIEditors.cs b/Editor/Inspectors/PSXUIEditors.cs index 168dfb8..5926b2d 100644 --- a/Editor/Inspectors/PSXUIEditors.cs +++ b/Editor/Inspectors/PSXUIEditors.cs @@ -4,7 +4,7 @@ using SplashEdit.RuntimeCode; namespace SplashEdit.EditorCode { - // ─── Scene Preview Gizmos ─── + // --- Scene Preview Gizmos --- // These draw filled rectangles in the Scene view for WYSIWYG UI preview. public static class PSXUIGizmos @@ -76,9 +76,8 @@ namespace SplashEdit.EditorCode Color fill = new Color(0, 0, 0, selected ? 0.3f : 0.1f); Handles.DrawSolidRectangleWithOutline(corners, fill, border); - // Draw text preview at actual PSX glyph size. - // On the PS1, chainprintf renders each glyph top-left aligned at the - // element's position — no centering. Mimic that here. + // Pixel-perfect preview: render each glyph from the actual font bitmap + // at PS1 scale, using advance widths for proportional positioning. string label = string.IsNullOrEmpty(text.DefaultText) ? "[empty]" : text.DefaultText; PSXFontAsset font = text.GetEffectiveFont(); @@ -86,39 +85,85 @@ namespace SplashEdit.EditorCode int glyphH = font != null ? font.GlyphHeight : 16; Handles.BeginGUI(); - // Convert top-left corner (corners[1] in Unity is top-left after GetWorldCorners) Vector2 topLeft = HandleUtility.WorldToGUIPoint(corners[1]); Vector2 botRight = HandleUtility.WorldToGUIPoint(corners[3]); - // Calculate pixel scale: how many screen pixels represent one PSX pixel - float rectWorldW = Vector3.Distance(corners[0], corners[3]); float rectScreenW = Mathf.Abs(botRight.x - topLeft.x); float rectW = rt.rect.width; float psxPixelScale = (rectW > 0.01f) ? rectScreenW / rectW : 1f; - float charScreenW = glyphW * psxPixelScale; - float charScreenH = glyphH * psxPixelScale; - int fontSize = Mathf.Clamp(Mathf.RoundToInt(charScreenH * 0.75f), 6, 72); - - GUIStyle style = new GUIStyle(EditorStyles.label); - style.normal.textColor = text.TextColor; - style.alignment = TextAnchor.UpperLeft; - style.fontSize = fontSize; - style.wordWrap = false; - style.clipping = TextClipping.Clip; - style.padding = new RectOffset(0, 0, 0, 0); - - // Render position = top-left of the rect (matching PSX chainprintf behavior) + float guiX = Mathf.Min(topLeft.x, botRight.x); + float guiY = Mathf.Min(topLeft.y, botRight.y); float guiW = Mathf.Abs(botRight.x - topLeft.x); float guiH = Mathf.Abs(botRight.y - topLeft.y); - Rect guiRect = new Rect( - Mathf.Min(topLeft.x, botRight.x), - Mathf.Min(topLeft.y, botRight.y), - guiW, guiH); - GUI.color = new Color(text.TextColor.r, text.TextColor.g, text.TextColor.b, selected ? 1f : 0.7f); - GUI.Label(guiRect, label, style); - GUI.color = Color.white; + Color tintColor = text.TextColor; + tintColor.a = selected ? 1f : 0.7f; + + // If we have a font bitmap, render pixel-perfect from the texture + if (font != null && font.FontTexture != null && font.SourceFont != null) + { + Texture2D fontTex = font.FontTexture; + int glyphsPerRow = font.GlyphsPerRow; + int texH = font.TextureHeight; + float cellScreenW = glyphW * psxPixelScale; + float cellScreenH = glyphH * psxPixelScale; + + // Get advance widths for proportional positioning + float cursorX = guiX; + GUI.color = tintColor; + foreach (char ch in label) + { + if (ch < 32 || ch > 126) continue; + int charIdx = ch - 32; + int col = charIdx % glyphsPerRow; + int row = charIdx / glyphsPerRow; + + float advance = glyphW; // fallback + if (font.AdvanceWidths != null && charIdx < font.AdvanceWidths.Length) + { + advance = font.AdvanceWidths[charIdx]; + } + + if (ch != ' ') + { + // UV rect for this glyph in the font bitmap + float uvX = (float)(col * glyphW) / fontTex.width; + float uvY = 1f - (float)((row + 1) * glyphH) / fontTex.height; + float uvW = (float)glyphW / fontTex.width; + float uvH = (float)glyphH / fontTex.height; + + // Draw at advance width, not cell width - matches PS1 proportional rendering + float spriteScreenW = advance * psxPixelScale; + Rect screenRect = new Rect(cursorX, guiY, spriteScreenW, cellScreenH); + // UV: only show the portion of the cell that the advance covers + float uvWScaled = uvW * (advance / glyphW); + Rect uvRect = new Rect(uvX, uvY, uvWScaled, uvH); + + if (screenRect.xMax > guiX && screenRect.x < guiX + guiW) + GUI.DrawTextureWithTexCoords(screenRect, fontTex, uvRect); + } + + cursorX += advance * psxPixelScale; + } + GUI.color = Color.white; + } + else + { + // Fallback: use Unity's text rendering for system font + int fSize = Mathf.Clamp(Mathf.RoundToInt(glyphH * psxPixelScale * 0.75f), 6, 72); + GUIStyle style = new GUIStyle(EditorStyles.label); + style.normal.textColor = tintColor; + style.alignment = TextAnchor.UpperLeft; + style.fontSize = fSize; + style.wordWrap = false; + style.clipping = TextClipping.Clip; + + Rect guiRect = new Rect(guiX, guiY, guiW, guiH); + GUI.color = tintColor; + GUI.Label(guiRect, label, style); + GUI.color = Color.white; + } Handles.EndGUI(); } @@ -259,7 +304,7 @@ namespace SplashEdit.EditorCode if (img.SourceTexture != null) { if (img.SourceTexture.width > 256 || img.SourceTexture.height > 256) - EditorGUILayout.HelpBox("Texture exceeds 256×256. It will be resized during export.", MessageType.Warning); + EditorGUILayout.HelpBox("Texture exceeds 256x256. It will be resized during export.", MessageType.Warning); } serializedObject.ApplyModifiedProperties(); @@ -377,31 +422,40 @@ namespace SplashEdit.EditorCode { serializedObject.Update(); + PSXFontAsset font = (PSXFontAsset)target; + EditorGUILayout.LabelField("PSX Font Asset", EditorStyles.boldLabel); EditorGUILayout.Space(4); - // Source font (TTF/OTF) + // ── Source font (TTF/OTF) ── EditorGUILayout.LabelField("Auto-Convert from Font", EditorStyles.miniBoldLabel); EditorGUILayout.PropertyField(serializedObject.FindProperty("sourceFont"), new GUIContent("Source Font (TTF/OTF)", - "Assign a Unity Font (TrueType/OpenType). Click 'Generate Bitmap' to rasterize it.")); + "Assign a Unity Font (TrueType/OpenType). Click 'Generate Bitmap' to rasterize it.\n" + + "Glyph cell dimensions are auto-derived from the font metrics.")); EditorGUILayout.PropertyField(serializedObject.FindProperty("fontSize"), new GUIContent("Font Size", - "Pixel height for rasterization. Determines glyph cell height.")); + "Pixel height for rasterization. Determines glyph cell height.\n" + + "Glyph cell width is auto-derived from the widest character.\n" + + "Changing this and re-generating will update both the bitmap AND the glyph dimensions.")); - PSXFontAsset font = (PSXFontAsset)target; if (font.SourceFont != null) { - if (GUILayout.Button("Generate Bitmap from Font")) + EditorGUILayout.Space(2); + if (GUILayout.Button("Generate Bitmap from Font", GUILayout.Height(28))) { + Undo.RecordObject(font, "Generate PSX Font Bitmap"); font.GenerateBitmapFromFont(); } if (font.FontTexture == null) - EditorGUILayout.HelpBox("Click 'Generate Bitmap' to create the font texture.", MessageType.Info); + EditorGUILayout.HelpBox( + "Click 'Generate Bitmap' to create the font texture.\n" + + "If generation fails, check that the font's import settings have " + + "'Character' set to 'ASCII Default Set'.", MessageType.Info); } EditorGUILayout.Space(8); - // Manual bitmap + // ── Manual bitmap ── EditorGUILayout.LabelField("Manual Bitmap Source", EditorStyles.miniBoldLabel); EditorGUILayout.PropertyField(serializedObject.FindProperty("fontTexture"), new GUIContent("Font Texture", "256px wide bitmap. Glyphs in ASCII order from 0x20 (space). " + @@ -409,15 +463,32 @@ namespace SplashEdit.EditorCode EditorGUILayout.Space(4); - // Glyph metrics + // ── Glyph metrics ── + // If using auto-convert, these are set by GenerateBitmapFromFont. + // For manual bitmaps, the user sets them directly. EditorGUILayout.LabelField("Glyph Metrics", EditorStyles.miniBoldLabel); - EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphWidth"), new GUIContent("Glyph Width", - "Width of each glyph cell in pixels.")); - EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphHeight"), new GUIContent("Glyph Height", - "Height of each glyph cell in pixels.")); + if (font.SourceFont != null && font.FontTexture != null) + { + // Show as read-only when auto-generated + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.IntField(new GUIContent("Glyph Width", "Auto-derived from font. Re-generate to change."), font.GlyphWidth); + EditorGUILayout.IntField(new GUIContent("Glyph Height", "Auto-derived from font. Re-generate to change."), font.GlyphHeight); + EditorGUI.EndDisabledGroup(); + EditorGUILayout.HelpBox("Glyph dimensions are auto-derived when generating from a font.\n" + + "Change the Font Size slider and re-generate to adjust.", MessageType.Info); + } + else + { + // Editable for manual bitmap workflow + EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphWidth"), new GUIContent("Glyph Width", + "Width of each glyph cell in pixels. Must divide 256 evenly (4, 8, 16, or 32).")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("glyphHeight"), new GUIContent("Glyph Height", + "Height of each glyph cell in pixels.")); + } EditorGUILayout.Space(4); + // ── Layout info ── int glyphsPerRow = font.GlyphsPerRow; int rowCount = font.RowCount; int totalH = font.TextureHeight; @@ -427,15 +498,42 @@ namespace SplashEdit.EditorCode $"Layout: {glyphsPerRow} glyphs/row, {rowCount} rows\n" + $"Texture: 256 x {totalH} pixels (4bpp)\n" + $"VRAM: {vramBytes} bytes ({vramBytes / 1024f:F1} KB)\n" + - $"Glyph size: {font.GlyphWidth} x {font.GlyphHeight}", + $"Glyph cell: {font.GlyphWidth} x {font.GlyphHeight}", MessageType.Info); + // Show advance width status + if (font.AdvanceWidths != null && font.AdvanceWidths.Length >= 95) + { + int minAdv = 255, maxAdv = 0; + for (int i = 1; i < 95; i++) // skip space + { + if (font.AdvanceWidths[i] < minAdv) minAdv = font.AdvanceWidths[i]; + if (font.AdvanceWidths[i] > maxAdv) maxAdv = font.AdvanceWidths[i]; + } + EditorGUILayout.HelpBox( + $"Advance widths: {minAdv}-{maxAdv}px (proportional spacing stored)", + MessageType.Info); + } + else if (font.FontTexture != null) + { + EditorGUILayout.HelpBox( + "No advance widths stored. Click 'Generate Bitmap' to compute them.", + MessageType.Warning); + } + + // ── Validation ── if (font.FontTexture != null) { if (font.FontTexture.width != 256) EditorGUILayout.HelpBox($"Font texture must be 256 pixels wide (currently {font.FontTexture.width}).", MessageType.Error); + if (256 % font.GlyphWidth != 0) + EditorGUILayout.HelpBox($"Glyph width ({font.GlyphWidth}) must divide 256 evenly. " + + "Valid values: 4, 8, 16, 32.", MessageType.Error); + // Show preview + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("Preview", EditorStyles.miniBoldLabel); Rect previewRect = EditorGUILayout.GetControlRect(false, 64); GUI.DrawTexture(previewRect, font.FontTexture, ScaleMode.ScaleToFit); } diff --git a/Runtime/PSXCanvasData.cs b/Runtime/PSXCanvasData.cs index b9110bf..cb1ff6e 100644 --- a/Runtime/PSXCanvasData.cs +++ b/Runtime/PSXCanvasData.cs @@ -81,5 +81,8 @@ namespace SplashEdit.RuntimeCode /// Packed 4bpp pixel data ready for VRAM upload. public byte[] PixelData; + + /// Per-character advance widths (96 entries, ASCII 0x20-0x7F) for proportional rendering. + public byte[] AdvanceWidths; } } diff --git a/Runtime/PSXFontAsset.cs b/Runtime/PSXFontAsset.cs index 529544e..65f7fb4 100644 --- a/Runtime/PSXFontAsset.cs +++ b/Runtime/PSXFontAsset.cs @@ -7,76 +7,54 @@ using UnityEditor; namespace SplashEdit.RuntimeCode { - /// - /// Defines a custom bitmap font for PSX UI text rendering. - /// - /// Two workflows are supported: - /// 1. Manual bitmap: assign a 256px-wide texture with glyphs in ASCII order. - /// 2. Auto-convert: assign a Unity Font (TTF/OTF) and set glyph dimensions. - /// The bitmap is generated automatically from the font at the chosen size. - /// - /// On export the texture is converted to 4bpp indexed format: - /// pixel value 0 = transparent, value 1 = opaque. - /// The color is applied at runtime through the CLUT. - /// [CreateAssetMenu(fileName = "New PSXFont", menuName = "PSX/Font Asset")] public class PSXFontAsset : ScriptableObject { - [Header("Source — Option A: TrueType/OTF Font")] - [Tooltip("Assign a Unity Font asset (TTF/OTF). A bitmap will be auto-generated.\n" + - "Leave this empty to use a manual bitmap texture instead.")] + [Header("Source - Option A: TrueType/OTF Font")] + [Tooltip("Assign a Unity Font asset (TTF/OTF). Click 'Generate Bitmap' to rasterize.")] [SerializeField] private Font sourceFont; - [Tooltip("Font size in pixels used to rasterize the TrueType font. " + - "This determines glyph height; width is derived auto-fit per glyph.")] + [Tooltip("Font size in pixels. Larger = more detail but uses more VRAM.\n" + + "The actual glyph cell size is auto-computed to fit within PS1 texture page limits.")] [Range(6, 32)] [SerializeField] private int fontSize = 16; - [Header("Source — Option B: Manual Bitmap")] - [Tooltip("Font bitmap texture. Must be 256 pixels wide. " + - "Glyphs arranged left-to-right, top-to-bottom in ASCII order starting from space (0x20). " + - "Transparent pixels = background, any opaque pixel = foreground.")] + [Header("Source - Option B: Manual Bitmap")] + [Tooltip("Font bitmap texture. Must be 256 pixels wide.\n" + + "Glyphs in ASCII order from 0x20, transparent = bg, opaque = fg.")] [SerializeField] private Texture2D fontTexture; [Header("Glyph Metrics")] - [Tooltip("Width of each glyph cell in pixels (e.g. 8 for an 8x16 font).")] - [Range(4, 32)] + [Tooltip("Width of each glyph cell (auto-set from font, editable for manual bitmap).\n" + + "Must divide 256 evenly: 4, 8, 16, or 32.")] [SerializeField] private int glyphWidth = 8; - [Tooltip("Height of each glyph cell in pixels (e.g. 16 for an 8x16 font).")] - [Range(4, 32)] + [Tooltip("Height of each glyph cell (auto-set from font, editable for manual bitmap).")] [SerializeField] private int glyphHeight = 16; - /// The Unity Font source (TTF/OTF). Null if using manual bitmap. + [HideInInspector] + [SerializeField] private byte[] storedAdvanceWidths; + + // Valid glyph widths: must divide 256 evenly for PSYQo texture UV wrapping. + private static readonly int[] ValidGlyphWidths = { 4, 8, 16, 32 }; + + // PS1 texture page is 256 pixels tall. Font texture MUST fit in one page. + private const int MAX_TEXTURE_PAGE_HEIGHT = 256; + public Font SourceFont => sourceFont; - - /// Font size for TrueType rasterization. public int FontSize => fontSize; - - /// Source font texture (256px wide, monochrome). May be auto-generated. public Texture2D FontTexture => fontTexture; - - /// Width of each glyph cell in pixels. public int GlyphWidth => glyphWidth; - - /// Height of each glyph cell in pixels. public int GlyphHeight => glyphHeight; - /// Number of glyphs per row (always 256 / glyphWidth). + /// Per-character advance widths (96 entries, ASCII 0x20-0x7F). Computed during generation. + public byte[] AdvanceWidths => storedAdvanceWidths; + public int GlyphsPerRow => 256 / glyphWidth; - - /// Number of rows needed for all 95 printable ASCII characters. public int RowCount => Mathf.CeilToInt(95f / GlyphsPerRow); - - /// Total height of the font texture in pixels. public int TextureHeight => RowCount * glyphHeight; #if UNITY_EDITOR - /// - /// Generate a 256px-wide monochrome bitmap from the assigned TrueType font. - /// Each printable ASCII character (0x20–0x7E) is rasterized into - /// a fixed-size glyph cell. The result is stored in fontTexture. - /// public void GenerateBitmapFromFont() { if (sourceFont == null) @@ -85,153 +63,338 @@ namespace SplashEdit.RuntimeCode return; } - // Request all printable ASCII characters from the font at the target size - CharacterInfo ci; + // ── Step 1: Populate the font atlas ── string ascii = ""; for (int c = 0x20; c <= 0x7E; c++) ascii += (char)c; sourceFont.RequestCharactersInTexture(ascii, fontSize, FontStyle.Normal); + // ── Step 2: Get readable copy of atlas texture ── + // For non-dynamic fonts, the atlas may only be populated at the native size. + // Try the requested size first, then fall back to size=0. + Texture fontTex = sourceFont.material != null ? sourceFont.material.mainTexture : null; + if (fontTex == null || fontTex.width == 0 || fontTex.height == 0) + { + // Retry with size=0 (native size) for non-dynamic fonts + sourceFont.RequestCharactersInTexture(ascii, 0, FontStyle.Normal); + fontTex = sourceFont.material != null ? sourceFont.material.mainTexture : null; + } + if (fontTex == null) + { + Debug.LogError("PSXFontAsset: Font atlas is null. Set Character to 'ASCII Default Set' in font import settings."); + return; + } + + int fontTexW = fontTex.width; + int fontTexH = fontTex.height; + if (fontTexW == 0 || fontTexH == 0) + { + Debug.LogError("PSXFontAsset: Font atlas has zero dimensions. Try re-importing the font with 'ASCII Default Set'."); + return; + } + + Color[] fontPixels; + { + RenderTexture rt = RenderTexture.GetTemporary(fontTexW, fontTexH, 0, RenderTextureFormat.ARGB32); + Graphics.Blit(fontTex, rt); + RenderTexture prev = RenderTexture.active; + RenderTexture.active = rt; + Texture2D readable = new Texture2D(fontTexW, fontTexH, TextureFormat.RGBA32, false); + readable.ReadPixels(new Rect(0, 0, fontTexW, fontTexH), 0, 0); + readable.Apply(); + RenderTexture.active = prev; + RenderTexture.ReleaseTemporary(rt); + fontPixels = readable.GetPixels(); + DestroyImmediate(readable); + } + + // Verify atlas isn't blank + bool hasAnyPixel = false; + for (int i = 0; i < fontPixels.Length && !hasAnyPixel; i++) + { + if (fontPixels[i].a > 0.1f) hasAnyPixel = true; + } + if (!hasAnyPixel) + { + Debug.LogError("PSXFontAsset: Font atlas is blank. Set Character to 'ASCII Default Set' in font import settings."); + return; + } + + // ── Step 3: Get character info ── + // Non-dynamic fonts only respond to size=0 or their native size. + // Dynamic fonts respond to any size. + CharacterInfo[] charInfos = new CharacterInfo[95]; + bool[] charValid = new bool[95]; + int validCount = 0; + int workingSize = fontSize; + + // Try requested fontSize first + for (int c = 0x20; c <= 0x7E; c++) + { + int idx = c - 0x20; + if (sourceFont.GetCharacterInfo((char)c, out charInfos[idx], fontSize, FontStyle.Normal)) + { + charValid[idx] = true; + validCount++; + } + } + + // If that failed, try size=0 (non-dynamic fonts need this) + if (validCount == 0) + { + sourceFont.RequestCharactersInTexture(ascii, 0, FontStyle.Normal); + for (int c = 0x20; c <= 0x7E; c++) + { + int idx = c - 0x20; + if (sourceFont.GetCharacterInfo((char)c, out charInfos[idx], 0, FontStyle.Normal)) + { + charValid[idx] = true; + validCount++; + } + } + if (validCount > 0) + { + workingSize = 0; + Debug.Log("PSXFontAsset: Using font's native size for character info."); + } + } + + // Last resort: read characterInfo array directly + if (validCount == 0 && sourceFont.characterInfo != null) + { + foreach (CharacterInfo fci in sourceFont.characterInfo) + { + int c = fci.index; + if (c >= 0x20 && c <= 0x7E) + { + charInfos[c - 0x20] = fci; + charValid[c - 0x20] = true; + validCount++; + } + } + } + + if (validCount == 0) + { + Debug.LogError("PSXFontAsset: Could not get character info from font."); + return; + } + + // ── Step 4: Choose glyph cell dimensions ── + // Constraints: + // - glyphWidth must divide 256 (valid: 4, 8, 16, 32) + // - ceil(95 / (256/glyphWidth)) * glyphHeight <= 256 (must fit in one texture page) + // - glyphHeight in [4, 32] + // Strategy: pick the smallest valid width where everything fits. + // Glyphs that exceed the cell are scaled to fit. + + int measuredMaxW = 0, measuredMaxH = 0; + for (int idx = 1; idx < 95; idx++) // skip space + { + if (!charValid[idx]) continue; + CharacterInfo ci = charInfos[idx]; + int pw = Mathf.Abs(ci.maxX - ci.minX); + int ph = Mathf.Abs(ci.maxY - ci.minY); + if (pw > measuredMaxW) measuredMaxW = pw; + if (ph > measuredMaxH) measuredMaxH = ph; + } + + // Target height based on measured glyphs + margin + int targetH = Mathf.Clamp(measuredMaxH + 2, 4, 32); + + // Find the best valid width: start from the IDEAL (closest to measured width) + // and go smaller only if the texture wouldn't fit in 256px vertically. + // This maximizes glyph quality by using the widest cells that fit. + int bestW = -1, bestH = -1; + + // Find ideal: smallest valid width >= measured glyph width + int idealIdx = ValidGlyphWidths.Length - 1; // default to largest (32) + for (int i = 0; i < ValidGlyphWidths.Length; i++) + { + if (ValidGlyphWidths[i] >= measuredMaxW) + { + idealIdx = i; + break; + } + } + + // Try from ideal downward until we find one that fits + for (int i = idealIdx; i >= 0; i--) + { + int vw = ValidGlyphWidths[i]; + int perRow = 256 / vw; + int rows = Mathf.CeilToInt(95f / perRow); + int totalH = rows * targetH; + if (totalH <= MAX_TEXTURE_PAGE_HEIGHT) + { + bestW = vw; + bestH = targetH; + break; + } + } + + // If nothing fits even at width=4, clamp height + if (bestW < 0) + { + bestW = 4; + int rows4 = Mathf.CeilToInt(95f / 64); // 64 per row at width 4 + bestH = Mathf.Clamp(MAX_TEXTURE_PAGE_HEIGHT / rows4, 4, 32); + Debug.LogWarning($"PSXFontAsset: Font too large for PS1 texture page. " + + $"Clamping to {bestW}x{bestH} cells."); + } + + glyphWidth = bestW; + glyphHeight = bestH; + int texW = 256; int glyphsPerRow = texW / glyphWidth; int rowCount = Mathf.CeilToInt(95f / glyphsPerRow); int texH = rowCount * glyphHeight; - // Create output texture + // Compute baseline metrics for proper vertical positioning. + // Characters sit on a common baseline. Ascenders go up, descenders go down. + int maxAscender = 0; // highest point above baseline (positive) + int maxDescender = 0; // lowest point below baseline (negative) + for (int idx = 1; idx < 95; idx++) + { + if (!charValid[idx]) continue; + CharacterInfo ci = charInfos[idx]; + if (ci.maxY > maxAscender) maxAscender = ci.maxY; + if (ci.minY < maxDescender) maxDescender = ci.minY; + } + int totalFontH = maxAscender - maxDescender; + + // Vertical scale only if font exceeds cell height + float vScale = 1f; + int usableH = glyphHeight - 2; + if (totalFontH > usableH) + vScale = (float)usableH / totalFontH; + + // NO horizontal scaling. Glyphs rendered at native width, left-aligned. + // This makes the native advance widths match the bitmap exactly for + // proportional rendering. Characters wider than cell get clipped (rare). + + Debug.Log($"PSXFontAsset: Cell {glyphWidth}x{glyphHeight}, {glyphsPerRow}/row, " + + $"{rowCount} rows, texture {texW}x{texH} " + + $"(measured: {measuredMaxW}x{measuredMaxH}, " + + $"ascender={maxAscender}, descender={maxDescender}, vScale={vScale:F2})"); + + // ── Step 5: Render glyphs into grid ── + // Each glyph is LEFT-ALIGNED at native width for proportional rendering. + // The advance widths from CharacterInfo match native glyph proportions. Texture2D bmp = new Texture2D(texW, texH, TextureFormat.RGBA32, false); bmp.filterMode = FilterMode.Point; bmp.wrapMode = TextureWrapMode.Clamp; - // Fill transparent Color[] clearPixels = new Color[texW * texH]; - for (int i = 0; i < clearPixels.Length; i++) - clearPixels[i] = new Color(0, 0, 0, 0); bmp.SetPixels(clearPixels); - // Get the font's internal texture for reading glyph pixels - Texture2D fontTex = (Texture2D)sourceFont.material.mainTexture; - Color[] fontPixels = null; - int fontTexW = 0, fontTexH = 0; - - if (fontTex != null) - { - // Make sure it's readable - fontTexW = fontTex.width; - fontTexH = fontTex.height; - try - { - if (fontTex != null) - { - fontTexW = fontTex.width; - fontTexH = fontTex.height; - - // Make a readable copy - RenderTexture rt = RenderTexture.GetTemporary(fontTexW, fontTexH, 0); - Graphics.Blit(fontTex, rt); - RenderTexture.active = rt; - - Texture2D readableCopy = new Texture2D(fontTexW, fontTexH); - readableCopy.ReadPixels(new Rect(0, 0, fontTexW, fontTexH), 0, 0); - readableCopy.Apply(); - - RenderTexture.active = null; - RenderTexture.ReleaseTemporary(rt); - - // Get pixels from the copy - fontPixels = readableCopy.GetPixels(); - - // Done with it — dispose immediately - DestroyImmediate(readableCopy); - } - } - catch(Exception e) - { - Debug.LogException(e); - // Font texture not readable — fall back to RenderTexture copy - RenderTexture rt = RenderTexture.GetTemporary(fontTexW, fontTexH, 0, RenderTextureFormat.ARGB32); - Graphics.Blit(fontTex, rt); - RenderTexture prev = RenderTexture.active; - RenderTexture.active = rt; - Texture2D readable = new Texture2D(fontTexW, fontTexH, TextureFormat.RGBA32, false); - readable.ReadPixels(new Rect(0, 0, fontTexW, fontTexH), 0, 0); - readable.Apply(); - RenderTexture.active = prev; - RenderTexture.ReleaseTemporary(rt); - fontPixels = readable.GetPixels(); - DestroyImmediate(readable); - } - } - - // Rasterize each glyph into fixed-size cells. - // Glyphs are scaled to fill their bounding box using bilinear sampling. - // A 1-pixel margin is kept on each side to prevent bleeding between glyphs. - int margin = 1; - int drawW = glyphWidth - margin * 2; - int drawH = glyphHeight - margin * 2; - if (drawW < 2) drawW = glyphWidth; // fallback if cell too tiny - if (drawH < 2) drawH = glyphHeight; + int renderedCount = 0; for (int idx = 0; idx < 95; idx++) { - char ch = (char)(0x20 + idx); + if (!charValid[idx]) continue; + CharacterInfo ci = charInfos[idx]; + int col = idx % glyphsPerRow; int row = idx / glyphsPerRow; int cellX = col * glyphWidth; - int cellY = row * glyphHeight; // top-down in output + int cellY = row * glyphHeight; - if (!sourceFont.GetCharacterInfo(ch, out ci, fontSize, FontStyle.Normal)) - continue; - - if (fontPixels == null) continue; - - // ci gives UV coords in font texture int gw = Mathf.Abs(ci.maxX - ci.minX); int gh = Mathf.Abs(ci.maxY - ci.minY); if (gw <= 0 || gh <= 0) continue; - // UV rect of the source glyph in the font's internal texture - float uvLeft = Mathf.Min(ci.uvBottomLeft.x, ci.uvTopRight.x); - float uvRight = Mathf.Max(ci.uvBottomLeft.x, ci.uvTopRight.x); - float uvBottom = Mathf.Min(ci.uvBottomLeft.y, ci.uvTopRight.y); - float uvTop = Mathf.Max(ci.uvBottomLeft.y, ci.uvTopRight.y); - bool flipped = ci.uvBottomLeft.y > ci.uvTopRight.y; + // Use all four UV corners to handle atlas rotation. + // Unity's atlas packer can rotate glyphs 90 degrees to pack efficiently. + // Wide characters like 'm' and 'M' are commonly rotated. + // Bilinear interpolation across the UV quad handles any orientation. + Vector2 uvBL = ci.uvBottomLeft; + Vector2 uvBR = ci.uvBottomRight; + Vector2 uvTL = ci.uvTopLeft; + Vector2 uvTR = ci.uvTopRight; - // Scale the glyph to fit the cell exactly (minus margin). - // Each output pixel samples the source glyph proportionally. - for (int py = 0; py < drawH; py++) + // Native width (clipped to cell), scaled height + int renderW = Mathf.Min(gw, glyphWidth); + int renderH = Mathf.Max(1, Mathf.RoundToInt(gh * vScale)); + + // Y offset: baseline positioning + int baselineFromTop = 1 + Mathf.RoundToInt(maxAscender * vScale); + int glyphTopFromBaseline = Mathf.RoundToInt(ci.maxY * vScale); + int offsetY = baselineFromTop - glyphTopFromBaseline; + if (offsetY < 0) offsetY = 0; + + // Include left bearing so glyph sits at correct position within + // the advance space. Negative bearing (left overhang) clamped to 0. + int offsetX = Mathf.Max(0, ci.minX); + + bool anyPixel = false; + + for (int py = 0; py < renderH && (offsetY + py) < glyphHeight; py++) { - for (int px = 0; px < drawW; px++) + for (int px = 0; px < renderW && (offsetX + px) < glyphWidth; px++) { - // Map output pixel to source glyph UV - float srcU = (px + 0.5f) / drawW; - float srcV = (py + 0.5f) / drawH; + // Scale to fit if glyph wider than cell, 1:1 otherwise + float srcU = (px + 0.5f) / renderW; + float srcV = (py + 0.5f) / renderH; - float u = Mathf.Lerp(uvLeft, uvRight, srcU); - float v; - if (flipped) - v = Mathf.Lerp(uvTop, uvBottom, srcV); - else - v = Mathf.Lerp(uvBottom, uvTop, 1f - srcV); + // Bilinear interpolation across the UV quad (handles rotation) + // Bottom edge: lerp BL->BR by srcU + // Top edge: lerp TL->TR by srcU + // Then lerp bottom->top by (1-srcV) for top-down rendering + float t = 1f - srcV; // 0=bottom, 1=top -> invert for top-down + float u = Mathf.Lerp( + Mathf.Lerp(uvBL.x, uvBR.x, srcU), + Mathf.Lerp(uvTL.x, uvTR.x, srcU), t); + float v = Mathf.Lerp( + Mathf.Lerp(uvBL.y, uvBR.y, srcU), + Mathf.Lerp(uvTL.y, uvTR.y, srcU), t); int sx = Mathf.Clamp(Mathf.FloorToInt(u * fontTexW), 0, fontTexW - 1); int sy = Mathf.Clamp(Mathf.FloorToInt(v * fontTexH), 0, fontTexH - 1); Color sc = fontPixels[sy * fontTexW + sx]; - // Threshold: if alpha or luminance > 0.3, mark as opaque - bool opaque = sc.a > 0.3f || (sc.r + sc.g + sc.b) / 3f > 0.3f; - if (!opaque) continue; + if (sc.a <= 0.3f) continue; - // Write to output: output Y is top-down, Unity texture Y is bottom-up - int outX = cellX + margin + px; - int outY = texH - 1 - (cellY + margin + py); + int outX = cellX + offsetX + px; + int outY = texH - 1 - (cellY + offsetY + py); if (outX >= 0 && outX < texW && outY >= 0 && outY < texH) + { bmp.SetPixel(outX, outY, Color.white); + anyPixel = true; + } } } + if (anyPixel) renderedCount++; } bmp.Apply(); - // Save as asset + if (renderedCount == 0) + { + Debug.LogError("PSXFontAsset: Generated bitmap is empty."); + DestroyImmediate(bmp); + return; + } + + Debug.Log($"PSXFontAsset: Rendered {renderedCount}/95 glyphs."); + + // Store advance widths from the same CharacterInfo used for rendering. + // This guarantees advances match the bitmap glyphs exactly. + storedAdvanceWidths = new byte[96]; + for (int idx = 0; idx < 96; idx++) + { + if (idx < 95 && charValid[idx]) + { + CharacterInfo ci = charInfos[idx]; + storedAdvanceWidths[idx] = (byte)Mathf.Clamp(Mathf.CeilToInt(ci.advance), 1, 255); + } + else + { + storedAdvanceWidths[idx] = (byte)glyphWidth; // fallback + } + } + + // ── Step 6: Save ── string path = AssetDatabase.GetAssetPath(this); if (string.IsNullOrEmpty(path)) { @@ -241,12 +404,10 @@ namespace SplashEdit.RuntimeCode string dir = System.IO.Path.GetDirectoryName(path); string texPath = dir + "/" + name + "_bitmap.png"; - byte[] pngData = bmp.EncodeToPNG(); - System.IO.File.WriteAllBytes(texPath, pngData); + System.IO.File.WriteAllBytes(texPath, bmp.EncodeToPNG()); DestroyImmediate(bmp); AssetDatabase.ImportAsset(texPath, ImportAssetOptions.ForceUpdate); - // Set import settings for point filter, no compression TextureImporter importer = AssetImporter.GetAtPath(texPath) as TextureImporter; if (importer != null) { @@ -263,23 +424,16 @@ namespace SplashEdit.RuntimeCode fontTexture = AssetDatabase.LoadAssetAtPath(texPath); EditorUtility.SetDirty(this); AssetDatabase.SaveAssets(); - Debug.Log($"PSXFontAsset: Generated bitmap {texW}x{texH} at {texPath}"); + Debug.Log($"PSXFontAsset: Saved {texW}x{texH} bitmap at {texPath}"); } #endif - /// - /// Convert the font texture to packed 4bpp data suitable for PS1 VRAM upload. - /// Returns byte array where each byte contains two 4-bit pixels. - /// Pixel value 0 = transparent, value 1 = opaque. - /// Output is 256 pixels wide × TextureHeight pixels tall. - /// public byte[] ConvertTo4BPP() { if (fontTexture == null) return null; - int texW = 256; // always 256 pixels wide in 4bpp + int texW = 256; int texH = TextureHeight; - // 4bpp: 2 pixels per byte → 128 bytes per row int bytesPerRow = texW / 2; byte[] result = new byte[bytesPerRow * texH]; @@ -291,9 +445,8 @@ namespace SplashEdit.RuntimeCode { for (int x = 0; x < texW; x += 2) { - byte lo = SamplePixel(pixels, srcW, srcH, x, y, texH); - byte hi = SamplePixel(pixels, srcW, srcH, x + 1, y, texH); - // Pack two 4-bit values: low nibble first (PS1 byte order) + byte lo = SamplePixel(pixels, srcW, srcH, x, y); + byte hi = SamplePixel(pixels, srcW, srcH, x + 1, y); result[y * bytesPerRow + x / 2] = (byte)(lo | (hi << 4)); } } @@ -301,23 +454,13 @@ namespace SplashEdit.RuntimeCode return result; } - /// - /// Sample the font texture at (x, y) in output space. - /// Y=0 is top of texture (PS1 convention: top-down). - /// Returns 0 (transparent) or 1 (opaque). - /// - private byte SamplePixel(Color[] pixels, int srcW, int srcH, int x, int y, int outH) + private byte SamplePixel(Color[] pixels, int srcW, int srcH, int x, int y) { if (x >= srcW || y >= srcH) return 0; - // Source texture is bottom-up (Unity convention), output is top-down (PS1) - int srcY = srcH - 1 - y; + int srcY = srcH - 1 - y; // top-down (PS1) to bottom-up (Unity) if (srcY < 0 || srcY >= srcH) return 0; Color c = pixels[srcY * srcW + x]; - // Opaque if alpha > 0.5 or luminance > 0.5 (handles both alpha-based and grayscale fonts) - if (c.a > 0.5f || (c.r + c.g + c.b) / 3f > 0.5f) - return 1; - return 0; + return c.a > 0.5f ? (byte)1 : (byte)0; } } } - diff --git a/Runtime/PSXSceneWriter.cs b/Runtime/PSXSceneWriter.cs index aa058e1..a33522e 100644 --- a/Runtime/PSXSceneWriter.cs +++ b/Runtime/PSXSceneWriter.cs @@ -634,7 +634,7 @@ namespace SplashEdit.RuntimeCode // ────────────────────────────────────────────────────── // UI canvas + font data (version 13) - // Font descriptors: 16 bytes each (before canvas data) + // Font descriptors: 112 bytes each (before canvas data) // Font pixel data: raw 4bpp (after font descriptors) // Canvas descriptor table: 12 bytes per canvas // Element records: 48 bytes each @@ -645,7 +645,7 @@ namespace SplashEdit.RuntimeCode AlignToFourBytes(writer); long uiTableStart = writer.BaseStream.Position; - // ── Font descriptors (16 bytes each) ── + // ── Font descriptors (112 bytes each) ── // Layout: glyphW(1) glyphH(1) vramX(2) vramY(2) textureH(2) // dataOffset(4) dataSize(4) List fontDataOffsetPositions = new List(); @@ -661,6 +661,11 @@ namespace SplashEdit.RuntimeCode fontDataOffsetPositions.Add(writer.BaseStream.Position); writer.Write((uint)0); // [8-11] dataOffset placeholder writer.Write((uint)(font.PixelData?.Length ?? 0)); // [12-15] dataSize + // [16-111] per-character advance widths for proportional rendering + if (font.AdvanceWidths != null && font.AdvanceWidths.Length >= 96) + writer.Write(font.AdvanceWidths, 0, 96); + else + writer.Write(new byte[96]); // zero-fill if missing } } diff --git a/Runtime/PSXUIExporter.cs b/Runtime/PSXUIExporter.cs index 0d0d83e..d31f53b 100644 --- a/Runtime/PSXUIExporter.cs +++ b/Runtime/PSXUIExporter.cs @@ -13,7 +13,7 @@ namespace SplashEdit.RuntimeCode /// public static class PSXUIExporter { - /// + /// /// Collect all PSXCanvas components and their child UI elements, /// converting RectTransform coordinates to PS1 pixel space. /// Also collects and deduplicates custom fonts. @@ -49,28 +49,60 @@ namespace SplashEdit.RuntimeCode } } - // Build font data with VRAM positions - // Font textures go at x=960 (same column as system font), stacking upward from y=464 - // System font: (960, 464)-(1023, 511) → 64 wide, 48 tall + // Build font data with VRAM positions. + // Each font gets its own texture page to avoid V-coordinate overflow. + // Font textures go at x=960: + // Font 1: y=0 (page 15,0) - 256px available + // Font 2: y=256 (page 15,1) - 208px available (system font at y=464) + // Font 3: not supported (would need different VRAM column) + // System font: (960, 464) in page (15,1), occupies y=464-511. List fontDataList = new List(); - ushort fontVramY = 0; // start from top of VRAM at x=960 + ushort[] fontPageStarts = { 0, 256 }; // one per texture page + int fontPageIndex = 0; + foreach (PSXFontAsset fa in uniqueFonts) { byte[] pixelData = fa.ConvertTo4BPP(); if (pixelData == null) continue; + // Read advance widths directly from the font asset. + // These were computed during bitmap generation from the exact same + // CharacterInfo used to render the glyphs - guaranteed to match. + byte[] advances = fa.AdvanceWidths; + if (advances == null || advances.Length < 96) + { + Debug.LogWarning($"PSXUIExporter: Font '{fa.name}' has no stored advance widths. Using cell width as fallback."); + advances = new byte[96]; + for (int i = 0; i < 96; i++) advances[i] = (byte)fa.GlyphWidth; + } + ushort texH = (ushort)fa.TextureHeight; + + if (fontPageIndex >= fontPageStarts.Length) + { + Debug.LogError($"PSXUIExporter: Max 2 custom fonts supported (need separate texture pages). Skipping '{fa.name}'."); + continue; + } + + ushort vramY = fontPageStarts[fontPageIndex]; + int maxHeight = (fontPageIndex == 1) ? 208 : 256; // page 1 shares with system font + if (texH > maxHeight) + { + Debug.LogWarning($"PSXUIExporter: Font '{fa.name}' texture ({texH}px) exceeds page limit ({maxHeight}px). May be clipped."); + } + fontDataList.Add(new PSXFontData { Source = fa, GlyphWidth = (byte)fa.GlyphWidth, GlyphHeight = (byte)fa.GlyphHeight, - VramX = 960, // same column as system font (64 VRAM hwords for 256px 4bpp) - VramY = fontVramY, + VramX = 960, + VramY = vramY, TextureHeight = texH, - PixelData = pixelData + PixelData = pixelData, + AdvanceWidths = advances }); - fontVramY += texH; + fontPageIndex++; } fonts = fontDataList.ToArray();