psst
This commit is contained in:
845
Runtime/BSP.cs
845
Runtime/BSP.cs
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15144e67b42b92447a546346e594155b
|
||||
393
Runtime/BVH.cs
Normal file
393
Runtime/BVH.cs
Normal 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
2
Runtime/BVH.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 735c7edec8b9f5d4facdf22f48d99ee0
|
||||
8
Runtime/Core.meta
Normal file
8
Runtime/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90864d7c8ee7ae6409c8a0c0a2ea9075
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Runtime/IPSXBinaryWritable.cs
Normal file
18
Runtime/IPSXBinaryWritable.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Runtime/IPSXBinaryWritable.cs.meta
Normal file
2
Runtime/IPSXBinaryWritable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa53647f0fc3ed24292dd3fd9a0b294e
|
||||
20
Runtime/IPSXExportable.cs
Normal file
20
Runtime/IPSXExportable.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
2
Runtime/IPSXExportable.cs.meta
Normal file
2
Runtime/IPSXExportable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0598c601ee3672b40828f0d31bbec29b
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1291c85b333132b8392486949420d31a
|
||||
guid: c760e5745d5c72746aec8ac9583c456f
|
||||
@@ -1,6 +1,6 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Splashedit.RuntimeCode
|
||||
namespace SplashEdit.RuntimeCode
|
||||
{
|
||||
public class LuaFile : ScriptableObject
|
||||
{
|
||||
@@ -13,3 +13,4 @@ namespace Splashedit.RuntimeCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3b07239f3beb7a87ad987c3fedae9c1
|
||||
guid: 00e64fcbfc4e23e4dbe284131fa4d89b
|
||||
43
Runtime/PSXAudioSource.cs
Normal file
43
Runtime/PSXAudioSource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXAudioSource.cs.meta
Normal file
2
Runtime/PSXAudioSource.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c4c3feb30e8c264baddc3a5e774473b
|
||||
357
Runtime/PSXCollisionExporter.cs
Normal file
357
Runtime/PSXCollisionExporter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXCollisionExporter.cs.meta
Normal file
2
Runtime/PSXCollisionExporter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 701b39be55b3bbb46b1c2a4ddaa34132
|
||||
@@ -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 = "";
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbd8c66199e036896848ce1569567dd6
|
||||
guid: b6e1524fb8b4b754e965d03e634658e6
|
||||
61
Runtime/PSXInteractable.cs
Normal file
61
Runtime/PSXInteractable.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXInteractable.cs.meta
Normal file
2
Runtime/PSXInteractable.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b542f4ca31fa6548b8914e96dd0fae2
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b707b7d499862621fb6c82aba4caa183
|
||||
guid: 15a0e6c8af6d78e46bb65ef21c3f75fb
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9025daa0c62549ee29d968f86c69eec9
|
||||
guid: 0bde77749a0264146a4ead39946dce2f
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
522
Runtime/PSXNavRegionBuilder.cs
Normal file
522
Runtime/PSXNavRegionBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXNavRegionBuilder.cs.meta
Normal file
2
Runtime/PSXNavRegionBuilder.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7446b9ee150d0994fb534c61cd894d6c
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
59
Runtime/PSXPortalLink.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXPortalLink.cs.meta
Normal file
2
Runtime/PSXPortalLink.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4d8e9f3a52c6b05c937fa20e48d1b6f
|
||||
439
Runtime/PSXRoom.cs
Normal file
439
Runtime/PSXRoom.cs
Normal 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
2
Runtime/PSXRoom.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3c7d8e2f41b5a94b826e91f3d7c0a5e
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
693
Runtime/PSXSceneWriter.cs
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PSXSceneWriter.cs.meta
Normal file
2
Runtime/PSXSceneWriter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c213ee895b048d04088d60920fbf4bfc
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac29cfb818d45b12dba84e61317c794b
|
||||
guid: 0ee2ed052f09f7f419c794493d751029
|
||||
@@ -251,7 +251,6 @@ namespace SplashEdit.RuntimeCode
|
||||
atlas.PositionY = y;
|
||||
_finalizedAtlases.Add(atlas);
|
||||
placed = true;
|
||||
Debug.Log($"Placed an atlas at: {x},{y}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae72963f3a7f0820cbc31cb4764c51cb
|
||||
guid: 2a5f290a3e0de24448841847f2039f58
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8c9b0581c1e4eeb6296f4c162359043f
|
||||
guid: eed25c4c241a9114fa75776896d726a7
|
||||
@@ -7,8 +7,11 @@
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"overrideReferences": true,
|
||||
"precompiledReferences": [
|
||||
"DotRecast.Core.dll",
|
||||
"DotRecast.Recast.dll"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1afaf17520143848ea52af093808349d
|
||||
guid: 4db83d7a3934bae48ae5d04fda8335bb
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
|
||||
Reference in New Issue
Block a user