Skip to content

fix(example/game-2d): prevent infinite collision loop and preserve ball state across fullscreen transitions - #136

Merged
eastspire merged 1 commit into
euv-dev:masterfrom
eastspire:master
Sep 3, 2026
Merged

fix(example/game-2d): prevent infinite collision loop and preserve ball state across fullscreen transitions#136
eastspire merged 1 commit into
euv-dev:masterfrom
eastspire:master

Conversation

@eastspire

Copy link
Copy Markdown
Collaborator

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:

  1. Infinite vertical-oscillation collision loop. When the canvas became densely packed, gravity overpowered the impulse-driven ball-to-ball separation and the bounded GAME_2D_COLLISION_ITERATIONS=4 loop could not converge. Balls oscillated vertically forever and pegged the main thread at 100% CPU.
  2. Ball motion reset on fullscreen exit. Exiting fullscreen with N>30 balls clamped every ball to y = radius of the smaller inline canvas (the resize-tick update_balls ran before rescale_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_collision already 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-tick rescale_balls_to_canvas ran after the physics tick. update_balls therefore 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_collision now:

  • Always performs positional projection as Phase 1 (already true; doc clarified).
  • Skips the impulse step when balls are already separating (velocity_along_normal > 0) so restitution is never applied as a gain on separating velocity.
  • Restitution applied multiplicatively to the post-impulse normal velocity so the bounce dissipates over time.

A new resolve_stuck_balls function runs as a last-resort pass after every substep's iteration loop:

  • Counts each ball's persistent overlap beyond GAME_2D_STUCK_MIN_OVERLAP=0.5px against every neighbour.
  • Any ball exceeding count / 4 overlap events has its radius multiplied by GAME_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.97 and GAME_2D_STUCK_MIN_OVERLAP = 0.5 — see above.

rescale_balls_to_canvas signature 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. The prev_positions buffer is truncated/padded in lockstep so interpolate_balls never 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_dirty helper before update_balls. The helper:

  • Consumes the resize_dirty debounce flag.
  • Reads the live canvas CSS dimensions, rescales ball positions, and updates last_canvas_size.
  • Returns bool indicating 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_canvas2d that 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 synthetic resize event dispatched by enter_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_dirty helpers run this safety net unconditionally (Canvas 2D variant) or via the same debounced flag (WebGL / WebGPU), so even a missed resize debounce no longer stalls the rescale.

Verification

cargo check --target wasm32-unknown-unknown --release -p euv-example passes.
cargo clippy --target wasm32-unknown-unknown --release -p euv-example --lib -- -D warnings passes.
cargo fmt --all -- --check passes.
euv fmt runs cleanly.

Headless chromium + playwright on all three tabs (Canvas2D / WebGL / WebGPU):

  • Spawn 35 balls → 39 total (4 initial + 35 clicks) on all tabs.
  • FPS stays at 60 across all tabs after settle (samples 60.1 / 60 / 60).
  • Settled screenshot at +5s shows balls in stable floor columns, no vibration.
  • Enter Fullscreen → Exit Fullscreen: ball count stays 39, motion preserved (one ball mid-flight bouncing in the after-exit screenshot).
  • Trim path verified: when the new canvas is smaller than the old one, oldest balls are trimmed so the total ball area is at or below the cap.

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.

@eastspire
eastspire merged commit 0078103 into euv-dev:master Sep 3, 2026
8 checks passed
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>
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