Merge pull request #3 from psxsplash/splashpack

Merge splashpack into main
This commit is contained in:
Bandwidth
2025-04-02 13:49:27 +02:00
committed by GitHub
14 changed files with 1119 additions and 317 deletions

View File

@@ -14,7 +14,7 @@ namespace SplashEdit.EditorCode
private Texture2D vramTexture; // VRAM representation of the texture
private List<VRAMPixel> clut; // Color Lookup Table (CLUT), stored as a 1D list
private ushort[] indexedPixelData; // Indexed pixel data for VRAM storage
private PSXBPP bpp;
private PSXBPP bpp = PSXBPP.TEX_4BIT;
private readonly int previewSize = 256;
[MenuItem("Window/Quantized Preview")]

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using SplashEdit.RuntimeCode;
using Unity.Collections;
using UnityEditor;
@@ -11,8 +12,10 @@ namespace SplashEdit.EditorCode
{
public class VRAMEditorWindow : EditorWindow
{
private const int VramWidth = 1024;
private const int VramHeight = 512;
private int VramWidth => VRAMPacker.VramWidth;
private int VramHeight => VRAMPacker.VramHeight;
private static readonly Vector2 MinSize = new Vector2(800, 600);
private List<ProhibitedArea> prohibitedAreas = new List<ProhibitedArea>();
private Vector2 scrollPosition;
private Texture2D vramImage;
@@ -22,8 +25,6 @@ namespace SplashEdit.EditorCode
private Color bufferColor1 = new Color(1, 0, 0, 0.5f);
private Color bufferColor2 = new Color(0, 1, 0, 0.5f);
private Color prohibitedColor = new Color(1, 0, 0, 0.3f);
private static string _psxDataPath = "Assets/PSXData.asset";
private PSXData _psxData;
private static readonly Vector2[] resolutions =
@@ -34,13 +35,14 @@ namespace SplashEdit.EditorCode
new Vector2(512, 240), new Vector2(512, 480),
new Vector2(640, 240), new Vector2(640, 480)
};
private static string[] resolutionsStrings => resolutions.Select(c => $"{c.x}x{c.y}").ToArray();
[MenuItem("Window/VRAM Editor")]
public static void ShowWindow()
{
VRAMEditorWindow window = GetWindow<VRAMEditorWindow>("VRAM Editor");
// Set minimum window dimensions.
window.minSize = new Vector2(1600, 600);
window.minSize = MinSize;
}
private void OnEnable()
@@ -53,9 +55,9 @@ namespace SplashEdit.EditorCode
blackPixels.Dispose();
// Ensure minimum window size is applied.
this.minSize = new Vector2(800, 600);
this.minSize = MinSize;
LoadData();
_psxData = DataStorage.LoadData(out selectedResolution, out dualBuffering, out verticalLayout, out prohibitedAreas);
}
/// <summary>
@@ -113,13 +115,11 @@ namespace SplashEdit.EditorCode
PSXObjectExporter[] objects = FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
foreach (PSXObjectExporter exp in objects)
{
exp.CreatePSXTexture2D();
exp.CreatePSXTextures2D();
}
// Define framebuffer regions based on selected resolution and layout.
Rect buffer1 = new Rect(0, 0, selectedResolution.x, selectedResolution.y);
Rect buffer2 = verticalLayout ? new Rect(0, 256, selectedResolution.x, selectedResolution.y)
: new Rect(selectedResolution.x, 0, selectedResolution.x, selectedResolution.y);
(Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout);
List<Rect> framebuffers = new List<Rect> { buffer1 };
if (dualBuffering)
@@ -136,20 +136,24 @@ namespace SplashEdit.EditorCode
{
for (int x = 0; x < VramWidth; x++)
{
vramImage.SetPixel(x, VramHeight - y - 1, packed._vramPixels[x, y].GetUnityColor());
vramImage.SetPixel(x, VramHeight - y - 1, packed.vramPixels[x, y].GetUnityColor());
}
}
vramImage.Apply();
// Prompt the user to select a file location and save the VRAM data.
string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin");
if (path != string.Empty)
{
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)))
{
for (int y = 0; y < VramHeight; y++)
{
for (int x = 0; x < VramWidth; x++)
{
writer.Write(packed._vramPixels[x, y].Pack());
writer.Write(packed.vramPixels[x, y].Pack());
}
}
}
}
@@ -162,12 +166,11 @@ namespace SplashEdit.EditorCode
GUILayout.Label("VRAM Editor", EditorStyles.boldLabel);
// Dropdown for resolution selection.
selectedResolution = resolutions[EditorGUILayout.Popup("Resolution", System.Array.IndexOf(resolutions, selectedResolution),
new string[] { "256x240", "256x480", "320x240", "320x480", "368x240", "368x480", "512x240", "512x480", "640x240", "640x480" })];
selectedResolution = resolutions[EditorGUILayout.Popup("Resolution", System.Array.IndexOf(resolutions, selectedResolution), resolutionsStrings)];
// Check resolution constraints for dual buffering.
bool canDBHorizontal = selectedResolution[0] * 2 <= 1024;
bool canDBVertical = selectedResolution[1] * 2 <= 512;
bool canDBHorizontal = selectedResolution.x * 2 <= VramWidth;
bool canDBVertical = selectedResolution.y * 2 <= VramHeight;
if (canDBHorizontal || canDBVertical)
{
@@ -192,30 +195,51 @@ namespace SplashEdit.EditorCode
}
GUILayout.Space(10);
GUILayout.Label("Prohibited areas", EditorStyles.boldLabel);
scrollPosition = GUILayout.BeginScrollView(scrollPosition, GUILayout.MaxHeight(150f));
GUILayout.Label("Prohibited Areas", EditorStyles.boldLabel);
GUILayout.Space(10);
scrollPosition = GUILayout.BeginScrollView(scrollPosition, false, true, GUILayout.MinHeight(300f), GUILayout.ExpandWidth(true));
// List and edit each prohibited area.
List<int> toRemove = new List<int>();
for (int i = 0; i < prohibitedAreas.Count; i++)
{
var area = prohibitedAreas[i];
area.X = EditorGUILayout.IntField("X", area.X);
area.Y = EditorGUILayout.IntField("Y", area.Y);
GUI.backgroundColor = new Color(0.95f, 0.95f, 0.95f);
GUILayout.BeginVertical("box");
GUI.backgroundColor = Color.white;
// Display fields for editing the area
area.X = EditorGUILayout.IntField("X Coordinate", area.X);
area.Y = EditorGUILayout.IntField("Y Coordinate", area.Y);
area.Width = EditorGUILayout.IntField("Width", area.Width);
area.Height = EditorGUILayout.IntField("Height", area.Height);
if (GUILayout.Button("Remove"))
if (GUILayout.Button("Remove", GUILayout.Height(30)))
{
prohibitedAreas.RemoveAt(i);
break;
toRemove.Add(i); // Mark for removal
}
prohibitedAreas[i] = area;
GUILayout.EndVertical();
GUILayout.Space(10);
}
// Remove the areas marked for deletion outside the loop to avoid skipping elements
foreach (var index in toRemove.OrderByDescending(x => x))
{
prohibitedAreas.RemoveAt(index);
}
GUILayout.EndScrollView();
GUILayout.Space(10);
if (GUILayout.Button("Add Prohibited Area"))
{
prohibitedAreas.Add(new ProhibitedArea());
@@ -230,19 +254,26 @@ namespace SplashEdit.EditorCode
// Button to save settings; saving now occurs only on button press.
if (GUILayout.Button("Save Settings"))
{
StoreData();
_psxData.OutputResolution = selectedResolution;
_psxData.DualBuffering = dualBuffering;
_psxData.VerticalBuffering = verticalLayout;
_psxData.ProhibitedAreas = prohibitedAreas;
DataStorage.StoreData(_psxData);
EditorUtility.DisplayDialog("splashedit", "Vram configuration saved", "OK");
}
GUILayout.EndVertical();
// Display VRAM image preview.
Rect vramRect = GUILayoutUtility.GetRect(VramWidth, VramHeight, GUILayout.ExpandWidth(false));
if (vramImage)
{
EditorGUI.DrawPreviewTexture(vramRect, vramImage, null, ScaleMode.ScaleToFit, 0, 0, ColorWriteMask.All);
}
// Draw framebuffer overlays.
Rect buffer1 = new Rect(vramRect.x, vramRect.y, selectedResolution.x, selectedResolution.y);
Rect buffer2 = verticalLayout ? new Rect(vramRect.x, 256, selectedResolution.x, selectedResolution.y)
: new Rect(vramRect.x + selectedResolution.x, vramRect.y, selectedResolution.x, selectedResolution.y);
(Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout, vramRect.min);
EditorGUI.DrawRect(buffer1, bufferColor1);
GUI.Label(new Rect(buffer1.center.x - 40, buffer1.center.y - 10, 120, 20), "Framebuffer A", EditorStyles.boldLabel);
@@ -263,42 +294,10 @@ namespace SplashEdit.EditorCode
GUILayout.EndHorizontal();
}
/// <summary>
/// Loads stored PSX data from the asset.
/// </summary>
private void LoadData()
{
_psxData = AssetDatabase.LoadAssetAtPath<PSXData>(_psxDataPath);
if (!_psxData)
{
_psxData = CreateInstance<PSXData>();
AssetDatabase.CreateAsset(_psxData, _psxDataPath);
AssetDatabase.SaveAssets();
}
selectedResolution = _psxData.OutputResolution;
dualBuffering = _psxData.DualBuffering;
verticalLayout = _psxData.VerticalBuffering;
prohibitedAreas = _psxData.ProhibitedAreas;
}
/// <summary>
/// Stores current configuration to the PSX data asset.
/// This is now triggered manually via the "Save Settings" button.
/// </summary>
private void StoreData()
{
if (_psxData != null)
{
_psxData.OutputResolution = selectedResolution;
_psxData.DualBuffering = dualBuffering;
_psxData.VerticalBuffering = verticalLayout;
_psxData.ProhibitedAreas = prohibitedAreas;
EditorUtility.SetDirty(_psxData);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
}
}

