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();