gpucanvas 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,21 @@ Put thousands of live DOM nodes on an infinite canvas and let the GPU pan and zo
4
4
 
5
5
  ![The same 5,000 live React screens as plain DOM (left) and in gpucanvas (right), with each side's frame rate measured live](docs/benchmark.gif)
6
6
 
7
- The same session on both sides: 5,000 live React screens, the same camera path, a message typed and sent. Left, every screen is plain DOM under a CSS transform. Right, gpucanvas. Recorded in Chrome on a MacBook Pro; the frame rates are measured live in each run. Run it yourself with the demo below.
7
+ The same session on both sides: 5,000 live React screens, the same camera path, a message typed and sent. Left, every screen is plain DOM under a CSS transform. Right, gpucanvas. Recorded in Chrome on a MacBook Pro; the frame rates are measured live in each run. Run it yourself in the [live demo](https://berkantay.github.io/gpucanvas/).
8
8
 
9
- Status: early (0.1). The API can still change.
9
+ ## Try it
10
+
11
+ Open **[berkantay.github.io/gpucanvas](https://berkantay.github.io/gpucanvas/)** in Chrome 157 or later. Thirty agent cursors build the first view, then the camera pulls out to all 5,000 screens. Press `b` to run the benchmark tour.
12
+
13
+ The GPU path needs HTML-in-Canvas, which is behind a flag:
14
+
15
+ 1. Open `chrome://flags/#canvas-draw-element`
16
+ 2. Set it to Enabled and relaunch Chrome
17
+ 3. Reload the demo
18
+
19
+ Without the flag the demo runs the DOM fallback with 1,000 screens and says so.
20
+
21
+ Status: early (0.3). The API can still change.
10
22
 
11
23
  ```sh
12
24
  npm install gpucanvas
@@ -29,6 +41,129 @@ HTML-in-Canvas are available (Chrome 157+), nodes are snapshotted into a GPU atl
29
41
  one instanced draw call. Everywhere else it falls back to a CSS-transformed container with the
30
42
  same API.
31
43
 
44
+ ## Editing
45
+
46
+ `bindEditor` turns the canvas into an editor: select, drill in, move and resize. The GPU draws
47
+ the outlines, 2 CSS pixels wide at any zoom, and they follow their element's layout. Names,
48
+ sizes, handles and snapping guides sit in a thin overlay above it.
49
+
50
+ ```ts
51
+ import { bindEditor } from 'gpucanvas';
52
+
53
+ const editor = bindEditor(canvas, {
54
+ pick: 'element', // 'node' (default), 'element', or a function
55
+ trigger: 'click', // an edit mode; the default 'shift-click' leaves clicks to your components
56
+ onChange: (layers) => console.log(layers),
57
+ onTransform: ({ phase, layers, to }) => {
58
+ // The editor never moves anything itself. Apply `to` with updateNode, state or styles.
59
+ },
60
+ });
61
+ ```
62
+
63
+ A click selects the outermost layer under the pointer, a whole node. Each double-click goes one
64
+ layer deeper, and later clicks stay at that depth. Wrappers the size of their parent are skipped,
65
+ so every double-click visibly changes something.
66
+
67
+ | Input | Does |
68
+ | ------------------ | ---------------------------------------------------------------------------- |
69
+ | Double-click | One layer deeper |
70
+ | Enter, Shift+Enter | First child, parent |
71
+ | Tab, Shift+Tab | Next and previous sibling |
72
+ | Escape | Cancels a drag, then goes up a layer, then clears |
73
+ | Drag | Moves, snapping to the edges and centers of neighbors (Shift locks the axis) |
74
+ | Handles | Resize (Shift on a corner keeps the ratio) |
75
+ | Drag on open space | A box that selects the layers it touches, inside the layer you are in |
76
+
77
+ With the default `trigger: 'shift-click'`, the same works with Shift held, and clicks still reach
78
+ your components. A layer is an array of elements, so one layer can be a component that renders
79
+ several. `pick` decides the layers:
80
+
81
+ - `'node'`: whole nodes only.
82
+ - `'element'`: every element from the node down to the one under the pointer.
83
+ - a function of that element, returning the layers that hold it, outermost first.
84
+
85
+ In React, `useEditor` gives the selection as state, and `pickComponents` makes the layers React
86
+ components:
87
+
88
+ ```tsx
89
+ import { labelComponent, pickComponents, useEditor } from 'gpucanvas/react';
90
+
91
+ // Inside <GpuCanvas>:
92
+ const { selected, clear } = useEditor({ pick: pickComponents, label: labelComponent });
93
+ // Click a screen, double-click: SignIn, SignInCard, Button.
94
+ ```
95
+
96
+ `pickComponents` and `componentOf` read React's internal fibers, as click-to-component tools do.
97
+ Production builds usually minify component names, so mark components with your own attributes
98
+ when you need names there.
99
+
100
+ The building blocks, for your own interactions:
101
+
102
+ ```ts
103
+ canvas.getElementAt(point); // deepest element at a world point
104
+ canvas.getElementRect(el); // any element's rect in world units
105
+ canvas.getNodeOf(el); // the node that holds it
106
+ canvas.getNodesIn(rect); // nodes overlapping a world rect
107
+ canvas.setSelection(elements); // outline these; [] clears
108
+ canvas.setHover(el); // a lighter outline; null clears
109
+ canvas.screenToWorld(point); // surface pixels to world units, and worldToScreen back
110
+ ```
111
+
112
+ Colors are the `selectionColor` and `hoverColor` options. The demo picks React components in the edit mode;
113
+ `?pick=element` or `?pick=node` picks elements or whole screens, and `?edit=0` leaves clicks to
114
+ the components.
115
+
116
+ ## Shader effects
117
+
118
+ Components stay live DOM, and the GPU draws them through a shader. One call per node:
119
+
120
+ ```ts
121
+ import { effects } from 'gpucanvas';
122
+
123
+ canvas.setEffect(node, effects.ripple); // the effect's default parameters
124
+ canvas.setEffect(node, effects.glow, [1, 0.4, 0.2, 32]); // orange, 32 px reach
125
+ canvas.setEffect(node, null); // back to plain
126
+ ```
127
+
128
+ Built in:
129
+
130
+ - Following the pointer: `glass` (a magnifying lens), `frost` (ice, clear around the pointer),
131
+ `dither` (a lens of big dithered pixels).
132
+ - On each press: `shockwave`, a ring that bends the node as it spreads.
133
+ - Running on their own: `glitch`, `vhs`, `glow`, `hue`, `ripple`.
134
+ - Still: `blur`, `pixelate`, `grayscale`, and `dissolve` for a node leaving or arriving (its
135
+ parameter is the progress, 0 to 1).
136
+
137
+ Animated effects draw every frame while their node is in view; still ones cost nothing once
138
+ drawn.
139
+
140
+ Your own is a WGSL function over the node's pixels:
141
+
142
+ ```ts
143
+ import { defineEffect } from 'gpucanvas';
144
+
145
+ const scanlines = defineEffect({
146
+ name: 'scanlines',
147
+ animated: true,
148
+ params: [3], // line spacing in CSS pixels
149
+ wgsl: /* wgsl */ `
150
+ fn effect(input: EffectInput) -> vec4f {
151
+ let color = source(input.uv);
152
+ let y = input.uv.y * input.size.y + input.time * 20.0;
153
+ return color * (0.85 + 0.15 * step(0.5, fract(y / input.params.x)));
154
+ }`,
155
+ });
156
+ ```
157
+
158
+ `EffectInput` has `uv` (0 to 1 across the node), `size` in CSS pixels, `time` in seconds,
159
+ `zoom`, the four `params`, the `pointer` and the last `press` in the node's uv, and
160
+ `sincePress` in seconds. `source(uv)` reads the node's pixels, premultiplied, and is
161
+ transparent outside it; `hash` and `noise` give random values. An effect that draws past its node, like a glow, declares how far with
162
+ `pad`. Effects read the node's whole snapshot, so very close in they are softer than plain nodes.
163
+
164
+ The DOM backend shows effects that have a `css` look as a CSS `filter` (animated ones as filter
165
+ animations) and the rest as plain nodes. The demo's toolbar puts one effect on every component.
166
+
32
167
  ## Run the demo
33
168
 
34
169
  ```sh
@@ -38,32 +173,44 @@ bun run dev # http://localhost:5199/?mode=gpu&n=10000 (mode: auto | gpu
38
173
 
39
174
  ## How it works
40
175
 
41
- 1. Nodes live inside `<canvas content="drawable">`.
42
- 2. A node that needs pixels is promoted (`drawable` attribute). In the next paint event its
43
- snapshot is rasterized with `queue.drawElementImageToTexture` into a texture-array atlas, at a
44
- power-of-two scale of the zoom.
45
- 3. After that the node is parked with `display: none`. Every drawable costs Chrome layout and a
46
- recording on each rendering update, so only nodes waiting for a raster stay drawable.
47
- 4. A MutationObserver marks a node dirty when its content changes, which re-promotes it.
48
- 5. Each frame culls nodes against the view and draws every visible one with one instanced quad
49
- draw. Nodes without a snapshot yet draw as placeholders.
50
- 6. While the camera moves, only missing snapshots are rasterized. Blurry ones are upgraded once
51
- it rests, center first. Zooming out keeps the sharper snapshots.
52
- 7. Only the node under the pointer is live (hit-testable). Pans and zooms cost no DOM work.
53
-
54
- ## Measured (M-series Mac, Chrome 157, DPR 2, 120 Hz)
55
-
56
- The 6-second scripted path runs overview, then 100% zoom, then a pan, then overview again.
57
-
58
- | Nodes | gpu avg fps | gpu p95 | dom avg fps | dom p95 | mount gpu / dom |
59
- | ------ | ----------- | ------- | ----------- | -------- | --------------- |
60
- | 1,000 | 120 | 10.1 ms | 120 | 10.1 ms | 1 s / 1 s |
61
- | 10,000 | 98 | 16.7 ms | 85 | 17.0 ms | 1 s / 1.3 s |
62
- | 50,000 | 64 | 33.4 ms | 7 | 189.3 ms | 7 s / 13 s |
63
-
64
- - At 50,000 nodes the overview shows placeholders: nodes under 6 screen pixels wide are not
65
- rasterized.
66
- - At 10,000 nodes the overview takes about 6 s to fill.
176
+ 1. **Pages.** Nodes are live DOM inside `<canvas content="drawable">`, packed side by side into
177
+ pages of 2048 CSS pixels. Chrome records a page as one snapshot, so one
178
+ `drawElementImageToTexture` call rasterizes dozens of nodes at once.
179
+ 2. **Rasterize, then park.** A page with nodes waiting for pixels becomes `drawable`. In the next
180
+ paint event it is drawn into a texture-array atlas, whole or one node at a time when only a
181
+ few changed. Then it is parked with `content-visibility: hidden`, which keeps layout but skips
182
+ paint, so the browser spends nothing on it per frame.
183
+ 3. **Thumbnails first.** Every node gets a 1/16 scale thumbnail in an atlas layer that is never
184
+ evicted, so a zoomed-out view of thousands of screens is always filled in. Sharper snapshots
185
+ go on top at power-of-two scales, so zooming reuses them.
186
+ 4. **Tiles up close.** Past 100% zoom on a 2x display, only the visible part of a node is
187
+ rasterized, as sharp tiles, center first. While its content animates, one half-resolution preview stands in.
188
+ 5. **One draw call.** Each frame culls nodes against the view and draws every visible one with
189
+ one instanced quad draw. Panning and zooming never touch the DOM. Raster work has a
190
+ per-frame budget, so the camera stays smooth while pixels fill in.
191
+ 6. **Live updates.** A MutationObserver marks a node dirty when its content changes, and
192
+ loading images, web fonts and CSS transitions trigger a redraw. Animated content on nodes
193
+ under 200 screen pixels refreshes at most four times a second.
194
+ 7. **Input.** Only the page under the pointer is registered for hit testing, and a CSS transform
195
+ puts the node under the pointer exactly where the GPU drew it. Clicks, typing, selection and
196
+ hover all go to the real element. A focused node keeps its page live.
197
+
198
+ ## Measured (M-series Mac, Chrome 157, 1600 x 900 window, DPR 1)
199
+
200
+ The demo's benchmark (press `b`) runs a 30-second tour: a message typed and sent, a chart
201
+ animating, a push to 500% zoom, shift-click picks and a selection sweep, while 30 agent cursors
202
+ build screens around the view. Every frame is timed. At 5,000 screens it measures 113 to 115 fps
203
+ for gpucanvas and 18 to 20 for DOM.
204
+
205
+ The table below was measured with the 0.1 benchmark, a 94-second session from the overview into
206
+ clusters of app screens, close-ups up to 500% zoom, 20 clicks and typed messages, then back out.
207
+
208
+ | Screens | gpucanvas avg fps | gpucanvas p95 | DOM avg fps | DOM p95 | Mount gpucanvas / DOM |
209
+ | ------- | ----------------- | ------------- | ----------- | -------- | --------------------- |
210
+ | 5,000 | 117 | 9.2 ms | 23 | 50.2 ms | 1.2 s / 1.3 s |
211
+ | 10,000 | 108 | 16.7 ms | 9 | 133.8 ms | 1.8 s / 3.4 s |
212
+
213
+ DOM is the same screens in one CSS-transformed container, which is also the fallback backend.
67
214
 
68
215
  ## Chrome 157 findings (differ from the WICG explainer)
69
216
 
@@ -78,13 +225,16 @@ The 6-second scripted path runs overview, then 100% zoom, then a pan, then overv
78
225
  - **Newly drawable elements.** An element that just became drawable is not reported in
79
226
  `changedElements`. Its snapshot is available from the next paint event.
80
227
  - **Per-call cost.** Each `drawElementImageToTexture` call allocates and fully rasterizes, about
81
- 250 to 550 calls per second in total. This, not compositing, limits the fill rate.
228
+ 250 to 550 calls per second in total. This, not compositing, limits the fill rate, which is
229
+ why nodes are batched into pages.
230
+ - **Cropping.** A source rectangle still rasterizes the whole source element at the requested
231
+ scale before cropping. Zoomed in, a node therefore moves to a page of its own.
82
232
  - **Drawable count.** Recording cost scales with the number of drawables (and non-`display:none`
83
233
  canvas children) on every `requestPaint`. 10,000 always-drawable nodes run at 22 fps even when
84
234
  idle.
85
235
 
86
236
  ## Next
87
237
 
88
- - **Keyboard and accessibility.** Parked nodes are `display: none`, so Tab skips them. Needs a
89
- focus model, such as a mirror or pinning the focused node.
238
+ - **Keyboard and accessibility.** Parked pages use `content-visibility: hidden`, so Tab skips
239
+ their nodes. A focused node already stays live; moving focus across pages needs a focus model.
90
240
  - **Workers.** Composite in a worker with `captureElementImage`, and drive culling from the GPU.