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