using System; using System.Collections.Generic; using UnityEngine; namespace SplashEdit.RuntimeCode { /// /// 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. /// [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; /// World-space AABB of this room. 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; } /// Check if a world-space point is inside this room volume. 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 } } /// /// Portal between two PSXRoom volumes, stored during export. /// Built from PSXPortalLink scene components. /// 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) } /// /// 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. /// public class PSXRoomBuilder { private PSXRoom[] _rooms; private List _portals = new List(); private List[] _roomTriRefs; private List _catchAllTriRefs = new List(); 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; } } /// /// Build the room system: assign triangles to rooms and read user-defined portals. /// /// All PSXRoom components in the scene. /// All PSXPortalLink components (user-placed portals). /// All object exporters (for triangle centroid testing). /// GTE coordinate scaling factor. public void Build(PSXRoom[] rooms, PSXPortalLink[] portalLinks, PSXObjectExporter[] exporters, float gteScaling) { _rooms = rooms; if (rooms == null || rooms.Length == 0) return; _roomTriRefs = new List[rooms.Length]; for (int i = 0; i < rooms.Length; i++) _roomTriRefs[i] = new List(); _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(); 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)"); } /// /// 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. /// 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[_rooms.Length]; for (int i = 0; i < _rooms.Length; i++) { roomSets[i] = new HashSet(); 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[] roomSets, ref int duplicated) { var srcList = new List(_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(); 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++; } } } /// /// Convert PSXPortalLink components into PSXPortal entries. /// Maps PSXRoom references to room indices, validates, and stores center positions. /// private void BuildPortals(PSXPortalLink[] portalLinks) { if (portalLinks == null) return; // Build a fast lookup: PSXRoom instance → index. var roomIndex = new Dictionary(); 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 }); } } /// /// Write room/portal data to the splashpack binary. /// 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; } } }