diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs
new file mode 100644
index 000000000..c79fa93dc
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs
@@ -0,0 +1,37 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.IO;
+using UnityEditor;
+using VisualPinball.Unity.Simulation;
+
+namespace VisualPinball.Unity.Editor
+{
+ ///
+ /// Opens the folder the writes to. Recording
+ /// itself is toggled on the .
+ ///
+ public static class SimulationTraceMenu
+ {
+ [MenuItem("Pinball/Editor/Open Simulation Trace Folder", false, 513)]
+ private static void OpenFolder()
+ {
+ var directory = SimulationTrace.DefaultOutputDirectory();
+ Directory.CreateDirectory(directory);
+ EditorUtility.RevealInFinder(directory);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs.meta
new file mode 100644
index 000000000..7c03799f5
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ca9b93f4c296f12501eaed5638eaa9cf
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs
new file mode 100644
index 000000000..1c81540ad
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs
@@ -0,0 +1,67 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NUnit.Framework;
+using Unity.Mathematics;
+using VisualPinball.Engine.Common;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// The broad phase queries with the volume a ball can reach within the
+ /// searched time, not with VP's full-step box.
+ ///
+ public class BallSweptBoundsTests
+ {
+ [Test]
+ public void SweptBoundsCoverTheSearchedTimeOnly()
+ {
+ var ball = new BallState { Radius = 25f, Position = new float3(100f, 200f, 25f), Velocity = new float3(60f, -30f, 0f) };
+
+ // one sub cycle: the ball moves 6 in x and -3 in y
+ var swept = ball.GetSweptAabb(PhysicsConstants.PhysFactor);
+ Assert.That(swept.Left, Is.EqualTo(100f - 25.05f).Within(1e-4f));
+ Assert.That(swept.Right, Is.EqualTo(106f + 25.05f).Within(1e-4f));
+ Assert.That(swept.Top, Is.EqualTo(197f - 25.05f).Within(1e-4f));
+ Assert.That(swept.Bottom, Is.EqualTo(200f + 25.05f).Within(1e-4f));
+ Assert.That(swept.ZLow, Is.EqualTo(25f - 25.05f).Within(1e-4f));
+ Assert.That(swept.ZHigh, Is.EqualTo(25f + 25.05f).Within(1e-4f));
+
+ // the end position of the motion is inside, VP's full-step reach is not
+ var end = ball.Position + ball.Velocity * PhysicsConstants.PhysFactor;
+ Assert.That(Contains(swept, end), Is.True);
+ Assert.That(Contains(swept, ball.Position + ball.Velocity), Is.False);
+ Assert.That(Contains(ball.Aabb, ball.Position + ball.Velocity), Is.True, "the conservative box still covers the full step");
+ }
+
+ private static bool Contains(in Aabb aabb, float3 p)
+ => p.x >= aabb.Left && p.x <= aabb.Right && p.y >= aabb.Top && p.y <= aabb.Bottom && p.z >= aabb.ZLow && p.z <= aabb.ZHigh;
+
+ [Test]
+ public void SweptBoundsOfARestingBallAreTheBallPlusMargin()
+ {
+ var ball = new BallState { Radius = 25f, Position = new float3(10f, 20f, 25f) };
+ var swept = ball.GetSweptAabb(PhysicsConstants.PhysFactor);
+ Assert.That(swept.Left, Is.EqualTo(10f - 25.05f).Within(1e-4f));
+ Assert.That(swept.Right, Is.EqualTo(10f + 25.05f).Within(1e-4f));
+ Assert.That(swept.Width, Is.EqualTo(ball.Aabb.Width).Within(1e-4f));
+
+ // a negative or zero search window degenerates to the same box
+ Assert.That(ball.GetSweptAabb(0f).Width, Is.EqualTo(swept.Width).Within(1e-4f));
+ Assert.That(ball.GetSweptAabb(-1f).Width, Is.EqualTo(swept.Width).Within(1e-4f));
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs.meta
new file mode 100644
index 000000000..1086f9b03
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f86227cc4417f41edb2400edddf29186
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs
new file mode 100644
index 000000000..511441c9a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs
@@ -0,0 +1,151 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using UnityEngine;
+using VisualPinball.Engine.VPT;
+using VisualPinball.Engine.VPT.Plunger;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// Collider bounds must contain everything the hit test can report, since the
+ /// broad phase only inflates the ball by its radius and the searched motion.
+ ///
+ public class ColliderBoundsTests
+ {
+ [TestCase(121f, 60f)] // right flipper
+ [TestCase(-121f, -60f)] // left flipper
+ [TestCase(170f, 200f)] // sweep across 180 degrees
+ [TestCase(30f, 30f)] // no sweep
+ public void FlipperBoundsContainBaseAndEndCircleAcrossTheSweep(float startAngle, float endAngle)
+ {
+ const float flipperRadius = 130f;
+ const float baseRadius = 21.5f;
+ const float endRadius = 13f;
+ var aabb = FlipperCollider.ComputeSweepBounds(flipperRadius, baseRadius, endRadius, startAngle, endAngle, 0f, 50f);
+
+ // base circle
+ Assert.That(aabb.Left, Is.LessThanOrEqualTo(-baseRadius));
+ Assert.That(aabb.Right, Is.GreaterThanOrEqualTo(baseRadius));
+ Assert.That(aabb.Top, Is.LessThanOrEqualTo(-baseRadius));
+ Assert.That(aabb.Bottom, Is.GreaterThanOrEqualTo(baseRadius));
+ Assert.That(aabb.ZLow, Is.EqualTo(0f));
+ Assert.That(aabb.ZHigh, Is.EqualTo(50f));
+
+ // end circle at every degree of the sweep
+ var from = math.min(startAngle, endAngle);
+ var to = math.max(startAngle, endAngle);
+ for (var deg = from; deg <= to; deg += 1f) {
+ var a = math.radians(deg);
+ var center = new float2(math.sin(a), -math.cos(a)) * flipperRadius;
+ Assert.That(aabb.Left, Is.LessThanOrEqualTo(center.x - endRadius), $"left at {deg} deg");
+ Assert.That(aabb.Right, Is.GreaterThanOrEqualTo(center.x + endRadius), $"right at {deg} deg");
+ Assert.That(aabb.Top, Is.LessThanOrEqualTo(center.y - endRadius), $"top at {deg} deg");
+ Assert.That(aabb.Bottom, Is.GreaterThanOrEqualTo(center.y + endRadius), $"bottom at {deg} deg");
+ }
+
+ // and not absurdly larger than the reach
+ var reach = flipperRadius + endRadius + 1f;
+ Assert.That(aabb.Width, Is.LessThanOrEqualTo(2f * reach));
+ Assert.That(aabb.Height, Is.LessThanOrEqualTo(2f * reach));
+ }
+
+ [TestCase(ColliderType.KickerCircle, ItemType.Kicker)]
+ [TestCase(ColliderType.TriggerCircle, ItemType.Trigger)]
+ public void KickerAndTriggerCircleBoundsCoverTheSphereCap(ColliderType type, ItemType itemType)
+ {
+ // a large round trigger, hit-tested against a sphere of 2.6 r centered
+ // 2.4 r below the top: the cap reaches 0.2 r above the cylinder
+ const float radius = 200f;
+ var center = new float2(500f, 500f);
+ var info = new ColliderInfo { ItemId = 1, ItemType = itemType };
+ var circle = new CircleCollider(center, radius, 0f, 50f, info, type);
+ Assert.That(circle.Bounds.Aabb.ZHigh, Is.EqualTo(50f + 0.2f * radius).Within(1e-4f));
+ Assert.That(new CircleCollider(center, radius, 0f, 50f, info).Bounds.Aabb.ZHigh, Is.EqualTo(50f).Within(1e-4f), "plain circles keep their cylinder");
+
+ // the transformed bounds (non-transformable colliders) reach as high, at every corner
+ var rotated = circle.GetTransformedAabb(float4x4.RotateZ(math.radians(37f)));
+ Assert.That(rotated.ZHigh, Is.EqualTo(50f + 0.2f * radius).Within(1e-3f));
+ Assert.That(rotated.ZLow, Is.EqualTo(0f).Within(1e-3f));
+
+ // a ball above the top, moving horizontally into the cap: the narrow phase
+ // reports a hit within the searched time, so the swept bounds must overlap
+ var insideOfs = new InsideOfs(Allocator.Temp);
+ try {
+ var ball = new BallState { Id = 7, Radius = 25f, Position = new float3(605f, 500f, 80f), Velocity = new float3(-60f, 0f, 0f) };
+ var collEvent = new CollisionEventData();
+ var hitTime = circle.HitTestBasicRadius(ref collEvent, ref insideOfs, in ball, 0.1f, false, false, false);
+ Assert.That(hitTime, Is.GreaterThanOrEqualTo(0f).And.LessThanOrEqualTo(0.1f), "the cap is hit within the search window");
+ Assert.That(circle.Bounds.Aabb.IntersectRect(ball.GetSweptAabb(0.1f)), Is.True, "bounds must admit the ball the hit test can hit");
+ } finally {
+ insideOfs.Dispose();
+ }
+ }
+
+ [Test]
+ public void PlungerBoundsFollowThePlungerHeight()
+ {
+ var go = new GameObject("plunger");
+ try {
+ var plunger = go.AddComponent();
+ var collider = go.AddComponent();
+ plunger.Position = new Vector3(450f, 2000f, 100f);
+ var info = new ColliderInfo { ItemId = 1, ItemType = ItemType.Plunger };
+ var plungerCollider = new PlungerCollider(plunger, collider, info);
+ Assert.That(plungerCollider.Bounds.Aabb.ZLow, Is.EqualTo(100f).Within(1e-3f));
+ Assert.That(plungerCollider.Bounds.Aabb.ZHigh, Is.EqualTo(100f + Plunger.PlungerHeight).Within(1e-3f));
+ Assert.That(plungerCollider.LineSegEnd.ZLow, Is.EqualTo(plungerCollider.Bounds.Aabb.ZLow).Within(1e-3f), "line colliders and bounds share the height range");
+ Assert.That(plungerCollider.LineSegEnd.ZHigh, Is.EqualTo(plungerCollider.Bounds.Aabb.ZHigh).Within(1e-3f));
+ } finally {
+ Object.DestroyImmediate(go);
+ }
+ }
+
+ [Test]
+ public void MeshColliderGenerationSkipsDegenerateTriangles()
+ {
+ var nonTransformable = new NativeParallelHashMap(1, Allocator.Persistent);
+ var references = new ColliderReference(ref nonTransformable, Allocator.Persistent);
+ var vertices = new NativeArray(new[] {
+ new Vector3(0f, 0f, 0f), new Vector3(100f, 0f, 0f), new Vector3(0f, 100f, 0f),
+ new Vector3(0f, 0f, 50f), new Vector3(0f, 0f, 100f), // collinear with vertex 0
+ }, Allocator.Persistent);
+ var indices = new NativeArray(new[] { 0, 1, 2, 0, 3, 4 }, Allocator.Persistent);
+ try {
+ var info = new ColliderInfo { ItemId = 1, ItemType = ItemType.Primitive };
+ ColliderUtils.GenerateCollidersFromMesh(in vertices, in indices, float4x4.identity, info, ref references, true);
+ Assert.That(references.Count, Is.EqualTo(1), "only the proper triangle gets a collider");
+ Assert.That(ColliderUtils.IsDegenerate(vertices[0], vertices[3], vertices[4]), Is.True);
+ Assert.That(ColliderUtils.IsDegenerate(vertices[0], vertices[1], vertices[2]), Is.False);
+
+ // the test is scale independent: a 2 mm triangle in meters is a valid triangle,
+ // a collinear one in meters is not, and a zero-length edge is degenerate
+ var m = new float3(0.1f, 0.2f, 0f);
+ Assert.That(ColliderUtils.IsDegenerate(m, m + new float3(0.002f, 0f, 0f), m + new float3(0f, 0.002f, 0f)), Is.False);
+ Assert.That(ColliderUtils.IsDegenerate(m, m + new float3(0.002f, 0f, 0f), m + new float3(0.004f, 0f, 0f)), Is.True);
+ Assert.That(ColliderUtils.IsDegenerate(m, m, m + new float3(0f, 0.002f, 0f)), Is.True);
+ } finally {
+ indices.Dispose();
+ vertices.Dispose();
+ references.Dispose();
+ nonTransformable.Dispose();
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs.meta
new file mode 100644
index 000000000..839625fc4
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: fc7a75c04d6a5d0247a782018e587f0a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs
new file mode 100644
index 000000000..ee65cf220
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs
@@ -0,0 +1,129 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Engine.Common;
+using VisualPinball.Engine.VPT;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// A ball that something pressed into the floor must come back out, and a
+ /// collider embedded in the ball from above must not push it into the floor.
+ ///
+ public class ContactDepenetrationTests
+ {
+ private static readonly float3 Gravity = new(0f, 0f, -0.8f);
+
+ [Test]
+ public void FloorContactRecoversAnEmbeddedBall()
+ {
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0f, 0f, 20f) };
+ var contact = new CollisionEventData { ColliderId = 3, HitNormal = new float3(0f, 0f, 1f), HitDistance = -5f };
+
+ BallCollider.HandleStaticContact(ref ball, in contact, 0.1f, PhysicsConstants.PhysFactor, in Gravity, float3.zero);
+
+ Assert.That(ball.Position.z, Is.GreaterThan(24.9f), "a sustained floor contact lifts the ball out");
+ }
+
+ [Test]
+ public void CeilingContactDoesNotShoveTheBallDown()
+ {
+ // a cover 10 units lower than the ball is tall, embedded from above
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0f, 0f, 25f) };
+ var contact = new CollisionEventData { ColliderId = 3, HitNormal = new float3(0f, 0f, -1f), HitDistance = -10f };
+
+ BallCollider.HandleStaticContact(ref ball, in contact, 0.1f, PhysicsConstants.PhysFactor, in Gravity, float3.zero);
+
+ Assert.That(ball.Position.z, Is.EqualTo(25f).Within(1e-4f), "no recovery along gravity");
+ }
+
+ [Test]
+ public void SlopedSupportContactStillRecovers()
+ {
+ // a 35 degree ramp: its normal opposes gravity, so the ball still recovers along it
+ var normal = math.normalize(new float3(0f, math.sin(math.radians(35f)), math.cos(math.radians(35f))));
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0f, 0f, 25f) };
+ var contact = new CollisionEventData { ColliderId = 3, HitNormal = normal, HitDistance = -3f };
+
+ BallCollider.HandleStaticContact(ref ball, in contact, 0.1f, PhysicsConstants.PhysFactor, in Gravity, float3.zero);
+
+ Assert.That(math.dot(ball.Position - new float3(0f, 0f, 25f), normal), Is.GreaterThan(2.9f), "recovered along the ramp normal");
+ }
+
+ [Test]
+ public void WallContactStillRecoversSideways()
+ {
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0f, 0f, 25f) };
+ var contact = new CollisionEventData { ColliderId = 3, HitNormal = new float3(1f, 0f, 0f), HitDistance = -2f };
+
+ BallCollider.HandleStaticContact(ref ball, in contact, 0.1f, PhysicsConstants.PhysFactor, in Gravity, float3.zero);
+
+ Assert.That(ball.Position.x, Is.GreaterThan(1.9f), "a wall contact still pushes the ball out of the wall");
+ }
+
+ [TestCase(ItemType.Playfield, 25f)]
+ [TestCase(ItemType.Primitive, 20f)]
+ public void PlayfieldFloorCollisionPushesTheBallAllTheWayOut(ItemType itemType, float expectedZ)
+ {
+ // floor triangle at z = 0 with an upward normal, large enough to contain the ball
+ var collider = new TriangleCollider(new float3(-1000f, -1000f, 0f), new float3(-1000f, 1000f, 0f),
+ new float3(1000f, -1000f, 0f), new ColliderInfo { ItemId = 1, ItemType = itemType });
+ Assert.That(collider.Normal().z, Is.GreaterThan(0.99f));
+
+ var state = new PhysicsState();
+ var events = new NativeQueue(Allocator.Temp);
+ try {
+ var writer = events.AsParallelWriter();
+ var collision = new CollisionEventData { ColliderId = 0, HitNormal = new float3(0f, 0f, 1f), HitDistance = -10f };
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(0f, 0f, 15f), Velocity = new float3(2f, 0f, -0.5f) };
+
+ collider.Collide(ref ball, ref writer, in collision, ref state);
+
+ // the playfield pushes the ball fully out; other meshes only by DispLimit per impact
+ Assert.That(ball.Position.z, Is.EqualTo(expectedZ).Within(0.01f));
+ Assert.That(ball.Velocity.z, Is.GreaterThanOrEqualTo(0f), "the impact reflects the downward velocity");
+ } finally {
+ events.Dispose();
+ }
+ }
+
+ [Test]
+ public void PlayfieldWallTriangleDoesNotPushSidewaysBeyondTheImpactLimit()
+ {
+ // a vertical playfield triangle (a cutout wall) with a +x normal
+ var collider = new TriangleCollider(new float3(0f, -1000f, -100f), new float3(0f, -1000f, 1000f),
+ new float3(0f, 1000f, -100f), new ColliderInfo { ItemId = 1, ItemType = ItemType.Playfield });
+ Assert.That(collider.Normal().x, Is.GreaterThan(0.99f));
+
+ var state = new PhysicsState();
+ var events = new NativeQueue(Allocator.Temp);
+ try {
+ var writer = events.AsParallelWriter();
+ var collision = new CollisionEventData { ColliderId = 0, HitNormal = new float3(1f, 0f, 0f), HitDistance = -10f };
+ var ball = new BallState { Id = 1, Mass = 1f, Radius = 25f, Position = new float3(15f, 0f, 25f), Velocity = new float3(-1f, 0f, 0f) };
+
+ collider.Collide(ref ball, ref writer, in collision, ref state);
+
+ Assert.That(ball.Position.x, Is.EqualTo(15f + PhysicsConstants.DispLimit).Within(0.01f));
+ } finally {
+ events.Dispose();
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs.meta
new file mode 100644
index 000000000..cfe9d4bc6
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b5aff6dbaa149780bbc4841472137321
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs
new file mode 100644
index 000000000..dc9905cda
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs
@@ -0,0 +1,116 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// A ball created inside a kicker must end up captured at the kicker's capture
+ /// position, so that the next kick launches it from there.
+ ///
+ public class KickerCaptureTests
+ {
+ private const int KickerId = 42;
+ private static readonly float3 CreationPosition = new(769f, 1868f, 25f);
+
+ private static KickerStaticState SunkenKicker() => new() {
+ Center = new float2(769f, 1868f),
+ ZLow = -108f,
+ FallIn = true,
+ FallThrough = false,
+ HitAccuracy = 1f,
+ Scatter = 0f,
+ LegacyMode = true,
+ };
+
+ private static void CreateBall(ref BallState ball, ref KickerCollisionState collState, in KickerStaticState staticState, bool staleMembership = false)
+ {
+ var events = new NativeQueue(Allocator.Temp);
+ var insideOfs = new InsideOfs(Allocator.Temp);
+ try {
+ if (staleMembership) {
+ insideOfs.SetInsideOf(KickerId, ball.Id);
+ }
+ var writer = events.AsParallelWriter();
+ ball.CollisionEvent.HitFlag = true; // as KickerApi.CreateBall does
+ var collEvent = ball.CollisionEvent;
+ KickerCollider.Collide(new float3(staticState.Center, staticState.ZLow), ref ball, ref writer,
+ ref insideOfs, ref collState, in staticState, default, in collEvent, KickerId, true);
+ } finally {
+ insideOfs.Dispose();
+ events.Dispose();
+ }
+ }
+
+ [Test]
+ public void CreatedBallIsCapturedAtTheKickerHeight()
+ {
+ var ball = new BallState { Id = 7, Radius = 25f, Mass = 1f, Position = CreationPosition, Velocity = new float3(0.1f, 0f, 0f) };
+ var collState = new KickerCollisionState();
+
+ CreateBall(ref ball, ref collState, SunkenKicker());
+
+ Assert.That(collState.BallId, Is.EqualTo(7));
+ Assert.That(ball.IsFrozen, Is.True);
+ Assert.That(ball.Position.z, Is.EqualTo(-108f + 25f).Within(0.01f), "held at the kicker, not at the creation height");
+ Assert.That(math.length(ball.Velocity), Is.EqualTo(0f));
+ }
+
+ [Test]
+ public void StaleReferenceToTheSameBallIdDoesNotBlockTheCapture()
+ {
+ // the kicker still references a ball id that a previous ball had, and the new ball got that id
+ var ball = new BallState { Id = 7, Radius = 25f, Mass = 1f, Position = CreationPosition };
+ var collState = new KickerCollisionState { BallId = 7 };
+
+ CreateBall(ref ball, ref collState, SunkenKicker());
+
+ Assert.That(collState.BallId, Is.EqualTo(7));
+ Assert.That(ball.IsFrozen, Is.True);
+ Assert.That(ball.Position.z, Is.EqualTo(-108f + 25f).Within(0.01f));
+ }
+
+ [Test]
+ public void StaleVolumeMembershipDoesNotBlockTheCapture()
+ {
+ // the kicker's volume set still lists the new ball's id from a previous ball
+ var ball = new BallState { Id = 7, Radius = 25f, Mass = 1f, Position = CreationPosition };
+ var collState = new KickerCollisionState();
+
+ CreateBall(ref ball, ref collState, SunkenKicker(), staleMembership: true);
+
+ Assert.That(collState.BallId, Is.EqualTo(7));
+ Assert.That(ball.IsFrozen, Is.True);
+ Assert.That(ball.Position.z, Is.EqualTo(-108f + 25f).Within(0.01f));
+ }
+
+ [Test]
+ public void AnotherHeldBallKeepsTheKicker()
+ {
+ var ball = new BallState { Id = 7, Radius = 25f, Mass = 1f, Position = CreationPosition };
+ var collState = new KickerCollisionState { BallId = 3 };
+
+ CreateBall(ref ball, ref collState, SunkenKicker());
+
+ Assert.That(collState.BallId, Is.EqualTo(3), "the held ball is not displaced");
+ Assert.That(ball.IsFrozen, Is.False);
+ Assert.That(ball.Position.z, Is.EqualTo(25f).Within(0.01f), "the new ball stays where it was created");
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs.meta
new file mode 100644
index 000000000..373adca4d
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c781cfaa911e41c59d4b824d581f3027
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs
new file mode 100644
index 000000000..b084be56e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs
@@ -0,0 +1,162 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using NativeTrees;
+using NUnit.Framework;
+using Unity.Collections;
+using Unity.Mathematics;
+using VisualPinball.Engine.Common;
+using VisualPinball.Engine.VPT;
+
+namespace VisualPinball.Unity.Test
+{
+ ///
+ /// Moving kinematic items are excluded from the kinematic octree and
+ /// broad-phased directly, so the octree is rebuilt when an item starts or
+ /// stops moving instead of on every pose update.
+ ///
+ public class KinematicBroadPhaseTests
+ {
+ private const int ItemId = 1;
+
+ [Test]
+ public void MovingItemIsSkippedByOctreeAndFoundDirectly()
+ {
+ var nonTransformable = new NativeParallelHashMap(1, Allocator.Persistent);
+ var kinematicTransforms = new NativeParallelHashMap(1, Allocator.Persistent);
+ var targets = new NativeParallelHashMap(1, Allocator.Persistent);
+ var outOfOctree = new NativeParallelHashSet(1, Allocator.Persistent);
+ var itemBounds = new NativeParallelHashMap(1, Allocator.Persistent);
+ var velocities = new NativeParallelHashMap(1, Allocator.Persistent);
+ var overlaps = new NativeParallelHashSet(8, Allocator.Persistent);
+ var octree = new NativeOctree(new AABB(new float3(-2000f), new float3(2000f)), 16, 4, Allocator.Persistent);
+ var references = new ColliderReference(ref nonTransformable, Allocator.Persistent, true);
+ var lookups = default(NativeParallelHashMap);
+ var matrix = float4x4.Translate(new float3(100f, 0f, 0f));
+ try {
+ // a 100 x 100 floor centered at x = 100, at z = 0
+ references.Add(new TriangleCollider(new float3(-50f, -50f, 0f), new float3(-50f, 50f, 0f),
+ new float3(50f, -50f, 0f), new ColliderInfo { ItemId = ItemId, ItemType = ItemType.Primitive }), matrix);
+ using var current = new NativeColliders(ref references, Allocator.Persistent);
+ kinematicTransforms.Add(ItemId, matrix);
+ targets.Add(ItemId, matrix);
+ lookups = references.CreateLookup(Allocator.Persistent);
+ references.TransformToIdentity(ref kinematicTransforms);
+ using var identity = new NativeColliders(ref references, Allocator.Persistent);
+
+ var state = new PhysicsState {
+ KinematicColliders = current,
+ KinematicCollidersAtIdentity = identity,
+ KinematicTransforms = kinematicTransforms,
+ KinematicTargetTransforms = targets,
+ KinematicColliderLookups = lookups,
+ KinematicVelocities = velocities,
+ KinematicItemsOutOfOctree = outOfOctree,
+ KinematicMovingItemBounds = itemBounds,
+ };
+ var ballOnFloor = new BallState { Id = 7, Radius = 25f, Position = new float3(90f, -10f, 25f) };
+ var ballFarAway = new BallState { Id = 8, Radius = 25f, Position = new float3(900f, 900f, 25f) };
+ const int colliderId = 0;
+
+ // idle item: found through the octree, the direct pass adds nothing
+ PhysicsKinematics.RebuildOctree(ref octree, ref state);
+ PhysicsStaticBroadPhase.FindOverlaps(in octree, in ballOnFloor, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Contains(colliderId), Is.True, "idle item must be in the octree");
+ overlaps.Clear();
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Count(), Is.Zero, "idle item must not be tested directly");
+
+ // moving item: skipped by the octree, found by the direct pass
+ outOfOctree.Add(ItemId);
+ PhysicsKinematics.RebuildOctree(ref octree, ref state);
+ PhysicsStaticBroadPhase.FindOverlaps(in octree, in ballOnFloor, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Count(), Is.Zero, "moving item must be excluded from the octree");
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Contains(colliderId), Is.True, "moving item must be found by the direct pass");
+
+ overlaps.Clear();
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballFarAway, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Count(), Is.Zero, "a ball away from the moving item must not get its colliders");
+
+ // item bounds are the union of the collider bounds at the current pose
+ // and reject the far ball with a single test
+ var colliderIds = state.KinematicColliderLookups[ItemId];
+ PhysicsKinematics.UpdateMovingItemBounds(ref state, ItemId, in colliderIds);
+ Assert.That(itemBounds.TryGetValue(ItemId, out var bounds), Is.True);
+ Assert.That(bounds.Left, Is.EqualTo(50f).Within(1e-4f));
+ Assert.That(bounds.Right, Is.EqualTo(150f).Within(1e-4f));
+ Assert.That(bounds.IntersectRect(ballOnFloor.GetSweptAabb(PhysicsConstants.PhysFactor)), Is.True);
+ Assert.That(bounds.IntersectRect(ballFarAway.GetSweptAabb(PhysicsConstants.PhysFactor)), Is.False);
+ overlaps.Clear();
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Contains(colliderId), Is.True, "item bounds must not reject a ball that overlaps the item");
+
+ // a surface approaching the ball within the tick is admitted even when the
+ // ball's own bounds do not yet touch the collider: the floor at z = 0 is
+ // reported moving up at 10 units per step, the ball hovers 0.5 above it
+ var hoveringBall = new BallState { Id = 9, Radius = 25f, Position = new float3(100f, 0f, 25.5f) };
+ overlaps.Clear();
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in hoveringBall, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Count(), Is.Zero, "without surface velocity a hovering ball is out of reach");
+ velocities.Add(ItemId, new KinematicVelocityState {
+ LinearVelocity = new float3(0f, 0f, 10f),
+ Pivot = matrix.c3.xyz,
+ });
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in hoveringBall, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Contains(colliderId), Is.True, "surface velocity must extend the query by one tick of motion");
+ velocities.Remove(ItemId);
+
+ // bounds are not maintained for idle items
+ outOfOctree.Remove(ItemId);
+ itemBounds.Remove(ItemId);
+ PhysicsKinematics.UpdateMovingItemBounds(ref state, ItemId, in colliderIds);
+ Assert.That(itemBounds.ContainsKey(ItemId), Is.False);
+ } finally {
+ if (lookups.IsCreated) {
+ using (var enumerator = lookups.GetEnumerator()) {
+ while (enumerator.MoveNext()) {
+ enumerator.Current.Value.Dispose();
+ }
+ }
+ lookups.Dispose();
+ }
+ references.Dispose();
+ octree.Dispose();
+ overlaps.Dispose();
+ velocities.Dispose();
+ itemBounds.Dispose();
+ outOfOctree.Dispose();
+ targets.Dispose();
+ kinematicTransforms.Dispose();
+ nonTransformable.Dispose();
+ }
+ }
+
+ [Test]
+ public void UncreatedMovingItemSetTreatsEveryItemAsIdle()
+ {
+ var overlaps = new NativeParallelHashSet(8, Allocator.Persistent);
+ try {
+ var state = new PhysicsState();
+ var ball = new BallState { Id = 7, Radius = 25f, Position = new float3(90f, -10f, 25f) };
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ball, ref overlaps, PhysicsConstants.PhysFactor);
+ Assert.That(overlaps.Count(), Is.Zero);
+ } finally {
+ overlaps.Dispose();
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs.meta
new file mode 100644
index 000000000..2f755e34e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e60db1bcd72c4978ac6ffee039175f51
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
index 1ee108d3f..c87baf0d6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/MagnetPhysicsTests.cs
@@ -1925,6 +1925,8 @@ internal sealed class PhysicsStateHarness : IDisposable
private bool _swapBallCollisionHandling;
private NativeParallelHashMap> _elasticityLuts;
private NativeParallelHashMap> _frictionLuts;
+ private NativeParallelHashSet _kinematicItemsOutOfOctree;
+ private NativeParallelHashMap _kinematicMovingItemBounds;
internal PhysicsStateHarness()
{
@@ -1951,7 +1953,8 @@ internal PhysicsState CreateState()
ref _hitTargetStates, ref _kickerStates, ref MagnetStates, ref _plungerStates, ref _spinnerStates,
ref SpringHingeStates,
ref _surfaceStates, ref _turntableStates, ref _triggerStates, ref _disabledCollisionItems, ref _swapBallCollisionHandling,
- ref _elasticityLuts, ref _frictionLuts, ref KinematicVelocities);
+ ref _elasticityLuts, ref _frictionLuts, ref KinematicVelocities,
+ ref _kinematicItemsOutOfOctree, ref _kinematicMovingItemBounds);
}
internal void SetStaticColliders(ref ColliderReference colliders)
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs
index 9c7e0a87a..46ee9c118 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs
@@ -27,10 +27,10 @@ public void DynamicBroadPhaseRefitsAfterAStationaryBallIsAccelerated()
try {
balls.Add(1, CreateBall(1, float3.zero, float3.zero));
balls.Add(2, CreateBall(2, new float3(20f, 0f, 0f), float3.zero));
- PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls);
+ PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls, 0.5f);
var other = balls[2];
- PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls);
+ PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls, 0.5f);
Assert.That(overlaps.Contains(1), Is.False);
ref var accelerated = ref balls.GetValueByRef(1);
@@ -38,7 +38,7 @@ public void DynamicBroadPhaseRefitsAfterAStationaryBallIsAccelerated()
Assert.That(PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref octree,
ref balls, 0.5f), Is.True);
- PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls);
+ PhysicsDynamicBroadPhase.FindOverlaps(in octree, in other, ref overlaps, ref balls, 0.5f);
Assert.That(overlaps.Contains(1), Is.True,
"the second ball must query the accelerated ball's refitted swept bounds");
} finally {
@@ -48,6 +48,34 @@ public void DynamicBroadPhaseRefitsAfterAStationaryBallIsAccelerated()
}
}
+ [Test]
+ public void DynamicBroadPhaseDoesNotRefitFromFloatNoiseAtPlayfieldCoordinates()
+ {
+ // the containment check recomputes the end of the motion from the displaced
+ // position; at playfield coordinates that differs from the inserted end by a
+ // few ulps and must not count as an escape
+ var balls = new NativeParallelHashMap(1, Allocator.Temp);
+ NativeTrees.AABB bounds = new Aabb(new float3(-100f, -100f, -100f), new float3(3000f, 3000f, 300f));
+ var octree = new NativeOctree(bounds, 8, 3, Allocator.Temp);
+ try {
+ var velocity = new float3(41.794586f, -37.3f, 0.7f);
+ balls.Add(1, new BallState { Id = 1, Position = new float3(1992.2317f, 1731.77f, 25.0001f), Velocity = velocity, Radius = 25f, Mass = 1f });
+ const float dTime = 0.1f;
+ PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls, dTime);
+
+ var remaining = dTime;
+ foreach (var hitTime in new[] { 0.05084206f, 0.013f, 0.02f, 0.0071f }) {
+ ref var ball = ref balls.GetValueByRef(1);
+ ball.Position += velocity * hitTime;
+ remaining -= hitTime;
+ Assert.That(PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref octree, ref balls, remaining), Is.False, $"after {hitTime}");
+ }
+ } finally {
+ octree.Dispose();
+ balls.Dispose();
+ }
+ }
+
[Test]
public void DynamicBroadPhaseDoesNotRefitWhileInsertedBoundsContainMotion()
{
@@ -56,7 +84,7 @@ public void DynamicBroadPhaseDoesNotRefitWhileInsertedBoundsContainMotion()
var octree = new NativeOctree(bounds, 8, 3, Allocator.Temp);
try {
balls.Add(1, CreateBall(1, float3.zero, new float3(2f, 0f, 0f)));
- PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls);
+ PhysicsDynamicBroadPhase.RebuildOctree(ref octree, ref balls, 0.5f);
Assert.That(PhysicsDynamicBroadPhase.RebuildIfMotionEscapes(ref octree,
ref balls, 0.1f), Is.False);
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/KickerTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/KickerTests.cs
index 194684066..855d7d5f6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/KickerTests.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Test/VPT/KickerTests.cs
@@ -77,5 +77,60 @@ public void ShouldUseRuntimeKickDirectionConventionForPreview()
Assert.That(velocity.z, Is.EqualTo(4f).Within(0.001f));
}
+ [Test]
+ public void ShouldCreateTheBallAtTheKickerHeight()
+ {
+ // a trough exit kicker sits below the playfield; its ball spawns down there, like in VP
+ var playfieldGo = new GameObject("Playfield");
+ var kickerGo = new GameObject("Kicker");
+ try {
+ playfieldGo.AddComponent();
+ kickerGo.transform.SetParent(playfieldGo.transform, false);
+ kickerGo.transform.localPosition = new Vector3(0.4f, -0.05f, -1f);
+ var kicker = kickerGo.AddComponent();
+
+ var expected = kicker.PositionInPlayfield;
+ var creation = kicker.GetBallCreationPosition();
+
+ Assert.That(expected.z, Is.Not.EqualTo(0f).Within(0.001f), "the kicker is not at playfield level");
+ Assert.That(creation.X, Is.EqualTo(expected.x).Within(0.001f));
+ Assert.That(creation.Y, Is.EqualTo(expected.y).Within(0.001f));
+ Assert.That(creation.Z, Is.EqualTo(expected.z).Within(0.001f));
+ } finally {
+ Object.DestroyImmediate(playfieldGo);
+ }
+ }
+
+ [Test]
+ public void ShouldHoldTheBallWhereTheColliderIsForAGroupedKicker()
+ {
+ var playfieldGo = new GameObject("Playfield");
+ var groupGo = new GameObject("Group");
+ var kickerGo = new GameObject("Kicker");
+ try {
+ playfieldGo.AddComponent();
+ groupGo.transform.SetParent(playfieldGo.transform, false);
+ groupGo.transform.localPosition = new Vector3(0.1f, 0.02f, -0.3f);
+ kickerGo.transform.SetParent(groupGo.transform, false);
+ kickerGo.transform.localPosition = new Vector3(0.05f, -0.05f, -0.2f);
+ var kicker = kickerGo.AddComponent();
+ kickerGo.AddComponent();
+
+ var inPlayfield = kicker.PositionInPlayfield;
+ var local = kicker.Position;
+ Assert.That(math.distance((float3)inPlayfield, (float3)local), Is.GreaterThan(1f), "the group offsets the kicker");
+
+ var state = kicker.CreateState();
+ try {
+ Assert.That(state.Static.Center.x, Is.EqualTo(inPlayfield.x).Within(0.001f));
+ Assert.That(state.Static.Center.y, Is.EqualTo(inPlayfield.y).Within(0.001f));
+ Assert.That(state.Static.ZLow, Is.EqualTo(inPlayfield.z).Within(0.001f));
+ } finally {
+ state.Dispose();
+ }
+ } finally {
+ Object.DestroyImmediate(playfieldGo);
+ }
+ }
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
index da59eb003..8d5c6ead7 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs
@@ -24,20 +24,104 @@
namespace VisualPinball.Unity
{
+ ///
+ /// Per-update work counters of the physics cycle, for diagnostics. Reset at
+ /// the start of every .
+ ///
+ public struct PhysicsCounters
+ {
+ /// Inner cycle iterations (hit-time sub steps) across all ticks of the update.
+ public int Iterations;
+ /// Static and kinematic collider hit tests.
+ public int HitTests;
+ /// Ball-ball hit tests.
+ public int BallTests;
+ /// Contacts handed to the contact solver.
+ public int Contacts;
+ /// Octree objects visited (bounds tests) by the static and kinematic broad phases.
+ public int BroadPhaseVisits;
+ /// Most collider hit tests one ball needed in one iteration.
+ public int MaxBallHitTests;
+ public int MaxBallId;
+ public float3 MaxBallPosition;
+ public int Triangle;
+ public int Line3D;
+ public int Line;
+ public int Point;
+ public int Plane;
+ public int Circle;
+ public int Flipper;
+ public int Other;
+ /// Most negative hit distance (deepest penetration) reported by any hit or contact in the update.
+ public float DeepestHitDistance;
+ public int DeepestHitColliderId;
+ public int DeepestHitItemId;
+ public int DeepestHitType;
+ public int DeepestHitIsContact;
+ public int DeepestHitBallId;
+ public float3 DeepestHitNormal;
+ public float3 DeepestHitBallPosition;
+ public float3 DeepestHitBallVelocity;
+
+ internal void RecordHit(ref PhysicsState state, in BallState ball, in CollisionEventData collEvent)
+ {
+ if (collEvent.HitDistance >= DeepestHitDistance) {
+ return;
+ }
+ DeepestHitDistance = collEvent.HitDistance;
+ DeepestHitColliderId = collEvent.ColliderId;
+ DeepestHitIsContact = collEvent.IsContact ? 1 : 0;
+ DeepestHitBallId = ball.Id;
+ DeepestHitNormal = collEvent.HitNormal;
+ DeepestHitBallPosition = ball.Position;
+ DeepestHitBallVelocity = ball.Velocity;
+ if (collEvent.ColliderId >= 0) {
+ ref var colliders = ref (collEvent.IsKinematic ? ref state.KinematicColliders : ref state.Colliders);
+ ref var header = ref state.GetColliderHeader(ref colliders, collEvent.ColliderId);
+ DeepestHitItemId = header.ItemId;
+ DeepestHitType = (int)header.Type;
+ } else {
+ DeepestHitItemId = collEvent.BallId;
+ DeepestHitType = -1;
+ }
+ }
+
+ internal void CountHitTest(ColliderType type)
+ {
+ HitTests++;
+ switch (type) {
+ case ColliderType.Triangle: Triangle++; break;
+ case ColliderType.Line3D: Line3D++; break;
+ case ColliderType.Line:
+ case ColliderType.LineZ:
+ case ColliderType.LineSlingShot: Line++; break;
+ case ColliderType.Point: Point++; break;
+ case ColliderType.Plane: Plane++; break;
+ case ColliderType.Circle:
+ case ColliderType.Bumper:
+ case ColliderType.KickerCircle:
+ case ColliderType.TriggerCircle: Circle++; break;
+ case ColliderType.Flipper: Flipper++; break;
+ default: Other++; break;
+ }
+ }
+ }
+
public struct PhysicsCycle : IDisposable
{
private NativeList _contacts;
-
private static readonly ProfilerMarker PerfMarker = new("PhysicsCycle");
private static readonly ProfilerMarker PerfMarkerDisplacement = new("Displacement");
private static readonly ProfilerMarker PerfMarkerCollision = new("Collision");
private static readonly ProfilerMarker PerfMarkerContacts = new("Contacts");
internal int DynamicBroadPhaseRefitCount { get; private set; }
+ internal PhysicsCounters Counters;
public PhysicsCycle(Allocator a)
{
_contacts = new NativeList(a);
DynamicBroadPhaseRefitCount = 0;
+ Counters = default;
}
internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet overlappingColliders, ref NativeOctree kinematicOctree, ref NativeOctree ballOctree, float dTime)
@@ -47,9 +131,10 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
// rebuild octree of ball-to-ball collision (clear + re-insert, no alloc)
// it's okay to have this code outside of the inner loop, as the ball hitrects already include the maximum distance they can travel in that timespan
- PhysicsDynamicBroadPhase.RebuildOctree(ref ballOctree, ref state.Balls);
+ PhysicsDynamicBroadPhase.RebuildOctree(ref ballOctree, ref state.Balls, dTime);
while (dTime > 0) {
+ Counters.Iterations++;
var hitTime = dTime; // begin time search from now ... until delta ends
var mechanismStopTime = -1f;
@@ -72,12 +157,15 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
// init contacts and event
ball.CollisionEvent.ClearCollider(hitTime); // search upto current hit time
- // hit testing (overlappingColliders is cleared in broad phase)
- PhysicsStaticBroadPhase.FindOverlaps(in state.Octree, in ball, ref overlappingColliders);
- PhysicsStaticNarrowPhase.FindNextCollision(ref state.Colliders, ref ball, ref overlappingColliders, ref _contacts, ref state);
+ // hit testing (overlappingColliders is cleared in broad phase); the
+ // broad phase covers the same time window the narrow phase searches
+ var hitTestsBefore = Counters.HitTests;
+ PhysicsStaticBroadPhase.FindOverlaps(in state.Octree, in ball, ref overlappingColliders, hitTime, ref Counters);
+ PhysicsStaticNarrowPhase.FindNextCollision(ref state.Colliders, ref ball, ref overlappingColliders, ref _contacts, ref state, ref Counters);
- PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders);
- PhysicsStaticNarrowPhase.FindNextCollision(ref state.KinematicColliders, ref ball, ref overlappingColliders, ref _contacts, ref state);
+ PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders, hitTime, ref Counters);
+ PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ball, ref overlappingColliders, hitTime);
+ PhysicsStaticNarrowPhase.FindNextCollision(ref state.KinematicColliders, ref ball, ref overlappingColliders, ref _contacts, ref state, ref Counters);
RecordSpringHingeHitTime(ref springHingeHitTime, in ball, ref state);
// no negative time allowed
@@ -85,13 +173,21 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
ball.CollisionEvent.ClearCollider();
}
- PhysicsDynamicBroadPhase.FindOverlaps(in ballOctree, in ball, ref overlappingColliders, ref state.Balls);
- PhysicsDynamicNarrowPhase.FindNextCollision(ref ball, ref overlappingColliders, ref _contacts, ref state);
+ PhysicsDynamicBroadPhase.FindOverlaps(in ballOctree, in ball, ref overlappingColliders, ref state.Balls, hitTime);
+ PhysicsDynamicNarrowPhase.FindNextCollision(ref ball, ref overlappingColliders, ref _contacts, ref state, ref Counters);
+
+ var ballHitTests = Counters.HitTests - hitTestsBefore;
+ if (ballHitTests > Counters.MaxBallHitTests) {
+ Counters.MaxBallHitTests = ballHitTests;
+ Counters.MaxBallId = ball.Id;
+ Counters.MaxBallPosition = ball.Position;
+ }
// apply static time
ApplyStaticTime(ref hitTime, ref staticCounts, in ball);
}
}
+ Counters.Contacts += _contacts.Length;
ClampToMechanismStop(ref hitTime, mechanismStopTime);
ClampToSpringHingeHit(ref hitTime, springHingeHitTime);
@@ -204,6 +300,8 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov
internal void ResetDynamicBroadPhaseRefitCount()
{
DynamicBroadPhaseRefitCount = 0;
+ Counters = default;
+ Counters.DeepestHitDistance = float.MaxValue;
}
internal static void ApplyBallSpinCorrection(ref BallState ball)
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
index e9a3fcaae..d4acb5be7 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs
@@ -26,17 +26,35 @@ public static class PhysicsDynamicBroadPhase
{
private const float MotionBoundsMargin = 0.05f;
private const float ContainmentTolerance = 1e-5f;
+
+ ///
+ /// Extra padding on the inserted bounds. The containment check recomputes the
+ /// end of the motion from the displaced position, which at playfield
+ /// coordinates differs from the inserted end by a few float ulps; without
+ /// padding that noise alone would refit the octree on most iterations.
+ ///
+ private const float RefitPadding = 0.5f;
private static readonly ProfilerMarker PerfMarkerBallOctree = new("CreateBallOctree");
private static readonly ProfilerMarker PerfMarkerDynamicBroadPhase = new("DynamicBroadPhase");
- internal static void RebuildOctree(ref NativeOctree octree, ref NativeParallelHashMap balls)
+ ///
+ /// Inserts every ball with the bounds it can reach within
+ /// (see ), padded by .
+ /// refits when a ball's remaining motion
+ /// leaves its inserted bounds.
+ ///
+ internal static void RebuildOctree(ref NativeOctree octree, ref NativeParallelHashMap balls, float dTime)
{
PerfMarkerBallOctree.Begin();
octree.Clear();
using var enumerator = balls.GetEnumerator();
while (enumerator.MoveNext()) {
ref var ball = ref enumerator.Current.Value;
- ball.DynamicBroadPhaseAabb = ball.Aabb;
+ var swept = ball.GetSweptAabb(dTime);
+ ball.DynamicBroadPhaseAabb = new Aabb(
+ swept.Left - RefitPadding, swept.Right + RefitPadding,
+ swept.Top - RefitPadding, swept.Bottom + RefitPadding,
+ swept.ZLow - RefitPadding, swept.ZHigh + RefitPadding);
octree.Insert(ball.Id, ball.DynamicBroadPhaseAabb);
}
PerfMarkerBallOctree.End();
@@ -48,7 +66,7 @@ internal static bool RebuildIfMotionEscapes(ref NativeOctree octree,
if (remainingTime <= 0f || !RequiresRebuild(ref balls, remainingTime)) {
return false;
}
- RebuildOctree(ref octree, ref balls);
+ RebuildOctree(ref octree, ref balls, remainingTime);
return true;
}
@@ -81,11 +99,11 @@ internal static bool IsRemainingMotionContained(in BallState ball, float remaini
&& max.z <= inserted.ZHigh + ContainmentTolerance;
}
- internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingBalls, ref NativeParallelHashMap balls)
+ internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingBalls, ref NativeParallelHashMap balls, float dTime)
{
PerfMarkerDynamicBroadPhase.Begin();
overlappingBalls.Clear();
- octree.RangeAABBUnique(ball.Aabb, overlappingBalls);
+ octree.RangeAABBUnique(ball.GetSweptAabb(dTime), overlappingBalls);
// Collect IDs to remove into a stack-allocated list to avoid copying the hash set to a NativeArray.
var toRemove = new FixedList64Bytes();
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs
index 05a25fb3f..d24f5c02b 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs
@@ -25,7 +25,7 @@ internal static class PhysicsDynamicNarrowPhase
private static readonly ProfilerMarker PerfMarkerDynamicNarrowPhase = new("DynamicNarrowPhase");
internal static void FindNextCollision(ref BallState ball, ref NativeParallelHashSet collidingBalls,
- ref NativeList contacts, ref PhysicsState state)
+ ref NativeList contacts, ref PhysicsState state, ref PhysicsCounters counters)
{
// don't play with frozen balls
if (ball.IsFrozen) {
@@ -36,6 +36,7 @@ internal static void FindNextCollision(ref BallState ball, ref NativeParallelHas
ref var collEvent = ref ball.CollisionEvent;
using var enumerator = collidingBalls.GetEnumerator();
while (enumerator.MoveNext()) {
+ counters.BallTests++;
var collidingBallId = enumerator.Current;
ref var collBall = ref state.Balls.GetValueByRef(collidingBallId);
@@ -46,6 +47,7 @@ internal static void FindNextCollision(ref BallState ball, ref NativeParallelHas
if (newCollEvent.IsContact || validHit) {
newCollEvent.SetBallItem(collidingBallId);
newCollEvent.HitTime = newTime;
+ counters.RecordHit(ref state, in ball, in newCollEvent);
if (newCollEvent.IsContact) {
contacts.Add(new ContactBufferElement(ball.Id, newCollEvent));
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
index 29487defe..5094260e6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs
@@ -533,7 +533,7 @@ internal void DetachNativeInputManager(NativeInputManager inputManager)
///
public void NudgeSensorStatus(out float x, out float y)
{
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.NudgeStatus)) {
var nudge = _ctx.PhysicsEnv.Nudge;
var acceleration = nudge.ReadAndResetMaxCabinetAcceleration();
_ctx.PhysicsEnv.Nudge = nudge;
@@ -547,7 +547,7 @@ public void NudgeSensorStatus(out float x, out float y)
///
public void NudgeTiltStatus(out float plumbX, out float plumbY, out float tiltPercent)
{
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.TiltStatus)) {
var plumb = _ctx.PhysicsEnv.Plumb;
var status = plumb.ReadAndResetTiltStatus();
_ctx.PhysicsEnv.Plumb = plumb;
@@ -567,7 +567,7 @@ public NudgeTelemetry GetNudgeTelemetry()
return default;
}
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.NudgeTelemetry)) {
var nudge = _ctx.PhysicsEnv.Nudge;
var plumb = _ctx.PhysicsEnv.Plumb;
var threshold = plumb.TiltThresholdRad;
@@ -864,10 +864,17 @@ internal void RegisterRuntimeBall(BallComponent ball)
{
var ballId = UnityObjectId.Get(ball.gameObject);
var ballState = ball.CreateState();
+ if (_ctx.BallComponents.TryGetValue(ballId, out var existing) && existing != ball) {
+ // ids are folded from Unity's entity ids; a clash would make two balls share one
+ // physics state, which shows up as a ball that is rendered somewhere else than
+ // where it is simulated
+ var other = existing ? $"\"{existing.name}\"" : "a destroyed ball";
+ Debug.LogError($"[PhysicsEngine] Ball id {ballId} of \"{ball.name}\" is already used by {other}. The new ball shares that ball's physics state.");
+ }
_ctx.BallComponents[ballId] = ball;
if (_ctx.UseExternalTiming) {
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.RegisterBall)) {
ref var ballStates = ref _ctx.BallStates.Ref;
if (!ballStates.ContainsKey(ballId)) {
ballStates[ballId] = ballState;
@@ -887,7 +894,7 @@ internal BallComponent UnregisterRuntimeBall(int ballId)
_ctx.BallComponents.Remove(ballId);
if (_ctx.UseExternalTiming) {
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.UnregisterBall)) {
if (_ctx.BallStates.Ref.IsCreated) {
_ctx.BallStates.Ref.Remove(ballId);
}
@@ -922,6 +929,48 @@ internal void DisableCollider(int itemId)
public bool TryGetBall(int itemId, out BallComponent ballComponent)
=> _ctx.BallComponents.TryGetValue(itemId, out ballComponent);
+ ///
+ /// Visitor for . Invoked once per live ball on the
+ /// simulation thread; runs from 0 to - 1.
+ ///
+ public delegate void BallStateVisitor(in BallState ball, int index, int count, ulong physicsTimeUsec);
+
+ ///
+ /// Schedules a read-only visit of every live ball state. Diagnostics only.
+ ///
+ ///
+ /// Thread: The visitor runs on the simulation thread inside PhysicsLock
+ /// (inline on the main thread in single-threaded mode). It must not touch Unity APIs
+ /// and should only copy what it needs. Balls are visited in hash-map order.
+ ///
+ ///
+ /// Simulation-tick cost diagnostics in microseconds. Resets the execute maximum on read.
+ ///
+ public void GetSimulationTimingDiagnostics(out long octreeRebuildLastUsec, out long octreeRebuildCount,
+ out long physicsExecuteLastUsec, out long physicsExecuteMaxUsec, out long physicsBusyTotalUsec)
+ {
+ octreeRebuildLastUsec = Interlocked.Read(ref _ctx.LastKinematicOctreeRebuildUsec);
+ octreeRebuildCount = Interlocked.Read(ref _ctx.KinematicOctreeRebuildCount);
+ physicsExecuteLastUsec = Interlocked.Read(ref _ctx.LastPhysicsExecuteUsec);
+ physicsExecuteMaxUsec = Interlocked.Exchange(ref _ctx.MaxPhysicsExecuteUsec, 0);
+ physicsBusyTotalUsec = Interlocked.Read(ref _ctx.PhysicsBusyTotalUsec);
+ }
+
+ public void VisitBallStates(BallStateVisitor visitor)
+ {
+ if (visitor == null) {
+ return;
+ }
+ MutateState((ref PhysicsState state) => {
+ var count = state.Balls.Count();
+ var index = 0;
+ using var enumerator = state.Balls.GetEnumerator();
+ while (enumerator.MoveNext()) {
+ visitor(in enumerator.Current.Value, index++, count, state.Env.CurPhysicsFrameTime);
+ }
+ });
+ }
+
///
/// Gets the currently captured ball while holding the physics lock in
/// external-timing mode. Invalid stale references are repaired in place.
@@ -929,7 +978,7 @@ public bool TryGetBall(int itemId, out BallComponent ballComponent)
internal bool TryGetKickerBallId(int itemId, out int ballId)
{
if (_ctx.UseExternalTiming) {
- lock (_ctx.PhysicsLock) {
+ using (SimulationTrace.Lock(_ctx.PhysicsLock, SimulationTrace.LockSite.KickerBallId)) {
return TryGetKickerBallIdUnsafe(itemId, out ballId);
}
}
@@ -970,18 +1019,26 @@ private bool TryGetKickerBallIdUnsafe(int itemId, out int ballId)
///
///
/// Intended for debugging and visualization (e.g. editor gizmos).
- /// Returns false if the item hasn't moved since the game started.
+ /// Returns false if the item hasn't moved since the game started.
+ /// Thread: Main thread. Acquires PhysicsLock non-blockingly;
+ /// if the simulation thread is mid-tick, this returns false rather
+ /// than stalling the caller (gizmo callbacks run every editor repaint for
+ /// every kinematic item, so blocking here stalls the whole frame).
///
public bool TryGetKinematicVelocity(int itemId, out float3 linearVelocity, out float3 angularVelocity, out float3 pivot)
{
// engine time unit (DefaultStepTime, 10 ms) to seconds
const float perSecond = (float)(1e6 / PhysicsConstants.DefaultStepTime);
- lock (_ctx.PhysicsLock) {
- if (_ctx.KinematicVelocities.Ref.IsCreated && _ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var velocity)) {
- linearVelocity = velocity.LinearVelocity * perSecond;
- angularVelocity = velocity.AngularVelocity * perSecond;
- pivot = velocity.Pivot;
- return true;
+ if (_ctx != null && _ctx.IsInitialized && Monitor.TryEnter(_ctx.PhysicsLock)) {
+ try {
+ if (_ctx.KinematicVelocities.Ref.IsCreated && _ctx.KinematicVelocities.Ref.TryGetValue(itemId, out var velocity)) {
+ linearVelocity = velocity.LinearVelocity * perSecond;
+ angularVelocity = velocity.AngularVelocity * perSecond;
+ pivot = velocity.Pivot;
+ return true;
+ }
+ } finally {
+ Monitor.Exit(_ctx.PhysicsLock);
}
}
linearVelocity = float3.zero;
@@ -1019,7 +1076,11 @@ internal bool TrySnapshotAnimations(ref SimulationState.Snapshot snapshot)
return false;
}
+ var lockStartTicks = Stopwatch.GetTimestamp();
lock (_ctx.PhysicsLock) {
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.SnapshotLockUsec = SimulationTrace.ElapsedUsec(lockStartTicks, Stopwatch.GetTimestamp());
+ }
if (!_ctx.IsInitialized) {
return false;
}
@@ -1218,6 +1279,21 @@ private void Update()
}
var currentTimeUsec = NowUsec;
if (_ctx.UseExternalTiming) {
+ var tracing = SimulationTrace.IsRecording;
+ var updateStartTicks = Stopwatch.GetTimestamp();
+ if (tracing) {
+ SimulationTrace.BeginFrame();
+ ref var frame = ref SimulationTrace.Frame;
+ frame.Frame = Time.frameCount;
+ frame.StartUsec = SimulationTrace.TicksToUsec(updateStartTicks);
+ frame.UnityTimeUsec = (long)currentTimeUsec;
+ frame.RealtimeUsec = (long)(Time.realtimeSinceStartupAsDouble * 1_000_000);
+ frame.UnscaledDeltaUsec = (int)(Time.unscaledDeltaTime * 1_000_000);
+ frame.DeltaUsec = (int)(Time.deltaTime * 1_000_000);
+ frame.TimeScale = Time.timeScale;
+ frame.Gc0 = GC.CollectionCount(0);
+ }
+
// Simulation thread mode: physics runs on simulation thread,
// but managed callbacks must run on Unity main thread.
_threading.DrainExternalThreadCallbacks();
@@ -1227,6 +1303,11 @@ private void Update()
_threading.UpdateKinematicTransformsFromMainThread(currentTimeUsec);
_threading.ApplyMovements();
+
+ if (tracing) {
+ SimulationTrace.Frame.UpdateUsec = SimulationTrace.ElapsedUsec(updateStartTicks, Stopwatch.GetTimestamp());
+ SimulationTrace.CommitFrame();
+ }
} else {
// Normal mode: Execute full physics update
_threading.ExecutePhysicsUpdate(currentTimeUsec);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs
index fe229afdf..4267d5789 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs
@@ -52,18 +52,21 @@ internal class PhysicsEngineContext : IDisposable
///
///
/// Created once in with
- /// Allocator.Persistent. Cleared and rebuilt only when
- /// is set, rather than every
- /// physics tick. This reduces overhead from ~1 kHz to ~60 Hz.
+ /// Allocator.Persistent. Holds only idle kinematic items and is
+ /// cleared and rebuilt only when is
+ /// set, i.e. when an item starts or stops moving or is snapped in place.
+ /// Moving items are kept in and
+ /// broad-phased directly, so a moving mechanism costs no rebuild per frame.
///
public NativeOctree KinematicOctree;
///
/// Whether the kinematic octree needs to be rebuilt before the
- /// next physics tick. Set to true when kinematic transforms
- /// change (either via pending staging in threaded mode or via
- /// direct detection in single-threaded mode). Initialized to
- /// true so the first tick builds the octree.
+ /// next physics tick. Set to true when the set of idle kinematic
+ /// items changes or an idle item is snapped to a new pose (either via
+ /// pending staging in threaded mode or via direct detection in
+ /// single-threaded mode). Initialized to true so the first tick
+ /// builds the octree.
///
public bool KinematicOctreeDirty = true;
@@ -163,6 +166,23 @@ internal class PhysicsEngineContext : IDisposable
///
public readonly LazyInit> KinematicVelocities = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+ ///
+ /// Kinematic items currently excluded from
+ /// because they are moving; their colliders are broad-phased directly.
+ /// See .
+ ///
+ ///
+ /// Written by: the thread that stages kinematic targets (sim thread, or
+ /// main thread in single-threaded mode). Read by: the physics loop.
+ ///
+ public readonly LazyInit> KinematicItemsOutOfOctree = new(() => new NativeParallelHashSet(0, Allocator.Persistent));
+
+ ///
+ /// Current-pose bounds of the items in .
+ /// See .
+ ///
+ public readonly LazyInit> KinematicMovingItemBounds = new(() => new NativeParallelHashMap(0, Allocator.Persistent));
+
///
/// The current matrix to which the ball will be transformed to, if
/// it collides with a non-transformable collider. This changes as
@@ -284,6 +304,12 @@ internal class PhysicsEngineContext : IDisposable
public long LastKinematicScanUsec;
public long LastEventDrainUsec;
+ // diagnostics: sim-tick cost breakdown (microseconds), see PhysicsEngine.GetSimulationTimingDiagnostics
+ public long LastKinematicOctreeRebuildUsec;
+ public long KinematicOctreeRebuildCount;
+ public long LastPhysicsExecuteUsec;
+ public long MaxPhysicsExecuteUsec;
+
#endregion
#region Methods
@@ -303,7 +329,8 @@ internal PhysicsState CreateState()
ref HitTargetStates.Ref, ref KickerStates.Ref, ref MagnetStates.Ref, ref PlungerStates.Ref, ref SpinnerStates.Ref,
ref SpringHingeStates.Ref,
ref SurfaceStates.Ref, ref TurntableStates.Ref, ref TriggerStates.Ref, ref DisabledCollisionItems.Ref, ref SwapBallCollisionHandling,
- ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref);
+ ref ElasticityOverVelocityLUTs, ref FrictionOverVelocityLUTs, ref KinematicVelocities.Ref,
+ ref KinematicItemsOutOfOctree.Ref, ref KinematicMovingItemBounds.Ref);
}
///
@@ -359,6 +386,8 @@ public void Dispose()
KinematicTransforms.Ref.Dispose();
KinematicTargetTransforms.Ref.Dispose();
KinematicVelocities.Ref.Dispose();
+ KinematicItemsOutOfOctree.Ref.Dispose();
+ KinematicMovingItemBounds.Ref.Dispose();
PendingKinematicTransforms.Ref.Dispose();
NonTransformableColliderTransforms.Ref.Dispose();
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs
index b1c09a9d4..3d6f84f7a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs
@@ -82,6 +82,24 @@ internal class PhysicsEngineThreading
private readonly Dictionary _heldIsolatedPoses = new();
private readonly List _heldPosesToApply = new();
+ ///
+ /// Time (same clock as the hold timeout) of the latest streamed target per
+ /// kinematic item that is currently excluded from the kinematic octree.
+ /// An item returns to the octree once it has been quiet for
+ /// and its pose has settled on
+ /// its target. Owned by the thread that stages kinematic targets.
+ ///
+ private readonly Dictionary _kinematicMovingSinceUsec = new();
+ private readonly List _kinematicOctreeReturns = new();
+
+ ///
+ /// Quiet time after the last streamed target before a moving item is put
+ /// back into the kinematic octree. Matches the continuity window, so a
+ /// low-cadence mover (10 Hz updates) stays out of the octree for its whole
+ /// motion and the rebuild happens twice per motion, not per update.
+ ///
+ private const ulong KinematicOctreeReturnQuietUsec = 250_000;
+
private struct HeldKinematicPose
{
public float4x4 Pose;
@@ -195,7 +213,11 @@ internal void ExecuteTick(ulong timeUsec)
if (!_ctx.IsInitialized) return;
_physicsEngine.MarkCurrentThreadAsSimulationThread();
+ var lockStartTicks = Stopwatch.GetTimestamp();
lock (_ctx.PhysicsLock) {
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.LockWaitUsec = SimulationTrace.ElapsedUsec(lockStartTicks, Stopwatch.GetTimestamp());
+ }
if (!_ctx.IsInitialized) {
return;
}
@@ -216,16 +238,24 @@ internal void ExecuteTick(ulong timeUsec)
private void ExecutePhysicsSimulation(ulong currentTimeUsec)
{
var sw = Stopwatch.StartNew();
+ var tracing = SimulationTrace.IsRecording;
+ var tickStartTicks = Stopwatch.GetTimestamp();
// Apply kinematic transform updates staged by main thread.
ApplyPendingKinematicTransforms(currentTimeUsec);
+ var kinematicDoneTicks = Stopwatch.GetTimestamp();
var state = _ctx.CreateState();
// Rebuild kinematic octree only when transforms have changed.
+ var rebuildUsec = 0L;
if (_ctx.KinematicOctreeDirty) {
- PhysicsKinematics.RebuildOctree(ref _ctx.KinematicOctree, ref state);
+ var rebuildStartTicks = Stopwatch.GetTimestamp();
+ PhysicsUpdate.RebuildKinematicOctree(ref _ctx.KinematicOctree, ref state);
_ctx.KinematicOctreeDirty = false;
+ rebuildUsec = ElapsedUsec(rebuildStartTicks, Stopwatch.GetTimestamp());
+ Interlocked.Exchange(ref _ctx.LastKinematicOctreeRebuildUsec, rebuildUsec);
+ Interlocked.Increment(ref _ctx.KinematicOctreeRebuildCount);
}
// process input
@@ -234,6 +264,8 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec)
ProcessPendingNudgeSensorSamples();
// run physics loop (Burst-compiled, thread-safe)
+ var physicsTimeBefore = _ctx.PhysicsEnv.CurPhysicsFrameTime;
+ var executeStartTicks = Stopwatch.GetTimestamp();
PhysicsUpdate.Execute(
ref state,
ref _ctx.PhysicsEnv,
@@ -243,8 +275,32 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec)
ref _ctx.PhysicsCycle,
currentTimeUsec
);
+ var executeUsec = ElapsedUsec(executeStartTicks, Stopwatch.GetTimestamp());
+ Interlocked.Exchange(ref _ctx.LastPhysicsExecuteUsec, executeUsec);
+ // compare-exchange loop: a concurrent reset by GetSimulationTimingDiagnostics
+ // between a plain read and exchange would otherwise drop this sample
+ var observedMax = Interlocked.Read(ref _ctx.MaxPhysicsExecuteUsec);
+ while (executeUsec > observedMax) {
+ var previousMax = Interlocked.CompareExchange(ref _ctx.MaxPhysicsExecuteUsec, executeUsec, observedMax);
+ if (previousMax == observedMax) {
+ break;
+ }
+ observedMax = previousMax;
+ }
Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime);
+ if (tracing) {
+ ref var trace = ref SimulationTrace.Tick;
+ trace.KinematicUsec = SimulationTrace.ElapsedUsec(tickStartTicks, kinematicDoneTicks);
+ trace.RebuildUsec = (int)rebuildUsec;
+ trace.ExecuteUsec = (int)executeUsec;
+ trace.PhysicsTimeUsec = (long)_ctx.PhysicsEnv.CurPhysicsFrameTime;
+ trace.PhysicsAdvanceUsec = (int)(_ctx.PhysicsEnv.CurPhysicsFrameTime - physicsTimeBefore);
+ trace.MovingItems = _kinematicMovingSinceUsec.Count;
+ trace.Counters = _ctx.PhysicsCycle.Counters;
+ trace.BallOctreeRefits = _ctx.PhysicsCycle.DynamicBroadPhaseRefitCount;
+ }
+
RecordPhysicsBusyTime(sw.ElapsedTicks);
}
@@ -338,12 +394,14 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec)
{
if (!_ctx.PendingKinematicTransforms.Ref.IsCreated) return;
+ var drained = 0;
lock (_ctx.PendingKinematicLock) {
if (_ctx.PendingKinematicTransforms.Ref.Count() > 0) {
using var enumerator = _ctx.PendingKinematicTransforms.Ref.GetEnumerator();
while (enumerator.MoveNext()) {
var sample = enumerator.Current.Value;
StageKinematicTarget(enumerator.Current.Key, sample.Matrix, sample.SampleTimeUsec, currentTimeUsec);
+ drained++;
}
_ctx.PendingKinematicTransforms.Ref.Clear();
}
@@ -356,6 +414,10 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec)
}
ProcessHeldKinematicPoses(currentTimeUsec);
+ ProcessKinematicOctreeReturns(currentTimeUsec);
+ }
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.KinematicUpdates = drained;
}
}
@@ -387,16 +449,65 @@ private void StageKinematicTarget(int itemId, in float4x4 matrix, ulong sampleTi
// continuous motion (incl. resolving a hold): stream toward the target,
// capped per tick, so a fast collider can't skip past a ball
_ctx.KinematicTargetTransforms.Ref[itemId] = matrix;
- // colliderless kinematic items (magnets, turntables) don't affect the octree
- if (_ctx.KinematicColliderLookups.ContainsKey(itemId)) {
- _ctx.KinematicOctreeDirty = true;
- }
+ MarkKinematicItemMoving(itemId, holdTimeUsec);
}
var item = GetKinematicTransformComponent(itemId);
item?.OnTransformationChanged(matrix);
}
+ ///
+ /// Takes a streaming item out of the kinematic octree so its per-frame pose
+ /// updates no longer force an octree rebuild; its colliders are broad-phased
+ /// directly while it moves ().
+ /// The octree is rebuilt once, without the item.
+ ///
+ private void MarkKinematicItemMoving(int itemId, ulong nowUsec)
+ {
+ // colliderless kinematic items (magnets, turntables) don't affect the octree
+ if (!_ctx.KinematicColliderLookups.ContainsKey(itemId)) {
+ return;
+ }
+ _kinematicMovingSinceUsec[itemId] = nowUsec;
+ if (_ctx.KinematicItemsOutOfOctree.Ref.Add(itemId)) {
+ _ctx.KinematicOctreeDirty = true;
+ }
+ }
+
+ ///
+ /// Puts items back into the kinematic octree once they have been quiet for
+ /// and their stepped pose has
+ /// reached the target. The octree is rebuilt once per returning batch.
+ ///
+ ///
+ /// Thread: Simulation thread (inside PhysicsLock), or main
+ /// thread in single-threaded mode.
+ ///
+ private void ProcessKinematicOctreeReturns(ulong nowUsec)
+ {
+ if (_kinematicMovingSinceUsec.Count == 0) {
+ return;
+ }
+ _kinematicOctreeReturns.Clear();
+ foreach (var kvp in _kinematicMovingSinceUsec) {
+ if (nowUsec < kvp.Value || nowUsec - kvp.Value < KinematicOctreeReturnQuietUsec) {
+ continue;
+ }
+ if (_ctx.KinematicTargetTransforms.Ref.TryGetValue(kvp.Key, out var target)
+ && !_ctx.KinematicTransforms.Ref[kvp.Key].Equals(target)) {
+ continue; // still catching up
+ }
+ _kinematicOctreeReturns.Add(kvp.Key);
+ }
+ foreach (var itemId in _kinematicOctreeReturns) {
+ _kinematicMovingSinceUsec.Remove(itemId);
+ if (_ctx.KinematicItemsOutOfOctree.Ref.Remove(itemId)) {
+ _ctx.KinematicMovingItemBounds.Ref.Remove(itemId);
+ _ctx.KinematicOctreeDirty = true;
+ }
+ }
+ }
+
///
/// Apply a pose directly with teleport semantics: no stepping, no velocity,
/// no impulse — the item is just somewhere else now.
@@ -411,7 +522,12 @@ private void SnapKinematicPose(int itemId, in float4x4 matrix)
for (var i = 0; i < colliderLookups.Length; i++) {
state.TransformKinematicColliders(colliderLookups[i], matrix);
}
- _ctx.KinematicOctreeDirty = true;
+ if (_ctx.KinematicItemsOutOfOctree.Ref.Contains(itemId)) {
+ // moving item: not in the octree, only its direct-test bounds change
+ PhysicsKinematics.UpdateMovingItemBounds(ref state, itemId, in colliderLookups);
+ } else {
+ _ctx.KinematicOctreeDirty = true;
+ }
}
}
@@ -545,6 +661,9 @@ internal void SnapshotAnimations(ref SimulationState.Snapshot snapshot)
_snapshotSpringHingeIds.Length, ballSourceCount);
snapshot.BallCount = suppressOwnedSnapshot ? 0 : ballCount;
snapshot.BallSourceCount = ballSourceCount;
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.BallCount = ballSourceCount;
+ }
snapshot.BallSnapshotsTruncated = ballSourceCount > SimulationState.MaxBalls ? (byte)1 : (byte)0;
if (!_ballSnapshotOverflowWarningIssued && snapshot.BallSnapshotsTruncated != 0) {
_ballSnapshotOverflowWarningIssued = true;
@@ -708,8 +827,20 @@ internal void ApplyMovements()
"Call SetSimulationState() before enabling external timing.");
}
+ var applyStartTicks = Stopwatch.GetTimestamp();
ref readonly var snapshot = ref _ctx.SimulationState.AcquireReadBuffer();
ApplyMovementsFromSnapshot(in snapshot);
+ if (SimulationTrace.IsRecording) {
+ ref var frame = ref SimulationTrace.Frame;
+ frame.ApplyUsec = SimulationTrace.ElapsedUsec(applyStartTicks, Stopwatch.GetTimestamp());
+ frame.SnapshotSimTimeUsec = snapshot.SimulationTimeUsec;
+ frame.SnapshotPublishUsec = snapshot.PublishRealTimeUsec;
+ frame.SnapshotBallCount = snapshot.BallCount;
+ if (snapshot.BallCount > 0) { frame.Ball0Id = snapshot.BallSnapshots[0].Id; frame.Ball0 = snapshot.BallSnapshots[0].Position; }
+ if (snapshot.BallCount > 1) { frame.Ball1Id = snapshot.BallSnapshots[1].Id; frame.Ball1 = snapshot.BallSnapshots[1].Position; }
+ if (snapshot.BallCount > 2) { frame.Ball2Id = snapshot.BallSnapshots[2].Id; frame.Ball2 = snapshot.BallSnapshots[2].Position; }
+ if (snapshot.BallCount > 3) { frame.Ball3Id = snapshot.BallSnapshots[3].Id; frame.Ball3 = snapshot.BallSnapshots[3].Position; }
+ }
}
///
@@ -775,6 +906,9 @@ internal void DrainExternalThreadCallbacks()
var drainStartTicks = Stopwatch.GetTimestamp();
if (!Monitor.TryEnter(_ctx.PhysicsLock)) {
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Frame.DrainSkipped = 1;
+ }
return; // sim thread is mid-tick; drain next frame
}
try {
@@ -797,7 +931,14 @@ internal void DrainExternalThreadCallbacks()
action();
}
- Interlocked.Exchange(ref _ctx.LastEventDrainUsec, ElapsedUsec(drainStartTicks, Stopwatch.GetTimestamp()));
+ var drainUsec = ElapsedUsec(drainStartTicks, Stopwatch.GetTimestamp());
+ Interlocked.Exchange(ref _ctx.LastEventDrainUsec, drainUsec);
+ if (SimulationTrace.IsRecording) {
+ ref var frame = ref SimulationTrace.Frame;
+ frame.DrainUsec = (int)drainUsec;
+ frame.EventsDrained = _deferredMainThreadEvents.Count;
+ frame.ActionsDrained = _deferredMainThreadScheduledActions.Count;
+ }
}
///
@@ -844,6 +985,13 @@ internal void UpdateKinematicTransformsFromMainThread(ulong sampleTimeUsec)
new KinematicTransformSample { Matrix = currMatrix, SampleTimeUsec = sampleTimeUsec }));
}
+ if (SimulationTrace.IsRecording) {
+ ref var frame = ref SimulationTrace.Frame;
+ frame.ScanUsec = SimulationTrace.ElapsedUsec(scanStartTicks, Stopwatch.GetTimestamp());
+ frame.KinematicChanged = _pendingKinematicUpdates.Count;
+ frame.KinematicStopped = _pendingKinematicStopUpdates.Count;
+ }
+
if (_pendingKinematicUpdates.Count == 0 && _pendingKinematicStopUpdates.Count == 0) {
return;
}
@@ -910,12 +1058,13 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec)
StageKinematicTarget(item.ItemId, in currTransformationMatrix, currentTimeUsec, currentTimeUsec);
}
ProcessHeldKinematicPoses(currentTimeUsec);
+ ProcessKinematicOctreeReturns(currentTimeUsec);
var state = _ctx.CreateState();
// Rebuild kinematic octree only when transforms have changed.
if (_ctx.KinematicOctreeDirty) {
- PhysicsKinematics.RebuildOctree(ref _ctx.KinematicOctree, ref state);
+ PhysicsUpdate.RebuildKinematicOctree(ref _ctx.KinematicOctree, ref state);
_ctx.KinematicOctreeDirty = false;
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
index 4728bb773..33468468a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsKinematics.cs
@@ -187,11 +187,32 @@ internal static void StepKinematics(ref PhysicsState state, ulong currentTimeUse
for (var i = 0; i < colliderLookups.Length; i++) {
state.TransformKinematicColliders(colliderLookups[i], current);
}
+ UpdateMovingItemBounds(ref state, itemId, in colliderLookups);
}
}
PerfMarkerTransform.End();
}
+ ///
+ /// Refreshes the current-pose bounds of an item that is excluded from the
+ /// kinematic octree (see ),
+ /// from the bounds of its freshly transformed colliders. No-op for items in
+ /// the octree.
+ ///
+ internal static void UpdateMovingItemBounds(ref PhysicsState state, int itemId, in NativeColliderIds colliderIds)
+ {
+ if (!state.KinematicItemsOutOfOctree.IsCreated || !state.KinematicMovingItemBounds.IsCreated
+ || !state.KinematicItemsOutOfOctree.Contains(itemId)) {
+ return;
+ }
+ var bounds = new Aabb();
+ bounds.Clear();
+ for (var i = 0; i < colliderIds.Length; i++) {
+ bounds.Extend(state.GetKinematicColliderAabb(colliderIds[i]));
+ }
+ state.KinematicMovingItemBounds[itemId] = bounds;
+ }
+
///
/// Stops using a transform sample's derived velocity when the producer has
/// not refreshed it within the normal low-cadence update window.
@@ -441,12 +462,20 @@ internal static void RebuildOctree(ref NativeOctree octree, ref PhysicsStat
var memoAngle = 0f;
var memoPivot = float3.zero;
+ var skipMovingItems = state.KinematicItemsOutOfOctree.IsCreated && !state.KinematicItemsOutOfOctree.IsEmpty;
+
for (var i = 0; i < state.KinematicCollidersAtIdentity.Length; i++) {
+ var itemId = state.KinematicCollidersAtIdentity.GetItemId(i);
+
+ // moving items are broad-phased directly, see FindMovingKinematicOverlaps
+ if (skipMovingItems && state.KinematicItemsOutOfOctree.Contains(itemId)) {
+ continue;
+ }
+
// while an item steps toward its target pose, cover the whole swept
// range: union of the AABBs at the current and at the target pose,
// so the octree stays valid for every sub-tick step of this frame
var aabb = state.KinematicCollidersAtIdentity.GetTransformedAabb(i, ref state.KinematicTransforms);
- var itemId = state.KinematicCollidersAtIdentity.GetItemId(i);
if (state.KinematicTargetTransforms.ContainsKey(itemId)) {
var targetAabb = state.KinematicCollidersAtIdentity.GetTransformedAabb(i, ref state.KinematicTargetTransforms);
aabb = new Aabb(
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
index a5d2edc04..3a9ce7638 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsState.cs
@@ -129,6 +129,28 @@ internal struct PhysicsState
///
internal NativeParallelHashMap KinematicColliderLookups;
+ ///
+ /// Kinematic items whose colliders are currently excluded from the kinematic
+ /// octree because the item is moving. The octree only ever holds idle items,
+ /// so it is rebuilt when an item starts or stops moving instead of on every
+ /// pose update; moving items are broad-phased by testing their colliders'
+ /// bounds directly ().
+ ///
+ ///
+ /// Maintained by PhysicsEngineThreading on the thread that stages
+ /// kinematic targets. May be uncreated in unit tests, in which case every item
+ /// is treated as idle.
+ ///
+ internal NativeParallelHashSet KinematicItemsOutOfOctree;
+
+ ///
+ /// Union of the collider bounds of each moving item at its current pose,
+ /// refreshed by whenever the
+ /// pose steps. Lets the moving-item broad phase reject a whole item with one
+ /// test. A missing entry means the item's colliders are tested individually.
+ ///
+ internal NativeParallelHashMap KinematicMovingItemBounds;
+
internal NativeQueue.ParallelWriter EventQueue;
internal InsideOfs InsideOfs;
internal NativeParallelHashMap Balls;
@@ -168,7 +190,9 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native
ref NativeParallelHashSet disabledCollisionItems, ref bool swapBallCollisionHandling,
ref NativeParallelHashMap> elasticityOverVelocityLUTs,
ref NativeParallelHashMap> frictionOverVelocityLUTs,
- ref NativeParallelHashMap kinematicVelocities)
+ ref NativeParallelHashMap kinematicVelocities,
+ ref NativeParallelHashSet kinematicItemsOutOfOctree,
+ ref NativeParallelHashMap kinematicMovingItemBounds)
{
Env = env;
Octree = octree;
@@ -201,6 +225,8 @@ public PhysicsState(ref PhysicsEnv env, ref NativeOctree octree, ref Native
ElasticityOverVelocityLUTs = elasticityOverVelocityLUTs;
FrictionOverVelocityLUTs = frictionOverVelocityLUTs;
KinematicVelocities = kinematicVelocities;
+ KinematicItemsOutOfOctree = kinematicItemsOutOfOctree;
+ KinematicMovingItemBounds = kinematicMovingItemBounds;
}
internal ref ColliderHeader GetColliderHeader(ref NativeColliders colliders, int colliderId) => ref colliders.GetHeader(colliderId);
@@ -341,9 +367,23 @@ internal float3 GetKinematicVelocityAt(int itemId, in float3 position)
return linear + math.cross(angular, position - pivot);
}
- private bool TryGetKinematicVelocity(int itemId, out float3 linear, out float3 angular, out float3 pivot)
+ ///
+ /// Current-pose bounds of a kinematic collider. Baked (fully transformable)
+ /// colliders carry their transformed bounds; the others are stored at identity
+ /// and hit-tested in item space, so their bounds are derived from the identity
+ /// collider and the item's current transform, the same way the octree rebuild
+ /// does it.
+ ///
+ internal Aabb GetKinematicColliderAabb(int colliderId)
+ {
+ return KinematicColliders.IsTransformed(colliderId)
+ ? KinematicColliders.GetAabb(colliderId)
+ : KinematicCollidersAtIdentity.GetTransformedAabb(colliderId, ref KinematicTransforms);
+ }
+
+ internal bool TryGetKinematicVelocity(int itemId, out float3 linear, out float3 angular, out float3 pivot)
{
- if (!KinematicVelocities.TryGetValue(itemId, out var velocity)) {
+ if (!KinematicVelocities.IsCreated || !KinematicVelocities.TryGetValue(itemId, out var velocity)) {
linear = float3.zero;
angular = float3.zero;
pivot = float3.zero;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs
index 2e7d7dcdf..a412e6905 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs
@@ -16,7 +16,9 @@
using NativeTrees;
using Unity.Collections;
+using Unity.Mathematics;
using Unity.Profiling;
+using VisualPinball.Engine.Common;
namespace VisualPinball.Unity
{
@@ -24,12 +26,104 @@ public static class PhysicsStaticBroadPhase
{
private static readonly ProfilerMarker PerfMarkerBroadPhase = new("BroadPhase");
- internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingColliders)
+ ///
+ /// Collects the colliders whose bounds the ball can reach within
+ /// (see ).
+ ///
+ internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingColliders, float dTime)
{
PerfMarkerBroadPhase.Begin();
overlappingColliders.Clear();
- octree.RangeAABBUnique(ball.Aabb, overlappingColliders);
+ octree.RangeAABBUnique(ball.GetSweptAabb(dTime), overlappingColliders);
PerfMarkerBroadPhase.End();
}
+
+ ///
+ /// Same as ,
+ /// counting the octree objects visited (bounds tests) for diagnostics.
+ ///
+ internal static void FindOverlaps(in NativeOctree octree, in BallState ball, ref NativeParallelHashSet overlappingColliders, float dTime, ref PhysicsCounters counters)
+ {
+ PerfMarkerBroadPhase.Begin();
+ overlappingColliders.Clear();
+ var visitor = new CountingRangeVisitor { Results = overlappingColliders };
+ octree.Range(ball.GetSweptAabb(dTime), ref visitor);
+ counters.BroadPhaseVisits += visitor.Visits;
+ PerfMarkerBroadPhase.End();
+ }
+
+ private struct CountingRangeVisitor : IOctreeRangeVisitor
+ {
+ public NativeParallelHashSet Results;
+ public int Visits;
+
+ public bool OnVisit(int obj, AABB objBounds, AABB queryRange)
+ {
+ Visits++;
+ if (objBounds.Overlaps(queryRange)) {
+ Results.Add(obj);
+ }
+ return true;
+ }
+ }
+
+ ///
+ /// Adds the colliders of moving kinematic items that overlap the ball's bounds.
+ /// Moving items are excluded from the kinematic octree (see
+ /// ) so the octree does not
+ /// have to be rebuilt on every pose update; instead their colliders' current
+ /// bounds are tested here directly, after a one-test rejection against the
+ /// item's overall bounds. Must run after for the
+ /// kinematic octree, which clears the set.
+ ///
+ ///
+ /// The kinematic narrow phase hit-tests in the collider's frame, i.e. with the
+ /// ball's velocity relative to the item's surface velocity at the ball position,
+ /// so a surface can hit a ball within the tick even though its pose is fixed for
+ /// the tick. The ball's bounds are therefore inflated, per axis, by the distance
+ /// that surface velocity covers in one tick, which is what the swept octree
+ /// bounds used to provide for these items.
+ ///
+ internal static void FindMovingKinematicOverlaps(ref PhysicsState state, in BallState ball, ref NativeParallelHashSet overlappingColliders, float dTime)
+ {
+ if (!state.KinematicItemsOutOfOctree.IsCreated || state.KinematicItemsOutOfOctree.IsEmpty) {
+ return;
+ }
+ PerfMarkerBroadPhase.Begin();
+ var ballAabb = ball.GetSweptAabb(dTime);
+ using var items = state.KinematicItemsOutOfOctree.GetEnumerator();
+ while (items.MoveNext()) {
+ var itemId = items.Current;
+ if (!state.KinematicColliderLookups.TryGetValue(itemId, out var colliderIds)) {
+ continue;
+ }
+
+ // same relative velocity the narrow phase subtracts, over one tick
+ var query = ballAabb;
+ if (state.TryGetKinematicVelocity(itemId, out var linear, out var angular, out var pivot)) {
+ var surfaceVelocity = linear + math.cross(angular, ball.Position - pivot);
+ query = Inflate(in ballAabb, math.abs(surfaceVelocity) * PhysicsConstants.PhysFactor);
+ }
+
+ if (state.KinematicMovingItemBounds.IsCreated
+ && state.KinematicMovingItemBounds.TryGetValue(itemId, out var itemBounds)
+ && !itemBounds.IntersectRect(query)) {
+ continue;
+ }
+ for (var i = 0; i < colliderIds.Length; i++) {
+ var colliderId = colliderIds[i];
+ if (state.GetKinematicColliderAabb(colliderId).IntersectRect(query)) {
+ overlappingColliders.Add(colliderId);
+ }
+ }
+ }
+ PerfMarkerBroadPhase.End();
+ }
+
+ private static Aabb Inflate(in Aabb aabb, in float3 margin)
+ {
+ return new Aabb(aabb.Left - margin.x, aabb.Right + margin.x, aabb.Top - margin.y, aabb.Bottom + margin.y,
+ aabb.ZLow - margin.z, aabb.ZHigh + margin.z);
+ }
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs
index ad4e728ff..20f0a1cdf 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs
@@ -31,7 +31,8 @@ internal static void FindNextCollision(
ref BallState ball,
ref NativeParallelHashSet overlappingColliders,
ref NativeList contacts,
- ref PhysicsState state
+ ref PhysicsState state,
+ ref PhysicsCounters counters
)
{
PerfMarkerNarrowPhase.Begin();
@@ -42,6 +43,7 @@ ref PhysicsState state
if (!state.IsColliderActive(ref colliders, overlappingColliderId)) {
continue;
}
+ counters.CountHitTest(state.GetColliderType(ref colliders, overlappingColliderId));
float newTime;
var newCollEvent = new CollisionEventData();
@@ -74,7 +76,7 @@ ref PhysicsState state
newTime = state.HitTest(ref colliders, overlappingColliderId, ref ball, ref newCollEvent, ref contacts);
}
- SaveCollisions(ref ball, ref newCollEvent, ref contacts, overlappingColliderId, newTime, colliders.IsKinematic);
+ SaveCollisions(ref ball, ref newCollEvent, ref contacts, overlappingColliderId, newTime, colliders.IsKinematic, ref state, ref counters);
}
}
@@ -87,11 +89,14 @@ private static bool IsValidHit(ref BallState ball, float newTime)
}
private static void SaveCollisions(ref BallState ball, ref CollisionEventData newCollEvent,
- ref NativeList contacts, int colliderId, float newTime, bool isKinematic)
+ ref NativeList contacts, int colliderId, float newTime, bool isKinematic,
+ ref PhysicsState state, ref PhysicsCounters counters)
{
if (newCollEvent.IsContact || IsValidHit(ref ball, newTime)) { // todo why newCollEvent.IsContact? it's not in vpx source
newCollEvent.SetCollider(colliderId, isKinematic);
newCollEvent.HitTime = newTime;
+ // diagnostics: every accepted hit, since a later one at the same time replaces the event
+ counters.RecordHit(ref state, in ball, in newCollEvent);
if (newCollEvent.IsContact) { // remember all contacts?
contacts.Add(new ContactBufferElement(ball.Id, newCollEvent));
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
index 7093a377a..1783204a6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsUpdate.cs
@@ -181,6 +181,18 @@ public static void Execute(ref PhysicsState state, ref PhysicsEnv env, ref Nativ
}
}
+ ///
+ /// Rebuilds the kinematic broad-phase octree. Burst entry point for
+ /// : the rebuild walks every kinematic
+ /// collider of the table, and as managed code it costs milliseconds per call, which
+ /// starves the 1 kHz simulation thread whenever a kinematic item moves.
+ ///
+ [BurstCompile]
+ public static void RebuildKinematicOctree(ref NativeOctree octree, ref PhysicsState state)
+ {
+ PhysicsKinematics.RebuildOctree(ref octree, ref state);
+ }
+
internal static void UpdateSpringHingeVelocities(ref PhysicsState state, in float3 gravity,
in float2 cabinetAcceleration, float step)
{
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
index c3cd5ba0c..9bc303aa1 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs
@@ -64,10 +64,20 @@ public CircleCollider(float2 center, float radius, float zLow, float zHigh, Coll
Center.y - Radius,
Center.y + Radius,
ZLow,
- ZHigh
+ BoundsZHigh
));
}
+ ///
+ /// Top of the bounds. Kickers and triggers are hit-tested with a sphere cap
+ /// above (see : radius
+ /// 2.6 r centered 2.4 r below the top), which reaches 0.2 r higher than the
+ /// cylinder, so their bounds must too.
+ ///
+ private float BoundsZHigh => Header.Type is ColliderType.KickerCircle or ColliderType.TriggerCircle
+ ? ZHigh + Radius * 0.2f
+ : ZHigh;
+
#region Narrowphase
public float HitTest(ref CollisionEventData collEvent, ref InsideOfs insideOfs, in BallState ball, float dTime)
@@ -293,10 +303,11 @@ public Aabb GetTransformedAabb(float4x4 matrix)
var p2 = matrix.MultiplyPoint(new float3(Center.x + Radius, Center.y - Radius, ZLow));
var p3 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y + Radius, ZLow));
var p4 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y - Radius, ZLow));
- var p5 = matrix.MultiplyPoint(new float3(Center.x + Radius, Center.y + Radius, ZHigh));
- var p6 = matrix.MultiplyPoint(new float3(Center.x + Radius, Center.y - Radius, ZHigh));
- var p7 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y + Radius, ZHigh));
- var p8 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y - Radius, ZHigh));
+ var zHigh = BoundsZHigh;
+ var p5 = matrix.MultiplyPoint(new float3(Center.x + Radius, Center.y + Radius, zHigh));
+ var p6 = matrix.MultiplyPoint(new float3(Center.x + Radius, Center.y - Radius, zHigh));
+ var p7 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y + Radius, zHigh));
+ var p8 = matrix.MultiplyPoint(new float3(Center.x - Radius, Center.y - Radius, zHigh));
var min = math.min(p1, math.min(p2, math.min(p3, math.min(p4, math.min(p5, math.min(p6, math.min(p7, p8)))))));
var max = math.max(p1, math.max(p2, math.max(p3, math.max(p4, math.max(p5, math.max(p6, math.max(p7, p8)))))));
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs
index dc1474de8..b7c1a83b6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs
@@ -79,7 +79,9 @@ public static void GenerateCollidersFromMesh(Mesh mesh, ColliderInfo info, ref C
var rgv1 = mesh.Vertices[i1].GetVertex().ToUnityFloat3();
var rgv2 = mesh.Vertices[i2].GetVertex().ToUnityFloat3();
- colliders.Add(new TriangleCollider(rgv0, rgv2, rgv1, info), matrix);
+ if (!IsDegenerate(rgv0, rgv1, rgv2)) {
+ colliders.Add(new TriangleCollider(rgv0, rgv2, rgv1, info), matrix);
+ }
if (!onlyTriangles) {
if (addedEdges.ShouldAddHitEdge(i0, i1)) {
@@ -120,7 +122,9 @@ public static void GenerateCollidersFromMesh(in NativeArray vertices, i
var rgv1 = vertices[i1];
var rgv2 = vertices[i2];
- colliders.Add(new TriangleCollider(rgv0, rgv2, rgv1, info), matrix);
+ if (!IsDegenerate(rgv0, rgv1, rgv2)) {
+ colliders.Add(new TriangleCollider(rgv0, rgv2, rgv1, info), matrix);
+ }
if (!onlyTriangles) {
@@ -146,5 +150,23 @@ public static void GenerateCollidersFromMesh(in NativeArray vertices, i
addedEdges.Dispose();
PerfMarker2.End();
}
+
+ ///
+ /// Squared sine of the corner angle below which a mesh triangle counts as
+ /// collinear and gets no triangle collider: such a triangle has no normal,
+ /// and its hit test then reports a contact at distance zero for any ball
+ /// inside its bounds. Its edges and vertices are still added. The test is
+ /// relative to the edge lengths, so it does not depend on the unit the mesh
+ /// comes in (meters or VPX units).
+ ///
+ private const float DegenerateTriangleSinSq = 1e-10f;
+
+ internal static bool IsDegenerate(in float3 rgv0, in float3 rgv1, in float3 rgv2)
+ {
+ var e1 = rgv1 - rgv0;
+ var e2 = rgv2 - rgv0;
+ // |e1 x e2|^2 = |e1|^2 |e2|^2 sin^2(angle); zero-length edges are degenerate too
+ return math.lengthsq(math.cross(e1, e2)) <= DegenerateTriangleSinSq * math.lengthsq(e1) * math.lengthsq(e2);
+ }
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs
index 231593146..c61f20a22 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/TriangleCollider.cs
@@ -30,6 +30,9 @@ namespace VisualPinball.Unity
///
internal struct TriangleCollider : ICollider
{
+ /// Minimal upward normal component for a playfield triangle to count as floor.
+ private const float FloorNormalMinZ = 0.7f;
+
public int Id
{
get => Header.Id;
@@ -176,6 +179,17 @@ public void Collide(ref BallState ball, ref NativeQueue.ParallelWrite
var dot = -math.dot(collEvent.HitNormal, ball.Velocity);
BallCollider.Collide3DWall(ref ball, in Header.Material, in collEvent, in _normal, ref state);
+ // The playfield surface is authoritative, as VP's playfield plane is (see
+ // PlaneCollider.Collide): a ball that something pressed into the floor is
+ // pushed all the way out, not by the per-impact DispLimit only. Floor-like
+ // triangles only, so a wall of a cutout cannot shove a ball sideways.
+ if (Header.ItemType == ItemType.Playfield && _normal.z > FloorNormalMinZ) {
+ var bnd = math.dot(_normal, ball.Position - Rgv0) - ball.Radius;
+ if (bnd < 0) {
+ ball.Position -= _normal * bnd;
+ }
+ }
+
if (Header.FireEvents && dot >= Header.Threshold && Header.IsPrimitive) {
// todo m_obj->m_currentHitThreshold = dot;
Collider.FireHitEvent(ref ball, ref hitEvents, in Header);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs
index f2af18f85..d6c0760bb 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationState.cs
@@ -138,6 +138,8 @@ public struct Snapshot
public long KinematicScanUsec;
public long EventDrainUsec;
public long FenceUpdateIntervalUsec;
+ /// Wall-clock time the simulation thread skipped because its tick backlog exceeded the catch-up bound.
+ public long DroppedBacklogUsec;
public float GamelogicCallbackRateHz;
public int PendingInputActionCount;
public int PendingScheduledActionCount;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs
index 9fd59b731..f409b15b3 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs
@@ -36,6 +36,21 @@ public class SimulationThread : IDisposable
private const long TickIntervalUsec = 1000; // 1ms = 1000 microseconds
private const long BusyWaitThresholdUsec = 25; // Keep active spin short to avoid starving render/main thread
+
+ ///
+ /// Maximal tick backlog the loop replays after a stall. Missed ticks beyond
+ /// this are dropped from the timetable instead of being run back to back,
+ /// which would otherwise fast-forward the game at several times real time
+ /// until the timetable is caught up (a 1.5 s stall used to replay as ~1.5 s
+ /// of physics at 4-5x). Dropping only shortens the timetable; the simulation
+ /// clock itself is not advanced, so dropping never pushes it ahead of Unity's
+ /// scaled clock. If Unity time got ahead during the stall, the regular main-thread
+ /// clock sync in brings the simulation clock up
+ /// and physics steps to it, bounded by PhysicsConstants.MaxSubSteps.
+ /// The bound is generous enough to absorb frame hitches and sleep jitter
+ /// unnoticed, and short enough that a skip reads as a brief hitch.
+ ///
+ private const long MaxBacklogUsec = 100_000;
private const int MaxCoilOutputsPerTick = 128;
#endregion
@@ -63,8 +78,11 @@ public class SimulationThread : IDisposable
// Timing (Stopwatch ticks - avoids high-frequency P/Invoke)
private readonly long _tickIntervalTicks;
private readonly long _busyWaitThresholdTicks;
+ private readonly long _maxBacklogTicks;
private long _lastTickTicks;
private long _simulationTimeUsec;
+ private long _droppedBacklogUsec;
+ private bool _rebaseTimetable;
private long _lastTimeFenceUsec = long.MinValue;
private long _lastTimeFenceIntervalUsec;
private long _lastSimulationTickDurationUsec;
@@ -142,6 +160,7 @@ public SimulationThread(PhysicsEngine physicsEngine, IGamelogicEngine gamelogicE
// Precompute timing constants
_tickIntervalTicks = (Stopwatch.Frequency * TickIntervalUsec) / 1_000_000;
_busyWaitThresholdTicks = (Stopwatch.Frequency * BusyWaitThresholdUsec) / 1_000_000;
+ _maxBacklogTicks = (Stopwatch.Frequency * MaxBacklogUsec) / 1_000_000;
if (_tickIntervalTicks <= 0) {
_tickIntervalTicks = 1;
}
@@ -183,6 +202,10 @@ public void Start()
_inputEventsDropped = 0;
_needsInitialSwitchSync = true;
+ if (SimulationTrace.Enabled) {
+ SimulationTrace.Begin();
+ }
+
_thread = new Thread(SimulationThreadFunc)
{
Name = "VPE Simulation Thread",
@@ -204,9 +227,18 @@ public void Stop()
_running = false;
+ var exited = true;
if (_thread != null && _thread.IsAlive)
{
- _thread.Join(5000); // Wait up to 5 seconds
+ exited = _thread.Join(5000); // Wait up to 5 seconds
+ }
+
+ if (exited) {
+ SimulationTrace.End();
+ } else {
+ // the thread may still be writing into the trace buffers; never free them under it
+ Logger.Warn($"{LogPrefix} [SimulationThread] Thread did not exit within 5 s, abandoning the simulation trace.");
+ SimulationTrace.Abandon();
}
Logger.Info($"{LogPrefix} [SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events, {_inputEventsDropped} dropped");
@@ -403,12 +435,21 @@ private void SimulationThreadFunc()
// Build input mappings once (not on hot path)
BuildInputMappingsIfNeeded();
+ // The initialization wait above may have taken a while; start the
+ // timetable now so it does not begin with a backlog.
+ _lastTickTicks = Stopwatch.GetTimestamp();
+ var iterationStartTicks = _lastTickTicks;
+
// Main simulation loop
while (_running)
{
if (_paused)
{
Thread.Sleep(10);
+ // Unity time stands still while paused (the menu sets timeScale to
+ // zero); hold the timetable so no backlog accumulates to replay.
+ _lastTickTicks = Stopwatch.GetTimestamp();
+ iterationStartTicks = _lastTickTicks;
continue;
}
@@ -416,6 +457,28 @@ private void SimulationThreadFunc()
long targetTicks = _lastTickTicks + _tickIntervalTicks;
long nowTicks = Stopwatch.GetTimestamp();
long sleepTicks = targetTicks - nowTicks;
+ var tracing = SimulationTrace.IsRecording;
+ if (tracing) {
+ SimulationTrace.Tick.WaitRequestedUsec = (int)SimulationTrace.TicksToUsec(sleepTicks);
+ SimulationTrace.Tick.DroppedUsec = 0;
+ SimulationTrace.Tick.WaitMode = 0;
+ }
+
+ // Bound the catch-up after a stall (GC, editor hitch, overloaded ticks):
+ // keep at most MaxBacklogUsec of missed ticks to replay and drop the rest
+ // from the timetable, instead of replaying every missed tick at multiple
+ // real-time speed. See MaxBacklogUsec for why the clock is left alone.
+ if (-sleepTicks > _maxBacklogTicks)
+ {
+ var skippedTicks = -sleepTicks - _maxBacklogTicks;
+ _lastTickTicks += skippedTicks;
+ targetTicks += skippedTicks;
+ sleepTicks += skippedTicks;
+ _droppedBacklogUsec += (skippedTicks * 1_000_000L) / Stopwatch.Frequency;
+ if (tracing) {
+ SimulationTrace.Tick.DroppedUsec = (int)SimulationTrace.TicksToUsec(skippedTicks);
+ }
+ }
if (sleepTicks > _busyWaitThresholdTicks)
{
@@ -425,6 +488,9 @@ private void SimulationThreadFunc()
} else {
Thread.Yield();
}
+ if (tracing) {
+ SimulationTrace.Tick.WaitMode = sleepMs > 0 ? 2 : 1;
+ }
}
#if !UNITY_EDITOR
@@ -436,10 +502,29 @@ private void SimulationThreadFunc()
}
#endif
+ if (tracing) {
+ var startTicks = Stopwatch.GetTimestamp();
+ SimulationTrace.Tick.StartUsec = SimulationTrace.TicksToUsec(startTicks);
+ SimulationTrace.Tick.TargetUsec = SimulationTrace.TicksToUsec(targetTicks);
+ SimulationTrace.Tick.WaitUsec = SimulationTrace.ElapsedUsec(iterationStartTicks, startTicks);
+ }
+
// Execute simulation tick (hot path - must be allocation-free!)
SimulationTick();
+ iterationStartTicks = Stopwatch.GetTimestamp();
- _lastTickTicks = targetTicks;
+ if (_rebaseTimetable)
+ {
+ // The tick synchronized the simulation clock to Unity time, which
+ // already accounts for whatever stalled us; replaying the timetable
+ // debt on top would count that interval twice.
+ _rebaseTimetable = false;
+ _lastTickTicks = Stopwatch.GetTimestamp();
+ }
+ else
+ {
+ _lastTickTicks = targetTicks;
+ }
_tickCount++;
}
}
@@ -465,25 +550,42 @@ private void SimulationThreadFunc()
private void SimulationTick()
{
var tickStartTicks = Stopwatch.GetTimestamp();
+ var tracing = SimulationTrace.IsRecording;
+ if (tracing) {
+ ref var trace = ref SimulationTrace.Tick;
+ trace.Index = _tickCount;
+ trace.SimTimeUsec = _simulationTimeUsec;
+ trace.SyncedClockUsec = _hasMainThreadClockSync ? Interlocked.Read(ref _latestMainThreadClockUsec) : 0;
+ trace.ClockJumpUsec = 0;
+ trace.Gc0 = GC.CollectionCount(0);
+ }
if (_hasMainThreadClockSync) {
var syncedClockUsec = Interlocked.Read(ref _latestMainThreadClockUsec);
if (syncedClockUsec > _simulationTimeUsec) {
+ if (tracing) {
+ SimulationTrace.Tick.ClockJumpUsec = (int)(syncedClockUsec - _simulationTimeUsec);
+ }
_simulationTimeUsec = syncedClockUsec;
+ _rebaseTimetable = true;
}
}
// 0. Process switch events that originated on Unity/main thread.
ProcessExternalSwitchEvents();
+ var switchesDoneTicks = Stopwatch.GetTimestamp();
// 1. Process input events from ring buffer
ProcessInputEvents();
+ var inputDoneTicks = Stopwatch.GetTimestamp();
// 2. Apply low-latency coil outputs from gamelogic to simulation-side handlers.
ProcessGamelogicOutputs();
+ var outputsDoneTicks = Stopwatch.GetTimestamp();
// 3. Update physics simulation
UpdatePhysics();
+ var physicsDoneTicks = Stopwatch.GetTimestamp();
// 4. Move the emulation fence after inputs+outputs+physics.
// Throttle updates to reduce fence wake/sleep churn in PinMAME.
@@ -492,13 +594,25 @@ private void SimulationTick()
_timeFence.SetTimeFence(_simulationTimeUsec / 1_000_000.0);
_lastTimeFenceUsec = _simulationTimeUsec;
}
+ var fenceDoneTicks = Stopwatch.GetTimestamp();
// 5. Write to shared state and swap buffers
WriteSharedState();
// Increment simulation time
_simulationTimeUsec += ScaledTickIntervalUsec();
- _lastSimulationTickDurationUsec = (Stopwatch.GetTimestamp() - tickStartTicks) * 1_000_000L / Stopwatch.Frequency;
+ var tickEndTicks = Stopwatch.GetTimestamp();
+ _lastSimulationTickDurationUsec = (tickEndTicks - tickStartTicks) * 1_000_000L / Stopwatch.Frequency;
+
+ if (tracing) {
+ ref var trace = ref SimulationTrace.Tick;
+ trace.SwitchesUsec = SimulationTrace.ElapsedUsec(tickStartTicks, switchesDoneTicks);
+ trace.InputUsec = SimulationTrace.ElapsedUsec(switchesDoneTicks, inputDoneTicks);
+ trace.OutputsUsec = SimulationTrace.ElapsedUsec(inputDoneTicks, outputsDoneTicks);
+ trace.FenceUsec = SimulationTrace.ElapsedUsec(physicsDoneTicks, fenceDoneTicks);
+ trace.TotalUsec = SimulationTrace.ElapsedUsec(tickStartTicks, tickEndTicks);
+ SimulationTrace.CommitTick();
+ }
}
///
@@ -956,8 +1070,14 @@ private void UpdatePhysics()
// Execute physics tick directly on simulation thread
// This works now because we changed Allocator.Temp to Allocator.TempJob
// in the physics hot path, allowing custom threads to execute physics.
+ var startTicks = Stopwatch.GetTimestamp();
_physicsEngine.ExecuteTick((ulong)_simulationTimeUsec);
+ var executeDoneTicks = Stopwatch.GetTimestamp();
ProcessPlumbTiltEvents();
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.PhysicsUsec = SimulationTrace.ElapsedUsec(startTicks, executeDoneTicks);
+ SimulationTrace.Tick.PlumbUsec = SimulationTrace.ElapsedUsec(executeDoneTicks, Stopwatch.GetTimestamp());
+ }
}
}
@@ -1001,12 +1121,14 @@ private void WriteSharedState()
writeBuffer.RealTimeUsec = GetTimestampUsec();
writeBuffer.SimulationTickDurationUsec = _lastSimulationTickDurationUsec;
writeBuffer.FenceUpdateIntervalUsec = _lastTimeFenceIntervalUsec;
+ writeBuffer.DroppedBacklogUsec = _droppedBacklogUsec;
writeBuffer.LastSwitchDispatchUsec = _lastSwitchDispatchUsec;
writeBuffer.LastFlipperInputUsec = _lastFlipperInputUsec;
writeBuffer.LastCoilDispatchUsec = _lastCoilDispatchUsec;
lock (_externalSwitchQueueLock) {
writeBuffer.ExternalSwitchQueueDepth = _externalSwitchQueue.Count;
}
+ var diagStartTicks = Stopwatch.GetTimestamp();
_physicsEngine.FillDiagnostics(ref writeBuffer);
if (_gamelogicPerformanceStats != null && _gamelogicPerformanceStats.TryGetPerformanceStats(out var performanceStats)) {
writeBuffer.GamelogicCallbackRateHz = performanceStats.CallbackRateHz;
@@ -1015,12 +1137,17 @@ private void WriteSharedState()
writeBuffer.LastSwitchObservationUsec = latencyStats.LastSwitchObservationUsec;
writeBuffer.LastCoilOutputUsec = latencyStats.LastCoilOutputUsec;
}
+ var diagDoneTicks = Stopwatch.GetTimestamp();
// Copy PinMAME state (coils, lamps, GI)
writeBuffer.CoilCount = 0;
writeBuffer.LampCount = 0;
writeBuffer.GICount = 0;
_sharedStateWriter?.WriteSharedState(ref writeBuffer);
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.DiagUsec = SimulationTrace.ElapsedUsec(diagStartTicks, diagDoneTicks);
+ SimulationTrace.Tick.WriterUsec = SimulationTrace.ElapsedUsec(diagDoneTicks, Stopwatch.GetTimestamp());
+ }
// Increment physics state version (main thread will detect changes)
writeBuffer.PhysicsStateVersion++;
@@ -1034,6 +1161,9 @@ private void WriteSharedState()
}
_lastSnapshotCopyUsec = GetTimestampUsec() - snapshotStartUsec;
writeBuffer.SnapshotCopyUsec = _lastSnapshotCopyUsec;
+ if (SimulationTrace.IsRecording) {
+ SimulationTrace.Tick.SnapshotUsec = (int)_lastSnapshotCopyUsec;
+ }
writeBuffer.PublishRealTimeUsec = GetTimestampUsec();
// Atomically publish this buffer (lock-free triple-buffer swap)
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs
index 703574884..7711353d5 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs
@@ -243,6 +243,9 @@ public static SimulationThreadComponent EnsureForTable(GameObject tableRoot)
[Range(1f, 10f)]
public float StatisticsInterval = 5f;
+ [Tooltip("Record the last ten minutes of simulation-thread and frame timing and write them as CSV files when the simulation stops (editor: Logs/SimulationTrace in the project, player: the persistent data path). -simulation-trace and -no-simulation-trace on the command line override this. Not packaged.")]
+ public bool RecordSimulationTrace = true;
+
#endregion
#region Fields
@@ -405,6 +408,10 @@ public void StartSimulation()
}
}
+ // Timing trace of the last minutes, written when the simulation stops
+ SimulationTrace.Enabled = SimulationTrace.CommandLineOverride() ?? RecordSimulationTrace;
+ SimulationTrace.OutputDirectory = SimulationTrace.DefaultOutputDirectory();
+
// Start simulation thread
_simulationThread.Start();
@@ -830,7 +837,7 @@ private void LogStatistics(in SimulationState.Snapshot state)
? state.PublishRealTimeUsec - state.LastCoilDispatchUsec
: -1;
- Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}, Tick={state.SimulationTickDurationUsec}us, Snapshot={state.SnapshotCopyUsec}us, Kinematic={state.KinematicScanUsec}us, EventDrain={state.EventDrainUsec}us, InputQ={state.PendingInputActionCount}, ScheduledQ={state.PendingScheduledActionCount}, SwitchQ={state.ExternalSwitchQueueDepth}, GLE={state.GamelogicCallbackRateHz:F1}Hz, Fence={state.FenceUpdateIntervalUsec}us, SnapshotAge={snapshotAgeUsec}us, Switch->PinMAME={switchToObservationUsec}us, Flipper->Coil={flipperToCoilOutputUsec}us, Coil->Publish={coilDispatchToPublishUsec}us, Balls={state.BallCount}/{state.BallSourceCount}, Floats={state.FloatAnimationCount}/{state.FloatAnimationSourceCount}, Float2={state.Float2AnimationCount}/{state.Float2AnimationSourceCount}");
+ Logger.Info($"{LogPrefix} [SimulationThread] Stats: SimTime={simTimeMs}ms, RealTime={realTimeMs}ms, Ratio={ratio:F3}x, PhysicsVer={state.PhysicsStateVersion}, Tick={state.SimulationTickDurationUsec}us, Snapshot={state.SnapshotCopyUsec}us, Kinematic={state.KinematicScanUsec}us, EventDrain={state.EventDrainUsec}us, InputQ={state.PendingInputActionCount}, ScheduledQ={state.PendingScheduledActionCount}, SwitchQ={state.ExternalSwitchQueueDepth}, GLE={state.GamelogicCallbackRateHz:F1}Hz, Fence={state.FenceUpdateIntervalUsec}us, DroppedBacklog={state.DroppedBacklogUsec / 1000}ms, SnapshotAge={snapshotAgeUsec}us, Switch->PinMAME={switchToObservationUsec}us, Flipper->Coil={flipperToCoilOutputUsec}us, Coil->Publish={coilDispatchToPublishUsec}us, Balls={state.BallCount}/{state.BallSourceCount}, Floats={state.FloatAnimationCount}/{state.FloatAnimationSourceCount}, Float2={state.Float2AnimationCount}/{state.Float2AnimationSourceCount}");
if (state.BallSnapshotsTruncated != 0 || state.FloatAnimationsTruncated != 0 || state.Float2AnimationsTruncated != 0) {
Logger.Warn($"{LogPrefix} [SimulationThread] Snapshot truncation detected: Balls={state.BallSnapshotsTruncated != 0}, Floats={state.FloatAnimationsTruncated != 0}, Float2={state.Float2AnimationsTruncated != 0}");
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs
new file mode 100644
index 000000000..576f98bca
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs
@@ -0,0 +1,628 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+// https://github.com/freezy/VisualPinball.Engine
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+using System;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading;
+using Unity.Collections;
+using Unity.Collections.LowLevel.Unsafe;
+using NLog;
+using Unity.Mathematics;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity.Simulation
+{
+ ///
+ /// Allocation-free timing tracer for the simulation thread and the main-thread
+ /// side of the physics engine. Records one entry per simulation tick, one per
+ /// rendered frame and one per traced main-thread physics lock acquisition into
+ /// preallocated ring buffers, and writes them as CSV (plus a summary) when the
+ /// simulation thread stops.
+ ///
+ ///
+ /// Inactive unless is set before the simulation thread
+ /// starts. While recording, the hot path costs a handful of timestamp reads per
+ /// tick. All timestamps are Stopwatch-based microseconds so tick, frame and lock
+ /// records share one clock.
+ ///
+ public static class SimulationTrace
+ {
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ /// Per simulation tick, written by the simulation thread.
+ public struct TickRecord
+ {
+ public long Index;
+ /// Wall clock at tick start.
+ public long StartUsec;
+ /// Wall clock the tick was scheduled for.
+ public long TargetUsec;
+ /// Simulation clock at tick start, before the main-thread clock sync.
+ public long SimTimeUsec;
+ /// Latest Unity clock published by the main thread.
+ public long SyncedClockUsec;
+ /// Physics frame time after the physics update.
+ public long PhysicsTimeUsec;
+ /// Clock sync advance applied at this tick.
+ public int ClockJumpUsec;
+ /// Timetable backlog dropped before this tick.
+ public int DroppedUsec;
+ /// Time to the scheduled target when the wait started (negative when already late).
+ public int WaitRequestedUsec;
+ /// Time between the end of the previous tick and the start of this one.
+ public int WaitUsec;
+ /// 0 = none, 1 = yield, 2 = sleep.
+ public int WaitMode;
+ public int SwitchesUsec;
+ public int InputUsec;
+ public int OutputsUsec;
+ /// Wait for the physics lock in ExecuteTick.
+ public int LockWaitUsec;
+ /// Staged kinematic transforms applied.
+ public int KinematicUsec;
+ /// Kinematic octree rebuild, when one happened.
+ public int RebuildUsec;
+ /// Burst physics update.
+ public int ExecuteUsec;
+ /// Physics time advanced by the update (1000 per sub step).
+ public int PhysicsAdvanceUsec;
+ /// ExecuteTick total, including the lock wait.
+ public int PhysicsUsec;
+ public int PlumbUsec;
+ public int FenceUsec;
+ /// Gamelogic shared-state writer (coils, lamps, GI).
+ public int WriterUsec;
+ public int DiagUsec;
+ /// Wait for the physics lock in the animation snapshot.
+ public int SnapshotLockUsec;
+ /// Animation snapshot copy, including its lock wait.
+ public int SnapshotUsec;
+ /// SimulationTick total.
+ public int TotalUsec;
+ public int BallCount;
+ /// Staged kinematic samples drained this tick.
+ public int KinematicUpdates;
+ /// Kinematic items currently kept out of the octree.
+ public int MovingItems;
+ /// GC.CollectionCount(0) at tick start.
+ public int Gc0;
+ /// Physics work counters of the update.
+ public PhysicsCounters Counters;
+ public int BallOctreeRefits;
+ }
+
+ /// Per Unity frame, written by the main thread in PhysicsEngine.Update.
+ public struct FrameRecord
+ {
+ public int Frame;
+ public long StartUsec;
+ /// Time.timeAsDouble, the clock the simulation thread is synced to.
+ public long UnityTimeUsec;
+ public long RealtimeUsec;
+ public int UnscaledDeltaUsec;
+ public int DeltaUsec;
+ public float TimeScale;
+ public int Gc0;
+ /// Simulation clock of the snapshot the frame rendered.
+ public long SnapshotSimTimeUsec;
+ /// Wall clock the rendered snapshot was published at.
+ public long SnapshotPublishUsec;
+ public int SnapshotBallCount;
+ public int DrainUsec;
+ /// 1 when the event drain skipped the frame because the simulation thread held the lock.
+ public int DrainSkipped;
+ public int EventsDrained;
+ public int ActionsDrained;
+ public int ScanUsec;
+ public int KinematicChanged;
+ public int KinematicStopped;
+ public int ApplyUsec;
+ /// PhysicsEngine.Update total.
+ public int UpdateUsec;
+ public int Ball0Id;
+ public float3 Ball0;
+ public int Ball1Id;
+ public float3 Ball1;
+ public int Ball2Id;
+ public float3 Ball2;
+ public int Ball3Id;
+ public float3 Ball3;
+ }
+
+ /// A traced main-thread acquisition of the physics lock.
+ public struct LockRecord
+ {
+ public long StartUsec;
+ public LockSite Site;
+ public int WaitUsec;
+ public int HoldUsec;
+ }
+
+ public enum LockSite : byte
+ {
+ Unknown,
+ RegisterBall,
+ UnregisterBall,
+ KickerBallId,
+ NudgeStatus,
+ TiltStatus,
+ NudgeTelemetry,
+ }
+
+ /// Main-thread scope that times a physics lock acquisition.
+ internal readonly struct LockScope : IDisposable
+ {
+ private readonly object _lock;
+ private readonly LockSite _site;
+ private readonly long _startUsec;
+ private readonly long _acquiredUsec;
+
+ public LockScope(object lockObject, LockSite site)
+ {
+ _lock = lockObject;
+ _site = site;
+ _startUsec = NowUsec();
+ Monitor.Enter(lockObject);
+ _acquiredUsec = NowUsec();
+ }
+
+ public void Dispose()
+ {
+ var releasedUsec = NowUsec();
+ Monitor.Exit(_lock);
+ RecordLock(_site, _startUsec, _acquiredUsec, releasedUsec);
+ }
+ }
+
+ private static readonly double TicksToUsecFactor = 1e6 / Stopwatch.Frequency;
+
+ ///
+ /// Whether the next simulation run is recorded. Set by
+ /// from its inspector toggle, unless
+ /// says otherwise.
+ ///
+ public static bool Enabled { get; set; }
+
+ /// Where the files go. See .
+ public static string OutputDirectory { get; set; }
+
+ ///
+ /// Length of the recorded window. Older records are overwritten, so a long
+ /// session keeps its last minutes; memory and per-tick cost do not depend on
+ /// the session length.
+ ///
+ public const int WindowSeconds = 600;
+
+ /// Sessions kept in the output folder; older files are deleted when a new one is written.
+ public const int KeptSessions = 3;
+
+ private const int TickCapacity = WindowSeconds * 1000; // 1 kHz
+ private const int FrameCapacity = WindowSeconds * 240; // up to 240 fps
+ private const int LockCapacity = 1 << 16;
+
+ private static volatile bool _recording;
+ private static unsafe TickRecord* _ticks;
+ private static unsafe FrameRecord* _frames;
+ private static unsafe LockRecord* _locks;
+ private static int _tickHead, _frameHead, _lockHead;
+ private static long _tickCount, _frameCount, _lockCount;
+ private static long _beginUsec;
+
+ ///
+ /// Command-line override for player builds: -simulation-trace records,
+ /// -no-simulation-trace does not. Null when neither is given.
+ ///
+ public static bool? CommandLineOverride()
+ {
+ var result = (bool?)null;
+ foreach (var arg in Environment.GetCommandLineArgs()) {
+ if (string.Equals(arg, "-simulation-trace", StringComparison.OrdinalIgnoreCase)) {
+ result = true;
+ } else if (string.Equals(arg, "-no-simulation-trace", StringComparison.OrdinalIgnoreCase)) {
+ result = false;
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// The project's Logs folder in the editor, the persistent data path in a
+ /// player, each with a SimulationTrace sub folder. Main thread only.
+ ///
+ public static string DefaultOutputDirectory()
+ {
+ var root = UnityEngine.Application.isEditor
+ ? Path.Combine(Directory.GetParent(UnityEngine.Application.dataPath)!.FullName, "Logs")
+ : UnityEngine.Application.persistentDataPath;
+ return Path.Combine(root, "SimulationTrace");
+ }
+
+ /// Current tick, simulation thread only.
+ internal static TickRecord Tick;
+
+ /// Current frame, main thread only.
+ internal static FrameRecord Frame;
+
+ internal static bool IsRecording => _recording;
+
+ internal static long NowUsec() => (long)(Stopwatch.GetTimestamp() * TicksToUsecFactor);
+
+ internal static long TicksToUsec(long ticks) => (long)(ticks * TicksToUsecFactor);
+
+ internal static int ElapsedUsec(long startTicks, long endTicks)
+ {
+ var elapsed = endTicks - startTicks;
+ return elapsed <= 0 ? 0 : (int)(elapsed * TicksToUsecFactor);
+ }
+
+ ///
+ /// Allocates the ring buffers in native memory (outside the managed heap, so
+ /// the collector never scans or moves them) and starts recording. Main
+ /// thread, before the simulation thread starts.
+ ///
+ internal static unsafe void Begin()
+ {
+ if (_recording) {
+ return;
+ }
+ _ticks = (TickRecord*)UnsafeUtility.Malloc((long)TickCapacity * sizeof(TickRecord), UnsafeUtility.AlignOf(), Allocator.Persistent);
+ _frames = (FrameRecord*)UnsafeUtility.Malloc((long)FrameCapacity * sizeof(FrameRecord), UnsafeUtility.AlignOf(), Allocator.Persistent);
+ _locks = (LockRecord*)UnsafeUtility.Malloc((long)LockCapacity * sizeof(LockRecord), UnsafeUtility.AlignOf(), Allocator.Persistent);
+ _tickHead = _frameHead = _lockHead = 0;
+ _tickCount = _frameCount = _lockCount = 0;
+ Tick = default;
+ Frame = default;
+ _beginUsec = NowUsec();
+ _recording = true;
+ Logger.Info($"[SimulationTrace] Recording the last {WindowSeconds / 60} minutes ({(TickCapacity * sizeof(TickRecord) + FrameCapacity * sizeof(FrameRecord)) >> 20} MB).");
+ }
+
+ ///
+ /// Stops recording, writes the files and frees the buffers. Main thread,
+ /// after the simulation thread has exited.
+ ///
+ internal static unsafe void End()
+ {
+ if (!_recording) {
+ return;
+ }
+ _recording = false;
+ try {
+ var directory = string.IsNullOrEmpty(OutputDirectory) ? "SimulationTrace" : OutputDirectory;
+ Directory.CreateDirectory(directory);
+ var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture);
+ WriteTicks(Path.Combine(directory, $"ticks-{stamp}.csv"));
+ WriteFrames(Path.Combine(directory, $"frames-{stamp}.csv"));
+ WriteLocks(Path.Combine(directory, $"locks-{stamp}.csv"));
+ WriteSummary(Path.Combine(directory, $"summary-{stamp}.txt"));
+ DeleteOldSessions(directory, stamp);
+ Logger.Info($"[SimulationTrace] Wrote {_tickCount} ticks, {_frameCount} frames, {_lockCount} lock records to {directory} (stamp {stamp}).");
+ UnityEngine.Debug.Log($"[SimulationTrace] Wrote trace to {directory} (stamp {stamp}).");
+ } catch (Exception e) {
+ Logger.Error(e, "[SimulationTrace] Failed to write trace.");
+ } finally {
+ UnsafeUtility.Free(_ticks, Allocator.Persistent);
+ UnsafeUtility.Free(_frames, Allocator.Persistent);
+ UnsafeUtility.Free(_locks, Allocator.Persistent);
+ _ticks = null;
+ _frames = null;
+ _locks = null;
+ }
+ }
+
+ ///
+ /// Stops recording without writing or freeing anything, for the case where
+ /// the simulation thread could not be joined and may still touch the buffers.
+ /// The buffers are leaked; the next allocates new ones.
+ ///
+ internal static unsafe void Abandon()
+ {
+ if (!_recording) {
+ return;
+ }
+ _recording = false;
+ _ticks = null;
+ _frames = null;
+ _locks = null;
+ }
+
+ /// Keeps the newest sessions in the folder.
+ private static void DeleteOldSessions(string directory, string currentStamp)
+ {
+ var stamps = new System.Collections.Generic.List();
+ foreach (var file in Directory.GetFiles(directory, "summary-*.txt")) {
+ var name = Path.GetFileNameWithoutExtension(file);
+ stamps.Add(name.Substring("summary-".Length));
+ }
+ stamps.Sort(StringComparer.Ordinal);
+ for (var i = 0; i < stamps.Count - KeptSessions; i++) {
+ if (stamps[i] == currentStamp) {
+ continue;
+ }
+ foreach (var prefix in new[] { "ticks-", "frames-", "locks-", "summary-" }) {
+ foreach (var file in Directory.GetFiles(directory, prefix + stamps[i] + ".*")) {
+ File.Delete(file);
+ }
+ }
+ }
+ }
+
+ internal static unsafe void CommitTick()
+ {
+ if (!_recording) {
+ return;
+ }
+ _ticks[_tickHead] = Tick;
+ _tickHead = (_tickHead + 1) % TickCapacity;
+ _tickCount++;
+ }
+
+ internal static void BeginFrame()
+ {
+ Frame = default;
+ }
+
+ internal static unsafe void CommitFrame()
+ {
+ if (!_recording) {
+ return;
+ }
+ _frames[_frameHead] = Frame;
+ _frameHead = (_frameHead + 1) % FrameCapacity;
+ _frameCount++;
+ }
+
+ internal static unsafe void RecordLock(LockSite site, long startUsec, long acquiredUsec, long releasedUsec)
+ {
+ if (!_recording) {
+ return;
+ }
+ _locks[_lockHead] = new LockRecord {
+ StartUsec = startUsec,
+ Site = site,
+ WaitUsec = (int)(acquiredUsec - startUsec),
+ HoldUsec = (int)(releasedUsec - acquiredUsec),
+ };
+ _lockHead = (_lockHead + 1) % LockCapacity;
+ _lockCount++;
+ }
+
+ internal static LockScope Lock(object lockObject, LockSite site) => new(lockObject, site);
+
+ #region Output
+
+ private static int StoredCount(long count, int capacity) => (int)math.min(count, capacity);
+
+ private static int FirstIndex(long count, int capacity, int head) => count < capacity ? 0 : head;
+
+ private static unsafe void WriteTicks(string path)
+ {
+ using var writer = new StreamWriter(path, false, Encoding.ASCII, 1 << 16);
+ writer.WriteLine("index,start_us,target_us,late_us,sim_time_us,synced_clock_us,physics_time_us,clock_jump_us,dropped_us,wait_requested_us,wait_us,wait_mode,switches_us,input_us,outputs_us,lock_wait_us,kinematic_us,rebuild_us,execute_us,physics_advance_us,physics_us,plumb_us,fence_us,writer_us,diag_us,snapshot_lock_us,snapshot_us,total_us,balls,kinematic_updates,moving_items,gc0,iterations,hit_tests,ball_tests,contacts,max_ball_tests,max_ball_id,max_ball_x,max_ball_y,max_ball_z,tests_triangle,tests_line3d,tests_line,tests_point,tests_plane,tests_circle,tests_flipper,tests_other,ball_octree_refits,broad_phase_visits,deepest_dist,deepest_collider,deepest_item,deepest_type,deepest_contact,deepest_ball,deepest_nx,deepest_ny,deepest_nz,deepest_bx,deepest_by,deepest_bz,deepest_vx,deepest_vy,deepest_vz");
+ var sb = new StringBuilder(512);
+ var n = StoredCount(_tickCount, TickCapacity);
+ var i = FirstIndex(_tickCount, TickCapacity, _tickHead);
+ for (var k = 0; k < n; k++, i = (i + 1) % TickCapacity) {
+ ref var t = ref _ticks[i];
+ sb.Clear();
+ sb.Append(t.Index).Append(',').Append(t.StartUsec).Append(',').Append(t.TargetUsec).Append(',')
+ .Append(t.StartUsec - t.TargetUsec).Append(',')
+ .Append(t.SimTimeUsec).Append(',').Append(t.SyncedClockUsec).Append(',').Append(t.PhysicsTimeUsec).Append(',')
+ .Append(t.ClockJumpUsec).Append(',').Append(t.DroppedUsec).Append(',')
+ .Append(t.WaitRequestedUsec).Append(',').Append(t.WaitUsec).Append(',').Append(t.WaitMode).Append(',')
+ .Append(t.SwitchesUsec).Append(',').Append(t.InputUsec).Append(',').Append(t.OutputsUsec).Append(',')
+ .Append(t.LockWaitUsec).Append(',').Append(t.KinematicUsec).Append(',').Append(t.RebuildUsec).Append(',')
+ .Append(t.ExecuteUsec).Append(',').Append(t.PhysicsAdvanceUsec).Append(',').Append(t.PhysicsUsec).Append(',')
+ .Append(t.PlumbUsec).Append(',').Append(t.FenceUsec).Append(',').Append(t.WriterUsec).Append(',').Append(t.DiagUsec).Append(',')
+ .Append(t.SnapshotLockUsec).Append(',').Append(t.SnapshotUsec).Append(',').Append(t.TotalUsec).Append(',')
+ .Append(t.BallCount).Append(',').Append(t.KinematicUpdates).Append(',').Append(t.MovingItems).Append(',').Append(t.Gc0);
+ ref var c = ref t.Counters;
+ sb.Append(',').Append(c.Iterations).Append(',').Append(c.HitTests).Append(',').Append(c.BallTests).Append(',').Append(c.Contacts).Append(',')
+ .Append(c.MaxBallHitTests).Append(',').Append(c.MaxBallId).Append(',')
+ .Append(c.MaxBallPosition.x.ToString("F1", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.MaxBallPosition.y.ToString("F1", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.MaxBallPosition.z.ToString("F1", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.Triangle).Append(',').Append(c.Line3D).Append(',').Append(c.Line).Append(',').Append(c.Point).Append(',')
+ .Append(c.Plane).Append(',').Append(c.Circle).Append(',').Append(c.Flipper).Append(',').Append(c.Other).Append(',')
+ .Append(t.BallOctreeRefits).Append(',').Append(c.BroadPhaseVisits).Append(',')
+ .Append(c.DeepestHitDistance == float.MaxValue ? "" : c.DeepestHitDistance.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitColliderId).Append(',').Append(c.DeepestHitItemId).Append(',').Append(c.DeepestHitType).Append(',')
+ .Append(c.DeepestHitIsContact).Append(',').Append(c.DeepestHitBallId).Append(',')
+ .Append(c.DeepestHitNormal.x.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitNormal.y.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitNormal.z.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallPosition.x.ToString("F2", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallPosition.y.ToString("F2", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallPosition.z.ToString("F2", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallVelocity.x.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallVelocity.y.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(c.DeepestHitBallVelocity.z.ToString("F3", CultureInfo.InvariantCulture));
+ writer.WriteLine(sb.ToString());
+ }
+ }
+
+ private static unsafe void WriteFrames(string path)
+ {
+ using var writer = new StreamWriter(path, false, Encoding.ASCII, 1 << 16);
+ writer.WriteLine("frame,start_us,unity_time_us,realtime_us,unscaled_delta_us,delta_us,time_scale,gc0,snapshot_sim_time_us,snapshot_publish_us,snapshot_age_us,snapshot_balls,drain_us,drain_skipped,events_drained,actions_drained,scan_us,kinematic_changed,kinematic_stopped,apply_us,update_us,ball0_id,ball0_x,ball0_y,ball0_z,ball1_id,ball1_x,ball1_y,ball1_z,ball2_id,ball2_x,ball2_y,ball2_z,ball3_id,ball3_x,ball3_y,ball3_z");
+ var sb = new StringBuilder(512);
+ var n = StoredCount(_frameCount, FrameCapacity);
+ var i = FirstIndex(_frameCount, FrameCapacity, _frameHead);
+ for (var k = 0; k < n; k++, i = (i + 1) % FrameCapacity) {
+ ref var f = ref _frames[i];
+ sb.Clear();
+ sb.Append(f.Frame).Append(',').Append(f.StartUsec).Append(',').Append(f.UnityTimeUsec).Append(',').Append(f.RealtimeUsec).Append(',')
+ .Append(f.UnscaledDeltaUsec).Append(',').Append(f.DeltaUsec).Append(',').Append(f.TimeScale.ToString("R", CultureInfo.InvariantCulture)).Append(',')
+ .Append(f.Gc0).Append(',').Append(f.SnapshotSimTimeUsec).Append(',').Append(f.SnapshotPublishUsec).Append(',')
+ .Append(f.SnapshotPublishUsec > 0 ? f.StartUsec - f.SnapshotPublishUsec : -1).Append(',').Append(f.SnapshotBallCount).Append(',')
+ .Append(f.DrainUsec).Append(',').Append(f.DrainSkipped).Append(',').Append(f.EventsDrained).Append(',').Append(f.ActionsDrained).Append(',')
+ .Append(f.ScanUsec).Append(',').Append(f.KinematicChanged).Append(',').Append(f.KinematicStopped).Append(',')
+ .Append(f.ApplyUsec).Append(',').Append(f.UpdateUsec);
+ AppendBall(sb, f.Ball0Id, f.Ball0);
+ AppendBall(sb, f.Ball1Id, f.Ball1);
+ AppendBall(sb, f.Ball2Id, f.Ball2);
+ AppendBall(sb, f.Ball3Id, f.Ball3);
+ writer.WriteLine(sb.ToString());
+ }
+ }
+
+ private static void AppendBall(StringBuilder sb, int id, float3 position)
+ {
+ sb.Append(',').Append(id).Append(',')
+ .Append(position.x.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(position.y.ToString("F3", CultureInfo.InvariantCulture)).Append(',')
+ .Append(position.z.ToString("F3", CultureInfo.InvariantCulture));
+ }
+
+ private static unsafe void WriteLocks(string path)
+ {
+ using var writer = new StreamWriter(path, false, Encoding.ASCII, 1 << 16);
+ writer.WriteLine("start_us,site,wait_us,hold_us");
+ var n = StoredCount(_lockCount, LockCapacity);
+ var i = FirstIndex(_lockCount, LockCapacity, _lockHead);
+ for (var k = 0; k < n; k++, i = (i + 1) % LockCapacity) {
+ ref var l = ref _locks[i];
+ writer.WriteLine($"{l.StartUsec},{l.Site},{l.WaitUsec},{l.HoldUsec}");
+ }
+ }
+
+ private static unsafe void WriteSummary(string path)
+ {
+ using var writer = new StreamWriter(path, false, Encoding.ASCII);
+ var n = StoredCount(_tickCount, TickCapacity);
+ var first = FirstIndex(_tickCount, TickCapacity, _tickHead);
+ writer.WriteLine($"ticks recorded: {_tickCount} (stored {n}), frames: {_frameCount}, locks: {_lockCount}");
+ if (n == 0) {
+ return;
+ }
+
+ var total = new int[n];
+ var execute = new int[n];
+ var lockWait = new int[n];
+ var snapshotLock = new int[n];
+ var late = new int[n];
+ var gap = new int[n];
+ long droppedTotal = 0, jumpTotal = 0, physicsTotal = 0, gaps5 = 0, gaps20 = 0, gaps100 = 0, multiStep = 0, rebuilds = 0;
+ var gcFirst = _ticks[first].Gc0;
+ var gcLast = gcFirst;
+ var i = first;
+ for (var k = 0; k < n; k++, i = (i + 1) % TickCapacity) {
+ ref var t = ref _ticks[i];
+ total[k] = t.TotalUsec;
+ execute[k] = t.ExecuteUsec;
+ lockWait[k] = t.LockWaitUsec;
+ snapshotLock[k] = t.SnapshotLockUsec;
+ late[k] = (int)(t.StartUsec - t.TargetUsec);
+ gap[k] = t.WaitUsec;
+ droppedTotal += t.DroppedUsec;
+ jumpTotal += t.ClockJumpUsec;
+ physicsTotal += t.PhysicsUsec;
+ if (t.WaitUsec > 5_000) gaps5++;
+ if (t.WaitUsec > 20_000) gaps20++;
+ if (t.WaitUsec > 100_000) gaps100++;
+ if (t.PhysicsAdvanceUsec > 1_000) multiStep++;
+ if (t.RebuildUsec > 0) rebuilds++;
+ gcLast = t.Gc0;
+ }
+ var firstTick = _ticks[first];
+ var lastTick = _ticks[(first + n - 1) % TickCapacity];
+ var wallUsec = lastTick.StartUsec - firstTick.StartUsec;
+ var simUsec = lastTick.SimTimeUsec - firstTick.SimTimeUsec;
+ writer.WriteLine($"wall time: {wallUsec / 1000} ms, sim time advanced: {simUsec / 1000} ms, ticks/s: {(wallUsec > 0 ? n * 1_000_000.0 / wallUsec : 0):F1}");
+ writer.WriteLine($"sim thread busy: {physicsTotal * 100.0 / math.max(1, wallUsec):F1}% in physics, dropped backlog total: {droppedTotal / 1000} ms, clock jumps total: {jumpTotal / 1000} ms");
+ writer.WriteLine($"ticks with >1 sub step: {multiStep}, octree rebuilds: {rebuilds}, gc0 collections during run: {gcLast - gcFirst}");
+ writer.WriteLine($"wait gaps >5 ms: {gaps5}, >20 ms: {gaps20}, >100 ms: {gaps100}");
+ WritePercentiles(writer, "tick total us", total);
+ WritePercentiles(writer, "execute us", execute);
+ WritePercentiles(writer, "lock wait us", lockWait);
+ WritePercentiles(writer, "snapshot lock wait us", snapshotLock);
+ WritePercentiles(writer, "lateness us", late);
+ WritePercentiles(writer, "wait gap us", gap);
+
+ // largest gaps between consecutive ticks, with what preceded them
+ writer.WriteLine();
+ writer.WriteLine("largest wait gaps (gap = end of previous tick to start of this tick):");
+ writer.WriteLine(" start_ms_rel gap_us requested_us mode gc0_delta prev_total_us prev_physics_us prev_snapshot_us prev_lock_wait_us clock_jump_us dropped_us balls");
+ var order = new int[n];
+ for (var k = 0; k < n; k++) order[k] = k;
+ Array.Sort(order, (a, b) => gap[b].CompareTo(gap[a]));
+ for (var k = 0; k < math.min(40, n); k++) {
+ var idx = order[k];
+ var cur = _ticks[(first + idx) % TickCapacity];
+ var prev = idx > 0 ? _ticks[(first + idx - 1) % TickCapacity] : default;
+ writer.WriteLine($" {(cur.StartUsec - _beginUsec) / 1000,12} {cur.WaitUsec,6} {cur.WaitRequestedUsec,12} {cur.WaitMode,4} {cur.Gc0 - prev.Gc0,9} {prev.TotalUsec,13} {prev.PhysicsUsec,15} {prev.SnapshotUsec,16} {prev.LockWaitUsec,17} {cur.ClockJumpUsec,13} {cur.DroppedUsec,10} {cur.BallCount,5}");
+ }
+
+ // slowest ticks
+ writer.WriteLine();
+ writer.WriteLine("slowest ticks:");
+ writer.WriteLine(" start_ms_rel total_us lock_wait_us kinematic_us rebuild_us execute_us advance_us plumb_us fence_us writer_us diag_us snapshot_lock_us snapshot_us switches_us input_us outputs_us balls iterations hit_tests ball_tests contacts max_ball_tests triangle line3d line point plane circle flipper other bp_visits max_ball_pos");
+ Array.Sort(order, (a, b) => total[b].CompareTo(total[a]));
+ for (var k = 0; k < math.min(40, n); k++) {
+ var cur = _ticks[(first + order[k]) % TickCapacity];
+ var c = cur.Counters;
+ writer.WriteLine($" {(cur.StartUsec - _beginUsec) / 1000,12} {cur.TotalUsec,8} {cur.LockWaitUsec,12} {cur.KinematicUsec,12} {cur.RebuildUsec,10} {cur.ExecuteUsec,10} {cur.PhysicsAdvanceUsec,10} {cur.PlumbUsec,8} {cur.FenceUsec,8} {cur.WriterUsec,9} {cur.DiagUsec,7} {cur.SnapshotLockUsec,16} {cur.SnapshotUsec,11} {cur.SwitchesUsec,11} {cur.InputUsec,8} {cur.OutputsUsec,10} {cur.BallCount,5} {c.Iterations,10} {c.HitTests,9} {c.BallTests,10} {c.Contacts,8} {c.MaxBallHitTests,14} {c.Triangle,8} {c.Line3D,6} {c.Line,4} {c.Point,5} {c.Plane,5} {c.Circle,6} {c.Flipper,7} {c.Other,5} {c.BroadPhaseVisits,9} ({c.MaxBallPosition.x:F0},{c.MaxBallPosition.y:F0},{c.MaxBallPosition.z:F0})");
+ }
+
+ // frames
+ var fn = StoredCount(_frameCount, FrameCapacity);
+ if (fn > 0) {
+ var ff = FirstIndex(_frameCount, FrameCapacity, _frameHead);
+ var frameDelta = new int[fn];
+ var snapshotAge = new int[fn];
+ var update = new int[fn];
+ long staleSnapshots = 0, drainSkipped = 0;
+ long lastPublish = -1;
+ var fi = ff;
+ for (var k = 0; k < fn; k++, fi = (fi + 1) % FrameCapacity) {
+ ref var f = ref _frames[fi];
+ frameDelta[k] = f.UnscaledDeltaUsec;
+ snapshotAge[k] = f.SnapshotPublishUsec > 0 ? (int)(f.StartUsec - f.SnapshotPublishUsec) : 0;
+ update[k] = f.UpdateUsec;
+ if (f.SnapshotPublishUsec == lastPublish) staleSnapshots++;
+ lastPublish = f.SnapshotPublishUsec;
+ drainSkipped += f.DrainSkipped;
+ }
+ writer.WriteLine();
+ writer.WriteLine($"frames: {fn}, frames that re-rendered the previous snapshot: {staleSnapshots}, event drains skipped: {drainSkipped}");
+ WritePercentiles(writer, "frame delta us", frameDelta);
+ WritePercentiles(writer, "snapshot age us", snapshotAge);
+ WritePercentiles(writer, "physics update us", update);
+ }
+
+ // locks
+ var ln = StoredCount(_lockCount, LockCapacity);
+ if (ln > 0) {
+ var lf = FirstIndex(_lockCount, LockCapacity, _lockHead);
+ var waits = new int[ln];
+ var holds = new int[ln];
+ var li = lf;
+ for (var k = 0; k < ln; k++, li = (li + 1) % LockCapacity) {
+ waits[k] = _locks[li].WaitUsec;
+ holds[k] = _locks[li].HoldUsec;
+ }
+ writer.WriteLine();
+ writer.WriteLine($"main-thread physics lock acquisitions: {ln}");
+ WritePercentiles(writer, "main lock wait us", waits);
+ WritePercentiles(writer, "main lock hold us", holds);
+ }
+ }
+
+ private static void WritePercentiles(TextWriter writer, string label, int[] values)
+ {
+ var sorted = (int[])values.Clone();
+ Array.Sort(sorted);
+ var n = sorted.Length;
+ int P(double p) => sorted[math.clamp((int)(p * (n - 1)), 0, n - 1)];
+ writer.WriteLine($"{label,-24} p50={P(0.5),8} p90={P(0.9),8} p99={P(0.99),8} p99.9={P(0.999),8} max={sorted[n - 1],8}");
+ }
+
+ #endregion
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs.meta
new file mode 100644
index 000000000..cc283936a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5a5217dbd8c2a480f78e0d1443f8c822
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs
index 9fdc4b7c0..873b27288 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallCollider.cs
@@ -166,8 +166,13 @@ internal static void HandleStaticContact(ref BallState ball, in CollisionEventDa
// error accumulates until a one-sided triangle rejects the ball as being
// behind it. Keep kinematic contacts unchanged so an intentionally moving
// support remains authoritative while it carries the ball.
+ // Never recover along gravity: a collider embedded in the ball from above
+ // (a cover or wire lower than the ball is tall) would otherwise shove the
+ // ball through the floor, up to DispLimit per tick, and the floor's own
+ // recovery would fight it, leaving the ball visibly sunk while it rolls.
if (collEvent.ColliderId >= 0 && !collEvent.IsKinematic &&
- collEvent.HitDistance < -StaticContactPenetrationTolerance) {
+ collEvent.HitDistance < -StaticContactPenetrationTolerance &&
+ math.dot(collEvent.HitNormal, gravity) <= 0.2f * math.length(gravity)) {
var correction = math.min(-PhysicsConstants.DispGain * collEvent.HitDistance,
PhysicsConstants.DispLimit);
ball.Position += collEvent.HitNormal * correction;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
index cdcd58668..2a23a9ad0 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Ball/BallState.cs
@@ -71,6 +71,11 @@ public struct BallState
public BallPositions LastPositions;
+ ///
+ /// Conservative bounds: the ball inflated by a full step's velocity in every
+ /// direction (VP's hit box). The physics cycle uses
+ /// instead, which only covers the time actually searched.
+ ///
public Aabb Aabb {
get {
var vl = math.length(Velocity) + Radius + 0.05f; // 0.05f = paranoia
@@ -85,6 +90,27 @@ public Aabb Aabb {
}
}
+ ///
+ /// Bounds of the volume the ball can occupy within
+ /// (in step units, the same as ): the segment from the
+ /// current position to the position after , inflated
+ /// by the radius and the contact margin.
+ ///
+ ///
+ /// The narrow phase rejects hits later than the searched time, and the
+ /// velocity is constant between collisions, so nothing outside this box can
+ /// be hit within the search window. VP inflates by the full 10 ms velocity in
+ /// every direction because its search window is the full step; with 1 ms
+ /// sub cycles that box is up to ten times larger than needed along the motion
+ /// and pulls in every collider around a fast ball.
+ ///
+ public Aabb GetSweptAabb(float dTime)
+ {
+ var end = Position + Velocity * math.max(0f, dTime);
+ var margin = Radius + 0.05f; // 0.05f = paranoia, matches Aabb
+ return new Aabb(math.min(Position, end) - margin, math.max(Position, end) + margin);
+ }
+
public float CollisionRadiusSqr {
get {
var v1 = math.length(Velocity) + Radius + 0.05f;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs
index 7b25f6ed8..1598922c5 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/ColliderComponent.cs
@@ -171,6 +171,7 @@ public virtual void OnTransformationChanged(float4x4 currTransformationMatrix)
#if UNITY_EDITOR
private Player _player;
+ private PlayfieldComponent _playfieldComponent;
private NativeOctree _octree;
///
@@ -207,11 +208,13 @@ private void OnDrawGizmos()
if (linSq < 1e-6f && angSq < 1e-6f) {
return;
}
- var playfieldComponent = GetComponentInParent();
- if (!playfieldComponent) {
- return;
+ if (!_playfieldComponent) {
+ _playfieldComponent = GetComponentInParent();
+ if (!_playfieldComponent) {
+ return;
+ }
}
- var vpxToWorld = playfieldComponent.transform.localToWorldMatrix * (Matrix4x4)Physics.VpxToWorld;
+ var vpxToWorld = _playfieldComponent.transform.localToWorldMatrix * (Matrix4x4)Physics.VpxToWorld;
var origin = vpxToWorld.MultiplyPoint3x4(pivot);
if (linSq >= 1e-6f) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
index 1e407c1cb..3f2046e9f 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Flipper/FlipperCollider.cs
@@ -72,91 +72,42 @@ public FlipperCollider(float height, float flipperRadius, float startRadius, flo
_zLow = bounds.Aabb.ZLow;
_zHigh = bounds.Aabb.ZHigh;
- // compute bounds. we look at the flipper angles to compute the smallest possible bounds.
- var r2 = endRadius + 0.1f;
- var r3 = startRadius + 0.1f;
-
- var a0 = ClampDegrees(startAngle);
- var a1 = ClampDegrees(endAngle);
-
- // start with no bounds
- var aabb = new Aabb(0, 0, 0, 0, _zLow, _zHigh);
-
- // extend with start and end position
- aabb = ExtendBoundsAtPosition(aabb, flipperRadius, r2, a0);
- aabb = ExtendBoundsAtPosition(aabb, flipperRadius, r2, a1);
-
- // extend with extremes (-90°, 0°, 90° and 180°)
- aabb = ExtendBoundsAtExtreme(aabb, flipperRadius, r2, r3, a0, a1, -90f);
- aabb = ExtendBoundsAtExtreme(aabb, flipperRadius, r2, r3, a0, a1, 0f);
- aabb = ExtendBoundsAtExtreme(aabb, flipperRadius, r2, r3, a0, a1, 90f);
- aabb = ExtendBoundsAtExtreme(aabb, flipperRadius, r2, r3, a0, a1, 180f);
-
- // var l = flipperRadius * 1.2f;
- // aabb = new Aabb(-l, l, -l, l, -l, l);
-
- Bounds = new ColliderBounds(Header.ItemId, Header.Id, aabb);
- }
-
- private static Aabb ExtendBoundsAtExtreme(Aabb aabb, float length, float endRadius, float startRadius, float startAngle, float endAngle, float angle)
- {
- if (startAngle < angle && endAngle > angle || endAngle < angle && startAngle > angle) {
- // extend front side
- return ExtendBoundsAtPosition(aabb, length, endRadius, angle);
- }
-
- // extend back side
- return ExtendBacksideBounds(aabb, startRadius, ClampDegrees(angle + 180));
- }
-
- private static Aabb ExtendBacksideBounds(Aabb bounds, float fixedRadius, float angle)
- {
- switch (angle) {
- case -90f: bounds.Right = math.max(bounds.Right, fixedRadius); break;
- case 90f: bounds.Left = math.min(bounds.Left, fixedRadius); break;
- case 0f: bounds.Bottom = math.max(bounds.Bottom, fixedRadius); break;
- case 180f: bounds.Top = math.min(bounds.Top, fixedRadius); break;
- }
-
- return bounds;
- }
-
- private static Aabb ExtendBoundsAtPosition(Aabb bounds, float length, float fixedRadius, float angle)
- {
- var deg = ClampDegrees(angle);
- if (deg > 0) {
- var l = math.sin(math.radians(180 - deg));
- var d1 = length * l;
- var d2 = math.sign(l) * fixedRadius;
- bounds.Right = math.max(bounds.Right, d1 + d2);
-
- } else {
- var l = math.sin(math.radians(180 - deg));
- var d1 = length * l;
- var d2 = math.sign(l) * fixedRadius;
- bounds.Left = math.min(bounds.Left, d1 + d2);
- }
-
- if (deg > 90 || deg < -90) {
- var l = math.cos(math.radians(180 - deg));
- var d1 = length * l;
- var d2 = math.sign(l) * fixedRadius;
- bounds.Bottom = math.max(bounds.Bottom, d1 + d2);
-
- } else {
- var l = math.cos(math.radians(180 - deg));
- var d1 = length * l;
- var d2 = math.sign(l) * fixedRadius;
- bounds.Top = math.min(bounds.Top, d1 + d2);
- }
-
- return bounds;
+ Bounds = new ColliderBounds(Header.ItemId, Header.Id,
+ ComputeSweepBounds(flipperRadius, baseRadius, endRadius, startAngle, endAngle, _zLow, _zHigh));
}
- private static float ClampDegrees(float angle)
+ ///
+ /// Bounds of everything the flipper can occupy: the base circle plus the end
+ /// circle at every angle of the sweep from to
+ /// , sampled every two degrees. Local space, the
+ /// base at the origin; angle 0 points to -y, 90 to +x (see the hit test).
+ ///
+ ///
+ /// The previous closed-form version extended the box towards the sweep
+ /// extremes but dropped the base radius on the sides the flipper never
+ /// points to, so a ball touching the base from those sides was outside the
+ /// box. That went unnoticed while the broad phase inflated the ball's box by a
+ /// full step of velocity.
+ ///
+ internal static Aabb ComputeSweepBounds(float flipperRadius, float baseRadius, float endRadius, float startAngle, float endAngle, float zLow, float zHigh)
{
- var deg = angle % 360;
- return deg > 180 ? deg - 360 : deg;
+ const float margin = 0.1f;
+ var r2 = endRadius + margin;
+ var r3 = baseRadius + margin;
+ var aabb = new Aabb(-r3, r3, -r3, r3, zLow, zHigh);
+
+ var a0 = math.radians(startAngle);
+ var a1 = math.radians(endAngle);
+ var steps = math.max(1, (int)math.ceil(math.abs(a1 - a0) / math.radians(2f)));
+ for (var i = 0; i <= steps; i++) {
+ var a = math.lerp(a0, a1, (float)i / steps);
+ var center = new float2(math.sin(a), -math.cos(a)) * flipperRadius;
+ aabb.Left = math.min(aabb.Left, center.x - r2);
+ aabb.Right = math.max(aabb.Right, center.x + r2);
+ aabb.Top = math.min(aabb.Top, center.y - r2);
+ aabb.Bottom = math.max(aabb.Bottom, center.y + r2);
+ }
+ return aabb;
}
#endregion
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
index 0da4abb81..d17c4fac5 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerApi.cs
@@ -17,16 +17,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using NLog;
using Unity.Mathematics;
using UnityEngine;
using VisualPinball.Engine.VPT.Kicker;
using VisualPinball.Unity.Collections;
+using Logger = NLog.Logger;
namespace VisualPinball.Unity
{
public class KickerApi : CollidableApi,
IApi, IApiHittable, IApiSwitch, IApiSwitchDevice, IApiCoilDevice, IApiWireDeviceDest
{
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
///
/// Event emitted when the table is started.
///
@@ -78,12 +82,33 @@ void IApi.OnDestroy()
}
public void CreateBall(GameObject ballPrefab = null, float radius = 25f, float mass = 1f)
+ {
+ CreateCapturedBall(ballPrefab, radius, mass);
+ }
+
+ public void CreateSizedBallWithMass(float radius, float mass)
+ {
+ CreateCapturedBall(null, radius, mass);
+ }
+
+ ///
+ /// Creates a ball in this kicker and captures it right away, like VP's
+ /// CreateSizedBallWithMass does, so that the next kick launches it from the
+ /// kicker's capture position.
+ ///
+ private void CreateCapturedBall(GameObject ballPrefab, float radius, float mass)
{
var ballId = BallManager.CreateBall(MainComponent, radius, mass, ballPrefab);
var kickerId = ItemId;
+ var kickerName = MainComponent.name; // resolved here, the callback runs on the simulation thread
PhysicsEngine.MutateState((ref PhysicsState state) => {
- if (!state.Balls.ContainsKey(ballId) || !state.KickerStates.ContainsKey(kickerId)) {
+ if (!state.Balls.ContainsKey(ballId)) {
+ Logger.Warn($"Kicker \"{kickerName}\": ball {ballId} was created but is unknown to the physics state, it cannot be captured.");
+ return;
+ }
+ if (!state.KickerStates.ContainsKey(kickerId)) {
+ Logger.Warn($"Kicker \"{kickerName}\": no kicker state, ball {ballId} stays where it was created.");
return;
}
@@ -100,12 +125,13 @@ public void CreateBall(GameObject ballPrefab = null, float radius = 25f, float m
KickerCollider.Collide(new float3(kickerState.Static.Center, kickerState.Static.ZLow),
ref ball, ref events, ref state.InsideOfs, ref kickerState.Collision,
in kickerState.Static, in kickerState.CollisionMesh, in collEvent, kickerId, true);
+
+ // A created ball is meant to sit captured in this kicker. If it does not, the next
+ // kick launches whatever the kicker holds from wherever it is, so say so.
+ if (!kickerState.Static.FallThrough && kickerState.Collision.BallId != ballId) {
+ Logger.Warn($"Kicker \"{kickerName}\": created ball {ballId} was not captured, the kicker holds ball {kickerState.Collision.BallId}. The new ball is at {ball.Position}.");
+ }
});
- }
-
- public void CreateSizedBallWithMass(float radius, float mass)
- {
- BallManager.CreateBall(MainComponent, radius, mass);
}
public void Kick(float angle, float speed, float inclination = 0)
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerCollider.cs
index b098aaf98..a36b1a79a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerCollider.cs
@@ -37,7 +37,13 @@ public static void Collide(float3 position, ref BallState ball, ref NativeQueue<
{
// a previous ball already in kicker?
if (collState.HasBall) {
- return;
+ // a stale reference to this very ball (its id was handed out again) must not
+ // block its own capture, or the new ball stays wherever it was created and the
+ // next kick launches it from there
+ if (!newBall || collState.BallId != ball.Id) {
+ return;
+ }
+ collState.BallId = 0;
}
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
@@ -45,8 +51,10 @@ public static void Collide(float3 position, ref BallState ball, ref NativeQueue<
var hitNormal = collEvent.HitNormal;
var hitBit = collEvent.HitFlag;
- // check if kicker in ball's volume set
- var isBallInside = insideOfs.IsInsideOf(itemId, ball.Id);
+ // check if kicker in ball's volume set. A new ball cannot be inside anything yet;
+ // a membership left behind under the same id would send it down the exit branch
+ // and leave it uncaptured.
+ var isBallInside = !newBall && insideOfs.IsInsideOf(itemId, ball.Id);
// if "New or (Hit && !Vol || UnHit && Vol)", continue.
if (!newBall && hitBit != isBallInside) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs
index 19ce23bb4..352604925 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Kicker/KickerComponent.cs
@@ -285,22 +285,27 @@ public override void CopyFromObject(GameObject go)
internal KickerState CreateState()
{
+ // The capture position and the hit mesh live in playfield space, like the colliders.
+ // A kicker grouped under an offset parent would otherwise hold its ball somewhere else
+ // than where its collider is.
+ var position = PositionInPlayfield;
+
// collision
var colliderComponent = GetComponent();
var staticData = colliderComponent
? new KickerStaticState {
- Center = new float2(Position.x, Position.y),
+ Center = new float2(position.x, position.y),
FallIn = colliderComponent.FallIn,
FallThrough = colliderComponent.FallThrough,
HitAccuracy = colliderComponent.HitAccuracy,
Scatter = colliderComponent.Scatter,
LegacyMode = colliderComponent.LegacyMode,
- ZLow = Position.z
+ ZLow = position.z
} : default;
var meshData = colliderComponent.LegacyMode
? new ColliderMeshData(Array.Empty(), 0, float3.zero, Allocator.Persistent)
- : new ColliderMeshData(KickerHitMesh.Vertices, Radius, Position, Allocator.Persistent);
+ : new ColliderMeshData(KickerHitMesh.Vertices, Radius, position, Allocator.Persistent);
return new KickerState(
staticData,
@@ -371,11 +376,13 @@ private void Start()
#region IBallCreationPosition
// The ball is parented to the playfield, so its spawn point has to be expressed in playfield
- // space - not relative to whatever the kicker happens to be grouped under.
+ // space - not relative to whatever the kicker happens to be grouped under. Like VP, the ball
+ // spawns at the kicker's own height: a kicker sunk below the playfield (a modelled trough
+ // exit) creates its ball down there, where the capture puts it anyway.
public Vertex3D GetBallCreationPosition()
{
var position = PositionInPlayfield;
- return new Vertex3D(position.x, position.y, 0);
+ return new Vertex3D(position.x, position.y, position.z);
}
public Vertex3D GetBallCreationVelocity() => new Vertex3D(0.1f, 0, 0);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
index a25138fb3..5de69bd6e 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Plunger/PlungerCollider.cs
@@ -87,13 +87,14 @@ public PlungerCollider(PlungerComponent comp, PlungerColliderComponent collComp,
JointEnd1 = new LineZCollider(new float2(x2, position), zHeight, zHeight + Plunger.PlungerHeight, info);
PosY = 0;
+ // same height range as the line colliders above
Bounds = new ColliderBounds(Header.ItemId, Header.Id, new Aabb(
x - 0.1f,
x2 + 0.1f,
frameTop - 0.1f,
y + 0.1f,
- 0,
- 50
+ zHeight,
+ zHeight + Plunger.PlungerHeight
));
}