Files
secretsplash/Runtime/PSXFontAsset.cs
2026-03-25 12:25:48 +01:00

324 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
using System;
#if UNITY_EDITOR
using UnityEditor;
#endif
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.")]
[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;
/// <summary>The Unity Font source (TTF/OTF). Null if using manual bitmap.</summary>
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>
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)
{
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<Texture2D>(texPath);
EditorUtility.SetDirty(this);
AssetDatabase.SaveAssets();
Debug.Log($"PSXFontAsset: Generated bitmap {texW}x{texH} 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 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;
}
/// <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)
{
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;
}
}
}