7
LICENSE.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: c1679c9d58898f14494d614dfe5f76a6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,88 @@
using UnityEngine;
public static class PSXLightingBaker
{
/// <summary>
/// Computes the per-vertex lighting from all scene light sources.
/// Incorporates ambient, diffuse, and spotlight falloff.
/// </summary>
/// <param name="vertex">The world-space position of the vertex.</param>
/// <param name="normal">The normalized world-space normal of the vertex.</param>
/// <returns>A Color representing the lit vertex.</returns>
public static Color ComputeLighting(Vector3 vertex, Vector3 normal)
{
Color finalColor = Color.black;
Light[] lights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None);
foreach (Light light in lights)
{
if (!light.enabled)
continue;
Color lightContribution = Color.black;
if (light.type == LightType.Directional)
{
Vector3 lightDir = -light.transform.forward;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir));
lightContribution = light.color * light.intensity * NdotL;
}
else if (light.type == LightType.Point)
{
Vector3 lightDir = light.transform.position - vertex;
float distance = lightDir.magnitude;
lightDir.Normalize();
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, lightDir));
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
lightContribution = light.color * light.intensity * NdotL * attenuation;
}
else if (light.type == LightType.Spot)
{
Vector3 L = light.transform.position - vertex;
float distance = L.magnitude;
L = L / distance;
float NdotL = Mathf.Max(0f, Vector3.Dot(normal, L));
float attenuation = 1.0f / Mathf.Max(distance * distance, 0.0001f);
float outerAngleRad = (light.spotAngle * 0.5f) * Mathf.Deg2Rad;
float innerAngleRad = outerAngleRad * 0.8f;
if (light is Light spotLight)
{
if (spotLight.innerSpotAngle > 0)
{
innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad;
}
}
float cosOuter = Mathf.Cos(outerAngleRad);
float cosInner = Mathf.Cos(innerAngleRad);
float cosAngle = Vector3.Dot(L, -light.transform.forward);
if (cosAngle >= cosOuter)
{
float spotFactor = Mathf.Clamp01((cosAngle - cosOuter) / (cosInner - cosOuter));
spotFactor = Mathf.Pow(spotFactor, 4.0f);
lightContribution = light.color * light.intensity * NdotL * attenuation * spotFactor;
}
else
{
lightContribution = Color.black;
}
}
finalColor += lightContribution;
}
finalColor.r = Mathf.Clamp(finalColor.r, 0.0f, 0.8f);
finalColor.g = Mathf.Clamp(finalColor.g, 0.0f, 0.8f);
finalColor.b = Mathf.Clamp(finalColor.b, 0.0f, 0.8f);
finalColor.a = 1f;
return finalColor;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b707b7d499862621fb6c82aba4caa183

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace SplashEdit.RuntimeCode
@@ -26,6 +27,9 @@ namespace SplashEdit.RuntimeCode
public PSXVertex v0;
public PSXVertex v1;
public PSXVertex v2;
public PSXTexture2D Texture;
public readonly PSXVertex[] Vertexes => new PSXVertex[] { v0, v1, v2 };
}
/// <summary>
@@ -36,6 +40,40 @@ namespace SplashEdit.RuntimeCode
{
public List<Tri> Triangles;
private static Vector3[] RecalculateSmoothNormals(Mesh mesh)
{
Vector3[] normals = new Vector3[mesh.vertexCount];
Dictionary<Vector3, List<int>> vertexMap = new Dictionary<Vector3, List<int>>();
for (int i = 0; i < mesh.vertexCount; i++)
{
Vector3 vertex = mesh.vertices[i];
if (!vertexMap.ContainsKey(vertex))
{
vertexMap[vertex] = new List<int>();
}
vertexMap[vertex].Add(i);
}
foreach (var kvp in vertexMap)
{
Vector3 smoothNormal = Vector3.zero;
foreach (int index in kvp.Value)
{
smoothNormal += mesh.normals[index];
}
smoothNormal.Normalize();
foreach (int index in kvp.Value)
{
normals[index] = smoothNormal;
}
}
return normals;
}
/// <summary>
/// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading.
/// </summary>
@@ -44,40 +82,74 @@ namespace SplashEdit.RuntimeCode
/// <param name="textureHeight">Height of the texture (default is 256).</param>
/// <param name="transform">Optional transform to convert vertices to world space.</param>
/// <returns>A new PSXMesh containing the converted triangles.</returns>
public static PSXMesh CreateFromUnityMesh(Mesh mesh, int textureWidth = 256, int textureHeight = 256, Transform transform = null)
public static PSXMesh CreateFromUnityRenderer(Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
PSXMesh psxMesh = new PSXMesh { Triangles = new List<Tri>() };
// Get materials and mesh.
Material[] materials = renderer.sharedMaterials;
Mesh mesh = renderer.GetComponent<MeshFilter>().sharedMesh;
// Iterate over each submesh.
for (int submeshIndex = 0; submeshIndex < materials.Length; submeshIndex++)
{
// Get the triangles for this submesh.
int[] submeshTriangles = mesh.GetTriangles(submeshIndex);
// Get the material for this submesh.
Material material = materials[submeshIndex];
// Get the corresponding texture for this material (assume mainTexture).
Texture2D texture = material.mainTexture as Texture2D;
PSXTexture2D psxTexture = null;
if (texture != null)
{
// Find the corresponding PSX texture based on the Unity texture.
psxTexture = textures.FirstOrDefault(t => t.OriginalTexture == texture);
}
if (psxTexture == null)
{
continue;
}
// Get mesh data arrays.
mesh.RecalculateNormals();
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals;
Vector3[] smoothNormals = RecalculateSmoothNormals(mesh);
Vector2[] uv = mesh.uv;
int[] indices = mesh.triangles;
// Determine the primary light's direction and color for shading.
Light mainLight = RenderSettings.sun;
Vector3 lightDir = mainLight ? mainLight.transform.forward : Vector3.down; // Fixed: Removed negation.
Color lightColor = mainLight ? mainLight.color * mainLight.intensity : Color.white;
// Iterate over each triangle (group of 3 indices).
for (int i = 0; i < indices.Length; i += 3)
PSXVertex convertData(int index)
{
int vid0 = indices[i];
int vid1 = indices[i + 1];
int vid2 = indices[i + 2];
// Scale the vertex based on world scale.
Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale);
// Transform the vertex to world space.
Vector3 wv = transform.TransformPoint(vertices[index]);
// Transform the normals to world space.
Vector3 wn = transform.TransformDirection(smoothNormals[index]).normalized;
// Compute lighting for each vertex (this can be a custom function).
Color c = PSXLightingBaker.ComputeLighting(wv, wn);
// Convert vertex to PSX format, including fixed-point conversion and shading.
return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index], psxTexture?.Width, psxTexture?.Height, c);
}
// Iterate through the triangles of the submesh.
for (int i = 0; i < submeshTriangles.Length; i += 3)
{
int vid0 = submeshTriangles[i];
int vid1 = submeshTriangles[i + 1];
int vid2 = submeshTriangles[i + 2];
// Transform vertices to world space if a transform is provided.
Vector3 v0 = transform ? transform.TransformPoint(vertices[vid0]) : vertices[vid0];
Vector3 v1 = transform ? transform.TransformPoint(vertices[vid1]) : vertices[vid1];
Vector3 v2 = transform ? transform.TransformPoint(vertices[vid2]) : vertices[vid2];
Vector3 faceNormal = Vector3.Cross(vertices[vid1] - vertices[vid0], vertices[vid2] - vertices[vid0]).normalized;
// Convert vertices to PSX format including fixed-point conversion and shading.
PSXVertex psxV0 = ConvertToPSXVertex(v0, normals[vid0], uv[vid0], lightDir, lightColor, textureWidth, textureHeight);
PSXVertex psxV1 = ConvertToPSXVertex(v1, normals[vid1], uv[vid1], lightDir, lightColor, textureWidth, textureHeight);
PSXVertex psxV2 = ConvertToPSXVertex(v2, normals[vid2], uv[vid2], lightDir, lightColor, textureWidth, textureHeight);
if (Vector3.Dot(faceNormal, normals[vid0]) < 0)
{
(vid1, vid2) = (vid2, vid1);
}
// Add the constructed triangle to the mesh.
psxMesh.Triangles.Add(new Tri { v0 = psxV0, v1 = psxV1, v2 = psxV2 });
psxMesh.Triangles.Add(new Tri { v0 = convertData(vid0), v1 = convertData(vid0), v2 = convertData(vid0), Texture = psxTexture });
}
}
return psxMesh;
@@ -94,36 +166,30 @@ namespace SplashEdit.RuntimeCode
/// <param name="textureWidth">Width of the texture for UV scaling.</param>
/// <param name="textureHeight">Height of the texture for UV scaling.</param>
/// <returns>A PSXVertex with converted coordinates, normals, UVs, and color.</returns>
private static PSXVertex ConvertToPSXVertex(Vector3 vertex, Vector3 normal, Vector2 uv, Vector3 lightDir, Color lightColor, int textureWidth, int textureHeight)
private static PSXVertex ConvertToPSXVertex(Vector3 vertex, float GTEScaling, Vector3 normal, Vector2 uv, int? textureWidth, int? textureHeight, Color color)
{
// Calculate light intensity based on the angle between the normalized normal and light direction.
float lightIntensity = Mathf.Clamp01(Vector3.Dot(normal.normalized, lightDir));
// Remap the intensity to a specific range for a softer shading effect.
lightIntensity = Mathf.Lerp(0.4f, 0.7f, lightIntensity);
// Compute the final shaded color by multiplying the light color by the intensity.
Color shadedColor = lightColor * lightIntensity;
int width = textureWidth ?? 0;
int height = textureHeight ?? 0;
PSXVertex psxVertex = new PSXVertex
{
// Convert position to fixed-point, clamping values to a defined range.
vx = (short)(Mathf.Clamp(vertex.x, -4f, 3.999f) * 4096),
vy = (short)(Mathf.Clamp(-vertex.y, -4f, 3.999f) * 4096),
vz = (short)(Mathf.Clamp(vertex.z, -4f, 3.999f) * 4096),
vx = PSXTrig.ConvertCoordinateToPSX(vertex.x, GTEScaling),
vy = PSXTrig.ConvertCoordinateToPSX(-vertex.y, GTEScaling),
vz = PSXTrig.ConvertCoordinateToPSX(vertex.z, GTEScaling),
// Convert normals to fixed-point.
nx = (short)(Mathf.Clamp(normal.x, -4f, 3.999f) * 4096),
ny = (short)(Mathf.Clamp(-normal.y, -4f, 3.999f) * 4096),
nz = (short)(Mathf.Clamp(normal.z, -4f, 3.999f) * 4096),
nx = PSXTrig.ConvertCoordinateToPSX(normal.x),
ny = PSXTrig.ConvertCoordinateToPSX(-normal.y),
nz = PSXTrig.ConvertCoordinateToPSX(normal.z),
// Map UV coordinates to a byte range after scaling based on texture dimensions.
u = (byte)(Mathf.Clamp((uv.x * (textureWidth - 1)), 0, 255)),
v = (byte)(Mathf.Clamp(((1.0f - uv.y) * (textureHeight - 1)), 0, 255)),
u = (byte)Mathf.Clamp(uv.x * (width - 1), 0, 255),
v = (byte)Mathf.Clamp((1.0f - uv.y) * (height - 1), 0, 255),
// Convert the computed color to a byte range.
r = (byte)(Mathf.Clamp(shadedColor.r * 255, 0, 255)),
g = (byte)(Mathf.Clamp(shadedColor.g * 255, 0, 255)),
b = (byte)(Mathf.Clamp(shadedColor.b * 255, 0, 255))
// Apply lighting to the colors.
r = Utils.Clamp0255(color.r),
g = Utils.Clamp0255(color.g),
b = Utils.Clamp0255(color.b),
};
return psxVertex;

View File

@@ -1,50 +1,116 @@
using System.Collections.Generic;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
[RequireComponent(typeof(Renderer))]
public class PSXObjectExporter : MonoBehaviour
{
public PSXBPP BitDepth = PSXBPP.TEX_8BIT; // Defines the bit depth of the texture (e.g., 4BPP, 8BPP)
public bool MeshIsStatic = true; // Determines if the mesh is static, affecting how it's processed. Non-static meshes don't export correctly as of now.
[HideInInspector]
public PSXTexture2D Texture; // Stores the converted PlayStation-style texture
public List<PSXTexture2D> Textures { get; set; } = new List<PSXTexture2D>(); // Stores the converted PlayStation-style texture
public PSXMesh Mesh { get; set; } // Stores the converted PlayStation-style mesh
[SerializeField] private bool PreviewNormals = false;
[SerializeField] private float normalPreviewLength = 0.5f; // Length of the normal lines
private void OnDrawGizmos()
{
if (PreviewNormals)
{
MeshFilter filter = GetComponent<MeshFilter>();
if (filter != null)
{
Mesh mesh = filter.sharedMesh;
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals;
Gizmos.color = Color.green; // Normal color
for (int i = 0; i < vertices.Length; i++)
{
Vector3 worldVertex = transform.TransformPoint(vertices[i]); // Convert to world space
Vector3 worldNormal = transform.TransformDirection(normals[i]); // Transform normal to world space
Gizmos.DrawLine(worldVertex, worldVertex + worldNormal * normalPreviewLength);
}
}
}
}
[HideInInspector]
public PSXMesh Mesh; // Stores the converted PlayStation-style mesh
/// <summary>
/// Converts the object's material texture into a PlayStation-compatible texture.
/// </summary>
public void CreatePSXTexture2D()
///
public void CreatePSXTextures2D()
{
Renderer renderer = GetComponent<Renderer>();
if (renderer != null && renderer.sharedMaterial != null && renderer.sharedMaterial.mainTexture is Texture2D texture)
Textures.Clear();
if (renderer != null)
{
Texture = PSXTexture2D.CreateFromTexture2D(texture, BitDepth);
Texture.OriginalTexture = texture; // Stores reference to the original texture
}
}
Material[] materials = renderer.sharedMaterials;
/// <summary>
/// Converts the object's mesh into a PlayStation-compatible mesh.
/// </summary>
public void CreatePSXMesh()
foreach (Material mat in materials)
{
MeshFilter meshFilter = gameObject.GetComponent<MeshFilter>();
if (meshFilter != null)
if (mat != null && mat.mainTexture != null)
{
if (MeshIsStatic)
Texture mainTexture = mat.mainTexture;
Texture2D tex2D = null;
// Check if it's already a Texture2D
if (mainTexture is Texture2D existingTex2D)
{
// Static meshes take object transformation into account
Mesh = PSXMesh.CreateFromUnityMesh(meshFilter.sharedMesh, Texture.Width, Texture.Height, transform);
tex2D = existingTex2D;
}
else
{
// Dynamic meshes do not consider object transformation
Mesh = PSXMesh.CreateFromUnityMesh(meshFilter.sharedMesh, Texture.Width, Texture.Height);
// If not a Texture2D, try to convert
tex2D = ConvertToTexture2D(mainTexture);
}
if (tex2D != null)
{
PSXTexture2D tex = PSXTexture2D.CreateFromTexture2D(tex2D, BitDepth);
tex.OriginalTexture = tex2D; // Store reference to the original texture
Textures.Add(tex);
}
}
}
}
}
private Texture2D ConvertToTexture2D(Texture texture)
{
// Create a new Texture2D with the same dimensions and format
Texture2D texture2D = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false);
// Read the texture pixels
RenderTexture currentActiveRT = RenderTexture.active;
RenderTexture.active = texture as RenderTexture;
texture2D.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
texture2D.Apply();
RenderTexture.active = currentActiveRT;
return texture2D;
}
/// <summary>
/// Converts the object's mesh into a PlayStation-compatible mesh.
/// </summary>
public void CreatePSXMesh(float GTEScaling)
{
Renderer renderer = GetComponent<Renderer>();
if (renderer != null)
{
Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures);
}
}
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Overlays;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace SplashEdit.RuntimeCode
{
@@ -11,27 +11,28 @@ namespace SplashEdit.RuntimeCode
[ExecuteInEditMode]
public class PSXSceneExporter : MonoBehaviour
{
public float GTEScaling = 100.0f;
private PSXObjectExporter[] _exporters;
private TextureAtlas[] _atlases;
private PSXData _psxData;
private readonly string _psxDataPath = "Assets/PSXData.asset";
private Vector2 selectedResolution;
private bool dualBuffering;
private bool verticalLayout;
private List<ProhibitedArea> prohibitedAreas;
private VRAMPixel[,] vramPixels;
public void Export()
{
LoadData();
_psxData = DataStorage.LoadData(out selectedResolution, out dualBuffering, out verticalLayout, out prohibitedAreas);
_exporters = FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
foreach (PSXObjectExporter exp in _exporters)
{
exp.CreatePSXTexture2D();
exp.CreatePSXMesh();
exp.CreatePSXTextures2D();
exp.CreatePSXMesh(GTEScaling);
}
PackTextures();
ExportFile();
@@ -39,10 +40,7 @@ namespace SplashEdit.RuntimeCode
void PackTextures()
{
Rect buffer1 = new Rect(0, 0, selectedResolution.x, selectedResolution.y);
Rect buffer2 = verticalLayout ? new Rect(0, 256, selectedResolution.x, selectedResolution.y)
: new Rect(selectedResolution.x, 0, selectedResolution.x, selectedResolution.y);
(Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout);
List<Rect> framebuffers = new List<Rect> { buffer1 };
if (dualBuffering)
@@ -53,106 +51,253 @@ namespace SplashEdit.RuntimeCode
VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas);
var packed = tp.PackTexturesIntoVRAM(_exporters);
_exporters = packed.processedObjects;
vramPixels = packed._vramPixels;
_atlases = packed.atlases;
}
void ExportFile()
{
string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin");
int totalFaces = 0;
// Lists for mesh data offsets.
List<long> offsetPlaceholderPositions = new List<long>();
List<long> meshDataOffsets = new List<long>();
// Lists for atlas data offsets.
List<long> atlasOffsetPlaceholderPositions = new List<long>();
List<long> atlasDataOffsets = new List<long>();
int clutCount = 0;
// Cluts
foreach (TextureAtlas atlas in _atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
{
clutCount++;
}
}
}
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)))
{
// VramPixels are always 1MB
for (int y = 0; y < vramPixels.GetLength(1); y++)
{
for (int x = 0; x < vramPixels.GetLength(0); x++)
{
writer.Write(vramPixels[x, y].Pack());
}
}
// Header
writer.Write('S');
writer.Write('P');
writer.Write((ushort)1);
writer.Write((ushort)_exporters.Length);
writer.Write((ushort)_atlases.Length);
writer.Write((ushort)clutCount);
writer.Write((ushort)0);
// Start of Metadata section
// GameObject section (exporters)
foreach (PSXObjectExporter exporter in _exporters)
{
// Write object's transform
writer.Write((int)PSXTrig.ConvertCoordinateToPSX(exporter.transform.localToWorldMatrix.GetPosition().x, GTEScaling));
writer.Write((int)PSXTrig.ConvertCoordinateToPSX(-exporter.transform.localToWorldMatrix.GetPosition().y, GTEScaling));
writer.Write((int)PSXTrig.ConvertCoordinateToPSX(exporter.transform.localToWorldMatrix.GetPosition().z, GTEScaling));
int[,] rotationMatrix = PSXTrig.ConvertRotationToPSXMatrix(exporter.transform.rotation);
int expander = 16 / ((int)exporter.Texture.BitDepth);
writer.Write((int)rotationMatrix[0, 0]);
writer.Write((int)rotationMatrix[0, 1]);
writer.Write((int)rotationMatrix[0, 2]);
writer.Write((int)rotationMatrix[1, 0]);
writer.Write((int)rotationMatrix[1, 1]);
writer.Write((int)rotationMatrix[1, 2]);
writer.Write((int)rotationMatrix[2, 0]);
writer.Write((int)rotationMatrix[2, 1]);
writer.Write((int)rotationMatrix[2, 2]);
// Write placeholder for mesh data offset and record its position.
offsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for mesh data offset.
writer.Write((int)exporter.Mesh.Triangles.Count);
}
// Atlas metadata section
foreach (TextureAtlas atlas in _atlases)
{
// Write placeholder for texture atlas raw data offset.
atlasOffsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for atlas data offset.
writer.Write((ushort)atlas.Width);
writer.Write((ushort)TextureAtlas.Height);
writer.Write((ushort)atlas.PositionX);
writer.Write((ushort)atlas.PositionY);
}
// Cluts
foreach (TextureAtlas atlas in _atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
{
foreach (VRAMPixel clutPixel in texture.ColorPalette)
{
writer.Write((ushort)clutPixel.Pack());
}
for (int i = texture.ColorPalette.Count; i < 256; i++)
{
writer.Write((ushort)0);
}
writer.Write((ushort)texture.ClutPackingX);
writer.Write((ushort)texture.ClutPackingY);
writer.Write((ushort)texture.ColorPalette.Count);
writer.Write((ushort)0);
}
}
}
// Start of data section
// Mesh data section: Write mesh data for each exporter.
foreach (PSXObjectExporter exporter in _exporters)
{
AlignToFourBytes(writer);
// Record the current offset for this exporter's mesh data.
long meshDataOffset = writer.BaseStream.Position;
meshDataOffsets.Add(meshDataOffset);
totalFaces += exporter.Mesh.Triangles.Count;
writer.Write((ushort)exporter.Mesh.Triangles.Count);
writer.Write((byte)exporter.Texture.BitDepth);
writer.Write((byte)exporter.Texture.TexpageX);
writer.Write((byte)exporter.Texture.TexpageY);
writer.Write((ushort)exporter.Texture.ClutPackingX);
writer.Write((ushort)exporter.Texture.ClutPackingY);
writer.Write((byte)0);
void writeVertexPosition(PSXVertex v)
{
writer.Write((short)v.vx);
writer.Write((short)v.vy);
writer.Write((short)v.vz);
}
void writeVertexNormals(PSXVertex v)
{
writer.Write((short)v.nx);
writer.Write((short)v.ny);
writer.Write((short)v.nz);
}
void writeVertexColor(PSXVertex v)
{
writer.Write((byte)v.r);
writer.Write((byte)v.g);
writer.Write((byte)v.b);
writer.Write((byte)0); // padding
}
void writeVertexUV(PSXVertex v, PSXTexture2D t ,int expander)
{
writer.Write((byte)(v.u + t.PackingX * expander));
writer.Write((byte)(v.v + t.PackingY));
}
void foreachVertexDo(Tri tri, Action<PSXVertex> action)
{
for (int i = 0; i < tri.Vertexes.Length; i++)
{
action(tri.Vertexes[i]);
}
}
foreach (Tri tri in exporter.Mesh.Triangles)
{
writer.Write((short)tri.v0.vx);
writer.Write((short)tri.v0.vy);
writer.Write((short)tri.v0.vz);
writer.Write((short)tri.v0.nx);
writer.Write((short)tri.v0.ny);
writer.Write((short)tri.v0.nz);
writer.Write((byte)(tri.v0.u + exporter.Texture.PackingX * expander));
writer.Write((byte)(tri.v0.v + exporter.Texture.PackingY));
writer.Write((byte) tri.v0.r);
writer.Write((byte) tri.v0.g);
writer.Write((byte) tri.v0.b);
for(int i = 0; i < 7; i ++) writer.Write((byte) 0);
int expander = 16 / ((int)tri.Texture.BitDepth);
// Write vertices coordinates
foreachVertexDo(tri, (v) => writeVertexPosition(v));
writer.Write((short)tri.v1.vx);
writer.Write((short)tri.v1.vy);
writer.Write((short)tri.v1.vz);
writer.Write((short)tri.v1.nx);
writer.Write((short)tri.v1.ny);
writer.Write((short)tri.v1.nz);
writer.Write((byte)(tri.v1.u + exporter.Texture.PackingX * expander));
writer.Write((byte)(tri.v1.v + exporter.Texture.PackingY));
writer.Write((byte) tri.v1.r);
writer.Write((byte) tri.v1.g);
writer.Write((byte) tri.v1.b);
for(int i = 0; i < 7; i ++) writer.Write((byte) 0);
// Write vertex normals for v0 only
writeVertexNormals(tri.v0);
writer.Write((short)tri.v2.vx);
writer.Write((short)tri.v2.vy);
writer.Write((short)tri.v2.vz);
writer.Write((short)tri.v2.nx);
writer.Write((short)tri.v2.ny);
writer.Write((short)tri.v2.nz);
writer.Write((byte)(tri.v2.u + exporter.Texture.PackingX * expander));
writer.Write((byte)(tri.v2.v + exporter.Texture.PackingY));
writer.Write((byte) tri.v2.r);
writer.Write((byte) tri.v2.g);
writer.Write((byte) tri.v2.b);
for(int i = 0; i < 7; i ++) writer.Write((byte) 0);
// Write vertex colors with padding
foreachVertexDo(tri, (v) => writeVertexColor(v));
// Write UVs for each vertex, adjusting for texture packing
foreachVertexDo(tri, (v) => writeVertexUV(v, tri.Texture, expander));
writer.Write((ushort)0); // padding
TPageAttr tpage = new TPageAttr();
tpage.SetPageX(tri.Texture.TexpageX);
tpage.SetPageY(tri.Texture.TexpageY);
tpage.Set(tri.Texture.BitDepth.ToColorMode());
tpage.SetDithering(true);
writer.Write((ushort)tpage.info);
writer.Write((ushort)tri.Texture.ClutPackingX);
writer.Write((ushort)tri.Texture.ClutPackingY);
writer.Write((ushort)0);
}
}
// Atlas data section: Write raw texture data for each atlas.
foreach (TextureAtlas atlas in _atlases)
{
AlignToFourBytes(writer);
// Record the current offset for this atlas's data.
long atlasDataOffset = writer.BaseStream.Position;
atlasDataOffsets.Add(atlasDataOffset);
// Write the atlas's raw texture data.
for (int y = 0; y < atlas.vramPixels.GetLength(1); y++)
{
for (int x = 0; x < atlas.vramPixels.GetLength(0); x++)
{
writer.Write(atlas.vramPixels[x, y].Pack());
}
}
}
// Backfill the mesh data offsets into the metadata section.
if (offsetPlaceholderPositions.Count == meshDataOffsets.Count)
{
for (int i = 0; i < offsetPlaceholderPositions.Count; i++)
{
writer.Seek((int)offsetPlaceholderPositions[i], SeekOrigin.Begin);
writer.Write((int)meshDataOffsets[i]);
}
}
else
{
Debug.LogError("Mismatch between metadata mesh offset placeholders and mesh data blocks!");
}
// Backfill the atlas data offsets into the metadata section.
if (atlasOffsetPlaceholderPositions.Count == atlasDataOffsets.Count)
{
for (int i = 0; i < atlasOffsetPlaceholderPositions.Count; i++)
{
writer.Seek((int)atlasOffsetPlaceholderPositions[i], SeekOrigin.Begin);
writer.Write((int)atlasDataOffsets[i]);
}
}
else
{
Debug.LogError("Mismatch between atlas offset placeholders and atlas data blocks!");
}
}
Debug.Log(totalFaces);
}
public void LoadData()
void AlignToFourBytes(BinaryWriter writer)
{
_psxData = AssetDatabase.LoadAssetAtPath<PSXData>(_psxDataPath);
if (!_psxData)
{
_psxData = ScriptableObject.CreateInstance<PSXData>();
AssetDatabase.CreateAsset(_psxData, _psxDataPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
selectedResolution = _psxData.OutputResolution;
dualBuffering = _psxData.DualBuffering;
verticalLayout = _psxData.VerticalBuffering;
prohibitedAreas = _psxData.ProhibitedAreas;
long position = writer.BaseStream.Position;
int padding = (int)(4 - (position % 4)) % 4; // Compute needed padding
writer.Write(new byte[padding]); // Write zero padding
}
void OnDrawGizmos()
{
Gizmos.DrawIcon(transform.position, "Packages/net.psxsplash.splashedit/Icons/PSXSceneExporter.png", true);
}
Vector3 sceneOrigin = new Vector3(0, 0, 0);
Vector3 cubeSize = new Vector3(8.0f * GTEScaling, 8.0f * GTEScaling, 8.0f * GTEScaling);
Gizmos.color = Color.red;
Gizmos.DrawWireCube(sceneOrigin, cubeSize);
}
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using UnityEngine;
@@ -159,7 +160,7 @@ namespace SplashEdit.RuntimeCode
return psxTex;
}
psxTex._maxColors = (int)Mathf.Pow((int)bitDepth, 2);
psxTex._maxColors = (int)Mathf.Pow(2, (int)bitDepth);
TextureQuantizer.QuantizedResult result = TextureQuantizer.Quantize(inputTexture, psxTex._maxColors);
@@ -230,7 +231,7 @@ namespace SplashEdit.RuntimeCode
{
for (int x = 0; x < Height; x++)
{
tex.SetPixel(x, y, ImageData[x, y].GetUnityColor());
tex.SetPixel(x, Height - 1 - y, ImageData[x, y].GetUnityColor());
}
}
@@ -275,5 +276,15 @@ namespace SplashEdit.RuntimeCode
return vramTexture;
}
/// <summary>
/// Check if we need to update stored texture
/// </summary>
/// <param name="bitDepth">new settings for color bit depth</param>
/// <param name="texture">new texture</param>
/// <returns>return true if sored texture is different from a new one</returns>
internal bool NeedUpdate(PSXBPP bitDepth, Texture2D texture)
{
return BitDepth != bitDepth || texture.GetInstanceID() != texture.GetInstanceID();
}
}
}

