Skip to content

physics: fix simulation-thread fast-forward and cut broad-phase cost - #577

Merged
freezy merged 14 commits into
masterfrom
fix/kinematic-physics
Sep 18, 2026
Merged

freezy merged 14 commits into
masterfrom
fix/kinematic-physics

Conversation

@freezy

@freezy freezy commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Problem

In the editor, balls released from a kinematic mechanism sometimes raced down the playfield as if the incline were several times steeper, fading after about a second. Measured with a per-frame log of physics time against Unity time: physics ran at 4 to 5 times real time for about 1.5 s, starting exactly when the building stopped.

Two things combined:

  1. PhysicsKinematics.RebuildOctree was called from managed code once per Unity frame whenever any kinematic item moved. One rebuild took 12 to 14 ms in the editor, on a simulation thread that already spends about 0.6 ms of every 1 ms tick. A moving mechanism saturated the thread and physics time fell up to 1.75 s behind the wall clock.
  2. SimulationThread schedules ticks against a wall-clock timetable with no backlog bound. When the load dropped, it replayed every missed tick back to back, which is the fast-forward. Resuming from the in-game pause replayed the whole pause the same way.

Player builds never showed it because the same rebuild is cheap enough there to keep the thread under budget.

Changes

  • Bound simulation thread catch-up. At most 100 ms of missed ticks are replayed; the rest is dropped from the timetable without advancing the simulation clock. If Unity time got ahead during a 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 interval is not counted twice. The paused branch and the post-initialization start re-anchor the timetable. Dropped time is published in the snapshot and the statistics line.
  • Burst-compile the kinematic octree rebuild through a [BurstCompile] entry in PhysicsUpdate. Rebuild cost in the editor dropped from 12 to 14 ms to about 3 ms. Adds sim-tick diagnostics (PhysicsEngine.GetSimulationTimingDiagnostics, PhysicsEngine.VisitBallStates).
  • Keep moving kinematic items out of the octree. The octree only holds idle items and is rebuilt when an item starts or stops moving (250 ms quiet, pose settled), not per pose update. Moving items are broad-phased directly against the ball bounds, inflated by one tick of the surface velocity the narrow phase subtracts. Non-baked kinematic collider types get their current bounds from the identity collider and item transform, same as the rebuild.

Measurements

Same sequence, editor:

Before After
Octree rebuilds per run one per frame while moving (~490) 13
Sim thread rate while moving 530 to 700 Hz 1000 Hz
Physics lag behind real time grows to 1.75 s, then replayed at 4-5x constant
Physics per 20 ms frame after release 77 to 110 ms 20 ms

Ball velocities and spin were rolling-consistent throughout; the fault was pacing, not ball response.

Tests

  • New KinematicBroadPhaseTests: idle item found via octree only, moving item skipped by the rebuild and found by the direct pass, far ball rejected, item bounds union, surface-velocity margin admits an approaching surface, bounds not maintained for idle items.
  • MagnetPhysicsTests harness updated for the two new PhysicsState fields.

Second part: broad-phase cost and collider bounds

Playing a full game in the editor was still choppy: the ball froze for a split second and then caught up, worse in multiball, without any kinematic item involved. A new timing trace (see below) showed the simulation thread was not being stalled at all, no GC or lock waits to speak of, but overloaded: the Burst physics update took 5 to 10 ms per tick for hundreds of consecutive ticks whenever a ball crossed dense collider geometry, so the timetable hit the new 100 ms backlog cap and the game replayed the backlog.

Two causes, one per side:

  • Editor only: the Burst option Native Debug Mode Compilation was on in the editor preferences, which compiles Burst code unoptimized. Same dense tick: 2.6 ms with it on, 0.33 ms off. Not a code change, but the reason the player build never showed it.
  • In the code: 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 10 ms step per cycle, so that is its reach. The cycle here covers one millisecond 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 a wire-form lane one fast ball tested about 500 colliders per inner iteration, 15 iterations per tick.

Changes:

  • Swept ball bounds. BallState.GetSweptAabb(dTime) covers the segment from the position to the position after the searched time, inflated by radius and contact margin. The static, kinematic and ball-ball broad phases query with it, using the same hitTime the narrow phase searches. Verified with a temporary in-tick pass that re-ran every ball's narrow phase with the legacy box and compared the chosen collider, hit time and contact count: zero mismatches over three launches once the collider bounds below were fixed. Launch up the lane: worst query 937 to 236 colliders, worst tick 7681 to 452 hit tests.
  • Collider bounds that did not cover their hit volume, hidden until now by the huge legacy box: the flipper box omitted the base circle on two sides (sign error in the backside extension; replaced by a sampled sweep of base and end circle), kicker and trigger circles are hit-tested against a sphere cap reaching 0.2 r above their top, and the plunger box used a fixed 0 to 50 height instead of the plunger's.
  • Degenerate mesh triangles no longer get a triangle collider. A collinear triangle has no normal and reported a contact at distance zero for any ball in its bounds. The test is relative to the edge lengths, since meshes reach the generator in meters; an earlier absolute threshold dropped nearly every triangle and is fixed in a follow-up commit on the branch.
  • Ball octree refit padding. With exact swept bounds, float noise at playfield coordinates counted as an escape and rebuilt the ball octree on most iterations of a moving ball (300 per launch in a trace, 24 with half a unit of padding).
  • Gizmos no longer block on the physics lock. The kinematic velocity gizmo took the lock blockingly once per kinematic collider per repaint.
  • Simulation trace. SimulationTrace records the last ten minutes of per-tick timing (wait, lateness, clock jumps, dropped backlog, every phase of the tick, physics work counters, GC count), per-frame main-thread timing (Unity vs wall time, rendered snapshot and its age, event drain, kinematic scan, first ball positions) and main-thread physics lock acquisitions into native ring buffers, and writes CSV files plus a summary when the simulation stops. Toggle on the simulation thread component, on by default; -simulation-trace / -no-simulation-trace override it; files go to Logs/SimulationTrace in the editor project, the persistent data path in a player, newest three sessions kept. Constant memory and per-tick cost regardless of session length.

Tests: BallSweptBoundsTests, ColliderBoundsTests (flipper sweep for left, right, wrap-around and zero sweep; kicker and trigger cap; plunger height; degenerate triangles at meter and playfield scale), a float-noise refit test in SpringHingeIntegrationTests, and the updated broad-phase tests.

Third part: balls pushed into the floor

Traced with the new per-tick "deepest hit" columns: a ball rolling under a cover whose collider sits lower than the ball is tall gets that collider reported as a sustained contact with a downward normal, about ten units deep. The position recovery for sustained static contacts then moved the ball along that normal into the floor, up to DispLimit per tick, the floor's recovery moved it back, and with several cycle iterations per tick the ball ended up five to ten units below the playfield, rolling on visibly sunk. The recovery now never moves a ball along gravity, and playfield floor triangles push an embedded ball fully out on impact like the playfield plane already did. ContactDepenetrationTests covers floor, ceiling, wall and sloped-support contacts and the playfield push-out. Reproduced on a real table before and after: dips to z 15 before, none below z 24 after, with the ball proceeding normally. The covers themselves are a table problem and are reported separately.

Fourth part: balls created in a kicker

A ball created inside a kicker (the 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 whenever the capture did not happen, the kick 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.
  • The capture no longer trips over state left behind under the same ball id (held-ball reference, volume membership), and CreateSizedBallWithMass captures like CreateBall does (VP calls DoCollide for both).
  • A created ball that still ends up uncaptured is reported with the kicker, the held ball and its position, and a ball id that is already in use is reported as well.
  • The capture position and hit mesh are resolved in playfield space, consistent with the colliders, so a kicker grouped under an offset parent holds its ball where its collider is.

@freezy
freezy force-pushed the fix/kinematic-physics branch 2 times, most recently from 2ab3691 to 83b4498 Compare September 16, 2026 13:01
@greptile-apps

greptile-apps Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; the previous findings are resolved and no actionable regression from the subsequent changes remains.

Summary

This PR substantially revises simulation pacing and physics broad-phase behavior to prevent fast-forward after stalls and reduce dense-collider workload.

  • Bounds simulation-thread catch-up and adds detailed bounded-memory timing traces.
  • Keeps moving kinematic items out of the rebuilt octree and broad-phases them directly.
  • Uses time-window swept ball bounds and corrects collider bounds exposed by the tighter queries.
  • Improves contact depenetration, kicker creation/capture behavior, and lifecycle cleanup.
  • Adds focused regression tests for swept bounds, collider bounds, kinematic broad phase, contact recovery, and kicker capture.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  WallClock[Wall-clock schedule] --> CatchUp[Bounded catch-up]
  CatchUp --> Tick[Simulation tick]
  Tick --> Swept[Compute swept ball bounds]
  Swept --> Static[Static octree query]
  Swept --> IdleKin[Idle kinematic octree query]
  Swept --> MovingKin[Direct moving-kinematic query]
  Swept --> Balls[Ball octree query]
  Static --> Narrow[Narrow phase]
  IdleKin --> Narrow
  MovingKin --> Narrow
  Balls --> Narrow
  Narrow --> Resolve[Collision and contact resolution]
  Resolve --> Snapshot[Publish snapshot and diagnostics]
Loading

Reviews (7) · Last reviewed commit: "kicker: create balls at the kicker's hei..."

Comment thread VisualPinball.Unity/VisualPinball.Unity/Game/PhysicsEngineThreading.cs Outdated
@freezy

freezy commented Sep 16, 2026

Copy link
Copy Markdown
Owner Author

Addressed the maximum-sample race: the update is now a compare-exchange loop, so a concurrent reset cannot drop a sample.

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.
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.
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.
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.
@freezy
freezy force-pushed the fix/kinematic-physics branch from 5a9fe7a to f2aa493 Compare September 16, 2026 18:30
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.
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.
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.
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.
… 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.
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.
@freezy freezy changed the title physics: fix simulation-thread fast-forward after kinematic motion physics: fix simulation-thread fast-forward and cut broad-phase cost Sep 17, 2026
Comment thread VisualPinball.Unity/VisualPinball.Unity/Simulation/SimulationThread.cs Outdated
Comment thread VisualPinball.Unity/VisualPinball.Unity/Physics/Collider/CircleCollider.cs Outdated
…cle 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.
…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.
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.
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.
@freezy
freezy merged commit 03c5e5e into master Sep 18, 2026
15 checks passed
@freezy
freezy deleted the fix/kinematic-physics branch September 18, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant