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
94 changes: 94 additions & 0 deletions crates/nsh-platform-macos/src/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
//! does, and rasterizing one runs on the main thread, where a full replay
//! is only recorded for Core Animation's drawing threads.
//!
//! Where a canvas draws ([`CanvasState::draws_asynchronously`]): at once on
//! the main thread, unless it paints often ([`FREQUENT`] times a second or
//! more: an animation, live data) and keeps no layer, when Core Animation
//! replays it on its threads, which keeps the main thread free. Drawing
//! there costs graphics memory (~50 MB for the process on macOS 27, held
//! while any canvas draws so) and Metal work per layer, which a canvas
//! redrawn now and then (a chart updated every few seconds) gains nothing
//! from.
//!
//! Images are loaded through [`crate::image::load`] when a drawing names
//! them, kept per canvas and drawn once loaded, at their natural size in
//! pixels as points (as a canvas bitmap's CSS pixels).
Expand All @@ -29,6 +38,7 @@ use std::{
f64::consts::TAU,
rc::Rc,
sync::Arc,
time::{Duration, Instant},
};

use nsh_core::{
Expand Down Expand Up @@ -81,6 +91,48 @@ pub(crate) struct CanvasState {
prefix: RefCell<Option<Prefix>>,
/// How often the drawing was painted since it was set.
redrawn: Cell<u32>,
/// How often it paints ([`CanvasState::draws_asynchronously`]).
cadence: Cadence,
}

/// How many paints a second make a canvas draw on Core Animation's threads.
const FREQUENT: u32 = 10;

/// How often a canvas paints, counted a second at a time.
#[derive(Default)]
struct Cadence {
/// When the current second began, and its paints so far.
since: Cell<Option<Instant>>,
paints: Cell<u32>,
/// [`FREQUENT`] paints in the current second, or in the last one.
frequent: Cell<bool>,
}

impl Cadence {
/// A paint at `now`.
fn paint(&self, now: Instant) {
match self.since.get() {
Some(since) if now.duration_since(since) < Duration::from_secs(1) => {
let paints = self.paints.get() + 1;
self.paints.set(paints);
if paints >= FREQUENT {
self.frequent.set(true);
}
}
since => {
// A new second: the last one was slow, or a second without
// paints came between.
if since.is_some_and(|since| {
self.paints.get() < FREQUENT
|| now.duration_since(since) >= Duration::from_secs(2)
}) {
self.frequent.set(false);
}
self.since.set(Some(now));
self.paints.set(1);
}
}
}
}

/// How often a drawing is painted unchanged before its static part is kept
Expand Down Expand Up @@ -235,9 +287,16 @@ impl CanvasState {
0 < self.split && self.redrawn.get() >= STABLE
}

/// Whether Core Animation draws it on its threads
/// (`drawsAsynchronously`): when it paints often and keeps no layer.
pub(crate) fn draws_asynchronously(&self) -> bool {
self.cadence.frequent.get() && !self.keeps_prefix()
}

/// [`draw`]'s replay; `asynchronous`: `cx` records for Core Animation's
/// drawing threads.
fn paint(&self, cx: &CGContext, dirty: CGRect, size: CGSize, scale: f64, asynchronous: bool) {
self.cadence.paint(Instant::now());
let canvas = self;
let Some(drawing) = canvas.drawing.as_ref() else {
return;
Expand Down Expand Up @@ -1355,6 +1414,41 @@ mod tests {
canvas
}

#[test]
fn a_canvas_painting_often_draws_asynchronously() {
let start = Instant::now();
let at = |ms: u64| start + Duration::from_millis(ms);
// Redrawn every 2 s: at once.
let rare = Cadence::default();
for n in 0..5 {
rare.paint(at(n * 2000));
}
assert!(!rare.frequent.get());
// An animation at 120 Hz: on Core Animation's threads from its
// tenth frame.
let animated = Cadence::default();
for n in 0..9 {
animated.paint(at(n * 8));
}
assert!(!animated.frequent.get());
animated.paint(at(72));
assert!(animated.frequent.get());
for n in 10..240 {
animated.paint(at(n * 8));
}
assert!(animated.frequent.get());
// It stops: the next paint, a second on, draws at once again.
animated.paint(at(240 * 8 + 3000));
assert!(!animated.frequent.get());
// A canvas keeping its static part draws at once however often.
let mut canvas = sparkline_canvas(5.0);
canvas.cadence.frequent.set(true);
canvas.split = MIN_PREFIX;
assert!(!canvas.draws_asynchronously());
canvas.redrawn.set(0);
assert!(canvas.draws_asynchronously());
}

#[test]
fn the_static_part_is_drawn_from_its_layer() {
// Not before the drawing has been painted unchanged a while.
Expand Down
17 changes: 9 additions & 8 deletions crates/nsh-platform-macos/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,10 @@ define_class!(
crate::canvas::draw(cx, &state, dirty, size, scale, asynchronous);
}
CGContext::restore_g_state(c);
// A canvas that keeps its static part redraws little: drawn at
// once, without the drawing threads' cost per layer.
// Where the next paint happens (`canvas.rs`): Core Animation's
// threads for a canvas painting often, at once for the rest.
if let Some(canvas) = &state.canvas
&& canvas.keeps_prefix() == asynchronous
&& canvas.draws_asynchronously() != asynchronous
{
layer.setDrawsAsynchronously(!asynchronous);
}
Expand Down Expand Up @@ -1197,11 +1197,12 @@ impl NshView {
layer.setDelegate(Some(objc2::runtime::ProtocolObject::from_ref(&*drawer)));
layer.setAnchorPoint(CGPoint::new(0.0, 0.0));
layer.setNeedsDisplayOnBoundsChange(true);
// Canvases are drawn on Core Animation's threads (paths and
// gradients are costly; measured on the Stocks demo, main-thread
// time per frame went from 3.5 to 1.8 ms); text is cheap to draw
// and drawn at once, which was faster.
layer.setDrawsAsynchronously(self.state().canvas.is_some());
// Drawn at once: text is cheap to draw, which measured faster than
// asynchronously, and a canvas moves to Core Animation's threads
// once it paints often (`canvas.rs`; paths and gradients are
// costly: on the Stocks demo main-thread time per frame went from
// 3.5 to 1.8 ms).
layer.setDrawsAsynchronously(false);
layer.setContentsScale(self.backing_scale());
// Above the decoration behind it (fills, gradients, inset
// shadows), below the children and the borders in front.
Expand Down
5 changes: 3 additions & 2 deletions docs/dev/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ that operation on runs again (the operations before it still set the state; thei
animated part begins a path of its own). macOS keeps that part too, as a `CGLayer` (`canvas.rs` `Prefix`), when it
is long (128 operations or more: drawing a layer costs about what replaying a sparkline does) and the drawing has been
painted unchanged 30 times (a drawing that keeps changing would rasterize it on the main thread more often than it
draws from it); such a canvas then draws at once instead of asynchronously, where its layer is made, and goes back to
Core Animation's threads when its drawing changes. On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not
draws from it); such a canvas draws at once, where its layer is made. Other canvases draw at once too unless they
paint 10 times a second or more (`canvas.rs` `Cadence`), when Core Animation replays them on its threads
(`docs/dev/macos.md`). On Linux, a canvas out of sight (outside a clipping ancestor or the window) is not
drawn until it comes into sight: its animations ask for no frames. Android, the web runtime and the GPUI backend (which rasterizes the drawing, text outlines
included, with tiny-skia into a pixmap in device pixels: `nsh-platform-gpui` `canvas.rs`) still redraw the
whole canvas. Numbers:
Expand Down
17 changes: 11 additions & 6 deletions docs/dev/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,17 @@ server client, log forwarding, the error overlay: `dev.rs`). The runtime needs m
every frame of a scroll (~165 `viewWillDraw` and backing-store checks per frame in the gallery's Lists demo, ~70% of
the frame's main-thread time). Plain sublayers are not visible-rect managed. A content layer is drawn again when
its content changes and when its size does (`needsDisplayOnBoundsChange`), not when the view moves (transitions
redrew every moving text on every frame before). Canvases draw asynchronously (`drawsAsynchronously`: Core
Animation replays the drawing on its threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU
~60% → ~30%); text draws at once, which measured faster than asynchronously. A canvas whose long static part is
kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged 30 times) draws at once instead: each frame draws the
layer and replays the animated rest, and Core Animation's drawing threads, their Metal work and their IOSurface
backing stores cost more than that (the gallery's Canvas page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB).
redrew every moving text on every frame before). Text draws at once, which measured faster than asynchronously.
A canvas draws at once too until it paints 10 times a second or more (an animation, live data: `canvas.rs`
`Cadence`), when it draws asynchronously (`drawsAsynchronously`: Core Animation replays the drawing on its
threads; Stocks idle, main-thread time per frame ~3.3 → ~1.8 ms, process CPU ~60% → ~30%), and at once again after
a slow second. Drawing asynchronously costs graphics memory (`footprint`'s "Owned physical footprint (unmapped)
(graphics)", ~50–60 MB for the process while any canvas draws so) and Metal work per layer, which a canvas redrawn
every few seconds gains nothing from: three 340×40 charts redrawn every 2 s hold ~21 MB at once against ~83 MB
asynchronously. A canvas whose long static part is kept as a `CGLayer` (`canvas.rs`: a drawing painted unchanged
30 times) draws at once however often it paints: each frame draws the layer and replays the animated rest, which
costs less than the drawing threads, their Metal work and their IOSurface backing stores (the gallery's Canvas
page: CPU 7–10% → 4–6%, GPU 5–6% → 0%, ~210 → ~105 MB).
Contents stay in the screen's format: 8-bit contents (`kCAContentsFormatRGBA8Uint`) measured slower on the
ProMotion display, which converts them on every frame.
- **No masks on what scrolls, where avoidable** (`visual.rs`): the window server pays for a mask on every frame its
Expand Down
5 changes: 5 additions & 0 deletions docs/dev/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ draw a panel.
every canvas at once (Stocks p95 ~3.3 → ~7.5 ms, 2% of frames over a refresh: the main thread fills the screen's
RGBA16F backing stores), 8-bit contents (canvas phase ~0.45 → ~1.6 ms).

Canvases drawn at once unless they paint often (`canvas.rs` `Cadence`: 10 paints a second): three 340×40 line
charts redrawn every 2 s (a menu-bar panel's) hold ~83 MB of footprint asynchronously and ~21 MB at once, with
~30% less CPU time over 40 s. The gallery's tour is unchanged in frame time and CPU; peak memory on the pages with
canvases drops (Canvas scrolling ~383 → ~310 MB, Animations ~290 → ~232, Gestures ~279 → ~221).

List binds while scrolling (release host, 120 Hz MacBook Pro; the list scrolled by chained native
`scrollToOffset({ animated: true })` every 300 ms, no input; each display link refresh logged with its link
timestamp and main-thread time; three runs each, refreshes missed = link gaps of two periods or more). Before =
Expand Down
8 changes: 5 additions & 3 deletions website/docs/performance-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ Monitor shows); *resident* adds the pages of the system's shared libraries:

Through the gallery's tour the process holds 84–230 MB up to the Canvas page and ~350–360 MB from the Text View page
on, with what it caches on purpose (decoded images, bounded at 128 MB; the Text View page's documents) and the
frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~380 MB with all three
frameworks it loads on first use (WebKit for web views); the Canvas page's rasters, up to ~310 MB with all three
canvases on screen, go when the page closes.

### App size
Expand All @@ -238,8 +238,10 @@ What makes these numbers:

- **Frames only for changes**: the display link ticks while a native animation runs and stops with it; an animation
stops with its page (its view's deletion ends its keyframes, a loop stops in its component's `cleanup`).
- **Canvases on Core Animation's threads**: canvases draw asynchronously (`drawsAsynchronously`), and an animated value
redraws only the area the operations reading it cover.
- **Canvases drawn where they cost least**: a canvas that paints often (an animation, live data) draws on Core
Animation's threads (`drawsAsynchronously`), which keeps the main thread free; one redrawn now and then draws at
once, without the graphics memory asynchronous drawing holds. An animated value redraws only the area the
operations reading it cover.
- **Static canvas parts kept**: once a drawing stays unchanged, a long part before its first animated operation is
kept as a layer; each frame draws that layer and replays only the animated rest, at once on the main thread (the
Canvas page's gauge, clock and dot matrix: no GPU work and half the memory).
Expand Down
4 changes: 2 additions & 2 deletions website/zh-CN/docs/performance-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 ×

浏览 gallery 的过程中,进程在 Canvas 页之前占用 84–230 MB,从 Text View 页起约 350–360 MB,增长的是它有意保留的缓存(解码后的图片,
上限 128 MB;Text View 页的文档)以及首次使用时加载的框架(网页视图用的 WebKit);Canvas 页的栅格在三个 canvas 都可见时最多约
380 MB,离开页面即释放。
310 MB,离开页面即释放。

### 应用体积

Expand All @@ -221,7 +221,7 @@ gallery 的每个页面,打开时以及测试滚动它时,同样在 1200 ×
这些数字来自:

- **只为变化绘制帧**:display link 在原生动画运行时跳动,并随动画一起停止;动画随页面一起停止(视图删除时它的关键帧动画结束,循环在组件的 `cleanup` 中停止)。
- **Canvas 在 Core Animation 的线程上绘制**:canvas 异步绘制(`drawsAsynchronously`),动画值变化时只重绘读取它的操作覆盖的区域。
- **Canvas 在开销最小的地方绘制**:频繁重绘的 canvas(动画、实时数据)在 Core Animation 的线程上异步绘制(`drawsAsynchronously`),不占用主线程;偶尔才重绘的 canvas 直接在主线程上绘制,省下异步绘制占用的显存。动画值变化时只重绘读取它的操作覆盖的区域。
- **保留 canvas 的静态部分**:绘制内容稳定后,第一个动画操作之前较长的部分保留为一个图层;每帧绘制这个图层,只重放动画的其余部分,并直接在主线程上完成(Canvas 页的仪表盘、时钟和点阵:不占 GPU,内存减半)。
- **内容变化时才绘制**:文本和 canvas 绘制在各自的 layer 里,内容或尺寸变化时才重绘,移动或滚动时不重绘。

Expand Down
Loading