View File

@@ -15,6 +15,7 @@ namespace SplashEdit.RuntimeCode
public int PositionY; // Y position of the atlas in VRAM.
public int Width; // Width of the atlas.
public const int Height = 256; // Fixed height for all atlases.
public VRAMPixel[,] vramPixels;
public List<PSXTexture2D> ContainedTextures = new List<PSXTexture2D>(); // Textures packed in this atlas.
}
@@ -29,8 +30,8 @@ namespace SplashEdit.RuntimeCode
private List<TextureAtlas> _finalizedAtlases = new List<TextureAtlas>(); // Atlases that have been successfully placed.
private List<Rect> _allocatedCLUTs = new List<Rect>(); // Allocated regions for CLUTs.
private const int VRAM_WIDTH = 1024;
private const int VRAM_HEIGHT = 512;
public static readonly int VramWidth = 1024;
public static readonly int VramHeight = 512;
private VRAMPixel[,] _vramPixels; // Simulated VRAM pixel data.
@@ -53,22 +54,36 @@ namespace SplashEdit.RuntimeCode
_reservedAreas.Add(framebuffers[0]);
_reservedAreas.Add(framebuffers[1]);
_vramPixels = new VRAMPixel[VRAM_WIDTH, VRAM_HEIGHT];
_vramPixels = new VRAMPixel[VramWidth, VramHeight];
}
/// <summary>
/// Packs the textures from the provided PSXObjectExporter array into VRAM.
/// Each exporter now holds a list of textures.
/// Duplicates (textures with the same underlying OriginalTexture and BitDepth) across all exporters are merged.
/// Returns the processed objects and the final VRAM pixel array.
/// </summary>
/// <param name="objects">Array of PSXObjectExporter objects to process.</param>
/// <returns>Tuple containing processed objects and the VRAM pixel array.</returns>
public (PSXObjectExporter[] processedObjects, VRAMPixel[,] _vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects)
/// <returns>Tuple containing processed objects, texture atlases, and the VRAM pixel array.</returns>
public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects)
{
List<PSXTexture2D> uniqueTextures = new List<PSXTexture2D>();
// Group objects by texture bit depth (high to low).
var groupedObjects = objects.GroupBy(obj => obj.Texture.BitDepth).OrderByDescending(g => g.Key);
// Gather all textures from all exporters.
List<PSXTexture2D> allTextures = new List<PSXTexture2D>();
foreach (var obj in objects)
{
allTextures.AddRange(obj.Textures);
}
foreach (var group in groupedObjects)
// List to track unique textures.
List<PSXTexture2D> uniqueTextures = new List<PSXTexture2D>();
// Group textures by bit depth (highest first).
var texturesByBitDepth = allTextures
.GroupBy(tex => tex.BitDepth)
.OrderByDescending(g => g.Key);
// Process each group.
foreach (var group in texturesByBitDepth)
{
// Determine atlas width based on texture bit depth.
int atlasWidth = group.Key switch
@@ -79,33 +94,47 @@ namespace SplashEdit.RuntimeCode
_ => 256
};
// Create a new atlas for this group.
// Create an initial atlas for this group.
TextureAtlas atlas = new TextureAtlas { BitDepth = group.Key, Width = atlasWidth, PositionX = 0, PositionY = 0 };
_textureAtlases.Add(atlas);
// Process each texture in descending order of area (width * height).
foreach (var obj in group.OrderByDescending(obj => obj.Texture.QuantizedWidth * obj.Texture.Height))
// Process each texture in descending order of area.
foreach (var texture in group.OrderByDescending(tex => tex.QuantizedWidth * tex.Height))
{
// Remove duplicate textures
if (uniqueTextures.Any(tex => tex.OriginalTexture.GetInstanceID() == obj.Texture.OriginalTexture.GetInstanceID() && tex.BitDepth == obj.Texture.BitDepth))
if (uniqueTextures.Any(tex => tex.OriginalTexture.GetInstanceID() == texture.OriginalTexture.GetInstanceID() && tex.BitDepth == texture.BitDepth))
{
obj.Texture = uniqueTextures.First(tex => tex.OriginalTexture.GetInstanceID() == obj.Texture.OriginalTexture.GetInstanceID());
// Skip packing this texture it will be replaced later.
continue;
}
// Try to place the texture in the current atlas.
if (!TryPlaceTextureInAtlas(atlas, obj.Texture))
if (!TryPlaceTextureInAtlas(atlas, texture))
{
// If failed, create a new atlas and try again.
// If failed, create a new atlas for this bit depth group and try again.
atlas = new TextureAtlas { BitDepth = group.Key, Width = atlasWidth, PositionX = 0, PositionY = 0 };
_textureAtlases.Add(atlas);
if (!TryPlaceTextureInAtlas(atlas, obj.Texture))
if (!TryPlaceTextureInAtlas(atlas, texture))
{
Debug.LogError($"Failed to pack texture {obj.Texture}. It might not fit.");
break;
Debug.LogError($"Failed to pack texture {texture}. It might not fit.");
continue;
}
}
uniqueTextures.Add(obj.Texture);
uniqueTextures.Add(texture);
}
}
// Now update every exporter so that duplicate textures reference the unique instance.
foreach (var obj in objects)
{
for (int i = 0; i < obj.Textures.Count; i++)
{
var unique = uniqueTextures.FirstOrDefault(tex => tex.OriginalTexture.GetInstanceID() == obj.Textures[i].OriginalTexture.GetInstanceID() &&
tex.BitDepth == obj.Textures[i].BitDepth);
if (unique != null)
{
obj.Textures[i] = unique;
}
}
}
@@ -113,10 +142,9 @@ namespace SplashEdit.RuntimeCode
ArrangeAtlasesInVRAM();
// Allocate color lookup tables (CLUTs) for textures that use palettes.
AllocateCLUTs();
// Build the final VRAM pixel array from placed textures and CLUTs.
BuildVram();
return (objects, _vramPixels);
return (objects, _finalizedAtlases.ToArray(), _vramPixels);
}
/// <summary>
@@ -161,10 +189,10 @@ namespace SplashEdit.RuntimeCode
{
bool placed = false;
// Try every possible row (stepping by atlas height).
for (int y = 0; y <= VRAM_HEIGHT - TextureAtlas.Height; y += 256)
for (int y = 0; y <= VramHeight - TextureAtlas.Height; y += 256)
{
// Try every possible column (stepping by 64 pixels).
for (int x = 0; x <= VRAM_WIDTH - atlas.Width; x += 64)
for (int x = 0; x <= VramWidth - atlas.Width; x += 64)
{
// Only consider atlases that haven't been placed yet.
if (atlas.PositionX == 0 && atlas.PositionY == 0)
@@ -218,15 +246,15 @@ namespace SplashEdit.RuntimeCode
bool placed = false;
// Iterate over possible CLUT positions in VRAM.
for (ushort x = 0; x < VRAM_WIDTH; x += 16)
for (ushort x = 0; x < VramWidth; x += 16)
{
for (ushort y = 0; y <= VRAM_HEIGHT; y++)
for (ushort y = 0; y <= VramHeight; y++)
{
var candidate = new Rect(x, y, clutWidth, clutHeight);
if (IsPlacementValid(candidate))
{
_allocatedCLUTs.Add(candidate);
texture.ClutPackingX = x;
texture.ClutPackingX = (ushort)(x / 16);
texture.ClutPackingY = y;
placed = true;
break;
@@ -249,6 +277,8 @@ namespace SplashEdit.RuntimeCode
{
foreach (TextureAtlas atlas in _finalizedAtlases)
{
atlas.vramPixels = new VRAMPixel[atlas.Width, TextureAtlas.Height];
foreach (PSXTexture2D texture in atlas.ContainedTextures)
{
// Copy texture image data into VRAM using atlas and texture packing offsets.
@@ -256,6 +286,7 @@ namespace SplashEdit.RuntimeCode
{
for (int x = 0; x < texture.QuantizedWidth; x++)
{
atlas.vramPixels[x + texture.PackingX, y + texture.PackingY] = texture.ImageData[x, y];
_vramPixels[x + atlas.PositionX + texture.PackingX, y + atlas.PositionY + texture.PackingY] = texture.ImageData[x, y];
}
}
@@ -281,8 +312,8 @@ namespace SplashEdit.RuntimeCode
private bool IsPlacementValid(Rect rect)
{
// Ensure the rectangle fits within VRAM boundaries.
if (rect.x + rect.width > VRAM_WIDTH) return false;
if (rect.y + rect.height > VRAM_HEIGHT) return false;
if (rect.x + rect.width > VramWidth) return false;
if (rect.y + rect.height > VramHeight) return false;
// Check for overlaps with existing atlases.
bool overlapsAtlas = _finalizedAtlases.Any(a => new Rect(a.PositionX, a.PositionY, a.Width, TextureAtlas.Height).Overlaps(rect));
@@ -293,20 +324,5 @@ namespace SplashEdit.RuntimeCode
return !(overlapsAtlas || overlapsReserved || overlapsCLUT);
}
/// <summary>
/// Calculates the texpage index from given VRAM coordinates.
/// This helper method divides VRAM into columns and rows.
/// </summary>
/// <param name="x">The X coordinate in VRAM.</param>
/// <param name="y">The Y coordinate in VRAM.</param>
/// <returns>The calculated texpage index.</returns>
private int CalculateTexpage(int x, int y)
{
int columns = 16;
int colIndex = x / 64;
int rowIndex = y / 256;
return (rowIndex * columns) + colIndex;
}
}
}

View File

@@ -1,7 +1,61 @@
using System.Runtime.InteropServices;
using UnityEditor;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
public static class DataStorage
{
private static readonly string psxDataPath = "Assets/PSXData.asset";
/// <summary>
/// Loads stored PSX data from the asset.
/// </summary>
public static PSXData LoadData(out Vector2 selectedResolution, out bool dualBuffering, out bool verticalLayout, out List<ProhibitedArea> prohibitedAreas)
{
var _psxData = AssetDatabase.LoadAssetAtPath<PSXData>(psxDataPath);
if (!_psxData)
{
_psxData = ScriptableObject.CreateInstance<PSXData>();
AssetDatabase.CreateAsset(_psxData, psxDataPath);
AssetDatabase.SaveAssets();
}
selectedResolution = _psxData.OutputResolution;
dualBuffering = _psxData.DualBuffering;
verticalLayout = _psxData.VerticalBuffering;
prohibitedAreas = _psxData.ProhibitedAreas;
return _psxData;
}
public static PSXData LoadData()
{
PSXData psxData = AssetDatabase.LoadAssetAtPath<PSXData>(psxDataPath);
if (!psxData)
{
psxData = ScriptableObject.CreateInstance<PSXData>();
AssetDatabase.CreateAsset(psxData, psxDataPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
return psxData;
}
public static void StoreData(PSXData psxData)
{
if (psxData != null)
{
EditorUtility.SetDirty(psxData);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
}
}
/// <summary>
/// Represents a prohibited area in PlayStation 2D VRAM where textures should not be packed.
/// This class provides conversion methods to and from Unity's Rect structure.
@@ -41,4 +95,244 @@ namespace SplashEdit.RuntimeCode
return new Rect(X, Y, Width, Height);
}
}
/// <summary>
/// A utility class containing methods for converting Unity-specific data formats to PSX-compatible formats.
/// This includes converting coordinates and rotations to PSX's 3.12 fixed-point format.
/// </summary>
public static class PSXTrig
{
/// <summary>
/// Converts a floating-point coordinate to a PSX-compatible 3.12 fixed-point format.
/// The value is clamped to the range [-4, 3.999] and scaled by the provided GTEScaling factor.
/// </summary>
/// <param name="value">The coordinate value to convert.</param>
/// <param name="GTEScaling">A scaling factor for the value (default is 1.0f).</param>
/// <returns>The converted coordinate in 3.12 fixed-point format.</returns>
public static short ConvertCoordinateToPSX(float value, float GTEScaling = 1.0f)
{
return (short)(Mathf.Clamp(value / GTEScaling, -4f, 3.999f) * 4096);
}
/// <summary>
/// Converts a quaternion rotation to a PSX-compatible 3x3 rotation matrix.
/// The matrix is adjusted for the difference in the Y-axis orientation between Unity (Y-up) and PSX (Y-down).
/// Each matrix element is converted to a 3.12 fixed-point format.
/// </summary>
/// <param name="rotation">The quaternion representing the rotation to convert.</param>
/// <returns>A 3x3 matrix representing the PSX-compatible rotation.</returns>
public static int[,] ConvertRotationToPSXMatrix(Quaternion rotation)
{
// Standard quaternion-to-matrix conversion.
float x = rotation.x, y = rotation.y, z = rotation.z, w = rotation.w;
float m00 = 1f - 2f * (y * y + z * z);
float m01 = 2f * (x * y - z * w);
float m02 = 2f * (x * z + y * w);
float m10 = 2f * (x * y + z * w);
float m11 = 1f - 2f * (x * x + z * z);
float m12 = 2f * (y * z - x * w);
float m20 = 2f * (x * z - y * w);
float m21 = 2f * (y * z + x * w);
float m22 = 1f - 2f * (x * x + y * y);
// Apply Y-axis flip to match the PSX's Y-down convention.
// This replicates the behavior of:
// { m00, -m01, m02 },
// { -m10, m11, -m12 },
// { m20, -m21, m22 }
float[,] fixedMatrix = new float[3, 3]
{
{ m00, -m01, m02 },
{ -m10, m11, -m12 },
{ m20, -m21, m22 }
};
// Convert to PSX fixed-point format.
int[,] psxMatrix = new int[3, 3];
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
psxMatrix[i, j] = ConvertToFixed12(fixedMatrix[i, j]);
}
}
return psxMatrix;
}
/// <summary>
/// Converts a floating-point value to a 3.12 fixed-point format (PSX format).
/// The value is scaled by a factor of 4096 and clamped to the range of a signed 16-bit integer.
/// </summary>
/// <param name="value">The floating-point value to convert.</param>
/// <returns>The converted value in 3.12 fixed-point format as a 16-bit signed integer.</returns>
public static short ConvertToFixed12(float value)
{
int fixedValue = Mathf.RoundToInt(value * 4096.0f); // Scale to 3.12 format
return (short)Mathf.Clamp(fixedValue, -32768, 32767); // Clamp to signed 16-bit
}
}
/// <summary>
/// Represents the attributes of a texture page in the PSX graphics system.
/// Provides methods for setting various properties such as the page coordinates, transparency type, color mode, dithering, and display area.
/// </summary>
public struct TPageAttr
{
public ushort info; // Stores the packed attribute information as a 16-bit unsigned integer.
/// <summary>
/// Sets the X-coordinate of the texture page.
/// The lower 4 bits of the 'info' field are used to store the X value.
/// </summary>
/// <param name="x">The X-coordinate value (0 to 15).</param>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr SetPageX(byte x)
{
info &= 0xFFF0; // Clear lower 4 bits
x &= 0x0F; // Ensure only lower 4 bits are used
info |= x;
return this;
}
/// <summary>
/// Sets the Y-coordinate of the texture page.
/// The 4th bit of the 'info' field is used to store the Y value (0 or 1).
/// </summary>
/// <param name="y">The Y-coordinate value (0 or 1).</param>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr SetPageY(byte y)
{
info &= 0xFFEF; // Clear bit 4
y &= 0x01; // Ensure only lower 1 bit is used
info |= (ushort)(y << 4);
return this;
}
/// <summary>
/// Sets the transparency type of the texture page.
/// The transparency type is stored in bits 5 and 6 of the 'info' field.
/// </summary>
/// <param name="trans">The transparency type to set.</param>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr Set(SemiTrans trans)
{
info &= 0xFF9F; // Clear bits 5 and 6
uint t = (uint)trans;
info |= (ushort)(t << 5);
return this;
}
/// <summary>
/// Sets the color mode of the texture page.
/// The color mode is stored in bits 7 and 8 of the 'info' field.
/// </summary>
/// <param name="mode">The color mode to set (4-bit, 8-bit, or 16-bit).</param>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr Set(ColorMode mode)
{
info &= 0xFE7F; // Clear bits 7 and 8
uint m = (uint)mode;
info |= (ushort)(m << 7);
return this;
}
/// <summary>
/// Enables or disables dithering for the texture page.
/// Dithering is stored in bit 9 of the 'info' field.
/// </summary>
/// <param name="dithering">True to enable dithering, false to disable it.</param>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr SetDithering(bool dithering)
{
if (dithering)
info |= 0x0200; // Set bit 9 to enable dithering
else
info &= 0xFDFF; // Clear bit 9 to disable dithering
return this;
}
/// <summary>
/// Disables the display area for the texture page.
/// This will clear bit 10 of the 'info' field.
/// </summary>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr DisableDisplayArea()
{
info &= 0xFBFF; // Clear bit 10
return this;
}
/// <summary>
/// Enables the display area for the texture page.
/// This will set bit 10 of the 'info' field.
/// </summary>
/// <returns>The updated TPageAttr instance.</returns>
public TPageAttr EnableDisplayArea()
{
info |= 0x0400; // Set bit 10 to enable display area
return this;
}
/// <summary>
/// Returns a string representation of the TPageAttr instance, showing the 'info' value in hexadecimal.
/// </summary>
/// <returns>A string representing the 'info' value in hexadecimal format.</returns>
public override string ToString() => $"Info: 0x{info:X4}";
// Define the enums for SemiTrans and ColorMode (assuming their values)
/// <summary>
/// Defines the transparency types for a texture page.
/// </summary>
public enum SemiTrans : uint
{
None = 0,
Type1 = 1,
Type2 = 2,
Type3 = 3
}
/// <summary>
/// Defines the color modes for a texture page.
/// </summary>
public enum ColorMode : uint
{
Mode4Bit = 0,
Mode8Bit = 1,
Mode16Bit = 2
}
}
public static class Utils
{
public static (Rect, Rect) BufferForResolution(Vector2 selectedResolution, bool verticalLayout, Vector2 offset = default)
{
if (offset == default)
{
offset = Vector2.zero;
}
Rect buffer1 = new Rect(offset.x, offset.y, selectedResolution.x, selectedResolution.y);
Rect buffer2 = verticalLayout ? new Rect(offset.x, 256, selectedResolution.x, selectedResolution.y)
: new Rect(offset.x + selectedResolution.x, offset.y, selectedResolution.x, selectedResolution.y);
return (buffer1, buffer2);
}
public static TPageAttr.ColorMode ToColorMode(this PSXBPP depth)
{
return depth switch
{
PSXBPP.TEX_4BIT => TPageAttr.ColorMode.Mode4Bit,
PSXBPP.TEX_8BIT => TPageAttr.ColorMode.Mode8Bit,
PSXBPP.TEX_16BIT => TPageAttr.ColorMode.Mode16Bit,
_ => throw new System.NotImplementedException(),
};
}
public static byte Clamp0255(float v) => (byte)(Mathf.Clamp(v, 0, 255));
}
}

8
doc.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 311ff9868024b5078bd12a6c2252a4ef
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

93
doc/splashbundle.md Normal file
View File

@@ -0,0 +1,93 @@
# SPLASHPACK Binary File Format Specification
All numeric values are stored in littleendian format. All offsets are counted from the beginning of the file.
---
## 1. File Header (12 bytes)
| Offset | Size | Type | Description |
| ------ | ---- | ------ | ----------------------------------- |
| 0x00 | 2 | char | `'SP'` File magic |
| 0x02 | 2 | uint16 | Version number (currently **1**) |
| 0x04 | 2 | uint16 | Number of Exporter descriptors |
| 0x06 | 2 | uint16 | Number of Texture Atlas descriptors |
| 0x08 | 2 | uint16 | Number of CLUT descriptors |
| 0x0A | 2 | uint16 | Reserved (always 0) |
---
## 2. Metadata Section
The metadata section comprises three groups of descriptors.
### 2.1 Exporter Descriptors (56 bytes each)
Each exporter descriptor stores the transform and mesh metadata for one GameObject.
| Offset (per entry) | Size | Type | Description |
| ------------------ | ---- | ------ | --------------------------------- |
| 0x00 | 4 | int | X coordinate (Fixed-point) |
| 0x04 | 4 | int | Y coordinate (Fixed-point) |
| 0x08 | 4 | int | Z coordinate (Fixed-point) |
| 0x0C | 36 | int[9] | 3×3 Rotation matrix (Fixed-point) |
| 0x30 | 4 | int | **Mesh Data Offset Placeholder** |
| 0x34 | 4 | int | Triangle count in the mesh |
### 2.2 Texture Atlas Descriptors (12 bytes each)
Each texture atlas descriptor holds atlas layout data and a placeholder for the atlas raw data offset.
| Offset (per entry) | Size | Type | Description |
| ------------------ | ---- | ------ | -------------------------------------------------------- |
| 0x00 | 4 | int | **Atlas Data Offset Placeholder** |
| 0x04 | 2 | uint16 | Atlas width |
| 0x06 | 2 | uint16 | Atlas height (currently always 256, for future-proofing) |
| 0x08 | 2 | uint16 | Atlas position X relative to VRAM origin |
| 0x0A | 2 | uint16 | Atlas position Y relative to VRAM origin |
### 2.3 CLUT Descriptors (520 bytes each)
CLUTs are the only data which is stored in the Metadata section.
For each CLUT (Color Lookup Table) associated with an atlas texture that has a palette:
| Offset (per entry) | Size | Type | Description |
| ------------------ | ---- | ----------- | --------------------------------------------------------------------------------- |
| 0x00 | 512 | uint16[256] | Color palette entries (each entry is 2 bytes 16bpp). If unused, entries are zero. |
| 0x200 | 2 | uint16 | CLUT packing X coordinate - already in 16 pixel steps |
| 0x202 | 2 | uint16 | CLUT packing Y coordinate |
| 0x204 | 2 | uint16 | Palette count (number of valid palette entries) |
| 0x206 | 2 | uint16 | Reserved (always 0) |
---
## 3. Data Section
The data section contains the actual mesh and atlas raw data.
### 3.1 Mesh Data Blocks
For each exporter, a mesh data block is written at the offset specified in its descriptor. Each mesh block contains data for all triangles of the associated mesh.
#### **Triangle Data Layout (per triangle 52 bytes total):**
| Field | Size | Description |
| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Vertex Coordinates** | 3 vertices × 3 × 2 bytes = 18 bytes | For each vertex (v0, v1, v2): X, Y, Z coordinates (int16) |
| **Vertex Normal** | 3 × 2 bytes = 6 bytes | Normal vector for vertex v0 (int16: nx, ny, nz) |
| **Vertex Colors** | 3 vertices × (3 bytes color + 1 byte padding) = 12 bytes | For each vertex (v0, v1, v2): Red, Green, Blue (uint8) plus 1 byte padding |
| **Texture Coordinates (UVs)** | 3 vertices × 2 bytes = 6 bytes | For each vertex (v0, v1, v2): U and V coordinates (uint8), adjusted by texture packing factors |
| **UV Padding** | 2 bytes | Padding (uint16, set to zero) |
| **Texture Attributes** | 2 + 2 + 2 + 2 = 8 bytes | Contains: <br> • Texture page attributes (uint16 encoded from page X/Y, bit depth, dithering)<br> • Texture CLUT packing X (uint16)<br> • Texture CLUT packing Y (uint16)<br> • Reserved (uint16, set to zero) |
*Triangles are written sequentially. Prior to writing each mesh data block, the file pointer is aligned to a 4-byte boundary.*
### 3.2 Atlas Data Blocks
For each atlas, a raw texture data block is written at the offset specified in its descriptor. Before writing, the file pointer is aligned to a 4-byte boundary.
- **Raw Texture Data:**
The atlas data is written pixel by pixel as returned by the pixel packing function. The total size equals
*(Atlas Width × Atlas Height)* The data is prepared for a DMA transfer to the VRAM.
---

7
doc/splashbundle.md.meta Normal file
View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1944ac962a00b23c2a880b5134cdc7ab
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: