From f50cd680f5db6c138123deca0d79aebe29ea5ab0 Mon Sep 17 00:00:00 2001 From: Will B Date: Tue, 4 Aug 2026 21:05:33 -0600 Subject: [PATCH 01/67] Add DotRecast-based navigation subsystem A Unity-shaped navigation stack built on DotRecast: baked navmesh assets, runtime queries, crowd-driven agents, off-mesh links, obstacle carving, and the editor tooling to author and inspect it. Runtime - NavMeshData assets and NavMeshBuilder, with region rebuilds that scale with the changed area rather than the map size, synchronous or off-thread. - NavMeshWorld on Scene.Navigation: registered instances, a pooled thread-safe query layer, one crowd per agent type, and a demand-driven tile-cache pump that costs nothing per frame for surfaces with no queued work. - Components: NavMeshSurface, NavMeshAgent, NavMeshObstacle (carve or velocity-block), NavMeshLink, NavMeshModifier and NavMeshModifierVolume. - Project-level agent types and 32 Unity-style areas with per-agent costs. - Our own area-aware rasterizer with pooled heightfield spans, so repeated tile bakes on destructible geometry do not churn the heap. Every surface is tile-cache backed and can carve; there is no representation for a user to choose. Links ride on the asset and are re-injected as tiles are contoured, so carving and links coexist. Escape hatches to the underlying DotRecast objects are exposed, in the spirit of PhysicsWorld exposing Jitter. Editor - Surface inspector with bake-to-asset, a .navmesh importer, Navigation project settings, area and agent-type drawers, and a scene-view overlay that follows runtime carving. Covered by 127 runtime navigation tests. --- Prowl.Editor.Test/BuildSystemTests.cs | 1 + .../Importers/NavMeshDataImporter.cs | 13 + Prowl.Editor/GUI/AttributeHandlers.cs | 222 ++++- .../GUI/CustomEditors/NavMeshSurfaceEditor.cs | 147 +++ .../GUI/NavMeshAreaAttributeHandlers.cs | 190 ++++ .../Projects/Settings/NavigationSettings.cs | 353 +++++++ Prowl.Runtime.Test/NavMeshAllocationTests.cs | 88 ++ Prowl.Runtime.Test/NavMeshBuildTests.cs | 572 +++++++++++ Prowl.Runtime.Test/NavMeshCollectorTests.cs | 210 ++++ Prowl.Runtime.Test/NavMeshComponentTests.cs | 591 +++++++++++ Prowl.Runtime.Test/NavMeshCrowdTests.cs | 507 ++++++++++ Prowl.Runtime.Test/NavMeshLinkTests.cs | 457 +++++++++ Prowl.Runtime.Test/NavMeshModifierTests.cs | 347 +++++++ Prowl.Runtime.Test/NavMeshObstacleTests.cs | 937 ++++++++++++++++++ Prowl.Runtime.Test/NavMeshQueryTests.cs | 230 +++++ Prowl.Runtime.Test/RuntimeTestBase.cs | 44 + .../Components/Navigation/NavMeshAgent.cs | 774 +++++++++++++++ .../Components/Navigation/NavMeshLink.cs | 359 +++++++ .../Components/Navigation/NavMeshModifier.cs | 49 + .../Navigation/NavMeshModifierVolume.cs | 62 ++ .../Components/Navigation/NavMeshObstacle.cs | 460 +++++++++ .../Components/Navigation/NavMeshSurface.cs | 645 ++++++++++++ .../Attributes/InspectorAttributes.cs | 18 + Prowl.Runtime/Navigation/NavMesh.cs | 103 ++ Prowl.Runtime/Navigation/NavMeshAgentTypes.cs | 162 +++ .../Navigation/NavMeshAreaAttributes.cs | 20 + Prowl.Runtime/Navigation/NavMeshAreaVolume.cs | 108 ++ Prowl.Runtime/Navigation/NavMeshAreas.cs | 131 +++ .../Navigation/NavMeshBuildSettings.cs | 130 +++ Prowl.Runtime/Navigation/NavMeshBuilder.cs | 349 +++++++ Prowl.Runtime/Navigation/NavMeshData.cs | 203 ++++ .../Navigation/NavMeshGeometryCollector.cs | 440 ++++++++ .../Navigation/NavMeshGeometrySource.cs | 47 + Prowl.Runtime/Navigation/NavMeshHit.cs | 30 + Prowl.Runtime/Navigation/NavMeshLinkSource.cs | 82 ++ Prowl.Runtime/Navigation/NavMeshPath.cs | 74 ++ .../Navigation/NavMeshQueryFilter.cs | 82 ++ Prowl.Runtime/Navigation/NavMeshRasterizer.cs | 363 +++++++ .../Navigation/NavMeshTileBuilder.cs | 481 +++++++++ .../Navigation/NavMeshTriangulation.cs | 75 ++ Prowl.Runtime/Navigation/NavMeshWorld.cs | 853 ++++++++++++++++ .../Navigation/ProwlInputGeomProvider.cs | 202 ++++ Prowl.Runtime/PlayerSettingsFiles.cs | 3 +- Prowl.Runtime/PlayerSettingsLoader.cs | 59 ++ Prowl.Runtime/Prowl.Runtime.csproj | 6 + Prowl.Runtime/Resources/Scene.cs | 16 + 46 files changed, 11260 insertions(+), 35 deletions(-) create mode 100644 Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs create mode 100644 Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs create mode 100644 Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs create mode 100644 Prowl.Editor/Projects/Settings/NavigationSettings.cs create mode 100644 Prowl.Runtime.Test/NavMeshAllocationTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshBuildTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshCollectorTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshComponentTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshCrowdTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshLinkTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshModifierTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshObstacleTests.cs create mode 100644 Prowl.Runtime.Test/NavMeshQueryTests.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshAgent.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshLink.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshModifier.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshModifierVolume.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs create mode 100644 Prowl.Runtime/Components/Navigation/NavMeshSurface.cs create mode 100644 Prowl.Runtime/Navigation/NavMesh.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshAgentTypes.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshAreaAttributes.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshAreaVolume.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshAreas.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshBuildSettings.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshBuilder.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshData.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshGeometrySource.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshHit.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshLinkSource.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshPath.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshQueryFilter.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshRasterizer.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshTileBuilder.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshTriangulation.cs create mode 100644 Prowl.Runtime/Navigation/NavMeshWorld.cs create mode 100644 Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs 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/AssetsDatabase/Importers/NavMeshDataImporter.cs b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs new file mode 100644 index 000000000..cc443d1ae --- /dev/null +++ b/Prowl.Editor/AssetsDatabase/Importers/NavMeshDataImporter.cs @@ -0,0 +1,13 @@ +using Prowl.Runtime; + +namespace Prowl.Editor.Importers; + +/// +/// Imports .navmesh files - Echo-serialized NavMeshData objects (baked navigation meshes). +/// +[ImporterFor(".navmesh")] +public class NavMeshDataImporter : AssetImporter +{ + public override int Version => 1; + public override bool Import(ImportContext ctx) => ImportHelper.ImportEcho(ctx, "nav mesh data"); +} diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index f2596f36a..a60cda1ac 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -6,6 +6,7 @@ // and are registered by the editor at startup. using System; +using System.Collections.Generic; using System.Reflection; using Prowl.PaperUI; @@ -14,6 +15,40 @@ namespace Prowl.Editor.GUI; +/// +/// The default property-grid row recipe (gutter padding, label width/colour/truncation) for +/// handler-drawn fields, so they align with grid-drawn rows instead of each hand-copying the +/// layout. This is the one place the recipe lives — grid metric changes go here. +/// +public 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(); + } + } +} + /// [Header("text")] - draws a header label above the field. public class HeaderAttributeHandler : OrigamiUI.AttributeHandler { @@ -58,6 +93,141 @@ public override bool OnBeforeDraw(Paper paper, string id, Attribute attr, FieldI } } +/// [EnableIf("memberName")] - greys the field out (visible, not editable) while the +/// named bool member is false. Interaction is blocked via BeginReadOnly, and the field is +/// drawn under a faded theme so the disabled state is visually obvious. +public class EnableIfAttributeHandler : OrigamiUI.AttributeHandler +{ + // OnBeforeDraw/OnAfterDraw run as a strict pair on one thread per field; the stack keeps + // nested [EnableIf] objects balanced. The entry carries the pushed-theme scope to pop. + [ThreadStatic] private static Stack? t_scopes; + + // Faded clone of the active theme, rebuilt only when the theme object changes. + private static OrigamiUI.OrigamiTheme? _dimSource; + private static OrigamiUI.OrigamiTheme? _dimTheme; + + private static bool EvaluateCondition(string member, object target) + { + var type = target.GetType(); + var condField = type.GetField(member, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (condField != null && condField.FieldType == typeof(bool)) + return (bool)(condField.GetValue(target) ?? false); + var condProp = type.GetProperty(member, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (condProp != null && condProp.PropertyType == typeof(bool)) + return (bool)(condProp.GetValue(target) ?? false); + return true; // condition not found: leave enabled + } + + private static OrigamiUI.OrigamiTheme GetDimmedTheme(OrigamiUI.OrigamiTheme source) + { + if (!ReferenceEquals(_dimSource, source) || _dimTheme == null) + { + var dim = source.Clone(); + FadeRamp(dim.Ink); // labels + field text + FadeRamp(dim.Neutral); // field backgrounds/borders + _dimSource = source; + _dimTheme = dim; + } + return _dimTheme; + } + + private static void FadeRamp(OrigamiUI.OrigamiRamp ramp) + { + ramp.C100 = Fade(ramp.C100); ramp.C200 = Fade(ramp.C200); ramp.C300 = Fade(ramp.C300); + ramp.C400 = Fade(ramp.C400); ramp.C500 = Fade(ramp.C500); ramp.C600 = Fade(ramp.C600); + ramp.C700 = Fade(ramp.C700); + } + + private static System.Drawing.Color Fade(System.Drawing.Color c) + => System.Drawing.Color.FromArgb((int)(c.A * 0.45f), c.R, c.G, c.B); + + /// + /// Draw a stretch of UI as disabled: blocks interaction (BeginReadOnly) and renders it + /// under the faded theme so the state is visually obvious. Dispose the scope to restore. + /// Reusable by any editor UI that wants the same look as [EnableIf]. + /// + public static IDisposable PushDisabledScope() + { + OrigamiUI.Origami.BeginReadOnly(); + IDisposable themeScope = OrigamiUI.Origami.PushTheme(GetDimmedTheme(OrigamiUI.Origami.Current)); + return new DisabledScope(themeScope); + } + + private sealed class DisabledScope(IDisposable themeScope) : IDisposable + { + private bool _disposed; + public void Dispose() + { + if (_disposed) return; + _disposed = true; + themeScope.Dispose(); + OrigamiUI.Origami.EndReadOnly(); + } + } + + public override bool OnBeforeDraw(Paper paper, string id, Attribute attr, FieldInfo field, object target, int depth) + { + bool disabled = !EvaluateCondition(((EnableIfAttribute)attr).ConditionMember, target); + IDisposable? scope = null; + if (disabled) + { + OrigamiUI.Origami.BeginReadOnly(); + scope = OrigamiUI.Origami.PushTheme(GetDimmedTheme(OrigamiUI.Origami.Current)); + } + (t_scopes ??= new()).Push(scope); // null = field was enabled + return true; + } + + public override void OnAfterDraw(Paper paper, string id, Attribute attr, FieldInfo field, object target, int depth) + { + if (t_scopes == null || t_scopes.Count == 0) return; + IDisposable? scope = t_scopes.Pop(); + if (scope == null) return; // was enabled + scope.Dispose(); + OrigamiUI.Origami.EndReadOnly(); + } +} + +/// [InspectorName("label")] - overrides the field's display label; on enum-typed +/// fields the dropdown also shows each member's own [InspectorName] instead of the raw name. +public class InspectorNameAttributeHandler : OrigamiUI.AttributeHandler +{ + /// Display name for an enum member: its [InspectorName] if present, else the + /// nicified member name. + public static string GetEnumDisplayName(Type enumType, object value) + { + string name = Enum.GetName(enumType, value) ?? value.ToString() ?? ""; + var attr = enumType.GetField(name)?.GetCustomAttribute(); + return attr?.DisplayName ?? PropertyGridUtils.NicifyName(name); + } + + public override bool OnDraw(Paper paper, string id, string label, Attribute attr, + FieldInfo field, object target, Action onChange, int depth) + { + string displayLabel = ((InspectorNameAttribute)attr).DisplayName; + Type type = field.FieldType; + + // Non-flags enums get a dropdown honouring per-member display names. + if (type.IsEnum && !type.IsDefined(typeof(FlagsAttribute), false)) + { + object value = field.GetValue(target) ?? Enum.GetValues(type).GetValue(0)!; + HandlerRowLayout.LabelledRow(paper, id, displayLabel, () => + { + var values = new List(); + foreach (object v in Enum.GetValues(type)) values.Add(v); + OrigamiUI.Origami.Dropdown(paper, $"{id}_dd", value, v => onChange(v), values) + .Display(v => GetEnumDisplayName(type, v)) + .Show(); + }); + return true; + } + + // Everything else: default rendering, relabelled. + PropertyGridUtils.DrawField(paper, id, displayLabel, type, field.GetValue(target), onChange, depth); + return true; + } +} + /// [ReadOnly] - makes the field non-editable. public class ReadOnlyAttributeHandler : OrigamiUI.AttributeHandler { @@ -82,46 +252,25 @@ public override bool OnDraw(Paper paper, string id, string label, Attribute attr var range = (RangeAttribute)attr; var value = field.GetValue(target); var type = field.FieldType; - var theme = OrigamiUI.Origami.Current; - var m = theme.Metrics; - var font = theme.Font; - var ink = theme.Ink; + if (type != typeof(float) && type != typeof(int)) + return false; // unsupported type: fall through to default rendering - using (paper.Row(id).Height(UnitValue.Auto).MinHeight(m.RowHeight) - .RowBetween(m.SpacingMedium).Margin(0, 0, 0, m.SpacingSmall).Enter()) + HandlerRowLayout.LabelledRow(paper, id, label, () => { - if (font != null && !string.IsNullOrEmpty(label)) + if (type == typeof(float)) { - paper.Box($"{id}_lbl") - .Width(m.LabelWidth).Height(m.RowHeight) - .Padding(m.PaddingSmall, 0, 0, 0) - .IsNotInteractable() - .Text(label, font).TextColor(ink.C500) - .FontSize(m.FontSize); + float f = (float)(value ?? 0f); + OrigamiUI.Origami.Slider(paper, $"{id}_sl", f, + v => onChange(v), range.Min, range.Max).Format("F2").Show(); } - - using (paper.Box($"{id}_ctl").Width(UnitValue.Stretch()) - .Height(m.RowHeight).Enter()) + else { - if (type == typeof(float)) - { - float f = (float)(value ?? 0f); - OrigamiUI.Origami.Slider(paper, $"{id}_sl", f, - v => onChange(v), range.Min, range.Max).Format("F2").Show(); - } - else if (type == typeof(int)) - { - int i = (int)(value ?? 0); - OrigamiUI.Origami.Slider(paper, $"{id}_sl", (float)i, - v => onChange((int)MathF.Round(v)), range.Min, range.Max) - .Format("F0").Step(1f).Show(); - } - else - { - return false; - } + int i = (int)(value ?? 0); + OrigamiUI.Origami.Slider(paper, $"{id}_sl", (float)i, + v => onChange((int)MathF.Round(v)), range.Min, range.Max) + .Format("F0").Step(1f).Show(); } - } + }); return true; } @@ -178,9 +327,14 @@ public static void Register(OrigamiUI.AttributeHandlerRegistry registry) registry.Register(new HeaderAttributeHandler()); registry.Register(new SpaceAttributeHandler()); registry.Register(new ShowIfAttributeHandler()); + registry.Register(new EnableIfAttributeHandler()); + registry.Register(new InspectorNameAttributeHandler()); registry.Register(new ReadOnlyAttributeHandler()); registry.Register(new RangeAttributeHandler()); registry.Register(new TextAreaAttributeHandler()); + registry.Register(new NavMeshAreaAttributeHandler()); + registry.Register(new NavMeshAreaMaskAttributeHandler()); + registry.Register(new NavMeshAgentTypeAttributeHandler()); // TooltipAttribute is handled inline by PropertyGrid row rendering } } diff --git a/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs b/Prowl.Editor/GUI/CustomEditors/NavMeshSurfaceEditor.cs new file mode 100644 index 000000000..56cb7b7ac --- /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); + File.WriteAllText(fileAbs, Serializer.Serialize(typeof(object), data).WriteToString()); + + 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..36efbd7ce --- /dev/null +++ b/Prowl.Editor/GUI/NavMeshAreaAttributeHandlers.cs @@ -0,0 +1,190 @@ +// 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; + +/// +/// [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..aececd7ef --- /dev/null +++ b/Prowl.Editor/Projects/Settings/NavigationSettings.cs @@ -0,0 +1,353 @@ +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 + + private static List CreateDefaultNames() + { + var names = new List(NavMeshAreas.MaxAreas); + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + names.Add(NavMeshAreas.GetAreaName(i)); + return names; + } + + private static List CreateDefaultCosts() + { + var costs = new List(NavMeshAreas.MaxAreas); + for (int i = 0; i < NavMeshAreas.MaxAreas; i++) + costs.Add(NavMeshAreas.GetAreaCost(i)); + 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(); + AreaNames[NavMeshAreas.Walkable] = "Walkable"; + AreaNames[NavMeshAreas.NotWalkable] = "Not Walkable"; + AreaNames[NavMeshAreas.Jump] = "Jump"; + 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..6e5798c2f --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshBuildTests.cs @@ -0,0 +1,572 @@ +// 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 DotRecast.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, + }; + + /// + /// 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 baked asset triangulates without being registered with any scene or world — this is + /// what lets the editor draw the surface overlay outside play mode, where nothing + /// registers the surface (previously the overlay only appeared until the next reload). + /// + [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 any of its eight neighbours as + /// well as by its own, and each arrival is one Detour budgeted nothing for. Rationing only + /// departures let eight neighbours each stay under the limit while jointly swamping one + /// destination — no link severed, no warning, and IndexOutOfRange at load on an asset that + /// baked and saved cleanly. This drives every neighbour at the 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); + for (int k = 0; k < perNeighbour; k++) + data.Links.Add(NavMeshData.NavMeshLinkEntry.From(new NavMeshLinkSource( + new Float3(source.X + k * 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); + // Rationing may drop the excess; at least one route into the destination must survive. + Assert.True(instance!.ContainsLinkId(1), "The first link must reach the live navmesh."); + } + + /// + /// A tile can only hold so many connections leaving it, so when links crowd one boundary the + /// tile builder rations them — breadth first, one lane per link before any link gets a + /// second. A wide link shedding lanes still crosses, just at fewer points, and must stay + /// silent; only a link left with NO lane has actually stopped working, and only that is + /// worth interrupting anyone over. + /// + [Theory] + [InlineData(1, 5f, 1, false)] // one wide link: lanes are shed, the link still works + [InlineData(1, 20f, 1, false)] + [InlineData(4, 0f, 4, false)] // exactly the budget + [InlineData(6, 0f, 4, true)] // two links genuinely lose their route + public void Build_LinksCrowdingATileBoundary_RationBreadthFirst( + int linkCount, float width, int expectedInMesh, bool expectWarning) + { + 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(expectedInMesh, inMesh); + Assert.Equal(expectWarning, warnings.Count > 0); + } + 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..f0ba09a8e --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshCollectorTests.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 Prowl.Runtime; +using Prowl.Runtime.Resources; +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 + } + + /// + /// The bug this guards: an agent standing on the floor at bake time used to voxelize as + /// an obstruction, leaving 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); + } +} diff --git a/Prowl.Runtime.Test/NavMeshComponentTests.cs b/Prowl.Runtime.Test/NavMeshComponentTests.cs new file mode 100644 index 000000000..e060534c4 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshComponentTests.cs @@ -0,0 +1,591 @@ +// 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; + +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" }]); + } + } + + [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..2cb9a9076 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshCrowdTests.cs @@ -0,0 +1,507 @@ +// 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. Its + /// facing used to follow the crowd's ACTUAL velocity, which carries avoidance and collision + /// corrections that do not shrink with speed: once the agent slowed near the goal those + /// corrections dominated a small vector and swung its direction frame to frame, so it + /// wobbled left and right while tracking the path exactly. Facing follows the steering + /// vector now, which points down the path the whole way in. + /// + [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 walked the agent centimetres off its line by the time it arrived. Avoidance is + /// skipped for an agent with no neighbours now, which also skips the most expensive part of + /// its crowd step. covers the other half — that a blocker + /// in range still deflects it. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Agent_AloneOnAStraightPath_DoesNotDriftSideways(bool alongX) + { + (Scene scene, _) = CreateBakedFloorScene(); + 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."); + } + + // ── 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.Equal(1, typeZeroCrowd.GetActiveAgents().Count); + + // 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.Equal(0, typeZeroCrowd.GetActiveAgents().Count); // 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() + { + 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 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..0482cc6ea --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshLinkTests.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 DotRecast.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; + } + + 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 must not false-fire while the agent is traversing a link: + /// RemainingDistance previously collapsed to ~0 as the hop animation landed, so waypoint + /// scripts driven by "!PathPending && RemainingDistance <= StoppingDistance" + /// issued their next destination mid-hop and ping-ponged the agent across the link + /// forever. Mid-hop the value must stay 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..868cac86b --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshModifierTests.cs @@ -0,0 +1,347 @@ +// 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 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..47fea933f --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshObstacleTests.cs @@ -0,0 +1,937 @@ +// 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.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."); + } + + /// Regenerated layers replace the asset's blobs (a later save/instantiate agrees + /// with the live mesh), and the mutated asset still round-trips and re-instantiates. + [Fact] + public void LayerRegeneration_MirrorsIntoAsset() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Runtime.NavMeshData data = surface.NavMeshData.Res!; + var blobsBefore = new System.Collections.Generic.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 in the asset."); + + EchoObject echo = Serializer.Serialize(data); + 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 void 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 = surface.RebuildTilesAsync(region, surface.CollectSources()).Result; + 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. OnEnable runs + /// in the editor and queues the carve, but the pump used to be gameplay-gated, so the request + /// sat unprocessed forever: the component looked configured, the mesh looked untouched, and + /// the scene-view overlay had nothing to show. + /// + [Fact] + public void Obstacle_CarvesAndFollowsOutsidePlayMode() + { + (Scene scene, NavMeshSurface surface) = CreateFloorScene(); + Assert.True(surface.BuildNavMesh()); + Tick(scene, 2); + + bool wasPlaying = Application.IsPlaying; + Application.IsPlaying = false; // gameplay callbacks stop; lifecycle and [ExecuteAlways] do not + try + { + 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."); + } + finally + { + Application.IsPlaying = wasPlaying; + } + } + + /// + /// 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 — indistinguishable from an obstacle that + /// is only steering agents around itself. The pump used to fire only on the + /// converged->working edge, which a carve small enough to finish inside one cache update + /// never crosses: it reported up-to-date on its first call and the notification was + /// swallowed. Anything queued into a cache must report, every frame it works and on the + /// frame it finishes. + /// + [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; + DotRecast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; + foreach (DotRecast.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); + DotRecast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; + foreach (DotRecast.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) + { + DotRecast.Detour.Crowd.DtCrowd? crowd = scene.Navigation.NativeCrowd; + if (crowd == null) return 0; + int count = 1; + foreach (DotRecast.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..7c1da3f53 --- /dev/null +++ b/Prowl.Runtime.Test/NavMeshQueryTests.cs @@ -0,0 +1,230 @@ +// 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; + +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); + } +} diff --git a/Prowl.Runtime.Test/RuntimeTestBase.cs b/Prowl.Runtime.Test/RuntimeTestBase.cs index ad2067643..db6635ac1 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; @@ -96,6 +97,49 @@ protected void StepPhysics(Scene scene, int steps = 1) scene.FixedUpdate(); } + /// 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() { foreach (var scene in _scenes) diff --git a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs new file mode 100644 index 000000000..8559fbbda --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs @@ -0,0 +1,774 @@ +// 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 DotRecast.Core.Numerics; +using DotRecast.Detour; +using DotRecast.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; + + // 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; + + /// True when the current path only reaches partway to the destination. + public bool IsPathStale => _agent?.partial ?? false; + + /// 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 NavMeshLink.FindByLinkId(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 the remaining hop PLUS the path after + // landing: the hop distance alone also collapses to ~0 as the animation lands, + // which makes waypoint scripts issue their next destination mid-hop and ping-pong + // the agent across the link forever. + 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; + TryRegister(); + } + + public override void OnDisable() + { + if (_world != null) + { + _world.NavMeshChanged -= OnNavMeshChanged; + 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 the AgentTypeId field: if gameplay rewrote the + // field and a navmesh event fires 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 permanent ghost agent (and leak its filter-slot refcount). + // Type changes are handled only by the drift check, which Unregisters properly. + 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. + TryRegister(); + } + else if (AutoRepath && _hasDestination && !_isStopped && !_arrived) + { + // Ground moved under us: replan. + 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) + 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 by steering to its end (the crowd re-plans the + /// corridor itself; the path supplies the destination). + public bool SetPath(NavMeshPath path) + { + ArgumentNullException.ThrowIfNull(path); + if (path.Status == NavMeshPathStatus.PathInvalid || path.CornerCount == 0) return false; + Float3[] corners = path.Corners; + return SetDestination(corners[^1]); + } + + /// 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) + { + Transform.Position = newPosition + new Float3(0, BaseOffset, 0); + if (_world == null) return false; + + DtCrowd? crowd = _crowd; + if (_agent == null || crowd == null) return IsOnNavMesh; + + // Detour has no teleport: re-add the agent at the new position. + crowd.RemoveAgent(_agent); + _agent = crowd.AddAgent(ToRc(newPosition), BuildAgentParams()); + if (_hasDestination && !_isStopped && !_arrived) + RequestPathTo(_destination); + return true; + } + + /// Displace the agent by a world-space offset, constrained to the navmesh. + public void Move(Float3 offset) => Warp(NextPosition + offset); + + /// 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; + } + + /// Sample a position on the navmesh near the agent with the agent's filter. + public bool SamplePathPosition(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: with nothing in range to dodge, the winner is + // merely the sample nearest the velocity we asked for, and that rounding walks the agent + // a few centimetres sideways off a straight line by the time it arrives. An agent with + // no neighbours has nothing to avoid, so let it steer exactly — and skip the sampling. + // Neighbours come from the last crowd step, so engaging avoidance lags by one frame; + // they are gathered from several metres out, which is many frames of approach. + if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance) + { + _agent.option.updateFlags = _agent.nneis > 0 + ? _agent.option.updateFlags | DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE + : _agent.option.updateFlags & ~DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE; + } + + if (UpdatePosition) + Transform.Position = ToFloat3(_agent.npos) + new Float3(0, BaseOffset, 0); + + if (UpdateRotation) + { + // Face where the agent STEERS, not where it moves: the actual velocity carries + // avoidance corrections that do not shrink with speed, so braking into a goal they + // take over its direction and the agent shivers along a dead straight path. The two + // gates below stop the heading chasing a vector that has stopped meaning anything — + // one too slow to have a direction, one pointing at a target already underfoot, + // where what is left is drift and following it spins the agent on the spot. + 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 (it holds at most the crowd's few visible + // corners), so its distance only means "arrived" when the window actually reaches the + // path end. Without this gate, a tight switchback whose visible corners total less + // than StoppingDistance would falsely latch mid-path, and a congestion-jammed agent + // could trip the braked latch far from its goal. An EMPTY window is untrustworthy: + // it happens both when standing on the target and transiently right after an + // off-mesh hop lands (corners recompute on the next crowd update) — so measure + // straight to the target instead of trusting a 0-length window. + 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..0f5492f83 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs @@ -0,0 +1,359 @@ +// 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. +/// +[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 (). Assigned + /// on first enable; stable across sessions via serialization. Resolution is best-effort + /// (ids can be re-minted on duplicate clashes, and baked data can outlive components) — + /// don't hang gameplay-critical logic on CurrentOffMeshLinkData.Link. + [HideInInspector] + public int LinkId; + + private bool UsesExplicitAgentTypes => !AffectAllAgentTypes; + + // linkId → live component, for resolving a crowd agent's off-mesh connection back to the + // component it came from. Session-local; ids themselves persist in baked data. + private static readonly Dictionary s_liveLinks = []; + + // 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); + + /// The live link with the given id, or null (agent link resolution). + public static NavMeshLink? FindByLinkId(int linkId) + => s_liveLinks.TryGetValue(linkId, out NavMeshLink? link) && link.IsValid() ? link : null; + + /// 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() + { + // First enable mints the persistent id; a clash with another LIVE link (duplicated + // in-scene prefab) re-mints so resolution stays unambiguous. The re-mint only lives + // in memory — warn so the duplication gets fixed and saved rather than silently + // re-minting every session. + if (LinkId != 0 && s_liveLinks.TryGetValue(LinkId, out NavMeshLink? clash) && clash.IsValid() && !ReferenceEquals(clash, this)) + Debug.LogWarning($"[Navigation] NavMeshLink '{GameObject.Name}' shares link id {LinkId} with '{clash.GameObject.Name}' (duplicated object?); re-minting. Re-save the scene to persist distinct ids."); + while (LinkId == 0 || (s_liveLinks.TryGetValue(LinkId, out NavMeshLink? other) && other.IsValid() && !ReferenceEquals(other, this))) + LinkId = Random.Shared.Next(int.MinValue, int.MaxValue); + s_liveLinks[LinkId] = this; + + 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; + } + if (Activated) CatchUp(); + } + + public override void OnDisable() + { + s_liveLinks.Remove(LinkId); + _catchUpDone.Clear(); + if (_world != null) + { + _world.NavMeshChanged -= OnNavMeshChanged; + _world = null; + } + // Scene teardown never pays this: Scene.OnDispose marks the scene disposed and clears + // the navigation world BEFORE GameObjects dispose, so RequestRebuild's scene-validity + // early-out (and the surfaces' dead instances) make it a no-op. A gameplay disable + // (pooling, destroyed building) keeps its rebuild — the world really changed. + if (_appliedActive) RequestRebuild(_appliedStart, _appliedEnd); + } + + 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. + // The event also fires per frame while a surface converges a carve, and each + // catch-up walks the whole scene — so gate on the structural counter, or every carving + // frame pays a scene scan 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) return; + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return; + + // Replaced instances (full rebakes) would otherwise be pinned by the checked set. + _catchUpDone.RemoveWhere(i => _world?.GetInstance(i.AgentTypeId) != i); + + foreach (GameObject go in scene!.ActiveObjects) + { + if (go.IsNotValid()) continue; + foreach (NavMeshSurface surface in go.GetComponents()) + { + 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 + RebuildEndpointRegions(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) return; + var scene = GameObject.IsValid() ? GameObject.Scene : null; + if (scene.IsNotValid()) return; + + foreach (GameObject go in scene!.ActiveObjects) + { + if (go.IsNotValid()) continue; + foreach (NavMeshSurface surface in go.GetComponents()) + { + if (surface.Instance == null || !AffectsOrDidAffect(surface.AgentTypeId)) continue; + RebuildEndpointRegions(surface, start, end); + } + } + } + + /// Rebuild around both endpoints: one call when the padded regions overlap (the + /// common short ladder/ledge link — rebuilding the same tiles twice back-to-back would + /// double the dominant cost), two separate calls when they don't (a merged AABB across a + /// long link would rebuild everything between the endpoints). + private void RebuildEndpointRegions(NavMeshSurface surface, Float3 start, Float3 end) + { + float pad = Width * 0.5f + 1f; + AABB startRegion = new AABB(start, start).Expanded(pad); + AABB endRegion = new AABB(end, end).Expanded(pad); + + // A rebuild replaces the whole link registry, so the collection is identical for both + // regions — gather it once rather than paying a scene scan per region. + List links = surface.CollectLinks(null); + + if (startRegion.Intersects(endRegion)) + surface.RebuildLinkTiles(startRegion.Encapsulating(endRegion), links); + else + { + surface.RebuildLinkTiles(startRegion, links); + surface.RebuildLinkTiles(endRegion, links); + } + } + + public override void DrawGizmosSelected() + { + Color c = NavMeshSurface.AreaColor(Area); + var color = new Color(c.R, c.G, c.B, 1f); + Float3 start = WorldStart, end = WorldEnd; + Debug.DrawLine(start, end, color); + Debug.DrawWireSphere(start, 0.15f, color); + Debug.DrawWireSphere(end, 0.15f, color); + if (Width > 0f) + { + // Width extent ticks at both endpoints. + 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); + Float3 half = perp * (Width * 0.5f); + Debug.DrawLine(start - half, start + half, color); + Debug.DrawLine(end - half, end + half, 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..5b6f635d3 --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs @@ -0,0 +1,460 @@ +// 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 DotRecast.Core.Numerics; +using DotRecast.Detour.TileCache; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// Shape of a . +public enum NavMeshObstacleShape +{ + /// Capsule input, carved as a cylinder of the same radius/height. + Capsule, + /// 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 itself routes around the +/// obstacle. The affected tiles rebuild incrementally over the following frames, and with +/// the hole lifts while the obstacle moves and re-applies +/// once it has been still for . +/// +/// off is Unity's velocity-obstacle mode: the mesh is untouched and the +/// obstacle instead joins each crowd as an immovable neighbour, so agents steer around it +/// locally. That costs nothing per move, which makes it the right mode for something that moves +/// often — but paths are computed as if it weren't there, so an agent whose only route is +/// blocked will press against it rather than reroute. +/// +/// Either way the object's own geometry stays out of bakes: an obstacle is 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: a capsule carves a cylinder; a box carves an oriented (yaw-only) box.")] + 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, 2, 1); + + [Tooltip("Capsule radius (scaled by the largest horizontal Transform scale).")] + [ShowIf(nameof(IsCapsule))] + public float Radius = 0.5f; + + [Tooltip("Capsule height (scaled by the vertical Transform scale).")] + [ShowIf(nameof(IsCapsule))] + 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 IsCapsule => Shape == NavMeshObstacleShape.Capsule; + + 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 (their cache died with the instance) 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 — + // and re-attaching per frame 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 — the mode for things that move. It also absorbs any + // displacement the crowd's collision-resolution pass applies (measured at under a + // centimetre even under sustained pressure, but it costs nothing to be exact). + Float3 position = BlockerPosition(height); + var pinned = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z); + foreach (DotRecast.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) + { + DotRecast.Detour.Crowd.DtCrowd? crowd = _world.GetNativeCrowd(type.Id); + if (crowd == null) continue; + if (_blockers.TryGetValue(crowd, out DotRecast.Detour.Crowd.DtCrowdAgent? existing)) + { + crowd.UpdateAgentParameters(existing, BlockerParams(radius, height)); + continue; + } + + DotRecast.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 (DotRecast.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 (DotRecast.Detour.Crowd.DtCrowd crowd in dead) + _blockers.Remove(crowd); + + _blockerCrowdCount = _world.CrowdCount; + } + + private void RemoveBlockers() + { + foreach ((DotRecast.Detour.Crowd.DtCrowd crowd, DotRecast.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(DotRecast.Detour.Crowd.DtCrowdAgent blocker) + { + if (blocker.state != DotRecast.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); + } + + /// 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.Capsule) + return Radius * MathF.Max(0.01f, (float)Math.Max(Math.Abs(scale.X), Math.Abs(scale.Z))); + + double x = Size.X * 0.5f * Math.Abs(scale.X); + double z = Size.Z * 0.5f * Math.Abs(scale.Z); + return MathF.Max(0.01f, (float)Math.Sqrt(x * x + z * z)); + } + + private float BlockerHeight(Float3 scale) + { + float scaleY = MathF.Max(0.01f, (float)Math.Abs(scale.Y)); + return MathF.Max(0.01f, (Shape == NavMeshObstacleShape.Capsule ? Height : (float)Size.Y) * scaleY); + } + + private DotRecast.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. + /// The affected tiles rebuild incrementally in NavMeshWorld.Update. + 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.CachePending = true; + } + _carveApplied = true; + CaptureAppliedGeometry(); + } + + // No try/catch: verified against DotRecast 2026.1.3 — 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.Capsule) + { + float radius = Radius * MathF.Max(0.01f, (float)Math.Max(Math.Abs(scale.X), Math.Abs(scale.Z))); + float height = Height * MathF.Max(0.01f, (float)Math.Abs(scale.Y)); + // 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); + } + + // Horizontal only: erosion is a footprint concern, and growing the box vertically would + // start carving under things the obstacle passes beneath. + var halfExtents = new RcVec3f( + (float)(Size.X * 0.5f * Math.Abs(scale.X)) + clearance, + (float)(Size.Y * 0.5f * Math.Abs(scale.Y)), + (float)(Size.Z * 0.5f * Math.Abs(scale.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.CachePending = true; + } + _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); + } + + public override void DrawGizmosSelected() + { + var color = new Color(1f, 0.5f, 0.1f, 1f); + if (Shape == NavMeshObstacleShape.Box) + { + Debug.PushMatrix(Transform.LocalToWorldMatrix); + Debug.DrawWireCube(Center, Size * 0.5f, color); + Debug.PopMatrix(); + } + else + { + Float3 worldCenter = Transform.TransformPoint(Center); + Debug.DrawWireSphere(worldCenter, Radius, color); + Debug.DrawLine(worldCenter - new Float3(0, Height * 0.5f, 0), worldCenter + new Float3(0, Height * 0.5f, 0), color); + } + } +} diff --git a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs new file mode 100644 index 000000000..8de2d923d --- /dev/null +++ b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs @@ -0,0 +1,645 @@ +// 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 DotRecast.Detour; +using DotRecast.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). +/// +[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 bool IsVolumeMode => CollectObjects == NavMeshCollectObjects.Volume; + + /// The live navmesh registration, while enabled and a navmesh is loaded. + public NavMeshInstance? Instance => _instance; + + /// 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() => Register(); + + public override void OnDisable() + { + 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; + + Runtime.NavMeshData? data = NavMeshData.Res; + if (data.IsNotValid() || !data!.HasTiles) return; + + _instance = world.AddNavMeshData(data); + } + + private void Unregister() + { + if (_instance == null) return; + World?.RemoveNavMeshData(_instance); + _instance = 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 = NavMeshData.Res; + if (data.IsNotValid()) return false; + // Collection (terrain decimation) uses the ASSET's 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) + { + NavMeshInstance? instance = Instance; + Runtime.NavMeshData? data = NavMeshData.Res; + if (instance == null || data.IsNotValid()) return false; + if (data!.TileWorldSize <= 0) return false; + + links ??= CollectLinks(null); + NavMeshWorld? world = World; + if (world == null) return false; + + world.MutateTileCache(instance, cache => + { + instance.TileCacheLinks.SetLinks(links, data.Settings.AgentRadius, data.Origin, data.TileWorldSize); + + float ts = data.TileWorldSize; + int tx0 = (int)Math.Floor((worldBounds.Min.X - data.Origin.X) / ts); + int tx1 = (int)Math.Floor((worldBounds.Max.X - data.Origin.X) / ts); + int tz0 = (int)Math.Floor((worldBounds.Min.Z - data.Origin.Z) / ts); + int tz1 = (int)Math.Floor((worldBounds.Max.Z - data.Origin.Z) / ts); + for (int tz = tz0; tz <= tz1; tz++) + for (int tx = tx0; tx <= tx1; tx++) + foreach (long tileRef in cache.GetTilesAt(tx, tz)) + cache.BuildNavMeshTile(tileRef); + }); + + // Mirror onto the asset so a save (or a later re-instantiation of this data) starts + // from the same link set the live mesh is using. + 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 = NavMeshData.Res; + 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) + { + Runtime.NavMeshData? data = NavMeshData.Res; + 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 + /// affected tile's layers (removing the paired navmesh tiles — the cache's RemoveTile does + /// not), refreshes every obstacle's touched-tile list (tile replacement bumps salts, so + /// captured refs go stale — without the refresh, regenerated tiles would rebuild WITHOUT + /// their carves), 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 = NavMeshData.Res; + 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 the serializable asset so a later save/instantiate agrees with + // the live mesh. Note this mutates the loaded NavMeshData INSTANCE — for an imported + // .navmesh asset that is the shared imported object, and nothing is written to disk + // unless the asset is explicitly saved; leaving play mode without saving discards the + // in-memory changes with the usual asset reload. Obstacles are runtime state and never + // serialize — the asset stores 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 like the rest of the rebuild path. Volume mode composes: the volume is + /// intersected with the filter. sets terrain + /// decimation granularity — pass the voxel size of the settings the geometry will be + /// voxelized with (bakes: the freshly resolved settings; partial rebuilds: the asset's + /// snapshot settings). + /// + 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; + + IEnumerable objects = CollectObjects == NavMeshCollectObjects.Children + ? EnumerateSelfAndChildren(GameObject) + : scene!.ActiveObjects; + + NavMeshGeometryCollector.CollectLinks(objects, 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 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 reflects runtime rebuilds and carving). Outside play mode + // nothing registers the surface, so fall back to triangulating the baked asset — the + // overlay must not depend on having just baked this session. + 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; + } + + NavMeshTriangulation tri = _debugTriangulation.Value; + // Lift slightly off the surface so the overlay doesn't z-fight the floor. + var lift = new Float3(0, 0.03f, 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); + } + } + + /// 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/GameObject/Attributes/InspectorAttributes.cs b/Prowl.Runtime/GameObject/Attributes/InspectorAttributes.cs index 8cfb4bb71..c30237732 100644 --- a/Prowl.Runtime/GameObject/Attributes/InspectorAttributes.cs +++ b/Prowl.Runtime/GameObject/Attributes/InspectorAttributes.cs @@ -59,6 +59,24 @@ public class ShowIfAttribute : Attribute public ShowIfAttribute(string conditionMember) => ConditionMember = conditionMember; } +/// Greys the field out (visible but not editable) unless the named bool +/// field/property is true. Use for values only meaningful behind an enabling toggle. +[AttributeUsage(AttributeTargets.Field)] +public class EnableIfAttribute : Attribute +{ + public string ConditionMember { get; } + public EnableIfAttribute(string conditionMember) => ConditionMember = conditionMember; +} + +/// Overrides the display name the inspector shows for a field or an enum member, +/// without renaming the code symbol (e.g. keep an API-parity enum name but show "None"). +[AttributeUsage(AttributeTargets.Field)] +public class InspectorNameAttribute : Attribute +{ + public string DisplayName { get; } + public InspectorNameAttribute(string displayName) => DisplayName = displayName; +} + /// Draws a string field as a multi-line text area. [AttributeUsage(AttributeTargets.Field)] public class TextAreaAttribute : Attribute 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..0a2dc0e4e --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAgentTypes.cs @@ -0,0 +1,162 @@ +// 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; + + public NavMeshAgentType Clone() => (NavMeshAgentType)MemberwiseClone(); +} + +/// +/// 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; + + private static readonly List 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) + { + for (int i = 0; i < s_types.Count; i++) + if (s_types[i].Id == agentTypeId) + return s_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; + for (int i = 0; i < s_types.Count; i++) + if (string.Equals(s_types[i].Name, name, StringComparison.Ordinal)) + return s_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); + + s_types.Clear(); + foreach (NavMeshAgentType type in types) + { + if (type == null) continue; + if (Get(type.Id) != 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; '{Get(type.Id)!.Name}' keeps the id."); + continue; + } + NavMeshAgentType copy = type.Clone(); + if (copy.Id == Humanoid) copy.Name = "Humanoid"; + s_types.Add(copy); + } + + if (Get(Humanoid) == null) + s_types.Insert(0, CreateHumanoid()); + } + + /// + /// 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, + }; + } + + 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..125ec2ff3 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshAreas.cs @@ -0,0 +1,131 @@ +// 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; + +/// +/// 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; + + private static readonly string[] s_names = CreateDefaultNames(); + private static readonly float[] s_costs = CreateDefaultCosts(); + + 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; + s_names[areaIndex] = name ?? string.Empty; + } + + /// 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; + for (int i = 0; i < MaxAreas; i++) + if (string.Equals(s_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; + s_costs[areaIndex] = Math.Max(1f, cost); + } + + /// Replace the whole area table (names + costs). Called by project-settings loading. + public static void ApplyTable(IReadOnlyList names, IReadOnlyList costs) + { + for (int i = 0; i < MaxAreas; i++) + { + if (names != null && i < names.Count && i > Jump) s_names[i] = names[i] ?? string.Empty; + if (costs != null && i < costs.Count) s_costs[i] = Math.Max(1f, costs[i]); + } + } + + /// 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() + { + var defined = new List(8); + for (int i = 0; i < MaxAreas; i++) + if (i <= Jump || !string.IsNullOrEmpty(s_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..d400a302b --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshBuildSettings.cs @@ -0,0 +1,130 @@ +// 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; + + /// 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. Note this is + /// a MemberwiseClone — valid only while every field is a value type; a future reference + /// field must be cloned explicitly here. + public NavMeshBuildSettings Clone() => (NavMeshBuildSettings)MemberwiseClone(); +} + +/// +/// 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; + + public NavMeshBuildOverrides Clone() => (NavMeshBuildOverrides)MemberwiseClone(); +} diff --git a/Prowl.Runtime/Navigation/NavMeshBuilder.cs b/Prowl.Runtime/Navigation/NavMeshBuilder.cs new file mode 100644 index 000000000..ab632dd1f --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshBuilder.cs @@ -0,0 +1,349 @@ +// 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 DotRecast.Core.Numerics; +using DotRecast.Detour; +using DotRecast.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, tile + /// capacity, and the bounds later rebuilds anchor to are sized from it instead of from the + /// initial geometry, so RebuildTiles can add tiles anywhere inside it. The vertical + /// range still unions with the geometry — callers know their footprint, not their height, + /// and Recast clips spans to the heightfield's vertical range. + /// 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) + { + Parallel.For(0, tilesX * tilesZ, new ParallelOptions { MaxDegreeOfParallelism = threads, CancellationToken = CancellationToken.None }, i => + { + if (cancellation.IsCancellationRequested) return; + layerResults[i] = NavMeshTileBuilder.BuildTileLayers(geom, cfg, bmin, bmax, i % tilesX, i / tilesX); + }); + } + 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 (XZ anchored to the bake, Y unioned with current geometry) + /// and expanding by the erosion border. 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 — the swap 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 and only here: + /// + /// Sources may legitimately be empty (a region walled in completely) — the provider is + /// then null and the affected tiles are EMPTIED; "no geometry" must not be conflated with + /// "no change". The tile grid is anchored in XZ to the ORIGINAL bake bounds (fresh + /// geometry bounds would shift tile (0,0) and misalign every tile against the live + /// navmesh), while the Y range follows the CURRENT geometry — Recast clips rasterized + /// spans to the heightfield's vertical range, so new geometry above the original bounds + /// (a wall dropped on a flat floor) would silently vanish from the rebuild. (With no + /// geometry the Y union is skipped: an empty provider reports (0,0,0) bounds, which would + /// spuriously widen bakes that don't straddle Y=0.) The affected range expands by the + /// erosion border, and a changed region entirely OUTSIDE the baked bounds returns false — + /// clamping it would drag the tile range onto the nearest edge column and rebuild healthy + /// edge tiles against sources that don't cover them. Easy to hit from destructible-world + /// events near the map border. + /// + 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 tile size the bake will actually use into , so the + /// bake, the serialized asset, the TileCache instantiated from it, and later tile rebuilds + /// all read one agreed value. + /// + /// A compressed layer header stores the layer's grid dimensions as BYTES, so a tile wider + /// than voxels wraps and decompresses as an + /// empty layer: the bake reports success and the navmesh comes out with no polygons at all. + /// clamps to keep that unreachable; + /// this reports it when the clamp actually moved a value the user asked for. + /// + 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..804eb13c8 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshData.cs @@ -0,0 +1,203 @@ +// 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 DotRecast.Core.Numerics; +using DotRecast.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 DotRecast upgrade changing Detour's tile layout), so stale assets fail with a + /// clear message instead of a deserialize throw. Version 4 dropped the finished-tile + /// representation: every navmesh is now compressed layers plus . + public const int CurrentFormatVersion = 4; + + /// Oldest format version this engine still reads. Versions 1-3 could hold finished + /// Detour tiles, which nothing instantiates any more; those assets must be rebaked. + public const int MinReadableFormatVersion = 4; + + /// 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 = []; + + /// + /// 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 DotRecast.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 DotRecast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles, + out NavMeshTileBuilder.ProwlTileCacheMeshProcess meshProcess) + { + ValidateVersion(); + DtNavMesh navMesh = CreateEmptyNavMesh(); + DotRecast.Detour.TileCache.DtTileCache cache = NavMeshTileBuilder.CreateTileCache(this, navMesh, maxObstacles, out meshProcess); + + 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; + } + 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..e19476106 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs @@ -0,0 +1,440 @@ +// 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: its render surface and heightfield collider + // are the same heightmap. + foreach (TerrainCollider terrain in go.GetComponents()) + 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's collider or renderer stamps a hole + /// into the mesh wherever it happened to be at bake time, and that hole never moves again; + /// both components block agents at runtime instead, wherever the object actually is. Whole + /// subtrees are excluded because visuals and colliders usually hang off child objects. + /// Memoized like the modifier walk. + /// + 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 hole that stays put forever once it is enabled and moves — the same frozen + // hole this rule exists to prevent, just harder to spot. The cost of the stricter rule + // is that an obstacle disabled for the whole session leaves its object out of the mesh; + // an object meant to be 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); + ArgumentNullException.ThrowIfNull(results); + + foreach (GameObject go in objects) + { + if (go.IsNotValid() || !go.EnabledInHierarchy) continue; + if (!layers.HasLayer(go.LayerIndex)) continue; + + foreach (NavMeshLink link in go.GetComponents()) + { + if (link.IsNotValid() || !link.EnabledInHierarchy || !link.Activated || !link.AffectsAgentType(agentTypeId)) + continue; + + NavMeshLinkSource source = link.ToLinkSource(); + if (bounds is AABB filter && !source.Bounds.Intersects(filter)) continue; + results.Add(source); + } + } + } + + /// 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 reuse the shared physics bake so + /// the triangle extraction cost is paid once per mesh, not per bake. + /// + 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. + /// + public static void CollectTerrain(TerrainCollider terrain, float voxelSize, int area, List results, AABB? bounds = null) + { + if (terrain.IsNotValid() || !terrain.EnabledInHierarchy) return; + + int res = terrain.Width; + if (res < 2) return; + + var terrainComponent = terrain.GetComponent(); + if (terrainComponent.IsNotValid()) return; + TerrainData? data = terrainComponent.Data.Res; + if (data.IsNotValid()) return; + + if (bounds is AABB filter) + { + // Terrain is axis-aligned (see the origin note below): position + size is its AABB. + Float3 tMin = terrain.Transform.Position; + var tMax = new Float3(tMin.X + data!.Size, tMin.Y + data.Height, tMin.Z + data.Size); + if (tMin.X > filter.Max.X || tMax.X < filter.Min.X + || tMin.Z > filter.Max.Z || tMax.Z < filter.Min.Z) + return; + } + + float cellSize = data.Size / (res - 1); + int stride = Math.Max(1, (int)MathF.Floor(Math.Max(voxelSize, 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; + + // Terrain is axis-aligned by engine convention: the physics heightfield proxy also + // registers with position only (see TerrainCollider), so rotation/scale on a terrain + // object is ignored consistently across physics and navigation. + Float3 origin = terrain.Transform.Position; + 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]; + terrain.TryGetHeight(x, z, out float height); // world-space Y (includes terrain position) + vertices[zi * n + xi] = new Float3( + origin.X + x * cellSize, + height, + origin.Z + 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(terrain, 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; + + // Vertices are already world-space (heights include the terrain's Y). + results.Add(new NavMeshGeometrySource(vertices, [.. indices], Float4x4.Identity, area)); + } + + private static bool AnyHole(TerrainCollider terrain, int x0, int z0, int x1, int z1) + { + for (int z = z0; z < z1; z++) + for (int x = x0; x < x1; x++) + if (terrain.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..45e6c7fc2 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshPath.cs @@ -0,0 +1,74 @@ +// 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 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; + + /// 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; + Status = NavMeshPathStatus.PathInvalid; + } + + 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..0a6285fed --- /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 DotRecast.Core.Numerics; +using DotRecast.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/NavMeshRasterizer.cs b/Prowl.Runtime/Navigation/NavMeshRasterizer.cs new file mode 100644 index 000000000..f82ba44c6 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshRasterizer.cs @@ -0,0 +1,363 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. +// +// The triangle rasterization below is ported from DotRecast (RcRasterizations.cs), which is +// itself a port of Recast by Mikko Mononen — both zlib licensed: +// Copyright (c) 2009-2010 Mikko Mononen memon@inside.org +// DotRecast Copyright (c) 2023-2024 Choi Ikpil ikpil@naver.com +// Altered for Prowl: spans are allocated from the heightfield's span pool (the RcSpanPool / +// freelist mechanism that upstream C++ Recast uses but the C# port's public AddSpan bypasses +// with per-span `new`), merged-away spans are returned to the freelist, and the walkable-slope +// test is folded into rasterization so no per-chunk area arrays are allocated. + +using System; + +using DotRecast.Core.Numerics; +using DotRecast.Recast; + +namespace Prowl.Runtime; + +/// +/// Allocation-free (steady-state) triangle rasterization into an . +/// Span objects come from the heightfield's pool pages, which +/// recycles across tiles per thread — so rebuilding tiles +/// allocates no span objects once the pools have grown to the working-set size. +/// +internal static class NavMeshRasterizer +{ + /// + /// Rasterize triangles with a per-triangle walkable-slope test: up-facing triangles within + /// the slope limit get (a Detour area value), steeper ones + /// rasterize as null-area (solid, unwalkable — they still occupy space). + /// + /// Target heightfield (spans come from its pool). + /// Vertex components (x,y,z per vertex). + /// Triangle vertex indices (three per triangle). + /// Number of triangles to rasterize. + /// Cosine of the maximum walkable slope angle. + /// Detour area value for walkable triangles. + /// Span merge threshold in voxels (walkable climb). + public static void RasterizeTriangles(RcHeightfield heightfield, float[] verts, int[] tris, int numTris, + float walkableSlopeCos, int walkableArea, int flagMergeThreshold) + { + float inverseCellSize = 1.0f / heightfield.cs; + float inverseCellHeight = 1.0f / heightfield.ch; + + for (int t = 0; t < numTris; t++) + { + int v0 = tris[t * 3 + 0]; + int v1 = tris[t * 3 + 1]; + int v2 = tris[t * 3 + 2]; + + // Walkable-slope test (RcRecast.MarkWalkableTriangles, inlined per triangle). + int area = TriangleNormalY(verts, v0, v1, v2) > walkableSlopeCos ? walkableArea : 0; + + RasterizeTri(verts, v0, v1, v2, area, heightfield, + heightfield.bmin, heightfield.bmax, heightfield.cs, inverseCellSize, inverseCellHeight, flagMergeThreshold); + } + } + + /// Y component of the (normalized) face normal — all the slope test needs. + private static float TriangleNormalY(float[] verts, int v0, int v1, int v2) + { + float ax = verts[v1 * 3 + 0] - verts[v0 * 3 + 0]; + float ay = verts[v1 * 3 + 1] - verts[v0 * 3 + 1]; + float az = verts[v1 * 3 + 2] - verts[v0 * 3 + 2]; + float bx = verts[v2 * 3 + 0] - verts[v0 * 3 + 0]; + float by = verts[v2 * 3 + 1] - verts[v0 * 3 + 1]; + float bz = verts[v2 * 3 + 2] - verts[v0 * 3 + 2]; + + float nx = ay * bz - az * by; + float ny = az * bx - ax * bz; + float nz = ax * by - ay * bx; + float lenSq = nx * nx + ny * ny + nz * nz; + if (lenSq <= 1e-12f) return 0f; // degenerate: not walkable + return ny / MathF.Sqrt(lenSq); + } + + #region Span pool (upstream Recast's rcAllocSpan/rcFreeSpan, unused by the C# port's public path) + + /// Rebuild a freelist chaining every span of every pool page. Used when recycled + /// pool pages are attached to a fresh heightfield: all their spans are free by definition. + public static RcSpan? BuildFreeList(RcSpanPool? pools) + { + RcSpan? freelist = null; + for (RcSpanPool? pool = pools; pool != null; pool = pool.next) + { + for (int i = 0; i < pool.items.Length; i++) + { + pool.items[i].next = freelist; + freelist = pool.items[i]; + } + } + return freelist; + } + + private static RcSpan AllocSpan(RcHeightfield heightfield) + { + if (heightfield.freelist == null) + { + // New pool page; chain its spans onto the freelist. + var spanPool = new RcSpanPool { next = heightfield.pools }; + heightfield.pools = spanPool; + RcSpan freeList = heightfield.freelist; + for (int i = 0; i < spanPool.items.Length; i++) + { + spanPool.items[i].next = freeList; + freeList = spanPool.items[i]; + } + heightfield.freelist = freeList; + } + + RcSpan newSpan = heightfield.freelist; + heightfield.freelist = heightfield.freelist.next; + return newSpan; + } + + private static void FreeSpan(RcHeightfield heightfield, RcSpan span) + { + span.next = heightfield.freelist; + heightfield.freelist = span; + } + + #endregion + + /// rcAddSpan with pooled spans: allocations go through the pool and spans removed + /// by merging are returned to it (as upstream C++ does; the C# port drops them). + private static void AddSpan(RcHeightfield heightfield, int x, int z, int min, int max, int areaID, int flagMergeThreshold) + { + RcSpan newSpan = AllocSpan(heightfield); + newSpan.smin = min; + newSpan.smax = max; + newSpan.area = areaID; + newSpan.next = null; + + int columnIndex = x + z * heightfield.width; + + if (heightfield.spans[columnIndex] == null) + { + heightfield.spans[columnIndex] = newSpan; + return; + } + + RcSpan? previousSpan = null; + RcSpan? currentSpan = heightfield.spans[columnIndex]; + + while (currentSpan != null) + { + if (currentSpan.smin > newSpan.smax) + { + break; // current span is past the new span + } + + if (currentSpan.smax < newSpan.smin) + { + // Entirely before the new span; keep walking. + previousSpan = currentSpan; + currentSpan = currentSpan.next; + } + else + { + // Overlap: merge into newSpan. + if (currentSpan.smin < newSpan.smin) newSpan.smin = currentSpan.smin; + if (currentSpan.smax > newSpan.smax) newSpan.smax = currentSpan.smax; + + if (Math.Abs(newSpan.smax - currentSpan.smax) <= flagMergeThreshold) + newSpan.area = Math.Max(newSpan.area, currentSpan.area); + + // Unlink the merged span and recycle it. + RcSpan? next = currentSpan.next; + if (previousSpan != null) previousSpan.next = next; + else heightfield.spans[columnIndex] = next; + FreeSpan(heightfield, currentSpan); + currentSpan = next; + } + } + + if (previousSpan != null) + { + newSpan.next = previousSpan.next; + previousSpan.next = newSpan; + } + else + { + newSpan.next = heightfield.spans[columnIndex]; + heightfield.spans[columnIndex] = newSpan; + } + } + + #region Triangle clipping (verbatim port of RcRasterizations.DividePoly / RasterizeTri) + + private static bool OverlapBounds(RcVec3f aMin, RcVec3f aMax, RcVec3f bMin, RcVec3f bMax) + { + return aMin.X <= bMax.X && aMax.X >= bMin.X + && aMin.Y <= bMax.Y && aMax.Y >= bMin.Y + && aMin.Z <= bMax.Z && aMax.Z >= bMin.Z; + } + + private static void CopyVert(Span dst, int dstOffset, ReadOnlySpan src, int srcOffset) + { + dst[dstOffset + 0] = src[srcOffset + 0]; + dst[dstOffset + 1] = src[srcOffset + 1]; + dst[dstOffset + 2] = src[srcOffset + 2]; + } + + private static void DividePoly(Span inVerts, int inVertsOffset, int inVertsCount, + int outVerts1, out int outVerts1Count, + int outVerts2, out int outVerts2Count, + float axisOffset, int axis) + { + Span inVertAxisDelta = stackalloc float[12]; + for (int inVert = 0; inVert < inVertsCount; ++inVert) + inVertAxisDelta[inVert] = axisOffset - inVerts[inVertsOffset + inVert * 3 + axis]; + + int poly1Vert = 0; + int poly2Vert = 0; + for (int inVertA = 0, inVertB = inVertsCount - 1; inVertA < inVertsCount; inVertB = inVertA, ++inVertA) + { + bool sameSide = (inVertAxisDelta[inVertA] >= 0) == (inVertAxisDelta[inVertB] >= 0); + if (!sameSide) + { + float s = inVertAxisDelta[inVertB] / (inVertAxisDelta[inVertB] - inVertAxisDelta[inVertA]); + inVerts[outVerts1 + poly1Vert * 3 + 0] = inVerts[inVertsOffset + inVertB * 3 + 0] + (inVerts[inVertsOffset + inVertA * 3 + 0] - inVerts[inVertsOffset + inVertB * 3 + 0]) * s; + inVerts[outVerts1 + poly1Vert * 3 + 1] = inVerts[inVertsOffset + inVertB * 3 + 1] + (inVerts[inVertsOffset + inVertA * 3 + 1] - inVerts[inVertsOffset + inVertB * 3 + 1]) * s; + inVerts[outVerts1 + poly1Vert * 3 + 2] = inVerts[inVertsOffset + inVertB * 3 + 2] + (inVerts[inVertsOffset + inVertA * 3 + 2] - inVerts[inVertsOffset + inVertB * 3 + 2]) * s; + CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, outVerts1 + poly1Vert * 3); + poly1Vert++; + poly2Vert++; + + if (inVertAxisDelta[inVertA] > 0) + { + CopyVert(inVerts, outVerts1 + poly1Vert * 3, inVerts, inVertsOffset + inVertA * 3); + poly1Vert++; + } + else if (inVertAxisDelta[inVertA] < 0) + { + CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, inVertsOffset + inVertA * 3); + poly2Vert++; + } + } + else + { + if (inVertAxisDelta[inVertA] >= 0) + { + CopyVert(inVerts, outVerts1 + poly1Vert * 3, inVerts, inVertsOffset + inVertA * 3); + poly1Vert++; + if (inVertAxisDelta[inVertA] != 0) + continue; + } + + CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, inVertsOffset + inVertA * 3); + poly2Vert++; + } + } + + outVerts1Count = poly1Vert; + outVerts2Count = poly2Vert; + } + + private static void RasterizeTri(float[] verts, int v0, int v1, int v2, + int areaID, RcHeightfield heightfield, + RcVec3f heightfieldBBMin, RcVec3f heightfieldBBMax, + float cellSize, float inverseCellSize, float inverseCellHeight, + int flagMergeThreshold) + { + var triBBMin = new RcVec3f(verts[v0 * 3], verts[v0 * 3 + 1], verts[v0 * 3 + 2]); + var triBBMax = triBBMin; + for (int i = 0; i < 2; i++) + { + int v = i == 0 ? v1 : v2; + var p = new RcVec3f(verts[v * 3], verts[v * 3 + 1], verts[v * 3 + 2]); + triBBMin = RcVec3f.Min(triBBMin, p); + triBBMax = RcVec3f.Max(triBBMax, p); + } + + if (!OverlapBounds(triBBMin, triBBMax, heightfieldBBMin, heightfieldBBMax)) + return; + + int w = heightfield.width; + int h = heightfield.height; + float by = heightfieldBBMax.Y - heightfieldBBMin.Y; + + int z0 = (int)((triBBMin.Z - heightfieldBBMin.Z) * inverseCellSize); + int z1 = (int)((triBBMax.Z - heightfieldBBMin.Z) * inverseCellSize); + + // -1 rather than 0 so the polygon is cut properly at the start of the tile. + z0 = Math.Clamp(z0, -1, h - 1); + z1 = Math.Clamp(z1, 0, h - 1); + + Span buf = stackalloc float[7 * 3 * 4]; + int @in = 0; + int inRow = 7 * 3; + int p1 = inRow + 7 * 3; + int p2 = p1 + 7 * 3; + + CopyVert(buf, 0, verts.AsSpan(v0 * 3, 3), 0); + CopyVert(buf, 3, verts.AsSpan(v1 * 3, 3), 0); + CopyVert(buf, 6, verts.AsSpan(v2 * 3, 3), 0); + int nvRow; + int nvIn = 3; + + for (int z = z0; z <= z1; ++z) + { + float cellZ = heightfieldBBMin.Z + z * cellSize; + DividePoly(buf, @in, nvIn, inRow, out nvRow, p1, out nvIn, cellZ + cellSize, RcAxis.RC_AXIS_Z); + (@in, p1) = (p1, @in); + + if (nvRow < 3) continue; + if (z < 0) continue; + + float minX = buf[inRow]; + float maxX = buf[inRow]; + for (int i = 1; i < nvRow; ++i) + { + float v = buf[inRow + i * 3]; + minX = Math.Min(minX, v); + maxX = Math.Max(maxX, v); + } + + int x0 = (int)((minX - heightfieldBBMin.X) * inverseCellSize); + int x1 = (int)((maxX - heightfieldBBMin.X) * inverseCellSize); + if (x1 < 0 || x0 >= w) continue; + + x0 = Math.Clamp(x0, -1, w - 1); + x1 = Math.Clamp(x1, 0, w - 1); + + int nv; + int nv2 = nvRow; + for (int x = x0; x <= x1; ++x) + { + float cx = heightfieldBBMin.X + x * cellSize; + DividePoly(buf, inRow, nv2, p1, out nv, p2, out nv2, cx + cellSize, RcAxis.RC_AXIS_X); + (inRow, p2) = (p2, inRow); + + if (nv < 3) continue; + if (x < 0) continue; + + float spanMin = buf[p1 + 1]; + float spanMax = buf[p1 + 1]; + for (int i = 1; i < nv; ++i) + { + spanMin = Math.Min(spanMin, buf[p1 + i * 3 + 1]); + spanMax = Math.Max(spanMax, buf[p1 + i * 3 + 1]); + } + + spanMin -= heightfieldBBMin.Y; + spanMax -= heightfieldBBMin.Y; + + if (spanMax < 0.0f) continue; + if (spanMin > by) continue; + + if (spanMin < 0.0f) spanMin = 0; + if (spanMax > by) spanMax = by; + + int spanMinCellIndex = Math.Clamp((int)MathF.Floor(spanMin * inverseCellHeight), 0, RcRecast.RC_SPAN_MAX_HEIGHT); + int spanMaxCellIndex = Math.Clamp((int)MathF.Ceiling(spanMax * inverseCellHeight), spanMinCellIndex + 1, RcRecast.RC_SPAN_MAX_HEIGHT); + + AddSpan(heightfield, x, z, spanMinCellIndex, spanMaxCellIndex, areaID, flagMergeThreshold); + } + } + } + + #endregion +} diff --git a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs new file mode 100644 index 000000000..7ecc946e0 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs @@ -0,0 +1,481 @@ +// 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 DotRecast.Core; +using DotRecast.Core.Numerics; +using DotRecast.Detour; +using DotRecast.Detour.TileCache; +using DotRecast.Detour.TileCache.Io.Compress; +using DotRecast.Recast; +using DotRecast.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; + + /// + /// Per-thread 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). Thread-static because full bakes build tiles in Parallel.For. + /// + private 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; + } + + [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) + { + solid.pools = scratch.SpanPools; + solid.freelist = NavMeshRasterizer.BuildFreeList(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]) + { + NavMeshRasterizer.RasterizeTriangles(solid, verts, node.tris, node.tris.Length / 3, + walkableSlopeCos, areaMesh.DetourArea, 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. + /// + public static List BuildTileLayers(ProwlInputGeomProvider geom, RcConfig cfg, RcVec3f bmin, RcVec3f bmax, int tileX, int tileZ) + { + var builderCfg = new RcBuilderConfig(cfg, bmin, bmax, tileX, tileZ); + + List[]? overlappingChunks = CollectOverlappingChunks(geom, builderCfg); + if (overlappingChunks == null) + return []; + + TileBuildScratch scratch = 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); + 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). The layers themselves + // are partitioned at runtime by the TileCache, so 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 is the partitioning Recast intends + // for layer builds — which is what these tiles become — needs no distance field + // (the expensive half of watershed, and this runs on every tile of every bake), and + // zeroes culled region ids the same way, which is 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 = []; + + /// + /// Unbudgeted links a single tile's pool can absorb. Detour sizes that pool when the tile + /// is built, from the connections whose endpoints are inside it — and it under-counts in + /// BOTH directions. A connection that leaves the tile costs its source one extra link + /// (the bidirectional back-link), and costs the tile it LANDS in one more, which that + /// tile budgeted nothing for because the connection is not stored there. Past a handful + /// the pool overflows and AddTile throws IndexOutOfRange — at instantiation, on an asset + /// that baked and saved cleanly. + /// + /// DO NOT raise this without measuring. A tile's real spare capacity is whatever is left + /// of edgeCount + portalCount*2 after its own polygons are linked, so a sparse + /// tile — two flat planes, one polygon each — has the least of it and sets the limit for + /// everyone. Raising this to 5, 6 or 8 was tried: all three crash that geometry, while a + /// denser 40x40 bake survives 8 arrivals happily. The bound cannot be derived either; + /// the binding constraint is arrivals from up to eight neighbours, which are only + /// visible from the global pass in , before any tile exists to + /// measure. + /// + private const int MaxTileCrossingConnections = 4; + + // Ration bookkeeping, reused across calls: connections charged to each tile, by grid + // coordinate. Rationing runs once per link-set change, never per tile build. + private readonly Dictionary<(int X, int Z), int> _tileBudget = []; + private int _severedLinks; + + /// + /// Replace the link set future tile builds inject. Call under the instance's write lock, + /// then rebuild the tiles that should carry the change. + /// + /// Rationing happens HERE, once, rather than per tile build: a tile's pool is spent by + /// connections arriving from any of its eight neighbours as well as by its own, and a + /// tile build sees only its own. Every connection is charged to the tile it starts in + /// and the tile it ends in, so a destination cannot be swamped by sources that each stay + /// under the limit on their own. Lanes are handed out breadth first — every link keeps + /// one before any link gets a second — so a wide link never crowds out another link. + /// + /// Tile grid origin, from the asset. + /// Tile side length in world units, from the asset. + public void SetLinks(IReadOnlyList? links, float agentRadius, Float3 origin, float tileWorldSize) + { + _connections.Clear(); + _tileBudget.Clear(); + _severedLinks = 0; + + if (links == null || links.Count == 0) return; + + float radius = Math.Max(0.01f, agentRadius); + float ts = tileWorldSize > 0 ? tileWorldSize : float.MaxValue; // ungridded: one tile + List<(Float3 Start, Float3 End)> crossings = []; + var lanes = new List<(Float3 Start, Float3 End, int Link)>(); + var linkFirstLane = new int[links.Count]; + + for (int l = 0; l < links.Count; l++) + { + crossings.Clear(); + links[l].ExpandCrossings(radius, crossings); + linkFirstLane[l] = lanes.Count; + foreach ((Float3 start, Float3 end) in crossings) + lanes.Add((start, end, l)); + } + + (int, int) Tile(Float3 p) => ((int)MathF.Floor((float)(p.X - origin.X) / ts), + (int)MathF.Floor((float)(p.Z - origin.Z) / ts)); + + // A connection wholly inside one tile costs that tile nothing extra, so it is never + // rationed; only the two ends of a crossing are charged. + bool TryCharge((Float3 Start, Float3 End, int Link) lane, bool commit) + { + (int, int) from = Tile(lane.Start), to = Tile(lane.End); + if (from == to) return true; + _tileBudget.TryGetValue(from, out int a); + _tileBudget.TryGetValue(to, out int b); + if (a >= MaxTileCrossingConnections || b >= MaxTileCrossingConnections) return false; + if (commit) { _tileBudget[from] = a + 1; _tileBudget[to] = b + 1; } + return true; + } + + Span taken = lanes.Count <= 256 ? stackalloc bool[lanes.Count] : new bool[lanes.Count]; + for (int l = 0; l < links.Count; l++) + { + int end = l + 1 < links.Count ? linkFirstLane[l + 1] : lanes.Count; + bool got = false; + for (int i = linkFirstLane[l]; i < end && !got; i++) + { + if (!TryCharge(lanes[i], commit: true)) continue; + taken[i] = true; + got = true; + } + if (!got && end > linkFirstLane[l]) _severedLinks++; + } + + // Spend what is left widening the links that got through. + for (int l = 0; l < links.Count; l++) + { + int start = linkFirstLane[l], end = l + 1 < links.Count ? linkFirstLane[l + 1] : lanes.Count; + bool linkIsIn = false; + for (int i = start; i < end; i++) if (taken[i]) { linkIsIn = true; break; } + if (!linkIsIn) continue; + for (int i = start; i < end; i++) + { + if (taken[i] || !TryCharge(lanes[i], commit: true)) continue; + taken[i] = true; + } + } + + for (int i = 0; i < lanes.Count; i++) + { + if (!taken[i]) continue; + NavMeshLinkSource link = links[lanes[i].Link]; + _connections.Add((lanes[i].Start, lanes[i].End, radius, link.Bidirectional, + ProwlInputGeomProvider.DetourAreaFor(link.Area), link.UserId)); + } + + if (_severedLinks > 0) + Debug.LogWarning($"[Navigation] {_severedLinks} NavMeshLink(s) cross a navmesh tile boundary already carrying the {MaxTileCrossingConnections} connections its link pool holds, and were dropped — agents cannot use them. Spread the links out, move them off the tile edge, or bake with a larger tile size."); + } + + 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 the connections whose START point lies in the tile (an XZ test), + // so do that first: handing over every link on the map would allocate six arrays + // sized by the whole set for a tile that usually contains none of them. The tile box + // is widened by each connection's radius so this can never be stricter than the + // classification it front-runs. Rationing already happened in SetLinks — everything + // still here is approved. + 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, + // 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, data.Origin, data.TileWorldSize); + + // 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 (the rebuild + /// checks membership in each obstacle's touched list). Call with the cache quiescent + /// (Update() reporting up-to-date) so every obstacle is in its settled state. + /// 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..eeeada898 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshTriangulation.cs @@ -0,0 +1,75 @@ +// 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 DotRecast.Detour; + +using Prowl.Vector; + +namespace Prowl.Runtime; + +/// +/// A triangulated snapshot of a navmesh, for debug drawing and user tooling +/// (matches Unity's NavMeshTriangulation). Triangles are fan-triangulated from the navmesh +/// polygons (not the height-detail mesh), which is exact in XZ and approximate in Y on slopes. +/// +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; + + /// An empty triangulation (no navmesh to walk). + public static NavMeshTriangulation Empty => new() { Vertices = [], Indices = [], Areas = [] }; + + /// + /// 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 indices = []; + List areas = []; + + for (int t = 0; t < mesh.GetMaxTiles(); t++) + { + DtMeshTile tile = mesh.GetTile(t); + if (tile?.data?.header == null) continue; + + 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; + + int baseVert = vertices.Count; + for (int v = 0; v < poly.vertCount; v++) + { + int vi = poly.verts[v] * 3; + vertices.Add(new Float3(tile.data.verts[vi], tile.data.verts[vi + 1], tile.data.verts[vi + 2])); + } + + int area = NavMeshAreas.FromDetourArea(poly.GetArea()); + for (int v = 2; v < poly.vertCount; v++) + { + indices.Add(baseVert); + indices.Add(baseVert + v - 1); + indices.Add(baseVert + v); + areas.Add(area); + } + } + } + + return new NavMeshTriangulation { Vertices = [.. vertices], Indices = [.. indices], Areas = [.. areas] }; + } +} diff --git a/Prowl.Runtime/Navigation/NavMeshWorld.cs b/Prowl.Runtime/Navigation/NavMeshWorld.cs new file mode 100644 index 000000000..bc48afe37 --- /dev/null +++ b/Prowl.Runtime/Navigation/NavMeshWorld.cs @@ -0,0 +1,853 @@ +// 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.Threading; + +using DotRecast.Core.Numerics; +using DotRecast.Detour; +using DotRecast.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. Doubles as the "this instance changed" signal: only a flagged + // instance is pumped, so being pumped is itself proof there was something to report. + // Registration seeds every tile synchronously, so a freshly registered instance 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, DotRecast.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 DotRecast.Detour.TileCache.DtTileCache TileCache { get; } + + /// Tell the pump this cache has work waiting. Only needed after queuing on + /// directly; and the + /// navigation components already do it. 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; + + // Off-mesh connection user ids present in the mesh, built lazily and invalidated on + // mutation — turns per-link containment checks (every NavMeshLink at scene load) into + // O(1) after one O(tiles) pass per instance. Main-thread only, like registration. + private HashSet? _linkIds; + + internal void InvalidateLinkIds() => _linkIds = null; + + /// Whether the mesh holds an off-mesh connection stamped with the given link id + /// (see ). Main thread. + public bool ContainsLinkId(int linkId) + { + if (_linkIds == null) + { + _linkIds = []; + for (int t = 0; t < Mesh.GetMaxTiles(); t++) + { + var cons = Mesh.GetTile(t)?.data?.offMeshCons; + if (cons == null) continue; + foreach (DtOffMeshConnection con in cons) + if (con.userId != 0) + _linkIds.Add(con.userId); + } + } + return _linkIds.Contains(linkId); + } +} + +/// +/// 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 allocated per distinct (AreaMask, cost-overrides) agent +/// configuration and refcounted, so agents with identical steering filters share a slot. +/// Slot numbers are NOT stable across release/re-acquire (a config can land on a different +/// free slot) — 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(); + } +} + +/// +/// 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 +{ + // Capacity limits for a single query, in polys/corners. Detour needs explicit maximums; + // these match the sizes the Recast demos use for long paths. + private const int MaxPolyPath = 1024; + private const int MaxStraightPath = 256; + + private readonly List _instances = []; + private readonly Lock _instancesLock = new(); + + [ThreadStatic] private static NavMeshQueryFilter? t_scratchFilter; + + /// 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 = new(1f, 2f, 1f); + + /// 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. cannot stand + /// in for this: it also fires for tile-content changes, which means every frame a carve is + /// converging. Components that only care about instances appearing + /// or dying (link catch-up, obstacle re-attachment) compare this instead, so gameplay-rate + /// carving stops waking 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. Agents waiting for a + /// navmesh subscribe to this instead of polling. + public event Action? NavMeshChanged; + + #region Registration + + /// Obstacle capacity navmeshes are instantiated with. Set BEFORE the surface + /// registers — applied at instantiation. + public int TileCacheMaxObstacles = 256; + + /// + /// 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 + { + DotRecast.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; + + // Wait out in-flight queries, then poison the pool. + instance.Lock.EnterWriteLock(); + instance.QueryPool.Clear(); + instance.Lock.ExitWriteLock(); + + // 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.Lock.EnterWriteLock(); + instance.QueryPool.Clear(); + instance.Lock.ExitWriteLock(); + } + _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 DotRecast 2026.1.3, DtNavMeshQuery holds only the + /// mesh reference (the same object we mutate) plus node pools and an open list that every + /// query method clears on entry — there is no cached tile state, so discarding the pool + /// here would only churn tens-of-KB query objects on every rebuild 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); + + instance.Lock.EnterWriteLock(); + try + { + mutation(instance.TileCache); + } + finally + { + instance.Lock.ExitWriteLock(); + } + // 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(); + } + + #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); + if (instance == null) + { + lease = default; + return false; + } + + // Benign race with RemoveNavMeshData: the instance may be unregistered between the + // lookup and the lock, in which case this queries a just-removed (but still fully + // alive and internally consistent) mesh one last time. Do not "fix" with a global + // lock — the stale answer is indistinguishable from having queried a moment earlier. + 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; + + long[] polys = ArrayPool.Shared.Rent(MaxPolyPath); + DtStraightPath[] straight = ArrayPool.Shared.Rent(MaxStraightPath); + Float3[] corners = ArrayPool.Shared.Rent(MaxStraightPath); + try + { + DtStatus status = query.FindPath(startRef, endRef, startPt, endPt, filter, polys.AsSpan(0, MaxPolyPath), out int polyCount, MaxPolyPath); + 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, MaxStraightPath), out int cornerCount, MaxStraightPath, 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 >= MaxStraightPath) + 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); + 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; + + long[] polys = ArrayPool.Shared.Rent(MaxPolyPath); + try + { + DtStatus status = query.Raycast(startRef, startPt, end, filter, out float t, out RcVec3f normal, + polys.AsSpan(0, MaxPolyPath), out int _, MaxPolyPath); + 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); + hit.Hit = blocked; + return blocked; + } + finally + { + ArrayPool.Shared.Return(polys); + } + } + } + + /// Locate the closest navmesh border edge from a point. + public bool FindClosestEdge(Float3 sourcePosition, out NavMeshHit hit, int areaMask) + => FindClosestEdge(sourcePosition, out hit, GetScratchFilter(areaMask)); + + /// + public bool FindClosestEdge(Float3 sourcePosition, 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; + query.FindNearestPoly(ToRc(sourcePosition), ToRc(DefaultQueryExtents), filter, out long startRef, out RcVec3f startPt, out _); + if (startRef == 0) + return false; + + // Search radius: generous enough to always find the border of the current region. + DtStatus status = query.FindDistanceToWall(startRef, startPt, 100f, 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) + return NavMeshTriangulation.Empty; + + instance.Lock.EnterReadLock(); + try + { + return NavMeshTriangulation.FromNavMesh(instance.Mesh); + } + finally + { + instance.Lock.ExitReadLock(); + } + } + + 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); + } + + // 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 — the mesh looking + // untouched while the component looks configured. 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 entirely means a navmesh nothing ever carves + // costs nothing at all per frame — its tiles are finished Detour tiles and stay that way. + _cachePumpScratch.Clear(); + lock (_instancesLock) + { + foreach (NavMeshInstance instance in _instances) + if (instance.CachePending) + _cachePumpScratch.Add(instance); + } + + foreach (NavMeshInstance instance in _cachePumpScratch) + { + bool upToDate; + instance.Lock.EnterWriteLock(); + try + { + upToDate = instance.TileCache.Update(); + } + finally + { + instance.Lock.ExitWriteLock(); + } + + // Reaching here means work was queued, so report unconditionally — idle instances + // never enter the scratch list. Do NOT gate this on the converged edge: a carve + // small enough to finish inside one Update() reports up-to-date on its first call, + // which swallowed the only notification it would ever send and left agents and the + // scene-view overlay on stale geometry. Pooled queries deliberately survive the tile + // swaps — same verified invariant as MutateTileCache; only instance death poisons. + instance.InvalidateLinkIds(); + NavMeshChanged?.Invoke(); + if (upToDate) instance.CachePending = false; + } + } + + #endregion +} diff --git a/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs new file mode 100644 index 000000000..c77fcd500 --- /dev/null +++ b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs @@ -0,0 +1,202 @@ +// 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 DotRecast.Core.Numerics; +using DotRecast.Recast; +using DotRecast.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), DetourAreaFor(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); + } + + /// + /// Bake-side area conversion: Not Walkable maps to Detour's null area (0), so the marked + /// voxels are obstacles rather than traversable "area 1" polys — Unity's Not Walkable + /// semantics for sources, modifiers, and modifier volumes alike. Everything else goes + /// through . + /// + internal static int DetourAreaFor(int area) + => area == NavMeshAreas.NotWalkable ? 0 : NavMeshAreas.ToDetourArea(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 04561ad78..6ba33fab0 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,8 @@ public static void Apply(string settingsDir) ApplyAudio(settingsDir); ApplyTime(settingsDir); ApplyTagsAndLayers(settingsDir); + ApplyGeneral(settingsDir); + ApplyNavigation(settingsDir); // Physics needs to apply to each new scene's PhysicsWorld ApplyPhysics(settingsDir); @@ -188,6 +191,62 @@ private static void ApplyTagsAndLayers(string dir) catch (Exception ex) { Debug.LogWarning($"[PlayerSettings] Failed to apply tags/layers: {ex.Message}"); } } + private static void ApplyGeneral(string dir) + { + // Informational only for now + } + + 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 add47a084..0e1df270c 100644 --- a/Prowl.Runtime/Prowl.Runtime.csproj +++ b/Prowl.Runtime/Prowl.Runtime.csproj @@ -29,6 +29,12 @@ + + + + + + diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index f79563b52..878271a51 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -102,6 +102,13 @@ public static void Unload() 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(); @@ -626,6 +633,9 @@ public 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. @@ -707,6 +717,12 @@ public void Update() { EnsureNotDisposed(); _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(); From a5556c9f4b2aa9f72099938e104ed75cb3ec6ec1 Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 07:16:19 -0600 Subject: [PATCH 02/67] Remove dead code - Apply General This was self introduced and not part of main --- Prowl.Runtime/PlayerSettingsLoader.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Prowl.Runtime/PlayerSettingsLoader.cs b/Prowl.Runtime/PlayerSettingsLoader.cs index 6ba33fab0..0ed842c10 100644 --- a/Prowl.Runtime/PlayerSettingsLoader.cs +++ b/Prowl.Runtime/PlayerSettingsLoader.cs @@ -30,7 +30,6 @@ public static void Apply(string settingsDir) ApplyAudio(settingsDir); ApplyTime(settingsDir); ApplyTagsAndLayers(settingsDir); - ApplyGeneral(settingsDir); ApplyNavigation(settingsDir); // Physics needs to apply to each new scene's PhysicsWorld @@ -191,11 +190,6 @@ private static void ApplyTagsAndLayers(string dir) catch (Exception ex) { Debug.LogWarning($"[PlayerSettings] Failed to apply tags/layers: {ex.Message}"); } } - private static void ApplyGeneral(string dir) - { - // Informational only for now - } - private static void ApplyNavigation(string dir) { var settings = Read(dir, PlayerSettingsFiles.Navigation); From 1e6099a70a677b915c1d9129f52ff11c1c33fc50 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 00:26:17 +1000 Subject: [PATCH 03/67] Keep UI dirty while assets stream in --- Prowl.Runtime/Components/GameCanvas.cs | 9 ++++++++- Prowl.Runtime/Components/UI/TextComponent.cs | 4 ++++ Prowl.Runtime/Components/UI/UIBehaviour.cs | 12 ++++++++++++ Prowl.Runtime/Components/UI/UIImage.cs | 3 +++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 076492abb..033eefcce 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -131,6 +131,10 @@ public static Material SharedTextMaterial [SerializeIgnore] private bool _isDirty = true; [SerializeIgnore] private UIDirtyFlags _aggregateDirty = UIDirtyFlags.All; + /// Set during a rebuild when some element was still waiting on an asset. Keeps the canvas + /// dirty so it rebuilds again, since nothing else re-triggers one once it goes clean. + [SerializeIgnore] private bool _contentPending; + /// /// Size of the surface this canvas was last built against (in real pixels). /// Tracked so that a resolution change - typical when the editor's game viewport resizes @@ -258,10 +262,12 @@ public void RebuildIfDirty() _rootRect = rootRect; // the canvas has no RectTransform; children lay out against this directly int dfs = 0; + _contentPending = false; BuildRecursive(GameObject, rootRect, UIContext.Default, canvasScissor: null, activeClip: null, ref dfs); Tree.SortHierarchical(); - _isDirty = false; + // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. + _isDirty = _contentPending; _aggregateDirty = UIDirtyFlags.None; } @@ -345,6 +351,7 @@ private void BuildRecursive(GameObject parent, Rect parentRect, UIContext ctx, R if (!ui.EnabledInHierarchy) continue; EnsureBaked(ui, childCtx); + if (ui.IsContentPending) _contentPending = true; if (ui.CachedMesh is { } mesh) EmitItem(ui, mesh, dfsIndex++, childClip); } diff --git a/Prowl.Runtime/Components/UI/TextComponent.cs b/Prowl.Runtime/Components/UI/TextComponent.cs index 1288b546e..a51873bfa 100644 --- a/Prowl.Runtime/Components/UI/TextComponent.cs +++ b/Prowl.Runtime/Components/UI/TextComponent.cs @@ -43,6 +43,10 @@ public FontAsset? ResolvedFont } } + /// A font is assigned but hasn't loaded, so this text is currently laid out with the + /// built-in fallback and has to be rebuilt once the real one arrives. + public override bool IsContentPending => !_font.IsExplicitNull && _font.Res.IsNotValid(); + [SerializeField] private Color _textColor = Color.White; public Color TextColor { diff --git a/Prowl.Runtime/Components/UI/UIBehaviour.cs b/Prowl.Runtime/Components/UI/UIBehaviour.cs index 371e2b8ee..7a38f3f93 100644 --- a/Prowl.Runtime/Components/UI/UIBehaviour.cs +++ b/Prowl.Runtime/Components/UI/UIBehaviour.cs @@ -84,6 +84,18 @@ public override void OnRemovedFromScene() /// Subclasses fill in canvas-local pixel space. public abstract void GenerateMesh(UIMeshBuilder builder, in UIContext context); + /// + /// True while this element's geometry is built from an asset that hasn't streamed in yet, so what it + /// baked is missing or a placeholder. + /// + /// The canvas stays dirty while any element reports this. Nothing else would bring it back: a clean + /// canvas skips the whole rebuild walk, so an element that baked nothing on frame one is never asked + /// again, and the asset arriving changes nothing on screen until something unrelated (a window + /// resize) happens to dirty the canvas. + /// + /// + public virtual bool IsContentPending => false; + /// Subclasses bind per-item shader properties (textures, scalars). Called every frame the item is visible. public virtual void PopulateProperties(PropertyState props, in UIContext context) { } diff --git a/Prowl.Runtime/Components/UI/UIImage.cs b/Prowl.Runtime/Components/UI/UIImage.cs index e9bda0944..978fb8733 100644 --- a/Prowl.Runtime/Components/UI/UIImage.cs +++ b/Prowl.Runtime/Components/UI/UIImage.cs @@ -82,6 +82,9 @@ public AssetRef Sprite /// The source texture bound for drawing: the sprite's texture, or null when no sprite is set. private Texture2D? SourceTexture { get { var s = Spr; return s.IsValid() ? s.Texture.Res : null; } } + /// A sprite is assigned but it (or its texture) is still loading, so this image drew nothing. + public override bool IsContentPending => !_sprite.IsExplicitNull && SourceTexture is null; + /// 9-slice border in source pixels, taken from the sprite (zero when no sprite is set). private Float4 EffectiveBorder => Spr is Sprite s ? s.Border : Float4.Zero; From d6ef0d22978b916478717c3495aba53491e612bc Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 00:33:36 +1000 Subject: [PATCH 04/67] Made transform gizmo 25 pixels bigger --- Prowl.Editor/GUI/Panels/SceneViewPanel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs index 18ebdefa8..d4ca17bba 100644 --- a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs +++ b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs @@ -836,6 +836,7 @@ private void UpdateTransformGizmo() // Create gizmo if needed _transformGizmo ??= new Gizmo.TransformGizmo(SceneTools.GizmoMode); + _transformGizmo.GizmoSize = 100f; // Pivot mode picks what the handle sits on: the selection's centre, or the active object. Float3 center; From dcd5d393f7ca2b7d6028ceb3e7651b791b7425b5 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 00:35:33 +1000 Subject: [PATCH 05/67] Added some tests to ensure unreadable or unwritable files should never break assets --- Prowl.Editor.Test/AssetRobustnessTests.cs | 99 +++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 Prowl.Editor.Test/AssetRobustnessTests.cs diff --git a/Prowl.Editor.Test/AssetRobustnessTests.cs b/Prowl.Editor.Test/AssetRobustnessTests.cs new file mode 100644 index 000000000..95b5478c6 --- /dev/null +++ b/Prowl.Editor.Test/AssetRobustnessTests.cs @@ -0,0 +1,99 @@ +// 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 ImageMagick; + +using Prowl.Editor.Importers; +using Prowl.Runtime; + +using Xunit; + +namespace Prowl.Editor.Test; + +/// +/// A transiently unreadable or unwritable file must never be treated as a permanent verdict about an +/// asset's identity or its imported state - both are how references silently die. +/// +[Trait("Category", "Build")] +public class AssetRobustnessTests : EditorTestHarness +{ + private Guid MakeTexture(string relativePath) + { + string abs = AssetAbsolutePath(relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(abs)!); + using (var image = new MagickImage(new MagickColor(20, 40, 60, 255), 8, 8)) + { + image.Format = MagickFormat.Png; + image.Write(abs); + } + Guid guid = Assets.ImportFile(relativePath); + Assert.NotEqual(Guid.Empty, guid); + return guid; + } + + // A .meta that exists but momentarily cannot be read (antivirus, a sync client, a backup agent + // holding it) must not be replaced with a fresh GUID - that orphans the asset and every sub-asset. + [Fact] + public void UnreadableMetaFile_DoesNotRegenerateTheGuid() + { + Guid texGuid = MakeTexture("Locked.png"); + string metaPath = MetaFile.GetMetaPath(AssetAbsolutePath("Locked.png")); + Assert.True(File.Exists(metaPath)); + + using (File.Open(metaPath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + // Refuse outright rather than mint a replacement identity. + Assert.Throws( + () => MetaFile.EnsureMeta(AssetAbsolutePath("Locked.png"), nameof(TextureImporter))); + + // And a full refresh over the locked file has to skip it, not orphan it. + Assets.Refresh(); + } + + Assert.Equal(texGuid, MetaFile.Read(metaPath).Guid); + Assert.Equal(texGuid, Assets.PathToGuid("Locked.png")); + } + + // Duplicating an asset (file + .meta) gives two files one GUID. The copy must be the one re-minted; + // if the original is picked instead, every reference in the project silently retargets. + [Fact] + public void DuplicatedMetaFile_ReMintsTheCopyNotTheOriginal() + { + // Named so the copy sorts first, which is the order a directory walk hands them over in. + Guid originalGuid = MakeTexture("Original.png"); + string originalAbs = AssetAbsolutePath("Original.png"); + + File.Copy(originalAbs, AssetAbsolutePath("Copy.png")); + File.Copy(MetaFile.GetMetaPath(originalAbs), MetaFile.GetMetaPath(AssetAbsolutePath("Copy.png"))); + + // Simulate a fresh checkout: no metadata.db, so the scan has only the .meta files to go on. + File.Delete(Project.MetadataDbPath); + ReopenDatabase(); + + Assert.Equal(originalGuid, Assets.PathToGuid("Original.png")); + Assert.NotEqual(originalGuid, Assets.PathToGuid("Copy.png")); + Assert.NotEqual(Guid.Empty, Assets.PathToGuid("Copy.png")); + } + + // If the import cannot write its cache, the entry must not be recorded as freshly imported - + // it would leave the previous cache in place while claiming to be current, which is what a build ships. + [Fact] + public void FailedCacheWrite_LeavesTheAssetMarkedStale() + { + Guid texGuid = MakeTexture("Cached.png"); + string cachePath = Path.Combine(Project.CachePath, $"{texGuid}.asset"); + Assert.True(File.Exists(cachePath)); + + // Touch the source so the reimport has something new to write, then block the write. + string abs = AssetAbsolutePath("Cached.png"); + File.SetLastWriteTimeUtc(abs, DateTime.UtcNow.AddSeconds(5)); + + using (File.Open(cachePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + Assets.Reimport(texGuid); + } + + Assert.True(Assets.EnsureCacheUpToDate(texGuid), + "An import whose cache write failed must still be considered stale."); + } +} From 0e087d63cbfa41c84db448b52af65d8ec26e6939 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 00:53:13 +1000 Subject: [PATCH 06/67] Made EngineObject.OnDispose Protected --- Prowl.Runtime.Test/EngineObjectTests.cs | 2 +- Prowl.Runtime.Test/LifecycleTests.cs | 2 +- Prowl.Runtime/Components/GameCanvas.cs | 2 +- Prowl.Runtime/Components/LineRenderer.cs | 2 +- Prowl.Runtime/Components/SpriteRenderer.cs | 2 +- Prowl.Runtime/Components/TextMeshComponent.cs | 2 +- Prowl.Runtime/Components/UI/UIBehaviour.cs | 2 +- Prowl.Runtime/EngineObject.cs | 2 +- Prowl.Runtime/GameObject/GameObject.cs | 2 +- Prowl.Runtime/GameObject/MonoBehaviour.cs | 2 +- Prowl.Runtime/Resources/AudioClip.cs | 2 +- Prowl.Runtime/Resources/Cubemap.cs | 2 +- Prowl.Runtime/Resources/Mesh.cs | 2 +- Prowl.Runtime/Resources/RenderTexture.cs | 2 +- Prowl.Runtime/Resources/Scene.cs | 2 +- Prowl.Runtime/Resources/Shader.cs | 2 +- Prowl.Runtime/Resources/Texture.cs | 2 +- Samples/LifecycleTest/LifecycleComponent.cs | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Prowl.Runtime.Test/EngineObjectTests.cs b/Prowl.Runtime.Test/EngineObjectTests.cs index ebf9b64f2..26786f913 100644 --- a/Prowl.Runtime.Test/EngineObjectTests.cs +++ b/Prowl.Runtime.Test/EngineObjectTests.cs @@ -16,7 +16,7 @@ private sealed class TestEngineObject : EngineObject public int DisposeCount; public TestEngineObject() : base() { } public TestEngineObject(string name) : base(name) { } - public override void OnDispose() => DisposeCount++; + protected override void OnDispose() => DisposeCount++; } [Fact] diff --git a/Prowl.Runtime.Test/LifecycleTests.cs b/Prowl.Runtime.Test/LifecycleTests.cs index bf7c64458..1e5602a98 100644 --- a/Prowl.Runtime.Test/LifecycleTests.cs +++ b/Prowl.Runtime.Test/LifecycleTests.cs @@ -41,7 +41,7 @@ public override void Start() Events.Add("Start"); } - public override void OnDispose() + protected override void OnDispose() { Events.Add("OnDispose"); base.OnDispose(); diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 033eefcce..504b80f27 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -402,7 +402,7 @@ private static void EnsureBaked(UIBehaviour ui, in UIContext childCtx) { // Element produced no geometry (e.g. empty text): drop the old mesh, disposing its // GPU buffers rather than orphaning them. - if (ui.CachedMesh.IsValid()) ui.CachedMesh.OnDispose(); + if (ui.CachedMesh.IsValid()) ui.CachedMesh.Dispose(); ui.CachedMesh = null; } else diff --git a/Prowl.Runtime/Components/LineRenderer.cs b/Prowl.Runtime/Components/LineRenderer.cs index b11261adc..9346ad30c 100644 --- a/Prowl.Runtime/Components/LineRenderer.cs +++ b/Prowl.Runtime/Components/LineRenderer.cs @@ -167,7 +167,7 @@ public void SetPositions(Float3[] positions) public override void OnDisable() { // Clean up the mesh when disabled - if (_cachedMesh.IsValid()) _cachedMesh.OnDispose(); + if (_cachedMesh.IsValid()) _cachedMesh.Dispose(); _cachedMesh = null; } diff --git a/Prowl.Runtime/Components/SpriteRenderer.cs b/Prowl.Runtime/Components/SpriteRenderer.cs index dbbc7979c..3128b2f22 100644 --- a/Prowl.Runtime/Components/SpriteRenderer.cs +++ b/Prowl.Runtime/Components/SpriteRenderer.cs @@ -175,7 +175,7 @@ public override void DrawGizmosSelected() Debug.DrawLine(p - up, p + up, pivotColor); } - public override void OnDispose() + protected override void OnDispose() { if (_mesh.IsValid()) _mesh.Dispose(); _mesh = null; diff --git a/Prowl.Runtime/Components/TextMeshComponent.cs b/Prowl.Runtime/Components/TextMeshComponent.cs index 166000a5d..0e3b15cb2 100644 --- a/Prowl.Runtime/Components/TextMeshComponent.cs +++ b/Prowl.Runtime/Components/TextMeshComponent.cs @@ -125,7 +125,7 @@ private void SetField(ref T field, T value) public override void OnDisable() { - if (_mesh.IsValid()) _mesh.OnDispose(); + if (_mesh.IsValid()) _mesh.Dispose(); _mesh = null; _hasGeometry = false; _dirty = true; diff --git a/Prowl.Runtime/Components/UI/UIBehaviour.cs b/Prowl.Runtime/Components/UI/UIBehaviour.cs index 7a38f3f93..91ce37547 100644 --- a/Prowl.Runtime/Components/UI/UIBehaviour.cs +++ b/Prowl.Runtime/Components/UI/UIBehaviour.cs @@ -76,7 +76,7 @@ public override void OnRemovedFromScene() // Free the baked GPU buffers - the canvas will re-bake from scratch if this element // is ever re-added. Without this every created/destroyed UI element leaks its mesh. - if (CachedMesh.IsValid()) CachedMesh.OnDispose(); + if (CachedMesh.IsValid()) CachedMesh.Dispose(); CachedMesh = null; DirtyFlags |= UIDirtyFlags.Vertices; } diff --git a/Prowl.Runtime/EngineObject.cs b/Prowl.Runtime/EngineObject.cs index 0b3399663..ea29bb4dc 100644 --- a/Prowl.Runtime/EngineObject.cs +++ b/Prowl.Runtime/EngineObject.cs @@ -67,7 +67,7 @@ public void Dispose() public override bool Equals(object? obj) => this == (obj as EngineObject); public override int GetHashCode() => _instanceID; - public virtual void OnDispose() { } + protected virtual void OnDispose() { } /// /// Call at the top of any accessor a caller might reasonably use every frame (a texture's Width, diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 1cba0d1e5..10fb62659 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -993,7 +993,7 @@ internal bool IsComponentRequired(MonoBehaviour requiredComponent, out Type depe /// /// Disposes of the GameObject and its components. /// - public override void OnDispose() + protected override void OnDispose() { for (int i = Children.Count - 1; i >= 0; i--) Children[i].Dispose(); diff --git a/Prowl.Runtime/GameObject/MonoBehaviour.cs b/Prowl.Runtime/GameObject/MonoBehaviour.cs index 57a4872c7..8710a5894 100644 --- a/Prowl.Runtime/GameObject/MonoBehaviour.cs +++ b/Prowl.Runtime/GameObject/MonoBehaviour.cs @@ -493,7 +493,7 @@ public void OnAfterDeserialize() /// Called when the MonoBehaviour will be destroyed. /// This is an override of EngineObject.OnDispose() and is also exposed as a virtual lifecycle method. /// - public override void OnDispose() + protected override void OnDispose() { if (GameObject.IsValid()) GameObject.RemoveComponent(this); diff --git a/Prowl.Runtime/Resources/AudioClip.cs b/Prowl.Runtime/Resources/AudioClip.cs index 4a58c19c1..bdd87b5ff 100644 --- a/Prowl.Runtime/Resources/AudioClip.cs +++ b/Prowl.Runtime/Resources/AudioClip.cs @@ -146,7 +146,7 @@ public AudioClip(byte[] data, bool isUnique = false) } } - public override void OnDispose() + protected override void OnDispose() { AudioContext.Remove(this); } diff --git a/Prowl.Runtime/Resources/Cubemap.cs b/Prowl.Runtime/Resources/Cubemap.cs index 110a4f69c..7a432d000 100644 --- a/Prowl.Runtime/Resources/Cubemap.cs +++ b/Prowl.Runtime/Resources/Cubemap.cs @@ -172,7 +172,7 @@ public GraphicsFrameBuffer GetFaceTarget(int face, int mip, bool withDepth = fal return fb; } - public override void OnDispose() + protected override void OnDispose() { foreach (var fb in _faceTargets.Values) fb?.Dispose(); diff --git a/Prowl.Runtime/Resources/Mesh.cs b/Prowl.Runtime/Resources/Mesh.cs index 77b036305..f3c2af760 100644 --- a/Prowl.Runtime/Resources/Mesh.cs +++ b/Prowl.Runtime/Resources/Mesh.cs @@ -946,7 +946,7 @@ public bool Raycast(Ray ray) #endregion - public override void OnDispose() => DeleteGPUBuffers(); + protected override void OnDispose() => DeleteGPUBuffers(); ~Mesh() => Dispose(); diff --git a/Prowl.Runtime/Resources/RenderTexture.cs b/Prowl.Runtime/Resources/RenderTexture.cs index bb23a1dba..d0d2347f4 100644 --- a/Prowl.Runtime/Resources/RenderTexture.cs +++ b/Prowl.Runtime/Resources/RenderTexture.cs @@ -118,7 +118,7 @@ private void ReleaseResources() _frameBuffer = null; } - public override void OnDispose() => ReleaseResources(); + protected override void OnDispose() => ReleaseResources(); ~RenderTexture() => Dispose(); diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 878271a51..48cbd6464 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -619,7 +619,7 @@ public void Flush() obj.Scene = null; } - public override void OnDispose() + protected override void OnDispose() { base.OnDispose(); diff --git a/Prowl.Runtime/Resources/Shader.cs b/Prowl.Runtime/Resources/Shader.cs index 307b58553..4b3e543b7 100644 --- a/Prowl.Runtime/Resources/Shader.cs +++ b/Prowl.Runtime/Resources/Shader.cs @@ -218,7 +218,7 @@ public void OnAfterDeserialize() RegisterPass(_passes[i], i); } - public override void OnDispose() + protected override void OnDispose() { foreach (var pass in _passes) pass.Dispose(); diff --git a/Prowl.Runtime/Resources/Texture.cs b/Prowl.Runtime/Resources/Texture.cs index 2371701f2..159c40dd6 100644 --- a/Prowl.Runtime/Resources/Texture.cs +++ b/Prowl.Runtime/Resources/Texture.cs @@ -98,7 +98,7 @@ public void GenerateMipmaps() Graphics.SetTextureFilters(_handle, _isMipmapped ? DefaultMipmapMinFilter : DefaultMinFilter, DefaultMagFilter); } - public override void OnDispose() + protected override void OnDispose() { _handle.Dispose(); } diff --git a/Samples/LifecycleTest/LifecycleComponent.cs b/Samples/LifecycleTest/LifecycleComponent.cs index a53188026..b63be138f 100644 --- a/Samples/LifecycleTest/LifecycleComponent.cs +++ b/Samples/LifecycleTest/LifecycleComponent.cs @@ -47,7 +47,7 @@ public override void Update() } } - public override void OnDispose() + protected override void OnDispose() { Debug.Log($"[{ComponentName}] OnDispose - GameObject: {GameObject.Name}"); } From 8a05c202b92553b8633cb04aa6d253dd11acd0d2 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 01:18:32 +1000 Subject: [PATCH 07/67] Add end-of-frame EngineObject destruction --- Prowl.Runtime/EngineObject.cs | 37 +++++++++++++++++++++++++++++++++ Prowl.Runtime/Game.cs | 39 ++++++++++++++++++++++------------- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/Prowl.Runtime/EngineObject.cs b/Prowl.Runtime/EngineObject.cs index ea29bb4dc..7e443d9de 100644 --- a/Prowl.Runtime/EngineObject.cs +++ b/Prowl.Runtime/EngineObject.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See the LICENSE file in the project root for details. using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; @@ -58,6 +59,42 @@ public void Dispose() OnDispose(); } + private static readonly List s_destroyQueue = []; + + /// + /// Queues this object to be disposed at the end of the frame, once every callback has finished. + /// It stays fully usable until then, so anything still holding it this frame keeps working, and + /// teardown never lands in the middle of an Update, a render or a physics callback. + /// + /// A destroyed GameObject still ticks and still collides for the rest of the frame. Set + /// Enabled = false alongside this if that matters, or call to tear + /// down right now and deal with the consequences. + /// + public void Destroy() + { + if (IsDisposed) return; + lock (s_destroyQueue) s_destroyQueue.Add(this); + } + + internal static void ProcessDestroyed() + { + EngineObject[] queued; + lock (s_destroyQueue) + { + if (s_destroyQueue.Count == 0) return; + queued = [.. s_destroyQueue]; + s_destroyQueue.Clear(); + } + + foreach (EngineObject obj in queued) + { + if (obj.IsDisposed) continue; // disposed by hand, or by an owner that went first + + try { obj.Dispose(); } + catch (Exception ex) { Debug.LogError($"[{obj.Name}/{obj.GetType().Name}] Dispose() threw while being destroyed: {ex.Message}\n{ex.StackTrace}"); } + } + } + public static bool operator ==(EngineObject left, EngineObject right) { diff --git a/Prowl.Runtime/Game.cs b/Prowl.Runtime/Game.cs index b15cbf5c1..a79cdfd6c 100644 --- a/Prowl.Runtime/Game.cs +++ b/Prowl.Runtime/Game.cs @@ -46,7 +46,7 @@ public abstract class Game public virtual void InitializeWindow(string title, int width, int height) { Window.InitWindow(title, width, height, Silk.NET.Windowing.WindowState.Normal, false); - } + } public void Run(string title, int width, int height) { @@ -173,19 +173,6 @@ public void Run(string title, int width, int height) _paper.EndFrame(); - // === End Graphics === - - RenderTexture.UpdatePool(); - // Dispose any GPU resources that were replaced mid-frame (e.g. - // grown instance buffers). This only ENQUEUES delete CBs; the render - // thread is still draining this frame's queue. Because the deletes are - // submitted after every draw that referenced the old handle, submit - // order guarantees they execute last on the render thread. - Graphics.FlushDeferredDisposes(); - - // === End of End Graphics === - - Debug.ClearGizmos(); } catch (Exception e) { @@ -195,6 +182,27 @@ public void Run(string title, int width, int height) } }; + Window.PostRender += (delta) => + { + // === End Graphics === + + RenderTexture.UpdatePool(); + // Dispose any GPU resources that were replaced mid-frame (e.g. + // grown instance buffers). This only ENQUEUES delete CBs; the render + // thread is still draining this frame's queue. Because the deletes are + // submitted after every draw that referenced the old handle, submit + // order guarantees they execute last on the render thread. + Graphics.FlushDeferredDisposes(); + + Debug.ClearGizmos(); + + // === End of End Graphics === + + // Last thing in the frame: everything Destroy()ed stayed usable right through + // update, render and GUI, and is torn down here where nothing is mid-callback. + EngineObject.ProcessDestroyed(); + }; + Window.Resize += (size) => { // Paper's resolution is resynced from PreparePaperFrame each render frame. @@ -272,6 +280,9 @@ public void RunHeadless(HeadlessRunOptions? options = null) SimulationStep(Time.DeltaTime); EndUpdate(); + // No render phase here, so the end of the simulation step is the end of the frame. + EngineObject.ProcessDestroyed(); + frame++; if (options.MaxFrames > 0 && frame >= options.MaxFrames) break; if (options.MaxSeconds > 0 && runClock.Elapsed.TotalSeconds >= options.MaxSeconds) break; From 078f11be9dfc40744a44aece2e195fccae52b9ae Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 01:32:49 +1000 Subject: [PATCH 08/67] EngineObject.Destroy() Defer dispose to end of frame, Migrated Undo/Redo --- Prowl.Editor/Core/Undo.cs | 4 +- Prowl.Editor/GUI/Panels/HierarchyPanel.cs | 2 +- .../Resources/NewTimedDestroy.cstemplate | 2 +- Prowl.Editor/Utils/PrefabUtility.cs | 8 +-- Prowl.Runtime.Test/EngineObjectTests.cs | 64 +++++++++++++++++++ Prowl.Runtime/EngineObject.cs | 8 ++- Prowl.Runtime/Game.cs | 40 ++++++------ Prowl.Runtime/GameObject/GameObject.cs | 6 +- Prowl.Runtime/Resources/Scene.cs | 4 +- Samples/PhysicsCubes/Program.cs | 2 +- 10 files changed, 102 insertions(+), 38 deletions(-) diff --git a/Prowl.Editor/Core/Undo.cs b/Prowl.Editor/Core/Undo.cs index 5dbfea4a7..088775a47 100644 --- a/Prowl.Editor/Core/Undo.cs +++ b/Prowl.Editor/Core/Undo.cs @@ -353,7 +353,7 @@ public static (Action undo, Action redo) CaptureCreatedObject(GameObject go) Selection.Clear(); scene.Remove(target); - target.Dispose(); + target.Destroy(); // TODO: Should this be Dispose... or Destroy? Destroy defers it to end of frame? }, redo: () => { @@ -432,7 +432,7 @@ public static void RegisterDestroyObject(GameObject go, string description) Selection.Clear(); scene.Remove(target); - target.Dispose(); + target.Destroy(); // TODO: Should this be Dispose... or Destroy? Destroy defers it to end of frame? EditorSceneManager.MarkDirty(); }); } diff --git a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs index 29cd67968..6622d3527 100644 --- a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs +++ b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs @@ -1104,7 +1104,7 @@ internal static void DeleteGameObject(GameObject go) Selection.RemoveFromSelection(go); scene.Remove(go); - go.Dispose(); + go.Destroy(); // TODO should this be Destroy (deferred) or Dispose? } // ================================================================ diff --git a/Prowl.Editor/Resources/NewTimedDestroy.cstemplate b/Prowl.Editor/Resources/NewTimedDestroy.cstemplate index da269c034..ef9f854db 100644 --- a/Prowl.Editor/Resources/NewTimedDestroy.cstemplate +++ b/Prowl.Editor/Resources/NewTimedDestroy.cstemplate @@ -12,7 +12,7 @@ public class {[className]} : MonoBehaviour if (_elapsed >= Lifetime) { Scene?.Remove(GameObject); - GameObject.Dispose(); + GameObject.Destroy(); } } } diff --git a/Prowl.Editor/Utils/PrefabUtility.cs b/Prowl.Editor/Utils/PrefabUtility.cs index 18fcfa6e6..dddfd7a6c 100644 --- a/Prowl.Editor/Utils/PrefabUtility.cs +++ b/Prowl.Editor/Utils/PrefabUtility.cs @@ -216,7 +216,7 @@ public static void RevertOverrides(GameObject instanceRoot) if (scene != null) { scene.Remove(instanceRoot); - instanceRoot.Dispose(); + instanceRoot.Destroy(); // TODO should this be Destroy (deferred) or Dispose? scene.Add(fresh); if (parent != null) { @@ -245,7 +245,7 @@ public static void RevertOverrides(GameObject instanceRoot) var p = current.Parent; s.Remove(current); - current.Dispose(); + current.Destroy(); // TODO should this be Destroy (deferred) or Dispose? s.Add(restored); if (p != null) restored.SetParent(p); Selection.Select(restored); @@ -270,7 +270,7 @@ public static void RevertOverrides(GameObject instanceRoot) f2.Name = oldGo.Name; var p2 = oldGo.Parent; s.Remove(oldGo); - oldGo.Dispose(); + oldGo.Destroy(); // TODO should this be Destroy (deferred) or Dispose? s.Add(f2); if (p2 != null) f2.SetParent(p2); Selection.Select(f2); @@ -527,7 +527,7 @@ public static void RefreshAllInstances(Guid prefabGuid) fresh.Transform.LocalScale = scale; scene.Remove(root); - root.Dispose(); + root.Destroy(); // TODO should this be Destroy (deferred) or Dispose? scene.Add(fresh); if (parent != null) { diff --git a/Prowl.Runtime.Test/EngineObjectTests.cs b/Prowl.Runtime.Test/EngineObjectTests.cs index 26786f913..e292ea97e 100644 --- a/Prowl.Runtime.Test/EngineObjectTests.cs +++ b/Prowl.Runtime.Test/EngineObjectTests.cs @@ -134,4 +134,68 @@ public void Defaults_AssetIdEmpty_AssetPathEmpty() Assert.Equal(Guid.Empty, obj.AssetID); Assert.Equal(string.Empty, obj.AssetPath); } + + // ---- Destroy ---- + + [Fact] + public void Destroy_KeepsObjectUsableUntilProcessed() + { + var obj = new TestEngineObject(); + + obj.Destroy(); + + Assert.False(obj.IsDisposed); + Assert.True(obj.IsValid()); + + EngineObject.ProcessDestroyed(); + + Assert.True(obj.IsDisposed); + Assert.Equal(1, obj.DisposeCount); + } + + [Fact] + public void Destroy_TwiceDisposesOnce() + { + var obj = new TestEngineObject(); + + obj.Destroy(); + obj.Destroy(); + EngineObject.ProcessDestroyed(); + + Assert.Equal(1, obj.DisposeCount); + } + + [Fact] + public void Destroy_SkipsAnObjectAlreadyDisposedByHand() + { + var obj = new TestEngineObject(); + + obj.Destroy(); + obj.Dispose(); + EngineObject.ProcessDestroyed(); + + Assert.Equal(1, obj.DisposeCount); + } + + [Fact] + public void Destroy_FromWithinDisposeWaitsForTheNextProcess() + { + var second = new TestEngineObject(); + var first = new DestroyOnDispose(second); + + first.Destroy(); + EngineObject.ProcessDestroyed(); + + Assert.True(first.IsDisposed); + Assert.False(second.IsDisposed); // queued during the drain, so it goes to the next frame + + EngineObject.ProcessDestroyed(); + + Assert.True(second.IsDisposed); + } + + private sealed class DestroyOnDispose(EngineObject other) : EngineObject + { + protected override void OnDispose() => other.Destroy(); + } } diff --git a/Prowl.Runtime/EngineObject.cs b/Prowl.Runtime/EngineObject.cs index 7e443d9de..153053f04 100644 --- a/Prowl.Runtime/EngineObject.cs +++ b/Prowl.Runtime/EngineObject.cs @@ -65,7 +65,7 @@ public void Dispose() /// Queues this object to be disposed at the end of the frame, once every callback has finished. /// It stays fully usable until then, so anything still holding it this frame keeps working, and /// teardown never lands in the middle of an Update, a render or a physics callback. - /// + /// /// A destroyed GameObject still ticks and still collides for the rest of the frame. Set /// Enabled = false alongside this if that matters, or call to tear /// down right now and deal with the consequences. @@ -76,7 +76,11 @@ public void Destroy() lock (s_destroyQueue) s_destroyQueue.Add(this); } - internal static void ProcessDestroyed() + /// + /// Disposes everything queued. Driven once per frame by the game loop, + /// after rendering. Anything queued while this runs waits for the next frame. + /// + public static void ProcessDestroyed() { EngineObject[] queued; lock (s_destroyQueue) diff --git a/Prowl.Runtime/Game.cs b/Prowl.Runtime/Game.cs index a79cdfd6c..b82f13d71 100644 --- a/Prowl.Runtime/Game.cs +++ b/Prowl.Runtime/Game.cs @@ -46,7 +46,7 @@ public abstract class Game public virtual void InitializeWindow(string title, int width, int height) { Window.InitWindow(title, width, height, Silk.NET.Windowing.WindowState.Normal, false); - } + } public void Run(string title, int width, int height) { @@ -173,6 +173,23 @@ public void Run(string title, int width, int height) _paper.EndFrame(); + // === End Graphics === + + RenderTexture.UpdatePool(); + // Dispose any GPU resources that were replaced mid-frame (e.g. + // grown instance buffers). This only ENQUEUES delete CBs; the render + // thread is still draining this frame's queue. Because the deletes are + // submitted after every draw that referenced the old handle, submit + // order guarantees they execute last on the render thread. + Graphics.FlushDeferredDisposes(); + + // === End of End Graphics === + + Debug.ClearGizmos(); + + // Last thing in the frame: everything Destroy()ed stayed usable right through + // update, render and GUI, and is torn down here where nothing is mid-callback. + EngineObject.ProcessDestroyed(); } catch (Exception e) { @@ -182,27 +199,6 @@ public void Run(string title, int width, int height) } }; - Window.PostRender += (delta) => - { - // === End Graphics === - - RenderTexture.UpdatePool(); - // Dispose any GPU resources that were replaced mid-frame (e.g. - // grown instance buffers). This only ENQUEUES delete CBs; the render - // thread is still draining this frame's queue. Because the deletes are - // submitted after every draw that referenced the old handle, submit - // order guarantees they execute last on the render thread. - Graphics.FlushDeferredDisposes(); - - Debug.ClearGizmos(); - - // === End of End Graphics === - - // Last thing in the frame: everything Destroy()ed stayed usable right through - // update, render and GUI, and is torn down here where nothing is mid-callback. - EngineObject.ProcessDestroyed(); - }; - Window.Resize += (size) => { // Paper's resolution is resynced from PreparePaperFrame each render frame. diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 10fb62659..934d18817 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -640,7 +640,7 @@ public void RemoveAll() where T : MonoBehaviour foreach (MonoBehaviour c in componentList) { if (c.HasBeenEnabled) // OnDispose is only called if OnEnable was previously called - c.Dispose(); + c.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable _components.Remove(c); } @@ -665,7 +665,7 @@ public void RemoveComponent(T component) where T : MonoBehaviour if (component.HasBeenEnabled) { if (component.EnabledInHierarchy) component.InternalOnDisable(); - component.Dispose(); + component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable } } @@ -685,7 +685,7 @@ public void RemoveComponent(MonoBehaviour component) if (component.HasBeenEnabled) { if (component.EnabledInHierarchy) component.InternalOnDisable(); - component.Dispose(); + component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable } } } diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 48cbd6464..7ad4173a3 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -41,7 +41,7 @@ public static void Load(Scene scene) { if (Current.IsActive) Current.Disable(); - Current.Dispose(); + Current.Destroy(); // Will call Dispose at end of frame not immediately so the scene technically is still usable } Current = scene; @@ -67,7 +67,7 @@ public static void Unload() { if (Current.IsActive) Current.Disable(); - Current.Dispose(); + Current.Destroy(); // Will call Dispose at end of frame not immediately so the scene technically is still usable Current = null; } } diff --git a/Samples/PhysicsCubes/Program.cs b/Samples/PhysicsCubes/Program.cs index 16b83db62..69d4c3cbd 100644 --- a/Samples/PhysicsCubes/Program.cs +++ b/Samples/PhysicsCubes/Program.cs @@ -506,7 +506,7 @@ public override void EndUpdate() if (Input.GetKeyDown(KeyCode.X) && lastShot.IsValid()) { - lastShot.Dispose(); + lastShot.Destroy(); } // Weight selection with number keys From 74a9c50dbf0fac8837309bdfbe3f055f9cfbbe39 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 01:33:18 +1000 Subject: [PATCH 09/67] Added Cursor shapes for Scene Editors and Gizmo's --- Prowl.Editor/GUI/Panels/SceneViewPanel.cs | 11 +++++++-- .../Editors/LightProbeGroupSceneEditor.cs | 1 + Prowl.Editor/GUI/SceneView/HandleContext.cs | 24 +++++++++++++++++++ Prowl.Editor/GUI/SceneView/SceneTool.cs | 4 ++++ Prowl.Editor/GUI/SceneView/UISceneEditor.cs | 17 +++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs index d4ca17bba..588da88ba 100644 --- a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs +++ b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs @@ -261,8 +261,11 @@ private void DrawViewport(Paper paper, Scribe.FontFile font, float width, float // Depth 0: the view cube is a screen-space overlay drawn on top of the scene, so it // wins every overlap rather than competing on scene depth. if (_viewCubeRadius > 0f) - _handles.AddControl(_handles.GetControlID(ViewCubeControl), - _handles.DistanceToScreenPoint(_viewCubeCenter, _viewCubeRadius), 0f); + { + ControlID viewCube = _handles.GetControlID(ViewCubeControl); + _handles.AddControl(viewCube, _handles.DistanceToScreenPoint(_viewCubeCenter, _viewCubeRadius), 0f); + _handles.RequestCursor(viewCube, PaperCursor.Pointer); + } UpdatePickControl(scene, new Float2(width, height)); _handles.EndFrame(); @@ -286,6 +289,9 @@ private void DrawViewport(Paper paper, Scribe.FontFile font, float width, float { paper.Box("sv_viewport") .Size(width, height) + // Handles ask for a shape while they own the cursor; Paper resolves it from the + // hovered element exactly as it does for any UI widget. + .Cursor(_handles.Cursor) .OnPostLayout((handle, rect) => { // Cache absolute rect for gizmo coordinate space @@ -893,6 +899,7 @@ private void UpdateTransformGizmo() // and the gizmo's own depth decides overlaps against handles stacked on top of it. bool gizmoHovered = _transformGizmo.IsOver && !_handles.Blocked; _handles.AddControl(control, gizmoHovered ? 0f : float.MaxValue, _handles.DepthOf(center)); + _handles.RequestCursor(control, PaperCursor.ResizeAll); // Gizmo drawing happens in the viewport's DrawForeground callback (needs canvas) diff --git a/Prowl.Editor/GUI/SceneView/Editors/LightProbeGroupSceneEditor.cs b/Prowl.Editor/GUI/SceneView/Editors/LightProbeGroupSceneEditor.cs index 70adf90b5..f618eb994 100644 --- a/Prowl.Editor/GUI/SceneView/Editors/LightProbeGroupSceneEditor.cs +++ b/Prowl.Editor/GUI/SceneView/Editors/LightProbeGroupSceneEditor.cs @@ -95,6 +95,7 @@ public override void OnSceneInput(SceneToolContext toolCtx) Float3 world = Float4x4.TransformPoint(_group.ProbePositions[i], l2w); // World overload carries depth, so stacked probes resolve to the nearest one. ctx.AddControl(id, world, ProbePickRadius); + ctx.RequestCursor(id, PaperUI.PaperCursor.Grab); if (ctx.IsNearest(id)) { hitId = id; hitIndex = i; } } diff --git a/Prowl.Editor/GUI/SceneView/HandleContext.cs b/Prowl.Editor/GUI/SceneView/HandleContext.cs index 82b2e10e2..9fe45e1a2 100644 --- a/Prowl.Editor/GUI/SceneView/HandleContext.cs +++ b/Prowl.Editor/GUI/SceneView/HandleContext.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using Prowl.Editor.Core; +using Prowl.PaperUI; using Prowl.Runtime; using Prowl.Vector; @@ -102,6 +103,28 @@ public sealed class HandleContext /// Key-held that yields to text editing. See . public bool GetKey(KeyCode key) => !KeyboardCaptured && Input.GetKey(key); + /// + /// Cursor shape requested by handles this frame, or when none + /// asked. The viewport applies it to its own element, so Paper resolves it exactly like any UI + /// hover cursor rather than fighting it. + /// + public PaperCursor Cursor { get; private set; } + + /// + /// Ask for a cursor shape while this control owns the cursor. Ignored unless the control is + /// active (hovered with nothing dragging, or itself dragging), so a handle can call it + /// unconditionally and only the one the user is actually on gets to change the pointer. + /// + public void RequestCursor(ControlID id, PaperCursor shape) + { + if (shape == PaperCursor.Inherit || !IsActive(id)) return; + + // A drag in progress outranks a mere hover, so the shape does not flicker when the cursor + // strays over a different handle mid-drag. + if (Cursor == PaperCursor.Inherit || IsHot(id)) + Cursor = shape; + } + // ================================================================ // Lifecycle // ================================================================ @@ -137,6 +160,7 @@ public void BeginFrame(Camera camera, Rect viewportAbsolute, Float2 mouseLocal, Alt = Input.IsAltPressed; Blocked = Input.IsAltPressed || Input.GetMouseButton(1) || Input.GetMouseButton(2); KeyboardCaptured = EditorApplication.Instance?.PaperInstance?.WantsCaptureKeyboard == true; + Cursor = PaperCursor.Inherit; // A drag whose owner never saw the release (viewport lost focus mid-drag) would otherwise // hold Hot forever. diff --git a/Prowl.Editor/GUI/SceneView/SceneTool.cs b/Prowl.Editor/GUI/SceneView/SceneTool.cs index a6080ee4e..fecd83915 100644 --- a/Prowl.Editor/GUI/SceneView/SceneTool.cs +++ b/Prowl.Editor/GUI/SceneView/SceneTool.cs @@ -136,6 +136,10 @@ protected void DrawWireCube(Float3 center, Float3 halfExtents, Color32 color, fl protected void DrawHandleDot(Float3 center, Color32 color, float sizePixels = 7f, HandleCap cap = HandleCap.Square) => Draw.Dot(center, color, sizePixels, cap); + /// Ask for a cursor shape while owns the cursor. Safe to call + /// unconditionally; only the control the user is actually on changes the pointer. + protected void SetCursor(ControlID id, PaperCursor shape) => Handles.RequestCursor(id, shape); + /// Project a world point to viewport pixels; null when behind the camera. protected Float2? WorldToScreen(Float3 world) => Handles.WorldToScreen(world); diff --git a/Prowl.Editor/GUI/SceneView/UISceneEditor.cs b/Prowl.Editor/GUI/SceneView/UISceneEditor.cs index 504cc589a..330265f88 100644 --- a/Prowl.Editor/GUI/SceneView/UISceneEditor.cs +++ b/Prowl.Editor/GUI/SceneView/UISceneEditor.cs @@ -313,6 +313,7 @@ void Register(Handle h, float distance, float depth) { ControlID id = ctx.GetControlID(HandleControl, (int)h); ctx.AddControl(id, distance, depth); + ctx.RequestCursor(id, CursorFor(h)); if (ctx.IsNearest(id)) { nearest = h; nearestControl = id; } } @@ -361,6 +362,22 @@ void Point(Handle h, Float2 designPos) return nearest; } + /// The pointer shape that matches what a handle actually does. Corner and edge handles + /// use the resize arrow along their own axis; the rect body and the anchors move things. + private static PaperCursor CursorFor(Handle h) => h switch + { + Handle.ResizeL or Handle.ResizeR => PaperCursor.ResizeHorizontal, + Handle.ResizeB or Handle.ResizeT => PaperCursor.ResizeVertical, + // Screen Y grows downward while the rect's Y grows upward, so the diagonals are swapped + // relative to the naive reading of the corner names. + Handle.ResizeTL or Handle.ResizeBR => PaperCursor.ResizeNESW, + Handle.ResizeTR or Handle.ResizeBL => PaperCursor.ResizeNWSE, + Handle.Pivot => PaperCursor.Crosshair, + Handle.AnchorBL or Handle.AnchorBR or Handle.AnchorTR or Handle.AnchorTL => PaperCursor.Grab, + Handle.Move => PaperCursor.ResizeAll, + _ => PaperCursor.Inherit, + }; + // ================================================================ // Drag application // ================================================================ From 3d18d35824b38eefb339606a8b609b3332d9ee20 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 02:25:45 +1000 Subject: [PATCH 10/67] Refactor Scene API --- Prowl.Editor.Test/ComponentClipboardTests.cs | 3 + Prowl.Editor.Test/EditorTestHarness.cs | 3 +- Prowl.Editor.Test/PrefabEdgeCaseTests.cs | 1 + Prowl.Editor.Test/PrefabOverrideTests.cs | 1 + Prowl.Editor.Test/UndoTests.cs | 5 + Prowl.Editor/Core/EditorApplication.cs | 15 +-- .../GUI/SceneView/EditorSceneManager.cs | 6 +- .../GUI/SceneView/PrefabEditingMode.cs | 10 +- Prowl.Runtime.Test/HeadlessRunTests.cs | 2 +- Prowl.Runtime.Test/LifecycleTests.cs | 4 +- Prowl.Runtime.Test/RuntimeTestBase.cs | 7 ++ Prowl.Runtime.Test/SceneManagementTests.cs | 95 ++++++++++++++---- Prowl.Runtime/Game.cs | 14 ++- Prowl.Runtime/GameObject/GameObject.cs | 5 + Prowl.Runtime/GameObject/MonoBehaviour.cs | 7 ++ Prowl.Runtime/Resources/Scene.cs | 98 +++++++++++++------ 16 files changed, 202 insertions(+), 74 deletions(-) diff --git a/Prowl.Editor.Test/ComponentClipboardTests.cs b/Prowl.Editor.Test/ComponentClipboardTests.cs index 3c075b6a3..e13496cc1 100644 --- a/Prowl.Editor.Test/ComponentClipboardTests.cs +++ b/Prowl.Editor.Test/ComponentClipboardTests.cs @@ -124,6 +124,7 @@ private static Scene MakeScene(out GameObject a, out GameObject b) scene.Add(a); scene.Add(b); Scene.Load(scene); + Scene.ProcessPendingLoad(); return scene; } @@ -359,6 +360,7 @@ public void SceneReferences_PastedIntoDifferentScene_AreNull() var host = new GameObject("Host"); other.Add(host); Scene.Load(other); + Scene.ProcessPendingLoad(); var pasted = ComponentClipboard.PasteAsNew(host) as ClipRefComp; @@ -385,6 +387,7 @@ public void SceneReferences_SurviveSceneSerializationRoundTrip() var echo = Echo.Serializer.Serialize(scene); var restored = Echo.Serializer.Deserialize(echo)!; Scene.Load(restored); + Scene.ProcessPendingLoad(); var restoredA = restored.AllObjects.First(g => g.Identifier == originalId); var host = new GameObject("Host"); diff --git a/Prowl.Editor.Test/EditorTestHarness.cs b/Prowl.Editor.Test/EditorTestHarness.cs index 322f9cafc..a470a8075 100644 --- a/Prowl.Editor.Test/EditorTestHarness.cs +++ b/Prowl.Editor.Test/EditorTestHarness.cs @@ -324,7 +324,8 @@ protected static (int exit, string stdout, string stderr) RunDotnet(string args, public virtual void Dispose() { - try { if (Scene.Current != null) Scene.Unload(); } catch { } + // Drop this test's scene so the next one starts from a fresh empty Scene.Current. + try { Scene.Current.Dispose(); } catch { } Assets.Dispose(); // stops the FileSystemWatcher and clears AssetDatabase.Current / Instance Project.CloseCurrent(); diff --git a/Prowl.Editor.Test/PrefabEdgeCaseTests.cs b/Prowl.Editor.Test/PrefabEdgeCaseTests.cs index cae6abc84..296ea7492 100644 --- a/Prowl.Editor.Test/PrefabEdgeCaseTests.cs +++ b/Prowl.Editor.Test/PrefabEdgeCaseTests.cs @@ -37,6 +37,7 @@ private void SetSceneCurrent(params GameObject[] instances) var scene = new Scene(); foreach (var i in instances) scene.Add(i); Scene.Load(scene); + Scene.ProcessPendingLoad(); } private void RewritePrefab(string path, GameObject newSource) diff --git a/Prowl.Editor.Test/PrefabOverrideTests.cs b/Prowl.Editor.Test/PrefabOverrideTests.cs index 2402562f2..c8ca23ae1 100644 --- a/Prowl.Editor.Test/PrefabOverrideTests.cs +++ b/Prowl.Editor.Test/PrefabOverrideTests.cs @@ -39,6 +39,7 @@ private void SetSceneCurrent(GameObject instance) var scene = new Scene(); scene.Add(instance); Scene.Load(scene); + Scene.ProcessPendingLoad(); } // --------------------------------------------------------------------- diff --git a/Prowl.Editor.Test/UndoTests.cs b/Prowl.Editor.Test/UndoTests.cs index 47aaeffa8..bd944d026 100644 --- a/Prowl.Editor.Test/UndoTests.cs +++ b/Prowl.Editor.Test/UndoTests.cs @@ -35,6 +35,7 @@ public class UndoTests : EditorTestHarness var comp = go.AddComponent(); scene.Add(go); Scene.Load(scene); + Scene.ProcessPendingLoad(); return (scene, go, comp); } @@ -155,6 +156,7 @@ public void RegisterCreatedObject_Undo_Destroys_Redo_Recreates() { var scene = new Scene(); Scene.Load(scene); + Scene.ProcessPendingLoad(); Undo.Clear(); var go = new GameObject("Created"); @@ -204,6 +206,7 @@ public void RegisterDestroyObject_Undo_RestoresChildren() { var scene = new Scene(); Scene.Load(scene); + Scene.ProcessPendingLoad(); Undo.Clear(); var parent = new GameObject("Parent"); @@ -469,6 +472,7 @@ public void ApplyGameObjectChanges_AppliesToAll_AsOneUndoStep() var b = new GameObject("B"); scene.Add(a); scene.Add(b); Scene.Load(scene); + Scene.ProcessPendingLoad(); Undo.Clear(); Undo.ApplyGameObjectChanges(new[] { a, b }, "Rename", g => g.Name, (g, v) => g.Name = v, "Renamed"); @@ -511,6 +515,7 @@ public void Continuous_MultipleTargets_RestoredTogether() var b = new GameObject("B"); scene.Add(a); scene.Add(b); Scene.Load(scene); + Scene.ProcessPendingLoad(); Undo.Clear(); a.Transform.LocalPosition = Float3.Zero; b.Transform.LocalPosition = Float3.Zero; diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index 4c7987baa..1a5063665 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -489,10 +489,6 @@ public override void BeginGui(Paper paper) } } - // Ensure a scene always exists - if (Project.Current != null && Runtime.Resources.Scene.Current == null) - EditorSceneManager.EnsureSceneLoaded(); - // Editor backdrop (behind the translucent glass panels) shared with the launcher. _nebula ??= new GUI.NebulaBackground(paper); GUI.NebulaBackground.DrawEditorBackground(paper, _nebula, "nebula_bg", w, h, (float)Time.UnscaledDeltaTime); @@ -1553,10 +1549,8 @@ private void EnterPlayMode() // Clear selection (references will be invalid) Selection.Clear(); - // Unload the editor scene - Runtime.Resources.Scene.Unload(); - - // Deserialize a fresh play copy + // Deserialize a fresh play copy. Loading it is what disposes the editor scene, at the end of + // the frame, so a failure here leaves the editor scene loaded and the editor usable. var playCtx = Importers.ImportHelper.CreateTrackingContext(out _); var playScene = Echo.Serializer.Deserialize(_savedEditorScene, playCtx); if (playScene == null) @@ -1604,10 +1598,7 @@ private void ExitPlayMode() // Clear selection (play scene references) Selection.Clear(); - // Unload the play scene - Runtime.Resources.Scene.Unload(); - - // Restore the editor scene WITHOUT lifecycle callbacks + // Restore the editor scene. Loading it is what disposes the play scene, at the end of the frame. if (_savedEditorScene != null) { var ctx = Importers.ImportHelper.CreateTrackingContext(out _); diff --git a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs index 90d55413c..52a41d4cb 100644 --- a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs +++ b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs @@ -116,13 +116,11 @@ public static bool SaveAs(string relativePath) } /// - /// Ensure a scene is loaded. If Scene.Current is null, restore the last scene or create a default. - /// Called after project open. + /// Opens the project's last scene, or creates a default one. Called after project open, where the + /// engine's own scene is the empty placeholder nobody has edited yet. /// public static void EnsureSceneLoaded() { - if (Scene.Current != null) return; - // Try to restore last scene if (EditorRegistries.SettingsEntries.Count > 0) { diff --git a/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs b/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs index c26ed893f..a16921438 100644 --- a/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs +++ b/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs @@ -176,8 +176,14 @@ public static void SaveAndExit() // Restore original scene RestoreScene(); - // Now refresh instances in the restored scene with the updated prefab - PrefabUtility.RefreshAllInstances(prefabGuid); + // The restore only queues the swap, so refresh instances once that scene is actually current. + Action? onLoaded = null; + onLoaded = () => + { + Scene.OnSceneLoaded -= onLoaded; + PrefabUtility.RefreshAllInstances(prefabGuid); + }; + Scene.OnSceneLoaded += onLoaded; Cleanup(); Debug.Log("[Prefab] Saved and exited editing mode."); diff --git a/Prowl.Runtime.Test/HeadlessRunTests.cs b/Prowl.Runtime.Test/HeadlessRunTests.cs index 5c619f519..5ac77b8a7 100644 --- a/Prowl.Runtime.Test/HeadlessRunTests.cs +++ b/Prowl.Runtime.Test/HeadlessRunTests.cs @@ -43,7 +43,7 @@ public void RunHeadless_RunsRequestedFrames_ThenExits() Assert.Equal(10, game.UpdateCount); Assert.False(Application.IsHeadless); // reset on exit - Assert.Null(Scene.Current); // scene unloaded on exit + Assert.True(scene.IsDisposed); // the loaded scene is torn down on exit } finally { diff --git a/Prowl.Runtime.Test/LifecycleTests.cs b/Prowl.Runtime.Test/LifecycleTests.cs index 1e5602a98..540bfaa5a 100644 --- a/Prowl.Runtime.Test/LifecycleTests.cs +++ b/Prowl.Runtime.Test/LifecycleTests.cs @@ -379,8 +379,10 @@ public void RemovingComponent_CallsOnDisableAndOnDispose() comp.ClearEvents(); go.RemoveComponent(comp); - Assert.Contains("OnDisable", comp.Events); + + // The component is only disposed once the frame's destroy queue is drained. + EngineObject.ProcessDestroyed(); Assert.Contains("OnDispose", comp.Events); } diff --git a/Prowl.Runtime.Test/RuntimeTestBase.cs b/Prowl.Runtime.Test/RuntimeTestBase.cs index db6635ac1..1bcdf0956 100644 --- a/Prowl.Runtime.Test/RuntimeTestBase.cs +++ b/Prowl.Runtime.Test/RuntimeTestBase.cs @@ -80,6 +80,7 @@ protected void Tick(Scene scene, int steps = 1) { scene.FixedUpdate(); scene.Update(); + EngineObject.ProcessDestroyed(); } } @@ -87,14 +88,20 @@ protected void Tick(Scene scene, int steps = 1) protected void Update(Scene scene, int frames = 1) { for (int i = 0; i < frames; i++) + { scene.Update(); + EngineObject.ProcessDestroyed(); + } } /// Steps physics via the given number of times. protected void StepPhysics(Scene scene, int steps = 1) { for (int i = 0; i < steps; i++) + { scene.FixedUpdate(); + EngineObject.ProcessDestroyed(); + } } /// Coarse voxels and small tiles, so navmesh bakes in tests stay fast. diff --git a/Prowl.Runtime.Test/SceneManagementTests.cs b/Prowl.Runtime.Test/SceneManagementTests.cs index 98abe4047..875aa4ff5 100644 --- a/Prowl.Runtime.Test/SceneManagementTests.cs +++ b/Prowl.Runtime.Test/SceneManagementTests.cs @@ -267,6 +267,28 @@ public void FindObjectByIdentifier_FindsGameObjectAndComponent() } // ---- Static scene manager ---- + // + // Load only queues. The swap lands at the end of the frame, which the game loop drives and these + // tests drive by hand. There is no unload: there is always a current scene. + + [Fact] + public void Current_IsNeverNull() + { + Assert.NotNull(Scene.Current); + Assert.False(Scene.Current.IsDisposed); + } + + [Fact] + public void Current_RebuildsAfterTheCurrentSceneIsDisposed() + { + Scene first = Scene.Current; + first.Dispose(); + + Scene second = Scene.Current; + + Assert.NotSame(first, second); + Assert.False(second.IsDisposed); + } [Fact] public void Load_SetsCurrent_EnablesScene_FiresEvent() @@ -278,6 +300,7 @@ public void Load_SetsCurrent_EnablesScene_FiresEvent() try { Scene.Load(scene); + Scene.ProcessPendingLoad(); Assert.Same(scene, Scene.Current); Assert.True(scene.IsActive); @@ -286,38 +309,74 @@ public void Load_SetsCurrent_EnablesScene_FiresEvent() finally { Scene.OnSceneLoaded -= handler; - Scene.Unload(); } } + [Fact] + public void Load_QueuedUntilProcessed() + { + Scene before = Scene.Current; + var scene = CreateScene(); + + Scene.Load(scene); + + Assert.Same(before, Scene.Current); + Assert.False(scene.IsActive); + + Scene.ProcessPendingLoad(); + + Assert.Same(scene, Scene.Current); + } + [Fact] public void Load_ReplacingCurrent_DisposesPrevious() { var first = CreateScene(); var second = CreateScene(); - try - { - Scene.Load(first); - Scene.Load(second); - Assert.Same(second, Scene.Current); - Assert.True(first.IsDisposed); - } - finally - { - Scene.Unload(); - } + Scene.Load(first); + Scene.ProcessPendingLoad(); + + Scene.Load(second); + + // The outgoing scene stays usable until the swap actually applies. + Assert.Same(first, Scene.Current); + Assert.False(first.IsDisposed); + + Scene.ProcessPendingLoad(); + + Assert.Same(second, Scene.Current); + Assert.True(first.IsDisposed); } [Fact] - public void Unload_DisposesAndClearsCurrent() + public void Load_LastRequestOfTheFrameWins() { - var scene = CreateScene(); - Scene.Load(scene); + var first = CreateScene(); + var second = CreateScene(); + + Scene.Load(first); + Scene.Load(second); + Scene.ProcessPendingLoad(); + + Assert.Same(second, Scene.Current); + Assert.False(first.IsActive); + } + + [Fact] + public void Load_SkipsASceneDisposedBeforeItApplied() + { + var current = CreateScene(); + var queued = CreateScene(); + + Scene.Load(current); + Scene.ProcessPendingLoad(); - Scene.Unload(); + Scene.Load(queued); + queued.Dispose(); + Scene.ProcessPendingLoad(); - Assert.Null(Scene.Current); - Assert.True(scene.IsDisposed); + Assert.Same(current, Scene.Current); + Assert.False(current.IsDisposed); } } diff --git a/Prowl.Runtime/Game.cs b/Prowl.Runtime/Game.cs index b82f13d71..c83c291c4 100644 --- a/Prowl.Runtime/Game.cs +++ b/Prowl.Runtime/Game.cs @@ -190,6 +190,10 @@ public void Run(string title, int width, int height) // Last thing in the frame: everything Destroy()ed stayed usable right through // update, render and GUI, and is torn down here where nothing is mid-callback. EngineObject.ProcessDestroyed(); + + // Then the scene swap, so a load requested this frame tears the outgoing scene down + // here rather than under whatever was still running. + Scene.ProcessPendingLoad(); } catch (Exception e) { @@ -214,8 +218,8 @@ public void Run(string title, int width, int height) { Closing(); - // Unload the current scene - Scene.Unload(); + // Dispose the current scene so everything in it runs its teardown callbacks. + Scene.Shutdown(); AudioContext.Deinitialize(); @@ -279,6 +283,10 @@ public void RunHeadless(HeadlessRunOptions? options = null) // No render phase here, so the end of the simulation step is the end of the frame. EngineObject.ProcessDestroyed(); + // Then the scene swap, so a load requested this frame tears the outgoing scene down + // here rather than under whatever was still running. + Scene.ProcessPendingLoad(); + frame++; if (options.MaxFrames > 0 && frame >= options.MaxFrames) break; if (options.MaxSeconds > 0 && runClock.Elapsed.TotalSeconds >= options.MaxSeconds) break; @@ -296,7 +304,7 @@ public void RunHeadless(HeadlessRunOptions? options = null) { try { Console.CancelKeyPress -= cancelHandler; } catch { } Closing(); - Scene.Unload(); + Scene.Shutdown(); Application.IsHeadless = false; } } diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 934d18817..f6915fff8 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -642,6 +642,7 @@ public void RemoveAll() where T : MonoBehaviour if (c.HasBeenEnabled) // OnDispose is only called if OnEnable was previously called c.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable + c.DetachFromGameObject(); _components.Remove(c); } _componentCache.Remove(typeof(T)); @@ -667,6 +668,8 @@ public void RemoveComponent(T component) where T : MonoBehaviour if (component.EnabledInHierarchy) component.InternalOnDisable(); component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable } + + component.DetachFromGameObject(); } /// @@ -687,6 +690,8 @@ public void RemoveComponent(MonoBehaviour component) if (component.EnabledInHierarchy) component.InternalOnDisable(); component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable } + + component.DetachFromGameObject(); } } diff --git a/Prowl.Runtime/GameObject/MonoBehaviour.cs b/Prowl.Runtime/GameObject/MonoBehaviour.cs index 8710a5894..1780fbddb 100644 --- a/Prowl.Runtime/GameObject/MonoBehaviour.cs +++ b/Prowl.Runtime/GameObject/MonoBehaviour.cs @@ -247,6 +247,13 @@ internal void AttachToGameObject(GameObject go) _enabledInHierarchy = isEnabled; } + /// + /// Marks this component as no longer part of its GameObject. Disposal waits for the end of the + /// frame, so without this a removed component keeps ticking off the already-built dispatch list + /// while nothing can find it on the GameObject any more. + /// + internal void DetachFromGameObject() => _enabledInHierarchy = false; + /// /// Updates the enabled state based on changes in the hierarchy. /// OnEnable/OnDisable are only called if the GameObject is in an active Scene. diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 7ad4173a3..63cbb6eca 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -17,59 +17,92 @@ public class Scene : EngineObject, ISerializationCallbackReceiver { #region Scene Manager + private static Scene? _current; + /// - /// The currently active scene managed by the built-in Scene Manager. - /// For simple games, use Scene.Load() and Scene.Current for automatic scene management. - /// For advanced use cases (e.g., multiplayer servers with multiple scenes), - /// create and manage your own Scene instances directly. + /// The currently active scene. There is always one: reading this before anything has been loaded + /// creates an empty scene, so the engine is never in a no-scene state and callers never have to + /// handle null. Use to replace it. /// - public static Scene? Current { get; private set; } + public static Scene Current + { + get + { + if (_current is null || _current.IsDisposed) + { + _current = new Scene { Name = "Untitled" }; + _current.Enable(); + } + return _current; + } + } /// Fires after a scene is loaded via Load(). public static event Action? OnSceneLoaded; + private static Scene? _pendingScene; + /// - /// Loads a scene as the current active scene, replacing any previously loaded scene. - /// The previous scene will be disabled and disposed. + /// Queues a scene to become the current one, replacing the previously loaded scene. The swap + /// happens at the end of the frame, alongside the destroy queue, so the outgoing scene stays + /// usable for everything still running this frame. /// public static void Load(Scene scene) { if (scene == null) throw new ArgumentNullException(nameof(scene)); - if (Current != null) + _pendingScene = scene; + } + + /// + /// Applies a queued . Driven once per frame by the game loop, right after the + /// destroy queue. Nothing is mid-callback at that point, so the outgoing scene is disposed + /// outright rather than queued for another frame. + /// + public static void ProcessPendingLoad() + { + if (_pendingScene is null) return; + + Scene next = _pendingScene; + _pendingScene = null; + + if (next.IsDisposed) + { + Debug.LogWarning("[Scene] The scene queued for loading was disposed before the frame ended, so it was skipped."); + return; + } + + if (_current is not null && !_current.IsDisposed) { - if (Current.IsActive) - Current.Disable(); - Current.Destroy(); // Will call Dispose at end of frame not immediately so the scene technically is still usable + if (_current.IsActive) + _current.Disable(); + _current.Dispose(); } - Current = scene; - Current.Enable(); + _current = next; + _current.Enable(); OnSceneLoaded?.Invoke(); } /// - /// Unloads the current scene, disabling and disposing it. - /// After calling this, Scene.Current will be null. + /// Disposes the current scene, so everything in it runs its teardown callbacks. Driven by the + /// game loop on the way out. Reading afterwards creates a fresh empty scene. /// - /// - /// Loads a scene as Current without calling Enable(). - /// Kept for backward compatibility now just calls Load() since lifecycle gating - /// is handled per-component via ShouldExecuteGameplay. - /// - [Obsolete("Use Scene.Load() instead. Lifecycle gating is now per-component via [ExecuteAlways].")] - public static void LoadWithoutEnable(Scene scene) => Load(scene); - - public static void Unload() + internal static void Shutdown() { - if (Current != null) + _pendingScene = null; + + if (_current is null || _current.IsDisposed) { - if (Current.IsActive) - Current.Disable(); - Current.Destroy(); // Will call Dispose at end of frame not immediately so the scene technically is still usable - Current = null; + _current = null; + return; } + + if (_current.IsActive) + _current.Disable(); + _current.Dispose(); + _current = null; } #endregion @@ -623,9 +656,10 @@ protected override void OnDispose() { base.OnDispose(); - // Clear the current scene reference if this is the current scene - if (Current == this) - Current = null; + // Drop the current-scene reference without going through the property, which would build a + // replacement scene in the middle of this one's teardown. + if (ReferenceEquals(_current, this)) + _current = null; // Scene-scoped locks auto-expire with the scene rather than leaking forever. AssetDatabase.ReleaseSceneLocks(this); From 89d1c479e0922db38c56ef7025091df53ad43dfe Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:51:10 +1000 Subject: [PATCH 11/67] Guard hierarchy state for invalid GameObjects --- Prowl.Runtime/GameObject/MonoBehaviour.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Prowl.Runtime/GameObject/MonoBehaviour.cs b/Prowl.Runtime/GameObject/MonoBehaviour.cs index 1780fbddb..5c029ca9c 100644 --- a/Prowl.Runtime/GameObject/MonoBehaviour.cs +++ b/Prowl.Runtime/GameObject/MonoBehaviour.cs @@ -260,13 +260,15 @@ internal void AttachToGameObject(GameObject go) /// internal void HierarchyStateChanged() { - bool newState = _enabled && _go.EnabledInHierarchy; + // A component can be toggled before it is ever attached, and stays attached to a GameObject + // that has been disposed. Neither has a hierarchy to be enabled in. + bool newState = _enabled && _go.IsValid() && _go.EnabledInHierarchy; if (newState != _enabledInHierarchy) { _enabledInHierarchy = newState; // Only call OnEnable/OnDisable if we're in an active Scene - Scene? scene = _go.Scene; + Scene? scene = _go.IsValid() ? _go.Scene : null; if (scene.IsValid() && scene.IsActive) { if (newState) From 0df574adfd2509151c6425345318b0810bd2c884 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:52:48 +1000 Subject: [PATCH 12/67] Always dispose removed components (remove requirement that OnEnabled be called) --- Prowl.Runtime/GameObject/GameObject.cs | 45 ++++++++------------------ 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index f6915fff8..19d8647a1 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -639,9 +639,7 @@ public void RemoveAll() where T : MonoBehaviour foreach (MonoBehaviour c in componentList) { - if (c.HasBeenEnabled) // OnDispose is only called if OnEnable was previously called - c.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable - + c.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable c.DetachFromGameObject(); _components.Remove(c); } @@ -657,19 +655,7 @@ public void RemoveAll() where T : MonoBehaviour public void RemoveComponent(T component) where T : MonoBehaviour { ArgumentNullException.ThrowIfNull(component, nameof(component)); - if (component.CanDestroy() == false) return; - - _components.Remove(component); - _componentCache.Remove(component.GetType(), component); - - // OnDisable and OnDispose are only called if OnEnable was previously called - if (component.HasBeenEnabled) - { - if (component.EnabledInHierarchy) component.InternalOnDisable(); - component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable - } - - component.DetachFromGameObject(); + RemoveComponent((MonoBehaviour)component); } /// @@ -684,13 +670,12 @@ public void RemoveComponent(MonoBehaviour component) { _componentCache.Remove(component.GetType(), component); - // OnDisable and OnDispose are only called if OnEnable was previously called - if (component.HasBeenEnabled) - { - if (component.EnabledInHierarchy) component.InternalOnDisable(); - component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable - } + // OnDisable only if OnEnable ran, but disposal is unconditional: a component can take + // ownership of something from its constructor, long before it is ever enabled. + if (component.HasBeenEnabled && component.EnabledInHierarchy) + component.InternalOnDisable(); + component.Destroy(); // Will call Dispose at end of frame not immediately so the component technically is still usable component.DetachFromGameObject(); } } @@ -1008,17 +993,13 @@ protected override void OnDispose() MonoBehaviour component = _components[i]; if (component.IsDisposed) continue; - // Only call OnDisable/OnDispose if OnEnable was previously called - if (component.HasBeenEnabled) - { - // Only call OnDisable if the component is enabled in hierarchy AND the scene is active - // This prevents calling OnDisable twice when disposing after scene deactivation - Scene? scene = Scene; - if (component.EnabledInHierarchy && scene.IsValid() && scene.IsActive) - component.InternalOnDisable(); + // Only call OnDisable if OnEnable previously ran, the component is enabled in hierarchy + // and the scene is active, so it is never delivered twice after a scene deactivation. + Scene? scene = Scene; + if (component.HasBeenEnabled && component.EnabledInHierarchy && scene.IsValid() && scene.IsActive) + component.InternalOnDisable(); - component.Dispose(); - } + component.Dispose(); } _components.Clear(); From f17a23e378dbe78da828162476f372820a3f4b38 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:53:26 +1000 Subject: [PATCH 13/67] Guard RemoveComponent against null input --- Prowl.Runtime/GameObject/GameObject.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 19d8647a1..4086efe02 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -664,6 +664,7 @@ public void RemoveComponent(T component) where T : MonoBehaviour /// The component instance to remove. public void RemoveComponent(MonoBehaviour component) { + ArgumentNullException.ThrowIfNull(component, nameof(component)); if (component.CanDestroy() == false) return; if (_components.Remove(component)) From b033f454bfa2ce29c698058a86861fe325c3c4a2 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:53:48 +1000 Subject: [PATCH 14/67] Skip re-adding components to same GameObject --- Prowl.Runtime/GameObject/GameObject.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 4086efe02..34a4abd62 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -563,6 +563,8 @@ public void AddComponent(MonoBehaviour comp) { ArgumentNullException.ThrowIfNull(comp, nameof(comp)); + if (ReferenceEquals(comp.GameObject, this)) return; + Type type = comp.GetType(); RequireComponentAttribute? requireComponentAttribute = type.GetCustomAttribute(); if (requireComponentAttribute != null) From 5d7ece70127e32ee46ca5ba5f6bdcb4522de61a6 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:54:15 +1000 Subject: [PATCH 15/67] Detach component before reassigning owner --- Prowl.Runtime/GameObject/GameObject.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 34a4abd62..124528a48 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -565,6 +565,11 @@ public void AddComponent(MonoBehaviour comp) if (ReferenceEquals(comp.GameObject, this)) return; + // A component belongs to exactly one GameObject. Leaving it registered on its previous one + // would have both report it from GetComponent, and would destroy it when that one is disposed. + if (comp.GameObject.IsValid()) + comp.GameObject.DetachComponent(comp); + Type type = comp.GetType(); RequireComponentAttribute? requireComponentAttribute = type.GetCustomAttribute(); if (requireComponentAttribute != null) @@ -683,6 +688,21 @@ public void RemoveComponent(MonoBehaviour component) } } + /// + /// Removes a component from this GameObject without destroying it, for a move to another one. + /// + internal void DetachComponent(MonoBehaviour component) + { + if (!_components.Remove(component)) return; + + _componentCache.Remove(component.GetType(), component); + + if (component.HasBeenEnabled && component.EnabledInHierarchy) + component.InternalOnDisable(); + + component.DetachFromGameObject(); + } + /// /// Removes a specific component from the GameObject By its Identifier. /// From dee6c9494e0e95b2aaf57e48eb3d7f1b291df808 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:55:15 +1000 Subject: [PATCH 16/67] Snapshot GameObject component queries --- Prowl.Runtime/GameObject/GameObject.cs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/Prowl.Runtime/GameObject/GameObject.cs b/Prowl.Runtime/GameObject/GameObject.cs index 124528a48..338bcf739 100644 --- a/Prowl.Runtime/GameObject/GameObject.cs +++ b/Prowl.Runtime/GameObject/GameObject.cs @@ -780,22 +780,16 @@ public void RemoveComponent(Guid component) /// An IEnumerable of MonoBehaviour components of the specified type. public IEnumerable GetComponents(Type type) { + // Snapshotted rather than yielded off the live storage, so a caller (or a lifecycle callback + // it triggers) can add or remove components while walking the result. Component counts are + // small enough that the copy costs less than the crash it prevents. if (type == typeof(MonoBehaviour)) - { - // Special case for Component - foreach (MonoBehaviour comp in _components) - yield return comp; - } - else - { - if (_componentCache.TryGetValue(type, out IReadOnlyCollection? components)) - foreach (MonoBehaviour comp in components) - yield return comp; - else - foreach (MonoBehaviour comp in _components) - if (comp.GetType().IsAssignableTo(type)) - yield return comp; - } + return _components.ToArray(); + + if (_componentCache.TryGetValue(type, out IReadOnlyCollection? components)) + return components.ToArray(); + + return _components.Where(comp => comp.GetType().IsAssignableTo(type)).ToArray(); } /// From cb99fcea7b480d6a379183583aa34f9fc08d8c07 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:56:07 +1000 Subject: [PATCH 17/67] Guard against reloading current scene --- Prowl.Runtime/Resources/Scene.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 63cbb6eca..bba29bc01 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -73,6 +73,9 @@ public static void ProcessPendingLoad() return; } + // Loading the scene that is already current would dispose it and then enable the corpse. + if (ReferenceEquals(next, _current)) return; + if (_current is not null && !_current.IsDisposed) { if (_current.IsActive) From 7ddc2a601a6717aaf29820425194f1a0b04629fa Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:56:24 +1000 Subject: [PATCH 18/67] Make Scene lifecycle ops idempotent --- Prowl.Runtime/Resources/Scene.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index bba29bc01..4e692f749 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -362,7 +362,7 @@ public Scene() public void Enable() { EnsureNotDisposed(); - if (_isActive) throw new Exception("Scene is already enabled!"); + if (_isActive) return; // already enabled, nothing to deliver _isActive = true; @@ -395,7 +395,7 @@ public void Enable() public void Disable() { EnsureNotDisposed(); - if (!_isActive) throw new Exception("Scene is not enabled!"); + if (!_isActive) return; // already disabled, nothing to deliver // Create a copy to avoid collection modification during enumeration List allObjectsCopy = [.. AllObjects]; @@ -640,7 +640,7 @@ public void Clear() /// Unregisters all dead / disposed GameObjects public void Flush() { - EnsureNotDisposed(); + if (IsDisposed) return; List removed = []; foreach (GameObject obj in _allObj) { @@ -752,7 +752,7 @@ public void OnAfterDeserialize() /// public void Update() { - EnsureNotDisposed(); + if (IsDisposed) return; _dispatcher.RunStart(); // Navigation (crowd steering) advances on the variable update, before component Updates @@ -772,7 +772,7 @@ public void Update() /// public void FixedUpdate() { - EnsureNotDisposed(); + if (IsDisposed) return; // Start must run before a component's first FixedUpdate. The loop runs FixedUpdate before // Update, so drive Start here too (RunStart is idempotent - it only starts un-started ones). _dispatcher.RunStart(); @@ -792,7 +792,7 @@ public void FixedUpdate() /// public void CollectRenderables(Camera camera, List renderables, List lights) { - EnsureNotDisposed(); + if (IsDisposed) return; _dispatcher.RunRenderCollect(camera, renderables, lights); } @@ -801,7 +801,7 @@ public void CollectRenderables(Camera camera, List renderables, Lis /// public void DrawGizmos() { - EnsureNotDisposed(); + if (IsDisposed) return; _dispatcher.RunDrawGizmos(); Flush(); @@ -813,7 +813,7 @@ public void DrawGizmos() /// public void OnGui(Paper paper) { - EnsureNotDisposed(); + if (IsDisposed) return; _dispatcher.RunOnGui(paper); Flush(); @@ -851,7 +851,7 @@ internal List GatherActiveCameras() /// True if any cameras were rendered, false otherwise public bool Render(RenderTexture? target = null) { - EnsureNotDisposed(); + if (IsDisposed) return false; // Renderables are now collected per-camera inside pipeline.Render() List Cameras = GatherActiveCameras(); From 1703f83f5f5a6eaf5ab6f168856cce2635a808ca Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:57:02 +1000 Subject: [PATCH 19/67] Dont need to make a copy of GetComponents anymore --- Prowl.Runtime/Components/UI/Input/EventSystem.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Prowl.Runtime/Components/UI/Input/EventSystem.cs b/Prowl.Runtime/Components/UI/Input/EventSystem.cs index 8f2e030c6..551235bdd 100644 --- a/Prowl.Runtime/Components/UI/Input/EventSystem.cs +++ b/Prowl.Runtime/Components/UI/Input/EventSystem.cs @@ -195,8 +195,7 @@ public void SetSelected(GameObject? go) GameObject? first = null; while (node != null) { - System.Collections.Generic.List components = [..node.GetComponents()]; - foreach (MonoBehaviour comp in components) + foreach (MonoBehaviour comp in node.GetComponents()) { if (comp is TInterface handler && comp.IsValid() && comp.EnabledInHierarchy) { From 8a7af6e528aa51de19b2a0bbe62afc16e55c950c Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 04:59:36 +1000 Subject: [PATCH 20/67] Remove MonoBehaviour array copies in Scene --- Prowl.Runtime/Resources/Scene.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 4e692f749..e21cb9ffa 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -376,8 +376,7 @@ public void Enable() if (go.EnabledInHierarchy) { - // Create a copy of components to avoid modification during enumeration - MonoBehaviour[] components = [.. go.GetComponents()]; + var components = go.GetComponents(); foreach (MonoBehaviour component in components) { if (component.IsDisposed) continue; @@ -407,8 +406,7 @@ public void Disable() if (go.EnabledInHierarchy) { - // Create a copy of components to avoid modification during enumeration - MonoBehaviour[] components = [.. go.GetComponents()]; + var components = go.GetComponents(); foreach (MonoBehaviour component in components) { if (component.IsDisposed) continue; @@ -516,8 +514,7 @@ private void AddObject(GameObject obj) _allObj.Add(obj); obj.Scene = this; - // Create a copy of components to avoid modification during enumeration - MonoBehaviour[] components = [.. obj.GetComponents()]; + var components = obj.GetComponents(); // Call OnAddedToScene for all components foreach (MonoBehaviour component in components) @@ -555,8 +552,7 @@ private void RemoveObject(GameObject obj) if (_allObjSet.Remove(obj)) { _allObj.Remove(obj); - // Create a copy of components to avoid modification during enumeration - MonoBehaviour[] components = [.. obj.GetComponents()]; + var components = obj.GetComponents(); // Call OnDisable for currently enabled components (only if scene is active) if (IsActive && obj.EnabledInHierarchy) From 83be85b96a89c868623b5c11e35720b437200821 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 05:22:43 +1000 Subject: [PATCH 21/67] Some additional scene/component lifecycle tests --- Prowl.Runtime.Test/ComponentTests.cs | 86 +++++++++++++++ Prowl.Runtime.Test/LifecycleTests.cs | 115 +++++++++++++++++---- Prowl.Runtime.Test/SceneManagementTests.cs | 68 ++++++++++++ 3 files changed, 248 insertions(+), 21 deletions(-) diff --git a/Prowl.Runtime.Test/ComponentTests.cs b/Prowl.Runtime.Test/ComponentTests.cs index bd166cde3..e7eb4a864 100644 --- a/Prowl.Runtime.Test/ComponentTests.cs +++ b/Prowl.Runtime.Test/ComponentTests.cs @@ -295,6 +295,92 @@ public void RemoveComponent_RequiredByAnother_IsBlocked() Assert.NotNull(go.GetComponent()); } + // ---- Enumeration safety ---- + + // GetComponents used to yield straight off the live list, so adding one while walking the + // result threw "Collection was modified". + [Fact] + public void GetComponents_TolerartesAddDuringEnumeration() + { + var go = CreateGameObject(); + go.AddComponent(); + go.AddComponent(); + + int seen = 0; + foreach (var _ in go.GetComponents()) + { + seen++; + go.AddComponent(); + } + + Assert.Equal(2, seen); // the snapshot taken when enumeration started + Assert.Equal(4, go.GetComponents().Count()); + } + + [Fact] + public void GetComponents_TolerartesRemoveDuringEnumeration() + { + var go = CreateGameObject(); + var a = go.AddComponent(); + go.AddComponent(); + + foreach (var _ in go.GetComponents()) + go.RemoveComponent(a); + + Assert.Single(go.GetComponents()); + } + + // ---- Ownership ---- + + [Fact] + public void RemoveComponent_FromWrongGameObject_DoesNothing() + { + var scene = CreateScene(enable: true); + var owner = CreateGameObject("Owner"); + var other = CreateGameObject("Other"); + var comp = owner.AddComponent(); + scene.Add(owner); scene.Add(other); + + other.RemoveComponent(comp); // non-generic overload + other.RemoveComponent(comp); // generic overload + EngineObject.ProcessDestroyed(); + + Assert.True(comp.IsValid(), "A GameObject that does not own the component must not destroy it."); + Assert.Same(comp, owner.GetComponent()); + Assert.Same(owner, comp.GameObject); + } + + [Fact] + public void AddComponent_Instance_MovesItOffItsPreviousGameObject() + { + var a = CreateGameObject("A"); + var b = CreateGameObject("B"); + var comp = a.AddComponent(); + + b.AddComponent(comp); + + Assert.Same(b, comp.GameObject); + Assert.Empty(a.GetComponents()); + Assert.Same(comp, b.GetComponent()); + } + + [Fact] + public void AddComponent_Instance_SurvivesDisposalOfThePreviousGameObject() + { + var scene = CreateScene(enable: true); + var a = CreateGameObject("A"); + var b = CreateGameObject("B"); + var comp = a.AddComponent(); + scene.Add(a); scene.Add(b); + + b.AddComponent(comp); + a.Dispose(); + EngineObject.ProcessDestroyed(); + + Assert.True(comp.IsValid(), "Disposing the old GameObject must not destroy a component that moved away."); + Assert.Same(comp, b.GetComponent()); + } + // ---- ExecutionOrder ---- [Fact] diff --git a/Prowl.Runtime.Test/LifecycleTests.cs b/Prowl.Runtime.Test/LifecycleTests.cs index 540bfaa5a..fa0420aab 100644 --- a/Prowl.Runtime.Test/LifecycleTests.cs +++ b/Prowl.Runtime.Test/LifecycleTests.cs @@ -342,11 +342,13 @@ public void DisposingGameObject_CallsOnDisableAndOnDispose() } /// - /// Test 11b: Disposing GameObject that was never enabled - /// Expected: No OnDisable or OnDispose (component was never enabled) + /// Test 11b: Disposing a GameObject whose component was never enabled. + /// Expected: no OnDisable (it was never enabled), but OnDispose still runs. A component can + /// acquire resources from its constructor or from a method called before it was ever enabled, + /// so disposal is not conditional on OnEnable having happened. /// [Fact] - public void DisposingGameObject_NeverEnabled_NoOnDispose() + public void DisposingGameObject_NeverEnabled_StillDisposes() { var scene = CreateScene(); // Scene is NOT enabled @@ -358,9 +360,9 @@ public void DisposingGameObject_NeverEnabled_NoOnDispose() go.Dispose(); - // Neither OnDisable nor OnDispose should be called Assert.DoesNotContain("OnDisable", comp.Events); - Assert.DoesNotContain("OnDispose", comp.Events); + Assert.Contains("OnDispose", comp.Events); + Assert.True(comp.IsDisposed); } /// @@ -387,11 +389,12 @@ public void RemovingComponent_CallsOnDisableAndOnDispose() } /// - /// Test 12b: Removing Component that was never enabled - /// Expected: No OnDisable or OnDispose + /// Test 12b: Removing a component that was never enabled. + /// Expected: no OnDisable, but it is still disposed. See + /// . /// [Fact] - public void RemovingComponent_NeverEnabled_NoOnDispose() + public void RemovingComponent_NeverEnabled_StillDisposes() { var scene = CreateScene(); // Scene is NOT enabled @@ -402,9 +405,65 @@ public void RemovingComponent_NeverEnabled_NoOnDispose() comp.ClearEvents(); go.RemoveComponent(comp); + EngineObject.ProcessDestroyed(); Assert.DoesNotContain("OnDisable", comp.Events); - Assert.DoesNotContain("OnDispose", comp.Events); + Assert.Contains("OnDispose", comp.Events); + Assert.True(comp.IsDisposed); + } + + // ---- Reentrancy from lifecycle callbacks ---- + + private sealed class AddsComponentOnEnable : MonoBehaviour + { + public override void OnEnable() => GameObject.AddComponent(); + } + + private sealed class RemovesComponentOnDisable : MonoBehaviour + { + public MonoBehaviour? Victim; + public override void OnDisable() => GameObject.RemoveComponent(Victim!); + } + + // The hierarchy state walk used to enumerate the live component list, so a callback that + // touched the list threw "Collection was modified". + [Fact] + public void OnEnable_CanAddAComponent() + { + var scene = CreateScene(enable: true); + var go = CreateGameObject(); + go.Enabled = false; + go.AddComponent(); + scene.Add(go); + + go.Enabled = true; + + Assert.Single(go.GetComponents()); + } + + [Fact] + public void OnDisable_CanRemoveAComponent() + { + var scene = CreateScene(enable: true); + var go = CreateGameObject(); + var driver = go.AddComponent(); + driver.Victim = go.AddComponent(); + scene.Add(go); + + go.Enabled = false; + + Assert.Empty(go.GetComponents()); + } + + [Fact] + public void Enabled_OnAComponentWithNoGameObject_DoesNotThrow() + { + var comp = new PlainComponent(); + + comp.Enabled = false; + + Assert.False(comp.Enabled); + Assert.False(comp.EnabledInHierarchy); } /// @@ -519,11 +578,11 @@ public void SceneCleanup_DisableAndDispose_CallsProperSequence() } /// - /// Test 15b: Scene Cleanup when components were never enabled - /// Expected: No OnDisable or OnDispose + /// Test 15b: Scene cleanup when components were never enabled. + /// Expected: no OnDisable, but they are still disposed. /// [Fact] - public void SceneCleanup_NeverEnabled_NoOnDispose() + public void SceneCleanup_NeverEnabled_StillDisposes() { var scene = CreateScene(); // Scene is NOT enabled @@ -536,7 +595,7 @@ public void SceneCleanup_NeverEnabled_NoOnDispose() scene.Dispose(); Assert.DoesNotContain("OnDisable", comp.Events); - Assert.DoesNotContain("OnDispose", comp.Events); + Assert.Contains("OnDispose", comp.Events); } /// @@ -633,28 +692,42 @@ public void ChildExplicitlyRemoved_GetsOnDisableAndOnRemovedFromScene() } /// - /// Additional test: Scene not double-enabled - /// Expected: Throws exception when enabling already enabled scene + /// Additional test: enabling an already enabled scene delivers nothing a second time. + /// Enable/Disable are idempotent, since Load has no way to know whether a scene handed to it + /// was enabled already. /// [Fact] - public void EnablingAlreadyEnabledScene_ThrowsException() + public void EnablingAlreadyEnabledScene_DoesNotRepeatOnEnable() { var scene = CreateScene(); + var go = CreateGameObject(); + var comp = go.AddComponent(); + scene.Add(go); scene.Enable(); + comp.ClearEvents(); - Assert.Throws(() => scene.Enable()); + scene.Enable(); + + Assert.True(scene.IsActive); + Assert.DoesNotContain("OnEnable", comp.Events); } /// - /// Additional test: Scene not double-disabled - /// Expected: Throws exception when disabling already disabled scene + /// Additional test: disabling an already disabled scene delivers nothing. /// [Fact] - public void DisablingAlreadyDisabledScene_ThrowsException() + public void DisablingAlreadyDisabledScene_DoesNotRepeatOnDisable() { var scene = CreateScene(); + var go = CreateGameObject(); + var comp = go.AddComponent(); + scene.Add(go); + comp.ClearEvents(); - Assert.Throws(() => scene.Disable()); + scene.Disable(); + + Assert.False(scene.IsActive); + Assert.DoesNotContain("OnDisable", comp.Events); } /// diff --git a/Prowl.Runtime.Test/SceneManagementTests.cs b/Prowl.Runtime.Test/SceneManagementTests.cs index 875aa4ff5..aba711775 100644 --- a/Prowl.Runtime.Test/SceneManagementTests.cs +++ b/Prowl.Runtime.Test/SceneManagementTests.cs @@ -363,6 +363,74 @@ public void Load_LastRequestOfTheFrameWins() Assert.False(first.IsActive); } + // Loading the scene that is already current used to dispose it and then enable the corpse. + [Fact] + public void Load_TheCurrentScene_IsANoOp() + { + Scene current = Scene.Current; + + Scene.Load(current); + Scene.ProcessPendingLoad(); + + Assert.Same(current, Scene.Current); + Assert.False(current.IsDisposed); + Assert.True(current.IsActive); + } + + [Fact] + public void Load_AnAlreadyEnabledScene_DoesNotThrow() + { + var scene = CreateScene(enable: true); + + Scene.Load(scene); + Scene.ProcessPendingLoad(); + + Assert.Same(scene, Scene.Current); + Assert.True(scene.IsActive); + } + + [Fact] + public void EnableAndDisable_AreIdempotent() + { + var scene = CreateScene(); + + scene.Enable(); + scene.Enable(); + Assert.True(scene.IsActive); + + scene.Disable(); + scene.Disable(); + Assert.False(scene.IsActive); + } + + // A component disposing its own scene mid-callback used to blow up on the trailing Flush(). + [Fact] + public void FrameCallbacks_OnASceneDisposedMidCallback_DoNotThrow() + { + var scene = CreateScene(enable: true); + var go = CreateGameObject(); + var driver = go.AddComponent(); + driver.Action = () => scene.Dispose(); + scene.Add(go); + + scene.Update(); // must not throw + + Assert.True(scene.IsDisposed); + } + + [Fact] + public void FrameCallbacks_OnADisposedScene_AreNoOps() + { + var scene = CreateScene(enable: true); + scene.Dispose(); + + scene.Update(); + scene.FixedUpdate(); + scene.DrawGizmos(); + scene.Flush(); + Assert.False(scene.Render()); + } + [Fact] public void Load_SkipsASceneDisposedBeforeItApplied() { From 4c526cd64ee66a2088b8d4ba098a050cbd659edb Mon Sep 17 00:00:00 2001 From: Wulferis Date: Wed, 5 Aug 2026 13:08:01 +1000 Subject: [PATCH 22/67] Added Scene.DontDestroyOnLoad(go) --- Prowl.Runtime.Test/SceneManagementTests.cs | 178 +++++++++++++++++++++ Prowl.Runtime/Resources/Scene.cs | 99 ++++++++++++ 2 files changed, 277 insertions(+) diff --git a/Prowl.Runtime.Test/SceneManagementTests.cs b/Prowl.Runtime.Test/SceneManagementTests.cs index aba711775..39d73fb7e 100644 --- a/Prowl.Runtime.Test/SceneManagementTests.cs +++ b/Prowl.Runtime.Test/SceneManagementTests.cs @@ -431,6 +431,184 @@ public void FrameCallbacks_OnADisposedScene_AreNoOps() Assert.False(scene.Render()); } + // ---- Surviving a scene load ---- + + private sealed class TickCounter : MonoBehaviour + { + public int Enables, Disables, Updates; + public override void OnEnable() => Enables++; + public override void OnDisable() => Disables++; + public override void Update() => Updates++; + } + + // The hand-rolled version: take the object out of the outgoing scene and put it in the incoming + // one. It works, as long as you add it to the scene you are loading and not to Scene.Current, + // which is still the outgoing scene until the swap applies. + [Fact] + public void ManualPreserve_RemoveFromOldSceneAndAddToTheNextOne_Survives() + { + var first = CreateScene(); + var keeper = CreateGameObject("Keeper"); + first.Add(keeper); + Scene.Load(first); + Scene.ProcessPendingLoad(); + + var second = CreateScene(); + first.Remove(keeper); + second.Add(keeper); + Scene.Load(second); + Scene.ProcessPendingLoad(); + + Assert.False(keeper.IsDisposed); + Assert.Same(second, keeper.Scene); + Assert.Contains(keeper, second.AllObjects); + } + + // The trap: after Load(), Scene.Current is still the outgoing scene, so adding there hands the + // object to the scene that is about to be disposed. + [Fact] + public void ManualPreserve_AddingBackToSceneCurrentAfterLoad_LosesTheObject() + { + var first = CreateScene(); + var keeper = CreateGameObject("Keeper"); + first.Add(keeper); + Scene.Load(first); + Scene.ProcessPendingLoad(); + + var second = CreateScene(); + first.Remove(keeper); + Scene.Load(second); + Scene.Current.Add(keeper); // still `first` at this point + Scene.ProcessPendingLoad(); + + Assert.True(keeper.IsDisposed, "Scene.Current is only the new scene once the swap applies."); + } + + [Fact] + public void DontDestroyOnLoad_SurvivesTheLoad_AndJoinsTheNewScene() + { + var first = CreateScene(); + var keeper = CreateGameObject("Keeper"); + var doomed = CreateGameObject("Doomed"); + first.Add(keeper); + first.Add(doomed); + Scene.Load(first); + Scene.ProcessPendingLoad(); + + Scene.DontDestroyOnLoad(keeper); + + var second = CreateScene(); + Scene.Load(second); + Scene.ProcessPendingLoad(); + + Assert.False(keeper.IsDisposed); + Assert.Same(second, keeper.Scene); + Assert.Contains(keeper, second.AllObjects); + Assert.True(doomed.IsDisposed, "Anything not preserved goes with the old scene."); + } + + [Fact] + public void DontDestroyOnLoad_KeepsTicking_InTheNewScene() + { + var first = CreateScene(enable: true); + var keeper = CreateGameObject("Keeper"); + var comp = keeper.AddComponent(); + first.Add(keeper); + Scene.Load(first); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(keeper); + + Update(first); + Assert.Equal(1, comp.Updates); + + var second = CreateScene(); + Scene.Load(second); + Scene.ProcessPendingLoad(); + + Update(second); + Assert.Equal(2, comp.Updates); // re-registered with the new scene's dispatcher + } + + [Fact] + public void DontDestroyOnLoad_DoesNotRestartTheObject() + { + var first = CreateScene(enable: true); + var keeper = CreateGameObject("Keeper"); + var comp = keeper.AddComponent(); + first.Add(keeper); + Scene.Load(first); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(keeper); + int enables = comp.Enables; + + Scene.Load(CreateScene()); + Scene.ProcessPendingLoad(); + + Assert.Equal(enables, comp.Enables); + Assert.Equal(0, comp.Disables); + } + + [Fact] + public void DontDestroyOnLoad_OnAChild_PreservesItsRootInstead() + { + var first = CreateScene(); + var root = CreateGameObject("Root"); + var child = CreateGameObject("Child"); + child.SetParent(root); + first.Add(root); + Scene.Load(first); + Scene.ProcessPendingLoad(); + + Scene.DontDestroyOnLoad(child); + + var second = CreateScene(); + Scene.Load(second); + Scene.ProcessPendingLoad(); + + Assert.False(root.IsDisposed); + Assert.False(child.IsDisposed); + Assert.Same(second, root.Scene); + Assert.Same(second, child.Scene); + Assert.Same(root, child.Parent); + } + + [Fact] + public void CancelDontDestroyOnLoad_LetsItDieWithTheScene() + { + var first = CreateScene(); + var go = CreateGameObject(); + first.Add(go); + Scene.Load(first); + Scene.ProcessPendingLoad(); + + Scene.DontDestroyOnLoad(go); + Scene.CancelDontDestroyOnLoad(go); + + Scene.Load(CreateScene()); + Scene.ProcessPendingLoad(); + + Assert.True(go.IsDisposed); + } + + [Fact] + public void DontDestroyOnLoad_ADestroyedObject_IsDroppedNotResurrected() + { + var first = CreateScene(); + var go = CreateGameObject(); + first.Add(go); + Scene.Load(first); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(go); + + go.Dispose(); + + var second = CreateScene(); + Scene.Load(second); + Scene.ProcessPendingLoad(); // must not throw or re-add the corpse + + Assert.Empty(second.AllObjects); + } + [Fact] public void Load_SkipsASceneDisposedBeforeItApplied() { diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index e21cb9ffa..902526347 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -55,6 +55,54 @@ public static void Load(Scene scene) _pendingScene = scene; } + private static readonly List _preserved = []; + + /// + /// Keeps a GameObject alive across scene loads. It moves straight from the outgoing scene to the + /// incoming one when the swap applies, so it is never held by a scene that is about to be + /// disposed, and it is not disabled and re-enabled on the way. + /// + /// Only roots can be preserved, since half a hierarchy surviving a load is never what was meant. + /// Passing a child preserves its root instead. + /// + public static void DontDestroyOnLoad(GameObject go) + { + if (go.IsNotValid()) + { + Debug.LogWarning("[Scene] DontDestroyOnLoad on a null or destroyed GameObject does nothing."); + return; + } + + GameObject root = go; + while (root.Parent.IsValid()) + root = root.Parent; + + if (!ReferenceEquals(root, go)) + Debug.LogWarning($"[Scene] '{go.Name}' is not a root object, so its root '{root.Name}' is preserved instead."); + + if (!_preserved.Any(p => ReferenceEquals(p, root))) + _preserved.Add(root); + } + + /// + /// Stops preserving a GameObject. It stays in whatever scene it is in now, and goes with that + /// scene on the next load. + /// + public static void CancelDontDestroyOnLoad(GameObject go) + => _preserved.RemoveAll(p => ReferenceEquals(p, go)); + + /// Whether this GameObject (or the root it belongs to) survives scene loads. + public static bool IsPreserved(GameObject go) + { + if (go.IsNotValid()) return false; + + GameObject root = go; + while (root.Parent.IsValid()) + root = root.Parent; + + return _preserved.Any(p => ReferenceEquals(p, root)); + } + /// /// Applies a queued . Driven once per frame by the game loop, right after the /// destroy queue. Nothing is mid-callback at that point, so the outgoing scene is disposed @@ -76,6 +124,13 @@ public static void ProcessPendingLoad() // Loading the scene that is already current would dispose it and then enable the corpse. if (ReferenceEquals(next, _current)) return; + // Preserved objects leave before the outgoing scene is disposed, and join the incoming one + // after it is enabled, so they are never registered with a scene that is being torn down. + _preserved.RemoveAll(p => p.IsNotValid()); + foreach (GameObject go in _preserved) + if (ReferenceEquals(go.Scene, _current)) + _current!.Detach(go); + if (_current is not null && !_current.IsDisposed) { if (_current.IsActive) @@ -85,6 +140,10 @@ public static void ProcessPendingLoad() _current = next; _current.Enable(); + + foreach (GameObject go in _preserved) + _current.Attach(go); + OnSceneLoaded?.Invoke(); } @@ -507,6 +566,46 @@ public void Remove(GameObject obj) RemoveObject(obj); } + /// + /// Hands a GameObject tree over to another scene without running any lifecycle callback. Only the + /// registration moves: the scene's object list and, for each enabled component, the per-frame + /// dispatch slot. Used by , where the object is not entering or + /// leaving the world, just changing which scene holds it. + /// + internal void Detach(GameObject obj) + { + foreach (GameObject child in obj.Children.ToArray()) + Detach(child); + + if (!_allObjSet.Remove(obj)) return; + + _allObj.Remove(obj); + + foreach (MonoBehaviour component in obj._components) + if (!component.IsDisposed) + _dispatcher.Unregister(component); + + obj.Scene = null; + } + + /// + internal void Attach(GameObject obj) + { + if (_allObjSet.Add(obj)) + { + _allObj.Add(obj); + obj.Scene = this; + + if (IsActive && obj.EnabledInHierarchy) + foreach (MonoBehaviour component in obj._components) + if (!component.IsDisposed && component.Enabled && component.EnabledInHierarchy) + _dispatcher.Register(component); + } + + foreach (GameObject child in obj.Children.ToArray()) + Attach(child); + } + private void AddObject(GameObject obj) { if (_allObjSet.Add(obj)) From d37252f982103db2e6cf7a294fc83f1edf492a13 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 12:29:49 +1000 Subject: [PATCH 23/67] Fixed typo in Character Controller Template --- Prowl.Editor/Resources/NewCharacterController.cstemplate | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prowl.Editor/Resources/NewCharacterController.cstemplate b/Prowl.Editor/Resources/NewCharacterController.cstemplate index 6122671c8..33d25d3b7 100644 --- a/Prowl.Editor/Resources/NewCharacterController.cstemplate +++ b/Prowl.Editor/Resources/NewCharacterController.cstemplate @@ -23,7 +23,7 @@ public class {[className]} : MonoBehaviour _velocity.X = planar.X * MoveSpeed; _velocity.Z = planar.Z * MoveSpeed; - if (_controller.IsGrounded && _velocity.y <= 0f) + if (_controller.IsGrounded && _velocity.Y <= 0f) { _velocity.Y = 0f; if (Input.GetKeyDown(KeyCode.Space)) From d0f28991c02a1d13ef62672e218d01a331d95ebd Mon Sep 17 00:00:00 2001 From: Paolo Date: Wed, 5 Aug 2026 10:33:39 +0200 Subject: [PATCH 24/67] Fixed DontDestroyOnLoad preserving objects after play mode as well, modified ProwlAction to be clearer, added a compile date check to recompile assemblies that are older than the underlying engine --- Prowl.Editor.Test/ScriptCompilationTests.cs | 19 +++ Prowl.Editor/Core/EditorApplication.cs | 4 + .../Scripting/ScriptAssemblyManager.cs | 30 ++++ .../Projects/Scripting/ScriptCompiler.cs | 23 +++ Prowl.Runtime.Test/ProwlActionTests.cs | 149 ++++++++++++++++++ Prowl.Runtime.Test/RuntimeTestBase.cs | 4 + Prowl.Runtime.Test/SceneManagementTests.cs | 71 +++++++++ Prowl.Runtime/Resources/Scene.cs | 25 +++ Prowl.Runtime/Utils/ProwlAction.cs | 35 +++- 9 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 Prowl.Runtime.Test/ProwlActionTests.cs diff --git a/Prowl.Editor.Test/ScriptCompilationTests.cs b/Prowl.Editor.Test/ScriptCompilationTests.cs index 70465b610..22fa9e722 100644 --- a/Prowl.Editor.Test/ScriptCompilationTests.cs +++ b/Prowl.Editor.Test/ScriptCompilationTests.cs @@ -185,6 +185,25 @@ public void ProjectNamedLikeEnginePrefix_StillReferencesEngineDlls() finally { TryDeleteDir(parent); } } + // Script files are the only input the recompile rule watches, so an engine rebuilt underneath an + // unchanged project leaves assemblies bound to an API that may no longer exist - surfacing much + // later as a MissingMethodException from whichever call site happened to run first. + [Fact] + public void ScriptsPredateEngine_TracksTheEngineBuild_NotJustTheScripts() + { + WriteScript("Persistent.cs", "using Prowl.Runtime; public class Persistent : MonoBehaviour { }"); + var result = ScriptCompiler.CompileAll(Project); + Assert.True(result.Success, $"Compile failed:\n{result.Errors}"); + + // Freshly compiled: the assembly is younger than the engine it was built against. + Assert.False(ScriptAssemblyManager.ScriptsPredateEngine(Project)); + + // As if the engine had been rebuilt after that compile. + File.SetLastWriteTimeUtc(Project.GameAssemblyPath, DateTime.UtcNow.AddDays(-1)); + + Assert.True(ScriptAssemblyManager.ScriptsPredateEngine(Project)); + } + private string InvokeVersionedTag() { // Load by bytes so the file stays unlocked for the next recompile. diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index 1a5063665..fca08b9fe 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -1573,6 +1573,8 @@ private void EnterPlayMode() // Push play-mode input handler (only forwards input when Game View focused) Input.PushHandler(new GameViewInputHandler(Input.Current)); + Runtime.Resources.Scene.DestroyPreserved(); + // Load with full lifecycle (Enable -> OnEnable/Start will fire) Runtime.Resources.Scene.Load(playScene); Undo.Clear(); @@ -1598,6 +1600,8 @@ private void ExitPlayMode() // Clear selection (play scene references) Selection.Clear(); + Runtime.Resources.Scene.DestroyPreserved(); + // Restore the editor scene. Loading it is what disposes the play scene, at the end of the frame. if (_savedEditorScene != null) { diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 9bd58badd..892038827 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -248,6 +248,36 @@ public static void LoadAssemblies(Project project) // Load every produced user assembly in dependency order. foreach (var dll in ScriptCompiler.GetEditorAssemblyPaths(project)) LoadAssembly(dll, Path.GetFileNameWithoutExtension(dll)); + + if (ScriptsPredateEngine(project)) + { + Runtime.Debug.Log("[Scripts] The engine has been rebuilt since these scripts were compiled; recompiling them."); + RequestRecompile(); + } + } + + /// + /// Whether the compiled user assemblies predate the engine they are about to run against. + /// + /// They were built against whatever engine was running at the time, so one rebuilt since can have + /// moved or dropped a member they still call - and nothing else notices, because the script files + /// themselves are unchanged and that is the only thing the recompile rule looks at. The mismatch + /// then waits until the affected code path runs and throws MissingMethodException, by which point + /// nothing points back at the engine change that caused it. + /// + internal static bool ScriptsPredateEngine(Project project) + { + DateTime oldestBuilt = DateTime.MaxValue; + + foreach (var dll in ScriptCompiler.GetEditorAssemblyPaths(project)) + { + if (!File.Exists(dll)) continue; + + DateTime built = File.GetLastWriteTimeUtc(dll); + if (built < oldestBuilt) oldestBuilt = built; + } + + return oldestBuilt != DateTime.MaxValue && ScriptCompiler.EngineBuildTimeUtc() > oldestBuilt; } /// Snapshot the project's plugins so the resolvers can satisfy user-assembly imports. diff --git a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs index 2facf4b64..2699018b5 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs @@ -127,6 +127,29 @@ public static CompileResult CompileAll(Project project) /// packages (a bad name or version surfaces now instead of only after the first script is written) /// and lets IDEs resolve them. Returns success with nothing to reload when there is nothing to do. /// + /// + /// When the engine the scripts compile against was last built. User assemblies are compiled + /// against the running editor's own binaries, so a newer engine invalidates them exactly like an + /// edited script does: the API they were bound to may no longer exist, and the mismatch does not + /// surface until a call into the changed member throws MissingMethodException at runtime. + /// + public static DateTime EngineBuildTimeUtc() + { + string engineDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + + DateTime newest = DateTime.MinValue; + foreach (string name in new[] { "Prowl.Runtime.dll", "Prowl.Editor.dll" }) + { + string path = Path.Combine(engineDir, name); + if (!File.Exists(path)) continue; + + DateTime stamp = File.GetLastWriteTimeUtc(path); + if (stamp > newest) newest = stamp; + } + + return newest; + } + private static CompileResult RestorePackagesOnly(Project project, List units) { if (!ProjectDeclaresPackages(project)) diff --git a/Prowl.Runtime.Test/ProwlActionTests.cs b/Prowl.Runtime.Test/ProwlActionTests.cs new file mode 100644 index 000000000..366662293 --- /dev/null +++ b/Prowl.Runtime.Test/ProwlActionTests.cs @@ -0,0 +1,149 @@ +// 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 Xunit; + +namespace Prowl.Runtime.Test; + +/// +/// Tests for / : what a configured call invokes, and +/// what it reports when the target is gone or the target itself throws. The reporting matters as much +/// as the dispatch here - these calls are wired up in the inspector, so the log is all an author has. +/// +public class ProwlActionTests : RuntimeTestBase +{ + private sealed class CallTarget : MonoBehaviour + { + public int Calls; + public int LastInt; + + public void Ping() => Calls++; + public void PingInt(int value) { Calls++; LastInt = value; } + public void Boom() => throw new InvalidOperationException("the target's own failure"); + } + + private CallTarget MakeTarget() + { + var scene = CreateScene(enable: true); + var go = CreateGameObject("Target"); + var comp = go.AddComponent(); + scene.Add(go); + return comp; + } + + private static ProwlAction ActionFor(EngineObject? target, string member, + ProwlActionArgType argType = ProwlActionArgType.None, int intArg = 0) + { + var action = new ProwlAction(); + action.Calls.Add(new ProwlCall { Target = target, Member = member, ArgType = argType, IntArg = intArg }); + return action; + } + + /// Records everything logged while it is alive. + private sealed class LogCapture : IDisposable + { + private readonly List _messages = []; + + public LogCapture() => Debug.OnLog += Record; + + private void Record(string message, DebugStackTrace? trace, LogSeverity severity) => _messages.Add(message); + + public bool Logged(string text) => _messages.Exists(m => m.Contains(text, StringComparison.Ordinal)); + + public void Dispose() => Debug.OnLog -= Record; + } + + [Fact] + public void Invoke_CallsTheTargetMethod() + { + var target = MakeTarget(); + + ActionFor(target, nameof(CallTarget.Ping)).Invoke(); + + Assert.Equal(1, target.Calls); + } + + [Fact] + public void Invoke_PassesTheConfiguredArgument() + { + var target = MakeTarget(); + + ActionFor(target, nameof(CallTarget.PingInt), ProwlActionArgType.Int, intArg: 42).Invoke(); + + Assert.Equal(1, target.Calls); + Assert.Equal(42, target.LastInt); + } + + // EngineObject's == is reference equality, so a destroyed target is not null and reflection would + // happily call into it. Anything the method touches on the way (GameObject, Scene) is already gone. + [Fact] + public void Invoke_OnADestroyedTarget_DoesNotCallIt() + { + var target = MakeTarget(); + var action = ActionFor(target, nameof(CallTarget.Ping)); + + target.GameObject.Dispose(); + + using var log = new LogCapture(); + action.Invoke(); + + Assert.Equal(0, target.Calls); + Assert.True(log.Logged("null or destroyed"), "A call that silently did nothing is indistinguishable from a mis-wired one."); + } + + [Fact] + public void Invoke_OnANullTarget_IsANoOp() + { + var action = ActionFor(null, nameof(CallTarget.Ping)); + + action.Invoke(); // must not throw + } + + // Reflection wraps whatever the target threw in a TargetInvocationException whose message and + // stack are about reflection ("Exception has been thrown by the target of an invocation"), which + // says nothing about the actual fault. The report has to name what really went wrong. + [Fact] + public void Invoke_WhenTheTargetThrows_ReportsTheTargetsOwnException() + { + var target = MakeTarget(); + using var log = new LogCapture(); + + ActionFor(target, nameof(CallTarget.Boom)).Invoke(); + + Assert.True(log.Logged("the target's own failure"), "The target's message must reach the log."); + Assert.True(log.Logged(nameof(InvalidOperationException)), "So must its type."); + Assert.True(log.Logged("Target.Boom"), "And which call it was."); + Assert.False(log.Logged("target of an invocation"), "The reflection wrapper is noise."); + } + + // The DontDestroyOnLoad trap. A saved reference to an object outside the scene's own graph is + // written by value rather than as a link, so loading rebuilds it as a copy with no GameObject. + // The call then fails somewhere inside the author's own method, with nothing pointing at the wiring. + [Fact] + public void Invoke_OnADetachedTarget_SaysTheTargetIsDetached() + { + var target = MakeTarget(); + var detached = (CallTarget)Prowl.Echo.Serializer.Deserialize( + Prowl.Echo.Serializer.Serialize(typeof(MonoBehaviour), target))!; + + Assert.True(detached.GameObject.IsNotValid(), "Precondition: an out-of-graph reference loads back detached."); + + using var log = new LogCapture(); + ActionFor(detached, nameof(CallTarget.Boom)).Invoke(); + + Assert.True(log.Logged("detached"), "The wiring, not just the throw site, is what the author has to fix."); + } + + [Fact] + public void Invoke_WhenACallThrows_TheRestStillRun() + { + var target = MakeTarget(); + var action = ActionFor(target, nameof(CallTarget.Boom)); + action.Calls.Add(new ProwlCall { Target = target, Member = nameof(CallTarget.Ping) }); + + using var log = new LogCapture(); + action.Invoke(); + + Assert.Equal(1, target.Calls); + } +} diff --git a/Prowl.Runtime.Test/RuntimeTestBase.cs b/Prowl.Runtime.Test/RuntimeTestBase.cs index 1bcdf0956..2085205ee 100644 --- a/Prowl.Runtime.Test/RuntimeTestBase.cs +++ b/Prowl.Runtime.Test/RuntimeTestBase.cs @@ -149,6 +149,10 @@ protected int TickUntil(Scene scene, Func condition, int maxTicks = 240) public virtual void Dispose() { + // DontDestroyOnLoad is static state, so anything a test preserved would still be in the + // registry for the next one. Immediate teardown: no frame follows to drain a destroy queue. + Scene.DestroyPreserved(immediate: true); + foreach (var scene in _scenes) { if (scene.IsDisposed) continue; diff --git a/Prowl.Runtime.Test/SceneManagementTests.cs b/Prowl.Runtime.Test/SceneManagementTests.cs index 39d73fb7e..b6a1e2bad 100644 --- a/Prowl.Runtime.Test/SceneManagementTests.cs +++ b/Prowl.Runtime.Test/SceneManagementTests.cs @@ -609,6 +609,77 @@ public void DontDestroyOnLoad_ADestroyedObject_IsDroppedNotResurrected() Assert.Empty(second.AllObjects); } + // Leaving play mode ends the preservation session. Without this the objects a play session kept + // alive would ride the swap into the authoring scene the editor restores behind it, and stay there. + [Fact] + public void DestroyPreserved_KeepsThemOutOfTheNextScene() + { + var play = CreateScene(enable: true); + var keeper = CreateGameObject("Keeper"); + play.Add(keeper); + Scene.Load(play); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(keeper); + + Scene.DestroyPreserved(); + + // Same order the game loop uses: the destroy queue drains, then the scene swap applies. + var restored = CreateScene(); + Scene.Load(restored); + EngineObject.ProcessDestroyed(); + Scene.ProcessPendingLoad(); + + Assert.True(keeper.IsDisposed); + Assert.Empty(restored.AllObjects); + } + + // Queued like any other Destroy, so teardown lands at the end of the frame rather than under + // whatever was running when the play button was clicked. + [Fact] + public void DestroyPreserved_TearsDownAtTheEndOfTheFrame() + { + var scene = CreateScene(enable: true); + var keeper = CreateGameObject("Keeper"); + var comp = keeper.AddComponent(); + scene.Add(keeper); + Scene.Load(scene); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(keeper); + + Scene.DestroyPreserved(); + + Assert.False(keeper.IsDisposed); + Assert.Equal(0, comp.Disables); + + EngineObject.ProcessDestroyed(); + + Assert.True(keeper.IsDisposed); + Assert.Equal(1, comp.Disables); // torn down properly, not just dropped from the registry + } + + [Fact] + public void Shutdown_DestroysPreservedObjects() + { + var scene = CreateScene(enable: true); + var keeper = CreateGameObject("Keeper"); + scene.Add(keeper); + Scene.Load(scene); + Scene.ProcessPendingLoad(); + Scene.DontDestroyOnLoad(keeper); + + Scene.Shutdown(); + + // No frame follows a shutdown, so nothing would drain a destroy queue: teardown is immediate. + Assert.True(keeper.IsDisposed); + Assert.True(scene.IsDisposed); + + // And the registry is empty, so the next run does not inherit the last one's objects. + var next = CreateScene(); + Scene.Load(next); + Scene.ProcessPendingLoad(); + Assert.Empty(next.AllObjects); + } + [Fact] public void Load_SkipsASceneDisposedBeforeItApplied() { diff --git a/Prowl.Runtime/Resources/Scene.cs b/Prowl.Runtime/Resources/Scene.cs index 902526347..d2c76059b 100644 --- a/Prowl.Runtime/Resources/Scene.cs +++ b/Prowl.Runtime/Resources/Scene.cs @@ -91,6 +91,29 @@ public static void DontDestroyOnLoad(GameObject go) public static void CancelDontDestroyOnLoad(GameObject go) => _preserved.RemoveAll(p => ReferenceEquals(p, go)); + /// + /// Destroys everything is holding and empties the registry. + /// + /// Teardown is queued like any other , so it lands at the end of + /// the frame, which is before the scene swap and therefore before anything could be carried over. + /// Pass when no further frame will run, since nothing would be left + /// to drain the queue. + /// + internal static void DestroyPreserved(bool immediate = false) + { + foreach (GameObject go in _preserved) + { + if (go.IsNotValid()) continue; + + if (immediate) + go.Dispose(); + else + go.Destroy(); + } + + _preserved.Clear(); + } + /// Whether this GameObject (or the root it belongs to) survives scene loads. public static bool IsPreserved(GameObject go) { @@ -155,6 +178,8 @@ internal static void Shutdown() { _pendingScene = null; + DestroyPreserved(immediate: true); + if (_current is null || _current.IsDisposed) { _current = null; diff --git a/Prowl.Runtime/Utils/ProwlAction.cs b/Prowl.Runtime/Utils/ProwlAction.cs index 6ebb34ddc..16aaff482 100644 --- a/Prowl.Runtime/Utils/ProwlAction.cs +++ b/Prowl.Runtime/Utils/ProwlAction.cs @@ -71,7 +71,17 @@ public sealed class ProwlCall /// public void Invoke() { - if (_target == null || string.IsNullOrEmpty(_member)) return; + if (string.IsNullOrEmpty(_member)) return; + + // Not `_target == null`: EngineObject's == is reference equality, so that test lets a + // destroyed target through and reflection happily calls into the corpse. Common with objects + // whose lifetime differs from the scene holding the reference - a DontDestroyOnLoad object + // outliving the scene, or a scene object outliving nothing at all. + if (_target.IsNotValid()) + { + Debug.LogWarning($"[ProwlAction] '{_member}' was not called: its target is null or destroyed."); + return; + } Type type = _target.GetType(); object? arg = ArgValue(); @@ -79,7 +89,8 @@ public void Invoke() MethodInfo? method = FindMethod(type, _member, _argType); if (method != null) { - method.Invoke(_target, _argType == ProwlActionArgType.None ? null : new[] { arg }); + method.Invoke(_target, BindingFlags.DoNotWrapExceptions, null, + _argType == ProwlActionArgType.None ? null : new[] { arg }, null); return; } @@ -100,6 +111,17 @@ public void Invoke() Debug.LogWarning($"[ProwlAction] Member '{_member}' not found on {type.Name}."); } + internal string Describe() + => $"{(_target.IsValid() ? _target.Name : "")}.{(string.IsNullOrEmpty(_member) ? "" : _member)}"; + + + internal string? DiagnoseTarget() + => _target is MonoBehaviour component && component.GameObject.IsNotValid() + ? $"Its target '{_target.Name}' is a detached {_target.GetType().Name}: no GameObject behind it, " + + "which is what a reference to an object outside the saved scene loads back as. Re-wire the " + + "call at runtime instead of saving a cross-scene reference." + : null; + private static MethodInfo? FindMethod(Type type, string name, ProwlActionArgType argType) { foreach (MethodInfo m in type.GetMethods(BindingFlags.Public | BindingFlags.Instance)) @@ -141,7 +163,14 @@ public void Invoke() try { _calls[i].Invoke(); } catch (Exception ex) { - Debug.LogError($"[ProwlAction] Call {i} threw: {ex.Message}\n{ex.StackTrace}"); + Exception fault = ex is TargetInvocationException { InnerException: not null } wrapper + ? wrapper.InnerException + : ex; + + string hint = _calls[i].DiagnoseTarget() is { } diagnosis ? $"\n{diagnosis}" : ""; + + Debug.LogError($"[ProwlAction] Call {i} ({_calls[i].Describe()}) threw " + + $"{fault.GetType().Name}: {fault.Message}{hint}\n{fault.StackTrace}"); } } } From 4d70b7ad7692a69d112444d4b24eceeb45ac2f6b Mon Sep 17 00:00:00 2001 From: Paolo Date: Wed, 5 Aug 2026 19:57:28 +0200 Subject: [PATCH 25/67] Modified script recompile to be on project load, so that any outstanding changes will be always caught --- Prowl.Editor.Test/ScriptCompilationTests.cs | 19 ------------ Prowl.Editor/Core/EditorApplication.cs | 6 ++++ Prowl.Editor/Program.cs | 3 ++ .../Scripting/ScriptAssemblyManager.cs | 30 ------------------- .../Projects/Scripting/ScriptCompiler.cs | 23 -------------- 5 files changed, 9 insertions(+), 72 deletions(-) diff --git a/Prowl.Editor.Test/ScriptCompilationTests.cs b/Prowl.Editor.Test/ScriptCompilationTests.cs index 22fa9e722..70465b610 100644 --- a/Prowl.Editor.Test/ScriptCompilationTests.cs +++ b/Prowl.Editor.Test/ScriptCompilationTests.cs @@ -185,25 +185,6 @@ public void ProjectNamedLikeEnginePrefix_StillReferencesEngineDlls() finally { TryDeleteDir(parent); } } - // Script files are the only input the recompile rule watches, so an engine rebuilt underneath an - // unchanged project leaves assemblies bound to an API that may no longer exist - surfacing much - // later as a MissingMethodException from whichever call site happened to run first. - [Fact] - public void ScriptsPredateEngine_TracksTheEngineBuild_NotJustTheScripts() - { - WriteScript("Persistent.cs", "using Prowl.Runtime; public class Persistent : MonoBehaviour { }"); - var result = ScriptCompiler.CompileAll(Project); - Assert.True(result.Success, $"Compile failed:\n{result.Errors}"); - - // Freshly compiled: the assembly is younger than the engine it was built against. - Assert.False(ScriptAssemblyManager.ScriptsPredateEngine(Project)); - - // As if the engine had been rebuilt after that compile. - File.SetLastWriteTimeUtc(Project.GameAssemblyPath, DateTime.UtcNow.AddDays(-1)); - - Assert.True(ScriptAssemblyManager.ScriptsPredateEngine(Project)); - } - private string InvokeVersionedTag() { // Load by bytes so the file stays unlocked for the next recompile. diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index fca08b9fe..fe9d4ac91 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -99,6 +99,9 @@ public override void Initialize() // Load user script assemblies before registry scanning ScriptAssemblyManager.LoadAssemblies(project); + // Request a full recompile of scripts so that any missing API or compiler error can be caught right away + ScriptAssemblyManager.RequestRecompile(); + projectAlreadyInitialized = true; Window.InternalWindow.Title = $"Prowl Editor - {project.Name}"; } @@ -416,6 +419,9 @@ public override void BeginGui(Paper paper) // Load user script assemblies and re-register all types ScriptAssemblyManager.LoadAssemblies(Project.Current); + // Request a full recompile of scripts so that any missing API or compiler error can be caught right away + ScriptAssemblyManager.RequestRecompile(); + // Rebuild the scan-based registries (mesh features, menu items) against the loaded assemblies. ReinitializeRegistries(); diff --git a/Prowl.Editor/Program.cs b/Prowl.Editor/Program.cs index 6b96d44ba..061ee7325 100644 --- a/Prowl.Editor/Program.cs +++ b/Prowl.Editor/Program.cs @@ -103,6 +103,9 @@ public static void Main(string[] args) // Load user script assemblies before registry scanning ScriptAssemblyManager.LoadAssemblies(project); + // Request a full recompile of scripts so that any missing API or compiler error can be caught right away + ScriptAssemblyManager.RequestRecompile(); + // Initialize asset database for the already-opened project var db = new EditorAssetBackend(Project.Current!); db.Initialize(); diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 892038827..9bd58badd 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -248,36 +248,6 @@ public static void LoadAssemblies(Project project) // Load every produced user assembly in dependency order. foreach (var dll in ScriptCompiler.GetEditorAssemblyPaths(project)) LoadAssembly(dll, Path.GetFileNameWithoutExtension(dll)); - - if (ScriptsPredateEngine(project)) - { - Runtime.Debug.Log("[Scripts] The engine has been rebuilt since these scripts were compiled; recompiling them."); - RequestRecompile(); - } - } - - /// - /// Whether the compiled user assemblies predate the engine they are about to run against. - /// - /// They were built against whatever engine was running at the time, so one rebuilt since can have - /// moved or dropped a member they still call - and nothing else notices, because the script files - /// themselves are unchanged and that is the only thing the recompile rule looks at. The mismatch - /// then waits until the affected code path runs and throws MissingMethodException, by which point - /// nothing points back at the engine change that caused it. - /// - internal static bool ScriptsPredateEngine(Project project) - { - DateTime oldestBuilt = DateTime.MaxValue; - - foreach (var dll in ScriptCompiler.GetEditorAssemblyPaths(project)) - { - if (!File.Exists(dll)) continue; - - DateTime built = File.GetLastWriteTimeUtc(dll); - if (built < oldestBuilt) oldestBuilt = built; - } - - return oldestBuilt != DateTime.MaxValue && ScriptCompiler.EngineBuildTimeUtc() > oldestBuilt; } /// Snapshot the project's plugins so the resolvers can satisfy user-assembly imports. diff --git a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs index 2699018b5..2facf4b64 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs @@ -127,29 +127,6 @@ public static CompileResult CompileAll(Project project) /// packages (a bad name or version surfaces now instead of only after the first script is written) /// and lets IDEs resolve them. Returns success with nothing to reload when there is nothing to do. /// - /// - /// When the engine the scripts compile against was last built. User assemblies are compiled - /// against the running editor's own binaries, so a newer engine invalidates them exactly like an - /// edited script does: the API they were bound to may no longer exist, and the mismatch does not - /// surface until a call into the changed member throws MissingMethodException at runtime. - /// - public static DateTime EngineBuildTimeUtc() - { - string engineDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - - DateTime newest = DateTime.MinValue; - foreach (string name in new[] { "Prowl.Runtime.dll", "Prowl.Editor.dll" }) - { - string path = Path.Combine(engineDir, name); - if (!File.Exists(path)) continue; - - DateTime stamp = File.GetLastWriteTimeUtc(path); - if (stamp > newest) newest = stamp; - } - - return newest; - } - private static CompileResult RestorePackagesOnly(Project project, List units) { if (!ProjectDeclaresPackages(project)) From 7207cac858260bc770dcaf79bcb89c475fd73949 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 13:54:25 +1000 Subject: [PATCH 26/67] Trigger script recompile on asset moves --- Prowl.Editor.Test/AssetDatabaseTests.cs | 26 ++++++++++ Prowl.Editor.Test/ScriptCompilationTests.cs | 40 ++++++++++++++++ .../AssetsDatabase/EditorAssetBackend.cs | 48 +++++++++++++++++-- .../Projects/Scripting/RoslynScriptBackend.cs | 17 +++++-- .../Scripting/ScriptAssemblyManager.cs | 3 ++ .../Projects/Scripting/ScriptCompiler.cs | 2 +- 6 files changed, 127 insertions(+), 9 deletions(-) diff --git a/Prowl.Editor.Test/AssetDatabaseTests.cs b/Prowl.Editor.Test/AssetDatabaseTests.cs index 044a87bac..1dd089ef4 100644 --- a/Prowl.Editor.Test/AssetDatabaseTests.cs +++ b/Prowl.Editor.Test/AssetDatabaseTests.cs @@ -322,6 +322,32 @@ public void MoveFolder_PreservesGuids_RemapsPaths() Assert.True(File.Exists(AssetAbsolutePath("New/S.scene"))); } + // A moved script keeps its content and timestamp, so nothing else asks for a recompile - but the + // generated csproj still points at the old path and the owning assembly may have changed. + [Fact] + public void MoveAsset_Script_RequestsRecompile() + { + WriteScript("Moved.cs", "public class Moved { }"); + Assets.Refresh(); + Projects.Scripting.ScriptAssemblyManager.RecompilePending = false; + + Assert.True(Assets.MoveAsset("Moved.cs", "Sub/Moved.cs")); + + Assert.True(Projects.Scripting.ScriptAssemblyManager.RecompilePending); + } + + [Fact] + public void MoveFolder_WithScripts_RequestsRecompile() + { + WriteScript("Old/Moved.cs", "public class Moved { }"); + Assets.Refresh(); + Projects.Scripting.ScriptAssemblyManager.RecompilePending = false; + + Assert.True(Assets.MoveFolder("Old", "New")); + + Assert.True(Projects.Scripting.ScriptAssemblyManager.RecompilePending); + } + [Fact] public void DeleteAsset_RemovesFileMetaIndexAndCache() { diff --git a/Prowl.Editor.Test/ScriptCompilationTests.cs b/Prowl.Editor.Test/ScriptCompilationTests.cs index 70465b610..ed946d579 100644 --- a/Prowl.Editor.Test/ScriptCompilationTests.cs +++ b/Prowl.Editor.Test/ScriptCompilationTests.cs @@ -185,6 +185,46 @@ public void ProjectNamedLikeEnginePrefix_StillReferencesEngineDlls() finally { TryDeleteDir(parent); } } + // Moving a script leaves its content and timestamp untouched, so the compiler has to notice the + // path change itself: the csproj must list the new location and the assembly must be rebuilt. + [Fact] + public void MovedScript_UpdatesCsprojAndRebuilds() + { + WriteScript("Movable.cs", "public static class Movable { public static int V() => 1; }"); + Assert.True(ScriptCompiler.CompileAll(Project).Success); + + string from = AssetAbsolutePath("Movable.cs"); + string to = AssetAbsolutePath(Path.Combine("Sub", "Movable.cs")); + Directory.CreateDirectory(Path.GetDirectoryName(to)!); + File.Move(from, to); + + var result = ScriptCompiler.CompileAll(Project); + Assert.True(result.Success, $"Compile failed:\n{result.Errors}"); + Assert.True(result.RequiresReload, "A moved script changes the unit's file set, so it must rebuild."); + + string csproj = File.ReadAllText(Project.GameCsprojPath); + Assert.Contains(Path.GetRelativePath(Project.RootPath, to), csproj); + Assert.DoesNotContain(Path.GetRelativePath(Project.RootPath, from), csproj); + } + + // Moving a script into an Editor folder hands it to the editor assembly, without any edit to the file. + [Fact] + public void ScriptMovedIntoEditorFolder_SwitchesAssembly() + { + WriteScript("Relocated.cs", "public class Relocated { }"); + WriteScript("Keeper.cs", "public class Keeper { }"); // keeps the game assembly in the build + Assert.True(ScriptCompiler.CompileAll(Project).Success); + Assert.NotNull(Assembly.Load(File.ReadAllBytes(Project.GameAssemblyPath)).GetType("Relocated")); + + string to = AssetAbsolutePath(Path.Combine("Editor", "Relocated.cs")); + Directory.CreateDirectory(Path.GetDirectoryName(to)!); + File.Move(AssetAbsolutePath("Relocated.cs"), to); + + Assert.True(ScriptCompiler.CompileAll(Project).Success); + Assert.Null(Assembly.Load(File.ReadAllBytes(Project.GameAssemblyPath)).GetType("Relocated")); + Assert.NotNull(Assembly.Load(File.ReadAllBytes(Project.EditorAssemblyPath)).GetType("Relocated")); + } + private string InvokeVersionedTag() { // Load by bytes so the file stays unlocked for the next recompile. diff --git a/Prowl.Editor/AssetsDatabase/EditorAssetBackend.cs b/Prowl.Editor/AssetsDatabase/EditorAssetBackend.cs index 772513339..9d8ab903b 100644 --- a/Prowl.Editor/AssetsDatabase/EditorAssetBackend.cs +++ b/Prowl.Editor/AssetsDatabase/EditorAssetBackend.cs @@ -1326,11 +1326,29 @@ public void DeleteAsset(string relativePath) OnAssetsDeleted?.Invoke(new[] { relativePath }); _folderIndexDirty = true; - // Script deleted - trigger recompile - if (relativePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + if (AffectsCompilation(relativePath)) ScriptAssemblyManager.RequestRecompile(); } + /// + /// True for files whose path feeds script compilation: sources, assembly definitions (they own + /// scripts by folder) and managed plugins. Moving one changes the generated csproj and which + /// assembly a script lands in, so it has to recompile even though no file content changed. + /// + private static bool AffectsCompilation(string path) + { + string ext = Path.GetExtension(path); + return ext.Equals(".cs", StringComparison.OrdinalIgnoreCase) + || ext.Equals(AssemblyDefinitionDatabase.Extension, StringComparison.OrdinalIgnoreCase) + || ext.Equals(".dll", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsCompilationInput(string directory) + { + try { return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Any(AffectsCompilation); } + catch { return false; } + } + /// /// Move/rename an asset. The GUID stays the same. /// @@ -1399,6 +1417,9 @@ public bool MoveAsset(string oldRelativePath, string newRelativePath) MetadataCache.Save(_project.MetadataDbPath, _guidToEntry.Values); OnAssetMoved?.Invoke(oldRelativePath, newRelativePath); _folderIndexDirty = true; + + if (AffectsCompilation(oldRelativePath) || AffectsCompilation(newRelativePath)) + ScriptAssemblyManager.RequestRecompile(); return true; } @@ -1460,8 +1481,10 @@ public bool MoveFolder(string oldRelativeFolder, string newRelativeFolder) return false; } + bool recompile = false; foreach (var (oldPath, newPath, guid) in toRemap) { + recompile |= AffectsCompilation(oldPath); _pathToGuid.Remove(oldPath); _pathToGuid[newPath] = guid; if (_guidToEntry.TryGetValue(guid, out var entry)) @@ -1479,6 +1502,9 @@ public bool MoveFolder(string oldRelativeFolder, string newRelativeFolder) MetadataCache.Save(_project.MetadataDbPath, _guidToEntry.Values); _folderIndexDirty = true; + + if (recompile) + ScriptAssemblyManager.RequestRecompile(); return true; } @@ -1630,7 +1656,13 @@ private void ProcessFileEvent(FileEvent evt, List imported, List _folderIndexDirty = true; // Skip directory events - ScanAssets handles directory .meta creation - if (Directory.Exists(evt.Path)) return; + if (Directory.Exists(evt.Path)) + { + // A renamed folder relocates everything under it without any per-file event. + if (evt.Type == FileEventType.Renamed && ContainsCompilationInput(evt.Path)) + ScriptAssemblyManager.RequestRecompile(); + return; + } string relativePath = ToRelativePath(evt.Path); @@ -1666,8 +1698,7 @@ private void ProcessFileEvent(FileEvent evt, List imported, List deleted.Add(relativePath); - // Script deleted trigger recompile - if (relativePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + if (AffectsCompilation(relativePath)) ScriptAssemblyManager.RequestRecompile(); } break; @@ -1678,6 +1709,13 @@ private void ProcessFileEvent(FileEvent evt, List imported, List if (evt.OldPath != null) { string oldRelative = ToRelativePath(evt.OldPath); + + // A move keeps the file's timestamp and content, so nothing else here asks for a + // recompile, yet the csproj still lists the old path and asmdef ownership may + // have changed. + if (AffectsCompilation(oldRelative) || AffectsCompilation(relativePath)) + ScriptAssemblyManager.RequestRecompile(); + if (!_pathToGuid.TryGetValue(oldRelative, out var guid)) { // The old path was never tracked e.g. the "write-to-temp-then-rename- diff --git a/Prowl.Editor/Projects/Scripting/RoslynScriptBackend.cs b/Prowl.Editor/Projects/Scripting/RoslynScriptBackend.cs index b8bab38e9..7e0ae2e90 100644 --- a/Prowl.Editor/Projects/Scripting/RoslynScriptBackend.cs +++ b/Prowl.Editor/Projects/Scripting/RoslynScriptBackend.cs @@ -236,14 +236,25 @@ private static List BuildReferences( var refs = new List(); // Framework + engine assemblies: everything the editor process itself has loaded. An asmdef - // that opts out of engine references excludes the assemblies living in the engine folder. + // that opts out of engine references excludes the assemblies living in the engine folder, + // except the shared framework: a self-contained editor publish drops the whole .NET runtime + // there too, and dropping it would leave the compile without System.Object (CS0518). string tpa = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string ?? ""; foreach (var path in tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { - if (unit.NoEngineReferences && IsUnderDirectory(path, engineDir)) continue; - AddFileReference(refs, seen, Path.GetFileNameWithoutExtension(path), path); + string name = Path.GetFileNameWithoutExtension(path); + if (unit.NoEngineReferences && !ScriptCompiler.IsFrameworkAssembly(name) && IsUnderDirectory(path, engineDir)) + continue; + AddFileReference(refs, seen, name, path); } + // Nothing above is guaranteed to land: an empty TPA list, or a filter that goes wrong, leaves a + // compile with no System.Object at all (CS0518). Make sure the running corelib is in there. + var corelib = typeof(object).Assembly; + string corelibName = corelib.GetName().Name ?? "System.Private.CoreLib"; + if (!seen.Contains(corelibName)) + AddFileReference(refs, seen, corelibName, corelib.Location); + // User scripts resolve [ReloadIgnore], [ReloadInitializer] and the reload interfaces out of // Prowl.Ember.Contracts. It reaches the editor as a lazily loaded transitive dependency, so it may not be // in the trusted-platform-assemblies list above and has to be referenced explicitly. diff --git a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs index 9bd58badd..67607aeb2 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptAssemblyManager.cs @@ -81,6 +81,9 @@ public static IEnumerable LiveAssemblies() } } + /// Pending-recompile flag, so tests can assert that a change asked for one. + internal static bool RecompilePending { get => _recompileRequested; set => _recompileRequested = value; } + /// Signal that scripts have changed and need recompilation. public static void RequestRecompile() { diff --git a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs index 2facf4b64..d7537b9b6 100644 --- a/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs +++ b/Prowl.Editor/Projects/Scripting/ScriptCompiler.cs @@ -508,7 +508,7 @@ private static void AppendReference(StringBuilder sb, HashSet emitted, s /// reference assemblies for all of these, so a generated csproj must never reference the runtime's /// own implementation copies (which sit in the engine folder only on a self-contained publish). /// - private static bool IsFrameworkAssembly(string name) + internal static bool IsFrameworkAssembly(string name) { if (name.StartsWith("System.", StringComparison.OrdinalIgnoreCase)) return true; if (name.StartsWith("Microsoft.VisualBasic", StringComparison.OrdinalIgnoreCase)) return true; From 497265bd34344f903ae7b1969046c27832408076 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 13:54:45 +1000 Subject: [PATCH 27/67] Fix UI anchor parent rect resolution --- Prowl.Editor/GUI/SceneView/UISceneEditor.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Prowl.Editor/GUI/SceneView/UISceneEditor.cs b/Prowl.Editor/GUI/SceneView/UISceneEditor.cs index 330265f88..c87427a1a 100644 --- a/Prowl.Editor/GUI/SceneView/UISceneEditor.cs +++ b/Prowl.Editor/GUI/SceneView/UISceneEditor.cs @@ -185,7 +185,7 @@ public override void OnSceneInput(SceneToolContext toolCtx) upW = Float3.Normalize(upW); Float3 normalW = Float3.Normalize(Float3.Cross(rightW, upW)); - Rect parentRect = ResolveParentRect(rt, canvas.RootRect); + Rect parentRect = ResolveParentRect(rt, canvas); Float3 camPos = ctx.Camera.GameObject.Transform.Position; Float3 centerW = Float4x4.TransformPoint( @@ -604,14 +604,18 @@ private void EndDrag(HandleContext ctx) _moveIsClick = false; } - private static Rect ResolveParentRect(RectTransform rt, Rect canvasRootRect) + private static Rect ResolveParentRect(RectTransform rt, GameCanvas canvas) { GameObject? parentGo = rt.GameObject.Parent; - RectTransform? parent = parentGo.IsValid() ? parentGo.RectTransform : null; - // A top-level element's parent is the canvas, which has no RectTransform. Anchor against the - // canvas ROOT rect - never the element's own rect, which would move/resize with the element - // and feed back into layout (the anchor reference shifting each frame = the drag jitter). - return parent != null ? parent.ComputedRect : canvasRootRect; + // A top-level element lays out against the canvas ROOT rect. The canvas GameObject may still + // carry a RectTransform, but the rebuild never lays it out, so its ComputedRect is empty and + // using it would place every anchor at the design origin - the element jumps by half the + // canvas the moment a drag applies a rect. Never the element's own rect either, which would + // move with the element and feed back into layout. + RectTransform? parent = parentGo.IsValid() && !ReferenceEquals(parentGo, canvas.GameObject) + ? parentGo.RectTransform + : null; + return parent != null ? parent.ComputedRect : canvas.RootRect; } private void RegisterUndo(GameObject go, LayoutState before, LayoutState after) From d12f1617cfed4b74c6c5e262373684ca5b1f6252 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 15:04:38 +1000 Subject: [PATCH 28/67] Fixes to MeshCollider --- Prowl.Runtime.Test/PhysicsTests.cs | 22 +++++++++++++++++++ .../Physics/Colliders/MeshCollider.cs | 13 +++++++++-- Prowl.Runtime/Physics/BakedPhysicsMesh.cs | 6 ++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Prowl.Runtime.Test/PhysicsTests.cs b/Prowl.Runtime.Test/PhysicsTests.cs index 6ea836dfd..cb1a1d814 100644 --- a/Prowl.Runtime.Test/PhysicsTests.cs +++ b/Prowl.Runtime.Test/PhysicsTests.cs @@ -339,6 +339,28 @@ public void MeshCollider_Concave_RegistersTriangleMesh() $"Body should rest on the concave mesh, was at y={body.Transform.Position.Y}"); } + // Jitter drops degenerate triangles while baking, so the shape count has to come from the baked + // mesh and not from the source triangle soup - indexing by the soup count runs off the end. + [Fact] + public void MeshCollider_Concave_MeshWithDegenerateTriangle_BuildsShapes() + { + var mesh = new Mesh + { + Vertices = [new Float3(0, 0, 0), new Float3(1, 0, 0), new Float3(0, 0, 1), new Float3(2, 0, 0)] + }; + mesh.Indices = [0, 1, 2, 0, 0, 3]; // the second triangle has zero area + + var go = CreateGameObject("DegenerateMesh"); + var mc = go.AddComponent(); + mc.Mesh = mesh; + mc.Convex = false; + + var shapes = mc.CreateShapes(); + + Assert.NotNull(shapes); + Assert.Single(shapes); + } + [Fact] public void MeshCollider_Convex_RegistersHull() { diff --git a/Prowl.Runtime/Components/Physics/Colliders/MeshCollider.cs b/Prowl.Runtime/Components/Physics/Colliders/MeshCollider.cs index fe53af671..0ef61f3f1 100644 --- a/Prowl.Runtime/Components/Physics/Colliders/MeshCollider.cs +++ b/Prowl.Runtime/Components/Physics/Colliders/MeshCollider.cs @@ -88,9 +88,18 @@ public override RigidBodyShape[] CreateShapes() } else { + // Degenerate triangles are dropped from the baked mesh, so its triangle count is what + // indexes into it - the source soup can hold more. var triMesh = baked.TriangleMesh; - var shapes = new TriangleShape[baked.Triangles.Count]; - for (int i = 0; i < shapes.Length; i++) + int count = triMesh.Indices.Length; + if (count == 0) + { + Debug.LogWarning("MeshCollider: mesh has no non-degenerate triangles."); + return null; + } + + var shapes = new TriangleShape[count]; + for (int i = 0; i < count; i++) shapes[i] = new TriangleShape(triMesh, i); return shapes; } diff --git a/Prowl.Runtime/Physics/BakedPhysicsMesh.cs b/Prowl.Runtime/Physics/BakedPhysicsMesh.cs index 4612fe97f..08e4f19fa 100644 --- a/Prowl.Runtime/Physics/BakedPhysicsMesh.cs +++ b/Prowl.Runtime/Physics/BakedPhysicsMesh.cs @@ -24,7 +24,11 @@ public sealed class BakedPhysicsMesh /// The triangle soup in mesh-local space. Used to build convex hulls. public IReadOnlyList Triangles { get; } - /// The concave triangle mesh, shareable across many s. + /// + /// The concave triangle mesh, shareable across many s. It holds only the + /// non-degenerate triangles, so it can contain fewer than ; index into it by + /// its own Indices.Length, never by the soup count. + /// public TriangleMesh TriangleMesh { get; } /// The this was baked from, used to detect staleness. From 645b2881db5e3f4d9f6bc6cf04ac4b3095bca201 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 23:36:00 +1000 Subject: [PATCH 29/67] Treat indexless UI meshes as empty --- Prowl.Runtime/Components/UI/UIMeshBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prowl.Runtime/Components/UI/UIMeshBuilder.cs b/Prowl.Runtime/Components/UI/UIMeshBuilder.cs index 699f650f5..c630acfb4 100644 --- a/Prowl.Runtime/Components/UI/UIMeshBuilder.cs +++ b/Prowl.Runtime/Components/UI/UIMeshBuilder.cs @@ -18,7 +18,7 @@ public sealed class UIMeshBuilder public int VertexCount => _verts.Count; public int IndexCount => _indices.Count; - public bool IsEmpty => _verts.Count == 0; + public bool IsEmpty => _verts.Count == 0 || _indices.Count == 0; // ---------- UV sub-rect remap ---------- // Generation-time UVs are authored in 0..1 space. When drawing a Sprite that is a sub-rect of an From 66ef2eaae6fc3b5105c36c693a2bb360ca914770 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 23:37:36 +1000 Subject: [PATCH 30/67] Add more utilities to RectTransform --- Prowl.Runtime/Components/UI/RectTransform.cs | 149 +++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/Prowl.Runtime/Components/UI/RectTransform.cs b/Prowl.Runtime/Components/UI/RectTransform.cs index 722dcd42a..cb4f1ab9d 100644 --- a/Prowl.Runtime/Components/UI/RectTransform.cs +++ b/Prowl.Runtime/Components/UI/RectTransform.cs @@ -136,6 +136,155 @@ public Rect ComputeRect(Rect parentRect) return ComputedRect; } + // ============================================================ + // Derived rect accessors + // ============================================================ + + /// Which parent edge anchors to. + public enum Edge { Left, Right, Top, Bottom } + + /// Axis selector for . + public enum Axis { Horizontal, Vertical } + + /// + /// The laid-out rect in this element's own space, with the pivot at the origin - the same space + /// meshes are generated in. Valid after the owning canvas has run its layout. + /// + public Rect Rect + { + get + { + Float2 size = ComputedRect.Size; + return new Rect(-_pivot.X * size.X, -_pivot.Y * size.Y, + (1f - _pivot.X) * size.X, (1f - _pivot.Y) * size.Y); + } + } + + /// Offset of the lower-left corner from the lower-left anchor. Setting it moves that + /// corner, resizing the element rather than translating it. + public Float2 OffsetMin + { + get => _anchoredPosition - new Float2(_sizeDelta.X * _pivot.X, _sizeDelta.Y * _pivot.Y); + set + { + Float2 delta = value - OffsetMin; + SizeDelta = _sizeDelta - delta; + AnchoredPosition = _anchoredPosition + new Float2(delta.X * (1f - _pivot.X), delta.Y * (1f - _pivot.Y)); + } + } + + /// Offset of the upper-right corner from the upper-right anchor. Setting it moves that + /// corner, resizing the element rather than translating it. + public Float2 OffsetMax + { + get => _anchoredPosition + new Float2(_sizeDelta.X * (1f - _pivot.X), _sizeDelta.Y * (1f - _pivot.Y)); + set + { + Float2 delta = value - OffsetMax; + SizeDelta = _sizeDelta + delta; + AnchoredPosition = _anchoredPosition + new Float2(delta.X * _pivot.X, delta.Y * _pivot.Y); + } + } + + /// plus the Transform's Z, which is the one positional axis + /// the layout does not drive. + public Float3 AnchoredPosition3D + { + get => new(_anchoredPosition.X, _anchoredPosition.Y, LocalPosition.Z); + set + { + LocalPosition = new Float3(LocalPosition.X, LocalPosition.Y, value.Z); + AnchoredPosition = new Float2(value.X, value.Y); + } + } + + /// + /// Pins the element to one parent edge at a fixed inset and size on that axis, collapsing the + /// anchors on it. The other axis keeps whatever anchoring it had. + /// + public void SetInsetAndSizeFromParentEdge(Edge edge, float inset, float size) + { + bool horizontal = edge is Edge.Left or Edge.Right; + bool atMax = edge is Edge.Right or Edge.Top; + float anchor = atMax ? 1f : 0f; + + Float2 min = _anchorMin, max = _anchorMax, sd = _sizeDelta, ap = _anchoredPosition; + if (horizontal) + { + min.X = max.X = anchor; + sd.X = size; + ap.X = atMax ? -inset - size * (1f - _pivot.X) : inset + size * _pivot.X; + } + else + { + min.Y = max.Y = anchor; + sd.Y = size; + ap.Y = atMax ? -inset - size * (1f - _pivot.Y) : inset + size * _pivot.Y; + } + + AnchorMin = min; + AnchorMax = max; + SizeDelta = sd; + AnchoredPosition = ap; + } + + /// Resizes one axis to an absolute pixel size without touching the anchors, by solving + /// for the that produces it against the current anchor span. + public void SetSizeWithCurrentAnchors(Axis axis, float size) + { + Float2 parent = ParentSize(); + Float2 sd = _sizeDelta; + if (axis == Axis.Horizontal) sd.X = size - parent.X * (_anchorMax.X - _anchorMin.X); + else sd.Y = size - parent.Y * (_anchorMax.Y - _anchorMin.Y); + SizeDelta = sd; + } + + /// The four corners of in this element's own space, ordered + /// bottom-left, top-left, top-right, bottom-right. + public void GetLocalCorners(Float3[] fourCorners) + { + if (fourCorners is null || fourCorners.Length < 4) return; + Rect r = Rect; + fourCorners[0] = new Float3(r.Min.X, r.Min.Y, 0f); + fourCorners[1] = new Float3(r.Min.X, r.Max.Y, 0f); + fourCorners[2] = new Float3(r.Max.X, r.Max.Y, 0f); + fourCorners[3] = new Float3(r.Max.X, r.Min.Y, 0f); + } + + /// The four corners in world space, in the same order as . + /// Leaves the array untouched when the element is not under a canvas. + public void GetWorldCorners(Float3[] fourCorners) + { + if (fourCorners is null || fourCorners.Length < 4) return; + + GameCanvas? canvas = GameObject.GetComponentInParent(includeSelf: true); + if (canvas.IsNotValid()) return; + + GetLocalCorners(fourCorners); + Float4x4 model = canvas.CanvasToWorld * canvas.BuildRectModel(this); + for (int i = 0; i < 4; i++) + fourCorners[i] = Float4x4.TransformPoint(fourCorners[i], model); + } + + /// Rebuilds the owning canvas immediately so reflects + /// changes made this frame, instead of waiting for the next render. + public void ForceUpdateRectTransforms() + { + GameCanvas? canvas = GameObject.GetComponentInParent(includeSelf: true); + if (canvas.IsValid()) canvas.RebuildIfDirty(); + } + + /// Laid-out size of the parent rect this element anchors against: the parent's + /// RectTransform, or the canvas root rect when the parent is the canvas itself. + private Float2 ParentSize() + { + GameObject? parent = GameObject.Parent; + if (parent is null) return Float2.Zero; + if (parent.RectTransform is { } prt) return prt.ComputedRect.Size; + GameCanvas? canvas = parent.GetComponent(); + return canvas.IsValid() ? canvas.RootRect.Size : Float2.Zero; + } + public void MarkLayoutDirty() { foreach (UIBehaviour ui in GameObject.GetComponents()) From fed394a6d94303e17bb3e9724176c06c16cfe3b3 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Thu, 6 Aug 2026 23:43:00 +1000 Subject: [PATCH 31/67] Fix dropdown closing when clicking items --- Prowl.Runtime/Components/UI/Input/UIDropdown.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Prowl.Runtime/Components/UI/Input/UIDropdown.cs b/Prowl.Runtime/Components/UI/Input/UIDropdown.cs index f5fd6e489..94cf2e7d7 100644 --- a/Prowl.Runtime/Components/UI/Input/UIDropdown.cs +++ b/Prowl.Runtime/Components/UI/Input/UIDropdown.cs @@ -178,14 +178,19 @@ public override void Update() base.Update(); if (!Application.IsPlaying || !_open) return; - if (Input.GetMouseButtonDown(0) && !IsWithinSubtree(EventSystem.Current.IsValid() ? EventSystem.Current.Hovered : null)) + GameObject? hovered = EventSystem.Current.IsValid() ? EventSystem.Current.Hovered : null; + if (Input.GetMouseButtonDown(0) && !IsWithinDropdown(hovered)) Close(); } - private bool IsWithinSubtree(GameObject? go) + /// True when is inside this dropdown or inside its item list. The + /// list is checked separately because is a free reference - it is commonly + /// parented elsewhere so it can escape a mask or a scroll view. + private bool IsWithinDropdown(GameObject? go) { + GameObject? listRoot = _optionsRoot.IsValid() ? _optionsRoot.GameObject : null; for (GameObject? n = go; n != null; n = n.Parent) - if (ReferenceEquals(n, GameObject)) return true; + if (ReferenceEquals(n, GameObject) || ReferenceEquals(n, listRoot)) return true; return false; } From 394da165c689321c77da1b00832769aae67440b4 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 01:36:17 +1000 Subject: [PATCH 32/67] Centralize default scene creation logic --- Prowl.Editor/Core/Tasks/CreateAssetTask.cs | 2 +- Prowl.Editor/EditorRegistries.cs | 7 ++ Prowl.Editor/GUI/Panels/HierarchyPanel.cs | 2 +- Prowl.Editor/GUI/Panels/SceneViewPanel.cs | 61 +---------------- .../GUI/SceneView/EditorSceneManager.cs | 65 ++++++++++++++++++- .../GUI/SceneView/PrefabEditingMode.cs | 4 +- 6 files changed, 76 insertions(+), 65 deletions(-) diff --git a/Prowl.Editor/Core/Tasks/CreateAssetTask.cs b/Prowl.Editor/Core/Tasks/CreateAssetTask.cs index aec6707f9..3f0bbdf0c 100644 --- a/Prowl.Editor/Core/Tasks/CreateAssetTask.cs +++ b/Prowl.Editor/Core/Tasks/CreateAssetTask.cs @@ -138,7 +138,7 @@ public async void BeginCreateTask(AssetMenuEntry entry, string relativeFolder) try { - var instance = Activator.CreateInstance(entry.Type); + var instance = entry.Factory != null ? entry.Factory() : Activator.CreateInstance(entry.Type); var echo = Serializer.Serialize(typeof(object), instance); if (echo != null) File.WriteAllText(filePath, echo.WriteToString()); diff --git a/Prowl.Editor/EditorRegistries.cs b/Prowl.Editor/EditorRegistries.cs index 4a183d772..d9c767a1d 100644 --- a/Prowl.Editor/EditorRegistries.cs +++ b/Prowl.Editor/EditorRegistries.cs @@ -325,6 +325,12 @@ private static void ScanBuildTargetProvider(Type type) } } + /// Types whose "Create" menu entry needs more than a blank instance. + private static readonly Dictionary> _assetFactories = new() + { + [typeof(Runtime.Resources.Scene)] = GUI.SceneView.EditorSceneManager.CreateDefaultScene, + }; + private static void ScanAssetMenuEntry(Type type) { if (type.IsAbstract || !typeof(EngineObject).IsAssignableFrom(type)) return; @@ -337,6 +343,7 @@ private static void ScanAssetMenuEntry(Type type) Extension = attr.Extension, Icon = attr.Icon, Order = attr.Order, + Factory = _assetFactories.GetValueOrDefault(type), }; MenuItemAttribute.Register("Assets/Create/" + attr.Name, () => { diff --git a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs index 6622d3527..d86d7dc98 100644 --- a/Prowl.Editor/GUI/Panels/HierarchyPanel.cs +++ b/Prowl.Editor/GUI/Panels/HierarchyPanel.cs @@ -146,7 +146,7 @@ public override void OnGUI(Paper paper, float width, float height) if (scene == null) { EditorGUI.EmptyState(paper, "hier_empty", Loc.Get("hierarchy.no_scene_loaded"), font); - Origami.Button(paper, "hier_create_scene", $"{EditorIcons.Plus} {Loc.Get("hierarchy.new_scene")}", () => SceneViewPanel.CreateAndLoadDefaultScene()).Width(120).Show(); + Origami.Button(paper, "hier_create_scene", $"{EditorIcons.Plus} {Loc.Get("hierarchy.new_scene")}", () => EditorSceneManager.CreateAndLoadDefaultScene()).Width(120).Show(); return; } diff --git a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs index 588da88ba..98e40ea57 100644 --- a/Prowl.Editor/GUI/Panels/SceneViewPanel.cs +++ b/Prowl.Editor/GUI/Panels/SceneViewPanel.cs @@ -219,7 +219,7 @@ private void DrawViewport(Paper paper, Scribe.FontFile font, float width, float .Enter()) { paper.Box("sv_btn_spacer_l"); - Origami.Button(paper, "sv_create_scene", $"{EditorIcons.Plus} {Loc.Get("hierarchy.new_scene")}", () => CreateAndLoadDefaultScene()).Width(120).Show(); + Origami.Button(paper, "sv_create_scene", $"{EditorIcons.Plus} {Loc.Get("hierarchy.new_scene")}", () => EditorSceneManager.CreateAndLoadDefaultScene()).Width(120).Show(); paper.Box("sv_btn_spacer_r"); } @@ -650,65 +650,6 @@ public override void RestoreState(System.Text.Json.Nodes.JsonObject state) private bool? _pendingGrid; private bool? _pendingGizmos; - /// - /// Create a default scene with camera, light, floor, and cubes, and load it. - /// - public static void CreateAndLoadDefaultScene() - { - var scene = new Scene(); - scene.Name = "Untitled Scene"; - - var defaultMat = new AssetRef(BuiltInAssets.GuidFor(DefaultMaterial.Standard)); - var cubeMesh = new AssetRef(BuiltInAssets.GuidForMesh(DefaultModel.Cube)); - var planeMesh = new AssetRef(BuiltInAssets.GuidForMesh(DefaultModel.Plane)); - - // Main Camera - var camGo = new GameObject("Main Camera"); - camGo.Tag = "Main Camera"; - camGo.Transform.Position = new Float3(0, 5, -15); - camGo.Transform.LocalEulerAngles = new Float3(15, 0, 0); - var cam = camGo.AddComponent(); - cam.Depth = -1; - cam.HDR = true; - scene.Add(camGo); - - // Directional Light - var lightGo = new GameObject("Directional Light"); - lightGo.Transform.LocalEulerAngles = new Float3(-45, 45, 0); - var light = lightGo.AddComponent(); - light.Intensity = 1f; - scene.Add(lightGo); - - // Floor - var floorGo = new GameObject("Floor"); - floorGo.Transform.Position = new Float3(0, 0, 0); - floorGo.Transform.LocalScale = new Float3(1, 1, 1); - var floorRenderer = floorGo.AddComponent(); - floorRenderer.Mesh = planeMesh; - floorRenderer.Material = defaultMat; - scene.Add(floorGo); - - // Cube 1 - var cube1 = new GameObject("Cube"); - cube1.Transform.Position = new Float3(0, 0.5f, 0); - var cube1Renderer = cube1.AddComponent(); - cube1Renderer.Mesh = cubeMesh; - cube1Renderer.Material = defaultMat; - scene.Add(cube1); - - // Cube 2 - var cube2 = new GameObject("Cube (1)"); - cube2.Transform.Position = new Float3(2, 0.5f, 1); - var cube2Renderer = cube2.AddComponent(); - cube2Renderer.Mesh = cubeMesh; - cube2Renderer.Material = defaultMat; - scene.Add(cube2); - - Scene.Load(scene); - Undo.Clear(); - Runtime.Debug.Log("Created default scene."); - } - /// /// Raycast into the scene to find a drop position. Falls back to the XZ plane at Y=0. /// diff --git a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs index 52a41d4cb..f09a58753 100644 --- a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs +++ b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs @@ -6,6 +6,7 @@ using Prowl.Runtime; using Prowl.Runtime.Resources; +using Prowl.Vector; using Prowl.Editor.GUI.Panels; using Prowl.Editor.Projects.Settings; using Prowl.Editor.Core; @@ -36,7 +37,7 @@ public static class EditorSceneManager public static void NewScene() { if (Application.IsPlaying) { Debug.LogWarning("Cannot create new scene during play mode."); return; } - SceneViewPanel.CreateAndLoadDefaultScene(); + CreateAndLoadDefaultScene(); CurrentScenePath = null; IsDirty = false; Undo.Clear(); @@ -44,6 +45,68 @@ public static void NewScene() SaveLastScenePath(null); } + /// + /// Build a new default scene with camera, light, floor, and cubes. + /// + public static Scene CreateDefaultScene() + { + var scene = new Scene(); + scene.Name = "Untitled Scene"; + + var defaultMat = new AssetRef(BuiltInAssets.GuidFor(DefaultMaterial.Standard)); + var cubeMesh = new AssetRef(BuiltInAssets.GuidForMesh(DefaultModel.Cube)); + var planeMesh = new AssetRef(BuiltInAssets.GuidForMesh(DefaultModel.Plane)); + + var camGo = new GameObject("Main Camera"); + camGo.Tag = "Main Camera"; + camGo.Transform.Position = new Float3(0, 5, -15); + camGo.Transform.LocalEulerAngles = new Float3(15, 0, 0); + var cam = camGo.AddComponent(); + cam.Depth = -1; + cam.HDR = true; + scene.Add(camGo); + + var lightGo = new GameObject("Directional Light"); + lightGo.Transform.LocalEulerAngles = new Float3(-45, 45, 0); + var light = lightGo.AddComponent(); + light.Intensity = 1f; + scene.Add(lightGo); + + var floorGo = new GameObject("Floor"); + floorGo.Transform.Position = new Float3(0, 0, 0); + floorGo.Transform.LocalScale = new Float3(1, 1, 1); + var floorRenderer = floorGo.AddComponent(); + floorRenderer.Mesh = planeMesh; + floorRenderer.Material = defaultMat; + scene.Add(floorGo); + + var cube1 = new GameObject("Cube"); + cube1.Transform.Position = new Float3(0, 0.5f, 0); + var cube1Renderer = cube1.AddComponent(); + cube1Renderer.Mesh = cubeMesh; + cube1Renderer.Material = defaultMat; + scene.Add(cube1); + + var cube2 = new GameObject("Cube (1)"); + cube2.Transform.Position = new Float3(2, 0.5f, 1); + var cube2Renderer = cube2.AddComponent(); + cube2Renderer.Mesh = cubeMesh; + cube2Renderer.Material = defaultMat; + scene.Add(cube2); + + return scene; + } + + /// + /// Create a default scene and load it as the current scene. + /// + public static void CreateAndLoadDefaultScene() + { + Scene.Load(CreateDefaultScene()); + Undo.Clear(); + Debug.Log("Created default scene."); + } + /// /// Open a scene from a project-relative path. /// diff --git a/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs b/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs index a16921438..20223afae 100644 --- a/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs +++ b/Prowl.Editor/GUI/SceneView/PrefabEditingMode.cs @@ -217,12 +217,12 @@ private static void RestoreScene() else { Debug.LogWarning("[Prefab] Failed to restore scene. Creating default."); - SceneViewPanel.CreateAndLoadDefaultScene(); + EditorSceneManager.CreateAndLoadDefaultScene(); } } else { - SceneViewPanel.CreateAndLoadDefaultScene(); + EditorSceneManager.CreateAndLoadDefaultScene(); } } From c162ea09822b94e3829840ba25c461df518cfe24 Mon Sep 17 00:00:00 2001 From: Abdiel Lopez <48071553+PaperPrototype@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:54:44 -0400 Subject: [PATCH 33/67] Add launch options for samples in VS Code --- .vscode/launch.json | 200 ++++++++++++++++++++++++++++++++++ .vscode/tasks.json | 260 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index 02ad2bdac..98c48540c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,6 +23,206 @@ "args": [], "cwd": "${workspaceFolder}", "stopAtEntry": false + }, + { + "name": "AudioDemo (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: AudioDemo Debug", + "program": "${workspaceFolder}/Samples/AudioDemo/bin/Debug/net10.0/AudioDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/AudioDemo", + "stopAtEntry": false + }, + { + "name": "AudioDemo (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: AudioDemo Release", + "program": "${workspaceFolder}/Samples/AudioDemo/bin/Release/net10.0/AudioDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/AudioDemo", + "stopAtEntry": false + }, + { + "name": "BananaMan (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: BananaMan Debug", + "program": "${workspaceFolder}/Samples/BananaMan/bin/Debug/net10.0/BananaMan.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/BananaMan", + "stopAtEntry": false + }, + { + "name": "BananaMan (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: BananaMan Release", + "program": "${workspaceFolder}/Samples/BananaMan/bin/Release/net10.0/BananaMan.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/BananaMan", + "stopAtEntry": false + }, + { + "name": "CarPhysicsDemo (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: CarPhysicsDemo Debug", + "program": "${workspaceFolder}/Samples/CarPhysicsDemo/bin/Debug/net10.0/CarPhysicsDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/CarPhysicsDemo", + "stopAtEntry": false + }, + { + "name": "CarPhysicsDemo (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: CarPhysicsDemo Release", + "program": "${workspaceFolder}/Samples/CarPhysicsDemo/bin/Release/net10.0/CarPhysicsDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/CarPhysicsDemo", + "stopAtEntry": false + }, + { + "name": "FlyCamera (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: FlyCamera Debug", + "program": "${workspaceFolder}/Samples/FlyCamera/bin/Debug/net10.0/FlyCamera.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/FlyCamera", + "stopAtEntry": false + }, + { + "name": "FlyCamera (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: FlyCamera Release", + "program": "${workspaceFolder}/Samples/FlyCamera/bin/Release/net10.0/FlyCamera.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/FlyCamera", + "stopAtEntry": false + }, + { + "name": "LifecycleTest (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: LifecycleTest Debug", + "program": "${workspaceFolder}/Samples/LifecycleTest/bin/Debug/net10.0/LifecycleTest.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/LifecycleTest", + "stopAtEntry": false + }, + { + "name": "LifecycleTest (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: LifecycleTest Release", + "program": "${workspaceFolder}/Samples/LifecycleTest/bin/Release/net10.0/LifecycleTest.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/LifecycleTest", + "stopAtEntry": false + }, + { + "name": "PhysicsCubes (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: PhysicsCubes Debug", + "program": "${workspaceFolder}/Samples/PhysicsCubes/bin/Debug/net10.0/PhysicsCubes.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/PhysicsCubes", + "stopAtEntry": false + }, + { + "name": "PhysicsCubes (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: PhysicsCubes Release", + "program": "${workspaceFolder}/Samples/PhysicsCubes/bin/Release/net10.0/PhysicsCubes.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/PhysicsCubes", + "stopAtEntry": false + }, + { + "name": "PhysicsTesterDemo (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: PhysicsTesterDemo Debug", + "program": "${workspaceFolder}/Samples/PhysicsTesterDemo/bin/Debug/net10.0/PhysicsTesterDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/PhysicsTesterDemo", + "stopAtEntry": false + }, + { + "name": "PhysicsTesterDemo (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: PhysicsTesterDemo Release", + "program": "${workspaceFolder}/Samples/PhysicsTesterDemo/bin/Release/net10.0/PhysicsTesterDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/PhysicsTesterDemo", + "stopAtEntry": false + }, + { + "name": "ShapeCastDemo (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: ShapeCastDemo Debug", + "program": "${workspaceFolder}/Samples/ShapeCastDemo/bin/Debug/net10.0/ShapeCastDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/ShapeCastDemo", + "stopAtEntry": false + }, + { + "name": "ShapeCastDemo (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: ShapeCastDemo Release", + "program": "${workspaceFolder}/Samples/ShapeCastDemo/bin/Release/net10.0/ShapeCastDemo.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/ShapeCastDemo", + "stopAtEntry": false + }, + { + "name": "SimpleCube (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: SimpleCube Debug", + "program": "${workspaceFolder}/Samples/SimpleCube/bin/Debug/net10.0/SimpleCube.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/SimpleCube", + "stopAtEntry": false + }, + { + "name": "SimpleCube (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: SimpleCube Release", + "program": "${workspaceFolder}/Samples/SimpleCube/bin/Release/net10.0/SimpleCube.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/SimpleCube", + "stopAtEntry": false + }, + { + "name": "VoxelEngine (Debug)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: VoxelEngine Debug", + "program": "${workspaceFolder}/Samples/VoxelEngine/bin/Debug/net10.0/VoxelEngine.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/VoxelEngine", + "stopAtEntry": false + }, + { + "name": "VoxelEngine (Release)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build: VoxelEngine Release", + "program": "${workspaceFolder}/Samples/VoxelEngine/bin/Release/net10.0/VoxelEngine.dll", + "args": [], + "cwd": "${workspaceFolder}/Samples/VoxelEngine", + "stopAtEntry": false } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 839e364b9..9cdd10933 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -26,6 +26,266 @@ "group": "build", "presentation": { "reveal": "silent" }, "problemMatcher": "$msCompile" + }, + { + "label": "build: AudioDemo Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/AudioDemo/AudioDemo.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: AudioDemo Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/AudioDemo/AudioDemo.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: BananaMan Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/BananaMan/BananaMan.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: BananaMan Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/BananaMan/BananaMan.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: CarPhysicsDemo Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/CarPhysicsDemo/CarPhysicsDemo.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: CarPhysicsDemo Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/CarPhysicsDemo/CarPhysicsDemo.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: FlyCamera Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/FlyCamera/FlyCamera.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: FlyCamera Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/FlyCamera/FlyCamera.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: LifecycleTest Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/LifecycleTest/LifecycleTest.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: LifecycleTest Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/LifecycleTest/LifecycleTest.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: PhysicsCubes Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/PhysicsCubes/PhysicsCubes.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: PhysicsCubes Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/PhysicsCubes/PhysicsCubes.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: PhysicsTesterDemo Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/PhysicsTesterDemo/PhysicsTesterDemo.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: PhysicsTesterDemo Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/PhysicsTesterDemo/PhysicsTesterDemo.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: ShapeCastDemo Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/ShapeCastDemo/ShapeCastDemo.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: ShapeCastDemo Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/ShapeCastDemo/ShapeCastDemo.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: SimpleCube Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/SimpleCube/SimpleCube.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: SimpleCube Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/SimpleCube/SimpleCube.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: VoxelEngine Debug", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/VoxelEngine/VoxelEngine.csproj", + "-c", "Debug" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" + }, + { + "label": "build: VoxelEngine Release", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/Samples/VoxelEngine/VoxelEngine.csproj", + "-c", "Release" + ], + "group": "build", + "presentation": { "reveal": "silent" }, + "problemMatcher": "$msCompile" } ] } From 8d256c03e869f60724a045368298bda53bf673fd Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 01:38:14 +1000 Subject: [PATCH 34/67] UI Graphic Base Type --- .../GUI/CustomEditors/TextComponentEditor.cs | 4 +- Prowl.Editor/GUI/DefaultGameObjectCreators.cs | 6 +- Prowl.Runtime/Components/UI/Graphic.cs | 57 +++++++++++++++++++ Prowl.Runtime/Components/UI/TextComponent.cs | 23 +------- Prowl.Runtime/Components/UI/UIImage.cs | 18 +----- 5 files changed, 65 insertions(+), 43 deletions(-) create mode 100644 Prowl.Runtime/Components/UI/Graphic.cs diff --git a/Prowl.Editor/GUI/CustomEditors/TextComponentEditor.cs b/Prowl.Editor/GUI/CustomEditors/TextComponentEditor.cs index 30a5cb360..df0e6fab4 100644 --- a/Prowl.Editor/GUI/CustomEditors/TextComponentEditor.cs +++ b/Prowl.Editor/GUI/CustomEditors/TextComponentEditor.cs @@ -57,8 +57,8 @@ public override void OnGUI(Paper paper, string id, object target) paper.Box($"{id}_sp0.2").Height(6); - EditorGUI.Row(paper, $"{id}_color", "Text Color", () => - Origami.ColorField(paper, $"{id}_color_f", text.TextColor, v => text.TextColor = v).Show()); + EditorGUI.Row(paper, $"{id}_color", "Color", () => + Origami.ColorField(paper, $"{id}_color_f", text.Color, v => text.Color = v).Show()); paper.Box($"{id}_sp0.3").Height(6); diff --git a/Prowl.Editor/GUI/DefaultGameObjectCreators.cs b/Prowl.Editor/GUI/DefaultGameObjectCreators.cs index b81c0a225..206ab460c 100644 --- a/Prowl.Editor/GUI/DefaultGameObjectCreators.cs +++ b/Prowl.Editor/GUI/DefaultGameObjectCreators.cs @@ -335,7 +335,7 @@ static void CreateUIInputField() placeholder.Text = "Enter text..."; placeholder.Alignment = TextAlignment.CenterLeft; placeholder.Size = 16; - placeholder.TextColor = new Color(0.5f, 0.5f, 0.55f, 1f); + placeholder.Color = new Color(0.5f, 0.5f, 0.55f, 1f); var textGo = HierarchyPanel.CreateGameObject("Text", areaGo, select: false, beginRename: false); textGo.EnsureRectTransform(); @@ -343,7 +343,7 @@ static void CreateUIInputField() var text = textGo.AddComponent(); text.Alignment = TextAlignment.CenterLeft; text.Size = 16; - text.TextColor = new Color(0.90f, 0.90f, 0.92f, 1f); + text.Color = new Color(0.90f, 0.90f, 0.92f, 1f); var caretGo = HierarchyPanel.CreateGameObject("Caret", areaGo, select: false, beginRename: false); caretGo.EnsureRectTransform(); @@ -379,7 +379,7 @@ static void CreateUIDropdown() var label = labelGo.AddComponent(); label.Alignment = TextAlignment.CenterLeft; label.Size = 16; - label.TextColor = new Color(0.90f, 0.90f, 0.92f, 1f); + label.Color = new Color(0.90f, 0.90f, 0.92f, 1f); var optionsGo = HierarchyPanel.CreateGameObject("Options", go, select: false, beginRename: false); optionsGo.EnsureRectTransform(); diff --git a/Prowl.Runtime/Components/UI/Graphic.cs b/Prowl.Runtime/Components/UI/Graphic.cs new file mode 100644 index 000000000..b2226719a --- /dev/null +++ b/Prowl.Runtime/Components/UI/Graphic.cs @@ -0,0 +1,57 @@ +// 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.Echo; +using Prowl.Runtime.Rendering; +using Prowl.Runtime.Resources; +using Prowl.Vector; + +namespace Prowl.Runtime.UI; + +/// +/// Base for every UI element that actually draws something. Owns the tint, the optional material +/// override, and whether the element takes part in pointer hit-testing. +/// +/// +/// Behaviours that produce no geometry (layout groups, , , +/// ) derive from directly and are never raycast targets, +/// so a bare layout panel no longer swallows clicks meant for what is behind it. +/// +public abstract class Graphic : UIBehaviour +{ + /// + /// Whether this element blocks pointer hit-testing. Affects input dispatch only, not rendering. + /// + [SerializeField] private bool _raycastTarget = true; + public bool RaycastTarget + { + get => _raycastTarget; + set => SetField(ref _raycastTarget, value, UIDirtyFlags.Hierarchy); + } + + /// Material override. When unset the graphic draws with . + [SerializeField] private AssetRef _material; + public AssetRef Material + { + get => _material; + set => SetField(ref _material, value, UIDirtyFlags.Material); + } + + /// The tint applied to this graphic. Its alpha is multiplied by the inherited + /// alpha when the mesh is baked. + [SerializeField] private Color _color = Color.White; + public Color Color + { + get => _color; + set => SetField(ref _color, value, UIDirtyFlags.Vertices); + } + + /// The material used when no override is assigned. + protected virtual Material DefaultMaterial => GameCanvas.SharedUIMaterial; + + public sealed override Material GetMaterial() + { + Material? m = _material.Res; + return m.IsValid() ? m : DefaultMaterial; + } +} diff --git a/Prowl.Runtime/Components/UI/TextComponent.cs b/Prowl.Runtime/Components/UI/TextComponent.cs index a51873bfa..b35f337bc 100644 --- a/Prowl.Runtime/Components/UI/TextComponent.cs +++ b/Prowl.Runtime/Components/UI/TextComponent.cs @@ -24,7 +24,7 @@ namespace Prowl.Runtime; /// [AddComponentMenu("UI/Text")] [ComponentIcon("T")] // Text -public class TextComponent : UIBehaviour +public class TextComponent : Graphic { [SerializeField] private AssetRef _font; public AssetRef Font @@ -47,13 +47,6 @@ public FontAsset? ResolvedFont /// built-in fallback and has to be rebuilt once the real one arrives. public override bool IsContentPending => !_font.IsExplicitNull && _font.Res.IsNotValid(); - [SerializeField] private Color _textColor = Color.White; - public Color TextColor - { - get => _textColor; - set => SetField(ref _textColor, value, UIDirtyFlags.Vertices); - } - [SerializeField] private string _text = string.Empty; public string Text { @@ -93,19 +86,7 @@ public bool RichTextEnabled set => SetField(ref _richText, value, UIDirtyFlags.Vertices); } - // ---- Material override ---- - [SerializeField] private AssetRef _material; - public AssetRef Material - { - get => _material; - set => SetField(ref _material, value, UIDirtyFlags.Material); - } - - public override Material GetMaterial() - { - var m = _material.Res; - return m.IsValid() ? m : GameCanvas.SharedTextMaterial; - } + protected override Material DefaultMaterial => GameCanvas.SharedTextMaterial; /// /// Atlas version recorded at the last successful bake. When Scribe grows the atlas diff --git a/Prowl.Runtime/Components/UI/UIImage.cs b/Prowl.Runtime/Components/UI/UIImage.cs index 978fb8733..25a4318a0 100644 --- a/Prowl.Runtime/Components/UI/UIImage.cs +++ b/Prowl.Runtime/Components/UI/UIImage.cs @@ -48,7 +48,7 @@ public enum FillMethod /// The image fills the rect computed by the . /// Alpha from the parent is multiplied into . /// -public class UIImage : UIBehaviour +public class UIImage : Graphic { [SerializeIgnore] private static Texture2D _defaultTexture; public static Texture2D defaultTexture @@ -106,22 +106,6 @@ private void ApplySpriteUVRect(UIMeshBuilder b) } } - // ---- Material override ---- - [SerializeField] private AssetRef _material; - public AssetRef Material - { - get => _material; - set => SetField(ref _material, value, UIDirtyFlags.Material); - } - - /// The tint color of the image. Alpha is modulated by the parent . - [SerializeField] private Color _color = Color.White; - public Color Color - { - get => _color; - set => SetField(ref _color, value, UIDirtyFlags.Vertices); - } - /// Whether the image should preserve the source texture's aspect ratio. [SerializeField] private bool _preserveAspect; public bool PreserveAspect From 99e35a4ff039789c38411f6de7c29c91fa2600e0 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 01:39:26 +1000 Subject: [PATCH 35/67] Tons of refactors and improvements to GameObject UI --- Prowl.Runtime/Components/GameCanvas.cs | 112 ++++++---- .../Components/UI/Input/ColorBlock.cs | 37 ++++ .../Components/UI/Input/EventSystem.cs | 22 +- .../Components/UI/Input/Navigation.cs | 40 ++++ .../Components/UI/Input/Selectable.cs | 204 +++++++++++++++++- .../UI/Input/SelectableTransition.cs | 17 ++ .../Components/UI/Input/SpriteState.cs | 18 ++ .../Components/UI/Input/UIDropdown.cs | 4 +- .../Components/UI/Input/UIInputField.cs | 25 ++- .../Components/UI/Input/UIRaycaster.cs | 76 ++++--- .../Components/UI/Input/UIScrollRect.cs | 95 +++++++- .../Components/UI/Input/UIScrollbar.cs | 16 ++ Prowl.Runtime/Components/UI/Input/UISlider.cs | 18 ++ .../Components/UI/Layout/GridLayoutGroup.cs | 85 +++++--- .../Components/UI/Layout/LayoutElement.cs | 80 ++++--- .../Components/UI/Layout/LayoutGroup.cs | 81 ++++--- Prowl.Runtime/Components/UI/TextComponent.cs | 110 +++++++--- Prowl.Runtime/Components/UI/UIBehaviour.cs | 9 + Prowl.Runtime/Components/UI/UIImage.cs | 17 -- Prowl.Runtime/Components/UI/UIRenderItem.cs | 19 +- 20 files changed, 823 insertions(+), 262 deletions(-) create mode 100644 Prowl.Runtime/Components/UI/Input/ColorBlock.cs create mode 100644 Prowl.Runtime/Components/UI/Input/Navigation.cs create mode 100644 Prowl.Runtime/Components/UI/Input/SelectableTransition.cs create mode 100644 Prowl.Runtime/Components/UI/Input/SpriteState.cs diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 504b80f27..24c7f080f 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -129,7 +129,6 @@ public static Material SharedTextMaterial // Read-only auto-property: not serialized by Prowl.Echo. internal UIRenderTree Tree { get; } = new(); [SerializeIgnore] private bool _isDirty = true; - [SerializeIgnore] private UIDirtyFlags _aggregateDirty = UIDirtyFlags.All; /// Set during a rebuild when some element was still waiting on an asset. Keeps the canvas /// dirty so it rebuilds again, since nothing else re-triggers one once it goes clean. @@ -144,17 +143,27 @@ public static Material SharedTextMaterial /// [SerializeIgnore] private Float2 _lastBuildSize = Float2.Zero; + /// Whether the last rebuild laid out as world-space. + /// swaps the root rect and under us as the scene view and game view take + /// turns rendering, and it is not covered by - two panels at the same + /// pixel size would otherwise share one build and show each other's layout. + [SerializeIgnore] private bool _lastBuildWorldSpace; + + /// Glyph atlas version seen at the last rebuild. See the check in . + [SerializeIgnore] private int _lastAtlasVersion = -1; + /// The canvas's root rect (design pixels) from the last rebuild. The canvas itself has no /// RectTransform; this is its layout extent, used by children and the bounds gizmo. [SerializeIgnore] private Rect _rootRect; public Rect RootRect => _rootRect; - /// Called by descendants (or by property setters above) to request a rebuild. - public void MarkDirty(UIDirtyFlags flags) - { - _aggregateDirty |= flags; - _isDirty = true; - } + /// + /// Called by descendants (or by the property setters above) to request a rebuild. The canvas rebuild + /// is all-or-nothing: Layout and Hierarchy both need the full walk, and Vertices/Material are already + /// tracked per element (), so the flags are only carried for the + /// elements' benefit and not accumulated here. + /// + public void MarkDirty(UIDirtyFlags flags) => _isDirty = true; /// /// Backing-field setter for this canvas's properties: assigns only on a real change and @@ -208,25 +217,6 @@ public override void OnDisable() { /* tree retained but no canvas walk picks us ScreenSizeOverride?.X ?? Window.InternalWindow.FramebufferSize.X, ScreenSizeOverride?.Y ?? Window.InternalWindow.FramebufferSize.Y); - // ============================================================ - // NEW: WorldSpace IRenderable plumbing - // ============================================================ - - /// - /// For canvases, adds every item in - /// to the scene's main renderable list so they participate in #13 Transparent + UI passes. - /// Overlay/Camera canvases ignore this hook - they are pulled by - /// from the pipeline directly. - /// - public override void OnRenderCollect(Camera camera, List renderables, List _) - { - if (RenderMode != RenderMode.WorldSpace) return; - RebuildIfDirty(); - Tree.RefreshTransforms(); - foreach (UIRenderItem it in Tree.Items) - renderables.Add(it); - } - // ============================================================ // NEW: rebuild driver - replaces the old DrawGUI // ============================================================ @@ -241,11 +231,23 @@ public void RebuildIfDirty() // Detect a render-target size change (window resize, editor viewport change, switch // between cameras with different RT sizes). The pipeline pushes the active surface size // via GameCanvas.ScreenSizeOverride before calling here, so a mismatch forces a rebuild. + // The glyph atlas is rewritten (and every glyph UV rescaled) as new glyphs are rasterized, which + // silently invalidates already-baked text meshes. Checked here rather than from + // TextComponent.Update so it holds in edit mode, where Update does not run. + int atlasVersion = UIFontSystem.Default.System.AtlasVersion; + if (_lastAtlasVersion != atlasVersion) + { + _lastAtlasVersion = atlasVersion; + _isDirty = true; + } + Float2 currentSize = ResolveScreenSize(); - if (!_lastBuildSize.Equals(currentSize)) + bool currentWorldSpace = UseWorldSpace; + if (!_lastBuildSize.Equals(currentSize) || _lastBuildWorldSpace != currentWorldSpace) { _isDirty = true; _lastBuildSize = currentSize; + _lastBuildWorldSpace = currentWorldSpace; } if (!_isDirty) return; @@ -256,21 +258,39 @@ public void RebuildIfDirty() // this rebuild every frame in ScaleWithScreenSize mode. _scaleFactor = ComputeScaleFactor(); - Tree.Clear(); + // The walk itself can dirty the canvas: a ContentSizeFitter writes its SizeDelta mid-walk, so + // ancestors and earlier siblings were arranged against the previous size. Clear the flag first + // and re-walk while it comes back, which settles the layout within this frame instead of + // leaving it permanently stale (the flag used to be overwritten, discarding the request). + int pass = 0; + do + { + _isDirty = false; + + LayoutUtility.InvalidateCache(); + Tree.Clear(); - Rect rootRect = ComputeRootRect(); - _rootRect = rootRect; // the canvas has no RectTransform; children lay out against this directly + Rect rootRect = ComputeRootRect(); + _rootRect = rootRect; // the canvas has no RectTransform; children lay out against this directly - int dfs = 0; - _contentPending = false; - BuildRecursive(GameObject, rootRect, UIContext.Default, canvasScissor: null, activeClip: null, ref dfs); - Tree.SortHierarchical(); + int dfs = 0; + _contentPending = false; + BuildRecursive(GameObject, rootRect, UIContext.Default, canvasScissor: null, activeClip: null, ref dfs); + Tree.SortHierarchical(); + } + while (_isDirty && ++pass < MaxLayoutPasses); - // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. - _isDirty = _contentPending; - _aggregateDirty = UIDirtyFlags.None; + // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. A + // layout that never settled also stays dirty and retries next frame rather than showing a + // half-resolved result. + _isDirty |= _contentPending; } + /// How many times a single rebuild re-walks when the walk dirties the canvas (nested + /// content-size fitters need one pass per level). Beyond this the layout is treated as unstable and + /// left dirty for the next frame. + private const int MaxLayoutPasses = 4; + private Rect ComputeRootRect() { // World-space canvases have a fixed design size (their ReferenceResolution) and don't track the @@ -391,7 +411,8 @@ private static void EnsureBaked(UIBehaviour ui, in UIContext childCtx) bool needsBake = ui.CachedMesh is null || (ui.DirtyFlags & UIDirtyFlags.Vertices) != 0 || !ui.LastBakeSize.Equals(size) - || !ui.LastBakeAlpha.Equals(childCtx.Alpha); + || !ui.LastBakeAlpha.Equals(childCtx.Alpha) + || ui.LastBakeContentVersion != ui.ContentVersion; if (!needsBake) return; UIMeshBuilder builder = UIMeshBuilder.Rent(); @@ -416,6 +437,7 @@ private static void EnsureBaked(UIBehaviour ui, in UIContext childCtx) ui.DirtyFlags &= ~UIDirtyFlags.Vertices; ui.LastBakeSize = size; ui.LastBakeAlpha = childCtx.Alpha; + ui.LastBakeContentVersion = ui.ContentVersion; } private void EmitItem(UIBehaviour ui, Mesh mesh, int dfsIndex, UIClip? clip) @@ -439,7 +461,7 @@ private void EmitItem(UIBehaviour ui, Mesh mesh, int dfsIndex, UIClip? clip) /// overflow past 127) and 21 bits each for the canvas id and DFS index. The canvas discriminator /// keeps two equal-SortOrder canvases as contiguous layers instead of interleaving them. /// - private long BuildSortKey(int dfsIndex) + internal long BuildSortKey(int dfsIndex) { long canvasDisc = InstanceID & 0x1FFFFF; long dfs = (uint)dfsIndex & 0x1FFFFF; @@ -485,8 +507,12 @@ internal Float4x4 BuildItemModel(UIBehaviour b) /// /// Builds the canvas-design-pixel space matrix that places a RectTransform's pivot-centered - /// mesh into the canvas frame, threading parent rotation and scale down the chain. + /// mesh into the canvas frame, threading parent rotation, scale and Z down the chain. /// + /// + /// X and Y come from the layout (anchors, pivot, anchored position); Z comes from the GameObject's + /// Transform, so an element can be pushed off the canvas plane without the layout fighting it. + /// internal Float4x4 BuildRectModel(RectTransform rt) { Rect cr = rt.ComputedRect; @@ -499,7 +525,7 @@ internal Float4x4 BuildRectModel(RectTransform rt) // Element TRS: rotation/scale apply around the pivot (mesh is pivot-centered), // then the pivot is placed at its layout position. Float4x4 model = Float4x4.CreateTRS( - new Float3(pivotX, pivotY, 0), + new Float3(pivotX, pivotY, rt.LocalPosition.Z), rt.LocalRotation, rt.LocalScale); @@ -515,7 +541,9 @@ internal Float4x4 BuildRectModel(RectTransform rt) float pPivotY = pcr.Min.Y + prt.Pivot.Y * pcr.Size.Y; Float3 pPivot = new Float3(pPivotX, pPivotY, 0); - Float4x4 wrap = Float4x4.CreateTRS(pPivot, prt.LocalRotation, prt.LocalScale) + // Rotate/scale about the parent's pivot without moving it (XY placement already comes from + // the child's own computed rect), then carry the parent's Z offset down. + Float4x4 wrap = Float4x4.CreateTRS(pPivot + new Float3(0, 0, prt.LocalPosition.Z), prt.LocalRotation, prt.LocalScale) * Float4x4.CreateTranslation(-pPivot); model = wrap * model; diff --git a/Prowl.Runtime/Components/UI/Input/ColorBlock.cs b/Prowl.Runtime/Components/UI/Input/ColorBlock.cs new file mode 100644 index 000000000..cd9ce4522 --- /dev/null +++ b/Prowl.Runtime/Components/UI/Input/ColorBlock.cs @@ -0,0 +1,37 @@ +// 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.UI; + +/// +/// The five state tints a drives, plus the shared multiplier and fade time. +/// A convenience view over the individual color fields on . +/// +public struct ColorBlock +{ + public Color NormalColor; + public Color HighlightedColor; + public Color PressedColor; + public Color SelectedColor; + public Color DisabledColor; + + /// Multiplier applied to whichever state color is active. Values above 1 let a tint + /// brighten past the source graphic. + public float ColorMultiplier; + + /// Seconds the tint takes to reach a new state color. 0 snaps. + public float FadeDuration; + + public static ColorBlock Default => new() + { + NormalColor = new Color(1f, 1f, 1f, 1f), + HighlightedColor = new Color(0.96f, 0.96f, 0.96f, 1f), + PressedColor = new Color(0.78f, 0.78f, 0.78f, 1f), + SelectedColor = new Color(0.96f, 0.96f, 0.96f, 1f), + DisabledColor = new Color(0.78f, 0.78f, 0.78f, 0.5f), + ColorMultiplier = 1f, + FadeDuration = 0.08f, + }; +} diff --git a/Prowl.Runtime/Components/UI/Input/EventSystem.cs b/Prowl.Runtime/Components/UI/Input/EventSystem.cs index 551235bdd..ef8782643 100644 --- a/Prowl.Runtime/Components/UI/Input/EventSystem.cs +++ b/Prowl.Runtime/Components/UI/Input/EventSystem.cs @@ -276,9 +276,16 @@ private void Tick(float currentTime) } else { - winSize = new(Window.InternalWindow.Size.X, Window.InternalWindow.Size.Y); + // Canvases lay out against the framebuffer, but the OS mouse arrives in window coordinates. + // Those differ on a HiDPI display, so scale the pointer into framebuffer pixels or every hit + // test would be offset and scaled against what was actually drawn. + var fb = Window.InternalWindow.FramebufferSize; + var win = Window.InternalWindow.Size; + winSize = new(fb.X, fb.Y); Int2 mp = Input.MousePosition; - pos = new(mp.X, mp.Y); + float sx = win.X > 0 ? (float)fb.X / win.X : 1f; + float sy = win.Y > 0 ? (float)fb.Y / win.Y : 1f; + pos = new(mp.X * sx, mp.Y * sy); gated = false; } @@ -404,14 +411,13 @@ private void UpdateButton( e.ClickCount = 1; if (hovered != null) - { Bubble(hovered, e, static (h, ev) => h.OnPointerDown(ev)); - // Auto-focus a Selectable on press so subsequent keyboard input goes to that widget. - // Non-Selectable presses don't change focus. - if (button == MouseButton.Left) - SetSelected(FindAncestor(hovered)); - } + // Focus follows the press: a Selectable under the pointer takes focus, and a press on + // anything else - including empty space - clears it. Without the clear, an input field + // would keep its caret and keep eating keystrokes after the user clicked away. + if (button == MouseButton.Left) + SetSelected(hovered != null ? FindAncestor(hovered) : null); } // ---- Drag detection / continuation ---- diff --git a/Prowl.Runtime/Components/UI/Input/Navigation.cs b/Prowl.Runtime/Components/UI/Input/Navigation.cs new file mode 100644 index 000000000..74e9686d2 --- /dev/null +++ b/Prowl.Runtime/Components/UI/Input/Navigation.cs @@ -0,0 +1,40 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.UI; + +/// How a resolves the widget a directional move should focus. +public enum NavigationMode +{ + /// Directional moves do nothing. + None, + + /// Automatic, restricted to left and right. + Horizontal, + + /// Automatic, restricted to up and down. + Vertical, + + /// Picks the nearest selectable in the direction moved. + Automatic, + + /// Uses the explicitly wired SelectOn* targets. + Explicit, +} + +/// Keyboard/gamepad navigation settings for a . +public struct Navigation +{ + public NavigationMode Mode; + + /// When automatic navigation finds nothing in the direction moved, continue from the + /// far side instead of stopping. + public bool WrapAround; + + public Selectable? SelectOnUp; + public Selectable? SelectOnDown; + public Selectable? SelectOnLeft; + public Selectable? SelectOnRight; + + public static Navigation Default => new() { Mode = NavigationMode.Automatic }; +} diff --git a/Prowl.Runtime/Components/UI/Input/Selectable.cs b/Prowl.Runtime/Components/UI/Input/Selectable.cs index ceef8d421..1ebff3f93 100644 --- a/Prowl.Runtime/Components/UI/Input/Selectable.cs +++ b/Prowl.Runtime/Components/UI/Input/Selectable.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. See the LICENSE file in the project root for details. using Prowl.Echo; +using Prowl.Runtime.Resources; using Prowl.Vector; +using Prowl.Vector.Geometry; namespace Prowl.Runtime.UI; @@ -17,7 +19,7 @@ public enum SelectionState /// /// Base class for every interactive UI widget - buttons, toggles, sliders, dropdowns. -/// Tracks the pointer state machine, drives a sibling 's color +/// Tracks the pointer state machine, drives a sibling 's color /// across the four states, fires SFX through , and exposes /// per-instance overrides for both the colors and the audio. /// @@ -25,7 +27,7 @@ public enum SelectionState public class Selectable : UIBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerUpHandler, - ISelectHandler, IDeselectHandler + ISelectHandler, IDeselectHandler, IMoveHandler { // ============================================================ // Interactability @@ -74,17 +76,17 @@ public bool IsInteractable() public void RefreshInteractable() => RefreshState(immediate: false); // ============================================================ - // Target graphic - which UIImage do we tint? + // Target graphic - which Graphic do we tint? // ============================================================ - [SerializeField] private UIImage? _targetGraphic; - /// The whose Color the state machine drives. Defaults to a UIImage on this GameObject. - public UIImage? TargetGraphic + [SerializeField] private Graphic? _targetGraphic; + /// The whose Color the state machine drives. Defaults to a graphic on this GameObject. + public Graphic? TargetGraphic { get { if (_targetGraphic.IsNotValid()) - _targetGraphic = GetComponent(); + _targetGraphic = GetComponent(); return _targetGraphic; } set => _targetGraphic = value; @@ -110,6 +112,65 @@ public UIImage? TargetGraphic [SerializeField] private float _transitionDuration = 0.08f; public float TransitionDuration { get => _transitionDuration; set => _transitionDuration = Maths.Max(0f, value); } + [SerializeField] private float _colorMultiplier = 1f; + /// Multiplier applied to the active state color, so a tint can brighten past the source. + public float ColorMultiplier { get => _colorMultiplier; set { _colorMultiplier = value; RefreshState(immediate: false); } } + + /// All five state colors plus the multiplier and fade time, as one value. + public ColorBlock Colors + { + get => new() + { + NormalColor = _normalColor, + HighlightedColor = _highlightedColor, + PressedColor = _pressedColor, + SelectedColor = _selectedColor, + DisabledColor = _disabledColor, + ColorMultiplier = _colorMultiplier, + FadeDuration = _transitionDuration, + }; + set + { + _normalColor = value.NormalColor; + _highlightedColor = value.HighlightedColor; + _pressedColor = value.PressedColor; + _selectedColor = value.SelectedColor; + _disabledColor = value.DisabledColor; + _colorMultiplier = value.ColorMultiplier; + _transitionDuration = Maths.Max(0f, value.FadeDuration); + RefreshState(immediate: false); + } + } + + // ============================================================ + // Transition + // ============================================================ + + [SerializeField] private SelectableTransition _transition = SelectableTransition.ColorTint; + /// How the current state is shown. Defaults to tinting the target graphic. + public SelectableTransition Transition + { + get => _transition; + set { _transition = value; RefreshState(immediate: true); } + } + + [SerializeField] private SpriteState _spriteState; + /// Per-state sprites used when is + /// . + public SpriteState SpriteState + { + get => _spriteState; + set { _spriteState = value; RefreshState(immediate: true); } + } + + // ============================================================ + // Navigation + // ============================================================ + + [SerializeField] private Navigation _navigation = Navigation.Default; + /// How directional moves (arrow keys) hand focus to a neighbouring widget. + public Navigation Navigation { get => _navigation; set => _navigation = value; } + // ============================================================ // Runtime state // ============================================================ @@ -122,6 +183,7 @@ public UIImage? TargetGraphic [SerializeIgnore] private Color _fromColor = Color.White; [SerializeIgnore] private Color _toColor = Color.White; [SerializeIgnore] private float _transitionElapsed; + [SerializeIgnore] private AssetRef _authoredSprite; /// The current high-level state. Read-only for derived classes. public SelectionState CurrentState => _currentState; @@ -142,6 +204,7 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) { public override void OnEnable() { base.OnEnable(); + CaptureAuthoredSprite(); RefreshState(immediate: true); } @@ -149,6 +212,7 @@ public override void OnEnable() public override void Update() { if (!Application.IsPlaying) return; + if (_transition != SelectableTransition.ColorTint) return; if (TargetGraphic == null) return; float dur = _transitionDuration; @@ -216,13 +280,17 @@ public virtual void OnDeselect() // Helpers // ============================================================ - /// Re-evaluates the active and starts a tint lerp toward it. + /// Re-evaluates the active and applies the transition. protected void RefreshState(bool immediate) { SelectionState next = ComputeState(); if (next == _currentState && !immediate) return; _currentState = next; + + if (_transition == SelectableTransition.SpriteSwap) { ApplySpriteSwap(next); return; } + if (_transition == SelectableTransition.None) return; + Color target = next switch { SelectionState.Disabled => _disabledColor, @@ -230,7 +298,7 @@ protected void RefreshState(bool immediate) SelectionState.Highlighted => _highlightedColor, SelectionState.Selected => _selectedColor, _ => _normalColor, - }; + } * _colorMultiplier; _fromColor = _displayedColor; _toColor = target; @@ -243,6 +311,29 @@ protected void RefreshState(bool immediate) } } + private void ApplySpriteSwap(SelectionState state) + { + if (TargetGraphic is not UIImage image) return; + + AssetRef next = state switch + { + SelectionState.Disabled => _spriteState.DisabledSprite, + SelectionState.Pressed => _spriteState.PressedSprite, + SelectionState.Highlighted => _spriteState.HighlightedSprite, + SelectionState.Selected => _spriteState.SelectedSprite, + _ => _authoredSprite, + }; + + image.Sprite = next.IsExplicitNull ? _authoredSprite : next; + } + + /// Remembers the sprite the target graphic was authored with so the Normal state (and any + /// unassigned state) can return to it. + private void CaptureAuthoredSprite() + { + if (TargetGraphic is UIImage image) _authoredSprite = image.Sprite; + } + private SelectionState ComputeState() { if (!IsInteractable()) return SelectionState.Disabled; @@ -251,4 +342,99 @@ private SelectionState ComputeState() if (_isSelected) return SelectionState.Selected; return SelectionState.Normal; } + + // ============================================================ + // Navigation + // ============================================================ + + /// Gives this widget keyboard focus through the active . + public void Select() + { + EventSystem? es = EventSystem.Current; + if (es.IsValid()) es.SetSelected(GameObject); + } + + /// Moves focus to the neighbour in , if navigation allows it. + public virtual void OnMove(MoveDirection direction) + { + Selectable? next = direction switch + { + MoveDirection.Left => FindSelectableOnLeft(), + MoveDirection.Right => FindSelectableOnRight(), + MoveDirection.Up => FindSelectableOnUp(), + MoveDirection.Down => FindSelectableOnDown(), + _ => null, + }; + if (next.IsValid()) next.Select(); + } + + public Selectable? FindSelectableOnLeft() => FindForDirection(new Float2(-1f, 0f), _navigation.SelectOnLeft, horizontal: true); + public Selectable? FindSelectableOnRight() => FindForDirection(new Float2(1f, 0f), _navigation.SelectOnRight, horizontal: true); + public Selectable? FindSelectableOnUp() => FindForDirection(new Float2(0f, 1f), _navigation.SelectOnUp, horizontal: false); + public Selectable? FindSelectableOnDown() => FindForDirection(new Float2(0f, -1f), _navigation.SelectOnDown, horizontal: false); + + private Selectable? FindForDirection(Float2 dir, Selectable? explicitTarget, bool horizontal) + { + switch (_navigation.Mode) + { + case NavigationMode.None: return null; + case NavigationMode.Explicit: return explicitTarget.IsValid() ? explicitTarget : null; + case NavigationMode.Horizontal when !horizontal: return null; + case NavigationMode.Vertical when horizontal: return null; + } + return FindSelectable(dir); + } + + /// + /// The nearest interactable lying in from this one. + /// Candidates are ranked by how well their offset lines up with the direction relative to distance, + /// so a widget straight ahead beats a nearer one off to the side. + /// + public Selectable? FindSelectable(Float2 dir) + { + Scene? scene = GameObject.Scene; + if (scene is null) return null; + + Float2 origin = RectCenter(this); + Selectable? best = null; + float bestScore = float.NegativeInfinity; + Selectable? wrap = null; + float wrapScore = float.NegativeInfinity; + + foreach (GameObject go in scene.ActiveObjects) + { + foreach (Selectable candidate in go.GetComponents()) + { + if (ReferenceEquals(candidate, this) || !candidate.EnabledInHierarchy) continue; + if (!candidate.IsInteractable() || candidate._navigation.Mode == NavigationMode.None) continue; + + Float2 offset = RectCenter(candidate) - origin; + float distance = Float2.Length(offset); + if (distance < 1e-4f) continue; + + float alignment = Float2.Dot(offset / distance, dir); + if (alignment > 0.1f) + { + float score = alignment / distance; + if (score > bestScore) { bestScore = score; best = candidate; } + } + else if (_navigation.WrapAround) + { + // Furthest widget in the opposite direction, so a move off one end lands on the other. + float score = -alignment * distance; + if (score > wrapScore) { wrapScore = score; wrap = candidate; } + } + } + } + + return best.IsValid() ? best : wrap; + } + + private static Float2 RectCenter(Selectable s) + { + RectTransform? rt = s.GameObject.RectTransform; + if (rt is null) return Float2.Zero; + Rect r = rt.ComputedRect; + return (r.Min + r.Max) * 0.5f; + } } diff --git a/Prowl.Runtime/Components/UI/Input/SelectableTransition.cs b/Prowl.Runtime/Components/UI/Input/SelectableTransition.cs new file mode 100644 index 000000000..3b70250d0 --- /dev/null +++ b/Prowl.Runtime/Components/UI/Input/SelectableTransition.cs @@ -0,0 +1,17 @@ +// This file is part of the Prowl Game Engine +// Licensed under the MIT License. See the LICENSE file in the project root for details. + +namespace Prowl.Runtime.UI; + +/// How a shows its current . +public enum SelectableTransition +{ + /// No visual feedback; the widget still tracks state for its own logic. + None, + + /// Lerps the target graphic's color across the states. The default. + ColorTint, + + /// Swaps the target 's sprite per state (see ). + SpriteSwap, +} diff --git a/Prowl.Runtime/Components/UI/Input/SpriteState.cs b/Prowl.Runtime/Components/UI/Input/SpriteState.cs new file mode 100644 index 000000000..ee0fa885e --- /dev/null +++ b/Prowl.Runtime/Components/UI/Input/SpriteState.cs @@ -0,0 +1,18 @@ +// 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; + +namespace Prowl.Runtime.UI; + +/// +/// Per-state sprites used by . An unset entry falls back +/// to the sprite the target graphic was authored with. +/// +public struct SpriteState +{ + public AssetRef HighlightedSprite; + public AssetRef PressedSprite; + public AssetRef SelectedSprite; + public AssetRef DisabledSprite; +} diff --git a/Prowl.Runtime/Components/UI/Input/UIDropdown.cs b/Prowl.Runtime/Components/UI/Input/UIDropdown.cs index 94cf2e7d7..79d5109b8 100644 --- a/Prowl.Runtime/Components/UI/Input/UIDropdown.cs +++ b/Prowl.Runtime/Components/UI/Input/UIDropdown.cs @@ -214,7 +214,7 @@ private void RebuildItems() it.Label.Text = _options[i]; it.Label.Size = _itemTextSize; - it.Label.TextColor = _itemTextColor; + it.Label.Color = _itemTextColor; it.Background.Color = (i == _value) ? _itemSelectedColor : _itemColor; RectTransform rt = it.Go.RectTransform!; @@ -243,7 +243,7 @@ private Item CreateItem(int index) TextComponent label = labelGo.AddComponent(); label.Alignment = TextAlignment.CenterLeft; label.Size = _itemTextSize; - label.TextColor = _itemTextColor; + label.Color = _itemTextColor; labelGo.SetParent(go, worldPositionStays: false); // Stretch the label to fill the item with a small left inset. diff --git a/Prowl.Runtime/Components/UI/Input/UIInputField.cs b/Prowl.Runtime/Components/UI/Input/UIInputField.cs index 08bc07205..f2a8b2b19 100644 --- a/Prowl.Runtime/Components/UI/Input/UIInputField.cs +++ b/Prowl.Runtime/Components/UI/Input/UIInputField.cs @@ -210,6 +210,14 @@ public void OnCancel() // The event system clears focus after this; nothing extra to revert for now. } + /// Horizontal moves drive the caret (see ), so they must not + /// also hand focus to a neighbouring widget. Vertical moves navigate as usual. + public override void OnMove(MoveDirection direction) + { + if (direction is MoveDirection.Left or MoveDirection.Right) return; + base.OnMove(direction); + } + // ============================================================ // Pointer // ============================================================ @@ -242,15 +250,7 @@ private int IndexFromLocalX(float localX) { string d = DisplayText; if (_textComponent is null || d.Length == 0 || localX <= 0f) return 0; - - float prev = 0f; - for (int i = 1; i <= d.Length; i++) - { - float w = _textComponent.MeasureWidth(d.Substring(0, i)); - if (localX < (prev + w) * 0.5f) return i - 1; - prev = w; - } - return d.Length; + return Math.Clamp(_textComponent.MeasureCaretIndexAt(d, localX), 0, d.Length); } // ============================================================ @@ -502,8 +502,7 @@ private void UpdateCaretVisual() if (_textComponent is null) return; string d = DisplayText; - int caret = Math.Clamp(_caretPos, 0, d.Length); - float caretX = _textComponent.MeasureWidth(d.Substring(0, caret)); + float caretX = _textComponent.MeasureCaretOffset(d, _caretPos); float viewW = Area.ComputedRect.Size.X; // Scroll so the caret stays inside the viewport, then clamp so we never scroll past the text. @@ -519,8 +518,8 @@ private void UpdateCaretVisual() if (_selection != null && HasSelection) { - float aX = _textComponent.MeasureWidth(d.Substring(0, SelMin)); - float bX = _textComponent.MeasureWidth(d.Substring(0, SelMax)); + float aX = _textComponent.MeasureCaretOffset(d, SelMin); + float bX = _textComponent.MeasureCaretOffset(d, SelMax); _selection.AnchoredPosition = new Float2(aX - _scrollX, _selection.AnchoredPosition.Y); _selection.SizeDelta = new Float2(bX - aX, _selection.SizeDelta.Y); } diff --git a/Prowl.Runtime/Components/UI/Input/UIRaycaster.cs b/Prowl.Runtime/Components/UI/Input/UIRaycaster.cs index eefb66a37..950e7071e 100644 --- a/Prowl.Runtime/Components/UI/Input/UIRaycaster.cs +++ b/Prowl.Runtime/Components/UI/Input/UIRaycaster.cs @@ -35,8 +35,7 @@ public static bool TryPick(Scene? scene, Float2 screenPos, Float2 windowSize, ou GameObject? bestGO = null; GameCanvas? bestCanvas = null; Float2 bestDesign = Float2.Zero; - int bestSortOrder = int.MinValue; - int bestDfs = -1; + long bestKey = long.MinValue; foreach (GameObject go in scene.ActiveObjects) { @@ -58,21 +57,19 @@ public static bool TryPick(Scene? scene, Float2 screenPos, Float2 windowSize, ou int dfs = 0; GameObject? localHit = null; int localDfs = -1; - WalkRecurse(canvas, canvas.GameObject, designPt, rayO, rayD, scissor: null, ref dfs, ref localHit, ref localDfs); + WalkRecurse(canvas, canvas.GameObject, designPt, rayO, rayD, scissor: null, blocksRaycasts: true, ref dfs, ref localHit, ref localDfs); if (localHit == null) continue; - // Higher SortOrder wins; ties resolved by deeper DFS index (drawn on top). - bool wins = - canvas.SortOrder > bestSortOrder || - (canvas.SortOrder == bestSortOrder && localDfs > bestDfs); - - if (wins) + // Rank with the same key the renderer sorts by (SortOrder, then a per-canvas discriminator, + // then depth-first index) so the element the pointer picks is always the one drawn on top. + // Both walks are depth-first pre-order over the same tree, so their indices order alike. + long key = canvas.BuildSortKey(localDfs); + if (key > bestKey) { + bestKey = key; bestGO = localHit; bestCanvas = canvas; bestDesign = designPt; - bestSortOrder = canvas.SortOrder; - bestDfs = localDfs; } } @@ -118,7 +115,7 @@ private static void TryPickWorld(Scene scene, Float2 screenPos, Float2 windowSiz int dfs = 0; GameObject? localHit = null; int localDfs = -1; - WalkRecurse(canvas, canvas.GameObject, designPt, rayO, rayD, scissor: null, ref dfs, ref localHit, ref localDfs); + WalkRecurse(canvas, canvas.GameObject, designPt, rayO, rayD, scissor: null, blocksRaycasts: true, ref dfs, ref localHit, ref localDfs); if (localHit == null) continue; bestT = t; @@ -196,8 +193,9 @@ private static bool ScreenToDesign(GameCanvas canvas, Float2 screenPos, Float2 w // pt is the pointer on the canvas plane (used for the RectMask scissor, which is canvas-aligned); // (rayO, rayD) is the same pointer as a design-space ray, intersected with each element's own quad - // so out-of-plane (3D) element rotation is respected. - private static void WalkRecurse(GameCanvas canvas, GameObject parent, Float2 pt, Float3 rayO, Float3 rayD, Rect? scissor, ref int dfs, ref GameObject? bestGO, ref int bestDfs) + // so out-of-plane (3D) element rotation is respected. blocksRaycasts is inherited from the enclosing + // CanvasGroups, matching how the render walk threads UIContext down. + private static void WalkRecurse(GameCanvas canvas, GameObject parent, Float2 pt, Float3 rayO, Float3 rayD, Rect? scissor, bool blocksRaycasts, ref int dfs, ref GameObject? bestGO, ref int bestDfs) { foreach (GameObject child in parent.Children) { @@ -219,39 +217,39 @@ private static void WalkRecurse(GameCanvas canvas, GameObject parent, Float2 pt, continue; } - // A CanvasGroup with BlocksRaycasts off makes the whole subtree transparent - // to the pointer - children still draw, but they don't consume input. + // A CanvasGroup with BlocksRaycasts off makes the whole subtree transparent to the pointer - + // children still draw, they just don't consume input. IgnoreParentGroups restarts the chain. + bool childBlocks = blocksRaycasts; CanvasGroup? grp = child.GetComponent(); - bool blocked = grp == null || grp.BlocksRaycasts; + if (grp != null && grp.EnabledInHierarchy) + childBlocks = (grp.IgnoreParentGroups || blocksRaycasts) && grp.BlocksRaycasts; - if (blocked) + dfs++; + if (childBlocks && child.RectTransform is { } rt && IsRaycastTarget(child) + && RayHitsRect(canvas, rt, rayO, rayD)) { - RectTransform? rt = child.RectTransform; - if (rt != null) - { - bool inside = RayHitsRect(canvas, rt, rayO, rayD); - - foreach (UIBehaviour ui in child.GetComponents()) - { - if (!ui.EnabledInHierarchy) continue; - if (ui is UIImage img && !img.RaycastTarget) { dfs++; continue; } - if (ui is CanvasGroup) continue; - if (ui is RectMask) continue; - - if (inside) - { - bestGO = child; - bestDfs = dfs; - } - dfs++; - } - } + // Later in the depth-first order means drawn on top, so a plain overwrite keeps the + // top-most hit. + bestGO = child; + bestDfs = dfs; } - WalkRecurse(canvas, child, pt, rayO, rayD, childScissor, ref dfs, ref bestGO, ref bestDfs); + WalkRecurse(canvas, child, pt, rayO, rayD, childScissor, childBlocks, ref dfs, ref bestGO, ref bestDfs); } } + /// + /// True when the GameObject carries an enabled that opts into hit-testing. + /// Behaviours that draw nothing (layout groups, fitters, scroll rects, ) are + /// deliberately not targets on their own - a widget needs a graphic to be clickable, as in Unity. + /// + private static bool IsRaycastTarget(GameObject go) + { + foreach (Graphic g in go.GetComponents()) + if (g.EnabledInHierarchy && g.RaycastTarget) return true; + return false; + } + internal static Rect IntersectRect(Rect a, Rect b) { float minX = System.MathF.Max(a.Min.X, b.Min.X); diff --git a/Prowl.Runtime/Components/UI/Input/UIScrollRect.cs b/Prowl.Runtime/Components/UI/Input/UIScrollRect.cs index 31078041d..3dba7be3e 100644 --- a/Prowl.Runtime/Components/UI/Input/UIScrollRect.cs +++ b/Prowl.Runtime/Components/UI/Input/UIScrollRect.cs @@ -60,6 +60,25 @@ public enum ScrollbarVisibilityMode [SerializeField] private float _verticalScrollbarSpacing = 0f; public float VerticalScrollbarSpacing { get => _verticalScrollbarSpacing; set => _verticalScrollbarSpacing = value; } + /// How the content behaves at the ends of its travel. + public enum ScrollMovementType + { + /// No bounds at all; the content can be dragged anywhere. + Unrestricted, + /// The content can be dragged past the end but resists, then springs back. + Elastic, + /// The content stops hard at the ends. + Clamped, + } + + [SerializeField] private ScrollMovementType _movementType = ScrollMovementType.Elastic; + public ScrollMovementType MovementType { get => _movementType; set => _movementType = value; } + + /// Roughly the seconds the content takes to spring back after being dragged past an end. + /// Only used by . + [SerializeField] private float _elasticity = 0.1f; + public float Elasticity { get => _elasticity; set => _elasticity = Maths.Max(0f, value); } + [SerializeField] private bool _horizontal = true; public bool Horizontal { get => _horizontal; set => _horizontal = value; } @@ -111,10 +130,42 @@ public void OnDrag(PointerEventData e) if (_horizontal) target.X += delta.X; if (_vertical) target.Y += delta.Y; - SetContentPosition(ClampContent(target)); + SetContentPosition(ConstrainDrag(target)); e.Use(); } + /// Applies to a dragged-to position. + private Float2 ConstrainDrag(Float2 candidate) + { + if (_movementType == ScrollMovementType.Unrestricted) return FilterAxes(candidate); + if (_movementType == ScrollMovementType.Clamped) return ClampContent(candidate); + + // Elastic: allow travel past the end, but with sharply diminishing returns. + Float2 clamped = ClampContent(candidate); + Float2 over = FilterAxes(candidate) - clamped; + Rect vp = ViewportRect(); + return new Float2( + clamped.X + RubberDelta(over.X, vp.Size.X), + clamped.Y + RubberDelta(over.Y, vp.Size.Y)); + } + + /// Overshoot remapped so it approaches (but never reaches) the viewport size. + private static float RubberDelta(float overshoot, float viewSize) + { + if (viewSize <= 0f || overshoot == 0f) return 0f; + float magnitude = Maths.Abs(overshoot); + return (1f - 1f / (magnitude * 0.55f / viewSize + 1f)) * viewSize * MathF.Sign(overshoot); + } + + /// Drops movement on any axis this scroll rect does not scroll. + private Float2 FilterAxes(Float2 candidate) + { + Float2 cur = _content!.AnchoredPosition; + if (!_horizontal) candidate.X = cur.X; + if (!_vertical) candidate.Y = cur.Y; + return candidate; + } + public void OnEndDrag(PointerEventData e) { _dragging = false; @@ -130,7 +181,8 @@ public void OnScroll(PointerEventData e) else if (_horizontal) pos.X += e.ScrollDelta * _scrollSensitivity; _velocity = Float2.Zero; - SetContentPosition(ClampContent(pos)); + // The wheel always lands inside the bounds; only a drag is allowed to overshoot. + SetContentPosition(_movementType == ScrollMovementType.Unrestricted ? FilterAxes(pos) : ClampContent(pos)); e.Use(); } @@ -157,17 +209,35 @@ public override void Update() return; } + // Past the end and no longer held: pull back in. Elastic eases over Elasticity seconds, + // Clamped snaps. Unrestricted has no ends to be past. + if (_movementType != ScrollMovementType.Unrestricted) + { + Float2 pos = _content.AnchoredPosition; + Float2 inBounds = ClampContent(pos); + Float2 offset = inBounds - pos; + if (Maths.Abs(offset.X) > 1e-4f || Maths.Abs(offset.Y) > 1e-4f) + { + float t = _movementType == ScrollMovementType.Clamped || _elasticity <= 0f + ? 1f + : Maths.Clamp(Time.DeltaTime / _elasticity, 0f, 1f); + _velocity = Float2.Zero; + SetContentPosition(pos + offset * t); + return; + } + } + if (!_inertia || Maths.Abs(_velocity.X) + Maths.Abs(_velocity.Y) < 1f) { _velocity = Float2.Zero; return; } float decay = MathF.Pow(_decelerationRate, Time.DeltaTime); _velocity *= decay; Float2 target = _content.AnchoredPosition + _velocity * Time.DeltaTime; - Float2 clamped = ClampContent(target); - // Kill velocity on the axis that hit a clamp edge so it doesn't fight the wall. - if (clamped.X != target.X) _velocity.X = 0f; - if (clamped.Y != target.Y) _velocity.Y = 0f; - SetContentPosition(clamped); + // Clamped stops dead at the wall; Elastic coasts past it and the spring above brings it back. + Float2 result = _movementType == ScrollMovementType.Clamped ? ClampContent(target) : FilterAxes(target); + if (result.X != target.X) _velocity.X = 0f; + if (result.Y != target.Y) _velocity.Y = 0f; + SetContentPosition(result); } private void SetContentPosition(Float2 pos) @@ -202,6 +272,17 @@ private Float2 ClampContent(Float2 candidate) return candidate; } + /// Live overshoot past the ends, zero when the content sits inside its bounds. + public Float2 Overshoot + { + get + { + if (_content == null) return Float2.Zero; + Float2 pos = _content.AnchoredPosition; + return pos - ClampContent(pos); + } + } + // ============================================================ // Scrollbar binding // ============================================================ diff --git a/Prowl.Runtime/Components/UI/Input/UIScrollbar.cs b/Prowl.Runtime/Components/UI/Input/UIScrollbar.cs index 05dc9e1d4..a88c19487 100644 --- a/Prowl.Runtime/Components/UI/Input/UIScrollbar.cs +++ b/Prowl.Runtime/Components/UI/Input/UIScrollbar.cs @@ -91,6 +91,22 @@ public void OnDrag(PointerEventData e) e.Use(); } + /// Arrows along the bar's own axis step the value; the cross axis navigates away. + public override void OnMove(MoveDirection direction) + { + bool horizontal = _direction is ScrollbarDirection.LeftToRight or ScrollbarDirection.RightToLeft; + bool alongAxis = horizontal + ? direction is MoveDirection.Left or MoveDirection.Right + : direction is MoveDirection.Up or MoveDirection.Down; + + if (!alongAxis || !IsInteractable()) { base.OnMove(direction); return; } + + float sign = direction is MoveDirection.Right or MoveDirection.Up ? 1f : -1f; + if (_direction is ScrollbarDirection.RightToLeft or ScrollbarDirection.TopToBottom) sign = -sign; + + SetValue(_value + sign * 0.1f, notify: true); + } + private float ValueFromPointer(PointerEventData e) { Rect rect = GameObject.RectTransform!.ComputedRect; // design space, +Y up diff --git a/Prowl.Runtime/Components/UI/Input/UISlider.cs b/Prowl.Runtime/Components/UI/Input/UISlider.cs index 783c1a67c..0a63f163f 100644 --- a/Prowl.Runtime/Components/UI/Input/UISlider.cs +++ b/Prowl.Runtime/Components/UI/Input/UISlider.cs @@ -107,6 +107,24 @@ public void OnDrag(PointerEventData e) e.Use(); } + /// Arrows along the slider's own axis nudge the value; the cross axis navigates away. + public override void OnMove(MoveDirection direction) + { + bool horizontal = _direction is SliderDirection.LeftToRight or SliderDirection.RightToLeft; + bool alongAxis = horizontal + ? direction is MoveDirection.Left or MoveDirection.Right + : direction is MoveDirection.Up or MoveDirection.Down; + + if (!alongAxis || !IsInteractable()) { base.OnMove(direction); return; } + + float sign = direction is MoveDirection.Right or MoveDirection.Up ? 1f : -1f; + if (_direction is SliderDirection.RightToLeft or SliderDirection.TopToBottom) sign = -sign; + + float span = _maxValue - _minValue; + float step = _wholeNumbers ? 1f : Maths.Abs(span) * 0.1f; + SetValue(_value + sign * step, notify: true); + } + private void UpdateValueFromPointer(PointerEventData e) { Rect rect = GameObject.RectTransform!.ComputedRect; // design space, +Y up, origin bottom-left diff --git a/Prowl.Runtime/Components/UI/Layout/GridLayoutGroup.cs b/Prowl.Runtime/Components/UI/Layout/GridLayoutGroup.cs index ec06e5a57..b4231f0b2 100644 --- a/Prowl.Runtime/Components/UI/Layout/GridLayoutGroup.cs +++ b/Prowl.Runtime/Components/UI/Layout/GridLayoutGroup.cs @@ -37,7 +37,7 @@ public override void Arrange(Rect rect) rect.Min.X + _paddingLeft, rect.Min.Y + _paddingBottom, rect.Max.X - _paddingRight, rect.Max.Y - _paddingTop); - int cols = Maths.Max(1, ColumnsFor(n, content.Size.X)); + int cols = ColumnsForWidth(n, content.Size.X); int rows = (n + cols - 1) / cols; float cellW = _cellSize.X, cellH = _cellSize.Y; @@ -59,42 +59,73 @@ public override void Arrange(Rect rect) } } - private int ColumnsFor(int n, float availableWidth) => _constraint switch + /// + /// The column count the grid actually fills, given the content width available to it. The single + /// source of truth: and the reported height both go through it, so a grid + /// inside a reports the size it will really lay out to. + /// + private int ColumnsForWidth(int n, float availableWidth) { - Constraint.FixedColumnCount => _constraintCount, - Constraint.FixedRowCount => (n + _constraintCount - 1) / _constraintCount, - _ => FlexibleColumns(n, availableWidth), - }; + switch (_constraint) + { + case Constraint.FixedColumnCount: + return Maths.Max(1, _constraintCount); + case Constraint.FixedRowCount: + int rows = Maths.Max(1, _constraintCount); + return Maths.Max(1, (n + rows - 1) / rows); + default: + float step = _cellSize.X + _spacing.X; + if (step <= 0f) return Maths.Max(1, n); + int fit = (int)MathF.Floor((availableWidth + _spacing.X + 0.001f) / step); + return Maths.Clamp(fit, 1, Maths.Max(1, n)); + } + } - private int FlexibleColumns(int n, float availableWidth) + /// Content width currently available to the grid, from the last layout pass. + private float ContentWidth() { - float step = _cellSize.X + _spacing.X; - if (step <= 0f) return n; - int c = (int)((availableWidth + _spacing.X) / step); - return Maths.Clamp(c, 1, Maths.Max(1, n)); + RectTransform? rt = GameObject.RectTransform; + float w = rt is null ? 0f : rt.ComputedRect.Size.X; + return Maths.Max(0f, w - _paddingLeft - _paddingRight); } // ---- ILayoutElement (grid content size) ---- - private void Dimensions(out int cols, out int rows) + // Width is reported without knowing how wide the parent will make us, so it reports the narrowest + // useful grid (min) and a squarish one (preferred). Height is then derived from the width we + // actually ended up with, which is what makes it agree with Arrange. + + private int WidthColumns(int n, bool minimum) { - int n = GetLayoutChildren().Count; - cols = _constraint switch + switch (_constraint) { - Constraint.FixedColumnCount => Maths.Max(1, _constraintCount), - Constraint.FixedRowCount => Maths.Max(1, (n + _constraintCount - 1) / Maths.Max(1, _constraintCount)), - _ => Maths.Max(1, (int)MathF.Ceiling(MathF.Sqrt(Maths.Max(1, n)))), - }; - rows = n == 0 ? 0 : (n + cols - 1) / cols; + case Constraint.FixedColumnCount: + return Maths.Max(1, _constraintCount); + case Constraint.FixedRowCount: + int rows = Maths.Max(1, _constraintCount); + return Maths.Max(1, (n + rows - 1) / rows); + default: + return minimum ? 1 : Maths.Max(1, (int)MathF.Ceiling(MathF.Sqrt(Maths.Max(1, n)))); + } } - public override float PreferredWidth - { - get { Dimensions(out int cols, out _); return _paddingLeft + _paddingRight + cols * _cellSize.X + Maths.Max(0, cols - 1) * _spacing.X; } - } - public override float PreferredHeight + private float WidthFor(int cols) + => _paddingLeft + _paddingRight + cols * _cellSize.X + Maths.Max(0, cols - 1) * _spacing.X; + + private float HeightForCurrentWidth() { - get { Dimensions(out _, out int rows); return _paddingTop + _paddingBottom + rows * _cellSize.Y + Maths.Max(0, rows - 1) * _spacing.Y; } + int n = GetLayoutChildren().Count; + if (n == 0) return _paddingTop + _paddingBottom; + + int cols = ColumnsForWidth(n, ContentWidth()); + int rows = _constraint == Constraint.FixedRowCount + ? Maths.Min(Maths.Max(1, _constraintCount), n) + : (n + cols - 1) / cols; + + return _paddingTop + _paddingBottom + rows * _cellSize.Y + Maths.Max(0, rows - 1) * _spacing.Y; } - public override float MinWidth => PreferredWidth; - public override float MinHeight => PreferredHeight; + + public override float PreferredWidth => WidthFor(WidthColumns(GetLayoutChildren().Count, minimum: false)); + public override float MinWidth => WidthFor(WidthColumns(GetLayoutChildren().Count, minimum: true)); + public override float PreferredHeight => HeightForCurrentWidth(); + public override float MinHeight => HeightForCurrentWidth(); } diff --git a/Prowl.Runtime/Components/UI/Layout/LayoutElement.cs b/Prowl.Runtime/Components/UI/Layout/LayoutElement.cs index 4c79cbbb3..7ee2a366f 100644 --- a/Prowl.Runtime/Components/UI/Layout/LayoutElement.cs +++ b/Prowl.Runtime/Components/UI/Layout/LayoutElement.cs @@ -1,6 +1,8 @@ // 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.Echo; using Prowl.Vector; @@ -57,11 +59,51 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) { /// priority (a nested reports its content size this way), otherwise the /// element's intrinsic size (its ). /// +/// +/// Results are memoized for the duration of one canvas layout pass. A nested group answers a size query +/// by querying all of its own children, so without the memo the cost multiplies at every nesting level - +/// each of the three queries below would re-walk the entire subtree beneath it. +/// public static class LayoutUtility { - public static Float2 GetPreferredSize(GameObject go) + private readonly struct Sizes + { + public readonly Float2 Min; + public readonly Float2 Preferred; + public readonly Float2 Flexible; + + public Sizes(Float2 min, Float2 preferred, Float2 flexible) + { + Min = min; Preferred = preferred; Flexible = flexible; + } + } + + private static readonly Dictionary s_cache = new(); + + /// Bumped by . Layout groups stamp their own cached + /// aggregates with it so they expire together with this cache. + internal static int Generation { get; private set; } + + /// Drops the memo. Called by the canvas at the start of every layout pass, since a + /// can rewrite sizes part-way through a walk. + internal static void InvalidateCache() { - float pw = -1f, ph = -1f, mw = -1f, mh = -1f; + s_cache.Clear(); + Generation++; + } + + public static Float2 GetPreferredSize(GameObject go) => Resolve(go).Preferred; + + public static Float2 GetMinSize(GameObject go) => Resolve(go).Min; + + /// Per-axis flexible weight (0 when none set). + public static Float2 GetFlexible(GameObject go) => Resolve(go).Flexible; + + private static Sizes Resolve(GameObject go) + { + if (s_cache.TryGetValue(go, out Sizes cached)) return cached; + + float pw = -1f, ph = -1f, mw = -1f, mh = -1f, fw = 0f, fh = 0f; foreach (MonoBehaviour c in go.GetComponents()) { if (c is not ILayoutElement le || !c.EnabledInHierarchy) continue; @@ -69,40 +111,24 @@ public static Float2 GetPreferredSize(GameObject go) ph = Maths.Max(ph, le.PreferredHeight); mw = Maths.Max(mw, le.MinWidth); mh = Maths.Max(mh, le.MinHeight); + if (le.FlexibleWidth > fw) fw = le.FlexibleWidth; + if (le.FlexibleHeight > fh) fh = le.FlexibleHeight; } Float2 intrinsic = Intrinsic(go); + float w = pw >= 0f ? pw : (mw >= 0f ? mw : intrinsic.X); float h = ph >= 0f ? ph : (mh >= 0f ? mh : intrinsic.Y); if (mw >= 0f) w = Maths.Max(w, mw); if (mh >= 0f) h = Maths.Max(h, mh); - return new Float2(w, h); - } - public static Float2 GetMinSize(GameObject go) - { - float mw = -1f, mh = -1f; - foreach (MonoBehaviour c in go.GetComponents()) - { - if (c is not ILayoutElement le || !c.EnabledInHierarchy) continue; - mw = Maths.Max(mw, le.MinWidth); - mh = Maths.Max(mh, le.MinHeight); - } - Float2 intrinsic = Intrinsic(go); - return new Float2(mw >= 0f ? mw : intrinsic.X, mh >= 0f ? mh : intrinsic.Y); - } + Sizes sizes = new Sizes( + new Float2(mw >= 0f ? mw : intrinsic.X, mh >= 0f ? mh : intrinsic.Y), + new Float2(w, h), + new Float2(fw, fh)); - /// Per-axis flexible weight (0 when none set). - public static Float2 GetFlexible(GameObject go) - { - float fw = 0f, fh = 0f; - foreach (MonoBehaviour c in go.GetComponents()) - { - if (c is not ILayoutElement le || !c.EnabledInHierarchy) continue; - if (le.FlexibleWidth > fw) fw = le.FlexibleWidth; - if (le.FlexibleHeight > fh) fh = le.FlexibleHeight; - } - return new Float2(fw, fh); + s_cache[go] = sizes; + return sizes; } private static Float2 Intrinsic(GameObject go) diff --git a/Prowl.Runtime/Components/UI/Layout/LayoutGroup.cs b/Prowl.Runtime/Components/UI/Layout/LayoutGroup.cs index fcc46f85a..1a7160e01 100644 --- a/Prowl.Runtime/Components/UI/Layout/LayoutGroup.cs +++ b/Prowl.Runtime/Components/UI/Layout/LayoutGroup.cs @@ -43,17 +43,22 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) { public abstract float PreferredHeight { get; } public virtual float FlexibleHeight => -1f; + // Refilled in place rather than reallocated: a group's children are queried several times per + // layout pass (once per reported dimension, once to arrange). Never enumerated across a nested + // query - a child's sizes come from components on the child, never back from this group. + [SerializeIgnore] private readonly List _layoutChildren = new(); + protected List GetLayoutChildren() { - var list = new List(); + _layoutChildren.Clear(); foreach (GameObject child in GameObject.Children) { if (!child.EnabledInHierarchy || child.RectTransform is null) continue; LayoutElement? le = child.GetComponent(); if (le is { IgnoreLayout: true }) continue; - list.Add(child); + _layoutChildren.Add(child); } - return list; + return _layoutChildren; } protected static void SetChildRect(GameObject child, Rect rect) @@ -95,38 +100,49 @@ public abstract class HorizontalOrVerticalLayoutGroup : LayoutGroup private float AlongOf(Float2 s) => IsVertical ? s.Y : s.X; private float CrossOf(Float2 s) => IsVertical ? s.X : s.Y; - public override float PreferredWidth => IsVertical ? CrossPreferred() : AlongPreferred(); - public override float PreferredHeight => IsVertical ? AlongPreferred() : CrossPreferred(); - public override float MinWidth => IsVertical ? CrossMin() : AlongMin(); - public override float MinHeight => IsVertical ? AlongMin() : CrossMin(); + public override float PreferredWidth { get { EnsureSizes(); return IsVertical ? _crossPreferred : _alongPreferred; } } + public override float PreferredHeight { get { EnsureSizes(); return IsVertical ? _alongPreferred : _crossPreferred; } } + public override float MinWidth { get { EnsureSizes(); return IsVertical ? _crossMin : _alongMin; } } + public override float MinHeight { get { EnsureSizes(); return IsVertical ? _alongMin : _crossMin; } } - private float AlongPreferred() - { - var kids = GetLayoutChildren(); - float sum = _spacing * Maths.Max(0, kids.Count - 1) + PaddingAlong; - foreach (GameObject k in kids) sum += AlongOf(LayoutUtility.GetPreferredSize(k)); - return sum; - } - private float AlongMin() - { - var kids = GetLayoutChildren(); - float sum = _spacing * Maths.Max(0, kids.Count - 1) + PaddingAlong; - foreach (GameObject k in kids) sum += AlongOf(LayoutUtility.GetMinSize(k)); - return sum; - } - private float CrossPreferred() + [SerializeIgnore] private int _sizesGeneration = -1; + [SerializeIgnore] private float _alongPreferred, _alongMin, _crossPreferred, _crossMin; + + /// Computes all four reported dimensions in a single walk of the children, cached for the + /// current layout pass. A parent asks for every one of them, and each child query can itself be a + /// whole nested group, so recomputing per property would multiply the work at every level. + private void EnsureSizes() { - var kids = GetLayoutChildren(); - float max = 0f; - foreach (GameObject k in kids) max = Maths.Max(max, CrossOf(LayoutUtility.GetPreferredSize(k))); - return max + PaddingCross; + if (_sizesGeneration == LayoutUtility.Generation) return; + _sizesGeneration = LayoutUtility.Generation; + + List kids = GetLayoutChildren(); + float gaps = _spacing * Maths.Max(0, kids.Count - 1) + PaddingAlong; + float along = gaps, alongMin = gaps, cross = 0f, crossMin = 0f; + + foreach (GameObject k in kids) + { + Float2 pref = LayoutUtility.GetPreferredSize(k); + Float2 min = LayoutUtility.GetMinSize(k); + along += AlongOf(pref); + alongMin += AlongOf(min); + cross = Maths.Max(cross, CrossOf(pref)); + crossMin = Maths.Max(crossMin, CrossOf(min)); + } + + _alongPreferred = along; + _alongMin = alongMin; + _crossPreferred = cross + PaddingCross; + _crossMin = crossMin + PaddingCross; } - private float CrossMin() + + // Per-child scratch for Arrange, grown on demand instead of reallocated every layout pass. + [SerializeIgnore] private float[] _pref = [], _min = [], _flex = [], _size = []; + + private void EnsureScratch(int n) { - var kids = GetLayoutChildren(); - float max = 0f; - foreach (GameObject k in kids) max = Maths.Max(max, CrossOf(LayoutUtility.GetMinSize(k))); - return max + PaddingCross; + if (_pref.Length >= n) return; + _pref = new float[n]; _min = new float[n]; _flex = new float[n]; _size = new float[n]; } public override void Arrange(Rect rect) @@ -146,7 +162,8 @@ public override void Arrange(Rect rect) bool forceExpandMain = IsVertical ? _childForceExpandHeight : _childForceExpandWidth; bool controlCross = IsVertical ? _childControlWidth : _childControlHeight; - float[] pref = new float[n], min = new float[n], flex = new float[n], size = new float[n]; + EnsureScratch(n); + float[] pref = _pref, min = _min, flex = _flex, size = _size; float totalPref = _spacing * (n - 1), totalFlex = 0f, totalShrink = 0f; for (int i = 0; i < n; i++) { diff --git a/Prowl.Runtime/Components/UI/TextComponent.cs b/Prowl.Runtime/Components/UI/TextComponent.cs index b35f337bc..218fa13db 100644 --- a/Prowl.Runtime/Components/UI/TextComponent.cs +++ b/Prowl.Runtime/Components/UI/TextComponent.cs @@ -89,13 +89,12 @@ public bool RichTextEnabled protected override Material DefaultMaterial => GameCanvas.SharedTextMaterial; /// - /// Atlas version recorded at the last successful bake. When Scribe grows the atlas - /// (e.g. a new glyph or pixel-size variant is introduced) all existing AtlasGlyph - /// UVs are recomputed against the new texture dimensions, so any cached glyph mesh - /// is now pointing at the wrong region. We catch that in and - /// force a rebake. + /// When Scribe grows the atlas (a new glyph or pixel-size variant) every existing AtlasGlyph UV is + /// recomputed against the new texture dimensions, so any cached glyph mesh points at the wrong + /// region. Reporting the atlas version here makes the canvas re-bake this text in both play and + /// edit mode. /// - [SerializeIgnore] private int _lastAtlasVersion = -1; + public override int ContentVersion => UIFontSystem.Default.System.AtlasVersion; // Cached rich-text layout. Reused across frames so its animation start-time survives (a fresh // layout each rebuild would re-anchor to "now" and freeze animated effects). Rebuilt only when the @@ -106,13 +105,6 @@ public bool RichTextEnabled public override void Update() { - int v = UIFontSystem.Default.System.AtlasVersion; - if (v != _lastAtlasVersion) - { - _lastAtlasVersion = v; - MarkDirty(UIDirtyFlags.Vertices); - } - // Animated rich-text effects (wave, shake, rainbow, typewriter, ...) are time-driven, so the // mesh has to be rebuilt every frame to advance them. if (_richText && _richAnimated) @@ -146,7 +138,7 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) float originX = -pivot.X * w; float originY = (1f - pivot.Y) * h; - Color tinted = TextColor * new Color(1f, 1f, 1f, context.Alpha); + Color tinted = Color * new Color(1f, 1f, 1f, context.Alpha); FontColor color = new FontColor(tinted.R, tinted.G, tinted.B, tinted.A); // Scribe generates the geometry (plain or rich-tag parsed) and drives DrawQuads; the capture @@ -204,31 +196,83 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) } } + // ============================================================ + // Single-line measurement (caret / selection support for input fields) + // ============================================================ + + // One cached layout, rebuilt only when the text or the font settings change. Callers hit-test + // against it instead of re-laying-out a substring per character, which was quadratic per frame. + [SerializeIgnore] private TextLayout? _measureLayout; + [SerializeIgnore] private string _measureText = string.Empty; + [SerializeIgnore] private int _measureSig; + + private TextLayout? GetSingleLineLayout(string? s) + { + FontAsset? font = ResolvedFont; + if (font.IsNotValid() || font.FontFile is null) return null; + + string text = s ?? string.Empty; + int sig = HashCode.Combine(font.FontFile, _size, (int)_quality); + + if (_measureLayout is null || sig != _measureSig || !string.Equals(_measureText, text, StringComparison.Ordinal)) + { + TextLayoutSettings settings = new TextLayoutSettings + { + Font = font.FontFile, + PixelSize = Maths.Max(1, _size), + Quality = _quality, + Alignment = ScribeAlign.Left, + MaxWidth = float.MaxValue, + WrapMode = TextWrapMode.NoWrap, + LineHeight = 1.0f, + TabSize = 4, + LetterSpacing = 0f, + WordSpacing = 0f, + }; + _measureLayout = UIFontSystem.Default.System.CreateLayout(text, settings); + _measureText = text; + _measureSig = sig; + } + else + { + _measureLayout.EnsureUpToDate(UIFontSystem.Default.System); + } + + return _measureLayout; + } + /// /// Measures the width, in design pixels, of laid out on a single line with this - /// component's current font, size and quality. Input fields use this to place the caret and selection - /// box relative to the text. Returns 0 for empty text or when no font is available. + /// component's current font, size and quality. Returns 0 for empty text or when no font is available. /// public float MeasureWidth(string? s) { - FontAsset? font = ResolvedFont; - if (font.IsNotValid() || font.FontFile is null || string.IsNullOrEmpty(s)) return 0f; + if (string.IsNullOrEmpty(s)) return 0f; + TextLayout? layout = GetSingleLineLayout(s); + return layout is null ? 0f : (float)layout.Size.X; + } - TextLayoutSettings settings = new TextLayoutSettings - { - Font = font.FontFile, - PixelSize = Maths.Max(1, _size), - Quality = _quality, - Alignment = ScribeAlign.Left, - MaxWidth = float.MaxValue, - WrapMode = TextWrapMode.NoWrap, - LineHeight = 1.0f, - TabSize = 4, - LetterSpacing = 0f, - WordSpacing = 0f, - }; - TextLayout layout = UIFontSystem.Default.System.CreateLayout(s, settings); - return (float)layout.Size.X; + /// + /// X offset, in design pixels from the left edge of , of the caret sitting before + /// character . Single-line layout. + /// + public float MeasureCaretOffset(string? s, int charIndex) + { + TextLayout? layout = GetSingleLineLayout(s); + if (layout is null) return 0f; + int clamped = Math.Clamp(charIndex, 0, (s ?? string.Empty).Length); + return (float)layout.GetCursorPosition(clamped).X; + } + + /// + /// The character index whose caret slot is nearest , measured in design pixels + /// from the left edge of . Single-line layout. + /// + public int MeasureCaretIndexAt(string? s, float x) + { + TextLayout? layout = GetSingleLineLayout(s); + if (layout is null) return 0; + return layout.GetCursorIndex(new Float2(x, 0f)); } public override void PopulateProperties(PropertyState p, in UIContext _) diff --git a/Prowl.Runtime/Components/UI/UIBehaviour.cs b/Prowl.Runtime/Components/UI/UIBehaviour.cs index 91ce37547..cdf1a9b76 100644 --- a/Prowl.Runtime/Components/UI/UIBehaviour.cs +++ b/Prowl.Runtime/Components/UI/UIBehaviour.cs @@ -34,6 +34,15 @@ public abstract class UIBehaviour : MonoBehaviour // force a re-bake when they drift (otherwise a stretched child renders at its old size). [SerializeIgnore] internal Float2 LastBakeSize = new(float.NaN, float.NaN); [SerializeIgnore] internal float LastBakeAlpha = float.NaN; + [SerializeIgnore] internal int LastBakeContentVersion = -1; + + /// + /// Stamp of external state the baked mesh depends on but which nothing dirties: text geometry is + /// tied to the glyph atlas, and the atlas is rewritten (and its UVs rescaled) as new glyphs arrive. + /// The canvas re-bakes whenever this moves, so it holds in edit mode too, where Update does + /// not run. + /// + public virtual int ContentVersion => 0; public override void OnEnable() { diff --git a/Prowl.Runtime/Components/UI/UIImage.cs b/Prowl.Runtime/Components/UI/UIImage.cs index 25a4318a0..870dbb907 100644 --- a/Prowl.Runtime/Components/UI/UIImage.cs +++ b/Prowl.Runtime/Components/UI/UIImage.cs @@ -167,23 +167,6 @@ public bool FillClockwise set => SetField(ref _fillClockwise, value, UIDirtyFlags.Vertices); } - /// - /// Whether this element should block raycasts (pointer hit-testing). - /// Affects input dispatch only - does not change rendering. - /// - [SerializeField] private bool _raycastTarget = true; - public bool RaycastTarget - { - get => _raycastTarget; - set => SetField(ref _raycastTarget, value, UIDirtyFlags.Hierarchy); - } - - public override Material GetMaterial() - { - var m = _material.Res; - return m.IsValid() ? m : base.GetMaterial(); - } - public override void GenerateMesh(UIMeshBuilder b, in UIContext ctx) { var rt = GameObject.RectTransform; diff --git a/Prowl.Runtime/Components/UI/UIRenderItem.cs b/Prowl.Runtime/Components/UI/UIRenderItem.cs index c3df2e8c4..82af0f662 100644 --- a/Prowl.Runtime/Components/UI/UIRenderItem.cs +++ b/Prowl.Runtime/Components/UI/UIRenderItem.cs @@ -52,7 +52,7 @@ internal sealed class UIRenderItem : IRenderable // -------- Sort + lifecycle -------- public long SortKey; // (SortOrder << 42) | (canvasDiscriminator << 21) | depthFirstIndex public UISurface Surface; // mirrors Canvas.RenderMode, frozen at rebuild time - public uint LastTransformVersion; // (matrix-only refresh) + public Float4x4 LastOwnerWorld; // owner's world matrix at the last model refresh public UIDirtyFlags PropertyCacheState; // tracks whether Props needs repopulating // -------- Clip (mask) state (set by the canvas during BuildRecursive) -------- @@ -138,7 +138,7 @@ internal void Initialize(UIBehaviour owner, GameCanvas canvas, Mesh mesh, Materi Model = model; SortKey = sortKey; Surface = surface; - LastTransformVersion = owner.Transform.Version; + LastOwnerWorld = owner.Transform.LocalToWorldMatrix; PropertyCacheState = UIDirtyFlags.All; // forces first GetRenderingData to populate if (clip is { } c) @@ -160,15 +160,22 @@ internal void Initialize(UIBehaviour owner, GameCanvas canvas, Mesh mesh, Materi /// /// Called once per frame, before the pipeline draws this surface, by /// . Patches - /// when only the owning has changed (no mesh rebuild needed). + /// when only the transforms have changed (no mesh rebuild needed). /// + /// + /// The model is composed from the owner's transform and every ancestor's: parent panels + /// contribute rotation/scale/Z and the canvas contributes CanvasToWorld. The owner's world + /// matrix already folds in that whole chain, so comparing it catches a moved canvas or a rotated + /// parent panel - which the local-only does not. Reading it is a + /// couple of version compares per ancestor while nothing has moved. + /// internal void RefreshModelIfDirty() { - uint v = Owner.Transform.Version; - if (v == LastTransformVersion) return; + Float4x4 world = Owner.Transform.LocalToWorldMatrix; + if (world == LastOwnerWorld) return; Model = Canvas.BuildItemModel(Owner); if (HasClip && ClipSource != null) ClipToLocal = Canvas.BuildItemModel(ClipSource).Invert(); - LastTransformVersion = v; + LastOwnerWorld = world; } } From 0954e15454b59312d772bacce604781c18266238 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:21:26 +1000 Subject: [PATCH 36/67] Remove sealed from Graphic.GetMaterial() --- Prowl.Runtime/Components/UI/Graphic.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prowl.Runtime/Components/UI/Graphic.cs b/Prowl.Runtime/Components/UI/Graphic.cs index b2226719a..cea629800 100644 --- a/Prowl.Runtime/Components/UI/Graphic.cs +++ b/Prowl.Runtime/Components/UI/Graphic.cs @@ -49,7 +49,7 @@ public Color Color /// The material used when no override is assigned. protected virtual Material DefaultMaterial => GameCanvas.SharedUIMaterial; - public sealed override Material GetMaterial() + public override Material GetMaterial() { Material? m = _material.Res; return m.IsValid() ? m : DefaultMaterial; From 36c79daabb1b15b8678c65af88b39c7c6fea331c Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:21:53 +1000 Subject: [PATCH 37/67] Avoid lazy-init of font system on atlas version check --- Prowl.Runtime/Components/GameCanvas.cs | 6 ++++-- Prowl.Runtime/Components/UI/UIFontSystem.cs | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 24c7f080f..1081d60a3 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -233,8 +233,10 @@ public void RebuildIfDirty() // via GameCanvas.ScreenSizeOverride before calling here, so a mismatch forces a rebuild. // The glyph atlas is rewritten (and every glyph UV rescaled) as new glyphs are rasterized, which // silently invalidates already-baked text meshes. Checked here rather than from - // TextComponent.Update so it holds in edit mode, where Update does not run. - int atlasVersion = UIFontSystem.Default.System.AtlasVersion; + // TextComponent.Update so it holds in edit mode, where Update does not run. Read without forcing + // the font system to exist: it allocates a GPU texture, and a canvas with no text must not + // trigger that (nor must a headless context that never draws). + int atlasVersion = UIFontSystem.CurrentAtlasVersion; if (_lastAtlasVersion != atlasVersion) { _lastAtlasVersion = atlasVersion; diff --git a/Prowl.Runtime/Components/UI/UIFontSystem.cs b/Prowl.Runtime/Components/UI/UIFontSystem.cs index 1f2a24ce5..fb36150d2 100644 --- a/Prowl.Runtime/Components/UI/UIFontSystem.cs +++ b/Prowl.Runtime/Components/UI/UIFontSystem.cs @@ -34,6 +34,13 @@ internal sealed class UIFontSystem : IFontRenderer private static UIFontSystem? s_default; public static UIFontSystem Default => s_default ??= new UIFontSystem(); + /// + /// Atlas version without forcing the font system into existence, for callers that only want to know + /// whether glyph UVs moved. Constructing it allocates a GPU texture, so a canvas holding no text + /// (or any headless context) must not be the thing that brings it up. + /// + public static int CurrentAtlasVersion => s_default is null ? 0 : s_default.System.AtlasVersion; + /// The Scribe font system. public FontSystem System { get; } From 7181601acf1bcecd802a3f62bc2db16ab4f15625 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:22:07 +1000 Subject: [PATCH 38/67] Clear layout cache after canvas rebuild --- Prowl.Runtime/Components/GameCanvas.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 1081d60a3..2a6cbf2e3 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -282,6 +282,9 @@ public void RebuildIfDirty() } while (_isDirty && ++pass < MaxLayoutPasses); + // The memo keys on GameObject, so holding it between rebuilds would keep destroyed objects alive. + LayoutUtility.InvalidateCache(); + // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. A // layout that never settled also stays dirty and retries next frame rather than showing a // half-resolved result. From ccd2d910630d227eedc0b0b4c695160a102c9229 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:22:37 +1000 Subject: [PATCH 39/67] Lazy-capture authored sprite on first use --- Prowl.Runtime/Components/UI/Input/Selectable.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Prowl.Runtime/Components/UI/Input/Selectable.cs b/Prowl.Runtime/Components/UI/Input/Selectable.cs index 1ebff3f93..b03cbdf3b 100644 --- a/Prowl.Runtime/Components/UI/Input/Selectable.cs +++ b/Prowl.Runtime/Components/UI/Input/Selectable.cs @@ -184,6 +184,7 @@ public SpriteState SpriteState [SerializeIgnore] private Color _toColor = Color.White; [SerializeIgnore] private float _transitionElapsed; [SerializeIgnore] private AssetRef _authoredSprite; + [SerializeIgnore] private bool _authoredSpriteCaptured; /// The current high-level state. Read-only for derived classes. public SelectionState CurrentState => _currentState; @@ -204,7 +205,6 @@ public override void GenerateMesh(UIMeshBuilder builder, in UIContext context) { public override void OnEnable() { base.OnEnable(); - CaptureAuthoredSprite(); RefreshState(immediate: true); } @@ -315,6 +315,14 @@ private void ApplySpriteSwap(SelectionState state) { if (TargetGraphic is not UIImage image) return; + // Captured on first use rather than in OnEnable: the target graphic may be added after this + // component, and capturing an empty ref would make the Normal state wipe the real sprite. + if (!_authoredSpriteCaptured) + { + _authoredSprite = image.Sprite; + _authoredSpriteCaptured = true; + } + AssetRef next = state switch { SelectionState.Disabled => _spriteState.DisabledSprite, @@ -327,13 +335,6 @@ private void ApplySpriteSwap(SelectionState state) image.Sprite = next.IsExplicitNull ? _authoredSprite : next; } - /// Remembers the sprite the target graphic was authored with so the Normal state (and any - /// unassigned state) can return to it. - private void CaptureAuthoredSprite() - { - if (TargetGraphic is UIImage image) _authoredSprite = image.Sprite; - } - private SelectionState ComputeState() { if (!IsInteractable()) return SelectionState.Disabled; From b55d0b39ffffc44e551acc0ee9c08d93f1e3a11c Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:24:13 +1000 Subject: [PATCH 40/67] Fix ParentSize to walk up ancestor chain --- Prowl.Runtime/Components/UI/RectTransform.cs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Prowl.Runtime/Components/UI/RectTransform.cs b/Prowl.Runtime/Components/UI/RectTransform.cs index cb4f1ab9d..dc33b9817 100644 --- a/Prowl.Runtime/Components/UI/RectTransform.cs +++ b/Prowl.Runtime/Components/UI/RectTransform.cs @@ -274,15 +274,20 @@ public void ForceUpdateRectTransforms() if (canvas.IsValid()) canvas.RebuildIfDirty(); } - /// Laid-out size of the parent rect this element anchors against: the parent's - /// RectTransform, or the canvas root rect when the parent is the canvas itself. + /// + /// Laid-out size of the rect this element anchors against. Walks up past any ancestor without a + /// RectTransform, because the canvas passes its parent rect straight through those, and ends at the + /// canvas root rect. + /// private Float2 ParentSize() { - GameObject? parent = GameObject.Parent; - if (parent is null) return Float2.Zero; - if (parent.RectTransform is { } prt) return prt.ComputedRect.Size; - GameCanvas? canvas = parent.GetComponent(); - return canvas.IsValid() ? canvas.RootRect.Size : Float2.Zero; + for (GameObject? node = GameObject.Parent; node != null; node = node.Parent) + { + if (node.RectTransform is { } prt) return prt.ComputedRect.Size; + GameCanvas? canvas = node.GetComponent(); + if (canvas.IsValid()) return canvas.RootRect.Size; + } + return Float2.Zero; } public void MarkLayoutDirty() From 848e8bd6d2d2fbd688e5b51ff4c5d5d1148ee506 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:24:48 +1000 Subject: [PATCH 41/67] Fix UI navigation across canvases --- .../Components/UI/Input/Selectable.cs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/Prowl.Runtime/Components/UI/Input/Selectable.cs b/Prowl.Runtime/Components/UI/Input/Selectable.cs index b03cbdf3b..a3e8c8097 100644 --- a/Prowl.Runtime/Components/UI/Input/Selectable.cs +++ b/Prowl.Runtime/Components/UI/Input/Selectable.cs @@ -395,8 +395,16 @@ public virtual void OnMove(MoveDirection direction) { Scene? scene = GameObject.Scene; if (scene is null) return null; + if (!TryWorldCenter(this, out Float3 origin, out Float4x4 model)) return null; + + // Map the design-space direction through the canvas so a rotated (or world-space) canvas + // navigates along its own axes rather than the world's. + Float3 dirWorld = Float4x4.TransformPoint(new Float3(dir.X, dir.Y, 0f), model) + - Float4x4.TransformPoint(Float3.Zero, model); + float dirLength = Float3.Length(dirWorld); + if (dirLength < 1e-6f) return null; + dirWorld /= dirLength; - Float2 origin = RectCenter(this); Selectable? best = null; float bestScore = float.NegativeInfinity; Selectable? wrap = null; @@ -408,12 +416,13 @@ public virtual void OnMove(MoveDirection direction) { if (ReferenceEquals(candidate, this) || !candidate.EnabledInHierarchy) continue; if (!candidate.IsInteractable() || candidate._navigation.Mode == NavigationMode.None) continue; + if (!TryWorldCenter(candidate, out Float3 center, out _)) continue; - Float2 offset = RectCenter(candidate) - origin; - float distance = Float2.Length(offset); + Float3 offset = center - origin; + float distance = Float3.Length(offset); if (distance < 1e-4f) continue; - float alignment = Float2.Dot(offset / distance, dir); + float alignment = Float3.Dot(offset / distance, dirWorld); if (alignment > 0.1f) { float score = alignment / distance; @@ -431,11 +440,21 @@ public virtual void OnMove(MoveDirection direction) return best.IsValid() ? best : wrap; } - private static Float2 RectCenter(Selectable s) + private static bool TryWorldCenter(Selectable s, out Float3 center, out Float4x4 model) { + center = default; + model = Float4x4.Identity; + RectTransform? rt = s.GameObject.RectTransform; - if (rt is null) return Float2.Zero; - Rect r = rt.ComputedRect; - return (r.Min + r.Max) * 0.5f; + if (rt is null) return false; + + GameCanvas? canvas = s.GetCanvas(); + if (canvas.IsNotValid()) return false; + + model = canvas.CanvasToWorld * canvas.BuildRectModel(rt); + Rect local = rt.Rect; + Float2 localCenter = (local.Min + local.Max) * 0.5f; + center = Float4x4.TransformPoint(new Float3(localCenter.X, localCenter.Y, 0f), model); + return true; } } From 7e7166a6c2c2be4ececc67bf59e98d661c758b7a Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:34:53 +1000 Subject: [PATCH 42/67] Fixed Tooltip attribute not working # Conflicts: # Prowl.Editor/GUI/AttributeHandlers.cs --- Prowl.Editor/GUI/AttributeHandlers.cs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index a60cda1ac..32bea7cd9 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Reflection; +using Prowl.OrigamiUI; using Prowl.PaperUI; using Prowl.PaperUI.LayoutEngine; using Prowl.Runtime; @@ -279,8 +280,21 @@ public override bool OnDraw(Paper paper, string id, string label, Attribute attr /// [Tooltip("text")] - attaches a tooltip to the field row. public class TooltipAttributeHandler : OrigamiUI.AttributeHandler { - // Tooltips are handled by the PropertyGrid row itself checking for the attribute - // and calling .Tooltip() on the row element. No pre/post draw needed. + private readonly Stack _scopes = new(); + + public override bool OnBeforeDraw(Paper paper, string id, Attribute attr, FieldInfo field, object target, int depth) + { + var tooltip = (TooltipAttribute)attr; + _scopes.Push(paper.Column($"{id}_tip").Width(UnitValue.Stretch()).Height(UnitValue.Auto) + .Tooltip(tooltip.Text).Enter()); + return true; + } + + public override void OnAfterDraw(Paper paper, string id, Attribute attr, FieldInfo field, object target, int depth) + { + if (_scopes.Count > 0) + _scopes.Pop().Dispose(); + } } /// [TextArea(min, max)] - replaces string field with a multiline text area. @@ -332,9 +346,9 @@ public static void Register(OrigamiUI.AttributeHandlerRegistry registry) registry.Register(new ReadOnlyAttributeHandler()); registry.Register(new RangeAttributeHandler()); registry.Register(new TextAreaAttributeHandler()); + registry.Register(new TooltipAttributeHandler()); registry.Register(new NavMeshAreaAttributeHandler()); registry.Register(new NavMeshAreaMaskAttributeHandler()); registry.Register(new NavMeshAgentTypeAttributeHandler()); - // TooltipAttribute is handled inline by PropertyGrid row rendering } } From 8349b211eed1afe4c4daed01aa37d00ee054d123 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:42:21 +1000 Subject: [PATCH 43/67] Fixed double click and right click -> open, behaving much differently --- Prowl.Editor/GUI/Panels/ProjectPanel.cs | 38 +++++++++++++++-------- Prowl.Runtime/Components/GameCanvas.cs | 41 +++++++++---------------- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/Prowl.Editor/GUI/Panels/ProjectPanel.cs b/Prowl.Editor/GUI/Panels/ProjectPanel.cs index 6ad66da1d..ea49131e0 100644 --- a/Prowl.Editor/GUI/Panels/ProjectPanel.cs +++ b/Prowl.Editor/GUI/Panels/ProjectPanel.cs @@ -772,13 +772,12 @@ private void DrawListView(Paper paper, Scribe.FontFile font, List e .OnRowActivate(i => { var it = visible[i].item; - if (it.IsFolder) NavigateTo(it.RelativePath); - else if (it.Subs.Count > 0) + if (!it.IsFolder && it.Subs.Count > 0) { if (_expandedAssets.Contains(it.Guid)) _expandedAssets.Remove(it.Guid); else _expandedAssets.Add(it.Guid); } - else EditorSceneManager.HandleAssetDoubleClick(it.RelativePath, it.Guid); + else OpenItem(it); }) .OnRowContext(i => { @@ -905,10 +904,7 @@ private Action ItemContextMenu(ContentItem item, bool inTree = f builder.Title(isMulti ? Loc.Get("project.item_count", new { count = Selection.Count }) : item.Name, iconDraw: titleStyle.Icon); // Open / reveal. - if (item.IsFolder) - builder.Item(Loc.Get("launcher.open"), () => NavigateTo(item.RelativePath), icon: EditorIcons.FolderOpen); - else - builder.Item(Loc.Get("launcher.open"), () => OpenWithSystem(item), icon: EditorIcons.FolderOpen); + builder.Item(Loc.Get("launcher.open"), () => OpenItem(item), icon: EditorIcons.FolderOpen); builder.Item(Loc.Get("project.show_in_explorer"), () => ShowInExplorer(item), icon: EditorIcons.FolderTree); builder.Separator(); @@ -1111,6 +1107,23 @@ private void StartRename(ContentItem item, bool inTree = false) }); } + /// + /// Open an item: folders navigate, assets go to their registered + /// handler, and anything without one + /// falls back to the system default application. + /// + private void OpenItem(ContentItem item) + { + if (item.IsFolder) + { + NavigateTo(item.RelativePath); + return; + } + + if (!EditorRegistries.DispatchDoubleClick(item.RelativePath, item.Guid)) + OpenWithSystem(item); + } + private static void OpenWithSystem(ContentItem item) { string absPath = Path.Combine(Project.Current!.AssetsPath, item.RelativePath); @@ -1118,7 +1131,10 @@ private static void OpenWithSystem(ContentItem item) { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(absPath) { UseShellExecute = true }); } - catch { } + catch (Exception ex) + { + Runtime.Debug.LogError($"Failed to open {item.RelativePath}: {ex.Message}"); + } } private static void ShowInExplorer(ContentItem item) @@ -1260,15 +1276,13 @@ private void DrawGridItem(Paper paper, Scribe.FontFile font, string id, ContentI }) .OnDoubleClick(item, (it, _) => { - if (it.IsFolder) - NavigateTo(it.RelativePath); - else if (it.HasSubAssets) + if (!it.IsFolder && it.HasSubAssets) { if (_expandedAssets.Contains(it.Guid)) _expandedAssets.Remove(it.Guid); else _expandedAssets.Add(it.Guid); } else - EditorSceneManager.HandleAssetDoubleClick(it.RelativePath, it.Guid); + OpenItem(it); }) .OnDragStart(item, (it, _) => { diff --git a/Prowl.Runtime/Components/GameCanvas.cs b/Prowl.Runtime/Components/GameCanvas.cs index 2a6cbf2e3..6826b6cd8 100644 --- a/Prowl.Runtime/Components/GameCanvas.cs +++ b/Prowl.Runtime/Components/GameCanvas.cs @@ -260,42 +260,31 @@ public void RebuildIfDirty() // this rebuild every frame in ScaleWithScreenSize mode. _scaleFactor = ComputeScaleFactor(); - // The walk itself can dirty the canvas: a ContentSizeFitter writes its SizeDelta mid-walk, so - // ancestors and earlier siblings were arranged against the previous size. Clear the flag first - // and re-walk while it comes back, which settles the layout within this frame instead of - // leaving it permanently stale (the flag used to be overwritten, discarding the request). - int pass = 0; - do - { - _isDirty = false; + // Clear the flag before walking, not after. The walk can legitimately raise it again - a + // ContentSizeFitter writes SizeDelta as it goes - and the old code assigned over the flag at the + // end, discarding the request. One walk is always enough to produce a correct layout: sizes are + // resolved by LayoutUtility, which recurses through the whole subtree on its own, so a fitter + // writes a value the pass has already accounted for and its own rect is computed right after. + _isDirty = false; - LayoutUtility.InvalidateCache(); - Tree.Clear(); + LayoutUtility.InvalidateCache(); + Tree.Clear(); - Rect rootRect = ComputeRootRect(); - _rootRect = rootRect; // the canvas has no RectTransform; children lay out against this directly + Rect rootRect = ComputeRootRect(); + _rootRect = rootRect; // the canvas has no RectTransform; children lay out against this directly - int dfs = 0; - _contentPending = false; - BuildRecursive(GameObject, rootRect, UIContext.Default, canvasScissor: null, activeClip: null, ref dfs); - Tree.SortHierarchical(); - } - while (_isDirty && ++pass < MaxLayoutPasses); + int dfs = 0; + _contentPending = false; + BuildRecursive(GameObject, rootRect, UIContext.Default, canvasScissor: null, activeClip: null, ref dfs); + Tree.SortHierarchical(); // The memo keys on GameObject, so holding it between rebuilds would keep destroyed objects alive. LayoutUtility.InvalidateCache(); - // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. A - // layout that never settled also stays dirty and retries next frame rather than showing a - // half-resolved result. + // Stay dirty while anything is still streaming in, so it gets rebuilt with the real asset. _isDirty |= _contentPending; } - /// How many times a single rebuild re-walks when the walk dirties the canvas (nested - /// content-size fitters need one pass per level). Beyond this the layout is treated as unstable and - /// left dirty for the next frame. - private const int MaxLayoutPasses = 4; - private Rect ComputeRootRect() { // World-space canvases have a fixed design size (their ReferenceResolution) and don't track the From dda53a88d6a01c6c17a9847defb72b66405aeb4e Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:43:02 +1000 Subject: [PATCH 44/67] Removed EditorSceneManager.HandleAssetDoubleClick - Obsolete --- Prowl.Editor/GUI/SceneView/EditorSceneManager.cs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs index f09a58753..fdfcfeec0 100644 --- a/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs +++ b/Prowl.Editor/GUI/SceneView/EditorSceneManager.cs @@ -203,14 +203,6 @@ public static void EnsureSceneLoaded() NewScene(); } - /// - /// Handle double-clicking an asset in the project panel. Dispatches to a handler - /// registered via . Returns true if the - /// asset was handled. - /// - public static bool HandleAssetDoubleClick(string relativePath, Guid guid) - => EditorRegistries.DispatchDoubleClick(relativePath, guid); - [AssetDoubleClickHandler(".scene")] private static bool OpenSceneHandler(string relativePath, Guid guid) => OpenScene(relativePath); From 272cf8f4e5348d07d451f4605aecf1bb6eae7ec5 Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:44:17 +1000 Subject: [PATCH 45/67] Fixed created assets landing in wrong directories at times --- Prowl.Editor/AssetsDatabase/AssetCreateMenu.cs | 6 +++++- Prowl.Editor/Core/Tasks/CreateAssetTask.cs | 9 ++++++--- Prowl.Editor/GUI/Panels/ProjectPanel.cs | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Prowl.Editor/AssetsDatabase/AssetCreateMenu.cs b/Prowl.Editor/AssetsDatabase/AssetCreateMenu.cs index 49d1121db..6cfce8c16 100644 --- a/Prowl.Editor/AssetsDatabase/AssetCreateMenu.cs +++ b/Prowl.Editor/AssetsDatabase/AssetCreateMenu.cs @@ -56,12 +56,16 @@ static void CreateFolderItem() catch (Exception ex) { Debug.LogError($"Failed to create {entry.Name}: {ex.Message}"); return null; } } + /// + /// The folder new assets go into: the selected folder if there is one, otherwise the folder + /// the project panel is currently browsing. + /// public static string GetCurrentFolder() { var selected = Selection.GetActiveAs(); if (selected != null && selected.IsFolder) return selected.RelativePath; - return ""; + return ProjectPanel.Instance?.CurrentFolder ?? ""; } public static string GetAbsoluteFolder(string relativeFolder) diff --git a/Prowl.Editor/Core/Tasks/CreateAssetTask.cs b/Prowl.Editor/Core/Tasks/CreateAssetTask.cs index 3f0bbdf0c..8c4b8bb4c 100644 --- a/Prowl.Editor/Core/Tasks/CreateAssetTask.cs +++ b/Prowl.Editor/Core/Tasks/CreateAssetTask.cs @@ -45,6 +45,9 @@ public async void BeginCreateTask(AssetMenuEntry entry, string relativeFolder) var panel = ProjectPanel.Instance; if (panel != null) { + // The rename placeholder is drawn in the browsed folder, so show the target folder. + panel.NavigateTo(relativeFolder); + string newName = entry.Name; string? renameResult = null; bool finished = false; @@ -74,9 +77,9 @@ public async void BeginCreateTask(AssetMenuEntry entry, string relativeFolder) { var path = TaskType switch { - AssetType.Asset => CreateAsset(entry, panel.CurrentFolder, renameResult), - AssetType.Shader => CreateShader(renameResult, panel.CurrentFolder), - AssetType.Folder => CreateFolder(renameResult, panel.CurrentFolder), + AssetType.Asset => CreateAsset(entry, relativeFolder, renameResult), + AssetType.Shader => CreateShader(renameResult, relativeFolder), + AssetType.Folder => CreateFolder(renameResult, relativeFolder), _ => null }; } diff --git a/Prowl.Editor/GUI/Panels/ProjectPanel.cs b/Prowl.Editor/GUI/Panels/ProjectPanel.cs index ea49131e0..023f1206b 100644 --- a/Prowl.Editor/GUI/Panels/ProjectPanel.cs +++ b/Prowl.Editor/GUI/Panels/ProjectPanel.cs @@ -93,7 +93,7 @@ private enum SortMode { Name, Type, Size, Modified } private bool IsListView => _thumbnailSize < ListThreshold; - private void NavigateTo(string folder) + public void NavigateTo(string folder) { if (folder == _currentFolder) return; _navBack.Push(_currentFolder); From b611d0b243095f222c5a0b32ffce2681ce15505e Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 03:53:05 +1000 Subject: [PATCH 46/67] Fixed double drawing button attributes in inspector --- Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs | 7 ++++--- Prowl.Editor/GUI/Panels/ComponentPopoutPanel.cs | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs b/Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs index f6d1eea84..5ca743fe8 100644 --- a/Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs +++ b/Prowl.Editor/GUI/CustomEditors/GameObjectInspector.cs @@ -907,12 +907,13 @@ private static void DrawComponents(Paper paper, Prowl.Scribe.FontFile font, Game { var customEditor = EditorRegistries.GetCustomEditor(comp.GetType()); if (customEditor != null) + { customEditor.OnGUI(paper, compId, comp); + // The PropertyGrid draws [Button] methods itself, a custom editor does not. + DrawButtonMethods(paper, $"{compId}_btns", comp); + } else PropertyGridUtils.Draw(paper, compId, comp); - - // Draw [Button] attributed methods - DrawButtonMethods(paper, $"{compId}_btns", comp); } catch (Exception ex) { diff --git a/Prowl.Editor/GUI/Panels/ComponentPopoutPanel.cs b/Prowl.Editor/GUI/Panels/ComponentPopoutPanel.cs index 6be2ebf45..744dc712b 100644 --- a/Prowl.Editor/GUI/Panels/ComponentPopoutPanel.cs +++ b/Prowl.Editor/GUI/Panels/ComponentPopoutPanel.cs @@ -106,14 +106,13 @@ public override void OnGUI(Paper paper, float width, float height) if (customEditor != null) { customEditor.OnGUI(paper, compId, comp); + // The PropertyGrid draws [Button] methods itself, a custom editor does not. + GameObjectInspector.DrawButtonMethods(paper, $"{compId}_btns", comp); } else { PropertyGridUtils.Draw(paper, compId, comp); } - - // Draw [Button] methods - GameObjectInspector.DrawButtonMethods(paper, $"{compId}_btns", comp); }); } From 6c5005c647f7e1ebed5b4ab92f0d047a10b6be2a Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 04:29:27 +1000 Subject: [PATCH 47/67] Show empty folder icon for empty folders --- Prowl.Editor/GUI/Panels/ProjectPanel.cs | 28 ++++++++++++++++--- .../GUI/Registries/AssetTypeStyles.cs | 1 + 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Prowl.Editor/GUI/Panels/ProjectPanel.cs b/Prowl.Editor/GUI/Panels/ProjectPanel.cs index 023f1206b..dfb3f3b12 100644 --- a/Prowl.Editor/GUI/Panels/ProjectPanel.cs +++ b/Prowl.Editor/GUI/Panels/ProjectPanel.cs @@ -620,6 +620,26 @@ private void DrawFolderTree(Paper paper, Scribe.FontFile font, float height) } } + /// True when a folder holds no visible files or subfolders. Served from the cached folder index. + private static bool IsFolderEmpty(string relativePath) + { + var db = EditorAssetBackend.Instance; + if (db == null) return false; + + var subs = db.GetSubFolders(relativePath); + for (int i = 0; i < subs.Count; i++) + if (!subs[i].Name.StartsWith('.')) return false; + + var files = db.GetFolderFiles(relativePath); + for (int i = 0; i < files.Count; i++) + if (!files[i].Name.StartsWith('.')) return false; + + return true; + } + + private static AssetTypeStyle FolderStyle(string relativePath) + => IsFolderEmpty(relativePath) ? AssetTypeStyles.EmptyFolder : AssetTypeStyles.Folder; + private static void BuildFolderNodes(List nodes, string relativePath, string displayName, int depth) { // Read the folder structure from the asset database's cached index instead of walking the @@ -636,7 +656,7 @@ private static void BuildFolderNodes(List nodes, string rela { Id = relativePath, Label = displayName, - Icon = EditorIcons.Folder, + Icon = IsFolderEmpty(relativePath) ? EditorIcons.FolderOpen : EditorIcons.Folder, IconColor = EditorTheme.Amber400, HasChildren = subDirs.Count > 0, DefaultExpanded = depth < 2, @@ -820,7 +840,7 @@ private void DrawTableCell(Paper paper, Scribe.FontFile font, Scribe.FontFile mo bool isSelected = Selection.IsSelected(item); bool hasSubs = item.Subs.Count > 0; bool expanded = hasSubs && _expandedAssets.Contains(item.Guid); - var style = item.IsFolder ? AssetTypeStyles.Folder : AssetTypeStyles.For(Path.GetExtension(item.Name), item.TypeLabel); + var style = item.IsFolder ? FolderStyle(item.RelativePath) : AssetTypeStyles.For(Path.GetExtension(item.Name), item.TypeLabel); if (col == 0) { @@ -898,7 +918,7 @@ private Action ItemContextMenu(ContentItem item, bool inTree = f bool isMulti = Selection.Count > 1; bool isRoot = string.IsNullOrEmpty(item.RelativePath); string folder = item.IsFolder ? item.RelativePath : _currentFolder; - var titleStyle = item.IsFolder ? AssetTypeStyles.Folder : AssetTypeStyles.For(Path.GetExtension(item.Name), item.TypeLabel); + var titleStyle = item.IsFolder ? FolderStyle(item.RelativePath) : AssetTypeStyles.For(Path.GetExtension(item.Name), item.TypeLabel); // Subject of the menu. builder.Title(isMulti ? Loc.Get("project.item_count", new { count = Selection.Count }) : item.Name, iconDraw: titleStyle.Icon); @@ -1362,7 +1382,7 @@ private void DrawGridItem(Paper paper, Scribe.FontFile font, string id, ContentI } else { - var style = item.IsFolder ? AssetTypeStyles.Folder + var style = item.IsFolder ? FolderStyle(item.RelativePath) : item.IsSubAsset ? AssetTypeStyles.SubAsset : AssetTypeStyles.For(Path.GetExtension(item.Name), item.TypeLabel); diff --git a/Prowl.Editor/GUI/Registries/AssetTypeStyles.cs b/Prowl.Editor/GUI/Registries/AssetTypeStyles.cs index 2853bdeda..ae65bcb4a 100644 --- a/Prowl.Editor/GUI/Registries/AssetTypeStyles.cs +++ b/Prowl.Editor/GUI/Registries/AssetTypeStyles.cs @@ -33,6 +33,7 @@ public static class AssetTypeStyles Gray = C(148, 143, 171), Red = C(251, 113, 133); public static AssetTypeStyle Folder => new() { Icon = EditorIcons.Folder_I, Color = Amber, Bare = true }; + public static AssetTypeStyle EmptyFolder => new() { Icon = EditorIcons.FolderOpen_I, Color = Amber, Bare = true }; public static AssetTypeStyle SubAsset => new() { Icon = EditorIcons.Cube_I, Color = Purple }; private static readonly System.Collections.Generic.Dictionary _map = From ae5475c5f8a2eb0c480c7555be2923fa996c299b Mon Sep 17 00:00:00 2001 From: Wulferis Date: Fri, 7 Aug 2026 23:08:40 +1000 Subject: [PATCH 48/67] Bumped version to v1.0-preview-3 --- Prowl.Editor/Prowl.Editor.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prowl.Editor/Prowl.Editor.csproj b/Prowl.Editor/Prowl.Editor.csproj index d23ceb855..12e6074b7 100644 --- a/Prowl.Editor/Prowl.Editor.csproj +++ b/Prowl.Editor/Prowl.Editor.csproj @@ -6,7 +6,7 @@ disable enable true - 1.0-preview-2 + 1.0-preview-3 8600;8601;8618;8602;8603;8604;8625 prowl.ico From acd0f7ed49288b2736c75c163851902ee1984f5f Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 08:50:46 -0600 Subject: [PATCH 49/67] Move Enum rename to its own drawer, then cleanup previous references --- Prowl.Editor/Core/EditorApplication.cs | 1 + Prowl.Editor/GUI/AttributeHandlers.cs | 71 ++----------------- .../InspectorNameEnumDrawer.cs | 56 +++++++++++++++ 3 files changed, 63 insertions(+), 65 deletions(-) create mode 100644 Prowl.Editor/GUI/PropertyEditors/InspectorNameEnumDrawer.cs diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index fe9d4ac91..a8d0fd152 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -175,6 +175,7 @@ public override void Initialize() { if (typeof(Runtime.EngineObject).IsAssignableFrom(fieldType)) EngineObjectPropertyEditor.SetFieldType(fieldType); + InspectorNameEnumDrawer.EnsureRegistered(PropertyGridConfig.Drawers, fieldType); }; PropertyGridConfig.DrawTypePicker = (paper, id, baseType, currentValue, onChange) => { diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index 32bea7cd9..3871e5bc7 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Reflection; +using Prowl.Editor.GUI.PropertyEditors; using Prowl.OrigamiUI; using Prowl.PaperUI; using Prowl.PaperUI.LayoutEngine; @@ -16,40 +17,6 @@ namespace Prowl.Editor.GUI; -/// -/// The default property-grid row recipe (gutter padding, label width/colour/truncation) for -/// handler-drawn fields, so they align with grid-drawn rows instead of each hand-copying the -/// layout. This is the one place the recipe lives — grid metric changes go here. -/// -public 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(); - } - } -} - /// [Header("text")] - draws a header label above the field. public class HeaderAttributeHandler : OrigamiUI.AttributeHandler { @@ -189,42 +156,16 @@ public override void OnAfterDraw(Paper paper, string id, Attribute attr, FieldIn } } -/// [InspectorName("label")] - overrides the field's display label; on enum-typed -/// fields the dropdown also shows each member's own [InspectorName] instead of the raw name. +/// [InspectorName("label")] - overrides the field's display label. public class InspectorNameAttributeHandler : OrigamiUI.AttributeHandler { - /// Display name for an enum member: its [InspectorName] if present, else the - /// nicified member name. - public static string GetEnumDisplayName(Type enumType, object value) - { - string name = Enum.GetName(enumType, value) ?? value.ToString() ?? ""; - var attr = enumType.GetField(name)?.GetCustomAttribute(); - return attr?.DisplayName ?? PropertyGridUtils.NicifyName(name); - } - public override bool OnDraw(Paper paper, string id, string label, Attribute attr, FieldInfo field, object target, Action onChange, int depth) { - string displayLabel = ((InspectorNameAttribute)attr).DisplayName; - Type type = field.FieldType; - - // Non-flags enums get a dropdown honouring per-member display names. - if (type.IsEnum && !type.IsDefined(typeof(FlagsAttribute), false)) - { - object value = field.GetValue(target) ?? Enum.GetValues(type).GetValue(0)!; - HandlerRowLayout.LabelledRow(paper, id, displayLabel, () => - { - var values = new List(); - foreach (object v in Enum.GetValues(type)) values.Add(v); - OrigamiUI.Origami.Dropdown(paper, $"{id}_dd", value, v => onChange(v), values) - .Display(v => GetEnumDisplayName(type, v)) - .Show(); - }); - return true; - } - - // Everything else: default rendering, relabelled. - PropertyGridUtils.DrawField(paper, id, displayLabel, type, field.GetValue(target), onChange, depth); + // Nothing type-specific here: an enum's member names come from the field drawer + // registered for its type, which DrawField resolves like any other. + PropertyGridUtils.DrawField(paper, id, ((InspectorNameAttribute)attr).DisplayName, + field.FieldType, field.GetValue(target), onChange, depth); return true; } } diff --git a/Prowl.Editor/GUI/PropertyEditors/InspectorNameEnumDrawer.cs b/Prowl.Editor/GUI/PropertyEditors/InspectorNameEnumDrawer.cs new file mode 100644 index 000000000..b2c19ee37 --- /dev/null +++ b/Prowl.Editor/GUI/PropertyEditors/InspectorNameEnumDrawer.cs @@ -0,0 +1,56 @@ +// 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.Runtime; + +namespace Prowl.Editor.GUI.PropertyEditors; + +/// +/// Draws a non-flags enum using its members' [InspectorName]s — the one place that naming lives, whether or not the field itself carries the attribute. +/// The property grid has no per-member naming of its own, so the enum type gets its own drawer, registered on demand. +/// InspectorNameAttributeHandler's own enum branch delegates here so there is one dropdown. +/// +public sealed class InspectorNameEnumDrawer : OrigamiUI.FieldDrawer +{ + /// Stateless, so every enum that wants one shares the instance. + private static readonly InspectorNameEnumDrawer s_instance = new(); + + /// Display name for an enum member: its [InspectorName] if present, else the + /// nicified member name. + public static string GetEnumDisplayName(Type enumType, object value) + { + string name = Enum.GetName(enumType, value) ?? value.ToString() ?? ""; + var attr = enumType.GetField(name)?.GetCustomAttribute(); + return attr?.DisplayName ?? PropertyGridUtils.NicifyName(name); + } + + /// Whether this type needs the drawer at all — only enums that actually rename a + /// member, so every other enum keeps the grid's own rendering. + public static bool AppliesTo(Type type) + => type.IsEnum && !type.IsDefined(typeof(FlagsAttribute), false) + && Array.Exists(type.GetFields(BindingFlags.Public | BindingFlags.Static), + f => f.IsDefined(typeof(InspectorNameAttribute), false)); + + public static void EnsureRegistered(OrigamiUI.FieldDrawerRegistry drawers, Type type) + { + if (drawers.GetDrawer(type) == null && AppliesTo(type)) + drawers.Register(type, s_instance); + } + + public override void Draw(Paper paper, string id, object? value, Type type, + Action onChange, int depth) + { + object current = value ?? Enum.GetValues(type).GetValue(0)!; + var values = new List(); + foreach (object v in Enum.GetValues(type)) values.Add(v); + + OrigamiUI.Origami.Dropdown(paper, $"{id}_dd", current, v => onChange(v), values) + .Display(v => GetEnumDisplayName(type, v)) + .Show(); + } +} From 042d4132bbb7715671b0342f81d9ada06466cc5b Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 09:39:33 -0600 Subject: [PATCH 50/67] Fix range attribute drawing to stay in the property grid --- Prowl.Editor/Core/EditorApplication.cs | 1 + Prowl.Editor/GUI/AttributeHandlers.cs | 43 ++++++------ .../GUI/PropertyEditors/RangeSliderDrawer.cs | 67 +++++++++++++++++++ 3 files changed, 90 insertions(+), 21 deletions(-) create mode 100644 Prowl.Editor/GUI/PropertyEditors/RangeSliderDrawer.cs diff --git a/Prowl.Editor/Core/EditorApplication.cs b/Prowl.Editor/Core/EditorApplication.cs index a8d0fd152..2cf70451c 100644 --- a/Prowl.Editor/Core/EditorApplication.cs +++ b/Prowl.Editor/Core/EditorApplication.cs @@ -164,6 +164,7 @@ public override void Initialize() // Build the editor's PropertyGrid config PropertyGridConfig = new OrigamiUI.PropertyGridConfig(); OrigamiUI.BuiltInFieldDrawers.Register(PropertyGridConfig.Drawers); + RangeSliderDrawer.Register(PropertyGridConfig.Drawers); // wraps the built-in float/int drawers BuiltInAttributeHandlers.Register(PropertyGridConfig.Handlers); PropertyGridConfig.OnBeginRoot = target => Undo.Snapshot(target); PropertyGridConfig.OnFieldChanged = target => diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index 3871e5bc7..199b131ed 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -185,37 +185,38 @@ public override void OnAfterDraw(Paper paper, string id, Attribute attr, FieldIn } } -/// [Range(min, max)] - replaces numeric fields with a slider. +/// [Range(min, max)] - replaces numeric fields with a slider. The row and label still come +/// from the property grid, so they match every other field; only the control is swapped, by +/// RangeSliderDrawer. public class RangeAttributeHandler : OrigamiUI.AttributeHandler { public override bool OnDraw(Paper paper, string id, string label, Attribute attr, FieldInfo field, object target, Action onChange, int depth) { - var range = (RangeAttribute)attr; - var value = field.GetValue(target); - var type = field.FieldType; + Type type = field.FieldType; if (type != typeof(float) && type != typeof(int)) - return false; // unsupported type: fall through to default rendering + return false; // no slider for this type: let the grid draw the field untouched - HandlerRowLayout.LabelledRow(paper, id, label, () => + var range = (RangeAttribute)attr; + RangeSliderDrawer.Pending = range; + try { - if (type == typeof(float)) - { - float f = (float)(value ?? 0f); - OrigamiUI.Origami.Slider(paper, $"{id}_sl", f, - v => onChange(v), range.Min, range.Max).Format("F2").Show(); - } - else - { - int i = (int)(value ?? 0); - OrigamiUI.Origami.Slider(paper, $"{id}_sl", (float)i, - v => onChange((int)MathF.Round(v)), range.Min, range.Max) - .Format("F0").Step(1f).Show(); - } - }); - + // The grid makes a numeric field's label a drag-scrubber that writes straight through + // onChange, so the bounds have to be enforced here rather than by the slider alone. + PropertyGridUtils.DrawField(paper, id, label, type, field.GetValue(target), + v => onChange(Clamp(v, range, type)), depth); + } + finally + { + RangeSliderDrawer.Pending = null; + } return true; } + + private static object Clamp(object? value, RangeAttribute range, Type type) + => type == typeof(float) + ? Math.Clamp((float)(value ?? 0f), range.Min, range.Max) + : Math.Clamp((int)(value ?? 0), (int)MathF.Round(range.Min), (int)MathF.Round(range.Max)); } /// [Tooltip("text")] - attaches a tooltip to the field row. diff --git a/Prowl.Editor/GUI/PropertyEditors/RangeSliderDrawer.cs b/Prowl.Editor/GUI/PropertyEditors/RangeSliderDrawer.cs new file mode 100644 index 000000000..5ae8405cf --- /dev/null +++ b/Prowl.Editor/GUI/PropertyEditors/RangeSliderDrawer.cs @@ -0,0 +1,67 @@ +// 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.PaperUI; +using Prowl.Runtime; + +namespace Prowl.Editor.GUI.PropertyEditors; + +/// +/// Draws a float or int as a slider while its field's [Range] is in scope, and defers to the +/// built-in numeric drawer the rest of the time. Swapping only the control lets the property grid +/// keep drawing the row and label, so [Range] fields line up with every other field instead of +/// following a hand-copied row recipe that drifts as the grid's metrics change. +/// +public sealed class RangeSliderDrawer : OrigamiUI.FieldDrawer +{ + /// The [Range] of the field being drawn right now, published by + /// around its one DrawField call. The grid draws + /// fields one at a time on the UI thread, so at most one is ever in flight. + [ThreadStatic] public static RangeAttribute? Pending; + + private readonly OrigamiUI.FieldDrawer _inner; + + private RangeSliderDrawer(OrigamiUI.FieldDrawer inner) => _inner = inner; + + /// Wraps the built-in float and int drawers, so must run after + /// BuiltInFieldDrawers.Register has put them in the registry. + public static void Register(OrigamiUI.FieldDrawerRegistry drawers) + { + Wrap(typeof(float)); + Wrap(typeof(int)); + + void Wrap(Type type) + { + OrigamiUI.FieldDrawer inner = drawers.GetDrawer(type) + ?? throw new InvalidOperationException( + $"No built-in drawer for {type.Name} to wrap - register RangeSliderDrawer after BuiltInFieldDrawers."); + drawers.Register(type, new RangeSliderDrawer(inner)); + } + } + + public override void Draw(Paper paper, string id, object? value, Type type, + Action onChange, int depth) + { + // Consume on read: one publish draws one slider, so a range can never bleed into the next + // numeric field even if this drawer is reached by some path the handler doesn't bracket. + RangeAttribute? range = Pending; + Pending = null; + + if (range == null) + { + _inner.Draw(paper, id, value, type, onChange, depth); + return; + } + + if (type == typeof(float)) + OrigamiUI.Origami.Slider(paper, $"{id}_sl", (float)(value ?? 0f), + v => onChange(v), range.Min, range.Max) + .Format("F2").Show(); + else + OrigamiUI.Origami.Slider(paper, $"{id}_sl", (float)(int)(value ?? 0), + v => onChange((int)MathF.Round(v)), range.Min, range.Max) + .Format("F0").Step(1f).Show(); + } +} From c3b4210b106b5ad74867246ca28a47d619137a3b Mon Sep 17 00:00:00 2001 From: Will B Date: Tue, 4 Aug 2026 21:05:33 -0600 Subject: [PATCH 51/67] Add DotRecast-based navigation subsystem A Unity-shaped navigation stack built on DotRecast: baked navmesh assets, runtime queries, crowd-driven agents, off-mesh links, obstacle carving, and the editor tooling to author and inspect it. Runtime - NavMeshData assets and NavMeshBuilder, with region rebuilds that scale with the changed area rather than the map size, synchronous or off-thread. - NavMeshWorld on Scene.Navigation: registered instances, a pooled thread-safe query layer, one crowd per agent type, and a demand-driven tile-cache pump that costs nothing per frame for surfaces with no queued work. - Components: NavMeshSurface, NavMeshAgent, NavMeshObstacle (carve or velocity-block), NavMeshLink, NavMeshModifier and NavMeshModifierVolume. - Project-level agent types and 32 Unity-style areas with per-agent costs. - Our own area-aware rasterizer with pooled heightfield spans, so repeated tile bakes on destructible geometry do not churn the heap. Every surface is tile-cache backed and can carve; there is no representation for a user to choose. Links ride on the asset and are re-injected as tiles are contoured, so carving and links coexist. Escape hatches to the underlying DotRecast objects are exposed, in the spirit of PhysicsWorld exposing Jitter. Editor - Surface inspector with bake-to-asset, a .navmesh importer, Navigation project settings, area and agent-type drawers, and a scene-view overlay that follows runtime carving. Covered by 127 runtime navigation tests. --- Prowl.Editor/GUI/AttributeHandlers.cs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Prowl.Editor/GUI/AttributeHandlers.cs b/Prowl.Editor/GUI/AttributeHandlers.cs index 199b131ed..79d01d13d 100644 --- a/Prowl.Editor/GUI/AttributeHandlers.cs +++ b/Prowl.Editor/GUI/AttributeHandlers.cs @@ -17,6 +17,40 @@ namespace Prowl.Editor.GUI; +/// +/// The default property-grid row recipe (gutter padding, label width/colour/truncation) for +/// handler-drawn fields, so they align with grid-drawn rows instead of each hand-copying the +/// layout. This is the one place the recipe lives — grid metric changes go here. +/// +public 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(); + } + } +} + /// [Header("text")] - draws a header label above the field. public class HeaderAttributeHandler : OrigamiUI.AttributeHandler { From 81293f0b8003d815b5356265b143944747de7ba7 Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 12:15:25 -0600 Subject: [PATCH 52/67] Swap DotRecast namespaces to Prowl recast --- Prowl.Runtime.Test/NavMeshBuildTests.cs | 2 +- Prowl.Runtime.Test/NavMeshLinkTests.cs | 2 +- Prowl.Runtime.Test/NavMeshObstacleTests.cs | 12 ++++---- .../Components/Navigation/NavMeshAgent.cs | 6 ++-- .../Components/Navigation/NavMeshObstacle.cs | 30 +++++++++---------- .../Components/Navigation/NavMeshSurface.cs | 4 +-- Prowl.Runtime/Navigation/NavMeshBuilder.cs | 6 ++-- Prowl.Runtime/Navigation/NavMeshData.cs | 12 ++++---- .../Navigation/NavMeshQueryFilter.cs | 4 +-- Prowl.Runtime/Navigation/NavMeshRasterizer.cs | 4 +-- .../Navigation/NavMeshTileBuilder.cs | 14 ++++----- .../Navigation/NavMeshTriangulation.cs | 2 +- Prowl.Runtime/Navigation/NavMeshWorld.cs | 16 +++++----- .../Navigation/ProwlInputGeomProvider.cs | 6 ++-- Prowl.Runtime/Prowl.Runtime.csproj | 7 ++--- 15 files changed, 62 insertions(+), 65 deletions(-) diff --git a/Prowl.Runtime.Test/NavMeshBuildTests.cs b/Prowl.Runtime.Test/NavMeshBuildTests.cs index 6e5798c2f..8ffd8e974 100644 --- a/Prowl.Runtime.Test/NavMeshBuildTests.cs +++ b/Prowl.Runtime.Test/NavMeshBuildTests.cs @@ -1,7 +1,7 @@ // 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 DotRecast.Detour; +using Prowl.Recast.Detour; using Prowl.Echo; using Prowl.Runtime; diff --git a/Prowl.Runtime.Test/NavMeshLinkTests.cs b/Prowl.Runtime.Test/NavMeshLinkTests.cs index 0482cc6ea..97f46972e 100644 --- a/Prowl.Runtime.Test/NavMeshLinkTests.cs +++ b/Prowl.Runtime.Test/NavMeshLinkTests.cs @@ -1,7 +1,7 @@ // 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 DotRecast.Detour; +using Prowl.Recast.Detour; using Prowl.Runtime; using Prowl.Runtime.Resources; diff --git a/Prowl.Runtime.Test/NavMeshObstacleTests.cs b/Prowl.Runtime.Test/NavMeshObstacleTests.cs index 47fea933f..af76a7049 100644 --- a/Prowl.Runtime.Test/NavMeshObstacleTests.cs +++ b/Prowl.Runtime.Test/NavMeshObstacleTests.cs @@ -838,8 +838,8 @@ public void NonCarvingObstacle_FollowsAMovingObstacle() } Float3 expected = cart.Transform.Position; - DotRecast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; - foreach (DotRecast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + 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, @@ -878,8 +878,8 @@ public void NonCarvingObstacle_StaysPutUnderPressure() } Tick(scene, 300); - DotRecast.Detour.Crowd.DtCrowd crowd = scene.Navigation.NativeCrowd!; - foreach (DotRecast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + 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, @@ -926,10 +926,10 @@ public void Obstacle_SwitchingCarveMode_LeavesNothingBehind() /// a count so a failure reports which side of the switch broke. private static int BlockerCount(Scene scene, NavMeshObstacle obstacle) { - DotRecast.Detour.Crowd.DtCrowd? crowd = scene.Navigation.NativeCrowd; + Prowl.Recast.Detour.Crowd.DtCrowd? crowd = scene.Navigation.NativeCrowd; if (crowd == null) return 0; int count = 1; - foreach (DotRecast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent a in crowd.GetActiveAgents()) if (ReferenceEquals(a.option.userData, obstacle)) count++; return count; diff --git a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs index 8559fbbda..964eeee54 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs @@ -3,9 +3,9 @@ using System; -using DotRecast.Core.Numerics; -using DotRecast.Detour; -using DotRecast.Detour.Crowd; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.Crowd; using Prowl.Vector; diff --git a/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs index 5b6f635d3..909ec2522 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshObstacle.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; -using DotRecast.Core.Numerics; -using DotRecast.Detour.TileCache; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour.TileCache; using Prowl.Vector; @@ -89,7 +89,7 @@ public class NavMeshObstacle : MonoBehaviour private bool _carveApplied; // Velocity-obstacle mode: one immovable agent per live crowd, re-pinned every frame. - private readonly Dictionary _blockers = []; + private readonly Dictionary _blockers = []; private int _blockerCrowdCount = -1; private float _blockerRadius, _blockerHeight; private bool _warnedBlockerUnplaced; @@ -251,7 +251,7 @@ private void UpdateBlockers() // centimetre even under sustained pressure, but it costs nothing to be exact). Float3 position = BlockerPosition(height); var pinned = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z); - foreach (DotRecast.Detour.Crowd.DtCrowdAgent blocker in _blockers.Values) + foreach (Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker in _blockers.Values) blocker.npos = pinned; } @@ -265,22 +265,22 @@ private void RefreshBlockers(float radius, float height) var rcPosition = new RcVec3f((float)position.X, (float)position.Y, (float)position.Z); foreach (NavMeshAgentType type in NavMeshAgentTypes.All) { - DotRecast.Detour.Crowd.DtCrowd? crowd = _world.GetNativeCrowd(type.Id); + Prowl.Recast.Detour.Crowd.DtCrowd? crowd = _world.GetNativeCrowd(type.Id); if (crowd == null) continue; - if (_blockers.TryGetValue(crowd, out DotRecast.Detour.Crowd.DtCrowdAgent? existing)) + if (_blockers.TryGetValue(crowd, out Prowl.Recast.Detour.Crowd.DtCrowdAgent? existing)) { crowd.UpdateAgentParameters(existing, BlockerParams(radius, height)); continue; } - DotRecast.Detour.Crowd.DtCrowdAgent blocker = crowd.AddAgent(rcPosition, BlockerParams(radius, height)); + 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 (DotRecast.Detour.Crowd.DtCrowd crowd in _blockers.Keys) + List? dead = null; + foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in _blockers.Keys) { bool live = false; foreach (NavMeshAgentType type in NavMeshAgentTypes.All) @@ -288,7 +288,7 @@ private void RefreshBlockers(float radius, float height) if (!live) (dead ??= []).Add(crowd); } if (dead != null) - foreach (DotRecast.Detour.Crowd.DtCrowd crowd in dead) + foreach (Prowl.Recast.Detour.Crowd.DtCrowd crowd in dead) _blockers.Remove(crowd); _blockerCrowdCount = _world.CrowdCount; @@ -296,7 +296,7 @@ private void RefreshBlockers(float radius, float height) private void RemoveBlockers() { - foreach ((DotRecast.Detour.Crowd.DtCrowd crowd, DotRecast.Detour.Crowd.DtCrowdAgent blocker) in _blockers) + foreach ((Prowl.Recast.Detour.Crowd.DtCrowd crowd, Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker) in _blockers) crowd.RemoveAgent(blocker); _blockers.Clear(); _blockerCrowdCount = -1; @@ -309,9 +309,9 @@ private void RemoveBlockers() /// ), so an obstacle floating well above the /// walkable surface — a tall Center offset, spawned mid-air — lands invalid. /// - private void WarnIfUnplaced(DotRecast.Detour.Crowd.DtCrowdAgent blocker) + private void WarnIfUnplaced(Prowl.Recast.Detour.Crowd.DtCrowdAgent blocker) { - if (blocker.state != DotRecast.Detour.Crowd.DtCrowdAgentState.DT_CROWDAGENT_STATE_INVALID) return; + 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."); @@ -343,7 +343,7 @@ private float BlockerHeight(Float3 scale) return MathF.Max(0.01f, (Shape == NavMeshObstacleShape.Capsule ? Height : (float)Size.Y) * scaleY); } - private DotRecast.Detour.Crowd.DtCrowdAgentParams BlockerParams(float radius, float height) => new() + private Prowl.Recast.Detour.Crowd.DtCrowdAgentParams BlockerParams(float radius, float height) => new() { radius = radius, height = height, @@ -381,7 +381,7 @@ private void TryApplyCarve() CaptureAppliedGeometry(); } - // No try/catch: verified against DotRecast 2026.1.3 — AllocObstacle grows its pool and + // 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 diff --git a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs index 8de2d923d..927893117 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs @@ -6,8 +6,8 @@ using System.Threading; using System.Threading.Tasks; -using DotRecast.Detour; -using DotRecast.Detour.TileCache; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.TileCache; using Prowl.Vector; diff --git a/Prowl.Runtime/Navigation/NavMeshBuilder.cs b/Prowl.Runtime/Navigation/NavMeshBuilder.cs index ab632dd1f..0d2ea85de 100644 --- a/Prowl.Runtime/Navigation/NavMeshBuilder.cs +++ b/Prowl.Runtime/Navigation/NavMeshBuilder.cs @@ -6,9 +6,9 @@ using System.Threading; using System.Threading.Tasks; -using DotRecast.Core.Numerics; -using DotRecast.Detour; -using DotRecast.Recast; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast; using Prowl.Vector; diff --git a/Prowl.Runtime/Navigation/NavMeshData.cs b/Prowl.Runtime/Navigation/NavMeshData.cs index 804eb13c8..de465cafc 100644 --- a/Prowl.Runtime/Navigation/NavMeshData.cs +++ b/Prowl.Runtime/Navigation/NavMeshData.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; -using DotRecast.Core.Numerics; -using DotRecast.Detour; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; using Prowl.Vector; @@ -54,7 +54,7 @@ public sealed class NavMeshLinkEntry } /// Current serialized-tile format version. Bump when the tile byte format changes - /// (e.g. a DotRecast upgrade changing Detour's tile layout), so stale assets fail with a + /// (e.g. a Prowl.Recast upgrade changing Detour's tile layout), so stale assets fail with a /// clear message instead of a deserialize throw. Version 4 dropped the finished-tile /// representation: every navmesh is now compressed layers plus . public const int CurrentFormatVersion = 4; @@ -172,19 +172,19 @@ public NavMeshTriangulation CalculateTriangulation() /// affected tiles incrementally via DtTileCache.Update. /// /// Obstacle capacity the cache is created with. - public DotRecast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles) + 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 DotRecast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles, + internal Prowl.Recast.Detour.TileCache.DtTileCache CreateTileCache(int maxObstacles, out NavMeshTileBuilder.ProwlTileCacheMeshProcess meshProcess) { ValidateVersion(); DtNavMesh navMesh = CreateEmptyNavMesh(); - DotRecast.Detour.TileCache.DtTileCache cache = NavMeshTileBuilder.CreateTileCache(this, navMesh, maxObstacles, out meshProcess); + Prowl.Recast.Detour.TileCache.DtTileCache cache = NavMeshTileBuilder.CreateTileCache(this, navMesh, maxObstacles, out meshProcess); foreach (NavMeshTile layer in CacheLayers) { diff --git a/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs b/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs index 0a6285fed..ba77ed837 100644 --- a/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs +++ b/Prowl.Runtime/Navigation/NavMeshQueryFilter.cs @@ -3,8 +3,8 @@ using System; -using DotRecast.Core.Numerics; -using DotRecast.Detour; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; namespace Prowl.Runtime; diff --git a/Prowl.Runtime/Navigation/NavMeshRasterizer.cs b/Prowl.Runtime/Navigation/NavMeshRasterizer.cs index f82ba44c6..d23e74b99 100644 --- a/Prowl.Runtime/Navigation/NavMeshRasterizer.cs +++ b/Prowl.Runtime/Navigation/NavMeshRasterizer.cs @@ -12,8 +12,8 @@ using System; -using DotRecast.Core.Numerics; -using DotRecast.Recast; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast; namespace Prowl.Runtime; diff --git a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs index 7ecc946e0..314f16d8e 100644 --- a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs +++ b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; -using DotRecast.Core; -using DotRecast.Core.Numerics; -using DotRecast.Detour; -using DotRecast.Detour.TileCache; -using DotRecast.Detour.TileCache.Io.Compress; -using DotRecast.Recast; -using DotRecast.Recast.Geom; +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; diff --git a/Prowl.Runtime/Navigation/NavMeshTriangulation.cs b/Prowl.Runtime/Navigation/NavMeshTriangulation.cs index eeeada898..f938e0c03 100644 --- a/Prowl.Runtime/Navigation/NavMeshTriangulation.cs +++ b/Prowl.Runtime/Navigation/NavMeshTriangulation.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; -using DotRecast.Detour; +using Prowl.Recast.Detour; using Prowl.Vector; diff --git a/Prowl.Runtime/Navigation/NavMeshWorld.cs b/Prowl.Runtime/Navigation/NavMeshWorld.cs index bc48afe37..0e019705a 100644 --- a/Prowl.Runtime/Navigation/NavMeshWorld.cs +++ b/Prowl.Runtime/Navigation/NavMeshWorld.cs @@ -7,9 +7,9 @@ using System.Collections.Generic; using System.Threading; -using DotRecast.Core.Numerics; -using DotRecast.Detour; -using DotRecast.Detour.Crowd; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast.Detour; +using Prowl.Recast.Detour.Crowd; using Prowl.Vector; @@ -37,7 +37,7 @@ public sealed class NavMeshInstance // registration itself. internal bool CachePending; - internal NavMeshInstance(NavMeshData data, DotRecast.Detour.TileCache.DtTileCache tileCache, + internal NavMeshInstance(NavMeshData data, Prowl.Recast.Detour.TileCache.DtTileCache tileCache, NavMeshTileBuilder.ProwlTileCacheMeshProcess tileCacheLinks) { Data = data; @@ -57,7 +57,7 @@ internal NavMeshInstance(NavMeshData data, DotRecast.Detour.TileCache.DtTileCach /// 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 DotRecast.Detour.TileCache.DtTileCache TileCache { get; } + public Prowl.Recast.Detour.TileCache.DtTileCache TileCache { get; } /// Tell the pump this cache has work waiting. Only needed after queuing on /// directly; and the @@ -405,7 +405,7 @@ private static DtObstacleAvoidanceParams CreateDefaultAvoidanceParams(int slot) NavMeshInstance instance; try { - DotRecast.Detour.TileCache.DtTileCache cache = data.CreateTileCache(TileCacheMaxObstacles, + Prowl.Recast.Detour.TileCache.DtTileCache cache = data.CreateTileCache(TileCacheMaxObstacles, out NavMeshTileBuilder.ProwlTileCacheMeshProcess links); instance = new NavMeshInstance(data, cache, links); } @@ -497,7 +497,7 @@ public void Clear() /// /// 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 DotRecast 2026.1.3, DtNavMeshQuery holds only the + /// survive the mutation: verified against Prowl.Recast, DtNavMeshQuery holds only the /// mesh reference (the same object we mutate) plus node pools and an open list that every /// query method clears on entry — there is no cached tile state, so discarding the pool /// here would only churn tens-of-KB query objects on every rebuild for nothing. @@ -505,7 +505,7 @@ public void Clear() /// Threading: fires synchronously on the calling thread (see /// — same main-thread contract). /// - public void MutateTileCache(NavMeshInstance instance, Action mutation) + public void MutateTileCache(NavMeshInstance instance, Action mutation) { ArgumentNullException.ThrowIfNull(instance); ArgumentNullException.ThrowIfNull(mutation); diff --git a/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs index c77fcd500..a0ed1d3ab 100644 --- a/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs +++ b/Prowl.Runtime/Navigation/ProwlInputGeomProvider.cs @@ -4,9 +4,9 @@ using System; using System.Collections.Generic; -using DotRecast.Core.Numerics; -using DotRecast.Recast; -using DotRecast.Recast.Geom; +using Prowl.Recast.Core.Numerics; +using Prowl.Recast; +using Prowl.Recast.Geom; using Prowl.Vector; diff --git a/Prowl.Runtime/Prowl.Runtime.csproj b/Prowl.Runtime/Prowl.Runtime.csproj index 0e1df270c..046e87967 100644 --- a/Prowl.Runtime/Prowl.Runtime.csproj +++ b/Prowl.Runtime/Prowl.Runtime.csproj @@ -30,20 +30,17 @@ - - - - - + + From 136f369a46d1df9adc456bbf7ff9a1159653bcc6 Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 13:49:48 -0600 Subject: [PATCH 53/67] Adjustments to account for NavMesh workarounds modified upstream in Prowl.Recast Swap the five DotRecast.* packages for the Prowl.Recast fork and delete the Prowl-side workarounds it makes unnecessary. Off-mesh link rationing: Detour sizes a tile's link pool from the connections stored in the tile and budgets nothing for those arriving from neighbours, then indexed the failed allocation unchecked. Prowl capped crossings at four per tile and dropped the rest with a warning. Prowl.Recast restores upstream C++ Detour's DT_NULL_LINK guard at all five unguarded AllocLink sites, so the cap, the per-tile budget, the two-pass rationing and the warning are gone. Links crowding a boundary now all cross. Rasterizer: NavMeshRasterizer.cs existed only because the C# port's AddSpan ignores the RcSpanPool free list it ships and news up every span. Prowl.Recast wires the pool up and adds a slope-cosine RasterizeTriangles overload plus AdoptSpanPools, so the vendored copy is deleted (-363 lines). Steady-state tile rebuilds now allocate nothing. Bake scratch lifetime: a [ThreadStatic] scratch set on a Parallel.For worker outlived the bake by the life of that thread, pinning span pages per worker. Bakes now pass a loop-local scratch; runtime rebuilds keep sharing the main thread's. Also: reset the navmesh asset format version to 1, and drop comments describing development history rather than current behaviour. --- Prowl.Runtime.Test/NavMeshBuildTests.cs | 66 ++-- Prowl.Runtime.Test/NavMeshCollectorTests.cs | 4 +- Prowl.Runtime.Test/NavMeshCrowdTests.cs | 10 +- Prowl.Runtime.Test/NavMeshLinkTests.cs | 9 +- Prowl.Runtime.Test/NavMeshObstacleTests.cs | 19 +- .../Components/Navigation/NavMeshSurface.cs | 2 +- Prowl.Runtime/Navigation/NavMeshBuilder.cs | 41 +- Prowl.Runtime/Navigation/NavMeshData.cs | 10 +- Prowl.Runtime/Navigation/NavMeshRasterizer.cs | 363 ------------------ .../Navigation/NavMeshTileBuilder.cs | 143 ++----- 10 files changed, 114 insertions(+), 553 deletions(-) delete mode 100644 Prowl.Runtime/Navigation/NavMeshRasterizer.cs diff --git a/Prowl.Runtime.Test/NavMeshBuildTests.cs b/Prowl.Runtime.Test/NavMeshBuildTests.cs index 8ffd8e974..55b008d1d 100644 --- a/Prowl.Runtime.Test/NavMeshBuildTests.cs +++ b/Prowl.Runtime.Test/NavMeshBuildTests.cs @@ -373,9 +373,32 @@ public void Build_IsDeterministic_SingleThreaded() } /// - /// A baked asset triangulates without being registered with any scene or world — this is - /// what lets the editor draw the surface overlay outside play mode, where nothing - /// registers the surface (previously the overlay only appeared until the next reload). + /// 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() @@ -435,11 +458,9 @@ public void Build_LinksAcrossTileBoundary_Instantiate(int linkCount, float width } /// - /// A tile's link pool is spent by connections ARRIVING from any of its eight neighbours as - /// well as by its own, and each arrival is one Detour budgeted nothing for. Rationing only - /// departures let eight neighbours each stay under the limit while jointly swamping one - /// destination — no link severed, no warning, and IndexOutOfRange at load on an asset that - /// baked and saved cleanly. This drives every neighbour at the destination at once. + /// 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)] @@ -471,24 +492,23 @@ public void Build_LinksArrivingFromEveryNeighbour_Instantiate(int perNeighbour) var world = new NavMeshWorld(); NavMeshInstance? instance = world.AddNavMeshData(data); Assert.NotNull(instance); - // Rationing may drop the excess; at least one route into the destination must survive. - Assert.True(instance!.ContainsLinkId(1), "The first link must reach the live navmesh."); + + for (int link = 1; link < id; link++) + Assert.True(instance!.ContainsLinkId(link), $"Link {link} must reach the live navmesh."); } /// - /// A tile can only hold so many connections leaving it, so when links crowd one boundary the - /// tile builder rations them — breadth first, one lane per link before any link gets a - /// second. A wide link shedding lanes still crosses, just at fewer points, and must stay - /// silent; only a link left with NO lane has actually stopped working, and only that is - /// worth interrupting anyone over. + /// 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, 1, false)] // one wide link: lanes are shed, the link still works - [InlineData(1, 20f, 1, false)] - [InlineData(4, 0f, 4, false)] // exactly the budget - [InlineData(6, 0f, 4, true)] // two links genuinely lose their route - public void Build_LinksCrowdingATileBoundary_RationBreadthFirst( - int linkCount, float width, int expectedInMesh, bool expectWarning) + [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++) @@ -518,8 +538,8 @@ void Capture(string message, DebugStackTrace? trace, LogSeverity severity) int inMesh = 0; for (int i = 1; i <= linkCount; i++) if (instance!.ContainsLinkId(i)) inMesh++; - Assert.Equal(expectedInMesh, inMesh); - Assert.Equal(expectWarning, warnings.Count > 0); + Assert.Equal(linkCount, inMesh); + Assert.Empty(warnings); } finally { diff --git a/Prowl.Runtime.Test/NavMeshCollectorTests.cs b/Prowl.Runtime.Test/NavMeshCollectorTests.cs index f0ba09a8e..ac4c70bba 100644 --- a/Prowl.Runtime.Test/NavMeshCollectorTests.cs +++ b/Prowl.Runtime.Test/NavMeshCollectorTests.cs @@ -170,8 +170,8 @@ public void Collect_SkipsAgentsAndTheirChildren() } /// - /// The bug this guards: an agent standing on the floor at bake time used to voxelize as - /// an obstruction, leaving a permanent hole in the navmesh under wherever it stood. + /// 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() diff --git a/Prowl.Runtime.Test/NavMeshCrowdTests.cs b/Prowl.Runtime.Test/NavMeshCrowdTests.cs index 2cb9a9076..b23a0b552 100644 --- a/Prowl.Runtime.Test/NavMeshCrowdTests.cs +++ b/Prowl.Runtime.Test/NavMeshCrowdTests.cs @@ -29,12 +29,10 @@ private NavMeshAgent AddAgent(Scene scene, Float3 position) } /// - /// An agent walking a straight line must not shiver as it brakes into its destination. Its - /// facing used to follow the crowd's ACTUAL velocity, which carries avoidance and collision - /// corrections that do not shrink with speed: once the agent slowed near the goal those - /// corrections dominated a small vector and swung its direction frame to frame, so it - /// wobbled left and right while tracking the path exactly. Facing follows the steering - /// vector now, which points down the path the whole way in. + /// 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() diff --git a/Prowl.Runtime.Test/NavMeshLinkTests.cs b/Prowl.Runtime.Test/NavMeshLinkTests.cs index 97f46972e..722581e77 100644 --- a/Prowl.Runtime.Test/NavMeshLinkTests.cs +++ b/Prowl.Runtime.Test/NavMeshLinkTests.cs @@ -325,11 +325,10 @@ public void Link_RuntimeToggle_RebuildsAffectedTiles() } /// - /// The Unity arrival idiom must not false-fire while the agent is traversing a link: - /// RemainingDistance previously collapsed to ~0 as the hop animation landed, so waypoint - /// scripts driven by "!PathPending && RemainingDistance <= StoppingDistance" - /// issued their next destination mid-hop and ping-ponged the agent across the link - /// forever. Mid-hop the value must stay bounded below by the path remaining AFTER landing. + /// 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() diff --git a/Prowl.Runtime.Test/NavMeshObstacleTests.cs b/Prowl.Runtime.Test/NavMeshObstacleTests.cs index af76a7049..b23bf2b30 100644 --- a/Prowl.Runtime.Test/NavMeshObstacleTests.cs +++ b/Prowl.Runtime.Test/NavMeshObstacleTests.cs @@ -588,10 +588,8 @@ void Capture(string message, DebugStackTrace? trace, LogSeverity severity) } /// - /// Carving works outside play mode, because that is where you place buildings. OnEnable runs - /// in the editor and queues the carve, but the pump used to be gameplay-gated, so the request - /// sat unprocessed forever: the component looked configured, the mesh looked untouched, and - /// the scene-view overlay had nothing to show. + /// Carving works outside play mode, because that is where you place buildings: OnEnable queues + /// the carve in the editor, and the pump must process it rather than waiting for play. /// [Fact] public void Obstacle_CarvesAndFollowsOutsidePlayMode() @@ -673,14 +671,11 @@ public void Carve_KeepsAgentsAFullRadiusClear() } /// - /// 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 — indistinguishable from an obstacle that - /// is only steering agents around itself. The pump used to fire only on the - /// converged->working edge, which a carve small enough to finish inside one cache update - /// never crosses: it reported up-to-date on its first call and the notification was - /// swallowed. Anything queued into a cache must report, every frame it works and on the - /// frame it finishes. + /// 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() diff --git a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs index 927893117..1ebf2a3a4 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshSurface.cs @@ -246,7 +246,7 @@ public bool RebuildLinkTiles(AABB worldBounds, IReadOnlyList? world.MutateTileCache(instance, cache => { - instance.TileCacheLinks.SetLinks(links, data.Settings.AgentRadius, data.Origin, data.TileWorldSize); + instance.TileCacheLinks.SetLinks(links, data.Settings.AgentRadius); float ts = data.TileWorldSize; int tx0 = (int)Math.Floor((worldBounds.Min.X - data.Origin.X) / ts); diff --git a/Prowl.Runtime/Navigation/NavMeshBuilder.cs b/Prowl.Runtime/Navigation/NavMeshBuilder.cs index 0d2ea85de..dd7c65fbb 100644 --- a/Prowl.Runtime/Navigation/NavMeshBuilder.cs +++ b/Prowl.Runtime/Navigation/NavMeshBuilder.cs @@ -108,11 +108,20 @@ public static class NavMeshBuilder var layerResults = new List?[tilesX * tilesZ]; if (threads > 1) { - Parallel.For(0, tilesX * tilesZ, new ParallelOptions { MaxDegreeOfParallelism = threads, CancellationToken = CancellationToken.None }, i => - { - if (cancellation.IsCancellationRequested) return; - layerResults[i] = NavMeshTileBuilder.BuildTileLayers(geom, cfg, bmin, bmax, i % tilesX, i / tilesX); - }); + // 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 { @@ -191,19 +200,15 @@ public static class NavMeshBuilder /// Prologue of the partial-rebuild path: builds the geometry provider, applies volumes, and /// derives the affected tile range. The grid-anchoring invariant lives HERE and only here: /// - /// Sources may legitimately be empty (a region walled in completely) — the provider is - /// then null and the affected tiles are EMPTIED; "no geometry" must not be conflated with - /// "no change". The tile grid is anchored in XZ to the ORIGINAL bake bounds (fresh - /// geometry bounds would shift tile (0,0) and misalign every tile against the live - /// navmesh), while the Y range follows the CURRENT geometry — Recast clips rasterized - /// spans to the heightfield's vertical range, so new geometry above the original bounds - /// (a wall dropped on a flat floor) would silently vanish from the rebuild. (With no - /// geometry the Y union is skipped: an empty provider reports (0,0,0) bounds, which would - /// spuriously widen bakes that don't straddle Y=0.) The affected range expands by the - /// erosion border, and a changed region entirely OUTSIDE the baked bounds returns false — - /// clamping it would drag the tile range onto the nearest edge column and rebuild healthy - /// edge tiles against sources that don't cover them. Easy to hit from destructible-world - /// events near the map border. + /// Empty sources are legitimate (a region walled in completely) — the provider is null and the + /// affected tiles are EMPTIED, since "no geometry" is not "no change". XZ anchors to the + /// ORIGINAL bake bounds, or tile (0,0) shifts and every tile misaligns against the live + /// navmesh. Y follows the CURRENT geometry, because Recast clips spans to the heightfield's + /// vertical range and a wall dropped on a flat floor would otherwise vanish — but is skipped + /// when there is no geometry, since an empty provider reports (0,0,0) and would widen bakes + /// that don't straddle Y=0. The range expands by the erosion border. A region entirely OUTSIDE + /// the baked bounds returns false rather than clamping, which would drag the range onto the + /// nearest edge column and rebuild healthy tiles against sources that don't cover them. /// private static bool TryPrepareRebuild(NavMeshData data, IReadOnlyList sources, int defaultArea, IReadOnlyList? volumes, Float3 worldMin, Float3 worldMax, diff --git a/Prowl.Runtime/Navigation/NavMeshData.cs b/Prowl.Runtime/Navigation/NavMeshData.cs index de465cafc..798ad70f3 100644 --- a/Prowl.Runtime/Navigation/NavMeshData.cs +++ b/Prowl.Runtime/Navigation/NavMeshData.cs @@ -55,13 +55,11 @@ public sealed class NavMeshLinkEntry /// 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. Version 4 dropped the finished-tile - /// representation: every navmesh is now compressed layers plus . - public const int CurrentFormatVersion = 4; + /// clear message instead of a deserialize throw. + public const int CurrentFormatVersion = 1; - /// Oldest format version this engine still reads. Versions 1-3 could hold finished - /// Detour tiles, which nothing instantiates any more; those assets must be rebaked. - public const int MinReadableFormatVersion = 4; + /// 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; diff --git a/Prowl.Runtime/Navigation/NavMeshRasterizer.cs b/Prowl.Runtime/Navigation/NavMeshRasterizer.cs deleted file mode 100644 index d23e74b99..000000000 --- a/Prowl.Runtime/Navigation/NavMeshRasterizer.cs +++ /dev/null @@ -1,363 +0,0 @@ -// This file is part of the Prowl Game Engine -// Licensed under the MIT License. See the LICENSE file in the project root for details. -// -// The triangle rasterization below is ported from DotRecast (RcRasterizations.cs), which is -// itself a port of Recast by Mikko Mononen — both zlib licensed: -// Copyright (c) 2009-2010 Mikko Mononen memon@inside.org -// DotRecast Copyright (c) 2023-2024 Choi Ikpil ikpil@naver.com -// Altered for Prowl: spans are allocated from the heightfield's span pool (the RcSpanPool / -// freelist mechanism that upstream C++ Recast uses but the C# port's public AddSpan bypasses -// with per-span `new`), merged-away spans are returned to the freelist, and the walkable-slope -// test is folded into rasterization so no per-chunk area arrays are allocated. - -using System; - -using Prowl.Recast.Core.Numerics; -using Prowl.Recast; - -namespace Prowl.Runtime; - -/// -/// Allocation-free (steady-state) triangle rasterization into an . -/// Span objects come from the heightfield's pool pages, which -/// recycles across tiles per thread — so rebuilding tiles -/// allocates no span objects once the pools have grown to the working-set size. -/// -internal static class NavMeshRasterizer -{ - /// - /// Rasterize triangles with a per-triangle walkable-slope test: up-facing triangles within - /// the slope limit get (a Detour area value), steeper ones - /// rasterize as null-area (solid, unwalkable — they still occupy space). - /// - /// Target heightfield (spans come from its pool). - /// Vertex components (x,y,z per vertex). - /// Triangle vertex indices (three per triangle). - /// Number of triangles to rasterize. - /// Cosine of the maximum walkable slope angle. - /// Detour area value for walkable triangles. - /// Span merge threshold in voxels (walkable climb). - public static void RasterizeTriangles(RcHeightfield heightfield, float[] verts, int[] tris, int numTris, - float walkableSlopeCos, int walkableArea, int flagMergeThreshold) - { - float inverseCellSize = 1.0f / heightfield.cs; - float inverseCellHeight = 1.0f / heightfield.ch; - - for (int t = 0; t < numTris; t++) - { - int v0 = tris[t * 3 + 0]; - int v1 = tris[t * 3 + 1]; - int v2 = tris[t * 3 + 2]; - - // Walkable-slope test (RcRecast.MarkWalkableTriangles, inlined per triangle). - int area = TriangleNormalY(verts, v0, v1, v2) > walkableSlopeCos ? walkableArea : 0; - - RasterizeTri(verts, v0, v1, v2, area, heightfield, - heightfield.bmin, heightfield.bmax, heightfield.cs, inverseCellSize, inverseCellHeight, flagMergeThreshold); - } - } - - /// Y component of the (normalized) face normal — all the slope test needs. - private static float TriangleNormalY(float[] verts, int v0, int v1, int v2) - { - float ax = verts[v1 * 3 + 0] - verts[v0 * 3 + 0]; - float ay = verts[v1 * 3 + 1] - verts[v0 * 3 + 1]; - float az = verts[v1 * 3 + 2] - verts[v0 * 3 + 2]; - float bx = verts[v2 * 3 + 0] - verts[v0 * 3 + 0]; - float by = verts[v2 * 3 + 1] - verts[v0 * 3 + 1]; - float bz = verts[v2 * 3 + 2] - verts[v0 * 3 + 2]; - - float nx = ay * bz - az * by; - float ny = az * bx - ax * bz; - float nz = ax * by - ay * bx; - float lenSq = nx * nx + ny * ny + nz * nz; - if (lenSq <= 1e-12f) return 0f; // degenerate: not walkable - return ny / MathF.Sqrt(lenSq); - } - - #region Span pool (upstream Recast's rcAllocSpan/rcFreeSpan, unused by the C# port's public path) - - /// Rebuild a freelist chaining every span of every pool page. Used when recycled - /// pool pages are attached to a fresh heightfield: all their spans are free by definition. - public static RcSpan? BuildFreeList(RcSpanPool? pools) - { - RcSpan? freelist = null; - for (RcSpanPool? pool = pools; pool != null; pool = pool.next) - { - for (int i = 0; i < pool.items.Length; i++) - { - pool.items[i].next = freelist; - freelist = pool.items[i]; - } - } - return freelist; - } - - private static RcSpan AllocSpan(RcHeightfield heightfield) - { - if (heightfield.freelist == null) - { - // New pool page; chain its spans onto the freelist. - var spanPool = new RcSpanPool { next = heightfield.pools }; - heightfield.pools = spanPool; - RcSpan freeList = heightfield.freelist; - for (int i = 0; i < spanPool.items.Length; i++) - { - spanPool.items[i].next = freeList; - freeList = spanPool.items[i]; - } - heightfield.freelist = freeList; - } - - RcSpan newSpan = heightfield.freelist; - heightfield.freelist = heightfield.freelist.next; - return newSpan; - } - - private static void FreeSpan(RcHeightfield heightfield, RcSpan span) - { - span.next = heightfield.freelist; - heightfield.freelist = span; - } - - #endregion - - /// rcAddSpan with pooled spans: allocations go through the pool and spans removed - /// by merging are returned to it (as upstream C++ does; the C# port drops them). - private static void AddSpan(RcHeightfield heightfield, int x, int z, int min, int max, int areaID, int flagMergeThreshold) - { - RcSpan newSpan = AllocSpan(heightfield); - newSpan.smin = min; - newSpan.smax = max; - newSpan.area = areaID; - newSpan.next = null; - - int columnIndex = x + z * heightfield.width; - - if (heightfield.spans[columnIndex] == null) - { - heightfield.spans[columnIndex] = newSpan; - return; - } - - RcSpan? previousSpan = null; - RcSpan? currentSpan = heightfield.spans[columnIndex]; - - while (currentSpan != null) - { - if (currentSpan.smin > newSpan.smax) - { - break; // current span is past the new span - } - - if (currentSpan.smax < newSpan.smin) - { - // Entirely before the new span; keep walking. - previousSpan = currentSpan; - currentSpan = currentSpan.next; - } - else - { - // Overlap: merge into newSpan. - if (currentSpan.smin < newSpan.smin) newSpan.smin = currentSpan.smin; - if (currentSpan.smax > newSpan.smax) newSpan.smax = currentSpan.smax; - - if (Math.Abs(newSpan.smax - currentSpan.smax) <= flagMergeThreshold) - newSpan.area = Math.Max(newSpan.area, currentSpan.area); - - // Unlink the merged span and recycle it. - RcSpan? next = currentSpan.next; - if (previousSpan != null) previousSpan.next = next; - else heightfield.spans[columnIndex] = next; - FreeSpan(heightfield, currentSpan); - currentSpan = next; - } - } - - if (previousSpan != null) - { - newSpan.next = previousSpan.next; - previousSpan.next = newSpan; - } - else - { - newSpan.next = heightfield.spans[columnIndex]; - heightfield.spans[columnIndex] = newSpan; - } - } - - #region Triangle clipping (verbatim port of RcRasterizations.DividePoly / RasterizeTri) - - private static bool OverlapBounds(RcVec3f aMin, RcVec3f aMax, RcVec3f bMin, RcVec3f bMax) - { - return aMin.X <= bMax.X && aMax.X >= bMin.X - && aMin.Y <= bMax.Y && aMax.Y >= bMin.Y - && aMin.Z <= bMax.Z && aMax.Z >= bMin.Z; - } - - private static void CopyVert(Span dst, int dstOffset, ReadOnlySpan src, int srcOffset) - { - dst[dstOffset + 0] = src[srcOffset + 0]; - dst[dstOffset + 1] = src[srcOffset + 1]; - dst[dstOffset + 2] = src[srcOffset + 2]; - } - - private static void DividePoly(Span inVerts, int inVertsOffset, int inVertsCount, - int outVerts1, out int outVerts1Count, - int outVerts2, out int outVerts2Count, - float axisOffset, int axis) - { - Span inVertAxisDelta = stackalloc float[12]; - for (int inVert = 0; inVert < inVertsCount; ++inVert) - inVertAxisDelta[inVert] = axisOffset - inVerts[inVertsOffset + inVert * 3 + axis]; - - int poly1Vert = 0; - int poly2Vert = 0; - for (int inVertA = 0, inVertB = inVertsCount - 1; inVertA < inVertsCount; inVertB = inVertA, ++inVertA) - { - bool sameSide = (inVertAxisDelta[inVertA] >= 0) == (inVertAxisDelta[inVertB] >= 0); - if (!sameSide) - { - float s = inVertAxisDelta[inVertB] / (inVertAxisDelta[inVertB] - inVertAxisDelta[inVertA]); - inVerts[outVerts1 + poly1Vert * 3 + 0] = inVerts[inVertsOffset + inVertB * 3 + 0] + (inVerts[inVertsOffset + inVertA * 3 + 0] - inVerts[inVertsOffset + inVertB * 3 + 0]) * s; - inVerts[outVerts1 + poly1Vert * 3 + 1] = inVerts[inVertsOffset + inVertB * 3 + 1] + (inVerts[inVertsOffset + inVertA * 3 + 1] - inVerts[inVertsOffset + inVertB * 3 + 1]) * s; - inVerts[outVerts1 + poly1Vert * 3 + 2] = inVerts[inVertsOffset + inVertB * 3 + 2] + (inVerts[inVertsOffset + inVertA * 3 + 2] - inVerts[inVertsOffset + inVertB * 3 + 2]) * s; - CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, outVerts1 + poly1Vert * 3); - poly1Vert++; - poly2Vert++; - - if (inVertAxisDelta[inVertA] > 0) - { - CopyVert(inVerts, outVerts1 + poly1Vert * 3, inVerts, inVertsOffset + inVertA * 3); - poly1Vert++; - } - else if (inVertAxisDelta[inVertA] < 0) - { - CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, inVertsOffset + inVertA * 3); - poly2Vert++; - } - } - else - { - if (inVertAxisDelta[inVertA] >= 0) - { - CopyVert(inVerts, outVerts1 + poly1Vert * 3, inVerts, inVertsOffset + inVertA * 3); - poly1Vert++; - if (inVertAxisDelta[inVertA] != 0) - continue; - } - - CopyVert(inVerts, outVerts2 + poly2Vert * 3, inVerts, inVertsOffset + inVertA * 3); - poly2Vert++; - } - } - - outVerts1Count = poly1Vert; - outVerts2Count = poly2Vert; - } - - private static void RasterizeTri(float[] verts, int v0, int v1, int v2, - int areaID, RcHeightfield heightfield, - RcVec3f heightfieldBBMin, RcVec3f heightfieldBBMax, - float cellSize, float inverseCellSize, float inverseCellHeight, - int flagMergeThreshold) - { - var triBBMin = new RcVec3f(verts[v0 * 3], verts[v0 * 3 + 1], verts[v0 * 3 + 2]); - var triBBMax = triBBMin; - for (int i = 0; i < 2; i++) - { - int v = i == 0 ? v1 : v2; - var p = new RcVec3f(verts[v * 3], verts[v * 3 + 1], verts[v * 3 + 2]); - triBBMin = RcVec3f.Min(triBBMin, p); - triBBMax = RcVec3f.Max(triBBMax, p); - } - - if (!OverlapBounds(triBBMin, triBBMax, heightfieldBBMin, heightfieldBBMax)) - return; - - int w = heightfield.width; - int h = heightfield.height; - float by = heightfieldBBMax.Y - heightfieldBBMin.Y; - - int z0 = (int)((triBBMin.Z - heightfieldBBMin.Z) * inverseCellSize); - int z1 = (int)((triBBMax.Z - heightfieldBBMin.Z) * inverseCellSize); - - // -1 rather than 0 so the polygon is cut properly at the start of the tile. - z0 = Math.Clamp(z0, -1, h - 1); - z1 = Math.Clamp(z1, 0, h - 1); - - Span buf = stackalloc float[7 * 3 * 4]; - int @in = 0; - int inRow = 7 * 3; - int p1 = inRow + 7 * 3; - int p2 = p1 + 7 * 3; - - CopyVert(buf, 0, verts.AsSpan(v0 * 3, 3), 0); - CopyVert(buf, 3, verts.AsSpan(v1 * 3, 3), 0); - CopyVert(buf, 6, verts.AsSpan(v2 * 3, 3), 0); - int nvRow; - int nvIn = 3; - - for (int z = z0; z <= z1; ++z) - { - float cellZ = heightfieldBBMin.Z + z * cellSize; - DividePoly(buf, @in, nvIn, inRow, out nvRow, p1, out nvIn, cellZ + cellSize, RcAxis.RC_AXIS_Z); - (@in, p1) = (p1, @in); - - if (nvRow < 3) continue; - if (z < 0) continue; - - float minX = buf[inRow]; - float maxX = buf[inRow]; - for (int i = 1; i < nvRow; ++i) - { - float v = buf[inRow + i * 3]; - minX = Math.Min(minX, v); - maxX = Math.Max(maxX, v); - } - - int x0 = (int)((minX - heightfieldBBMin.X) * inverseCellSize); - int x1 = (int)((maxX - heightfieldBBMin.X) * inverseCellSize); - if (x1 < 0 || x0 >= w) continue; - - x0 = Math.Clamp(x0, -1, w - 1); - x1 = Math.Clamp(x1, 0, w - 1); - - int nv; - int nv2 = nvRow; - for (int x = x0; x <= x1; ++x) - { - float cx = heightfieldBBMin.X + x * cellSize; - DividePoly(buf, inRow, nv2, p1, out nv, p2, out nv2, cx + cellSize, RcAxis.RC_AXIS_X); - (inRow, p2) = (p2, inRow); - - if (nv < 3) continue; - if (x < 0) continue; - - float spanMin = buf[p1 + 1]; - float spanMax = buf[p1 + 1]; - for (int i = 1; i < nv; ++i) - { - spanMin = Math.Min(spanMin, buf[p1 + i * 3 + 1]); - spanMax = Math.Max(spanMax, buf[p1 + i * 3 + 1]); - } - - spanMin -= heightfieldBBMin.Y; - spanMax -= heightfieldBBMin.Y; - - if (spanMax < 0.0f) continue; - if (spanMin > by) continue; - - if (spanMin < 0.0f) spanMin = 0; - if (spanMax > by) spanMax = by; - - int spanMinCellIndex = Math.Clamp((int)MathF.Floor(spanMin * inverseCellHeight), 0, RcRecast.RC_SPAN_MAX_HEIGHT); - int spanMaxCellIndex = Math.Clamp((int)MathF.Ceiling(spanMax * inverseCellHeight), spanMinCellIndex + 1, RcRecast.RC_SPAN_MAX_HEIGHT); - - AddSpan(heightfield, x, z, spanMinCellIndex, spanMaxCellIndex, areaID, flagMergeThreshold); - } - } - } - - #endregion -} diff --git a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs index 314f16d8e..cec7fb28a 100644 --- a/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs +++ b/Prowl.Runtime/Navigation/NavMeshTileBuilder.cs @@ -34,12 +34,11 @@ internal static class NavMeshTileBuilder public const int VertsPerPoly = 6; /// - /// Per-thread 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). Thread-static because full bakes build tiles in Parallel.For. + /// 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). /// - private sealed class TileBuildScratch + internal sealed class TileBuildScratch { public readonly RcContext Context = new(); /// Recycled span pool pages, transplanted into each tile's heightfield. Grows @@ -47,6 +46,11 @@ private sealed class TileBuildScratch 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 @@ -83,18 +87,15 @@ private sealed class TileBuildScratch } private static RcHeightfield BuildHeightfieldPooled(ProwlInputGeomProvider geom, RcBuilderConfig builderCfg, - List[]? overlappingChunks, TileBuildScratch? scratch) + 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) - { - solid.pools = scratch.SpanPools; - solid.freelist = NavMeshRasterizer.BuildFreeList(scratch.SpanPools); - } + if (scratch.SpanPools != null) + RcRasterizations.AdoptSpanPools(solid, scratch.SpanPools); if (overlappingChunks == null) return solid; @@ -107,8 +108,8 @@ private static RcHeightfield BuildHeightfieldPooled(ProwlInputGeomProvider geom, // Chunky-mesh culling: only triangles overlapping this tile (plus border) rasterize. foreach (RcChunkyTriMeshNode node in overlappingChunks[i]) { - NavMeshRasterizer.RasterizeTriangles(solid, verts, node.tris, node.tris.Length / 3, - walkableSlopeCos, areaMesh.DetourArea, cfg.WalkableClimb); + RcRasterizations.RasterizeTriangles(scratch.Context, verts, node.tris, node.tris.Length / 3, + walkableSlopeCos, areaMesh.DetourArea, solid, cfg.WalkableClimb); } } @@ -123,7 +124,10 @@ private static RcHeightfield BuildHeightfieldPooled(ProwlInputGeomProvider geom, /// Contours/polymeshes are NOT built here — the TileCache builds them per tile at runtime, /// which is what lets obstacles re-carve without re-voxelizing. /// - public static List BuildTileLayers(ProwlInputGeomProvider geom, RcConfig cfg, RcVec3f bmin, RcVec3f bmax, int tileX, int tileZ) + /// 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); @@ -131,7 +135,7 @@ public static List BuildTileLayers(ProwlInputGeomProvider geom, RcConfig if (overlappingChunks == null) return []; - TileBuildScratch scratch = t_scratch ??= new TileBuildScratch(); + TileBuildScratch scratch = reusable ?? (t_scratch ??= new TileBuildScratch()); RcHeightfield solid = BuildHeightfieldPooled(geom, builderCfg, overlappingChunks, scratch); RcContext ctx = scratch.Context; @@ -219,121 +223,27 @@ public sealed class ProwlTileCacheMeshProcess : IDtTileCacheMeshProcess { private readonly List<(Float3 Start, Float3 End, float Radius, bool Bidirectional, int Area, int UserId)> _connections = []; - /// - /// Unbudgeted links a single tile's pool can absorb. Detour sizes that pool when the tile - /// is built, from the connections whose endpoints are inside it — and it under-counts in - /// BOTH directions. A connection that leaves the tile costs its source one extra link - /// (the bidirectional back-link), and costs the tile it LANDS in one more, which that - /// tile budgeted nothing for because the connection is not stored there. Past a handful - /// the pool overflows and AddTile throws IndexOutOfRange — at instantiation, on an asset - /// that baked and saved cleanly. - /// - /// DO NOT raise this without measuring. A tile's real spare capacity is whatever is left - /// of edgeCount + portalCount*2 after its own polygons are linked, so a sparse - /// tile — two flat planes, one polygon each — has the least of it and sets the limit for - /// everyone. Raising this to 5, 6 or 8 was tried: all three crash that geometry, while a - /// denser 40x40 bake survives 8 arrivals happily. The bound cannot be derived either; - /// the binding constraint is arrivals from up to eight neighbours, which are only - /// visible from the global pass in , before any tile exists to - /// measure. - /// - private const int MaxTileCrossingConnections = 4; - - // Ration bookkeeping, reused across calls: connections charged to each tile, by grid - // coordinate. Rationing runs once per link-set change, never per tile build. - private readonly Dictionary<(int X, int Z), int> _tileBudget = []; - private int _severedLinks; - /// /// Replace the link set future tile builds inject. Call under the instance's write lock, /// then rebuild the tiles that should carry the change. - /// - /// Rationing happens HERE, once, rather than per tile build: a tile's pool is spent by - /// connections arriving from any of its eight neighbours as well as by its own, and a - /// tile build sees only its own. Every connection is charged to the tile it starts in - /// and the tile it ends in, so a destination cannot be swamped by sources that each stay - /// under the limit on their own. Lanes are handed out breadth first — every link keeps - /// one before any link gets a second — so a wide link never crowds out another link. /// - /// Tile grid origin, from the asset. - /// Tile side length in world units, from the asset. - public void SetLinks(IReadOnlyList? links, float agentRadius, Float3 origin, float tileWorldSize) + public void SetLinks(IReadOnlyList? links, float agentRadius) { _connections.Clear(); - _tileBudget.Clear(); - _severedLinks = 0; if (links == null || links.Count == 0) return; float radius = Math.Max(0.01f, agentRadius); - float ts = tileWorldSize > 0 ? tileWorldSize : float.MaxValue; // ungridded: one tile List<(Float3 Start, Float3 End)> crossings = []; - var lanes = new List<(Float3 Start, Float3 End, int Link)>(); - var linkFirstLane = new int[links.Count]; - for (int l = 0; l < links.Count; l++) + foreach (NavMeshLinkSource link in links) { crossings.Clear(); - links[l].ExpandCrossings(radius, crossings); - linkFirstLane[l] = lanes.Count; + link.ExpandCrossings(radius, crossings); foreach ((Float3 start, Float3 end) in crossings) - lanes.Add((start, end, l)); - } - - (int, int) Tile(Float3 p) => ((int)MathF.Floor((float)(p.X - origin.X) / ts), - (int)MathF.Floor((float)(p.Z - origin.Z) / ts)); - - // A connection wholly inside one tile costs that tile nothing extra, so it is never - // rationed; only the two ends of a crossing are charged. - bool TryCharge((Float3 Start, Float3 End, int Link) lane, bool commit) - { - (int, int) from = Tile(lane.Start), to = Tile(lane.End); - if (from == to) return true; - _tileBudget.TryGetValue(from, out int a); - _tileBudget.TryGetValue(to, out int b); - if (a >= MaxTileCrossingConnections || b >= MaxTileCrossingConnections) return false; - if (commit) { _tileBudget[from] = a + 1; _tileBudget[to] = b + 1; } - return true; - } - - Span taken = lanes.Count <= 256 ? stackalloc bool[lanes.Count] : new bool[lanes.Count]; - for (int l = 0; l < links.Count; l++) - { - int end = l + 1 < links.Count ? linkFirstLane[l + 1] : lanes.Count; - bool got = false; - for (int i = linkFirstLane[l]; i < end && !got; i++) - { - if (!TryCharge(lanes[i], commit: true)) continue; - taken[i] = true; - got = true; - } - if (!got && end > linkFirstLane[l]) _severedLinks++; + _connections.Add((start, end, radius, link.Bidirectional, + ProwlInputGeomProvider.DetourAreaFor(link.Area), link.UserId)); } - - // Spend what is left widening the links that got through. - for (int l = 0; l < links.Count; l++) - { - int start = linkFirstLane[l], end = l + 1 < links.Count ? linkFirstLane[l + 1] : lanes.Count; - bool linkIsIn = false; - for (int i = start; i < end; i++) if (taken[i]) { linkIsIn = true; break; } - if (!linkIsIn) continue; - for (int i = start; i < end; i++) - { - if (taken[i] || !TryCharge(lanes[i], commit: true)) continue; - taken[i] = true; - } - } - - for (int i = 0; i < lanes.Count; i++) - { - if (!taken[i]) continue; - NavMeshLinkSource link = links[lanes[i].Link]; - _connections.Add((lanes[i].Start, lanes[i].End, radius, link.Bidirectional, - ProwlInputGeomProvider.DetourAreaFor(link.Area), link.UserId)); - } - - if (_severedLinks > 0) - Debug.LogWarning($"[Navigation] {_severedLinks} NavMeshLink(s) cross a navmesh tile boundary already carrying the {MaxTileCrossingConnections} connections its link pool holds, and were dropped — agents cannot use them. Spread the links out, move them off the tile edge, or bake with a larger tile size."); } public void Process(DtNavMeshCreateParams option) @@ -347,8 +257,7 @@ public void Process(DtNavMeshCreateParams option) // so do that first: handing over every link on the map would allocate six arrays // sized by the whole set for a tile that usually contains none of them. The tile box // is widened by each connection's radius so this can never be stricter than the - // classification it front-runs. Rationing already happened in SetLinks — everything - // still here is approved. + // classification it front-runs. int count = 0; for (int i = 0; i < _connections.Count; i++) if (StartsInTile(_connections[i], option)) count++; @@ -425,7 +334,7 @@ public static DtTileCache CreateTileCache(NavMeshData data, DtNavMesh navMesh, i var links = new List(data.Links.Count); foreach (NavMeshData.NavMeshLinkEntry entry in data.Links) links.Add(entry.ToSource()); - meshProcess.SetLinks(links, data.Settings.AgentRadius, data.Origin, data.TileWorldSize); + 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), From 8dee297de803190021d43daf11f99bb3eae550cc Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 14:56:05 -0600 Subject: [PATCH 54/67] Fix agent API contracts --- Prowl.Runtime.Test/NavMeshCrowdTests.cs | 76 ++++++++++++-- .../Components/Navigation/NavMeshAgent.cs | 99 ++++++++++++++----- Prowl.Runtime/Navigation/NavMeshPath.cs | 20 ++++ Prowl.Runtime/Navigation/NavMeshWorld.cs | 1 + 4 files changed, 163 insertions(+), 33 deletions(-) diff --git a/Prowl.Runtime.Test/NavMeshCrowdTests.cs b/Prowl.Runtime.Test/NavMeshCrowdTests.cs index b23a0b552..81fb4606a 100644 --- a/Prowl.Runtime.Test/NavMeshCrowdTests.cs +++ b/Prowl.Runtime.Test/NavMeshCrowdTests.cs @@ -80,19 +80,23 @@ public void Agent_ApproachingDestination_DoesNotWobble() /// /// 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 walked the agent centimetres off its line by the time it arrived. Avoidance is - /// skipped for an agent with no neighbours now, which also skips the most expensive part of - /// its crowd step. covers the other half — that a blocker - /// in range still deflects it. + /// 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) { - (Scene scene, _) = CreateBakedFloorScene(); + // 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); @@ -114,6 +118,60 @@ public void Agent_AloneOnAStraightPath_DoesNotDriftSideways(bool alongX) $"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() @@ -420,12 +478,12 @@ public void SetAreaCost_BiasesCrowdCorridorChoice() // ── Filter slot allocation ────────────────────────────────────────── - private (Scene scene, NavMeshSurface surface) CreateBakedFloorScene() + 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(20, 1, 20); + floor.AddComponent().Size = new Float3(size, 1, size); floor.Transform.Position = new Float3(0, -0.5f, 0); GameObject surfaceGo = CreateGameObject("NavMeshSurface"); diff --git a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs index 964eeee54..795ebbfd0 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshAgent.cs @@ -148,6 +148,10 @@ public class NavMeshAgent : MonoBehaviour 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; @@ -191,9 +195,6 @@ or DtMoveRequestState.DT_CROWDAGENT_TARGET_WAITING_FOR_QUEUE /// True when the agent has a path it is following. public bool HasPath => _agent != null && _agent.targetState == DtMoveRequestState.DT_CROWDAGENT_TARGET_VALID; - /// True when the current path only reaches partway to the destination. - public bool IsPathStale => _agent?.partial ?? false; - /// Status of the current path. public NavMeshPathStatus PathStatus { @@ -412,7 +413,7 @@ private DtCrowdAgentParams BuildAgentParams() | DtCrowdAgentUpdateFlags.DT_CROWD_OPTIMIZE_TOPO; if (Separation) updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_SEPARATION; - if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance) + if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance && AvoidanceEngaged) updateFlags |= DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE; float radius = Math.Max(0.01f, Radius); @@ -502,14 +503,34 @@ private bool RequestPathTo(Float3 target) } } - /// Follow a pre-calculated path by steering to its end (the crowd re-plans the - /// corridor itself; the path supplies the destination). + /// + /// 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; - Float3[] corners = path.Corners; - return SetDestination(corners[^1]); + 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. @@ -525,22 +546,49 @@ public void ResetPath() /// current destination. public bool Warp(Float3 newPosition) { - Transform.Position = newPosition + new Float3(0, BaseOffset, 0); - if (_world == null) return false; - DtCrowd? crowd = _crowd; - if (_agent == null || crowd == null) return IsOnNavMesh; + if (_world == null || _agent == null || crowd == null) + { + Transform.Position = newPosition + new Float3(0, BaseOffset, 0); + return _world != null && IsOnNavMesh; + } // Detour has no teleport: re-add the agent at the new position. crowd.RemoveAgent(_agent); _agent = crowd.AddAgent(ToRc(newPosition), BuildAgentParams()); - if (_hasDestination && !_isStopped && !_arrived) + + // 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. - public void Move(Float3 offset) => Warp(NextPosition + offset); + /// + /// 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. @@ -599,17 +647,20 @@ public override void LateUpdate() RefreshParams(); // Velocity-obstacle sampling is the expensive half of a crowd step, and it picks from a - // DISCRETE set of candidate velocities: with nothing in range to dodge, the winner is - // merely the sample nearest the velocity we asked for, and that rounding walks the agent - // a few centimetres sideways off a straight line by the time it arrives. An agent with - // no neighbours has nothing to avoid, so let it steer exactly — and skip the sampling. - // Neighbours come from the last crowd step, so engaging avoidance lags by one frame; - // they are gathered from several metres out, which is many frames of approach. + // DISCRETE set of candidate velocities, so running it with nothing in range still rounds + // the result and walks the agent centimetres sideways off a straight line. Skip it only + // when nothing is in range at all: the query consumes navmesh boundary segments as well as + // neighbouring agents, so an agent alone beside a wall still has the wall to keep off. + // Both come from the last crowd step, so engaging lags a frame — they are gathered from + // metres out, which is many frames of approach. if (ObstacleAvoidanceQuality != ObstacleAvoidanceType.NoObstacleAvoidance) { - _agent.option.updateFlags = _agent.nneis > 0 - ? _agent.option.updateFlags | DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE - : _agent.option.updateFlags & ~DtCrowdAgentUpdateFlags.DT_CROWD_OBSTACLE_AVOIDANCE; + bool engage = _agent.nneis > 0 || _agent.boundary.GetSegmentCount() > 0; + if (engage != AvoidanceEngaged) + { + AvoidanceEngaged = engage; + _crowd?.UpdateAgentParameters(_agent, BuildAgentParams()); + } } if (UpdatePosition) diff --git a/Prowl.Runtime/Navigation/NavMeshPath.cs b/Prowl.Runtime/Navigation/NavMeshPath.cs index 45e6c7fc2..9dc3b6371 100644 --- a/Prowl.Runtime/Navigation/NavMeshPath.cs +++ b/Prowl.Runtime/Navigation/NavMeshPath.cs @@ -28,6 +28,13 @@ 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; @@ -46,6 +53,10 @@ public Float3[] Corners /// 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) @@ -60,9 +71,18 @@ public int GetCornersNonAlloc(Float3[] results) 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) diff --git a/Prowl.Runtime/Navigation/NavMeshWorld.cs b/Prowl.Runtime/Navigation/NavMeshWorld.cs index 0e019705a..fa48d092b 100644 --- a/Prowl.Runtime/Navigation/NavMeshWorld.cs +++ b/Prowl.Runtime/Navigation/NavMeshWorld.cs @@ -625,6 +625,7 @@ public bool CalculatePath(Float3 sourcePosition, Float3 targetPosition, NavMeshQ 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 From fc489ca035557426af3186bddb1510b60833c94c Mon Sep 17 00:00:00 2001 From: Will B Date: Fri, 7 Aug 2026 15:52:44 -0600 Subject: [PATCH 55/67] Fix build and data correctness issues from review --- Prowl.Editor.Test/ProjectSettingsTests.cs | 19 ++++++++++ .../Projects/Settings/NavigationSettings.cs | 16 ++++++--- Prowl.Runtime.Test/NavMeshLinkTests.cs | 22 ++++++++++++ Prowl.Runtime.Test/NavMeshModifierTests.cs | 24 +++++++++++++ .../Components/Navigation/NavMeshLink.cs | 36 +++++++++++-------- .../Navigation/NavMeshGeometryCollector.cs | 4 +-- .../Navigation/NavMeshTileBuilder.cs | 7 ++++ Prowl.Runtime/Navigation/NavMeshWorld.cs | 24 +++++++++---- .../Navigation/ProwlInputGeomProvider.cs | 22 ++++++++---- 9 files changed, 138 insertions(+), 36 deletions(-) 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/Projects/Settings/NavigationSettings.cs b/Prowl.Editor/Projects/Settings/NavigationSettings.cs index aececd7ef..6efd925e6 100644 --- a/Prowl.Editor/Projects/Settings/NavigationSettings.cs +++ b/Prowl.Editor/Projects/Settings/NavigationSettings.cs @@ -33,11 +33,20 @@ public class NavigationSettings : ProjectSettingsBase 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(NavMeshAreas.GetAreaName(i)); + names.Add(i switch + { + NavMeshAreas.Walkable => "Walkable", + NavMeshAreas.NotWalkable => "Not Walkable", + NavMeshAreas.Jump => "Jump", + _ => string.Empty, + }); return names; } @@ -45,7 +54,7 @@ private static List CreateDefaultCosts() { var costs = new List(NavMeshAreas.MaxAreas); for (int i = 0; i < NavMeshAreas.MaxAreas; i++) - costs.Add(NavMeshAreas.GetAreaCost(i)); + costs.Add(1f); return costs; } @@ -68,9 +77,6 @@ public override void ResetToDefaults() { AreaNames = CreateDefaultNames(); AreaCosts = CreateDefaultCosts(); - AreaNames[NavMeshAreas.Walkable] = "Walkable"; - AreaNames[NavMeshAreas.NotWalkable] = "Not Walkable"; - AreaNames[NavMeshAreas.Jump] = "Jump"; AgentTypes = [new NavMeshAgentType { Id = NavMeshAgentTypes.Humanoid, Name = "Humanoid" }]; NextAgentTypeId = 1; Apply(); diff --git a/Prowl.Runtime.Test/NavMeshLinkTests.cs b/Prowl.Runtime.Test/NavMeshLinkTests.cs index 722581e77..63552a2f9 100644 --- a/Prowl.Runtime.Test/NavMeshLinkTests.cs +++ b/Prowl.Runtime.Test/NavMeshLinkTests.cs @@ -52,6 +52,28 @@ private NavMeshLink AddLink(Scene scene, float width = 0f) 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, NavMeshLink.FindByLinkId(before)); + } + private static NavMeshPathStatus PathStatus(Scene scene, Float3 from, Float3 to, int areaMask = NavMesh.AllAreas) { var path = new NavMeshPath(); diff --git a/Prowl.Runtime.Test/NavMeshModifierTests.cs b/Prowl.Runtime.Test/NavMeshModifierTests.cs index 868cac86b..da74ef703 100644 --- a/Prowl.Runtime.Test/NavMeshModifierTests.cs +++ b/Prowl.Runtime.Test/NavMeshModifierTests.cs @@ -332,6 +332,30 @@ public void RebuildTiles_HonorsModifierChanges() // ── 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] diff --git a/Prowl.Runtime/Components/Navigation/NavMeshLink.cs b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs index 0f5492f83..a8a3404c6 100644 --- a/Prowl.Runtime/Components/Navigation/NavMeshLink.cs +++ b/Prowl.Runtime/Components/Navigation/NavMeshLink.cs @@ -57,13 +57,27 @@ public class NavMeshLink : MonoBehaviour [EnableIf(nameof(UsesExplicitAgentTypes))] public List AffectedAgentTypeIds = []; - /// Persistent id stamped on the baked connections, resolving a traversing agent - /// back to this component (). Assigned - /// on first enable; stable across sessions via serialization. Resolution is best-effort - /// (ids can be re-minted on duplicate clashes, and baked data can outlive components) — - /// don't hang gameplay-critical logic on CurrentOffMeshLinkData.Link. - [HideInInspector] - public int LinkId; + /// 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; @@ -176,14 +190,6 @@ public NavMeshLinkSource ToLinkSource() public override void OnEnable() { - // First enable mints the persistent id; a clash with another LIVE link (duplicated - // in-scene prefab) re-mints so resolution stays unambiguous. The re-mint only lives - // in memory — warn so the duplication gets fixed and saved rather than silently - // re-minting every session. - if (LinkId != 0 && s_liveLinks.TryGetValue(LinkId, out NavMeshLink? clash) && clash.IsValid() && !ReferenceEquals(clash, this)) - Debug.LogWarning($"[Navigation] NavMeshLink '{GameObject.Name}' shares link id {LinkId} with '{clash.GameObject.Name}' (duplicated object?); re-minting. Re-save the scene to persist distinct ids."); - while (LinkId == 0 || (s_liveLinks.TryGetValue(LinkId, out NavMeshLink? other) && other.IsValid() && !ReferenceEquals(other, this))) - LinkId = Random.Shared.Next(int.MinValue, int.MaxValue); s_liveLinks[LinkId] = this; CaptureAppliedDefinition(); diff --git a/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs b/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs index e19476106..0db3b7fa0 100644 --- a/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs +++ b/Prowl.Runtime/Navigation/NavMeshGeometryCollector.cs @@ -260,8 +260,8 @@ public static void CollectMeshRenderer(MeshRenderer renderer, int area, List