diff --git a/src/Box2D.NET.Samples/SampleApp.cs b/src/Box2D.NET.Samples/SampleApp.cs index c739d8cf..d7fa3bac 100644 --- a/src/Box2D.NET.Samples/SampleApp.cs +++ b/src/Box2D.NET.Samples/SampleApp.cs @@ -804,7 +804,7 @@ private void UpdateUI() ImGui.Checkbox("Islands", ref _context.debugDraw.drawIslands); ImGui.Checkbox("Counters", ref _context.drawCounters); ImGui.Checkbox("Profile", ref _context.drawProfile); - ImGui.Separator(); + ImGui.Checkbox("Frame Time", ref _context.frameTime); ImGui.Separator(); @@ -932,4 +932,4 @@ private void UpdateUI() s_sample.UpdateGui(); } } -} \ No newline at end of file +} diff --git a/src/Box2D.NET.Samples/SampleContext.cs b/src/Box2D.NET.Samples/SampleContext.cs index e779e13b..e39b975b 100644 --- a/src/Box2D.NET.Samples/SampleContext.cs +++ b/src/Box2D.NET.Samples/SampleContext.cs @@ -37,6 +37,7 @@ public class SampleContext public bool enableRecycling = true; public bool enableSleep = true; public bool showUI = true; + public bool frameTime = false; // These are persisted public int sampleIndex = 0; @@ -120,6 +121,12 @@ public void Load() debugDraw.drawContactFeatures = settings.drawContactFeatures; debugDraw.drawFrictionForces = settings.drawFrictionForces; debugDraw.drawIslands = settings.drawIslands; + drawCounters = settings.drawCounters; + drawProfile = settings.drawProfile; + frameTime = settings.frameTime; + enableWarmStarting = settings.enableWarmStarting; + enableContinuous = settings.enableContinuous; + enableSleep = settings.enableSleep; // debugDraw.jointScale = settings.jointScale; @@ -205,4 +212,4 @@ public static void DrawStringFcn(in B2Vec2 p, string s, B2HexColor color, object SampleContext sampleContext = (SampleContext)(context); DrawWorldString(sampleContext.draw, sampleContext.camera, p, color, s); } -} \ No newline at end of file +} diff --git a/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkCapacity.cs b/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkCapacity.cs index 78972497..4504f031 100644 --- a/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkCapacity.cs +++ b/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkCapacity.cs @@ -35,7 +35,6 @@ public BenchmarkCapacity(SampleContext context) : base(context) m_context.camera.zoom = 200.0f; } - m_context.enableSleep = false; { B2BodyDef bodyDef = b2DefaultBodyDef(); bodyDef.position.Y = -5.0f; diff --git a/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkJunkyard.cs b/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkJunkyard.cs new file mode 100644 index 00000000..18b1d0df --- /dev/null +++ b/src/Box2D.NET.Samples/Samples/Benchmarks/BenchmarkJunkyard.cs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 Erin Catto +// SPDX-FileCopyrightText: 2025 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +using Box2D.NET.Shared; +using static Box2D.NET.Shared.Benchmarks; + +namespace Box2D.NET.Samples.Samples.Benchmarks; + +public class BenchmarkJunkyard : Sample +{ + private static readonly int BenchmarkJunkyardIndex = SampleFactory.Shared.RegisterSample("Benchmark", "Junkyard", Create); + + private readonly JunkyardData m_junkyardData; + + private static Sample Create(SampleContext context) + { + return new BenchmarkJunkyard(context); + } + + public BenchmarkJunkyard(SampleContext context) : base(context) + { + if (m_context.restart == false) + { + m_context.camera.center = new B2Vec2(8.0f, 25.0f); + m_context.camera.zoom = 60.0f; + } + + m_junkyardData = CreateJunkyard(m_worldId); + } + + public override void Step() + { + if (m_context.pause == false || m_context.singleStep == true) + { + StepJunkyard(m_junkyardData, m_worldId, m_stepCount); + } + + base.Step(); + } +} diff --git a/src/Box2D.NET.Samples/Samples/Sample.cs b/src/Box2D.NET.Samples/Samples/Sample.cs index 27ca6221..13aa2d8d 100644 --- a/src/Box2D.NET.Samples/Samples/Sample.cs +++ b/src/Box2D.NET.Samples/Samples/Sample.cs @@ -3,10 +3,12 @@ // SPDX-License-Identifier: MIT using System; +using System.Numerics; using System.Text; using Box2D.NET.Samples.Graphics; using Box2D.NET.Samples.Helpers; using Box2D.NET.Samples.Primitives; +using ImGuiNET; using Silk.NET.GLFW; using static Box2D.NET.B2Joints; using static Box2D.NET.B2Ids; @@ -28,6 +30,7 @@ public class Sample : IDisposable public const int k_maxContactPoints = 12 * 2048; public const int m_maxTasks = 64; public const int m_maxThreads = 64; + public const int m_profileCapacity = 512; #if DEBUG public const bool m_isDebug = true; @@ -46,16 +49,29 @@ public class Sample : IDisposable private B2BodyId m_mouseBodyId; + // public B2WorldId m_worldId; private B2JointId m_mouseJointId; private B2Vec2 m_mousePoint; protected float m_mouseForceScale; public int m_stepCount; + private int m_textLine; + private int m_textIncrement; + + // + private readonly B2Profile[] m_profiles; + private int m_currentProfileIndex; + private ulong m_profileReadIndex; + private ulong m_profileWriteIndex; + + // private B2Profile m_maxProfile; private B2Profile m_totalProfile; + + // + private bool m_didStep; - private int m_textLine; - private int m_textIncrement; + private readonly float[] m_frameTimes; public Sample(SampleContext context) { @@ -83,11 +99,19 @@ public Sample(SampleContext context) m_mouseJointId = b2_nullJointId; m_stepCount = 0; + m_didStep = false; m_mouseBodyId = b2_nullBodyId; m_mousePoint = new B2Vec2(); m_mouseForceScale = 100.0f; + m_frameTimes = new float[m_profileCapacity]; + + m_profiles = new B2Profile[m_profileCapacity]; + m_currentProfileIndex = 0; + m_profileReadIndex = 0; + m_profileWriteIndex = 0; + m_maxProfile = new B2Profile(); m_totalProfile = new B2Profile(); @@ -157,10 +181,13 @@ public void TestMathCpp() public virtual void UpdateGui() { - if (m_context.drawProfile) + if (m_context.frameTime) { - B2Profile p = b2World_GetProfile(m_worldId); + UpdateFrameTimeGui(); + } + if (m_context.drawProfile) + { B2Profile aveProfile = new B2Profile(); if (m_stepCount > 0) { @@ -189,6 +216,8 @@ public virtual void UpdateGui() aveProfile.sensors = scale * m_totalProfile.sensors; } + ref readonly B2Profile p = ref m_profiles[m_currentProfileIndex]; + DrawTextLine($"step [ave] (max) = {p.step,5:F2} [{aveProfile.step,6:F2}] ({m_maxProfile.step,6:F2})"); DrawTextLine($"pairs [ave] (max) = {p.pairs,5:F2} [{aveProfile.pairs,6:F2}] ({m_maxProfile.pairs,6:F2})"); DrawTextLine($"collide [ave] (max) = {p.collide,5:F2} [{aveProfile.collide,6:F2}] ({m_maxProfile.collide,6:F2})"); DrawTextLine($"solve [ave] (max) = {p.solve,5:F2} [{aveProfile.solve,6:F2}] ({m_maxProfile.solve,6:F2})"); @@ -213,6 +242,105 @@ public virtual void UpdateGui() } } + private void UpdateFrameTimeGui() + { + const float frameTimeHeight = 400.0f; + const float frameTimeWidth = 800.0f; + + ImGui.SetNextWindowPos(new Vector2(30.0f, 30.0f), ImGuiCond.FirstUseEver); + ImGui.SetNextWindowSize(new Vector2(frameTimeWidth, frameTimeHeight), ImGuiCond.FirstUseEver); + + ImGui.Begin("Frame Time", ref m_context.frameTime, ImGuiWindowFlags.NoCollapse); + ImGui.PushItemWidth(ImGui.GetWindowWidth() - 20.0f); + + int count = (int)(m_profileWriteIndex - m_profileReadIndex); + float maxValue = 0.0f; + for (int i = 0; i < count; ++i) + { + int index = (int)((m_profileReadIndex + (ulong)i) & (m_profileCapacity - 1)); + m_frameTimes[i] = i / 60.0f; + maxValue = b2MaxFloat(maxValue, m_profiles[index].step); + } + + // This is the pixel size, not the range. + Vector2 plotSize = new Vector2(-1.0f, 22.0f * ImGui.GetTextLineHeight()); + DrawProfilePlot("Profile", count, maxValue, plotSize); + + ImGui.PopItemWidth(); + ImGui.End(); + } + + private void DrawProfilePlot(string label, int count, float maxValue, Vector2 size) + { + if (count == 0) + { + ImGui.TextUnformatted("No frame data"); + return; + } + + maxValue = b2MaxFloat(maxValue, 0.001f); + Vector2 canvasPos = ImGui.GetCursorScreenPos(); + Vector2 canvasSize = size; + if (canvasSize.X < 0.0f) + { + canvasSize.X = ImGui.GetContentRegionAvail().X; + } + + canvasSize.X = MathF.Max(canvasSize.X, 120.0f); + canvasSize.Y = MathF.Max(canvasSize.Y, 120.0f); + + ImGui.InvisibleButton(label, canvasSize); + + Vector2 min = canvasPos; + Vector2 max = canvasPos + canvasSize; + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + uint borderColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.35f, 0.35f, 0.35f, 1.0f)); + uint gridColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.20f, 0.20f, 0.20f, 1.0f)); + uint textColor = ImGui.ColorConvertFloat4ToU32(new Vector4(0.85f, 0.85f, 0.85f, 1.0f)); + + drawList.AddRect(min, max, borderColor); + for (int i = 1; i < 4; ++i) + { + float y = min.Y + canvasSize.Y * i / 4.0f; + drawList.AddLine(new Vector2(min.X, y), new Vector2(max.X, y), gridColor); + } + + DrawProfileSeries(drawList, min, canvasSize, count, maxValue, p => p.step, new Vector4(0.20f, 0.70f, 1.00f, 1.0f)); + DrawProfileSeries(drawList, min, canvasSize, count, maxValue, p => p.collide, new Vector4(0.95f, 0.65f, 0.20f, 1.0f)); + DrawProfileSeries(drawList, min, canvasSize, count, maxValue, p => p.solve, new Vector4(0.30f, 0.85f, 0.45f, 1.0f)); + + drawList.AddText(min + new Vector2(8.0f, 6.0f), textColor, $"step / collide / solve max {maxValue:F2} ms"); + drawList.AddText(new Vector2(min.X + 8.0f, max.Y - 22.0f), textColor, $"0s .. {m_frameTimes[count - 1]:F1}s"); + } + + private void DrawProfileSeries(ImDrawListPtr drawList, Vector2 origin, Vector2 size, int count, float maxValue, Func selector, Vector4 color) + { + if (count < 2) + { + return; + } + + uint lineColor = ImGui.ColorConvertFloat4ToU32(color); + Vector2 previous = default; + float invCount = 1.0f / (count - 1); + + for (int i = 0; i < count; ++i) + { + int index = (int)((m_profileReadIndex + (ulong)i) & (m_profileCapacity - 1)); + float value = selector(m_profiles[index]); + float x = origin.X + size.X * i * invCount; + float y = origin.Y + size.Y * (1.0f - b2ClampFloat(value / maxValue, 0.0f, 1.0f)); + Vector2 current = new Vector2(x, y); + + if (i > 0) + { + drawList.AddLine(previous, current, lineColor, 1.5f); + } + + previous = current; + } + } + private static object EnqueueTask(b2TaskCallback task, int itemCount, int minRange, object taskContext, object userContext) { @@ -383,10 +511,15 @@ public void ResetProfile() m_totalProfile = new B2Profile(); m_maxProfile = new B2Profile(); m_stepCount = 0; + m_currentProfileIndex = 0; + m_profileReadIndex = 0; + m_profileWriteIndex = 0; } public virtual void Step() { + m_didStep = false; + float timeStep = m_context.hertz > 0.0f ? 1.0f / m_context.hertz : 0.0f; if (m_context.pause) @@ -440,12 +573,23 @@ public virtual void Step() if (timeStep > 0.0f) { - ++m_stepCount; + m_stepCount += 1; + m_didStep = true; + + if (m_profileWriteIndex == m_profileCapacity + m_profileReadIndex) + { + m_profileReadIndex += 1; + } + + m_currentProfileIndex = (int)(m_profileWriteIndex & (m_profileCapacity - 1)); + m_profiles[m_currentProfileIndex] = b2World_GetProfile(m_worldId); + m_profileWriteIndex += 1; } // Track maximum profile times + if (m_didStep) { - B2Profile p = b2World_GetProfile(m_worldId); + B2Profile p = m_profiles[m_currentProfileIndex]; m_maxProfile.step = b2MaxFloat(m_maxProfile.step, p.step); m_maxProfile.pairs = b2MaxFloat(m_maxProfile.pairs, p.pairs); m_maxProfile.collide = b2MaxFloat(m_maxProfile.collide, p.collide); @@ -545,4 +689,4 @@ protected InputAction GetKey(Keys key) { return GlfwHelpers.GetKey(m_context, key); } -} \ No newline at end of file +} diff --git a/src/Box2D.NET.Samples/Samples/SampleFactory.cs b/src/Box2D.NET.Samples/Samples/SampleFactory.cs index 628c7dbd..59a522c9 100644 --- a/src/Box2D.NET.Samples/Samples/SampleFactory.cs +++ b/src/Box2D.NET.Samples/Samples/SampleFactory.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Reflection; using Box2D.NET.Samples.Primitives; -using Box2D.NET.Samples.Samples.Determinisms; using Serilog; namespace Box2D.NET.Samples.Samples; diff --git a/src/Box2D.NET.Samples/Settings.cs b/src/Box2D.NET.Samples/Settings.cs index 0b7fc734..39f87907 100644 --- a/src/Box2D.NET.Samples/Settings.cs +++ b/src/Box2D.NET.Samples/Settings.cs @@ -40,6 +40,7 @@ public class Settings public bool drawGraphColors = false; public bool drawCounters = false; public bool drawProfile = false; + public bool frameTime = false; public bool enableWarmStarting = true; public bool enableContinuous = true; public bool enableSleep = true; @@ -85,6 +86,7 @@ public static Settings CopyFrom(SampleContext context) // setting.drawCounters = context.drawCounters; setting.drawProfile = context.drawProfile; + setting.frameTime = context.frameTime; setting.enableWarmStarting = context.enableWarmStarting; setting.enableContinuous = context.enableContinuous; setting.enableSleep = context.enableSleep; @@ -107,4 +109,4 @@ public static Settings CopyFrom(SampleContext context) return setting; } -} \ No newline at end of file +} diff --git a/src/Box2D.NET.Shared/Benchmarks.cs b/src/Box2D.NET.Shared/Benchmarks.cs index 79541899..bd108ef9 100644 --- a/src/Box2D.NET.Shared/Benchmarks.cs +++ b/src/Box2D.NET.Shared/Benchmarks.cs @@ -670,5 +670,113 @@ public static void CreateWasher(B2WorldId worldId) } } } + + public static JunkyardData CreateJunkyard(B2WorldId worldId) + { + var junkyardData = new JunkyardData(); + + { + float gridSize = 1.0f; + + B2BodyDef bodyDef = b2DefaultBodyDef(); + B2BodyId groundId = b2CreateBody(worldId, bodyDef); + + B2ShapeDef shapeDef = b2DefaultShapeDef(); + + float y = 0.0f; + float x = -80.0f * gridSize; + for (int i = 0; i < 161; ++i) + { + B2Polygon box = b2MakeOffsetBox(0.55f * gridSize, 0.5f * gridSize, new B2Vec2(x, y), b2Rot_identity); + b2CreatePolygonShape(groundId, shapeDef, box); + x += gridSize; + } + + y = gridSize; + x = -80.0f * gridSize; + for (int i = 0; i < 50; ++i) + { + B2Polygon box = b2MakeOffsetBox(0.5f * gridSize, 0.55f * gridSize, new B2Vec2(x, y), b2Rot_identity); + b2CreatePolygonShape(groundId, shapeDef, box); + y += gridSize; + } + + y = gridSize; + x = 80.0f * gridSize; + for (int i = 0; i < 50; ++i) + { + B2Polygon box = b2MakeOffsetBox(0.5f * gridSize, 0.55f * gridSize, new B2Vec2(x, y), b2Rot_identity); + b2CreatePolygonShape(groundId, shapeDef, box); + y += gridSize; + } + } + + int columnCount = 200; + int rowCount = BENCHMARK_DEBUG ? 2 : 40; + + float radius = 0.25f; + B2Polygon polygon; + { + // Fibonacci sphere algorithm + float phi = B2_PI * (MathF.Sqrt(5.0f) - 1.0f); + B2Vec2[] points = new B2Vec2[5]; + + for (int i = 0; i < 5; ++i) + { + float theta = phi * i; + B2CosSin cs = b2ComputeCosSin(theta); + points[i].X = radius * cs.cosine; + points[i].Y = radius * cs.sine; + } + + B2Hull hull = b2ComputeHull(points, 5); + polygon = b2MakePolygon(hull, 0.0f); + } + + { + B2BodyDef bodyDef = b2DefaultBodyDef(); + bodyDef.type = B2BodyType.b2_dynamicBody; + B2ShapeDef shapeDef = b2DefaultShapeDef(); + + float side = -0.1f; + float yStart = 15.0f; + + for (int i = 0; i < columnCount; ++i) + { + float x = 1.5f * (2.0f * i - columnCount) * radius; + + for (int j = 0; j < rowCount; ++j) + { + float y = 4.0f * j * radius + yStart; + + bodyDef.position = new B2Vec2(x + side, y); + side = -side; + + B2BodyId bodyId = b2CreateBody(worldId, bodyDef); + b2CreatePolygonShape(bodyId, shapeDef, polygon); + } + } + + bodyDef.type = B2BodyType.b2_kinematicBody; + bodyDef.position = b2Vec2_zero; + junkyardData.pusherId = b2CreateBody(worldId, bodyDef); + B2Polygon pusherBox = b2MakeOffsetBox(2.0f, 4.0f, new B2Vec2(0.0f, 4.0f), b2Rot_identity); + b2CreatePolygonShape(junkyardData.pusherId, shapeDef, pusherBox); + } + + return junkyardData; + } + + public static float StepJunkyard(JunkyardData junkyardData, B2WorldId worldId, int stepCount) + { + B2_UNUSED(worldId); + + float timeStep = 1.0f / 60.0f; + float time = timeStep * stepCount; + B2CosSin cs = b2ComputeCosSin(0.2f * time); + B2Transform target = new B2Transform(new B2Vec2(60.0f * cs.sine, 0.0f), b2Rot_identity); + b2Body_SetTargetTransform(junkyardData.pusherId, target, timeStep, true); + return 0.0f; + } } -} \ No newline at end of file +} diff --git a/src/Box2D.NET.Shared/JunkyardData.cs b/src/Box2D.NET.Shared/JunkyardData.cs new file mode 100644 index 00000000..1904d278 --- /dev/null +++ b/src/Box2D.NET.Shared/JunkyardData.cs @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 Erin Catto +// SPDX-FileCopyrightText: 2025 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +namespace Box2D.NET.Shared +{ + public class JunkyardData + { + public B2BodyId pusherId; + } +} diff --git a/src/Box2D.NET/B2Arrays.cs b/src/Box2D.NET/B2Arrays.cs index 05803822..c05fbccc 100644 --- a/src/Box2D.NET/B2Arrays.cs +++ b/src/Box2D.NET/B2Arrays.cs @@ -7,6 +7,7 @@ using System.Runtime.CompilerServices; using static Box2D.NET.B2Constants; using static Box2D.NET.B2Buffers; +using static Box2D.NET.B2Diagnostics; namespace Box2D.NET { @@ -182,6 +183,26 @@ public static int b2Array_ByteCount(ref B2Array a) return a; } + // Remove an element from an int arrayA by swapping with the last element. This updates the index contained + // in the moved element in arrayB. Assumes the integers in arrayA index into arrayB. Assumes + // the elements of arrayB have an indexName member that is the index in arrayA. + public static void b2RemoveUpdate(ref B2Array arrayA, ref B2Array arrayB, int indexB, Func getIndexName, Action setIndexName) + { + int lastIndex = (arrayA).count - 1; + B2_ASSERT(0 <= (indexB) && (indexB) < (arrayB).count); + int indexA = getIndexName.Invoke((arrayB).data[indexB]); + B2_ASSERT(0 <= indexA && indexA < (arrayA).count); + if (indexA != lastIndex) + { + int movedIndex = (arrayA).data[lastIndex]; + (arrayA).data[indexA] = movedIndex; + setIndexName.Invoke((arrayB).data[movedIndex], indexA); + } + + (arrayA).count -= 1; + } + + /* Reserve */ [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void b2Array_Reserve(ref B2Array a, int newCapacity) where T : new() diff --git a/src/Box2D.NET/B2Bodies.cs b/src/Box2D.NET/B2Bodies.cs index 4e368a12..3e2fc75d 100644 --- a/src/Box2D.NET/B2Bodies.cs +++ b/src/Box2D.NET/B2Bodies.cs @@ -107,77 +107,45 @@ public static B2BodyState b2GetBodyState(B2World world, B2Body body) public static void b2CreateIslandForBody(B2World world, int setIndex, B2Body body) { B2_ASSERT(body.islandId == B2_NULL_INDEX); - B2_ASSERT(body.islandPrev == B2_NULL_INDEX); - B2_ASSERT(body.islandNext == B2_NULL_INDEX); B2_ASSERT(setIndex != (int)B2SolverSetType.b2_disabledSet); B2Island island = b2CreateIsland(world, setIndex); - + b2Array_Push(ref island.bodies, body.id); body.islandId = island.islandId; - island.headBody = body.id; - island.tailBody = body.id; - island.bodyCount = 1; + body.islandIndex = 0; + + b2ValidateIsland(world, island.islandId); } internal static void b2RemoveBodyFromIsland(B2World world, B2Body body) { if (body.islandId == B2_NULL_INDEX) { - B2_ASSERT(body.islandPrev == B2_NULL_INDEX); - B2_ASSERT(body.islandNext == B2_NULL_INDEX); + B2_ASSERT(body.islandIndex == B2_NULL_INDEX); return; } int islandId = body.islandId; B2Island island = b2Array_Get(ref world.islands, islandId); - // Fix the island's linked list of sims - if (body.islandPrev != B2_NULL_INDEX) - { - B2Body prevBody = b2Array_Get(ref world.bodies, body.islandPrev); - prevBody.islandNext = body.islandNext; - } - - if (body.islandNext != B2_NULL_INDEX) - { - B2Body nextBody = b2Array_Get(ref world.bodies, body.islandNext); - nextBody.islandPrev = body.islandPrev; - } - - B2_ASSERT(island.bodyCount > 0); - island.bodyCount -= 1; - bool islandDestroyed = false; + b2RemoveUpdate(ref island.bodies, ref world.bodies, body.id, x => x.islandIndex, (x, idx) => x.islandIndex = idx); - if (island.headBody == body.id) + if (island.bodies.count == 0) { - island.headBody = body.islandNext; + // Destroy empty island + B2_ASSERT(island.contacts.count == 0); + B2_ASSERT(island.joints.count == 0); - if (island.headBody == B2_NULL_INDEX) - { - // Destroy empty island - B2_ASSERT(island.tailBody == body.id); - B2_ASSERT(island.bodyCount == 0); - B2_ASSERT(island.contactCount == 0); - B2_ASSERT(island.jointCount == 0); - - // Free the island - b2DestroyIsland(world, island.islandId); - islandDestroyed = true; - } + // Free the island + b2DestroyIsland(world, island.islandId); } - else if (island.tailBody == body.id) - { - island.tailBody = body.islandPrev; - } - - if (islandDestroyed == false) + else { b2ValidateIsland(world, islandId); } body.islandId = B2_NULL_INDEX; - body.islandPrev = B2_NULL_INDEX; - body.islandNext = B2_NULL_INDEX; + body.islandIndex = B2_NULL_INDEX; } public static void b2DestroyBodyContacts(B2World world, B2Body body, bool wakeBodies) @@ -326,8 +294,7 @@ public static B2BodyId b2CreateBody(B2WorldId worldId, in B2BodyDef def) body.headJointKey = B2_NULL_INDEX; body.jointCount = 0; body.islandId = B2_NULL_INDEX; - body.islandPrev = B2_NULL_INDEX; - body.islandNext = B2_NULL_INDEX; + body.islandIndex = B2_NULL_INDEX; body.bodyMoveIndex = B2_NULL_INDEX; body.id = bodyId; body.mass = 0.0f; diff --git a/src/Box2D.NET/B2Body.cs b/src/Box2D.NET/B2Body.cs index 2a6495e1..c9eeceef 100644 --- a/src/Box2D.NET/B2Body.cs +++ b/src/Box2D.NET/B2Body.cs @@ -36,9 +36,8 @@ public class B2Body // All enabled dynamic and kinematic bodies are in an island. public int islandId; - // doubly-linked island list - public int islandPrev; - public int islandNext; + // Need this island index for faster union-find + public int islandIndex; public float mass; diff --git a/src/Box2D.NET/B2Contact.cs b/src/Box2D.NET/B2Contact.cs index e78c56f1..0f301fe9 100644 --- a/src/Box2D.NET/B2Contact.cs +++ b/src/Box2D.NET/B2Contact.cs @@ -8,6 +8,15 @@ namespace Box2D.NET // connectivity. public class B2Contact { + public B2FixedArray2 edges; + + // A contact only belongs to an island if touching, otherwise B2_NULL_INDEX. + public int islandId; + + // Index into the island's contacts array for O(1) swap-removal. + // B2_NULL_INDEX when not in an island. + public int islandIndex; + // index of simulation set stored in b2World // B2_NULL_INDEX when slot is free public int setIndex; @@ -25,12 +34,6 @@ public class B2Contact public int shapeIdB; public int contactId; - // A contact only belongs to an island if touching, otherwise B2_NULL_INDEX. - public B2FixedArray2 edges; - public int islandPrev; - public int islandNext; - public int islandId; - // b2ContactFlags public uint flags; diff --git a/src/Box2D.NET/B2ContactLink.cs b/src/Box2D.NET/B2ContactLink.cs new file mode 100644 index 00000000..b8c03279 --- /dev/null +++ b/src/Box2D.NET/B2ContactLink.cs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 Erin Catto +// SPDX-FileCopyrightText: 2025 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +namespace Box2D.NET +{ + // Cached contact data stored in the island for fast contiguous iteration. + // Avoids touching b2Contact during union-find in b2SplitIsland. + public class B2ContactLink + { + public int contactId; + public int bodyIdA; + public int bodyIdB; + + public B2ContactLink ToCopy() + { + var copy = new B2ContactLink(); + copy.contactId = contactId; + copy.bodyIdA = bodyIdA; + copy.bodyIdB = bodyIdB; + + return copy; + } + } +} diff --git a/src/Box2D.NET/B2Contacts.cs b/src/Box2D.NET/B2Contacts.cs index 73793c64..c511c363 100644 --- a/src/Box2D.NET/B2Contacts.cs +++ b/src/Box2D.NET/B2Contacts.cs @@ -212,8 +212,7 @@ public static void b2CreateContact(B2World world, B2Shape shapeA, B2Shape shapeB contact.colorIndex = B2_NULL_INDEX; contact.localIndex = set.contactSims.count; contact.islandId = B2_NULL_INDEX; - contact.islandPrev = B2_NULL_INDEX; - contact.islandNext = B2_NULL_INDEX; + contact.islandIndex = B2_NULL_INDEX; contact.shapeIdA = shapeIdA; contact.shapeIdB = shapeIdB; //contact.isMarked = false; @@ -674,4 +673,4 @@ public static B2ContactData b2Contact_GetData(B2ContactId contactId) /**@}*/ } -} \ No newline at end of file +} diff --git a/src/Box2D.NET/B2Diagnostics.cs b/src/Box2D.NET/B2Diagnostics.cs index 6706a0fd..0e3b69dc 100644 --- a/src/Box2D.NET/B2Diagnostics.cs +++ b/src/Box2D.NET/B2Diagnostics.cs @@ -89,7 +89,7 @@ public static void b2SetAssertFcn(b2AssertFcn assertFcn) b2AssertHandler = assertFcn; } - internal static int b2InternalAssertFcn(string condition, string fileName, int lineNumber) + internal static int b2InternalAssert(string condition, string fileName, int lineNumber) { return b2AssertHandler(condition, fileName, lineNumber); } diff --git a/src/Box2D.NET/B2Island.cs b/src/Box2D.NET/B2Island.cs index ce0031d8..4ef99760 100644 --- a/src/Box2D.NET/B2Island.cs +++ b/src/Box2D.NET/B2Island.cs @@ -4,10 +4,11 @@ namespace Box2D.NET { - // Persistent island for awake bodies, joints, and contacts + // Persistent island for awake bodies, joints, and contacts. + // Contacts are touching. + // Contacts and joints may connect to static bodies, but static bodies are not in the island. // https://en.wikipedia.org/wiki/Component_(graph_theory) // https://en.wikipedia.org/wiki/Dynamic_connectivity - // map from int to solver set and index public class B2Island { // index of solver set stored in b2World @@ -20,20 +21,19 @@ public class B2Island public int islandId; - public int headBody; - public int tailBody; - public int bodyCount; - - public int headContact; - public int tailContact; - public int contactCount; - - public int headJoint; - public int tailJoint; - public int jointCount; - // Keeps track of how many contacts have been removed from this island. // This is used to determine if an island is a candidate for splitting. public int constraintRemoveCount; + + // I tried using a stack array for this but the data pointer goes out of + // sync when the world island array grows. + public B2Array bodies; + + // Contacts and joints that belong to this island. May connect to static + // bodies not in the island. + // Each link has the two body ids so that b2SplitIsland's union-find pass + // never needs to touch b2Contact/b2Joint. + public B2Array contacts; + public B2Array joints; } } diff --git a/src/Box2D.NET/B2Islands.cs b/src/Box2D.NET/B2Islands.cs index 1c694305..ee58b825 100644 --- a/src/Box2D.NET/B2Islands.cs +++ b/src/Box2D.NET/B2Islands.cs @@ -4,14 +4,14 @@ using System; using static Box2D.NET.B2Arrays; -using static Box2D.NET.B2Diagnostics; -using static Box2D.NET.B2Profiling; +using static Box2D.NET.B2Buffers; using static Box2D.NET.B2Constants; -using static Box2D.NET.B2Worlds; +using static Box2D.NET.B2Diagnostics; using static Box2D.NET.B2IdPools; +using static Box2D.NET.B2Profiling; using static Box2D.NET.B2SolverSets; -using static Box2D.NET.B2ArenaAllocators; using static Box2D.NET.B2Timers; +using static Box2D.NET.B2ArenaAllocators; namespace Box2D.NET { @@ -34,8 +34,7 @@ public static B2Island b2CreateIsland(B2World world, int setIndex) if (islandId == world.islands.count) { - B2Island emptyIsland = new B2Island(); - b2Array_Push(ref world.islands, emptyIsland); + b2Array_Push(ref world.islands, new B2Island()); } else { @@ -48,15 +47,9 @@ public static B2Island b2CreateIsland(B2World world, int setIndex) island.setIndex = setIndex; island.localIndex = set.islandSims.count; island.islandId = islandId; - island.headBody = B2_NULL_INDEX; - island.tailBody = B2_NULL_INDEX; - island.bodyCount = 0; - island.headContact = B2_NULL_INDEX; - island.tailContact = B2_NULL_INDEX; - island.contactCount = 0; - island.headJoint = B2_NULL_INDEX; - island.tailJoint = B2_NULL_INDEX; - island.jointCount = 0; + island.bodies = b2Array_Create(); + island.contacts = b2Array_Create(); + island.joints = b2Array_Create(); island.constraintRemoveCount = 0; ref B2IslandSim islandSim = ref b2Array_Add(ref set.islandSims); @@ -87,14 +80,18 @@ public static void b2DestroyIsland(B2World world, int islandId) } // Free island and id (preserve island revision) + b2Array_Destroy(ref island.bodies); + b2Array_Destroy(ref island.contacts); + b2Array_Destroy(ref island.joints); + island.constraintRemoveCount = 0; island.islandId = B2_NULL_INDEX; island.setIndex = B2_NULL_INDEX; island.localIndex = B2_NULL_INDEX; + b2FreeId(world.islandIdPool, islandId); } - - internal static int b2MergeIslands(B2World world, int islandIdA, int islandIdB) + private static int b2MergeIslands(B2World world, int islandIdA, int islandIdB) { if (islandIdA == islandIdB) { @@ -113,167 +110,102 @@ internal static int b2MergeIslands(B2World world, int islandIdA, int islandIdB) return islandIdA; } - B2Island islandA = b2Array_Get(ref world.islands, islandIdA); - B2Island islandB = b2Array_Get(ref world.islands, islandIdB); - - // Keep the biggest island to reduce cache misses - B2Island big; - B2Island small; - if (islandA.bodyCount >= islandB.bodyCount) + B2Island smallIsland; + B2Island bigIsland; { - big = islandA; - small = islandB; - } - else - { - big = islandB; - small = islandA; - } + B2Island islandA = b2Array_Get(ref world.islands, islandIdA); + B2Island islandB = b2Array_Get(ref world.islands, islandIdB); - int bigId = big.islandId; - - // remap island indices (cache misses) - int bodyId = small.headBody; - while (bodyId != B2_NULL_INDEX) - { - B2Body body = b2Array_Get(ref world.bodies, bodyId); - body.islandId = bigId; - bodyId = body.islandNext; + // Keep the biggest island to reduce cache misses + if (islandA.bodies.count >= islandB.bodies.count) + { + bigIsland = islandA; + smallIsland = islandB; + } + else + { + bigIsland = islandB; + smallIsland = islandA; + } } - int contactId = small.headContact; - while (contactId != B2_NULL_INDEX) - { - B2Contact contact = b2Array_Get(ref world.contacts, contactId); - contact.islandId = bigId; - contactId = contact.islandNext; - } + int bigIslandId = bigIsland.islandId; + b2Array_Reserve(ref bigIsland.bodies, bigIsland.bodies.count + smallIsland.bodies.count); - int jointId = small.headJoint; - while (jointId != B2_NULL_INDEX) + // Move bodies from smaller island to larger island + for (int i = 0; i < smallIsland.bodies.count; ++i) { - B2Joint joint = b2Array_Get(ref world.joints, jointId); - joint.islandId = bigId; - jointId = joint.islandNext; + int bodyId = smallIsland.bodies.data[i]; + B2Body body = b2Array_Get(ref world.bodies, bodyId); + B2_VALIDATE(body.islandId == smallIsland.islandId); + body.islandId = bigIslandId; + body.islandIndex = bigIsland.bodies.count; + b2Array_Push(ref bigIsland.bodies, bodyId); } - // connect body lists - B2_ASSERT(big.tailBody != B2_NULL_INDEX); - B2Body tailBody = b2Array_Get(ref world.bodies, big.tailBody); - B2_ASSERT(tailBody.islandNext == B2_NULL_INDEX); - tailBody.islandNext = small.headBody; - - B2_ASSERT(small.headBody != B2_NULL_INDEX); - B2Body headBody = b2Array_Get(ref world.bodies, small.headBody); - B2_ASSERT(headBody.islandPrev == B2_NULL_INDEX); - headBody.islandPrev = big.tailBody; - - big.tailBody = small.tailBody; - big.bodyCount += small.bodyCount; - - // connect contact lists - if (big.headContact == B2_NULL_INDEX) - { - // Big island has no contacts - B2_ASSERT(big.tailContact == B2_NULL_INDEX && big.contactCount == 0); - big.headContact = small.headContact; - big.tailContact = small.tailContact; - big.contactCount = small.contactCount; - } - else if (small.headContact != B2_NULL_INDEX) + // Migrate contacts from smaller island to larger island + if (smallIsland.contacts.count > 0) { - // Both islands have contacts - B2_ASSERT(small.tailContact != B2_NULL_INDEX && small.contactCount > 0); - B2_ASSERT(big.tailContact != B2_NULL_INDEX && big.contactCount > 0); - - B2Contact tailContact = b2Array_Get(ref world.contacts, big.tailContact); - B2_ASSERT(tailContact.islandNext == B2_NULL_INDEX); - tailContact.islandNext = small.headContact; - - B2Contact headContact = b2Array_Get(ref world.contacts, small.headContact); - B2_ASSERT(headContact.islandPrev == B2_NULL_INDEX); - headContact.islandPrev = big.tailContact; + b2Array_Reserve(ref bigIsland.contacts, bigIsland.contacts.count + smallIsland.contacts.count); - big.tailContact = small.tailContact; - big.contactCount += small.contactCount; + for (int i = 0; i < smallIsland.contacts.count; ++i) + { + B2ContactLink link = smallIsland.contacts.data[i]; + B2Contact contact = b2Array_Get(ref world.contacts, link.contactId); + contact.islandId = bigIslandId; + contact.islandIndex = bigIsland.contacts.count; + b2Array_Push(ref bigIsland.contacts, link.ToCopy()); + } } - if (big.headJoint == B2_NULL_INDEX) + // Migrate joints from smaller island to larger island + if (smallIsland.joints.count > 0) { - // Root island has no joints - B2_ASSERT(big.tailJoint == B2_NULL_INDEX && big.jointCount == 0); - big.headJoint = small.headJoint; - big.tailJoint = small.tailJoint; - big.jointCount = small.jointCount; - } - else if (small.headJoint != B2_NULL_INDEX) - { - // Both islands have joints - B2_ASSERT(small.tailJoint != B2_NULL_INDEX && small.jointCount > 0); - B2_ASSERT(big.tailJoint != B2_NULL_INDEX && big.jointCount > 0); - - B2Joint tailJoint = b2Array_Get(ref world.joints, big.tailJoint); - B2_ASSERT(tailJoint.islandNext == B2_NULL_INDEX); - tailJoint.islandNext = small.headJoint; + b2Array_Reserve(ref bigIsland.joints, bigIsland.joints.count + smallIsland.joints.count); - B2Joint headJoint = b2Array_Get(ref world.joints, small.headJoint); - B2_ASSERT(headJoint.islandPrev == B2_NULL_INDEX); - headJoint.islandPrev = big.tailJoint; - - big.tailJoint = small.tailJoint; - big.jointCount += small.jointCount; + for (int i = 0; i < smallIsland.joints.count; ++i) + { + B2JointLink link = smallIsland.joints.data[i]; + B2Joint joint = b2Array_Get(ref world.joints, link.jointId); + joint.islandId = bigIslandId; + joint.islandIndex = bigIsland.joints.count; + b2Array_Push(ref bigIsland.joints, link.ToCopy()); + } } // Track removed constraints - big.constraintRemoveCount += small.constraintRemoveCount; + bigIsland.constraintRemoveCount += smallIsland.constraintRemoveCount; - small.bodyCount = 0; - small.contactCount = 0; - small.jointCount = 0; - small.headBody = B2_NULL_INDEX; - small.headContact = B2_NULL_INDEX; - small.headJoint = B2_NULL_INDEX; - small.tailBody = B2_NULL_INDEX; - small.tailContact = B2_NULL_INDEX; - small.tailJoint = B2_NULL_INDEX; - small.constraintRemoveCount = 0; + b2DestroyIsland(world, smallIsland.islandId); - b2DestroyIsland(world, small.islandId); + b2ValidateIsland(world, bigIslandId); - b2ValidateIsland(world, bigId); - - return bigId; + return bigIslandId; } - internal static void b2AddContactToIsland(B2World world, int islandId, B2Contact contact) + private static void b2AddContactToIsland(B2World world, int islandId, B2Contact contact) { B2_ASSERT(contact.islandId == B2_NULL_INDEX); - B2_ASSERT(contact.islandPrev == B2_NULL_INDEX); - B2_ASSERT(contact.islandNext == B2_NULL_INDEX); + B2_ASSERT(contact.islandIndex == B2_NULL_INDEX); B2Island island = b2Array_Get(ref world.islands, islandId); - if (island.headContact != B2_NULL_INDEX) - { - contact.islandNext = island.headContact; - B2Contact headContact = b2Array_Get(ref world.contacts, island.headContact); - headContact.islandPrev = contact.contactId; - } + contact.islandId = islandId; + contact.islandIndex = island.contacts.count; - island.headContact = contact.contactId; - if (island.tailContact == B2_NULL_INDEX) + B2ContactLink link = new B2ContactLink { - island.tailContact = island.headContact; - } - - island.contactCount += 1; - contact.islandId = islandId; + contactId = contact.contactId, + bodyIdA = contact.edges[0].bodyId, + bodyIdB = contact.edges[1].bodyId, + }; + b2Array_Push(ref island.contacts, link); b2ValidateIsland(world, islandId); } // Link a contact into an island. - internal static void b2LinkContact(B2World world, B2Contact contact) + public static void b2LinkContact(B2World world, B2Contact contact) { B2_ASSERT((contact.flags & (uint)B2ContactFlags.b2_contactTouchingFlag) != 0); @@ -313,9 +245,8 @@ internal static void b2LinkContact(B2World world, B2Contact contact) b2AddContactToIsland(world, finalIslandId, contact); } - // Unlink contact from the island graph when it stops having contact points // This is called when a contact no longer has contact points or when a contact is destroyed. - internal static void b2UnlinkContact(B2World world, B2Contact contact) + public static void b2UnlinkContact(B2World world, B2Contact contact) { B2_ASSERT(contact.islandId != B2_NULL_INDEX); @@ -323,70 +254,50 @@ internal static void b2UnlinkContact(B2World world, B2Contact contact) int islandId = contact.islandId; B2Island island = b2Array_Get(ref world.islands, islandId); - if (contact.islandPrev != B2_NULL_INDEX) - { - B2Contact prevContact = b2Array_Get(ref world.contacts, contact.islandPrev); - B2_ASSERT(prevContact.islandNext == contact.contactId); - prevContact.islandNext = contact.islandNext; - } - - if (contact.islandNext != B2_NULL_INDEX) - { - B2Contact nextContact = b2Array_Get(ref world.contacts, contact.islandNext); - B2_ASSERT(nextContact.islandPrev == contact.contactId); - nextContact.islandPrev = contact.islandPrev; - } - - if (island.headContact == contact.contactId) - { - island.headContact = contact.islandNext; - } + int removeIndex = contact.islandIndex; + B2_ASSERT(0 <= removeIndex && removeIndex < island.contacts.count); + B2_ASSERT(island.contacts.data[removeIndex].contactId == contact.contactId); - if (island.tailContact == contact.contactId) + int movedIndex = b2Array_RemoveSwap(ref island.contacts, removeIndex); + if (movedIndex != B2_NULL_INDEX) { - island.tailContact = contact.islandPrev; + // Fix islandIndex on the contact that was swapped into removeIndex + B2ContactLink movedLink = island.contacts.data[removeIndex]; + B2Contact movedContact = b2Array_Get(ref world.contacts, movedLink.contactId); + B2_ASSERT(movedContact.islandIndex == movedIndex); + movedContact.islandIndex = removeIndex; } - B2_ASSERT(island.contactCount > 0); - island.contactCount -= 1; island.constraintRemoveCount += 1; contact.islandId = B2_NULL_INDEX; - contact.islandPrev = B2_NULL_INDEX; - contact.islandNext = B2_NULL_INDEX; + contact.islandIndex = B2_NULL_INDEX; b2ValidateIsland(world, islandId); } - internal static void b2AddJointToIsland(B2World world, int islandId, B2Joint joint) + private static void b2AddJointToIsland(B2World world, int islandId, B2Joint joint) { B2_ASSERT(joint.islandId == B2_NULL_INDEX); - B2_ASSERT(joint.islandPrev == B2_NULL_INDEX); - B2_ASSERT(joint.islandNext == B2_NULL_INDEX); + B2_ASSERT(joint.islandIndex == B2_NULL_INDEX); B2Island island = b2Array_Get(ref world.islands, islandId); - if (island.headJoint != B2_NULL_INDEX) - { - joint.islandNext = island.headJoint; - B2Joint headJoint = b2Array_Get(ref world.joints, island.headJoint); - headJoint.islandPrev = joint.jointId; - } + joint.islandId = islandId; + joint.islandIndex = island.joints.count; - island.headJoint = joint.jointId; - if (island.tailJoint == B2_NULL_INDEX) + B2JointLink link = new B2JointLink { - island.tailJoint = island.headJoint; - } - - island.jointCount += 1; - joint.islandId = islandId; + jointId = joint.jointId, + bodyIdA = joint.edges[0].bodyId, + bodyIdB = joint.edges[1].bodyId, + }; + b2Array_Push(ref island.joints, link); b2ValidateIsland(world, islandId); } - // Link a joint into the island graph when it is created - internal static void b2LinkJoint(B2World world, B2Joint joint) + public static void b2LinkJoint(B2World world, B2Joint joint) { B2Body bodyA = b2Array_Get(ref world.bodies, joint.edges[0].bodyId); B2Body bodyB = b2Array_Get(ref world.bodies, joint.edges[1].bodyId); @@ -414,8 +325,7 @@ internal static void b2LinkJoint(B2World world, B2Joint joint) b2AddJointToIsland(world, finalIslandId, joint); } - // Unlink a joint from the island graph when it is destroyed - internal static void b2UnlinkJoint(B2World world, B2Joint joint) + public static void b2UnlinkJoint(B2World world, B2Joint joint) { if (joint.islandId == B2_NULL_INDEX) { @@ -426,304 +336,364 @@ internal static void b2UnlinkJoint(B2World world, B2Joint joint) int islandId = joint.islandId; B2Island island = b2Array_Get(ref world.islands, islandId); - if (joint.islandPrev != B2_NULL_INDEX) - { - B2Joint prevJoint = b2Array_Get(ref world.joints, joint.islandPrev); - B2_ASSERT(prevJoint.islandNext == joint.jointId); - prevJoint.islandNext = joint.islandNext; - } - - if (joint.islandNext != B2_NULL_INDEX) - { - B2Joint nextJoint = b2Array_Get(ref world.joints, joint.islandNext); - B2_ASSERT(nextJoint.islandPrev == joint.jointId); - nextJoint.islandPrev = joint.islandPrev; - } + int removeIndex = joint.islandIndex; + B2_ASSERT(0 <= removeIndex && removeIndex < island.joints.count); + B2_ASSERT(island.joints.data[removeIndex].jointId == joint.jointId); - if (island.headJoint == joint.jointId) - { - island.headJoint = joint.islandNext; - } - - if (island.tailJoint == joint.jointId) + int movedIndex = b2Array_RemoveSwap(ref island.joints, removeIndex); + if (movedIndex != B2_NULL_INDEX) { - island.tailJoint = joint.islandPrev; + // Fix islandIndex on the joint that was swapped into removeIndex + B2JointLink movedLink = island.joints.data[removeIndex]; + B2Joint movedJoint = b2Array_Get(ref world.joints, movedLink.jointId); + B2_ASSERT(movedJoint.islandIndex == movedIndex); + movedJoint.islandIndex = removeIndex; } - B2_ASSERT(island.jointCount > 0); - island.jointCount -= 1; island.constraintRemoveCount += 1; joint.islandId = B2_NULL_INDEX; - joint.islandPrev = B2_NULL_INDEX; - joint.islandNext = B2_NULL_INDEX; + joint.islandIndex = B2_NULL_INDEX; b2ValidateIsland(world, islandId); } - // Possible optimizations: - // 2. start from the sleepy bodies and stop processing if a sleep body is connected to a non-sleepy body - // 3. use a sleepy flag on bodies to avoid velocity access - internal static void b2SplitIsland(B2World world, int baseId) + // Find parent of a node. Use path halving to speed up further queries. + private static int b2IslandFindParent(Span parents, int node) { - B2Island baseIsland = b2Array_Get(ref world.islands, baseId); - int setIndex = baseIsland.setIndex; - - if (setIndex != (int)B2SolverSetType.b2_awakeSet) + // Walk the chain of parents to find the node that is its own parent (the root) + while (parents[node] != node) { - // can only split awake island - return; + int grandParent = parents[parents[node]]; + parents[node] = grandParent; + node = grandParent; } - if (baseIsland.constraintRemoveCount == 0) + return node; + } + + // Connect the components containing node1 and node2. + // Uses rank to keep tree balanced. Tracks per-component contact and joint counts. + private static void b2IslandUnion(Span parents, Span ranks, int node1, int node2, Span contactCounts, Span jointCounts) + { + int root1 = b2IslandFindParent(parents, node1); + int root2 = b2IslandFindParent(parents, node2); + if (root1 != root2) { - // this island doesn't need to be split - return; + if (ranks[root1] < ranks[root2]) + { + parents[root1] = root2; + contactCounts[root2] += contactCounts[root1]; + jointCounts[root2] += jointCounts[root1]; + } + else if (ranks[root1] > ranks[root2]) + { + parents[root2] = root1; + contactCounts[root1] += contactCounts[root2]; + jointCounts[root1] += jointCounts[root2]; + } + else + { + parents[root2] = root1; + ranks[root1] += 1; + contactCounts[root1] += contactCounts[root2]; + jointCounts[root1] += jointCounts[root2]; + } } + } + + // This uses union-find. + // https://en.wikipedia.org/wiki/Disjoint-set_data_structure + public static void b2SplitIsland(B2World world, int baseId) + { + B2Island baseIsland = b2Array_Get(ref world.islands, baseId); + B2_ASSERT(baseIsland.constraintRemoveCount > 0); + B2_ASSERT(baseIsland.setIndex == (int)B2SolverSetType.b2_awakeSet); b2ValidateIsland(world, baseId); - int bodyCount = baseIsland.bodyCount; + // Cache base island fields before b2CreateIsland, which may reallocate + // world.islands and invalidate the baseIsland pointer. + int baseBodyCount = baseIsland.bodies.count; + int[] baseBodyIds = baseIsland.bodies.data; + int baseBodyCapacity = baseIsland.bodies.capacity; + + int baseContactCount = baseIsland.contacts.count; + B2ContactLink[] baseContacts = baseIsland.contacts.data; + int baseContactCapacity = baseIsland.contacts.capacity; + + int baseJointCount = baseIsland.joints.count; + B2JointLink[] baseJoints = baseIsland.joints.data; + int baseJointCapacity = baseIsland.joints.capacity; - B2Body[] bodies = world.bodies.data; B2ArenaAllocator alloc = world.arena; // No lock is needed because I ensure the allocator is not used while this task is active. - ArraySegment stack = b2AllocateArenaItem(alloc, bodyCount * sizeof(int), "island stack"); - ArraySegment bodyIds = b2AllocateArenaItem(alloc, bodyCount * sizeof(int), "body ids"); + // Allocate contactCounts and jointCounts before ranks so ranks can be freed first (LIFO arena). + ArraySegment parents = b2AllocateArenaItem(alloc, baseBodyCount, "parents"); + ArraySegment contactCounts = b2AllocateArenaItem(alloc, baseBodyCount, "contact counts"); + ArraySegment jointCounts = b2AllocateArenaItem(alloc, baseBodyCount, "joint counts"); + ArraySegment ranks = b2AllocateArenaItem(alloc, baseBodyCount, "ranks"); + for (int i = 0; i < baseBodyCount; ++i) + { + parents[i] = i; + ranks[i] = 0; + contactCounts[i] = 0; + jointCounts[i] = 0; + } + + Span bodies = world.bodies.data; + + // Union over contacts, tracking per-component contact counts + for (int i = 0; i < baseContactCount; ++i) + { + int bodyIdA = baseContacts[i].bodyIdA; + int bodyIdB = baseContacts[i].bodyIdB; + B2_VALIDATE(0 <= bodyIdA && bodyIdA < world.bodies.count); + B2_VALIDATE(0 <= bodyIdB && bodyIdB < world.bodies.count); + B2Body bodyA = bodies[bodyIdA]; + B2Body bodyB = bodies[bodyIdB]; + int islandIndexA = bodyA.islandIndex; + int islandIndexB = bodyB.islandIndex; + + // Only connect non-static bodies + if (islandIndexA != B2_NULL_INDEX && islandIndexB != B2_NULL_INDEX) + { + B2_VALIDATE(0 <= islandIndexA && islandIndexA < baseBodyCount); + B2_VALIDATE(0 <= islandIndexB && islandIndexB < baseBodyCount); + b2IslandUnion(parents, ranks, islandIndexA, islandIndexB, contactCounts, jointCounts); + int root = b2IslandFindParent(parents, islandIndexA); + contactCounts[root] += 1; + } + else + { + int islandIndex = islandIndexA != B2_NULL_INDEX ? islandIndexA : islandIndexB; + int root = b2IslandFindParent(parents, islandIndex); + contactCounts[root] += 1; + } + } - // Build array containing all body indices from @base island. These - // serve as seed bodies for the depth first search (DFS). - int index = 0; - int nextBody = baseIsland.headBody; - while (nextBody != B2_NULL_INDEX) + // Union over joints, tracking per-component joint counts + for (int i = 0; i < baseJointCount; ++i) { - bodyIds[index++] = nextBody; - B2Body body = bodies[nextBody]; + int bodyIdA = baseJoints[i].bodyIdA; + int bodyIdB = baseJoints[i].bodyIdB; + B2_VALIDATE(0 <= bodyIdA && bodyIdA < world.bodies.count); + B2_VALIDATE(0 <= bodyIdB && bodyIdB < world.bodies.count); + B2Body bodyA = bodies[bodyIdA]; + B2Body bodyB = bodies[bodyIdB]; + int islandIndexA = bodyA.islandIndex; + int islandIndexB = bodyB.islandIndex; - nextBody = body.islandNext; + // Only connect non-static bodies + if (islandIndexA != B2_NULL_INDEX && islandIndexB != B2_NULL_INDEX) + { + B2_VALIDATE(0 <= islandIndexA && islandIndexA < baseBodyCount); + B2_VALIDATE(0 <= islandIndexB && islandIndexB < baseBodyCount); + b2IslandUnion(parents, ranks, islandIndexA, islandIndexB, contactCounts, jointCounts); + int root = b2IslandFindParent(parents, islandIndexA); + jointCounts[root] += 1; + } + else + { + int islandIndex = islandIndexA != B2_NULL_INDEX ? islandIndexA : islandIndexB; + int root = b2IslandFindParent(parents, islandIndex); + jointCounts[root] += 1; + } } - B2_ASSERT(index == bodyCount); + // Done with ranks + b2FreeArenaItem(alloc, ranks); + ranks = null; - // Each island is found as a depth first search starting from a seed body - for (int i = 0; i < bodyCount; ++i) + // Flatten all parent indices and count connected components. + int componentCount = 0; + for (int i = 0; i < baseBodyCount; ++i) { - int seedIndex = bodyIds[i]; - B2Body seed = bodies[seedIndex]; - B2_ASSERT(seed.setIndex == setIndex); - - if (seed.islandId != baseId) + parents[i] = b2IslandFindParent(parents, i); + if (parents[i] == i) { - // The body has already been visited - continue; + componentCount += 1; } + } - int stackCount = 0; - stack[stackCount++] = seedIndex; + // Early return — island is still fully connected, no split needed. + if (componentCount == 1) + { + baseIsland.constraintRemoveCount = 0; + b2FreeArenaItem(alloc, jointCounts); + b2FreeArenaItem(alloc, contactCounts); + b2FreeArenaItem(alloc, parents); + return; + } - // Create new island - // No lock needed because only a single island can split per time step. No islands are being used during the constraint - // solve. However, islands are touched during body finalization. - B2Island island = b2CreateIsland(world, setIndex); + // Detach body/contact/joint arrays from base island so b2DestroyIsland won't free them + baseIsland.bodies.data = null; + baseIsland.bodies.count = 0; + baseIsland.bodies.capacity = 0; - int islandId = island.islandId; - seed.islandId = islandId; + baseIsland.contacts.data = null; + baseIsland.contacts.count = 0; + baseIsland.contacts.capacity = 0; - // Perform a depth first search (DFS) on the constraint graph. - while (stackCount > 0) - { - // Grab the next body off the stack and add it to the island. - int bodyId = stack[--stackCount]; - B2Body body = bodies[bodyId]; - B2_ASSERT(body.setIndex == (int)B2SolverSetType.b2_awakeSet); - B2_ASSERT(body.islandId == islandId); + baseIsland.joints.data = null; + baseIsland.joints.count = 0; + baseIsland.joints.capacity = 0; + + // Null so code below doesn't accidentally use this. + baseIsland = null; + + // Map from body index to new island index. Only set for root bodies. + ArraySegment rootMap = b2AllocateArenaItem(alloc, baseBodyCount, "root map"); + for (int i = 0; i < baseBodyCount; ++i) + { + rootMap[i] = B2_NULL_INDEX; + } + + ArraySegment componentBodyCounts = b2AllocateArenaItem(alloc, componentCount, "component body counts"); + ArraySegment componentContactCounts = b2AllocateArenaItem(alloc, componentCount, "component contact counts"); + ArraySegment componentJointCounts = b2AllocateArenaItem(alloc, componentCount, "component joint counts"); + int islandCount = 0; - // Add body to island - if (island.tailBody != B2_NULL_INDEX) - { - bodies[island.tailBody].islandNext = bodyId; - } - - body.islandPrev = island.tailBody; - body.islandNext = B2_NULL_INDEX; - island.tailBody = bodyId; - - if (island.headBody == B2_NULL_INDEX) - { - island.headBody = bodyId; - } - - island.bodyCount += 1; - - // Search all contacts connected to this body. - int contactKey = body.headContactKey; - while (contactKey != B2_NULL_INDEX) - { - int contactId = contactKey >> 1; - int edgeIndex = contactKey & 1; - - B2Contact contact = b2Array_Get(ref world.contacts, contactId); - B2_ASSERT(contact.contactId == contactId); - - // Next key - contactKey = contact.edges[edgeIndex].nextKey; - - // Has this contact already been added to this island? - if (contact.islandId == islandId) - { - continue; - } - - // Is this contact enabled and touching? - if ((contact.flags & (uint)B2ContactFlags.b2_contactTouchingFlag) == 0) - { - continue; - } - - int otherEdgeIndex = edgeIndex ^ 1; - int otherBodyId = contact.edges[otherEdgeIndex].bodyId; - B2Body otherBody = bodies[otherBodyId]; - - // Maybe add other body to stack - if (otherBody.islandId != islandId && otherBody.setIndex != (int)B2SolverSetType.b2_staticSet) - { - B2_ASSERT(stackCount < bodyCount); - stack[stackCount++] = otherBodyId; - - // Need to update the body's island id immediately so it is not traversed again - otherBody.islandId = islandId; - } - - // Add contact to island - contact.islandId = islandId; - if (island.tailContact != B2_NULL_INDEX) - { - B2Contact tailContact = b2Array_Get(ref world.contacts, island.tailContact); - tailContact.islandNext = contactId; - } - - contact.islandPrev = island.tailContact; - contact.islandNext = B2_NULL_INDEX; - island.tailContact = contactId; - - if (island.headContact == B2_NULL_INDEX) - { - island.headContact = contactId; - } - - island.contactCount += 1; - } - - // Search all joints connect to this body. - int jointKey = body.headJointKey; - while (jointKey != B2_NULL_INDEX) - { - int jointId = jointKey >> 1; - int edgeIndex = jointKey & 1; - - B2Joint joint = b2Array_Get(ref world.joints, jointId); - B2_ASSERT(joint.jointId == jointId); - - // Next key - jointKey = joint.edges[edgeIndex].nextKey; - - // Has this joint already been added to this island? - if (joint.islandId == islandId) - { - continue; - } - - // todo redundant with test below? - if (joint.setIndex == (int)B2SolverSetType.b2_disabledSet) - { - continue; - } - - int otherEdgeIndex = edgeIndex ^ 1; - int otherBodyId = joint.edges[otherEdgeIndex].bodyId; - B2Body otherBody = bodies[otherBodyId]; - - // Don't simulate joints connected to disabled bodies. - if (otherBody.setIndex == (int)B2SolverSetType.b2_disabledSet) - { - continue; - } - - // At least one body must be dynamic - if (body.type != B2BodyType.b2_dynamicBody && otherBody.type != B2BodyType.b2_dynamicBody) - { - continue; - } - - // Maybe add other body to stack - if (otherBody.islandId != islandId && otherBody.setIndex == (int)B2SolverSetType.b2_awakeSet) - { - B2_ASSERT(stackCount < bodyCount); - stack[stackCount++] = otherBodyId; - - // Need to update the body's island id immediately so it is not traversed again - otherBody.islandId = islandId; - } - - // Add joint to island - joint.islandId = islandId; - if (island.tailJoint != B2_NULL_INDEX) - { - B2Joint tailJoint = b2Array_Get(ref world.joints, island.tailJoint); - tailJoint.islandNext = jointId; - } - - joint.islandPrev = island.tailJoint; - joint.islandNext = B2_NULL_INDEX; - island.tailJoint = jointId; - - if (island.headJoint == B2_NULL_INDEX) - { - island.headJoint = jointId; - } - - island.jointCount += 1; - } + // Find the root body for each body and create islands as needed. + // Extract per-component counts from the root nodes' accumulated counts. + for (int i = 0; i < baseBodyCount; ++i) + { + int rootIndex = parents[i]; + if (rootMap[rootIndex] == B2_NULL_INDEX) + { + rootMap[rootIndex] = islandCount; + componentBodyCounts[islandCount] = 0; + componentContactCounts[islandCount] = contactCounts[rootIndex]; + componentJointCounts[islandCount] = jointCounts[rootIndex]; + islandCount += 1; } - b2ValidateIsland(world, islandId); + componentBodyCounts[rootMap[rootIndex]] += 1; } - // Done with the base split island. This is delayed because the baseId is used as a marker and it - // should not be recycled in while splitting. + B2_ASSERT(islandCount == componentCount); + + // Map from new island index to island id + ArraySegment islandIds = b2AllocateArenaItem(alloc, islandCount, "island ids"); + + // Create new islands and reserve body/contact/joint arrays + for (int i = 0; i < islandCount; ++i) + { + // WARNING: this invalidates baseIsland pointer + B2Island newIsland = b2CreateIsland(world, (int)B2SolverSetType.b2_awakeSet); + islandIds[i] = newIsland.islandId; + + // Reserve arrays to avoid wasteful growth and memcpy. + b2Array_Reserve(ref newIsland.bodies, componentBodyCounts[i]); + b2Array_Reserve(ref newIsland.contacts, componentContactCounts[i]); + b2Array_Reserve(ref newIsland.joints, componentJointCounts[i]); + } + + // Assign bodies to new islands + for (int i = 0; i < baseBodyCount; ++i) + { + int bodyId = baseBodyIds[i]; + int root = b2IslandFindParent(parents, i); + int newIslandId = islandIds[rootMap[root]]; + + B2Body body = b2Array_Get(ref world.bodies, bodyId); + B2Island newIsland = b2Array_Get(ref world.islands, newIslandId); + + body.islandId = newIslandId; + body.islandIndex = newIsland.bodies.count; + + // Ensure the array has the correct capacity + B2_VALIDATE(newIsland.bodies.count < newIsland.bodies.capacity); + b2Array_Push(ref newIsland.bodies, bodyId); + } + + // Assign contacts to the island of their bodies + for (int i = 0; i < baseContactCount; ++i) + { + B2ContactLink link = baseContacts[i]; + B2Contact contact = b2Array_Get(ref world.contacts, link.contactId); + + // Static bodies don't have an island id. + B2Body bodyA = b2Array_Get(ref world.bodies, link.bodyIdA); + B2Body bodyB = b2Array_Get(ref world.bodies, link.bodyIdB); + int targetIslandId = bodyA.islandId != B2_NULL_INDEX ? bodyA.islandId : bodyB.islandId; + + B2Island targetIsland = b2Array_Get(ref world.islands, targetIslandId); + contact.islandId = targetIslandId; + contact.islandIndex = targetIsland.contacts.count; + + // Ensure the array has the correct capacity + B2_VALIDATE(targetIsland.contacts.count < targetIsland.contacts.capacity); + b2Array_Push(ref targetIsland.contacts, link.ToCopy()); + } + + // Assign joints to the island of their bodies + for (int i = 0; i < baseJointCount; ++i) + { + B2JointLink link = baseJoints[i]; + B2Joint joint = b2Array_Get(ref world.joints, link.jointId); + + // Static bodies don't have an island id. + B2Body bodyA = b2Array_Get(ref world.bodies, link.bodyIdA); + B2Body bodyB = b2Array_Get(ref world.bodies, link.bodyIdB); + int targetIslandId = bodyA.islandId != B2_NULL_INDEX ? bodyA.islandId : bodyB.islandId; + + B2Island targetIsland = b2Array_Get(ref world.islands, targetIslandId); + joint.islandId = targetIslandId; + joint.islandIndex = targetIsland.joints.count; + + // Ensure the array has the correct capacity + B2_VALIDATE(targetIsland.joints.count < targetIsland.joints.capacity); + b2Array_Push(ref targetIsland.joints, link.ToCopy()); + } + + // Destroy the base island b2DestroyIsland(world, baseId); - b2FreeArenaItem(alloc, bodyIds); - b2FreeArenaItem(alloc, stack); + // Free the detached arrays manually + b2Free(baseBodyIds, baseBodyCapacity); + b2Free(baseContacts, baseContactCapacity); + b2Free(baseJoints, baseJointCapacity); + + // Free arena items in LIFO order + b2FreeArenaItem(alloc, islandIds); + b2FreeArenaItem(alloc, componentJointCounts); + b2FreeArenaItem(alloc, componentContactCounts); + b2FreeArenaItem(alloc, componentBodyCounts); + b2FreeArenaItem(alloc, rootMap); + b2FreeArenaItem(alloc, jointCounts); + b2FreeArenaItem(alloc, contactCounts); + b2FreeArenaItem(alloc, parents); } -// Split an island because some contacts and/or joints have been removed. -// This is called during the constraint solve while islands are not being touched. This uses DFS and touches a lot of memory, -// so it can be quite slow. -// Note: contacts/joints connected to static bodies must belong to an island but don't affect island connectivity -// Note: static bodies are never in an island -// Note: this task interacts with some allocators without locks under the assumption that no other tasks -// are interacting with these data structures. - internal static void b2SplitIslandTask(int startIndex, int endIndex, uint threadIndex, object context) + // Split an island because some contacts and/or joints have been removed. + // This is called during the constraint solve while islands are not being touched. This uses union find and + // touches a lot of memory, so it can be slow. + // Note: contacts/joints connected to static bodies must belong to an island but don't affect island connectivity + // Note: static bodies are never in an island + // Note: this task interacts with some allocators without locks under the assumption that no other tasks + // are interacting with these data structures. + public static void b2SplitIslandTask(int startIndex, int endIndex, uint threadIndex, object context) { b2TracyCZoneNC(B2TracyCZone.split, "Split Island", B2HexColor.b2_colorOlive, true); B2_UNUSED(startIndex, endIndex, threadIndex); ulong ticks = b2GetTicks(); - B2World world = context as B2World; + B2World world = (B2World)context; B2_ASSERT(world.splitIslandId != B2_NULL_INDEX); b2SplitIsland(world, world.splitIslandId); + world.splitIslandId = B2_NULL_INDEX; world.profile.splitIslands += b2GetMilliseconds(ticks); b2TracyCZoneEnd(B2TracyCZone.split); } #if DEBUG - internal static void b2ValidateIsland(B2World world, int islandId) + public static void b2ValidateIsland(B2World world, int islandId) { if (islandId == B2_NULL_INDEX) { @@ -733,116 +703,53 @@ internal static void b2ValidateIsland(B2World world, int islandId) B2Island island = b2Array_Get(ref world.islands, islandId); B2_ASSERT(island.islandId == islandId); B2_ASSERT(island.setIndex != B2_NULL_INDEX); - B2_ASSERT(island.headBody != B2_NULL_INDEX); { - B2_ASSERT(island.tailBody != B2_NULL_INDEX); - B2_ASSERT(island.bodyCount > 0); - if (island.bodyCount > 1) - { - B2_ASSERT(island.tailBody != island.headBody); - } - - B2_ASSERT(island.bodyCount <= b2GetIdCount(world.bodyIdPool)); + B2_ASSERT(island.bodies.count > 0); + B2_ASSERT(island.bodies.count <= b2GetIdCount(world.bodyIdPool)); - int count = 0; - int bodyId = island.headBody; - while (bodyId != B2_NULL_INDEX) + for (int i = 0; i < island.bodies.count; ++i) { - B2Body body = b2Array_Get(ref world.bodies, bodyId); + B2Body body = b2Array_Get(ref world.bodies, island.bodies.data[i]); B2_ASSERT(body.islandId == islandId); + B2_ASSERT(body.islandIndex == i); B2_ASSERT(body.setIndex == island.setIndex); - count += 1; - - if (count == island.bodyCount) - { - B2_ASSERT(bodyId == island.tailBody); - } - - bodyId = body.islandNext; } - - B2_ASSERT(count == island.bodyCount); } - if (island.headContact != B2_NULL_INDEX) + if (island.contacts.count > 0) { - B2_ASSERT(island.tailContact != B2_NULL_INDEX); - B2_ASSERT(island.contactCount > 0); - if (island.contactCount > 1) - { - B2_ASSERT(island.tailContact != island.headContact); - } + B2_ASSERT(island.contacts.count <= b2GetIdCount(world.contactIdPool)); - B2_ASSERT(island.contactCount <= b2GetIdCount(world.contactIdPool)); - - int count = 0; - int contactId = island.headContact; - while (contactId != B2_NULL_INDEX) + for (int i = 0; i < island.contacts.count; ++i) { - B2Contact contact = b2Array_Get(ref world.contacts, contactId); + B2ContactLink link = island.contacts.data[i]; + B2Contact contact = b2Array_Get(ref world.contacts, link.contactId); B2_ASSERT(contact.setIndex == island.setIndex); B2_ASSERT(contact.islandId == islandId); - count += 1; - - if (count == island.contactCount) - { - B2_ASSERT(contactId == island.tailContact); - } - - contactId = contact.islandNext; + B2_ASSERT(contact.islandIndex == i); } - - B2_ASSERT(count == island.contactCount); - } - else - { - B2_ASSERT(island.tailContact == B2_NULL_INDEX); - B2_ASSERT(island.contactCount == 0); } - if (island.headJoint != B2_NULL_INDEX) + if (island.joints.count > 0) { - B2_ASSERT(island.tailJoint != B2_NULL_INDEX); - B2_ASSERT(island.jointCount > 0); - if (island.jointCount > 1) - { - B2_ASSERT(island.tailJoint != island.headJoint); - } - - B2_ASSERT(island.jointCount <= b2GetIdCount(world.jointIdPool)); + B2_ASSERT(island.joints.count <= b2GetIdCount(world.jointIdPool)); - int count = 0; - int jointId = island.headJoint; - while (jointId != B2_NULL_INDEX) + for (int i = 0; i < island.joints.count; ++i) { - B2Joint joint = b2Array_Get(ref world.joints, jointId); + B2JointLink link = island.joints.data[i]; + B2Joint joint = b2Array_Get(ref world.joints, link.jointId); B2_ASSERT(joint.setIndex == island.setIndex); - count += 1; - - if (count == island.jointCount) - { - B2_ASSERT(jointId == island.tailJoint); - } - - jointId = joint.islandNext; + B2_ASSERT(joint.islandId == islandId); + B2_ASSERT(joint.islandIndex == i); } - - B2_ASSERT(count == island.jointCount); - } - else - { - B2_ASSERT(island.tailJoint == B2_NULL_INDEX); - B2_ASSERT(island.jointCount == 0); } } - #else - internal static void b2ValidateIsland(B2World world, int islandId) + public static void b2ValidateIsland(B2World world, int islandId) { - B2_UNUSED(world); - B2_UNUSED(islandId); + B2_UNUSED(world, islandId); } #endif } -} \ No newline at end of file +} diff --git a/src/Box2D.NET/B2Joint.cs b/src/Box2D.NET/B2Joint.cs index 5d1ecae3..e70f03ec 100644 --- a/src/Box2D.NET/B2Joint.cs +++ b/src/Box2D.NET/B2Joint.cs @@ -25,8 +25,10 @@ public class B2Joint public int jointId; public int islandId; - public int islandPrev; - public int islandNext; + + // Index into the island's joints array for O(1) swap-removal. + // B2_NULL_INDEX when not in an island. + public int islandIndex; public float drawScale; diff --git a/src/Box2D.NET/B2JointLink.cs b/src/Box2D.NET/B2JointLink.cs new file mode 100644 index 00000000..ee9d1266 --- /dev/null +++ b/src/Box2D.NET/B2JointLink.cs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2023 Erin Catto +// SPDX-FileCopyrightText: 2025 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +namespace Box2D.NET +{ + // Cached joint data stored in the island for fast contiguous iteration. + public class B2JointLink + { + public int jointId; + public int bodyIdA; + public int bodyIdB; + + public B2JointLink ToCopy() + { + var copy = new B2JointLink(); + copy.jointId = jointId; + copy.bodyIdA = bodyIdA; + copy.bodyIdB = bodyIdB; + + return copy; + } + } +} diff --git a/src/Box2D.NET/B2Joints.cs b/src/Box2D.NET/B2Joints.cs index af8c0df7..a45cb07a 100644 --- a/src/Box2D.NET/B2Joints.cs +++ b/src/Box2D.NET/B2Joints.cs @@ -228,8 +228,7 @@ public static B2JointPair b2CreateJoint(B2World world, in B2JointDef def, B2Join joint.colorIndex = B2_NULL_INDEX; joint.localIndex = B2_NULL_INDEX; joint.islandId = B2_NULL_INDEX; - joint.islandPrev = B2_NULL_INDEX; - joint.islandNext = B2_NULL_INDEX; + joint.islandIndex = B2_NULL_INDEX; joint.drawScale = def.drawScale; joint.type = type; joint.collideConnected = def.collideConnected; @@ -1494,8 +1493,8 @@ internal static void b2DrawJoint(B2DebugDraw draw, B2World world, B2Joint joint) B2Vec2 pB = b2TransformPoint(transformB, jointSim.localFrameB.p); B2HexColor color = B2HexColor.b2_colorDarkSeaGreen; - - float scale = b2MaxFloat( 0.0001f, draw.jointScale * joint.drawScale ); + + float scale = b2MaxFloat(0.0001f, draw.jointScale * joint.drawScale); switch (joint.type) { diff --git a/src/Box2D.NET/B2SolverSets.cs b/src/Box2D.NET/B2SolverSets.cs index d4d3a562..2bc72231 100644 --- a/src/Box2D.NET/B2SolverSets.cs +++ b/src/Box2D.NET/B2SolverSets.cs @@ -176,8 +176,8 @@ internal static void b2TrySleepIsland(B2World world, int islandId) B2Island island = b2Array_Get(ref world.islands, islandId); B2_ASSERT(island.setIndex == (int)B2SolverSetType.b2_awakeSet); - // cannot put an island to sleep while it has a pending split - if (island.constraintRemoveCount > 0) + // Cannot put an island to sleep while it has a pending split and more than one body. + if (island.constraintRemoveCount > 0 && island.bodies.count > 1) { return; } @@ -205,20 +205,21 @@ internal static void b2TrySleepIsland(B2World world, int islandId) B2_ASSERT(0 <= island.localIndex && island.localIndex < awakeSet.islandSims.count); sleepSet.setIndex = sleepSetId; - sleepSet.bodySims = b2Array_Create(island.bodyCount); - sleepSet.contactSims = b2Array_Create(island.contactCount); - sleepSet.jointSims = b2Array_Create(island.jointCount); + sleepSet.bodySims = b2Array_Create(island.bodies.count); + sleepSet.contactSims = b2Array_Create(island.contacts.count); + sleepSet.jointSims = b2Array_Create(island.joints.count); // move awake bodies to sleeping set // this shuffles around bodies in the awake set { B2SolverSet disabledSet = b2Array_Get(ref world.solverSets, (int)B2SolverSetType.b2_disabledSet); - int bodyId = island.headBody; - while (bodyId != B2_NULL_INDEX) + for (int i = 0; i < island.bodies.count; ++i) { + int bodyId = island.bodies.data[i]; B2Body body = b2Array_Get(ref world.bodies, bodyId); B2_ASSERT(body.setIndex == (int)B2SolverSetType.b2_awakeSet); B2_ASSERT(body.islandId == islandId); + B2_ASSERT(body.islandIndex == i); // Update the body move event to indicate this body fell asleep // It could happen the body is forced asleep before it ever moves. @@ -316,20 +317,19 @@ internal static void b2TrySleepIsland(B2World world, int islandId) movedContact.localIndex = localIndex; } } - - bodyId = body.islandNext; } } // move touching contacts // this shuffles contacts in the awake set { - int contactId = island.headContact; - while (contactId != B2_NULL_INDEX) + for (int i = 0; i < island.contacts.count; ++i) { + int contactId = island.contacts.data[i].contactId; B2Contact contact = b2Array_Get(ref world.contacts, contactId); B2_ASSERT(contact.setIndex == (int)B2SolverSetType.b2_awakeSet); B2_ASSERT(contact.islandId == islandId); + B2_ASSERT(contact.islandIndex == i); int colorIndex = contact.colorIndex; B2_ASSERT(0 <= colorIndex && colorIndex < B2_GRAPH_COLOR_COUNT); @@ -364,20 +364,19 @@ internal static void b2TrySleepIsland(B2World world, int islandId) contact.setIndex = sleepSetId; contact.colorIndex = B2_NULL_INDEX; contact.localIndex = sleepContactIndex; - - contactId = contact.islandNext; } } // move joints // this shuffles joints in the awake set { - int jointId = island.headJoint; - while (jointId != B2_NULL_INDEX) + for (int i = 0; i < island.joints.count; ++i) { + int jointId = island.joints.data[i].jointId; B2Joint joint = b2Array_Get(ref world.joints, jointId); B2_ASSERT(joint.setIndex == (int)B2SolverSetType.b2_awakeSet); B2_ASSERT(joint.islandId == islandId); + B2_ASSERT(joint.islandIndex == i); int colorIndex = joint.colorIndex; int localIndex = joint.localIndex; @@ -413,8 +412,6 @@ internal static void b2TrySleepIsland(B2World world, int islandId) joint.setIndex = sleepSetId; joint.colorIndex = B2_NULL_INDEX; joint.localIndex = sleepJointIndex; - - jointId = joint.islandNext; } } @@ -439,6 +436,11 @@ internal static void b2TrySleepIsland(B2World world, int islandId) island.setIndex = sleepSetId; island.localIndex = 0; + + if (world.splitIslandId == islandId) + { + world.splitIslandId = B2_NULL_INDEX; + } } b2ValidateSolverSets(world); @@ -654,4 +656,4 @@ internal static void b2TransferJoint(B2World world, B2SolverSet targetSet, B2Sol } } } -} \ No newline at end of file +} diff --git a/src/Box2D.NET/B2StackArray.cs b/src/Box2D.NET/B2StackArray.cs new file mode 100644 index 00000000..60a8c4ed --- /dev/null +++ b/src/Box2D.NET/B2StackArray.cs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Erin Catto +// SPDX-FileCopyrightText: 2026 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +namespace Box2D.NET +{ + // A stack array uses a fixed size buffer but can grow on the heap if necessary. + public struct B2StackArray + { + public T[] stackData; + public T[] data; + public int count; + public int capacity; + } +} diff --git a/src/Box2D.NET/B2StackArrays.cs b/src/Box2D.NET/B2StackArrays.cs new file mode 100644 index 00000000..81daf98d --- /dev/null +++ b/src/Box2D.NET/B2StackArrays.cs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Erin Catto +// SPDX-FileCopyrightText: 2026 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +using System; +using System.Runtime.CompilerServices; +using static Box2D.NET.B2Buffers; +using static Box2D.NET.B2Diagnostics; + +namespace Box2D.NET +{ + public static class B2StackArrays + { + // Used to define a stack array instance + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static B2StackArray b2StackArray_Create(int stackCapacity) where T : new() + { + B2_ASSERT(stackCapacity > 0); + + var a = new B2StackArray(); + a.stackData = new T[stackCapacity]; + InitializeReferenceElements(a.stackData, 0, stackCapacity); + a.data = a.stackData; + a.count = 0; + a.capacity = stackCapacity; + return a; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void b2StackArray_Destroy(ref B2StackArray a) + { + if (a.data == null) + { + return; + } + + if (a.data != null && ReferenceEquals(a.data, a.stackData) == false) + { + b2Free(a.data, a.capacity); + } + + a.stackData = null; + a.data = null; + a.count = 0; + a.capacity = 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void b2StackArray_Reserve(ref B2StackArray a, int newCapacity) where T : new() + { + B2_ASSERT(a.data != null && a.capacity > 0); + if (a.capacity >= newCapacity) + { + return; + } + + int oldCapacity = a.capacity; + if (ReferenceEquals(a.data, a.stackData)) + { + T[] newData = b2GrowAlloc(null, 0, newCapacity); + Array.Copy(a.stackData, newData, oldCapacity); + a.data = newData; + } + else + { + a.data = b2GrowAlloc(a.data, oldCapacity, newCapacity); + } + + a.capacity = newCapacity; + } + + // Push a new element by value + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void b2StackArray_Push(ref B2StackArray a, T value) where T : new() + { + B2_ASSERT(a.data != null && a.capacity > 0); + if (a.count >= a.capacity) + { + b2StackArray_Reserve(ref a, 2 * a.capacity); + } + + a.data[a.count] = value; + a.count += 1; + } + + // Get a pointer to an element + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ref T b2StackArray_Get(ref B2StackArray a, int index) + { + B2_ASSERT(0 <= index && index < a.count); + return ref a.data[index]; + } + + // Remove an element from an int arrayA by swapping with the last element. This updates the index contained + // in the moved element in arrayB. Assumes the integers in arrayA index into arrayB. Assumes + // the elements of arrayB have an indexName member that is the index in arrayA. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void b2RemoveUpdate(ref B2StackArray arrayA, ref B2Array arrayB, int indexB, Func getIndexName, Action setIndexName) + { + int lastIndex = arrayA.count - 1; + B2_ASSERT(0 <= indexB && indexB < arrayB.count); + int indexA = getIndexName.Invoke(arrayB.data[indexB]); + B2_ASSERT(0 <= indexA && indexA < arrayA.count); + if (indexA != lastIndex) + { + int movedIndex = arrayA.data[lastIndex]; + arrayA.data[indexA] = movedIndex; + setIndexName.Invoke(arrayB.data[movedIndex], indexA); + } + + arrayA.count -= 1; + } + + private static void InitializeReferenceElements(T[] data, int startIndex, int count) where T : new() + { + if (typeof(T).IsValueType) + { + return; + } + + for (int i = startIndex; i < count; ++i) + { + data[i] = new T(); + } + } + } +} \ No newline at end of file diff --git a/src/Box2D.NET/B2World.cs b/src/Box2D.NET/B2World.cs index 326995e7..6397a568 100644 --- a/src/Box2D.NET/B2World.cs +++ b/src/Box2D.NET/B2World.cs @@ -50,7 +50,7 @@ public class B2World // Used to create stable ids for islands public B2IdPool islandIdPool; - // This is a sparse array that maps island ids to the island data stored in the solver sets. + // Persistent islands public B2Array islands; public B2IdPool shapeIdPool; @@ -214,7 +214,7 @@ public void Clear() stepIndex = 0; - splitIslandId = 0; + splitIslandId = B2_NULL_INDEX; gravity = new B2Vec2(); hitEventThreshold = 0.0f; diff --git a/src/Box2D.NET/B2Worlds.cs b/src/Box2D.NET/B2Worlds.cs index d6eb8ac0..50f0dd3b 100644 --- a/src/Box2D.NET/B2Worlds.cs +++ b/src/Box2D.NET/B2Worlds.cs @@ -348,6 +348,14 @@ public static void b2DestroyWorld(B2WorldId worldId) b2Array_Destroy(ref world.chainShapes); b2Array_Destroy(ref world.contacts); b2Array_Destroy(ref world.joints); + + for (int i = 0; i < world.islands.count; ++i) + { + b2Array_Destroy(ref world.islands.data[i].bodies); + b2Array_Destroy(ref world.islands.data[i].contacts); + b2Array_Destroy(ref world.islands.data[i].joints); + } + b2Array_Destroy(ref world.islands); // Destroy solver sets @@ -1268,9 +1276,9 @@ public static void b2World_Draw(B2WorldId worldId, B2DebugDraw draw) upperBound: new B2Vec2(-float.MaxValue, -float.MaxValue) ); - int islandBodyId = island.headBody; - while (islandBodyId != B2_NULL_INDEX) + for (int bodyIndex = 0; bodyIndex < island.bodies.count; ++bodyIndex) { + int islandBodyId = island.bodies.data[bodyIndex]; B2Body islandBody = b2Array_Get(ref world.bodies, islandBodyId); int shapeId = islandBody.headShapeId; while (shapeId != B2_NULL_INDEX) @@ -1280,8 +1288,6 @@ public static void b2World_Draw(B2WorldId worldId, B2DebugDraw draw) shapeCount += 1; shapeId = shape.nextShapeId; } - - islandBodyId = islandBody.islandNext; } if (shapeCount > 0) @@ -1825,6 +1831,7 @@ public static void b2World_DumpMemoryStats(B2WorldId worldId) writer.Write("solver sets: {0}\n", b2Array_ByteCount(ref world.solverSets)); writer.Write("joints: {0}\n", b2Array_ByteCount(ref world.joints)); writer.Write("contacts: {0}\n", b2Array_ByteCount(ref world.contacts)); + // todo account for body/contact/joint arrays in island writer.Write("islands: {0}\n", b2Array_ByteCount(ref world.islands)); writer.Write("shapes: {0}\n", b2Array_ByteCount(ref world.shapes)); writer.Write("chains: {0}\n", b2Array_ByteCount(ref world.chainShapes)); diff --git a/test/Box2D.NET.Test/B2ArrayTests.cs b/test/Box2D.NET.Test/B2ArrayTests.cs index 433c7265..2d0c88ac 100644 --- a/test/Box2D.NET.Test/B2ArrayTests.cs +++ b/test/Box2D.NET.Test/B2ArrayTests.cs @@ -5,6 +5,7 @@ using System; using System.Runtime.InteropServices; using NUnit.Framework; +using Box2D.NET.Test.Primitives; using static Box2D.NET.B2Arrays; using static Box2D.NET.B2Constants; @@ -12,34 +13,6 @@ namespace Box2D.NET.Test; public class B2ArrayTests { - public class DummyObject - { - public T Value; - - public DummyObject() - { - Value = default(T); - } - - public DummyObject(T value) - { - Value = value; - } - } - - public struct DummyStruct - { - public int Key; - public float Value; - - public DummyStruct(int key, float value) - { - Key = key; - Value = value; - } - } - - [Test] public void Test_b2Array_Destroy_Should_ClearDataAndResetCountAndCapacity() { @@ -397,7 +370,7 @@ public void Test_b2Array_Set() array.data[2] = new DummyObject(3); var expectedValue = new DummyObject(99); - + b2Array_Set(ref array, 1, expectedValue); Assert.That(array.data[1], Is.EqualTo(expectedValue)); // Verify that value at index 1 is updated to 99 @@ -407,7 +380,7 @@ public void Test_b2Array_Set() // Test invalid index (out of bounds) Assert.Throws((Action)(() => b2Array_Set(ref array, 5, expectedValue))); } - + [Test] public void Test_b2Array_RemoveSwap() { @@ -437,7 +410,7 @@ public void Test_b2Array_RemoveSwap() // Test invalid index (out of bounds) Assert.Throws((Action)(() => b2Array_RemoveSwap(ref array, 5))); } - + [Test] public void Test_b2Array_Pop() { @@ -460,4 +433,4 @@ public void Test_b2Array_Pop() Assert.Throws((Action)(() => b2Array_Pop(ref emptyArray))); } -} +} \ No newline at end of file diff --git a/test/Box2D.NET.Test/B2StackTests.cs b/test/Box2D.NET.Test/B2StackTests.cs new file mode 100644 index 00000000..00e6365b --- /dev/null +++ b/test/Box2D.NET.Test/B2StackTests.cs @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2025 Erin Catto +// SPDX-FileCopyrightText: 2025 Ikpil Choi(ikpil@naver.com) +// SPDX-License-Identifier: MIT + +using NUnit.Framework; +using Box2D.NET.Test.Primitives; +using static Box2D.NET.B2Arrays; + +namespace Box2D.NET.Test; + +public class B2StackTests +{ + [Test] + public void Test_b2StackArray_CreatePushReserveDestroy() + { + var stack = B2StackArrays.b2StackArray_Create(3); + var stackData = stack.stackData; + + B2StackArrays.b2StackArray_Push(ref stack, 1); + B2StackArrays.b2StackArray_Push(ref stack, 2); + B2StackArrays.b2StackArray_Push(ref stack, 3); + + Assert.That(stack.data, Is.SameAs(stackData)); + Assert.That(stack.count, Is.EqualTo(3)); + Assert.That(stack.capacity, Is.EqualTo(3)); + + B2StackArrays.b2StackArray_Push(ref stack, 4); + + Assert.That(stack.data, Is.Not.SameAs(stackData)); + Assert.That(stack.count, Is.EqualTo(4)); + Assert.That(stack.capacity, Is.EqualTo(6)); + Assert.That(stack.data[0], Is.EqualTo(1)); + Assert.That(stack.data[3], Is.EqualTo(4)); + + ref int value = ref B2StackArrays.b2StackArray_Get(ref stack, 2); + Assert.That(value, Is.EqualTo(3)); + + B2StackArrays.b2StackArray_Destroy(ref stack); + + Assert.That(stack.stackData, Is.Null); + Assert.That(stack.data, Is.Null); + Assert.That(stack.count, Is.EqualTo(0)); + Assert.That(stack.capacity, Is.EqualTo(0)); + } + + [Test] + public void Test_b2StackArray_RemoveUpdate() + { + var a = B2StackArrays.b2StackArray_Create(8); + var owners = b2Array_Create(); + + int n = 21; + for (int i = 0; i < n; ++i) + { + ref DummyClass dummyClass = ref b2Array_Add(ref owners); + dummyClass.index = i; + B2StackArrays.b2StackArray_Push(ref a, i); + } + + B2StackArrays.b2RemoveUpdate(ref a, ref owners, 0, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, 0).index = -1; + B2StackArrays.b2RemoveUpdate(ref a, ref owners, 3, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, 3).index = -1; + B2StackArrays.b2RemoveUpdate(ref a, ref owners, 8, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, 8).index = -1; + B2StackArrays.b2RemoveUpdate(ref a, ref owners, 5, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, 5).index = -1; + B2StackArrays.b2RemoveUpdate(ref a, ref owners, owners.count - 1, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, owners.count - 1).index = -1; + + int count = 0; + for (int i = 0; i < owners.count; ++i) + { + DummyClass dummyClass = b2Array_Get(ref owners, i); + if (dummyClass.index == -1) + { + continue; + } + + int indexA = B2StackArrays.b2StackArray_Get(ref a, dummyClass.index); + Assert.That(indexA, Is.EqualTo(i)); + count += 1; + } + + Assert.That(count, Is.EqualTo(a.count)); + + B2StackArrays.b2StackArray_Destroy(ref a); + b2Array_Destroy(ref owners); + } + + [Test] + public void Test_b2StackArray_RemoveUpdateAll() + { + var a = B2StackArrays.b2StackArray_Create(8); + var owners = b2Array_Create(); + + int n = 5; + for (int i = 0; i < n; ++i) + { + ref DummyClass dummyClass = ref b2Array_Add(ref owners); + dummyClass.index = i; + B2StackArrays.b2StackArray_Push(ref a, i); + } + + // Remove all elements one by one + for (int i = 0; i < n; ++i) + { + // Find a valid owner to remove + int removeIdx = -1; + for (int j = 0; j < owners.count; ++j) + { + if (b2Array_Get(ref owners, j).index != -1) + { + removeIdx = j; + break; + } + } + + if (removeIdx == -1) + { + break; + } + + B2StackArrays.b2RemoveUpdate(ref a, ref owners, removeIdx, x => x.index, (x, idx) => x.index = idx); + b2Array_Get(ref owners, removeIdx).index = -1; + } + + Assert.That(a.count, Is.EqualTo(0)); + + B2StackArrays.b2StackArray_Destroy(ref a); + b2Array_Destroy(ref owners); + } +} \ No newline at end of file diff --git a/test/Box2D.NET.Test/Primitives/DummyClass.cs b/test/Box2D.NET.Test/Primitives/DummyClass.cs new file mode 100644 index 00000000..b68898c5 --- /dev/null +++ b/test/Box2D.NET.Test/Primitives/DummyClass.cs @@ -0,0 +1,7 @@ +namespace Box2D.NET.Test.Primitives; + +public class DummyClass +{ + public int index; +} + diff --git a/test/Box2D.NET.Test/Primitives/DummyObject.cs b/test/Box2D.NET.Test/Primitives/DummyObject.cs new file mode 100644 index 00000000..9ca35139 --- /dev/null +++ b/test/Box2D.NET.Test/Primitives/DummyObject.cs @@ -0,0 +1,16 @@ +namespace Box2D.NET.Test.Primitives; + +public class DummyObject +{ + public T Value; + + public DummyObject() + { + Value = default(T); + } + + public DummyObject(T value) + { + Value = value; + } +} \ No newline at end of file diff --git a/test/Box2D.NET.Test/Primitives/DummyStruct.cs b/test/Box2D.NET.Test/Primitives/DummyStruct.cs new file mode 100644 index 00000000..4ccfb61e --- /dev/null +++ b/test/Box2D.NET.Test/Primitives/DummyStruct.cs @@ -0,0 +1,13 @@ +namespace Box2D.NET.Test.Primitives; + +public struct DummyStruct +{ + public int Key; + public float Value; + + public DummyStruct(int key, float value) + { + Key = key; + Value = value; + } +}