using UnityEngine; using System; #if UNITY_EDITOR using UnityEditor; #endif namespace SplashEdit.RuntimeCode { [CreateAssetMenu(fileName = "New PSXFont", menuName = "PSX/Font Asset")] [Icon("Packages/net.psxsplash.splashedit/Icons/PSXFontAsset.png")] public class PSXFontAsset : ScriptableObject { [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. 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.\n" + "Glyphs in ASCII order from 0x20, transparent = bg, opaque = fg.")] [SerializeField] private Texture2D fontTexture; [Header("Glyph Metrics")] [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 (auto-set from font, editable for manual bitmap).")] [SerializeField] private int glyphHeight = 16; [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; public int FontSize => fontSize; public Texture2D FontTexture => fontTexture; public int GlyphWidth => glyphWidth; public int GlyphHeight => glyphHeight; /// Per-character advance widths (96 entries, ASCII 0x20-0x7F). Computed during generation. public byte[] AdvanceWidths => storedAdvanceWidths; public int GlyphsPerRow => 256 / glyphWidth; public int RowCount => Mathf.CeilToInt(95f / GlyphsPerRow); public int TextureHeight => RowCount * glyphHeight; #if UNITY_EDITOR public void GenerateBitmapFromFont() { if (sourceFont == null) { Debug.LogWarning("PSXFontAsset: No source font assigned."); return; } // ── 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; } } // 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; // 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). // ── 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; Color[] clearPixels = new Color[texW * texH]; bmp.SetPixels(clearPixels); int renderedCount = 0; for (int idx = 0; idx < 95; 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; int gw = Mathf.Abs(ci.maxX - ci.minX); int gh = Mathf.Abs(ci.maxY - ci.minY); if (gw <= 0 || gh <= 0) continue; // 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; // 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 < renderW && (offsetX + px) < glyphWidth; px++) { // Scale to fit if glyph wider than cell, 1:1 otherwise float srcU = (px + 0.5f) / renderW; float srcV = (py + 0.5f) / renderH; // 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]; if (sc.a <= 0.3f) continue; 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(); if (renderedCount == 0) { Debug.LogError("PSXFontAsset: Generated bitmap is empty."); DestroyImmediate(bmp); return; } // 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)) { fontTexture = bmp; return; } string dir = System.IO.Path.GetDirectoryName(path); string texPath = dir + "/" + name + "_bitmap.png"; System.IO.File.WriteAllBytes(texPath, bmp.EncodeToPNG()); DestroyImmediate(bmp); AssetDatabase.ImportAsset(texPath, ImportAssetOptions.ForceUpdate); TextureImporter importer = AssetImporter.GetAtPath(texPath) as TextureImporter; if (importer != null) { importer.textureType = TextureImporterType.Default; importer.filterMode = FilterMode.Point; importer.textureCompression = TextureImporterCompression.Uncompressed; importer.isReadable = true; importer.npotScale = TextureImporterNPOTScale.None; importer.mipmapEnabled = false; importer.alphaIsTransparency = true; importer.SaveAndReimport(); } fontTexture = AssetDatabase.LoadAssetAtPath(texPath); EditorUtility.SetDirty(this); AssetDatabase.SaveAssets(); } #endif public byte[] ConvertTo4BPP() { if (fontTexture == null) return null; int texW = 256; int texH = TextureHeight; int bytesPerRow = texW / 2; byte[] result = new byte[bytesPerRow * texH]; Color[] pixels = fontTexture.GetPixels(0, 0, fontTexture.width, fontTexture.height); int srcW = fontTexture.width; int srcH = fontTexture.height; for (int y = 0; y < texH; y++) { for (int x = 0; x < texW; x += 2) { 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)); } } return result; } private byte SamplePixel(Color[] pixels, int srcW, int srcH, int x, int y) { if (x >= srcW || y >= srcH) return 0; 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]; return c.a > 0.5f ? (byte)1 : (byte)0; } } }