Files
secretsplash/Runtime/PSXNavRegionBuilder.cs
2026-03-27 16:39:42 +01:00

525 lines
22 KiB
C#

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)
{
if (exporter.CollisionType == PSXCollisionType.Dynamic)
continue;
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;
}
}