324 lines
14 KiB
C#
324 lines
14 KiB
C#
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 (0x20–0x7E) 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;
|
||
}
|
||
}
|
||
}
|
||
|