diff --git a/Prowl.Editor.Test/AssetDatabaseTests.cs b/Prowl.Editor.Test/AssetDatabaseTests.cs index 3a4135b84..a7befedf5 100644 --- a/Prowl.Editor.Test/AssetDatabaseTests.cs +++ b/Prowl.Editor.Test/AssetDatabaseTests.cs @@ -784,5 +784,36 @@ public void UnknownExtension_IsTrackedButNotResolvable() Assert.Null(Assets.Get(g)); } + /// + /// A .navmesh is written and read as binary Echo. Its payload is compressed voxelization + /// blobs, which as text become base64 — bigger, slower to parse, and no more readable. A + /// text one does not parse as binary, so it fails the import outright rather than loading + /// as something wrong; rebaking is the migration. + /// + [Fact] + public void NavMesh_RoundTripsAsBinary_AndRejectsText() + { + NavMeshData? baked = NavMeshBuilder.Build(new NavMeshBuildSettings(), [FlatQuad(20f)]); + Assert.NotNull(baked); + EchoObject echo = Serializer.Serialize(typeof(object), baked!); + + echo.WriteToBinary(new FileInfo(AssetAbsolutePath("Baked.navmesh"))); + Guid guid = Assets.ImportFile("Baked.navmesh"); + Assert.NotEqual(Guid.Empty, guid); + + var loaded = Assets.Get(guid) as NavMeshData; + Assert.NotNull(loaded); + Assert.Equal(baked!.CacheLayers.Count, loaded!.CacheLayers.Count); + + File.WriteAllText(AssetAbsolutePath("Legacy.navmesh"), echo.WriteToString()); + Assert.Null(Assets.Get(Assets.ImportFile("Legacy.navmesh"))); + } + + private static NavMeshGeometrySource FlatQuad(float size) + { + Prowl.Vector.Float3[] verts = [new(0, 0, 0), new(0, 0, size), new(size, 0, size), new(size, 0, 0)]; + return new NavMeshGeometrySource(verts, [0, 1, 2, 0, 2, 3], Prowl.Vector.Float4x4.Identity); + } + #endregion } diff --git a/Prowl.Editor.Test/BuildSystemTests.cs b/Prowl.Editor.Test/BuildSystemTests.cs index 78df9cac2..4c17e5ba9 100644 --- a/Prowl.Editor.Test/BuildSystemTests.cs +++ b/Prowl.Editor.Test/BuildSystemTests.cs @@ -1964,6 +1964,7 @@ public void EveryDesktopTarget_MapsToTheRightRuntimePrefix(string targetId, stri [InlineData(PlayerSettingsFiles.Time)] [InlineData(PlayerSettingsFiles.Assets)] [InlineData(PlayerSettingsFiles.TagsAndLayers)] + [InlineData(PlayerSettingsFiles.Navigation)] public void EveryFileThePlayerReads_IsExportedByATypeOfThatName(string expected) { var entry = Assert.Single(EditorRegistries.SettingsEntries.Where(e => e.Type.Name == expected)); diff --git a/Prowl.Editor.Test/ProjectSettingsTests.cs b/Prowl.Editor.Test/ProjectSettingsTests.cs index 88a9946d8..8218264f1 100644 --- a/Prowl.Editor.Test/ProjectSettingsTests.cs +++ b/Prowl.Editor.Test/ProjectSettingsTests.cs @@ -16,6 +16,25 @@ public ProjectSettingsTests() EditorRegistries.OnProjectOpened(); } + // ResetToDefaults is not a button — it runs when a project is opened, before that project's + // own settings load. Deriving the "defaults" from the live NavMeshAreas table would therefore + // carry the PREVIOUS project's area names and costs into the new one. + [Fact] + public void NavigationSettings_ResetToDefaults_IgnoresTheLiveAreaTable() + { + const int Custom = 3; // built-in areas are immutable, so only custom ones can drift + Prowl.Runtime.NavMeshAreas.SetAreaName(Custom, "Swamp"); + Prowl.Runtime.NavMeshAreas.SetAreaCost(Custom, 5f); + + var settings = EditorRegistries.GetSettings(); + settings.ResetToDefaults(); + + Assert.Equal(string.Empty, settings.AreaNames[Custom]); + Assert.Equal(1f, settings.AreaCosts[Custom]); + Assert.Equal("Walkable", settings.AreaNames[Prowl.Runtime.NavMeshAreas.Walkable]); + Assert.Equal("Jump", settings.AreaNames[Prowl.Runtime.NavMeshAreas.Jump]); + } + // Settings persist as Echo YAML: a saved value must survive a save/load round-trip. [Fact] public void SettingsSaveLoad_RoundTripsYaml() diff --git a/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs b/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs index 214e94acd..933690280 100644 --- a/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs +++ b/Prowl.Editor/AssetsDatabase/Importers/ImportHelper.cs @@ -34,11 +34,21 @@ public static bool ImportEchoObject(ImportContext ctx, string errorLabel) => ImportEcho(ctx, errorLabel); public static bool ImportEcho(ImportContext ctx, string errorLabel) where T : EngineObject + => ImportEcho(ctx, errorLabel, static path => EchoObject.ReadFromString(File.ReadAllText(path))); + + /// + /// As , for assets written with Echo's + /// binary format — the one to use when the payload is bulk bytes rather than something a + /// human reads or diffs. + /// + public static bool ImportEchoBinary(ImportContext ctx, string errorLabel) where T : EngineObject + => ImportEcho(ctx, errorLabel, static path => EchoObject.ReadFromBinary(new FileInfo(path))); + + private static bool ImportEcho(ImportContext ctx, string errorLabel, Func read) where T : EngineObject { try { - string text = File.ReadAllText(ctx.AbsolutePath); - var echo = EchoObject.ReadFromString(text); + var echo = read(ctx.AbsolutePath); var serCtx = CreateTrackingContext(out var dependencies); var asset = Serializer.Deserialize(echo, serCtx); if (asset != null) diff --git a/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs new file mode 100644 index 000000000..3fd2b3a15 --- /dev/null +++ b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs @@ -0,0 +1,18 @@ +using Prowl.Runtime; + +namespace Prowl.Editor.Importers; + +/// +/// Imports .navmesh files - Echo-serialized NavMeshData objects (baked navigation meshes). +/// Binary, because a navmesh is almost entirely compressed voxelization blobs: as text they are +/// base64, which is larger, slower to parse, and undiffable anyway. +/// +[ImporterFor(".navmesh")] +public class NavMeshDataImporter : AssetImporter +{ + /// Bumping this reimports every .navmesh. A text-written one does not parse as + /// binary, so it fails the import rather than loading wrong — rebake it. + public override int Version => 2; + + public override bool Import(ImportContext ctx) => ImportHelper.ImportEchoBinary(ctx, "nav mesh data"); +} diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index c1c019bab..6de419c53 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -290,5 +290,8 @@ public static void Register(OrigamiUI.AttributeHandlerRegistry registry) registry.Register(new RangeAttributeHandler()); registry.Register(new TextAreaAttributeHandler()); registry.Register(new TooltipAttributeHandler()); + registry.Register(new NavMeshAreaAttributeHandler()); + registry.Register(new NavMeshAreaMaskAttributeHandler()); + registry.Register(new NavMeshAgentTypeAttributeHandler()); } } diff --git a/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs b/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs new file mode 100644 index 000000000..863d26102 --- /dev/null +++ b/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs @@ -0,0 +1,147 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.IO; + +using Prowl.Echo; +using Prowl.Editor.Core; +using Prowl.Editor.GUI; +using Prowl.Editor.GUI.SceneView; +using Prowl.Editor.Projects; +using Prowl.Editor.Theming; +using Prowl.OrigamiUI; +using Prowl.PaperUI; +using Prowl.Runtime; + +namespace Prowl.Editor.Inspector; + +/// +/// Inspector for : the default property grid plus an editor bake +/// that saves the result as a .navmesh asset in a SceneName_navmesh folder (mirroring +/// the lightmapper's SceneName_lightmaps convention) and assigns it to the surface, so the +/// baked navmesh survives scene reloads and ships with the project. +/// +[CustomEditor(typeof(NavMeshSurface))] +public class NavMeshSurfaceEditor : CustomEditor +{ + public override void OnGUI(Paper paper, string id, object target) + { + var surface = (NavMeshSurface)target; + + // Pre-snapshot: captures entire component state before any widget mutates it, so + // default-grid edits, the Advanced fields, and the Reset button all undo uniformly. + Undo.Snapshot(surface); + + // Basic fields (agent type, collection, layers, geometry, data ref) via the default + // grid; DefaultArea and the build overrides are [HideInInspector] and drawn below — + // the same basic/advanced split Unity's NavMeshSurface uses. + DrawDefaultInspector(paper, $"{id}_def", target); + + paper.Box($"{id}_adv_sp").Height(4); + Origami.Foldout(paper, $"{id}_adv", "Advanced").Body(() => + { + NavMeshAreaAttributeHandler.DrawAreaField(paper, $"{id}_adv_area", "Default Area", surface.DefaultArea, v => + { + surface.DefaultArea = v; + EditorSceneManager.MarkDirty(); + }); + + PropertyGridUtils.Draw(paper, $"{id}_adv_ovr", surface.BuildOverrides, + _ => EditorSceneManager.MarkDirty()); + + paper.Box($"{id}_adv_reset_sp").Height(4); + Origami.Button(paper, $"{id}_adv_reset", "Reset Advanced To Defaults", () => + { + surface.BuildOverrides = new NavMeshBuildOverrides(); + surface.DefaultArea = NavMeshAreas.Walkable; + EditorSceneManager.MarkDirty(); + }).Show(); + }); + + paper.Box($"{id}_sp").Height(6); + Origami.Header(paper, $"{id}_bake_hdr", $"{EditorIcons.Map} Baking").Underline().Show(); + + // One Bake button (Unity-style). In edit mode it bakes to a .navmesh asset so the + // result persists; during play it does an in-memory bake (baking to disk mid-play is + // rarely intended). The code-only runtime API remains NavMeshSurface.BuildNavMesh(). + Origami.Button(paper, $"{id}_bake", "Bake NavMesh", () => + { + if (Application.IsPlaying) surface.BuildNavMesh(); + else BakeToAsset(surface); + }).Show(); + + var data = surface.NavMeshData.Res; + if (data.IsValid() && data!.HasTiles) + { + Origami.Button(paper, $"{id}_clear", $"{EditorIcons.Trash} Clear", () => Clear(surface)).Show(); + + paper.Box($"{id}_sp2").Height(4); + Origami.Label(paper, $"{id}_stats", + $"{data.CacheLayers.Count} cache layers · agent r={data.Settings.AgentRadius:0.##} h={data.Settings.AgentHeight:0.##} · voxel {data.Settings.EffectiveVoxelSize:0.###} · tile {data.Settings.EffectiveTileSize}") + .Show(); + } + } + + /// Unregister and drop the surface's baked data reference. The .navmesh file (if + /// any) is left on disk; delete it from the Assets panel to remove it fully. + private static void Clear(NavMeshSurface surface) + { + surface.NavMeshData = default; + surface.RefreshRegistration(); + EditorSceneManager.MarkDirty(); + } + + private static void BakeToAsset(NavMeshSurface surface) + { + try + { + // Bake in memory first (also registers with the scene while playing/valid). + if (!surface.BuildNavMesh()) + return; + + Runtime.NavMeshData? data = surface.NavMeshData.Res; + if (data.IsNotValid()) return; + + var db = EditorAssetBackend.Instance; + var scene = surface.GameObject.Scene; + + // A subfolder named after the scene, next to the scene asset, holding + // " NavMesh.navmesh" (e.g. Assets/Scenes/Test Scene/Test Scene NavMesh.navmesh). + string sceneRel = scene.IsValid() && !string.IsNullOrEmpty(scene!.AssetPath) ? scene.AssetPath : ""; + string sceneDir = string.IsNullOrEmpty(sceneRel) ? "" : (Path.GetDirectoryName(sceneRel) ?? "").Replace('\\', '/'); + string sceneName = string.IsNullOrEmpty(sceneRel) ? "Scene" : Path.GetFileNameWithoutExtension(sceneRel); + string folderRel = (string.IsNullOrEmpty(sceneDir) ? "" : sceneDir + "/") + sceneName; + string folderAbs = Path.Combine(Project.Current!.AssetsPath, folderRel); + Directory.CreateDirectory(folderAbs); + + string fileRel = folderRel + "/" + Sanitize(sceneName + " NavMesh") + ".navmesh"; + string fileAbs = Path.Combine(Project.Current.AssetsPath, fileRel); + data!.Name = Path.GetFileNameWithoutExtension(fileRel); + Serializer.Serialize(typeof(object), data).WriteToBinary(new FileInfo(fileAbs)); + + Guid guid = db.ImportFile(fileRel); + if (guid == Guid.Empty) + { + Runtime.Debug.LogError($"[Navigation] Failed to import baked navmesh at {fileRel}."); + return; + } + + surface.NavMeshData = new AssetRef(guid); + surface.RefreshRegistration(); + EditorSceneManager.MarkDirty(); + Runtime.Debug.Log($"[Navigation] Baked navmesh saved to {fileRel} ({data.CacheLayers.Count} cache layers)."); + } + catch (Exception e) + { + Runtime.Debug.LogError($"[Navigation] Bake failed: {e.Message}\n{e.StackTrace}"); + } + } + + private static string Sanitize(string name) + { + foreach (char c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '_'); + return string.IsNullOrEmpty(name) ? "NavMesh" : name; + } +} diff --git a/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs b/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs new file mode 100644 index 000000000..33e5475f7 --- /dev/null +++ b/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs @@ -0,0 +1,225 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Reflection; + +using Prowl.PaperUI; +using Prowl.PaperUI.LayoutEngine; +using Prowl.Runtime; + +namespace Prowl.Editor.GUI; + +/// +/// The property-grid row recipe (gutter padding, label width/colour/truncation) these handlers +/// draw with. They are reachable from outside the grid's field loop — a custom editor laying +/// fields out by hand calls the same dropdowns — so the recipe lives in one place rather than +/// being hand-copied into each, and grid metric changes land here. +/// +internal static class HandlerRowLayout +{ + /// Draw a label + control row matching the default grid rows. The control is + /// drawn inside a stretch-width, row-height box. + public static void LabelledRow(Paper paper, string id, string label, Action drawControl) + { + var theme = OrigamiUI.Origami.Current; + var m = theme.Metrics; + var font = theme.Font; + + using (paper.Row(id).Height(UnitValue.Auto).MinHeight(m.RowHeight) + .Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter()) + { + if (font != null && !string.IsNullOrEmpty(label)) + { + paper.Box($"{id}_lbl") + .Width(m.LabelWidth).Height(m.RowHeight) + .Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch()) + .IsNotInteractable() + .Text(label, font).TextColor(theme.Ink.C300) + .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate(); + } + + using (paper.Box($"{id}_ctl").Width(UnitValue.Stretch()).Height(m.RowHeight).Enter()) + drawControl(); + } + } +} + +/// +/// [NavMeshArea] - draws an int field as a dropdown of the navigation areas defined in +/// project settings, so users pick "Walkable" instead of typing 0. +/// +public class NavMeshAreaAttributeHandler : OrigamiUI.AttributeHandler +{ + public override bool OnDraw(Paper paper, string id, string label, Attribute attr, + FieldInfo field, object target, Action onChange, int depth) + { + if (field.FieldType != typeof(int)) return false; + DrawAreaField(paper, id, label, (int)(field.GetValue(target) ?? 0), v => onChange(v)); + return true; + } + + /// Labelled area dropdown over the defined areas — shared by the attribute + /// handler and custom editors that lay fields out by hand. + public static void DrawAreaField(Paper paper, string id, string label, int value, Action onChange) + { + List areas = NavMeshAreas.GetDefinedAreas(); + if (!areas.Contains(value)) areas.Add(value); // never hide the current value, even if undefined + DrawIdDropdown(paper, id, label, value, onChange, areas, DisplayName); + } + + internal static string DisplayName(int area) + { + string name = NavMeshAreas.GetAreaName(area); + return string.IsNullOrEmpty(name) ? $"Area {area}" : name; + } + + /// Shared labelled-dropdown-over-int-ids row, laid out via + /// so handler-drawn fields don't visually stick out of the + /// inspector column. + internal static void DrawIdDropdown(Paper paper, string id, string label, int value, + Action onChange, List ids, Func display) + { + HandlerRowLayout.LabelledRow(paper, id, label, () => + { + OrigamiUI.Origami.Dropdown(paper, $"{id}_dd", value, v => onChange(v), ids) + .Display(display) + .Show(); + }); + } +} + +/// +/// [NavMeshAgentType] - draws an int field as a dropdown of the agent types defined in +/// project settings, so users pick "Humanoid" instead of typing 0. On a List<int> field +/// it draws a multi-select of agent types instead (the agent-type analogue of the +/// LayerMask/[NavMeshAreaMask] editor), used by the modifier components' affected-types lists. +/// +public class NavMeshAgentTypeAttributeHandler : OrigamiUI.AttributeHandler +{ + public override bool OnDraw(Paper paper, string id, string label, Attribute attr, + FieldInfo field, object target, Action onChange, int depth) + { + if (field.FieldType == typeof(List)) + { + DrawAgentTypeList(paper, id, label, (List?)field.GetValue(target) ?? [], onChange); + return true; + } + + if (field.FieldType != typeof(int)) return false; + + var ids = new List(NavMeshAgentTypes.All.Count); + foreach (NavMeshAgentType type in NavMeshAgentTypes.All) + ids.Add(type.Id); + int value = (int)(field.GetValue(target) ?? 0); + if (!ids.Contains(value)) ids.Add(value); // stale reference stays visible + + // The nicified "Agent Type Id" is noise — collapse it to Unity's "Agent Type". Any + // other field name keeps its own label (the handler is registered globally, so a + // third-party [NavMeshAgentType] int PatrolAgentType must not be relabelled). + string shown = label == "Agent Type Id" ? "Agent Type" : label; + NavMeshAreaAttributeHandler.DrawIdDropdown(paper, id, shown, value, v => onChange(v), ids, NavMeshAgentTypes.GetName); + return true; + } + + private static void DrawAgentTypeList(Paper paper, string id, string label, List current, Action onChange) + { + var theme = OrigamiUI.Origami.Current; + var m = theme.Metrics; + var font = theme.Font; + float rh = m.RowHeight; + + var ids = new List(NavMeshAgentTypes.All.Count); + foreach (NavMeshAgentType type in NavMeshAgentTypes.All) + ids.Add(type.Id); + foreach (int selected in current) + if (!ids.Contains(selected)) + ids.Add(selected); // stale references stay visible (named "Agent Type N") + + // "Affected Agent Type Ids" nicified is noise — collapse to Unity's "Affected Agents"; + // other field names keep their own label (same rule as the int branch). + string shown = label == "Affected Agent Type Ids" ? "Affected Agents" : label; + + // Not HandlerRowLayout.LabelledRow: the multi-select needs Height(Auto) so chip + // wrapping reflows the column (same as the [NavMeshAreaMask] editor) — a row-height + // control box would clip the wrapped chips. Label block mirrors the helper's recipe. + using (paper.Row(id).Height(UnitValue.Auto).MinHeight(rh).Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter()) + { + if (font != null && !string.IsNullOrEmpty(shown)) + { + paper.Box($"{id}_lbl") + .Width(m.LabelWidth).Height(rh).Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch()) + .IsNotInteractable() + .Text(shown, font).TextColor(theme.Ink.C300) + .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate(); + } + + OrigamiUI.Origami.MultiDropdown(paper, $"{id}_md", current, picked => onChange(new List(picked)), ids) + .Display(NavMeshAgentTypes.GetName) + .Height(rh) + .SummaryFormat("{0} agent types") + .Searchable() + .Show(); + } + } +} + +/// +/// [NavMeshAreaMask] - draws an int field as a multi-select of the defined navigation areas +/// (the area analogue of the LayerMask editor). +/// +public class NavMeshAreaMaskAttributeHandler : OrigamiUI.AttributeHandler +{ + public override bool OnDraw(Paper paper, string id, string label, Attribute attr, + FieldInfo field, object target, Action onChange, int depth) + { + if (field.FieldType != typeof(int)) return false; + int mask = (int)(field.GetValue(target) ?? NavMeshAreas.AllAreas); + + var theme = OrigamiUI.Origami.Current; + var m = theme.Metrics; + var font = theme.Font; + float rh = m.RowHeight; + + List areas = NavMeshAreas.GetDefinedAreas(); + var selected = new List(); + foreach (int i in areas) + if ((mask & (1 << i)) != 0) selected.Add(i); + + // Not HandlerRowLayout.LabelledRow: the multi-select needs Height(Auto) so chip + // wrapping reflows the column (same as the LayerMask editor) — a row-height control + // box would clip the wrapped chips. Label block mirrors the helper's recipe. + using (paper.Row(id).Height(UnitValue.Auto).MinHeight(rh).Padding(m.PaddingLarge, m.PaddingLarge, 0, 0).RowBetween(m.Padding).Enter()) + { + if (font != null && !string.IsNullOrEmpty(label)) + { + paper.Box($"{id}_lbl") + .Width(m.LabelWidth).Height(rh).Margin(0, 0, UnitValue.Stretch(), UnitValue.Stretch()) + .IsNotInteractable() + .Text(label, font).TextColor(theme.Ink.C300) + .FontSize(m.FontSize).Alignment(TextAlignment.MiddleLeft).TextTruncate(); + } + + OrigamiUI.Origami.MultiDropdown(paper, $"{id}_md", selected, picked => + { + // Everything selected stores as AllAreas (-1) so future areas are included + // automatically — matching how "Everything" behaves on layer masks. + if (picked.Count == areas.Count) + { + onChange(NavMeshAreas.AllAreas); + return; + } + int updated = 0; + foreach (int i in picked) updated |= 1 << i; + onChange(updated); + }, areas) + .Display(NavMeshAreaAttributeHandler.DisplayName) + .Height(rh) + .SummaryFormat("{0} areas") + .Searchable() + .Show(); + } + return true; + } +} diff --git a/Prowl.Editor/Projects/Settings/NavigationSettings.cs b/Prowl.Editor/Projects/Settings/NavigationSettings.cs new file mode 100644 index 000000000..6efd925e6 --- /dev/null +++ b/Prowl.Editor/Projects/Settings/NavigationSettings.cs @@ -0,0 +1,359 @@ +using System; +using System.Collections.Generic; + +using Prowl.OrigamiUI; +using Prowl.PaperUI; +using Prowl.PaperUI.LayoutEngine; +using Prowl.Runtime; +using Prowl.Editor.Theming; + +using Prowl.Editor.GUI; + +namespace Prowl.Editor.Projects.Settings; + +/// +/// Project-wide navigation configuration: the area table (names + default path costs) +/// applied to . Walkable, Not Walkable, and Jump are built-in; +/// users add up to 29 more, Tags-style. Removal clears the slot rather than shifting later +/// areas — masks and baked polygons reference areas by index, so indices must stay stable. +/// Written to Navigation.yaml for the built player, where +/// PlayerSettingsLoader.ApplyNavigation restores it. +/// +[ProjectSettings("Navigation", EditorIcons.Compass, order: 21)] +public class NavigationSettings : ProjectSettingsBase +{ + public List AreaNames = CreateDefaultNames(); + public List AreaCosts = CreateDefaultCosts(); + public List AgentTypes = [new NavMeshAgentType { Id = NavMeshAgentTypes.Humanoid, Name = "Humanoid" }]; + + /// Monotonic id counter for Add Agent Type. Persisted so deleting the + /// highest-id type can never hand its id to a later, unrelated type — surfaces and + /// agents still referencing the deleted id would silently rebind to the new one. + public int NextAgentTypeId = 1; + + private int _activeTab; // 0 = Agents, 1 = Areas + + // Literal defaults, not reads of NavMeshAreas: these feed ResetToDefaults, which runs as a + // project opens, before that project's settings load — the statics still hold the previous + // project's table at that point. + private static List CreateDefaultNames() + { + var names = new List(NavMeshAreas.MaxAreas); + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + names.Add(i switch + { + NavMeshAreas.Walkable => "Walkable", + NavMeshAreas.NotWalkable => "Not Walkable", + NavMeshAreas.Jump => "Jump", + _ => string.Empty, + }); + return names; + } + + private static List CreateDefaultCosts() + { + var costs = new List(NavMeshAreas.MaxAreas); + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + costs.Add(1f); + return costs; + } + + private void EnsureSize() + { + while (AreaNames.Count < NavMeshAreas.MaxAreas) AreaNames.Add(string.Empty); + while (AreaCosts.Count < NavMeshAreas.MaxAreas) AreaCosts.Add(1f); + } + + public override void Apply() + { + EnsureSize(); + // Not Walkable is never traversed, so its cost is meaningless; pin it (matches Unity). + AreaCosts[NavMeshAreas.NotWalkable] = 1f; + NavMeshAreas.ApplyTable(AreaNames, AreaCosts); + NavMeshAgentTypes.ApplyTable(AgentTypes); + } + + public override void ResetToDefaults() + { + AreaNames = CreateDefaultNames(); + AreaCosts = CreateDefaultCosts(); + AgentTypes = [new NavMeshAgentType { Id = NavMeshAgentTypes.Humanoid, Name = "Humanoid" }]; + NextAgentTypeId = 1; + Apply(); + } + + private void Changed() + { + Apply(); + EditorRegistries.SaveSettings(); + } + + /// Row swatch: the same per-area color the scene-view navmesh overlay draws + /// with (opaque for the UI). + private static System.Drawing.Color AreaSwatchColor(int areaIndex) + { + Prowl.Vector.Color c = NavMeshSurface.AreaColor(areaIndex); + return System.Drawing.Color.FromArgb(255, + (int)(Math.Clamp(c.R, 0f, 1f) * 255), + (int)(Math.Clamp(c.G, 0f, 1f) * 255), + (int)(Math.Clamp(c.B, 0f, 1f) * 255)); + } + + public override void OnGUI(Paper paper, float width) + { + EnsureSize(); + var font = EditorTheme.DefaultFont; + if (font == null) return; + + // Unity-style tab toolbar: Agents | Areas. + Origami.ButtonGroup(paper, "nav_tabs", _activeTab, t => _activeTab = t) + .Item("Agents") + .Item("Areas") + .Show(); + paper.Box("nav_tabs_sp").Height(8); + + if (_activeTab == 0) DrawAgentsTab(paper, font); + else DrawAreasTab(paper, font); + } + + // ── Agents tab ────────────────────────────────────────────────────── + + private void DrawAgentsTab(Paper paper, Prowl.Scribe.FontFile font) + { + Origami.Header(paper, "nav_agents_hdr", $"{EditorIcons.Compass} Agent Types").Underline().Show(); + + const float NumW = 64, DelW = 20; + + // Column headers, aligned with the rows below. + using (paper.Row("nav_agent_cols").Height(20).RowBetween(6).ChildLeft(8).ChildRight(4).Enter()) + { + paper.Box("nav_agent_cols_name") + .Width(UnitValue.Stretch()).Height(18).ChildLeft(4) + .Text("Name", font).TextColor(EditorTheme.Ink300) + .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft); + DrawAgentColHeader(paper, "nav_agent_cols_r", "Radius", NumW, font); + DrawAgentColHeader(paper, "nav_agent_cols_h", "Height", NumW, font); + DrawAgentColHeader(paper, "nav_agent_cols_s", "Slope°", NumW, font); + DrawAgentColHeader(paper, "nav_agent_cols_c", "Climb", NumW, font); + paper.Box("nav_agent_cols_del").Width(DelW).Height(18); + } + + for (int i = 0; i < AgentTypes.Count; i++) + { + int idx = i; + NavMeshAgentType type = AgentTypes[i]; + bool isBuiltin = type.Id == NavMeshAgentTypes.Humanoid; + + using (paper.Row($"nav_agent_{type.Id}").Height(26).RowBetween(6).ChildLeft(8).ChildRight(4).Enter()) + { + // Name: same control for every row; the built-in Humanoid's name is locked. + using (paper.Box($"nav_agent_name_{type.Id}").Width(UnitValue.Stretch()).Height(22).Enter()) + { + IDisposable? dim = isBuiltin ? EnableIfAttributeHandler.PushDisabledScope() : null; + try + { + Origami.TextField(paper, $"nav_agent_name_tf_{type.Id}", type.Name, v => + { + if (isBuiltin || string.IsNullOrWhiteSpace(v)) return; + string trimmed = v.Trim(); + // Duplicate names would make name lookup ambiguous. + foreach (NavMeshAgentType other in AgentTypes) + if (other != AgentTypes[idx] && string.Equals(other.Name, trimmed, StringComparison.Ordinal)) + return; + AgentTypes[idx].Name = trimmed; + Changed(); + }).Show(); + } + finally { dim?.Dispose(); } + } + + DrawAgentNumField(paper, $"nav_agent_r_{type.Id}", NumW, type.Radius, v => { AgentTypes[idx].Radius = MathF.Max(0.01f, v); Changed(); }); + DrawAgentNumField(paper, $"nav_agent_h_{type.Id}", NumW, type.Height, v => { AgentTypes[idx].Height = MathF.Max(0.01f, v); Changed(); }); + DrawAgentNumField(paper, $"nav_agent_s_{type.Id}", NumW, type.MaxSlope, v => { AgentTypes[idx].MaxSlope = Math.Clamp(v, 0f, 89f); Changed(); }); + DrawAgentNumField(paper, $"nav_agent_c_{type.Id}", NumW, type.MaxClimb, v => { AgentTypes[idx].MaxClimb = MathF.Max(0f, v); Changed(); }); + + if (!isBuiltin) + { + paper.Box($"nav_agent_del_{type.Id}") + .Width(DelW).Height(22).Rounded(3) + .Hovered.BackgroundColor(EditorTheme.Ink200).End() + .Text(EditorIcons.Xmark, font).TextColor(EditorTheme.Ink400) + .FontSize(9f).Alignment(TextAlignment.MiddleCenter) + .OnClick(idx, (id, _) => + { + // Ids are persistent - removing an entry never renumbers others. + AgentTypes.RemoveAt(id); + Changed(); + }); + } + else + { + paper.Box($"nav_agent_del_{type.Id}").Width(DelW).Height(22); // column alignment spacer + } + } + } + + paper.Box("nav_agents_sp").Height(4); + + Origami.Button(paper, "nav_add_agent", $"{EditorIcons.Plus} Add Agent Type", () => + { + // Max() guards settings saved before NextAgentTypeId existed (counter at default 1 + // with higher ids already in the table). + int nextId = NextAgentTypeId; + foreach (NavMeshAgentType t in AgentTypes) nextId = Math.Max(nextId, t.Id + 1); + NextAgentTypeId = nextId + 1; + + string name = "New Agent"; + for (int n = 1; AgentTypes.Exists(t => t.Name == name); n++) + name = $"New Agent ({n})"; + + AgentTypes.Add(new NavMeshAgentType { Id = nextId, Name = name }); + Changed(); + }).Show(); + } + + private static void DrawAgentColHeader(Paper paper, string id, string label, float width, Prowl.Scribe.FontFile font) + { + paper.Box(id) + .Width(width).Height(18).ChildLeft(4) + .Text(label, font).TextColor(EditorTheme.Ink300) + .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft); + } + + private static void DrawAgentNumField(Paper paper, string id, float width, float value, Action setter) + { + using (paper.Box(id).Width(width).Height(22).Enter()) + { + Origami.NumericField(paper, $"{id}_nf", value, setter).Show(); + } + } + + // ── Areas tab ─────────────────────────────────────────────────────── + + private void DrawAreasTab(Paper paper, Prowl.Scribe.FontFile font) + { + Origami.Header(paper, "nav_areas_hdr", $"{EditorIcons.Compass} Navigation Areas").Underline().Show(); + + // Column layout shared by the header and every row (mirrors Unity's Areas tab): + // [swatch 6] [slot label 76] [Name stretch] [Cost 70] [delete 20] + const float SwatchW = 6, SlotW = 76, CostW = 70, DelW = 20; + + using (paper.Row("nav_area_cols").Height(20).RowBetween(6).ChildLeft(8).ChildRight(4).Enter()) + { + paper.Box("nav_area_cols_swatch").Width(SwatchW).Height(18); + paper.Box("nav_area_cols_slot").Width(SlotW).Height(18); + paper.Box("nav_area_cols_name") + .Width(UnitValue.Stretch()).Height(18).ChildLeft(4) + .Text("Name", font).TextColor(EditorTheme.Ink300) + .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft); + paper.Box("nav_area_cols_cost") + .Width(CostW).Height(18).ChildLeft(4) + .Text("Cost", font).TextColor(EditorTheme.Ink300) + .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft); + paper.Box("nav_area_cols_del").Width(DelW).Height(18); + } + + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + { + int idx = i; + bool isBuiltin = i <= NavMeshAreas.Jump; + if (!isBuiltin && string.IsNullOrEmpty(AreaNames[i])) continue; // empty slot: hidden + + using (paper.Row($"nav_area_{i}").Height(26).RowBetween(6).ChildLeft(8).ChildRight(4).Enter()) + { + // Swatch in the same color the scene-view overlay uses for this area. + paper.Box($"nav_area_swatch_{i}") + .Width(SwatchW).Height(22).Rounded(2) + .BackgroundColor(AreaSwatchColor(i)); + + paper.Box($"nav_area_slot_{i}") + .Width(SlotW).Height(22) + .Text(isBuiltin ? $"Built-in {i}" : $"User {i}", font).TextColor(EditorTheme.Ink400) + .FontSize(EditorTheme.FontSizeSmall).Alignment(TextAlignment.MiddleLeft); + + // Name: always the same field control so the column lines up; built-ins are + // rendered disabled instead of as bare labels. + using (paper.Box($"nav_area_name_{i}").Width(UnitValue.Stretch()).Height(22).Enter()) + { + IDisposable? dim = isBuiltin ? EnableIfAttributeHandler.PushDisabledScope() : null; + try + { + Origami.TextField(paper, $"nav_area_name_tf_{i}", AreaNames[i], v => + { + if (isBuiltin || string.IsNullOrWhiteSpace(v)) return; + string trimmed = v.Trim(); + // Duplicate names would make GetAreaFromName ambiguous. + int existing = AreaNames.IndexOf(trimmed); + if (existing >= 0 && existing != idx) return; + AreaNames[idx] = trimmed; + Changed(); + }).Show(); + } + finally { dim?.Dispose(); } + } + + // Cost: plain float field, clamped to >= 1 on apply (Detour's A* heuristic + // assumes cost >= 1; smaller values make paths suboptimal). Not Walkable is + // never traversed, so its cost is pinned and disabled. + using (paper.Box($"nav_area_cost_{i}").Width(CostW).Height(22).Enter()) + { + bool costLocked = i == NavMeshAreas.NotWalkable; + IDisposable? dim = costLocked ? EnableIfAttributeHandler.PushDisabledScope() : null; + try + { + Origami.NumericField(paper, $"nav_area_cost_nf_{i}", AreaCosts[i], v => + { + if (costLocked) return; + AreaCosts[idx] = MathF.Max(1f, v); + Changed(); + }).Show(); + } + finally { dim?.Dispose(); } + } + + if (!isBuiltin) + { + paper.Box($"nav_area_del_{i}") + .Width(DelW).Height(22).Rounded(3) + .Hovered.BackgroundColor(EditorTheme.Ink200).End() + .Text(EditorIcons.Xmark, font).TextColor(EditorTheme.Ink400) + .FontSize(9f).Alignment(TextAlignment.MiddleCenter) + .OnClick(idx, (id, _) => + { + // Clear the slot in place; shifting would re-index later areas + // under existing masks and baked navmeshes. + AreaNames[id] = string.Empty; + AreaCosts[id] = 1f; + Changed(); + }); + } + else + { + paper.Box($"nav_area_del_{i}").Width(DelW).Height(22); // spacer keeps columns aligned + } + } + } + + paper.Box("nav_areas_sp").Height(4); + + // Add a new area into the first empty slot with a unique placeholder name; rename it + // in the row's name field. (A text-entry "add" would fire per keystroke.) + Origami.Button(paper, "nav_add_area", $"{EditorIcons.Plus} Add Area", () => + { + int slot = AreaNames.FindIndex(NavMeshAreas.Jump + 1, string.IsNullOrEmpty); + if (slot < 0) + { + Runtime.Debug.LogWarning($"[Navigation] All {NavMeshAreas.MaxAreas} area slots are in use."); + return; + } + + string name = "New Area"; + for (int n = 1; AreaNames.Contains(name); n++) + name = $"New Area ({n})"; + + AreaNames[slot] = name; + AreaCosts[slot] = 1f; + Changed(); + }).Show(); + } +} diff --git a/Prowl.Runtime.Test/NavMeshAllocationTests.cs b/Prowl.Runtime.Test/NavMeshAllocationTests.cs new file mode 100644 index 000000000..d63855e81 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshAllocationTests.cs @@ -0,0 +1,88 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Runtime; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// Locks in the tile-bake allocation behaviour destructible-map games depend on: tiles with +/// no geometry skip the pipeline entirely, and span-pool recycling across rebuilds does not +/// change build output. +/// +public class NavMeshAllocationTests +{ + private static NavMeshBuildSettings TestSettings() => new() + { + OverrideVoxelSize = true, + VoxelSize = 0.5f, + OverrideTileSize = true, + TileSize = 64, + }; + + private static NavMeshGeometrySource CornerQuad() + { + // Covers tile (0,0) of a 96x96 (3x3 tile) world; the other 8 tiles are empty. + Float3[] verts = [new(0, 0, 0), new(0, 0, 36), new(36, 0, 36), new(36, 0, 0)]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.Identity); + } + + /// + /// Tiles nothing overlaps must cost (almost) nothing: the chunky-index check replaces the + /// full heightfield + pipeline run (~132 KB each before the skip). On bounded bakes of + /// mostly-sealed worlds ~99% of tiles take this path, so this bound is what keeps + /// full-bake allocation proportional to walkable area rather than world area. + /// + [Fact] + public void EmptyTile_SkipsPipeline_AllocatingAlmostNothing() + { + var data = NavMeshBuilder.Build(TestSettings(), [CornerQuad()], + worldBounds: new AABB(new Float3(0, -1, 0), new Float3(96, 1, 96))); + Assert.NotNull(data); + + // Rebuild an empty region twice: warm-up, then measure. + List<(int X, int Z, List Layers)> warm = NavMeshBuilder.BuildTilesInBounds( + data!, [CornerQuad()], new Float3(70, -1, 70), new Float3(80, 1, 80)); + Assert.All(warm, t => Assert.Empty(t.Layers)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], new Float3(70, -1, 70), new Float3(80, 1, 80)); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True(allocated < 16 * 1024, + $"Rebuilding an empty tile should skip the pipeline (allocated {allocated / 1024.0:0.0} KB)."); + } + + /// + /// Span pooling must not change output: rebuilding the same tile repeatedly (cold pool, + /// then warm recycled pool) must produce byte-identical layers. This is the guard against a + /// recycled span leaking stale state into a later build. + /// + [Fact] + public void PooledRebuilds_ProduceIdenticalTiles() + { + var data = NavMeshBuilder.Build(TestSettings(), [CornerQuad()], + worldBounds: new AABB(new Float3(0, -1, 0), new Float3(96, 1, 96))); + Assert.NotNull(data); + + var bounds = (Min: new Float3(2, -1, 2), Max: new Float3(30, 1, 30)); + List<(int X, int Z, List Layers)> first = NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], bounds.Min, bounds.Max); + Assert.Contains(first, t => t.Layers.Count > 0); + + for (int i = 0; i < 4; i++) + { + List<(int X, int Z, List Layers)> again = NavMeshBuilder.BuildTilesInBounds(data!, [CornerQuad()], bounds.Min, bounds.Max); + Assert.Equal(first.Count, again.Count); + for (int t = 0; t < first.Count; t++) + { + Assert.Equal(first[t].X, again[t].X); + Assert.Equal(first[t].Z, again[t].Z); + Assert.Equal(first[t].Layers, again[t].Layers); + } + } + } +} diff --git a/Prowl.Runtime.Test/NavMeshBuildTests.cs b/Prowl.Runtime.Test/NavMeshBuildTests.cs new file mode 100644 index 000000000..2708fd923 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshBuildTests.cs @@ -0,0 +1,614 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Recast.Detour; + +using Prowl.Echo; +using Prowl.Runtime; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +public class NavMeshBuildTests +{ + /// + /// A 20x20 quad at y=0, wound counter-clockwise when viewed from above (+Y normal), + /// which is what Recast considers up-facing/walkable. + /// + private static NavMeshGeometrySource FlatQuad(float size = 20f) + { + Float3[] verts = + [ + new(0, 0, 0), + new(0, 0, size), + new(size, 0, size), + new(size, 0, 0), + ]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.Identity); + } + + /// A 10x10 up-facing plane centred on X, offset along Z. Two of these with the + /// default tile grid (64 voxels ≈ 10.67 units) sit either side of a tile boundary, which is + /// what the link-rationing tests need. + private static NavMeshGeometrySource Plane(float zOffset) => new( + [new(-5, 0, -5 + zOffset), new(-5, 0, 5 + zOffset), new(5, 0, 5 + zOffset), new(5, 0, -5 + zOffset)], + [0, 1, 2, 0, 2, 3], Float4x4.Identity); + + private static NavMeshBuildSettings TestSettings() => new() + { + // Coarse voxels + small tiles keep the test fast. + OverrideVoxelSize = true, + VoxelSize = 0.25f, + OverrideTileSize = true, + TileSize = 64, + }; + + /// + /// An asset whose tiles are in a format this engine cannot read says so, naming the versions, + /// rather than handing bytes to Detour that mean something else now. Nothing in the engine + /// produces such an asset — the guard exists for the next time the tile format changes, and + /// this is what keeps it honest until then. + /// + [Fact] + public void NavMeshData_FromAnUnreadableFormat_SaysWhichVersionsItReads() + { + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + Assert.NotNull(data); + data!.FormatVersion = NavMeshData.CurrentFormatVersion + 1; + + var thrown = Assert.Throws(() => data.CreateTileCache(maxObstacles: 1)); + Assert.Contains($"{NavMeshData.MinReadableFormatVersion}..{NavMeshData.CurrentFormatVersion}", thrown.Message); + Assert.Contains("Rebake", thrown.Message); + } + + /// + /// The winding gate: Recast derives walkability from triangle face normals, so a total + /// winding/handedness mismatch fails as "the bake produced nothing" rather than an error. + /// This test existing and passing is what proves the coordinate conventions line up. + /// + [Fact] + public void FlatQuad_UpFacingWinding_ProducesWalkablePolys() + { + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + + Assert.NotNull(data); + Assert.True(data!.HasTiles, "Expected at least one non-empty tile from a flat walkable quad."); + + DtNavMesh navMesh = data.CreateTileCache(1).GetNavMesh(); + int polyCount = 0; + for (int i = 0; i < navMesh.GetMaxTiles(); i++) + { + DtMeshTile tile = navMesh.GetTile(i); + if (tile?.data?.header != null) + polyCount += tile.data.header.polyCount; + } + + Assert.True(polyCount > 0, "Navmesh instantiated but contains no polygons."); + } + + /// The inverse gate: a downward-facing quad must produce nothing walkable — and + /// "nothing walkable" is a null return, never an empty NavMeshData (an empty one registers + /// nowhere and draws nothing, silently). + [Fact] + public void FlatQuad_DownFacingWinding_ReturnsNull() + { + NavMeshGeometrySource quad = FlatQuad(); + // Reverse winding: normals point -Y, nothing is walkable. + (quad.Indices[1], quad.Indices[2]) = (quad.Indices[2], quad.Indices[1]); + (quad.Indices[4], quad.Indices[5]) = (quad.Indices[5], quad.Indices[4]); + + Assert.Null(NavMeshBuilder.Build(TestSettings(), [quad])); + } + + /// + /// Explicit world bounds: a bake whose geometry covers a corner of a much larger world + /// must size its bounds and tile grid from the supplied extent, not the geometry — + /// otherwise later partial rebuilds outside the initial geometry are silently discarded + /// (the destructible-map case: one open spawn cavern in a sealed map). + /// + [Fact] + public void Build_WithWorldBounds_SizesGridFromBounds() + { + // A 10x10 quad in the corner of a declared 100x100 world. + var bounds = new AABB(new Float3(0, -1, 0), new Float3(100, 1, 100)); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad(10f)], worldBounds: bounds); + + Assert.NotNull(data); + Assert.Equal(0, data!.BoundsMin.X, 3); + Assert.Equal(0, data.BoundsMin.Z, 3); + Assert.Equal(100, data.BoundsMax.X, 3); + Assert.Equal(100, data.BoundsMax.Z, 3); + // Y unions with geometry rather than trusting the declared bounds alone. + Assert.True(data.BoundsMin.Y <= 0f && data.BoundsMax.Y >= 0f); + + // Tile capacity must span the declared world: 100/16 per axis => 7x7 = 49 tiles minimum. + Assert.True(data.MaxTiles >= 49, $"MaxTiles must cover the declared bounds, got {data.MaxTiles}."); + } + + [Fact] + public void Build_WithNoGeometry_ReturnsNull() + { + Assert.Null(NavMeshBuilder.Build(TestSettings(), [])); + } + + [Fact] + public void Build_AppliesDefaultAreaToPolys() + { + const int area = 4; + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()], defaultArea: area); + Assert.NotNull(data); + + DtNavMesh navMesh = data!.CreateTileCache(1).GetNavMesh(); + for (int i = 0; i < navMesh.GetMaxTiles(); i++) + { + DtMeshTile tile = navMesh.GetTile(i); + if (tile?.data?.header == null) continue; + for (int p = 0; p < tile.data.header.polyCount; p++) + Assert.Equal(area, NavMeshAreas.FromDetourArea(tile.data.polys[p].GetArea())); + } + } + + /// + /// Two quads separated by a gap wider than the agent: same tile grid, but paths must not + /// cross the gap. Locks in that disconnected geometry stays disconnected. + /// + [Fact] + public void SeparatedQuads_BothBake() + { + NavMeshGeometrySource left = FlatQuad(10f); + NavMeshGeometrySource right = FlatQuad(10f); + right.Transform = Float4x4.CreateTranslation(new Float3(20f, 0, 0)); + + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, right]); + Assert.NotNull(data); + Assert.True(data!.HasTiles); + } + + [Fact] + public void NavMeshData_EchoRoundTrip_PreservesTilesAndSettings() + { + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()], defaultArea: 3); + Assert.NotNull(data); + Assert.True(data!.HasTiles); + + // The serialize → deserialize path every .navmesh asset takes. + EchoObject echo = Serializer.Serialize(data); + NavMeshData? loaded = Serializer.Deserialize(echo); + + Assert.NotNull(loaded); + Assert.Equal(data.CacheLayers.Count, loaded!.CacheLayers.Count); + for (int i = 0; i < data.CacheLayers.Count; i++) + { + Assert.Equal(data.CacheLayers[i].X, loaded.CacheLayers[i].X); + Assert.Equal(data.CacheLayers[i].Z, loaded.CacheLayers[i].Z); + Assert.Equal(data.CacheLayers[i].Data, loaded.CacheLayers[i].Data); + } + + Assert.Equal(data.Settings.AgentRadius, loaded.Settings.AgentRadius); + Assert.Equal(data.TileWorldSize, loaded.TileWorldSize); + Assert.Equal(data.MaxTiles, loaded.MaxTiles); + Assert.Equal(data.MaxPolys, loaded.MaxPolys); + Assert.Equal(data.Origin, loaded.Origin); + + // The reloaded asset must instantiate a working navmesh. + DtNavMesh navMesh = loaded.CreateTileCache(1).GetNavMesh(); + int polyCount = 0; + for (int i = 0; i < navMesh.GetMaxTiles(); i++) + { + DtMeshTile tile = navMesh.GetTile(i); + if (tile?.data?.header != null) + polyCount += tile.data.header.polyCount; + } + Assert.True(polyCount > 0); + } + + /// Two coplanar adjacent quads as separate sources with different areas. + /// Left: x 0..10 (Walkable), right: x 10..20 (area 3). + private static (NavMeshGeometrySource left, NavMeshGeometrySource right) TwoAreaFloor() + { + Float3[] leftVerts = [new(0, 0, 0), new(0, 0, 20), new(10, 0, 20), new(10, 0, 0)]; + Float3[] rightVerts = [new(10, 0, 0), new(10, 0, 20), new(20, 0, 20), new(20, 0, 0)]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return ( + new NavMeshGeometrySource(leftVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable), + new NavMeshGeometrySource(rightVerts, indices, Float4x4.Identity, area: 3)); + } + + private static void AssertTwoAreaSemantics(NavMeshWorld world) + { + // Each side samples as its own area (Mask is the area's bit). + Assert.True(world.SamplePosition(new Float3(4, 0.2f, 10), out NavMeshHit leftHit, 0.5f, NavMesh.AllAreas)); + Assert.Equal(1 << NavMeshAreas.Walkable, leftHit.Mask); + Assert.True(world.SamplePosition(new Float3(16, 0.2f, 10), out NavMeshHit rightHit, 0.5f, NavMesh.AllAreas)); + Assert.Equal(1 << 3, rightHit.Mask); + + // The area boundary must remain traversable — different areas are neighbours, + // not walls. A rubble border must never become invisible geometry. + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(4, 0, 10), new Float3(16, 0, 10), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + + // Excluding area 3 makes the right side unreachable (partial path at best). + int maskWithout3 = ~(1 << 3); + world.CalculatePath(new Float3(4, 0, 10), new Float3(16, 0, 10), maskWithout3, path); + Assert.NotEqual(NavMeshPathStatus.PathComplete, path.Status); + } + + /// The per-source-area gate: NavMeshGeometrySource.Area must survive the bake + /// into Detour poly areas, on the correct side, without breaking adjacency. + [Fact] + public void Build_HonorsPerSourceAreas() + { + (NavMeshGeometrySource left, NavMeshGeometrySource right) = TwoAreaFloor(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, right]); + Assert.NotNull(data); + + var world = new NavMeshWorld(); + world.AddNavMeshData(data!); + + // The triangulation must contain both areas. + NavMeshTriangulation tri = world.CalculateTriangulation(); + var areas = new HashSet(tri.Areas); + Assert.Contains(NavMeshAreas.Walkable, areas); + Assert.Contains(3, areas); + + AssertTwoAreaSemantics(world); + } + + /// Per-source areas must also survive the partial-rebuild path (the drill path + /// builds its provider separately). + [Fact] + public void BuildTilesInBounds_HonorsPerSourceAreas() + { + // Bake the whole floor uniform first... + (NavMeshGeometrySource left, NavMeshGeometrySource right) = TwoAreaFloor(); + NavMeshGeometrySource uniformRight = right; + uniformRight.Area = NavMeshAreas.Walkable; + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [left, uniformRight]); + Assert.NotNull(data); + + var world = new NavMeshWorld(); + NavMeshInstance? instance = world.AddNavMeshData(data!); + Assert.NotNull(instance); + + // ...then rebuild with the right half as area 3 (rubble appearing after a drill). + List<(int X, int Z, List Layers)> rebuilt = NavMeshBuilder.BuildTilesInBounds( + data!, [left, right], new Float3(0, -1, 0), new Float3(20, 1, 20)); + Assert.NotEmpty(rebuilt); + + SwapRebuiltTiles(world, instance!, rebuilt); + AssertTwoAreaSemantics(world); + } + + /// The tile swap NavMeshSurface.ApplyRebuiltTiles performs, without a surface: drop + /// each affected tile's layers (and the navmesh tiles the cache built from them), add the + /// regenerated blobs, then re-contour. + private static void SwapRebuiltTiles(NavMeshWorld world, NavMeshInstance instance, + List<(int X, int Z, List Layers)> rebuilt) + { + world.MutateTileCache(instance, cache => + { + DtNavMesh navMesh = cache.GetNavMesh(); + var added = new List(); + foreach ((int x, int z, List blobs) in rebuilt) + { + foreach (long tileRef in cache.GetTilesAt(x, z)) + { + var header = cache.GetTileByRef(tileRef)?.header; + if (header != null) + { + long navRef = navMesh.GetTileRefAt(header.tx, header.ty, header.tlayer); + if (navRef != 0) navMesh.RemoveTile(navRef); + } + cache.RemoveTile(tileRef); + } + foreach (byte[] blob in blobs) + { + long tileRef = cache.AddTile(blob, 0); + if (tileRef != 0) added.Add(tileRef); + } + } + foreach (long tileRef in added) + cache.BuildNavMeshTile(tileRef); + }); + } + + /// Higher area cost must bias route choice: with a cheap detour available around + /// an expensive strip, the path avoids the strip; with uniform costs it goes straight. + [Fact] + public void AreaCosts_BiasPathSelection() + { + // 30x30 floor; a full-height strip (x 12..18) of area 4 crosses the middle. + Float3[] leftVerts = [new(0, 0, 0), new(0, 0, 30), new(12, 0, 30), new(12, 0, 0)]; + Float3[] stripVerts = [new(12, 0, 0), new(12, 0, 30), new(18, 0, 30), new(18, 0, 0)]; + Float3[] rightVerts = [new(18, 0, 0), new(18, 0, 30), new(30, 0, 30), new(30, 0, 0)]; + int[] indices = [0, 1, 2, 0, 2, 3]; + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), + [ + new NavMeshGeometrySource(leftVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable), + new NavMeshGeometrySource(stripVerts, indices, Float4x4.Identity, area: 4), + new NavMeshGeometrySource(rightVerts, indices, Float4x4.Identity, NavMeshAreas.Walkable), + ]); + Assert.NotNull(data); + var world = new NavMeshWorld(); + world.AddNavMeshData(data!); + + // The strip spans the full floor, so it cannot be avoided — but a filter that prices + // area 4 highly must still cross it (cost biases, never blocks). + var expensive = new NavMeshQueryFilter(); + expensive.SetAreaCost(4, 10f); + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(5, 0, 15), new Float3(25, 0, 15), expensive, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// Tiles carry no detail mesh — heights come from the polygon planes, which is exact on flat + /// geometry. Locks in that such a bake builds, serializes, round-trips, and answers queries. + /// + [Fact] + public void Build_WithoutDetailMesh_BakesAndQueries() + { + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + Assert.NotNull(data); + Assert.True(data!.HasTiles); + + // Serialized tiles with no detail mesh must round-trip and instantiate. + EchoObject echo = Serializer.Serialize(data); + NavMeshData? loaded = Serializer.Deserialize(echo); + Assert.NotNull(loaded); + + var world = new NavMeshWorld(); + Assert.NotNull(world.AddNavMeshData(loaded!)); + + // Queries work; heights come from the polygon planes (exact on a flat floor). + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(18, 0, 18), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + Assert.True(world.SamplePosition(new Float3(10, 0.5f, 10), out NavMeshHit hit, 1f, NavMesh.AllAreas)); + Assert.True(System.Math.Abs(hit.Position.Y) < 0.3f, $"Height should come from the poly plane, got y={hit.Position.Y:0.00}."); + } + + /// Same input twice must produce byte-identical tiles (single-threaded build). + [Fact] + public void Build_IsDeterministic_SingleThreaded() + { + NavMeshData? a = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + NavMeshData? b = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + + Assert.NotNull(a); + Assert.NotNull(b); + Assert.Equal(a!.CacheLayers.Count, b!.CacheLayers.Count); + for (int i = 0; i < a.CacheLayers.Count; i++) + { + Assert.Equal(a.CacheLayers[i].X, b.CacheLayers[i].X); + Assert.Equal(a.CacheLayers[i].Z, b.CacheLayers[i].Z); + Assert.Equal(a.CacheLayers[i].Data, b.CacheLayers[i].Data); + } + } + + /// + /// A threaded bake produces byte-identical tiles to a serial one. Every surface bake is + /// threaded, and workers build through their own reusable scratch — so a partition carrying + /// state between the tiles it builds, or writing results out of order, would show up here. + /// + [Fact] + public void Build_Threaded_MatchesSingleThreaded() + { + // 40x40 spans several tiles, so the work actually partitions across threads. + NavMeshData? serial = NavMeshBuilder.Build(TestSettings(), [FlatQuad(40f)], threads: 1); + NavMeshData? threaded = NavMeshBuilder.Build(TestSettings(), [FlatQuad(40f)], threads: 4); + + Assert.NotNull(serial); + Assert.NotNull(threaded); + Assert.True(serial!.CacheLayers.Count > 1, "Test geometry must span more than one tile."); + Assert.Equal(serial.CacheLayers.Count, threaded!.CacheLayers.Count); + for (int i = 0; i < serial.CacheLayers.Count; i++) + { + Assert.Equal(serial.CacheLayers[i].X, threaded.CacheLayers[i].X); + Assert.Equal(serial.CacheLayers[i].Z, threaded.CacheLayers[i].Z); + Assert.Equal(serial.CacheLayers[i].Data, threaded.CacheLayers[i].Data); + } + } + + /// + /// A baked asset triangulates without being registered with any scene or world, which is what + /// lets the editor draw the surface overlay outside play mode where nothing registers it. + /// + [Fact] + public void NavMeshData_CalculateTriangulation_WorksWithoutRegistration() + { + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [FlatQuad()]); + Assert.NotNull(data); + + NavMeshTriangulation tri = data!.CalculateTriangulation(); + + Assert.NotEmpty(tri.Vertices); + Assert.NotEmpty(tri.Indices); + Assert.Equal(tri.Indices.Length / 3, tri.Areas.Length); + Assert.All(tri.Areas, a => Assert.Equal(NavMeshAreas.Walkable, a)); + // Indices stay inside the vertex array — a fan-triangulation slip would blow past it. + Assert.All(tri.Indices, i => Assert.InRange(i, 0, tri.Vertices.Length - 1)); + } + + /// + /// Links whose endpoints fall in DIFFERENT tiles have to bake AND instantiate, however many + /// of them cross the same boundary. Detour sizes a tile's link pool when the tile is built + /// and does not fully budget connections that leave it, so past a handful the pool overflows + /// and AddTile throws IndexOutOfRange — at load, on an asset that baked and saved cleanly. + /// The trigger is the count crossing one boundary, not the width: a single Width=5 link and + /// five separate zero-width links failed identically. The tile builder now rations them, so + /// the excess is dropped with a warning rather than taking the whole navmesh down. + /// + [Theory] + [InlineData(1, 0f)] + [InlineData(1, 5f)] // one wide link: expands to 5 parallel connections + [InlineData(5, 0f)] // five separate narrow links across the same boundary + [InlineData(40, 0f)] + [InlineData(8, 12f)] // both at once + public void Build_LinksAcrossTileBoundary_Instantiate(int linkCount, float width) + { + var links = new List(); + for (int i = 0; i < linkCount; i++) + { + float x = linkCount == 1 ? 0f : -3f + i * (6f / (linkCount - 1)); + links.Add(new NavMeshLinkSource(new Float3(x, 0, 3.92f), new Float3(x, 0, 6.92f), + width, bidirectional: true, NavMeshAreas.Jump, userId: i + 1)); + } + + NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(), + [Plane(0f), Plane(11.08f)], links: links); + Assert.NotNull(data); + Assert.Equal(linkCount, data!.Links.Count); + + var world = new NavMeshWorld(); + NavMeshInstance? instance = world.AddNavMeshData(data); + Assert.NotNull(instance); + // Rationing may drop the excess, but never all of them — the route must survive. + Assert.True(instance!.ContainsLinkId(1), "The first link must reach the live navmesh."); + + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(0, 0, -3), new Float3(0, 0, 14), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// A tile's link pool is spent by connections ARRIVING from its eight neighbours as well as by + /// its own, and Detour budgets nothing for arrivals — so a destination can be swamped by + /// sources that are individually modest. This drives every neighbour at one destination at once. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(4)] + [InlineData(8)] + public void Build_LinksArrivingFromEveryNeighbour_Instantiate(int perNeighbour) + { + NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(), [FlatQuad(40f)]); + Assert.NotNull(data); + + float ts = data!.TileWorldSize; + Float3 o = data.Origin; + Float3 Centre(int tx, int tz) => new(o.X + (tx + 0.5f) * ts, 0, o.Z + (tz + 0.5f) * ts); + + Float3 destination = Centre(2, 2); + int id = 1; + for (int dx = -1; dx <= 1; dx++) + for (int dz = -1; dz <= 1; dz++) + { + if (dx == 0 && dz == 0) continue; + Float3 source = Centre(2 + dx, 2 + dz); + // Fanned out either side of the tile centre, not off to one side: the outermost + // tile centre is barely a tile from the quad's edge, and a source that lands past + // it (or inside the eroded margin) has no polygon to attach to — which would + // test the geometry rather than the link pool. + for (int k = 0; k < perNeighbour; k++) + data.Links.Add(NavMeshData.NavMeshLinkEntry.From(new NavMeshLinkSource( + new Float3(source.X + (k - (perNeighbour - 1) * 0.5f) * 0.4f, 0, source.Z), destination, + width: 0f, bidirectional: true, NavMeshAreas.Jump, userId: id++))); + } + + var world = new NavMeshWorld(); + NavMeshInstance? instance = world.AddNavMeshData(data); + Assert.NotNull(instance); + + for (int link = 1; link < id; link++) + Assert.True(instance!.ContainsLinkId(link), $"Link {link} must reach the live navmesh."); + } + + /// + /// Links crowding one tile boundary all cross, however many there are and however wide. + /// Detour sizes a tile's link pool from the connections stored in the tile and budgets nothing + /// for those arriving from neighbours, so a crowded boundary is where the pool runs out. + /// + [Theory] + [InlineData(1, 5f)] // one wide link + [InlineData(1, 20f)] + [InlineData(4, 0f)] + [InlineData(6, 0f)] + [InlineData(12, 0f)] + public void Build_LinksCrowdingATileBoundary_AllCross(int linkCount, float width) + { + var links = new List(); + for (int i = 0; i < linkCount; i++) + { + float x = linkCount == 1 ? 0f : -3f + i * (6f / (linkCount - 1)); + links.Add(new NavMeshLinkSource(new Float3(x, 0, 3.92f), new Float3(x, 0, 6.92f), + width, bidirectional: true, NavMeshAreas.Jump, userId: i + 1)); + } + + NavMeshData? data = NavMeshBuilder.Build(new NavMeshBuildSettings(), + [Plane(0f), Plane(11.08f)], links: links); + Assert.NotNull(data); + + var warnings = new List(); + void Capture(string message, DebugStackTrace? trace, LogSeverity severity) + { + if (severity == LogSeverity.Warning && message.Contains("NavMeshLink")) warnings.Add(message); + } + + Debug.OnLog += Capture; + try + { + var world = new NavMeshWorld(); + NavMeshInstance? instance = world.AddNavMeshData(data!); + Assert.NotNull(instance); + + int inMesh = 0; + for (int i = 1; i <= linkCount; i++) + if (instance!.ContainsLinkId(i)) inMesh++; + Assert.Equal(linkCount, inMesh); + Assert.Empty(warnings); + } + finally + { + Debug.OnLog -= Capture; + } + } + + /// + /// Min Region Area (Unity's) culls islands too small to be worth standing on. The layers a + /// carving bake stores are partitioned at runtime, so the cull has to happen at bake time + /// or not at all — this pins that it happens, and that turning it off keeps the island. + /// + [Theory] + [InlineData(20f, false)] + [InlineData(0f, true)] + public void Build_MinRegionArea_CullsSmallIslands(float minRegionArea, bool islandSurvives) + { + NavMeshBuildSettings settings = TestSettings(); + settings.MinRegionArea = minRegionArea; + + // A 4x4 platform floating well above the floor, far enough inside one tile that it is + // not exempted as a border region. Erosion leaves ~6 units² of it — under the 20 above. + NavMeshData? data = NavMeshBuilder.Build(settings, [FlatQuad(), Platform(3f, 3f, 4f, 2f)]); + + Assert.NotNull(data); + NavMeshTriangulation tri = data!.CalculateTriangulation(); + + bool island = false; + foreach (Float3 v in tri.Vertices) + if (v.Y > 1.5f) island = true; + + Assert.Equal(islandSurvives, island); + // The floor is far too big to cull either way. + Assert.Contains(tri.Vertices, v => v.Y < 1.5f); + } + + /// An up-facing quad of at height . + private static NavMeshGeometrySource Platform(float minX, float minZ, float size, float y) + { + Float3[] verts = + [ + new(minX, y, minZ), + new(minX, y, minZ + size), + new(minX + size, y, minZ + size), + new(minX + size, y, minZ), + ]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.Identity); + } +} diff --git a/Prowl.Runtime.Test/NavMeshCollectorTests.cs b/Prowl.Runtime.Test/NavMeshCollectorTests.cs new file mode 100644 index 000000000..d0b2c8f77 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshCollectorTests.cs @@ -0,0 +1,797 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Recast.Detour; +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Runtime.Terrain; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +public class NavMeshCollectorTests : RuntimeTestBase +{ + private static NavMeshBuildSettings TestSettings() => new() + { + OverrideVoxelSize = true, + VoxelSize = 0.25f, + OverrideTileSize = true, + TileSize = 64, + }; + + [Fact] + public void BoxCollider_CollectsAndBakesWalkableFloor() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + var box = floor.AddComponent(); + box.Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); // top surface at y=0 + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, TestSettings().EffectiveVoxelSize, NavMeshAreas.Walkable, sources); + + Assert.Single(sources); + + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources); + Assert.NotNull(data); + Assert.True(data!.HasTiles); + + // Path across the box top must work end to end. + var world = new NavMeshWorld(); + world.AddNavMeshData(data); + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + [Fact] + public void Collect_RespectsLayerMask() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(10, 1, 10); + floor.LayerIndex = 5; + + List sources = []; + LayerMask without5 = LayerMask.Everything; + without5.RemoveLayer(5); + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + without5, 0.25f, NavMeshAreas.Walkable, sources); + Assert.Empty(sources); + + LayerMask with5 = LayerMask.Everything; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + with5, 0.25f, NavMeshAreas.Walkable, sources); + Assert.Single(sources); + } + + [Fact] + public void Collect_SkipsDisabledObjects() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(10, 1, 10); + floor.Enabled = false; + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources); + Assert.Empty(sources); + } + + [Fact] + public void MeshRenderer_Collects_WithWorldTransform() + { + Scene scene = CreateScene(enable: true); + GameObject go = CreateGameObject("Plane"); + scene.Add(go); + var renderer = go.AddComponent(); + renderer.Mesh = Mesh.CreateCube(new Float3(10, 0.2f, 10)); + go.Transform.Position = new Float3(100, 0, 100); + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.RenderMeshes, + LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources); + Assert.Single(sources); + + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources); + Assert.NotNull(data); + Assert.True(data!.HasTiles); + + // The navmesh must be where the object is, not at the origin. + var world = new NavMeshWorld(); + world.AddNavMeshData(data); + Assert.True(world.SamplePosition(new Float3(100, 0.5f, 100), out NavMeshHit hit, 2f, NavMesh.AllAreas)); + Assert.True(System.Math.Abs(hit.Position.X - 100) < 3f); + Assert.False(world.SamplePosition(new Float3(0, 0, 0), out _, 2f, NavMesh.AllAreas)); + } + + [Fact] + public void RotatedBoxCollider_BakesRotated() + { + Scene scene = CreateScene(enable: true); + GameObject ramp = CreateGameObject("Floor"); + scene.Add(ramp); + var box = ramp.AddComponent(); + box.Size = new Float3(20, 1, 6); + // 45° yaw (FromEuler takes degrees): the walkable strip runs diagonally. + ramp.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45f, 0)); + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources); + Assert.NotNull(data); + + var world = new NavMeshWorld(); + world.AddNavMeshData(data!); + + // Center is always on the strip. + Assert.True(world.SamplePosition(new Float3(0, 1f, 0), out _, 2f, NavMesh.AllAreas)); + // The unrotated +X end is ~6.4 units off the rotated strip's center line: not walkable. + Assert.False(world.SamplePosition(new Float3(9f, 1f, 0), out _, 2f, NavMesh.AllAreas)); + // Exactly one diagonal lies along the rotated strip (which one depends on yaw handedness). + bool posDiagonal = world.SamplePosition(new Float3(5f, 1f, 5f), out _, 2f, NavMesh.AllAreas); + bool negDiagonal = world.SamplePosition(new Float3(5f, 1f, -5f), out _, 2f, NavMesh.AllAreas); + Assert.True(posDiagonal ^ negDiagonal, $"Expected exactly one diagonal walkable (got +Z:{posDiagonal}, -Z:{negDiagonal})."); + } + + /// An agent's own geometry, and geometry on its children, stay out of the bake. + [Fact] + public void Collect_SkipsAgentsAndTheirChildren() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + GameObject agent = CreateGameObject("Agent"); + scene.Add(agent); + agent.Transform.Position = new Float3(0, 1, 0); + agent.AddComponent(); + agent.AddComponent(); + + GameObject visual = CreateGameObject("AgentVisual"); + scene.Add(visual); + visual.SetParent(agent); + visual.AddComponent().Size = new Float3(1, 2, 1); + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, 0.25f, NavMeshAreas.Walkable, sources); + + Assert.Single(sources); // the floor only + } + + /// + /// An agent standing on the floor at bake time must not voxelize as an obstruction, or it + /// leaves a permanent hole in the navmesh under wherever it stood. + /// + [Fact] + public void Bake_WithAgentStandingOnFloor_LeavesNoHole() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + var standing = new Float3(4, 0, 4); + GameObject agent = CreateGameObject("Agent"); + scene.Add(agent); + agent.Transform.Position = standing + new Float3(0, 1, 0); + agent.AddComponent(); + var body = agent.AddComponent(); + body.Size = new Float3(2, 2, 2); + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, TestSettings().EffectiveVoxelSize, NavMeshAreas.Walkable, sources); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), sources); + Assert.NotNull(data); + + var world = new NavMeshWorld(); + world.AddNavMeshData(data!); + + // The floor under the agent is walkable, and a path runs straight through it. + Assert.True(world.SamplePosition(standing, out NavMeshHit hit, 0.5f, NavMesh.AllAreas)); + Assert.True(Float3.Distance(hit.Position, standing) < 0.5f); + + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + private const float TerrainSize = 64f; + private const float TerrainHeight = 8f; + + /// A terrain spanning 0..64 on both axes, at whatever the caller's transform says. + /// takes world-space X and Z and returns 0..1; flat when + /// omitted. + private TerrainComponent AddTerrain(Scene scene, Func? normalizedHeight = null, + float height = TerrainHeight) + { + GameObject go = CreateGameObject("Terrain"); + scene.Add(go); + + const int res = 129; + var data = new TerrainData { Size = TerrainSize, Height = height }; + data.ResizeHeightmap(res); + if (normalizedHeight != null) + { + float cell = TerrainSize / (res - 1); + for (int z = 0; z < res; z++) + for (int x = 0; x < res; x++) + data.SetHeight(x, z, normalizedHeight(x * cell, z * cell)); + } + + var terrain = go.AddComponent(); + terrain.Data = data; + go.AddComponent(); + return terrain; + } + + /// One full sine period across the terrain, gentle enough to be walkable throughout. + private static float RollingHills(float x, float z) + => 0.5f + 0.5f * MathF.Sin(x / TerrainSize * MathF.PI * 2f) * MathF.Cos(z / TerrainSize * MathF.PI * 2f); + + private NavMeshWorld BakeTerrain(Scene scene, bool heightDetail = true) + { + NavMeshBuildSettings settings = TestSettings(); + settings.BuildHeightDetail = heightDetail; + + List sources = []; + NavMeshGeometryCollector.Collect(scene.ActiveObjects, NavMeshCollectGeometry.PhysicsColliders, + LayerMask.Everything, settings.EffectiveVoxelSize, NavMeshAreas.Walkable, sources); + Assert.Single(sources); + + NavMeshData? data = NavMeshBuilder.Build(settings, sources); + Assert.NotNull(data); + + var world = new NavMeshWorld(); + world.AddNavMeshData(data!); + return world; + } + + /// + /// Baking happens with the editor open, where nothing but + /// components have had a lifecycle callback. Terrain has to be readable there or every bake + /// from the editor silently leaves it out. + /// + [Fact] + public void Terrain_CollectsAndBakesInEditMode() + { + using (EditMode()) + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene); + + NavMeshWorld world = BakeTerrain(scene); + Assert.True(world.SamplePosition(new Float3(32, 0, 32), out NavMeshHit hit, 2f, NavMesh.AllAreas)); + Assert.True(Math.Abs(hit.Position.Y) < 1f); + } + } + + /// Heights are terrain-local, so the object's transform is what puts them in the world. + [Fact] + public void Terrain_BakesWhereItsTransformPutsIt() + { + Scene scene = CreateScene(enable: true); + TerrainComponent terrain = AddTerrain(scene); + terrain.Transform.Position = new Float3(-32, 25, -32); + terrain.Transform.LocalScale = new Float3(2, 1, 2); + + NavMeshWorld world = BakeTerrain(scene); + + // Scaled to 128 a side from a corner at -32, so the far edge reaches +96. + Assert.True(world.SamplePosition(new Float3(90, 25, 90), out NavMeshHit hit, 2f, NavMesh.AllAreas)); + Assert.True(Math.Abs(hit.Position.Y - 25) < 1f, $"terrain baked at y={hit.Position.Y}, expected 25"); + Assert.False(world.SamplePosition(new Float3(110, 25, 110), out _, 2f, NavMesh.AllAreas)); + } + + /// + /// The navmesh has to track a curved surface, not stretch across it. Polygons carry height + /// only at their corners, and region growing hands a whole hillside to one polygon, so without + /// height detail the mesh spans dips as a flat sheet and sinks into rises — measured at nearly + /// a metre on this terrain, which is gentle. Sampling the whole surface is what catches that: + /// a spot check would land on a polygon corner and read exactly right. + /// + [Fact] + public void Terrain_NavMeshTracksTheSurfaceItCovers() + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene, RollingHills); + + NavMeshWorld world = BakeTerrain(scene); + + float worst = 0, deepest = 0; int samples = 0; Float3 worstAt = default; + for (float z = 4; z < TerrainSize - 4; z += 0.5f) + { + for (float x = 4; x < TerrainSize - 4; x += 0.5f) + { + float expected = RollingHills(x, z) * TerrainHeight; + Assert.True(world.SamplePosition(new Float3(x, expected, z), out NavMeshHit hit, 4f, NavMesh.AllAreas), + $"no navmesh over ({x}, {z})"); + samples++; + + float error = hit.Position.Y - expected; + deepest = MathF.Min(deepest, error); + if (MathF.Abs(error) > worst) { worst = MathF.Abs(error); worstAt = new Float3(x, expected, z); } + } + } + + Assert.True(samples > 10000); + // Voxelization puts the walkable surface at the top of a voxel column, so the mesh rides + // slightly high everywhere. Contour corners sit on the surface rather than above it + // (heights come through span connectivity), so between detail samples the mesh may sag + // a hair below a curved rise — about a voxel height, never more. + Assert.True(worst < 0.5f, $"worst vertical error {worst:F3} at {worstAt}"); + Assert.True(deepest > -0.15f, $"navmesh sits {-deepest:F3} below the terrain"); + } + + /// + /// What the overlay and user tooling read has to track the surface as well. Position queries + /// and triangulation resolve heights by different routes, so one can be right while the other + /// still draws each polygon as a flat outline stretched over whatever it covers. + /// + [Fact] + public void Terrain_TriangulationTracksTheSurfaceItCovers() + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene, RollingHills); + + NavMeshTriangulation tri = BakeTerrain(scene).CalculateTriangulation(); + + float worst = 0; int checkedTris = 0; Float3 worstAt = default; + for (int t = 0; t < tri.Areas.Length; t++) + { + // The centroid is the part of a triangle furthest from any vertex the mesh got right. + Float3 centroid = (tri.Vertices[tri.Indices[t * 3 + 0]] + + tri.Vertices[tri.Indices[t * 3 + 1]] + + tri.Vertices[tri.Indices[t * 3 + 2]]) / 3f; + if (centroid.X < 4 || centroid.X > TerrainSize - 4 || centroid.Z < 4 || centroid.Z > TerrainSize - 4) + continue; + + checkedTris++; + float error = MathF.Abs(centroid.Y - RollingHills(centroid.X, centroid.Z) * TerrainHeight); + if (error > worst) { worst = error; worstAt = centroid; } + } + + Assert.True(checkedTris > 100); + Assert.True(worst < 0.5f, $"worst vertical error {worst:F3} at {worstAt} over {checkedTris} triangles"); + } + + /// + /// Steep ground is where navmesh generation degenerates, so bake terrain steep enough that + /// the 45° walk limit carves the surface up (24 units of relief; the walkable slopes reach + /// ~40°) and hold the mesh to the whole integrity set: no torn vertices, no unstitched tile + /// portals, no sliver polygons, no near-vertical facets, and a walked surface that is + /// continuous across polygon edges and stays on the ground it covers. + /// + [Fact] + public void Terrain_SteepBake_SurfaceIsContinuousGroundedAndSliverFree() + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene, RollingHills, height: 24f); + AssertBakeIntegrity(BakeTerrain(scene), RollingHills, 24f); + } + + /// + /// A sculpted plateau: flat low ground, a ~37° ramp whose base line meanders the way + /// hand-sculpted terrain does, sharp creases at base and lip, flat top. The meander is the + /// point — it puts kinks in the walkable-region borders that a straight crease never makes, + /// and those kinks are what mint sliver polygons. + /// + [Fact] + public void Terrain_SculptedPlateauBake_SurfaceIsContinuousGroundedAndSliverFree() + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene, WigglyPlateau, height: 24f); + AssertBakeIntegrity(BakeTerrain(scene), WigglyPlateau, 24f); + } + + private static float WigglyPlateau(float x, float z) + { + float x0 = 24f + 2.5f * MathF.Sin(z * 0.45f) + 1.2f * MathF.Sin(z * 1.3f); + float t = Math.Clamp((x - x0) / 16f, 0f, 1f); + return 0.5f * t * t * (3f - 2f * t) + 0.25f * (z / TerrainSize); + } + + /// + /// Every structural guarantee a baked terrain navmesh makes, asserted in one pass: + /// + /// no two vertices in a tile share an XZ column (a torn vertex splits the mesh into + /// overlapping sheets); + /// every tile-border edge carries a link to its neighbouring tile (an unstitched + /// portal is a wall agents cannot cross); + /// no facet of the walked surface stands past 60° — the walk limit is 45°, and + /// anything past 60° is not ground, it is an artifact drawn as a sheet and felt as a pop. + /// One carve-out: polygons narrower than the sliver-absorption threshold (3 voxels). A few + /// such strips survive where every union with a neighbour would bend reflex, and across a + /// strip that thin a single quantization step already reads as 45°+, so their facets are + /// only bounded (75°) rather than forbidden — a hand-span wedge at a crease, never a + /// wall; + /// adjacent polygons agree about the surface along their shared edge — the two sides + /// read the same height cells, so any disagreement is a tear an agent falls through + /// visually even when Detour walks it fine. Tile borders are held tighter still: both + /// tiles describe the seam from the same cells, so they must meet on it, not near it; + /// the surface sits on the terrain: a little high everywhere (voxelization rides the + /// top of the cell), never buried, because a buried stretch draws as a hole. + /// + /// + private void AssertBakeIntegrity(NavMeshWorld world, Func normalizedHeight, float height) + { + DtNavMesh mesh = world.GetInstance()!.NativeNavMesh; + + int tornVerts = 0, unstitchedPortals = 0, steepFacets = 0, steepStripFacets = 0, crackedEdges = 0; + double worstCrack = 0, highestAbove = 0, deepestBelow = 0, worstSlope = 0; + double worstCrossTileStep = 0; + Float3 worstSlopeAt = default, worstCrossTileStepAt = default; + + var detailCache = new Dictionary<(int t, int p), List>(); + List Det(int dt, int dp) + { + if (!detailCache.TryGetValue((dt, dp), out List? tris)) + detailCache[(dt, dp)] = tris = DetailTris(mesh.GetTile(dt), dp); + return tris; + } + + var polyRings = new Dictionary<(int t, int p), Float3[]>(); + var borderEdges = new List<(Float3 a, Float3 b, int t, int p)>(); + + for (int t = 0; t < mesh.GetMaxTiles(); t++) + { + DtMeshTile tile = mesh.GetTile(t); + if (tile?.data?.header == null) continue; + + var columns = new HashSet<(int X, int Z)>(); + for (int v = 0; v < tile.data.header.vertCount; v++) + if (!columns.Add(((int)MathF.Round(tile.data.verts[v * 3] * 64), (int)MathF.Round(tile.data.verts[v * 3 + 2] * 64)))) + tornVerts++; + + for (int p = 0; p < tile.data.header.polyCount; p++) + { + DtPoly poly = tile.data.polys[p]; + if (poly.GetPolyType() == DtPolyTypes.DT_POLYTYPE_OFFMESH_CONNECTION) continue; + + var corners = new Float3[poly.vertCount]; + for (int v = 0; v < poly.vertCount; v++) + corners[v] = NavMeshConnection.VertexAt(tile, poly.verts[v]); + polyRings[(t, p)] = corners; + for (int v = 0; v < poly.vertCount; v++) + if (poly.neis[v] == 0) + borderEdges.Add((corners[v], corners[(v + 1) % poly.vertCount], t, p)); + + bool narrowStrip = PolyWidthXZ(corners) < 0.75; // the builder's absorption threshold (3 voxels) + foreach (Float3[] tri in Det(t, p)) + { + Float3 n = Float3.Cross(tri[1] - tri[0], tri[2] - tri[0]); + double len = Math.Sqrt(n.X * n.X + n.Y * n.Y + n.Z * n.Z); + double slope = len < 1e-9 ? 90 : Math.Acos(Math.Clamp(Math.Abs(n.Y / len), 0, 1)) * 180.0 / Math.PI; + if (slope > worstSlope && !narrowStrip) + { + worstSlope = slope; + worstSlopeAt = (tri[0] + tri[1] + tri[2]) / 3f; + } + if (slope > 60.0 && !narrowStrip) + steepFacets++; + if (slope > 75.0 && narrowStrip) + steepStripFacets++; + + // The surface between the vertices is where errors hide; vertices alone + // always read within quantization. + Float3[] samples = + [ + (tri[0] + tri[1] + tri[2]) / 3f, + (tri[0] + tri[1]) / 2f, + (tri[1] + tri[2]) / 2f, + (tri[2] + tri[0]) / 2f, + ]; + foreach (Float3 s in samples) + { + double dev = s.Y - normalizedHeight((float)s.X, (float)s.Z) * height; + highestAbove = Math.Max(highestAbove, dev); + deepestBelow = Math.Min(deepestBelow, dev); + } + } + + for (int j = 0; j < poly.vertCount; j++) + { + Float3 a = corners[j], b = corners[(j + 1) % poly.vertCount]; + int nei = poly.neis[j]; + + if ((nei & DtDetour.DT_EXT_LINK) != 0) + { + bool linked = false; + for (int l = poly.firstLink; l != DtDetour.DT_NULL_LINK; l = tile.links[l].next) + { + if (tile.links[l].edge != j || tile.links[l].refs == 0) continue; + linked = true; + + // The far side reads its own tile's layer, whose heights can + // quantize a step or two apart from this one's. + DtDetour.DecodePolyId(tile.links[l].refs, out _, out int nt, out int np); + if (mesh.GetTile(nt)?.data?.header == null) continue; + double crossGap = 0; + for (int k = 1; k < 8; k++) + { + double u = k / 8.0; + double x = a.X + (b.X - a.X) * u, z = a.Z + (b.Z - a.Z) * u; + double? hp = DetailHeightAt(Det(t, p), x, z, 0.05); + double? hq = DetailHeightAt(Det(nt, np), x, z, 0.05); + if (hp is double vp && hq is double vq) + crossGap = Math.Max(crossGap, Math.Abs(vp - vq)); + } + if (crossGap > worstCrossTileStep) + { + worstCrossTileStep = crossGap; + worstCrossTileStepAt = new Float3( + (a.X + b.X) / 2f, (a.Y + b.Y) / 2f, (a.Z + b.Z) / 2f); + } + } + if (!linked) unstitchedPortals++; + continue; + } + + // Interior edge, visited once per pair: both polygons' detail surfaces must + // tell the same story along it. + if (nei == 0 || nei - 1 <= p) continue; + int q = nei - 1; + + double gap = 0; + for (int k = 1; k < 8; k++) + { + double u = k / 8.0; + double x = a.X + (b.X - a.X) * u, z = a.Z + (b.Z - a.Z) * u; + double? hp = DetailHeightAt(Det(t, p), x, z); + double? hq = DetailHeightAt(Det(t, q), x, z); + if (hp is double vp && hq is double vq) + gap = Math.Max(gap, Math.Abs(vp - vq)); + } + + worstCrack = Math.Max(worstCrack, gap); + if (gap > 0.1) + crackedEdges++; + } + } + } + + // Two polygons covering the same ground: the crack checks compare along shared edges, + // so surfaces crossing without sharing one would slip past them. A detail triangle's + // centroid strictly inside another polygon's footprint is coverage claimed twice. + int overlapSamples = 0; + Float3 overlapAt = default; + foreach (((int t, int p) ka, Float3[] _) in polyRings) + { + foreach (Float3[] tri in Det(ka.t, ka.p)) + { + Float3 c = (tri[0] + tri[1] + tri[2]) / 3f; + foreach (((int t, int p) kb, Float3[] ringB) in polyRings) + { + if (kb == ka || !InsideXZ(ringB, c.X, c.Z, 0.02)) continue; + overlapSamples++; + overlapAt = c; + break; + } + } + } + + // Detached slits: two border edges facing each other across a thin gap at similar + // height — a hair-wide hole between polygons that should abut, drawn as a dark tear. + // Border chains meeting at a corner are one hole's outline and do not count. + int slitSamples = 0; + Float3 slitAt = default; + foreach ((Float3 a, Float3 b, int t, int p) ea in borderEdges) + { + for (int k = 1; k < 4 && slitSamples == 0; k++) + { + Float3 m = ea.a + (ea.b - ea.a) * (k / 4f); + foreach ((Float3 a, Float3 b, int t, int p) eb in borderEdges) + { + if ((eb.t == ea.t && eb.p == ea.p) + || SamePointXZ(ea.a, eb.a) || SamePointXZ(ea.a, eb.b) + || SamePointXZ(ea.b, eb.a) || SamePointXZ(ea.b, eb.b)) + continue; + + (double d, double dy, double u) = PointToSegment(eb.a, eb.b, m); + if (d > 0.005 && d < 0.35 && dy < 0.6 && u > 0.05 && u < 0.95) + { + slitSamples++; + slitAt = m; + break; + } + } + } + } + + // Tile seams close exactly, not nearly. Both tiles read the same cells and describe the + // seam the same way, so a residual step means one of them built its seam from something + // the other did not have. A fraction of a voxel is enough: it draws as a dark hairline + // at a grazing angle. + Assert.True(worstCrossTileStep < 0.005, + $"tile surfaces meet {worstCrossTileStep:F3} apart at a seam (worst at {worstCrossTileStepAt})"); + + // The outlines the debug view draws must lie on the surface it draws them around. The + // detail bends every polygon edge between its corners, so a chord corner to corner + // leaves the surface and reads as a dark seam under it — a defect that exists only in + // the drawing, which is the worst kind to chase. + NavMeshTriangulation triangulation = NavMeshTriangulation.FromNavMesh(mesh); + int floatingEdges = 0; + double worstFloatingEdge = 0; + Float3 floatingEdgeAt = default; + foreach (NavMeshEdge edge in triangulation.Edges) + { + Float3 mid = (edge.A + edge.B) / 2f; + double best = double.MaxValue; + foreach ((_, List tris) in detailCache) + { + double? surface = DetailHeightAt(tris, mid.X, mid.Z); + if (surface.HasValue) + best = Math.Min(best, Math.Abs(surface.Value - mid.Y)); + } + + if (best == double.MaxValue || best <= 0.02) continue; + floatingEdges++; + if (best > worstFloatingEdge) + { + worstFloatingEdge = best; + floatingEdgeAt = mid; + } + } + + Assert.True(floatingEdges == 0, $"{floatingEdges} drawn outline segments leave the surface (worst {worstFloatingEdge:F2} at {floatingEdgeAt})"); + Assert.True(tornVerts == 0, $"{tornVerts} torn vertices (same XZ column twice in one tile layer)"); + Assert.True(unstitchedPortals == 0, $"{unstitchedPortals} tile-border edges without a link to the neighbouring tile"); + Assert.True(overlapSamples == 0, $"{overlapSamples} detail triangles sit inside another polygon's footprint (e.g. at {overlapAt})"); + Assert.True(slitSamples == 0, $"hair-wide holes between polygons that should abut (e.g. at {slitAt})"); + Assert.True(steepFacets == 0, $"{steepFacets} facets steeper than 60° on ground that never exceeds ~40° (worst {worstSlope:F1}° at {worstSlopeAt})"); + Assert.True(steepStripFacets == 0, $"{steepStripFacets} facets steeper than 75° inside sub-absorption-width strips"); + Assert.True(crackedEdges == 0, $"{crackedEdges} shared edges where adjacent detail surfaces disagree by more than 0.1 (worst {worstCrack:F2})"); + // Measured on these terrains: rides up to ~+0.7 high (hull corners carry the max of + // their 2x2 cell neighbourhood plus quantization), dips no lower than chord sag between + // detail samples. Growth past these bounds is a defect, not drift. + Assert.True(highestAbove < 0.9, $"surface floats {highestAbove:F2} above the terrain"); + Assert.True(deepestBelow > -0.35, $"surface buried {-deepestBelow:F2} below the terrain"); + } + + /// The walked surface of one polygon: its height-detail triangles, or the corner + /// fan Detour falls back to when a tile carries no detail. Reads the tile directly, per + /// polygon, rather than through , which flattens every + /// polygon into one buffer. Both decode the same convention — a detail index below the + /// polygon's vertex count means a corner, above it means a vertex the detail added — so a + /// change to that convention lands on both. + private static List DetailTris(DtMeshTile tile, int p) + { + DtPoly poly = tile.data.polys[p]; + var corners = new Float3[poly.vertCount]; + for (int v = 0; v < poly.vertCount; v++) + corners[v] = NavMeshConnection.VertexAt(tile, poly.verts[v]); + + List tris = []; + if (tile.data.detailMeshes == null) + { + for (int v = 2; v < poly.vertCount; v++) + tris.Add([corners[0], corners[v - 1], corners[v]]); + return tris; + } + + DtPolyDetail det = tile.data.detailMeshes[p]; + Float3 VertOf(int idx) + { + if (idx < poly.vertCount) return corners[idx]; + int i = (det.vertBase + (idx - poly.vertCount)) * 3; + return new Float3(tile.data.detailVerts[i], tile.data.detailVerts[i + 1], tile.data.detailVerts[i + 2]); + } + for (int d = 0; d < det.triCount; d++) + { + int i = (det.triBase + d) * 4; + tris.Add([VertOf(tile.data.detailTris[i]), VertOf(tile.data.detailTris[i + 1]), VertOf(tile.data.detailTris[i + 2])]); + } + return tris; + } + + /// Whether an XZ point sits strictly inside a convex ring, at least + /// in from every edge — points on shared edges and corners + /// therefore do not count. + private static bool InsideXZ(Float3[] ring, double x, double z, double shrink) + { + int n = ring.Length; + double sign = 0; + for (int i = 0, j = n - 1; i < n; j = i++) + { + double ex = ring[i].X - ring[j].X, ez = ring[i].Z - ring[j].Z; + double len = Math.Sqrt(ex * ex + ez * ez); + if (len < 1e-9) continue; + double d = (ex * (z - ring[j].Z) - ez * (x - ring[j].X)) / len; + if (sign == 0 && Math.Abs(d) > 1e-9) sign = Math.Sign(d); + if (sign != 0 && d * sign < shrink) return false; + } + return sign != 0; + } + + private static bool SamePointXZ(Float3 a, Float3 b) + => Math.Abs(a.X - b.X) < 1e-3 && Math.Abs(a.Z - b.Z) < 1e-3; + + /// XZ distance from a point to a segment, the height difference at the closest + /// spot, and how far along the segment it lies. + private static (double d, double dy, double u) PointToSegment(Float3 a, Float3 b, Float3 p) + { + double abx = b.X - a.X, abz = b.Z - a.Z; + double len2 = abx * abx + abz * abz; + double u = len2 > 1e-12 ? Math.Clamp(((p.X - a.X) * abx + (p.Z - a.Z) * abz) / len2, 0, 1) : 0; + double x = a.X + u * abx, z = a.Z + u * abz, y = a.Y + u * (b.Y - a.Y); + double dx = x - p.X, dz = z - p.Z; + return (Math.Sqrt(dx * dx + dz * dz), Math.Abs(y - p.Y), u); + } + + /// A polygon's XZ footprint width: twice its area over its longest edge — the + /// width of the strip it covers, the same measure sliver absorption uses. + private static double PolyWidthXZ(Float3[] corners) + { + double area2 = 0, maxEdgeSq = 0; + for (int i = 0, j = corners.Length - 1; i < corners.Length; j = i++) + { + area2 += corners[j].X * corners[i].Z - corners[i].X * corners[j].Z; + double dx = corners[i].X - corners[j].X, dz = corners[i].Z - corners[j].Z; + maxEdgeSq = Math.Max(maxEdgeSq, dx * dx + dz * dz); + } + return Math.Abs(area2) / Math.Max(1e-9, Math.Sqrt(maxEdgeSq)); + } + + /// Height of a polygon's walked surface at an XZ point, from whichever of its + /// triangles covers it; null when the point is outside them all. Points exactly on a + /// triangle edge sit on the boundary of two, so a little slack keeps the lookup from + /// falling between them. + private static double? DetailHeightAt(List tris, double x, double z, double slackLimit = 0.02) + { + double? best = null; + double bestSlack = double.MaxValue; + foreach (Float3[] t in tris) + { + double d = (t[1].Z - t[2].Z) * (t[0].X - t[2].X) + (t[2].X - t[1].X) * (t[0].Z - t[2].Z); + if (Math.Abs(d) < 1e-12) continue; + double wa = ((t[1].Z - t[2].Z) * (x - t[2].X) + (t[2].X - t[1].X) * (z - t[2].Z)) / d; + double wb = ((t[2].Z - t[0].Z) * (x - t[2].X) + (t[0].X - t[2].X) * (z - t[2].Z)) / d; + double wc = 1 - wa - wb; + double slack = -Math.Min(wa, Math.Min(wb, wc)); + if (slack < bestSlack) + { + bestSlack = slack; + best = wa * t[0].Y + wb * t[1].Y + wc * t[2].Y; + } + } + return bestSlack < slackLimit ? best : null; + } + + + /// + /// Turning height detail off has to actually skip it — the setting exists to buy back the + /// build time it costs on every tile, including every obstacle carve, for scenes whose ground + /// is flat enough that polygon corners already describe it. Asserted on the vertices + /// themselves: a coarse bake's surface is made of polygon corners and nothing else, while a + /// detailed bake of curved ground must have added some. + /// + [Fact] + public void Terrain_WithoutHeightDetail_KeepsTheCoarsePolygons() + { + Scene scene = CreateScene(enable: true); + AddTerrain(scene, RollingHills); + NavMeshTriangulation detailed = BakeTerrain(scene).CalculateTriangulation(); + + Scene coarseScene = CreateScene(enable: true); + AddTerrain(coarseScene, RollingHills); + NavMeshTriangulation coarse = BakeTerrain(coarseScene, heightDetail: false).CalculateTriangulation(); + + Assert.True(Array.TrueForAll(coarse.IsPolygonCorner, c => c), + "the coarse bake carries height-detail vertices despite BuildHeightDetail being off"); + Assert.Contains(false, detailed.IsPolygonCorner); + } +} diff --git a/Prowl.Runtime.Test/NavMeshComponentTests.cs b/Prowl.Runtime.Test/NavMeshComponentTests.cs new file mode 100644 index 000000000..d3c78699e --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshComponentTests.cs @@ -0,0 +1,625 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +public class NavMeshComponentTests : RuntimeTestBase +{ + private (Scene scene, NavMeshSurface surface) CreateBakedFloorScene(float size = 20f) + => CreateFloorScene(size, bake: true); + + /// + /// Agent-type resolution end to end: two types with different radii baked from the same + /// scene produce two navmeshes, and a corridor passable for the small type is eroded shut + /// for the large one. Also locks the agent-table → surface composition + /// (ResolveBuildSettings) and per-type instance lookup. + /// + [Fact] + public void Surfaces_TwoAgentTypes_ProduceDifferentWalkability() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f, Height = 2f, MaxSlope = 45f, MaxClimb = 0.4f }, + new NavMeshAgentType { Id = 7, Name = "Tank", Radius = 1.4f, Height = 2f, MaxSlope = 45f, MaxClimb = 0.4f }, + ]); + + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Corridor"); + scene.Add(floor); + // 3-unit-wide corridor: walkable inset survives radius 0.5, vanishes at radius 1.4. + floor.AddComponent().Size = new Float3(30, 1, 3); + floor.Transform.Position = new Float3(15, -0.5f, 1.5f); + + GameObject smallGo = CreateGameObject("SmallSurface"); + scene.Add(smallGo); + var small = smallGo.AddComponent(); + ApplyFastBakeSettings(small); + small.AgentTypeId = 0; + Assert.True(small.BuildNavMesh()); + + GameObject largeGo = CreateGameObject("LargeSurface"); + scene.Add(largeGo); + var large = largeGo.AddComponent(); + ApplyFastBakeSettings(large); + large.AgentTypeId = 7; + // The large type's bake legitimately produces nothing walkable in this corridor. + bool largeBaked = large.BuildNavMesh(); + + // Resolution pulled the right envelopes from the table. + Assert.Equal(0.5f, small.ResolveBuildSettings().AgentRadius); + Assert.Equal(1.4f, large.ResolveBuildSettings().AgentRadius); + + // Small type walks the corridor; large type has no mesh there (either its bake was + // empty or its instance has nothing at the sample point). + Assert.True(scene.Navigation.HasNavMesh(0)); + Assert.True(scene.Navigation.SamplePosition(new Float3(15, 0.2f, 1.5f), out _, 0.5f, + new NavMeshQueryFilter { AgentTypeId = 0 })); + + bool largeWalkable = largeBaked && scene.Navigation.SamplePosition(new Float3(15, 0.2f, 1.5f), out _, 0.5f, + new NavMeshQueryFilter { AgentTypeId = 7 }); + Assert.False(largeWalkable, "A 1.4-radius agent type must not fit a 3-unit corridor."); + } + finally + { + // Agent-type table is global static state; restore defaults for other tests. + NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]); + } + } + + /// + /// The table is read while bakes resolve their envelope from it, so a new one is built aside + /// and published in a single store. A source that faults part way through therefore leaves + /// the previous table standing rather than a half-built one. + /// + [Fact] + public void AgentTypes_ApplyTable_IsAllOrNothing() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid" }, + new NavMeshAgentType { Id = 7, Name = "Tank", Radius = 1.4f }, + ]); + + Assert.Throws(() => NavMeshAgentTypes.ApplyTable(FaultingSource())); + Assert.Equal(1.4f, NavMeshAgentTypes.Get(7)?.Radius); + } + finally + { + NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]); + } + + static IEnumerable FaultingSource() + { + yield return new NavMeshAgentType { Id = 0, Name = "Humanoid" }; + throw new InvalidOperationException("Malformed settings."); + } + } + + [Fact] + public void Surface_BakeRegistersWithSceneWorld() + { + (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(); + + Assert.True(scene.Navigation.HasNavMesh()); + Assert.NotNull(surface.Instance); + + // The static facade reaches it when the scene is current. + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + [Fact] + public void Surface_DisableUnregisters() + { + (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(); + Assert.True(scene.Navigation.HasNavMesh()); + + surface.GameObject.Enabled = false; + Assert.False(scene.Navigation.HasNavMesh()); + + surface.GameObject.Enabled = true; + Assert.True(scene.Navigation.HasNavMesh()); + } + + [Fact] + public void Agent_WalksTowardDestination() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + agent.Speed = 10f; + agent.Acceleration = 100f; + + Assert.True(agent.SetDestination(new Float3(8, 0, 8)) || agent.PathPending || !agent.IsOnNavMesh); + + double startDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8)); + Tick(scene, 240); // 4 simulated seconds at 60 Hz + + Assert.True(agent.IsOnNavMesh, "Agent should have joined the crowd."); + double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8)); + Assert.True(endDistance < startDistance - 5.0, + $"Agent should approach the destination (start {startDistance:0.0}, end {endDistance:0.0})."); + } + + [Fact] + public void Agent_RegistersWhenNavMeshAppearsLater() + { + Scene scene = CreateScene(enable: true); + + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + // Agent first: no navmesh yet. + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-5, 0, -5); + var agent = agentGo.AddComponent(); + Tick(scene, 2); + Assert.False(agent.IsOnNavMesh); + + // Surface bakes afterwards (the runtime-rebake ordering). + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + Assert.True(surface.BuildNavMesh()); + + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh, "Agent should register via NavMeshChanged once a navmesh exists."); + } + + [Fact] + public void Agent_IsStoppedHaltsMovement() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + agent.Speed = 10f; + agent.Acceleration = 100f; + + Tick(scene, 2); // register + agent.SetDestination(new Float3(8, 0, 8)); + Tick(scene, 30); + + agent.IsStopped = true; + Tick(scene, 5); // let velocity decay + Float3 posWhenStopped = agentGo.Transform.Position; + Tick(scene, 60); + double drift = Float3.Distance(agentGo.Transform.Position, posWhenStopped); + Assert.True(drift < 0.6, $"Stopped agent should not keep moving (drifted {drift:0.00})."); + + agent.IsStopped = false; + Tick(scene, 60); + double moved = Float3.Distance(agentGo.Transform.Position, posWhenStopped); + Assert.True(moved > 1.0, $"Resumed agent should move again (moved {moved:0.00})."); + } + + [Fact] + public void Agent_WarpMovesAgentAndKeepsWorking() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + Tick(scene, 2); + + Assert.True(agent.Warp(new Float3(5, 0, 5))); + Tick(scene, 1); + Assert.True(Float3.Distance(agentGo.Transform.Position, new Float3(5, 0, 5)) < 1.0); + Assert.True(agent.IsOnNavMesh); + } + + /// + /// The destructible-map flow: block a corridor at runtime, rebuild only the affected + /// tiles, and the path reroutes. This is the RubbleRangers acceptance scenario. + /// + [Fact] + public void RebuildTiles_ReflectsChangedGeometry() + { + (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(30f); + + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + int cornersBefore = path.CornerCount; + + // Drop a wall across the middle: x in [-1,1], z spanning the whole floor. + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 30); + wall.Transform.Position = new Float3(0, 2, 0); + + // Partial rebuild only around the wall. + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -16), new Float3(2, 5, 16)))); + + // The straight path is now impossible; with the wall spanning the full width the + // destination becomes unreachable (partial path at best). + Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + Float3 last = path.Corners[path.CornerCount - 1]; + Assert.True(last.X < 0, $"Partial path must stop on the near side of the wall (end x = {last.X:0.0})."); + + // Remove the wall and rebuild the same tiles: route restored. + wall.Enabled = false; + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -16), new Float3(2, 5, 16)))); + Assert.True(scene.Navigation.CalculatePath(new Float3(-12, 0, 0), new Float3(12, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + _ = cornersBefore; + } + + private static NavMeshGeometrySource FloorQuad(float minX, float minZ, float maxX, float maxZ) + { + Float3[] verts = + [ + new(minX, 0, minZ), + new(minX, 0, maxZ), + new(maxX, 0, maxZ), + new(maxX, 0, minZ), + ]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.Identity); + } + + /// + /// A surface whose navmesh was built from explicit geometry with NO scene colliders or + /// renderers at all — the shape of a game with custom rendering/collision, where + /// CollectSources() returns nothing (the RubbleRangers case). + /// + private (Scene scene, NavMeshSurface surface) CreateExplicitGeometryScene() + { + Scene scene = CreateScene(enable: true); + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + + // 30x30 floor from (0,0,0) to (30,0,30): 2x2 tiles at tileWorldSize 16. + NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(), [FloorQuad(0, 0, 30, 30)]); + Assert.NotNull(data); + surface.ApplyNavMeshData(data!); + Assert.NotNull(surface.Instance); + + // Sanity: the collectors really do see nothing in this scene. + Assert.Empty(surface.CollectSources()); + return (scene, surface); + } + + /// + /// The explicit-sources overload: rebuild one region from caller-supplied partial geometry + /// (a hole punched in the floor) and verify the changed region changed, untouched tiles + /// were not disturbed, and paths still cross the tile seam — i.e. the tile grid stayed + /// anchored to the original bake even though the incoming geometry spans only a corner. + /// + [Fact] + public void RebuildTiles_WithExplicitSources_ChangesRegionAndKeepsGridAnchored() + { + (Scene scene, NavMeshSurface surface) = CreateExplicitGeometryScene(); + + var path = new NavMeshPath(); + Assert.True(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas)); + + // Punch a hole at x/z 4..12 by rebuilding from partial sources: the same floor but + // with the hole missing. The AABB passed is the CHANGED region (the hole), from which + // the engine derives the affected tile set (tile (0,0) here, border included); the + // sources cover that tile plus its erosion border (out to 18) — NOT the whole bake. + // That partial coverage is the point of the overload. + const float pad = 18f; + NavMeshGeometrySource[] holeRegion = + [ + FloorQuad(0, 0, pad, 4), // south strip + FloorQuad(0, 12, pad, pad), // north strip + FloorQuad(0, 4, 4, 12), // west strip + FloorQuad(12, 4, pad, 12), // east strip + ]; + + Assert.True(surface.RebuildTiles(new AABB(new Float3(4, -1, 4), new Float3(12, 1, 12)), holeRegion, out int rebuiltTiles)); + Assert.True(rebuiltTiles > 0); + + // Inside the hole: no longer walkable. + Assert.False(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas)); + // Rebuilt tile outside the hole: still walkable. + Assert.True(scene.Navigation.SamplePosition(new Float3(2, 0.2f, 2), out _, 0.5f, NavMesh.AllAreas)); + // Untouched far tile: undisturbed. + Assert.True(scene.Navigation.SamplePosition(new Float3(25, 0.2f, 25), out _, 0.5f, NavMesh.AllAreas)); + + // The grid-anchoring assertion: a path from the rebuilt region into an untouched tile + // must still connect across the tile seam. If the rebuild had re-anchored the grid to + // the incoming geometry, the swapped tiles would misalign and this seam would break. + Assert.True(scene.Navigation.CalculatePath(new Float3(2, 0, 2), new Float3(25, 0, 25), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// An empty source list empties the affected tiles (a fully walled-in region) rather than + /// no-opping — "no geometry" and "no change" must not be conflated. + /// + [Fact] + public void RebuildTiles_WithEmptySources_EmptiesAffectedTiles() + { + (Scene scene, NavMeshSurface surface) = CreateExplicitGeometryScene(); + + // The changed-region AABB sits inside tile (0,0) so border expansion stays within it. + Assert.True(surface.RebuildTiles(new AABB(new Float3(2, -1, 2), new Float3(14, 1, 14)), [], out int rebuiltTiles)); + Assert.True(rebuiltTiles > 0); + + // The affected region is gone... + Assert.False(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas)); + // ...but tiles outside the bounds (plus border bleed) survive. + Assert.True(scene.Navigation.SamplePosition(new Float3(25, 0.2f, 25), out _, 0.5f, NavMesh.AllAreas)); + + // Restoring the region with explicit sources brings it back (padded past the border). + Assert.True(surface.RebuildTiles(new AABB(new Float3(2, -1, 2), new Float3(14, 1, 14)), [FloorQuad(0, 0, 18, 18)])); + Assert.True(scene.Navigation.SamplePosition(new Float3(8, 0.2f, 8), out _, 0.5f, NavMesh.AllAreas)); + + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(2, 0, 2), new Float3(25, 0, 25), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// The drill-outward scenario: bake a small spawn region inside declared world bounds + /// much larger than it, then RebuildTiles a region FAR from the original geometry and + /// assert walkable tiles appear there. Without explicit bounds the grid is sized to the + /// spawn region and every such rebuild is silently clamped away. + /// + [Fact] + public void RebuildTiles_OutsideOriginalGeometry_WorksWithDeclaredWorldBounds() + { + Scene scene = CreateScene(enable: true); + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + + // Spawn cavern: a 10x10 floor in the corner of a declared 100x100 world. + Float3[] verts = [new(0, 0, 0), new(0, 0, 10), new(10, 0, 10), new(10, 0, 0)]; + int[] indices = [0, 1, 2, 0, 2, 3]; + var spawn = new NavMeshGeometrySource(verts, indices, Float4x4.Identity); + NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(), [spawn], + worldBounds: new AABB(new Float3(0, -1, 0), new Float3(100, 1, 100))); + Assert.NotNull(data); + surface.ApplyNavMeshData(data!); + + // Far region (x/z 60..80) has nothing yet. + Assert.False(scene.Navigation.SamplePosition(new Float3(70, 0.2f, 70), out _, 0.5f, NavMesh.AllAreas)); + + // Drill opens a cavern there: rebuild with sources for just that region. + Float3[] farVerts = [new(58, 0, 58), new(58, 0, 82), new(82, 0, 82), new(82, 0, 58)]; + var farFloor = new NavMeshGeometrySource(farVerts, indices, Float4x4.Identity); + Assert.True(surface.RebuildTiles(new AABB(new Float3(60, -1, 60), new Float3(80, 1, 80)), [spawn, farFloor], out int rebuiltTiles)); + Assert.True(rebuiltTiles > 0, "Rebuild far from the original geometry must produce tiles, not clamp away."); + + // The far region is now walkable; the untouched spawn region still is. + Assert.True(scene.Navigation.SamplePosition(new Float3(70, 0.2f, 70), out _, 0.5f, NavMesh.AllAreas)); + Assert.True(scene.Navigation.SamplePosition(new Float3(5, 0.2f, 5), out _, 0.5f, NavMesh.AllAreas)); + } + + /// + /// Applying a second navmesh (map regeneration without a scene reload) must rebind the + /// crowd: the old crowd steered against a DtNavMesh that no longer exists, and agents must + /// rejoin the new one keeping their destination. + /// + [Fact] + public void Agent_SurvivesNavMeshReplacement() + { + (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + agent.Speed = 10f; + agent.Acceleration = 100f; + + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh); + agent.SetDestination(new Float3(8, 0, 8)); + Tick(scene, 30); + + // Regenerate: a fresh bake of the same floor produces a NEW DtNavMesh instance. + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + Assert.True(agent.IsOnNavMesh, "Agent must rejoin the replacement crowd after the navmesh swap."); + Assert.NotNull(scene.Navigation.NativeCrowd); + + // Destination survived the swap and the agent still gets there on the new mesh. + Tick(scene, 240); + double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8)); + Assert.True(endDistance < 2.0, $"Agent should reach its destination on the new mesh (got within {endDistance:0.0})."); + } + + /// + /// Rotation regression (degrees fed into a radians wrap helper froze rotation ~2° in): + /// travelling perpendicular to the initial facing, the yaw must converge to the direction + /// of travel, not park a couple of degrees off. + /// + [Fact] + public void Agent_RotatesTowardTravelDirection() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, 0); // facing +Z by default + var agent = agentGo.AddComponent(); + agent.Speed = 3f; + agent.Acceleration = 100f; + agent.AngularSpeed = 360f; + + Tick(scene, 2); + agent.SetDestination(new Float3(8, 0, 0)); // travel +X: yaw 90° from start + Tick(scene, 90); // 1.5 simulated seconds: mid-travel, well past any turn time + + Float3 forward = agentGo.Transform.Forward; + double alignment = forward.X; // dot(forward, +X) + Assert.True(alignment > 0.98, + $"Agent should face its +X travel direction (forward = ({forward.X:0.00}, {forward.Y:0.00}, {forward.Z:0.00}))."); + } + + /// + /// Arrival regression: with AutoBraking on (the default) and StoppingDistance 0, the + /// agent must still report arrival — RemainingDistance reads exactly 0, so the Unity + /// idiom "!PathPending && RemainingDistance <= StoppingDistance" terminates. + /// + [Fact] + public void Agent_DetectsArrival_WithAutoBraking() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + agent.Speed = 6f; + agent.Acceleration = 50f; + agent.AutoBraking = true; + agent.StoppingDistance = 0f; + + Tick(scene, 2); + agent.SetDestination(new Float3(8, 0, 8)); + + bool arrived = false; + for (int i = 0; i < 600 && !arrived; i++) + { + Tick(scene, 1); + arrived = !agent.PathPending && agent.RemainingDistance <= agent.StoppingDistance; + } + + Assert.True(arrived, $"Arrival was never reported (RemainingDistance ended at {agent.RemainingDistance:0.000})."); + double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 8)); + Assert.True(endDistance < 1.0, $"Arrival reported {endDistance:0.00} away from the destination."); + + // Unity parity: the destination stays readable after arrival (migrated code reads it). + Assert.True(Float3.Distance(agent.Destination, new Float3(8, 0, 8)) < 0.01, + $"Destination should still return the last target after arrival, got {agent.Destination}."); + } + + /// + /// Unity parity: SetDestination on a stopped agent remembers the target but does NOT + /// clear the stopped state — IsStopped is a pause flag that survives new destinations. + /// + [Fact] + public void Agent_SetDestinationWhileStopped_StaysHalted() + { + (Scene scene, _) = CreateBakedFloorScene(); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, -8); + var agent = agentGo.AddComponent(); + agent.Speed = 8f; + agent.Acceleration = 100f; + + Tick(scene, 2); + agent.IsStopped = true; + agent.SetDestination(new Float3(8, 0, 8)); + Assert.True(agent.IsStopped, "SetDestination must not clear the stopped state."); + + Float3 posBefore = agentGo.Transform.Position; + Tick(scene, 60); + Assert.True(Float3.Distance(agentGo.Transform.Position, posBefore) < 0.2, + "A stopped agent should not move toward a newly set destination."); + + agent.IsStopped = false; + Tick(scene, 120); + Assert.True(Float3.Distance(agentGo.Transform.Position, posBefore) > 3.0, + "Resuming should start movement toward the remembered destination."); + } + + /// + /// A changed-geometry region entirely outside the baked bounds must be a no-op — the old + /// clamp dragged the tile range onto the nearest edge column and, with explicit sources + /// that don't cover it, destroyed healthy edge tiles. + /// + [Fact] + public void RebuildTiles_RegionOutsideBakedBounds_IsNoOp() + { + (Scene scene, NavMeshSurface surface) = CreateBakedFloorScene(20f); // floor -10..10 + + // Edge of the floor is walkable before. + Assert.True(scene.Navigation.SamplePosition(new Float3(9, 0.2f, 0), out _, 1f, NavMesh.AllAreas)); + + // Region entirely outside the baked bounds, with sources that don't cover the edge. + bool changed = surface.RebuildTiles(new AABB(new Float3(50, -1, 50), new Float3(60, 1, 60)), + [], out int rebuiltTiles); + + Assert.False(changed, "An out-of-bounds region should rebuild nothing."); + Assert.Equal(0, rebuiltTiles); + // The edge tiles survived. + Assert.True(scene.Navigation.SamplePosition(new Float3(9, 0.2f, 0), out _, 1f, NavMesh.AllAreas), + "Edge tiles must not be clamped into the rebuild and destroyed."); + } + + /// + /// Corridor stability: a lone agent walking a 3-unit-wide corridor with tuned steering + /// (collision query range matched to the corridor instead of the open-level default of + /// radius x 12) must track the centreline instead of weaving between avoidance samples. + /// + [Fact] + public void Agent_TunedForCorridor_DoesNotWeave() + { + Scene scene = CreateScene(enable: true); + + GameObject floor = CreateGameObject("Corridor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(30, 1, 3); + floor.Transform.Position = new Float3(15, -0.5f, 1.5f); // corridor x 0..30, z 0..3 + + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(2, 0, 1.5f); + var agent = agentGo.AddComponent(); + agent.Speed = 4f; + agent.Acceleration = 50f; + agent.CollisionQueryRange = 2f; // corridor-scale, not radius x 12 = 6 + agent.Separation = false; // lone agent; separation has nothing useful to add + + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh); + agent.SetDestination(new Float3(28, 0, 1.5f)); + + double maxDeviation = 0; + for (int i = 0; i < 600; i++) + { + Tick(scene, 1); + Float3 pos = agentGo.Transform.Position; + if (pos.X > 3 && pos.X < 27) // measure the straightaway, not the endpoints + maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(pos.Z - 1.5)); + if (!agent.PathPending && agent.RemainingDistance <= 0f) break; + } + + Assert.True(maxDeviation < 0.6, + $"Agent weaved {maxDeviation:0.00} units off the corridor centreline."); + } +} diff --git a/Prowl.Runtime.Test/NavMeshCrowdTests.cs b/Prowl.Runtime.Test/NavMeshCrowdTests.cs new file mode 100644 index 000000000..9a7cf4748 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshCrowdTests.cs @@ -0,0 +1,563 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// Per-agent-type crowds and per-agent steering filters (area mask + cost overrides flowing +/// through the crowd's query-filter slots, not just explicit queries). +/// +public class NavMeshCrowdTests : RuntimeTestBase +{ + private NavMeshAgent AddAgent(Scene scene, Float3 position) + { + GameObject go = CreateGameObject("Agent"); + scene.Add(go); + go.Transform.Position = position; + var agent = go.AddComponent(); + agent.Speed = 6f; + agent.Acceleration = 100f; + agent.Separation = false; // lone-agent tests; separation adds noise + agent.CollisionQueryRange = 2f; // corridor-scale steering + return agent; + } + + /// + /// An agent walking a straight line must not shiver as it brakes into its destination. Facing + /// follows the steering vector rather than the crowd's actual velocity: the latter carries + /// avoidance and collision corrections that do not shrink with speed, so near the goal they + /// dominate a small vector and swing the agent's direction frame to frame. + /// + [Fact] + public void Agent_ApproachingDestination_DoesNotWobble() + { + (Scene scene, _) = CreateBakedFloorScene(); + NavMeshAgent agent = AddAgent(scene, new Float3(-8, 0, 0)); + Tick(scene, 2); + + // Straight down +X, no turns and nothing to avoid: every heading change is jitter. + Assert.True(agent.SetDestination(new Float3(8, 0, 0))); + + // Only the braking zone is measured. The agent legitimately turns 90° at full angular + // speed at the start, to face down the path; the wobble is what happens AFTER it is + // already aligned and slowing, where every heading change is jitter by definition. + double worstStep = 0; + int samples = 0; + double previousYaw = double.NaN; + bool arrived = false; + for (int i = 0; i < 400 && !arrived; i++) + { + Tick(scene, 1); + arrived = !agent.PathPending && agent.RemainingDistance <= 0f; + + Float3 v = agent.Velocity; + if (v.X * v.X + v.Z * v.Z < 0.0025) continue; // stopped: heading is meaningless + if (agent.RemainingDistance > 4f) { previousYaw = double.NaN; continue; } + + double yaw = agent.Transform.Rotation.EulerAngles.Y; + if (!double.IsNaN(previousYaw)) + { + double step = Math.Abs(Maths.DeltaAngle(yaw * Maths.Deg2Rad, previousYaw * Maths.Deg2Rad)) * Maths.Rad2Deg; + worstStep = Math.Max(worstStep, step); + samples++; + } + previousYaw = yaw; + } + + Assert.True(arrived, "The agent should reach the destination."); + Assert.True(samples > 5, $"Expected to sample the braking approach, got {samples} frames."); + // Already pointed down a straight path, so a real correction is a fraction of a degree + // per frame. The wobble showed up as degree-scale swings back and forth near the goal. + Assert.True(worstStep < 0.5, + $"Agent heading jittered by {worstStep:0.00}° in one frame while braking on a straight path."); + } + + /// + /// With nothing in range to dodge, an agent must travel the straight line it was given — + /// exactly, not approximately. Velocity-obstacle sampling picks from a discrete candidate set, + /// so running it against zero obstacles still rounds the chosen velocity, and the rounding + /// walks the agent centimetres off its line by the time it arrives. Skipping it also skips the + /// most expensive part of the crowd step. + /// + /// The floor is deliberately far wider than the walk: the same query consumes navmesh boundary + /// segments, so on a floor whose edge sits inside the agent's collision query range there IS + /// something in range and avoidance correctly stays on. + /// covers the other half — that a blocker in range still deflects it. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Agent_AloneOnAStraightPath_DoesNotDriftSideways(bool alongX) + { + // Walks ±8 with AddAgent's 2m query range, so 40 keeps every edge well out of range. + (Scene scene, _) = CreateBakedFloorScene(40f); + NavMeshAgent agent = AddAgent(scene, alongX ? new Float3(-8, 0, 0) : new Float3(0, 0, -8)); + Assert.NotEqual(ObstacleAvoidanceType.NoObstacleAvoidance, agent.ObstacleAvoidanceQuality); + Tick(scene, 2); + + Assert.True(agent.SetDestination(alongX ? new Float3(8, 0, 0) : new Float3(0, 0, 8))); + + double worstLateral = 0; + bool arrived = false; + for (int i = 0; i < 400 && !arrived; i++) + { + Tick(scene, 1); + Float3 p = agent.Transform.Position; + worstLateral = Math.Max(worstLateral, Math.Abs(alongX ? p.Z : p.X)); + arrived = !agent.PathPending && agent.RemainingDistance <= 0f; + } + + Assert.True(arrived, "The agent should reach the destination."); + Assert.True(worstLateral < 0.005, + $"Agent strayed {worstLateral:0.0000} units off a straight path with nothing to avoid."); + } + + /// + /// An agent alone beside a wall keeps avoidance switched on. The crowd feeds navmesh boundary + /// segments into the same query as neighbouring agents, so deciding on neighbour count alone + /// would leave a solitary agent with nothing keeping it off the edges. + /// + [Fact] + public void Agent_AloneNearABoundary_KeepsAvoidanceEngaged() + { + // Walk the agent one metre from an edge, inside AddAgent's 2m query range. + (Scene scene, _) = CreateBakedFloorScene(12f); + NavMeshAgent agent = AddAgent(scene, new Float3(-4, 0, 5)); + Tick(scene, 2); + + Assert.True(agent.SetDestination(new Float3(4, 0, 5))); + Tick(scene, 5); + + Assert.Equal(0, agent.NativeAgent!.nneis); + Assert.True(agent.NativeAgent.boundary.GetSegmentCount() > 0, "The edge should be in range."); + Assert.True(agent.AvoidanceEngaged, + "A lone agent within range of the navmesh boundary must keep avoiding it."); + } + + /// + /// SetPath steers along the route it is given, rather than throwing it away and re-planning to + /// its endpoint. A path that does not begin where the agent stands is refused, since adopting + /// it would jump the corridor somewhere the agent is not. + /// + [Fact] + public void Agent_SetPath_FollowsTheSuppliedRoute() + { + (Scene scene, _) = CreateBakedFloorScene(40f); + NavMeshAgent agent = AddAgent(scene, new Float3(-8, 0, 0)); + Tick(scene, 2); + + Assert.False(agent.SetPath(new NavMeshPath()), "An unusable path must be refused."); + + var path = new NavMeshPath(); + Assert.True(agent.CalculatePath(new Float3(8, 0, 0), path)); + Assert.True(agent.SetPath(path)); + Assert.True(agent.HasPath); + Assert.False(agent.PathPending, "An adopted path is already planned; nothing should be pending."); + + bool arrived = false; + for (int i = 0; i < 400 && !arrived; i++) + { + Tick(scene, 1); + arrived = !agent.PathPending && agent.RemainingDistance <= 0f; + } + + Assert.True(arrived, "The agent should walk the supplied path to its end."); + Assert.True(Float3.Distance(agent.Transform.Position, new Float3(8, 0, 0)) < 1.0, + $"Agent finished at {agent.Transform.Position}, not the path's end."); + } + + // ── Per-agent-type crowds ─────────────────────────────────────────── + + private static void RestoreDefaultAgentTable() + => NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]); + + /// + /// Two agent types in one scene get two independent crowds, and each agent walks the mesh + /// baked for its own type. + /// + [Fact] + public void TwoAgentTypes_GetSeparateCrowds_AndBothWalk() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f }, + new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f }, + ]); + + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + foreach (int typeId in (int[])[0, 3]) + { + GameObject surfaceGo = CreateGameObject($"Surface{typeId}"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + surface.AgentTypeId = typeId; + Assert.True(surface.BuildNavMesh()); + } + + NavMeshAgent humanoid = AddAgent(scene, new Float3(-8, 0, -8)); + NavMeshAgent scout = AddAgent(scene, new Float3(-8, 0, 8)); + scout.AgentTypeId = 3; + + Tick(scene, 2); + Assert.True(humanoid.IsOnNavMesh); + Assert.True(scout.IsOnNavMesh); + + // One crowd per type, and they are distinct objects. + Assert.NotNull(scene.Navigation.GetNativeCrowd(0)); + Assert.NotNull(scene.Navigation.GetNativeCrowd(3)); + Assert.NotSame(scene.Navigation.GetNativeCrowd(0), scene.Navigation.GetNativeCrowd(3)); + // NativeCrowd stays sugar for type 0. + Assert.Same(scene.Navigation.NativeCrowd, scene.Navigation.GetNativeCrowd(0)); + + humanoid.SetDestination(new Float3(8, 0, -8)); + scout.SetDestination(new Float3(8, 0, 8)); + Tick(scene, 300); + + Assert.True(Float3.Distance(humanoid.GameObject.Transform.Position, new Float3(8, 0, -8)) < 2.0, + "Humanoid should reach its destination on its own crowd."); + Assert.True(Float3.Distance(scout.GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0, + "Scout should reach its destination on its own crowd."); + } + finally + { + RestoreDefaultAgentTable(); + } + } + + /// + /// Replacing one agent type's navmesh rebinds only that type's crowd: its agents rejoin + /// (keeping destinations) while the other type's crowd is the same object throughout. + /// + [Fact] + public void NavMeshReplacement_RebindsOnlyThatTypesCrowd() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f }, + new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f }, + ]); + + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + NavMeshSurface? scoutSurface = null; + foreach (int typeId in (int[])[0, 3]) + { + GameObject surfaceGo = CreateGameObject($"Surface{typeId}"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + surface.AgentTypeId = typeId; + Assert.True(surface.BuildNavMesh()); + if (typeId == 3) scoutSurface = surface; + } + + NavMeshAgent humanoid = AddAgent(scene, new Float3(-8, 0, -8)); + NavMeshAgent scout = AddAgent(scene, new Float3(-8, 0, 8)); + scout.AgentTypeId = 3; + Tick(scene, 2); + + var humanoidCrowd = scene.Navigation.GetNativeCrowd(0); + var scoutCrowd = scene.Navigation.GetNativeCrowd(3); + scout.SetDestination(new Float3(8, 0, 8)); + humanoid.SetDestination(new Float3(8, 0, -8)); + Tick(scene, 30); + + // Regenerate the scout mesh only. + Assert.True(scoutSurface!.BuildNavMesh()); + Tick(scene, 2); + + Assert.True(scout.IsOnNavMesh, "Scout must rejoin its replacement crowd."); + Assert.NotSame(scoutCrowd, scene.Navigation.GetNativeCrowd(3)); + Assert.Same(humanoidCrowd, scene.Navigation.GetNativeCrowd(0)); + + // Destinations survived on both sides of the swap. + Tick(scene, 300); + Assert.True(Float3.Distance(scout.GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0, + "Scout should reach its destination on the new mesh."); + Assert.True(Float3.Distance(humanoid.GameObject.Transform.Position, new Float3(8, 0, -8)) < 2.0, + "Humanoid should be undisturbed by the other type's rebind."); + } + finally + { + RestoreDefaultAgentTable(); + } + } + + /// + /// Regression: writing AgentTypeId and then having ANY navmesh event fire before the + /// LateUpdate drift check runs must not strand a ghost agent in the old type's still-alive + /// crowd. The rebind check compares against the type the agent REGISTERED under, so the + /// event leaves it alone and the drift check later re-places it through Unregister. + /// + [Fact] + public void AgentTypeIdChange_RacingNavMeshEvent_LeavesNoGhostAgent() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid", Radius = 0.5f }, + new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f }, + ]); + + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(20, 1, 20); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + NavMeshSurface? humanoidSurface = null; + foreach (int typeId in (int[])[0, 3]) + { + GameObject surfaceGo = CreateGameObject($"Surface{typeId}"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + surface.AgentTypeId = typeId; + Assert.True(surface.BuildNavMesh()); + if (typeId == 0) humanoidSurface = surface; + } + + NavMeshAgent agent = AddAgent(scene, new Float3(0, 0, 0)); + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh); + var typeZeroCrowd = scene.Navigation.GetNativeCrowd(0)!; + Assert.Single(typeZeroCrowd.GetActiveAgents()); + + // The race: gameplay retypes the agent, then a navmesh event (here a partial tile + // rebuild — the event a destructible world produces constantly) fires BEFORE any + // LateUpdate has run the drift check. The type-0 crowd survives the event. + agent.AgentTypeId = 3; + Assert.True(humanoidSurface!.RebuildTiles(new AABB(new Float3(-2, -1, -2), new Float3(2, 1, 2)))); + Assert.Same(typeZeroCrowd, scene.Navigation.GetNativeCrowd(0)); + + Tick(scene, 2); // drift check re-places the agent onto type 3 + Assert.True(agent.IsOnNavMesh); + Assert.Same(agent.NativeAgent, System.Linq.Enumerable.FirstOrDefault(scene.Navigation.GetNativeCrowd(3)!.GetActiveAgents())); + Assert.Empty(typeZeroCrowd.GetActiveAgents()); // no ghost left behind + } + finally + { + RestoreDefaultAgentTable(); + } + } + + // ── Steering filters (mask + costs through the crowd) ─────────────── + + private static NavMeshGeometrySource Quad(float minX, float minZ, float maxX, float maxZ, int area = NavMeshGeometrySource.UnspecifiedArea) + { + Float3[] verts = + [ + new(minX, 0, minZ), + new(minX, 0, maxZ), + new(maxX, 0, maxZ), + new(maxX, 0, minZ), + ]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.Identity, area); + } + + private const int MudArea = 3; + + /// + /// A two-corridor map (the plan's "which side does the agent take" scenario): bottom + /// corridor z 0..3 and top corridor z 6..9, joined only by connectors at both ends + /// (x 0..4 and x 26..30) — the gap between them is void, so the corridor's raycast-based + /// visibility optimization cannot splice a shortcut across (it CAN re-straighten a route + /// through costly-but-passable ground in an open field, which is upstream Detour + /// behaviour). The bottom corridor carries a mud strip (area 3) at x 12..18. From + /// (2,1.5) to (28,1.5) the short route runs through the mud; the alternative goes up + /// and around through the top corridor. + /// + private (Scene scene, NavMeshSurface surface) CreateTwoCorridorScene() + { + Scene scene = CreateScene(enable: true); + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + + NavMeshData? data = NavMeshBuilder.Build(surface.ResolveBuildSettings(), + [ + Quad(0, 0, 12, 3), // bottom corridor, west of the mud + Quad(12, 0, 18, 3, MudArea), // the mud strip + Quad(18, 0, 30, 3), // bottom corridor, east of the mud + Quad(0, 6, 30, 9), // top corridor + Quad(0, 3, 4, 6), // west connector + Quad(26, 3, 30, 6), // east connector + ]); + Assert.NotNull(data); + surface.ApplyNavMeshData(data!); + return (scene, surface); + } + + /// Walk the agent from the bottom corridor's west end to its east end, recording + /// the highest z reached — crossing into the top corridor (z > 6) means it took the + /// detour; staying under z 3 means it went straight through the mud. + private (double maxZ, double endDistance) WalkBottomCorridor(Scene scene, NavMeshAgent agent) + { + var goal = new Float3(28, 0, 1.5f); + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh); + agent.SetDestination(goal); + + double maxZ = double.MinValue; + for (int i = 0; i < 900; i++) + { + Tick(scene, 1); + maxZ = System.Math.Max(maxZ, agent.GameObject.Transform.Position.Z); + if (!agent.PathPending && agent.RemainingDistance <= 0f) break; + } + return (maxZ, Float3.Distance(agent.GameObject.Transform.Position, goal)); + } + + /// + /// The area mask steers CROWD movement, not just explicit queries: an agent whose mask + /// excludes the mud area takes the top corridor, while an unrestricted agent walks + /// straight through the mud. + /// + [Fact] + public void AreaMask_RoutesCrowdSteeringAroundExcludedArea() + { + (Scene scene, _) = CreateTwoCorridorScene(); + + NavMeshAgent direct = AddAgent(scene, new Float3(2, 0, 1.5f)); + (double directMaxZ, double directEnd) = WalkBottomCorridor(scene, direct); + Assert.True(directEnd < 2.0, $"Unrestricted agent should arrive (ended {directEnd:0.0} away)."); + Assert.True(directMaxZ < 4.0, + $"Unrestricted agent should stay in the bottom corridor (reached z {directMaxZ:0.00})."); + direct.GameObject.Enabled = false; + + NavMeshAgent masked = AddAgent(scene, new Float3(2, 0, 1.5f)); + masked.AreaMask = NavMeshAreas.AllAreas & ~(1 << MudArea); + (double maskedMaxZ, double maskedEnd) = WalkBottomCorridor(scene, masked); + Assert.True(maskedEnd < 2.0, $"Masked agent should still arrive via the detour (ended {maskedEnd:0.0} away)."); + Assert.True(maskedMaxZ > 6.0, + $"Masked agent should take the top corridor around the mud (reached only z {maskedMaxZ:0.00})."); + } + + /// + /// SetAreaCost biases the crowd's corridor choice: with the mud expensive enough the + /// agent takes the top corridor, exactly like the mask case but by cost rather than + /// exclusion. + /// + [Fact] + public void SetAreaCost_BiasesCrowdCorridorChoice() + { + (Scene scene, _) = CreateTwoCorridorScene(); + + NavMeshAgent agent = AddAgent(scene, new Float3(2, 0, 1.5f)); + agent.SetAreaCost(MudArea, 20f); + Assert.Equal(20f, agent.GetAreaCost(MudArea)); + + (double maxZ, double endDistance) = WalkBottomCorridor(scene, agent); + Assert.True(endDistance < 2.0, $"Agent should arrive (ended {endDistance:0.0} away)."); + Assert.True(maxZ > 6.0, + $"With mud at cost 20 the agent should take the top corridor (reached only z {maxZ:0.00})."); + } + + // ── Filter slot allocation ────────────────────────────────────────── + + private (Scene scene, NavMeshSurface surface) CreateBakedFloorScene(float size = 20f) + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(size, 1, size); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + Assert.True(surface.BuildNavMesh()); + return (scene, surface); + } + + /// Agents with identical steering configs share one crowd filter slot; a + /// different config gets its own; the default config stays on slot 0. + [Fact] + public void FilterSlots_SharedByIdenticalConfigs() + { + (Scene scene, _) = CreateBakedFloorScene(); + + NavMeshAgent defaultAgent = AddAgent(scene, new Float3(0, 0, 0)); + NavMeshAgent maskedA = AddAgent(scene, new Float3(2, 0, 0)); + NavMeshAgent maskedB = AddAgent(scene, new Float3(4, 0, 0)); + NavMeshAgent costly = AddAgent(scene, new Float3(6, 0, 0)); + maskedA.AreaMask = ~(1 << 2); + maskedB.AreaMask = ~(1 << 2); + costly.SetAreaCost(2, 5f); + + Tick(scene, 2); + Assert.True(defaultAgent.IsOnNavMesh && maskedA.IsOnNavMesh && maskedB.IsOnNavMesh && costly.IsOnNavMesh); + + Assert.Equal(0, defaultAgent.NativeAgent!.option.queryFilterType); + Assert.NotEqual(0, maskedA.NativeAgent!.option.queryFilterType); + Assert.Equal(maskedA.NativeAgent.option.queryFilterType, maskedB.NativeAgent!.option.queryFilterType); + Assert.NotEqual(0, costly.NativeAgent!.option.queryFilterType); + Assert.NotEqual(maskedA.NativeAgent.option.queryFilterType, costly.NativeAgent.option.queryFilterType); + } + + /// + /// More distinct steering configs than slots: the overflow agents warn and steer with the + /// default filter (slot 0) but remain fully functional, and unregistering a slotted agent + /// frees its slot for the next distinct config. + /// + [Fact] + public void FilterSlots_ExhaustionFallsBackToDefault_AndReleaseFrees() + { + (Scene scene, _) = CreateBakedFloorScene(); + + // 17 distinct configs against 15 assignable slots (1..15; 0 is the shared default). + var agents = new NavMeshAgent[17]; + for (int i = 0; i < agents.Length; i++) + { + agents[i] = AddAgent(scene, new Float3(-8 + i, 0, -8)); + agents[i].SetAreaCost(1, 2f + i); // each cost table is unique + } + Tick(scene, 2); + + int onDefaultSlot = 0; + foreach (NavMeshAgent agent in agents) + { + Assert.True(agent.IsOnNavMesh); + if (agent.NativeAgent!.option.queryFilterType == 0) onDefaultSlot++; + } + Assert.Equal(2, onDefaultSlot); // 15 got slots, 2 overflowed onto the default + + // Overflowed or not, every agent still navigates. + agents[16].SetDestination(new Float3(8, 0, 8)); + Tick(scene, 300); + Assert.True(Float3.Distance(agents[16].GameObject.Transform.Position, new Float3(8, 0, 8)) < 2.0, + "An overflow agent should still reach its destination on the default filter."); + + // Free one slot and bring in an 18th distinct config: it should take the freed slot. + NavMeshAgent slotted = System.Array.Find(agents, a => a.NativeAgent!.option.queryFilterType != 0)!; + slotted.GameObject.Enabled = false; + NavMeshAgent late = AddAgent(scene, new Float3(0, 0, 6)); + late.SetAreaCost(1, 99f); + Tick(scene, 2); + Assert.NotEqual(0, late.NativeAgent!.option.queryFilterType); + } +} diff --git a/Prowl.Runtime.Test/NavMeshLinkTests.cs b/Prowl.Runtime.Test/NavMeshLinkTests.cs new file mode 100644 index 000000000..93299eacb --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshLinkTests.cs @@ -0,0 +1,628 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Recast.Detour; + +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// NavMeshLink: off-mesh connections baked from components — gap bridging, directionality, +/// area masking, parallel-connection width, crowd traversal, and runtime toggling via +/// targeted rebuilds. +/// +public class NavMeshLinkTests : RuntimeTestBase +{ + /// Two 8-wide floor islands separated by a 4-unit void gap (A: x -10..-2, + /// B: x 2..10), with a surface ready to bake. Nothing walkable connects them. + private (Scene scene, NavMeshSurface surface) CreateGapScene() + { + Scene scene = CreateScene(enable: true); + + GameObject islandA = CreateGameObject("IslandA"); + scene.Add(islandA); + islandA.AddComponent().Size = new Float3(8, 1, 8); + islandA.Transform.Position = new Float3(-6, -0.5f, 0); + + GameObject islandB = CreateGameObject("IslandB"); + scene.Add(islandB); + islandB.AddComponent().Size = new Float3(8, 1, 8); + islandB.Transform.Position = new Float3(6, -0.5f, 0); + + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + return (scene, surface); + } + + private NavMeshLink AddLink(Scene scene, float width = 0f) + { + GameObject linkGo = CreateGameObject("Link"); + scene.Add(linkGo); + var link = linkGo.AddComponent(); + link.StartPoint = new Float3(-3, 0, 0); // on island A + link.EndPoint = new Float3(3, 0, 0); // on island B + link.Width = width; + return link; + } + + /// + /// Link ids come from the component's persistent identifier, so each link has its own and it + /// does not change as the component is enabled and disabled. Nothing mints one at runtime, + /// which would dirty the scene just by entering play mode. + /// + [Fact] + public void Link_Id_IsDistinctPerComponentAndStable() + { + Scene scene = CreateScene(enable: true); + NavMeshLink a = AddLink(scene); + NavMeshLink b = AddLink(scene); + + Assert.NotEqual(0, a.LinkId); + Assert.NotEqual(a.LinkId, b.LinkId); + + int before = a.LinkId; + a.Enabled = false; + a.Enabled = true; + Assert.Equal(before, a.LinkId); + Assert.Same(a, scene.Navigation.FindLink(before)); + } + + /// + /// Baking is something you do in the editor, and a bake gathers its links from the world's + /// registry. A link that stayed inert outside play mode would never register, and would go + /// silently missing from every navmesh baked from the button — with nothing to see until an + /// agent refused to cross. + /// + [Fact] + public void Link_IsBakedIntoASurfaceBuiltInTheEditor() + { + using (EditMode()) + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); + + Assert.True(surface.BuildNavMesh()); + Assert.NotEmpty(surface.NavMeshData.Res!.Links); + Assert.Equal(NavMeshPathStatus.PathComplete, + PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0))); + } + } + + /// + /// What the scene view draws for a link comes from the mesh, not the component: the + /// endpoints Detour snapped onto walkable polygons, which are held on the connection's + /// polygon rather than on the connection (that keeps the positions originally asked for). + /// A link that attached to nothing is left out entirely, which is what makes a broken one + /// distinguishable from a working one at a glance. + /// + [Fact] + public void Link_ReportsTheConnectionTheMeshActuallyHolds() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + link.Bidirectional = false; + Assert.True(surface.BuildNavMesh()); + + NavMeshConnection connection = Assert.Single(scene.Navigation.CalculateTriangulation().Connections); + Assert.Equal(link.LinkId, connection.LinkId); + Assert.Equal(NavMeshAreas.Jump, connection.Area); + Assert.False(connection.Bidirectional); + + // Snapped onto the islands, so within a tolerance of the authored ends rather than at + // them — and on the walkable surface, not the y=0 plane the component was authored on. + Assert.True(Float3.Distance(connection.Start, link.WorldStart) < 1.5f, + $"Start {connection.Start} should be near the authored {link.WorldStart}."); + Assert.True(Float3.Distance(connection.End, link.WorldEnd) < 1.5f, + $"End {connection.End} should be near the authored {link.WorldEnd}."); + + // Move an end over the gap: inside the tile grid, so the connection is still stored, but + // with no walkable polygon under it Detour never attaches that end. Either end failing + // makes the link untraversable, and both must therefore be reported as nothing — the far + // end is attached separately from the start, so it is its own way to fail. + link.EndPoint = new Float3(0, 0, 0); + Tick(scene, 2); + Assert.Empty(scene.Navigation.CalculateTriangulation().Connections); + Assert.Equal(NavMeshPathStatus.PathPartial, + PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0))); + + link.StartPoint = new Float3(0, 0, 0); + link.EndPoint = new Float3(3, 0, 0); + Tick(scene, 2); + Assert.Empty(scene.Navigation.CalculateTriangulation().Connections); + } + + /// + /// A frame's worth of link edits is applied as one pass per surface. Demolishing a building + /// disables all its ladders in the same frame; applied one at a time, each edit re-collects + /// the scene's links, replaces the whole link set again, and re-contours tiles the edit + /// before it just did — so the cost is per link rather than per affected tile. + /// + [Fact] + public void Links_ChangedInOneFrame_RebuildInOnePass() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + var links = new List(); + for (int i = 0; i < 6; i++) + links.Add(AddLink(scene)); + + Assert.True(surface.BuildNavMesh()); + Assert.True(TickUntil(scene, () => !scene.Navigation.GetInstance()!.CachePending) >= 0); + + int changes = 0; + scene.Navigation.NavMeshChanged += () => changes++; + + foreach (NavMeshLink link in links) + link.Activated = false; + Tick(scene, 2); // links mark from LateUpdate; the world drains them at the next update + + // One from the coalesced rebuild, one from the pump draining the tile work it queued. + // Uncoalesced this is two per link — each endpoint region is its own rebuild. + Assert.InRange(changes, 1, 2); + Assert.Equal(NavMeshPathStatus.PathPartial, + PathStatus(scene, new Float3(-6, 0, 0), new Float3(6, 0, 0))); + } + + /// + /// Draining the batch raises NavMeshChanged, and a handler is free to disable a link from it — + /// which marks tiles while the drain is still running. Those marks have to survive: the batch + /// is swapped out before draining, so they land in the next frame's instead of being dropped + /// when this one finishes. (A handler that dirties a surface not already in the batch would + /// also have mutated the collection mid-enumeration; the swap covers both, this covers the + /// half that is deterministic to reproduce.) + /// + [Fact] + public void Links_MarkedFromAChangeHandler_SurviveTheDrain() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink first = AddLink(scene); + NavMeshLink second = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Assert.True(TickUntil(scene, () => !scene.Navigation.GetInstance()!.CachePending) >= 0); + Assert.NotEmpty(scene.Navigation.CalculateTriangulation().Connections); + + bool disabledFromHandler = false; + void DisableTheOtherLink() + { + if (disabledFromHandler) return; + disabledFromHandler = true; + second.Enabled = false; // OnDisable marks its endpoint tiles, mid-drain + } + + scene.Navigation.NavMeshChanged += DisableTheOtherLink; + try + { + first.Activated = false; + Tick(scene, 4); + } + finally + { + scene.Navigation.NavMeshChanged -= DisableTheOtherLink; + } + + Assert.True(disabledFromHandler, "The drain should have raised NavMeshChanged."); + Assert.Empty(scene.Navigation.CalculateTriangulation().Connections); + } + + /// A link answers for its own scene only: two additively loaded scenes derive ids + /// from their own components and have no reason to agree on them, so one scene resolving + /// another's link would hand an agent a component from the wrong world. + [Fact] + public void Link_ResolvesOnlyWithinItsOwnScene() + { + Scene home = CreateScene(enable: true); + Scene other = CreateScene(enable: true); + NavMeshLink link = AddLink(home); + + Assert.Same(link, home.Navigation.FindLink(link.LinkId)); + Assert.Null(other.Navigation.FindLink(link.LinkId)); + } + + private static NavMeshPathStatus PathStatus(Scene scene, Float3 from, Float3 to, int areaMask = NavMesh.AllAreas) + { + var path = new NavMeshPath(); + return scene.Navigation.CalculatePath(from, to, areaMask, path) ? path.Status : NavMeshPathStatus.PathInvalid; + } + + /// A link across the gap makes the far island reachable; without one (or with + /// Activated off at bake) the path stays partial. + [Fact] + public void Link_BridgesGap_AndActivatedOffDoesNot() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + NavMeshLink link = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + link.Activated = false; + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// + /// The headline for links: the connection survives the cache re-contouring a tile. Tiles are + /// rebuilt from geometry-only layers, so connections cannot simply be baked in once — they + /// ride on the asset and are re-injected on every tile build. An obstacle carving inside the + /// link's own tile is the exact moment a baked-in connection would be regenerated away. + /// + [Fact] + public void Link_SurvivesObstacleCarve() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + // Carve a hole on island A, well clear of the link's landing point but in its tiles. + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(-7, 1, 2.5f); + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(2, 3, 2); + + bool carved = false; + for (int i = 0; i < 240 && !carved; i++) + { + Tick(scene, 1); + carved = !scene.Navigation.SamplePosition(new Float3(-7, 0.2f, 2.5f), out _, 0.4f, NavMesh.AllAreas); + } + Assert.True(carved, "The obstacle should carve."); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// A link added after a bake inserts itself through the catch-up path, re-contouring + /// the affected tiles without re-voxelizing anything. + [Fact] + public void Link_AddedAtRuntime() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + AddLink(scene); + Tick(scene, 3); // catch-up runs in LateUpdate + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// Disabling a link at runtime removes it — the removal path runs through OnDisable, + /// which depends on the component already reading as disabled by the time collection + /// re-runs. + [Fact] + public void Link_DisabledAtRuntime() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + link.Enabled = false; + Tick(scene, 3); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + link.Enabled = true; + Tick(scene, 3); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// + /// Agent-type scoping is part of the link's definition, so writing it after AddComponent has + /// to re-resolve like the endpoints do. Narrowing the scope must also REMOVE the link from + /// the surfaces it no longer applies to, which needs the rebuild to visit the outgoing scope + /// as well as the incoming one. + /// + [Fact] + public void Link_AgentTypeScopeEdit_RemovesItFromUnaffectedSurfaces() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid" }, + new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f }, + ]); + + (Scene scene, NavMeshSurface surface) = CreateGapScene(); // agent type 0 + NavMeshLink link = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + // Spawn-then-configure, but for scoping: hand the link to another agent type only. + link.AffectAllAgentTypes = false; + link.AffectedAgentTypeIds = [3]; + Tick(scene, 3); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + // And back: widening re-attaches it. + link.AffectAllAgentTypes = true; + Tick(scene, 3); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + finally + { + NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]); + } + } + + /// Links round-trip on the asset: a reloaded navmesh re-injects them when it + /// instantiates, with no live component present. + [Fact] + public void Link_RoundTripsOnAsset() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); + Assert.True(surface.BuildNavMesh()); + + Runtime.NavMeshData baked = surface.NavMeshData.Res!; + Assert.NotEmpty(baked.Links); + + Prowl.Echo.EchoObject echo = Prowl.Echo.Serializer.Serialize(typeof(object), baked); + var loaded = Prowl.Echo.Serializer.Deserialize(echo); + Assert.NotNull(loaded); + Assert.Equal(baked.Links.Count, loaded!.Links.Count); + + var world = new NavMeshWorld(); + Assert.NotNull(world.AddNavMeshData(loaded)); + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// A one-directional link works one way only. + [Fact] + public void Link_OneDirectional_WorksOneWayOnly() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + link.Bidirectional = false; + Assert.True(surface.BuildNavMesh()); + + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(8, 0, 0), new Float3(-8, 0, 0))); + } + + /// The link's area participates in masking: excluding it severs the route. + [Fact] + public void Link_AreaMask_ExcludingLinkAreaSeversRoute() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); // Area = Jump by default + Assert.True(surface.BuildNavMesh()); + + Assert.Equal(NavMeshPathStatus.PathComplete, + PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + Assert.Equal(NavMeshPathStatus.PathPartial, + PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas & ~(1 << NavMeshAreas.Jump))); + } + + private static int CountOffMeshPolys(NavMeshSurface surface) + { + DtNavMesh mesh = surface.Instance!.NativeNavMesh; + int count = 0; + for (int t = 0; t < mesh.GetMaxTiles(); t++) + { + DtMeshTile tile = mesh.GetTile(t); + if (tile?.data?.polys == null) continue; + for (int p = 0; p < tile.data.header.polyCount; p++) + if (tile.data.polys[p].GetPolyType() == DtPolyTypes.DT_POLYTYPE_OFFMESH_CONNECTION) + count++; + } + return count; + } + + /// Width expands into parallel connections (⌈width / 2·agentRadius⌉) so an agent + /// enters at the nearest point along the span rather than queueing through its middle; + /// width 0 is a single connection. + [Fact] + public void Link_Width_EmitsParallelConnections() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene, width: 3f); // agent radius 0.5 → 3 connections + Assert.True(surface.BuildNavMesh()); + Assert.Equal(3, CountOffMeshPolys(surface)); + + link.Width = 0f; + Assert.True(surface.BuildNavMesh()); + Assert.Equal(1, CountOffMeshPolys(surface)); + } + + /// An agent physically crosses via the crowd, reports the off-mesh state mid-hop, + /// and the traversal data resolves back to the component. + [Fact] + public void Agent_CrossesLink_AndReportsOffMeshState() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, 0); + var agent = agentGo.AddComponent(); + agent.Speed = 6f; + agent.Acceleration = 100f; + agent.Separation = false; + + Tick(scene, 2); + Assert.True(agent.IsOnNavMesh); + agent.SetDestination(new Float3(8, 0, 0)); + + bool sawOffMesh = false; + NavMeshLink? resolved = null; + for (int i = 0; i < 900; i++) + { + Tick(scene, 1); + if (agent.IsOnOffMeshLink) + { + sawOffMesh = true; + OffMeshLinkData data = agent.CurrentOffMeshLinkData; + if (data.Valid && data.Link != null) resolved = data.Link; + } + if (!agent.PathPending && agent.RemainingDistance <= 0f) break; + } + + Assert.True(sawOffMesh, "Agent should traverse the gap through the off-mesh link."); + Assert.Same(link, resolved); + double endDistance = Float3.Distance(agentGo.Transform.Position, new Float3(8, 0, 0)); + Assert.True(endDistance < 2.0, $"Agent should reach the far island (ended {endDistance:0.0} away)."); + } + + /// Toggling Activated at runtime rebuilds the endpoint tiles automatically + /// (AutoRebuild): the route severs and comes back without a full rebake. + [Fact] + public void Link_RuntimeToggle_RebuildsAffectedTiles() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + link.Activated = false; + Tick(scene, 2); // LateUpdate change detection → targeted rebuild + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + link.Activated = true; + Tick(scene, 2); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// + /// The Unity arrival idiom "!PathPending && RemainingDistance <= StoppingDistance" + /// must not false-fire mid-link, or a waypoint script issues its next destination during the + /// hop and ping-pongs the agent across the link forever. Mid-hop the value stays bounded below + /// by the path remaining AFTER landing. + /// + [Fact] + public void Agent_MidHop_NeverReportsArrival() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Agent"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, 0); + var agent = agentGo.AddComponent(); + agent.Speed = 6f; + agent.Acceleration = 100f; + agent.Separation = false; + + Tick(scene, 2); + var goal = new Float3(8, 0, 0); + agent.SetDestination(goal); + + // Drive with the arrival idiom, exactly like gameplay code would. + bool sawHop = false; + float minMidHopRemaining = float.MaxValue; + int arrivedAtTick = -1; + for (int i = 0; i < 900; i++) + { + Tick(scene, 1); + if (agent.IsOnOffMeshLink) + { + sawHop = true; + minMidHopRemaining = MathF.Min(minMidHopRemaining, agent.RemainingDistance); + } + if (!agent.PathPending && agent.RemainingDistance <= agent.StoppingDistance) + { + arrivedAtTick = i; + break; + } + } + + Assert.True(sawHop, "Agent should traverse the link."); + // The link lands ~5 units from the goal; mid-hop the reading must never drop below + // most of that post-landing path (never anywhere near a stopping distance of 0). + Assert.True(minMidHopRemaining > 3f, + $"RemainingDistance collapsed to {minMidHopRemaining:0.00} mid-hop — the arrival idiom would false-fire."); + Assert.True(arrivedAtTick >= 0, "Arrival idiom should eventually fire at the real destination."); + Assert.True(Float3.Distance(agentGo.Transform.Position, goal) < 1.0, + $"Arrival idiom fired {Float3.Distance(agentGo.Transform.Position, goal):0.00} away from the destination."); + } + + /// + /// Adverse enable order: a link enabled while no navmesh exists must insert itself when a + /// surface later registers a STALE bake (one that predates the link) — the NavMeshChanged + /// subscription closes the ordering hole. + /// + [Fact] + public void Link_EnabledBeforeSurfaceRegisters_CatchesUpOnStaleBake() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + + // Bake WITHOUT the link, then take the navmesh offline. + link.GameObject.Enabled = false; + Assert.True(surface.BuildNavMesh()); + surface.GameObject.Enabled = false; + + // Link enables first (no navmesh anywhere), surface second with the stale bake. + link.GameObject.Enabled = true; + surface.GameObject.Enabled = true; + Tick(scene, 2); + + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// + /// The baked-in safeguard observed directly: re-registering a navmesh that already + /// contains the link must trigger NO catch-up rebuild — only the unregister/register + /// events themselves fire. This is the safeguard whose failure mode is "everything still + /// works, just slower at load", so it needs a direct observer. + /// + [Fact] + public void Link_BakedIn_ReregistrationTriggersNoRebuild() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + AddLink(scene); + Assert.True(surface.BuildNavMesh()); // link baked in + Tick(scene, 2); + + int navMeshEvents = 0; + scene.Navigation.NavMeshChanged += () => navMeshEvents++; + + // Unregister + re-register: a fresh NavMeshInstance from the same baked data. + surface.GameObject.Enabled = false; + surface.GameObject.Enabled = true; + Tick(scene, 2); + + // Exactly the remove + add events; a catch-up rebuild would fire additional + // mutation events on top. + Assert.Equal(2, navMeshEvents); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } + + /// Moving a link with AutoUpdatePosition rebuilds both the old and new endpoint + /// regions: the connection follows the Transform. + [Fact] + public void Link_AutoUpdatePosition_FollowsTransform() + { + (Scene scene, NavMeshSurface surface) = CreateGapScene(); + NavMeshLink link = AddLink(scene); + link.AutoUpdatePosition = true; + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + // Slide the link into the void: both endpoints now hang over the gap, so the + // connection can't attach and the route severs. + link.GameObject.Transform.Position = new Float3(0, 0, 30); + Tick(scene, 2); + Assert.Equal(NavMeshPathStatus.PathPartial, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + + // Slide it back: the route returns. + link.GameObject.Transform.Position = Float3.Zero; + Tick(scene, 2); + Assert.Equal(NavMeshPathStatus.PathComplete, PathStatus(scene, new Float3(-8, 0, 0), new Float3(8, 0, 0))); + } +} diff --git a/Prowl.Runtime.Test/NavMeshModifierTests.cs b/Prowl.Runtime.Test/NavMeshModifierTests.cs new file mode 100644 index 000000000..da74ef703 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshModifierTests.cs @@ -0,0 +1,371 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// NavMeshModifier (per-object area/exclusion at collection time), NavMeshModifierVolume +/// (area stamped over a region post-rasterization), and the Not Walkable null-area semantics +/// both rely on. +/// +public class NavMeshModifierTests : RuntimeTestBase +{ + private const int Mud = 3; // arbitrary user area + + private GameObject AddFloorBox(Scene scene, string name, Float3 center, Float3 size) + { + GameObject go = CreateGameObject(name); + scene.Add(go); + go.AddComponent().Size = size; + go.Transform.Position = center; + return go; + } + + private NavMeshSurface AddSurface(Scene scene, int agentTypeId = 0) + { + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + surface.AgentTypeId = agentTypeId; + return surface; + } + + private static int SampleAreaMask(Scene scene, Float3 position, int agentTypeId = 0) + { + var filter = new NavMeshQueryFilter { AgentTypeId = agentTypeId }; + return scene.Navigation.SamplePosition(position, out NavMeshHit hit, 0.5f, filter) ? hit.Mask : 0; + } + + // ── NavMeshModifier ───────────────────────────────────────────────── + + /// OverrideArea stamps the modifier's area on that object's polys only. + [Fact] + public void Modifier_OverrideArea_LandsOnThatObjectsPolys() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Plain", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10)); + GameObject marked = AddFloorBox(scene, "Marked", new Float3(5, -0.5f, 0), new Float3(10, 1, 10)); + var modifier = marked.AddComponent(); + modifier.OverrideArea = true; + modifier.Area = Mud; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(-5, 0.2f, 0))); + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(5, 0.2f, 0))); + } + + /// IgnoreFromBuild removes the object's geometry from the bake. + [Fact] + public void Modifier_IgnoreFromBuild_ExcludesGeometry() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Plain", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10)); + GameObject ignored = AddFloorBox(scene, "Ignored", new Float3(5, -0.5f, 0), new Float3(10, 1, 10)); + ignored.AddComponent().IgnoreFromBuild = true; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(-5, 0.2f, 0))); + Assert.Equal(0, SampleAreaMask(scene, new Float3(5, 0.2f, 0))); + } + + /// A parent's modifier applies to children, but a child's own modifier wins. + [Fact] + public void Modifier_ChildModifier_BeatsInheritedParentModifier() + { + Scene scene = CreateScene(enable: true); + + GameObject parent = CreateGameObject("Parent"); + scene.Add(parent); + var parentModifier = parent.AddComponent(); + parentModifier.OverrideArea = true; + parentModifier.Area = Mud; + parentModifier.ApplyToChildren = true; + + GameObject inheriting = AddFloorBox(scene, "Inheriting", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10)); + inheriting.SetParent(parent); + GameObject overriding = AddFloorBox(scene, "Overriding", new Float3(5, -0.5f, 0), new Float3(10, 1, 10)); + overriding.SetParent(parent); + var childModifier = overriding.AddComponent(); + childModifier.OverrideArea = true; + childModifier.Area = NavMeshAreas.Jump; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(-5, 0.2f, 0))); + Assert.Equal(1 << NavMeshAreas.Jump, SampleAreaMask(scene, new Float3(5, 0.2f, 0))); + } + + /// A modifier scoped to another agent type is transparent to this bake. + [Fact] + public void Modifier_AgentTypeScoped_OnlyAffectsThatTypesBakes() + { + try + { + NavMeshAgentTypes.ApplyTable( + [ + new NavMeshAgentType { Id = 0, Name = "Humanoid" }, + new NavMeshAgentType { Id = 3, Name = "Scout", Radius = 0.4f }, + ]); + + Scene scene = CreateScene(enable: true); + GameObject floor = AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + var modifier = floor.AddComponent(); + modifier.IgnoreFromBuild = true; + modifier.AffectAllAgentTypes = false; + modifier.AffectedAgentTypeIds = [3]; + + // Type 0: modifier is transparent, the floor bakes. + Assert.True(AddSurface(scene, agentTypeId: 0).BuildNavMesh()); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + + // Type 3: the floor is excluded and the bake has nothing. + Assert.False(AddSurface(scene, agentTypeId: 3).BuildNavMesh()); + } + finally + { + NavMeshAgentTypes.ApplyTable([new NavMeshAgentType { Id = 0, Name = "Humanoid" }]); + } + } + + /// Non-affecting modifiers are transparent, not shielding: a parent whose + /// modifier is scoped to another agent type is walked PAST, so the child inherits the + /// grandparent's override. + [Fact] + public void Modifier_WalksPastNonAffectingAncestor_ToHigherOne() + { + Scene scene = CreateScene(enable: true); + + GameObject grandparent = CreateGameObject("Grandparent"); + scene.Add(grandparent); + var grandModifier = grandparent.AddComponent(); + grandModifier.OverrideArea = true; + grandModifier.Area = Mud; + grandModifier.ApplyToChildren = true; + + GameObject parent = CreateGameObject("Parent"); + scene.Add(parent); + parent.SetParent(grandparent); + var parentModifier = parent.AddComponent(); + parentModifier.OverrideArea = true; + parentModifier.Area = NavMeshAreas.Jump; + parentModifier.ApplyToChildren = true; + parentModifier.AffectAllAgentTypes = false; + parentModifier.AffectedAgentTypeIds = [7]; // not this bake's type + + GameObject child = AddFloorBox(scene, "Child", new Float3(0, -0.5f, 0), new Float3(10, 1, 10)); + child.SetParent(parent); + + Assert.True(AddSurface(scene).BuildNavMesh()); + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + } + + /// An object's own modifier shields inherited overrides even when it overrides + /// nothing itself: a valid child modifier with OverrideArea off means "default area", + /// not "fall through to the parent's override". + [Fact] + public void Modifier_OwnNoOpModifier_ShieldsInheritedOverride() + { + Scene scene = CreateScene(enable: true); + + GameObject parent = CreateGameObject("Parent"); + scene.Add(parent); + var parentModifier = parent.AddComponent(); + parentModifier.OverrideArea = true; + parentModifier.Area = Mud; + parentModifier.ApplyToChildren = true; + + GameObject inheriting = AddFloorBox(scene, "Inheriting", new Float3(-5, -0.5f, 0), new Float3(10, 1, 10)); + inheriting.SetParent(parent); + GameObject shielded = AddFloorBox(scene, "Shielded", new Float3(5, -0.5f, 0), new Float3(10, 1, 10)); + shielded.SetParent(parent); + shielded.AddComponent(); // valid, but OverrideArea off — a no-op that still wins + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(-5, 0.2f, 0))); + Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(5, 0.2f, 0))); + } + + // ── NavMeshModifierVolume ─────────────────────────────────────────── + + /// The volume stamps its area only inside its footprint. + [Fact] + public void ModifierVolume_StampsAreaOnlyInsideFootprint() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + + GameObject volumeGo = CreateGameObject("MudZone"); + scene.Add(volumeGo); + volumeGo.Transform.Position = new Float3(5, 0, 5); + var volume = volumeGo.AddComponent(); + volume.Size = new Float3(6, 3, 6); // covers x/z 2..8 + volume.Area = Mud; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(5, 0.2f, 5))); + Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(-5, 0.2f, -5))); + } + + /// A Not Walkable volume erases walkability inside the footprint — the hole is + /// real for pathing, not just recoloured. + [Fact] + public void ModifierVolume_NotWalkable_PunchesHole() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + + GameObject volumeGo = CreateGameObject("Hole"); + scene.Add(volumeGo); + volumeGo.Transform.Position = new Float3(0, 0, 0); + var volume = volumeGo.AddComponent(); + volume.Size = new Float3(4, 3, 4); + volume.Area = NavMeshAreas.NotWalkable; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(7, 0.2f, 7))); + + // A path across the hole routes around it: some corner deviates from the straight line. + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + double maxDeviation = 0; + foreach (Float3 corner in path.Corners) + maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(corner.Z)); + Assert.True(maxDeviation > 1.5, $"Path should route around the hole (max |z| = {maxDeviation:0.00})."); + } + + /// A rotated volume marks its rotated footprint, not its axis-aligned box: a + /// 45°-yawed square's corner regions stay unmarked while its center is marked. + [Fact] + public void ModifierVolume_RotatedFootprint_IsHonored() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(24, 1, 24)); + + GameObject volumeGo = CreateGameObject("Diamond"); + scene.Add(volumeGo); + volumeGo.Transform.Position = new Float3(0, 0, 0); + volumeGo.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45, 0)); + var volume = volumeGo.AddComponent(); + volume.Size = new Float3(8, 3, 8); // rotated 45°: a diamond with tips at ±5.66 on the axes + volume.Area = Mud; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + // Center: inside the diamond. + Assert.Equal(1 << Mud, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + // The axis-aligned corner (4.5, 4.5) is OUTSIDE the diamond (|x|+|z| = 9 > 5.66) but + // inside the unrotated box's AABB — marked only if rotation were ignored. + Assert.Equal(1 << NavMeshAreas.Walkable, SampleAreaMask(scene, new Float3(4.5f, 0.2f, 4.5f))); + } + + // ── Partial rebuilds ──────────────────────────────────────────────── + + /// The collector-based RebuildTiles picks up modifier volumes automatically: + /// spawning and removing a Not Walkable volume at runtime opens and closes the hole. + [Fact] + public void RebuildTiles_AppliesAndRemovesVolume() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + NavMeshSurface surface = AddSurface(scene); + Assert.True(surface.BuildNavMesh()); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + + // Spawn a hole mid-game. + GameObject volumeGo = CreateGameObject("Hole"); + scene.Add(volumeGo); + volumeGo.Transform.Position = new Float3(0, 0, 0); + var volume = volumeGo.AddComponent(); + volume.Size = new Float3(4, 3, 4); + volume.Area = NavMeshAreas.NotWalkable; + + var region = new AABB(new Float3(-3, -1, -3), new Float3(3, 2, 3)); + Assert.True(surface.RebuildTiles(region)); + Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(7, 0.2f, 7))); + + // Remove it and rebuild the same region: walkability returns. + volumeGo.Enabled = false; + Assert.True(surface.RebuildTiles(region)); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + } + + /// Modifiers resolve during rebuild collection too: toggling IgnoreFromBuild on + /// an obstacle and rebuilding its region updates the mesh. + [Fact] + public void RebuildTiles_HonorsModifierChanges() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + // A wall segment initially ignored by the bake (a ghost preview, say). + GameObject wall = AddFloorBox(scene, "Wall", new Float3(0, 2, 0), new Float3(2, 4, 20)); + var modifier = wall.AddComponent(); + modifier.IgnoreFromBuild = true; + + NavMeshSurface surface = AddSurface(scene); + Assert.True(surface.BuildNavMesh()); + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + + // The wall becomes real: stop ignoring it and rebuild its region. + modifier.IgnoreFromBuild = false; + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)))); + + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + } + + // ── Not Walkable source parity ────────────────────────────────────── + + /// + /// A Not Walkable object sitting ON a walkable floor still erases it. Rasterizing it as the + /// null area would not: merging two spans in a column keeps the HIGHER of their areas, so + /// the floor it rests on would win and the object would come out walkable. + /// + [Fact] + public void Modifier_NotWalkableOnAWalkableFloor_ErasesIt() + { + Scene scene = CreateScene(enable: true); + AddFloorBox(scene, "Floor", new Float3(0, -0.5f, 0), new Float3(20, 1, 20)); + + // Thin and flush with the floor, so the two span tops land within the climb threshold and + // their areas merge — the case where the null area would be discarded. + GameObject blocked = AddFloorBox(scene, "Blocked", new Float3(0, 0.05f, 0), new Float3(6, 0.1f, 6)); + var modifier = blocked.AddComponent(); + modifier.OverrideArea = true; + modifier.Area = NavMeshAreas.NotWalkable; + + Assert.True(AddSurface(scene).BuildNavMesh()); + + Assert.Equal(0, SampleAreaMask(scene, new Float3(0, 0.2f, 0))); + Assert.NotEqual(0, SampleAreaMask(scene, new Float3(8, 0.2f, 8))); + } + + /// A source marked Not Walkable produces no navmesh at all (Unity parity): its + /// geometry is an obstacle, not traversable "area 1" polys. + [Fact] + public void Source_NotWalkableArea_ProducesNoPolys() + { + Float3[] verts = [new(0, 0, 0), new(0, 0, 10), new(10, 0, 10), new(10, 0, 0)]; + int[] indices = [0, 1, 2, 0, 2, 3]; + var source = new NavMeshGeometrySource(verts, indices, Float4x4.Identity, NavMeshAreas.NotWalkable); + + var settings = new NavMeshBuildSettings { OverrideVoxelSize = true, VoxelSize = 0.25f }; + Assert.Null(NavMeshBuilder.Build(settings, [source])); + } +} diff --git a/Prowl.Runtime.Test/NavMeshObstacleTests.cs b/Prowl.Runtime.Test/NavMeshObstacleTests.cs new file mode 100644 index 000000000..dd018fedb --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshObstacleTests.cs @@ -0,0 +1,1038 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Prowl.Echo; +using Prowl.Runtime; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// NavMeshObstacle carving: obstacles carve holes that incremental updates apply across frames, +/// carve-only-stationary lifts the carve while moving, and the non-carving mode blocks agents by +/// local avoidance instead. Also covers the bake side that carving depends on — cache layers +/// query like finished tiles and round-trip through the asset. +/// +public class NavMeshObstacleTests : RuntimeTestBase +{ + private static bool Walkable(Scene scene, Float3 position) + => scene.Navigation.SamplePosition(position, out _, 0.5f, NavMesh.AllAreas); + + /// A bake produces cache layers, is queryable, and the layers survive an Echo + /// round-trip and re-instantiation. + [Fact] + public void Surface_BakesQueriesAndRoundTrips() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + Runtime.NavMeshData data = surface.NavMeshData.Res!; + Assert.NotEmpty(data.CacheLayers); + + Assert.True(Walkable(scene, new Float3(0, 0.2f, 0))); + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, -8), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + + // Layers round-trip through the asset serializer and instantiate again. + EchoObject echo = Serializer.Serialize(data); + Runtime.NavMeshData? loaded = Serializer.Deserialize(echo); + Assert.NotNull(loaded); + Assert.Equal(data.CacheLayers.Count, loaded!.CacheLayers.Count); + var cache = loaded.CreateTileCache(maxObstacles: 16); + Assert.NotNull(cache.GetNavMesh()); + Assert.True(cache.GetNavMesh().GetMaxTiles() > 0); + } + + /// + /// A surface with NOTHING overridden has to produce a usable navmesh. A tile wider than a + /// compressed layer header can describe (its dimensions are bytes) wraps to an empty layer: + /// the bake reports success and every tile comes out with no polygons — no gizmo, no agent + /// placement, no movement. Every other test here overrides the tile size, which is exactly + /// why that went unseen once. + /// + [Fact] + public void Bake_WithDefaultSettings_ProducesQueryableMesh() + { + Scene scene = CreateScene(enable: true); + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(40, 1, 40); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + surface.UseGeometry = NavMeshCollectGeometry.PhysicsColliders; + + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + Assert.True(Walkable(scene, new Float3(0, 0.2f, 0)), "A default bake must be queryable."); + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-15, 0, -15), new Float3(15, 0, 15), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + // The editor overlay reads this: empty layers drew nothing, which is how it looked unbaked. + Assert.NotEmpty(surface.NavMeshData.Res!.CalculateTriangulation().Vertices); + } + + /// An explicit oversized tile size is clamped rather than silently producing an + /// empty mesh, and the asset records the size actually used — the TileCache instantiated + /// from it later reads those same settings. + [Fact] + public void Bake_ClampsTileSizeToLayerFormatLimit() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + surface.BuildOverrides.OverrideTileSize = true; + surface.BuildOverrides.TileSize = 1024; + + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + Assert.Equal(NavMeshBuildSettings.MaxTileSize, surface.NavMeshData.Res!.Settings.EffectiveTileSize); + Assert.True(Walkable(scene, new Float3(0, 0.2f, 0))); + } + + /// + /// Carving costs nothing until something carves. A navmesh nobody has put an obstacle on is + /// never handed to the per-frame pump at all — that is what lets every surface be + /// carve-capable without every surface paying for it. A carve marks it, and convergence + /// clears the mark again. + /// + [Fact] + public void Surface_WithoutPendingWork_IsNotPumped() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + // Registration seeds every tile synchronously, so a fresh instance starts settled. + NavMeshInstance instance = surface.Instance!; + Assert.False(instance.CachePending); + Tick(scene, 5); + Assert.False(instance.CachePending); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + crate.AddComponent().Size = new Float3(4, 3, 4); + Assert.True(instance.CachePending, "Queuing a carve must hand the instance to the pump."); + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + Assert.True(TickUntil(scene, () => !instance.CachePending) >= 0, + "Once the carve has converged the instance should drop out of the pump again."); + } + + /// An obstacle carves a hole (paths detour, the hole is unwalkable) and removing + /// it restores the surface — all through incremental frame updates, no rebake. + [Fact] + public void Obstacle_CarvesHole_AndRemovalRestores() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + Assert.True(Walkable(scene, new Float3(0, 0.2f, 0))); + + GameObject obstacleGo = CreateGameObject("Crate"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(0, 1, 0); + var obstacle = obstacleGo.AddComponent(); + obstacle.Shape = NavMeshObstacleShape.Box; + obstacle.Size = new Float3(4, 3, 4); + + // The obstacle sits on the 2x2 tile grid's crossing point, so the hole spans four + // tiles that rebuild incrementally over several frames — wait until every quadrant of + // the hole is carved, not just the first rebuilt tile. + int carveTicks = TickUntil(scene, () => + !Walkable(scene, new Float3(1, 0.2f, 1)) && !Walkable(scene, new Float3(-1, 0.2f, 1)) + && !Walkable(scene, new Float3(1, 0.2f, -1)) && !Walkable(scene, new Float3(-1, 0.2f, -1))); + Assert.True(carveTicks >= 0, "Obstacle should carve all affected tiles within the tick budget."); + // Surrounding floor survives the carve. + Assert.True(Walkable(scene, new Float3(7, 0.2f, 7))); + + // A path across detours around the hole. + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + double maxDeviation = 0; + foreach (Float3 corner in path.Corners) + maxDeviation = System.Math.Max(maxDeviation, System.Math.Abs(corner.Z)); + Assert.True(maxDeviation > 1.5, $"Path should route around the carved hole (max |z| = {maxDeviation:0.00})."); + + // Removal restores. + obstacleGo.Enabled = false; + int restoreTicks = TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))); + Assert.True(restoreTicks >= 0, "Removing the obstacle should restore walkability."); + } + + /// With CarveOnlyStationary, the carve lifts while the obstacle moves and + /// re-applies after it has settled for CarvingTimeToStationary. + [Fact] + public void Obstacle_CarveOnlyStationary_LiftsWhileMoving() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject obstacleGo = CreateGameObject("Cart"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(0, 1, 0); + var obstacle = obstacleGo.AddComponent(); + obstacle.Shape = NavMeshObstacleShape.Box; + obstacle.Size = new Float3(4, 3, 4); + obstacle.CarveOnlyStationary = true; + obstacle.CarvingTimeToStationary = 0.2f; + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "A spawned-still obstacle should carve."); + + // Drag it along: each tick moves beyond the threshold, so the carve lifts and stays + // lifted while in motion. + for (int i = 0; i < 30; i++) + { + obstacleGo.Transform.Position += new Float3(0.3f, 0, 0); + Tick(scene, 1); + } + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "The original spot should be restored once the obstacle moved away."); + Assert.True(Walkable(scene, new Float3(obstacleGo.Transform.Position.X, 0.2f, 0)), + "A moving obstacle should not carve."); + + // Settle: after the stationary time it carves at the new spot. + Float3 rest = obstacleGo.Transform.Position; + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(rest.X, 0.2f, 0))) >= 0, + "A settled obstacle should carve at its new position."); + } + + /// Flipping Carve off at runtime must remove the existing hole, not leave it + /// carved forever (the non-carving mode is unsupported and does nothing). + [Fact] + public void Obstacle_CarveTurnedOff_RemovesExistingHole() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject obstacleGo = CreateGameObject("Ghost"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(0, 1, 0); + var obstacle = obstacleGo.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + + obstacle.Carve = false; + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "Turning Carve off should remove the hole."); + + obstacle.Carve = true; + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "Turning Carve back on should re-carve."); + } + + /// + /// A yawed box carves its ORIENTED footprint. The box is deliberately non-square (long + /// axis local Z), so a yaw-sign mismatch against Detour's convention would mirror the + /// carve onto the other diagonal and fail both assertions. + /// + [Fact] + public void Obstacle_RotatedBox_CarvesOrientedFootprint() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject obstacleGo = CreateGameObject("Barrier"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(0, 1, 0); + obstacleGo.Transform.Rotation = Quaternion.FromEuler(new Float3(0, 45, 0)); + var obstacle = obstacleGo.AddComponent(); + obstacle.Size = new Float3(1.5f, 3, 7); // long axis local Z, yawed 45° + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "The rotated obstacle should carve at its center."); + + // Which world diagonal the long axis lies along after +45° yaw — measured from the + // Transform so the assertion tracks Prowl's yaw convention rather than assuming it. + Float3 along = obstacleGo.Transform.Forward; + var farAlong = new Float3((float)(along.X * 2.5f), 0.2f, (float)(along.Z * 2.5f)); + var farAcross = new Float3((float)(-along.Z * 2.5f), 0.2f, (float)(along.X * 2.5f)); + + // The far end of a long obstacle reaches tiles the centre does not, and the cache + // rebuilds one tile per frame — waiting only for the centre samples the rest too early. + Assert.True(TickUntil(scene, () => !Walkable(scene, farAlong)) >= 0, + "The strip along the box's long axis should be carved."); + Assert.True(Walkable(scene, farAcross), + "Perpendicular to the long axis (outside the 1.5-wide footprint) should stay walkable."); + } + + // ── Layer regeneration (geometry rebuilds) ───────────────────────── + + /// RebuildTiles regenerates the affected tiles' compressed layers: dropped geometry + /// blocks paths, removal restores them — no rebake. + [Fact] + public void RebuildTiles_ReflectsGeometry() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(0, 2, 0); + + var region = new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)); + Assert.True(surface.RebuildTiles(region)); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + + wall.Enabled = false; + Assert.True(surface.RebuildTiles(region)); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// The stage-3b headline: an obstacle's carve survives regeneration of the tiles it sits + /// in. Tile replacement invalidates the refs in every obstacle's touched list (salt + /// bump); without the refresh step the regenerated tiles rebuild WITHOUT their carves. + /// + [Fact] + public void LayerRegeneration_PreservesExistingCarves() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + // Carve a hole away from the wall line but inside the tile the wall rebuild touches. + GameObject obstacleGo = CreateGameObject("Crate"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(4, 1, 4); + var obstacle = obstacleGo.AddComponent(); + obstacle.Size = new Float3(3, 3, 3); + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(4, 0.2f, 4))) >= 0); + + // Destructible-world event: a wall drops and its tiles regenerate. + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(0, 2, 0); + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)))); + + // The new geometry is in... + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + // ...AND the carve survived the regeneration of its tile. + Assert.False(Walkable(scene, new Float3(4, 0.2f, 4)), + "The obstacle's carve must survive layer regeneration of its tile."); + // Sanity: the floor between wall and carve is still there. + Assert.True(Walkable(scene, new Float3(4, 0.2f, -4))); + + // The obstacle still owns its carve: removal restores through the incremental path. + obstacleGo.Enabled = false; + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(4, 0.2f, 4))) >= 0, + "The carve must remain removable after regeneration (live obstacle refs intact)."); + } + + /// + /// An obstacle STRADDLING a regenerated tile and an untouched one: the refresh must + /// produce a mixed touched list (new ref for the regenerated tile + surviving ref for the + /// untouched one). A subtle refresh bug keeps half the carve — or loses half on removal. + /// + [Fact] + public void LayerRegeneration_ObstacleSpanningRebuiltAndUntouchedTiles() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + // Tile columns split at x = 6 (origin -10, tile world size 16). The crate straddles it. + GameObject obstacleGo = CreateGameObject("Crate"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(6, 1, 0); + var obstacle = obstacleGo.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); // carve x 4..8, both sides of the seam + Assert.True(TickUntil(scene, () => + !Walkable(scene, new Float3(5, 0.2f, 0)) && !Walkable(scene, new Float3(7, 0.2f, 0))) >= 0); + + // Regenerate only the LEFT tile column (wall far from the seam). + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(-6, 2, 0); + Assert.True(surface.RebuildTiles(new AABB(new Float3(-8, -1, -11), new Float3(-4, 5, 11)))); + + // Both halves of the carve survive: the regenerated-tile half re-applied, the + // untouched-tile half undisturbed. + Assert.False(Walkable(scene, new Float3(5, 0.2f, 0)), "Carve half in the regenerated tile must survive."); + Assert.False(Walkable(scene, new Float3(7, 0.2f, 0)), "Carve half in the untouched tile must survive."); + + // Removal restores BOTH halves — the mixed touched list must hold the new ref and + // the surviving ref. + obstacleGo.Enabled = false; + Assert.True(TickUntil(scene, () => + Walkable(scene, new Float3(5, 0.2f, 0)) && Walkable(scene, new Float3(7, 0.2f, 0))) >= 0, + "Removing the spanning obstacle must restore both sides of the seam."); + } + + /// + /// A navmesh built at runtime is the surface's own — nothing else points at it — so rebuilds + /// land on it directly. Copying it would mean the next registration rebuilt from the original + /// bake and threw away every rebuild since, which for a game that generates its map is the + /// whole navmesh. + /// + [Fact] + public void LayerRegeneration_OnARuntimeBake_LandsOnTheDataItself() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Assert.Same(surface.NavMeshData.Res, surface.RuntimeData); + + Runtime.NavMeshData data = surface.RuntimeData!; + var blobsBefore = new Dictionary<(int, int), byte[]>(); + foreach (Runtime.NavMeshData.NavMeshTile layer in data.CacheLayers) + blobsBefore[(layer.X, layer.Z)] = layer.Data; + + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(0, 2, 0); + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)))); + + bool anyReplaced = false; + foreach (Runtime.NavMeshData.NavMeshTile layer in data.CacheLayers) + if (blobsBefore.TryGetValue((layer.X, layer.Z), out byte[]? before) && !ReferenceEquals(before, layer.Data)) + anyReplaced = true; + Assert.True(anyReplaced, "Affected tiles' blobs should be replaced."); + + // Re-registering must keep the rebuilt tiles rather than reverting to the original bake. + surface.RefreshRegistration(); + Assert.Same(data, surface.RuntimeData); + } + + /// + /// A navmesh the asset database handed out is shared with every other surface pointing at the + /// same .navmesh and with the next scene that loads it, so the surface rebuilds a copy and + /// leaves the asset holding every blob it was baked with. That copy still round-trips. + /// + [Fact] + public void LayerRegeneration_OnAnAsset_MirrorsIntoACopyAndLeavesTheAsset() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + // Stand in for an imported asset: what marks one is carrying a database id. + Runtime.NavMeshData asset = surface.NavMeshData.Res!; + asset.AssetID = Guid.NewGuid(); + surface.RefreshRegistration(); + + Runtime.NavMeshData runtime = surface.RuntimeData!; + Assert.NotSame(asset, runtime); + + List assetLayersBefore = [.. asset.CacheLayers]; + var blobsBefore = new Dictionary<(int, int), byte[]>(); + foreach (Runtime.NavMeshData.NavMeshTile layer in assetLayersBefore) + blobsBefore[(layer.X, layer.Z)] = layer.Data; + + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(0, 2, 0); + Assert.True(surface.RebuildTiles(new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)))); + + bool anyReplaced = false; + foreach (Runtime.NavMeshData.NavMeshTile layer in runtime.CacheLayers) + if (blobsBefore.TryGetValue((layer.X, layer.Z), out byte[]? before) && !ReferenceEquals(before, layer.Data)) + anyReplaced = true; + Assert.True(anyReplaced, "Affected tiles' blobs should be replaced on the runtime copy."); + + Assert.Equal(assetLayersBefore.Count, asset.CacheLayers.Count); + for (int i = 0; i < assetLayersBefore.Count; i++) + Assert.Same(assetLayersBefore[i], asset.CacheLayers[i]); + + EchoObject echo = Serializer.Serialize(runtime); + Runtime.NavMeshData? loaded = Serializer.Deserialize(echo); + Assert.NotNull(loaded); + var cache = loaded!.CreateTileCache(maxObstacles: 16); + Assert.True(cache.GetNavMesh().GetMaxTiles() > 0); + } + + /// The async pair: layers regenerate off the main thread and apply as a swap. + [Fact] + public async Task RebuildTilesAsync_AppliesLikeSyncPath() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject wall = CreateGameObject("Wall"); + scene.Add(wall); + wall.AddComponent().Size = new Float3(2, 4, 20); + wall.Transform.Position = new Float3(0, 2, 0); + + var region = new AABB(new Float3(-2, -1, -11), new Float3(2, 5, 11)); + var rebuilt = await surface.RebuildTilesAsync(region, surface.CollectSources()); + Assert.NotEmpty(rebuilt); + Assert.True(surface.ApplyRebuiltTiles(rebuilt, out int rebuiltTiles)); + Assert.True(rebuiltTiles > 0); + + var path = new NavMeshPath(); + Assert.True(scene.Navigation.CalculatePath(new Float3(-8, 0, 0), new Float3(8, 0, 0), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + } + + /// + /// A crowd agent routes around a carve rather than walking through it — the two halves of + /// the feature (carving, crowd) had no test that exercised them together. + /// + [Fact] + public void Agent_PathsAroundCarve() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject obstacleGo = CreateGameObject("Crate"); + scene.Add(obstacleGo); + obstacleGo.Transform.Position = new Float3(0, 1, 0); + var obstacle = obstacleGo.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); // straddles the straight line across + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + + GameObject agentGo = CreateGameObject("Walker"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, 0); + var agent = agentGo.AddComponent(); + agent.Speed = 6f; + agent.Acceleration = 100f; + agent.Separation = false; + Tick(scene, 2); + + Assert.True(agent.IsOnNavMesh, "The agent should place on a TileCache-built navmesh."); + Assert.True(agent.SetDestination(new Float3(8, 0, 0))); + Tick(scene, 5); + Assert.Equal(NavMeshPathStatus.PathComplete, agent.PathStatus); + + bool arrived = false; + for (int i = 0; i < 600 && !arrived; i++) + { + Tick(scene, 1); + Float3 p = agentGo.Transform.Position; + Assert.False(System.Math.Abs(p.X) < 1.8 && System.Math.Abs(p.Z) < 1.8, + $"The agent walked into the carved hole at {p}."); + arrived = p.X > 7; + } + Assert.True(arrived, "The agent should reach the far side by routing around the carve."); + } + + /// + /// A bake must not voxelize a carving obstacle's own geometry: the carve is the hole. Baking + /// it too freezes a second hole in the mesh that stays behind forever once the obstacle + /// moves — the carve follows, the baked one does not. + /// + [Fact] + public void Bake_ExcludesCarvingObstacleGeometry() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + + // Present at bake time, and short enough that no agent fits under it — so if its + // geometry were collected it really would cut the floor. + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(6, 0.6f, 6); + crate.AddComponent().Size = new Float3(4, 1.2f, 4); + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(4, 1.2f, 4); + obstacle.CarvingTimeToStationary = 0.1f; + + Assert.True(surface.BuildNavMesh()); + Assert.Single(surface.CollectSources()); // the floor only — the crate is not collected + + // The hole is there — carved, not baked. + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(6, 0.2f, 6))) >= 0, + "The obstacle should carve at its resting position."); + + crate.Transform.Position = new Float3(-6, 0.6f, -6); + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(6, 0.2f, 6))) >= 0, + "The old spot must heal — nothing about the obstacle should be baked into the mesh."); + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(-6, 0.2f, -6))) >= 0, + "The carve should follow the obstacle to its new resting position."); + } + + /// Obstacles stay out of bakes in both carve settings — they block at runtime, + /// from wherever the object actually is. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Bake_ExcludesObstacleGeometry(bool carve) + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(6, 0.6f, 6); + crate.AddComponent().Size = new Float3(4, 1.2f, 4); + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(4, 1.2f, 4); + obstacle.Carve = carve; + + Assert.Single(surface.CollectSources()); // the floor only + } + + /// + /// Exclusion is by presence, not enabled state — deliberately, so a bake can never depend + /// on when a component was last toggled. A disabled obstacle that bakes its geometry would + /// freeze a hole at that spot, and enabling it later and moving it would strand that hole + /// forever while the runtime blocking correctly follows. + /// + [Fact] + public void Bake_ExcludesObstacleGeometry_EvenWhileDisabled() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(6, 0.6f, 6); + crate.AddComponent().Size = new Float3(4, 1.2f, 4); + crate.AddComponent().Enabled = false; + + Assert.Single(surface.CollectSources()); // the floor only + } + + /// A blocker that cannot be placed on the navmesh is an invisible failure — it must + /// say so rather than sit there avoided by nobody. + [Fact] + public void NonCarvingObstacle_OffNavMesh_WarnsItCannotBlock() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Walker"); + scene.Add(agentGo); + agentGo.AddComponent(); // creates the crowd a blocker would join + Tick(scene, 2); + + List warnings = []; + void Capture(string message, DebugStackTrace? trace, LogSeverity severity) + { + if (severity == LogSeverity.Warning && message.Contains("FloatingCrate")) warnings.Add(message); + } + + Debug.OnLog += Capture; + try + { + GameObject crate = CreateGameObject("FloatingCrate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 60, 0); // far above any walkable surface + var obstacle = crate.AddComponent(); + obstacle.Carve = false; + obstacle.Size = new Float3(2, 2, 2); + + Tick(scene, 30); + Assert.Single(warnings); // once, not once per frame + Assert.Contains("blocker", warnings[0]); + } + finally + { + Debug.OnLog -= Capture; + } + } + + /// + /// Carving works outside play mode, because that is where you place buildings. The scene here + /// is opened rather than baked — its asset came from an earlier session — so the surface has + /// to register from OnEnable alone. Without that there is no live navmesh for the obstacle to + /// carve, and the scene view shows an untouched mesh while play mode avoids the crate fine. + /// + [Fact] + public void Obstacle_CarvesAndFollowsOutsidePlayMode() + { + (Scene _, NavMeshSurface baker) = CreateFloorScene(bake: true); + NavMeshData asset = baker.NavMeshData.Res!; + + using (EditMode()) + { + Scene scene = CreateScene(enable: true); + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + NavMeshSurface surface = surfaceGo.AddComponent(); + surface.NavMeshData = asset; + scene.Add(surfaceGo); // enabling is what registers it + + Assert.NotNull(surface.Instance); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); + obstacle.CarvingTimeToStationary = 0.1f; + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "An obstacle placed in the editor should carve."); + + // And dragging it in the editor moves the hole, rather than stranding it. + crate.Transform.Position = new Float3(6, 1, 6); + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "The old spot should heal when the obstacle is dragged away."); + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(6, 0.2f, 6))) >= 0, + "The carve should follow to where the obstacle was dropped."); + } + } + + /// + /// A carve reports progress on every frame it works, but settles only when it finishes. + /// Anything expensive — replanning a crowd, rebuilding a cached triangulation — hangs off the + /// settled event so it runs on the finished navmesh rather than on each intermediate frame. + /// + [Fact] + public void Carve_ReportsEveryFrame_ButSettlesOnlyWhenDone() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + // One tile per frame, and enough obstacles spread far enough apart to touch several + // tiles, so the drain is unambiguously spread across frames. + scene.Navigation.MaxTileUpdatesPerFrame = 1; + + int changed = 0, settled = 0; + scene.Navigation.NavMeshChanged += () => changed++; + scene.Navigation.NavMeshSettled += () => settled++; + + Float3[] corners = + [ + new(-14, 1, -14), new(14, 1, -14), new(-14, 1, 14), new(14, 1, 14), + ]; + + foreach (Float3 spot in corners) + { + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = spot; + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); + obstacle.CarvingTimeToStationary = 0.1f; + } + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(14, 0.2f, 14))) >= 0, "The obstacles should carve."); + Tick(scene, 10); // let the drain finish + + Assert.True(settled >= 1, "A finished carve must settle."); + Assert.True(changed > settled, $"Progress should outnumber settles (changed={changed}, settled={settled})."); + + // Settling ends the work, so the pump stops and neither event fires again. + int changedAtRest = changed, settledAtRest = settled; + Tick(scene, 5); + Assert.Equal(changedAtRest, changed); + Assert.Equal(settledAtRest, settled); + } + + /// + /// A carved hole has to keep agents off the obstacle the way a baked wall does. A navmesh + /// records where an agent's CENTRE may be, not where its body fits — a bake pulls the mesh + /// back from every wall by the agent radius — so a hole cut to the obstacle's exact + /// footprint let agents walk their centre onto its surface and stand half inside it. On a + /// 10x10 floor the baked edge sits a full radius in from the rim; a carve must match. + /// + [Fact] + public void Carve_KeepsAgentsAFullRadiusClear() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + crate.AddComponent().Size = new Float3(2, 3, 2); // spans -1..1 in X and Z + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + Tick(scene, 30); + + GameObject go = CreateGameObject("Walker"); + scene.Add(go); + go.Transform.Position = new Float3(-6, 0, 0); + var agent = go.AddComponent(); + agent.Speed = 3f; + agent.Acceleration = 100f; + Tick(scene, 2); + Assert.True(agent.SetDestination(new Float3(6, 0, 0))); // straight through the obstacle + + double closest = double.MaxValue; + for (int i = 0; i < 500; i++) + { + Tick(scene, 1); + Float3 p = agent.Transform.Position; + double dx = Math.Max(Math.Abs(p.X) - 1.0, 0); + double dz = Math.Max(Math.Abs(p.Z) - 1.0, 0); + closest = Math.Min(closest, Math.Sqrt(dx * dx + dz * dz)); + if (!agent.PathPending && agent.RemainingDistance <= 0f) break; + } + + Assert.True(closest >= agent.Radius * 0.9, + $"Agent centre came within {closest:0.000} of the obstacle surface, inside its own {agent.Radius} radius."); + } + + /// + /// A carve has to ANNOUNCE itself. The scene-view overlay caches its triangulation and rebuilds + /// it when the world reports a change, so a carve nobody is told about leaves the overlay + /// drawing intact floor over a real hole. Anything queued into a cache reports every frame it + /// works AND on the frame it finishes — a carve small enough to complete inside one cache + /// update would otherwise never announce at all. + /// + [Fact] + public void Carve_FinishingInOneUpdate_StillReportsTheChange() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + surface.AlwaysShowNavMesh = true; + Assert.True(surface.BuildNavMesh()); + Tick(scene, 4); // settle, so nothing else is pending + + int changes = 0; + void Count() => changes++; + scene.Navigation.NavMeshChanged += Count; + try + { + // Small enough to land in one tile, which is what makes the cache converge at once. + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(3, 1, 3); + crate.AddComponent().Size = new Float3(2, 3, 2); + + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(3, 0.2f, 3))) >= 0, + "The obstacle should carve."); + Assert.True(changes > 0, "A carve must raise NavMeshChanged, or the overlay never redraws."); + + // And once it has settled the world goes quiet again, so the overlay is not + // re-triangulated every frame for the rest of the session. + Tick(scene, 5); + changes = 0; + Tick(scene, 10); + Assert.Equal(0, changes); + } + finally + { + scene.Navigation.NavMeshChanged -= Count; + } + } + + /// + /// What the scene-view overlay draws must show the carve, or there is no way to tell a real + /// hole from an agent merely steering around something. The live triangulation reflects it; + /// the asset's does not, and cannot — obstacles are runtime state and never serialize — so + /// the overlay has to be reading the live navmesh whenever one is registered. + /// + [Fact] + public void LiveTriangulation_ShowsTheCarve() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + NavMeshTriangulation before = scene.Navigation.CalculateTriangulation(surface.AgentTypeId); + Assert.NotEmpty(before.Vertices); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + crate.AddComponent().Size = new Float3(6, 3, 6); + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + + NavMeshTriangulation after = scene.Navigation.CalculateTriangulation(surface.AgentTypeId); + int inside = 0; + for (int t = 0; t < after.Areas.Length; t++) + { + Float3 centroid = (after.Vertices[after.Indices[t * 3 + 0]] + + after.Vertices[after.Indices[t * 3 + 1]] + + after.Vertices[after.Indices[t * 3 + 2]]) / 3f; + if (Math.Abs(centroid.X) < 2 && Math.Abs(centroid.Z) < 2) inside++; + } + Assert.Equal(0, inside); // the hole is a hole in the drawn mesh too + Assert.NotEqual(before.Areas.Length, after.Areas.Length); + } + + // ── Velocity obstacles (Carve off) ────────────────────────────────── + + /// + /// Carve off is Unity's velocity-obstacle mode, not a disabled component: the mesh is + /// untouched (the path still leads straight through) but agents steer around the obstacle + /// locally. It joins the crowd as an immovable neighbour, so it works on Static surfaces + /// too, where carving is impossible. + /// + [Fact] + public void NonCarvingObstacle_DeflectsAgent_WithoutTouchingMesh() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + Assert.True(surface.BuildNavMesh()); + + GameObject crate = CreateGameObject("Cart"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); // straight across the agent's route + var obstacle = crate.AddComponent(); + obstacle.Carve = false; + obstacle.Size = new Float3(3, 2, 3); + + // The mesh is untouched: the straight line is still walkable and still the path. + Tick(scene, 2); + Assert.True(Walkable(scene, new Float3(0, 0.2f, 0))); + + GameObject agentGo = CreateGameObject("Walker"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-10, 0, 0); + var agent = agentGo.AddComponent(); + agent.Speed = 4f; + agent.Acceleration = 20f; + agent.Separation = false; + agent.Radius = 0.5f; + Tick(scene, 2); + Assert.True(agent.SetDestination(new Float3(10, 0, 0))); + + double closest = double.MaxValue, maxLateral = 0; + bool arrived = false; + for (int i = 0; i < 900 && !arrived; i++) + { + Tick(scene, 1); + Float3 p = agentGo.Transform.Position; + closest = Math.Min(closest, Float3.Distance(p, new Float3(0, p.Y, 0))); + maxLateral = Math.Max(maxLateral, Math.Abs(p.Z)); + arrived = p.X > 9; + } + + Assert.True(arrived, "The agent should still reach the far side."); + Assert.True(maxLateral > 0.5, $"The agent should have been pushed off the straight line (max |z| = {maxLateral:0.00})."); + Assert.True(closest > 0.9, $"The agent should not have walked through the obstacle (closest approach {closest:0.00})."); + } + + /// + /// The mode's reason for existing: an obstacle that moves. A blocker has no steering of its + /// own, so it only tracks the Transform because the component writes its position each + /// frame — and this costs no rebuild at all, unlike carving, which lifts and re-cuts the + /// mesh on every move. + /// + [Fact] + public void NonCarvingObstacle_FollowsAMovingObstacle() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Walker"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-10, 0, 0); + agentGo.AddComponent(); + Tick(scene, 2); + + GameObject cart = CreateGameObject("Cart"); + scene.Add(cart); + cart.Transform.Position = new Float3(0, 1, 0); + var obstacle = cart.AddComponent(); + obstacle.Carve = false; + obstacle.Size = new Float3(2, 2, 2); + Tick(scene, 2); + + for (int i = 0; i < 40; i++) + { + cart.Transform.Position += new Float3(0.2f, 0, 0.1f); + Tick(scene, 1); + } + + Float3 expected = cart.Transform.Position; + Prowl.Recast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + { + if (!ReferenceEquals(a.option.userData, obstacle)) continue; + Assert.True(Math.Abs(a.npos.X - expected.X) < 0.05f && Math.Abs(a.npos.Z - expected.Z) < 0.05f, + $"The blocker sat at ({a.npos.X:0.00}, {a.npos.Z:0.00}) while the obstacle moved to ({expected.X:0.00}, {expected.Z:0.00})."); + return; + } + Assert.Fail("The obstacle registered no blocker with the crowd."); + } + + /// The blocker holds its ground rather than being shoved aside by the traffic it + /// exists to deflect. + [Fact] + public void NonCarvingObstacle_StaysPutUnderPressure() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(40f); + Assert.True(surface.BuildNavMesh()); + + GameObject crate = CreateGameObject("Cart"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + var obstacle = crate.AddComponent(); + obstacle.Carve = false; + obstacle.Size = new Float3(2, 2, 2); + + for (int i = 0; i < 6; i++) + { + GameObject go = CreateGameObject($"Pusher{i}"); + scene.Add(go); + go.Transform.Position = new Float3(-8, 0, -2.5f + i); + var pusher = go.AddComponent(); + pusher.Speed = 5f; + pusher.Acceleration = 50f; + pusher.Radius = 0.4f; + Tick(scene, 1); + pusher.SetDestination(new Float3(10, 0, 0)); + } + + Tick(scene, 300); + Prowl.Recast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + { + if (!ReferenceEquals(a.option.userData, obstacle)) continue; + Assert.True(Math.Abs(a.npos.X) < 0.05f && Math.Abs(a.npos.Z) < 0.05f, + $"The blocker drifted to ({a.npos.X:0.00}, {a.npos.Z:0.00}) under crowd pressure."); + return; + } + Assert.Fail("The obstacle registered no blocker with the crowd."); + } + + /// Flipping Carve at runtime swaps modes cleanly: neither the hole nor the blocker + /// is left behind by the other. + [Fact] + public void Obstacle_SwitchingCarveMode_LeavesNothingBehind() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + + GameObject agentGo = CreateGameObject("Walker"); + scene.Add(agentGo); + agentGo.Transform.Position = new Float3(-8, 0, 0); + agentGo.AddComponent(); // creates the crowd blockers join + Tick(scene, 2); + + GameObject crate = CreateGameObject("Crate"); + scene.Add(crate); + crate.Transform.Position = new Float3(0, 1, 0); + var obstacle = crate.AddComponent(); + obstacle.Size = new Float3(4, 3, 4); + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0); + Assert.Equal(1, BlockerCount(scene, obstacle)); // carving: no blocker + + obstacle.Carve = false; + Assert.True(TickUntil(scene, () => Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "Switching to velocity mode must remove the hole."); + Assert.Equal(2, BlockerCount(scene, obstacle)); + + obstacle.Carve = true; + Assert.True(TickUntil(scene, () => !Walkable(scene, new Float3(0, 0.2f, 0))) >= 0, + "Switching back to carving must cut the hole again."); + Assert.Equal(1, BlockerCount(scene, obstacle)); + } + + /// 1 when the obstacle has no blocker in the crowd, 2 when it has one — written as + /// a count so a failure reports which side of the switch broke. + private static int BlockerCount(Scene scene, NavMeshObstacle obstacle) + { + Prowl.Recast.Detour.Crowd.DtCrowd? crowd = scene.Navigation.NativeCrowd; + if (crowd == null) return 0; + int count = 1; + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + if (ReferenceEquals(a.option.userData, obstacle)) + count++; + return count; + } +} diff --git a/Prowl.Runtime.Test/NavMeshQueryTests.cs b/Prowl.Runtime.Test/NavMeshQueryTests.cs new file mode 100644 index 000000000..8813567b2 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshQueryTests.cs @@ -0,0 +1,257 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Runtime; +using Prowl.Vector; + +using Xunit; + +namespace Prowl.Runtime.Test; + +public class NavMeshQueryTests +{ + private static NavMeshBuildSettings TestSettings() => new() + { + OverrideVoxelSize = true, + VoxelSize = 0.25f, + OverrideTileSize = true, + TileSize = 64, + }; + + private static NavMeshGeometrySource Quad(float sizeX, float sizeZ, Float3 offset) + { + Float3[] verts = + [ + new(0, 0, 0), + new(0, 0, sizeZ), + new(sizeX, 0, sizeZ), + new(sizeX, 0, 0), + ]; + int[] indices = [0, 1, 2, 0, 2, 3]; + return new NavMeshGeometrySource(verts, indices, Float4x4.CreateTranslation(offset)); + } + + /// A 20x20 floor whose middle is blocked by a wall spanning z=0..16 at x≈10, + /// leaving a 4-unit passage at the far +Z side. Paths from left to right must detour + /// through the passage. + private static NavMeshWorld BuildUShapeWorld() + { + // Wall as a tall box: two vertical faces won't rasterize as walkable, and the floor + // is interrupted because the wall volume overwrites walkable spans below its top. + Float3[] wallVerts = + [ + // A solid box from (9.5,0,0) to (10.5,3,16) + new(9.5f, 0, 0), new(9.5f, 0, 16), new(10.5f, 0, 16), new(10.5f, 0, 0), // bottom + new(9.5f, 3, 0), new(9.5f, 3, 16), new(10.5f, 3, 16), new(10.5f, 3, 0), // top + ]; + int[] wallIndices = + [ + 4, 5, 6, 4, 6, 7, // top face (up-facing, but too high to connect: walkable island) + 0, 6, 5, 0, 7, 6, // sides via bottom ring (windings vary; solidity is what matters) + 0, 5, 1, 0, 4, 5, + 1, 6, 2, 1, 5, 6, + 2, 7, 3, 2, 6, 7, + 3, 4, 0, 3, 7, 4, + ]; + + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), + [ + Quad(20, 20, Float3.Zero), + new NavMeshGeometrySource(wallVerts, wallIndices, Float4x4.Identity), + ]); + Assert.NotNull(data); + Assert.NotNull(world.AddNavMeshData(data!)); + return world; + } + + [Fact] + public void CalculatePath_AroundWall_IsCompleteAndDetours() + { + NavMeshWorld world = BuildUShapeWorld(); + var path = new NavMeshPath(); + + // Straight across the wall at z=8: must detour via the z>16 passage. + bool found = world.CalculatePath(new Float3(5, 0, 8), new Float3(15, 0, 8), NavMesh.AllAreas, path); + + Assert.True(found); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + Assert.True(path.CornerCount >= 3, $"A detour needs intermediate corners, got {path.CornerCount}."); + + // The detour must pass beyond the wall's far end (z > 16 - some slack for corner cutting). + double maxZ = 0; + foreach (Float3 corner in path.Corners) + maxZ = System.Math.Max(maxZ, corner.Z); + Assert.True(maxZ > 14.0, $"Path should route around the wall end (max corner z was {maxZ:0.0})."); + + // Path length must be well above the straight-line distance of 10. + double length = 0; + Float3[] corners = path.Corners; + for (int i = 1; i < corners.Length; i++) + length += Float3.Distance(corners[i - 1], corners[i]); + Assert.True(length > 14.0, $"Detour should be much longer than the 10-unit straight line, got {length:0.0}."); + } + + [Fact] + public void CalculatePath_ToDisconnectedIsland_IsPartial() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), + [ + Quad(10, 10, Float3.Zero), + Quad(10, 10, new Float3(30, 0, 0)), // 20-unit gap: unreachable + ]); + Assert.NotNull(data); + world.AddNavMeshData(data!); + + var path = new NavMeshPath(); + bool found = world.CalculatePath(new Float3(5, 0, 5), new Float3(35, 0, 5), NavMesh.AllAreas, path); + + Assert.True(found, "A partial path to the closest reachable point should still be returned."); + Assert.Equal(NavMeshPathStatus.PathPartial, path.Status); + + // The partial path must end on the first island, not teleport across the gap. + Float3 last = path.Corners[path.CornerCount - 1]; + Assert.True(last.X <= 10.5f, $"Partial path leaked off its island (end x = {last.X:0.0})."); + } + + [Fact] + public void SamplePosition_SnapsToFloor_AndRespectsMaxDistance() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]); + world.AddNavMeshData(data!); + + // 1.5 units above the floor: inside a 2-unit radius, outside a 0.5-unit radius. + Assert.True(world.SamplePosition(new Float3(5, 1.5f, 5), out NavMeshHit hit, 2f, NavMesh.AllAreas)); + Assert.True(hit.Hit); + Assert.True(System.Math.Abs(hit.Position.Y) < 0.3f, $"Sample should land on the floor, got y={hit.Position.Y:0.00}."); + Assert.True(System.Math.Abs(hit.Position.X - 5) < 0.3f); + + Assert.False(world.SamplePosition(new Float3(5, 1.5f, 5), out _, 0.5f, NavMesh.AllAreas)); + } + + [Fact] + public void Raycast_AcrossFloor_ClearAndBlocked() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]); + world.AddNavMeshData(data!); + + // Within the floor: unobstructed. + Assert.False(world.Raycast(new Float3(2, 0, 5), new Float3(8, 0, 5), out NavMeshHit clear, NavMesh.AllAreas)); + Assert.False(clear.Hit); + + // Off the edge: blocked at the border. + Assert.True(world.Raycast(new Float3(5, 0, 5), new Float3(25, 0, 5), out NavMeshHit blocked, NavMesh.AllAreas)); + Assert.True(blocked.Hit); + Assert.True(blocked.Position.X < 10.5f, $"Blocked ray should stop at the mesh border, got x={blocked.Position.X:0.0}."); + } + + [Fact] + public void FindClosestEdge_ReturnsBorder() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]); + world.AddNavMeshData(data!); + + Assert.True(world.FindClosestEdge(new Float3(5, 0, 5), out NavMeshHit hit, NavMesh.AllAreas)); + Assert.True(hit.Hit); + // From the center of a 10x10 eroded floor, the nearest border is a few units away. + Assert.InRange(hit.Distance, 1f, 6f); + } + + [Fact] + public void AreaMask_ExcludingArea_BlocksPath() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)], defaultArea: 3); + world.AddNavMeshData(data!); + + var path = new NavMeshPath(); + // Mask that excludes area 3: nothing is traversable. + int maskWithout3 = ~(1 << 3); + Assert.False(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), maskWithout3, path)); + Assert.Equal(NavMeshPathStatus.PathInvalid, path.Status); + + // Including it works. + Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + [Fact] + public void Queries_WithNoNavMesh_ReturnFalse() + { + var world = new NavMeshWorld(); + var path = new NavMeshPath(); + + Assert.False(world.CalculatePath(Float3.Zero, new Float3(1, 0, 1), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathInvalid, path.Status); + Assert.False(world.SamplePosition(Float3.Zero, out _, 1f, NavMesh.AllAreas)); + Assert.False(world.Raycast(Float3.Zero, new Float3(1, 0, 1), out _, NavMesh.AllAreas)); + Assert.False(world.TryRentQuery(out _)); + } + + [Fact] + public void ParallelQueries_AreSafe() + { + NavMeshWorld world = BuildUShapeWorld(); + + System.Threading.Tasks.Parallel.For(0, 64, i => + { + var path = new NavMeshPath(); + var from = new Float3(2 + (i % 7), 0, 2 + (i % 11)); + var to = new Float3(18 - (i % 5), 0, 3 + (i % 13)); + bool found = world.CalculatePath(from, to, NavMesh.AllAreas, path); + Assert.True(found, $"Query {i} from {from} to {to} failed."); + Assert.True(world.SamplePosition(from, out _, 2f, NavMesh.AllAreas)); + }); + } + + [Fact] + public void MutateTileCache_DrainsPoolAndKeepsWorking() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]); + NavMeshInstance? instance = world.AddNavMeshData(data!); + Assert.NotNull(instance); + + var path = new NavMeshPath(); + Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + + bool mutated = false; + world.MutateTileCache(instance!, _ => mutated = true); + Assert.True(mutated); + + // Queries still work after the pool was invalidated. + Assert.True(world.CalculatePath(new Float3(2, 0, 2), new Float3(8, 0, 8), NavMesh.AllAreas, path)); + Assert.Equal(NavMeshPathStatus.PathComplete, path.Status); + } + + /// + /// An instance's lock holds wait handles that only Dispose releases, so unregistering has to + /// dispose it — but a query rented a moment earlier is still inside it, and disposing under + /// one throws on that thread instead of ours. Removal drops its own hold and bars new + /// queries; the last query out is what actually disposes. + /// + [Fact] + public void RemovingANavMesh_DisposesItsLockOnceTheLastQueryIsDone() + { + var world = new NavMeshWorld(); + NavMeshData? data = NavMeshBuilder.Build(TestSettings(), [Quad(10, 10, Float3.Zero)]); + NavMeshInstance instance = world.AddNavMeshData(data!)!; + + Assert.True(instance.TryAcquire()); // stands in for a query in flight on another thread + + world.RemoveNavMeshData(instance); + Assert.False(instance.TryAcquire(), "An unregistered navmesh must not admit new queries."); + + instance.Lock.EnterReadLock(); // the in-flight query still has a lock to finish under + instance.Lock.ExitReadLock(); + + instance.Release(); + Assert.Throws(instance.Lock.EnterReadLock); + } +} diff --git a/Prowl.Runtime.Test/RuntimeTestBase.cs b/Prowl.Runtime.Test/RuntimeTestBase.cs index 3cf9e0988..87c7f87c3 100644 --- a/Prowl.Runtime.Test/RuntimeTestBase.cs +++ b/Prowl.Runtime.Test/RuntimeTestBase.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See the LICENSE file in the project root for details. using Prowl.Runtime.Resources; +using Prowl.Vector; namespace Prowl.Runtime.Test; @@ -47,6 +48,34 @@ protected RuntimeTestBase() /// The fixed timestep used by physics, mirrored from . protected static float FixedDeltaTime => Time.FixedDeltaTime; + /// + /// Runs the block as an open editor with play mode off, so only components marked + /// receive gameplay callbacks. Build the scene inside + /// the scope: enabling one decides there and then which components get OnEnable, and a + /// scene assembled in play mode has already had them. + /// + protected static EditModeScope EditMode() => new(); + + protected readonly struct EditModeScope : IDisposable + { + private readonly bool _wasPlaying; + private readonly bool _wasEditor; + + public EditModeScope() + { + _wasPlaying = Application.IsPlaying; + _wasEditor = Application.IsEditor; + Application.IsPlaying = false; + Application.IsEditor = true; + } + + public void Dispose() + { + Application.IsPlaying = _wasPlaying; + Application.IsEditor = _wasEditor; + } + } + /// /// Creates a tracked scene. Pass to immediately enable it (play mode), /// otherwise it starts disabled so component OnEnable is deferred until . @@ -103,6 +132,49 @@ protected void StepPhysics(Scene scene, int steps = 1) } } + /// Coarse voxels and small tiles, so navmesh bakes in tests stay fast. + protected static void ApplyFastBakeSettings(NavMeshSurface surface) + { + surface.BuildOverrides.OverrideVoxelSize = true; + surface.BuildOverrides.VoxelSize = 0.25f; + surface.BuildOverrides.OverrideTileSize = true; + surface.BuildOverrides.TileSize = 64; + surface.UseGeometry = NavMeshCollectGeometry.PhysicsColliders; + } + + /// A flat collider floor with its top surface at y=0, plus a surface configured + /// for a fast bake. Pass to bake and register it immediately. + protected (Scene scene, NavMeshSurface surface) CreateFloorScene(float size = 20f, bool bake = false) + { + Scene scene = CreateScene(enable: true); + + GameObject floor = CreateGameObject("Floor"); + scene.Add(floor); + floor.AddComponent().Size = new Float3(size, 1, size); + floor.Transform.Position = new Float3(0, -0.5f, 0); + + GameObject surfaceGo = CreateGameObject("NavMeshSurface"); + scene.Add(surfaceGo); + var surface = surfaceGo.AddComponent(); + ApplyFastBakeSettings(surface); + if (bake && !surface.BuildNavMesh()) + throw new InvalidOperationException("CreateFloorScene: the floor bake produced no walkable geometry."); + + return (scene, surface); + } + + /// Tick until holds — incremental cache updates + /// process a bounded slice per frame. Returns the ticks taken, or -1 on timeout. + protected int TickUntil(Scene scene, Func condition, int maxTicks = 240) + { + for (int i = 0; i < maxTicks; i++) + { + if (condition()) return i; + Tick(scene, 1); + } + return condition() ? maxTicks : -1; + } + public virtual void Dispose() { // DontDestroyOnLoad is static state, so anything a test preserved would still be in the diff --git a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs new file mode 100644 index 000000000..a60529398 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs @@ -0,0 +1,828 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.Crowd; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Obstacle avoidance quality for a . Member names match +/// Unity's for migration; the inspector shows the friendly display names. +public enum ObstacleAvoidanceType +{ + [InspectorName("None")] + NoObstacleAvoidance = 0, + [InspectorName("Low")] + LowQualityObstacleAvoidance = 1, + [InspectorName("Medium")] + MedQualityObstacleAvoidance = 2, + [InspectorName("Good")] + GoodQualityObstacleAvoidance = 3, + [InspectorName("High")] + HighQualityObstacleAvoidance = 4, +} + +/// State of the off-mesh link a is traversing. +public readonly struct OffMeshLinkData +{ + /// True while the agent is on an off-mesh connection. + public readonly bool Valid; + + /// World-space start of the traversal. + public readonly Float3 StartPos; + + /// World-space end of the traversal. + public readonly Float3 EndPos; + + /// The component the connection came from, or null + /// (connection baked without a link id, or the component is gone). + public readonly NavMeshLink? Link; + + internal OffMeshLinkData(bool valid, Float3 startPos, Float3 endPos, NavMeshLink? link) + { + Valid = valid; + StartPos = startPos; + EndPos = endPos; + Link = link; + } +} + +/// +/// Moves a character along the navmesh using crowd simulation: give it a +/// (or call ) and it steers there, +/// avoiding other agents. Mirrors Unity's NavMeshAgent API. The agent joins the scene's crowd +/// when a navmesh for its is available and writes its position back +/// to the Transform each LateUpdate (disable to drive a +/// Rigidbody or CharacterController from yourself). +/// +[AddComponentMenu("Navigation/NavMesh Agent")] +[ComponentIcon("")] // Person Walking +public class NavMeshAgent : MonoBehaviour +{ + [Header("Agent")] + [Tooltip("The agent type whose navmesh this agent walks on.")] + [NavMeshAgentType] + public int AgentTypeId = NavMeshAgentTypes.Humanoid; + + [Tooltip("Agent radius for avoidance and crowd separation.")] + public float Radius = 0.5f; + + [Tooltip("Agent height (used by the crowd for vertical overlap checks).")] + public float Height = 2.0f; + + [Tooltip("Vertical offset between the navmesh surface and the Transform position.")] + public float BaseOffset = 0f; + + [Header("Steering")] + [Tooltip("Maximum movement speed in world units/second.")] + public float Speed = 3.5f; + + [Tooltip("Maximum turning speed in degrees/second, applied when UpdateRotation is on.")] + public float AngularSpeed = 120f; + + [Tooltip("Maximum acceleration in world units/second².")] + public float Acceleration = 8f; + + [Tooltip("Stop this far short of the destination.")] + public float StoppingDistance = 0f; + + [Tooltip("Decelerate to a stop as the destination is approached instead of overshooting.")] + public bool AutoBraking = true; + + [Header("Obstacle Avoidance")] + [Tooltip("Avoidance quality: higher avoids more reliably and costs more CPU.")] + [InspectorName("Quality")] + public ObstacleAvoidanceType ObstacleAvoidanceQuality = ObstacleAvoidanceType.MedQualityObstacleAvoidance; + + [Tooltip("Agents with lower priority values are avoided by agents with higher values (0 = most important, 99 = least). Mapped to crowd separation weight.")] + [Range(0, 99)] + public int AvoidancePriority = 50; + + [Tooltip("Push away from nearby agents (crowd separation). Disable when units should pack tightly or walk single file through corridors.")] + public bool Separation = true; + + [Tooltip("How far steering scans for neighbours and navmesh borders, in world units. 0 derives it from the radius (radius x 12, the open-level default). On tile maps, ranges wider than the corridors keep the borders inside view in every direction and make avoidance oscillate - tune this down toward the corridor width.")] + public float CollisionQueryRange = 0f; + + [Tooltip("Path visibility optimization range, in world units. 0 derives it from the radius (radius x 30).")] + public float PathOptimizationRange = 0f; + + [Header("Pathfinding")] + [Tooltip("Areas this agent may traverse.")] + [NavMeshAreaMask] + public int AreaMask = NavMeshAreas.AllAreas; + + [Tooltip("Automatically re-path when the navmesh changes under the current path.")] + public bool AutoRepath = true; + + [Tooltip("Write the crowd position to the Transform each frame.")] + public bool UpdatePosition = true; + + [Tooltip("Rotate the Transform to face the movement direction.")] + public bool UpdateRotation = true; + + private NavMeshWorld? _world; + private DtCrowdAgent? _agent; + // The crowd _agent belongs to. Captured at registration because the world's crowd can be + // replaced when the navmesh is swapped (rebake/regenerate) — a stale _agent must be + // detached against ITS crowd, never the current one. + private DtCrowd? _crowd; + // The crowd entry _agent registered with, for filter-slot bookkeeping (same capture + // rationale as _crowd). + private NavMeshCrowdEntry? _crowdEntry; + // The crowd filter slot this agent steers with, and the AreaMask baked into it — the mask + // is re-checked each LateUpdate so writing the AreaMask field "just works" like Unity. + private int _filterSlot; + private int _slotAreaMask = NavMeshAreas.AllAreas; + // The agent type this agent registered under, re-checked each LateUpdate so writing the + // AgentTypeId field re-places the agent on its new type's crowd (Unity does the same). + private int _registeredAgentTypeId; + private NavMeshQueryFilter? _filter; + private Float3 _destination; + private bool _hasDestination; + private bool _isStopped; + private bool _arrived; + + /// Whether obstacle avoidance is currently switched on for this agent. Starts on, so + /// a freshly added agent avoids until a crowd step proves there is nothing in range. + internal bool AvoidanceEngaged = true; + + // Corner-window distance below which a path counts as arrived when StoppingDistance is 0. + private const float ArrivalEpsilon = 0.05f; + + /// The raw crowd agent, while registered. Advanced use. + public DtCrowdAgent? NativeAgent => _agent; + + /// True while the agent is registered on a navmesh crowd. + public bool IsOnNavMesh => _agent != null; + + /// The query filter this agent paths with (area mask + agent type). Mutating its + /// costs directly (agent.Filter.SetAreaCost(...)) affects explicit queries only — + /// crowd STEERING keeps the old cost table until the next refresh. Use + /// (or call after) to apply costs to + /// both. + public NavMeshQueryFilter Filter + { + get + { + _filter ??= new NavMeshQueryFilter(); + _filter.AreaMask = AreaMask; + _filter.AgentTypeId = AgentTypeId; + return _filter; + } + } + + #region Destination / movement state + + /// Set or get the movement target. Setting it requests a new path. + public Float3 Destination + { + get => _hasDestination ? _destination : NextPosition; + set => SetDestination(value); + } + + /// True while a requested path is still being computed by the crowd's path queue. + public bool PathPending => _agent != null && + _agent.targetState is DtMoveRequestState.DT_CROWDAGENT_TARGET_REQUESTING + or DtMoveRequestState.DT_CROWDAGENT_TARGET_WAITING_FOR_QUEUE + or DtMoveRequestState.DT_CROWDAGENT_TARGET_WAITING_FOR_PATH; + + /// True when the agent has a path it is following. + public bool HasPath => _agent != null && _agent.targetState == DtMoveRequestState.DT_CROWDAGENT_TARGET_VALID; + + /// Status of the current path. + public NavMeshPathStatus PathStatus + { + get + { + if (_agent == null || _agent.targetState == DtMoveRequestState.DT_CROWDAGENT_TARGET_FAILED) + return NavMeshPathStatus.PathInvalid; + return _agent.partial ? NavMeshPathStatus.PathPartial : NavMeshPathStatus.PathComplete; + } + } + + /// True while the agent is traversing an off-mesh link (the crowd animates the + /// hop; traversal is always automatic). + public bool IsOnOffMeshLink => _agent != null && _agent.state == DtCrowdAgentState.DT_CROWDAGENT_STATE_OFFMESH; + + /// The off-mesh traversal in progress (Valid false while walking normally). + /// Resolves back to the component via the id stamped at bake. + public OffMeshLinkData CurrentOffMeshLinkData + { + get + { + if (!IsOnOffMeshLink || _agent!.animation == null || !_agent.animation.active) + return default; + DtCrowdAgentAnimation anim = _agent.animation; + return new OffMeshLinkData(true, ToFloat3(anim.startPos), ToFloat3(anim.endPos), ResolveLink(anim.polyRef)); + } + } + + /// The link component behind an off-mesh connection poly, via the user id + /// stamped at bake time. + private NavMeshLink? ResolveLink(long polyRef) + { + NavMeshInstance? instance = _world?.GetInstance(_registeredAgentTypeId); + if (instance == null) return null; + if (instance.NativeNavMesh.GetTileAndPolyByRef(polyRef, out DtMeshTile tile, out DtPoly poly).Failed()) + return null; + var cons = tile?.data?.offMeshCons; + if (cons == null) return null; + foreach (DtOffMeshConnection con in cons) + if (ReferenceEquals(tile!.data.polys[con.poly], poly)) + return _world!.FindLink(con.userId); + return null; + } + + /// Current velocity of the agent in the crowd simulation. + public Float3 Velocity => _agent != null ? ToFloat3(_agent.vel) : Float3.Zero; + + /// The velocity the agent wants (path steering before avoidance/acceleration limits). + /// Drive a Rigidbody or CharacterController from this when is off. + public Float3 DesiredVelocity => _agent != null ? ToFloat3(_agent.dvel) : Float3.Zero; + + /// The agent's position in the crowd simulation (before ). + public Float3 NextPosition => _agent != null ? ToFloat3(_agent.npos) : Transform.Position; + + /// The next corner the agent is steering toward. + public Float3 SteeringTarget => _agent != null && _agent.ncorners > 0 ? ToFloat3(_agent.corners[0].pos) : NextPosition; + + /// + /// Distance to the end of the current path along its corners. Infinity while no path is + /// available. When the path's visible corner window doesn't yet reach the destination this + /// is a lower bound (matches Unity's remainingDistance semantics closely enough for + /// arrival checks against ). + /// + public float RemainingDistance + { + get + { + if (_agent == null) return float.PositiveInfinity; + if (_arrived) return 0f; + if (!HasPath) return float.PositiveInfinity; + // Mid-hop the corner window is empty and would read 0 — falsely "arrived" for the + // Unity idiom. The honest lower bound is remaining hop distance PLUS the path after + // landing: the hop distance alone collapses to ~0 as the animation lands, which + // would make waypoint scripts issue their next destination mid-hop and ping-pong. + if (IsOnOffMeshLink && _agent.animation is { active: true } anim) + { + Float3 landing = ToFloat3(anim.endPos); + return (float)(Float3.Distance(ToFloat3(_agent.npos), landing) + + Float3.Distance(landing, ToFloat3(_agent.targetPos))); + } + // An empty corner window is also transient right after a hop lands (corners not + // recomputed until the next crowd update) — measure straight to the target rather + // than trusting 0. + if (_agent.ncorners == 0) + return (float)Float3.Distance(ToFloat3(_agent.npos), ToFloat3(_agent.targetPos)); + return CornerWindowDistance(); + } + } + + private float CornerWindowDistance() + { + if (_agent == null || _agent.ncorners == 0) return 0f; + + float total = 0f; + RcVec3f prev = _agent.npos; + for (int i = 0; i < _agent.ncorners; i++) + { + total += RcVec3f.Distance(prev, _agent.corners[i].pos); + prev = _agent.corners[i].pos; + } + return total; + } + + /// Stop (true) or resume (false) movement. Resuming re-requests the last + /// destination. Matches Unity: while stopped remembers the + /// target but does NOT clear the stopped state — movement resumes only when this is set + /// back to false. + public bool IsStopped + { + get => _isStopped; + set + { + if (_isStopped == value) return; + _isStopped = value; + if (_agent == null) return; + + if (value) + _crowd?.ResetMoveTarget(_agent); + else if (_hasDestination && !_arrived) + RequestPathTo(_destination); + } + } + + #endregion + + #region Lifecycle + + public override void OnEnable() + { + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return; + + _world = scene!.Navigation; + _world.NavMeshChanged += OnNavMeshChanged; + _world.NavMeshSettled += OnNavMeshSettled; + TryRegister(); + } + + public override void OnDisable() + { + if (_world != null) + { + _world.NavMeshChanged -= OnNavMeshChanged; + _world.NavMeshSettled -= OnNavMeshSettled; + Unregister(); + _world = null; + } + } + + private void OnNavMeshChanged() + { + // Our crowd may have been dropped with its navmesh (rebake/regenerate); our crowd agent + // and filter slot died with it, so forget both and fall through to re-registration + // (which re-requests the remembered destination). MUST compare against + // _registeredAgentTypeId, not AgentTypeId: if gameplay rewrote the field before the + // LateUpdate drift check runs, the registered type's crowd is still alive and still + // contains our agent — forgetting it here would strand a ghost agent and leak its + // filter-slot refcount. Type changes are handled only by the drift check. + if (_agent != null && _world != null && !ReferenceEquals(_world.GetNativeCrowd(_registeredAgentTypeId), _crowd)) + { + _agent = null; + _crowd = null; + _crowdEntry = null; + _filterSlot = 0; + } + + if (_agent == null) + { + // A navmesh may have just become available. Re-registering re-requests the + // destination, so there is no replan to do here. + TryRegister(); + } + } + + /// The ground stopped moving: replan once. Carves span several frames and report a + /// change on each, so replanning from that would throw the path away every frame of one. + private void OnNavMeshSettled() + { + if (_agent != null && AutoRepath && _hasDestination && !_isStopped && !_arrived) + RequestPathTo(_destination); + } + + private void TryRegister() + { + if (_agent != null || _world == null) return; + + NavMeshInstance? instance = _world.GetInstance(AgentTypeId); + if (instance == null) return; + + if (Radius > _world.CrowdMaxAgentRadius) + Debug.LogWarning($"[Navigation] Agent '{GameObject.Name}' radius {Radius:0.##} exceeds NavMeshWorld.CrowdMaxAgentRadius ({_world.CrowdMaxAgentRadius:0.##}); crowd proximity queries assume the smaller value. Raise CrowdMaxAgentRadius before the first agent registers."); + + NavMeshCrowdEntry entry = _world.EnsureCrowd(instance); + _crowdEntry = entry; + _registeredAgentTypeId = AgentTypeId; + _slotAreaMask = AreaMask; + _filterSlot = entry.AcquireFilterSlot(AreaMask, _filter?.CostOverrides, GameObject.Name); + _agent = entry.Crowd.AddAgent(ToRc(Transform.Position - new Float3(0, BaseOffset, 0)), BuildAgentParams()); + _crowd = entry.Crowd; + if (_hasDestination && !_isStopped && !_arrived) + RequestPathTo(_destination); + } + + private void Unregister() + { + if (_agent == null) return; + _crowd?.RemoveAgent(_agent); + // Releasing into an entry the world already dropped is a harmless no-op. + _crowdEntry?.ReleaseFilterSlot(_filterSlot); + _agent = null; + _crowd = null; + _crowdEntry = null; + _filterSlot = 0; + } + + private DtCrowdAgentParams BuildAgentParams() + { + int updateFlags = DtCrowdAgentUpdateFlags.DT_CROWD_ANTICIPATE_TURNS + | DtCrowdAgentUpdateFlags.DT_CROWD_OPTIMIZE_VIS + | DtCrowdAgentUpdateFlags.DT_CROWD_OPTIMIZE_TOPO; + if (Separation) + updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_SEPARATION; + if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance && AvoidanceEngaged) + updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE; + + float radius = Math.Max(0.01f, Radius); + return new DtCrowdAgentParams + { + radius = radius, + height = Math.Max(0.01f, Height), + maxAcceleration = Acceleration, + maxSpeed = Speed, + collisionQueryRange = CollisionQueryRange > 0f ? CollisionQueryRange : radius * 12f, + pathOptimizationRange = PathOptimizationRange > 0f ? PathOptimizationRange : radius * 30f, + updateFlags = updateFlags, + obstacleAvoidanceType = Math.Max(0, (int)ObstacleAvoidanceQuality - 1), + // Unity priority 0 (most important) pushes hardest; map to separation weight 0.5..3. + separationWeight = 0.5f + 2.5f * (1f - AvoidancePriority / 99f), + queryFilterType = _filterSlot, + userData = this, + }; + } + + /// Push current inspector values (speed, radius, avoidance, area mask/costs ...) + /// into the live crowd agent. Called automatically on validate, on + /// , and when the field changes; call + /// manually after changing other fields from code mid-simulation. + public void RefreshParams() + { + if (_agent == null || _crowd == null) return; + + // Re-derive the steering filter slot: release-then-acquire, so a config only this + // agent used frees its slot before (typically) being retaken with the new values. + if (_crowdEntry != null) + { + _crowdEntry.ReleaseFilterSlot(_filterSlot); + _slotAreaMask = AreaMask; + _filterSlot = _crowdEntry.AcquireFilterSlot(AreaMask, _filter?.CostOverrides, GameObject.Name); + } + + _crowd.UpdateAgentParameters(_agent, BuildAgentParams()); + } + + public override void OnValidate() => RefreshParams(); + + #endregion + + #region Commands + + /// + /// Override the path cost of an area for THIS agent (explicit queries and crowd steering + /// both). Clamped to >= 1 — see ; to prefer an + /// area, raise the other areas' costs instead. Unity API parity. + /// + public void SetAreaCost(int areaIndex, float cost) + { + Filter.SetAreaCost(areaIndex, cost); + RefreshParams(); // re-derive the crowd filter slot with the new cost table + } + + /// The path cost this agent pays in an area: its own override, or the project + /// default. + public float GetAreaCost(int areaIndex) => Filter.GetAreaCost(areaIndex); + + /// Request a path to . Returns false when the agent is + /// not on a navmesh or the target cannot be mapped onto it. A stopped agent + /// () remembers the destination but stays halted until resumed — + /// Unity semantics, where isStopped is a pause flag that survives new destinations. + public bool SetDestination(Float3 target) + { + _destination = target; + _hasDestination = true; + _arrived = false; + if (_agent == null || _isStopped) return false; // remembered; requested on registration/resume + return RequestPathTo(target); + } + + private bool RequestPathTo(Float3 target) + { + if (_agent == null || _world == null) return false; + DtCrowd? crowd = _crowd; + if (crowd == null) return false; + + if (!_world.TryRentQuery(out NavMeshQueryLease lease, AgentTypeId)) return false; + using (lease) + { + lease.Query.FindNearestPoly(ToRc(target), crowd.GetQueryExtents(), Filter, out long polyRef, out RcVec3f nearest, out _); + if (polyRef == 0) return false; + return crowd.RequestMoveTarget(_agent, polyRef, nearest); + } + } + + /// + /// Follow a pre-calculated path, steering along the route it describes rather than re-planning + /// one to its endpoint. The path must come from (or + /// ) and start where + /// the agent is standing; the crowd still re-plans later if the navmesh invalidates it. + /// + /// False if the path is unusable, or does not begin at the agent's current polygon. + public bool SetPath(NavMeshPath path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Status == NavMeshPathStatus.PathInvalid || path.CornerCount == 0) return false; + if (_agent == null || _crowd == null) return false; + + Span polys = path.Polys; + if (polys.Length == 0) return false; + + // The corridor must continue from where the agent stands, not teleport to wherever the + // path was computed from. + if (polys[0] != _agent.corridor.GetFirstPoly()) return false; + + Float3 destination = path.LastCorner; + if (!_crowd.SetAgentPath(_agent, polys[^1], ToRc(destination), polys, polys.Length)) + return false; + + _destination = destination; + _hasDestination = true; + _arrived = false; + return true; + } + + /// Clear the current path and destination without unregistering. + public void ResetPath() + { + _hasDestination = false; + _arrived = false; + if (_agent != null) + _crowd?.ResetMoveTarget(_agent); + } + + /// Teleport the agent (and Transform) to a position on the navmesh. Keeps the + /// current destination. + public bool Warp(Float3 newPosition) + { + DtCrowd? crowd = _crowd; + if (_world == null || _agent == null || crowd == null) + { + // The Transform moves, but with no crowd agent nothing snapped it to the mesh and + // there is no one to ask whether it landed on any. + Transform.Position = newPosition + new Float3(0, BaseOffset, 0); + return false; + } + + // Detour has no teleport: re-add the agent at the new position. + crowd.RemoveAgent(_agent); + _agent = crowd.AddAgent(ToRc(newPosition), BuildAgentParams()); + + // A teleport is a fresh approach, so a previous arrival would otherwise park the agent + // wherever it landed. + _arrived = false; + if (_hasDestination && !_isStopped) + RequestPathTo(_destination); + + // Use the position the crowd snapped to: the requested one can be off the mesh. + Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0); + return true; + } + + /// + /// Displace the agent by a world-space offset, constrained to the navmesh — the per-frame API + /// for driving an agent yourself. Slides the path corridor along, keeping the path, boundary + /// cache and neighbour set; use to jump somewhere unrelated, which rebuilds + /// all of that. + /// + public void Move(Float3 offset) + { + if (_agent == null || _world == null) return; + if (!_world.TryRentQuery(out NavMeshQueryLease lease, AgentTypeId)) return; + + using (lease) + { + RcVec3f target = ToRc(NextPosition + offset); + _agent.corridor.MovePosition(target, lease.Query, Filter); + _agent.npos = _agent.corridor.GetPos(); + } + + if (UpdatePosition) + Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0); + } + + /// Calculate a path from the agent's position with the agent's filter, without + /// moving the agent. + public bool CalculatePath(Float3 targetPosition, NavMeshPath path) + => _world?.CalculatePath(NextPosition, targetPosition, Filter, path) ?? false; + + /// Navmesh raycast from the agent's position with the agent's filter. + public bool Raycast(Float3 targetPosition, out NavMeshHit hit) + { + if (_world != null) return _world.Raycast(NextPosition, targetPosition, out hit, Filter); + hit = default; + return false; + } + + /// Closest navmesh edge from the agent's position with the agent's filter. + public bool FindClosestEdge(out NavMeshHit hit) + { + if (_world != null) return _world.FindClosestEdge(NextPosition, out hit, Filter); + hit = default; + return false; + } + + /// Find the closest navmesh point within of a + /// position, using the agent's filter. + public bool SamplePosition(Float3 sourcePosition, float maxDistance, out NavMeshHit hit) + { + if (_world != null) return _world.SamplePosition(sourcePosition, out hit, maxDistance, Filter); + hit = default; + return false; + } + + #endregion + + public override void LateUpdate() + { + if (_agent == null) + { + // Deliberate belt-and-braces: NavMeshChanged already covers late registration, but + // a subscription can be lost across domain edge cases (component re-enable racing a + // world swap), and this retry is nearly free while unregistered. + TryRegister(); + return; + } + + // Public fields can be written directly (Unity-style), so cheap per-frame int compares + // keep the crowd in sync without requiring RefreshParams calls: a changed AgentTypeId + // re-places the agent on its new type's crowd (keeping its destination), a changed + // AreaMask re-derives the steering filter slot. Cost overrides go through + // SetAreaCost, which refreshes itself. + if (AgentTypeId != _registeredAgentTypeId) + { + Unregister(); + TryRegister(); + if (_agent == null) return; + } + if (AreaMask != _slotAreaMask) + RefreshParams(); + + // Velocity-obstacle sampling is the expensive half of a crowd step, and it picks from a + // DISCRETE set of candidate velocities, so running it with nothing in range still rounds + // the result and walks the agent sideways off a straight line. Skip it only when nothing + // is in range at all — that includes navmesh boundary segments, so an agent alone beside + // a wall still has the wall to keep off. Both are read from the last crowd step, so + // engaging lags a frame, which is fine at the metres-out range they're gathered from. + if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance) + { + bool engage = _agent.nneis > 0 || _agent.boundary.GetSegmentCount() > 0; + if (engage != AvoidanceEngaged) + { + AvoidanceEngaged = engage; + _crowd?.UpdateAgentParameters(_agent, BuildAgentParams()); + } + } + + if (UpdatePosition) + Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0); + + if (UpdateRotation) + { + // Face where the agent STEERS, not where it moves: actual velocity carries avoidance + // corrections that don't shrink with speed, so braking into a goal lets them take + // over its direction and the agent shivers along a dead-straight path. The two gates + // below stop the heading chasing a vector that no longer means anything — one too + // slow to have a direction, one pointing at a target already underfoot, where + // following it would just spin the agent in place. + const double MinFacingSpeedSq = 0.01; // 0.1 m/s + Float3 face = ToFloat3(_agent.dvel); + double speedSq = face.X * face.X + face.Z * face.Z; + if (speedSq < MinFacingSpeedSq) + { + // Off-mesh hops: the crowd empties the steering vector and animates the agent + // across, so the actual velocity is the only heading available — and during a + // hop it is a clean straight line, with no avoidance running. + face = ToFloat3(_agent.vel); + speedSq = face.X * face.X + face.Z * face.Z; + } + // RemainingDistance walks the corner window, so only ask once the cheap gate passed. + if (speedSq > MinFacingSpeedSq && !(HasPath && !PathPending && RemainingDistance <= Radius)) + { + // Quaternion rotate-towards, no Euler round-trip: Quaternion.FromEuler is in + // degrees while Maths.DeltaAngle wraps in radians — never mix the two. + float targetYaw = MathF.Atan2((float)face.X, (float)face.Z) * Maths.Rad2Deg; + Quaternion targetRotation = Quaternion.FromEuler(new Float3(0, targetYaw, 0)); + Transform.Rotation = RotateTowards(Transform.Rotation, targetRotation, AngularSpeed * Time.DeltaTime); + } + } + + UpdateArrival(); + } + + /// + /// Arrival detection, independent of how the agent approaches (braking is HOW it arrives, + /// this is WHETHER it has): once the corner window closes to within the stopping distance + /// (or the agent has braked to a stop inside its own radius of the goal), the move target + /// is released and reads exactly 0, so the Unity-style + /// "!PathPending && RemainingDistance <= StoppingDistance" idiom terminates. + /// + private void UpdateArrival() + { + // IsOnOffMeshLink: the crowd empties the corner window during a hop, which reads as + // "corridor consumed" below — latching there resets the move target mid-traversal and + // strands the agent at the link mouth. + if (_agent == null || _arrived || _isStopped || !HasPath || PathPending || IsOnOffMeshLink) return; + + // The corner window is a LOWER bound (at most the crowd's few visible corners), so its + // distance only means "arrived" once the window reaches the path end — otherwise a tight + // switchback under StoppingDistance, or a congestion-jammed agent, could falsely latch. + // An EMPTY window is also untrustworthy: it happens both standing on the target and + // transiently right after a hop lands, so measure straight to the target instead. + float remaining; + if (_agent.ncorners == 0) + { + remaining = (float)Float3.Distance(ToFloat3(_agent.npos), ToFloat3(_agent.targetPos)); + } + else + { + if ((_agent.corners[_agent.ncorners - 1].flags & DtStraightPathFlags.DT_STRAIGHTPATH_END) == 0) + return; + remaining = CornerWindowDistance(); + } + float threshold = MathF.Max(ArrivalEpsilon, StoppingDistance); + + Float3 vel = ToFloat3(_agent.vel); + float horizontalSpeed = MathF.Sqrt((float)(vel.X * vel.X + vel.Z * vel.Z)); + // Auto-braking converges asymptotically, so also latch when the agent has effectively + // stopped within its own radius of the (visible) goal. + bool braked = remaining <= MathF.Max(0.1f, Radius) && horizontalSpeed <= 0.05f * MathF.Max(0.01f, Speed); + + if (remaining <= threshold || braked) + { + // _hasDestination stays true: Unity keeps agent.destination readable after + // arrival, and migrated code does read it. _arrived gates every re-path site. + _arrived = true; + _crowd?.ResetMoveTarget(_agent); + } + } + + private static Quaternion RotateTowards(Quaternion from, Quaternion to, float maxDegrees) + { + float dot = Math.Clamp(MathF.Abs(Quaternion.Dot(from, to)), 0f, 1f); + float angleDeg = 2f * MathF.Acos(dot) * Maths.Rad2Deg; + if (angleDeg <= maxDegrees || angleDeg < 1e-4f) return to; + return Quaternion.Slerp(from, to, maxDegrees / angleDeg); + } + + /// + /// The agent's steering envelope: the crowd treats an agent as an upright cylinder of + /// x standing on the navmesh, so that is what is + /// drawn — sized and placed exactly as the simulation sees it, including + /// . Drawn unselected (like colliders) so a whole crowd's footprints + /// are visible while tuning. + /// + public override void DrawGizmos() + { + float radius = MathF.Max(0.01f, Radius); + float height = MathF.Max(0.01f, Height); + + // BaseOffset is the gap between the Transform and the surface the agent stands on, so + // the cylinder's base sits that far below the Transform and rises by Height. + Float3 basePos = Transform.Position - new Float3(0, BaseOffset, 0); + Float3 center = basePos + new Float3(0, height * 0.5f, 0); + + var color = new Color(0f, 0.85f, 1f, 1f); + Debug.DrawWireCylinder(center, Quaternion.Identity, radius, height, color); + // Base ring, so the footprint reads clearly against the ground. + Debug.DrawWireCircle(basePos, Float3.UnitY, radius, color); + } + + /// + /// The route the agent is currently steering along, plus its destination — drawn from the + /// crowd's own corner list, so it shows what the simulation is actually following rather + /// than a re-planned guess. Only meaningful while a crowd is running (in the editor an + /// unregistered agent has no path), which matches Unity. + /// + public override void DrawGizmosSelected() + { + var pathColor = new Color(0.2f, 1f, 0.45f, 1f); + var lift = new Float3(0, 0.05f, 0); // clear of the surface so it isn't z-fought away + + if (_hasDestination) + { + Float3 destination = _destination + lift; + Debug.DrawWireSphere(destination, MathF.Max(0.05f, Radius * 0.35f), pathColor); + } + + if (_agent == null) return; + + // Mid-hop across an off-mesh link the crowd empties the corner window, so draw the hop + // itself — otherwise the route appears to vanish exactly when it is most interesting. + if (IsOnOffMeshLink) + { + OffMeshLinkData hop = CurrentOffMeshLinkData; + if (hop.Valid) + Debug.DrawLine(hop.StartPos + lift, hop.EndPos + lift, new Color(1f, 0.8f, 0.2f, 1f)); + return; + } + + // The crowd exposes a WINDOW of upcoming corners, not the whole route, so this is the + // planned path as far as the simulation currently sees it. + Float3 previous = NextPosition + lift; + for (int i = 0; i < _agent.ncorners; i++) + { + Float3 corner = ToFloat3(_agent.corners[i].pos) + lift; + Debug.DrawLine(previous, corner, pathColor); + previous = corner; + } + } + + private static RcVec3f ToRc(Float3 v) => new((float)v.X, (float)v.Y, (float)v.Z); + private static Float3 ToFloat3(RcVec3f v) => new(v.X, v.Y, v.Z); +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshLink.cs b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs new file mode 100644 index 000000000..031e29bb1 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs @@ -0,0 +1,358 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Connects two navmesh positions that aren't walkably connected — a jump over a gap, a drop +/// off a ledge, a ladder. Mirrors Unity's NavMeshLink: agents whose area mask includes +/// traverse the link automatically as part of pathing (the crowd animates +/// the hop; manual traversal is not supported). Links are BAKED data: they are collected into +/// bakes like geometry, and changing one at runtime requires rebuilding the tiles around its +/// endpoints — which this component does itself when is on. A bake +/// keeps its links beside its layers and re-injects them whenever a tile is re-contoured, so +/// carving and links coexist. +/// A link's traversal cost comes from its area's cost; to price a link individually, give it +/// its own area with the desired cost. +/// +// A link has to be enabled to be registered, and a bake gathers its links from that registry — +// so a link inert outside play mode would go missing from every bake pressed in the editor. +// Being live also means editing one re-contours the tiles around it there, the way it does in +// play, and the surface overlay redraws the connection to match. +[ExecuteAlways] +[AddComponentMenu("Navigation/NavMesh Link")] +[ComponentIcon("")] // link icon +public class NavMeshLink : MonoBehaviour +{ + [Tooltip("Link start position, local to this GameObject.")] + public Float3 StartPoint = new(0, 0, -2.5f); + + [Tooltip("Link end position, local to this GameObject.")] + public Float3 EndPoint = new(0, 0, 2.5f); + + [Tooltip("World-space width of the link: how wide a span of the edge it covers, which is also how far its endpoints may snap to reach walkable surface. 0 uses the agent's own radius.")] + public float Width; + + [Tooltip("Whether the link can be traversed in both directions.")] + public bool Bidirectional = true; + + [Tooltip("The link's area. Traversal cost comes from this area's cost, and agents whose mask excludes it won't use the link.")] + [NavMeshArea] + public int Area = NavMeshAreas.Jump; + + [Tooltip("Whether the link is traversable. Toggling at runtime rebuilds the affected tiles (with Auto Rebuild on).")] + public bool Activated = true; + + [Tooltip("Follow Transform movement at runtime by rebuilding the affected tiles when the endpoints move. Meant for occasional repositioning, not per-frame motion — every move pays a partial rebuild.")] + public bool AutoUpdatePosition; + + [Tooltip("Automatically rebuild the affected tiles of matching surfaces when this link changes (enable/disable, Activated, moves with Auto Update Position). Turn off in games that manage rebuilds themselves with explicit sources.")] + public bool AutoRebuild = true; + + [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")] + public bool AffectAllAgentTypes = true; + + [Tooltip("Agent types whose bakes include this link, when not affecting all.")] + [NavMeshAgentType] + [EnableIf(nameof(UsesExplicitAgentTypes))] + public List AffectedAgentTypeIds = []; + + /// Persistent id stamped on the baked connections, resolving a traversing agent back + /// to this component (). Derived from the + /// component's , which the scene persists, so it survives + /// a reload and a duplicated object gets its own. Resolution is best-effort — baked data can + /// outlive the component that produced it — so don't hang gameplay-critical logic on + /// CurrentOffMeshLinkData.Link. + public int LinkId => StableLinkId(Identifier); + + /// Fold an identifier into the non-zero int a baked connection stores. Written out + /// rather than using Guid.GetHashCode, which is only guaranteed stable within one process; + /// a baked id has to match across sessions. + private static int StableLinkId(Guid identifier) + { + Span bytes = stackalloc byte[16]; + identifier.TryWriteBytes(bytes); + + int id = 0; + for (int i = 0; i < 16; i += 4) + id ^= BitConverter.ToInt32(bytes.Slice(i, 4)); + return id == 0 ? 1 : id; + } + + private bool UsesExplicitAgentTypes => !AffectAllAgentTypes; + + // The state the navmesh last saw, for change detection in LateUpdate. World endpoints size + // the rebuild regions; the authored fields are tracked separately so writing them after + // AddComponent (spawn-then-configure) re-applies the link without needing + // AutoUpdatePosition, which is about following the Transform. + private Float3 _appliedStart, _appliedEnd; + private Float3 _appliedStartPoint, _appliedEndPoint; + private float _appliedWidth; + private int _appliedArea; + private bool _appliedBidirectional; + private bool _appliedActive; + // Agent-type scoping decides which surfaces the link resolves against, so it is part of the + // definition too; the id list is copied rather than aliased, or the comparison would be + // against the caller's own live list and never report a change. + private bool _appliedAffectAllAgentTypes; + private readonly List _appliedAgentTypeIds = []; + + private void CaptureAppliedDefinition() + { + _appliedStart = WorldStart; + _appliedEnd = WorldEnd; + _appliedStartPoint = StartPoint; + _appliedEndPoint = EndPoint; + _appliedWidth = Width; + _appliedArea = Area; + _appliedBidirectional = Bidirectional; + _appliedActive = Activated; + CaptureAppliedScope(); + } + + /// Everything except the scope (see ). + private void CaptureAppliedGeometry() + { + _appliedStart = WorldStart; + _appliedEnd = WorldEnd; + _appliedStartPoint = StartPoint; + _appliedEndPoint = EndPoint; + _appliedWidth = Width; + _appliedArea = Area; + _appliedBidirectional = Bidirectional; + _appliedActive = Activated; + } + + /// Committed separately from the rest, and only after a rebuild has run: narrowing + /// the scope has to rebuild the surfaces the link is being taken OFF, and those are only + /// identifiable from the previous snapshot. + private void CaptureAppliedScope() + { + _appliedAffectAllAgentTypes = AffectAllAgentTypes; + _appliedAgentTypeIds.Clear(); + if (AffectedAgentTypeIds != null) _appliedAgentTypeIds.AddRange(AffectedAgentTypeIds); + } + + /// Surfaces to revisit on a change: those this link applies to now, plus those it + /// applied to before. Collection filters by the current scope, so a surface in the second + /// group rebuilds without the link — which is how it gets removed. + private bool AffectsOrDidAffect(int agentTypeId) + => AffectsAgentType(agentTypeId) + || _appliedAffectAllAgentTypes + || _appliedAgentTypeIds.Contains(agentTypeId); + + private bool DefinitionChanged() + => !StartPoint.Equals(_appliedStartPoint) + || !EndPoint.Equals(_appliedEndPoint) + || Width != _appliedWidth + || Area != _appliedArea + || Bidirectional != _appliedBidirectional + || AffectAllAgentTypes != _appliedAffectAllAgentTypes + || AgentTypeIdsChanged(); + + private bool AgentTypeIdsChanged() + { + int count = AffectedAgentTypeIds?.Count ?? 0; + if (count != _appliedAgentTypeIds.Count) return true; + for (int i = 0; i < count; i++) + if (AffectedAgentTypeIds![i] != _appliedAgentTypeIds[i]) return true; + return false; + } + + private NavMeshWorld? _world; + // Instances this link has already run its catch-up check against — each instance is + // attempted at most once, which is what keeps the NavMeshChanged handler from looping + // (our own catch-up rebuild fires the event again) and keeps permanently-unattachable + // links (endpoints over void) from rebuilding on every navmesh event. + private readonly HashSet _catchUpDone = []; + + /// Does this link apply to bakes for the given agent type? + public bool AffectsAgentType(int agentTypeId) + => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId); + + /// World-space start position. + public Float3 WorldStart => Transform.TransformPoint(StartPoint); + + /// World-space end position. + public Float3 WorldEnd => Transform.TransformPoint(EndPoint); + + /// This link as a self-contained bake payload (world space, current state). + public NavMeshLinkSource ToLinkSource() + => new(WorldStart, WorldEnd, Width, Bidirectional, Area, LinkId); + + public override void OnEnable() + { + CaptureAppliedDefinition(); + + // Catch-up must survive any enable order between links and surfaces: a navmesh may + // already be live (runtime-spawned link), or may only register later (scene load + // order) — the NavMeshChanged subscription covers the latter, mirroring how agents + // handle late registration. + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsValid()) + { + _world = scene!.Navigation; + _world.NavMeshChanged += OnNavMeshChanged; + _world.RegisterLink(this); + } + if (Activated) CatchUp(); + } + + public override void OnDisable() + { + _catchUpDone.Clear(); + if (_world != null) + { + _world.NavMeshChanged -= OnNavMeshChanged; + _world.UnregisterLink(this); + + // Unregistered first, so the rebuild collects the links WITHOUT this one — but + // before the world reference is released, since that's what reaches the surfaces. + // Scene teardown costs nothing here: Scene.OnDispose clears the navigation world + // before GameObjects dispose, so every instance is already retired and the rebuild + // finds nothing to do; a gameplay disable (pooling, a destroyed building) keeps it. + if (_appliedActive) RequestRebuild(_appliedStart, _appliedEnd); + + _world = null; + } + } + + private bool _catchUpPending; + + /// A navmesh registered or changed: schedule the catch-up check so links baked + /// out of date (added/moved since the surface's last bake) insert themselves regardless + /// of component enable order. Deferred to LateUpdate because NavMeshChanged fires INSIDE + /// AddNavMeshData — before the registering surface has assigned its Instance — so an + /// immediate check would see no surface to rebuild through. + private void OnNavMeshChanged() + { + // Only a change to the SET of navmeshes can give this link somewhere new to attach, and + // the event also fires per frame while a surface converges a carve. Gate on the + // structural counter, or every carving frame wakes a check per link to discover nothing. + if (_world == null || _world.StructureGeneration == _seenStructureGeneration) return; + _seenStructureGeneration = _world.StructureGeneration; + _catchUpPending = true; + } + + private int _seenStructureGeneration = -1; + + /// + /// For each matching surface with a live navmesh this link hasn't checked yet: if the + /// baked mesh already contains the link (id stamped at bake) do nothing — baked-in links + /// cost nothing at scene load — otherwise rebuild the endpoint tiles to insert it. + /// + private void CatchUp() + { + if (!AutoRebuild || _world == null) return; + + // Replaced instances (full rebakes) would otherwise be pinned by the checked set. + _catchUpDone.RemoveWhere(i => _world.GetInstance(i.AgentTypeId) != i); + + IReadOnlyList surfaces = _world.Surfaces; + for (int i = 0; i < surfaces.Count; i++) + { + NavMeshSurface surface = surfaces[i]; + NavMeshInstance? instance = surface.Instance; + if (instance == null || !AffectsAgentType(surface.AgentTypeId)) continue; + if (!_catchUpDone.Add(instance)) continue; // one attempt per instance + if (instance.ContainsLinkId(LinkId)) continue; // already in the live mesh + MarkEndpointRegions(surface, _appliedStart, _appliedEnd); + } + } + + public override void LateUpdate() + { + if (_catchUpPending) + { + _catchUpPending = false; + if (Activated) CatchUp(); + } + + bool activeChanged = Activated != _appliedActive; + bool edited = DefinitionChanged(); + bool moved = AutoUpdatePosition + && (Float3.Distance(WorldStart, _appliedStart) > 0.01 || Float3.Distance(WorldEnd, _appliedEnd) > 0.01); + if (!activeChanged && !edited && !moved) return; + + // Rebuild around both the old and the new endpoints: the old tiles drop the stale + // connection, the new ones gain it. + Float3 oldStart = _appliedStart, oldEnd = _appliedEnd; + bool relocated = moved || edited; + CaptureAppliedGeometry(); + + // An edited link has to be re-offered to every instance: catch-up only attempts each + // one once, and the earlier attempt applied the old definition. + if (edited) _catchUpDone.Clear(); + + RequestRebuild(oldStart, oldEnd); + if (relocated) RequestRebuild(_appliedStart, _appliedEnd); + CaptureAppliedScope(); // both rebuilds have seen the outgoing scope + } + + /// Rebuild the tiles around both endpoints on every registered surface this link + /// affects. No-op when is off or no matching navmesh is live + /// (which includes scene teardown — see the note in ). + private void RequestRebuild(Float3 start, Float3 end) + { + if (!AutoRebuild || _world == null) return; + + IReadOnlyList surfaces = _world.Surfaces; + for (int i = 0; i < surfaces.Count; i++) + { + NavMeshSurface surface = surfaces[i]; + if (surface.Instance == null || !AffectsOrDidAffect(surface.AgentTypeId)) continue; + MarkEndpointRegions(surface, start, end); + } + } + + /// Dirty the tiles around both endpoints: one region when the padded regions overlap + /// (the common short ladder/ledge link), two when they don't — a merged AABB across a long + /// link would dirty everything between the endpoints. The world applies them, so a frame that + /// moves many links re-contours each affected tile once however many of them touched it. + /// + private void MarkEndpointRegions(NavMeshSurface surface, Float3 start, Float3 end) + { + if (_world == null) return; + + float pad = Width * 0.5f + 1f; + AABB startRegion = new AABB(start, start).Expanded(pad); + AABB endRegion = new AABB(end, end).Expanded(pad); + + if (startRegion.Intersects(endRegion)) + _world.MarkLinkTilesDirty(surface, startRegion.Encapsulating(endRegion)); + else + { + _world.MarkLinkTilesDirty(surface, startRegion); + _world.MarkLinkTilesDirty(surface, endRegion); + } + } + + /// + /// Draws whatever the navmesh made of this link — the same connection the surface's overlay + /// draws, in the same place, so the two agree wherever both are shown. A link that has not + /// attached has nothing there to draw, so it falls back to the authored line in grey: that + /// difference in colour is the only warning that it reached nothing walkable. + /// + public override void DrawGizmosSelected() + { + IReadOnlyList surfaces = _world?.Surfaces ?? []; + for (int i = 0; i < surfaces.Count; i++) + { + NavMeshInstance? instance = surfaces[i].Instance; + if (instance == null || !AffectsAgentType(surfaces[i].AgentTypeId)) continue; + if (!instance.TryGetConnection(LinkId, out NavMeshConnection connection)) continue; + NavMeshSurface.DrawConnection(connection, Float3.Zero); + return; + } + + var color = new Color(0.6f, 0.6f, 0.6f, 1f); + Debug.DrawLine(WorldStart, WorldEnd, color); + Debug.DrawWireSphere(WorldStart, NavMeshSurface.EndpointGizmoRadius, color); + Debug.DrawWireSphere(WorldEnd, NavMeshSurface.EndpointGizmoRadius, color); + } +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs b/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs new file mode 100644 index 000000000..215a17dac --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshModifier.cs @@ -0,0 +1,49 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System.Collections.Generic; + +namespace Prowl.Runtime; + +/// +/// Changes how this GameObject (and, by default, its children) contributes to navmesh bakes: +/// exclude it entirely, or override the area its geometry is stamped with. Mirrors Unity's +/// NavMeshModifier. Resolved at geometry-collection time — the nearest modifier up the +/// hierarchy wins, an object's own modifier always beats an inherited one, and a modifier +/// with off covers only its own object. Changing a modifier +/// does not rebake anything by itself; rebuild the surface (or the affected tiles) to apply. +/// One modifier per GameObject: additional NavMeshModifier components on the same object are +/// ignored (matches Unity). +/// +[AddComponentMenu("Navigation/NavMesh Modifier")] +[ComponentIcon("")] // pen ruler +public class NavMeshModifier : MonoBehaviour +{ + [Tooltip("Exclude this object's geometry from navmesh bakes entirely.")] + public bool IgnoreFromBuild; + + [Tooltip("Stamp this object's bake geometry with Area instead of the surface's default.")] + public bool OverrideArea; + + [Tooltip("The area applied when Override Area is on.")] + [NavMeshArea] + [EnableIf(nameof(OverrideArea))] + public int Area = NavMeshAreas.Walkable; + + [Tooltip("Also apply to child objects. A child's own modifier always takes precedence.")] + public bool ApplyToChildren = true; + + [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")] + public bool AffectAllAgentTypes = true; + + [Tooltip("Agent types whose bakes this modifier affects, when not affecting all.")] + [NavMeshAgentType] + [EnableIf(nameof(UsesExplicitAgentTypes))] + public List AffectedAgentTypeIds = []; + + private bool UsesExplicitAgentTypes => !AffectAllAgentTypes; + + /// Does this modifier apply to bakes for the given agent type? + public bool AffectsAgentType(int agentTypeId) + => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId); +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs b/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs new file mode 100644 index 000000000..998a9ddf6 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs @@ -0,0 +1,62 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System.Collections.Generic; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Stamps an area over a world region during navmesh bakes, independent of which objects the +/// geometry came from — mark a danger zone, make a doorway expensive, or (with Not Walkable) +/// erase walkability inside the box. Mirrors Unity's NavMeshModifierVolume. The volume only +/// re-marks surface that geometry produced; it never creates walkable surface. Applied during +/// full bakes AND partial rebuilds whose tiles intersect it; like modifiers, changing a +/// volume does not rebake anything by itself. When toggling or moving a volume at runtime, +/// rebuild its ENTIRE footprint region — rebuilding a sub-region leaves the stamp +/// half-applied in the untouched tiles. +/// +[AddComponentMenu("Navigation/NavMesh Modifier Volume")] +[ComponentIcon("")] // cube icon +public class NavMeshModifierVolume : MonoBehaviour +{ + [Tooltip("Volume center, local to this GameObject.")] + public Float3 Center; + + [Tooltip("Volume size, local to this GameObject (scaled and rotated by the Transform).")] + public Float3 Size = new(4, 3, 4); + + [Tooltip("The area stamped inside the volume. Not Walkable erases walkability (punches a hole).")] + [NavMeshArea] + public int Area = NavMeshAreas.Walkable; + + [Tooltip("Apply to bakes of every agent type. Turn off to pick specific types.")] + public bool AffectAllAgentTypes = true; + + [Tooltip("Agent types whose bakes this volume affects, when not affecting all.")] + [NavMeshAgentType] + [EnableIf(nameof(UsesExplicitAgentTypes))] + public List AffectedAgentTypeIds = []; + + private bool UsesExplicitAgentTypes => !AffectAllAgentTypes; + + /// Does this volume apply to bakes for the given agent type? + public bool AffectsAgentType(int agentTypeId) + => AffectAllAgentTypes || AffectedAgentTypeIds.Contains(agentTypeId); + + /// The world-space convex prism this volume marks (rotation and scale applied). + public NavMeshAreaVolume ComputeAreaVolume() + => NavMeshAreaVolume.FromOrientedBox(Transform.LocalToWorldMatrix, Center, Size, Area); + + public override void DrawGizmosSelected() + { + // Wire box in the same per-area colour the scene-view navmesh overlay uses, drawn + // under the full transform so the gizmo shows the same rotated/scaled region the bake + // marks (same idiom as BoxCollider.DrawGizmos). + Color c = NavMeshSurface.AreaColor(Area); + Debug.PushMatrix(Transform.LocalToWorldMatrix); + Debug.DrawWireCube(Center, Size * 0.5f, new Color(c.R, c.G, c.B, 1f)); + Debug.PopMatrix(); + } +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs new file mode 100644 index 000000000..2e7f89997 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs @@ -0,0 +1,477 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour.TileCache; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Shape of a . +public enum NavMeshObstacleShape +{ + /// Upright cylinder of the given radius and height. + Cylinder, + /// Oriented box (yaw only — Detour box obstacles rotate around Y). + Box, +} + +/// +/// Blocks agents while enabled — a parked vehicle, a dropped crate, a placed building. Mirrors +/// Unity's NavMeshObstacle, including both of its modes: +/// +/// on cuts a hole in the navmesh, so pathfinding routes around it. Affected +/// tiles rebuild incrementally over the following frames; with +/// the hole lifts while moving and re-applies once still for . +/// +/// off is Unity's velocity-obstacle mode: the mesh stays untouched and the +/// obstacle joins each crowd as an immovable neighbour instead, so agents steer around it +/// locally. Costs nothing per move — the right mode for something that moves often — but paths +/// are computed as if it weren't there, so an agent with no other route presses against it. +/// +/// Either way the object's own geometry stays out of bakes: it's a runtime thing, and +/// voxelizing it would freeze a hole where it happened to be standing. +/// +[AddComponentMenu("Navigation/NavMesh Obstacle")] +[ComponentIcon("")] // road barrier +// Carving runs in the editor as well as in play, so placing a building shows the hole it will +// cut without entering play mode. Only the live navmesh is affected — the baked asset never +// stores carves — and the velocity-obstacle path stays play-only, since it is crowd steering. +[ExecuteAlways] +public class NavMeshObstacle : MonoBehaviour +{ + [Tooltip("Obstacle shape. Both stand upright: a cylinder has no tilt, and a box is oriented by yaw only.")] + public NavMeshObstacleShape Shape = NavMeshObstacleShape.Box; + + [Tooltip("Obstacle center, local to this GameObject.")] + public Float3 Center; + + [Tooltip("Box size, local (scaled by the Transform).")] + [ShowIf(nameof(IsBox))] + public Float3 Size = new(1, 1, 1); + + [Tooltip("Cylinder radius (scaled by the largest horizontal Transform scale).")] + [ShowIf(nameof(IsCylinder))] + public float Radius = 0.5f; + + [Tooltip("Cylinder height (scaled by the vertical Transform scale).")] + [ShowIf(nameof(IsCylinder))] + public float Height = 2f; + + [Tooltip("On: cut a hole in the navmesh so paths route around this. Off: leave the mesh alone and make agents steer around it locally instead — cheaper, and the right choice for something that moves, but paths still lead through it.")] + public bool Carve = true; + + [Tooltip("Only carve while stationary: the carve lifts while the obstacle moves and re-applies once it has settled. Off re-carves on every move beyond the threshold — much more expensive for frequently-moving obstacles.")] + public bool CarveOnlyStationary = true; + + [Tooltip("Movement beyond this distance (world units) counts as moving.")] + public float CarvingMoveThreshold = 0.1f; + + [Tooltip("Seconds the obstacle must be still before it carves again (with Carve Only Stationary).")] + public float CarvingTimeToStationary = 0.5f; + + private bool IsBox => Shape == NavMeshObstacleShape.Box; + private bool IsCylinder => Shape == NavMeshObstacleShape.Cylinder; + + private NavMeshWorld? _world; + // Obstacle handle per navmesh instance the carve is registered with. Instances are + // per-agent-type; the obstacle applies to every one of them (Unity has no agent filter on + // obstacles). + private readonly Dictionary _refs = []; + private Float3 _appliedPosition; + private float _stillTime; + private bool _carveApplied; + + // Velocity-obstacle mode: one immovable agent per live crowd, re-pinned every frame. + private readonly Dictionary _blockers = []; + private int _blockerCrowdCount = -1; + private float _blockerRadius, _blockerHeight; + private bool _warnedBlockerUnplaced; + + // Geometry the live carve was registered with, re-checked each LateUpdate so writing the + // public fields after AddComponent (spawn-then-configure) re-carves — the same drift-check + // pattern as NavMeshAgent's AgentTypeId/AreaMask. Rotation compares quaternions by dot + // product: no per-frame Euler conversion, no wrap false-positives at ±180°. + private NavMeshObstacleShape _appliedShape; + private Float3 _appliedSize; + private float _appliedRadius, _appliedHeight; + private Quaternion _appliedRotation = Quaternion.Identity; + + private void CaptureAppliedGeometry() + { + _appliedShape = Shape; + _appliedSize = Size; + _appliedRadius = Radius; + _appliedHeight = Height; + _appliedRotation = Transform.Rotation; + } + + private bool GeometryChanged() + => Shape != _appliedShape + || !Size.Equals(_appliedSize) + || Radius != _appliedRadius + || Height != _appliedHeight + || (Shape == NavMeshObstacleShape.Box + && Math.Abs(Quaternion.Dot(Transform.Rotation, _appliedRotation)) < 0.9999); + + public override void OnEnable() + { + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsValid()) + { + _world = scene!.Navigation; + _world.NavMeshChanged += OnNavMeshChanged; + } + + _appliedPosition = Transform.Position; + _stillTime = CarvingTimeToStationary; // spawning still: carve immediately + TryApplyCarve(); + } + + public override void OnDisable() + { + RemoveCarve(); + RemoveBlockers(); + if (_world != null) + { + _world.NavMeshChanged -= OnNavMeshChanged; + _world = null; + } + } + + private void OnNavMeshChanged() + { + // An instance may have been replaced (rebake) or registered late; dead entries are + // dropped and the carve re-applies to any new instance on the next LateUpdate. Gated on + // the structural counter because the event also fires every frame a carve is converging + // — including this obstacle's own — which would make each carve pay for itself repeatedly. + if (_world == null || _world.StructureGeneration == _seenStructureGeneration) return; + _seenStructureGeneration = _world.StructureGeneration; + _refsPruneNeeded = true; + } + + private bool _refsPruneNeeded; + private int _seenStructureGeneration = -1; + + public override void LateUpdate() + { + if (!Carve) + { + // Switching modes at runtime must not leave the other mode's effect behind. + if (_carveApplied) RemoveCarve(); + // Velocity mode is crowd steering, and crowds only step during play — outside it a + // blocker would sit in a crowd nothing is running. + if (Application.IsPlaying) UpdateBlockers(); + else if (_blockers.Count > 0) RemoveBlockers(); + return; + } + // The other half of that: a blocker left over from velocity mode would sit inside the + // hole this obstacle carves, avoided by agents already routing around it. + if (_blockers.Count > 0) RemoveBlockers(); + + // Geometry drift MUST be evaluated before the new-instance pickup below: TryApplyCarve + // captures the applied-geometry snapshot, so running the pickup first (its flag is set + // by any NavMeshChanged — including the cache pump's own convergence events) would + // record the new field values without re-carving and swallow the drift for good. + if (_carveApplied && GeometryChanged()) + { + RemoveCarve(); + TryApplyCarve(); + } + + if (_refsPruneNeeded) + { + _refsPruneNeeded = false; + PruneDeadInstances(); + if (_carveApplied) TryApplyCarve(); // pick up newly registered navmeshes + } + + double moved = Float3.Distance(Transform.Position, _appliedPosition); + if (CarveOnlyStationary) + { + if (moved > CarvingMoveThreshold) + { + // Moving: lift the carve and restart the settle timer. + RemoveCarve(); + _appliedPosition = Transform.Position; + _stillTime = 0f; + } + else if (!_carveApplied) + { + _stillTime += (float)Time.DeltaTime; + if (_stillTime >= CarvingTimeToStationary) + TryApplyCarve(); + } + } + else if (moved > CarvingMoveThreshold) + { + // Follow mode: re-carve at the new position. Every move pays incremental tile + // rebuilds, hence the tooltip's warning. + RemoveCarve(); + _appliedPosition = Transform.Position; + TryApplyCarve(); + } + } + + /// + /// Velocity-obstacle mode: keep one immovable agent per live crowd sitting on this + /// obstacle, so every other agent's local avoidance treats it as a neighbour to steer + /// around. Crowds appear lazily (when the first agent of a type registers), so membership + /// is re-derived whenever the world's crowd count changes rather than only at enable. + /// + private void UpdateBlockers() + { + if (_world == null) return; + + // LossyScale walks the parent chain, so the shape is measured once per frame and passed + // down rather than re-derived by each helper. + Float3 scale = Transform.LossyScale; + float radius = BlockerRadius(scale); + float height = BlockerHeight(scale); + bool resized = Math.Abs(radius - _blockerRadius) > 1e-4f || Math.Abs(height - _blockerHeight) > 1e-4f; + if (_blockerCrowdCount != _world.CrowdCount || resized || _refsPruneNeeded) + { + _refsPruneNeeded = false; + _blockerRadius = radius; + _blockerHeight = height; + RefreshBlockers(radius, height); + } + + // Write the position straight onto the crowd agent every frame: a crowd agent is + // normally moved by its own steering, which a blocker has none of, so this is what makes + // it follow the Transform. It also absorbs the crowd's collision-resolution displacement + // (measured under a centimetre even under pressure, but costs nothing to be exact). + Float3 position = BlockerPosition(height); + var pinned = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z); + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker in _blockers.Values) + blocker.npos = pinned; + } + + /// Join every live crowd not already blocked, and drop memberships whose crowd + /// died with its navmesh. + private void RefreshBlockers(float radius, float height) + { + if (_world == null) return; + + Float3 position = BlockerPosition(height); + var rcPosition = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z); + foreach (NavMeshAgentType type in NavMeshAgentTypes.All) + { + Prowl.Recast.Detour.Crowd.DtCrowd? crowd = _world.GetNativeCrowd(type.Id); + if (crowd == null) continue; + if (_blockers.TryGetValue(crowd, out Prowl.Recast.Detour.Crowd.DtCrowdAgent? existing)) + { + crowd.UpdateAgentParameters(existing, BlockerParams(radius, height)); + continue; + } + + Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker = crowd.AddAgent(rcPosition, BlockerParams(radius, height)); + _blockers[crowd] = blocker; + WarnIfUnplaced(blocker); + } + + // A crowd the world no longer owns died with its navmesh; its agents went with it. + List? dead = null; + foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in _blockers.Keys) + { + bool live = false; + foreach (NavMeshAgentType type in NavMeshAgentTypes.All) + if (ReferenceEquals(_world.GetNativeCrowd(type.Id), crowd)) { live = true; break; } + if (!live) (dead ??= []).Add(crowd); + } + if (dead != null) + foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in dead) + _blockers.Remove(crowd); + + _blockerCrowdCount = _world.CrowdCount; + } + + private void RemoveBlockers() + { + foreach ((Prowl.Recast.Detour.Crowd.DtCrowd crowd, Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker) in _blockers) + crowd.RemoveAgent(blocker); + _blockers.Clear(); + _blockerCrowdCount = -1; + } + + /// + /// A blocker that failed to place is an invisible failure: the component looks configured + /// and nothing avoids it. The crowd's placement probe searches only a few units vertically + /// (sized from ), so an obstacle floating well + /// above the walkable surface — a tall Center offset, spawned mid-air — lands invalid. + /// + private void WarnIfUnplaced(Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker) + { + if (blocker.state != Prowl.Recast.Detour.Crowd.DtCrowdAgentState.DT_CROWDAGENT_STATE_INVALID) return; + if (_warnedBlockerUnplaced) return; + _warnedBlockerUnplaced = true; + Debug.LogWarning($"[Navigation] NavMeshObstacle '{GameObject.Name}' could not place its avoidance blocker: no navmesh near its base. Agents will not steer around it. Move the obstacle onto the navmesh (check Center and the object's height), or raise NavMeshWorld.CrowdMaxAgentRadius to widen the placement search."); + } + + /// Where the blocker stands: the obstacle's footprint centre at its base, matching + /// how agents sit on the mesh at their feet. + private Float3 BlockerPosition(float height) + { + Float3 center = Transform.TransformPoint(Center); + return new Float3(center.X, center.Y - height * 0.5f, center.Z); + } + + // The shape an obstacle occupies once the Transform's scale is applied. The carve, the + // avoidance blocker and the gizmo all describe the same volume and have to agree on it, so + // each shape's dimensions are worked out in exactly one place. + + /// Radius by the larger horizontal scale, height by the vertical one. + private (float Radius, float Height) ScaledCylinder(Float3 scale) => ( + Radius * MathF.Max(0.01f, MathF.Max(Math.Abs(scale.X), Math.Abs(scale.Z))), + Height * MathF.Max(0.01f, Math.Abs(scale.Y))); + + /// Per-axis, and without the carve's horizontal clearance — that is a navmesh + /// concern (an agent's centre must clear the box, not just its body) rather than part of + /// the shape the user authored. + private Float3 ScaledBoxHalfExtents(Float3 scale) => new( + Size.X * 0.5f * Math.Abs(scale.X), + Size.Y * 0.5f * Math.Abs(scale.Y), + Size.Z * 0.5f * Math.Abs(scale.Z)); + + /// Avoidance is circle-based, so a box is approximated by the circle enclosing its + /// footprint — agents give a rotated crate a slightly wider berth than its corners need. + private float BlockerRadius(Float3 scale) + { + if (Shape == NavMeshObstacleShape.Cylinder) + return MathF.Max(0.01f, ScaledCylinder(scale).Radius); + + Float3 half = ScaledBoxHalfExtents(scale); + return MathF.Max(0.01f, MathF.Sqrt(half.X * half.X + half.Z * half.Z)); + } + + private float BlockerHeight(Float3 scale) + => MathF.Max(0.01f, Shape == NavMeshObstacleShape.Cylinder + ? ScaledCylinder(scale).Height + : ScaledBoxHalfExtents(scale).Y * 2f); + + private Prowl.Recast.Detour.Crowd.DtCrowdAgentParams BlockerParams(float radius, float height) => new() + { + radius = radius, + height = height, + // Immovable: no steering of its own, and no update flags, so the crowd never tries to + // path, avoid or separate on its behalf. It exists purely to be avoided — which is also + // why its own neighbour query is kept to its radius rather than the multiple a steering + // agent needs: the crowd still runs that query every frame, and nothing consumes it. + maxAcceleration = 0f, + maxSpeed = 0f, + collisionQueryRange = radius, + pathOptimizationRange = 0f, + updateFlags = 0, + obstacleAvoidanceType = 0, + separationWeight = 0f, + queryFilterType = 0, + userData = this, + }; + + /// Queue the carve on every registered navmesh not already carrying it. Queued onto + /// the cache directly rather than through , because + /// nothing here touches the mesh but the request queue: the tiles rebuild in the pump, which + /// is where the write lock and the change notification belong. + private void TryApplyCarve() + { + if (_world == null || !Carve) return; + + foreach (NavMeshAgentType type in NavMeshAgentTypes.All) + { + NavMeshInstance? instance = _world.GetInstance(type.Id); + if (instance == null || _refs.ContainsKey(instance)) continue; + long obstacleRef = AddToCache(instance.TileCache, instance.NavMeshData.Settings.AgentRadius); + if (obstacleRef == 0) continue; + _refs[instance] = obstacleRef; + instance.MarkCachePending(); + } + _carveApplied = true; + CaptureAppliedGeometry(); + } + + // No try/catch: verified against Prowl.Recast — AllocObstacle grows its pool and + // the request queue is an unbounded list, so Add*Obstacle never throws and never returns + // 0 for capacity (maxObstacles only sizes the initial id encoding). + /// Envelope of the navmesh being carved. The hole is widened by it + /// because a navmesh stores where an agent's CENTRE may be, not where its body fits: a bake + /// pulls the mesh this far back from every wall, and a carve that did not would let agents + /// walk their centre onto the obstacle's surface and stand half inside it. + private long AddToCache(DtTileCache cache, float agentRadius) + { + Float3 scale = Transform.LossyScale; + Float3 worldCenter = Transform.TransformPoint(Center); + float clearance = Math.Max(0f, agentRadius); + + if (Shape == NavMeshObstacleShape.Cylinder) + { + (float radius, float height) = ScaledCylinder(scale); + // Cylinder obstacles anchor at the base center. + var basePos = new RcVec3f((float)worldCenter.X, (float)(worldCenter.Y - height * 0.5f), (float)worldCenter.Z); + return cache.AddObstacle(basePos, radius + clearance, height); + } + + // Clearance horizontally only: erosion is a footprint concern, and growing the box + // vertically would start carving under things the obstacle passes beneath. + Float3 half = ScaledBoxHalfExtents(scale); + var halfExtents = new RcVec3f(half.X + clearance, half.Y, half.Z + clearance); + float yawRadians = (float)(Transform.Rotation.EulerAngles.Y * Maths.Deg2Rad); + return cache.AddBoxObstacle(new RcVec3f((float)worldCenter.X, (float)worldCenter.Y, (float)worldCenter.Z), halfExtents, yawRadians); + } + + /// Queue removal of the carve everywhere it is registered. + private void RemoveCarve() + { + foreach ((NavMeshInstance instance, long obstacleRef) in _refs) + { + instance.TileCache.RemoveObstacle(obstacleRef); + instance.MarkCachePending(); + } + _refs.Clear(); + _carveApplied = false; + } + + /// Drop handles whose instance is no longer registered — the cache (and its + /// obstacle state) died with it, so there is nothing to remove. + private void PruneDeadInstances() + { + if (_refs.Count == 0 || _world == null) return; + List? dead = null; + foreach (NavMeshInstance instance in _refs.Keys) + { + if (_world.GetInstance(instance.AgentTypeId) != instance) + (dead ??= []).Add(instance); + } + if (dead != null) + foreach (NavMeshInstance instance in dead) + _refs.Remove(instance); + } + + /// + /// The volume this obstacle actually carves, which is not the volume its Transform describes: + /// both shapes stand upright however the object is pitched or rolled, because Detour orients a + /// box obstacle by yaw alone. Drawing the full transform would promise a tilt the navmesh + /// never cuts. + /// + public override void DrawGizmosSelected() + { + var color = new Color(1f, 0.5f, 0.1f, 1f); + Float3 scale = Transform.LossyScale; + Float3 worldCenter = Transform.TransformPoint(Center); + + if (Shape == NavMeshObstacleShape.Cylinder) + { + (float radius, float height) = ScaledCylinder(scale); + Debug.DrawWireCylinder(worldCenter, Quaternion.Identity, radius, height, color); + return; + } + + var yaw = Quaternion.FromEuler(new Float3(0, Transform.Rotation.EulerAngles.Y, 0)); + Debug.PushMatrix(Float4x4.CreateTRS(worldCenter, yaw, Float3.One)); + Debug.DrawWireCube(Float3.Zero, ScaledBoxHalfExtents(scale), color); + Debug.PopMatrix(); + } +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs new file mode 100644 index 000000000..02d217802 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs @@ -0,0 +1,848 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.TileCache; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Which objects a bake considers. +public enum NavMeshCollectObjects +{ + /// Every active object in the scene. + All, + /// Every active object whose geometry intersects the surface's volume + /// ( / ). + Volume, + /// Only this GameObject and its children. + Children, +} + +/// +/// Bakes and registers a navmesh for one agent type. The baked result is a standalone +/// asset; at runtime the surface registers it with the scene's +/// on enable. Rebuilds can run synchronously, in the background +/// (), or per-tile for localized geometry changes +/// ( — destructible worlds rebuild only what changed). +/// +// Registration is the whole of this component's lifecycle — there is no per-frame work — and the +// editor needs it as much as play does: an obstacle can only carve a live navmesh, and the scene +// view's overlay draws one. Without this, opening a scene leaves its baked navmesh unregistered +// until something bakes again, so carve previews only work in the session you pressed Bake in. +[ExecuteAlways] +[AddComponentMenu("Navigation/NavMesh Surface")] +[ComponentIcon("")] // map icon +public class NavMeshSurface : MonoBehaviour +{ + [Header("Bake")] + [Tooltip("The agent type this navmesh is built for (radius, height, slope, climb come from the project's agent table). Agents only use navmeshes of their own type.")] + [NavMeshAgentType] + public int AgentTypeId = NavMeshAgentTypes.Humanoid; + + [Tooltip("Surface-level rasterization settings (voxel/tile sizes and Recast detail). Most bakes never need to change these.")] + [HideInInspector] // drawn inside the editor's Advanced foldout + public NavMeshBuildOverrides BuildOverrides = new(); + + /// The resolved bake input: the agent type's envelope composed with this + /// surface's . What gets handed to + /// ; a fresh snapshot each call. + public NavMeshBuildSettings ResolveBuildSettings() + => NavMeshAgentTypes.GetBuildSettings(AgentTypeId, BuildOverrides); + + [Tooltip("Which objects contribute bake geometry. NavMeshAgents and their children never contribute — agents walk the mesh rather than forming it.")] + public NavMeshCollectObjects CollectObjects = NavMeshCollectObjects.All; + + [Tooltip("Volume center (local to this GameObject) when CollectObjects is Volume.")] + [ShowIf(nameof(IsVolumeMode))] + public Float3 Center; + + [Tooltip("Volume size when CollectObjects is Volume.")] + [ShowIf(nameof(IsVolumeMode))] + public Float3 Size = new(10, 10, 10); + + [Tooltip("Only objects on these layers contribute bake geometry.")] + public LayerMask Layers = LayerMask.Everything; + + [Tooltip("Voxelize render meshes or physics colliders.")] + public NavMeshCollectGeometry UseGeometry = NavMeshCollectGeometry.RenderMeshes; + + [Tooltip("Area applied to all walkable geometry in this bake.")] + [NavMeshArea] + [HideInInspector] // drawn inside the editor's Advanced foldout (Unity keeps it there too) + public int DefaultArea = NavMeshAreas.Walkable; + + [Tooltip("The baked navmesh. Assigned by baking, or point it at an existing .navmesh asset.")] + public AssetRef NavMeshData; + + [Tooltip("Draw the walkable surface in the scene view even when this object is not selected — the only way to watch obstacles carve while playing, since entering play mode clears the selection. Debug aid: it re-triangulates the whole mesh on every frame a carve or rebuild is converging, so leave it off in scenes you are profiling.")] + public bool AlwaysShowNavMesh; + + private NavMeshInstance? _instance; + private Runtime.NavMeshData? _runtimeData; + private bool IsVolumeMode => CollectObjects == NavMeshCollectObjects.Volume; + + /// The live navmesh registration, while enabled and a navmesh is loaded. + public NavMeshInstance? Instance => _instance; + + /// + /// What the live navmesh was built from, and what rebuilds rewrite. Null while unregistered. + /// For a .navmesh asset this is a private copy made at registration, because the object + /// the database hands out is shared by every surface pointing at it and by the next scene that + /// loads it. For a navmesh built at runtime and handed over through + /// it is that object itself — nothing else owns it. + /// + public Runtime.NavMeshData? RuntimeData => _runtimeData; + + /// The scene's navigation world, or null when not in a scene. + private NavMeshWorld? World + { + get + { + var scene = GameObject.IsValid() ? GameObject.Scene : null; + return scene.IsValid() ? scene.Navigation : null; + } + } + + public override void OnEnable() + { + World?.RegisterSurface(this); + Register(); + } + + public override void OnDisable() + { + World?.UnregisterSurface(this); + Unregister(); + + // Release the debug-overlay subscription; without this every surface ever selected + // stays referenced by the scene's NavMeshWorld until scene teardown. + if (_debugWorld != null) + { + _debugWorld.NavMeshChanged -= InvalidateDebugTriangulation; + _debugWorld = null; + } + _debugTriangulation = null; + } + + private void Register() + { + if (_instance != null) return; + NavMeshWorld? world = World; + if (world == null) return; + + // The navmesh has to be present now: registration happens once on enable and nothing + // retries it — a transient null from async streaming would leave the scene permanently + // without one. Block-load it, as the mesh and terrain colliders do for the same reason. + NavMeshData.EnsureLoaded(); + + Runtime.NavMeshData? data = NavMeshData.Res; + if (data.IsNotValid() || !data!.HasTiles) return; + + // Copy only what the asset database owns. A .navmesh asset is shared with every other + // surface pointing at it and with the next scene that loads it, so runtime tile and link + // rewrites must not land on it. One built at runtime and handed over through + // ApplyNavMeshData has no other owner — copying it would just cost a list per + // registration and throw away every rebuild since the original bake on re-registering. + // (Handing one runtime navmesh to two surfaces still shares it, as it always has.) + _runtimeData = NavMeshData.AssetID == Guid.Empty ? data : data.Clone(); + _instance = world.AddNavMeshData(_runtimeData); + if (_instance == null) _runtimeData = null; + } + + private void Unregister() + { + if (_instance == null) return; + World?.RemoveNavMeshData(_instance); + _instance = null; + _runtimeData = null; + } + + #region Building + + /// + /// Collect geometry and bake the navmesh synchronously, then (re)register it with the + /// scene. Blocks the calling thread for the duration of the bake — prefer + /// during gameplay. + /// + public bool BuildNavMesh() + { + NavMeshBuildSettings settings = ResolveBuildSettings(); // one resolve per bake: collection and build must agree + List sources = CollectSources(null, settings.EffectiveVoxelSize); + Runtime.NavMeshData? data = NavMeshBuilder.Build(settings, sources, DefaultArea, + threads: Math.Max(1, Environment.ProcessorCount - 1), worldBounds: ExplicitWorldBounds(), + volumes: CollectVolumes(null), links: CollectLinks(null)); + if (data == null) + { + Debug.LogWarning($"[Navigation] Bake of '{GameObject.Name}' produced no walkable geometry."); + return false; + } + + ApplyNavMeshData(data); + return true; + } + + /// In Volume mode the volume is an explicit statement of the bake's extent, so + /// the tile grid spans it even where no geometry exists yet (rooms opening up later can be + /// added via ). + private AABB? ExplicitWorldBounds() + => CollectObjects == NavMeshCollectObjects.Volume ? VolumeBounds : null; + + /// World-space extent of the Volume-mode box. + private AABB VolumeBounds => AABB.FromCenterAndSize(Transform.TransformPoint(Center), Size); + + /// + /// Bake in the background: geometry is collected on the calling (main) thread, the + /// voxelization runs on the thread pool. Apply the result with + /// from the main thread when the task completes. + /// + public Task BuildNavMeshAsync(CancellationToken cancellation = default) + { + NavMeshBuildSettings settings = ResolveBuildSettings(); // resolved on the main thread, once per bake + List sources = CollectSources(null, settings.EffectiveVoxelSize); + List volumes = CollectVolumes(null); // main thread: touches Transforms + List links = CollectLinks(null); + int defaultArea = DefaultArea; + AABB? worldBounds = ExplicitWorldBounds(); + return Task.Run(() => NavMeshBuilder.Build(settings, sources, defaultArea, + threads: Math.Max(1, Environment.ProcessorCount - 1), cancellation, worldBounds, volumes, links), cancellation); + } + + /// + /// Swap in a freshly built navmesh: replaces this surface's data (as a runtime resource) + /// and its registration in the scene. Main thread only. + /// + public void ApplyNavMeshData(NavMeshData data) + { + ArgumentNullException.ThrowIfNull(data); + + Unregister(); + NavMeshData = data; + Register(); + } + + /// Re-register the currently assigned (e.g. after the + /// asset reference was swapped by an editor bake). + public void RefreshRegistration() + { + Unregister(); + Register(); + } + + /// + /// Rebuild only the tiles intersecting against the current + /// scene geometry (via this surface's collectors) and swap them into the live navmesh — + /// cost scales with the changed volume, not the map size. The tile grid stays anchored to + /// the original bake, so geometry outside the original bounds needs a full + /// . Requires an enabled surface with a registered navmesh. + /// + public bool RebuildTiles(AABB worldBounds) + { + Runtime.NavMeshData? data = _runtimeData; + if (data.IsNotValid()) return false; + // Collection (terrain decimation) uses the BAKED voxel size, same as the tiles being + // rebuilt — the current agent table may disagree with the bake this grid came from. + AABB? collectionBounds = RebuildCollectionBounds(worldBounds); + return RebuildTiles(worldBounds, + CollectSources(collectionBounds, data!.Settings.EffectiveVoxelSize), out _, + CollectVolumes(collectionBounds)); + } + + /// + /// Re-collect this surface's s into the live navmesh and rebuild + /// the tiles overlapping so the change takes effect. Cheap + /// next to a geometry rebuild: the compressed layers are untouched and only the affected + /// tiles are re-contoured from them, with the new link set injected — where + /// has to re-voxelize. Returns false when this surface has + /// no live navmesh. + /// + /// Region whose tiles pick up the change — normally the link's + /// endpoints. The registry is replaced wholesale; only these tiles re-contour. + /// The surface's complete link set. Null (the default) collects the + /// scene's s; games driving navigation from explicit sources pass + /// their own list and skip the scene scan. + public bool RebuildLinkTiles(AABB worldBounds, IReadOnlyList? links = null) + => RebuildLinkTiles([worldBounds], links); + + /// + /// Regions whose tiles pick up the change. A tile several of them + /// cover re-contours once — which is the whole point of handing a frame's link edits over + /// together rather than applying them one at a time. + /// The surface's complete link set. Null (the default) collects the + /// scene's s; games driving navigation from explicit sources pass + /// their own list and skip the collection. + public bool RebuildLinkTiles(ReadOnlySpan worldBounds, IReadOnlyList? links = null) + { + NavMeshInstance? instance = Instance; + Runtime.NavMeshData? data = _runtimeData; + if (instance == null || data.IsNotValid()) return false; + if (data!.TileWorldSize <= 0) return false; + + links ??= CollectLinks(null); + NavMeshWorld? world = World; + if (world == null) return false; + + // Resolved before the write lock is taken, so worker-thread queries do not block on it. + HashSet<(int X, int Z)> tiles = []; + foreach (AABB bounds in worldBounds) + if (data.TryGetTileRange(bounds, out int tx0, out int tx1, out int tz0, out int tz1)) + for (int tz = tz0; tz <= tz1; tz++) + for (int tx = tx0; tx <= tx1; tx++) + tiles.Add((tx, tz)); + + world.MutateTileCache(instance, cache => + { + // Always replace the link set, even with no tiles in range: it is what tiles rebuilt + // later — by a carve, or by a rebuild of a neighbouring region — will be built from. + instance.TileCacheLinks.SetLinks(links, data.Settings.AgentRadius); + + foreach ((int tx, int tz) in tiles) + foreach (long tileRef in cache.GetTilesAt(tx, tz)) + cache.BuildNavMeshTile(tileRef); + }); + + // Mirror onto the runtime copy, so a rebuild that re-instantiates it starts from the + // link set the live mesh is using. The .navmesh asset is left alone: a link moving is a + // scene edit, and the baked artifact answers for it at the next bake. + data.Links.Clear(); + foreach (NavMeshLinkSource link in links) + data.Links.Add(Runtime.NavMeshData.NavMeshLinkEntry.From(link)); + return true; + } + + /// + /// World rect the collectors must cover for a rebuild of : + /// the affected TILES (rebuilds rasterize whole tiles, so sources clipped to just the + /// changed AABB would erase the rest of a partially-covered tile) plus the erosion border. + /// + private AABB? RebuildCollectionBounds(AABB worldBounds) + { + Runtime.NavMeshData? data = NavMeshData.Res; + if (data.IsNotValid() || data!.TileWorldSize <= 0) return null; // no grid: collect everything + + float ts = data.TileWorldSize; + // Conservative world-space erosion border (CalcBorder cells = ceil(radius/cs) + 3). + // Derived from the ASSET's snapshot settings — the grid being rebuilt is the one the + // asset was baked with, not whatever the surface's current configuration says. + float border = data.Settings.AgentRadius + 4f * data.Settings.EffectiveVoxelSize; + + double minTx = Math.Floor((worldBounds.Min.X - border - data.Origin.X) / ts); + double maxTx = Math.Floor((worldBounds.Max.X + border - data.Origin.X) / ts); + double minTz = Math.Floor((worldBounds.Min.Z - border - data.Origin.Z) / ts); + double maxTz = Math.Floor((worldBounds.Max.Z + border - data.Origin.Z) / ts); + + return new AABB( + new Float3((float)(data.Origin.X + minTx * ts - border), (float)data.BoundsMin.Y - 1, (float)(data.Origin.Z + minTz * ts - border)), + new Float3((float)(data.Origin.X + (maxTx + 1) * ts + border), (float)data.BoundsMax.Y + 1, (float)(data.Origin.Z + (maxTz + 1) * ts + border))); + } + + /// + /// Rebuild the tiles intersecting from caller-supplied + /// geometry, for games whose world isn't visible to the collectors (custom renderers, + /// custom collision). Pass the bounds of the CHANGED geometry — the affected tile set is + /// derived from them, expanded by the erosion border. Sources need only cover those tiles + /// plus the border (derive from and + /// ) — never the whole bake. + /// An empty source list is valid and empties the affected tiles. + /// + public bool RebuildTiles(AABB worldBounds, IReadOnlyList sources) + => RebuildTiles(worldBounds, sources, out _); + + /// + /// Number of tiles rebuilt or emptied, for cost profiling. + /// Area volumes applied to the rebuilt tiles. Null (the default) + /// collects the scene's s over the affected region — + /// note that collection walks the scene's active objects, so callers who chose explicit + /// sources to avoid scene scans should pass an empty list (no volumes, no scan) or their + /// own list. + public bool RebuildTiles(AABB worldBounds, IReadOnlyList sources, out int rebuiltTiles, + IReadOnlyList? volumes = null) + { + rebuiltTiles = 0; + Runtime.NavMeshData? data = _runtimeData; + if (World == null || _instance == null || data.IsNotValid()) + return false; + + volumes ??= CollectVolumes(RebuildCollectionBounds(worldBounds)); + List<(int X, int Z, List Layers)> rebuilt = NavMeshBuilder.BuildTilesInBounds( + data!, sources, worldBounds.Min, worldBounds.Max, DefaultArea, volumes: volumes); + return ApplyRebuiltTiles(rebuilt, out rebuiltTiles); + } + + /// + /// Voxelize the affected tiles on the thread pool, off the frame. The returned tiles are + /// NOT yet live — apply them with from the main thread. + /// Sequencing rules: do not run two rebuilds of overlapping regions concurrently, and do + /// not interleave with a full rebake — the build reads the asset's grid anchoring and the + /// apply assumes it is unchanged since dispatch. + /// + public Task Layers)>> RebuildTilesAsync( + AABB worldBounds, IReadOnlyList sources, CancellationToken cancellation = default, + IReadOnlyList? volumes = null) + { + // The surface's own copy, not the asset: the background build reads the tile grid off + // it, and it must be the grid the live mesh is on. + Runtime.NavMeshData? data = _runtimeData; + if (data.IsNotValid()) + return Task.FromResult(new List<(int, int, List)>()); + + // Volumes are collected on the calling (main) thread — they touch Transforms; the + // resulting payload is self-contained for the background build. The null default scans + // the scene's active objects — explicit-sources callers who avoid scene scans on + // purpose should pass [] (or their own list) instead. + volumes ??= CollectVolumes(RebuildCollectionBounds(worldBounds)); + int defaultArea = DefaultArea; + return Task.Run(() => NavMeshBuilder.BuildTilesInBounds( + data!, sources, worldBounds.Min, worldBounds.Max, defaultArea, cancellation, volumes), cancellation); + } + + /// + /// Swap rebuilt tiles (from or + /// ) into the live TileCache and mirror them + /// into the asset. Main thread only. The swap quiesces pending obstacle work, replaces each + /// tile's layers (the cache's RemoveTile doesn't remove the paired navmesh tile, so that's + /// done explicitly), refreshes every obstacle's touched-tile list (stale after a tile + /// replacement bumps its salt), then rebuilds the new tiles with carves re-applied. + /// + public bool ApplyRebuiltTiles(List<(int X, int Z, List Layers)> rebuilt, out int rebuiltTiles) + { + ArgumentNullException.ThrowIfNull(rebuilt); + rebuiltTiles = 0; + NavMeshWorld? world = World; + Runtime.NavMeshData? data = _runtimeData; + if (world == null || _instance == null || data.IsNotValid() || rebuilt.Count == 0) + return false; + rebuiltTiles = rebuilt.Count; + + world.MutateTileCache(_instance, cache => + { + // Quiesce: every obstacle settles and no pending rebuild references the tiles + // being replaced. Bounded — a healthy cache converges in a handful of slices. On + // exhaustion the refresh below skips unsettled obstacles, which is exactly the + // stale-carve failure this mechanism prevents, so it must not fail silent. + bool converged = false; + for (int i = 0; i < 1024 && !converged; i++) + converged = cache.Update(); + if (!converged) + Debug.LogWarning("[Navigation] ApplyRebuiltTiles: the tile cache did not converge within 1024 update slices; obstacle carves may not re-apply to the regenerated tiles."); + + DtNavMesh navMesh = cache.GetNavMesh(); + var addedRefs = new List(); + foreach ((int x, int z, List blobs) in rebuilt) + { + foreach (long tileRef in cache.GetTilesAt(x, z)) + { + // The cache's RemoveTile frees only the compressed tile; the built + // navmesh tile must be removed explicitly (while the header still exists). + DtTileCacheLayerHeader? header = cache.GetTileByRef(tileRef)?.header; + if (header != null) + { + long navRef = navMesh.GetTileRefAt(header.tx, header.ty, header.tlayer); + if (navRef != 0) navMesh.RemoveTile(navRef); + } + cache.RemoveTile(tileRef); + } + + foreach (byte[] blob in blobs) + { + try + { + long added = cache.AddTile(blob, 0); + if (added != 0) addedRefs.Add(added); + else Debug.LogWarning($"[Navigation] ApplyRebuiltTiles: layer for tile ({x}, {z}) collided with an existing layer slot and was skipped."); + } + catch (Exception e) + { + // AddTile throws on cache tile-capacity exhaustion (regeneration can + // legitimately grow the layer count past the bake's). + Debug.LogWarning($"[Navigation] ApplyRebuiltTiles: failed to add a layer for tile ({x}, {z}): {e.Message}"); + } + } + } + + NavMeshTileBuilder.RefreshObstacleTouchedTiles(cache, data!); + + foreach (long added in addedRefs) + cache.BuildNavMeshTile(added); // re-contours with carves applied via the refreshed lists + }); + + // Mirror the swap into this surface's runtime copy so a later re-instantiation agrees + // with the live mesh; the .navmesh asset on disk is not touched. Obstacles are runtime + // state and never serialize, so the copy holds clean regenerated layers. Single pass + // over the tile list: RemoveAll-per-tile would be O(total x rebuilt). + var replaced = new HashSet<(int, int)>(rebuilt.Count); + foreach ((int x, int z, _) in rebuilt) + replaced.Add((x, z)); + data!.CacheLayers.RemoveAll(t => replaced.Contains((t.X, t.Z))); + foreach ((int x, int z, List blobs) in rebuilt) + foreach (byte[] blob in blobs) + data.CacheLayers.Add(new Runtime.NavMeshData.NavMeshTile { X = x, Z = z, Data = blob }); + + return true; + } + + /// Collect this surface's bake geometry from the scene (main thread). + public List CollectSources() => CollectSources(null); + + /// + public List CollectSources(AABB? filterBounds) + => CollectSources(filterBounds, ResolveBuildSettings().EffectiveVoxelSize); + + /// + /// Collect this surface's bake geometry, restricted to objects whose bounds intersect + /// (conservative test against transformed local bounds) — + /// partial rebuilds pass their affected-tile rect so collection cost scales with the changed + /// region. Volume mode composes: the volume intersects with the filter. + /// sets terrain decimation granularity — pass whatever + /// settings the geometry will be voxelized with (bakes: freshly resolved; partial rebuilds: + /// the asset's snapshot). + /// + public List CollectSources(AABB? filterBounds, float terrainVoxelSize) + { + List sources = []; + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return sources; + if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds)) + return sources; // filter rect entirely outside the volume + + IEnumerable objects = CollectObjects == NavMeshCollectObjects.Children + ? EnumerateSelfAndChildren(GameObject) + : scene!.ActiveObjects; + + NavMeshGeometryCollector.Collect(objects, UseGeometry, Layers, terrainVoxelSize, DefaultArea, sources, bounds, AgentTypeId); + return sources; + } + + /// + /// Compose an optional world-space filter with the Volume-mode extent (the one bounds + /// rule shared by geometry and volume collection). False when the intersection is empty — + /// nothing can be collected. + /// + private bool TryResolveCollectionBounds(AABB? filterBounds, out AABB? bounds) + { + bounds = filterBounds; + if (CollectObjects != NavMeshCollectObjects.Volume) return true; + + AABB volume = VolumeBounds; + if (bounds is AABB b) + { + if (!b.Intersects(volume)) return false; + bounds = b.ClippedBy(volume); + } + else + { + bounds = volume; + } + return true; + } + + /// + /// Collect the scene's s that apply to this surface's + /// agent type (main thread), optionally restricted to volumes overlapping + /// . Same object scoping (CollectObjects/Layers) as + /// geometry collection. + /// + public List CollectVolumes(AABB? filterBounds) + { + List volumes = []; + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return volumes; + if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds)) + return volumes; + + IEnumerable objects = CollectObjects == NavMeshCollectObjects.Children + ? EnumerateSelfAndChildren(GameObject) + : scene!.ActiveObjects; + + NavMeshGeometryCollector.CollectModifierVolumes(objects, Layers, AgentTypeId, volumes, bounds); + return volumes; + } + + /// + /// Collect the scene's s that apply to this surface's agent type + /// (main thread), optionally restricted to links overlapping + /// . Same object scoping (CollectObjects/Layers) as + /// geometry collection. + /// + public List CollectLinks(AABB? filterBounds) + { + List links = []; + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return links; + if (!TryResolveCollectionBounds(filterBounds, out AABB? bounds)) + return links; + + // Scene-wide collection reads the world's registry rather than every GameObject: a link + // edit re-collects on the spot, so this runs at gameplay rate. Children mode still walks, + // because what it scopes to is the hierarchy. + if (CollectObjects == NavMeshCollectObjects.Children) + NavMeshGeometryCollector.CollectLinks(EnumerateSelfAndChildren(GameObject), Layers, AgentTypeId, links, bounds); + else + NavMeshGeometryCollector.CollectLinks(scene!.Navigation.Links, Layers, AgentTypeId, links, bounds); + return links; + } + + private static IEnumerable EnumerateSelfAndChildren(GameObject root) + { + yield return root; + foreach (GameObject child in root.Children) + { + if (child.IsNotValid()) continue; + foreach (GameObject go in EnumerateSelfAndChildren(child)) + yield return go; + } + } + + #endregion + + #region Gizmos + + private NavMeshTriangulation? _debugTriangulation; + private NavMeshWorld? _debugWorld; + // What the cached triangulation was built from, so it rebuilds when the asset is swapped + // or the surface gains/loses a live registration (entering or leaving play mode). + private Runtime.NavMeshData? _debugSource; + private bool _debugFromLive; + private List<(Float3 Position, bool Corner)>? _debugVertexMarkers; + private List<(Float3 A, Float3 B)>? _debugDetailEdges; + + private void InvalidateDebugTriangulation() => _debugTriangulation = null; + + /// Unselected drawing: only the walkable overlay, and only when asked for. Watching + /// obstacles carve needs it while something else is selected — and entering play mode clears + /// the selection outright, so selection-only drawing cannot show a runtime carve at all. + /// + public override void DrawGizmos() + { + if (AlwaysShowNavMesh) DrawWalkableOverlay(); + } + + public override void DrawGizmosSelected() + { + if (CollectObjects == NavMeshCollectObjects.Volume) + Debug.DrawWireCube(Transform.TransformPoint(Center), Size * 0.5f, Color.Cyan); + + Runtime.NavMeshData? data = NavMeshData.Res; + if (data.IsNotValid() || !data!.HasTiles) + return; + + Debug.DrawWireCube((data.BoundsMin + data.BoundsMax) * 0.5f, (data.BoundsMax - data.BoundsMin) * 0.5f, Color.Blue); + + // Already drawn unselected — drawing it twice would double the blend. + if (!AlwaysShowNavMesh) DrawWalkableOverlay(); + } + + /// The walkable surface, colored per area. Cached; invalidated on navmesh change. + private void DrawWalkableOverlay() + { + Runtime.NavMeshData? data = NavMeshData.Res; + if (data.IsNotValid() || !data!.HasTiles) + return; + + NavMeshWorld? world = World; + if (world != null && _debugWorld != world) + { + if (_debugWorld != null) _debugWorld.NavMeshChanged -= InvalidateDebugTriangulation; + world.NavMeshChanged += InvalidateDebugTriangulation; + _debugWorld = world; + _debugTriangulation = null; + } + + // Prefer the live navmesh: it is the one carving and rebuilds change. Fall back to the + // baked asset when this surface has no live instance to read — its data failed to + // instantiate, or another surface of the same agent type holds the registration. + bool live = world != null && world.GetInstance(AgentTypeId) != null; + if (_debugTriangulation == null || _debugFromLive != live || !ReferenceEquals(_debugSource, data)) + { + _debugTriangulation = live ? world!.CalculateTriangulation(AgentTypeId) : data.CalculateTriangulation(); + _debugFromLive = live; + _debugSource = data; + _debugVertexMarkers = BuildVertexMarkers(_debugTriangulation.Value); + _debugDetailEdges = BuildDetailEdges(_debugTriangulation.Value); + } + + NavMeshTriangulation tri = _debugTriangulation.Value; + // Lift off the surface so the overlay doesn't z-fight the floor. Sized past the mesh's + // own error band: quantized heights interpolated between samples can dip a few + // centimetres below finely-tessellated ground, and fragments under the ground take the + // gizmo shader's faded occluded styling in patches. + var lift = new Float3(0, 0.08f, 0); + for (int t = 0; t < tri.Areas.Length; t++) + { + Color color = AreaColor(tri.Areas[t]); + Debug.DrawTriangle( + tri.Vertices[tri.Indices[t * 3 + 0]] + lift, + tri.Vertices[tri.Indices[t * 3 + 1]] + lift, + tri.Vertices[tri.Indices[t * 3 + 2]] + lift, + color); + } + + // Polygon outlines over the fill, the way Unity draws its navmesh: the walkable border + // dark and solid, inner polygon edges light, tile seams warm — so the mesh's structure + // reads at a glance and a wrong edge points at itself. + foreach (NavMeshEdge edge in tri.Edges) + { + Color c = edge.Kind switch + { + NavMeshEdgeKind.Border => new Color(0.05f, 0.12f, 0.35f, 1f), + NavMeshEdgeKind.TilePortal => new Color(0.9f, 0.55f, 0.15f, 1f), + _ => new Color(0.65f, 0.85f, 1f, 0.9f), + }; + Debug.DrawLine(edge.A + lift, edge.B + lift, c); + } + + DrawDetailWireframe(_debugDetailEdges, lift); + DrawVertexMarkers(_debugVertexMarkers, lift); + + foreach (NavMeshConnection con in tri.Connections) + DrawConnection(con, lift); + } + + /// Each height-detail edge once. Neighbouring triangles share two thirds of their + /// edges, so drawing three per triangle submits most of them twice and doubles the alpha + /// where they overlap. Built with the markers, once per triangulation. + private static List<(Float3 A, Float3 B)> BuildDetailEdges(NavMeshTriangulation tri) + { + var seen = new HashSet<(int, int)>(tri.Areas.Length * 2); + var edges = new List<(Float3, Float3)>(tri.Areas.Length * 2); + for (int t = 0; t < tri.Areas.Length; t++) + { + for (int e = 0; e < 3; e++) + { + int i = tri.Indices[t * 3 + e]; + int j = tri.Indices[t * 3 + (e + 1) % 3]; + if (seen.Add((Math.Min(i, j), Math.Max(i, j)))) + edges.Add((tri.Vertices[i], tri.Vertices[j])); + } + } + + return edges; + } + + /// The height-detail triangle edges, faint: the carpet inside each polygon, + /// distinct from the polygon outlines drawn on top of it. + private static void DrawDetailWireframe(List<(Float3 A, Float3 B)>? edges, Float3 lift) + { + if (edges == null) return; + + var c = new Color(1f, 1f, 1f, 0.18f); + foreach ((Float3 a, Float3 b) in edges) + Debug.DrawLine(a + lift, b + lift, c); + } + + /// + /// One solid dot per distinct vertex position: white for polygon corners — the welded, + /// tile-stitched structure — orange for vertices the height detail added. The triangulation + /// repeats shared corners once per polygon, so markers dedupe by position; a corner and a + /// detail vertex landing on the same spot counts as the corner, the stronger claim. Built + /// once per triangulation rebuild — this draws every frame. + /// + private static List<(Float3 Position, bool Corner)> BuildVertexMarkers(NavMeshTriangulation tri) + { + var seen = new Dictionary<(int, int, int), bool>(tri.Vertices.Length); + for (int v = 0; v < tri.Vertices.Length; v++) + { + Float3 p = tri.Vertices[v]; + var key = ((int)Math.Round(p.X * 128), (int)Math.Round(p.Y * 128), (int)Math.Round(p.Z * 128)); + bool corner = tri.IsPolygonCorner[v]; + if (seen.TryGetValue(key, out bool wasCorner) && (wasCorner || !corner)) + continue; + seen[key] = corner; + } + + var markers = new List<(Float3, bool)>(seen.Count); + foreach (KeyValuePair<(int, int, int), bool> m in seen) + markers.Add((new Float3(m.Key.Item1 / 128f, m.Key.Item2 / 128f, m.Key.Item3 / 128f), m.Value)); + return markers; + } + + private static void DrawVertexMarkers(List<(Float3 Position, bool Corner)>? markers, Float3 lift) + { + if (markers == null) return; + + var cornerColor = new Color(1f, 1f, 1f, 1f); + var detailColor = new Color(1f, 0.6f, 0.1f, 1f); + // One size for both: colour already says which is which, and a corner drawn larger + // reads as more important than the detail vertex beside it when they are the same + // thing to everything downstream. + foreach ((Float3 position, bool corner) in markers) + DrawSolidDot(position + lift, 0.03f, corner ? cornerColor : detailColor); + } + + /// A tiny solid octahedron: reads as a dot from any angle, unlike a wire sphere, + /// and costs eight small triangles. + private static void DrawSolidDot(Float3 p, float r, Color color) + { + var xp = new Float3(r, 0, 0); var yp = new Float3(0, r, 0); var zp = new Float3(0, 0, r); + Debug.DrawTriangle(p + yp, p + xp, p + zp, color); + Debug.DrawTriangle(p + yp, p + zp, p - xp, color); + Debug.DrawTriangle(p + yp, p - xp, p - zp, color); + Debug.DrawTriangle(p + yp, p - zp, p + xp, color); + Debug.DrawTriangle(p - yp, p + zp, p + xp, color); + Debug.DrawTriangle(p - yp, p - xp, p + zp, color); + Debug.DrawTriangle(p - yp, p - zp, p - xp, color); + Debug.DrawTriangle(p - yp, p + xp, p - zp, color); + } + + /// Endpoint marker size, shared so a link's own gizmo matches the overlay. + internal const float EndpointGizmoRadius = 0.15f; + + /// + /// One off-mesh connection, drawn where the navmesh put it rather than where the component + /// asked: endpoints that snapped elsewhere show it, and a link that never attached is + /// visibly absent. Opaque, because the translucent surface fill is a poor read for a line. + /// + internal static void DrawConnection(NavMeshConnection con, Float3 lift) + { + Color area = AreaColor(con.Area); + var color = new Color(area.R, area.G, area.B, 1f); + Float3 start = con.Start + lift, end = con.End + lift; + + Debug.DrawLine(start, end, color); + Debug.DrawWireSphere(start, EndpointGizmoRadius, color); + Debug.DrawWireSphere(end, EndpointGizmoRadius, color); + + // Measured flat: the markings read as ground plan, and a steep link would otherwise + // splay them out of the surface. + var flat = new Float3(end.X - start.X, 0, end.Z - start.Z); + double length = Math.Sqrt(flat.X * flat.X + flat.Z * flat.Z); + if (length <= 1e-4) return; + var forward = new Float3((float)(flat.X / length), 0, (float)(flat.Z / length)); + var perp = new Float3(-forward.Z, 0, forward.X); + + // The width the connection actually covers, as a bar across each end. + if (con.Radius > 0f) + { + Float3 half = perp * con.Radius; + Debug.DrawLine(start - half, start + half, color); + Debug.DrawLine(end - half, end + half, color); + } + + // One-way connections get an arrowhead; a bidirectional one is just the line. + if (con.Bidirectional) return; + Float3 back = end - forward * (EndpointGizmoRadius * 3f); + Float3 barb = perp * (EndpointGizmoRadius * 1.5f); + Debug.DrawLine(end, back + barb, color); + Debug.DrawLine(end, back - barb, color); + } + + /// Stable debug color for an area index (Walkable is the familiar navmesh blue). + public static Color AreaColor(int areaIndex) + { + if (areaIndex == NavMeshAreas.Walkable) return new Color(0f, 0.75f, 1f, 0.35f); + // Deterministic hue per area index. + float hue = (areaIndex * 137.5f) % 360f / 360f; + float r = Math.Abs(hue * 6f - 3f) - 1f; + float g = 2f - Math.Abs(hue * 6f - 2f); + float b = 2f - Math.Abs(hue * 6f - 4f); + return new Color(Math.Clamp(r, 0f, 1f), Math.Clamp(g, 0f, 1f), Math.Clamp(b, 0f, 1f), 0.35f); + } + + #endregion +} diff --git a/Prowl.Runtime/Navigation/NavMesh.cs b/Prowl.Runtime/Navigation/NavMesh.cs new file mode 100644 index 000000000..18254882b --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMesh.cs @@ -0,0 +1,103 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Runtime.Resources; +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Static navigation query API over the current scene's — the +/// Unity-style entry point (NavMesh.CalculatePath, NavMesh.SamplePosition, ...). +/// Multi-scene setups and background threads that outlive scene changes should use +/// on a specific scene instead; this facade always targets +/// . Every method degrades to "no result" when no scene or navmesh +/// is loaded. +/// +public static class NavMesh +{ + /// Area mask that includes every area. + public const int AllAreas = NavMeshAreas.AllAreas; + + /// The current scene's navigation world, or null when no scene is loaded. + public static NavMeshWorld? World + { + get + { + Scene? scene = Scene.Current; + return scene.IsValid() ? scene.Navigation : null; + } + } + + /// Calculate a path between two points. Returns true when a complete or partial + /// path was found; carries the corners and exact status. + public static bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, int areaMask, NavMeshPath path) + => World?.CalculatePath(sourcePosition, targetPosition, areaMask, path) ?? MarkInvalid(path); + + /// + public static bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, NavMeshQueryFilter filter, NavMeshPath path) + => World?.CalculatePath(sourcePosition, targetPosition, filter, path) ?? MarkInvalid(path); + + private static bool MarkInvalid(NavMeshPath path) + { + path?.ClearCorners(); + return false; + } + + /// Find the closest navmesh point within of a position. + public static bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, int areaMask) + { + NavMeshWorld? world = World; + if (world != null) return world.SamplePosition(sourcePosition, out hit, maxDistance, areaMask); + hit = default; + return false; + } + + /// + public static bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, NavMeshQueryFilter filter) + { + NavMeshWorld? world = World; + if (world != null) return world.SamplePosition(sourcePosition, out hit, maxDistance, filter); + hit = default; + return false; + } + + /// Trace a walkability ray along the navmesh. Returns true when blocked before the target. + public static bool Raycast(Float3 sourcePosition, Float3 targetPosition, out NavMeshHit hit, int areaMask) + { + NavMeshWorld? world = World; + if (world != null) return world.Raycast(sourcePosition, targetPosition, out hit, areaMask); + hit = default; + return false; + } + + /// Locate the closest navmesh border edge from a point. + public static bool FindClosestEdge(Float3 sourcePosition, out NavMeshHit hit, int areaMask) + { + NavMeshWorld? world = World; + if (world != null) return world.FindClosestEdge(sourcePosition, out hit, areaMask); + hit = default; + return false; + } + + /// Triangulate the current navmesh for debug drawing or user tooling. + public static NavMeshTriangulation CalculateTriangulation() + => World?.CalculateTriangulation() ?? new NavMeshTriangulation { Vertices = [], Indices = [], Areas = [] }; + + /// Register a baked navmesh with the current scene. Returns its handle, or null. + public static NavMeshInstance? AddNavMeshData(NavMeshData navMeshData) + => World?.AddNavMeshData(navMeshData); + + /// Unregister a navmesh from the current scene. + public static void RemoveNavMeshData(NavMeshInstance? instance) + => World?.RemoveNavMeshData(instance); + + /// Find an area index by name, or -1. + public static int GetAreaFromName(string areaName) => NavMeshAreas.GetAreaFromName(areaName); + + /// The project-default path cost multiplier for an area. + public static float GetAreaCost(int areaIndex) => NavMeshAreas.GetAreaCost(areaIndex); + + /// Set the project-default path cost multiplier for an area. + public static void SetAreaCost(int areaIndex, float cost) => NavMeshAreas.SetAreaCost(areaIndex, cost); +} diff --git a/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs b/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs new file mode 100644 index 000000000..2379f4015 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs @@ -0,0 +1,184 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +namespace Prowl.Runtime; + +/// +/// One project-level agent type: the physical envelope navmeshes are voxelized for. Surfaces +/// and agents reference an entry by ; the inspector shows the name +/// (via ). +/// +public sealed class NavMeshAgentType +{ + /// Persistent identifier. Stable across renames and removals of other types — + /// never an index into the table. + public int Id; + + public string Name = string.Empty; + + /// Agent radius in world units. Walkable surfaces are eroded by this distance from walls. + public float Radius = 0.5f; + + /// Agent height in world units. Spaces lower than this are not walkable. + public float Height = 2.0f; + + /// Maximum walkable slope angle in degrees. + public float MaxSlope = 45f; + + /// Maximum ledge height the agent can step up, in world units. + public float MaxClimb = 0.4f; + + /// Copy, so the table holds entries of its own rather than the caller's objects. + /// Field by field: a reference field added later would be shared, and settings loading would + /// hand every table entry the same one. + public NavMeshAgentType Clone() => new() + { + Id = Id, + Name = Name, + Radius = Radius, + Height = Height, + MaxSlope = MaxSlope, + MaxClimb = MaxClimb, + }; +} + +/// +/// The project-wide agent type table (mirrors ): defined in the +/// editor's navigation settings, restored in players from Navigation.yaml, with a code-side +/// default (the built-in Humanoid, id 0) so headless and procedural use needs no settings +/// file. Unity's Agents tab equivalent. +/// +public static class NavMeshAgentTypes +{ + /// The built-in default agent type id. Always present; cannot be removed. + public const int Humanoid = 0; + + // Replaced wholesale rather than edited in place (see NavMeshAreas): bakes resolve their + // envelope from here off the main thread while the settings UI rewrites the table, and a + // reader walking one mid-rebuild could index past its own end. + private static volatile NavMeshAgentType[] s_types = [CreateHumanoid()]; + + private static NavMeshAgentType CreateHumanoid() => new() + { + Id = Humanoid, + Name = "Humanoid", + Radius = 0.5f, + Height = 2.0f, + MaxSlope = 45f, + MaxClimb = 0.4f, + }; + + /// All defined agent types, in table order. Do not mutate the entries — use + /// (settings) to change the table. + public static IReadOnlyList All => s_types; + + /// The agent type with the given id, or null when undefined. + public static NavMeshAgentType? Get(int agentTypeId) => Find(s_types, agentTypeId); + + private static NavMeshAgentType? Find(IReadOnlyList types, int agentTypeId) + { + for (int i = 0; i < types.Count; i++) + if (types[i].Id == agentTypeId) + return types[i]; + return null; + } + + /// Display name for an agent type id ("Agent Type N" for undefined ids, so stale + /// references stay visible rather than blank). + public static string GetName(int agentTypeId) + => Get(agentTypeId)?.Name ?? $"Agent Type {agentTypeId}"; + + /// Find an agent type id by name, or -1. Null/empty never matches. + public static int GetIdFromName(string name) + { + if (string.IsNullOrEmpty(name)) return -1; + NavMeshAgentType[] types = s_types; + for (int i = 0; i < types.Length; i++) + if (string.Equals(types[i].Name, name, StringComparison.Ordinal)) + return types[i].Id; + return -1; + } + + /// + /// Replace the table (called by settings loading). The built-in Humanoid entry is + /// enforced: id 0 always exists and keeps its name, though its envelope values are + /// editable like Unity's. + /// + public static void ApplyTable(IEnumerable types) + { + ArgumentNullException.ThrowIfNull(types); + + // Built aside and published in one store, so a reader never sees the table empty or + // half-filled. + List built = []; + foreach (NavMeshAgentType type in types) + { + if (type == null) continue; + NavMeshAgentType? clash = Find(built, type.Id); + if (clash != null) + { + // Unreachable from the editor UI; a hand-edited Navigation.yaml can do it. + // Get returns the first match, so a silent duplicate would shadow the second. + Debug.LogWarning($"[Navigation] Duplicate agent type id {type.Id} ('{type.Name}') ignored; '{clash.Name}' keeps the id."); + continue; + } + NavMeshAgentType copy = type.Clone(); + if (copy.Id == Humanoid) copy.Name = "Humanoid"; + built.Add(copy); + } + + if (Find(built, Humanoid) == null) + built.Insert(0, CreateHumanoid()); + s_types = [.. built]; + } + + /// + /// Compose the resolved bake input for an agent type: envelope from the table, everything + /// else from (or defaults). This is what surfaces hand to + /// ; the builder API itself only ever sees the resolved + /// . An undefined id falls back to the Humanoid envelope + /// with a warning — a bake with wrong-but-sane dimensions beats no bake. + /// + public static NavMeshBuildSettings GetBuildSettings(int agentTypeId, NavMeshBuildOverrides? overrides = null) + { + NavMeshAgentType? type = Get(agentTypeId); + if (type == null) + { + Debug.LogWarning($"[Navigation] Agent type {agentTypeId} is not defined in the navigation settings; baking with the Humanoid envelope. Define it in Project Settings > Navigation > Agents."); + type = Get(Humanoid) ?? CreateHumanoid(); + } + + overrides ??= s_defaultOverrides; + return new NavMeshBuildSettings + { + AgentTypeId = agentTypeId, + AgentRadius = type.Radius, + AgentHeight = type.Height, + AgentMaxSlope = type.MaxSlope, + AgentMaxClimb = type.MaxClimb, + + OverrideVoxelSize = overrides.OverrideVoxelSize, + VoxelSize = overrides.VoxelSize, + OverrideTileSize = overrides.OverrideTileSize, + TileSize = overrides.TileSize, + MinRegionArea = overrides.MinRegionArea, + EdgeMaxError = overrides.EdgeMaxError, + FilterLowHangingObstacles = overrides.FilterLowHangingObstacles, + FilterLedgeSpans = overrides.FilterLedgeSpans, + FilterWalkableLowHeightSpans = overrides.FilterWalkableLowHeightSpans, + BuildHeightDetail = overrides.BuildHeightDetail, + }; + } + + private static readonly NavMeshBuildOverrides s_defaultOverrides = new(); +} + +/// +/// Draws an int field as a dropdown of the agent types defined in project settings, instead +/// of a raw id (the agent-type analogue of ). +/// +[AttributeUsage(AttributeTargets.Field)] +public class NavMeshAgentTypeAttribute : Attribute { } diff --git a/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs b/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs new file mode 100644 index 000000000..574a2fde0 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs @@ -0,0 +1,20 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +namespace Prowl.Runtime; + +/// +/// Draws an int field as a dropdown of the navigation areas defined in project settings +/// (see ), instead of a raw number. +/// +[AttributeUsage(AttributeTargets.Field)] +public class NavMeshAreaAttribute : Attribute { } + +/// +/// Draws an int field as a multi-select of the navigation areas defined in project settings +/// (like a layer mask), instead of a raw bitmask number. +/// +[AttributeUsage(AttributeTargets.Field)] +public class NavMeshAreaMaskAttribute : Attribute { } diff --git a/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs b/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs new file mode 100644 index 000000000..45d55df21 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAreaVolume.cs @@ -0,0 +1,108 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// A world-space convex prism that stamps an area over already-rasterized geometry during a +/// bake (the payload of , applied via Recast's convex +/// volume marking). Self-contained — no Transform or component references — so it is safe to +/// hand to a background build alongside s. A volume only +/// re-marks voxels that geometry produced; it never creates walkable surface on its own, and +/// an volume erases walkability inside its footprint +/// (Unity's Modifier Volume behaviour). +/// +public readonly struct NavMeshAreaVolume +{ + /// World-space convex footprint polygon; only X/Z are used. + public readonly Float3[] Footprint; + + /// Vertical extent of the prism, in world space. + public readonly float MinY, MaxY; + + /// The area stamped inside the volume (see ). + public readonly int Area; + + public NavMeshAreaVolume(Float3[] footprint, float minY, float maxY, int area) + { + Footprint = footprint ?? throw new ArgumentNullException(nameof(footprint)); + MinY = minY; + MaxY = maxY; + Area = area; + } + + /// Conservative world AABB of the prism, for bounds filtering. + public AABB Bounds + { + get + { + // XZ from the footprint, Y from the prism's own extent (the hull keeps only the + // XZ-extreme corners, so their Y values don't span the prism). + AABB footprint = AABB.FromPoints(Footprint); + return new AABB( + new Float3(footprint.Min.X, MinY, footprint.Min.Z), + new Float3(footprint.Max.X, MaxY, footprint.Max.Z)); + } + } + + /// + /// Build the volume for an oriented box (local center/size under a world transform): the + /// 8 corners are transformed, the vertical range is their Y span, and the footprint is the + /// convex hull of their XZ projection. Yaw-only boxes yield a 4-gon; arbitrary rotations + /// project to up-to-6-gons, which stay convex and are marked exactly. + /// + public static NavMeshAreaVolume FromOrientedBox(in Float4x4 localToWorld, Float3 center, Float3 size, int area) + { + Float3 half = size * 0.5f; + var corners = new Float3[8]; + float minY = float.MaxValue, maxY = float.MinValue; + for (int i = 0; i < 8; i++) + { + var local = new Float3( + center.X + ((i & 1) == 0 ? -half.X : half.X), + center.Y + ((i & 2) == 0 ? -half.Y : half.Y), + center.Z + ((i & 4) == 0 ? -half.Z : half.Z)); + Float3 world = Float4x4.TransformPoint(local, localToWorld); + corners[i] = world; + minY = Math.Min(minY, (float)world.Y); + maxY = Math.Max(maxY, (float)world.Y); + } + + return new NavMeshAreaVolume(ConvexHullXZ(corners), minY, maxY, area); + } + + /// 2D convex hull (monotone chain) over the points' XZ projection. + private static Float3[] ConvexHullXZ(Float3[] points) + { + var sorted = new List(points); + sorted.Sort((a, b) => a.X != b.X ? a.X.CompareTo(b.X) : a.Z.CompareTo(b.Z)); + + static double Cross(Float3 o, Float3 a, Float3 b) + => (a.X - o.X) * (b.Z - o.Z) - (a.Z - o.Z) * (b.X - o.X); + + // Monotone chain. <= 0 drops collinear (and duplicate) points, so degenerate + // projections (an edge-on box) collapse below 3 vertices and the volume marks nothing. + var hull = new List(sorted.Count); + foreach (Float3 p in sorted) // lower hull + { + while (hull.Count >= 2 && Cross(hull[^2], hull[^1], p) <= 0) + hull.RemoveAt(hull.Count - 1); + hull.Add(p); + } + int lowerEnd = hull.Count + 1; + for (int i = sorted.Count - 2; i >= 0; i--) // upper hull (sorted[^1] already placed) + { + Float3 p = sorted[i]; + while (hull.Count >= lowerEnd && Cross(hull[^2], hull[^1], p) <= 0) + hull.RemoveAt(hull.Count - 1); + hull.Add(p); + } + hull.RemoveAt(hull.Count - 1); // the upper hull's last point repeats hull[0] + return [.. hull]; + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshAreas.cs b/Prowl.Runtime/Navigation/NavMeshAreas.cs new file mode 100644 index 000000000..cbe2b60b7 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAreas.cs @@ -0,0 +1,156 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Prowl.Runtime; + +/// +/// The project-wide navigation area table: up to 32 named areas with per-area path costs, +/// addressed by index and combined into 32-bit area masks (matching Unity's area model). +/// Area 0 is Walkable, area 1 is Not Walkable, area 2 is Jump; 3..31 are user-defined. +/// +/// Internally Detour stores a 6-bit area id per polygon where 0 (RC_NULL_AREA) is +/// reserved for "not part of the navmesh", so Prowl area index i is stored as Detour +/// area i + 1. / are the only +/// places that offset is applied — never hand-roll it. BAKE-side conversions go through +/// ProwlInputGeomProvider.DetourAreaFor instead, which additionally maps +/// to the null area (calling directly on +/// a source/volume area silently resurrects traversable "Not Walkable" polys). +/// +public static class NavMeshAreas +{ + /// Maximum number of areas (indices 0..31), the width of an area mask. + public const int MaxAreas = 32; + + /// The built-in default walkable area index. + public const int Walkable = 0; + + /// The built-in not-walkable area index. Geometry marked with this area is + /// excluded from the navmesh entirely. + public const int NotWalkable = 1; + + /// The built-in area index used for jump/off-mesh connections. + public const int Jump = 2; + + /// Area mask that includes every area. + public const int AllAreas = -1; + + // Replaced wholesale rather than edited in place: query filters read the cost table from + // worker threads while the settings UI rewrites it on every keystroke, and publishing a new + // array is one atomic store, so a reader gets a whole revision of the table rather than one + // caught mid-edit. Writers take a lock so two of them cannot clobber each other's copy. + private static volatile string[] s_names = CreateDefaultNames(); + private static volatile float[] s_costs = CreateDefaultCosts(); + private static readonly Lock s_writeLock = new(); + + private static string[] CreateDefaultNames() + { + string[] names = new string[MaxAreas]; + for (int i = 0; i < names.Length; i++) names[i] = string.Empty; + names[Walkable] = "Walkable"; + names[NotWalkable] = "Not Walkable"; + names[Jump] = "Jump"; + return names; + } + + private static float[] CreateDefaultCosts() + { + float[] costs = new float[MaxAreas]; + for (int i = 0; i < costs.Length; i++) costs[i] = 1f; + costs[Jump] = 2f; + return costs; + } + + /// Get the name of an area, or an empty string for unnamed user areas. + public static string GetAreaName(int areaIndex) + { + if (areaIndex < 0 || areaIndex >= MaxAreas) return string.Empty; + return s_names[areaIndex]; + } + + /// Rename an area. Built-in areas (0-2) cannot be renamed. + public static void SetAreaName(int areaIndex, string name) + { + if (areaIndex <= Jump || areaIndex >= MaxAreas) return; + lock (s_writeLock) + { + string[] names = [.. s_names]; + names[areaIndex] = name ?? string.Empty; + s_names = names; + } + } + + /// Find an area index by name, or -1 when no area has that name. Null/empty never + /// matches (unnamed slots store empty strings). + public static int GetAreaFromName(string areaName) + { + if (string.IsNullOrEmpty(areaName)) return -1; + string[] names = s_names; + for (int i = 0; i < MaxAreas; i++) + if (string.Equals(names[i], areaName, StringComparison.Ordinal)) + return i; + return -1; + } + + /// The default path cost multiplier for an area (used when a query filter does not override it). + public static float GetAreaCost(int areaIndex) + { + if (areaIndex < 0 || areaIndex >= MaxAreas) return 1f; + return s_costs[areaIndex]; + } + + /// Set the default path cost multiplier for an area. Clamped to >= 1: Detour's + /// A* heuristic (straight-line distance) is only admissible when no traversal is cheaper + /// than distance itself, so costs below 1 would silently produce suboptimal paths. To + /// express "agents prefer this area", raise every OTHER area's cost above 1 instead. + public static void SetAreaCost(int areaIndex, float cost) + { + if (areaIndex < 0 || areaIndex >= MaxAreas) return; + lock (s_writeLock) + { + float[] costs = [.. s_costs]; + costs[areaIndex] = Math.Max(1f, cost); + s_costs = costs; + } + } + + /// Replace the whole area table (names + costs). Called by project-settings loading. + public static void ApplyTable(IReadOnlyList names, IReadOnlyList costs) + { + lock (s_writeLock) + { + string[] newNames = [.. s_names]; + float[] newCosts = [.. s_costs]; + for (int i = 0; i < MaxAreas; i++) + { + if (names != null && i < names.Count && i > Jump) newNames[i] = names[i] ?? string.Empty; + if (costs != null && i < costs.Count) newCosts[i] = Math.Max(1f, costs[i]); + } + s_names = newNames; + s_costs = newCosts; + } + } + + /// Indices of all defined areas (the built-ins plus every user area with a + /// non-empty name), in index order. What area dropdowns enumerate. + public static List GetDefinedAreas() + { + string[] names = s_names; + var defined = new List(8); + for (int i = 0; i < MaxAreas; i++) + if (i <= Jump || !string.IsNullOrEmpty(names[i])) + defined.Add(i); + return defined; + } + + /// Convert a Prowl area index (0..31) to the Detour polygon area value (1..32). + public static int ToDetourArea(int areaIndex) => Math.Clamp(areaIndex, 0, MaxAreas - 1) + 1; + + /// Convert a Detour polygon area value back to a Prowl area index. Returns + /// for the reserved null area (0), which should not appear on + /// polygons that made it into a navmesh. + public static int FromDetourArea(int detourArea) => detourArea <= 0 ? NotWalkable : Math.Min(detourArea - 1, MaxAreas - 1); +} diff --git a/Prowl.Runtime/Navigation/NavMeshBuildSettings.cs b/Prowl.Runtime/Navigation/NavMeshBuildSettings.cs new file mode 100644 index 000000000..4419859a7 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshBuildSettings.cs @@ -0,0 +1,153 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +namespace Prowl.Runtime; + +/// +/// The parameters a navmesh is voxelized and built with: the agent's physical envelope plus +/// Recast rasterization detail. One instance describes one agent type; the project-wide agent +/// type table lives in navigation settings and surfaces reference an entry by . +/// Defaults match Unity's Humanoid agent. +/// +public sealed class NavMeshBuildSettings +{ + /// Identifies the agent type this navmesh is built for. Agents only use navmeshes + /// built for their own agent type. + public int AgentTypeId = 0; + + /// Agent radius in world units. Walkable surfaces are eroded by this distance from walls. + public float AgentRadius = 0.5f; + + /// Agent height in world units. Spaces lower than this are not walkable. + public float AgentHeight = 2.0f; + + /// Maximum walkable slope angle in degrees. + public float AgentMaxSlope = 45f; + + /// Maximum ledge height the agent can step up, in world units. + public float AgentMaxClimb = 0.4f; + + /// When false, the voxel size is derived from the agent radius (radius / 3, matching + /// Unity). Set true to use directly. + public bool OverrideVoxelSize = false; + + /// Explicit XZ voxel size in world units, used when is set. + [EnableIf(nameof(OverrideVoxelSize))] + public float VoxelSize = 0.1666667f; + + /// When false, the tile size defaults to voxels. Set + /// true to use directly. + public bool OverrideTileSize = false; + + /// Tile side length in voxels, used when is set. + /// Smaller tiles make partial rebuilds and carving cheaper but add per-tile overhead. + /// Clamped to 16... + [EnableIf(nameof(OverrideTileSize))] + public int TileSize = DefaultTileSize; + + /// Regions with a surface area smaller than this (world units²) are culled. + public float MinRegionArea = 2f; + + /// Maximum distance the simplified border may deviate from the raw contour, in voxels. + public float EdgeMaxError = 1.3f; + + /// Remove spans over low hanging walkable obstacles (curbs, steps). + public bool FilterLowHangingObstacles = true; + + /// Remove spans at ledges, preventing paths that overhang drops. + public bool FilterLedgeSpans = true; + + /// Remove walkable spans with too little clearance above them. + public bool FilterWalkableLowHeightSpans = true; + + /// Sample heights across each polygon so agents follow the surface it covers rather + /// than a plane through its corners. Turn off only where the ground is flat or planar, which + /// is where the detail costs build time and describes nothing the corners do not. + public bool BuildHeightDetail = true; + + /// The XZ voxel size actually used for the build. + public float EffectiveVoxelSize => OverrideVoxelSize ? Math.Max(0.01f, VoxelSize) : Math.Max(0.01f, AgentRadius / 3f); + + /// The voxel height actually used for the build (half the XZ voxel size). + public float EffectiveVoxelHeight => EffectiveVoxelSize * 0.5f; + + /// The tile side length in voxels actually used for the build. A bake stores the + /// resolved value back into its settings, so a baked asset always reports what was really + /// used. + public int EffectiveTileSize => OverrideTileSize ? Math.Clamp(TileSize, 16, MaxTileSize) : DefaultTileSize; + + /// Largest tile size a navmesh can represent: compressed layer headers store the + /// layer's grid dimensions in a byte, and a wider tile wraps to an empty layer — a navmesh + /// with no polygons at all, from a bake that reported success. + public const int MaxTileSize = 255; + + /// Tile size used when nothing is overridden. Carving re-contours a whole tile, so + /// tile size is the per-carve cost and the default stays well under the cap. + public const int DefaultTileSize = 64; + + /// Snapshot copy, so a bake isn't mutated by later inspector edits. Written out + /// field by field rather than memberwise: a reference field added later would be shared by + /// every copy, and the first symptom would be one bake's settings changing under another. + public NavMeshBuildSettings Clone() => new() + { + AgentTypeId = AgentTypeId, + AgentRadius = AgentRadius, + AgentHeight = AgentHeight, + AgentMaxSlope = AgentMaxSlope, + AgentMaxClimb = AgentMaxClimb, + OverrideVoxelSize = OverrideVoxelSize, + VoxelSize = VoxelSize, + OverrideTileSize = OverrideTileSize, + TileSize = TileSize, + MinRegionArea = MinRegionArea, + EdgeMaxError = EdgeMaxError, + FilterLowHangingObstacles = FilterLowHangingObstacles, + FilterLedgeSpans = FilterLedgeSpans, + FilterWalkableLowHeightSpans = FilterWalkableLowHeightSpans, + BuildHeightDetail = BuildHeightDetail, + }; +} + +/// +/// The surface-level half of the bake parameters: rasterization detail that belongs to a +/// particular bake rather than to an agent type (whose envelope comes from the project-level +/// table). Composed into a resolved +/// by . +/// Defaults match Unity's; most bakes never need to touch these. +/// +public sealed class NavMeshBuildOverrides +{ + [Tooltip("Use an explicit voxel size instead of deriving it from the agent radius (radius / 3). Smaller voxels capture finer geometry and cost more bake time and memory.")] + public bool OverrideVoxelSize = false; + + [Tooltip("Explicit XZ voxel size in world units, used when Override Voxel Size is on. The navmesh cannot represent features smaller than this.")] + [EnableIf(nameof(OverrideVoxelSize))] + public float VoxelSize = 0.1666667f; + + [Tooltip("Use an explicit tile size instead of the default (64 voxels). Smaller tiles make partial rebuilds and obstacle carving cheaper (less area re-voxelized per change) but add per-tile overhead.")] + public bool OverrideTileSize = false; + + [Tooltip("Tile side length in voxels, used when Override Tile Size is on. Capped at 255 (a format limit: layer headers store tile dimensions in a byte). Carving re-contours a whole tile, so keep this small.")] + [EnableIf(nameof(OverrideTileSize))] + public int TileSize = NavMeshBuildSettings.DefaultTileSize; + + [Tooltip("Walkable regions with a surface area smaller than this (world units squared) are removed. Raise it to cull small isolated islands like table tops.")] + public float MinRegionArea = 2f; + + [Tooltip("How far the simplified border may deviate from the raw voxel contour, in voxels. Lower is more faithful and produces more polygons.")] + public float EdgeMaxError = 1.3f; + + [Tooltip("Treat low obstacles (curbs, steps) the agent can climb as walkable.")] + public bool FilterLowHangingObstacles = true; + + [Tooltip("Remove walkable voxels at ledges, preventing paths that overhang drops.")] + public bool FilterLedgeSpans = true; + + [Tooltip("Remove walkable voxels with too little clearance above them for the agent to stand.")] + public bool FilterWalkableLowHeightSpans = true; + + [Tooltip("Sample heights across each polygon so agents follow the ground it covers. Without it a polygon is flat between its corners, which is exact on floors and ramps but stretches across curved ground like terrain. Costs build time on every tile, including each obstacle carve, so turn it off for scenes built entirely from flat and planar surfaces.")] + public bool BuildHeightDetail = true; +} diff --git a/Prowl.Runtime/Navigation/NavMeshBuilder.cs b/Prowl.Runtime/Navigation/NavMeshBuilder.cs new file mode 100644 index 000000000..655936ade --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshBuilder.cs @@ -0,0 +1,348 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Builds a from collected geometry. Pure CPU work over an +/// already-flattened triangle soup — no Transform or GameObject access — so it is safe to run +/// on a background thread once the sources have been collected on the main thread. +/// Navmeshes are always built tiled so they can be partially rebuilt later +/// (see NavMeshSurface.RebuildTiles). +/// +public static class NavMeshBuilder +{ + /// + /// Build a complete navmesh from geometry sources. Returns null when nothing walkable was + /// produced (no geometry, all down-facing, cancelled) — never an empty NavMeshData. + /// + /// Agent envelope + voxelization parameters. Snapshotted into the result. + /// Collected geometry. Vertices are transformed by each source's matrix during flattening. + /// Area for sources that don't specify one (see ). + /// Worker threads for tile building. 0 or 1 builds single-threaded (deterministic tile order). + /// Cancels between tiles; a cancelled build returns null. + /// Explicit XZ extent for the tile grid. Supply this when the + /// walkable world will GROW after baking (destructible/streamed maps): the grid and tile + /// capacity size from it instead of the initial geometry, so RebuildTiles can add + /// tiles anywhere inside it. Vertical range still unions with the geometry, since callers + /// know their footprint but not their height. + /// Convex area volumes stamped over the rasterized geometry (from + /// s, or built directly). Volumes never create + /// walkable surface; a Not Walkable volume erases it. + /// Off-mesh connections placed in the tiles containing their start + /// points (from s, or built directly). Stored on the asset and + /// re-injected as each tile is contoured, since tiles are rebuilt from geometry-only layers + /// at runtime. + public static NavMeshData? Build(NavMeshBuildSettings settings, IReadOnlyList sources, + int defaultArea = NavMeshAreas.Walkable, int threads = 0, CancellationToken cancellation = default, + AABB? worldBounds = null, IReadOnlyList? volumes = null, + IReadOnlyList? links = null) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(sources); + + int inputTriangles = 0; + for (int i = 0; i < sources.Count; i++) + inputTriangles += sources[i].TriangleCount; + if (inputTriangles == 0) + return null; + + var geom = new ProwlInputGeomProvider(sources, defaultArea); + if (geom.TriangleCount == 0) + return null; + AddVolumes(geom, volumes); + + settings = settings.Clone(); + ResolveTileSize(settings); + + float cs = settings.EffectiveVoxelSize; + int tileVoxels = settings.EffectiveTileSize; + RcConfig cfg = CreateConfig(settings, defaultArea); + + RcVec3f bmin = geom.GetMeshBoundsMin(); + RcVec3f bmax = geom.GetMeshBoundsMax(); + if (worldBounds is AABB wb) + { + // XZ extent from the caller; Y is the union of both so no geometry falls outside + // the heightfield's vertical range. + bmin = new RcVec3f((float)wb.Min.X, Math.Min(bmin.Y, (float)wb.Min.Y), (float)wb.Min.Z); + bmax = new RcVec3f((float)wb.Max.X, Math.Max(bmax.Y, (float)wb.Max.Y), (float)wb.Max.Z); + } + + RcRecast.CalcGridSize(bmin, bmax, cs, out int gridX, out int gridZ); + int tilesX = (gridX + tileVoxels - 1) / tileVoxels; + int tilesZ = (gridZ + tileVoxels - 1) / tileVoxels; + + var data = new NavMeshData + { + Settings = settings, + BoundsMin = new Float3(bmin.X, bmin.Y, bmin.Z), + BoundsMax = new Float3(bmax.X, bmax.Y, bmax.Z), + Origin = new Float3(bmin.X, bmin.Y, bmin.Z), + TileWorldSize = tileVoxels * cs, + MaxTiles = GetMaxTiles(bmin, bmax, cs, tileVoxels), + MaxPolys = GetMaxPolysPerTile(bmin, bmax, cs, tileVoxels), + }; + + // Detour packs tile + poly ids into shared reference bits (tile bits cap at 14), so a + // large enough grid overflows MaxTiles — AddTile then drops tiles at instantiation. + // Surface it at bake time, where the fix (larger tiles / tighter bounds) is actionable. + if (tilesX * tilesZ > data.MaxTiles) + Debug.LogWarning($"[Navigation] Bake grid is {tilesX}x{tilesZ} = {tilesX * tilesZ} tiles but the navmesh can only address {data.MaxTiles}; tiles beyond capacity will fail to add. Increase TileSize or shrink the bake bounds."); + + // Compressed voxelization blobs per tile, contoured on demand by the TileCache. The + // results array is indexed by tile, keeping output order deterministic regardless of + // thread scheduling. + var layerResults = new List?[tilesX * tilesZ]; + if (threads > 1) + { + // Loop-local scratch, not thread-static: a bake fans out over pool threads, and a + // thread-static set on one of those would outlive the bake by the life of the thread, + // pinning a tile's worth of span pages per worker. This still recycles across every + // tile a partition builds, then goes out of scope with the loop. + Parallel.For(0, tilesX * tilesZ, + new ParallelOptions { MaxDegreeOfParallelism = threads, CancellationToken = CancellationToken.None }, + () => new NavMeshTileBuilder.TileBuildScratch(), + (i, _, scratch) => + { + if (cancellation.IsCancellationRequested) return scratch; + layerResults[i] = NavMeshTileBuilder.BuildTileLayers(geom, cfg, bmin, bmax, i % tilesX, i / tilesX, scratch); + return scratch; + }, + _ => { }); + } + else + { + for (int i = 0; i < layerResults.Length; i++) + { + if (cancellation.IsCancellationRequested) return null; + layerResults[i] = NavMeshTileBuilder.BuildTileLayers(geom, cfg, bmin, bmax, i % tilesX, i / tilesX); + } + } + + if (cancellation.IsCancellationRequested) + return null; + + for (int i = 0; i < layerResults.Length; i++) + { + List? blobs = layerResults[i]; + if (blobs == null) continue; + foreach (byte[] blob in blobs) + data.CacheLayers.Add(new NavMeshData.NavMeshTile { X = i % tilesX, Z = i / tilesX, Data = blob }); + } + + // A bake that rasterized nothing walkable returns null, not an empty NavMeshData — + // every consumer rejects tile-less data anyway, and null keeps the "produced no + // walkable geometry" diagnostics accurate downstream. + if (data.CacheLayers.Count == 0) + return null; + + if (links != null) + foreach (NavMeshLinkSource link in links) + data.Links.Add(NavMeshData.NavMeshLinkEntry.From(link)); + + Debug.Log($"[Navigation] Baked {data.CacheLayers.Count} cache layers ({tilesX}x{tilesZ} grid, {geom.TriangleCount} input triangles, {data.Links.Count} links)."); + return data; + } + + /// + /// Rebuild the compressed layers of the tiles intersecting + /// .. against fresh geometry, keeping + /// the original bake's tile grid. A region entirely outside the baked bounds is a no-op — + /// growing the bounds needs a full rebuild. Returns one entry per affected tile; an empty + /// layer list means the tile is now empty. Apply with NavMeshSurface.ApplyRebuiltTiles, + /// which refreshes obstacle state so existing carves re-apply to the regenerated tiles. + /// + public static List<(int X, int Z, List Layers)> BuildTilesInBounds(NavMeshData data, + IReadOnlyList sources, Float3 worldMin, Float3 worldMax, + int defaultArea = NavMeshAreas.Walkable, CancellationToken cancellation = default, + IReadOnlyList? volumes = null) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(sources); + + var results = new List<(int, int, List)>(); + RcConfig cfg = CreateConfig(data.Settings, defaultArea); + + if (!TryPrepareRebuild(data, sources, defaultArea, volumes, worldMin, worldMax, cfg, + out ProwlInputGeomProvider? geom, out RcVec3f bmin, out RcVec3f bmax, + out int minTx, out int maxTx, out int minTz, out int maxTz)) + return results; + + for (int tz = minTz; tz <= maxTz; tz++) + { + for (int tx = minTx; tx <= maxTx; tx++) + { + if (cancellation.IsCancellationRequested) return results; + List layers = geom == null ? [] : NavMeshTileBuilder.BuildTileLayers(geom, cfg, bmin, bmax, tx, tz); + results.Add((tx, tz, layers)); + } + } + + return results; + } + + /// + /// Prologue of the partial-rebuild path: builds the geometry provider, applies volumes, and + /// derives the affected tile range. The grid-anchoring invariant lives here: + /// + /// XZ always anchors to the ORIGINAL bake bounds, never the current geometry, or tile (0,0) + /// shifts and every tile misaligns against the live navmesh. Y follows the CURRENT geometry + /// since Recast clips spans to the heightfield's vertical range — skipped when there's no + /// geometry, since an empty provider reports (0,0,0) and would widen bakes off Y=0. Empty + /// sources are still legitimate (a region walled in completely): the affected tiles are + /// emptied rather than skipped, since "no geometry" is not "no change". The range expands by + /// the erosion border, and a region entirely outside the baked bounds returns false rather + /// than clamping onto the nearest edge column. + /// + private static bool TryPrepareRebuild(NavMeshData data, IReadOnlyList sources, + int defaultArea, IReadOnlyList? volumes, Float3 worldMin, Float3 worldMax, + RcConfig cfg, out ProwlInputGeomProvider? geom, out RcVec3f bmin, out RcVec3f bmax, + out int minTx, out int maxTx, out int minTz, out int maxTz) + { + minTx = maxTx = minTz = maxTz = 0; + float cs = data.Settings.EffectiveVoxelSize; + + int inputTriangles = 0; + for (int i = 0; i < sources.Count; i++) + inputTriangles += sources[i].TriangleCount; + geom = inputTriangles > 0 ? new ProwlInputGeomProvider(sources, defaultArea) : null; + if (geom != null && geom.TriangleCount == 0) geom = null; // all triangles were degenerate/dropped + if (geom != null) AddVolumes(geom, volumes); // volumes only re-mark rasterized geometry + + bmin = new RcVec3f((float)data.BoundsMin.X, (float)data.BoundsMin.Y, (float)data.BoundsMin.Z); + bmax = new RcVec3f((float)data.BoundsMax.X, (float)data.BoundsMax.Y, (float)data.BoundsMax.Z); + if (geom != null) + { + bmin.Y = Math.Min(bmin.Y, geom.GetMeshBoundsMin().Y); + bmax.Y = Math.Max(bmax.Y, geom.GetMeshBoundsMax().Y); + } + + float ts = data.TileWorldSize; + if (ts <= 0) return false; + RcRecast.CalcGridSize(bmin, bmax, cs, out int gridX, out int gridZ); + int tilesX = (gridX + cfg.TileSizeX - 1) / cfg.TileSizeX; + int tilesZ = (gridZ + cfg.TileSizeZ - 1) / cfg.TileSizeZ; + + float border = cfg.BorderSize * cs; + if ((float)worldMax.X + border < bmin.X || (float)worldMin.X - border > bmax.X + || (float)worldMax.Z + border < bmin.Z || (float)worldMin.Z - border > bmax.Z) + return false; + + minTx = Math.Clamp((int)MathF.Floor(((float)worldMin.X - border - bmin.X) / ts), 0, tilesX - 1); + maxTx = Math.Clamp((int)MathF.Floor(((float)worldMax.X + border - bmin.X) / ts), 0, tilesX - 1); + minTz = Math.Clamp((int)MathF.Floor(((float)worldMin.Z - border - bmin.Z) / ts), 0, tilesZ - 1); + maxTz = Math.Clamp((int)MathF.Floor(((float)worldMax.Z + border - bmin.Z) / ts), 0, tilesZ - 1); + return true; + } + + /// Hand area volumes to the provider as Recast convex volumes; the stock pipeline + /// applies them to the compact heightfield after rasterization (RcBuilder.Build → + /// MarkConvexPolyArea), which only re-marks spans geometry produced — Not Walkable maps to + /// the null area and erases them. + private static void AddVolumes(ProwlInputGeomProvider geom, IReadOnlyList? volumes) + { + if (volumes == null) return; + foreach (NavMeshAreaVolume volume in volumes) + { + if (volume.Footprint == null || volume.Footprint.Length < 3) continue; + float[] verts = new float[volume.Footprint.Length * 3]; + for (int i = 0; i < volume.Footprint.Length; i++) + { + verts[i * 3 + 0] = (float)volume.Footprint[i].X; + verts[i * 3 + 1] = volume.MinY; + verts[i * 3 + 2] = (float)volume.Footprint[i].Z; + } + geom.AddConvexVolume(new RcConvexVolume + { + verts = verts, + hmin = volume.MinY, + hmax = volume.MaxY, + areaMod = new RcAreaModification(ProwlInputGeomProvider.DetourAreaFor(volume.Area)), + }); + } + } + + private static RcConfig CreateConfig(NavMeshBuildSettings settings, int defaultArea) + { + float cs = settings.EffectiveVoxelSize; + int tileVoxels = settings.EffectiveTileSize; + + // Contouring, polygonization and detail sampling all happen in the TileCache at runtime, + // which uses its own fixed parameters — the values RcConfig needs for those stages are + // never read on this path. Only rasterization, filtering, erosion and region culling are. + return new RcConfig( + useTiles: true, + tileSizeX: tileVoxels, + tileSizeZ: tileVoxels, + borderSize: RcConfig.CalcBorder(settings.AgentRadius, cs), + partition: RcPartition.WATERSHED, + cellSize: cs, + cellHeight: settings.EffectiveVoxelHeight, + agentMaxSlope: settings.AgentMaxSlope, + agentHeight: settings.AgentHeight, + agentRadius: settings.AgentRadius, + agentMaxClimb: settings.AgentMaxClimb, + minRegionArea: settings.MinRegionArea, + mergeRegionArea: 0, + edgeMaxLen: 0, + edgeMaxError: settings.EdgeMaxError, + vertsPerPoly: NavMeshTileBuilder.VertsPerPoly, + detailSampleDist: 0, + detailSampleMaxError: 0, + filterLowHangingObstacles: settings.FilterLowHangingObstacles, + filterLedgeSpans: settings.FilterLedgeSpans, + filterWalkableLowHeightSpans: settings.FilterWalkableLowHeightSpans, + walkableAreaMod: new RcAreaModification(ProwlInputGeomProvider.DetourAreaFor(defaultArea)), + buildMeshDetail: false); + } + + /// + /// Pin the resolved tile size into so the bake, the serialized + /// asset, its TileCache, and later rebuilds all agree on one value. + /// + /// A compressed layer header stores tile dimensions as BYTES: a tile wider than + /// voxels wraps and decompresses as an empty + /// layer, so the bake "succeeds" with no polygons. + /// clamps to prevent that; this just warns when the clamp actually moved the user's value. + /// + private static void ResolveTileSize(NavMeshBuildSettings settings) + { + int resolved = settings.EffectiveTileSize; + + if (settings.OverrideTileSize && settings.TileSize != resolved) + Debug.LogWarning($"[Navigation] Tile size must be 16..{NavMeshBuildSettings.MaxTileSize} voxels (a layer header stores tile dimensions in a byte, and tiles below 16 are all border); {settings.TileSize} was clamped to {resolved}. Carving cost scales with tile size, so smaller is usually better within that range."); + + settings.OverrideTileSize = true; + settings.TileSize = resolved; + } + + // Tile/poly capacity split: Detour packs tile id + poly id into one reference, so bits + // given to tiles are taken from polys. 22 total id bits, tile bits capped at 14 + // (the Recast demos' arithmetic). + + private static int GetMaxTiles(RcVec3f bmin, RcVec3f bmax, float cellSize, int tileSize) + => 1 << GetTileBits(bmin, bmax, cellSize, tileSize); + + private static int GetMaxPolysPerTile(RcVec3f bmin, RcVec3f bmax, float cellSize, int tileSize) + => 1 << (22 - GetTileBits(bmin, bmax, cellSize, tileSize)); + + private static int GetTileBits(RcVec3f bmin, RcVec3f bmax, float cellSize, int tileSize) + { + RcRecast.CalcGridSize(bmin, bmax, cellSize, out int sizeX, out int sizeZ); + int tilesX = (sizeX + tileSize - 1) / tileSize; + int tilesZ = (sizeZ + tileSize - 1) / tileSize; + return Math.Min(DtUtils.Ilog2(DtUtils.NextPow2(tilesX * tilesZ)), 14); + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshData.cs b/Prowl.Runtime/Navigation/NavMeshData.cs new file mode 100644 index 000000000..163f38a10 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshData.cs @@ -0,0 +1,268 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// A baked navmesh as a standalone, serializable asset (stored as a .navmesh file): +/// the Detour tiles as raw bytes plus everything needed to reinstantiate a +/// at load time. Produced by in the +/// editor or at runtime, consumed by . Like +/// , the asset is independent of any scene — procedural +/// worlds can build one at runtime and register it without an editor bake. +/// +public sealed class NavMeshData : EngineObject +{ + /// One serialized Detour tile. + public sealed class NavMeshTile + { + public int X; + public int Z; + public byte[] Data = []; + } + + /// One off-mesh link the cache re-injects when it rebuilds a tile. + /// The serializable mirror of . + public sealed class NavMeshLinkEntry + { + public Float3 Start; + public Float3 End; + public float Width; + public bool Bidirectional; + public int Area = NavMeshAreas.Jump; + public int UserId; + + public NavMeshLinkSource ToSource() => new(Start, End, Width, Bidirectional, Area, UserId); + + public static NavMeshLinkEntry From(NavMeshLinkSource source) => new() + { + Start = source.Start, + End = source.End, + Width = source.Width, + Bidirectional = source.Bidirectional, + Area = source.Area, + UserId = source.UserId, + }; + } + + /// Current serialized-tile format version. Bump when the tile byte format changes + /// (e.g. a Prowl.Recast upgrade changing Detour's tile layout), so stale assets fail with a + /// clear message instead of a deserialize throw. + public const int CurrentFormatVersion = 1; + + /// Oldest format version this engine still reads. Anything older must be rebaked. + public const int MinReadableFormatVersion = 1; + + /// The format version this asset's tiles were serialized with. + public int FormatVersion = CurrentFormatVersion; + + /// The settings this navmesh was built with (a snapshot — later inspector edits + /// to a surface do not retroactively change it). Rebuilds reuse these for consistency. + public NavMeshBuildSettings Settings = new(); + + /// World-space bounds of the baked geometry. + public Float3 BoundsMin; + + /// World-space bounds of the baked geometry. + public Float3 BoundsMax; + + /// Origin of the tile grid (world space). Tile (x, z) starts at + /// Origin + (x * TileWorldSize, 0, z * TileWorldSize). + public Float3 Origin; + + /// Side length of one tile in world units. + public float TileWorldSize; + + /// Capacity the Detour navmesh is initialized with. + public int MaxTiles; + + /// Per-tile polygon capacity the Detour navmesh is initialized with. + public int MaxPolys; + + /// + /// Compressed voxelization layers, one or more per tile. Each blob is self-describing (tile + /// coordinates and layer index live in its header); a tile contributes several vertical + /// layers where floors overlap. The TileCache contours these into Detour tiles, which is + /// what lets an obstacle re-carve a tile without re-voxelizing the world. + /// + public List CacheLayers = []; + + /// + /// A copy for one consumer's private use, so runtime tile and link rewrites do not land on + /// an asset every other consumer of the same .navmesh is reading. The blobs are + /// shared rather than duplicated: a rebuild replaces entries in these lists and never edits + /// one in place, so the copy costs two lists rather than the megabytes they point at. + /// + public NavMeshData Clone() => new() + { + Name = Name, + FormatVersion = FormatVersion, + Settings = Settings.Clone(), + BoundsMin = BoundsMin, + BoundsMax = BoundsMax, + Origin = Origin, + TileWorldSize = TileWorldSize, + MaxTiles = MaxTiles, + MaxPolys = MaxPolys, + CacheLayers = [.. CacheLayers], + Links = [.. Links], + }; + + /// + /// Baked tile coordinates overlapping , inclusive; false when + /// none do. Intersecting with the tiles that were actually baked keeps the range bounded by + /// the navmesh — a range taken straight from a caller's rect spans every coordinate in it, + /// however few tiles exist. + /// + internal bool TryGetTileRange(AABB worldBounds, out int minTx, out int maxTx, out int minTz, out int maxTz) + { + minTx = maxTx = minTz = maxTz = 0; + if (TileWorldSize <= 0) return false; + + int rx0 = (int)Math.Floor((worldBounds.Min.X - Origin.X) / TileWorldSize); + int rx1 = (int)Math.Floor((worldBounds.Max.X - Origin.X) / TileWorldSize); + int rz0 = (int)Math.Floor((worldBounds.Min.Z - Origin.Z) / TileWorldSize); + int rz1 = (int)Math.Floor((worldBounds.Max.Z - Origin.Z) / TileWorldSize); + + bool found = false; + foreach (NavMeshTile tile in CacheLayers) + { + if (tile.X < rx0 || tile.X > rx1 || tile.Z < rz0 || tile.Z > rz1) continue; + if (!found) + { + minTx = maxTx = tile.X; + minTz = maxTz = tile.Z; + found = true; + continue; + } + + minTx = Math.Min(minTx, tile.X); + maxTx = Math.Max(maxTx, tile.X); + minTz = Math.Min(minTz, tile.Z); + maxTz = Math.Max(maxTz, tile.Z); + } + + return found; + } + + /// + /// Off-mesh links. Tiles are rebuilt from geometry-only layers whenever an obstacle carves + /// or a region regenerates — anything baked into them is regenerated away — so links live + /// here and are re-injected on every tile build. Kept in step with the live + /// s by . + /// + public List Links = []; + + /// True when there is at least one layer to instantiate. + public bool HasTiles => CacheLayers != null && CacheLayers.Count > 0; + + private void ValidateVersion() + { + if (FormatVersion < MinReadableFormatVersion || FormatVersion > CurrentFormatVersion) + throw new InvalidOperationException($"NavMeshData '{Name}' has tile format version {FormatVersion}; this engine reads versions {MinReadableFormatVersion}..{CurrentFormatVersion}. Rebake the navmesh."); + } + + private DtNavMesh CreateEmptyNavMesh() + { + int maxTiles = Math.Max(1, MaxTiles); + int maxPolys = Math.Max(1, MaxPolys); + if (CacheLayers.Count > maxTiles) + { + // Every vertical layer occupies its own navmesh tile slot, and multi-layer tiles + // (overlapping floors, bridges) are the point of the layer set — size honestly + // from the actual layer count, re-splitting the shared 22 id bits with the same + // arithmetic the bake used (tile bits capped at 14). + int tileBits = Math.Min(DtUtils.Ilog2(DtUtils.NextPow2(CacheLayers.Count)), 14); + maxTiles = 1 << tileBits; + maxPolys = 1 << (22 - tileBits); + } + + var navMesh = new DtNavMesh(); + var navParams = new DtNavMeshParams + { + orig = new RcVec3f((float)Origin.X, (float)Origin.Y, (float)Origin.Z), + tileWidth = TileWorldSize, + tileHeight = TileWorldSize, + maxTiles = maxTiles, + maxPolys = maxPolys, + }; + + DtStatus status = navMesh.Init(navParams, NavMeshTileBuilder.VertsPerPoly); + if (status.Failed()) + throw new InvalidOperationException($"Failed to initialize DtNavMesh from NavMeshData '{Name}': {status}"); + return navMesh; + } + + /// + /// Triangulate this baked navmesh without registering it — for editor gizmos and tooling + /// that need to visualize an asset the scene isn't running. Instantiates a throwaway + /// navmesh, so cache the result rather than calling it per frame. + /// + public NavMeshTriangulation CalculateTriangulation() + { + if (!HasTiles) return NavMeshTriangulation.Empty; + try + { + // The layers only become polygons once a cache contours them, so this instantiates + // one that carves nothing and is discarded with the navmesh it built. + return NavMeshTriangulation.FromNavMesh(CreateTileCache(1).GetNavMesh()); + } + catch (Exception e) + { + Debug.LogWarning($"[Navigation] Could not triangulate NavMeshData '{Name}': {e.Message}"); + return NavMeshTriangulation.Empty; + } + } + + /// + /// Instantiate a TileCache (and its owned navmesh) from the compressed layers, seeded + /// synchronously so the mesh is queryable immediately. Obstacles added later rebuild + /// affected tiles incrementally via DtTileCache.Update. + /// + /// Obstacle capacity the cache is created with. + public Prowl.Recast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles) + => CreateTileCache(maxObstacles, out _); + + /// + /// Obstacle capacity the cache is created with. + /// The cache's link registry, so live s + /// can update the connections that later tile builds inject. + internal Prowl.Recast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles, + out NavMeshTileBuilder.ProwlTileCacheMeshProcess meshProcess) + { + ValidateVersion(); + DtNavMesh navMesh = CreateEmptyNavMesh(); + Prowl.Recast.Detour.TileCache.DtTileCache cache = NavMeshTileBuilder.CreateTileCache(this, navMesh, maxObstacles, out meshProcess); + + // Every layer goes in before any of them is meshed. A tile's seam with its neighbour is + // built from both sides' cells, so a tile meshed while its neighbours are still missing + // reads its own side only and describes the seam differently than the neighbour later + // does — the two surfaces then meet a fraction of a voxel apart along an edge they + // should share exactly. + var tileRefs = new List(CacheLayers.Count); + foreach (NavMeshTile layer in CacheLayers) + { + if (layer?.Data == null || layer.Data.Length == 0) continue; + long tileRef = cache.AddTile(layer.Data, 0); + if (tileRef == 0) + { + Debug.LogWarning($"[Navigation] NavMeshData '{Name}': failed to add cache layer for tile ({layer.X}, {layer.Z})."); + continue; + } + tileRefs.Add(tileRef); + } + + foreach (long tileRef in tileRefs) + cache.BuildNavMeshTile(tileRef); + + return cache; + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs b/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs new file mode 100644 index 000000000..41ca40f6b --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs @@ -0,0 +1,457 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Runtime.Resources; +using Prowl.Runtime.Terrain; +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Which scene representation a navmesh bake voxelizes. +public enum NavMeshCollectGeometry +{ + /// Use the visible render meshes (MeshRenderer). What you see is what you walk on. + RenderMeshes, + /// Use the physics colliders. Cheaper and usually simpler geometry; what physics + /// collides with is what agents walk on. + PhysicsColliders, +} + +/// +/// Gathers bake geometry from scene objects into chunks. +/// Runs on the main thread (it touches Transforms, meshes, and terrain data); the resulting +/// sources are self-contained and safe to hand to a background run. +/// +public static class NavMeshGeometryCollector +{ + /// + /// Collect geometry from a set of GameObjects (renderers or colliders per + /// , plus terrain either way). + /// + /// Objects to consider; disabled ones, and anything on or under a + /// or , are skipped. + /// Scene representation to voxelize. + /// Only objects on these layers contribute. + /// Bake voxel size, used to decimate terrain sampling. + /// Area recorded on collected sources. + /// Receives the collected sources. + /// Optional world-space filter: objects whose (conservatively + /// transformed) local bounds miss it are skipped before any vertex work, so partial + /// rebuilds don't pay whole-scene collection. + /// The bake's agent type, used to decide which + /// s apply. + public static void Collect(IEnumerable objects, NavMeshCollectGeometry geometry, LayerMask layers, + float voxelSize, int defaultArea, List results, AABB? bounds = null, int agentTypeId = 0) + { + ArgumentNullException.ThrowIfNull(objects); + ArgumentNullException.ThrowIfNull(results); + + // Modifier inheritance is resolved per object with the ancestor walks memoized here, + // so deep hierarchies stay O(objects) per collection. + var modifierCache = new Dictionary(); + var actorCache = new Dictionary(); + + foreach (GameObject go in objects) + { + if (go.IsNotValid() || !go.EnabledInHierarchy) continue; + if (!layers.HasLayer(go.LayerIndex)) continue; + if (BelongsToActor(go, actorCache)) continue; + + // Known cost center: this runs a GetComponent per in-scope object BEFORE the + // per-component bounds rejection, so bounds-filtered rebuilds over large scenes + // pay it for objects that contribute nothing. If it ever shows in a profile, + // resolve lazily on the first collectible component that survives the bounds test. + NavMeshModifier? modifier = ResolveModifier(go, agentTypeId, modifierCache); + if (modifier != null && modifier.IgnoreFromBuild) continue; + int area = modifier != null && modifier.OverrideArea ? modifier.Area : defaultArea; + + if (geometry == NavMeshCollectGeometry.RenderMeshes) + { + foreach (MeshRenderer renderer in go.GetComponents()) + CollectMeshRenderer(renderer, area, results, bounds); + } + else + { + foreach (Collider collider in go.GetComponents()) + CollectCollider(collider, area, results, bounds); + } + + // Terrain contributes in both modes, read from its heightmap asset either way — the + // collider builds its heightfield from that same asset, and reading the asset is what + // lets a bake with the editor open, where nothing has had a gameplay callback, see + // terrain at all. Collider mode still requires the collider to be there, so terrain + // that is scenery rather than ground is left out of a physics bake as any other + // collider-less object is. + foreach (TerrainComponent terrain in go.GetComponents()) + { + if (geometry != NavMeshCollectGeometry.RenderMeshes && go.GetComponent().IsNotValid()) + continue; + + CollectTerrain(terrain, voxelSize, area, results, bounds); + } + } + } + + /// + /// True when this object moves on the navmesh rather than forming it — an agent or an + /// obstacle — or sits under one. Baking such an object would stamp a permanent hole where it + /// happened to sit at bake time; both components block agents at runtime instead, wherever + /// they actually are. Whole subtrees are excluded since visuals/colliders hang off children. + /// + private static bool BelongsToActor(GameObject go, Dictionary cache) + { + if (cache.TryGetValue(go, out bool cached)) return cached; + + GameObject? parent = go.Parent; + // Both exclude by PRESENCE, never by enabled state, so bake output can't depend on when + // a component was last toggled — an obstacle disabled at bake time would otherwise + // voxelize a permanent hole once it later enables and moves. The tradeoff: an obstacle + // disabled for the whole session leaves its object out of the mesh entirely; permanent + // geometry should not carry the component at all. + bool result = go.GetComponent().IsValid() + || go.GetComponent().IsValid() + || (parent.IsValid() && BelongsToActor(parent!, cache)); + + cache[go] = result; + return result; + } + + /// + /// The modifier governing an object's bake contribution: its own (an object's modifier + /// always wins, whether or not it applies to children), else the nearest ancestor whose + /// modifier has on. Modifiers that are + /// disabled or don't affect this bake's agent type are transparent — the walk continues + /// past them rather than shielding higher ancestors. + /// + private static NavMeshModifier? ResolveModifier(GameObject go, int agentTypeId, + Dictionary cache) + { + NavMeshModifier? own = ValidModifier(go, agentTypeId); + if (own != null) return own; + GameObject? parent = go.Parent; + return parent.IsValid() ? InheritableModifier(parent!, agentTypeId, cache) : null; + } + + /// The modifier passes down to its children (memoized). + private static NavMeshModifier? InheritableModifier(GameObject go, int agentTypeId, + Dictionary cache) + { + if (cache.TryGetValue(go, out NavMeshModifier? cached)) return cached; + + NavMeshModifier? own = ValidModifier(go, agentTypeId); + NavMeshModifier? result; + if (own != null && own.ApplyToChildren) + { + result = own; + } + else + { + GameObject? parent = go.Parent; + result = parent.IsValid() ? InheritableModifier(parent!, agentTypeId, cache) : null; + } + + cache[go] = result; + return result; + } + + private static NavMeshModifier? ValidModifier(GameObject go, int agentTypeId) + { + var modifier = go.GetComponent(); + return modifier.IsValid() && modifier!.EnabledInHierarchy && modifier.AffectsAgentType(agentTypeId) + ? modifier : null; + } + + /// + /// Gather enabled s into self-contained + /// s (main thread — touches Transforms). Same layer and + /// bounds filtering as geometry collection; volumes whose AABB misses + /// are skipped, which is how partial rebuilds only pay for + /// volumes near the changed region. + /// + public static void CollectModifierVolumes(IEnumerable objects, LayerMask layers, int agentTypeId, + List results, AABB? bounds = null) + { + ArgumentNullException.ThrowIfNull(objects); + ArgumentNullException.ThrowIfNull(results); + + foreach (GameObject go in objects) + { + if (go.IsNotValid() || !go.EnabledInHierarchy) continue; + if (!layers.HasLayer(go.LayerIndex)) continue; + + foreach (NavMeshModifierVolume volume in go.GetComponents()) + { + if (volume.IsNotValid() || !volume.EnabledInHierarchy || !volume.AffectsAgentType(agentTypeId)) + continue; + + NavMeshAreaVolume areaVolume = volume.ComputeAreaVolume(); + if (areaVolume.Footprint.Length < 3) continue; // degenerate projection + if (bounds is AABB filter && !areaVolume.Bounds.Intersects(filter)) continue; + results.Add(areaVolume); + } + } + } + + /// + /// Gather enabled, activated s into self-contained + /// s (main thread — touches Transforms). Same layer and + /// bounds filtering as geometry collection. + /// + public static void CollectLinks(IEnumerable objects, LayerMask layers, int agentTypeId, + List results, AABB? bounds = null) + { + ArgumentNullException.ThrowIfNull(objects); + CollectLinks(EnumerateLinks(objects), layers, agentTypeId, results, bounds); + } + + /// + /// Takes the links themselves, which is how a whole-scene bake avoids visiting every + /// GameObject to find the handful that carry one — see . + public static void CollectLinks(IEnumerable links, LayerMask layers, int agentTypeId, + List results, AABB? bounds = null) + { + ArgumentNullException.ThrowIfNull(links); + ArgumentNullException.ThrowIfNull(results); + + foreach (NavMeshLink link in links) + { + // EnabledInHierarchy already folds in the GameObject's own state. + if (link.IsNotValid() || !link.EnabledInHierarchy || !link.Activated || !link.AffectsAgentType(agentTypeId)) + continue; + if (!layers.HasLayer(link.GameObject.LayerIndex)) continue; + + NavMeshLinkSource source = link.ToLinkSource(); + if (bounds is AABB filter && !source.Bounds.Intersects(filter)) continue; + results.Add(source); + } + } + + private static IEnumerable EnumerateLinks(IEnumerable objects) + { + foreach (GameObject go in objects) + { + if (go.IsNotValid()) continue; + foreach (NavMeshLink link in go.GetComponents()) + yield return link; + } + } + + /// Conservative overlap test: transform the 8 corners of a local AABB and test + /// the world AABB against the filter. O(1) per source instead of per-vertex. Corners are + /// walked inline rather than via AABB.TransformBy, which allocates a corner array — + /// this runs per object on every bounds-filtered collection. + private static bool TransformedBoundsIntersect(Float3 localMin, Float3 localMax, in Float4x4 transform, in AABB filter) + { + var min = new Float3(float.MaxValue, float.MaxValue, float.MaxValue); + var max = new Float3(float.MinValue, float.MinValue, float.MinValue); + for (int i = 0; i < 8; i++) + { + var corner = new Float3( + (i & 1) == 0 ? localMin.X : localMax.X, + (i & 2) == 0 ? localMin.Y : localMax.Y, + (i & 4) == 0 ? localMin.Z : localMax.Z); + Float3 world = Float4x4.TransformPoint(corner, transform); + min = Maths.Min(min, world); + max = Maths.Max(max, world); + } + + return new AABB(min, max).Intersects(filter); + } + + /// Collect one renderer's mesh, if available. + public static void CollectMeshRenderer(MeshRenderer renderer, int area, List results, AABB? bounds = null) + { + if (renderer.IsNotValid() || !renderer.EnabledInHierarchy) return; + + Mesh? mesh = renderer.Mesh.Res; + if (mesh.IsNotValid()) return; + + if (bounds is AABB filter + && !TransformedBoundsIntersect(mesh!.bounds.Min, mesh.bounds.Max, renderer.Transform.LocalToWorldMatrix, filter)) + return; + + Float3[] vertices = mesh!.Vertices; + uint[] indices = mesh.Indices; + if (vertices == null || indices == null || indices.Length < 3) return; + + results.Add(new NavMeshGeometrySource(vertices, ToIntIndices(indices), renderer.Transform.LocalToWorldMatrix, area)); + } + + /// + /// Collect one collider as triangles. Primitive colliders tessellate to the same shape the + /// physics engine uses (capsules included); mesh colliders hand over the shared mesh's own + /// vertex and index arrays, without copying them. + /// + public static void CollectCollider(Collider collider, int area, List results, AABB? bounds = null) + { + if (collider.IsNotValid() || !collider.EnabledInHierarchy) return; + + if (bounds is AABB filter) + { + // Conservative local bounds per collider type, tested O(1) before any tessellation + // or vertex extraction. Mesh colliders use the mesh's own (possibly off-center) + // bounds; primitives are origin-centered by construction. + Float3 localMin, localMax; + if (collider is MeshCollider mc) + { + Mesh? mcMesh = mc.Mesh.Res; + if (mcMesh.IsNotValid()) return; + localMin = mcMesh!.bounds.Min; + localMax = mcMesh.bounds.Max; + } + else + { + Float3 halfExtents = collider switch + { + BoxCollider box => box.Size * 0.5f, + SphereCollider sphere => new Float3(sphere.Radius, sphere.Radius, sphere.Radius), + CapsuleCollider capsule => new Float3(capsule.Radius, capsule.Height * 0.5f + capsule.Radius, capsule.Radius), + CylinderCollider cylinder => new Float3(cylinder.Radius, cylinder.Height * 0.5f, cylinder.Radius), + ConeCollider cone => new Float3(cone.Radius, cone.Height * 0.5f, cone.Radius), + _ => new Float3(float.MaxValue, float.MaxValue, float.MaxValue), // unknown: never reject + }; + localMin = -halfExtents; + localMax = halfExtents; + } + if (!TransformedBoundsIntersect(localMin, localMax, ColliderWorldMatrix(collider), filter)) + return; + } + + if (collider is MeshCollider meshCollider) + { + Mesh? sharedMesh = meshCollider.Mesh.Res; + if (sharedMesh.IsNotValid()) return; + Float3[] vertices = sharedMesh.Vertices; + uint[] indices = sharedMesh.Indices; + if (vertices == null || indices == null || indices.Length < 3) return; + results.Add(new NavMeshGeometrySource(vertices, ToIntIndices(indices), ColliderWorldMatrix(collider), area)); + return; + } + + // Primitive tessellation: same sizing conventions as each collider's Jitter shape and + // gizmo (origin-centered, GizmoMatrix places it). + Mesh? primitive = collider switch + { + BoxCollider box => Mesh.CreateCube(box.Size), + SphereCollider sphere => Mesh.CreateSphere(Math.Max(sphere.Radius, 0.01f), 12, 12), + // Collider capsule height is the cylindrical segment; CreateCapsule takes total height. + CapsuleCollider capsule => Mesh.CreateCapsule(Math.Max(capsule.Radius, 0.01f), capsule.Height + 2f * capsule.Radius, 12, 4), + CylinderCollider cylinder => Mesh.CreateCylinder(Math.Max(cylinder.Radius, 0.01f), cylinder.Height, 12), + ConeCollider cone => Mesh.CreateCone(Math.Max(cone.Radius, 0.01f), cone.Height, 12), + _ => null, + }; + if (primitive == null) return; + + try + { + results.Add(new NavMeshGeometrySource(primitive.Vertices, ToIntIndices(primitive.Indices), ColliderWorldMatrix(collider), area)); + } + finally + { + primitive.Dispose(); + } + } + + /// + /// Collect a terrain as a decimated height grid. Samples are spaced no finer than the bake + /// voxel size — Recast re-voxelizes at that resolution anyway, so finer triangles are pure + /// waste (a 1k heightmap would otherwise contribute ~2M triangles). Holes are skipped. + /// + /// Heights come from in terrain-local space, placed by the object's + /// transform exactly as places the physics heightfield — so a + /// moved, rotated or scaled terrain walks where it's drawn and where it collides. Reading the + /// asset (not the collider) is also what lets a bake with the editor open see terrain at all. + /// + public static void CollectTerrain(TerrainComponent terrain, float voxelSize, int area, List results, AABB? bounds = null) + { + if (terrain.IsNotValid() || !terrain.EnabledInHierarchy) return; + + terrain.Data.EnsureLoaded(); + TerrainData? data = terrain.Data.Res; + if (data.IsNotValid()) return; + + int res = data!.HeightmapResolution; + if (res < 2) return; + + // The stored heights span the full 0..Height band, so sculpting can never leave these bounds. + Float4x4 localToWorld = terrain.Transform.LocalToWorldMatrix; + if (bounds is AABB filter + && !TransformedBoundsIntersect(Float3.Zero, new Float3(data.Size, data.Height, data.Size), localToWorld, filter)) + return; + + // The sample budget is a world-space distance, so it converts to grid steps through the + // scale the terrain is drawn at. + float cellSize = data.Size / (res - 1); + Float3 scale = terrain.Transform.LossyScale; + float localVoxelSize = voxelSize / Math.Max(1e-4f, Math.Max(MathF.Abs(scale.X), MathF.Abs(scale.Z))); + int stride = Math.Max(1, (int)MathF.Floor(Math.Max(localVoxelSize, cellSize) / cellSize)); + + // Sampled grid dimensions (always include the far edge). + List steps = []; + for (int i = 0; i < res - 1; i += stride) steps.Add(i); + steps.Add(res - 1); + int n = steps.Count; + + var vertices = new Float3[n * n]; + for (int zi = 0; zi < n; zi++) + { + for (int xi = 0; xi < n; xi++) + { + int x = steps[xi], z = steps[zi]; + vertices[zi * n + xi] = new Float3(x * cellSize, data.GetHeight(x, z) * data.Height, z * cellSize); + } + } + + List indices = new(6 * (n - 1) * (n - 1)); + for (int zi = 0; zi < n - 1; zi++) + { + for (int xi = 0; xi < n - 1; xi++) + { + // A cell is a hole if any source cell under the decimated quad is a hole. + if (AnyHole(data, steps[xi], steps[zi], steps[xi + 1], steps[zi + 1])) continue; + + int v00 = zi * n + xi; + int v01 = (zi + 1) * n + xi; + int v11 = (zi + 1) * n + xi + 1; + int v10 = zi * n + xi + 1; + // Up-facing winding (CCW viewed from +Y), matching the builder's convention. + indices.Add(v00); indices.Add(v01); indices.Add(v11); + indices.Add(v00); indices.Add(v11); indices.Add(v10); + } + } + if (indices.Count == 0) return; + + results.Add(new NavMeshGeometrySource(vertices, [.. indices], localToWorld, area)); + } + + private static bool AnyHole(TerrainData data, int x0, int z0, int x1, int z1) + { + for (int z = z0; z < z1; z++) + for (int x = x0; x < x1; x++) + if (data.IsCellHole(x, z)) + return true; + return false; + } + + private static int[] ToIntIndices(uint[] indices) + { + int[] result = new int[indices.Length]; + for (int i = 0; i < indices.Length; i++) + result[i] = (int)indices[i]; + return result; + } + + /// World matrix for a collider's shape: the collider's Center/Rotation offsets + /// composed with the GameObject's world TRS (same composition as the collider gizmo). + private static Float4x4 ColliderWorldMatrix(Collider collider) + { + Float4x4 worldTRS = Float4x4.CreateTRS(collider.Transform.Position, collider.Transform.Rotation, collider.Transform.LossyScale); + return Float4x4.CreateTRS( + Float4x4.TransformPoint(collider.Center, worldTRS), + collider.Transform.Rotation * Quaternion.FromEuler(collider.Rotation), + collider.Transform.LossyScale); + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshGeometrySource.cs b/Prowl.Runtime/Navigation/NavMeshGeometrySource.cs new file mode 100644 index 000000000..901328af0 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshGeometrySource.cs @@ -0,0 +1,47 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// One chunk of triangle geometry contributed to a navmesh bake: source-local vertices and +/// indices plus the transform into world space. Collected from renderers, colliders, terrain, +/// or supplied directly by user code for procedural geometry. +/// +public struct NavMeshGeometrySource +{ + /// Vertices in source-local space. + public Float3[] Vertices; + + /// Triangle indices into (three per triangle). + public int[] Indices; + + /// Transforms into world space. + public Float4x4 Transform; + + /// Sentinel for : the source takes the bake's default area. + public const int UnspecifiedArea = -1; + + /// The navigation area for this geometry (index into ). + /// Walkable polygons rasterized from this source carry this area, so per-source costs and + /// masks apply. (the constructor default) falls back to the + /// bake's default area. Where surfaces of different areas overlap vertically within the + /// climb threshold, the HIGHER area index wins the merged span (Recast's convention) — not + /// the higher cost — so order user areas accordingly when stacking geometry. + public int Area; + + public NavMeshGeometrySource(Float3[] vertices, int[] indices, Float4x4 transform, int area = UnspecifiedArea) + { + Vertices = vertices ?? throw new ArgumentNullException(nameof(vertices)); + Indices = indices ?? throw new ArgumentNullException(nameof(indices)); + Transform = transform; + Area = area; + } + + /// Number of whole triangles described by . + public readonly int TriangleCount => (Indices?.Length ?? 0) / 3; +} diff --git a/Prowl.Runtime/Navigation/NavMeshHit.cs b/Prowl.Runtime/Navigation/NavMeshHit.cs new file mode 100644 index 000000000..5aa26ad17 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshHit.cs @@ -0,0 +1,30 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Result of a navmesh query such as , +/// , or +/// (matches Unity's NavMeshHit). +/// +public struct NavMeshHit +{ + /// The resulting location on the navmesh. + public Float3 Position; + + /// Normal at the hit (edge/wall normal for raycast and closest-edge queries; + /// straight up for position samples). + public Float3 Normal; + + /// Distance from the query origin to . + public float Distance; + + /// Area mask bit of the polygon at the hit location (1 << area index). + public int Mask; + + /// True when the query found something. + public bool Hit; +} diff --git a/Prowl.Runtime/Navigation/NavMeshLinkSource.cs b/Prowl.Runtime/Navigation/NavMeshLinkSource.cs new file mode 100644 index 000000000..9b11b5687 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshLinkSource.cs @@ -0,0 +1,82 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// A world-space off-mesh connection fed into a bake (the payload of ). +/// Self-contained — no Transform or component references — so it is safe to hand to a +/// background build. A link with > 0 is expanded into parallel +/// connections across the span, so an agent enters at the nearest point along it. +/// +public readonly struct NavMeshLinkSource +{ + /// World-space endpoints. Each must land within the agent radius of walkable + /// surface for the connection to attach. + public readonly Float3 Start, End; + + /// World-space width of the link: how wide a span of the edge it covers. 0 leaves + /// the connection at the agent's own radius. + public readonly float Width; + + /// Whether the link can be traversed end-to-start as well. + public readonly bool Bidirectional; + + /// The link's area (see ); traversal cost comes from + /// the area's cost. + public readonly int Area; + + /// Stable user id stamped on the baked connection, used to resolve a traversing + /// agent back to its component. 0 = none. + public readonly int UserId; + + public NavMeshLinkSource(Float3 start, Float3 end, float width, bool bidirectional, int area, int userId) + { + Start = start; + End = end; + Width = Math.Max(0f, width); + Bidirectional = bidirectional; + Area = area; + UserId = userId; + } + + /// Conservative world AABB covering both endpoints plus the width, for bounds + /// filtering and for sizing rebuild regions. + public AABB Bounds => new AABB(Start, Start).Encapsulating(End).Expanded(Width * 0.5f + 0.5f); + + /// + /// The crossing points this link becomes: one per parallel connection, spread across + /// (capped at 8). Unity lets an agent enter a wide link at the nearest + /// point along its entry edge; a Detour off-mesh connection is a single point, so a span is + /// approximated by several of them side by side and the agent takes the nearest. + /// Re-expanded every time the cache re-contours a tile. + /// + /// Bake agent radius: the connection radius, the spacing between + /// parallel connections, and the inset that keeps the outermost ones on the span. + /// Receives the crossings; not cleared. + public void ExpandCrossings(float agentRadius, System.Collections.Generic.List<(Float3 Start, Float3 End)> results) + { + ArgumentNullException.ThrowIfNull(results); + float radius = Math.Max(0.01f, agentRadius); + int count = Width <= 0f ? 1 : Math.Clamp((int)MathF.Ceiling(Width / (2f * radius)), 1, 8); + + // Horizontal perpendicular of the span, for spreading the parallel connections. A + // (near-)vertical link has no meaningful width axis; fall back to +X. + var dir = new Float3(End.X - Start.X, 0, End.Z - Start.Z); + double len = Math.Sqrt(dir.X * dir.X + dir.Z * dir.Z); + Float3 perp = len > 1e-4 ? new Float3((float)(-dir.Z / len), 0, (float)(dir.X / len)) : new Float3(1, 0, 0); + + // Endpoints inset by the radius so the outermost connections stay on the span. + float half = Math.Max(0f, Width * 0.5f - radius); + for (int i = 0; i < count; i++) + { + float t = count == 1 ? 0f : -half + i * (2f * half / (count - 1)); + Float3 offset = perp * t; + results.Add((Start + offset, End + offset)); + } + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshPath.cs b/Prowl.Runtime/Navigation/NavMeshPath.cs new file mode 100644 index 000000000..9dc3b6371 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshPath.cs @@ -0,0 +1,94 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Status of a calculated path (matches Unity's NavMeshPathStatus). +public enum NavMeshPathStatus +{ + /// The path reaches the destination. + PathComplete, + /// The path is valid but cannot reach the destination; it leads to the closest reachable point. + PathPartial, + /// No path exists (or the endpoints are off the navmesh). + PathInvalid, +} + +/// +/// A calculated navigation path: world-space corner points plus a status. Reusable — pass the +/// same instance to repeated +/// calls to avoid reallocating. +/// +public sealed class NavMeshPath +{ + private Float3[] _corners = []; + private int _cornerCount; + + // The polygons the corners were derived from, so NavMeshAgent.SetPath can hand the crowd the + // route itself. Corners cannot express one: two different polygon paths can share them. + private long[] _polys = []; + private int _polyCount; + + internal Span Polys => _polys.AsSpan(0, _polyCount); + + /// The state of the path. + public NavMeshPathStatus Status { get; internal set; } = NavMeshPathStatus.PathInvalid; + + /// The corner points of the path. Allocates a fresh array; use + /// on hot paths. + public Float3[] Corners + { + get + { + Float3[] result = new Float3[_cornerCount]; + Array.Copy(_corners, result, _cornerCount); + return result; + } + } + + /// Number of valid corners. + public int CornerCount => _cornerCount; + + /// The point the path actually reaches, which for a partial path is not the requested + /// destination. Callers must check first. + internal Float3 LastCorner => _corners[_cornerCount - 1]; + + /// Copy up to .Length corners into the given array, + /// returning the number written. + public int GetCornersNonAlloc(Float3[] results) + { + ArgumentNullException.ThrowIfNull(results); + int n = Math.Min(results.Length, _cornerCount); + Array.Copy(_corners, results, n); + return n; + } + + /// Erase all corner points and reset the status to invalid. + public void ClearCorners() + { + _cornerCount = 0; + _polyCount = 0; + Status = NavMeshPathStatus.PathInvalid; + } + + internal void SetPolys(ReadOnlySpan polys) + { + if (_polys.Length < polys.Length) + _polys = new long[polys.Length]; + polys.CopyTo(_polys); + _polyCount = polys.Length; + } + + internal void SetCorners(ReadOnlySpan corners, NavMeshPathStatus status) + { + if (_corners.Length < corners.Length) + _corners = new Float3[Math.Max(corners.Length, 16)]; + corners.CopyTo(_corners); + _cornerCount = corners.Length; + Status = status; + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs b/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs new file mode 100644 index 000000000..ba77ed837 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs @@ -0,0 +1,82 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; + +namespace Prowl.Runtime; + +/// +/// Filters navmesh queries by area mask and applies per-area path costs. Implements Detour's +/// filter interface directly against the 32-bit Prowl area mask, so all 32 areas are usable +/// (Detour's default filter only supports 16 flag bits). Cost overrides set here take +/// precedence over the project-wide defaults in . +/// +public sealed class NavMeshQueryFilter : IDtQueryFilter +{ + /// Bitmask of traversable areas (bit i = area index i). Defaults to everything. + public int AreaMask = NavMeshAreas.AllAreas; + + /// The agent type whose navmesh this filter queries. + public int AgentTypeId = 0; + + private float[]? _costOverrides; + + /// Path cost multiplier for an area: the override set on this filter, or the + /// project default. + public float GetAreaCost(int areaIndex) + { + if (_costOverrides != null && areaIndex >= 0 && areaIndex < _costOverrides.Length && _costOverrides[areaIndex] > 0f) + return _costOverrides[areaIndex]; + return NavMeshAreas.GetAreaCost(areaIndex); + } + + /// Override the path cost for an area on this filter only. Clamped to >= 1: + /// Detour's A* heuristic is only admissible when no traversal is cheaper than distance, + /// so costs below 1 would silently produce suboptimal paths. To prefer an area, raise the + /// other areas' costs instead. + public void SetAreaCost(int areaIndex, float cost) + { + if (areaIndex < 0 || areaIndex >= NavMeshAreas.MaxAreas) return; + _costOverrides ??= new float[NavMeshAreas.MaxAreas]; + _costOverrides[areaIndex] = Math.Max(1f, cost); + } + + /// Remove all per-filter cost overrides, falling back to project defaults. + public void ClearAreaCosts() => _costOverrides = null; + + /// Raw override table (0 = no override), or null when none were ever set. For + /// crowd filter-slot matching — treat as read-only. + internal float[]? CostOverrides => _costOverrides; + + /// Replace this filter's overrides with a copy of + /// (null clears). Used when a crowd filter slot takes on an agent's configuration — + /// a copy, so the agent mutating its own filter later can't skew a shared slot. + internal void CopyCostOverridesFrom(float[]? source) + { + if (source == null) + { + _costOverrides = null; + return; + } + _costOverrides ??= new float[NavMeshAreas.MaxAreas]; + Array.Clear(_costOverrides); + Array.Copy(source, _costOverrides, Math.Min(source.Length, _costOverrides.Length)); + } + + bool IDtQueryFilter.PassFilter(long refs, DtMeshTile tile, DtPoly poly) + { + if (poly.flags == 0) return false; + int area = NavMeshAreas.FromDetourArea(poly.GetArea()); + return (AreaMask & (1 << area)) != 0; + } + + float IDtQueryFilter.GetCost(RcVec3f pa, RcVec3f pb, long prevRef, DtMeshTile prevTile, DtPoly prevPoly, + long curRef, DtMeshTile curTile, DtPoly curPoly, long nextRef, DtMeshTile nextTile, DtPoly nextPoly) + { + int area = NavMeshAreas.FromDetourArea(curPoly.GetArea()); + return RcVec3f.Distance(pa, pb) * GetAreaCost(area); + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs new file mode 100644 index 000000000..a8c6b9567 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs @@ -0,0 +1,410 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Recast.Core; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.TileCache; +using Prowl.Recast.Detour.TileCache.Io.Compress; +using Prowl.Recast; +using Prowl.Recast.Geom; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Turns one Recast build result into a serialized Detour tile. Poly areas are preserved +/// as-is (they already carry the Prowl area mapping from +/// ) and every polygon gets the walkable +/// flag, since inclusion/exclusion is the query filter's job. +/// +internal static class NavMeshTileBuilder +{ + /// The single poly flag Prowl sets on every built polygon. Detour ignores polys + /// with zero flags, so something must be set; area-based filtering happens in + /// against the poly's area, not its flags. + public const int PolyFlagWalkable = 1; + + /// Vertices per navmesh polygon. Fixed, not a setting: the TileCache builds its + /// polygons at Detour's maximum and the navmesh must be initialized to match. + public const int VertsPerPoly = 6; + + /// + /// Reusable bake state. Tile building allocates a fixed working set per tile (heightfield + /// spans, context bookkeeping) regardless of geometry; recycling it across tiles removes that + /// churn from bake-heavy games (destructible maps rebuild tiles at gameplay frequency). + /// + internal sealed class TileBuildScratch + { + public readonly RcContext Context = new(); + /// Recycled span pool pages, transplanted into each tile's heightfield. Grows + /// to the largest tile's span count, then no span is ever allocated again. + public RcSpanPool? SpanPools; + } + + /// + /// Fallback scratch for callers that supply none — runtime tile rebuilds, which stay on the + /// main thread and want their pages warm between frames. Parallel bakes pass their own: a + /// thread-static set on a pool thread outlives the bake by the life of that thread. + /// + [ThreadStatic] private static TileBuildScratch? t_scratch; + + /// Chunk lists per area mesh overlapping this tile (parallel to + /// ), or null when nothing overlaps — + /// collected once and shared by the empty-tile check and the rasterization pass. + private static List[]? CollectOverlappingChunks(ProwlInputGeomProvider geom, RcBuilderConfig builderCfg) + { + var tileMin = new RcVec2f(builderCfg.bmin.X, builderCfg.bmin.Z); + var tileMax = new RcVec2f(builderCfg.bmax.X, builderCfg.bmax.Z); + + // Cheap pre-pass: on bounded bakes most tiles miss every area's XZ extent entirely, + // and this rejects them with zero allocations (GetChunksOverlappingRect allocates its + // return list even when empty). + bool anyPossible = false; + for (int i = 0; i < geom.AreaMeshes.Count; i++) + { + if (geom.AreaMeshes[i].OverlapsXZ(tileMin.X, tileMin.Y, tileMax.X, tileMax.Y)) + { + anyPossible = true; + break; + } + } + if (!anyPossible) + return null; + + var chunks = new List[geom.AreaMeshes.Count]; + bool any = false; + for (int i = 0; i < geom.AreaMeshes.Count; i++) + { + chunks[i] = geom.AreaMeshes[i].Mesh.GetChunksOverlappingRect(tileMin, tileMax); + any |= chunks[i].Count > 0; + } + return any ? chunks : null; + } + + private static RcHeightfield BuildHeightfieldPooled(ProwlInputGeomProvider geom, RcBuilderConfig builderCfg, + List[]? overlappingChunks, TileBuildScratch scratch) + { + RcConfig cfg = builderCfg.cfg; + var solid = new RcHeightfield(builderCfg.width, builderCfg.height, builderCfg.bmin, builderCfg.bmax, cfg.Cs, cfg.Ch, cfg.BorderSize); + + // Attach recycled span pool pages: every span in them is free (the previous tile's + // heightfield was discarded), so the freelist is simply all of them. + if (scratch.SpanPools != null) + RcRasterizations.AdoptSpanPools(solid, scratch.SpanPools); + + if (overlappingChunks == null) + return solid; + + float walkableSlopeCos = MathF.Cos(cfg.WalkableSlopeAngle / 180.0f * MathF.PI); + for (int i = 0; i < geom.AreaMeshes.Count; i++) + { + ProwlInputGeomProvider.AreaMesh areaMesh = geom.AreaMeshes[i]; + float[] verts = areaMesh.Mesh.GetVerts(); + // Chunky-mesh culling: only triangles overlapping this tile (plus border) rasterize. + foreach (RcChunkyTriMeshNode node in overlappingChunks[i]) + { + RcRasterizations.RasterizeTriangles(scratch.Context, verts, node.tris, node.tris.Length / 3, + walkableSlopeCos, areaMesh.DetourArea, solid, cfg.WalkableClimb); + } + } + + return solid; + } + + /// + /// Build one tile's compressed layers: area-aware pooled rasterization, the standard + /// filter + compact + erode + volume-marking steps, then heightfield layers compressed into + /// self-describing blobs. Returns an empty list for tiles no geometry overlaps — the common + /// case on bounded bakes of mostly-sealed worlds — without paying for a heightfield. + /// Contours/polymeshes are NOT built here — the TileCache builds them per tile at runtime, + /// which is what lets obstacles re-carve without re-voxelizing. + /// + /// Scratch to build through, so its span pages survive into the next + /// tile. Null shares the calling thread's. + public static List BuildTileLayers(ProwlInputGeomProvider geom, RcConfig cfg, RcVec3f bmin, RcVec3f bmax, + int tileX, int tileZ, TileBuildScratch? reusable = null) + { + var builderCfg = new RcBuilderConfig(cfg, bmin, bmax, tileX, tileZ); + + List[]? overlappingChunks = CollectOverlappingChunks(geom, builderCfg); + if (overlappingChunks == null) + return []; + + TileBuildScratch scratch = reusable ?? (t_scratch ??= new TileBuildScratch()); + RcHeightfield solid = BuildHeightfieldPooled(geom, builderCfg, overlappingChunks, scratch); + RcContext ctx = scratch.Context; + + // Filter + compact + erode + convex volumes: the same steps RcBuilder runs before + // region building, applied here because the layer path bypasses RcBuilder.Build. + if (cfg.FilterLowHangingObstacles) + RcFilters.FilterLowHangingWalkableObstacles(ctx, cfg.WalkableClimb, solid); + if (cfg.FilterLedgeSpans) + RcFilters.FilterLedgeSpans(ctx, cfg.WalkableHeight, cfg.WalkableClimb, solid); + if (cfg.FilterWalkableLowHeightSpans) + RcFilters.FilterWalkableLowHeightSpans(ctx, cfg.WalkableHeight, solid); + + RcCompactHeightfield chf = RcCompacts.BuildCompactHeightfield(ctx, cfg.WalkableHeight, cfg.WalkableClimb, solid); + + // Not Walkable rasterizes as a sentinel area so span merging cannot discard it; retire it + // here, before erosion, so agents keep their radius clear of it like any other wall. + for (int i = 0; i < chf.spanCount; i++) + if (chf.areas[i] == ProwlInputGeomProvider.NotWalkableRasterArea) + chf.areas[i] = RcRecast.RC_NULL_AREA; + + RcAreas.ErodeWalkableArea(ctx, cfg.WalkableRadius, chf); + foreach (RcConvexVolume vol in geom.ConvexVolumes()) + RcAreas.MarkConvexPolyArea(ctx, vol.verts, vol.hmin, vol.hmax, vol.areaMod, chf); + + // Cull islands too small to stand on (Unity's Min Region Area). Regions are built here + // only to find the spans to erase — BuildRegions zeroes the region id of anything it + // culled, and erasing those spans' areas keeps them out of the compressed layer for + // good. Regions reaching a tile border are exempt, so an island spanning two tiles + // survives in both. + if (cfg.MinRegionArea > 0) + { + // BuildLayerRegions, not the watershed pair: it's what Recast intends for layer + // builds, skips watershed's expensive distance-field step, and zeroes culled region + // ids the same way — all the sweep below reads. + RcRegions.BuildLayerRegions(ctx, chf, cfg.MinRegionArea); + for (int i = 0; i < chf.spanCount; i++) + if (chf.spans[i].reg == 0) + chf.areas[i] = RcRecast.RC_NULL_AREA; + } + + RcLayers.BuildHeightfieldLayers(ctx, chf, cfg.BorderSize, cfg.WalkableHeight, out RcHeightfieldLayerSet lset); + + // Keep the (possibly grown) pool pages for the next tile on this thread. + scratch.SpanPools = solid.pools; + + var blobs = new List(); + if (lset == null) return blobs; + + // Compatibility index 0 = the built-in FastLZ compressor; other indices return null + // unless a custom compressor was registered. Must stay paired with the + // cCompatibility: true storage layout below and in CreateTileCache. + IRcCompressor compressor = DtTileCacheCompressorFactory.Shared.Create(0); + for (int i = 0; i < lset.layers.Length; i++) + { + RcHeightfieldLayer layer = lset.layers[i]; + var header = new DtTileCacheLayerHeader + { + magic = DtTileCacheLayerHeader.DT_TILECACHE_MAGIC, + version = DtTileCacheLayerHeader.DT_TILECACHE_VERSION, + tx = tileX, + ty = tileZ, + tlayer = i, + bmin = layer.bmin, + bmax = layer.bmax, + width = layer.width, + height = layer.height, + minx = layer.minx, + maxx = layer.maxx, + miny = layer.miny, + maxy = layer.maxy, + hmin = layer.hmin, + hmax = layer.hmax, + }; + blobs.Add(DtTileCacheBuilder.CompressTileCacheLayer(header, layer.heights, layer.areas, layer.cons, + RcByteOrder.LITTLE_ENDIAN, cCompatibility: true, compressor)); + } + return blobs; + } + + /// + /// Poly finishing for cache-built tiles: every polygon gets the walkable flag — + /// inclusion/exclusion is the query filter's job, and areas already carry the Prowl mapping + /// from the baked layers. + /// + /// This is also where the navmesh gets its off-mesh links. The cache re-contours a whole + /// tile whenever an obstacle carves or a region regenerates, discarding anything previously + /// built into it, so connections cannot be baked in once — they are re-supplied here on + /// every tile build, and Detour keeps only those whose start point lands in the tile. + /// + public sealed class ProwlTileCacheMeshProcess : IDtTileCacheMeshProcess + { + private readonly List<(Float3 Start, Float3 End, float Radius, bool Bidirectional, int Area, int UserId)> _connections = []; + + /// + /// Replace the link set future tile builds inject. Call under the instance's write lock, + /// then rebuild the tiles that should carry the change. + /// + public void SetLinks(IReadOnlyList? links, float agentRadius) + { + _connections.Clear(); + + if (links == null || links.Count == 0) return; + + float radius = Math.Max(0.01f, agentRadius); + List<(Float3 Start, Float3 End)> crossings = []; + + foreach (NavMeshLinkSource link in links) + { + crossings.Clear(); + link.ExpandCrossings(radius, crossings); + foreach ((Float3 start, Float3 end) in crossings) + _connections.Add((start, end, radius, link.Bidirectional, + ProwlInputGeomProvider.DetourAreaFor(link.Area), link.UserId)); + } + } + + public void Process(DtNavMeshCreateParams option) + { + for (int i = 0; i < option.polyCount; i++) + option.polyFlags[i] = PolyFlagWalkable; + + if (_connections.Count == 0) return; + + // Detour keeps only connections whose start point lies in the tile (an XZ test, + // widened by each connection's radius). Counted first so the six output arrays can + // be sized exactly, then filled in a second pass — cheaper than collecting matches + // into a list first. + int count = 0; + for (int i = 0; i < _connections.Count; i++) + if (StartsInTile(_connections[i], option)) count++; + if (count == 0) return; + + option.offMeshConCount = count; + option.offMeshConVerts = new float[count * 6]; + option.offMeshConRad = new float[count]; + option.offMeshConDir = new int[count]; + option.offMeshConAreas = new int[count]; + option.offMeshConFlags = new int[count]; + option.offMeshConUserID = new int[count]; + int w = 0; + for (int i = 0; i < _connections.Count; i++) + { + if (!StartsInTile(_connections[i], option)) continue; + (Float3 start, Float3 end, float radius, bool bidir, int area, int userId) = _connections[i]; + option.offMeshConVerts[6 * w + 0] = (float)start.X; + option.offMeshConVerts[6 * w + 1] = (float)start.Y; + option.offMeshConVerts[6 * w + 2] = (float)start.Z; + option.offMeshConVerts[6 * w + 3] = (float)end.X; + option.offMeshConVerts[6 * w + 4] = (float)end.Y; + option.offMeshConVerts[6 * w + 5] = (float)end.Z; + option.offMeshConRad[w] = radius; + option.offMeshConDir[w] = bidir ? 1 : 0; + option.offMeshConAreas[w] = area; + option.offMeshConFlags[w] = PolyFlagWalkable; + option.offMeshConUserID[w] = userId; + w++; + } + } + + private static bool StartsInTile( + (Float3 Start, Float3 End, float Radius, bool Bidirectional, int Area, int UserId) connection, + DtNavMeshCreateParams option) + { + float margin = connection.Radius; + return connection.Start.X >= option.bmin.X - margin && connection.Start.X <= option.bmax.X + margin + && connection.Start.Z >= option.bmin.Z - margin && connection.Start.Z <= option.bmax.Z + margin; + } + } + + /// Create the DtTileCache wrapping a navmesh (unseeded — the caller adds the layer + /// blobs), with the asset's links loaded into the mesh process so every tile it builds + /// carries them. + public static DtTileCache CreateTileCache(NavMeshData data, DtNavMesh navMesh, int maxObstacles) + => CreateTileCache(data, navMesh, maxObstacles, out _); + + /// + /// The cache's link registry, for keeping the navmesh in step + /// with live s after instantiation. + public static DtTileCache CreateTileCache(NavMeshData data, DtNavMesh navMesh, int maxObstacles, + out ProwlTileCacheMeshProcess meshProcess) + { + NavMeshBuildSettings settings = data.Settings; + var option = new DtTileCacheParams + { + orig = new RcVec3f((float)data.Origin.X, (float)data.Origin.Y, (float)data.Origin.Z), + cs = settings.EffectiveVoxelSize, + ch = settings.EffectiveVoxelHeight, + width = settings.EffectiveTileSize, + height = settings.EffectiveTileSize, + walkableHeight = settings.AgentHeight, + walkableRadius = settings.AgentRadius, + walkableClimb = settings.AgentMaxClimb, + maxSimplificationError = settings.EdgeMaxError, + // Height detail, so polygons follow the surface instead of spanning flat between their + // corners. Recast recommends sampling every six voxels, given here in world units as + // the cache expects; zero is how it is told to skip detail, which is what the setting + // turns off. + detailSampleDist = settings.BuildHeightDetail ? settings.EffectiveVoxelSize * 6 : 0, + detailSampleMaxError = settings.EffectiveVoxelHeight, + // Watershed partitioning with standard contouring, not the cache's monotone sweep: + // avoids sub-voxel ribbon slivers on slopes and keeps holes in regions that enclose + // them. The edge cap is loose on purpose — over-splitting floods flat floors with + // polygons and gives the crowd extra portal corners to steer around. Area thresholds + // are cell counts converted from world units (not Recast's flat default), so voxel + // size doesn't change the mesh's character between rebakes. + watershedPartition = true, + minRegionArea = (int)(settings.MinRegionArea / (settings.EffectiveVoxelSize * settings.EffectiveVoxelSize)), + mergeRegionArea = (int)(20f / (settings.EffectiveVoxelSize * settings.EffectiveVoxelSize)), + maxEdgeLen = 24, + // Layer capacity: tiles can stack several vertical layers each, and the baked + // layer count is a hard floor. + maxTiles = Math.Max(Math.Max(1, data.MaxTiles) * DtTileCacheLayer.EXPECTED_LAYERS_PER_TILE, data.CacheLayers.Count), + maxObstacles = Math.Max(1, maxObstacles), + }; + + meshProcess = new ProwlTileCacheMeshProcess(); + var links = new List(data.Links.Count); + foreach (NavMeshData.NavMeshLinkEntry entry in data.Links) + links.Add(entry.ToSource()); + meshProcess.SetLinks(links, data.Settings.AgentRadius); + + // FastLZ + cCompatibility layout, matching how BuildTileLayers compressed the blobs. + return new DtTileCache(option, new DtTileCacheStorageParams(RcByteOrder.LITTLE_ENDIAN, true), + navMesh, DtTileCacheCompressorFactory.Shared.Create(0), meshProcess); + } + + /// + /// Recompute every obstacle's touched-tile list from the cache's CURRENT tiles. Replacing a + /// compressed tile bumps its salt, so refs captured when the obstacle was added go stale — + /// without this, a regenerated tile would rebuild WITHOUT its carves. Call with the cache + /// quiescent (Update() reporting up-to-date). Replicates the private + /// QueryTiles/CalcTightTileBounds pair from public API. + /// + public static void RefreshObstacleTouchedTiles(DtTileCache cache, NavMeshData data) + { + float cs = data.Settings.EffectiveVoxelSize; + float tw = data.TileWorldSize; + if (tw <= 0) return; + + for (int i = 0; i < cache.GetObstacleCount(); i++) + { + DtTileCacheObstacle ob = cache.GetObstacle(i); + if (ob.state != DtObstacleState.DT_OBSTACLE_PROCESSED) continue; + + RcVec3f bmin = default, bmax = default; + cache.GetObstacleBounds(ob, ref bmin, ref bmax); + ob.touched.Clear(); + + int tx0 = (int)MathF.Floor((bmin.X - (float)data.Origin.X) / tw); + int tx1 = (int)MathF.Floor((bmax.X - (float)data.Origin.X) / tw); + int tz0 = (int)MathF.Floor((bmin.Z - (float)data.Origin.Z) / tw); + int tz1 = (int)MathF.Floor((bmax.Z - (float)data.Origin.Z) / tw); + for (int tz = tz0; tz <= tz1; tz++) + { + for (int tx = tx0; tx <= tx1; tx++) + { + foreach (long tileRef in cache.GetTilesAt(tx, tz)) + { + DtTileCacheLayerHeader? header = cache.GetTileByRef(tileRef)?.header; + if (header == null) continue; + // Tight tile bounds (CalcTightTileBounds is internal upstream). + var tbmin = new RcVec3f(header.bmin.X + header.minx * cs, header.bmin.Y, header.bmin.Z + header.miny * cs); + var tbmax = new RcVec3f(header.bmin.X + (header.maxx + 1) * cs, header.bmax.Y, header.bmin.Z + (header.maxy + 1) * cs); + if (DtUtils.OverlapBounds(bmin, bmax, tbmin, tbmax)) + ob.touched.Add(tileRef); + } + } + } + } + } + +} diff --git a/Prowl.Runtime/Navigation/NavMeshTriangulation.cs b/Prowl.Runtime/Navigation/NavMeshTriangulation.cs new file mode 100644 index 000000000..c6f0139ee --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshTriangulation.cs @@ -0,0 +1,294 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System.Collections.Generic; + +using Prowl.Recast.Detour; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// An off-mesh connection an agent can actually traverse, at the endpoints Detour snapped onto +/// walkable polygons — which is not necessarily where the that produced +/// it asked for. A link that reached nothing walkable is reported here not at all, which is the +/// only way to tell it failed short of watching an agent refuse to cross. +/// +public readonly struct NavMeshConnection(Float3 start, Float3 end, float radius, int area, bool bidirectional, int linkId) +{ + public readonly Float3 Start = start; + public readonly Float3 End = end; + + /// Endpoint radius: half the width the link was built with. + public readonly float Radius = radius; + + /// Area index (see ), which is also what it costs. + public readonly int Area = area; + + public readonly bool Bidirectional = bidirectional; + + /// The stamped at bake, or 0 for a connection that + /// came from somewhere else. + public readonly int LinkId = linkId; + + /// + /// Read one of a tile's connections, unless it is not traversable end to end. Detour keeps + /// the stub in the tile whichever end failed, so the test is on the links themselves. + /// Everything worth reporting hangs off the connection's polygon rather than the connection + /// — the area, and the endpoints, since con.pos holds what was asked for while the + /// polygon's vertices hold where the ends snapped to. + /// + internal static bool TryFrom(DtMeshTile tile, DtOffMeshConnection con, out NavMeshConnection connection) + { + connection = default; + DtPoly poly = tile.data.polys[con.poly]; + if (!IsAttachedAtBothEnds(tile, poly)) return false; + + connection = new NavMeshConnection( + VertexAt(tile, poly.verts[0]), VertexAt(tile, poly.verts[1]), con.rad, + NavMeshAreas.FromDetourArea(poly.GetArea()), + (con.flags & DtDetour.DT_OFFMESH_CON_BIDIR) != 0, + con.userId); + return true; + } + + /// + /// A connection's two ends are attached independently — the start when its own tile is + /// built, the far end when the tile it lands in is — and each leaves a link on the + /// connection's polygon tagged with which end it is. An end that found nothing walkable + /// within the connection's radius leaves none, and an agent arriving at a connection with + /// no far end has nowhere to come out: the path across is partial, not complete. + /// + private static bool IsAttachedAtBothEnds(DtMeshTile tile, DtPoly poly) + { + bool start = false, end = false; + for (int i = poly.firstLink; i != DtDetour.DT_NULL_LINK; i = tile.links[i].next) + { + if (tile.links[i].edge == 0) start = true; + else if (tile.links[i].edge == 1) end = true; + } + return start && end; + } + + /// One of a tile's vertices, which are stored as loose floats. + internal static Float3 VertexAt(DtMeshTile tile, int vertexIndex) + { + int i = vertexIndex * 3; + return new Float3(tile.data.verts[i], tile.data.verts[i + 1], tile.data.verts[i + 2]); + } +} + +/// What a navmesh polygon edge borders, for debug drawing: the walkable boundary, +/// another polygon, or the seam to a neighbouring tile. +public enum NavMeshEdgeKind : byte +{ + /// Nothing walkable on the far side — the edge of the mesh. + Border, + + /// Another polygon in the same tile. + Inner, + + /// A tile seam; the far side lives in the neighbouring tile. + TilePortal, +} + +/// One segment of a navmesh polygon edge, classified. These are the polygon outlines — +/// the mesh's structure — as opposed to the height-detail triangles that carpet their interiors. +/// An outline is reported as the chain of segments the height detail actually renders, since the +/// detail bends the surface between corners and a single chord corner to corner would leave the +/// surface it is meant to outline. +public readonly struct NavMeshEdge(Float3 a, Float3 b, NavMeshEdgeKind kind) +{ + public readonly Float3 A = a; + public readonly Float3 B = b; + public readonly NavMeshEdgeKind Kind = kind; +} + +/// +/// A triangulated snapshot of a navmesh, for debug drawing and user tooling +/// (matches Unity's NavMeshTriangulation). Triangles come from each polygon's height detail, +/// which is the surface an agent is actually placed on — a polygon's own corners describe only +/// its outline, and reading heights from those alone flattens whatever the polygon spans. +/// +public struct NavMeshTriangulation +{ + /// World-space vertices. + public Float3[] Vertices; + + /// Triangle indices into (three per triangle). + public int[] Indices; + + /// Per-triangle area index (see ), parallel to + /// / 3. + public int[] Areas; + + /// Parallel to : true for a polygon corner, false for a + /// vertex the height detail added between corners. Corners are the navmesh's structure — + /// welded, shared across polygons and stitched across tiles — while detail vertices belong + /// to one polygon's surface only, which is the distinction debug drawing wants to show. + public bool[] IsPolygonCorner; + + /// Polygon outline edges, classified (border / inner / tile seam). Inner edges are + /// reported once per pair. + public NavMeshEdge[] Edges; + + /// The mesh's off-mesh connections. Kept apart from the triangles because a + /// connection is somewhere an agent may travel, not surface it travels on. + public NavMeshConnection[] Connections; + + /// An empty triangulation (no navmesh to walk). + public static NavMeshTriangulation Empty => new() { Vertices = [], Indices = [], Areas = [], IsPolygonCorner = [], Edges = [], Connections = [] }; + + /// + /// Fan-triangulate every walkable polygon of a Detour navmesh. Callers holding a live + /// instance must take its read lock around this; callers triangulating a mesh they built + /// themselves (an unregistered asset, e.g. for editor gizmos) own it exclusively already. + /// + public static NavMeshTriangulation FromNavMesh(DtNavMesh mesh) + { + if (mesh == null) return Empty; + + List vertices = []; + List isCorner = []; + List indices = []; + List areas = []; + List edges = []; + List connections = []; + List<(float T, int Index)> bends = []; + + for (int t = 0; t < mesh.GetMaxTiles(); t++) + { + DtMeshTile tile = mesh.GetTile(t); + if (tile?.data?.header == null) continue; + + foreach (DtOffMeshConnection con in tile.data.offMeshCons ?? []) + if (NavMeshConnection.TryFrom(tile, con, out NavMeshConnection connection)) + connections.Add(connection); + + for (int p = 0; p < tile.data.header.polyCount; p++) + { + DtPoly poly = tile.data.polys[p]; + // Its two vertices are endpoints, not a surface; it is reported in Connections. + if (poly.GetPolyType() == DtPolyTypes.DT_POLYTYPE_OFFMESH_CONNECTION) continue; + + int area = NavMeshAreas.FromDetourArea(poly.GetArea()); + int baseVert = vertices.Count; + for (int v = 0; v < poly.vertCount; v++) + { + vertices.Add(NavMeshConnection.VertexAt(tile, poly.verts[v])); + isCorner.Add(true); + } + + if (tile.data.detailMeshes == null) + { + // No detail to read: the polygon is its own flat fan (as Detour also assumes), + // and its outlines are the corner-to-corner chords. + for (int v = 0; v < poly.vertCount; v++) + if (TryClassifyEdge(poly, v, p, out NavMeshEdgeKind flatKind)) + edges.Add(new NavMeshEdge(vertices[baseVert + v], vertices[baseVert + (v + 1) % poly.vertCount], flatKind)); + + for (int v = 2; v < poly.vertCount; v++) + { + indices.Add(baseVert); + indices.Add(baseVert + v - 1); + indices.Add(baseVert + v); + areas.Add(area); + } + continue; + } + + // A detail sub-mesh reuses the polygon's corners as its first vertices and stores + // only the ones it added, so appending those keeps every detail index — corner or + // added — at baseVert + index. + DtPolyDetail detail = tile.data.detailMeshes[p]; + for (int v = 0; v < detail.vertCount; v++) + { + int i = (detail.vertBase + v) * 3; + vertices.Add(new Float3(tile.data.detailVerts[i], tile.data.detailVerts[i + 1], tile.data.detailVerts[i + 2])); + isCorner.Add(false); + } + + for (int d = 0; d < detail.triCount; d++) + { + int i = (detail.triBase + d) * 4; + indices.Add(baseVert + tile.data.detailTris[i]); + indices.Add(baseVert + tile.data.detailTris[i + 1]); + indices.Add(baseVert + tile.data.detailTris[i + 2]); + areas.Add(area); + } + + // Outlines follow the detail: every detail vertex the builder placed along an + // edge is a bend in the rendered surface, so the edge is reported as the chain + // through them. Which vertices those are is read off the geometry rather than + // the detail triangles' boundary flags, since a vertex shared by two edges (a + // corner's own copy) carries no flag of its own. + for (int v = 0; v < poly.vertCount; v++) + { + if (!TryClassifyEdge(poly, v, p, out NavMeshEdgeKind kind)) continue; + + Float3 a = vertices[baseVert + v]; + Float3 b = vertices[baseVert + (v + 1) % poly.vertCount]; + bends.Clear(); + for (int d = 0; d < detail.vertCount; d++) + { + Float3 q = vertices[baseVert + poly.vertCount + d]; + if (TryEdgeParameter(a, b, q, out float along)) + bends.Add((along, baseVert + poly.vertCount + d)); + } + + bends.Sort(static (x, y) => x.T.CompareTo(y.T)); + Float3 from = a; + foreach ((float _, int index) in bends) + { + edges.Add(new NavMeshEdge(from, vertices[index], kind)); + from = vertices[index]; + } + + edges.Add(new NavMeshEdge(from, b, kind)); + } + } + } + + return new NavMeshTriangulation + { + Vertices = [.. vertices], + Indices = [.. indices], + Areas = [.. areas], + IsPolygonCorner = [.. isCorner], + Edges = [.. edges], + Connections = [.. connections], + }; + } + + /// What the polygon's edge starting at borders, or false when + /// another polygon already reported it — an interior edge belongs to the lower-indexed of the + /// pair, so it is drawn once. + private static bool TryClassifyEdge(DtPoly poly, int v, int p, out NavMeshEdgeKind kind) + { + int nei = poly.neis[v]; + kind = nei == 0 ? NavMeshEdgeKind.Border + : (nei & DtDetour.DT_EXT_LINK) != 0 ? NavMeshEdgeKind.TilePortal + : NavMeshEdgeKind.Inner; + return nei == 0 || (nei & DtDetour.DT_EXT_LINK) != 0 || nei - 1 >= p; + } + + /// Where falls along the edge a→b, if it lies on it. Detail + /// vertices are placed on the edge line in XZ and carry the surface's height, so the test is + /// horizontal; the tolerance is the one the detail builder itself uses to decide a vertex is + /// on a polygon boundary. + private static bool TryEdgeParameter(Float3 a, Float3 b, Float3 q, out float t) + { + const float onEdgeSq = 0.001f * 0.001f; + t = 0; + float dx = b.X - a.X, dz = b.Z - a.Z; + float lenSq = dx * dx + dz * dz; + if (lenSq < 1e-12f) return false; + + t = ((q.X - a.X) * dx + (q.Z - a.Z) * dz) / lenSq; + if (t <= 0 || t >= 1) return false; + + float ex = a.X + dx * t - q.X, ez = a.Z + dz * t - q.Z; + return ex * ex + ez * ez <= onEdgeSq; + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshWorld.cs b/Prowl.Runtime/Navigation/NavMeshWorld.cs new file mode 100644 index 000000000..0142e6be9 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshWorld.cs @@ -0,0 +1,1059 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.Crowd; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// A registered navmesh inside a : the instantiated Detour navmesh, +/// its query pool, and the lock that lets queries run from any thread while tile mutations +/// (rebakes, partial rebuilds) exclude them. Obtained from +/// ; advanced users can reach the raw Detour objects +/// through . +/// +public sealed class NavMeshInstance +{ + internal NavMeshData Data; + internal DtNavMesh Mesh; + internal readonly ReaderWriterLockSlim Lock = new(LockRecursionPolicy.NoRecursion); + internal readonly ConcurrentBag QueryPool = new(); + + // Set when work is queued into the cache (an obstacle request, a tile swap), cleared once + // the pump drains it. Only flagged instances are pumped, so a freshly registered instance + // (every tile seeded synchronously) starts clean, and a surface nothing ever carves costs + // nothing per frame. Main-thread only, like registration itself. + internal bool CachePending; + + internal NavMeshInstance(NavMeshData data, Prowl.Recast.Detour.TileCache.DtTileCache tileCache, + NavMeshTileBuilder.ProwlTileCacheMeshProcess tileCacheLinks) + { + Data = data; + Mesh = tileCache.GetNavMesh(); + TileCache = tileCache; + TileCacheLinks = tileCacheLinks; + } + + /// The link set this instance's cache re-injects whenever it rebuilds a tile. + /// Mutate under the instance write lock and rebuild the affected tiles afterwards — see + /// . + internal NavMeshTileBuilder.ProwlTileCacheMeshProcess TileCacheLinks { get; } + + /// The TileCache backing this instance. Obstacles queue through it and + /// pumps its incremental tile rebuilds. Queue work through + /// , which flags the instance for you — the pump + /// only runs for instances known to have pending work, and DtTileCache cannot be asked + /// whether it has any, so a request enqueued behind its back waits forever. Code that + /// queues on this handle directly must call . + public Prowl.Recast.Detour.TileCache.DtTileCache TileCache { get; } + + /// Tell the pump this cache has work waiting. Needed after queuing on + /// directly, which is what does; + /// calls it for you. Main thread only. + public void MarkCachePending() => CachePending = true; + + /// The agent type this navmesh was built for. + public int AgentTypeId => Data.Settings.AgentTypeId; + + /// The asset this instance was created from. + public NavMeshData NavMeshData => Data; + + /// The underlying Detour navmesh, owned by . Advanced use; + /// mutating it directly bypasses the query locking and desyncs it from the cache that built + /// it — prefer for tile changes. + public DtNavMesh NativeNavMesh => Mesh; + + // The mesh's traversable off-mesh connections by link id, built lazily and invalidated on + // mutation — turns per-link lookups (every NavMeshLink at scene load, and again per frame + // while one is selected) into O(1) after a single O(tiles) pass. A link Detour could not + // attach is absent, so "contains" means usable rather than merely present, and a catch-up + // retries one that failed instead of taking the stub for success. Main thread only. + private Dictionary? _connections; + + internal void InvalidateLinkIds() => _connections = null; + + // ReaderWriterLockSlim owns kernel wait handles that only Dispose releases, and disposing + // one while a thread is inside it throws on that thread rather than this one. Since a worker + // can ask for a query at any moment — including the instant this instance is unregistered — + // users are counted: one for the registration, one per outstanding lease or mutation, and + // whichever is last out disposes. A count that reached zero cannot be revived, so that + // happens exactly once and with nobody inside. + private int _users = 1; + + private volatile bool _retired; + + internal bool TryAcquire() + { + if (_retired) return false; + int users = Volatile.Read(ref _users); + while (users > 0) + { + int seen = Interlocked.CompareExchange(ref _users, users + 1, users); + if (seen == users) return true; + users = seen; + } + return false; + } + + internal void Release() + { + if (Interlocked.Decrement(ref _users) == 0) + Lock.Dispose(); + } + + /// Unregistration, from the lock's point of view: stop admitting queries, wait out + /// the ones already inside, poison the pool, and drop the registration's own hold. + internal void Retire() + { + _retired = true; + + Lock.EnterWriteLock(); + QueryPool.Clear(); + Lock.ExitWriteLock(); + + Release(); + } + + /// Whether the mesh holds a traversable connection stamped with the given link id + /// (see ). Main thread. + public bool ContainsLinkId(int linkId) => Connections.ContainsKey(linkId); + + /// The connection the mesh holds for a link id — where its endpoints actually + /// snapped to, which is not necessarily where the component put them. False when the link + /// never attached. Main thread. + public bool TryGetConnection(int linkId, out NavMeshConnection connection) + => Connections.TryGetValue(linkId, out connection); + + private Dictionary Connections + { + get + { + if (_connections != null) return _connections; + + _connections = []; + for (int t = 0; t < Mesh.GetMaxTiles(); t++) + { + DtMeshTile? tile = Mesh.GetTile(t); + if (tile?.data?.offMeshCons == null) continue; + foreach (DtOffMeshConnection con in tile.data.offMeshCons) + if (con.userId != 0 && NavMeshConnection.TryFrom(tile, con, out NavMeshConnection connection)) + _connections[con.userId] = connection; + } + return _connections; + } + } +} + +/// +/// One agent type's crowd: the Detour crowd, the navmesh instance it steers against, and the +/// 16 query-filter slots it was constructed over. Slot 0 is the shared default (all areas, no +/// cost overrides); slots 1..15 are refcounted and allocated per distinct (AreaMask, +/// cost-overrides) configuration, so agents with identical filters share a slot. Slot numbers +/// are NOT stable across release/re-acquire — nothing outside this entry may key state on +/// them. Main-thread only, like all crowd state. +/// +internal sealed class NavMeshCrowdEntry +{ + public readonly DtCrowd Crowd; + public readonly NavMeshInstance Instance; + + // The filter objects the crowd reads live each update — mutating one changes the steering + // of every agent on that slot immediately. + private readonly NavMeshQueryFilter[] _filters; + private readonly int[] _refCounts = new int[DtCrowdConst.DT_CROWD_MAX_QUERY_FILTER_TYPE]; + + // Once per entry: a crowd rebind makes every agent re-acquire, and a persistent overflow + // population would otherwise warn per agent per rebake — log spam at destructible-world + // frequency. The entry is recreated on rebind, so each new crowd re-warns exactly once. + private bool _exhaustionWarned; + + public NavMeshCrowdEntry(DtCrowd crowd, NavMeshInstance instance, NavMeshQueryFilter[] filters) + { + Crowd = crowd; + Instance = instance; + _filters = filters; + } + + /// + /// Slot whose filter matches the configuration exactly, sharing where possible: the + /// default config maps to slot 0, a config already in use bumps that slot's refcount, and + /// a new config takes a free slot. On exhaustion (16 distinct steering configurations for + /// one agent type) warns and falls back to slot 0. + /// + public int AcquireFilterSlot(int areaMask, float[]? costOverrides, string? agentName = null) + { + if (areaMask == NavMeshAreas.AllAreas && OverridesEqual(costOverrides, null)) + return 0; + + // Exact-match scan beats hashing here: at most 15 candidates, and comparing the full + // config can never merge two different configurations the way a hash collision would. + for (int slot = 1; slot < _filters.Length; slot++) + { + if (_refCounts[slot] > 0 && _filters[slot].AreaMask == areaMask + && OverridesEqual(_filters[slot].CostOverrides, costOverrides)) + { + _refCounts[slot]++; + return slot; + } + } + + for (int slot = 1; slot < _filters.Length; slot++) + { + if (_refCounts[slot] == 0) + { + _filters[slot].AreaMask = areaMask; + _filters[slot].CopyCostOverridesFrom(costOverrides); + _refCounts[slot] = 1; + return slot; + } + } + + if (!_exhaustionWarned) + { + _exhaustionWarned = true; + string who = string.IsNullOrEmpty(agentName) ? "an agent" : $"agent '{agentName}'"; + Debug.LogWarning($"[Navigation] All {_filters.Length} crowd filter slots for agent type {Instance.AgentTypeId} are in use ({_filters.Length - 1} distinct AreaMask/cost configurations); {who} steers with the default filter instead. Explicit queries (CalculatePath etc.) are unaffected. Further overflows on this crowd will not be logged."); + } + return 0; + } + + /// Release a slot returned by . Slot 0 is shared + /// and never released. A slot's filter resets to defaults when its last user leaves. + public void ReleaseFilterSlot(int slot) + { + if (slot <= 0 || slot >= _refCounts.Length || _refCounts[slot] == 0) return; + if (--_refCounts[slot] == 0) + { + _filters[slot].AreaMask = NavMeshAreas.AllAreas; + _filters[slot].ClearAreaCosts(); + } + } + + private static bool OverridesEqual(float[]? a, float[]? b) + { + if (ReferenceEquals(a, b)) return true; // both null: the common mask-only case + // 0 means "no override", so a null array equals an all-zero one. + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + { + float av = a != null && i < a.Length ? a[i] : 0f; + float bv = b != null && i < b.Length ? b[i] : 0f; + if (av != bv) return false; + } + return true; + } +} + +/// +/// A rented thread-safe navmesh query. Dispose to return it to the pool. Leases hold a read +/// lock on the navmesh, so keep them short-lived — a lease held across frames blocks rebuilds. +/// +public readonly struct NavMeshQueryLease : IDisposable +{ + private readonly NavMeshInstance _instance; + + /// The Detour query, valid until this lease is disposed. + public DtNavMeshQuery Query { get; } + + internal NavMeshQueryLease(NavMeshInstance instance, DtNavMeshQuery query) + { + _instance = instance; + Query = query; + } + + public void Dispose() + { + if (_instance == null) return; + _instance.QueryPool.Add(Query); + _instance.Lock.ExitReadLock(); + _instance.Release(); + } +} + +/// +/// Per-scene navigation state: the registered navmeshes, the query API over them, and (once +/// agents register) the crowd simulation. Owned by the +/// same way physics state is owned by ; the static +/// facade forwards to the current scene's world. +/// +/// Queries are thread-safe: each takes a pooled Detour query under a read lock, so gameplay +/// code may path-find from worker threads. Tile mutations take the write lock and invalidate +/// pooled queries. +/// +public sealed class NavMeshWorld +{ + private int _maxPolyPath = 1024; + private int _maxStraightPath = 256; + + /// + /// How many navmesh polygons one path may cross. Detour needs an explicit ceiling; the + /// default matches what the Recast demos use for long paths. A route that would exceed it + /// comes back rather than failing, so the symptom + /// of setting it too low is agents that stop short on long journeys for no visible reason. + /// Buffers are rented per query, so the cost is per query in flight, not per world. + /// + public int MaxPolyPath + { + get => _maxPolyPath; + set => _maxPolyPath = Math.Max(2, value); + } + + /// + /// How many corners one path may have. The same trade as : a path + /// that fills the buffer is reported partial. Corners are the turns of the string-pulled + /// route, so this can be far smaller than the polygon count. + /// + public int MaxStraightPath + { + get => _maxStraightPath; + set => _maxStraightPath = Math.Max(2, value); + } + + private readonly List _instances = []; + private readonly Lock _instancesLock = new(); + + [ThreadStatic] private static NavMeshQueryFilter? t_scratchFilter; + + // Boxed because queries read the extents from worker threads and a Float3 is three separate + // floats: a plain field could be read mid-assignment and snap against a mix of the old and + // new value. Storing it behind a reference makes publication a single atomic write, so a + // reader sees one whole value or the other. Only the setter allocates. + private volatile object _defaultQueryExtents = new Float3(1f, 2f, 1f); + + /// Default half-extents used to snap query positions onto the navmesh, in world + /// units. Larger values tolerate more vertical mismatch but can snap to the wrong floor. + public Float3 DefaultQueryExtents + { + get => (Float3)_defaultQueryExtents; + set => _defaultQueryExtents = value; + } + + /// Maximum agent radius the crowds' proximity grids are sized for. Agents with a + /// larger Radius degrade neighbour queries silently, so registration warns when one + /// exceeds this. Set BEFORE the first agent of a type registers — each crowd is configured + /// with it at creation (a later change applies after that crowd's next rebind). + public float CrowdMaxAgentRadius = 2f; + + // One crowd per agent type, created when the first agent of that type registers and + // dropped when the navmesh instance it steers against is removed (its agents rejoin the + // replacement crowd via NavMeshChanged). Main-thread only, like registration. + private readonly Dictionary _crowds = []; + + /// The crowd simulation for the default agent type (0). Sugar for + /// . Null until the first such agent registers. + public DtCrowd? NativeCrowd => GetNativeCrowd(0); + + /// How many agent types currently have a crowd. Lets components notice cheaply + /// that a crowd appeared (the first agent of a type registering) without walking the + /// agent-type table every frame. + internal int CrowdCount => _crowds.Count; + + /// + /// Bumped whenever the SET of registered navmeshes changes — a surface registering, + /// unregistering, or being replaced by a rebake. can't stand in + /// for this: it also fires for tile-content changes, i.e. every frame a carve is converging. + /// Components that only care about instances appearing or dying (link catch-up, obstacle + /// re-attachment) should compare this instead, so gameplay-rate carving doesn't wake work + /// that has nothing to do. + /// + public int StructureGeneration { get; private set; } + + /// The crowd steering agents of the given type, or null while none have + /// registered. Advanced use — Prowl agents manage their crowd membership themselves. + public DtCrowd? GetNativeCrowd(int agentTypeId = 0) + => _crowds.TryGetValue(agentTypeId, out NavMeshCrowdEntry? entry) ? entry.Crowd : null; + + /// + /// Get or create the crowd for the instance's agent type. Called by agents on + /// registration; the crowd binds to the instance's Detour navmesh and is dropped with it. + /// + internal NavMeshCrowdEntry EnsureCrowd(NavMeshInstance instance) + { + int agentTypeId = instance.AgentTypeId; + if (_crowds.TryGetValue(agentTypeId, out NavMeshCrowdEntry? existing)) return existing; + + // The factory runs for all 16 slots inside the DtCrowd constructor; every slot gets a + // mutable NavMeshQueryFilter we keep, so slot configs can change without touching the crowd. + var filters = new NavMeshQueryFilter[DtCrowdConst.DT_CROWD_MAX_QUERY_FILTER_TYPE]; + var crowd = new DtCrowd(new DtCrowdConfig(CrowdMaxAgentRadius), instance.NativeNavMesh, + i => filters[i] = new NavMeshQueryFilter { AgentTypeId = agentTypeId }); + + // Presets + any user overrides live on the world (survive crowd rebinds); slots 0..3 + // map to Low/Medium/Good/High quality. + ApplyAvoidanceParams(crowd); + + var entry = new NavMeshCrowdEntry(crowd, instance, filters); + _crowds[agentTypeId] = entry; + return entry; + } + + // Per-quality obstacle-avoidance overrides (slot = ObstacleAvoidanceType - 1). Null slots + // use the built-in presets. Survive crowd rebinds: a replacement crowd re-applies them. + private readonly DtObstacleAvoidanceParams?[] _avoidanceOverrides = new DtObstacleAvoidanceParams?[4]; + + /// + /// The obstacle-avoidance parameters agents of the given quality steer with — the + /// override set via , or the built-in preset. + /// + public DtObstacleAvoidanceParams GetObstacleAvoidanceParams(ObstacleAvoidanceType quality) + { + int slot = AvoidanceSlot(quality); + return _avoidanceOverrides[slot] ?? CreateDefaultAvoidanceParams(slot); + } + + /// + /// Replace the obstacle-avoidance tuning for a quality level. The built-in presets are + /// Recast-demo values tuned for open levels; tight-corridor maps typically want a shorter + /// horizon and more current-velocity damping (raise weightCurVel) to stop + /// oscillation. Applies to the live crowd immediately and to any crowd created later. + /// + public void SetObstacleAvoidanceParams(ObstacleAvoidanceType quality, DtObstacleAvoidanceParams option) + { + ArgumentNullException.ThrowIfNull(option); + int slot = AvoidanceSlot(quality); + _avoidanceOverrides[slot] = option; + foreach (NavMeshCrowdEntry entry in _crowds.Values) + entry.Crowd.SetObstacleAvoidanceParams(slot, option); + } + + /// Push presets + overrides into a crowd (called on crowd creation/rebind). + internal void ApplyAvoidanceParams(DtCrowd crowd) + { + for (int slot = 0; slot < _avoidanceOverrides.Length; slot++) + crowd.SetObstacleAvoidanceParams(slot, _avoidanceOverrides[slot] ?? CreateDefaultAvoidanceParams(slot)); + } + + private static int AvoidanceSlot(ObstacleAvoidanceType quality) + { + if (quality == ObstacleAvoidanceType.NoObstacleAvoidance) + throw new ArgumentOutOfRangeException(nameof(quality), "NoObstacleAvoidance has no avoidance parameters."); + return (int)quality - 1; + } + + /// Built-in presets: slots 0..3 map to Low/Medium/Good/High quality. Values match + /// the Recast demo's, differing per slot in adaptive sampling density. + private static DtObstacleAvoidanceParams CreateDefaultAvoidanceParams(int slot) + { + (int divs, int rings, int depth)[] presets = [(5, 2, 1), (5, 2, 2), (7, 2, 3), (7, 3, 3)]; + (int divs, int rings, int depth) preset = presets[Math.Clamp(slot, 0, presets.Length - 1)]; + return new DtObstacleAvoidanceParams + { + velBias = 0.4f, + weightDesVel = 2.0f, + weightCurVel = 0.75f, + weightSide = 0.75f, + weightToi = 2.5f, + horizTime = 2.5f, + gridSize = 33, + adaptiveDivs = preset.divs, + adaptiveRings = preset.rings, + adaptiveDepth = preset.depth, + }; + } + + /// Raised at the start of each navigation update, before the crowd steps. + public event Action? PreUpdate; + + /// Raised whenever a navmesh is added, removed, or mutated — including on every + /// frame a carve is still converging, so a listener that only wants the finished result + /// should use . + public event Action? NavMeshChanged; + + /// + /// Raised when queued work finishes and the navmesh is stable again. A carve spans several + /// frames, and anything expensive — re-pathing a crowd, rebuilding a cached triangulation — + /// wants to run once at the end rather than on each of them. + /// + public event Action? NavMeshSettled; + + #region Registration + + /// Obstacle capacity navmeshes are instantiated with. Set BEFORE the surface + /// registers — applied at instantiation. + public int TileCacheMaxObstacles = 256; + + /// Tiles each instance may rebuild per frame while draining queued carves. Higher + /// spends more frame time to put a carve on the navmesh sooner: a batch takes + /// tiles / MaxTileUpdatesPerFrame frames to land. Values below 1 are treated as 1. + public int MaxTileUpdatesPerFrame = 4; + + /// + /// Instantiate and register a baked navmesh. Returns the instance handle, or null when the + /// data has no tiles or fails to instantiate. + /// + /// Threading: fires synchronously on the calling thread, and + /// subscribers (agents, editor overlays) touch the crowd and Transforms — call from the + /// main thread, or guarantee nothing is subscribed. (Queries are the thread-safe surface; + /// registration is not.) + /// + public NavMeshInstance? AddNavMeshData(NavMeshData data) + { + if (data == null || !data.HasTiles) return null; + + NavMeshInstance instance; + try + { + Prowl.Recast.Detour.TileCache.DtTileCache cache = data.CreateTileCache(TileCacheMaxObstacles, + out NavMeshTileBuilder.ProwlTileCacheMeshProcess links); + instance = new NavMeshInstance(data, cache, links); + } + catch (Exception e) + { + // Type and stack included deliberately: the throw comes from inside Detour, several + // frames below anything the message alone would name, and without them an + // instantiation failure is undiagnosable from the console. + Debug.LogError($"[Navigation] Failed to instantiate NavMeshData '{data.Name}' ({data.CacheLayers.Count} layers, MaxTiles={data.MaxTiles}, MaxPolys={data.MaxPolys}, tile={data.Settings.EffectiveTileSize} voxels, voxel={data.Settings.EffectiveVoxelSize:0.####}): {e}"); + return null; + } + + lock (_instancesLock) + _instances.Add(instance); + StructureGeneration++; + NavMeshChanged?.Invoke(); + return instance; + } + + /// Unregister a navmesh. Blocks until in-flight queries on it finish. + public void RemoveNavMeshData(NavMeshInstance? instance) + { + if (instance == null) return; + + bool removed; + lock (_instancesLock) + removed = _instances.Remove(instance); + if (!removed) return; + + instance.Retire(); + + // A crowd steers against its instance's DtNavMesh; it must not survive the mesh. + // Its agents notice their crowd is gone via NavMeshChanged and rejoin the next one + // (keeping their destinations) when a replacement instance registers. Other agent + // types' crowds are untouched. + if (_crowds.TryGetValue(instance.AgentTypeId, out NavMeshCrowdEntry? entry) + && ReferenceEquals(entry.Instance, instance)) + { + _crowds.Remove(instance.AgentTypeId); + } + + StructureGeneration++; + NavMeshChanged?.Invoke(); + } + + /// Remove every registered navmesh (scene teardown). + public void Clear() + { + List toRemove; + lock (_instancesLock) + { + toRemove = [.. _instances]; + _instances.Clear(); + } + foreach (NavMeshInstance instance in toRemove) + instance.Retire(); + _pendingLinkTiles.Clear(); + _crowds.Clear(); + if (toRemove.Count > 0) + { + StructureGeneration++; + NavMeshChanged?.Invoke(); + } + } + + /// The registered navmesh for an agent type, or null. When several are registered + /// for the same type, the first registered wins (one navmesh per agent type is the + /// supported setup; merging surfaces arrives with modifier support). + public NavMeshInstance? GetInstance(int agentTypeId = 0) + { + lock (_instancesLock) + { + for (int i = 0; i < _instances.Count; i++) + if (_instances[i].AgentTypeId == agentTypeId) + return _instances[i]; + } + return null; + } + + /// True when a navmesh is registered for the agent type. + public bool HasNavMesh(int agentTypeId = 0) => GetInstance(agentTypeId) != null; + + /// + /// Run a mutation against an instance's TileCache under the write lock (layer + /// regeneration, bulk obstacle edits). In-flight queries finish first. Pooled queries + /// survive the mutation — verified against Prowl.Recast, a DtNavMeshQuery holds only the + /// mesh reference plus node pools it clears on entry, no cached tile state, so discarding + /// the pool here would only churn tens-of-KB objects for nothing. + /// + /// Threading: fires synchronously on the calling thread (see + /// — same main-thread contract). + /// + public void MutateTileCache(NavMeshInstance instance, Action mutation) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(mutation); + // Unregistered: its cache is no longer anyone's navmesh, and its lock may already be + // gone. An async rebuild finishing after its surface was torn down lands here. + if (!instance.TryAcquire()) return; + + instance.Lock.EnterWriteLock(); + try + { + mutation(instance.TileCache); + } + finally + { + instance.Lock.ExitWriteLock(); + instance.Release(); + } + // A mutation can leave tiles queued (added tiles rebuild lazily, obstacle edits queue + // requests), so hand the instance to the pump regardless of what the caller did. + instance.CachePending = true; + instance.InvalidateLinkIds(); + NavMeshChanged?.Invoke(); + } + + // Link id -> the enabled component that owns it, for resolving a crowd agent's off-mesh + // connection back to the link it came from. Per world rather than per process: ids come from + // the component's scene identifier, so a global table would let one additively loaded scene + // answer for another's links. Main thread only, like the callbacks that fill it. + private readonly Dictionary _links = []; + + internal void RegisterLink(NavMeshLink link) => _links[link.LinkId] = link; + + /// Only if this link is the registered owner: on an id collision the loser must not + /// evict the winner when it is disabled. + internal void UnregisterLink(NavMeshLink link) + { + if (_links.TryGetValue(link.LinkId, out NavMeshLink? owner) && ReferenceEquals(owner, link)) + _links.Remove(link.LinkId); + } + + /// The enabled link with the given id in this scene, or null (see + /// ). + public NavMeshLink? FindLink(int linkId) + => _links.TryGetValue(linkId, out NavMeshLink? link) && link.IsValid() ? link : null; + + /// Every enabled link in this scene. What a bake gathers its off-mesh connections + /// from, so it never has to walk the scene to find them. + public IReadOnlyCollection Links => _links.Values; + + // The scene's enabled surfaces. A link needs the ones it applies to on every edit, so + // finding them by walking every GameObject would cost a scene scan per link moved — with + // the link collection each rebuild does nested inside it. + private readonly List _surfaces = []; + + internal void RegisterSurface(NavMeshSurface surface) + { + if (!_surfaces.Contains(surface)) _surfaces.Add(surface); + } + + internal void UnregisterSurface(NavMeshSurface surface) + { + _surfaces.Remove(surface); + _pendingLinkTiles.Remove(surface); + } + + // Link tiles waiting to re-contour, per surface. A link edit dirties the tiles around both + // its endpoints, and one event can edit many at once (a building coming down takes its + // ladders with it). Applying each edit as it arrives would re-collect and re-contour + // redundantly, so edits are held until the frame's are all in, then applied as one pass. + private Dictionary> _pendingLinkTiles = []; + + // The batch being drained, swapped with the one above rather than enumerated in place. The + // region lists are pooled between frames: a link following a Transform marks every frame, and + // this is otherwise a list per surface per frame for the life of the movement. + private Dictionary> _drainingLinkTiles = []; + private readonly Stack> _regionPool = []; + + internal void MarkLinkTilesDirty(NavMeshSurface surface, AABB region) + { + if (!_pendingLinkTiles.TryGetValue(surface, out List? regions)) + _pendingLinkTiles[surface] = regions = _regionPool.Count > 0 ? _regionPool.Pop() : []; + regions.Add(region); + } + + private void DrainLinkTiles() + { + if (_pendingLinkTiles.Count == 0) return; + + // Swapped out before draining: a rebuild raises NavMeshChanged synchronously, and a + // handler is free to disable a link or surface from it. Mutating the collection being + // enumerated would lose anything marked mid-drain; against the swapped-in batch it + // simply joins next frame's instead, which is where it belongs anyway. + (_pendingLinkTiles, _drainingLinkTiles) = (_drainingLinkTiles, _pendingLinkTiles); + + foreach ((NavMeshSurface surface, List regions) in _drainingLinkTiles) + { + if (surface.IsValid()) + surface.RebuildLinkTiles(CollectionsMarshal.AsSpan(regions)); + regions.Clear(); + _regionPool.Push(regions); + } + _drainingLinkTiles.Clear(); + } + + /// Every enabled surface in this scene, whether or not it has a navmesh loaded. + public IReadOnlyList Surfaces => _surfaces; + + #endregion + + #region Query lease + + /// + /// Rent a thread-safe query over the agent type's navmesh. Dispose the lease promptly — + /// it holds a read lock that blocks navmesh mutations. Returns false when no navmesh is + /// registered for the agent type. + /// + public bool TryRentQuery(out NavMeshQueryLease lease, int agentTypeId = 0) + { + NavMeshInstance? instance = GetInstance(agentTypeId); + // Acquiring is what keeps the instance's lock alive for the life of the lease — it may + // be unregistered a moment from now, and the last user out is what disposes. A retired + // one refuses, so a query never begins against a navmesh the world has already dropped. + if (instance == null || !instance.TryAcquire()) + { + lease = default; + return false; + } + + instance.Lock.EnterReadLock(); + if (!instance.QueryPool.TryTake(out DtNavMeshQuery? query)) + query = new DtNavMeshQuery(instance.Mesh); + lease = new NavMeshQueryLease(instance, query); + return true; + } + + #endregion + + #region Queries + + private static NavMeshQueryFilter GetScratchFilter(int areaMask) + { + NavMeshQueryFilter filter = t_scratchFilter ??= new NavMeshQueryFilter(); + filter.AreaMask = areaMask; + filter.AgentTypeId = 0; + return filter; + } + + private static RcVec3f ToRc(Float3 v) => new((float)v.X, (float)v.Y, (float)v.Z); + private static Float3 ToFloat3(RcVec3f v) => new(v.X, v.Y, v.Z); + + /// Calculate a path between two points. Returns true when the resulting path is + /// complete or partial; carries the corners and exact status. + public bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, int areaMask, NavMeshPath path) + => CalculatePath(sourcePosition, targetPosition, GetScratchFilter(areaMask), path); + + /// + public bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, NavMeshQueryFilter filter, NavMeshPath path) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(path); + path.ClearCorners(); + + if (!TryRentQuery(out NavMeshQueryLease lease, filter.AgentTypeId)) + return false; + + using (lease) + { + DtNavMeshQuery query = lease.Query; + RcVec3f ext = ToRc(DefaultQueryExtents); + + query.FindNearestPoly(ToRc(sourcePosition), ext, filter, out long startRef, out RcVec3f startPt, out _); + query.FindNearestPoly(ToRc(targetPosition), ext, filter, out long endRef, out RcVec3f endPt, out _); + if (startRef == 0 || endRef == 0) + return false; + + // Read once: the ceilings are settable, and a change between the rent and the span + // would size a buffer to one value and index it by another. + int maxPolys = MaxPolyPath, maxCorners = MaxStraightPath; + + long[] polys = ArrayPool.Shared.Rent(maxPolys); + DtStraightPath[] straight = ArrayPool.Shared.Rent(maxCorners); + Float3[] corners = ArrayPool.Shared.Rent(maxCorners); + try + { + DtStatus status = query.FindPath(startRef, endRef, startPt, endPt, filter, polys.AsSpan(0, maxPolys), out int polyCount, maxPolys); + if (status.Failed() || polyCount == 0) + return false; + + // A partial path's last poly isn't the target poly; steer to the closest point + // on it instead of the unreachable target. + bool partial = polys[polyCount - 1] != endRef; + RcVec3f steerTarget = endPt; + if (partial) + query.ClosestPointOnPoly(polys[polyCount - 1], endPt, out steerTarget, out _); + + DtStatus straightStatus = query.FindStraightPath(startPt, steerTarget, polys.AsSpan(0, polyCount), polyCount, + straight.AsSpan(0, maxCorners), out int cornerCount, maxCorners, 0); + if (straightStatus.Failed() || cornerCount == 0) + return false; + + // A corner buffer filled to capacity means FindStraightPath truncated the + // path; reporting that as complete would lie to the caller. + if (cornerCount >= maxCorners) + partial = true; + + for (int i = 0; i < cornerCount; i++) + corners[i] = ToFloat3(straight[i].pos); + + path.SetCorners(corners.AsSpan(0, cornerCount), partial ? NavMeshPathStatus.PathPartial : NavMeshPathStatus.PathComplete); + path.SetPolys(polys.AsSpan(0, polyCount)); + return true; + } + finally + { + ArrayPool.Shared.Return(polys); + ArrayPool.Shared.Return(straight); + ArrayPool.Shared.Return(corners); + } + } + } + + /// Find the closest point on the navmesh within of + /// . + public bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, int areaMask) + => SamplePosition(sourcePosition, out hit, maxDistance, GetScratchFilter(areaMask)); + + /// + public bool SamplePosition(Float3 sourcePosition, out NavMeshHit hit, float maxDistance, NavMeshQueryFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + hit = default; + + if (!TryRentQuery(out NavMeshQueryLease lease, filter.AgentTypeId)) + return false; + + using (lease) + { + var ext = new RcVec3f(maxDistance, maxDistance, maxDistance); + lease.Query.FindNearestPoly(ToRc(sourcePosition), ext, filter, out long nearestRef, out RcVec3f nearestPt, out _); + if (nearestRef == 0) + return false; + + Float3 position = ToFloat3(nearestPt); + float distance = (float)Float3.Distance(sourcePosition, position); + if (distance > maxDistance) + return false; + + hit.Position = position; + hit.Normal = Float3.UnitY; + hit.Distance = distance; + hit.Mask = GetPolyAreaMaskBit(lease.Query.GetAttachedNavMesh(), nearestRef); + hit.Hit = true; + return true; + } + } + + /// Trace a walkability ray along the navmesh surface. Returns true when the ray is + /// blocked before the target; holds the blocking edge either way. + public bool Raycast(Float3 sourcePosition, Float3 targetPosition, out NavMeshHit hit, int areaMask) + => Raycast(sourcePosition, targetPosition, out hit, GetScratchFilter(areaMask)); + + /// + public bool Raycast(Float3 sourcePosition, Float3 targetPosition, out NavMeshHit hit, NavMeshQueryFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + hit = default; + + if (!TryRentQuery(out NavMeshQueryLease lease, filter.AgentTypeId)) + return false; + + using (lease) + { + DtNavMeshQuery query = lease.Query; + RcVec3f start = ToRc(sourcePosition); + RcVec3f end = ToRc(targetPosition); + + query.FindNearestPoly(start, ToRc(DefaultQueryExtents), filter, out long startRef, out RcVec3f startPt, out _); + if (startRef == 0) + return false; + + int maxPolys = MaxPolyPath; + long[] polys = ArrayPool.Shared.Rent(maxPolys); + try + { + DtStatus status = query.Raycast(startRef, startPt, end, filter, out float t, out RcVec3f normal, + polys.AsSpan(0, maxPolys), out int _, maxPolys); + if (status.Failed()) + return false; + + bool blocked = t < float.MaxValue; + Float3 position = blocked + ? ToFloat3(RcVec3f.Lerp(startPt, end, Math.Clamp(t, 0f, 1f))) + : ToFloat3(end); + + hit.Position = position; + hit.Normal = blocked ? ToFloat3(normal) : Float3.UnitY; + hit.Distance = (float)Float3.Distance(sourcePosition, position); + // The area walked out of, which is the one the wall belongs to. + hit.Mask = GetPolyAreaMaskBit(query.GetAttachedNavMesh(), startRef); + hit.Hit = blocked; + return blocked; + } + finally + { + ArrayPool.Shared.Return(polys); + } + } + } + + /// Default for + /// : wide enough for a + /// typical level, not derived from the mesh. + public const float DefaultEdgeSearchDistance = 100f; + + /// Locate the closest navmesh border edge from a point. + /// How far to search. Cost grows with it and an edge beyond it is + /// not found, so pass the widest gap that matters rather than a blanket maximum. + public bool FindClosestEdge(Float3 sourcePosition, out NavMeshHit hit, int areaMask, + float maxDistance = DefaultEdgeSearchDistance) + => FindClosestEdge(sourcePosition, out hit, GetScratchFilter(areaMask), maxDistance); + + /// + public bool FindClosestEdge(Float3 sourcePosition, out NavMeshHit hit, NavMeshQueryFilter filter, + float maxDistance = DefaultEdgeSearchDistance) + { + ArgumentNullException.ThrowIfNull(filter); + hit = default; + + if (!TryRentQuery(out NavMeshQueryLease lease, filter.AgentTypeId)) + return false; + + using (lease) + { + DtNavMeshQuery query = lease.Query; + query.FindNearestPoly(ToRc(sourcePosition), ToRc(DefaultQueryExtents), filter, out long startRef, out RcVec3f startPt, out _); + if (startRef == 0) + return false; + + DtStatus status = query.FindDistanceToWall(startRef, startPt, maxDistance, filter, + out float distance, out RcVec3f hitPos, out RcVec3f hitNormal); + if (status.Failed()) + return false; + + hit.Position = ToFloat3(hitPos); + hit.Normal = ToFloat3(hitNormal); + hit.Distance = distance; + hit.Mask = GetPolyAreaMaskBit(query.GetAttachedNavMesh(), startRef); + hit.Hit = true; + return true; + } + } + + /// Triangulate the current navmesh for debug drawing or user tooling. Returns an + /// empty triangulation when no navmesh is registered for the agent type — to visualize a + /// baked asset that isn't registered, use . + public NavMeshTriangulation CalculateTriangulation(int agentTypeId = 0) + { + NavMeshInstance? instance = GetInstance(agentTypeId); + if (instance == null || !instance.TryAcquire()) + return NavMeshTriangulation.Empty; + + instance.Lock.EnterReadLock(); + try + { + return NavMeshTriangulation.FromNavMesh(instance.Mesh); + } + finally + { + instance.Lock.ExitReadLock(); + instance.Release(); + } + } + + private static int GetPolyAreaMaskBit(DtNavMesh mesh, long polyRef) + { + if (mesh.GetTileAndPolyByRef(polyRef, out _, out DtPoly poly).Failed()) + return 0; + return 1 << NavMeshAreas.FromDetourArea(poly.GetArea()); + } + + #endregion + + #region Update + + // Reused each frame for the tile-cache pump (instances can't be iterated under their own + // write locks while holding the registration lock). + private readonly List _cachePumpScratch = []; + + /// + /// Advance the navigation world one frame: fires , steps every + /// agent type's crowd, and pumps each TileCache's incremental update (obstacle carving + /// processes a bounded slice of tile rebuilds per frame, amortizing carve cost off the + /// critical path). Called by the scene's variable update. + /// + public void Update(float deltaTime) + { + // Steering is gameplay and stops with it. + if (Application.ShouldRunGameplay) + { + PreUpdate?.Invoke(deltaTime); + foreach (NavMeshCrowdEntry entry in _crowds.Values) + entry.Crowd.Update(deltaTime, null); + } + + // Links mark their tiles from LateUpdate, which runs after this, so what drains here is + // the previous frame's edits — one frame of latency in exchange for a frame's worth of + // them costing one pass. Immediately before the pump, so the tile work a rebuild queues + // is drained this frame rather than waiting for the next. + DrainLinkTiles(); + + // Carving is not: an obstacle queues its carve from OnEnable, which runs in the editor + // too, and without a pump that request would sit unprocessed forever. Pumping outside + // play is also what makes the scene view's overlay show a carve as you position a + // building. Only the live navmesh changes; obstacles never touch the baked asset. + // + // Only instances with queued work are pumped — an idle cache would report up-to-date + // immediately anyway, but skipping it means a navmesh nothing ever carves costs nothing + // per frame at all. + _cachePumpScratch.Clear(); + lock (_instancesLock) + { + foreach (NavMeshInstance instance in _instances) + if (instance.CachePending) + _cachePumpScratch.Add(instance); + } + + foreach (NavMeshInstance instance in _cachePumpScratch) + { + if (!instance.TryAcquire()) continue; + + bool upToDate; + instance.Lock.EnterWriteLock(); + try + { + upToDate = instance.TileCache.Update(MaxTileUpdatesPerFrame); + } + finally + { + instance.Lock.ExitWriteLock(); + instance.Release(); + } + + // Reaching here means work was queued, so report unconditionally — idle instances + // never enter the scratch list. Changed must NOT be gated on convergence: a carve + // small enough to finish inside one Update would otherwise get no notification at + // all. Settled is the gated one, for listeners that want the finished mesh rather + // than each step toward it. (Pooled queries survive the tile swap — same invariant + // as MutateTileCache.) + instance.InvalidateLinkIds(); + NavMeshChanged?.Invoke(); + if (upToDate) + { + instance.CachePending = false; + NavMeshSettled?.Invoke(); + } + } + } + + #endregion +} diff --git a/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs new file mode 100644 index 000000000..660dc7047 --- /dev/null +++ b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs @@ -0,0 +1,210 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +using System; +using System.Collections.Generic; + +using Prowl.Recast.Core.Numerics; +using Prowl.Recast; +using Prowl.Recast.Geom; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// Feeds collected Prowl geometry to the Recast builder as world-space triangle soups, +/// grouped one per navigation area so +/// can rasterize each group with its own +/// area (sources with resolve to the +/// bake's default area). +/// +internal sealed class ProwlInputGeomProvider : IRcInputGeomProvider +{ + /// One area's triangle soup, with the area pre-converted to Detour form and its + /// world-XZ extent for cheap tile rejection (most tiles of a bounded bake overlap nothing; + /// an AABB test here beats even the chunky-index walk and allocates nothing). + internal readonly struct AreaMesh + { + public readonly RcTriMesh Mesh; + public readonly int DetourArea; + public readonly float MinX, MinZ, MaxX, MaxZ; + + public AreaMesh(RcTriMesh mesh, int detourArea, float minX, float minZ, float maxX, float maxZ) + { + Mesh = mesh; + DetourArea = detourArea; + MinX = minX; + MinZ = minZ; + MaxX = maxX; + MaxZ = maxZ; + } + + /// Does this area's geometry overlap the XZ rect at all? + public bool OverlapsXZ(float minX, float minZ, float maxX, float maxZ) + => MinX <= maxX && MaxX >= minX && MinZ <= maxZ && MaxZ >= minZ; + } + + private readonly List _areaMeshes = []; + private readonly RcVec3f _boundsMin; + private readonly RcVec3f _boundsMax; + private readonly List _convexVolumes = []; + + /// Total triangle count across all areas. + public int TriangleCount { get; } + + /// The per-area triangle soups, for the area-aware voxelizer. + internal IReadOnlyList AreaMeshes => _areaMeshes; + + /// + /// Flatten sources into per-area world-space soups. Vertices are transformed by each + /// source's matrix here, on the calling thread, so the provider itself has no dependency + /// on live Transforms and is safe to hand to a background build. + /// + public ProwlInputGeomProvider(IReadOnlyList sources, int defaultArea = NavMeshAreas.Walkable) + { + ArgumentNullException.ThrowIfNull(sources); + + // Group source indices by resolved area. Order within a group is preserved, and + // groups are keyed in first-seen order, so identical input yields identical output. + var groups = new Dictionary>(); + var groupOrder = new List(); + for (int i = 0; i < sources.Count; i++) + { + if (sources[i].Vertices == null || sources[i].Indices == null) continue; + int area = sources[i].Area; + if (area < 0) area = defaultArea; + area = Math.Clamp(area, 0, NavMeshAreas.MaxAreas - 1); + + if (!groups.TryGetValue(area, out List? list)) + { + groups[area] = list = []; + groupOrder.Add(area); + } + list.Add(i); + } + + var min = new Float3(float.MaxValue, float.MaxValue, float.MaxValue); + var max = new Float3(float.MinValue, float.MinValue, float.MinValue); + int totalTris = 0; + bool anyVerts = false; + + foreach (int area in groupOrder) + { + List group = groups[area]; + + int vertCount = 0, triCount = 0; + foreach (int s in group) + { + vertCount += sources[s].Vertices.Length; + triCount += sources[s].TriangleCount; + } + if (triCount == 0) continue; + + float[] verts = new float[vertCount * 3]; + int[] tris = new int[triCount * 3]; + int vBase = 0, tWrite = 0; + float gMinX = float.MaxValue, gMinZ = float.MaxValue, gMaxX = float.MinValue, gMaxZ = float.MinValue; + + foreach (int s in group) + { + NavMeshGeometrySource source = sources[s]; + for (int v = 0; v < source.Vertices.Length; v++) + { + Float3 world = Float4x4.TransformPoint(source.Vertices[v], source.Transform); + int o = (vBase + v) * 3; + verts[o + 0] = (float)world.X; + verts[o + 1] = (float)world.Y; + verts[o + 2] = (float)world.Z; + min = Maths.Min(min, world); + max = Maths.Max(max, world); + gMinX = Math.Min(gMinX, verts[o + 0]); + gMinZ = Math.Min(gMinZ, verts[o + 2]); + gMaxX = Math.Max(gMaxX, verts[o + 0]); + gMaxZ = Math.Max(gMaxZ, verts[o + 2]); + anyVerts = true; + } + + // t + 2 < Length guards indices whose count isn't a multiple of 3 (same guard + // as BakedPhysicsMesh); out-of-range indices drop the whole triangle. + for (int t = 0; t + 2 < source.Indices.Length; t += 3) + { + int i0 = source.Indices[t + 0], i1 = source.Indices[t + 1], i2 = source.Indices[t + 2]; + if ((uint)i0 >= source.Vertices.Length || (uint)i1 >= source.Vertices.Length || (uint)i2 >= source.Vertices.Length) + continue; + tris[tWrite++] = vBase + i0; + tris[tWrite++] = vBase + i1; + tris[tWrite++] = vBase + i2; + } + + vBase += source.Vertices.Length; + } + + // Dropped triangles leave a tail of zeros that would become degenerate triangles + // at the origin; trim to what was actually written. + if (tWrite == 0) continue; + if (tWrite != tris.Length) + Array.Resize(ref tris, tWrite); + + totalTris += tWrite / 3; + _areaMeshes.Add(new AreaMesh(new RcTriMesh(verts, tris), RasterAreaFor(area), gMinX, gMinZ, gMaxX, gMaxZ)); + } + + TriangleCount = totalTris; + + if (!anyVerts) + { + min = Float3.Zero; + max = Float3.Zero; + } + + _boundsMin = new RcVec3f((float)min.X, (float)min.Y, (float)min.Z); + _boundsMax = new RcVec3f((float)max.X, (float)max.Y, (float)max.Z); + } + + /// Area conversion for values written straight onto the compact heightfield (convex + /// volumes) or into a tile (off-mesh connections): Not Walkable becomes Detour's null area, so + /// it is an obstacle rather than a traversable "area 1" poly. Rasterized geometry goes through + /// instead. + internal static int DetourAreaFor(int area) + => area == NavMeshAreas.NotWalkable ? 0 : NavMeshAreas.ToDetourArea(area); + + /// The area Not Walkable rasterizes as, above every real one: merging two spans keeps + /// the HIGHER of their areas, so the null area would lose to a walkable surface within the + /// climb threshold. retires it once the spans are compacted. + /// + internal const int NotWalkableRasterArea = NavMeshAreas.MaxAreas + 1; + + /// + internal static int RasterAreaFor(int area) + => area == NavMeshAreas.NotWalkable ? NotWalkableRasterArea : DetourAreaFor(area); + + /// The first area's soup (interface requirement; the area-aware voxelizer uses + /// instead, which carries all of them). + public RcTriMesh GetMesh() => _areaMeshes.Count > 0 ? _areaMeshes[0].Mesh : new RcTriMesh([], []); + + public RcVec3f GetMeshBoundsMin() => _boundsMin; + + public RcVec3f GetMeshBoundsMax() => _boundsMax; + + public IEnumerable Meshes() + { + foreach (AreaMesh areaMesh in _areaMeshes) + yield return areaMesh.Mesh; + } + + public void AddConvexVolume(RcConvexVolume convexVolume) => _convexVolumes.Add(convexVolume); + + public IList ConvexVolumes() => _convexVolumes; + + // Off-mesh connections never travel through the geometry provider: tiles are contoured by + // the TileCache at runtime, which injects the link set itself + // (NavMeshTileBuilder.ProwlTileCacheMeshProcess). Nothing reads these back, so there is + // nothing to store — they exist only because IRcInputGeomProvider declares them. + + public List GetOffMeshConnections() => []; + + public void AddOffMeshConnection(RcVec3f start, RcVec3f end, float radius, bool bidir, int area, int flags) { } + + public void RemoveOffMeshConnections(Predicate filter) { } +} diff --git a/Prowl.Runtime/PlayerSettingsFiles.cs b/Prowl.Runtime/PlayerSettingsFiles.cs index dcadb8e54..539dcfe8b 100644 --- a/Prowl.Runtime/PlayerSettingsFiles.cs +++ b/Prowl.Runtime/PlayerSettingsFiles.cs @@ -22,10 +22,11 @@ public static class PlayerSettingsFiles public const string Time = "TimeSettings"; public const string Assets = "AssetSettings"; public const string TagsAndLayers = "TagsAndLayersSettings"; + public const string Navigation = "NavigationSettings"; /// /// Every file the player looks for. What the build validates against. General settings are absent /// on purpose: product name, company and version reach the player through its manifest. /// - public static IReadOnlyList All => [Physics, Audio, Time, Assets, TagsAndLayers]; + public static IReadOnlyList All => [Physics, Audio, Time, Assets, TagsAndLayers, Navigation]; } diff --git a/Prowl.Runtime/PlayerSettingsLoader.cs b/Prowl.Runtime/PlayerSettingsLoader.cs index d958482d3..e74d98111 100644 --- a/Prowl.Runtime/PlayerSettingsLoader.cs +++ b/Prowl.Runtime/PlayerSettingsLoader.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using Prowl.Echo; @@ -29,6 +30,7 @@ public static void Apply(string settingsDir) ApplyAudio(settingsDir); ApplyTime(settingsDir); ApplyTagsAndLayers(settingsDir); + ApplyNavigation(settingsDir); // Physics needs to apply to each new scene's PhysicsWorld ApplyPhysics(settingsDir); @@ -198,6 +200,57 @@ private static void ApplyTagsAndLayers(string dir) catch (Exception ex) { Debug.LogWarning($"[PlayerSettings] Failed to apply tags/layers: {ex.Message}"); } } + private static void ApplyNavigation(string dir) + { + var settings = Read(dir, PlayerSettingsFiles.Navigation); + if (settings == null) return; + + try + { + // AreaNames / AreaCosts are List / List (serialize as lists directly). + var names = new List(); + if (settings.TryGet("AreaNames", out var namesProp) && namesProp!.TagType == EchoType.List) + foreach (var name in namesProp.List) + names.Add(name.StringValue); + + var costs = new List(); + if (settings.TryGet("AreaCosts", out var costsProp) && costsProp!.TagType == EchoType.List) + foreach (var cost in costsProp.List) + costs.Add(cost.FloatValue); + + if (names.Count > 0 || costs.Count > 0) + { + NavMeshAreas.ApplyTable(names, costs); + Debug.Log("[PlayerSettings] Navigation areas applied."); + } + + // AgentTypes is a List (a list of compounds). + if (settings.TryGet("AgentTypes", out var typesProp) && typesProp!.TagType == EchoType.List) + { + var types = new List(); + foreach (var entry in typesProp.List) + { + types.Add(new NavMeshAgentType + { + Id = entry.TryGet("Id", out var id) ? id!.IntValue : 0, + Name = entry.TryGet("Name", out var name) ? name!.StringValue : string.Empty, + Radius = entry.TryGet("Radius", out var r) ? r!.FloatValue : 0.5f, + Height = entry.TryGet("Height", out var h) ? h!.FloatValue : 2f, + MaxSlope = entry.TryGet("MaxSlope", out var s) ? s!.FloatValue : 45f, + MaxClimb = entry.TryGet("MaxClimb", out var c) ? c!.FloatValue : 0.4f, + }); + } + + if (types.Count > 0) + { + NavMeshAgentTypes.ApplyTable(types); + Debug.Log($"[PlayerSettings] Navigation agent types applied ({types.Count})."); + } + } + } + catch (Exception ex) { Debug.LogWarning($"[PlayerSettings] Failed to apply navigation settings: {ex.Message}"); } + } + /// /// Reads one settings file, or null when there is nothing usable to read. A file that exists but /// cannot be parsed is reported, since falling back to defaults silently is how a shipped game ends diff --git a/Prowl.Runtime/Prowl.Runtime.csproj b/Prowl.Runtime/Prowl.Runtime.csproj index 2bb172137..a6b83e6a5 100644 --- a/Prowl.Runtime/Prowl.Runtime.csproj +++ b/Prowl.Runtime/Prowl.Runtime.csproj @@ -34,11 +34,13 @@ + + diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index f73c4cd0d..1e2130bb6 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -222,6 +222,13 @@ internal static void Shutdown() public PhysicsWorld Physics { get { EnsureNotDisposed(); return _physics; } } + [SerializeIgnore] + private readonly NavMeshWorld _navigation = new(); + + /// This scene's navigation state (registered navmeshes, queries, crowd). The static + /// facade forwards to the current scene's world. + public NavMeshWorld Navigation { get { EnsureNotDisposed(); return _navigation; } } + [SerializeIgnore] private readonly SceneDispatcher _dispatcher = new(); @@ -809,6 +816,9 @@ protected override void OnDispose() // Clear the physics world _physics.Clear(); + // Clear the navigation world (waits out in-flight queries) + _navigation.Clear(); + // Dispose all GameObjects which will also remove them from the scene. Dispose() (not the raw // OnDispose() body) sets IsDisposed and is idempotent, so the flat list's double-hits on // already-disposed children are no-ops. @@ -890,6 +900,12 @@ public void Update() { if (IsDisposed) return; _dispatcher.RunStart(); + + // Navigation (crowd steering) advances on the variable update, before component Updates + // so gameplay code sees fresh agent state. A crowd blow-up must not crash the frame. + try { _navigation.Update(Time.DeltaTime); } + catch (Exception ex) { Debug.LogError($"[Navigation] Update threw and was skipped this frame: {ex.Message}\n{ex.StackTrace}"); } + _dispatcher.RunUpdate(); _dispatcher.RunLateUpdate();