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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **Chrome is pickable.** `SceneBuilder::push_pick_scope` / `pop_pick_scope` record the tree a primitive is drawn in, and `plot::pick` names it: a hit on an axis tick label, gridline, legend key, strip or title reports its plot, region, part and ordinal through `PlotPath`, and the scope stack is the path an event bubbles along.
- **`PickIndex::hits_at` returns every hit, topmost first**, each carrying its scope chain — it costs the same as topmost-only. `pick_at` remains the one-number convenience.
- **Rectangle and lasso queries.** `hits_in` (bounds intersect), `hits_within` (bounds enclosed, and exact) and `hits_in_path` (bounds-centre inside a path) for brushing and marquee selection, plus `*_into` variants that reuse a caller's buffer.
- **`Slot::from_name` and `Slot::ALL`** — the reverse of `Slot::name`, so a region name recovered from a hit round-trips back into `CompositionLayout::get`.
- **`Plot::index_in_patch` and `Axis::id`** — a plot's position within its patch's attach list, and the handle an axis was attached under. `(patch_id, index_in_patch)` is the pair `update_plot_at` already addresses plots by.
- **`RecordingScene::draw_ops` and `scope_at`** — the ops that draw something, and the pick-scope stack in effect at an op.

### Changed

- **Picking is a CPU spatial index built while a scene is drawn, not a second rasterization.** Every renderer's `Renderer::Scene` is a `PickIndexScene`, so hit testing rides on the scene and a vector backend or a renderer-free build answers the same way a GPU one does. `with_picking` and `pick_at` keep their names; `pick_at` takes a `Point` and no longer lags the frame it describes. At 100k marks the cost falls from 59 ms to ~7 ms on the sparse-strips backend, and no GPU readback happens at all.
- **`PickId::Id` spans the full `u32` range with nothing reserved.** Both the 24-bit cap and `Id(0)`'s equivalence to `Block` were artifacts of packing ids into a texture's color channels, where an uncovered pixel decoded to zero. `MAX_PICK_ID`, the build-time panic above `0xFF_FFFF` and `pick::raw_id` are gone, and a `pick_id` channel of row indices no longer has to start at 1. Occlusion is `PickId::Block` alone.
- **`PickId::Skip` means "no authoring id" rather than "not pickable".** A primitive is indexed when its id is not `Skip` *or* its innermost pick scope is a target — which is how chrome participates without a `PickId` argument at every chrome call site. A geom with no `pick_id` channel is still recorded nowhere.
- **SVG emits `<g data-pick-kind=…>` for pick scopes** under the existing `SvgConfig::pick_ids` flag, so an exported plot groups by axis, part and item rather than arriving as a flat list of paths.
- **A dashed stroke is hittable along its gaps, a glyph run picks as its layout box, and stroke caps and joins hit as round.** The index tests geometry rather than rasterized coverage; the differences are sub-pixel to a few pixels and on the generous side.

### Removed

- **The GPU hitmap.** `hitmap`, `try_finish_pick`, `render_to_texture_deferring_pick`, `set_refresh_pick` and `refreshes_pick` on both wgpu renderers; `WindowConfig::pick_interval`; `pick::id_to_color` and `pick::decode`. The pick pass they served no longer exists, and lazy tree construction subsumes the throttle: a window redrawing faster than it is queried builds nothing.

## 0.3.0

### Added
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ cargo +1.86 check --no-default-features --features document-write --ignore-rust-

cargo test # all tests
cargo test --test smoke # the GPU smoke test (requires a working wgpu adapter)
cargo test --test picking # picking round-trip
cargo test --test pick_index # hit testing; needs no features at all
cargo test --test image_geom # raster images through PlotComposition
cargo test --no-default-features --features vello-hybrid --test hybrid # the sparse-strips backend, end to end
cargo test --test window_blit # the window presentation blit, headless
Expand Down Expand Up @@ -114,7 +114,7 @@ The core types and traits compile with `--no-default-features` (no wgpu pulled i

The following belong in higher layers or other crates and should not land here:

- **Animation runtime** — picking emits pixel ids (see `src/CLAUDE.md`) and the `window` feature delivers them to an event handler, but tweening states and animation scheduling live in the host.
- **Animation runtime** — picking reports hits (see `src/CLAUDE.md`) and the `window` feature delivers them to an event handler, but tweening states and animation scheduling live in the host.
- **Filter effects** — blur, drop shadow, etc. Outside the Vello-∩-Blend2D intersection that governs the scene API.
- **Font selection / loading at the `SceneBuilder` level** — the scene API consumes already-positioned glyphs. Shaping and font discovery live in the `text` module (parley-backed); a host that wants its own shaper can replace it behind the `TextRun` / `draw_text` surface.

Expand Down
4 changes: 0 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -605,10 +605,6 @@ required-features = ["vello"]
name = "mesh"
required-features = ["vello"]

[[test]]
name = "picking"
required-features = ["vello"]

[[test]]
name = "hybrid"
required-features = ["vello-hybrid"]
Expand Down
4 changes: 2 additions & 2 deletions crates/hephaestus-wasm/js/hephaestus.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export interface PlotViewOptions {
colorScheme?: 'light' | 'dark' | 'auto';
/** Track the canvas's CSS box with a ResizeObserver. Default `true`. */
autoResize?: boolean;
/** Allocate a pick target and read it back per frame. Default `false`. */
/** Record a hit index while drawing, enabling `pickAt`. Default `false`. */
picking?: boolean;
/**
* Give the canvas the context menu an ordinary image has. A string names
Expand Down Expand Up @@ -147,7 +147,7 @@ export class PlotView {

/**
* The row id under a point, in CSS pixels, or `undefined` for empty space.
* Needs `picking: true`; may lag the visible frame slightly.
* Needs `picking: true`.
*/
pickAt(cssX: number, cssY: number): number | undefined;

Expand Down
10 changes: 5 additions & 5 deletions crates/hephaestus-wasm/js/hephaestus.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,9 @@ export class PlotView {
* @param {{ colorScheme?: 'light'|'dark'|'auto', autoResize?: boolean,
* picking?: boolean, saveOnRightClick?: boolean|string,
* defaultFont?: boolean, placeholder?: HTMLImageElement|string }} [opts]
* `picking` allocates a second render target and reads it back after
* every frame, so leave it off unless `pickAt` is going to be called.
* `picking` makes the scene record a hit index as it draws, which costs
* CPU per draw call, so leave it off unless `pickAt` is going to be
* called.
* `saveOnRightClick` gives the canvas an ordinary image's context menu.
* Pass a string to name the saved file — a bare `true` uses `plot.png`.
* The name is a hint: a `data:` URL has no path for a browser to take a
Expand Down Expand Up @@ -567,13 +568,12 @@ export class PlotView {
* otherwise get wrong on a high-density display.
*
* Returns `undefined` unless the view was created with `picking: true`.
* The hitmap may lag the visible frame slightly, since the readback is
* never waited on.
* Always describes the frame on screen.
*/
pickAt(cssX, cssY) {
if (this._freed) return undefined;
const ratio = this.canvas.width / (this.canvas.clientWidth || this.canvas.width);
return this.handle.pickAt(Math.round(cssX * ratio), Math.round(cssY * ratio));
return this.handle.pickAt(cssX * ratio, cssY * ratio);
}

/**
Expand Down
14 changes: 7 additions & 7 deletions crates/hephaestus-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,10 @@ impl PlotHandle {
/// synchronously. Fails if the document is unreadable or if no WebGPU
/// adapter is available — see [`is_supported`].
///
/// `picking` allocates a second render target and reads it back after
/// every frame, so it stays off unless [`Self::pick_at`] is going to be
/// called.
/// `picking` makes the scene record a spatial hit index as it is drawn.
/// That costs CPU per draw call whether or not anything is queried, so it
/// stays off unless [`Self::pick_at`] is going to be called. Nothing is
/// read back from the GPU either way.
#[wasm_bindgen(js_name = create)]
pub async fn create(
canvas: web_sys::HtmlCanvasElement,
Expand Down Expand Up @@ -340,11 +341,10 @@ impl PlotHandle {
/// pixels, so scale a pointer event by the device pixel ratio. Always
/// `undefined` unless `picking` was passed to [`Self::create`].
///
/// The readback is never waited on, so this can answer from a frame or
/// two ago. That is invisible for hover and is what keeps the call off
/// the main thread's critical path.
/// Answers from the index the scene built while it was drawn, so it
/// always describes the frame on screen.
#[wasm_bindgen(js_name = pickAt)]
pub fn pick_at(&mut self, x: u32, y: u32) -> Option<u32> {
pub fn pick_at(&self, x: f64, y: f64) -> Option<u32> {
self.host.pick_at(x, y)
}

Expand Down
95 changes: 87 additions & 8 deletions examples/backend_perf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@
//! cargo run --release --example backend_perf --features vello,vello-hybrid,png -- 100000
//! ```
//!
//! The last two rows are what picking costs: filling the index as the scene
//! is drawn, and querying it. Neither is a rasterisation.
//!
//! The first row is the geometry every backend has to build regardless, so
//! subtract it before comparing the rest — otherwise the shared cost reads as
//! though it belonged to whichever backend is listed first.
use hephaestus::backend::hybrid::HybridRenderer;
use hephaestus::backend::vello::VelloRenderer;
use hephaestus::color::rgb8;
use hephaestus::geometry::Point;
use hephaestus::{Affine, Brush, FillRule, PickId, Renderer, SceneBuilder};
use kurbo::Shape;
use std::time::Instant;
Expand Down Expand Up @@ -44,6 +48,37 @@ fn draw(scene: &mut dyn SceneBuilder, n: usize) {
}
}

/// The same scatter, drawn the way the plot layer draws it: one marker path
/// shared by every mark, placed by a per-mark transform.
///
/// The difference matters only to picking. `draw` builds a fresh path per
/// mark in absolute coordinates, which is the worst case for the hit index —
/// nothing can be shared, so every mark's geometry is stored. This is the
/// case `plot::PointGeom` actually produces, where one stored path serves
/// them all.
fn draw_shared_marker(scene: &mut dyn SceneBuilder, n: usize) {
let marker = kurbo::Circle::new((0.0, 0.0), 2.0).to_path(0.2);
let mut s = 0x2545_f491_4f6c_dd1du64;
let mut unit = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
(s >> 11) as f64 / (1u64 << 53) as f64
};
for i in 0..n {
let x = unit() * W as f64;
let y = unit() * H as f64;
scene.fill(
FillRule::NonZero,
Affine::translate((x, y)),
&Brush::Solid(rgb8(70, 120, 220)),
None,
&marker,
PickId::Id(i as u32 + 1),
);
}
}

/// Report the fastest of `iters` runs, after warming up.
///
/// The minimum rather than the mean: GPU clocks ramp, pipelines warm, and the
Expand Down Expand Up @@ -113,14 +148,6 @@ fn main() {
hyp.render_to_buffer(W, H, bg, &mut out).unwrap();
});

let mut hys = HybridRenderer::with_picking().unwrap();
hys.set_refresh_pick(false);
time("hybrid: picking on, pick pass SKIPPED", 10, || {
hys.scene().clear();
draw(hys.scene(), n);
hys.render_to_buffer(W, H, bg, &mut out).unwrap();
});

let mut ve = VelloRenderer::new().unwrap();
time("classic: encode + render, no picking", 10, || {
ve.scene().clear();
Expand All @@ -134,4 +161,56 @@ fn main() {
draw(vep.scene(), n);
let _ = vep.render_to_buffer(W, H, bg, &mut out);
});

let mut hys = HybridRenderer::with_picking().unwrap();
time("hybrid: shared marker, WITH picking", 10, || {
hys.scene().clear();
draw_shared_marker(hys.scene(), n);
hys.render_to_buffer(W, H, bg, &mut out).unwrap();
});

let mut hysn = HybridRenderer::new().unwrap();
time("hybrid: shared marker, no picking", 10, || {
hysn.scene().clear();
draw_shared_marker(hysn.scene(), n);
hysn.render_to_buffer(W, H, bg, &mut out).unwrap();
});

// What picking actually costs now: filling the index while drawing, then
// the first query building the tree. Both are CPU-side and neither is a
// rasterisation, which is the point of the whole arrangement.
let mut ix = HybridRenderer::with_picking().unwrap();
ix.scene().clear();
draw(ix.scene(), n);
ix.render_to_buffer(W, H, bg, &mut out).unwrap();
let index = ix.pick_index().expect("picking enabled");
// The tree is built lazily, so the first query after a frame pays for it.
let t = std::time::Instant::now();
let _ = std::hint::black_box(index.pick_at(Point::new(450.0, 280.0)));
println!(
"{:<46} {:8.2} ms",
"pick: first query (builds the tree)",
t.elapsed().as_secs_f64() * 1000.0
);
let mut s = 0x2545_f491_4f6c_dd1du64;
let mut unit = || {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
(s >> 11) as f64 / (1u64 << 53) as f64
};
let qs: Vec<Point> = (0..10_000)
.map(|_| Point::new(unit() * W as f64, unit() * H as f64))
.collect();
let t = std::time::Instant::now();
let mut found = 0usize;
for &q in &qs {
found += index.pick_at(q).is_some() as usize;
}
let us = t.elapsed().as_secs_f64() * 1e6 / qs.len() as f64;
println!(
"{:<46} {us:8.3} us/query ({found}/{} hit)",
"pick: warm query",
qs.len()
);
}
19 changes: 6 additions & 13 deletions examples/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
//! dragging between displays of different densities keeps text crisp and
//! physical sizes constant.
//! - **Picking.** Each point carries a `pick_id`; hovering reports the row
//! under the cursor and redraws it enlarged. `pick_interval` caps how often
//! the hitmap is rebuilt, which matters at high point counts because the
//! pick pass rasterises the scene a second time.
//! under the cursor and redraws it enlarged. The scene records a spatial
//! index as it draws, so a hover is a CPU query against the frame on
//! screen — there is no second rasterisation and nothing to read back.
//!
//! The point count is the first CLI argument, so the same scene scales up for
//! a rough feel of how the pipeline copes:
Expand All @@ -29,8 +29,7 @@
//! rows.
//!
//! A second argument names the backend, in a build that compiled both in. The
//! sparse-strip one has no draw cap at all, and picks without ever reporting an
//! id that was not drawn:
//! sparse-strip one has no draw cap at all:
//!
//! ```sh
//! cargo run --release --example window --features window,vello-hybrid -- 200000 hybrid
Expand All @@ -43,7 +42,6 @@ use hephaestus::plot::chrome::axis::{Axis, AxisPlacement};
use hephaestus::plot::{scale, Plot, PlotComposition, PointGeom};
use hephaestus::scales::chrome::AxisSide;
use hephaestus::window::{run, Backend, Event, EventCtx, Frame, WindowApp, WindowConfig};
use std::time::Duration;

const BASE_SIZE: f64 = 1.0;
const HOVER_SIZE: f64 = 7.0;
Expand Down Expand Up @@ -89,7 +87,7 @@ impl WindowApp for Demo {
fn event(&mut self, ctx: &mut EventCtx<'_>, event: Event) {
match event {
Event::CursorMoved { position } => {
let hit = ctx.pick_at(position.x.max(0.0) as u32, position.y.max(0.0) as u32);
let hit = ctx.pick_at(position.x, position.y);
if hit != self.hovered {
self.hovered = hit;
self.rebuild_geom();
Expand Down Expand Up @@ -211,12 +209,7 @@ fn main() {
.size(900, 560)
.background(rgb8(248, 248, 252))
.picking(true)
.backend(backend)
// The pick pass rasterises the whole scene a second time, so at high
// point counts it can cost as much as the visible frame. Capping how
// often it runs keeps a resize drag responsive; the hitmap goes at most
// this stale, which a pointer cannot notice.
.pick_interval(Duration::from_millis(30));
.backend(backend);

if let Err(err) = run(config, Demo::new(points)) {
eprintln!("window: {err}");
Expand Down
Loading
Loading