From 921a54ef1e1134984597f45d3330aab7a878d6a7 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 16 Sep 2026 14:05:15 +0200 Subject: [PATCH 01/14] physics: bound simulation thread catch-up The simulation loop schedules ticks against a wall-clock timetable and, after a stall, replayed every missed tick back to back. A 1.5 s stall ran physics at four to five times real time until the timetable was caught up, which players saw as balls racing down the playfield right after a kinematic mechanism stopped moving. Resuming from the in-game pause menu replayed the whole pause the same way. Keep at most 100 ms of missed ticks to replay and drop the rest from the timetable. The simulation clock is not advanced over the dropped interval; if Unity time got ahead during the stall, the existing main-thread clock sync brings the clock up and physics steps to it within MaxSubSteps. A sync that advances the clock also rebases the timetable so the same interval is not replayed on top. The paused branch and the post-initialization start re-anchor the timetable so no backlog accumulates while Unity time stands still. Dropped time is published in the snapshot and the statistics line. --- .../Simulation/SimulationState.cs | 2 + .../Simulation/SimulationThread.cs | 54 ++++++++++++++++++- .../Simulation/SimulationThreadComponent.cs | 2 +- 3 files changed, 56 insertions(+), 2 deletions(-) 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..3dd00e571 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; } @@ -403,12 +422,19 @@ 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(); + // 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(); continue; } @@ -417,6 +443,19 @@ private void SimulationThreadFunc() long nowTicks = Stopwatch.GetTimestamp(); long sleepTicks = targetTicks - nowTicks; + // 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 (sleepTicks > _busyWaitThresholdTicks) { var sleepMs = (int)(((sleepTicks - _busyWaitThresholdTicks) * 1000) / Stopwatch.Frequency); @@ -439,7 +478,18 @@ private void SimulationThreadFunc() // Execute simulation tick (hot path - must be allocation-free!) SimulationTick(); - _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++; } } @@ -470,6 +520,7 @@ private void SimulationTick() var syncedClockUsec = Interlocked.Read(ref _latestMainThreadClockUsec); if (syncedClockUsec > _simulationTimeUsec) { _simulationTimeUsec = syncedClockUsec; + _rebaseTimetable = true; } } @@ -1001,6 +1052,7 @@ private void WriteSharedState() writeBuffer.RealTimeUsec = GetTimestampUsec(); writeBuffer.SimulationTickDurationUsec = _lastSimulationTickDurationUsec; writeBuffer.FenceUpdateIntervalUsec = _lastTimeFenceIntervalUsec; + writeBuffer.DroppedBacklogUsec = _droppedBacklogUsec; writeBuffer.LastSwitchDispatchUsec = _lastSwitchDispatchUsec; writeBuffer.LastFlipperInputUsec = _lastFlipperInputUsec; writeBuffer.LastCoilDispatchUsec = _lastCoilDispatchUsec; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs index 703574884..f132d3d9c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThreadComponent.cs @@ -830,7 +830,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}"); From 295828f0c344021fd947d106845635af8166639f Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 16 Sep 2026 14:05:15 +0200 Subject: [PATCH 02/14] physics: burst-compile kinematic octree rebuild PhysicsKinematics.RebuildOctree walks every kinematic collider of the table and was called from managed code once per Unity frame whenever a kinematic item moved. In the editor one rebuild took 12 to 14 ms on a simulation thread that already spends about 0.6 ms of every 1 ms tick, so a moving mechanism saturated the thread and physics time fell up to 1.75 s behind the wall clock. Route the rebuild through a Burst-compiled entry point in PhysicsUpdate. Measured in the same scenario, a rebuild now takes about 3 ms and the thread keeps its 1 kHz pace while the mechanism moves. Add sim-tick diagnostics: the last rebuild duration and count and the last and maximal physics step duration are recorded per tick and exposed through PhysicsEngine.GetSimulationTimingDiagnostics, and PhysicsEngine.VisitBallStates schedules a read-only visit of all ball states on the simulation thread for tooling. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 42 +++++++++++++++++++ .../Game/PhysicsEngineContext.cs | 6 +++ .../Game/PhysicsEngineThreading.cs | 13 +++++- .../VisualPinball.Unity/Game/PhysicsUpdate.cs | 12 ++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 29487defe..89bb6d39d 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -922,6 +922,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. diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index fe229afdf..33369c5c3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs @@ -284,6 +284,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 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index b1c09a9d4..acbd92b18 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -224,8 +224,11 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) // Rebuild kinematic octree only when transforms have changed. if (_ctx.KinematicOctreeDirty) { - PhysicsKinematics.RebuildOctree(ref _ctx.KinematicOctree, ref state); + var rebuildStartTicks = Stopwatch.GetTimestamp(); + PhysicsUpdate.RebuildKinematicOctree(ref _ctx.KinematicOctree, ref state); _ctx.KinematicOctreeDirty = false; + Interlocked.Exchange(ref _ctx.LastKinematicOctreeRebuildUsec, ElapsedUsec(rebuildStartTicks, Stopwatch.GetTimestamp())); + Interlocked.Increment(ref _ctx.KinematicOctreeRebuildCount); } // process input @@ -234,6 +237,7 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ProcessPendingNudgeSensorSamples(); // run physics loop (Burst-compiled, thread-safe) + var executeStartTicks = Stopwatch.GetTimestamp(); PhysicsUpdate.Execute( ref state, ref _ctx.PhysicsEnv, @@ -243,6 +247,11 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ref _ctx.PhysicsCycle, currentTimeUsec ); + var executeUsec = ElapsedUsec(executeStartTicks, Stopwatch.GetTimestamp()); + Interlocked.Exchange(ref _ctx.LastPhysicsExecuteUsec, executeUsec); + if (executeUsec > Interlocked.Read(ref _ctx.MaxPhysicsExecuteUsec)) { + Interlocked.Exchange(ref _ctx.MaxPhysicsExecuteUsec, executeUsec); + } Interlocked.Exchange(ref _ctx.PublishedPhysicsFrameTimeUsec, (long)_ctx.PhysicsEnv.CurPhysicsFrameTime); RecordPhysicsBusyTime(sw.ElapsedTicks); @@ -915,7 +924,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) // 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/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) { From 6f42f8f1fcd25064ab19ba66c53693c738c7c2ce Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 16 Sep 2026 14:42:55 +0200 Subject: [PATCH 03/14] physics: keep moving kinematic items out of the octree Every new kinematic target, once per Unity frame while a mechanism moves, marked the kinematic octree dirty and rebuilt it from scratch, walking every kinematic collider of the table. Even Burst-compiled that is about 3 ms per frame on the simulation thread. The octree now only holds idle items. When an item starts streaming targets it is taken out of the octree (one rebuild without it) and its colliders are broad-phased directly: a one-test rejection against the item's current bounds, then the colliders' current bounds against the ball. The ball bounds are inflated by the surface velocity the narrow phase subtracts, over one tick, so approaching surfaces are still admitted the way the swept octree bounds admitted them. Once an item has had no new target for 250 ms and its pose has settled, it returns to the octree (one rebuild with it). Snapping an idle item in place still rebuilds; snapping a moving item only refreshes its bounds. Non-baked kinematic collider types keep their identity bounds, so their current bounds are derived from the identity collider and the item transform, the same as the rebuild does. Measured on a table's moving building mechanism: 13 rebuilds for a full up, shake and descent sequence instead of one per frame, with the simulation thread holding 1 kHz throughout. --- .../Physics/KinematicBroadPhaseTests.cs | 161 ++++++++++++++++++ .../Physics/KinematicBroadPhaseTests.cs.meta | 11 ++ .../Physics/MagnetPhysicsTests.cs | 5 +- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 1 + .../Game/PhysicsEngineContext.cs | 39 ++++- .../Game/PhysicsEngineThreading.cs | 84 ++++++++- .../Game/PhysicsKinematics.cs | 31 +++- .../VisualPinball.Unity/Game/PhysicsState.cs | 46 ++++- .../Game/PhysicsStaticBroadPhase.cs | 61 +++++++ 9 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs.meta 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..650e13f84 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs @@ -0,0 +1,161 @@ +// 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.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); + Assert.That(overlaps.Contains(colliderId), Is.True, "idle item must be in the octree"); + overlaps.Clear(); + PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps); + 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); + Assert.That(overlaps.Count(), Is.Zero, "moving item must be excluded from the octree"); + PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps); + 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); + 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.Aabb), Is.True); + Assert.That(bounds.IntersectRect(ballFarAway.Aabb), Is.False); + overlaps.Clear(); + PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ballOnFloor, ref overlaps); + 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); + 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); + 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); + 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/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index da59eb003..b7e5a2c08 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -77,6 +77,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov PhysicsStaticNarrowPhase.FindNextCollision(ref state.Colliders, ref ball, ref overlappingColliders, ref _contacts, ref state); PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders); + PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ball, ref overlappingColliders); PhysicsStaticNarrowPhase.FindNextCollision(ref state.KinematicColliders, ref ball, ref overlappingColliders, ref _contacts, ref state); RecordSpringHingeHitTime(ref springHingeHitTime, in ball, ref state); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineContext.cs index 33369c5c3..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 @@ -309,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); } /// @@ -365,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 acbd92b18..a1b444b4c 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; @@ -365,6 +383,7 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec) } ProcessHeldKinematicPoses(currentTimeUsec); + ProcessKinematicOctreeReturns(currentTimeUsec); } } @@ -396,16 +415,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. @@ -420,7 +488,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; + } } } @@ -919,6 +992,7 @@ internal void ExecutePhysicsUpdate(ulong currentTimeUsec) StageKinematicTarget(item.ItemId, in currTransformationMatrix, currentTimeUsec, currentTimeUsec); } ProcessHeldKinematicPoses(currentTimeUsec); + ProcessKinematicOctreeReturns(currentTimeUsec); var state = _ctx.CreateState(); 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..e7580aef9 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 { @@ -31,5 +33,64 @@ internal static void FindOverlaps(in NativeOctree octree, in BallState ball octree.RangeAABBUnique(ball.Aabb, overlappingColliders); PerfMarkerBroadPhase.End(); } + + /// + /// 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) + { + if (!state.KinematicItemsOutOfOctree.IsCreated || state.KinematicItemsOutOfOctree.IsEmpty) { + return; + } + PerfMarkerBroadPhase.Begin(); + var ballAabb = ball.Aabb; + 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); + } } } From f2aa4935347ceb452e6c272cc2a00531093aafa3 Mon Sep 17 00:00:00 2001 From: freezy Date: Wed, 16 Sep 2026 15:12:48 +0200 Subject: [PATCH 04/14] physics: keep the maximal step time sample under concurrent reset The maximal physics step duration was updated with a plain read followed by an exchange. A reset by GetSimulationTimingDiagnostics between the two could discard the current sample, and the next diagnostics interval then reported a maximum that was too low. Use a compare-exchange loop. --- .../Game/PhysicsEngineThreading.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index a1b444b4c..350d9a6a5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -267,8 +267,15 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) ); var executeUsec = ElapsedUsec(executeStartTicks, Stopwatch.GetTimestamp()); Interlocked.Exchange(ref _ctx.LastPhysicsExecuteUsec, executeUsec); - if (executeUsec > Interlocked.Read(ref _ctx.MaxPhysicsExecuteUsec)) { - Interlocked.Exchange(ref _ctx.MaxPhysicsExecuteUsec, 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); From ca99a81dc8e2d5c4b8d935b968c47d98529c071d Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 21:43:42 +0200 Subject: [PATCH 05/14] physics: fix flipper bounds and skip degenerate mesh triangles The flipper collider's bounds were extended towards the sweep extremes, but the backside extension set the left and top edges to a positive radius, so the base circle was outside the box on the sides the flipper never points to. A ball touching the base from those sides was not hit tested. The bounds are now the base circle plus the end circle sampled every two degrees of the sweep, with the same margin as before. Mesh colliders produced a triangle collider for collinear triangles. Such a triangle has no normal, so its hit test reports a contact at distance zero for any ball inside its bounds, which then goes through the contact solver with a zero normal. Degenerate triangles now get no triangle collider; their edges and vertices are still added. Both went unnoticed because the broad phase inflated the ball's bounds by a full step of velocity in every direction, which pulled in the flipper anyway and made the bogus contacts rare. --- .../Physics/ColliderBoundsTests.cs | 92 ++++++++++++++ .../Physics/ColliderBoundsTests.cs.meta | 11 ++ .../Physics/Collider/ColliderUtils.cs | 19 ++- .../VPT/Flipper/FlipperCollider.cs | 115 +++++------------- 4 files changed, 153 insertions(+), 84 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs.meta 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..40ed279a0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs @@ -0,0 +1,92 @@ +// Visual Pinball Engine +// Copyright (C) 2023 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; + +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)); + } + + [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); + } 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/Physics/Collider/ColliderUtils.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs index dc1474de8..694867b1c 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,16 @@ public static void GenerateCollidersFromMesh(in NativeArray vertices, i addedEdges.Dispose(); PerfMarker2.End(); } + + /// + /// Twice the squared area below which a mesh triangle gets no collider. A + /// collinear 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. + /// + private const float DegenerateTriangleThreshold = 1e-6f; + + internal static bool IsDegenerate(in float3 rgv0, in float3 rgv1, in float3 rgv2) + => math.lengthsq(math.cross(rgv1 - rgv0, rgv2 - rgv0)) < DegenerateTriangleThreshold; } } 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 From 775494b0a33a53f3b327bf4a7b5b37dc451aeb09 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 21:43:43 +0200 Subject: [PATCH 06/14] physics: query the broad phase with the ball's swept bounds The broad phase queried the octrees with VP's ball hit box: the ball inflated by its full-step velocity in every direction. VP searches a whole step per cycle, so that box is the reach. The cycle here covers one millisecond, a tenth of a step, and the narrow phase rejects any hit later than the searched time, so the box was up to ten times longer than the reach along the motion and just as wide across it. Around dense geometry that made every fast ball test hundreds of colliders per cycle iteration. With up to fifteen iterations per tick and several balls, a tick took many milliseconds, the simulation thread fell behind and the game replayed the backlog: a short freeze followed by a burst of speed. Balls now query with the volume they can reach within the searched time: the segment from the current position to the position at that time, inflated by the radius and the contact margin. The ball octree is built with the same bounds and refits as before when a ball's remaining motion escapes its inserted bounds. On a launch up a wire-form lane the worst query dropped from 937 to 236 colliders and the worst tick from 7681 to 452 hit tests, with the chosen collisions and contacts unchanged. --- .../Physics/BallSweptBoundsTests.cs | 67 +++++++++++++++++++ .../Physics/BallSweptBoundsTests.cs.meta | 11 +++ .../Physics/KinematicBroadPhaseTests.cs | 23 ++++--- .../Physics/SpringHingeIntegrationTests.cs | 8 +-- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 13 ++-- .../Game/PhysicsDynamicBroadPhase.cs | 15 +++-- .../Game/PhysicsStaticBroadPhase.cs | 12 ++-- .../VisualPinball.Unity/VPT/Ball/BallState.cs | 26 +++++++ 8 files changed, 145 insertions(+), 30 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs.meta 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..444964932 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs @@ -0,0 +1,67 @@ +// Visual Pinball Engine +// Copyright (C) 2023 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/KinematicBroadPhaseTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs index 650e13f84..b084be56e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KinematicBroadPhaseTests.cs @@ -18,6 +18,7 @@ using NUnit.Framework; using Unity.Collections; using Unity.Mathematics; +using VisualPinball.Engine.Common; using VisualPinball.Engine.VPT; namespace VisualPinball.Unity.Test @@ -72,22 +73,22 @@ public void MovingItemIsSkippedByOctreeAndFoundDirectly() // 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); + 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); + 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); + 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); + 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); + 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 @@ -97,10 +98,10 @@ public void MovingItemIsSkippedByOctreeAndFoundDirectly() 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.Aabb), Is.True); - Assert.That(bounds.IntersectRect(ballFarAway.Aabb), Is.False); + 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); + 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 @@ -108,13 +109,13 @@ public void MovingItemIsSkippedByOctreeAndFoundDirectly() // 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); + 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); + 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); @@ -151,7 +152,7 @@ public void UncreatedMovingItemSetTreatsEveryItemAsIdle() 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); + 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/SpringHingeIntegrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs index 9c7e0a87a..c888626b9 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 { @@ -56,7 +56,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/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index b7e5a2c08..ba33bea68 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -47,7 +47,7 @@ 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) { @@ -72,12 +72,13 @@ 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); + // hit testing (overlappingColliders is cleared in broad phase); the + // broad phase covers the same time window the narrow phase searches + PhysicsStaticBroadPhase.FindOverlaps(in state.Octree, in ball, ref overlappingColliders, hitTime); PhysicsStaticNarrowPhase.FindNextCollision(ref state.Colliders, ref ball, ref overlappingColliders, ref _contacts, ref state); - PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders); - PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ball, ref overlappingColliders); + PhysicsStaticBroadPhase.FindOverlaps(in kinematicOctree, in ball, ref overlappingColliders, hitTime); + PhysicsStaticBroadPhase.FindMovingKinematicOverlaps(ref state, in ball, ref overlappingColliders, hitTime); PhysicsStaticNarrowPhase.FindNextCollision(ref state.KinematicColliders, ref ball, ref overlappingColliders, ref _contacts, ref state); RecordSpringHingeHitTime(ref springHingeHitTime, in ball, ref state); @@ -86,7 +87,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov ball.CollisionEvent.ClearCollider(); } - PhysicsDynamicBroadPhase.FindOverlaps(in ballOctree, in ball, ref overlappingColliders, ref state.Balls); + PhysicsDynamicBroadPhase.FindOverlaps(in ballOctree, in ball, ref overlappingColliders, ref state.Balls, hitTime); PhysicsDynamicNarrowPhase.FindNextCollision(ref ball, ref overlappingColliders, ref _contacts, ref state); // apply static time diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs index e9a3fcaae..6946edcd6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs @@ -29,14 +29,19 @@ public static class PhysicsDynamicBroadPhase 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 ). + /// 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; + ball.DynamicBroadPhaseAabb = ball.GetSweptAabb(dTime); octree.Insert(ball.Id, ball.DynamicBroadPhaseAabb); } PerfMarkerBallOctree.End(); @@ -48,7 +53,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 +86,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/PhysicsStaticBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs index e7580aef9..f5d30f8f2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs @@ -26,11 +26,15 @@ 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(); } @@ -51,13 +55,13 @@ internal static void FindOverlaps(in NativeOctree octree, in BallState ball /// 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) + 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.Aabb; + var ballAabb = ball.GetSweptAabb(dTime); using var items = state.KinematicItemsOutOfOctree.GetEnumerator(); while (items.MoveNext()) { var itemId = items.Current; 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; From 199a1e99ab57f8663df36f9d664757f88b305dc7 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 21:43:43 +0200 Subject: [PATCH 07/14] editor: stop blocking on the physics lock from collider gizmos The kinematic velocity gizmo asked the physics engine for an item's velocity under the physics lock, once per kinematic collider per editor repaint. Whenever the simulation thread was mid-tick, the main thread waited for it, up to tens of milliseconds per frame with many items. The lookup now tries the lock and draws nothing for that repaint when the simulation thread holds it. The gizmo also caches its playfield component instead of walking up the hierarchy on every repaint. --- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 22 +++++++++++++------ .../VPT/ColliderComponent.cs | 11 ++++++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 89bb6d39d..7ca0c3a16 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -1012,18 +1012,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; 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) { From 1f7f928e2346ed75b9b0cb03912028bbb92c320a Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 21:53:43 +0200 Subject: [PATCH 08/14] physics: make the degenerate triangle test independent of the mesh unit The check that keeps collinear mesh triangles from becoming colliders compared the squared area against an absolute threshold. Primitive and target meshes reach the generator in meters and are only scaled to playfield units when the collider is added, so at that scale nearly every real triangle fell below the threshold: on a full table, 11,800 of 12,400 mesh triangles got no collider and balls fell through ramps, the trough channel and the shooter lane. The test now compares the squared cross product against the product of the squared edge lengths, which is the squared sine of the corner angle and does not depend on the unit. On the same table it drops the ten triangles that are actually collinear. --- .../Physics/ColliderBoundsTests.cs | 7 +++++++ .../Physics/Collider/ColliderUtils.cs | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs index 40ed279a0..0cd66e54b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs @@ -81,6 +81,13 @@ public void MeshColliderGenerationSkipsDegenerateTriangles() 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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs index 694867b1c..b7c1a83b6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/ColliderUtils.cs @@ -152,14 +152,21 @@ public static void GenerateCollidersFromMesh(in NativeArray vertices, i } /// - /// Twice the squared area below which a mesh triangle gets no collider. A - /// collinear 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. + /// 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 DegenerateTriangleThreshold = 1e-6f; + private const float DegenerateTriangleSinSq = 1e-10f; internal static bool IsDegenerate(in float3 rgv0, in float3 rgv1, in float3 rgv2) - => math.lengthsq(math.cross(rgv1 - rgv0, rgv2 - rgv0)) < DegenerateTriangleThreshold; + { + 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); + } } } From 0801e7933c57cfcd5d4ba9fb3bfbf20a94162f44 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 22:17:05 +0200 Subject: [PATCH 09/14] physics: keep collider bounds and ball octree refits in step with the swept broad phase Two more collider bounds did not cover their hit volume, which the old full-step ball box hid. Kickers and triggers are hit-tested against a sphere of 2.6 r centered 2.4 r below their top, so the cap reaches 0.2 r above the cylinder the bounds described; their bounds now include it. The plunger's bounds used a fixed height of 0 to 50 while its line colliders sit at the plunger's z, so a plunger above the playfield had its box offset from its geometry; the bounds now use the same range. The ball octree containment check recomputes the end of a ball's motion from the displaced position, which at playfield coordinates differs from the inserted end by a few float ulps. With swept bounds that exactly fit the motion, that noise counted as an escape and rebuilt the ball octree on most iterations of a moving ball, 300 times per launch in a trace. The inserted bounds are now padded by half a unit. New files carry the current copyright year. --- .../Physics/BallSweptBoundsTests.cs | 2 +- .../Physics/ColliderBoundsTests.cs | 49 ++++++++++++++++++- .../Physics/SpringHingeIntegrationTests.cs | 28 +++++++++++ .../Game/PhysicsDynamicBroadPhase.cs | 19 +++++-- .../Physics/Collider/CircleCollider.cs | 19 +++++-- .../VPT/Plunger/PlungerCollider.cs | 5 +- 6 files changed, 111 insertions(+), 11 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs index 444964932..1c81540ad 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/BallSweptBoundsTests.cs @@ -1,5 +1,5 @@ // Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team +// 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 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs index 0cd66e54b..58d832013 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs @@ -1,5 +1,5 @@ // Visual Pinball Engine -// Copyright (C) 2023 freezy and VPE Team +// 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 @@ -19,6 +19,7 @@ using Unity.Mathematics; using UnityEngine; using VisualPinball.Engine.VPT; +using VisualPinball.Engine.VPT.Plunger; namespace VisualPinball.Unity.Test { @@ -65,6 +66,52 @@ public void FlipperBoundsContainBaseAndEndCircleAcrossTheSweep(float startAngle, 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"); + + // 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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs index c888626b9..46ee9c118 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/SpringHingeIntegrationTests.cs @@ -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() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs index 6946edcd6..d4acb5be7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicBroadPhase.cs @@ -26,13 +26,22 @@ 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"); /// /// Inserts every ball with the bounds it can reach within - /// (see ). - /// refits when a ball's remaining motion leaves its inserted bounds. + /// (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) { @@ -41,7 +50,11 @@ internal static void RebuildOctree(ref NativeOctree octree, ref NativeParal using var enumerator = balls.GetEnumerator(); while (enumerator.MoveNext()) { ref var ball = ref enumerator.Current.Value; - ball.DynamicBroadPhaseAabb = ball.GetSweptAabb(dTime); + 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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs index c3cd5ba0c..93eac1ff6 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,9 +303,10 @@ 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 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))))))); 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 )); } From ecc4621996acb82052e1d8bd255868055bc631a2 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 23:09:05 +0200 Subject: [PATCH 10/14] physics: trace simulation-thread, frame and lock timing Adds SimulationTrace, an allocation-free recorder that keeps the last ten minutes of the simulation in native ring buffers and writes them as CSV files with a summary when the simulation thread stops: - one record per simulation tick with the wait before it, its lateness, clock sync jumps, dropped backlog, and the cost of every phase of the tick (switches, input, gamelogic outputs, physics lock wait, kinematic apply, octree rebuild, Burst update, plumb, time fence, shared-state writer, snapshot), plus the physics work counters of the update and the GC collection count; - one record per rendered frame with Unity and wall time, the snapshot that was rendered and its age, the event drain, the kinematic scan and the first four ball positions; - one record per traced main-thread acquisition of the physics lock. The physics cycle now counts iterations, hit tests by collider type, ball tests, contacts, broad-phase octree visits and the ball with the most tests, so an expensive tick can be attributed to the geometry that caused it. Recording is a toggle on the simulation thread component, on by default, and -simulation-trace / -no-simulation-trace override it on the command line. Files go to Logs/SimulationTrace in the editor project and to the persistent data path in a player; the newest three sessions are kept. A Pinball > Editor menu item opens the folder. Memory and per-tick cost do not depend on the session length. --- .../Simulation/SimulationTraceMenu.cs | 37 ++ .../Simulation/SimulationTraceMenu.cs.meta | 11 + .../VisualPinball.Unity/Game/PhysicsCycle.cs | 74 ++- .../Game/PhysicsDynamicNarrowPhase.cs | 3 +- .../VisualPinball.Unity/Game/PhysicsEngine.cs | 36 +- .../Game/PhysicsEngineThreading.cs | 63 +- .../Game/PhysicsStaticBroadPhase.cs | 29 + .../Game/PhysicsStaticNarrowPhase.cs | 4 +- .../Simulation/SimulationThread.cs | 73 ++- .../Simulation/SimulationThreadComponent.cs | 7 + .../Simulation/SimulationTrace.cs | 600 ++++++++++++++++++ .../Simulation/SimulationTrace.cs.meta | 11 + 12 files changed, 931 insertions(+), 17 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Simulation/SimulationTraceMenu.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs.meta 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/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index ba33bea68..aa74f82fe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -24,20 +24,71 @@ 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; + + 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) @@ -50,6 +101,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov 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; @@ -74,12 +126,13 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov // hit testing (overlappingColliders is cleared in broad phase); the // broad phase covers the same time window the narrow phase searches - PhysicsStaticBroadPhase.FindOverlaps(in state.Octree, in ball, ref overlappingColliders, hitTime); - PhysicsStaticNarrowPhase.FindNextCollision(ref state.Colliders, ref ball, ref overlappingColliders, ref _contacts, ref state); + 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, hitTime); + 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); + 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 @@ -88,12 +141,20 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov } PhysicsDynamicBroadPhase.FindOverlaps(in ballOctree, in ball, ref overlappingColliders, ref state.Balls, hitTime); - PhysicsDynamicNarrowPhase.FindNextCollision(ref ball, ref overlappingColliders, ref _contacts, ref state); + 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); @@ -206,6 +267,7 @@ internal void Simulate(ref PhysicsState state, ref NativeParallelHashSet ov internal void ResetDynamicBroadPhaseRefitCount() { DynamicBroadPhaseRefitCount = 0; + Counters = default; } internal static void ApplyBallSpinCorrection(ref BallState ball) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs index 05a25fb3f..cbd140ea3 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 7ca0c3a16..8c1e361c1 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; @@ -867,7 +867,7 @@ internal void RegisterRuntimeBall(BallComponent ball) _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 +887,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); } @@ -971,7 +971,7 @@ public void VisitBallStates(BallStateVisitor visitor) 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); } } @@ -1069,7 +1069,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; } @@ -1268,6 +1272,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(); @@ -1277,6 +1296,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/PhysicsEngineThreading.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs index 350d9a6a5..3d6f84f7a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs @@ -213,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; } @@ -234,18 +238,23 @@ 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) { var rebuildStartTicks = Stopwatch.GetTimestamp(); PhysicsUpdate.RebuildKinematicOctree(ref _ctx.KinematicOctree, ref state); _ctx.KinematicOctreeDirty = false; - Interlocked.Exchange(ref _ctx.LastKinematicOctreeRebuildUsec, ElapsedUsec(rebuildStartTicks, Stopwatch.GetTimestamp())); + rebuildUsec = ElapsedUsec(rebuildStartTicks, Stopwatch.GetTimestamp()); + Interlocked.Exchange(ref _ctx.LastKinematicOctreeRebuildUsec, rebuildUsec); Interlocked.Increment(ref _ctx.KinematicOctreeRebuildCount); } @@ -255,6 +264,7 @@ 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, @@ -279,6 +289,18 @@ private void ExecutePhysicsSimulation(ulong currentTimeUsec) } 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); } @@ -372,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(); } @@ -392,6 +416,9 @@ private void ApplyPendingKinematicTransforms(ulong currentTimeUsec) ProcessHeldKinematicPoses(currentTimeUsec); ProcessKinematicOctreeReturns(currentTimeUsec); } + if (SimulationTrace.IsRecording) { + SimulationTrace.Tick.KinematicUpdates = drained; + } } /// @@ -634,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; @@ -797,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; } + } } /// @@ -864,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 { @@ -886,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; + } } /// @@ -933,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; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs index f5d30f8f2..a412e6905 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticBroadPhase.cs @@ -38,6 +38,35 @@ internal static void FindOverlaps(in NativeOctree octree, in BallState ball 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 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs index ad4e728ff..a0094629a 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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 3dd00e571..1dfb0dc94 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -202,6 +202,10 @@ public void Start() _inputEventsDropped = 0; _needsInitialSwitchSync = true; + if (SimulationTrace.Enabled) { + SimulationTrace.Begin(); + } + _thread = new Thread(SimulationThreadFunc) { Name = "VPE Simulation Thread", @@ -228,6 +232,8 @@ public void Stop() _thread.Join(5000); // Wait up to 5 seconds } + SimulationTrace.End(); + Logger.Info($"{LogPrefix} [SimulationThread] Stopped after {_tickCount} ticks, {_inputEventsProcessed} input events, {_inputEventsDropped} dropped"); } @@ -425,6 +431,7 @@ private void SimulationThreadFunc() // 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) @@ -435,6 +442,7 @@ private void SimulationThreadFunc() // 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; } @@ -442,6 +450,12 @@ 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 @@ -454,6 +468,9 @@ private void SimulationThreadFunc() targetTicks += skippedTicks; sleepTicks += skippedTicks; _droppedBacklogUsec += (skippedTicks * 1_000_000L) / Stopwatch.Frequency; + if (tracing) { + SimulationTrace.Tick.DroppedUsec = (int)SimulationTrace.TicksToUsec(skippedTicks); + } } if (sleepTicks > _busyWaitThresholdTicks) @@ -464,6 +481,9 @@ private void SimulationThreadFunc() } else { Thread.Yield(); } + if (tracing) { + SimulationTrace.Tick.WaitMode = sleepMs > 0 ? 2 : 1; + } } #if !UNITY_EDITOR @@ -475,8 +495,16 @@ 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(); if (_rebaseTimetable) { @@ -515,10 +543,22 @@ 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; } @@ -526,15 +566,19 @@ private void SimulationTick() // 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. @@ -543,13 +587,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(); + } } /// @@ -1007,8 +1063,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()); + } } } @@ -1059,6 +1121,7 @@ private void WriteSharedState() 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; @@ -1067,12 +1130,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++; @@ -1086,6 +1154,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 f132d3d9c..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(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs new file mode 100644 index 000000000..3ec941afa --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs @@ -0,0 +1,600 @@ +// 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; + } + } + + /// 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"); + 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); + 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: From 1f5bdff6abf7d7ac142b56e91b6bfb50356a60b9 Mon Sep 17 00:00:00 2001 From: freezy Date: Thu, 17 Sep 2026 23:31:09 +0200 Subject: [PATCH 11/14] physics: keep the trace buffers alive under a stuck thread, fix a circle bounds corner If the simulation thread does not exit within the five-second join at shutdown, the trace is now abandoned instead of written: freeing the native ring buffers while the thread may still commit a tick would write through a dangling pointer. The buffers leak in that case and the next run allocates new ones. The transformed bounds of kicker and trigger circles extended seven of the eight corners to the sphere cap; the eighth still used the cylinder top. The test now covers the transformed path. --- .../Physics/ColliderBoundsTests.cs | 5 +++++ .../Physics/Collider/CircleCollider.cs | 2 +- .../Simulation/SimulationThread.cs | 11 +++++++++-- .../Simulation/SimulationTrace.cs | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs index 58d832013..511441c9a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ColliderBoundsTests.cs @@ -79,6 +79,11 @@ public void KickerAndTriggerCircleBoundsCoverTheSphereCap(ColliderType type, Ite 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); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs index 93eac1ff6..9bc303aa1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs @@ -307,7 +307,7 @@ public Aabb GetTransformedAabb(float4x4 matrix) 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 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/Simulation/SimulationThread.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs index 1dfb0dc94..f409b15b3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs @@ -227,12 +227,19 @@ 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 } - SimulationTrace.End(); + 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"); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs index 3ec941afa..0e0f64146 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs @@ -316,6 +316,22 @@ internal static unsafe void End() } } + /// + /// 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) { From 30a03da798ff08222747f92be3193ea6cc8bb50b Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 18 Sep 2026 00:33:49 +0200 Subject: [PATCH 12/14] physics: keep a ball out of the floor when a collider sits inside it from above A ball rolling under a cover or wire that is lower than the ball is tall gets that collider reported as a sustained contact with a downward normal, ten units deep. The position recovery for sustained static contacts then moved the ball along that normal, up to DispLimit per tick, straight into the floor, and the floor's recovery moved it back: within one tick with several cycle iterations the ball ended up five to ten units below the playfield and rolled on visibly sunk. The geometry is wrong in such a spot, but the engine must not resolve it by pushing the ball through the floor. The recovery now never moves a ball along gravity: contacts whose normal has a component in the gravity direction keep their normal force and friction but get no position correction. Supports, ramps and walls recover as before. Playfield floor triangles also push an embedded ball fully out on impact, the way the playfield plane already did (VP's C_EMBEDSHOT_PLANE), instead of by DispLimit per impact only. Cutout walls are excluded by their normal, so a ball is never shoved sideways. --- .../Physics/ContactDepenetrationTests.cs | 129 ++++++++++++++++++ .../Physics/ContactDepenetrationTests.cs.meta | 11 ++ .../Physics/Collider/TriangleCollider.cs | 14 ++ .../VPT/Ball/BallCollider.cs | 7 +- 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/ContactDepenetrationTests.cs.meta 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/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/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; From 5b536dec36ba97a17322f072343f366bdb092d2d Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 18 Sep 2026 00:33:49 +0200 Subject: [PATCH 13/14] physics: trace the deepest hit of every tick The simulation trace now records, per tick, the hit or contact with the most negative hit distance together with its collider, item, type, normal, and the ball's position and velocity, captured at the point where the narrow phase accepts each hit so that a later zero-time hit cannot hide an earlier, deeper one. This is what points a "ball sinks" report at the collider that is inside the ball. --- .../VisualPinball.Unity/Game/PhysicsCycle.cs | 34 +++++++++++++++++++ .../Game/PhysicsDynamicNarrowPhase.cs | 1 + .../Game/PhysicsStaticNarrowPhase.cs | 7 ++-- .../Simulation/SimulationTrace.cs | 16 +++++++-- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs index aa74f82fe..8d5c6ead7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsCycle.cs @@ -52,6 +52,39 @@ public struct PhysicsCounters 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) { @@ -268,6 +301,7 @@ 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/PhysicsDynamicNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs index cbd140ea3..d24f5c02b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsDynamicNarrowPhase.cs @@ -47,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/PhysicsStaticNarrowPhase.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs index a0094629a..20f0a1cdf 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsStaticNarrowPhase.cs @@ -76,7 +76,7 @@ ref PhysicsCounters counters 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); } } @@ -89,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/Simulation/SimulationTrace.cs b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs index 0e0f64146..576f98bca 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationTrace.cs @@ -404,7 +404,7 @@ internal static unsafe void RecordLock(LockSite site, long startUsec, long acqui 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"); + 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); @@ -430,7 +430,19 @@ private static unsafe void WriteTicks(string path) .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(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()); } } From 61037ca6b3d5fdf2e6083976306577ac9c702acd Mon Sep 17 00:00:00 2001 From: freezy Date: Fri, 18 Sep 2026 01:17:35 +0200 Subject: [PATCH 14/14] kicker: create balls at the kicker's height and always capture them A ball created inside a kicker (a trough eject, a teleporter destination) is meant to sit captured at the kicker's capture position, so that the kick that follows launches it from there. It was created at playfield level instead, and anything that kept the capture from happening then launched it from playfield level with the full kick velocity: for a trough exit modelled a hundred units below the playfield, that is a ball flying over the apron instead of rising through the trough channel. Balls now spawn at the kicker's own height, like in VP, where the capture puts them anyway. The capture itself no longer trips over state left behind under the same ball id (the kicker's held-ball reference or its volume membership), CreateSizedBallWithMass captures the ball like CreateBall does (VP calls DoCollide for both), and a created ball that still ends up uncaptured is reported with the kicker, the held ball and its position. A ball id that is already in use is reported too, since two balls would share one physics state. The capture position and hit mesh are resolved through the parent chain in playfield space, consistent with the colliders, so a kicker grouped under an offset parent holds its ball where its collider is. --- .../Physics/KickerCaptureTests.cs | 116 ++++++++++++++++++ .../Physics/KickerCaptureTests.cs.meta | 11 ++ .../VPT/KickerTests.cs | 55 +++++++++ .../VisualPinball.Unity/Game/PhysicsEngine.cs | 7 ++ .../VPT/Kicker/KickerApi.cs | 38 +++++- .../VPT/Kicker/KickerCollider.cs | 14 ++- .../VPT/Kicker/KickerComponent.cs | 17 ++- 7 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Test/Physics/KickerCaptureTests.cs.meta 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/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/PhysicsEngine.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs index 8c1e361c1..5094260e6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngine.cs @@ -864,6 +864,13 @@ 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) { 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);