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

439
Runtime/PSXRoom.cs Normal file
View File

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