This commit is contained in:
Jan Racek
2026-03-24 13:00:54 +01:00
parent 53e993f58e
commit 4aa4e49424
145 changed files with 10853 additions and 2965 deletions

View File

@@ -1,845 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
using System.Diagnostics;
using SplashEdit.RuntimeCode;
public class BSP
{
private List<PSXObjectExporter> _objects;
private Node root;
private const float EPSILON = 1e-6f;
private const int MAX_TRIANGLES_PER_LEAF = 256;
private const int MAX_TREE_DEPTH = 50;
private const int CANDIDATE_PLANE_COUNT = 15;
// Statistics
private int totalTrianglesProcessed;
private int totalSplits;
private int treeDepth;
private Stopwatch buildTimer;
public bool verboseLogging = false;
// Store the triangle that was used for the split plane for debugging
private Dictionary<Node, Triangle> splitPlaneTriangles = new Dictionary<Node, Triangle>();
private struct Triangle
{
public Vector3 v0;
public Vector3 v1;
public Vector3 v2;
public Vector3 n0;
public Vector3 n1;
public Vector3 n2;
public Vector2 uv0;
public Vector2 uv1;
public Vector2 uv2;
public Plane plane;
public Bounds bounds;
public PSXObjectExporter sourceExporter;
public int materialIndex; // Store material index instead of submesh index
public Triangle(Vector3 a, Vector3 b, Vector3 c, Vector3 na, Vector3 nb, Vector3 nc,
Vector2 uva, Vector2 uvb, Vector2 uvc, PSXObjectExporter exporter, int matIndex)
{
v0 = a;
v1 = b;
v2 = c;
n0 = na;
n1 = nb;
n2 = nc;
uv0 = uva;
uv1 = uvb;
uv2 = uvc;
sourceExporter = exporter;
materialIndex = matIndex;
// Calculate plane
Vector3 edge1 = v1 - v0;
Vector3 edge2 = v2 - v0;
Vector3 normal = Vector3.Cross(edge1, edge2);
if (normal.sqrMagnitude < 1e-4f)
{
plane = new Plane(Vector3.up, 0);
}
else
{
normal.Normalize();
plane = new Plane(normal, v0);
}
// Calculate bounds
bounds = new Bounds(v0, Vector3.zero);
bounds.Encapsulate(v1);
bounds.Encapsulate(v2);
}
public void Transform(Matrix4x4 matrix)
{
v0 = matrix.MultiplyPoint3x4(v0);
v1 = matrix.MultiplyPoint3x4(v1);
v2 = matrix.MultiplyPoint3x4(v2);
// Transform normals (using inverse transpose for correct scaling)
Matrix4x4 invTranspose = matrix.inverse.transpose;
n0 = invTranspose.MultiplyVector(n0).normalized;
n1 = invTranspose.MultiplyVector(n1).normalized;
n2 = invTranspose.MultiplyVector(n2).normalized;
// Recalculate plane and bounds after transformation
Vector3 edge1 = v1 - v0;
Vector3 edge2 = v2 - v0;
Vector3 normal = Vector3.Cross(edge1, edge2);
if (normal.sqrMagnitude < 1e-4f)
{
plane = new Plane(Vector3.up, 0);
}
else
{
normal.Normalize();
plane = new Plane(normal, v0);
}
bounds = new Bounds(v0, Vector3.zero);
bounds.Encapsulate(v1);
bounds.Encapsulate(v2);
}
}
private class Node
{
public Plane plane;
public Node front;
public Node back;
public List<Triangle> triangles;
public bool isLeaf = false;
public Bounds bounds;
public int depth;
public int triangleSourceIndex = -1;
}
public BSP(List<PSXObjectExporter> objects)
{
_objects = objects;
buildTimer = new Stopwatch();
}
public void Build()
{
buildTimer.Start();
List<Triangle> triangles = ExtractTrianglesFromMeshes();
totalTrianglesProcessed = triangles.Count;
if (verboseLogging)
UnityEngine.Debug.Log($"Starting BSP build with {totalTrianglesProcessed} triangles");
if (triangles.Count == 0)
{
root = null;
return;
}
// Calculate overall bounds
Bounds overallBounds = CalculateBounds(triangles);
// Build tree recursively with depth tracking
root = BuildNode(triangles, overallBounds, 0);
// Create modified meshes for all exporters
CreateModifiedMeshes();
buildTimer.Stop();
if (verboseLogging)
{
UnityEngine.Debug.Log($"BSP build completed in {buildTimer.Elapsed.TotalMilliseconds}ms");
UnityEngine.Debug.Log($"Total splits: {totalSplits}, Max depth: {treeDepth}");
}
}
private List<Triangle> ExtractTrianglesFromMeshes()
{
List<Triangle> triangles = new List<Triangle>();
foreach (var meshObj in _objects)
{
if (!meshObj.IsActive) continue;
MeshFilter mf = meshObj.GetComponent<MeshFilter>();
Renderer renderer = meshObj.GetComponent<Renderer>();
if (mf == null || mf.sharedMesh == null || renderer == null) continue;
Mesh mesh = mf.sharedMesh;
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals.Length > 0 ? mesh.normals : new Vector3[vertices.Length];
Vector2[] uvs = mesh.uv.Length > 0 ? mesh.uv : new Vector2[vertices.Length];
Matrix4x4 matrix = meshObj.transform.localToWorldMatrix;
// Handle case where normals are missing
if (mesh.normals.Length == 0)
{
for (int i = 0; i < normals.Length; i++)
{
normals[i] = Vector3.up;
}
}
// Handle case where UVs are missing
if (mesh.uv.Length == 0)
{
for (int i = 0; i < uvs.Length; i++)
{
uvs[i] = Vector2.zero;
}
}
// Process each submesh and track material index
for (int submesh = 0; submesh < mesh.subMeshCount; submesh++)
{
int materialIndex = Mathf.Min(submesh, renderer.sharedMaterials.Length - 1);
int[] indices = mesh.GetTriangles(submesh);
for (int i = 0; i < indices.Length; i += 3)
{
int idx0 = indices[i];
int idx1 = indices[i + 1];
int idx2 = indices[i + 2];
Vector3 v0 = vertices[idx0];
Vector3 v1 = vertices[idx1];
Vector3 v2 = vertices[idx2];
// Skip degenerate triangles
if (Vector3.Cross(v1 - v0, v2 - v0).sqrMagnitude < 1e-4f)
continue;
Vector3 n0 = normals[idx0];
Vector3 n1 = normals[idx1];
Vector3 n2 = normals[idx2];
Vector2 uv0 = uvs[idx0];
Vector2 uv1 = uvs[idx1];
Vector2 uv2 = uvs[idx2];
Triangle tri = new Triangle(v0, v1, v2, n0, n1, n2, uv0, uv1, uv2, meshObj, materialIndex);
tri.Transform(matrix);
triangles.Add(tri);
}
}
}
return triangles;
}
private Node BuildNode(List<Triangle> triangles, Bounds bounds, int depth)
{
if (triangles == null || triangles.Count == 0)
return null;
Node node = new Node
{
triangles = new List<Triangle>(),
bounds = bounds,
depth = depth
};
treeDepth = Mathf.Max(treeDepth, depth);
// Create leaf node if conditions are met
if (triangles.Count <= MAX_TRIANGLES_PER_LEAF || depth >= MAX_TREE_DEPTH)
{
node.isLeaf = true;
node.triangles = triangles;
if (verboseLogging && depth >= MAX_TREE_DEPTH)
UnityEngine.Debug.LogWarning($"Max tree depth reached at depth {depth} with {triangles.Count} triangles");
return node;
}
// Select the best splitting plane using multiple strategies
Triangle? splitTriangle = null;
if (!SelectBestSplittingPlane(triangles, bounds, out node.plane, out splitTriangle))
{
// Fallback: create leaf if no good split found
node.isLeaf = true;
node.triangles = triangles;
if (verboseLogging)
UnityEngine.Debug.Log($"Created leaf node with {triangles.Count} triangles (no good split found)");
return node;
}
// Store the triangle that provided the split plane for debugging
if (splitTriangle.HasValue)
{
splitPlaneTriangles[node] = splitTriangle.Value;
}
List<Triangle> frontList = new List<Triangle>();
List<Triangle> backList = new List<Triangle>();
List<Triangle> coplanarList = new List<Triangle>();
// Classify all triangles
foreach (var tri in triangles)
{
ClassifyTriangle(tri, node.plane, coplanarList, frontList, backList);
}
// Handle cases where splitting doesn't provide benefit
if (frontList.Count == 0 || backList.Count == 0)
{
// If split doesn't separate geometry, create a leaf
node.isLeaf = true;
node.triangles = triangles;
if (verboseLogging)
UnityEngine.Debug.Log($"Created leaf node with {triangles.Count} triangles (ineffective split)");
return node;
}
// Distribute coplanar triangles to the side with fewer triangles
if (coplanarList.Count > 0)
{
if (frontList.Count <= backList.Count)
{
frontList.AddRange(coplanarList);
}
else
{
backList.AddRange(coplanarList);
}
}
if (verboseLogging)
UnityEngine.Debug.Log($"Node at depth {depth}: {triangles.Count} triangles -> {frontList.Count} front, {backList.Count} back");
// Calculate bounds for children
Bounds frontBounds = CalculateBounds(frontList);
Bounds backBounds = CalculateBounds(backList);
// Recursively build child nodes
node.front = BuildNode(frontList, frontBounds, depth + 1);
node.back = BuildNode(backList, backBounds, depth + 1);
return node;
}
private bool SelectBestSplittingPlane(List<Triangle> triangles, Bounds bounds, out Plane bestPlane, out Triangle? splitTriangle)
{
bestPlane = new Plane();
splitTriangle = null;
int bestScore = int.MaxValue;
bool foundValidPlane = false;
// Strategy 1: Try planes from triangle centroids
int candidatesToTry = Mathf.Min(CANDIDATE_PLANE_COUNT, triangles.Count);
for (int i = 0; i < candidatesToTry; i++)
{
Triangle tri = triangles[i];
Plane candidate = tri.plane;
int score = EvaluateSplitPlane(triangles, candidate);
if (score < bestScore && score >= 0)
{
bestScore = score;
bestPlane = candidate;
splitTriangle = tri;
foundValidPlane = true;
}
}
// Strategy 2: Try axis-aligned planes through bounds center
if (!foundValidPlane || bestScore > triangles.Count * 3)
{
Vector3[] axes = { Vector3.right, Vector3.up, Vector3.forward };
for (int i = 0; i < 3; i++)
{
Plane candidate = new Plane(axes[i], bounds.center);
int score = EvaluateSplitPlane(triangles, candidate);
if (score < bestScore && score >= 0)
{
bestScore = score;
bestPlane = candidate;
splitTriangle = null;
foundValidPlane = true;
}
}
}
// Strategy 3: Try planes based on bounds extents
if (!foundValidPlane)
{
Vector3 extents = bounds.extents;
if (extents.x >= extents.y && extents.x >= extents.z)
bestPlane = new Plane(Vector3.right, bounds.center);
else if (extents.y >= extents.x && extents.y >= extents.z)
bestPlane = new Plane(Vector3.up, bounds.center);
else
bestPlane = new Plane(Vector3.forward, bounds.center);
splitTriangle = null;
foundValidPlane = true;
}
return foundValidPlane;
}
private int EvaluateSplitPlane(List<Triangle> triangles, Plane plane)
{
int frontCount = 0;
int backCount = 0;
int splitCount = 0;
int coplanarCount = 0;
foreach (var tri in triangles)
{
float d0 = plane.GetDistanceToPoint(tri.v0);
float d1 = plane.GetDistanceToPoint(tri.v1);
float d2 = plane.GetDistanceToPoint(tri.v2);
// Check for NaN/infinity
if (float.IsNaN(d0) || float.IsNaN(d1) || float.IsNaN(d2) ||
float.IsInfinity(d0) || float.IsInfinity(d1) || float.IsInfinity(d2))
{
return int.MaxValue;
}
bool front = d0 > EPSILON || d1 > EPSILON || d2 > EPSILON;
bool back = d0 < -EPSILON || d1 < -EPSILON || d2 < -EPSILON;
if (front && back)
splitCount++;
else if (front)
frontCount++;
else if (back)
backCount++;
else
coplanarCount++;
}
// Reject planes that would cause too many splits or imbalanced trees
if (splitCount > triangles.Count / 2)
return int.MaxValue;
// Score based on balance and split count
return Mathf.Abs(frontCount - backCount) + splitCount * 2;
}
private void ClassifyTriangle(Triangle tri, Plane plane, List<Triangle> coplanar, List<Triangle> front, List<Triangle> back)
{
float d0 = plane.GetDistanceToPoint(tri.v0);
float d1 = plane.GetDistanceToPoint(tri.v1);
float d2 = plane.GetDistanceToPoint(tri.v2);
// Check for numerical issues
if (float.IsNaN(d0) || float.IsNaN(d1) || float.IsNaN(d2) ||
float.IsInfinity(d0) || float.IsInfinity(d1) || float.IsInfinity(d2))
{
coplanar.Add(tri);
return;
}
bool front0 = d0 > EPSILON;
bool front1 = d1 > EPSILON;
bool front2 = d2 > EPSILON;
bool back0 = d0 < -EPSILON;
bool back1 = d1 < -EPSILON;
bool back2 = d2 < -EPSILON;
int fCount = (front0 ? 1 : 0) + (front1 ? 1 : 0) + (front2 ? 1 : 0);
int bCount = (back0 ? 1 : 0) + (back1 ? 1 : 0) + (back2 ? 1 : 0);
if (fCount == 3)
{
front.Add(tri);
}
else if (bCount == 3)
{
back.Add(tri);
}
else if (fCount == 0 && bCount == 0)
{
coplanar.Add(tri);
}
else
{
totalSplits++;
SplitTriangle(tri, plane, front, back);
}
}
private void SplitTriangle(Triangle tri, Plane plane, List<Triangle> front, List<Triangle> back)
{
// Get distances
float d0 = plane.GetDistanceToPoint(tri.v0);
float d1 = plane.GetDistanceToPoint(tri.v1);
float d2 = plane.GetDistanceToPoint(tri.v2);
// Classify points
bool[] frontSide = { d0 > EPSILON, d1 > EPSILON, d2 > EPSILON };
bool[] backSide = { d0 < -EPSILON, d1 < -EPSILON, d2 < -EPSILON };
// Count how many points are on each side
int frontCount = (frontSide[0] ? 1 : 0) + (frontSide[1] ? 1 : 0) + (frontSide[2] ? 1 : 0);
int backCount = (backSide[0] ? 1 : 0) + (backSide[1] ? 1 : 0) + (backSide[2] ? 1 : 0);
// 2 points on one side, 1 on the other
if (frontCount == 2 && backCount == 1)
{
int loneIndex = backSide[0] ? 0 : (backSide[1] ? 1 : 2);
SplitTriangle2To1(tri, plane, loneIndex, true, front, back);
}
else if (backCount == 2 && frontCount == 1)
{
int loneIndex = frontSide[0] ? 0 : (frontSide[1] ? 1 : 2);
SplitTriangle2To1(tri, plane, loneIndex, false, front, back);
}
else
{
// Complex case - add to both sides (should be rare)
front.Add(tri);
back.Add(tri);
}
}
private void SplitTriangle2To1(Triangle tri, Plane plane, int loneIndex, bool loneIsBack,
List<Triangle> front, List<Triangle> back)
{
Vector3[] v = { tri.v0, tri.v1, tri.v2 };
Vector3[] n = { tri.n0, tri.n1, tri.n2 };
Vector2[] uv = { tri.uv0, tri.uv1, tri.uv2 };
Vector3 loneVertex = v[loneIndex];
Vector3 loneNormal = n[loneIndex];
Vector2 loneUV = uv[loneIndex];
Vector3 v1 = v[(loneIndex + 1) % 3];
Vector3 v2 = v[(loneIndex + 2) % 3];
Vector3 n1 = n[(loneIndex + 1) % 3];
Vector3 n2 = n[(loneIndex + 2) % 3];
Vector2 uv1 = uv[(loneIndex + 1) % 3];
Vector2 uv2 = uv[(loneIndex + 2) % 3];
Vector3 i1 = PlaneIntersection(plane, loneVertex, v1);
float t1 = CalculateInterpolationFactor(plane, loneVertex, v1);
Vector3 n_i1 = Vector3.Lerp(loneNormal, n1, t1).normalized;
Vector2 uv_i1 = Vector2.Lerp(loneUV, uv1, t1);
Vector3 i2 = PlaneIntersection(plane, loneVertex, v2);
float t2 = CalculateInterpolationFactor(plane, loneVertex, v2);
Vector3 n_i2 = Vector3.Lerp(loneNormal, n2, t2).normalized;
Vector2 uv_i2 = Vector2.Lerp(loneUV, uv2, t2);
// Desired normal: prefer triangle's plane normal, fallback to geometric normal
Vector3 desired = tri.plane.normal;
if (desired.sqrMagnitude < 1e-4f)
desired = Vector3.Cross(tri.v1 - tri.v0, tri.v2 - tri.v0).normalized;
if (desired.sqrMagnitude < 1e-4f)
desired = Vector3.up;
// Helper: decide and swap b/c if necessary, then add triangle
void AddTriClockwise(List<Triangle> list,
Vector3 a, Vector3 b, Vector3 c,
Vector3 na, Vector3 nb, Vector3 nc,
Vector2 ua, Vector2 ub, Vector2 uc)
{
Vector3 cross = Vector3.Cross(b - a, c - a);
if (cross.z > 0f) // <-- assumes you're working in PS1 screen space (z forward)
{
// swap b <-> c
var tmpV = b; b = c; c = tmpV;
var tmpN = nb; nb = nc; nc = tmpN;
var tmpUv = ub; ub = uc; uc = tmpUv;
}
list.Add(new Triangle(a, b, c, na, nb, nc, ua, ub, uc, tri.sourceExporter, tri.materialIndex));
}
if (loneIsBack)
{
// back: (lone, i1, i2)
AddTriClockwise(back, loneVertex, i1, i2, loneNormal, n_i1, n_i2, loneUV, uv_i1, uv_i2);
// front: (v1, i1, i2) and (v1, i2, v2)
AddTriClockwise(front, v1, i1, i2, n1, n_i1, n_i2, uv1, uv_i1, uv_i2);
AddTriClockwise(front, v1, i2, v2, n1, n_i2, n2, uv1, uv_i2, uv2);
}
else
{
// front: (lone, i1, i2)
AddTriClockwise(front, loneVertex, i1, i2, loneNormal, n_i1, n_i2, loneUV, uv_i1, uv_i2);
// back: (v1, i1, i2) and (v1, i2, v2)
AddTriClockwise(back, v1, i1, i2, n1, n_i1, n_i2, uv1, uv_i1, uv_i2);
AddTriClockwise(back, v1, i2, v2, n1, n_i2, n2, uv1, uv_i2, uv2);
}
}
private Vector3 PlaneIntersection(Plane plane, Vector3 a, Vector3 b)
{
Vector3 ba = b - a;
float denominator = Vector3.Dot(plane.normal, ba);
// Check for parallel line (shouldn't happen in our case)
if (Mathf.Abs(denominator) < 1e-4f)
return a;
float t = (-plane.distance - Vector3.Dot(plane.normal, a)) / denominator;
return a + ba * Mathf.Clamp01(t);
}
private float CalculateInterpolationFactor(Plane plane, Vector3 a, Vector3 b)
{
Vector3 ba = b - a;
float denominator = Vector3.Dot(plane.normal, ba);
if (Mathf.Abs(denominator) < 1e-4f)
return 0.5f;
float t = (-plane.distance - Vector3.Dot(plane.normal, a)) / denominator;
return Mathf.Clamp01(t);
}
private Bounds CalculateBounds(List<Triangle> triangles)
{
if (triangles == null || triangles.Count == 0)
return new Bounds();
Bounds bounds = triangles[0].bounds;
for (int i = 1; i < triangles.Count; i++)
{
bounds.Encapsulate(triangles[i].bounds);
}
return bounds;
}
// Add a method to create modified meshes after BSP construction
// Add a method to create modified meshes after BSP construction
private void CreateModifiedMeshes()
{
if (root == null) return;
// Collect all triangles from the BSP tree
List<Triangle> allTriangles = new List<Triangle>();
CollectTrianglesFromNode(root, allTriangles);
// Group triangles by their source exporter and material index
Dictionary<PSXObjectExporter, Dictionary<int, List<Triangle>>> exporterTriangles =
new Dictionary<PSXObjectExporter, Dictionary<int, List<Triangle>>>();
foreach (var tri in allTriangles)
{
if (!exporterTriangles.ContainsKey(tri.sourceExporter))
{
exporterTriangles[tri.sourceExporter] = new Dictionary<int, List<Triangle>>();
}
var materialDict = exporterTriangles[tri.sourceExporter];
if (!materialDict.ContainsKey(tri.materialIndex))
{
materialDict[tri.materialIndex] = new List<Triangle>();
}
materialDict[tri.materialIndex].Add(tri);
}
// Create modified meshes for each exporter
foreach (var kvp in exporterTriangles)
{
PSXObjectExporter exporter = kvp.Key;
Dictionary<int, List<Triangle>> materialTriangles = kvp.Value;
Mesh originalMesh = exporter.GetComponent<MeshFilter>().sharedMesh;
Renderer renderer = exporter.GetComponent<Renderer>();
Mesh modifiedMesh = new Mesh();
modifiedMesh.name = originalMesh.name + "_BSP";
List<Vector3> vertices = new List<Vector3>();
List<Vector3> normals = new List<Vector3>();
List<Vector2> uvs = new List<Vector2>();
List<Vector4> tangents = new List<Vector4>();
List<Color> colors = new List<Color>();
// Create a list for each material's triangles
List<List<int>> materialIndices = new List<List<int>>();
for (int i = 0; i < renderer.sharedMaterials.Length; i++)
{
materialIndices.Add(new List<int>());
}
// Get the inverse transform to convert from world space back to object space
Matrix4x4 worldToLocal = exporter.transform.worldToLocalMatrix;
// Process each material
foreach (var materialKvp in materialTriangles)
{
int materialIndex = materialKvp.Key;
List<Triangle> triangles = materialKvp.Value;
// Add vertices, normals, and uvs for this material
for (int i = 0; i < triangles.Count; i++)
{
Triangle tri = triangles[i];
// Transform vertices from world space back to object space
Vector3 v0 = worldToLocal.MultiplyPoint3x4(tri.v0);
Vector3 v1 = worldToLocal.MultiplyPoint3x4(tri.v1);
Vector3 v2 = worldToLocal.MultiplyPoint3x4(tri.v2);
int vertexIndex = vertices.Count;
vertices.Add(v0);
vertices.Add(v1);
vertices.Add(v2);
// Transform normals from world space back to object space
Vector3 n0 = worldToLocal.MultiplyVector(tri.n0).normalized;
Vector3 n1 = worldToLocal.MultiplyVector(tri.n1).normalized;
Vector3 n2 = worldToLocal.MultiplyVector(tri.n2).normalized;
normals.Add(n0);
normals.Add(n1);
normals.Add(n2);
uvs.Add(tri.uv0);
uvs.Add(tri.uv1);
uvs.Add(tri.uv2);
// Add default tangents and colors (will be recalculated later)
tangents.Add(new Vector4(1, 0, 0, 1));
tangents.Add(new Vector4(1, 0, 0, 1));
tangents.Add(new Vector4(1, 0, 0, 1));
colors.Add(Color.white);
colors.Add(Color.white);
colors.Add(Color.white);
// Add indices for this material
materialIndices[materialIndex].Add(vertexIndex);
materialIndices[materialIndex].Add(vertexIndex + 1);
materialIndices[materialIndex].Add(vertexIndex + 2);
}
}
// Assign data to the mesh
modifiedMesh.vertices = vertices.ToArray();
modifiedMesh.normals = normals.ToArray();
modifiedMesh.uv = uvs.ToArray();
modifiedMesh.tangents = tangents.ToArray();
modifiedMesh.colors = colors.ToArray();
// Set up submeshes based on materials
modifiedMesh.subMeshCount = materialIndices.Count;
for (int i = 0; i < materialIndices.Count; i++)
{
modifiedMesh.SetTriangles(materialIndices[i].ToArray(), i);
}
// Recalculate important mesh properties
modifiedMesh.RecalculateBounds();
modifiedMesh.RecalculateTangents();
// Assign the modified mesh to the exporter
exporter.ModifiedMesh = modifiedMesh;
}
}
// Helper method to collect all triangles from the BSP tree
private void CollectTrianglesFromNode(Node node, List<Triangle> triangles)
{
if (node == null) return;
if (node.isLeaf)
{
triangles.AddRange(node.triangles);
}
else
{
CollectTrianglesFromNode(node.front, triangles);
CollectTrianglesFromNode(node.back, triangles);
}
}
public void DrawGizmos(int maxDepth)
{
if (root == null) return;
DrawNodeGizmos(root, 0, maxDepth);
}
private void DrawNodeGizmos(Node node, int depth, int maxDepth)
{
if (node == null) return;
if (depth > maxDepth) return;
Color nodeColor = Color.HSVToRGB((depth * 0.1f) % 1f, 0.8f, 0.8f);
Gizmos.color = nodeColor;
if (node.isLeaf)
{
foreach (var tri in node.triangles)
{
DrawTriangleGizmo(tri);
}
}
else
{
DrawPlaneGizmo(node.plane, node.bounds);
// Draw the triangle that was used for the split plane if available
if (splitPlaneTriangles.ContainsKey(node))
{
Gizmos.color = Color.magenta;
DrawTriangleGizmo(splitPlaneTriangles[node]);
Gizmos.color = nodeColor;
}
DrawNodeGizmos(node.front, depth + 1, maxDepth);
DrawNodeGizmos(node.back, depth + 1, maxDepth);
}
}
private void DrawTriangleGizmo(Triangle tri)
{
Gizmos.DrawLine(tri.v0, tri.v1);
Gizmos.DrawLine(tri.v1, tri.v2);
Gizmos.DrawLine(tri.v2, tri.v0);
}
private void DrawPlaneGizmo(Plane plane, Bounds bounds)
{
Vector3 center = bounds.center;
Vector3 normal = plane.normal;
Vector3 tangent = Vector3.Cross(normal, Vector3.up);
if (tangent.magnitude < 0.1f) tangent = Vector3.Cross(normal, Vector3.right);
tangent = tangent.normalized;
Vector3 bitangent = Vector3.Cross(normal, tangent).normalized;
float size = Mathf.Max(bounds.size.x, bounds.size.y, bounds.size.z) * 0.5f;
tangent *= size;
bitangent *= size;
Vector3 p0 = center - tangent - bitangent;
Vector3 p1 = center + tangent - bitangent;
Vector3 p2 = center + tangent + bitangent;
Vector3 p3 = center - tangent + bitangent;
Gizmos.DrawLine(p0, p1);
Gizmos.DrawLine(p1, p2);
Gizmos.DrawLine(p2, p3);
Gizmos.DrawLine(p3, p0);
Gizmos.color = Color.red;
Gizmos.DrawLine(center, center + normal * size * 0.5f);
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 15144e67b42b92447a546346e594155b

393
Runtime/BVH.cs Normal file
View File

@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using SplashEdit.RuntimeCode;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Bounding Volume Hierarchy for PS1 frustum culling.
/// Unlike BSP, BVH doesn't split triangles - it groups them by spatial locality.
/// This is better for PS1 because:
/// 1. No additional triangles created (memory constrained)
/// 2. Simple AABB tests are fast on 33MHz CPU
/// 3. Natural hierarchical culling
/// </summary>
public class BVH : IPSXBinaryWritable
{
// Configuration
private const int MAX_TRIANGLES_PER_LEAF = 64; // PS1 can handle batches of this size
private const int MAX_DEPTH = 16; // Prevent pathological cases
private const int MIN_TRIANGLES_TO_SPLIT = 8; // Don't split tiny groups
private List<PSXObjectExporter> _objects;
private BVHNode _root;
private List<BVHNode> _allNodes; // Flat list for export
private List<TriangleRef> _allTriangleRefs; // Triangle references for export
public int NodeCount => _allNodes?.Count ?? 0;
public int TriangleRefCount => _allTriangleRefs?.Count ?? 0;
/// <summary>
/// Reference to a triangle - doesn't copy data, just points to it
/// </summary>
public struct TriangleRef
{
public ushort objectIndex; // Which GameObject
public ushort triangleIndex; // Which triangle in that object's mesh
public TriangleRef(int objIdx, int triIdx)
{
objectIndex = (ushort)objIdx;
triangleIndex = (ushort)triIdx;
}
}
/// <summary>
/// BVH Node - 32 bytes when exported
/// </summary>
public class BVHNode
{
public Bounds bounds;
public BVHNode left;
public BVHNode right;
public List<TriangleRef> triangles; // Only for leaf nodes
public int depth;
// Export indices (filled during serialization)
public int nodeIndex = -1;
public int leftIndex = -1; // -1 = no child (leaf check)
public int rightIndex = -1;
public int firstTriangleIndex = -1;
public int triangleCount = 0;
public bool IsLeaf => left == null && right == null;
}
/// <summary>
/// Triangle with bounds for building
/// </summary>
private struct TriangleWithBounds
{
public TriangleRef reference;
public Bounds bounds;
public Vector3 centroid;
}
public BVH(List<PSXObjectExporter> objects)
{
_objects = objects;
_allNodes = new List<BVHNode>();
_allTriangleRefs = new List<TriangleRef>();
}
public void Build()
{
_allNodes.Clear();
_allTriangleRefs.Clear();
// Extract all triangles with their bounds
List<TriangleWithBounds> triangles = ExtractTriangles();
if (triangles.Count == 0)
{
Debug.LogWarning("BVH: No triangles to process");
return;
}
// Build the tree
_root = BuildNode(triangles, 0);
// Flatten for export
FlattenTree();
}
private List<TriangleWithBounds> ExtractTriangles()
{
var result = new List<TriangleWithBounds>();
for (int objIdx = 0; objIdx < _objects.Count; objIdx++)
{
var exporter = _objects[objIdx];
if (!exporter.IsActive) continue;
MeshFilter mf = exporter.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
Mesh mesh = mf.sharedMesh;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
// Calculate bounds
Bounds triBounds = new Bounds(v0, Vector3.zero);
triBounds.Encapsulate(v1);
triBounds.Encapsulate(v2);
result.Add(new TriangleWithBounds
{
reference = new TriangleRef(objIdx, i / 3),
bounds = triBounds,
centroid = (v0 + v1 + v2) / 3f
});
}
}
return result;
}
private BVHNode BuildNode(List<TriangleWithBounds> triangles, int depth)
{
if (triangles.Count == 0)
return null;
var node = new BVHNode { depth = depth };
// Calculate bounds encompassing all triangles
node.bounds = triangles[0].bounds;
foreach (var tri in triangles)
{
node.bounds.Encapsulate(tri.bounds);
}
// Create leaf if conditions met
if (triangles.Count <= MAX_TRIANGLES_PER_LEAF ||
depth >= MAX_DEPTH ||
triangles.Count < MIN_TRIANGLES_TO_SPLIT)
{
node.triangles = triangles.Select(t => t.reference).ToList();
return node;
}
// Find best split axis (longest extent)
Vector3 extent = node.bounds.size;
int axis = 0;
if (extent.y > extent.x && extent.y > extent.z) axis = 1;
else if (extent.z > extent.x && extent.z > extent.y) axis = 2;
// Sort by centroid along chosen axis
triangles.Sort((a, b) =>
{
float va = axis == 0 ? a.centroid.x : (axis == 1 ? a.centroid.y : a.centroid.z);
float vb = axis == 0 ? b.centroid.x : (axis == 1 ? b.centroid.y : b.centroid.z);
return va.CompareTo(vb);
});
// Find split plane position at median centroid
int mid = triangles.Count / 2;
if (mid == 0) mid = 1;
if (mid >= triangles.Count) mid = triangles.Count - 1;
float splitPos = axis == 0 ? triangles[mid].centroid.x :
(axis == 1 ? triangles[mid].centroid.y : triangles[mid].centroid.z);
// Partition triangles - allow overlap for triangles spanning the split plane
var leftTris = new List<TriangleWithBounds>();
var rightTris = new List<TriangleWithBounds>();
foreach (var tri in triangles)
{
float triMin = axis == 0 ? tri.bounds.min.x : (axis == 1 ? tri.bounds.min.y : tri.bounds.min.z);
float triMax = axis == 0 ? tri.bounds.max.x : (axis == 1 ? tri.bounds.max.y : tri.bounds.max.z);
// Triangle spans split plane - add to BOTH children (spatial split)
// This fixes large triangles at screen edges being culled incorrectly
if (triMin < splitPos && triMax > splitPos)
{
leftTris.Add(tri);
rightTris.Add(tri);
}
// Triangle entirely on left side
else if (triMax <= splitPos)
{
leftTris.Add(tri);
}
// Triangle entirely on right side
else
{
rightTris.Add(tri);
}
}
// Check if split is beneficial (prevents infinite recursion on coincident triangles)
if (leftTris.Count == 0 || rightTris.Count == 0 ||
(leftTris.Count == triangles.Count && rightTris.Count == triangles.Count))
{
node.triangles = triangles.Select(t => t.reference).ToList();
return node;
}
node.left = BuildNode(leftTris, depth + 1);
node.right = BuildNode(rightTris, depth + 1);
return node;
}
/// <summary>
/// Flatten tree to arrays for export
/// </summary>
private void FlattenTree()
{
_allNodes.Clear();
_allTriangleRefs.Clear();
if (_root == null) return;
// BFS to assign indices
var queue = new Queue<BVHNode>();
queue.Enqueue(_root);
while (queue.Count > 0)
{
var node = queue.Dequeue();
node.nodeIndex = _allNodes.Count;
_allNodes.Add(node);
if (node.left != null) queue.Enqueue(node.left);
if (node.right != null) queue.Enqueue(node.right);
}
// Second pass: fill in child indices and triangle data
foreach (var node in _allNodes)
{
if (node.left != null)
node.leftIndex = node.left.nodeIndex;
if (node.right != null)
node.rightIndex = node.right.nodeIndex;
if (node.IsLeaf && node.triangles != null && node.triangles.Count > 0)
{
// Sort tri-refs by objectIndex within each leaf so the C++ renderer
// can batch consecutive refs and avoid redundant GTE matrix reloads.
node.triangles.Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex));
node.firstTriangleIndex = _allTriangleRefs.Count;
node.triangleCount = node.triangles.Count;
_allTriangleRefs.AddRange(node.triangles);
}
}
}
/// <summary>
/// Export BVH to binary writer
/// Format:
/// - uint16 nodeCount
/// - uint16 triangleRefCount
/// - BVHNode[nodeCount] (32 bytes each)
/// - TriangleRef[triangleRefCount] (4 bytes each)
/// </summary>
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
// Note: counts are already in the file header (bvhNodeCount, bvhTriangleRefCount)
// Don't write them again here - C++ reads BVH data directly after colliders
// Write nodes (32 bytes each)
foreach (var node in _allNodes)
{
// AABB bounds (24 bytes)
Vector3 min = node.bounds.min;
Vector3 max = node.bounds.max;
writer.Write(PSXTrig.ConvertWorldToFixed12(min.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-max.y / gteScaling)); // Y flipped
writer.Write(PSXTrig.ConvertWorldToFixed12(min.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(max.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-min.y / gteScaling)); // Y flipped
writer.Write(PSXTrig.ConvertWorldToFixed12(max.z / gteScaling));
// Child indices (4 bytes) - 0xFFFF means no child
writer.Write((ushort)(node.leftIndex >= 0 ? node.leftIndex : 0xFFFF));
writer.Write((ushort)(node.rightIndex >= 0 ? node.rightIndex : 0xFFFF));
// Triangle data (4 bytes)
writer.Write((ushort)(node.firstTriangleIndex >= 0 ? node.firstTriangleIndex : 0));
writer.Write((ushort)node.triangleCount);
}
// Write triangle references (4 bytes each)
foreach (var triRef in _allTriangleRefs)
{
writer.Write(triRef.objectIndex);
writer.Write(triRef.triangleIndex);
}
}
/// <summary>
/// Get total bytes that will be written
/// </summary>
public int GetBinarySize()
{
// Just nodes + triangle refs, counts are in file header
return (_allNodes.Count * 32) + (_allTriangleRefs.Count * 4);
}
/// <summary>
/// Draw gizmos for debugging
/// </summary>
public void DrawGizmos(int maxDepth = 999)
{
if (_root == null) return;
DrawNodeGizmos(_root, maxDepth);
}
private void DrawNodeGizmos(BVHNode node, int maxDepth)
{
if (node == null || node.depth > maxDepth) return;
// Color by depth
Color c = Color.HSVToRGB((node.depth * 0.12f) % 1f, 0.7f, 0.9f);
c.a = node.IsLeaf ? 0.3f : 0.1f;
Gizmos.color = c;
// Draw bounds
Gizmos.DrawWireCube(node.bounds.center, node.bounds.size);
if (node.IsLeaf)
{
// Draw leaf as semi-transparent
Gizmos.color = new Color(c.r, c.g, c.b, 0.1f);
Gizmos.DrawCube(node.bounds.center, node.bounds.size);
}
// Recurse
DrawNodeGizmos(node.left, maxDepth);
DrawNodeGizmos(node.right, maxDepth);
}
/// <summary>
/// Get statistics for debugging
/// </summary>
public string GetStatistics()
{
if (_root == null) return "BVH not built";
int leafCount = 0;
int maxDepth = 0;
int totalTris = 0;
void CountNodes(BVHNode node)
{
if (node == null) return;
if (node.depth > maxDepth) maxDepth = node.depth;
if (node.IsLeaf)
{
leafCount++;
totalTris += node.triangleCount;
}
CountNodes(node.left);
CountNodes(node.right);
}
CountNodes(_root);
return $"Nodes: {_allNodes.Count}, Leaves: {leafCount}, Max Depth: {maxDepth}, Triangle Refs: {totalTris}";
}
}
}

2
Runtime/BVH.cs.meta Normal file
View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 735c7edec8b9f5d4facdf22f48d99ee0

8
Runtime/Core.meta Normal file
View File

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

View File

@@ -0,0 +1,18 @@
using System.IO;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Implemented by scene-level data builders that serialize their output
/// into the splashpack binary stream.
/// </summary>
public interface IPSXBinaryWritable
{
/// <summary>
/// Write binary data to the splashpack stream.
/// </summary>
/// <param name="writer">The binary writer positioned at the correct offset.</param>
/// <param name="gteScaling">GTE coordinate scaling factor.</param>
void WriteToBinary(BinaryWriter writer, float gteScaling);
}
}

View File

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

20
Runtime/IPSXExportable.cs Normal file
View File

@@ -0,0 +1,20 @@
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Implemented by MonoBehaviours that participate in the PSX scene export pipeline.
/// Each exportable object converts its Unity representation into PSX-ready data.
/// </summary>
public interface IPSXExportable
{
/// <summary>
/// Convert Unity textures into PSX texture data (palette-quantized, packed).
/// </summary>
void CreatePSXTextures2D();
/// <summary>
/// Convert the Unity mesh into a PSX-ready triangle list.
/// </summary>
/// <param name="gteScaling">GTE coordinate scaling factor.</param>
void CreatePSXMesh(float gteScaling);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0598c601ee3672b40828f0d31bbec29b

View File

@@ -70,7 +70,7 @@ namespace SplashEdit.RuntimeCode
List<Vector3> centroids = Enumerable.Range(0, k).Select(i => colors[i * colors.Count / k]).ToList();
List<List<Vector3>> clusters;
for (int i = 0; i < 10; i++) // Fixed iterations for performance.... i hate this...
for (int i = 0; i < 10; i++) // Fixed iteration count
{
clusters = Enumerable.Range(0, k).Select(_ => new List<Vector3>()).ToList();
foreach (Vector3 color in colors)

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: 1291c85b333132b8392486949420d31a
guid: c760e5745d5c72746aec8ac9583c456f

View File

@@ -1,6 +1,6 @@
using UnityEngine;
namespace Splashedit.RuntimeCode
namespace SplashEdit.RuntimeCode
{
public class LuaFile : ScriptableObject
{
@@ -13,3 +13,4 @@ namespace Splashedit.RuntimeCode
}
}
}

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: e3b07239f3beb7a87ad987c3fedae9c1
guid: 00e64fcbfc4e23e4dbe284131fa4d89b

43
Runtime/PSXAudioSource.cs Normal file
View File

@@ -0,0 +1,43 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Pre-converted audio clip data ready for splashpack serialization.
/// Populated by the Editor (PSXSceneExporter) so Runtime code never
/// touches PSXAudioConverter.
/// </summary>
public struct AudioClipExport
{
public byte[] adpcmData;
public int sampleRate;
public bool loop;
public string clipName;
}
/// <summary>
/// Attach to a GameObject to include an audio clip in the PS1 build.
/// At export time, the AudioClip is converted to SPU ADPCM and packed
/// into the splashpack binary. Use Audio.Play(clipIndex) from Lua.
/// </summary>
[AddComponentMenu("PSX/Audio Source")]
public class PSXAudioSource : MonoBehaviour
{
[Tooltip("Name used to identify this clip in Lua (Audio.Play(\"name\"))." )]
public string ClipName = "";
[Tooltip("Unity AudioClip to convert to PS1 SPU ADPCM format.")]
public AudioClip Clip;
[Tooltip("Target sample rate for the PS1 (lower = smaller, max 44100).")]
[Range(8000, 44100)]
public int SampleRate = 22050;
[Tooltip("Whether this clip should loop when played.")]
public bool Loop = false;
[Tooltip("Default playback volume (0-127).")]
[Range(0, 127)]
public int DefaultVolume = 100;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c4c3feb30e8c264baddc3a5e774473b

View File

@@ -0,0 +1,357 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Surface flags for collision triangles — must match C++ SurfaceFlag enum
/// </summary>
[Flags]
public enum PSXSurfaceFlag : byte
{
Solid = 0x01,
Slope = 0x02,
Stairs = 0x04,
Trigger = 0x08,
NoWalk = 0x10,
}
/// <summary>
/// Exports scene collision geometry as a flat world-space triangle soup
/// with per-triangle surface flags and world-space AABBs.
///
/// Binary layout (matches C++ structs):
/// CollisionDataHeader (20 bytes)
/// CollisionMeshHeader[meshCount] (32 bytes each)
/// CollisionTri[triangleCount] (52 bytes each)
/// CollisionChunk[chunkW*chunkH] (4 bytes each, exterior only)
/// </summary>
public class PSXCollisionExporter : IPSXBinaryWritable
{
// Configurable
public float WalkableSlopeAngle = 46.0f; // Degrees; steeper = wall
// Build results
private List<CollisionMesh> _meshes = new List<CollisionMesh>();
private List<CollisionTriExport> _allTriangles = new List<CollisionTriExport>();
private CollisionChunkExport[,] _chunks;
private Vector3 _chunkOrigin;
private float _chunkSize;
private int _chunkGridW, _chunkGridH;
public int MeshCount => _meshes.Count;
public int TriangleCount => _allTriangles.Count;
// Internal types
private class CollisionMesh
{
public Bounds worldAABB;
public int firstTriangle;
public int triangleCount;
public byte roomIndex;
}
private struct CollisionTriExport
{
public Vector3 v0, e1, e2, normal;
public byte flags;
public byte roomIndex;
}
private struct CollisionChunkExport
{
public int firstMeshIndex;
public int meshCount;
}
/// <summary>
/// Build collision data from scene exporters.
/// When autoIncludeSolid is true, objects with CollisionType=None are
/// automatically treated as Solid. This ensures all scene geometry
/// blocks the player without requiring manual flagging.
/// </summary>
public void Build(PSXObjectExporter[] exporters, float gteScaling,
bool autoIncludeSolid = true)
{
_meshes.Clear();
_allTriangles.Clear();
float cosWalkable = Mathf.Cos(WalkableSlopeAngle * Mathf.Deg2Rad);
int autoIncluded = 0;
foreach (var exporter in exporters)
{
PSXCollisionType effectiveType = exporter.CollisionType;
if (effectiveType == PSXCollisionType.None)
{
if (autoIncludeSolid)
{
// Auto-include as Solid so all geometry blocks the player
effectiveType = PSXCollisionType.Solid;
autoIncluded++;
}
else
{
continue;
}
}
// Get the collision mesh (custom or render mesh)
MeshFilter mf = exporter.GetComponent<MeshFilter>();
Mesh collisionMesh = exporter.CustomCollisionMesh != null
? exporter.CustomCollisionMesh
: mf?.sharedMesh;
if (collisionMesh == null)
continue;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
Vector3[] vertices = collisionMesh.vertices;
int[] indices = collisionMesh.triangles;
int firstTri = _allTriangles.Count;
Bounds meshBoundsWorld = new Bounds();
bool boundsInit = false;
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
Vector3 edge1 = v1 - v0;
Vector3 edge2 = v2 - v0;
Vector3 normal = Vector3.Cross(edge1, edge2).normalized;
// Determine surface flags
byte flags = 0;
if (effectiveType == PSXCollisionType.Trigger)
{
flags = (byte)PSXSurfaceFlag.Trigger;
}
else
{
// Floor-like: normal.y > cosWalkable
// Note: Unity Y is up; PS1 Y is down. We export in Unity space
// and convert to PS1 space during WriteToBinary.
float dotUp = normal.y;
if (dotUp > cosWalkable)
{
flags = (byte)PSXSurfaceFlag.Solid;
// Check if stairs (tagged on exporter or steep-ish)
if (exporter.ObjectFlags.HasFlag(PSXObjectFlags.Static) &&
dotUp < 0.95f && dotUp > cosWalkable)
{
flags |= (byte)PSXSurfaceFlag.Stairs;
}
}
else if (dotUp > 0.0f)
{
// Slope too steep to walk on
flags = (byte)(PSXSurfaceFlag.Solid | PSXSurfaceFlag.Slope);
}
else
{
// Wall or ceiling
flags = (byte)PSXSurfaceFlag.Solid;
}
}
_allTriangles.Add(new CollisionTriExport
{
v0 = v0,
e1 = edge1,
e2 = edge2,
normal = normal,
flags = flags,
roomIndex = 0xFF,
});
// Update world bounds
if (!boundsInit)
{
meshBoundsWorld = new Bounds(v0, Vector3.zero);
boundsInit = true;
}
meshBoundsWorld.Encapsulate(v0);
meshBoundsWorld.Encapsulate(v1);
meshBoundsWorld.Encapsulate(v2);
}
int triCount = _allTriangles.Count - firstTri;
if (triCount > 0)
{
_meshes.Add(new CollisionMesh
{
worldAABB = meshBoundsWorld,
firstTriangle = firstTri,
triangleCount = triCount,
roomIndex = 0xFF,
});
}
}
// Build spatial grid
if (_meshes.Count > 0)
{
BuildSpatialGrid(gteScaling);
}
else
{
_chunkGridW = 0;
_chunkGridH = 0;
}
}
private void BuildSpatialGrid(float gteScaling)
{
// Compute world bounds of all collision
Bounds allBounds = _meshes[0].worldAABB;
foreach (var mesh in _meshes)
allBounds.Encapsulate(mesh.worldAABB);
// Grid cell size: ~4 GTE units in world space
_chunkSize = 4.0f * gteScaling;
_chunkOrigin = new Vector3(allBounds.min.x, 0, allBounds.min.z);
_chunkGridW = Mathf.CeilToInt((allBounds.max.x - allBounds.min.x) / _chunkSize);
_chunkGridH = Mathf.CeilToInt((allBounds.max.z - allBounds.min.z) / _chunkSize);
// Clamp to reasonable limits
_chunkGridW = Mathf.Clamp(_chunkGridW, 1, 64);
_chunkGridH = Mathf.Clamp(_chunkGridH, 1, 64);
// For each chunk, find which meshes overlap it
// We store mesh indices sorted per chunk
var chunkMeshLists = new List<int>[_chunkGridW, _chunkGridH];
for (int z = 0; z < _chunkGridH; z++)
for (int x = 0; x < _chunkGridW; x++)
chunkMeshLists[x, z] = new List<int>();
for (int mi = 0; mi < _meshes.Count; mi++)
{
var mesh = _meshes[mi];
int minCX = Mathf.FloorToInt((mesh.worldAABB.min.x - _chunkOrigin.x) / _chunkSize);
int maxCX = Mathf.FloorToInt((mesh.worldAABB.max.x - _chunkOrigin.x) / _chunkSize);
int minCZ = Mathf.FloorToInt((mesh.worldAABB.min.z - _chunkOrigin.z) / _chunkSize);
int maxCZ = Mathf.FloorToInt((mesh.worldAABB.max.z - _chunkOrigin.z) / _chunkSize);
minCX = Mathf.Clamp(minCX, 0, _chunkGridW - 1);
maxCX = Mathf.Clamp(maxCX, 0, _chunkGridW - 1);
minCZ = Mathf.Clamp(minCZ, 0, _chunkGridH - 1);
maxCZ = Mathf.Clamp(maxCZ, 0, _chunkGridH - 1);
for (int cz = minCZ; cz <= maxCZ; cz++)
for (int cx = minCX; cx <= maxCX; cx++)
chunkMeshLists[cx, cz].Add(mi);
}
// Flatten into contiguous array (mesh indices already in order)
// We'll write chunks as (firstMeshIndex, meshCount) referencing the mesh header array
_chunks = new CollisionChunkExport[_chunkGridW, _chunkGridH];
for (int z = 0; z < _chunkGridH; z++)
{
for (int x = 0; x < _chunkGridW; x++)
{
var list = chunkMeshLists[x, z];
_chunks[x, z] = new CollisionChunkExport
{
firstMeshIndex = list.Count > 0 ? list[0] : 0,
meshCount = list.Count,
};
}
}
}
/// <summary>
/// Write collision data to binary.
/// All coordinates converted to PS1 20.12 fixed-point with Y flip.
/// </summary>
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
// Header (20 bytes)
writer.Write((ushort)_meshes.Count);
writer.Write((ushort)_allTriangles.Count);
writer.Write((ushort)_chunkGridW);
writer.Write((ushort)_chunkGridH);
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkOrigin.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(_chunkSize / gteScaling));
// Mesh headers (32 bytes each)
foreach (var mesh in _meshes)
{
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.max.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.min.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-mesh.worldAABB.min.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(mesh.worldAABB.max.z / gteScaling));
writer.Write((ushort)mesh.firstTriangle);
writer.Write((ushort)mesh.triangleCount);
writer.Write(mesh.roomIndex);
writer.Write((byte)0);
writer.Write((byte)0);
writer.Write((byte)0);
}
// Triangles (52 bytes each)
foreach (var tri in _allTriangles)
{
// v0
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.v0.y / gteScaling)); // Y flip
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.v0.z / gteScaling));
// edge1
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e1.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e1.z / gteScaling));
// edge2
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.e2.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.e2.z / gteScaling));
// normal (in PS1 space: Y negated)
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.x));
writer.Write(PSXTrig.ConvertWorldToFixed12(-tri.normal.y));
writer.Write(PSXTrig.ConvertWorldToFixed12(tri.normal.z));
// flags
writer.Write(tri.flags);
writer.Write(tri.roomIndex);
writer.Write((ushort)0); // pad
}
// Spatial grid chunks (4 bytes each, exterior only)
if (_chunkGridW > 0 && _chunkGridH > 0)
{
for (int z = 0; z < _chunkGridH; z++)
{
for (int x = 0; x < _chunkGridW; x++)
{
writer.Write((ushort)_chunks[x, z].firstMeshIndex);
writer.Write((ushort)_chunks[x, z].meshCount);
}
}
}
}
/// <summary>
/// Get total bytes that will be written.
/// </summary>
public int GetBinarySize()
{
int size = 20; // header
size += _meshes.Count * 32;
size += _allTriangles.Count * 52;
if (_chunkGridW > 0 && _chunkGridH > 0)
size += _chunkGridW * _chunkGridH * 4;
return size;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 701b39be55b3bbb46b1c2a4ddaa34132

View File

@@ -4,13 +4,7 @@ using UnityEngine;
namespace SplashEdit.RuntimeCode
{
public enum PSXConnectionType
{
REAL_HARDWARE, // Unirom
EMULATOR // PCSX-Redux
}
[CreateAssetMenu(fileName = "PSXData", menuName = "Scriptable Objects/PSXData")]
[CreateAssetMenu(fileName = "PSXData", menuName = "PSXSplash/PS1 Project Data")]
public class PSXData : ScriptableObject
{
@@ -19,20 +13,5 @@ namespace SplashEdit.RuntimeCode
public bool DualBuffering = true;
public bool VerticalBuffering = true;
public List<ProhibitedArea> ProhibitedAreas = new List<ProhibitedArea>();
// Connection settings
public PSXConnectionType ConnectionType = PSXConnectionType.REAL_HARDWARE;
// Real hardware settings
public string PortName = "COM3";
public int BaudRate = 0;
// Emulator settings
public string PCSXReduxPath = "";
}
}

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: cbd8c66199e036896848ce1569567dd6
guid: b6e1524fb8b4b754e965d03e634658e6

View File

@@ -0,0 +1,61 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Makes an object interactable by the player.
/// When the player is within range and presses the interact button,
/// the onInteract Lua event fires.
/// </summary>
[RequireComponent(typeof(PSXObjectExporter))]
public class PSXInteractable : MonoBehaviour
{
[Header("Interaction Settings")]
[Tooltip("Distance within which the player can interact with this object")]
[SerializeField] private float interactionRadius = 2.0f;
[Tooltip("Button that triggers interaction (0-15, matches PS1 button mapping)")]
[SerializeField] private int interactButton = 5; // Default to Cross button
[Tooltip("Can this object be interacted with multiple times?")]
[SerializeField] private bool isRepeatable = true;
[Tooltip("Cooldown between interactions (in frames, 60 = 1 second at NTSC)")]
[SerializeField] private ushort cooldownFrames = 30;
[Tooltip("Show interaction prompt when in range (requires UI system)")]
[SerializeField] private bool showPrompt = true;
[Header("Advanced")]
[Tooltip("Require line-of-sight to player for interaction")]
[SerializeField] private bool requireLineOfSight = false;
[Tooltip("Custom interaction point offset from object center")]
[SerializeField] private Vector3 interactionOffset = Vector3.zero;
// Public accessors for export
public float InteractionRadius => interactionRadius;
public int InteractButton => interactButton;
public bool IsRepeatable => isRepeatable;
public ushort CooldownFrames => cooldownFrames;
public bool ShowPrompt => showPrompt;
public bool RequireLineOfSight => requireLineOfSight;
public Vector3 InteractionOffset => interactionOffset;
private void OnDrawGizmosSelected()
{
// Draw interaction radius
Gizmos.color = new Color(1f, 1f, 0f, 0.3f); // Yellow, semi-transparent
Vector3 center = transform.position + interactionOffset;
Gizmos.DrawWireSphere(center, interactionRadius);
// Draw filled sphere with lower alpha
Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
Gizmos.DrawSphere(center, interactionRadius);
// Draw interaction point
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(center, 0.1f);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b542f4ca31fa6548b8914e96dd0fae2

View File

@@ -1,88 +1,87 @@
using UnityEngine;
public static class PSXLightingBaker
namespace SplashEdit.RuntimeCode
{
/// <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)
public static class PSXLightingBaker
{
Color finalColor = Color.black;
Light[] lights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None);
foreach (Light light in lights)
/// <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>
/// <param name="sceneLights">Pre-gathered array of enabled scene lights (pass to avoid per-vertex FindObjectsByType).</param>
/// <returns>A Color representing the lit vertex.</returns>
public static Color ComputeLighting(Vector3 vertex, Vector3 normal, Light[] sceneLights)
{
if (!light.enabled)
continue;
Color finalColor = Color.black;
Color lightContribution = Color.black;
if (light.type == LightType.Directional)
foreach (Light light in sceneLights)
{
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();
Color lightContribution = Color.black;
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 (light.type == LightType.Directional)
{
if (spotLight.innerSpotAngle > 0)
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)
{
innerAngleRad = (spotLight.innerSpotAngle * 0.5f) * Mathf.Deg2Rad;
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;
}
}
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 += 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;
}
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

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: b707b7d499862621fb6c82aba4caa183
guid: 15a0e6c8af6d78e46bb65ef21c3f75fb

View File

@@ -28,8 +28,17 @@ namespace SplashEdit.RuntimeCode
public PSXVertex v1;
public PSXVertex v2;
/// <summary>
/// Index into the texture list for this triangle's material.
/// -1 means untextured (vertex-color only, rendered as POLY_G3).
/// </summary>
public int TextureIndex;
public readonly PSXVertex[] Vertexes => new PSXVertex[] { v0, v1, v2 };
/// <summary>
/// Whether this triangle is untextured (vertex-color only).
/// Untextured triangles are rendered as GouraudTriangle (POLY_G3) on PS1.
/// </summary>
public bool IsUntextured => TextureIndex == -1;
}
/// <summary>
@@ -77,121 +86,56 @@ namespace SplashEdit.RuntimeCode
/// <summary>
/// Creates a PSXMesh from a Unity Mesh by converting its vertices, normals, UVs, and applying shading.
/// </summary>
/// <param name="mesh">The Unity mesh to convert.</param>
/// <param name="textureWidth">Width of the texture (default is 256).</param>
/// <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>
/// <summary>
/// Creates a PSXMesh from a Unity Renderer by extracting its mesh and materials.
/// </summary>
public static PSXMesh CreateFromUnityRenderer(Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
PSXMesh psxMesh = new PSXMesh { Triangles = new List<Tri>() };
Material[] materials = renderer.sharedMaterials;
Mesh mesh = renderer.GetComponent<MeshFilter>().sharedMesh;
for (int submeshIndex = 0; submeshIndex < materials.Length; submeshIndex++)
{
int[] submeshTriangles = mesh.GetTriangles(submeshIndex);
Material material = materials[submeshIndex];
Texture2D texture = material.mainTexture as Texture2D;
// Find texture index instead of the texture itself
int textureIndex = -1;
if (texture != null)
{
for (int i = 0; i < textures.Count; i++)
{
if (textures[i].OriginalTexture == texture)
{
textureIndex = i;
break;
}
}
}
if (textureIndex == -1)
{
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)
{
Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale);
Vector3 wv = transform.TransformPoint(vertices[index]);
Vector3 wn = transform.TransformDirection(smoothNormals[index]).normalized;
Color c = PSXLightingBaker.ComputeLighting(wv, wn);
return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index], textures[textureIndex]?.Width, textures[textureIndex]?.Height, c);
}
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);
}
psxMesh.Triangles.Add(new Tri
{
v0 = convertData(vid0),
v1 = convertData(vid1),
v2 = convertData(vid2),
TextureIndex = textureIndex
});
}
}
return psxMesh;
return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures);
}
/// <summary>
/// Creates a PSXMesh from a supplied Unity Mesh with the renderer's materials.
/// </summary>
public static PSXMesh CreateFromUnityMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
return BuildFromMesh(mesh, renderer, GTEScaling, transform, textures);
}
private static PSXMesh BuildFromMesh(Mesh mesh, Renderer renderer, float GTEScaling, Transform transform, List<PSXTexture2D> textures)
{
PSXMesh psxMesh = new PSXMesh { Triangles = new List<Tri>() };
Material[] materials = renderer.sharedMaterials;
// Ensure mesh has required data
// Guard: only recalculate normals if missing
if (mesh.normals == null || mesh.normals.Length == 0)
{
mesh.RecalculateNormals();
}
if (mesh.uv == null || mesh.uv.Length == 0)
{
Vector2[] uvs = new Vector2[mesh.vertices.Length];
mesh.uv = uvs;
}
mesh.uv = new Vector2[mesh.vertices.Length];
// Precompute smooth normals for the entire mesh
Vector3[] smoothNormals = RecalculateSmoothNormals(mesh);
// Cache lights once for the entire mesh
Light[] sceneLights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None)
.Where(l => l.enabled).ToArray();
// Precompute world positions and normals for all vertices
Vector3[] worldVertices = new Vector3[mesh.vertices.Length];
Vector3[] worldNormals = new Vector3[mesh.normals.Length];
for (int i = 0; i < mesh.vertices.Length; i++)
{
worldVertices[i] = transform.TransformPoint(mesh.vertices[i]);
worldNormals[i] = transform.TransformDirection(mesh.normals[i]).normalized;
worldNormals[i] = transform.TransformDirection(smoothNormals[i]).normalized;
}
for (int submeshIndex = 0; submeshIndex < mesh.subMeshCount; submeshIndex++)
{
int materialIndex = Mathf.Min(submeshIndex, materials.Length - 1);
Material material = materials[materialIndex];
Texture2D texture = material.mainTexture as Texture2D;
Texture2D texture = material != null ? material.mainTexture as Texture2D : null;
// Find texture index
int textureIndex = -1;
if (texture != null)
{
@@ -206,8 +150,6 @@ namespace SplashEdit.RuntimeCode
}
int[] submeshTriangles = mesh.GetTriangles(submeshIndex);
// Get mesh data arrays
Vector3[] vertices = mesh.vertices;
Vector3[] normals = mesh.normals;
Vector2[] uv = mesh.uv;
@@ -215,16 +157,20 @@ namespace SplashEdit.RuntimeCode
PSXVertex convertData(int index)
{
Vector3 v = Vector3.Scale(vertices[index], transform.lossyScale);
// Use precomputed world position and normal for consistent lighting
Vector3 wv = worldVertices[index];
Vector3 wn = worldNormals[index];
Color c = PSXLightingBaker.ComputeLighting(wv, wn, sceneLights);
// For split triangles, use the original vertex's lighting if possible
Color c = PSXLightingBaker.ComputeLighting(wv, wn);
if (textureIndex == -1)
{
Color matColor = material != null && material.HasProperty("_Color")
? material.color : Color.white;
c = new Color(c.r * matColor.r, c.g * matColor.g, c.b * matColor.b);
return ConvertToPSXVertex(v, GTEScaling, normals[index], Vector2.zero, null, null, c);
}
return ConvertToPSXVertex(v, GTEScaling, normals[index], uv[index],
textures[textureIndex]?.Width, textures[textureIndex]?.Height, c);
textures[textureIndex]?.Width, textures[textureIndex]?.Height, c);
}
for (int i = 0; i < submeshTriangles.Length; i += 3)

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: 9025daa0c62549ee29d968f86c69eec9
guid: 0bde77749a0264146a4ead39946dce2f

View File

@@ -1,95 +0,0 @@
using UnityEngine;
using Unity.AI.Navigation;
using UnityEngine.AI;
using System.Collections.Generic;
namespace SplashEdit.RuntimeCode
{
public struct PSXNavMeshTri
{
public PSXNavmeshVertex v0, v1, v2;
}
public struct PSXNavmeshVertex
{
public short vx, vy, vz;
}
[RequireComponent(typeof(NavMeshSurface))]
public class PSXNavMesh : MonoBehaviour
{
Mesh mesh;
[HideInInspector]
public List<PSXNavMeshTri> Navmesh { get; set; }
public void CreateNavmesh(float GTEScaling)
{
mesh = new Mesh();
Navmesh = new List<PSXNavMeshTri>();
NavMeshSurface navMeshSurface = GetComponent<NavMeshSurface>();
navMeshSurface.overrideTileSize = true;
navMeshSurface.tileSize = 16;
navMeshSurface.overrideVoxelSize = true;
navMeshSurface.voxelSize = 0.1f;
navMeshSurface.BuildNavMesh();
NavMeshTriangulation triangulation = NavMesh.CalculateTriangulation();
navMeshSurface.overrideTileSize = false;
navMeshSurface.overrideVoxelSize = false;
int[] triangles = triangulation.indices;
Vector3[] vertices = triangulation.vertices;
mesh.vertices = vertices;
mesh.triangles = triangles;
mesh.RecalculateNormals();
for (int i = 0; i < triangles.Length; i += 3)
{
int vid0 = triangles[i];
int vid1 = triangles[i + 1];
int vid2 = triangles[i + 2];
PSXNavMeshTri tri = new PSXNavMeshTri();
tri.v0.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].x, GTEScaling);
tri.v0.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid0].y, GTEScaling);
tri.v0.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid0].z, GTEScaling);
tri.v1.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].x, GTEScaling);
tri.v1.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid1].y, GTEScaling);
tri.v1.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid1].z, GTEScaling);
tri.v2.vx = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].x, GTEScaling);
tri.v2.vy = PSXTrig.ConvertCoordinateToPSX(-vertices[vid2].y, GTEScaling);
tri.v2.vz = PSXTrig.ConvertCoordinateToPSX(vertices[vid2].z, GTEScaling);
Navmesh.Add(tri);
}
}
public void OnDrawGizmos()
{
if (mesh == null) return;
Gizmos.DrawMesh(mesh);
Gizmos.color = Color.green;
var vertices = mesh.vertices;
var triangles = mesh.triangles;
for (int i = 0; i < triangles.Length; i += 3)
{
Vector3 v0 = vertices[triangles[i]];
Vector3 v1 = vertices[triangles[i + 1]];
Vector3 v2 = vertices[triangles[i + 2]];
Gizmos.DrawLine(v0, v1);
Gizmos.DrawLine(v1, v2);
Gizmos.DrawLine(v2, v0);
}
}
}
}

View File

@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 6a2f8d45e1591de1e945b3b7bdfb123b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: d695ef52da250cdcea6c30ab1122c56e, type: 3}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,522 @@
using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using DotRecast.Core;
using DotRecast.Core.Numerics;
using DotRecast.Recast;
namespace SplashEdit.RuntimeCode
{
public enum NavSurfaceType : byte { Flat = 0, Ramp = 1, Stairs = 2 }
/// <summary>
/// PS1 nav mesh builder using DotRecast (C# port of Recast).
/// Runs the full Recast voxelization pipeline on scene geometry,
/// then extracts convex polygons with accurate heights from the detail mesh.
/// </summary>
public class PSXNavRegionBuilder
{
public float AgentHeight = 1.8f;
public float AgentRadius = 0.3f;
public float MaxStepHeight = 0.35f;
public float WalkableSlopeAngle = 46.0f;
public float CellSize = 0.05f;
public float CellHeight = 0.025f;
public float MergePlaneError = 0.1f;
public const int MaxVertsPerRegion = 8;
private List<NavRegionExport> _regions = new();
private List<NavPortalExport> _portals = new();
private int _startRegion;
public int RegionCount => _regions.Count;
public int PortalCount => _portals.Count;
public IReadOnlyList<NavRegionExport> Regions => _regions;
public IReadOnlyList<NavPortalExport> Portals => _portals;
public VoxelCell[] DebugCells => null;
public int DebugGridW => 0;
public int DebugGridH => 0;
public float DebugOriginX => 0;
public float DebugOriginZ => 0;
public float DebugVoxelSize => 0;
public class NavRegionExport
{
public List<Vector2> vertsXZ = new();
public float planeA, planeB, planeD;
public int portalStart, portalCount;
public NavSurfaceType surfaceType;
public byte roomIndex;
public Plane floorPlane;
public List<Vector3> worldTris = new();
public List<int> sourceTriIndices = new();
}
public struct NavPortalExport
{
public Vector2 a, b;
public int neighborRegion;
public float heightDelta;
}
public struct VoxelCell
{
public float worldY, slopeAngle;
public bool occupied, blocked;
public int regionId;
}
/// <summary>PSXRoom volumes for spatial room assignment. Set before Build().</summary>
public PSXRoom[] PSXRooms { get; set; }
public void Build(PSXObjectExporter[] exporters, Vector3 spawn)
{
_regions.Clear(); _portals.Clear(); _startRegion = 0;
// 1. Collect world-space geometry from all exporters
var allVerts = new List<float>();
var allTris = new List<int>();
CollectGeometry(exporters, allVerts, allTris);
if (allVerts.Count < 9 || allTris.Count < 3)
{
Debug.LogWarning("[Nav] No geometry to build navmesh from.");
return;
}
float[] verts = allVerts.ToArray();
int[] tris = allTris.ToArray();
int nverts = allVerts.Count / 3;
int ntris = allTris.Count / 3;
// 2. Recast parameters (convert to voxel units)
float cs = CellSize;
float ch = CellHeight;
int walkableHeight = (int)Math.Ceiling(AgentHeight / ch);
int walkableClimb = (int)Math.Floor(MaxStepHeight / ch);
int walkableRadius = (int)Math.Ceiling(AgentRadius / cs);
int maxEdgeLen = (int)(12.0f / cs);
float maxSimplificationError = 1.3f;
int minRegionArea = 8;
int mergeRegionArea = 20;
int maxVertsPerPoly = 6;
float detailSampleDist = cs * 6;
float detailSampleMaxError = ch * 1;
// 3. Compute bounds with border padding
float bminX = float.MaxValue, bminY = float.MaxValue, bminZ = float.MaxValue;
float bmaxX = float.MinValue, bmaxY = float.MinValue, bmaxZ = float.MinValue;
for (int i = 0; i < verts.Length; i += 3)
{
bminX = Math.Min(bminX, verts[i]); bmaxX = Math.Max(bmaxX, verts[i]);
bminY = Math.Min(bminY, verts[i+1]); bmaxY = Math.Max(bmaxY, verts[i+1]);
bminZ = Math.Min(bminZ, verts[i+2]); bmaxZ = Math.Max(bmaxZ, verts[i+2]);
}
float borderPad = walkableRadius * cs;
bminX -= borderPad; bminZ -= borderPad;
bmaxX += borderPad; bmaxZ += borderPad;
var bmin = new RcVec3f(bminX, bminY, bminZ);
var bmax = new RcVec3f(bmaxX, bmaxY, bmaxZ);
int gw = (int)((bmaxX - bminX) / cs + 0.5f);
int gh = (int)((bmaxZ - bminZ) / cs + 0.5f);
// 4. Run Recast pipeline
var ctx = new RcContext();
// Create heightfield
var solid = new RcHeightfield(gw, gh, bmin, bmax, cs, ch, 0);
// Mark walkable triangles
int[] areas = RcRecast.MarkWalkableTriangles(ctx, WalkableSlopeAngle, verts, tris, ntris,
new RcAreaModification(RcRecast.RC_WALKABLE_AREA));
// Rasterize
RcRasterizations.RasterizeTriangles(ctx, verts, tris, areas, ntris, solid, walkableClimb);
// Filter
RcFilters.FilterLowHangingWalkableObstacles(ctx, walkableClimb, solid);
RcFilters.FilterLedgeSpans(ctx, walkableHeight, walkableClimb, solid);
RcFilters.FilterWalkableLowHeightSpans(ctx, walkableHeight, solid);
// Build compact heightfield
var chf = RcCompacts.BuildCompactHeightfield(ctx, walkableHeight, walkableClimb, solid);
// Erode walkable area
RcAreas.ErodeWalkableArea(ctx, walkableRadius, chf);
// Build distance field and regions
RcRegions.BuildDistanceField(ctx, chf);
RcRegions.BuildRegions(ctx, chf, minRegionArea, mergeRegionArea);
// Build contours
var cset = RcContours.BuildContours(ctx, chf, maxSimplificationError, maxEdgeLen,
(int)RcBuildContoursFlags.RC_CONTOUR_TESS_WALL_EDGES);
// Build polygon mesh
var pmesh = RcMeshs.BuildPolyMesh(ctx, cset, maxVertsPerPoly);
// Build detail mesh for accurate heights
var dmesh = RcMeshDetails.BuildPolyMeshDetail(ctx, pmesh, chf, detailSampleDist, detailSampleMaxError);
// 5. Extract polygons as NavRegions
int nvp = pmesh.nvp;
int RC_MESH_NULL_IDX = 0xffff;
for (int i = 0; i < pmesh.npolys; i++)
{
// Count valid vertices in this polygon
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
if (nv < 3) continue;
var region = new NavRegionExport();
var pts3d = new List<Vector3>();
for (int j = 0; j < nv; j++)
{
int vi = pmesh.polys[i * 2 * nvp + j];
// Get XZ from poly mesh (cell coords -> world)
float wx = pmesh.bmin.X + pmesh.verts[vi * 3 + 0] * pmesh.cs;
float wz = pmesh.bmin.Z + pmesh.verts[vi * 3 + 2] * pmesh.cs;
// Get accurate Y from detail mesh
float wy;
if (dmesh != null && i < dmesh.nmeshes)
{
int vbase = dmesh.meshes[i * 4 + 0];
// Detail mesh stores polygon verts first, in order
wy = dmesh.verts[(vbase + j) * 3 + 1];
}
else
{
// Fallback: coarse Y from poly mesh
wy = pmesh.bmin.Y + pmesh.verts[vi * 3 + 1] * pmesh.ch;
}
region.vertsXZ.Add(new Vector2(wx, wz));
pts3d.Add(new Vector3(wx, wy, wz));
}
// Ensure CCW winding
float signedArea = 0;
for (int j = 0; j < region.vertsXZ.Count; j++)
{
var a = region.vertsXZ[j];
var b = region.vertsXZ[(j + 1) % region.vertsXZ.Count];
signedArea += a.x * b.y - b.x * a.y;
}
if (signedArea < 0)
{
region.vertsXZ.Reverse();
pts3d.Reverse();
}
FitPlane(region, pts3d);
_regions.Add(region);
}
// 6. Build portals from Recast neighbor connectivity
var perRegion = new Dictionary<int, List<NavPortalExport>>();
for (int i = 0; i < _regions.Count; i++)
perRegion[i] = new List<NavPortalExport>();
// Build mapping: pmesh poly index -> region index
// (some polys may be skipped if nv < 3, so we need this mapping)
var polyToRegion = new Dictionary<int, int>();
int regionIdx = 0;
for (int i = 0; i < pmesh.npolys; i++)
{
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
if (nv < 3) continue;
polyToRegion[i] = regionIdx++;
}
for (int i = 0; i < pmesh.npolys; i++)
{
if (!polyToRegion.TryGetValue(i, out int srcRegion)) continue;
int nv = 0;
for (int j = 0; j < nvp; j++)
{
if (pmesh.polys[i * 2 * nvp + j] == RC_MESH_NULL_IDX) break;
nv++;
}
for (int j = 0; j < nv; j++)
{
int neighbor = pmesh.polys[i * 2 * nvp + nvp + j];
if (neighbor == RC_MESH_NULL_IDX || (neighbor & 0x8000) != 0) continue;
if (!polyToRegion.TryGetValue(neighbor, out int dstRegion)) continue;
// Portal edge vertices from pmesh directly (NOT from region,
// which may have been reversed for CCW winding)
int vi0 = pmesh.polys[i * 2 * nvp + j];
int vi1 = pmesh.polys[i * 2 * nvp + (j + 1) % nv];
float ax = pmesh.bmin.X + pmesh.verts[vi0 * 3 + 0] * pmesh.cs;
float az = pmesh.bmin.Z + pmesh.verts[vi0 * 3 + 2] * pmesh.cs;
float bx = pmesh.bmin.X + pmesh.verts[vi1 * 3 + 0] * pmesh.cs;
float bz = pmesh.bmin.Z + pmesh.verts[vi1 * 3 + 2] * pmesh.cs;
var a2 = new Vector2(ax, az);
var b2 = new Vector2(bx, bz);
// Height delta at midpoint of portal edge
var mid = new Vector2((ax + bx) / 2, (az + bz) / 2);
float yHere = EvalY(_regions[srcRegion], mid);
float yThere = EvalY(_regions[dstRegion], mid);
perRegion[srcRegion].Add(new NavPortalExport
{
a = a2,
b = b2,
neighborRegion = dstRegion,
heightDelta = yThere - yHere
});
}
}
// Assign portals
foreach (var kvp in perRegion)
{
_regions[kvp.Key].portalStart = _portals.Count;
_regions[kvp.Key].portalCount = kvp.Value.Count;
_portals.AddRange(kvp.Value);
}
// 7. Assign rooms: spatial containment if PSXRooms provided, BFS fallback
if (PSXRooms != null && PSXRooms.Length > 0)
AssignRoomsFromPSXRooms(PSXRooms);
else
AssignRoomsByBFS();
// 8. Find start region closest to spawn
_startRegion = FindClosestRegion(spawn);
}
void CollectGeometry(PSXObjectExporter[] exporters, List<float> outVerts, List<int> outTris)
{
foreach (var exporter in exporters)
{
MeshFilter mf = exporter.GetComponent<MeshFilter>();
Mesh mesh = mf?.sharedMesh;
if (mesh == null) continue;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
int baseVert = outVerts.Count / 3;
foreach (var v in vertices)
{
Vector3 w = worldMatrix.MultiplyPoint3x4(v);
outVerts.Add(w.x);
outVerts.Add(w.y);
outVerts.Add(w.z);
}
// Filter triangles: reject downward-facing normals
// (ceilings, roofs, undersides) which should never be walkable.
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
Vector3 normal = Vector3.Cross(v1 - v0, v2 - v0);
// Skip triangles whose world-space normal points downward (y < 0)
// This eliminates ceilings/roofs that Recast might incorrectly voxelize
if (normal.y < 0f) continue;
outTris.Add(indices[i] + baseVert);
outTris.Add(indices[i + 1] + baseVert);
outTris.Add(indices[i + 2] + baseVert);
}
}
}
int FindClosestRegion(Vector3 spawn)
{
int best = 0;
float bestDist = float.MaxValue;
for (int i = 0; i < _regions.Count; i++)
{
var r = _regions[i];
// Compute centroid
float cx = 0, cz = 0;
foreach (var v in r.vertsXZ) { cx += v.x; cz += v.y; }
cx /= r.vertsXZ.Count; cz /= r.vertsXZ.Count;
float cy = EvalY(r, new Vector2(cx, cz));
float dx = spawn.x - cx, dy = spawn.y - cy, dz = spawn.z - cz;
float dist = dx * dx + dy * dy + dz * dz;
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
float EvalY(NavRegionExport r, Vector2 xz) => r.planeA * xz.x + r.planeB * xz.y + r.planeD;
void FitPlane(NavRegionExport r, List<Vector3> pts)
{
int n = pts.Count;
if (n < 3) { r.planeA = 0; r.planeB = 0; r.planeD = n > 0 ? pts[0].y : 0; r.surfaceType = NavSurfaceType.Flat; return; }
if (n == 3)
{
// Exact 3-point solve: Y = A*X + B*Z + D
double x0 = pts[0].x, z0 = pts[0].z, y0 = pts[0].y;
double x1 = pts[1].x, z1 = pts[1].z, y1 = pts[1].y;
double x2 = pts[2].x, z2 = pts[2].z, y2 = pts[2].y;
double det = (x0 - x2) * (z1 - z2) - (x1 - x2) * (z0 - z2);
if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)((y0 + y1 + y2) / 3); }
else
{
double inv = 1.0 / det;
r.planeA = (float)(((y0 - y2) * (z1 - z2) - (y1 - y2) * (z0 - z2)) * inv);
r.planeB = (float)(((x0 - x2) * (y1 - y2) - (x1 - x2) * (y0 - y2)) * inv);
r.planeD = (float)(y0 - r.planeA * x0 - r.planeB * z0);
}
}
else
{
// Least-squares: Y = A*X + B*Z + D
double sX = 0, sZ = 0, sY = 0, sXX = 0, sXZ = 0, sZZ = 0, sXY = 0, sZY = 0;
foreach (var p in pts) { sX += p.x; sZ += p.z; sY += p.y; sXX += p.x * p.x; sXZ += p.x * p.z; sZZ += p.z * p.z; sXY += p.x * p.y; sZY += p.z * p.y; }
double det = sXX * (sZZ * n - sZ * sZ) - sXZ * (sXZ * n - sZ * sX) + sX * (sXZ * sZ - sZZ * sX);
if (Math.Abs(det) < 1e-12) { r.planeA = 0; r.planeB = 0; r.planeD = (float)(sY / n); }
else
{
double inv = 1.0 / det;
r.planeA = (float)((sXY * (sZZ * n - sZ * sZ) - sXZ * (sZY * n - sZ * sY) + sX * (sZY * sZ - sZZ * sY)) * inv);
r.planeB = (float)((sXX * (sZY * n - sZ * sY) - sXY * (sXZ * n - sZ * sX) + sX * (sXZ * sY - sZY * sX)) * inv);
r.planeD = (float)((sXX * (sZZ * sY - sZ * sZY) - sXZ * (sXZ * sY - sZY * sX) + sXY * (sXZ * sZ - sZZ * sX)) * inv);
}
}
float slope = Mathf.Atan(Mathf.Sqrt(r.planeA * r.planeA + r.planeB * r.planeB)) * Mathf.Rad2Deg;
r.surfaceType = slope < 3f ? NavSurfaceType.Flat : slope < 25f ? NavSurfaceType.Ramp : NavSurfaceType.Stairs;
}
/// <summary>
/// Assign room indices to nav regions using PSXRoom spatial containment.
/// Each region's centroid is tested against all PSXRoom volumes. The smallest
/// containing room wins (most specific). Regions outside all rooms get 0xFF.
/// This ensures nav region room indices match the PSXRoomBuilder room indices
/// used by the rendering portal system.
/// </summary>
void AssignRoomsFromPSXRooms(PSXRoom[] psxRooms)
{
const float MARGIN = 0.5f;
Bounds[] roomBounds = new Bounds[psxRooms.Length];
for (int r = 0; r < psxRooms.Length; r++)
{
roomBounds[r] = psxRooms[r].GetWorldBounds();
roomBounds[r].Expand(MARGIN * 2f);
}
for (int i = 0; i < _regions.Count; i++)
{
var reg = _regions[i];
// Compute region centroid from polygon vertices
float cx = 0, cz = 0;
foreach (var v in reg.vertsXZ) { cx += v.x; cz += v.y; }
cx /= reg.vertsXZ.Count; cz /= reg.vertsXZ.Count;
float cy = EvalY(reg, new Vector2(cx, cz));
Vector3 centroid = new Vector3(cx, cy, cz);
byte bestRoom = 0xFF;
float bestVolume = float.MaxValue;
for (int r = 0; r < psxRooms.Length; r++)
{
if (roomBounds[r].Contains(centroid))
{
float vol = roomBounds[r].size.x * roomBounds[r].size.y * roomBounds[r].size.z;
if (vol < bestVolume)
{
bestVolume = vol;
bestRoom = (byte)r;
}
}
}
reg.roomIndex = bestRoom;
}
}
/// <summary>
/// Fallback room assignment via BFS over nav portal connectivity.
/// Used when no PSXRoom volumes exist (exterior scenes).
/// </summary>
void AssignRoomsByBFS()
{
byte room = 0;
var vis = new bool[_regions.Count];
for (int i = 0; i < _regions.Count; i++)
{
if (vis[i]) continue;
byte rm = room++;
var q = new Queue<int>(); q.Enqueue(i); vis[i] = true;
while (q.Count > 0)
{
int ri = q.Dequeue(); _regions[ri].roomIndex = rm;
for (int p = _regions[ri].portalStart; p < _regions[ri].portalStart + _regions[ri].portalCount; p++)
{
int nb = _portals[p].neighborRegion;
if (nb >= 0 && nb < _regions.Count && !vis[nb]) { vis[nb] = true; q.Enqueue(nb); }
}
}
}
}
public void WriteToBinary(BinaryWriter writer, float gteScaling)
{
writer.Write((ushort)_regions.Count);
writer.Write((ushort)_portals.Count);
writer.Write((ushort)_startRegion);
writer.Write((ushort)0);
foreach (var r in _regions)
{
for (int v = 0; v < MaxVertsPerRegion; v++)
writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].x / gteScaling) : 0);
for (int v = 0; v < MaxVertsPerRegion; v++)
writer.Write(v < r.vertsXZ.Count ? PSXTrig.ConvertWorldToFixed12(r.vertsXZ[v].y / gteScaling) : 0);
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeA));
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeB));
writer.Write(PSXTrig.ConvertWorldToFixed12(-r.planeD / gteScaling));
writer.Write((ushort)r.portalStart);
writer.Write((byte)r.portalCount);
writer.Write((byte)r.vertsXZ.Count);
writer.Write((byte)r.surfaceType);
writer.Write(r.roomIndex);
writer.Write((byte)0);
writer.Write((byte)0);
}
foreach (var p in _portals)
{
writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.a.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(p.b.y / gteScaling));
writer.Write((ushort)p.neighborRegion);
writer.Write((short)PSXTrig.ConvertToFixed12(p.heightDelta / gteScaling));
}
}
public int GetBinarySize() => 8 + _regions.Count * 84 + _portals.Count * 20;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7446b9ee150d0994fb534c61cd894d6c

View File

@@ -1,16 +1,45 @@
using System.Collections.Generic;
using Splashedit.RuntimeCode;
using SplashEdit.RuntimeCode;
using UnityEngine;
using UnityEngine.Serialization;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Collision type for PS1 runtime
/// </summary>
public enum PSXCollisionType
{
None = 0, // No collision
Solid = 1, // Solid collision - blocks movement
Trigger = 2, // Trigger - fires events but doesn't block
Platform = 3 // Platform - solid from above, passable from below
}
/// <summary>
/// Object behavior flags for PS1 runtime
/// </summary>
[System.Flags]
public enum PSXObjectFlags
{
None = 0,
Static = 1 << 0, // Object never moves (can be optimized)
Dynamic = 1 << 1, // Object can move
Visible = 1 << 2, // Object is rendered
CastsShadow = 1 << 3, // Object casts shadows (future)
ReceivesShadow = 1 << 4, // Object receives shadows (future)
Interactable = 1 << 5, // Player can interact with this
AlwaysRender = 1 << 6, // Skip frustum culling for this object
}
[RequireComponent(typeof(Renderer))]
public class PSXObjectExporter : MonoBehaviour
public class PSXObjectExporter : MonoBehaviour, IPSXExportable
{
public LuaFile LuaFile => luaFile;
public bool IsActive = true;
[FormerlySerializedAs("IsActive")]
[SerializeField] private bool isActive = true;
public bool IsActive => isActive;
public List<PSXTexture2D> Textures { get; set; } = new List<PSXTexture2D>();
public PSXMesh Mesh { get; protected set; }
@@ -20,22 +49,41 @@ namespace SplashEdit.RuntimeCode
[SerializeField] private PSXBPP bitDepth = PSXBPP.TEX_8BIT;
[SerializeField] private LuaFile luaFile;
[Header("BSP Settings")]
[SerializeField] private Mesh _modifiedMesh; // Mesh after BSP processing
[Header("Object Flags")]
[SerializeField] private PSXObjectFlags objectFlags = PSXObjectFlags.Static | PSXObjectFlags.Visible;
[Header("Collision Settings")]
[SerializeField] private PSXCollisionType collisionType = PSXCollisionType.None;
[SerializeField] private bool exportCollisionMesh = false;
[SerializeField] private Mesh customCollisionMesh; // Optional simplified collision mesh
[Tooltip("Layer mask for collision detection (1-8)")]
[Range(1, 8)]
[SerializeField] private int collisionLayer = 1;
[Header("Navigation")]
[Tooltip("Include this object's walkable surfaces in nav region generation")]
[SerializeField] private bool generateNavigation = false;
[Header("Gizmo Settings")]
[FormerlySerializedAs("PreviewNormals")]
[SerializeField] private bool previewNormals = false;
[SerializeField] private float normalPreviewLength = 0.5f;
[SerializeField] private bool showCollisionBounds = true;
// Public accessors for editor and export
public PSXBPP BitDepth => bitDepth;
public PSXCollisionType CollisionType => collisionType;
public bool ExportCollisionMesh => exportCollisionMesh;
public Mesh CustomCollisionMesh => customCollisionMesh;
public int CollisionLayer => collisionLayer;
public PSXObjectFlags ObjectFlags => objectFlags;
public bool GenerateNavigation => generateNavigation;
// For assigning texture from editor
public Texture2D texture;
private readonly Dictionary<(int, PSXBPP), PSXTexture2D> cache = new();
public Mesh ModifiedMesh
{
get => _modifiedMesh;
set => _modifiedMesh = value;
}
private void OnDrawGizmos()
{
if (previewNormals)
@@ -60,6 +108,48 @@ namespace SplashEdit.RuntimeCode
}
}
}
private void OnDrawGizmosSelected()
{
// Draw collision bounds when object is selected
if (showCollisionBounds && collisionType != PSXCollisionType.None)
{
MeshFilter filter = GetComponent<MeshFilter>();
Mesh collisionMesh = customCollisionMesh != null ? customCollisionMesh : (filter?.sharedMesh);
if (collisionMesh != null)
{
Bounds bounds = collisionMesh.bounds;
// Choose color based on collision type
switch (collisionType)
{
case PSXCollisionType.Solid:
Gizmos.color = new Color(1f, 0.3f, 0.3f, 0.5f); // Red
break;
case PSXCollisionType.Trigger:
Gizmos.color = new Color(0.3f, 1f, 0.3f, 0.5f); // Green
break;
case PSXCollisionType.Platform:
Gizmos.color = new Color(0.3f, 0.3f, 1f, 0.5f); // Blue
break;
}
// Draw AABB
Matrix4x4 oldMatrix = Gizmos.matrix;
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawWireCube(bounds.center, bounds.size);
// Draw filled with lower alpha
Color fillColor = Gizmos.color;
fillColor.a = 0.1f;
Gizmos.color = fillColor;
Gizmos.DrawCube(bounds.center, bounds.size);
Gizmos.matrix = oldMatrix;
}
}
}
public void CreatePSXTextures2D()
{
@@ -67,6 +157,24 @@ namespace SplashEdit.RuntimeCode
Textures.Clear();
if (renderer != null)
{
// If an override texture is set, use it for all submeshes
if (texture != null)
{
PSXTexture2D tex;
if (cache.ContainsKey((texture.GetInstanceID(), bitDepth)))
{
tex = cache[(texture.GetInstanceID(), bitDepth)];
}
else
{
tex = PSXTexture2D.CreateFromTexture2D(texture, bitDepth);
tex.OriginalTexture = texture;
cache.Add((texture.GetInstanceID(), bitDepth), tex);
}
Textures.Add(tex);
return;
}
Material[] materials = renderer.sharedMaterials;
foreach (Material mat in materials)
@@ -129,34 +237,12 @@ namespace SplashEdit.RuntimeCode
return null;
}
public void CreatePSXMesh(float GTEScaling, bool useBSP = false)
public void CreatePSXMesh(float GTEScaling)
{
Renderer renderer = GetComponent<Renderer>();
if (renderer != null)
{
if (useBSP && _modifiedMesh != null)
{
// Create a temporary GameObject with the modified mesh but same materials
GameObject tempGO = new GameObject("TempBSPMesh");
tempGO.transform.position = transform.position;
tempGO.transform.rotation = transform.rotation;
tempGO.transform.localScale = transform.localScale;
MeshFilter tempMF = tempGO.AddComponent<MeshFilter>();
tempMF.sharedMesh = _modifiedMesh;
MeshRenderer tempMR = tempGO.AddComponent<MeshRenderer>();
tempMR.sharedMaterials = renderer.sharedMaterials;
Mesh = PSXMesh.CreateFromUnityRenderer(tempMR, GTEScaling, transform, Textures);
// Clean up
GameObject.DestroyImmediate(tempGO);
}
else
{
Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures);
}
Mesh = PSXMesh.CreateFromUnityRenderer(renderer, GTEScaling, transform, Textures);
}
}
}

View File

@@ -1,11 +1,2 @@
fileFormatVersion: 2
guid: bea0f31a495202580ac77bd9fd6e99f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: e11677149a517ca5186e32dfda3ec088, type: 3}
userData:
assetBundleName:
assetBundleVariant:
guid: a192e0a30d827ba40be5c99d32a83a12

View File

@@ -1,5 +1,4 @@
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Serialization;
@@ -7,27 +6,81 @@ namespace SplashEdit.RuntimeCode
{
public class PSXPlayer : MonoBehaviour
{
private const float LookOutDistance = 1000f;
[Header("Player Dimensions")]
[FormerlySerializedAs("PlayerHeight")]
[SerializeField] private float playerHeight;
[Tooltip("Camera eye height above the player's feet")]
[SerializeField] private float playerHeight = 1.8f;
[Tooltip("Collision radius for wall sliding")]
[SerializeField] private float playerRadius = 0.5f;
[Header("Movement")]
[Tooltip("Walk speed in world units per second")]
[SerializeField] private float moveSpeed = 3.0f;
[Tooltip("Sprint speed in world units per second")]
[SerializeField] private float sprintSpeed = 8.0f;
[Header("Navigation")]
[Tooltip("Maximum height the agent can step up")]
[SerializeField] private float maxStepHeight = 0.35f;
[Tooltip("Maximum walkable slope angle in degrees")]
[SerializeField] private float walkableSlopeAngle = 46.0f;
[Tooltip("Voxel size in XZ plane (smaller = more accurate but slower)")]
[SerializeField] private float navCellSize = 0.05f;
[Tooltip("Voxel height (smaller = more accurate vertical resolution)")]
[SerializeField] private float navCellHeight = 0.025f;
[Header("Jump & Gravity")]
[Tooltip("Peak jump height in world units")]
[SerializeField] private float jumpHeight = 2.0f;
[Tooltip("Downward acceleration in world units per second squared (positive value)")]
[SerializeField] private float gravity = 20.0f;
// Public accessors
public float PlayerHeight => playerHeight;
public float PlayerRadius => playerRadius;
public float MoveSpeed => moveSpeed;
public float SprintSpeed => sprintSpeed;
public float MaxStepHeight => maxStepHeight;
public float WalkableSlopeAngle => walkableSlopeAngle;
public float NavCellSize => navCellSize;
public float NavCellHeight => navCellHeight;
public float JumpHeight => jumpHeight;
public float Gravity => gravity;
public Vector3 CamPoint { get; protected set; }
public void FindNavmesh()
{
if (NavMesh.SamplePosition(transform.position, out NavMeshHit hit, LookOutDistance, NavMesh.AllAreas))
// Raycast down from the transform to find the ground,
// then place CamPoint at ground + playerHeight
if (Physics.Raycast(transform.position, Vector3.down, out RaycastHit hit, 100f))
{
CamPoint = hit.position + new Vector3(0, PlayerHeight, 0);
CamPoint = hit.point + new Vector3(0, playerHeight, 0);
}
else
{
// Fallback: no ground hit, use transform directly
CamPoint = transform.position + new Vector3(0, playerHeight, 0);
}
}
void OnDrawGizmos()
{
FindNavmesh();
// Red sphere at camera eye point
Gizmos.color = Color.red;
Gizmos.DrawSphere(CamPoint, 0.2f);
// Wireframe sphere at feet showing player radius
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
Vector3 feet = CamPoint - new Vector3(0, playerHeight, 0);
Gizmos.DrawWireSphere(feet, playerRadius);
}
}
}

View File

@@ -1,11 +1,2 @@
fileFormatVersion: 2
guid: dee32f3a19300d7a3aae7424f01c9332
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 4d7bd095e76e6f3df976224b15405059, type: 3}
userData:
assetBundleName:
assetBundleVariant:
guid: 3cc71b54d0db2604087cd4ae7781dc98

59
Runtime/PSXPortalLink.cs Normal file
View File

@@ -0,0 +1,59 @@
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Defines a portal connecting two PSXRoom volumes.
/// Place this object between two rooms and drag-and-drop the PSXRoom references
/// into RoomA and RoomB. The transform position becomes the portal center used
/// for the camera-forward visibility test at runtime on PS1.
///
/// This is independent of the navigation portal system (PSXNavRegion).
/// </summary>
[ExecuteInEditMode]
public class PSXPortalLink : MonoBehaviour
{
[Tooltip("First room connected by this portal.")]
public PSXRoom RoomA;
[Tooltip("Second room connected by this portal.")]
public PSXRoom RoomB;
[Tooltip("Size of the portal opening (width, height) in world units. " +
"Used for the gizmo visualization and the screen-space margin " +
"when checking portal visibility at runtime.")]
public Vector2 PortalSize = new Vector2(2f, 3f);
void OnDrawGizmos()
{
Gizmos.color = new Color(1f, 0.5f, 0f, 0.3f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f));
Gizmos.color = new Color(1f, 0.5f, 0f, 0.8f);
Gizmos.DrawWireCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f));
Gizmos.matrix = Matrix4x4.identity;
// Draw lines to connected rooms.
if (RoomA != null)
{
Gizmos.color = Color.green;
Gizmos.DrawLine(transform.position, RoomA.transform.position);
}
if (RoomB != null)
{
Gizmos.color = Color.cyan;
Gizmos.DrawLine(transform.position, RoomB.transform.position);
}
}
void OnDrawGizmosSelected()
{
Gizmos.color = new Color(1f, 0.7f, 0.2f, 0.6f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f));
Gizmos.color = Color.yellow;
Gizmos.DrawWireCube(Vector3.zero, new Vector3(PortalSize.x, PortalSize.y, 0.05f));
Gizmos.matrix = Matrix4x4.identity;
}
}
}

View File

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

439
Runtime/PSXRoom.cs Normal file
View File

@@ -0,0 +1,439 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Defines a convex room volume for the portal/room occlusion system.
/// Place one of these per room in the scene. Geometry is assigned to rooms by
/// centroid containment during export. Portals between adjacent rooms are detected
/// automatically.
///
/// This is independent of the navregion/portal system used for navigation.
/// </summary>
[ExecuteInEditMode]
public class PSXRoom : MonoBehaviour
{
[Tooltip("Optional display name for this room (used in editor gizmos).")]
public string RoomName = "";
[Tooltip("Size of the room volume in local space. Defaults to the object's scale.")]
public Vector3 VolumeSize = Vector3.one;
[Tooltip("Offset of the volume center relative to the transform position.")]
public Vector3 VolumeOffset = Vector3.zero;
/// <summary>World-space AABB of this room.</summary>
public Bounds GetWorldBounds()
{
Vector3 center = transform.TransformPoint(VolumeOffset);
// Transform the 8 corners to get a world-space AABB
Vector3 halfSize = VolumeSize * 0.5f;
Vector3 wMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
Vector3 wMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
for (int i = 0; i < 8; i++)
{
Vector3 corner = VolumeOffset + new Vector3(
(i & 1) != 0 ? halfSize.x : -halfSize.x,
(i & 2) != 0 ? halfSize.y : -halfSize.y,
(i & 4) != 0 ? halfSize.z : -halfSize.z
);
Vector3 world = transform.TransformPoint(corner);
wMin = Vector3.Min(wMin, world);
wMax = Vector3.Max(wMax, world);
}
Bounds b = new Bounds();
b.SetMinMax(wMin, wMax);
return b;
}
/// <summary>Check if a world-space point is inside this room volume.</summary>
public bool ContainsPoint(Vector3 worldPoint)
{
return GetWorldBounds().Contains(worldPoint);
}
void OnDrawGizmos()
{
Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.15f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(VolumeOffset, VolumeSize);
Gizmos.color = new Color(0.2f, 0.8f, 0.4f, 0.6f);
Gizmos.DrawWireCube(VolumeOffset, VolumeSize);
Gizmos.matrix = Matrix4x4.identity;
#if UNITY_EDITOR
if (!string.IsNullOrEmpty(RoomName))
{
UnityEditor.Handles.Label(transform.TransformPoint(VolumeOffset),
RoomName, new GUIStyle { normal = { textColor = Color.green } });
}
#endif
}
}
/// <summary>
/// Portal between two PSXRoom volumes, stored during export.
/// Built from PSXPortalLink scene components.
/// </summary>
public struct PSXPortal
{
public int roomA;
public int roomB;
public Vector3 center; // World-space portal center (from PSXPortalLink transform)
public Vector2 portalSize; // Portal opening size in world units (width, height)
public Vector3 normal; // Portal facing direction (from PSXPortalLink transform.forward)
public Vector3 right; // Portal local right axis (world space)
public Vector3 up; // Portal local up axis (world space)
}
/// <summary>
/// Builds and exports the room/portal system for a scene.
/// Called during PSXSceneExporter.Export().
/// Portals are user-defined via PSXPortalLink components instead of auto-detected.
/// </summary>
public class PSXRoomBuilder
{
private PSXRoom[] _rooms;
private List<PSXPortal> _portals = new List<PSXPortal>();
private List<BVH.TriangleRef>[] _roomTriRefs;
private List<BVH.TriangleRef> _catchAllTriRefs = new List<BVH.TriangleRef>();
public int RoomCount => _rooms?.Length ?? 0;
public int PortalCount => _portals?.Count ?? 0;
public int TotalTriRefCount
{
get
{
int count = 0;
if (_roomTriRefs != null)
foreach (var list in _roomTriRefs) count += list.Count;
count += _catchAllTriRefs.Count;
return count;
}
}
/// <summary>
/// Build the room system: assign triangles to rooms and read user-defined portals.
/// </summary>
/// <param name="rooms">All PSXRoom components in the scene.</param>
/// <param name="portalLinks">All PSXPortalLink components (user-placed portals).</param>
/// <param name="exporters">All object exporters (for triangle centroid testing).</param>
/// <param name="gteScaling">GTE coordinate scaling factor.</param>
public void Build(PSXRoom[] rooms, PSXPortalLink[] portalLinks,
PSXObjectExporter[] exporters, float gteScaling)
{
_rooms = rooms;
if (rooms == null || rooms.Length == 0) return;
_roomTriRefs = new List<BVH.TriangleRef>[rooms.Length];
for (int i = 0; i < rooms.Length; i++)
_roomTriRefs[i] = new List<BVH.TriangleRef>();
_catchAllTriRefs.Clear();
_portals.Clear();
// Assign each triangle to a room by vertex majority containment.
// For each triangle, test all 3 world-space vertices against each room's AABB
// (expanded by a margin to catch boundary geometry). The room containing the
// most vertices wins. Ties broken by centroid. This prevents boundary triangles
// (doorway walls, floor edges) from being assigned to the wrong room.
const float ROOM_MARGIN = 0.5f; // expand AABBs by this much for testing
Bounds[] roomBounds = new Bounds[rooms.Length];
for (int i = 0; i < rooms.Length; i++)
{
roomBounds[i] = rooms[i].GetWorldBounds();
roomBounds[i].Expand(ROOM_MARGIN * 2f); // Expand in all directions
}
for (int objIdx = 0; objIdx < exporters.Length; objIdx++)
{
var exporter = exporters[objIdx];
if (!exporter.IsActive) continue;
MeshFilter mf = exporter.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
Mesh mesh = mf.sharedMesh;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
for (int i = 0; i < indices.Length; i += 3)
{
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[i]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[i + 2]]);
// Test all 3 vertices against each room, pick room with most hits.
int bestRoom = -1;
int bestHits = 0;
float bestDist = float.MaxValue;
for (int r = 0; r < rooms.Length; r++)
{
int hits = 0;
if (roomBounds[r].Contains(v0)) hits++;
if (roomBounds[r].Contains(v1)) hits++;
if (roomBounds[r].Contains(v2)) hits++;
if (hits > bestHits)
{
bestHits = hits;
bestRoom = r;
bestDist = (roomBounds[r].center - (v0 + v1 + v2) / 3f).sqrMagnitude;
}
else if (hits == bestHits && hits > 0)
{
// Tie-break: pick room whose center is closest to centroid
float dist = (roomBounds[r].center - (v0 + v1 + v2) / 3f).sqrMagnitude;
if (dist < bestDist)
{
bestRoom = r;
bestDist = dist;
}
}
}
var triRef = new BVH.TriangleRef(objIdx, i / 3);
if (bestRoom >= 0)
_roomTriRefs[bestRoom].Add(triRef);
else
_catchAllTriRefs.Add(triRef);
}
}
// Build portals from user-placed PSXPortalLink components.
// (Must happen before boundary duplication so we know which rooms are adjacent.)
BuildPortals(portalLinks);
// Phase 3: Duplicate boundary triangles into both rooms at portal boundaries.
// When a triangle has vertices in multiple rooms, it was assigned to only
// the "best" room. For triangles near a portal, also add them to the adjacent
// room so doorway/boundary geometry is visible from both sides.
DuplicateBoundaryTriangles(exporters, roomBounds);
// Sort each room's tri-refs by objectIndex for GTE matrix batching.
for (int i = 0; i < _roomTriRefs.Length; i++)
_roomTriRefs[i].Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex));
_catchAllTriRefs.Sort((a, b) => a.objectIndex.CompareTo(b.objectIndex));
Debug.Log($"PSXRoomBuilder: {rooms.Length} rooms, {_portals.Count} portals, " +
$"{TotalTriRefCount} tri-refs ({_catchAllTriRefs.Count} catch-all)");
}
/// <summary>
/// For each portal, find triangles assigned to one adjacent room whose vertices
/// also touch the other adjacent room. Duplicate those triangles into the other
/// room so boundary geometry (doorway walls, floor edges) is visible from both sides.
/// </summary>
private void DuplicateBoundaryTriangles(PSXObjectExporter[] exporters, Bounds[] roomBounds)
{
if (_portals.Count == 0) return;
int duplicated = 0;
// Build a set of existing tri-refs per room for O(1) duplicate checking
var roomSets = new HashSet<long>[_rooms.Length];
for (int i = 0; i < _rooms.Length; i++)
{
roomSets[i] = new HashSet<long>();
foreach (var tr in _roomTriRefs[i])
roomSets[i].Add(((long)tr.objectIndex << 16) | tr.triangleIndex);
}
foreach (var portal in _portals)
{
int rA = portal.roomA, rB = portal.roomB;
if (rA < 0 || rA >= _rooms.Length || rB < 0 || rB >= _rooms.Length) continue;
// For each triangle in room A, check if any vertex is inside room B's AABB.
// If so, add a copy to room B (and vice versa).
DuplicateDirection(rA, rB, exporters, roomBounds, roomSets, ref duplicated);
DuplicateDirection(rB, rA, exporters, roomBounds, roomSets, ref duplicated);
}
if (duplicated > 0)
Debug.Log($"PSXRoomBuilder: Duplicated {duplicated} boundary triangles across portal edges.");
}
private void DuplicateDirection(int srcRoom, int dstRoom,
PSXObjectExporter[] exporters, Bounds[] roomBounds,
HashSet<long>[] roomSets, ref int duplicated)
{
var srcList = new List<BVH.TriangleRef>(_roomTriRefs[srcRoom]);
foreach (var triRef in srcList)
{
long key = ((long)triRef.objectIndex << 16) | triRef.triangleIndex;
if (roomSets[dstRoom].Contains(key)) continue; // Already in dst
if (triRef.objectIndex >= exporters.Length) continue;
var exporter = exporters[triRef.objectIndex];
MeshFilter mf = exporter.GetComponent<MeshFilter>();
if (mf == null || mf.sharedMesh == null) continue;
Mesh mesh = mf.sharedMesh;
Vector3[] vertices = mesh.vertices;
int[] indices = mesh.triangles;
Matrix4x4 worldMatrix = exporter.transform.localToWorldMatrix;
int triStart = triRef.triangleIndex * 3;
if (triStart + 2 >= indices.Length) continue;
Vector3 v0 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart]]);
Vector3 v1 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart + 1]]);
Vector3 v2 = worldMatrix.MultiplyPoint3x4(vertices[indices[triStart + 2]]);
// Check if any vertex is inside the destination room's AABB
if (roomBounds[dstRoom].Contains(v0) ||
roomBounds[dstRoom].Contains(v1) ||
roomBounds[dstRoom].Contains(v2))
{
_roomTriRefs[dstRoom].Add(triRef);
roomSets[dstRoom].Add(key);
duplicated++;
}
}
}
/// <summary>
/// Convert PSXPortalLink components into PSXPortal entries.
/// Maps PSXRoom references to room indices, validates, and stores center positions.
/// </summary>
private void BuildPortals(PSXPortalLink[] portalLinks)
{
if (portalLinks == null) return;
// Build a fast lookup: PSXRoom instance → index.
var roomIndex = new Dictionary<PSXRoom, int>();
for (int i = 0; i < _rooms.Length; i++)
roomIndex[_rooms[i]] = i;
foreach (var link in portalLinks)
{
if (link == null) continue;
if (link.RoomA == null || link.RoomB == null)
{
Debug.LogWarning($"PSXPortalLink '{link.name}' has unassigned room references — skipped.");
continue;
}
if (link.RoomA == link.RoomB)
{
Debug.LogWarning($"PSXPortalLink '{link.name}' references the same room twice — skipped.");
continue;
}
if (!roomIndex.TryGetValue(link.RoomA, out int idxA))
{
Debug.LogWarning($"PSXPortalLink '{link.name}': RoomA '{link.RoomA.name}' is not a known PSXRoom — skipped.");
continue;
}
if (!roomIndex.TryGetValue(link.RoomB, out int idxB))
{
Debug.LogWarning($"PSXPortalLink '{link.name}': RoomB '{link.RoomB.name}' is not a known PSXRoom — skipped.");
continue;
}
_portals.Add(new PSXPortal
{
roomA = idxA,
roomB = idxB,
center = link.transform.position,
portalSize = link.PortalSize,
normal = link.transform.forward,
right = link.transform.right,
up = link.transform.up
});
}
}
/// <summary>
/// Write room/portal data to the splashpack binary.
/// </summary>
public void WriteToBinary(System.IO.BinaryWriter writer, float gteScaling)
{
if (_rooms == null || _rooms.Length == 0) return;
// Per-room data (32 bytes each): AABB (24) + firstTriRef (2) + triRefCount (2) + pad (4)
int runningTriRefOffset = 0;
for (int i = 0; i < _rooms.Length; i++)
{
Bounds wb = _rooms[i].GetWorldBounds();
// PS1 coordinate space (negate Y, swap min/max Y)
writer.Write(PSXTrig.ConvertWorldToFixed12(wb.min.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-wb.max.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(wb.min.z / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(wb.max.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-wb.min.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(wb.max.z / gteScaling));
writer.Write((ushort)runningTriRefOffset);
writer.Write((ushort)_roomTriRefs[i].Count);
writer.Write((uint)0); // padding
runningTriRefOffset += _roomTriRefs[i].Count;
}
// Catch-all room (always rendered) — written as an extra "room" entry
{
// Catch-all AABB: max world extents
writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-1000f / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(1000f / gteScaling));
writer.Write((ushort)runningTriRefOffset);
writer.Write((ushort)_catchAllTriRefs.Count);
writer.Write((uint)0);
}
// Per-portal data (40 bytes each):
// roomA(2) + roomB(2) + center(12) + halfW(2) + halfH(2) +
// normal(6) + pad(2) + right(6) + up(6)
foreach (var portal in _portals)
{
writer.Write((ushort)portal.roomA);
writer.Write((ushort)portal.roomB);
// Center of portal (PS1 coords: negate Y)
writer.Write(PSXTrig.ConvertWorldToFixed12(portal.center.x / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(-portal.center.y / gteScaling));
writer.Write(PSXTrig.ConvertWorldToFixed12(portal.center.z / gteScaling));
// Portal half-size in GTE units (fp12)
float halfW = portal.portalSize.x * 0.5f;
float halfH = portal.portalSize.y * 0.5f;
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(halfW / gteScaling * 4096f), 1, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(halfH / gteScaling * 4096f), 1, 32767));
// Portal facing normal (PS1 coords: negate Y) - 4.12 fixed-point unit vector
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.normal.x * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.normal.y * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.normal.z * 4096f), -32768, 32767));
writer.Write((short)0); // pad
// Portal right axis (PS1 coords: negate Y) - 4.12 fixed-point unit vector
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.right.x * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.right.y * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.right.z * 4096f), -32768, 32767));
// Portal up axis (PS1 coords: negate Y) - 4.12 fixed-point unit vector
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.x * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(-portal.up.y * 4096f), -32768, 32767));
writer.Write((short)Mathf.Clamp(Mathf.RoundToInt(portal.up.z * 4096f), -32768, 32767));
}
// Triangle refs (4 bytes each) — rooms in order, then catch-all
for (int i = 0; i < _rooms.Length; i++)
{
foreach (var triRef in _roomTriRefs[i])
{
writer.Write(triRef.objectIndex);
writer.Write(triRef.triangleIndex);
}
}
foreach (var triRef in _catchAllTriRefs)
{
writer.Write(triRef.objectIndex);
writer.Write(triRef.triangleIndex);
}
}
public int GetBinarySize()
{
if (_rooms == null || _rooms.Length == 0) return 0;
int roomDataSize = (_rooms.Length + 1) * 32; // +1 for catch-all
int portalDataSize = _portals.Count * 40;
int triRefSize = TotalTriRefCount * 4;
return roomDataSize + portalDataSize + triRefSize;
}
}
}

2
Runtime/PSXRoom.cs.meta Normal file
View File

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

View File

@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Splashedit.RuntimeCode;
using SplashEdit.RuntimeCode;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
namespace SplashEdit.RuntimeCode
@@ -13,13 +13,43 @@ namespace SplashEdit.RuntimeCode
[ExecuteInEditMode]
public class PSXSceneExporter : MonoBehaviour
{
/// <summary>
/// Editor code sets this delegate so the Runtime assembly can convert
/// audio without directly referencing the Editor assembly.
/// Signature: (AudioClip clip, int sampleRate, bool loop) => byte[] adpcm
/// </summary>
public static Func<AudioClip, int, bool, byte[]> AudioConvertDelegate;
public float GTEScaling = 100.0f;
public LuaFile SceneLuaFile;
[Header("Fog Configuration")]
[Tooltip("Enable distance fog. Fog color is also used as the GPU clear color.")]
public bool FogEnabled = false;
[Tooltip("Fog color (RGB). Also used as the sky/clear color.")]
public Color FogColor = new Color(0.5f, 0.5f, 0.6f);
[Tooltip("Fog density (1 = light haze, 10 = pea soup).")]
[Range(1, 10)]
public int FogDensity = 5;
[Header("Scene Type")]
[Tooltip("Exterior uses BVH frustum culling. Interior uses room/portal occlusion.")]
public int SceneType = 0; // 0=exterior, 1=interior
private PSXObjectExporter[] _exporters;
private TextureAtlas[] _atlases;
private PSXNavMesh[] _navmeshes;
// Component arrays
private PSXInteractable[] _interactables;
private PSXAudioSource[] _audioSources;
// Phase 3+4: World collision and nav regions
private PSXCollisionExporter _collisionExporter;
private PSXNavRegionBuilder _navRegionBuilder;
// Phase 5: Room/portal system (interior scenes)
private PSXRoomBuilder _roomBuilder;
private PSXData _psxData;
@@ -31,14 +61,32 @@ namespace SplashEdit.RuntimeCode
private Vector3 _playerPos;
private Quaternion _playerRot;
private float _playerHeight;
private float _playerRadius;
private float _moveSpeed;
private float _sprintSpeed;
private float _jumpHeight;
private float _gravity;
private BSP _bsp;
private BVH _bvh;
public bool PreviewBSP = true;
public int BSPPreviewDepth = 9999;
public bool PreviewBVH = true;
public int BVHPreviewDepth = 9999;
/// <summary>
/// Export with a file dialog (legacy workflow).
/// </summary>
public void Export()
{
ExportToPath(null);
}
/// <summary>
/// Export to the given file path. If path is null, shows a file dialog.
/// Called by the Control Panel pipeline for automated exports.
/// </summary>
public void ExportToPath(string outputPath)
{
#if UNITY_EDITOR
_psxData = DataStorage.LoadData(out selectedResolution, out dualBuffering, out verticalLayout, out prohibitedAreas);
_exporters = FindObjectsByType<PSXObjectExporter>(FindObjectsSortMode.None);
@@ -47,16 +95,12 @@ namespace SplashEdit.RuntimeCode
PSXObjectExporter exp = _exporters[i];
EditorUtility.DisplayProgressBar($"{nameof(PSXSceneExporter)}", $"Export {nameof(PSXObjectExporter)}", ((float)i) / _exporters.Length);
exp.CreatePSXTextures2D();
exp.CreatePSXMesh(GTEScaling, true);
}
_navmeshes = FindObjectsByType<PSXNavMesh>(FindObjectsSortMode.None);
for (int i = 0; i < _navmeshes.Length; i++)
{
PSXNavMesh navmesh = _navmeshes[i];
EditorUtility.DisplayProgressBar($"{nameof(PSXSceneExporter)}", $"Export {nameof(PSXNavMesh)}", ((float)i) / _navmeshes.Length);
navmesh.CreateNavmesh(GTEScaling);
exp.CreatePSXMesh(GTEScaling);
}
// Collect components
_interactables = FindObjectsByType<PSXInteractable>(FindObjectsSortMode.None);
_audioSources = FindObjectsByType<PSXAudioSource>(FindObjectsSortMode.None);
EditorUtility.ClearProgressBar();
@@ -68,13 +112,73 @@ namespace SplashEdit.RuntimeCode
player.FindNavmesh();
_playerPos = player.CamPoint;
_playerHeight = player.PlayerHeight;
_playerRadius = player.PlayerRadius;
_moveSpeed = player.MoveSpeed;
_sprintSpeed = player.SprintSpeed;
_jumpHeight = player.JumpHeight;
_gravity = player.Gravity;
_playerRot = player.transform.rotation;
}
_bsp = new BSP(_exporters.ToList());
_bsp.Build();
_bvh = new BVH(_exporters.ToList());
_bvh.Build();
ExportFile();
// Phase 3: Build world collision soup
_collisionExporter = new PSXCollisionExporter();
_collisionExporter.Build(_exporters, GTEScaling);
if (_collisionExporter.MeshCount == 0)
Debug.LogWarning("No collision meshes! Set CollisionType=Solid on your floor/wall objects.");
// Phase 4+5: Room volumes are needed by BOTH the nav region builder
// (for spatial room assignment) and the room builder (for triangle assignment).
// Collect them early so both systems use the same room indices.
PSXRoom[] rooms = null;
PSXPortalLink[] portalLinks = null;
if (SceneType == 1)
{
rooms = FindObjectsByType<PSXRoom>(FindObjectsSortMode.None);
portalLinks = FindObjectsByType<PSXPortalLink>(FindObjectsSortMode.None);
}
// Phase 4: Build nav regions
_navRegionBuilder = new PSXNavRegionBuilder();
_navRegionBuilder.AgentRadius = _playerRadius;
_navRegionBuilder.AgentHeight = _playerHeight;
if (player != null)
{
_navRegionBuilder.MaxStepHeight = player.MaxStepHeight;
_navRegionBuilder.WalkableSlopeAngle = player.WalkableSlopeAngle;
_navRegionBuilder.CellSize = player.NavCellSize;
_navRegionBuilder.CellHeight = player.NavCellHeight;
}
// Pass PSXRoom volumes so nav regions get spatial room assignment
// instead of BFS connectivity. This ensures nav region roomIndex
// matches the PSXRoomBuilder room indices used by the renderer.
if (rooms != null && rooms.Length > 0)
_navRegionBuilder.PSXRooms = rooms;
_navRegionBuilder.Build(_exporters, _playerPos);
if (_navRegionBuilder.RegionCount == 0)
Debug.LogWarning("No nav regions! Enable 'Generate Navigation' on your floor meshes.");
// Phase 5: Build room/portal system (for interior scenes)
_roomBuilder = new PSXRoomBuilder();
if (SceneType == 1)
{
if (rooms != null && rooms.Length > 0)
{
_roomBuilder.Build(rooms, portalLinks, _exporters, GTEScaling);
if (portalLinks == null || portalLinks.Length == 0)
Debug.LogWarning("Interior scene has rooms but no PSXPortalLink components! " +
"Place PSXPortalLink objects between rooms for portal culling.");
}
else
{
Debug.LogWarning("Interior scene type but no PSXRoom volumes found! Place PSXRoom components.");
}
}
ExportFile(outputPath);
#endif
}
void PackTextures()
@@ -94,362 +198,74 @@ namespace SplashEdit.RuntimeCode
}
void ExportFile()
void ExportFile(string outputPath = null)
{
string path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin");
int totalFaces = 0;
#if UNITY_EDITOR
string path = outputPath;
if (string.IsNullOrEmpty(path))
path = EditorUtility.SaveFilePanel("Select Output File", "", "output", "bin");
if (string.IsNullOrEmpty(path))
return;
// Lists for lua data offsets.
OffsetData luaOffset = new();
// Lists for mesh data offsets.
OffsetData meshOffset = new();
// Lists for atlas data offsets.
OffsetData atlasOffset = new();
// Lists for clut data offsets.
OffsetData clutOffset = new();
// Lists for navmesh data offsets.
OffsetData navmeshOffset = new();
int clutCount = 0;
List<LuaFile> luaFiles = new List<LuaFile>();
// Cluts
foreach (TextureAtlas atlas in _atlases)
// Convert audio clips to ADPCM (Editor-only, before passing to Runtime writer)
AudioClipExport[] audioExports = null;
if (_audioSources != null && _audioSources.Length > 0)
{
foreach (var texture in atlas.ContainedTextures)
var list = new List<AudioClipExport>();
foreach (var src in _audioSources)
{
if (texture.ColorPalette != null)
if (src.Clip != null)
{
clutCount++;
}
}
}
// Lua files
foreach (PSXObjectExporter exporter in _exporters)
{
if (exporter.LuaFile != null)
{
//if not contains
if (!luaFiles.Contains(exporter.LuaFile))
{
luaFiles.Add(exporter.LuaFile);
}
}
}
if (SceneLuaFile != null)
{
if (!luaFiles.Contains(SceneLuaFile))
{
luaFiles.Add(SceneLuaFile);
}
}
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)))
{
// Header
writer.Write('S'); // 1 byte // 1
writer.Write('P'); // 1 byte // 2
writer.Write((ushort)1); // 2 bytes - version // 4
writer.Write((ushort)luaFiles.Count); // 2 bytes - padding // 6
writer.Write((ushort)_exporters.Length); // 2 bytes // 6
writer.Write((ushort)_navmeshes.Length); // 8
writer.Write((ushort)_atlases.Length); // 2 bytes // 10
writer.Write((ushort)clutCount); // 2 bytes // 12
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerPos.x, GTEScaling)); // 14
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-_playerPos.y, GTEScaling)); // 16
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerPos.z, GTEScaling)); // 18
writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.x * Mathf.Deg2Rad)); // 20
writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.y * Mathf.Deg2Rad)); // 22
writer.Write((ushort)PSXTrig.ConvertToFixed12(_playerRot.eulerAngles.z * Mathf.Deg2Rad)); // 24
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(_playerHeight, GTEScaling)); // 26
if (SceneLuaFile != null)
{
int index = luaFiles.IndexOf(SceneLuaFile);
writer.Write((short)index);
}
else
{
writer.Write((short)-1);
}
writer.Write((ushort)0);
// Lua file section
foreach (LuaFile luaFile in luaFiles)
{
// Write placeholder for lua file data offset and record its position.
luaOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for mesh data offset.
writer.Write((uint)luaFile.LuaScript.Length);
}
// GameObject section (exporters)
foreach (PSXObjectExporter exporter in _exporters)
{
// Write placeholder for mesh data offset and record its position.
meshOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for mesh data offset.
// 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]);
writer.Write((ushort)exporter.Mesh.Triangles.Count);
if (exporter.LuaFile != null)
{
int index = luaFiles.IndexOf(exporter.LuaFile);
writer.Write((short)index);
if (AudioConvertDelegate == null)
throw new InvalidOperationException("AudioConvertDelegate not set. Ensure PSXAudioConverter registers it.");
byte[] adpcm = AudioConvertDelegate(src.Clip, src.SampleRate, src.Loop);
list.Add(new AudioClipExport { adpcmData = adpcm, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName });
}
else
{
writer.Write((short)-1);
}
// Write 4-byte bitfield with LSB as exporter.isActive
int bitfield = exporter.IsActive ? 0b1 : 0b0;
writer.Write(bitfield);
}
// Navmesh metadata section
foreach (PSXNavMesh navmesh in _navmeshes)
{
// Write placeholder for navmesh raw data offset.
navmeshOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for navmesh data offset.
writer.Write((ushort)navmesh.Navmesh.Count);
writer.Write((ushort)0);
}
// Atlas metadata section
foreach (TextureAtlas atlas in _atlases)
{
// Write placeholder for texture atlas raw data offset.
atlasOffset.OffsetPlaceholderPositions.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)
{
clutOffset.OffsetPlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // 4-byte placeholder for clut data offset.
writer.Write((ushort)texture.ClutPackingX); // 2 bytes
writer.Write((ushort)texture.ClutPackingY); // 2 bytes
writer.Write((ushort)texture.ColorPalette.Count); // 2 bytes
writer.Write((ushort)0); // 2 bytes
}
Debug.LogWarning($"Audio source on {src.gameObject.name} has no clip assigned.");
list.Add(new AudioClipExport { adpcmData = null, sampleRate = src.SampleRate, loop = src.Loop, clipName = src.ClipName });
}
}
// Start of data section
// Lua data section: Write lua file data for each exporter.
foreach (LuaFile luaFile in luaFiles)
{
AlignToFourBytes(writer);
// Record the current offset for this lua file's data.
long luaDataOffset = writer.BaseStream.Position;
luaOffset.DataOffsets.Add(luaDataOffset);
writer.Write(Encoding.UTF8.GetBytes(luaFile.LuaScript));
}
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]);
}
}
// 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;
meshOffset.DataOffsets.Add(meshDataOffset);
totalFaces += exporter.Mesh.Triangles.Count;
foreach (Tri tri in exporter.Mesh.Triangles)
{
int expander = 16 / ((int)exporter.GetTexture(tri.TextureIndex).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, exporter.GetTexture(tri.TextureIndex), expander));
writer.Write((ushort)0); // padding
TPageAttr tpage = new TPageAttr();
tpage.SetPageX(exporter.GetTexture(tri.TextureIndex).TexpageX);
tpage.SetPageY(exporter.GetTexture(tri.TextureIndex).TexpageY);
tpage.Set(exporter.GetTexture(tri.TextureIndex).BitDepth.ToColorMode());
tpage.SetDithering(true);
writer.Write((ushort)tpage.info);
writer.Write((ushort)exporter.GetTexture(tri.TextureIndex).ClutPackingX);
writer.Write((ushort)exporter.GetTexture(tri.TextureIndex).ClutPackingY);
writer.Write((ushort)0);
}
}
foreach (PSXNavMesh navmesh in _navmeshes)
{
AlignToFourBytes(writer);
long navmeshDataOffset = writer.BaseStream.Position;
navmeshOffset.DataOffsets.Add(navmeshDataOffset);
foreach (PSXNavMeshTri tri in navmesh.Navmesh)
{
// Write vertices coordinates
writer.Write((int)tri.v0.vx);
writer.Write((int)tri.v0.vy);
writer.Write((int)tri.v0.vz);
writer.Write((int)tri.v1.vx);
writer.Write((int)tri.v1.vy);
writer.Write((int)tri.v1.vz);
writer.Write((int)tri.v2.vx);
writer.Write((int)tri.v2.vy);
writer.Write((int)tri.v2.vz);
}
}
// 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;
atlasOffset.DataOffsets.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());
}
}
}
// Clut data section
foreach (TextureAtlas atlas in _atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
{
AlignToFourBytes(writer);
long clutDataOffset = writer.BaseStream.Position;
clutOffset.DataOffsets.Add(clutDataOffset);
foreach (VRAMPixel color in texture.ColorPalette)
{
writer.Write((ushort)color.Pack());
}
}
}
}
writeOffset(writer, luaOffset, "lua");
writeOffset(writer, meshOffset, "mesh");
writeOffset(writer, navmeshOffset, "navmesh");
writeOffset(writer, atlasOffset, "atlas");
writeOffset(writer, clutOffset, "clut");
audioExports = list.ToArray();
}
Debug.Log(totalFaces);
}
private void writeOffset(BinaryWriter writer, OffsetData data, string type)
{
// Backfill the data offsets into the metadata section.
if (data.OffsetPlaceholderPositions.Count == data.DataOffsets.Count)
var scene = new PSXSceneWriter.SceneData
{
for (int i = 0; i < data.OffsetPlaceholderPositions.Count; i++)
{
writer.Seek((int)data.OffsetPlaceholderPositions[i], SeekOrigin.Begin);
writer.Write((int)data.DataOffsets[i]);
}
}
else
exporters = _exporters,
atlases = _atlases,
interactables = _interactables,
audioClips = audioExports,
collisionExporter = _collisionExporter,
navRegionBuilder = _navRegionBuilder,
roomBuilder = _roomBuilder,
bvh = _bvh,
sceneLuaFile = SceneLuaFile,
gteScaling = GTEScaling,
playerPos = _playerPos,
playerRot = _playerRot,
playerHeight = _playerHeight,
playerRadius = _playerRadius,
moveSpeed = _moveSpeed,
sprintSpeed = _sprintSpeed,
jumpHeight = _jumpHeight,
gravity = _gravity,
sceneType = SceneType,
fogEnabled = FogEnabled,
fogColor = FogColor,
fogDensity = FogDensity,
};
PSXSceneWriter.Write(path, in scene, (msg, type) =>
{
Debug.LogError("Mismatch between clut offset placeholders and clut data blocks!");
}
}
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
switch (type)
{
case LogType.Error: Debug.LogError(msg); break;
case LogType.Warning: Debug.LogWarning(msg); break;
default: Debug.Log(msg); break;
}
});
#endif
}
void OnDrawGizmos()
@@ -459,16 +275,9 @@ namespace SplashEdit.RuntimeCode
Gizmos.color = Color.red;
Gizmos.DrawWireCube(sceneOrigin, cubeSize);
if (_bsp == null || !PreviewBSP) return;
_bsp.DrawGizmos(BSPPreviewDepth);
if (_bvh == null || !PreviewBVH) return;
_bvh.DrawGizmos(BVHPreviewDepth);
}
}
public class OffsetData
{
public List<long> OffsetPlaceholderPositions = new List<long>();
public List<long> DataOffsets = new List<long>();
}
}

View File

@@ -1,11 +1,2 @@
fileFormatVersion: 2
guid: ab5195ad94fd173cfb6d48ee06eaf245
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {fileID: 2800000, guid: 0be7a2d4700082dbc83b9274837c70bc, type: 3}
userData:
assetBundleName:
assetBundleVariant:
guid: 3efc1583a56e9024f8d08551ddfbea56

693
Runtime/PSXSceneWriter.cs Normal file
View File

