Somewhat fixed ui

This commit is contained in:
Jan Racek
2026-03-25 17:14:22 +01:00
parent 8914ba35cc
commit 5fffcea6cf
5 changed files with 507 additions and 226 deletions

View File

@@ -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);
}

View File

@@ -81,5 +81,8 @@ namespace SplashEdit.RuntimeCode
/// <summary>Packed 4bpp pixel data ready for VRAM upload.</summary>
public byte[] PixelData;
/// <summary>Per-character advance widths (96 entries, ASCII 0x20-0x7F) for proportional rendering.</summary>
public byte[] AdvanceWidths;
}
}

View File

@@ -7,76 +7,54 @@ using UnityEditor;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// 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.
/// </summary>
[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;
/// <summary>The Unity Font source (TTF/OTF). Null if using manual bitmap.</summary>
[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;
/// <summary>Font size for TrueType rasterization.</summary>
public int FontSize => fontSize;
/// <summary>Source font texture (256px wide, monochrome). May be auto-generated.</summary>
public Texture2D FontTexture => fontTexture;
/// <summary>Width of each glyph cell in pixels.</summary>
public int GlyphWidth => glyphWidth;
/// <summary>Height of each glyph cell in pixels.</summary>
public int GlyphHeight => glyphHeight;
/// <summary>Number of glyphs per row (always 256 / glyphWidth).</summary>
/// <summary>Per-character advance widths (96 entries, ASCII 0x20-0x7F). Computed during generation.</summary>
public byte[] AdvanceWidths => storedAdvanceWidths;
public int GlyphsPerRow => 256 / glyphWidth;
/// <summary>Number of rows needed for all 95 printable ASCII characters.</summary>
public int RowCount => Mathf.CeilToInt(95f / GlyphsPerRow);
/// <summary>Total height of the font texture in pixels.</summary>
public int TextureHeight => RowCount * glyphHeight;
#if UNITY_EDITOR
/// <summary>
/// Generate a 256px-wide monochrome bitmap from the assigned TrueType font.
/// Each printable ASCII character (0x200x7E) is rasterized into
/// a fixed-size glyph cell. The result is stored in fontTexture.
/// </summary>
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<Texture2D>(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
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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).
/// </summary>
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;
}
}
}

View File

@@ -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<long> fontDataOffsetPositions = new List<long>();
@@ -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
}
}

View File

@@ -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<PSXFontData> fontDataList = new List<PSXFontData>();
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();