fix(example/game-2d): prevent infinite collision loop and preserve ball state across fullscreen transitions - #136
Merged
Conversation
…ll state across fullscreen transitions
eastspire
added a commit
that referenced
this pull request
Sep 3, 2026
Includes: - #136 fix(example/game-2d): prevent infinite collision loop and preserve ball state across fullscreen transitions CI's sync_workspace_version job will propagate the new root version to all sub-crates on master merge. Co-authored-by: eastspire <eastspire@users.noreply.github.com>
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.
fix(example/game-2d): prevent infinite collision loop and preserve ball state across fullscreen transitions
Fixes two regressions in the WebGL / WebGPU bouncing-balls demo that emerged once enough balls filled the canvas to exceed the bounded-iteration impulse solver's convergence envelope, or once the user toggled fullscreen with a large ball list:
GAME_2D_COLLISION_ITERATIONS=4loop could not converge. Balls oscillated vertically forever and pegged the main thread at 100% CPU.y = radiusof the smaller inline canvas (the resize-tickupdate_ballsran beforerescale_balls_to_canvas), and the proportional rescale then shrank those clamped positions. To the user the balls appeared to lose all motion and reset to the floor.Root causes
Bug 1 — impulse-only solver under high density
resolve_ball_collisionalready applied both positional projection and an impulse response, but the projection was only inverse-mass-weighted within a single call. Under high density the impulse step could not separate the balls, gravity re-created the overlap on the next physics tick, and the cycle repeated.Bug 2 — rescale-after-update ordering
In all three game loops (
start_game_2d_loop,start_game_2d_webgl_loop,start_game_2d_webgpu_loop) the resize-tickrescale_balls_to_canvasran after the physics tick.update_ballstherefore clamped balls to the new (smaller) canvas dimensions first, then the rescale proportionally shrank those clamped values — visibly resetting motion and pinning balls to the floor.Fix
1. Collision convergence guarantees
resolve_ball_collisionnow:velocity_along_normal > 0) so restitution is never applied as a gain on separating velocity.A new
resolve_stuck_ballsfunction runs as a last-resort pass after every substep's iteration loop:GAME_2D_STUCK_MIN_OVERLAP=0.5pxagainst every neighbour.count / 4overlap events has itsradiusmultiplied byGAME_2D_STUCK_RADIUS_SHRINK=0.97(an imperceptible 3% shrink that opens enough slack for the solver to converge on subsequent substeps). The shrink is permanent (applied once per jam event) so it does not create a visible pulsing effect.2. Dynamic ball-count cap by canvas area
New constants:
GAME_2D_MAX_BALL_AREA_RATIO = 0.70— empirical upper bound at which the solver still converges.GAME_2D_STUCK_RADIUS_SHRINK = 0.97andGAME_2D_STUCK_MIN_OVERLAP = 0.5— see above.rescale_balls_to_canvassignature changed from&mut [Ball]to&mut Vec<Ball>(so it can mutate the length) and now trims the oldest balls from the front of the list when the new canvas is smaller than the old one and the total ball cross-section area exceeds the cap. Theprev_positionsbuffer is truncated/padded in lockstep sointerpolate_ballsnever indexes past the end of the live ball list. The trim is a no-op when entering fullscreen (density only goes down).3. Rescale before physics tick (the actual ball-state fix)
Both the WebGL and WebGPU loops now call a new
handle_rescale_dirtyhelper beforeupdate_balls. The helper:resize_dirtydebounce flag.last_canvas_size.boolindicating whether a rescale actually happened so the renderer's backing-store resize fires exactly once per resize tick.The Canvas 2D loop has its own variant
handle_rescale_dirty_canvas2dthat also handles the SSAA canvas reset (Canvas 2D needs to re-acquire the SSAA canvas against the new CSS box). It additionally runs a CSS-mismatch safety net that detects the case where the euv signal-driven DOM re-render is still pending after the syntheticresizeevent dispatched byenter_game_2d_fullscreen/exit_game_2d_fullscreen.4. CSS-mismatch safety net for WebGL / WebGPU
The previous WebGL / WebGPU loops lacked the per-frame CSS-mismatch safety net that the Canvas 2D loop already had. The new
handle_rescale_dirtyhelpers run this safety net unconditionally (Canvas 2D variant) or via the same debounced flag (WebGL / WebGPU), so even a missedresizedebounce no longer stalls the rescale.Verification
cargo check --target wasm32-unknown-unknown --release -p euv-examplepasses.cargo clippy --target wasm32-unknown-unknown --release -p euv-example --lib -- -D warningspasses.cargo fmt --all -- --checkpasses.euv fmtruns cleanly.Headless chromium + playwright on all three tabs (Canvas2D / WebGL / WebGPU):
Files
example/src/page/game_2d/hook/const.rs— new constants.example/src/page/game_2d/hook/fn.rs— physics solver, rescale helper, loop-body reorderings.