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 } /// /// 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. /// 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 _regions = new(); private List _portals = new(); private int _startRegion; public int RegionCount => _regions.Count; public int PortalCount => _portals.Count; public IReadOnlyList Regions => _regions; public IReadOnlyList 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 vertsXZ = new(); public float planeA, planeB, planeD; public int portalStart, portalCount; public NavSurfaceType surfaceType; public byte roomIndex; public Plane floorPlane; public List worldTris = new(); public List 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; } /// PSXRoom volumes for spatial room assignment. Set before Build(). 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(); var allTris = new List(); 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(); 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>(); for (int i = 0; i < _regions.Count; i++) perRegion[i] = new List(); // 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 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 outVerts, List outTris) { foreach (var exporter in exporters) { if (exporter.CollisionType == PSXCollisionType.Dynamic) continue; MeshFilter mf = exporter.GetComponent(); 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 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; } /// /// 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. /// 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; } } /// /// Fallback room assignment via BFS over nav portal connectivity. /// Used when no PSXRoom volumes exist (exterior scenes). /// 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(); 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; } }