physics: fix simulation-thread fast-forward and cut broad-phase cost - #577
Merged
Merged
Conversation
freezy
force-pushed
the
fix/kinematic-physics
branch
2 times, most recently
from
September 16, 2026 13:01
2ab3691 to
83b4498
Compare
|
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
force-pushed
the
fix/kinematic-physics
branch
from
September 16, 2026 18:30
5a9fe7a to
f2aa493
Compare
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
PhysicsKinematics.RebuildOctreewas 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.SimulationThreadschedules 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
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.[BurstCompile]entry inPhysicsUpdate. Rebuild cost in the editor dropped from 12 to 14 ms to about 3 ms. Adds sim-tick diagnostics (PhysicsEngine.GetSimulationTimingDiagnostics,PhysicsEngine.VisitBallStates).Measurements
Same sequence, editor:
Ball velocities and spin were rolling-consistent throughout; the fault was pacing, not ball response.
Tests
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.MagnetPhysicsTestsharness updated for the two newPhysicsStatefields.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:
Changes:
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 samehitTimethe 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.SimulationTracerecords 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-traceoverride it; files go toLogs/SimulationTracein 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 inSpringHingeIntegrationTests, 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
DispLimitper 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.ContactDepenetrationTestscovers 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.
CreateSizedBallWithMasscaptures likeCreateBalldoes (VP callsDoCollidefor both).