From 02f87338df766512bdbd50b4ce06b203460bbaaa Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 21 Aug 2026 23:15:38 +0200 Subject: [PATCH] runtime: Support subdivision on managed threads. --- Packages/NativeTrees/CHANGELOG.md | 5 +- .../Runtime/Octree/NativeOctree.cs | 19 +-- .../Runtime/Quadtree/NativeQuadtree.cs | 19 +-- Packages/NativeTrees/Tests.meta | 8 ++ Packages/NativeTrees/Tests/Editor.meta | 8 ++ .../Tests/Editor/ThreadedSubdivisionTests.cs | 119 ++++++++++++++++++ .../Editor/ThreadedSubdivisionTests.cs.meta | 2 + .../Editor/bartofzo.nativetrees.tests.asmdef | 26 ++++ .../bartofzo.nativetrees.tests.asmdef.meta | 7 ++ Packages/manifest.json | 5 +- 10 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 Packages/NativeTrees/Tests.meta create mode 100644 Packages/NativeTrees/Tests/Editor.meta create mode 100644 Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs create mode 100644 Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs.meta create mode 100644 Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef create mode 100644 Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef.meta diff --git a/Packages/NativeTrees/CHANGELOG.md b/Packages/NativeTrees/CHANGELOG.md index b381f5a..3336dc3 100644 --- a/Packages/NativeTrees/CHANGELOG.md +++ b/Packages/NativeTrees/CHANGELOG.md @@ -1,3 +1,6 @@ +Unreleased (after v0.1.10) +- Fixed octree and quadtree subdivision from managed threads that are not Unity job workers. Copy and nearest-query helpers retain their existing allocator thread requirements. + v0.1.2 - Fixed bug for InsertPoint methods @@ -5,4 +8,4 @@ v0.1.1 - Removed redundant call to Clear in constructors v0.1 -- Initial release \ No newline at end of file +- Initial release diff --git a/Packages/NativeTrees/Runtime/Octree/NativeOctree.cs b/Packages/NativeTrees/Runtime/Octree/NativeOctree.cs index 13f2153..0353b04 100644 --- a/Packages/NativeTrees/Runtime/Octree/NativeOctree.cs +++ b/Packages/NativeTrees/Runtime/Octree/NativeOctree.cs @@ -59,6 +59,11 @@ public partial struct NativeOctree : INativeDisposable /// private NativeParallelHashMap nodes; private NativeParallelMultiHashMap objects; + + /// + /// Reused while redistributing a full node. Insertion is single-threaded, and redistribution completes before recursively subdividing children. + /// + private NativeArray subdivisionBuffer; /// /// Constructs an octree with a max depth of 8 @@ -84,6 +89,7 @@ public NativeOctree(AABB bounds, int objectsPerNode, int maxDepth, Allocator all objects = new NativeParallelMultiHashMap(initialCapacity, allocator); nodes = new NativeParallelHashMap(initialCapacity / objectsPerNode, allocator); + subdivisionBuffer = new NativeArray(objectsPerNode + 1, allocator, NativeArrayOptions.UninitializedMemory); this.objectsPerNode = objectsPerNode; this.maxDepth = maxDepth; @@ -195,9 +201,8 @@ bool TryInsert(uint nodeId, in QuarterSizeBounds extents, in ObjWrapper objWrapp void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) { int objectCount = 0; - NativeArray tempObjects = new NativeArray(objectsPerNode + 1, Allocator.Temp, NativeArrayOptions.UninitializedMemory); foreach (var tempObj in objects.GetValuesForKey(nodeId)) - tempObjects[objectCount++] = tempObj; + subdivisionBuffer[objectCount++] = tempObj; FixedList64Bytes countPerOctant = new FixedList64Bytes(); countPerOctant.Length = 8; @@ -205,7 +210,7 @@ void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) objects.Remove(nodeId); // remove all occurances of objects in our original for (int i = 0; i < objectCount; i++) { - var moveObject = tempObjects[i]; + var moveObject = subdivisionBuffer[i]; int aabbMask = GetBoundsMask(quarterSizeBounds.nodeCenter, moveObject.bounds); // Can't make the point optimization here because we can't be certain the node only contained points @@ -222,8 +227,6 @@ void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) } } - tempObjects.Dispose(); - // Update counts, create nodes when neccessary depth++; for (int i = 0; i < 8; i++) @@ -437,6 +440,7 @@ public void Dispose() { nodes.Dispose(); objects.Dispose(); + subdivisionBuffer.Dispose(); } /// @@ -444,7 +448,8 @@ public void Dispose() /// public JobHandle Dispose(JobHandle inputDeps) { - return JobHandle.CombineDependencies(nodes.Dispose(inputDeps), objects.Dispose(inputDeps)); + var containersHandle = JobHandle.CombineDependencies(nodes.Dispose(inputDeps), objects.Dispose(inputDeps)); + return JobHandle.CombineDependencies(containersHandle, subdivisionBuffer.Dispose(inputDeps)); } /// @@ -483,4 +488,4 @@ void Gizmos(uint nodeId, in ExtentsBounds quarterSizeBounds, int objectCount, in parentDepth: depth); } } -} \ No newline at end of file +} diff --git a/Packages/NativeTrees/Runtime/Quadtree/NativeQuadtree.cs b/Packages/NativeTrees/Runtime/Quadtree/NativeQuadtree.cs index f37fc4d..3362967 100644 --- a/Packages/NativeTrees/Runtime/Quadtree/NativeQuadtree.cs +++ b/Packages/NativeTrees/Runtime/Quadtree/NativeQuadtree.cs @@ -59,6 +59,11 @@ public partial struct NativeQuadtree : INativeDisposable where T : unmanaged /// private NativeParallelHashMap nodes; private NativeParallelMultiHashMap objects; + + /// + /// Reused while redistributing a full node. Insertion is single-threaded, and redistribution completes before recursively subdividing children. + /// + private NativeArray subdivisionBuffer; /// /// Constructs an quadtree with a max depth of 8 @@ -84,6 +89,7 @@ public NativeQuadtree(AABB2D bounds, int objectsPerNode, int maxDepth, Allocator objects = new NativeParallelMultiHashMap(initialCapacity, allocator); nodes = new NativeParallelHashMap(initialCapacity / objectsPerNode, allocator); + subdivisionBuffer = new NativeArray(objectsPerNode + 1, allocator, NativeArrayOptions.UninitializedMemory); this.objectsPerNode = objectsPerNode; this.maxDepth = maxDepth; @@ -195,9 +201,8 @@ bool TryInsert(uint nodeId, in QuarterSizeBounds extents, in ObjWrapper objWrapp void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) { int objectCount = 0; - NativeArray tempObjects = new NativeArray(objectsPerNode + 1, Allocator.Temp, NativeArrayOptions.UninitializedMemory); foreach (var tempObj in objects.GetValuesForKey(nodeId)) - tempObjects[objectCount++] = tempObj; + subdivisionBuffer[objectCount++] = tempObj; FixedList32Bytes countPerQuad = new FixedList32Bytes(); countPerQuad.Length = 4; @@ -205,7 +210,7 @@ void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) objects.Remove(nodeId); // remove all occurances of objects in our original for (int i = 0; i < objectCount; i++) { - var moveObject = tempObjects[i]; + var moveObject = subdivisionBuffer[i]; int aabbMask = GetBoundsMask(quarterSizeBounds.nodeCenter, moveObject.bounds); // Can't make the point optimization here because we can't be certain the node only contained points @@ -222,8 +227,6 @@ void Subdivide(uint nodeId, in QuarterSizeBounds quarterSizeBounds, int depth) } } - tempObjects.Dispose(); - // Update counts, create nodes when neccessary depth++; for (int i = 0; i < 4; i++) @@ -412,6 +415,7 @@ public void Dispose() { nodes.Dispose(); objects.Dispose(); + subdivisionBuffer.Dispose(); } /// @@ -419,7 +423,8 @@ public void Dispose() /// public JobHandle Dispose(JobHandle inputDeps) { - return JobHandle.CombineDependencies(nodes.Dispose(inputDeps), objects.Dispose(inputDeps)); + var containersHandle = JobHandle.CombineDependencies(nodes.Dispose(inputDeps), objects.Dispose(inputDeps)); + return JobHandle.CombineDependencies(containersHandle, subdivisionBuffer.Dispose(inputDeps)); } /// @@ -458,4 +463,4 @@ void Gizmos(uint nodeId, in ExtentsBounds quarterSizeBounds, int objectCount, in parentDepth: depth); } } -} \ No newline at end of file +} diff --git a/Packages/NativeTrees/Tests.meta b/Packages/NativeTrees/Tests.meta new file mode 100644 index 0000000..743e06f --- /dev/null +++ b/Packages/NativeTrees/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4fefe3ded347587408dee4c9e852942b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/NativeTrees/Tests/Editor.meta b/Packages/NativeTrees/Tests/Editor.meta new file mode 100644 index 0000000..7267d50 --- /dev/null +++ b/Packages/NativeTrees/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1ec15f08d7e7f6c4c8b76070a0069aae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs b/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs new file mode 100644 index 0000000..50a05b1 --- /dev/null +++ b/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Threading; +using NUnit.Framework; +using Unity.Collections; +using Unity.Mathematics; + +namespace NativeTrees.Tests +{ + public class ThreadedSubdivisionTests + { + [Test] + public void OctreeCanSubdivideOnManagedThread() + { + var treeBounds = new AABB(new float3(-10f), new float3(10f)); + var tree = new NativeOctree( + treeBounds, + 1, + 8, + Allocator.Persistent); + + var exception = InsertOnManagedThread( + () => + { + var point = new float3(1.2345f); + var bounds = new AABB(point, point); + tree.Insert(1, bounds); + tree.Insert(2, bounds); + + var secondPoint = new float3(-4.5f, 3.25f, -2.75f); + tree.Insert(3, new AABB(secondPoint, secondPoint)); + + var thirdPoint = new float3(4.25f, -3.5f, 2.5f); + tree.Insert(4, new AABB(thirdPoint, thirdPoint)); + }); + + try + { + Assert.That(exception, Is.Null); + + using var results = new NativeParallelHashSet(4, Allocator.Temp); + tree.RangeAABBUnique(treeBounds, results); + Assert.That(results.Count(), Is.EqualTo(4)); + Assert.That(results.Contains(1), Is.True); + Assert.That(results.Contains(2), Is.True); + Assert.That(results.Contains(3), Is.True); + Assert.That(results.Contains(4), Is.True); + } + finally + { + tree.Dispose(); + } + } + + [Test] + public void QuadtreeCanSubdivideOnManagedThread() + { + var treeBounds = new AABB2D(new float2(-10f), new float2(10f)); + var tree = new NativeQuadtree( + treeBounds, + 1, + 8, + Allocator.Persistent); + + var exception = InsertOnManagedThread( + () => + { + var point = new float2(1.2345f); + var bounds = new AABB2D(point, point); + tree.Insert(1, bounds); + tree.Insert(2, bounds); + + var secondPoint = new float2(-4.5f, 3.25f); + tree.Insert(3, new AABB2D(secondPoint, secondPoint)); + + var thirdPoint = new float2(4.25f, -3.5f); + tree.Insert(4, new AABB2D(thirdPoint, thirdPoint)); + }); + + try + { + Assert.That(exception, Is.Null); + + using var results = new NativeParallelHashSet(4, Allocator.Temp); + tree.RangeAABBUnique(treeBounds, results); + Assert.That(results.Count(), Is.EqualTo(4)); + Assert.That(results.Contains(1), Is.True); + Assert.That(results.Contains(2), Is.True); + Assert.That(results.Contains(3), Is.True); + Assert.That(results.Contains(4), Is.True); + } + finally + { + tree.Dispose(); + } + } + + private static Exception InsertOnManagedThread(ThreadStart insert) + { + Exception exception = null; + var thread = new Thread( + () => + { + try + { + insert(); + } + catch (Exception ex) + { + exception = ex; + } + }); + + thread.IsBackground = true; + thread.Start(); + Assert.That(thread.Join(TimeSpan.FromSeconds(10)), Is.True, "Managed insertion thread did not finish."); + return exception; + } + } +} diff --git a/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs.meta b/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs.meta new file mode 100644 index 0000000..9479982 --- /dev/null +++ b/Packages/NativeTrees/Tests/Editor/ThreadedSubdivisionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4a798714c14f26047a27403434e13784 \ No newline at end of file diff --git a/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef b/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef new file mode 100644 index 0000000..9b3234a --- /dev/null +++ b/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef @@ -0,0 +1,26 @@ +{ + "name": "com.bartofzo.nativetrees.tests", + "rootNamespace": "NativeTrees.Tests", + "references": [ + "com.bartofzo.nativetrees", + "Unity.Collections", + "Unity.Mathematics", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef.meta b/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef.meta new file mode 100644 index 0000000..71186d7 --- /dev/null +++ b/Packages/NativeTrees/Tests/Editor/bartofzo.nativetrees.tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4cd5b01c9dc27d04c8ce97deed2df0b3 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/manifest.json b/Packages/manifest.json index e48e951..0c3bc71 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -3,5 +3,8 @@ "com.unity.burst": "1.6.6", "com.unity.collections": "1.4.0", "com.unity.ide.rider": "3.0.16" - } + }, + "testables": [ + "com.bartofzo.nativetrees" + ] }