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 )); }