@@ -0,0 +1,693 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEngine;
namespace SplashEdit.RuntimeCode
{
/// <summary>
/// Pure binary serializer for the splashpack v8 format.
/// All I/O extracted from PSXSceneExporter so the MonoBehaviour stays thin.
/// </summary>
public static class PSXSceneWriter
{
/// <summary>
/// All scene data needed to produce a .bin file.
/// Populated by PSXSceneExporter before calling <see cref="Write"/>.
/// </summary>
public struct SceneData
{
public PSXObjectExporter[] exporters;
public TextureAtlas[] atlases;
public PSXInteractable[] interactables;
public AudioClipExport[] audioClips;
public PSXCollisionExporter collisionExporter;
public PSXNavRegionBuilder navRegionBuilder;
public PSXRoomBuilder roomBuilder;
public BVH bvh;
public LuaFile sceneLuaFile;
public float gteScaling;
// Player
public Vector3 playerPos;
public Quaternion playerRot;
public float playerHeight;
public float playerRadius;
public float moveSpeed;
public float sprintSpeed;
public float jumpHeight;
public float gravity;
// Scene configuration (v11)
public int sceneType; // 0=exterior, 1=interior
public bool fogEnabled;
public Color fogColor;
public int fogDensity; // 1-10
}
// ─── Offset bookkeeping ───
private sealed class OffsetData
{
public readonly List<long> PlaceholderPositions = new List<long>();
public readonly List<long> DataOffsets = new List<long>();
}
// ═══════════════════════════════════════════════════════════════
// Public API
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Serialize the scene to a splashpack v8 binary file.
/// </summary>
/// <param name="path">Absolute file path to write.</param>
/// <param name="scene">Pre-built scene data.</param>
/// <param name="log">Optional callback for progress messages.</param>
public static void Write(string path, in SceneData scene, Action<string, LogType> log = null)
{
float gte = scene.gteScaling;
int totalFaces = 0;
OffsetData luaOffset = new OffsetData();
OffsetData meshOffset = new OffsetData();
OffsetData atlasOffset = new OffsetData();
OffsetData clutOffset = new OffsetData();
int clutCount = 0;
List<LuaFile> luaFiles = new List<LuaFile>();
// Count CLUTs
foreach (TextureAtlas atlas in scene.atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
clutCount++;
}
}
// Collect unique Lua files
foreach (PSXObjectExporter exporter in scene.exporters)
{
if (exporter.LuaFile != null && !luaFiles.Contains(exporter.LuaFile))
luaFiles.Add(exporter.LuaFile);
}
if (scene.sceneLuaFile != null && !luaFiles.Contains(scene.sceneLuaFile))
luaFiles.Add(scene.sceneLuaFile);
using (BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)))
{
int colliderCount = 0;
foreach (var e in scene.exporters)
{
if (e.CollisionType != PSXCollisionType.None)
colliderCount++;
}
// Build exporter index lookup for components
Dictionary<PSXObjectExporter, int> exporterIndex = new Dictionary<PSXObjectExporter, int>();
for (int i = 0; i < scene.exporters.Length; i++)
exporterIndex[scene.exporters[i]] = i;
// ──────────────────────────────────────────────────────
// Header (72 bytes total — splashpack v8)
// ──────────────────────────────────────────────────────
writer.Write('S');
writer.Write('P');
writer.Write((ushort)11); // version
writer.Write((ushort)luaFiles.Count);
writer.Write((ushort)scene.exporters.Length);
writer.Write((ushort)0); // navmeshCount (legacy)
writer.Write((ushort)scene.atlases.Length);
writer.Write((ushort)clutCount);
writer.Write((ushort)colliderCount);
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.x, gte));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(-scene.playerPos.y, gte));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerPos.z, gte));
writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.x * Mathf.Deg2Rad));
writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.y * Mathf.Deg2Rad));
writer.Write((ushort)PSXTrig.ConvertToFixed12(scene.playerRot.eulerAngles.z * Mathf.Deg2Rad));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerHeight, gte));
// Scene Lua index
if (scene.sceneLuaFile != null)
writer.Write((short)luaFiles.IndexOf(scene.sceneLuaFile));
else
writer.Write((short)-1);
// BVH info
writer.Write((ushort)scene.bvh.NodeCount);
writer.Write((ushort)scene.bvh.TriangleRefCount);
// Component counts (version 4)
writer.Write((ushort)scene.interactables.Length);
writer.Write((ushort)0); // healthCount (removed)
writer.Write((ushort)0); // timerCount (removed)
writer.Write((ushort)0); // spawnerCount (removed)
// NavGrid (version 5, legacy)
writer.Write((ushort)0);
writer.Write((ushort)0);
// Scene type (version 6)
writer.Write((ushort)scene.sceneType); // 0=exterior, 1=interior
writer.Write((ushort)0);
// World collision + nav regions (version 7)
writer.Write((ushort)scene.collisionExporter.MeshCount);
writer.Write((ushort)scene.collisionExporter.TriangleCount);
writer.Write((ushort)scene.navRegionBuilder.RegionCount);
writer.Write((ushort)scene.navRegionBuilder.PortalCount);
// Movement parameters (version 8, 12 bytes)
{
const float fps = 30f;
float movePerFrame = scene.moveSpeed / fps / gte;
float sprintPerFrame = scene.sprintSpeed / fps / gte;
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(movePerFrame * 4096f), 0, 65535));
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(sprintPerFrame * 4096f), 0, 65535));
float jumpVel = Mathf.Sqrt(2f * scene.gravity * scene.jumpHeight) / gte;
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(jumpVel * 4096f), 0, 65535));
float gravPsx = scene.gravity / gte;
writer.Write((ushort)Mathf.Clamp(Mathf.RoundToInt(gravPsx * 4096f), 0, 65535));
writer.Write((ushort)PSXTrig.ConvertCoordinateToPSX(scene.playerRadius, gte));
writer.Write((ushort)0); // padding
}
// Name table offset placeholder (version 9, 4 bytes)
long nameTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // placeholder for name table offset
// Audio clip info (version 10, 8 bytes)
int audioClipCount = scene.audioClips?.Length ?? 0;
writer.Write((ushort)audioClipCount);
writer.Write((ushort)0); // padding
long audioTableOffsetPos = writer.BaseStream.Position;
writer.Write((uint)0); // placeholder for audio table offset
// Fog + room/portal header (version 11, 12 bytes)
{
writer.Write((byte)(scene.fogEnabled ? 1 : 0));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.r * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.g * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(Mathf.RoundToInt(scene.fogColor.b * 255f), 0, 255));
writer.Write((byte)Mathf.Clamp(scene.fogDensity, 1, 10));
writer.Write((byte)0); // reserved
int roomCount = scene.roomBuilder?.RoomCount ?? 0;
int portalCount = scene.roomBuilder?.PortalCount ?? 0;
int roomTriRefCount = scene.roomBuilder?.TotalTriRefCount ?? 0;
// roomCount is the room count NOT including catch-all; the binary adds +1 for it
writer.Write((ushort)(roomCount > 0 ? roomCount + 1 : 0));
writer.Write((ushort)portalCount);
writer.Write((ushort)roomTriRefCount);
}
// ──────────────────────────────────────────────────────
// Lua file metadata
// ──────────────────────────────────────────────────────
foreach (LuaFile luaFile in luaFiles)
{
luaOffset.PlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // placeholder
writer.Write((uint)Encoding.UTF8.GetByteCount(luaFile.LuaScript));
}
// ──────────────────────────────────────────────────────
// GameObject section
// ──────────────────────────────────────────────────────
Dictionary<PSXObjectExporter, int> interactableIndices = new Dictionary<PSXObjectExporter, int>();
for (int i = 0; i < scene.interactables.Length; i++)
{
var exp = scene.interactables[i].GetComponent<PSXObjectExporter>();
if (exp != null) interactableIndices[exp] = i;
}
foreach (PSXObjectExporter exporter in scene.exporters)
{
meshOffset.PlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // placeholder
// Transform — position as 20.12 fixed-point
Vector3 pos = exporter.transform.localToWorldMatrix.GetPosition();
writer.Write(PSXTrig.ConvertWorldToFixed12(pos.x / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(-pos.y / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(pos.z / gte));
int[,] rot = PSXTrig.ConvertRotationToPSXMatrix(exporter.transform.rotation);
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++)
writer.Write((int)rot[r, c]);
writer.Write((ushort)exporter.Mesh.Triangles.Count);
if (exporter.LuaFile != null)
writer.Write((short)luaFiles.IndexOf(exporter.LuaFile));
else
writer.Write((short)-1);
// Bitfield (LSB = isActive)
writer.Write(exporter.IsActive ? 1 : 0);
// Component indices (8 bytes)
writer.Write(interactableIndices.TryGetValue(exporter, out int interactIdx) ? (ushort)interactIdx : (ushort)0xFFFF);
writer.Write((ushort)0xFFFF); // _reserved0 (legacy healthIndex)
writer.Write((uint)0); // eventMask (runtime-only, must be zero)
// World-space AABB (24 bytes)
WriteObjectAABB(writer, exporter, gte);
}
// ──────────────────────────────────────────────────────
// Collider metadata (32 bytes each)
// ──────────────────────────────────────────────────────
for (int exporterIdx = 0; exporterIdx < scene.exporters.Length; exporterIdx++)
{
PSXObjectExporter exporter = scene.exporters[exporterIdx];
if (exporter.CollisionType == PSXCollisionType.None)
continue;
MeshFilter meshFilter = exporter.GetComponent<MeshFilter>();
Mesh collisionMesh = exporter.CustomCollisionMesh != null
? exporter.CustomCollisionMesh
: meshFilter?.sharedMesh;
if (collisionMesh == null)
continue;
WriteWorldAABB(writer, exporter, collisionMesh.bounds, gte);
// Collision metadata (8 bytes)
writer.Write((byte)exporter.CollisionType);
writer.Write((byte)(1 << (exporter.CollisionLayer - 1)));
writer.Write((ushort)exporterIdx);
writer.Write((uint)0); // padding
}
// ──────────────────────────────────────────────────────
// BVH data (inline)
// ──────────────────────────────────────────────────────
AlignToFourBytes(writer);
scene.bvh.WriteToBinary(writer, gte);
// ──────────────────────────────────────────────────────
// Interactable components (24 bytes each)
// ──────────────────────────────────────────────────────
AlignToFourBytes(writer);
foreach (PSXInteractable interactable in scene.interactables)
{
var exp = interactable.GetComponent<PSXObjectExporter>();
int goIndex = exporterIndex.TryGetValue(exp, out int idx) ? idx : 0xFFFF;
float radiusSq = interactable.InteractionRadius * interactable.InteractionRadius;
writer.Write(PSXTrig.ConvertWorldToFixed12(radiusSq / (gte * gte)));
Vector3 offset = interactable.InteractionOffset;
writer.Write(PSXTrig.ConvertWorldToFixed12(offset.x / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(-offset.y / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(offset.z / gte));
writer.Write((byte)interactable.InteractButton);
byte flags = 0;
if (interactable.IsRepeatable) flags |= 0x01;
if (interactable.ShowPrompt) flags |= 0x02;
if (interactable.RequireLineOfSight) flags |= 0x04;
writer.Write(flags);
writer.Write(interactable.CooldownFrames);
writer.Write((ushort)0); // currentCooldown (runtime)
writer.Write((ushort)goIndex);
}
// ──────────────────────────────────────────────────────
// World collision soup (version 7+)
// ──────────────────────────────────────────────────────
if (scene.collisionExporter.MeshCount > 0)
{
AlignToFourBytes(writer);
scene.collisionExporter.WriteToBinary(writer, gte);
}
// ──────────────────────────────────────────────────────
// Nav region data (version 7+)
// ──────────────────────────────────────────────────────
if (scene.navRegionBuilder.RegionCount > 0)
{
AlignToFourBytes(writer);
scene.navRegionBuilder.WriteToBinary(writer, gte);
}
// ──────────────────────────────────────────────────────
// Room/portal data (version 11, interior scenes)
// Must be in the sequential cursor section (after nav regions,
// before atlas metadata) so the C++ reader can find it.
// ──────────────────────────────────────────────────────
if (scene.roomBuilder != null && scene.roomBuilder.RoomCount > 0)
{
AlignToFourBytes(writer);
scene.roomBuilder.WriteToBinary(writer, scene.gteScaling);
log?.Invoke($"Room/portal data: {scene.roomBuilder.RoomCount} rooms, {scene.roomBuilder.PortalCount} portals, {scene.roomBuilder.TotalTriRefCount} tri-refs.", LogType.Log);
}
// ──────────────────────────────────────────────────────
// Atlas metadata
// ──────────────────────────────────────────────────────
foreach (TextureAtlas atlas in scene.atlases)
{
atlasOffset.PlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // placeholder
writer.Write((ushort)atlas.Width);
writer.Write((ushort)TextureAtlas.Height);
writer.Write((ushort)atlas.PositionX);
writer.Write((ushort)atlas.PositionY);
}
// ──────────────────────────────────────────────────────
// CLUT metadata
// ──────────────────────────────────────────────────────
foreach (TextureAtlas atlas in scene.atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
{
clutOffset.PlaceholderPositions.Add(writer.BaseStream.Position);
writer.Write((int)0); // placeholder
writer.Write((ushort)texture.ClutPackingX);
writer.Write((ushort)texture.ClutPackingY);
writer.Write((ushort)texture.ColorPalette.Count);
writer.Write((ushort)0);
}
}
}
// ══════════════════════════════════════════════════════
// Data sections
// ══════════════════════════════════════════════════════
// Lua data
foreach (LuaFile luaFile in luaFiles)
{
AlignToFourBytes(writer);
luaOffset.DataOffsets.Add(writer.BaseStream.Position);
writer.Write(Encoding.UTF8.GetBytes(luaFile.LuaScript));
}
// Mesh data
foreach (PSXObjectExporter exporter in scene.exporters)
{
AlignToFourBytes(writer);
meshOffset.DataOffsets.Add(writer.BaseStream.Position);
totalFaces += exporter.Mesh.Triangles.Count;
foreach (Tri tri in exporter.Mesh.Triangles)
{
// Vertex positions (3 × 6 bytes)
WriteVertexPosition(writer, tri.v0);
WriteVertexPosition(writer, tri.v1);
WriteVertexPosition(writer, tri.v2);
// Normal for v0 only
WriteVertexNormals(writer, tri.v0);
// Vertex colors (3 × 4 bytes)
WriteVertexColor(writer, tri.v0);
WriteVertexColor(writer, tri.v1);
WriteVertexColor(writer, tri.v2);
if (tri.IsUntextured)
{
// Zero UVs
writer.Write((byte)0); writer.Write((byte)0);
writer.Write((byte)0); writer.Write((byte)0);
writer.Write((byte)0); writer.Write((byte)0);
writer.Write((ushort)0); // padding
// Sentinel tpage = 0xFFFF marks untextured
writer.Write((ushort)0xFFFF);
writer.Write((ushort)0);
writer.Write((ushort)0);
writer.Write((ushort)0);
}
else
{
PSXTexture2D tex = exporter.GetTexture(tri.TextureIndex);
int expander = 16 / (int)tex.BitDepth;
WriteVertexUV(writer, tri.v0, tex, expander);
WriteVertexUV(writer, tri.v1, tex, expander);
WriteVertexUV(writer, tri.v2, tex, expander);
writer.Write((ushort)0); // padding
TPageAttr tpage = new TPageAttr();
tpage.SetPageX(tex.TexpageX);
tpage.SetPageY(tex.TexpageY);
tpage.Set(tex.BitDepth.ToColorMode());
tpage.SetDithering(true);
writer.Write((ushort)tpage.info);
writer.Write((ushort)tex.ClutPackingX);
writer.Write((ushort)tex.ClutPackingY);
writer.Write((ushort)0); // padding
}
}
}
// Atlas pixel data
foreach (TextureAtlas atlas in scene.atlases)
{
AlignToFourBytes(writer);
atlasOffset.DataOffsets.Add(writer.BaseStream.Position);
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());
}
// CLUT data
foreach (TextureAtlas atlas in scene.atlases)
{
foreach (var texture in atlas.ContainedTextures)
{
if (texture.ColorPalette != null)
{
AlignToFourBytes(writer);
clutOffset.DataOffsets.Add(writer.BaseStream.Position);
foreach (VRAMPixel color in texture.ColorPalette)
writer.Write((ushort)color.Pack());
}
}
}
// ──────────────────────────────────────────────────────
// Object name table (version 9)
// ──────────────────────────────────────────────────────
AlignToFourBytes(writer);
long nameTableStart = writer.BaseStream.Position;
foreach (PSXObjectExporter exporter in scene.exporters)
{
string objName = exporter.gameObject.name;
if (objName.Length > 24) objName = objName.Substring(0, 24);
byte[] nameBytes = Encoding.UTF8.GetBytes(objName);
writer.Write((byte)nameBytes.Length);
writer.Write(nameBytes);
writer.Write((byte)0); // null terminator
}
// Backfill name table offset
{
long endPos = writer.BaseStream.Position;
writer.Seek((int)nameTableOffsetPos, SeekOrigin.Begin);
writer.Write((uint)nameTableStart);
writer.Seek((int)endPos, SeekOrigin.Begin);
}
// ──────────────────────────────────────────────────────
// Audio clip data (version 10)
// ──────────────────────────────────────────────────────
if (audioClipCount > 0 && scene.audioClips != null)
{
// Write audio table: per clip metadata (12 bytes each)
AlignToFourBytes(writer);
long audioTableStart = writer.BaseStream.Position;
// First pass: write metadata placeholders (16 bytes each)
List<long> audioDataOffsetPositions = new List<long>();
List<long> audioNameOffsetPositions = new List<long>();
for (int i = 0; i < audioClipCount; i++)
{
var clip = scene.audioClips[i];
audioDataOffsetPositions.Add(writer.BaseStream.Position);
writer.Write((uint)0); // dataOffset placeholder
writer.Write((uint)(clip.adpcmData?.Length ?? 0)); // sizeBytes
writer.Write((ushort)clip.sampleRate);
string name = clip.clipName ?? "";
writer.Write((byte)(clip.loop ? 1 : 0));
writer.Write((byte)System.Math.Min(name.Length, 255));
audioNameOffsetPositions.Add(writer.BaseStream.Position);
writer.Write((uint)0); // nameOffset placeholder
}
// Second pass: write ADPCM data and backfill offsets
for (int i = 0; i < audioClipCount; i++)
{
byte[] data = scene.audioClips[i].adpcmData;
if (data != null && data.Length > 0)
{
AlignToFourBytes(writer);
long dataPos = writer.BaseStream.Position;
writer.Write(data);
// Backfill data offset
long curPos = writer.BaseStream.Position;
writer.Seek((int)audioDataOffsetPositions[i], SeekOrigin.Begin);
writer.Write((uint)dataPos);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
}
// Backfill audio table offset in header
{
long curPos = writer.BaseStream.Position;
writer.Seek((int)audioTableOffsetPos, SeekOrigin.Begin);
writer.Write((uint)audioTableStart);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
int totalAudioBytes = 0;
foreach (var clip in scene.audioClips)
if (clip.adpcmData != null) totalAudioBytes += clip.adpcmData.Length;
// Third pass: write audio clip names and backfill name offsets
for (int i = 0; i < audioClipCount; i++)
{
string name = scene.audioClips[i].clipName ?? "";
if (name.Length > 255) name = name.Substring(0, 255);
long namePos = writer.BaseStream.Position;
byte[] nameBytes = System.Text.Encoding.ASCII.GetBytes(name);
writer.Write(nameBytes);
writer.Write((byte)0); // null terminator
long curPos = writer.BaseStream.Position;
writer.Seek((int)audioNameOffsetPositions[i], SeekOrigin.Begin);
writer.Write((uint)namePos);
writer.Seek((int)curPos, SeekOrigin.Begin);
}
log?.Invoke($"{audioClipCount} audio clips ({totalAudioBytes / 1024}KB ADPCM) written.", LogType.Log);
}
// Backfill offsets
BackfillOffsets(writer, luaOffset, "lua", log);
BackfillOffsets(writer, meshOffset, "mesh", log);
BackfillOffsets(writer, atlasOffset, "atlas", log);
BackfillOffsets(writer, clutOffset, "clut", log);
}
log?.Invoke($"{totalFaces} faces written to {Path.GetFileName(path)}", LogType.Log);
}
// ═══════════════════════════════════════════════════════════════
// Static helpers
// ═══════════════════════════════════════════════════════════════
private static void WriteVertexPosition(BinaryWriter w, PSXVertex v)
{
w.Write((short)v.vx);
w.Write((short)v.vy);
w.Write((short)v.vz);
}
private static void WriteVertexNormals(BinaryWriter w, PSXVertex v)
{
w.Write((short)v.nx);
w.Write((short)v.ny);
w.Write((short)v.nz);
}
private static void WriteVertexColor(BinaryWriter w, PSXVertex v)
{
w.Write((byte)v.r);
w.Write((byte)v.g);
w.Write((byte)v.b);
w.Write((byte)0); // padding
}
private static void WriteVertexUV(BinaryWriter w, PSXVertex v, PSXTexture2D t, int expander)
{
w.Write((byte)(v.u + t.PackingX * expander));
w.Write((byte)(v.v + t.PackingY));
}
private static void WriteObjectAABB(BinaryWriter writer, PSXObjectExporter exporter, float gte)
{
MeshFilter mf = exporter.GetComponent<MeshFilter>();
Mesh mesh = mf?.sharedMesh;
if (mesh != null)
{
WriteWorldAABB(writer, exporter, mesh.bounds, gte);
}
else
{
for (int z = 0; z < 6; z++) writer.Write((int)0);
}
}
private static void WriteWorldAABB(BinaryWriter writer, PSXObjectExporter exporter, Bounds localBounds, float gte)
{
Vector3 ext = localBounds.extents;
Vector3 center = localBounds.center;
Vector3 aabbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue);
Vector3 aabbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue);
// Compute world-space AABB from 8 transformed corners
for (int i = 0; i < 8; i++)
{
Vector3 corner = center + new Vector3(
(i & 1) != 0 ? ext.x : -ext.x,
(i & 2) != 0 ? ext.y : -ext.y,
(i & 4) != 0 ? ext.z : -ext.z
);
Vector3 world = exporter.transform.TransformPoint(corner);
aabbMin = Vector3.Min(aabbMin, world);
aabbMax = Vector3.Max(aabbMax, world);
}
// PS1 coordinate space (negate Y, swap min/max)
writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.x / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMax.y / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMin.z / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.x / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(-aabbMin.y / gte));
writer.Write(PSXTrig.ConvertWorldToFixed12(aabbMax.z / gte));
}
private static void AlignToFourBytes(BinaryWriter writer)
{
long pos = writer.BaseStream.Position;
int padding = (int)(4 - (pos % 4)) % 4;
if (padding > 0)
writer.Write(new byte[padding]);
}
private static void BackfillOffsets(BinaryWriter writer, OffsetData data, string sectionName, Action<string, LogType> log)
{
if (data.PlaceholderPositions.Count != data.DataOffsets.Count)
{
log?.Invoke($"Offset mismatch in {sectionName}: {data.PlaceholderPositions.Count} placeholders vs {data.DataOffsets.Count} data blocks", LogType.Error);
return;
}
for (int i = 0; i < data.PlaceholderPositions.Count; i++)
{
writer.Seek((int)data.PlaceholderPositions[i], SeekOrigin.Begin);
writer.Write((int)data.DataOffsets[i]);
}
}
}
}

View File

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

View File

@@ -277,15 +277,5 @@ 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

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: ac29cfb818d45b12dba84e61317c794b
guid: 0ee2ed052f09f7f419c794493d751029

View File

@@ -251,7 +251,6 @@ namespace SplashEdit.RuntimeCode
atlas.PositionY = y;
_finalizedAtlases.Add(atlas);
placed = true;
Debug.Log($"Placed an atlas at: {x},{y}");
break;
}
}

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: ae72963f3a7f0820cbc31cb4764c51cb
guid: 2a5f290a3e0de24448841847f2039f58

View File

@@ -1,11 +1,13 @@
#if UNITY_EDITOR
using UnityEditor;
#endif
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
namespace SplashEdit.RuntimeCode
{
#if UNITY_EDITOR
public static class DataStorage
{
private static readonly string psxDataPath = "Assets/PSXData.asset";
@@ -54,6 +56,7 @@ namespace SplashEdit.RuntimeCode
}
}
}
#endif
/// <summary>
/// Represents a prohibited area in PlayStation 2D VRAM where textures should not be packed.
@@ -101,15 +104,18 @@ namespace SplashEdit.RuntimeCode
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.
/// Converts a floating-point coordinate to a PSX-compatible 4.12 fixed-point format (int16).
/// The value is divided by GTEScaling, then scaled to 4.12 fixed-point and clamped to int16 range.
/// Usable range: [-8.0, ~8.0) in GTE units (i.e. [-8*GTEScaling, 8*GTEScaling) in world units).
/// Use this for mesh vertex positions (local space) and other data that fits in int16.
/// </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>
/// <param name="value">The coordinate value in world units.</param>
/// <param name="GTEScaling">World-to-GTE scaling factor (default 1.0f). Value is divided by this.</param>
/// <returns>The converted coordinate in 4.12 fixed-point format as a 16-bit signed integer.</returns>
public static short ConvertCoordinateToPSX(float value, float GTEScaling = 1.0f)
{
return (short)(Mathf.Clamp(value / GTEScaling, -4f, 3.999f) * 4096);
int fixedValue = Mathf.RoundToInt((value / GTEScaling) * 4096.0f);
return (short)Mathf.Clamp(fixedValue, -32768, 32767);
}
/// <summary>
@@ -162,15 +168,31 @@ namespace SplashEdit.RuntimeCode
}
/// <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.
/// Converts a floating-point value to 4.12 fixed-point format (int16).
/// Suitable for values in [-8.0, ~8.0) range, such as rotation matrix elements or normals.
/// </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>
/// <returns>The converted value in 4.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
int fixedValue = Mathf.RoundToInt(value * 4096.0f);
return (short)Mathf.Clamp(fixedValue, -32768, 32767);
}
/// <summary>
/// Converts a GTE-space value to 20.12 fixed-point format (int32).
/// Use this for world-space positions, collision AABBs, BVH bounds, and any data
/// that needs the full int32 range. Caller must divide by GTEScaling BEFORE calling,
/// i.e. pass (worldValue / GTEScaling). This matches ConvertCoordinateToPSX's
/// coordinate space but returns int32 instead of clamping to int16.
/// Usable range: approximately [-524288.0, 524288.0).
/// </summary>
/// <param name="value">The value to convert (in GTE units, i.e. worldValue / GTEScaling).</param>
/// <returns>The converted value in 20.12 fixed-point format as a 32-bit signed integer.</returns>
public static int ConvertWorldToFixed12(float value)
{
long fixedValue = (long)Mathf.RoundToInt(value * 4096.0f);
return (int)Mathf.Clamp(fixedValue, int.MinValue, int.MaxValue);
}
}
/// <summary>
@@ -334,6 +356,7 @@ namespace SplashEdit.RuntimeCode
public static byte ColorUnityToPSX(float v) => (byte)(Mathf.Clamp(v * 255, 0, 255));
#if UNITY_EDITOR
public static void SetTextureImporterFormat(Texture2D texture, bool isReadable)
{
if (texture == null)
@@ -362,6 +385,7 @@ namespace SplashEdit.RuntimeCode
}
}
}
#endif
}
}

View File

@@ -1,2 +1,2 @@
fileFormatVersion: 2
guid: 8c9b0581c1e4eeb6296f4c162359043f
guid: eed25c4c241a9114fa75776896d726a7

View File

@@ -7,8 +7,11 @@
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"overrideReferences": true,
"precompiledReferences": [
"DotRecast.Core.dll",
"DotRecast.Recast.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1afaf17520143848ea52af093808349d
guid: 4db83d7a3934bae48ae5d04fda8335bb
AssemblyDefinitionImporter:
externalObjects: {}
userData: