Skip to content

fix(cg): redraw() forced stable on every frame, defeating zoom cache#616

Merged
softmarshmallow merged 1 commit intomainfrom
feature/vibrant-dewdney
Mar 30, 2026
Merged

fix(cg): redraw() forced stable on every frame, defeating zoom cache#616
softmarshmallow merged 1 commit intomainfrom
feature/vibrant-dewdney

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Mar 30, 2026

Summary

  • Fix: Application::redraw() passed stable=true to apply_changes() on every frame, nuking the zoom image cache during active interaction. Every zoom frame did a full O(N) draw instead of the O(1) cached texture blit. Changed to derive stable from !camera_change.any_changed().
  • Benchmarks: Added zoom-specific scenarios to grida-dev benchzoom_with_settle, zoom_forced_stable (bug reproduction), and frameloop_zoom (real FrameLoop path at various event intervals).
  • Docs: Updated optimization.md item 21 and cg-perf/SKILL.md with new scenario types.

Measured impact (90K nodes, headless GPU, Apple M2 Pro)

Scenario Before (bug) After (fix) Improvement
zoom_slow p50 4,511 µs 5 µs ~900×
zoom_slow p95 45,601 µs 8 µs ~5,700×
zoom_fast p50 5,663 µs 5 µs ~1,130×
zoom_fast p95 49,605 µs 6 µs ~8,270×

Test plan

  • cargo test -p cg — all 33 tests pass
  • cargo check -p cg -p grida-canvas-wasm -p grida-dev
  • cargo run -p grida-dev --release -- bench --size 300 --frames 100 confirms fix
  • Manual test: zoom in window demo (wd_windowed_mode) should be smooth

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Performance Improvements

    • Enhanced zoom gesture handling with improved cache retention during active interactions, reducing unnecessary full redraws and improving responsiveness.
  • Documentation & Testing

    • Updated performance documentation with refined guidance on renderer optimization behavior and expanded benchmark suite with new zoom-focused test scenarios.

…ache

Application::redraw() unconditionally passed stable=true to
apply_changes(), which nuked the zoom image cache on every interaction
frame. This forced every zoom frame into a full O(N) draw instead of
the O(1) cached texture blit, producing ~3 FPS on large scenes.

Fix: derive stable from !camera_change.any_changed() — unstable during
active interaction (preserves zoom cache), stable when idle (full
quality). Measured on 90K nodes: p50 drops from 4,511µs to 5µs
(~900× improvement).

Also adds zoom-specific benchmark scenarios to grida-dev bench runner:
- zoom_with_settle: settle frames interleaved with zoom (cache-cold spikes)
- zoom_forced_stable: reproduces the bug for A/B comparison
- frameloop_zoom: real FrameLoop path at various event intervals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blog Ready Ready Preview, Comment Mar 30, 2026 11:17am
docs Ready Ready Preview, Comment Mar 30, 2026 11:17am
grida Ready Ready Preview, Comment Mar 30, 2026 11:17am
viewer Ready Ready Preview, Comment Mar 30, 2026 11:17am
3 Skipped Deployments
Project Deployment Actions Updated (UTC)
backgrounds Ignored Ignored Preview Mar 30, 2026 11:17am
code Ignored Ignored Mar 30, 2026 11:17am
legacy Ignored Ignored Mar 30, 2026 11:17am

Request Review

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 30, 2026

Walkthrough

The PR modifies zoom image cache handling in the canvas renderer to improve interaction performance. It removes hard eviction logic based on zoom ratio, updates fast-path eligibility to use the zoom cache on no-change frames, and fixes the redraw path to compute stability based on actual camera changes rather than forcing it to true. New benchmark scenarios are added to measure this optimized behavior.

Changes

Cohort / File(s) Summary
Documentation & Benchmarking
.agents/skills/cg-perf/SKILL.md, docs/wg/feat-2d/optimization.md, crates/grida-dev/src/bench/runner.rs
Added documentation for three new benchmark scenarios (zoom_with_settle, zoom_forced_stable, frameloop_zoom) and updated guidance on cache behavior. Introduced three new benchmark pass functions with 550+ lines: run_zoom_with_settle_pass, run_frameloop_zoom_pass, and run_zoom_pass_forced_stable to measure zoom-cache effectiveness under different interaction patterns.
Renderer Zoom Cache Logic
crates/grida-canvas/src/runtime/scene.rs
Removed hard eviction logic (ZOOM_IMAGE_CACHE_MAX_RATIO), expanded zoom-cache fast-path eligibility to include no-change frames (CameraChangeKind::None), and updated deferred-plan heuristics to prefer zoom cache. Simplified try_zoom_cache_blit by removing zoom-ratio validation; cache now persists during active interaction.
Redraw Stability Semantics
crates/grida-canvas/src/window/application.rs
Changed UnknownTargetApplication::redraw() to compute stable dynamically as !camera_change.any_changed() instead of hardcoding stable=true, ensuring stability properly reflects actual camera state and enables correct cache invalidation behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

cg, performance

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the primary bug fix: redraw() forcing stable=true and defeating the zoom cache, which is the main change across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/vibrant-dewdney

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
crates/grida-canvas/src/runtime/scene.rs (1)

1098-1110: Extract the zoom-cache eligibility rule into one helper.

Lines 1108-1110, 1254-1256, and the zoom-cache branch in Lines 1617-1621 all encode the same zoom_changed() || None policy. Keeping that rule in three places makes the ready/deferred paths easy to drift apart the next time this heuristic is tuned.

♻️ Possible helper extraction
+    #[inline]
+    fn zoom_cache_candidate(camera_change: CameraChangeKind) -> bool {
+        camera_change.zoom_changed() || camera_change == CameraChangeKind::None
+    }
...
-        let use_zoom_cache = self.zoom_image_cache.is_some()
-            && (camera_change.zoom_changed() || camera_change == CameraChangeKind::None);
+        let use_zoom_cache =
+            self.zoom_image_cache.is_some() && Self::zoom_cache_candidate(camera_change);
...
-        let zoom_cache_usable = self.zoom_image_cache.is_some()
-            && (plan.camera_change.zoom_changed() || plan.camera_change == CameraChangeKind::None);
+        let zoom_cache_usable =
+            self.zoom_image_cache.is_some() && Self::zoom_cache_candidate(plan.camera_change);
// In `can_defer`, reuse the same helper for the zoom-cache branch and keep
// the pan-cache fallback as the separate `CameraChangeKind::None` case.

Also applies to: 1249-1256, 1614-1621

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/runtime/scene.rs` around lines 1098 - 1110, Extract
the repeated "zoom cache eligibility" logic into a single helper (e.g., a
function or method like zoom_cache_applicable(camera_change: CameraChangeKind)
-> bool or Scene::zoom_cache_applicable(&self, camera_change:
&CameraChangeKind)) that returns true when self.zoom_image_cache.is_some() &&
(camera_change.zoom_changed() || *camera_change == CameraChangeKind::None);
replace the three duplicated checks (the branch that currently reads
self.zoom_image_cache.is_some() && (camera_change.zoom_changed() ||
camera_change == CameraChangeKind::None) and the similar checks in can_defer and
the zoom-cache branch) with calls to this helper, and keep the pan-only fallback
logic as the separate CameraChangeKind::None case where appropriate.
crates/grida-dev/src/bench/runner.rs (1)

1856-1860: Warmup uses pan operations for zoom scenarios.

The warmup loop does renderer.camera.translate(1.0, 0.0) (pan) before running zoom scenarios. This primes the pan cache but not the zoom cache. The first zoom frame will be a cache miss regardless, so this likely doesn't affect measurements materially, and it's consistent with the warmup() helper used by frameloop scenarios. Just noting for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-dev/src/bench/runner.rs` around lines 1856 - 1860, The warmup
loop currently calls renderer.camera.translate(1.0, 0.0) which primes pan caches
but not zoom caches, so change the warmup to perform the same camera operation
used by zoom scenarios (e.g., call the zoom/scale method on renderer.camera with
the same parameters used in your zoom tests) in that for loop, then call
renderer.queue_unstable() and renderer.flush() as before; update the loop where
renderer.camera.translate, renderer.queue_unstable, and renderer.flush are
invoked (and adjust to use the zoom method name used elsewhere, such as
camera.zoom or camera.scale) so the warmup primes the zoom cache.
docs/wg/feat-2d/optimization.md (1)

879-883: Add blank line before table for markdown compliance.

The static analysis tool flagged that tables should be surrounded by blank lines (MD058). Line 879 ends with a colon and the table starts immediately on line 880.

📝 Proposed fix
     Measured on 01-135k.perf.grida (135K nodes):
+
     | Scenario              | Before p95 | After p95 | Before MAX | After MAX |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/feat-2d/optimization.md` around lines 879 - 883, Insert a single
blank line between the line that reads "Measured on 01-135k.perf.grida (135K
nodes):" and the Markdown table that immediately follows so the table is
separated by an empty line (fixes MD058); specifically, locate the block
containing that exact header text and add one blank line before the table header
row.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida-canvas/src/window/application.rs`:
- Around line 1453-1461: The change makes the `stable` flag (computed from
`self.renderer.camera.change_kind().any_changed()`) drive
`self.renderer.apply_changes(camera_change, stable)`, which can leave dev-only
paths that call `redraw_requested()` without a corresponding full `frame()` in
an unstable state; verify and ensure the dev integration either triggers a
settle/full frame after camera changes or adjust the logic: either (A) ensure
the dev path in `crates/grida-dev/src/platform/native_application.rs` schedules
a follow-up `frame()`/settle when `camera_change.any_changed()` is true, or (B)
modify `apply_changes` call-site to treat redraw-only calls as stable for
recapture (or add a guard) so zoom/pan caches are recaptured—look for the
symbols `camera.change_kind()`, `camera_change.any_changed()`,
`self.renderer.apply_changes(...)`, and the `redraw_requested()` path and make
the chosen fix consistent across dev/test code paths.

---

Nitpick comments:
In `@crates/grida-canvas/src/runtime/scene.rs`:
- Around line 1098-1110: Extract the repeated "zoom cache eligibility" logic
into a single helper (e.g., a function or method like
zoom_cache_applicable(camera_change: CameraChangeKind) -> bool or
Scene::zoom_cache_applicable(&self, camera_change: &CameraChangeKind)) that
returns true when self.zoom_image_cache.is_some() &&
(camera_change.zoom_changed() || *camera_change == CameraChangeKind::None);
replace the three duplicated checks (the branch that currently reads
self.zoom_image_cache.is_some() && (camera_change.zoom_changed() ||
camera_change == CameraChangeKind::None) and the similar checks in can_defer and
the zoom-cache branch) with calls to this helper, and keep the pan-only fallback
logic as the separate CameraChangeKind::None case where appropriate.

In `@crates/grida-dev/src/bench/runner.rs`:
- Around line 1856-1860: The warmup loop currently calls
renderer.camera.translate(1.0, 0.0) which primes pan caches but not zoom caches,
so change the warmup to perform the same camera operation used by zoom scenarios
(e.g., call the zoom/scale method on renderer.camera with the same parameters
used in your zoom tests) in that for loop, then call renderer.queue_unstable()
and renderer.flush() as before; update the loop where renderer.camera.translate,
renderer.queue_unstable, and renderer.flush are invoked (and adjust to use the
zoom method name used elsewhere, such as camera.zoom or camera.scale) so the
warmup primes the zoom cache.

In `@docs/wg/feat-2d/optimization.md`:
- Around line 879-883: Insert a single blank line between the line that reads
"Measured on 01-135k.perf.grida (135K nodes):" and the Markdown table that
immediately follows so the table is separated by an empty line (fixes MD058);
specifically, locate the block containing that exact header text and add one
blank line before the table header row.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44728ded-c707-45ba-a85d-38bbc1481416

📥 Commits

Reviewing files that changed from the base of the PR and between 8148b65 and 954bd53.

📒 Files selected for processing (5)
  • .agents/skills/cg-perf/SKILL.md
  • crates/grida-canvas/src/runtime/scene.rs
  • crates/grida-canvas/src/window/application.rs
  • crates/grida-dev/src/bench/runner.rs
  • docs/wg/feat-2d/optimization.md

Comment on lines +1453 to +1461
//
// Use unstable (stable=false) when a camera change is active — this
// preserves the zoom/pan image caches and allows the fast blit paths.
// Without this, every zoom frame would nuke the zoom cache and force
// a full O(N) draw, causing ~3 FPS on large scenes.
{
let camera_change = self.renderer.camera.change_kind();
self.renderer.apply_changes(camera_change, true);
let stable = !camera_change.any_changed();
self.renderer.apply_changes(camera_change, stable);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python <<'PY'
from pathlib import Path
import re

redraw = re.compile(r'\.\s*redraw_requested\s*\(')
frame = re.compile(r'\.\s*frame\s*\(')

for path in Path('.').rglob('*.rs'):
    text = path.read_text(errors='ignore')
    if redraw.search(text):
        redraw_hits = [text.count('\n', 0, m.start()) + 1 for m in redraw.finditer(text)]
        frame_hits = [text.count('\n', 0, m.start()) + 1 for m in frame.finditer(text)]
        print(f'== {path} ==')
        print(f'  redraw_requested calls: {redraw_hits}')
        print(f'  frame calls: {frame_hits or "NONE"}')
        print()
PY

Repository: gridaco/grida

Length of output: 288


🏁 Script executed:

head -20 crates/grida-dev/src/platform/native_application.rs && echo "..." && sed -n '360,370p' crates/grida-dev/src/platform/native_application.rs

Repository: gridaco/grida

Length of output: 1130


🏁 Script executed:

# Check the broader context around line 364 in native_application.rs
sed -n '355,375p' crates/grida-dev/src/platform/native_application.rs

Repository: gridaco/grida

Length of output: 723


🏁 Script executed:

# Check if grida-dev is a dev/test crate or part of the main application
head -30 crates/grida-dev/Cargo.toml

Repository: gridaco/grida

Length of output: 785


Potential redraw-only regression in dev application: verify settle-frame expectation.

The script confirms that crates/grida-dev/src/platform/native_application.rs (lines 364) calls redraw_requested() without a corresponding frame() path. While this is development/test code (publish = false), line 1460's change to make stable depend on camera_change.any_changed() means an unstable frame could be rendered if no subsequent stable settle is guaranteed. The continuous winit event loop makes this less likely to manifest, but the architectural risk remains: any host that relies only on redraw_requested()redraw() for interaction rendering will not trigger the full-quality settle recapture that the new zoom-cache optimization assumes.

Verify whether this dev integration path is expected to support the new cache behavior, or if this regression is accepted for test code only.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida-canvas/src/window/application.rs` around lines 1453 - 1461, The
change makes the `stable` flag (computed from
`self.renderer.camera.change_kind().any_changed()`) drive
`self.renderer.apply_changes(camera_change, stable)`, which can leave dev-only
paths that call `redraw_requested()` without a corresponding full `frame()` in
an unstable state; verify and ensure the dev integration either triggers a
settle/full frame after camera changes or adjust the logic: either (A) ensure
the dev path in `crates/grida-dev/src/platform/native_application.rs` schedules
a follow-up `frame()`/settle when `camera_change.any_changed()` is true, or (B)
modify `apply_changes` call-site to treat redraw-only calls as stable for
recapture (or add a guard) so zoom/pan caches are recaptured—look for the
symbols `camera.change_kind()`, `camera_change.any_changed()`,
`self.renderer.apply_changes(...)`, and the `redraw_requested()` path and make
the chosen fix consistent across dev/test code paths.

@softmarshmallow softmarshmallow merged commit ab0d5df into main Mar 30, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cg Core Graphics performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant