diff --git a/Editor/QuantizedPreviewWindow.cs b/Editor/QuantizedPreviewWindow.cs index 1a2038c..e5fb939 100644 --- a/Editor/QuantizedPreviewWindow.cs +++ b/Editor/QuantizedPreviewWindow.cs @@ -14,7 +14,7 @@ namespace SplashEdit.EditorCode private Texture2D vramTexture; // VRAM representation of the texture private List 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")] diff --git a/Editor/VramEditorWindow.cs b/Editor/VramEditorWindow.cs index 1cff27a..331f663 100644 --- a/Editor/VramEditorWindow.cs +++ b/Editor/VramEditorWindow.cs @@ -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 prohibitedAreas = new List(); 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 = @@ -33,14 +34,15 @@ namespace SplashEdit.EditorCode new Vector2(368, 240), new Vector2(368, 480), 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("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); } /// @@ -113,13 +115,11 @@ namespace SplashEdit.EditorCode PSXObjectExporter[] objects = FindObjectsByType(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 framebuffers = new List { 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"); - using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) + + if (path != string.Empty) { - for (int y = 0; y < VramHeight; y++) + using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) { - for (int x = 0; x < VramWidth; x++) + for (int y = 0; y < VramHeight; y++) { - writer.Write(packed._vramPixels[x, y].Pack()); + for (int x = 0; x < VramWidth; x++) + { + 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 toRemove = new List(); + 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)); - EditorGUI.DrawPreviewTexture(vramRect, vramImage, null, ScaleMode.ScaleToFit, 0, 0, ColorWriteMask.All); + 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(); } - /// - /// Loads stored PSX data from the asset. - /// - private void LoadData() - { - _psxData = AssetDatabase.LoadAssetAtPath(_psxDataPath); - if (!_psxData) - { - _psxData = CreateInstance(); - AssetDatabase.CreateAsset(_psxData, _psxDataPath); - AssetDatabase.SaveAssets(); - } - - selectedResolution = _psxData.OutputResolution; - dualBuffering = _psxData.DualBuffering; - verticalLayout = _psxData.VerticalBuffering; - prohibitedAreas = _psxData.ProhibitedAreas; - } - /// /// Stores current configuration to the PSX data asset. /// This is now triggered manually via the "Save Settings" button. /// - 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(); - } - } } } \ No newline at end of file diff --git a/LICENSE.meta b/LICENSE.meta new file mode 100644 index 0000000..af41383 --- /dev/null +++ b/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c1679c9d58898f14494d614dfe5f76a6 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PSXLightingBaker.cs b/Runtime/PSXLightingBaker.cs new file mode 100644 index 0000000..c9a8337 --- /dev/null +++ b/Runtime/PSXLightingBaker.cs @@ -0,0 +1,88 @@ +using UnityEngine; + +public static class PSXLightingBaker +{ + /// + /// Computes the per-vertex lighting from all scene light sources. + /// Incorporates ambient, diffuse, and spotlight falloff. + /// + /// The world-space position of the vertex. + /// The normalized world-space normal of the vertex. + /// A Color representing the lit vertex. + public static Color ComputeLighting(Vector3 vertex, Vector3 normal) + { + Color finalColor = Color.black; + + Light[] lights = Object.FindObjectsByType(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; + } +} diff --git a/Runtime/PSXLightingBaker.cs.meta b/Runtime/PSXLightingBaker.cs.meta new file mode 100644 index 0000000..7493f55 --- /dev/null +++ b/Runtime/PSXLightingBaker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b707b7d499862621fb6c82aba4caa183 \ No newline at end of file diff --git a/Runtime/PSXMesh.cs b/Runtime/PSXMesh.cs index 618864f..0faf94b 100644 --- a/Runtime/PSXMesh.cs +++ b/Runtime/PSXMesh.cs @@ -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 }; } /// @@ -36,6 +40,40 @@ namespace SplashEdit.RuntimeCode { public List Triangles; + private static Vector3[] RecalculateSmoothNormals(Mesh mesh) + { + Vector3[] normals = new Vector3[mesh.vertexCount]; + Dictionary> vertexMap = new Dictionary>(); + + for (int i = 0; i < mesh.vertexCount; i++) + { + Vector3 vertex = mesh.vertices[i]; + if (!vertexMap.ContainsKey(vertex)) + { + vertexMap[vertex] = new List(); + } + 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; + } + + /// /// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading. /// @@ -44,40 +82,74 @@ namespace SplashEdit.RuntimeCode /// Height of the texture (default is 256). /// Optional transform to convert vertices to world space. /// A new PSXMesh containing the converted triangles. - 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 textures) { PSXMesh psxMesh = new PSXMesh { Triangles = new List() }; - // Get mesh data arrays. - Vector3[] vertices = mesh.vertices; - Vector3[] normals = mesh.normals; - Vector2[] uv = mesh.uv; - int[] indices = mesh.triangles; + // Get materials and mesh. + Material[] materials = renderer.sharedMaterials; + Mesh mesh = renderer.GetComponent().sharedMesh; - // 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) + // Iterate over each submesh. + for (int submeshIndex = 0; submeshIndex < materials.Length; submeshIndex++) { - int vid0 = indices[i]; - int vid1 = indices[i + 1]; - int vid2 = indices[i + 2]; + // Get the triangles for this submesh. + int[] submeshTriangles = mesh.GetTriangles(submeshIndex); - // 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]; + // Get the material for this submesh. + Material material = materials[submeshIndex]; - // 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); + // Get the corresponding texture for this material (assume mainTexture). + Texture2D texture = material.mainTexture as Texture2D; + PSXTexture2D psxTexture = null; - // Add the constructed triangle to the mesh. - psxMesh.Triangles.Add(new Tri { v0 = psxV0, v1 = psxV1, v2 = psxV2 }); + 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; + PSXVertex convertData(int index) + { + // 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]; + + Vector3 faceNormal = Vector3.Cross(vertices[vid1] - vertices[vid0], vertices[vid2] - vertices[vid0]).normalized; + + if (Vector3.Dot(faceNormal, normals[vid0]) < 0) + { + (vid1, vid2) = (vid2, vid1); + } + + // Add the constructed triangle to the mesh. + 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 /// Width of the texture for UV scaling. /// Height of the texture for UV scaling. /// A PSXVertex with converted coordinates, normals, UVs, and color. - 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; diff --git a/Runtime/PSXObjectExporter.cs b/Runtime/PSXObjectExporter.cs index 38d7dc7..8cd3fda 100644 --- a/Runtime/PSXObjectExporter.cs +++ b/Runtime/PSXObjectExporter.cs @@ -1,49 +1,115 @@ +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 Textures { get; set; } = new List(); // 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(); + + 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 /// /// Converts the object's material texture into a PlayStation-compatible texture. /// - public void CreatePSXTexture2D() + /// + public void CreatePSXTextures2D() { Renderer renderer = GetComponent(); - 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; + + foreach (Material mat in materials) + { + if (mat != null && mat.mainTexture != null) + { + Texture mainTexture = mat.mainTexture; + Texture2D tex2D = null; + + // Check if it's already a Texture2D + if (mainTexture is Texture2D existingTex2D) + { + tex2D = existingTex2D; + } + else + { + // 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; + } /// /// Converts the object's mesh into a PlayStation-compatible mesh. /// - public void CreatePSXMesh() + public void CreatePSXMesh(float GTEScaling) { - MeshFilter meshFilter = gameObject.GetComponent(); - if (meshFilter != null) + Renderer renderer = GetComponent(); + if (renderer != null) { - if (MeshIsStatic) - { - // Static meshes take object transformation into account - Mesh = PSXMesh.CreateFromUnityMesh(meshFilter.sharedMesh, Texture.Width, Texture.Height, transform); - } - else - { - // Dynamic meshes do not consider object transformation - Mesh = PSXMesh.CreateFromUnityMesh(meshFilter.sharedMesh, Texture.Width, Texture.Height); - } + Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures); } } } diff --git a/Runtime/PSXSceneExporter.cs b/Runtime/PSXSceneExporter.cs index 5799463..dd88b01 100644 --- a/Runtime/PSXSceneExporter.cs +++ b/Runtime/PSXSceneExporter.cs @@ -1,158 +1,303 @@ +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 { - [ExecuteInEditMode] - public class PSXSceneExporter : MonoBehaviour - { - private PSXObjectExporter[] _exporters; - - private PSXData _psxData; - private readonly string _psxDataPath = "Assets/PSXData.asset"; - - private Vector2 selectedResolution; - private bool dualBuffering; - private bool verticalLayout; - private List prohibitedAreas; - private VRAMPixel[,] vramPixels; - - - - public void Export() - { - LoadData(); - _exporters = FindObjectsByType(FindObjectsSortMode.None); - foreach (PSXObjectExporter exp in _exporters) - { - exp.CreatePSXTexture2D(); - exp.CreatePSXMesh(); - } - PackTextures(); - ExportFile(); - } - - void PackTextures() + [ExecuteInEditMode] + public class PSXSceneExporter : MonoBehaviour { - 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); + public float GTEScaling = 100.0f; - List framebuffers = new List { buffer1 }; - if (dualBuffering) - { - framebuffers.Add(buffer2); - } + private PSXObjectExporter[] _exporters; + private TextureAtlas[] _atlases; - VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas); - var packed = tp.PackTexturesIntoVRAM(_exporters); - _exporters = packed.processedObjects; - vramPixels = packed._vramPixels; + private PSXData _psxData; - } + private Vector2 selectedResolution; + private bool dualBuffering; + private bool verticalLayout; + private List prohibitedAreas; - void ExportFile() - { - string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin"); - int totalFaces = 0; - using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create))) - { - // VramPixels are always 1MB - for (int y = 0; y < vramPixels.GetLength(1); y++) + public void Export() { - for (int x = 0; x < vramPixels.GetLength(0); x++) - { - writer.Write(vramPixels[x, y].Pack()); - } + _psxData = DataStorage.LoadData(out selectedResolution, out dualBuffering, out verticalLayout, out prohibitedAreas); + + _exporters = FindObjectsByType(FindObjectsSortMode.None); + foreach (PSXObjectExporter exp in _exporters) + { + exp.CreatePSXTextures2D(); + exp.CreatePSXMesh(GTEScaling); + } + PackTextures(); + ExportFile(); } - writer.Write((ushort)_exporters.Length); - foreach (PSXObjectExporter exporter in _exporters) + + void PackTextures() + { + (Rect buffer1, Rect buffer2) = Utils.BufferForResolution(selectedResolution, verticalLayout); + + List framebuffers = new List { buffer1 }; + if (dualBuffering) + { + framebuffers.Add(buffer2); + } + + VRAMPacker tp = new VRAMPacker(framebuffers, prohibitedAreas); + var packed = tp.PackTexturesIntoVRAM(_exporters); + _exporters = packed.processedObjects; + _atlases = packed.atlases; + + } + + void ExportFile() { - int expander = 16 / ((int)exporter.Texture.BitDepth); + string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin"); + int totalFaces = 0; - 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); - 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); + // Lists for mesh data offsets. + List offsetPlaceholderPositions = new List(); + List meshDataOffsets = new List(); - 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); + // Lists for atlas data offsets. + List atlasOffsetPlaceholderPositions = new List(); + List atlasDataOffsets = new List(); - 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); + 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))) + { + // 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); + + 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; + + 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 action) + { + for (int i = 0; i < tri.Vertexes.Length; i++) + { + action(tri.Vertexes[i]); + } + } + foreach (Tri tri in exporter.Mesh.Triangles) + { + int expander = 16 / ((int)tri.Texture.BitDepth); + // Write vertices coordinates + foreachVertexDo(tri, (v) => writeVertexPosition(v)); + + // Write vertex normals for v0 only + writeVertexNormals(tri.v0); + + // 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); } - } - Debug.Log(totalFaces); + + void AlignToFourBytes(BinaryWriter writer) + { + 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); + + } + } - - public void LoadData() - { - _psxData = AssetDatabase.LoadAssetAtPath(_psxDataPath); - - if (!_psxData) - { - _psxData = ScriptableObject.CreateInstance(); - AssetDatabase.CreateAsset(_psxData, _psxDataPath); - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - } - - selectedResolution = _psxData.OutputResolution; - dualBuffering = _psxData.DualBuffering; - verticalLayout = _psxData.VerticalBuffering; - prohibitedAreas = _psxData.ProhibitedAreas; - } - - void OnDrawGizmos() - { - Gizmos.DrawIcon(transform.position, "Packages/net.psxsplash.splashedit/Icons/PSXSceneExporter.png", true); - } - } } diff --git a/Runtime/PSXTexture2D.cs b/Runtime/PSXTexture2D.cs index 975b26b..e7cd2ee 100644 --- a/Runtime/PSXTexture2D.cs +++ b/Runtime/PSXTexture2D.cs @@ -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; } + /// + /// Check if we need to update stored texture + /// + /// new settings for color bit depth + /// new texture + /// return true if sored texture is different from a new one + internal bool NeedUpdate(PSXBPP bitDepth, Texture2D texture) + { + return BitDepth != bitDepth || texture.GetInstanceID() != texture.GetInstanceID(); + } } } \ No newline at end of file diff --git a/Runtime/TexturePacker.cs b/Runtime/TexturePacker.cs index fd2a9c8..7772fca 100644 --- a/Runtime/TexturePacker.cs +++ b/Runtime/TexturePacker.cs @@ -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 ContainedTextures = new List(); // Textures packed in this atlas. } @@ -29,8 +30,8 @@ namespace SplashEdit.RuntimeCode private List _finalizedAtlases = new List(); // Atlases that have been successfully placed. private List _allocatedCLUTs = new List(); // 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,59 +54,87 @@ namespace SplashEdit.RuntimeCode _reservedAreas.Add(framebuffers[0]); _reservedAreas.Add(framebuffers[1]); - _vramPixels = new VRAMPixel[VRAM_WIDTH, VRAM_HEIGHT]; + _vramPixels = new VRAMPixel[VramWidth, VramHeight]; } /// /// 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. /// /// Array of PSXObjectExporter objects to process. - /// Tuple containing processed objects and the VRAM pixel array. - public (PSXObjectExporter[] processedObjects, VRAMPixel[,] _vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects) + /// Tuple containing processed objects, texture atlases, and the VRAM pixel array. + public (PSXObjectExporter[] processedObjects, TextureAtlas[] atlases, VRAMPixel[,] vramPixels) PackTexturesIntoVRAM(PSXObjectExporter[] objects) { - List uniqueTextures = new List(); - // 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 allTextures = new List(); + foreach (var obj in objects) + { + allTextures.AddRange(obj.Textures); + } - foreach (var group in groupedObjects) + // List to track unique textures. + List uniqueTextures = new List(); + + // 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 { PSXBPP.TEX_16BIT => 256, - PSXBPP.TEX_8BIT => 128, - PSXBPP.TEX_4BIT => 64, - _ => 256 + PSXBPP.TEX_8BIT => 128, + PSXBPP.TEX_4BIT => 64, + _ => 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); } /// @@ -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); } - - /// - /// Calculates the texpage index from given VRAM coordinates. - /// This helper method divides VRAM into columns and rows. - /// - /// The X coordinate in VRAM. - /// The Y coordinate in VRAM. - /// The calculated texpage index. - private int CalculateTexpage(int x, int y) - { - int columns = 16; - int colIndex = x / 64; - int rowIndex = y / 256; - return (rowIndex * columns) + colIndex; - } } } diff --git a/Runtime/Utils.cs b/Runtime/Utils.cs index 1c17997..48839c3 100644 --- a/Runtime/Utils.cs +++ b/Runtime/Utils.cs @@ -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"; + + /// + /// Loads stored PSX data from the asset. + /// + public static PSXData LoadData(out Vector2 selectedResolution, out bool dualBuffering, out bool verticalLayout, out List prohibitedAreas) + { + var _psxData = AssetDatabase.LoadAssetAtPath(psxDataPath); + if (!_psxData) + { + _psxData = ScriptableObject.CreateInstance(); + 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(psxDataPath); + + if (!psxData) + { + psxData = ScriptableObject.CreateInstance(); + 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(); + } + } + } + /// /// 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); } } + /// + /// 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. + /// + public static class PSXTrig + { + /// + /// 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. + /// + /// The coordinate value to convert. + /// A scaling factor for the value (default is 1.0f). + /// The converted coordinate in 3.12 fixed-point format. + public static short ConvertCoordinateToPSX(float value, float GTEScaling = 1.0f) + { + return (short)(Mathf.Clamp(value / GTEScaling, -4f, 3.999f) * 4096); + } + + /// + /// 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. + /// + /// The quaternion representing the rotation to convert. + /// A 3x3 matrix representing the PSX-compatible rotation. + 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; + } + + /// + /// 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. + /// + /// The floating-point value to convert. + /// The converted value in 3.12 fixed-point format as a 16-bit signed integer. + 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 + } + } + /// + /// 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. + /// + public struct TPageAttr + { + public ushort info; // Stores the packed attribute information as a 16-bit unsigned integer. + + /// + /// Sets the X-coordinate of the texture page. + /// The lower 4 bits of the 'info' field are used to store the X value. + /// + /// The X-coordinate value (0 to 15). + /// The updated TPageAttr instance. + public TPageAttr SetPageX(byte x) + { + info &= 0xFFF0; // Clear lower 4 bits + x &= 0x0F; // Ensure only lower 4 bits are used + info |= x; + return this; + } + + /// + /// 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). + /// + /// The Y-coordinate value (0 or 1). + /// The updated TPageAttr instance. + 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; + } + + /// + /// Sets the transparency type of the texture page. + /// The transparency type is stored in bits 5 and 6 of the 'info' field. + /// + /// The transparency type to set. + /// The updated TPageAttr instance. + public TPageAttr Set(SemiTrans trans) + { + info &= 0xFF9F; // Clear bits 5 and 6 + uint t = (uint)trans; + info |= (ushort)(t << 5); + return this; + } + + /// + /// Sets the color mode of the texture page. + /// The color mode is stored in bits 7 and 8 of the 'info' field. + /// + /// The color mode to set (4-bit, 8-bit, or 16-bit). + /// The updated TPageAttr instance. + public TPageAttr Set(ColorMode mode) + { + info &= 0xFE7F; // Clear bits 7 and 8 + uint m = (uint)mode; + info |= (ushort)(m << 7); + return this; + } + + /// + /// Enables or disables dithering for the texture page. + /// Dithering is stored in bit 9 of the 'info' field. + /// + /// True to enable dithering, false to disable it. + /// The updated TPageAttr instance. + 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; + } + + /// + /// Disables the display area for the texture page. + /// This will clear bit 10 of the 'info' field. + /// + /// The updated TPageAttr instance. + public TPageAttr DisableDisplayArea() + { + info &= 0xFBFF; // Clear bit 10 + return this; + } + + /// + /// Enables the display area for the texture page. + /// This will set bit 10 of the 'info' field. + /// + /// The updated TPageAttr instance. + public TPageAttr EnableDisplayArea() + { + info |= 0x0400; // Set bit 10 to enable display area + return this; + } + + /// + /// Returns a string representation of the TPageAttr instance, showing the 'info' value in hexadecimal. + /// + /// A string representing the 'info' value in hexadecimal format. + public override string ToString() => $"Info: 0x{info:X4}"; + + // Define the enums for SemiTrans and ColorMode (assuming their values) + + /// + /// Defines the transparency types for a texture page. + /// + public enum SemiTrans : uint + { + None = 0, + Type1 = 1, + Type2 = 2, + Type3 = 3 + } + + /// + /// Defines the color modes for a texture page. + /// + 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)); + } } + diff --git a/doc.meta b/doc.meta new file mode 100644 index 0000000..4bc1249 --- /dev/null +++ b/doc.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 311ff9868024b5078bd12a6c2252a4ef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/doc/splashbundle.md b/doc/splashbundle.md new file mode 100644 index 0000000..e91cc8a --- /dev/null +++ b/doc/splashbundle.md @@ -0,0 +1,93 @@ +# SPLASHPACK Binary File Format Specification + +All numeric values are stored in little‐endian 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:
• Texture page attributes (uint16 – encoded from page X/Y, bit depth, dithering)
• Texture CLUT packing X (uint16)
• Texture CLUT packing Y (uint16)
• 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. + +--- diff --git a/doc/splashbundle.md.meta b/doc/splashbundle.md.meta new file mode 100644 index 0000000..f30ba70 --- /dev/null +++ b/doc/splashbundle.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1944ac962a00b23c2a880b5134cdc7ab +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: