using UnityEngine; using System; #if UNITY_EDITOR using UnityEditor; #endif 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.")] [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.")] [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.")] [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)] [SerializeField] private int glyphWidth = 8; [Tooltip("Height of each glyph cell in pixels (e.g. 16 for an 8x16 font).")] [Range(4, 32)] [SerializeField] private int glyphHeight = 16; /// The Unity Font source (TTF/OTF). Null if using manual bitmap. 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). 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) { Debug.LogWarning("PSXFontAsset: No source font assigned."); return; } // Request all printable ASCII characters from the font at the target size CharacterInfo ci; string ascii = ""; for (int c = 0x20; c <= 0x7E; c++) ascii += (char)c; sourceFont.RequestCharactersInTexture(ascii, fontSize, FontStyle.Normal); int texW = 256; int glyphsPerRow = texW / glyphWidth; int rowCount = Mathf.CeilToInt(95f / glyphsPerRow); int texH = rowCount * glyphHeight; // Create output texture 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; for (int idx = 0; idx < 95; idx++) { char ch = (char)(0x20 + idx); int col = idx % glyphsPerRow; int row = idx / glyphsPerRow; int cellX = col * glyphWidth; int cellY = row * glyphHeight; // top-down in output 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; // 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++) { for (int px = 0; px < drawW; px++) { // Map output pixel to source glyph UV float srcU = (px + 0.5f) / drawW; float srcV = (py + 0.5f) / drawH; 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); 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; // 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); if (outX >= 0 && outX < texW && outY >= 0 && outY < texH) bmp.SetPixel(outX, outY, Color.white); } } } bmp.Apply(); // Save as asset 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"; byte[] pngData = bmp.EncodeToPNG(); System.IO.File.WriteAllBytes(texPath, pngData); 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) { 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(); Debug.Log($"PSXFontAsset: Generated bitmap {texW}x{texH} 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 texH = TextureHeight; // 4bpp: 2 pixels per byte → 128 bytes per row 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, texH); byte hi = SamplePixel(pixels, srcW, srcH, x + 1, y, texH); // Pack two 4-bit values: low nibble first (PS1 byte order) result[y * bytesPerRow + x / 2] = (byte)(lo | (hi << 4)); } } 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) { if (x >= srcW || y >= srcH) return 0; // Source texture is bottom-up (Unity convention), output is top-down (PS1) int srcY = srcH - 1 - y; 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; } } }