Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions .agents/skills/cg-perf/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,19 +123,19 @@ reports `min/p50/p95/p99/MAX` plus per-stage breakdown and settle cost.

**Scenario types in the expanded matrix:**

| Kind | Scenarios | What it tests |
| -------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `pan` | slow/fast × fit/zoomed | Linear back-and-forth panning |
| `circle_pan` | small/large radius × fit/zoomed | Circular trackpad gesture (unpredictable edges) |
| `zigzag` | fast (continuous) / slow (with pauses) × fit/zoomed | Diagonal reading pattern with direction changes |
| `zoom` | slow/fast × around-fit/high | Zoom oscillation at different levels |
| `pan_with_settle` | slow/fast × fit/zoomed | Pan with settle frames interleaved every 12 frames |
| `zoom_with_settle` | slow/fast × fit/high | Zoom with settle frames interleaved every 12 frames — captures cache-cold spike after settle nukes zoom cache |
| `zoom_forced_stable` | slow/fast × fit/high (BUG prefix) | Forces `stable=true` on every zoom frame — reproduces the `redraw()` bug for A/B comparison |
| `realtime` | fast/slow × fit/zoomed | **Real-time event loop simulation** with sleep, 240Hz tick thread, and settle countdown matching the native viewer |
| `frameloop` | 16/50/80/120/200/300/500ms interval | **Real FrameLoop path** — the only bench that captures stable-frame jank during panning (see below) |
| `frameloop_zoom` | 16/50/80/120/200/500ms interval | **Real FrameLoop path for zoom** — captures stable-frame intrusion during zoom gestures |
| `resize` | alternating viewport sizes | `--resize` flag. Measures `resize()` + `redraw()` cost per cycle (layout rebuild + cache invalidation + repaint) |
| Kind | Scenarios | What it tests |
| ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `pan` | slow/fast × fit/zoomed | Linear back-and-forth panning |
| `circle_pan` | small/large radius × fit/zoomed | Circular trackpad gesture (unpredictable edges) |
| `zigzag` | fast (continuous) / slow (with pauses) × fit/zoomed | Diagonal reading pattern with direction changes |
| `zoom` | slow/fast × around-fit/high | Zoom oscillation at different levels |
| `pan_with_settle` | slow/fast × fit/zoomed | Pan with settle frames interleaved every 12 frames |
| `zoom_with_settle` | slow/fast × fit/high | Zoom with settle frames interleaved every 12 frames — captures cache-cold spike after settle nukes zoom cache |
| `baseline_nocache_zoom` | slow/fast × fit/high | Forces `stable=true` on every zoom frame — no-cache baseline measuring raw full-draw cost for A/B comparison |
| `realtime` | fast/slow × fit/zoomed | **Real-time event loop simulation** with sleep, 240Hz tick thread, and settle countdown matching the native viewer |
| `frameloop` | 16/50/80/120/200/300/500ms interval | **Real FrameLoop path** — the only bench that captures stable-frame jank during panning (see below) |
| `frameloop_zoom` | 16/50/80/120/200/500ms interval | **Real FrameLoop path for zoom** — captures stable-frame intrusion during zoom gestures |
| `resize` | alternating viewport sizes | `--resize` flag. Measures `resize()` + `redraw()` cost per cycle (layout rebuild + cache invalidation + repaint) |

**SurfaceUI overlay measurement (`--overlay`):**

Expand Down
5 changes: 5 additions & 0 deletions crates/grida-canvas/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ required-features = ["native-gl-context"]
# Raw Skia measurements, no engine involvement.
# Run: cargo run -p cg --example skia_bench_<name> --features native-gl-context --release

[[example]]
name = "skia_bench_subpixel"
path = "examples/skia_bench/skia_bench_subpixel.rs"
required-features = ["native-gl-context"]

[[example]]
name = "skia_bench_primitives"
path = "examples/skia_bench/skia_bench_primitives.rs"
Expand Down
2 changes: 1 addition & 1 deletion crates/grida-canvas/examples/golden_sk_paints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ fn draw_stacked(
// Paint order semantics:
// - `fills` is bottom → top. We pass as-is to the stacker, which composes
// each subsequent paint on top of the accumulated background.
if let Some(paint) = paint::sk_paint_stack(fills, size_tuple, images) {
if let Some(paint) = paint::sk_paint_stack(fills, size_tuple, images, true) {
canvas.draw_path(&path, &paint);
}

Expand Down
157 changes: 157 additions & 0 deletions crates/grida-canvas/examples/skia_bench/skia_bench_subpixel.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//! Skia GPU Sub-Pixel Rendering Cost Benchmark
//!
//! Measures the actual cost of drawing sub-pixel geometry at low zoom.
//! Compares:
//! A) Drawing N rects at full size (4x4 px each)
//! B) Drawing N rects at 0.02x zoom (0.08x0.08 px each — sub-pixel)
//! C) Skipping N rects entirely (baseline dispatch cost = 0)
//! D) Drawing N rects at full size, AA off
//! E) Drawing N rects at 0.02x zoom, AA off
//!
//! All use pre-recorded SkPictures (matching the real engine path).
//! GPU is synced after each frame for accurate timing.
//!
//! ```bash
//! cargo run -p cg --example skia_bench_subpixel --features native-gl-context --release
//! ```

#[cfg(not(feature = "native-gl-context"))]
fn main() {
eprintln!("This example requires --features native-gl-context");
}

#[cfg(feature = "native-gl-context")]
fn main() {
use cg::window::headless::HeadlessGpu;
use skia_safe::Color;
use std::time::Instant;

let mut gpu = HeadlessGpu::new(1000, 1000).expect("GPU init");
gpu.print_gl_info();
println!();

let surface = &mut gpu.surface;
let n_iter: u32 = 300;

for &count in &[1_000, 5_000, 10_000, 40_000] {
let rect_size = 4.0_f32;
let cols = 500usize; // spread across a large world

let pics_aa = record_rect_pictures(count, cols, rect_size, true);
let pics_noaa = record_rect_pictures(count, cols, rect_size, false);

flush_gpu(surface);

// A) Full size, AA on
let avg_full_aa = bench_pictures(surface, n_iter, &pics_aa, 1.0);

// B) 0.02x zoom, AA on
let avg_zoom_aa = bench_pictures(surface, n_iter, &pics_aa, 0.02);

// C) Skip (draw nothing, just clear + flush)
let avg_skip = {
flush_gpu(surface);
let start = Instant::now();
for _ in 0..n_iter {
let canvas = surface.canvas();
canvas.clear(Color::WHITE);
flush_gpu(surface);
}
start.elapsed() / n_iter
};

// D) Full size, AA off
let avg_full_noaa = bench_pictures(surface, n_iter, &pics_noaa, 1.0);

// E) 0.02x zoom, AA off
let avg_zoom_noaa = bench_pictures(surface, n_iter, &pics_noaa, 0.02);

println!("x{:<6}", count);
println!(
" full AA on: {:>7} us | AA off: {:>7} us",
avg_full_aa.as_micros(),
avg_full_noaa.as_micros(),
);
println!(
" 0.02x AA on: {:>7} us | AA off: {:>7} us",
avg_zoom_aa.as_micros(),
avg_zoom_noaa.as_micros(),
);
println!(" skip (0 draws): {:>5} us", avg_skip.as_micros(),);
let zoom_vs_skip = avg_zoom_aa.as_micros() as f64 - avg_skip.as_micros() as f64;
println!(
" per-node cost at 0.02x: {:.2} us (full: {:.2} us)",
zoom_vs_skip / count as f64,
(avg_full_aa.as_micros() as f64 - avg_skip.as_micros() as f64) / count as f64,
);
println!();
}
}

#[cfg(feature = "native-gl-context")]
fn record_rect_pictures(
count: usize,
cols: usize,
rect_size: f32,
aa: bool,
) -> Vec<skia_safe::Picture> {
use skia_safe::{Color, Paint, PictureRecorder, Rect};
(0..count)
.map(|i| {
let x = (i % cols) as f32 * rect_size;
let y = (i / cols) as f32 * rect_size;
let bounds = Rect::from_xywh(x, y, rect_size, rect_size);
let mut recorder = PictureRecorder::new();
let canvas = recorder.begin_recording(bounds, false);
let mut paint = Paint::default();
paint.set_anti_alias(aa);
paint.set_color(Color::from_argb(
255,
(i * 7 % 256) as u8,
(i * 13 % 256) as u8,
100,
));
canvas.draw_rect(bounds, &paint);
recorder.finish_recording_as_picture(Some(&bounds)).unwrap()
})
.collect()
}

#[cfg(feature = "native-gl-context")]
fn bench_pictures(
surface: &mut skia_safe::Surface,
n_iter: u32,
pics: &[skia_safe::Picture],
zoom: f32,
) -> std::time::Duration {
use skia_safe::Color;
use std::time::Instant;

flush_gpu(surface);
let start = Instant::now();
for _ in 0..n_iter {
let canvas = surface.canvas();
canvas.clear(Color::WHITE);
if zoom != 1.0 {
canvas.save();
canvas.scale((zoom, zoom));
}
for pic in pics {
canvas.draw_picture(pic, None, None);
}
if zoom != 1.0 {
canvas.restore();
}
flush_gpu(surface);
}
start.elapsed() / n_iter
}

#[cfg(feature = "native-gl-context")]
fn flush_gpu(surface: &mut skia_safe::Surface) {
if let Some(mut ctx) = surface.recording_context() {
if let Some(mut direct) = ctx.as_direct_context() {
direct.flush_submit_and_sync_cpu();
}
}
}
2 changes: 1 addition & 1 deletion crates/grida-canvas/src/cache/paragraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ impl ParagraphCache {
// Build the paragraph with paint applied (for rendering)
let fill_paint = if !fills.is_empty() {
// Use sk_paint_stack for all paint types (solid, gradient, image, multiple fills)
paint::sk_paint_stack(fills, layout_size, images)
paint::sk_paint_stack(fills, layout_size, images, true)
} else {
None
};
Expand Down
14 changes: 8 additions & 6 deletions crates/grida-canvas/src/painter/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ use super::{gradient, image};
use crate::{cg::prelude::*, runtime::image_repository::ImageRepository};
use skia_safe::{self, shaders, Color, Shader};

pub fn sk_solid_paint(paint: impl Into<SolidPaint>) -> skia_safe::Paint {
pub fn sk_solid_paint(paint: impl Into<SolidPaint>, aa: bool) -> skia_safe::Paint {
let p: SolidPaint = paint.into();
let mut skia_paint = skia_safe::Paint::default();
skia_paint.set_anti_alias(true);
skia_paint.set_anti_alias(aa);
let CGColor { r, g, b, a } = p.color;
let final_alpha = (a as f32 * p.opacity()) as u8;
skia_paint.set_color(skia_safe::Color::from_argb(final_alpha, r, g, b));
Expand All @@ -28,6 +28,7 @@ pub fn sk_paint_stack(
paints: &[Paint],
size: (f32, f32),
images: &ImageRepository,
aa: bool,
) -> Option<skia_safe::Paint> {
// Fast path: single solid fill — set color directly on the paint,
// avoiding shader object allocation and giving Skia's GPU backend
Expand All @@ -37,7 +38,7 @@ pub fn sk_paint_stack(
let CGColor { r, g, b, a } = solid.color;
let final_alpha = (a as f32 * solid.opacity()).round() as u8;
let mut paint = skia_safe::Paint::default();
paint.set_anti_alias(true);
paint.set_anti_alias(aa);
paint.set_color(Color::from_argb(final_alpha, r, g, b));
paint.set_blend_mode(solid.blend_mode.into());
return Some(paint);
Expand All @@ -63,7 +64,7 @@ pub fn sk_paint_stack(
}
}
let mut paint = skia_safe::Paint::default();
paint.set_anti_alias(true);
paint.set_anti_alias(aa);
paint.set_shader(shader);
// Apply the base paint's blend mode at the paint level so the first
// fill can blend with the canvas/background, matching editor semantics.
Expand All @@ -86,14 +87,15 @@ pub fn sk_paint_stack(
pub fn sk_paint_stack_without_images(
paints: &[Paint],
size: (f32, f32),
aa: bool,
) -> Option<skia_safe::Paint> {
// Fast path: single solid fill — direct color, no shader allocation.
if paints.len() == 1 {
if let Paint::Solid(solid) = &paints[0] {
let CGColor { r, g, b, a } = solid.color;
let final_alpha = (a as f32 * solid.opacity()).round() as u8;
let mut paint = skia_safe::Paint::default();
paint.set_anti_alias(true);
paint.set_anti_alias(aa);
paint.set_color(Color::from_argb(final_alpha, r, g, b));
paint.set_blend_mode(solid.blend_mode.into());
return Some(paint);
Expand All @@ -112,7 +114,7 @@ pub fn sk_paint_stack_without_images(
}
}
let mut paint = skia_safe::Paint::default();
paint.set_anti_alias(true);
paint.set_anti_alias(aa);
paint.set_shader(shader);
// Apply the base paint's blend mode at the paint level so the first
// fill can blend with the canvas/background, matching editor semantics.
Expand Down
Loading
Loading