@mocanvas/editor 4.0.0 → 4.0.2
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/BENCHMARK.md +244 -84
- package/README.md +1 -3
- package/package.json +4 -4
package/BENCHMARK.md
CHANGED
|
@@ -1,32 +1,196 @@
|
|
|
1
1
|
# Benchmark: mocanvas vs tldraw
|
|
2
2
|
|
|
3
|
-
_Generated 2026-09-
|
|
3
|
+
_Generated 2026-09-11 16:14:18 UTC by `apps/bench/scripts/bench.mjs`. Re-run with `pnpm --filter bench bench`._
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
mocanvas creates shapes 2.4–3.0× faster than tldraw and reaches first paint up to 12× faster,
|
|
8
|
+
answers hit-test queries 15–100× faster and holds 3–5× less JS heap; at 20,000 shapes it is also
|
|
9
|
+
14.1× faster on the median pan/zoom frame and 2.2–2.3× faster on select-all-drag.
|
|
10
|
+
At the smaller sizes the two converge on the median pan/zoom frame, for two
|
|
11
|
+
reasons set out in the caveats: at these sizes both libraries finish a frame inside the browser's frame cadence, so the deltas are floored by the cadence rather than by either library's work; and the pan/zoom metric counts only
|
|
12
|
+
main-thread work, which tldraw largely avoids by panning with a CSS transform on the compositor. The
|
|
13
|
+
`selectAllDrag` tables are the fairer frame comparison.
|
|
14
|
+
|
|
15
|
+
Rendering the same tldraw-authored document, mocanvas agrees with tldraw on 99.3% of shape interiors by
|
|
16
|
+
IoU at 97.7% colour agreement, and half its stroke pixels land within 0.00 px of a tldraw stroke
|
|
17
|
+
pixel of the same colour (95th percentile 9.90 px, against a stroke about 4 px wide). 2.52% of the
|
|
18
|
+
canvas differs pixel-for-pixel, most of it two hand-drawn outlines that miss each other by roughly a stroke
|
|
19
|
+
width. The largest differences that are not stroke randomness are a bound arrow that runs to the shape's
|
|
20
|
+
edge where tldraw stops short of it, and a lighter font; the stroke-band section lists the rest.
|
|
4
21
|
|
|
5
22
|
Both libraries are driven through an identical `window.bench` API (`apps/bench/src/bench-api.ts`)
|
|
6
23
|
with byte-identical workloads: same grid, same shape sizes, colours and fills, the same scripted
|
|
7
24
|
camera path, and the same hit-test sample points. Frame times are frame-to-frame `requestAnimationFrame`
|
|
8
25
|
deltas recorded while a camera animation runs (zoom to fit → zoom in 4× → horizontal pan sweep → zoom back out).
|
|
9
26
|
|
|
27
|
+
## What changed since the previous run
|
|
28
|
+
|
|
29
|
+
**Not shown for this run.** The previously published performance numbers (2026-09-04 09:26:29 UTC, git `15b670a-dirty`) were
|
|
30
|
+
measured with software (SwiftShader) rasterisation, and this run used
|
|
31
|
+
hardware rasterisation. A before/after table across that change would
|
|
32
|
+
read the rasteriser as if it were a code change — the effect is larger than anything in the diff between
|
|
33
|
+
the two revisions — so it is omitted rather than printed with a warning attached. The next run measured the
|
|
34
|
+
same way as this one will restore the section; update `PREVIOUS` in `apps/bench/scripts/bench.mjs` then.
|
|
35
|
+
|
|
10
36
|
## Environment
|
|
11
37
|
|
|
12
38
|
| | |
|
|
13
39
|
| :--- | :--- |
|
|
14
|
-
| Date | 2026-09-
|
|
40
|
+
| Date | 2026-09-11 16:14:18 UTC |
|
|
15
41
|
| Machine | Apple M3 Pro, 11 cores, 36 GB |
|
|
16
42
|
| OS | Darwin 25.5.0 (arm64) |
|
|
17
43
|
| Node | v25.9.0 |
|
|
18
44
|
| Browser | headless Chromium 151.0.7922.34 (Playwright 1.62.1) |
|
|
19
|
-
| Chromium flags | `--js-flags=--expose-gc --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows --hide-scrollbars --force-color-profile=srgb --font-render-hinting=none --ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy` |
|
|
20
|
-
| GL mode attempted |
|
|
21
|
-
| WebGL2 renderer | ANGLE (
|
|
22
|
-
| Rasterisation | **
|
|
45
|
+
| Chromium flags | `--js-flags=--expose-gc --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows --hide-scrollbars --force-color-profile=srgb --font-render-hinting=none --ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy --use-gl=angle --use-angle=metal` |
|
|
46
|
+
| GL mode attempted | hardware |
|
|
47
|
+
| WebGL2 renderer | ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro, Unspecified Version) |
|
|
48
|
+
| Rasterisation | **hardware** — the machine's own GPU, through ANGLE, headless |
|
|
49
|
+
| Pixel-comparison browser | headless Chromium, software (SwiftShader, CPU) |
|
|
50
|
+
| Pixel-comparison renderer | ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver) |
|
|
23
51
|
| tldraw | 5.4.0 |
|
|
24
|
-
| mocanvas |
|
|
52
|
+
| mocanvas | 4.0.1 (this repo, c78401a-dirty) |
|
|
25
53
|
| Builds | production (`vite build`, minified, `NODE_ENV=production`) for both |
|
|
26
54
|
| Viewport | 1200×800 CSS px, device scale 1 |
|
|
27
55
|
| Matrix | N ∈ {1000, 5000, 20000} × kind ∈ {geo, mixed}, 3 repeats, medians reported |
|
|
28
56
|
|
|
29
|
-
_Measured from a **dirty working tree** at `
|
|
57
|
+
_Measured from a **dirty working tree** at `c78401a`: the bundle under measurement was built from the files as they were when this run started, which are not any committed state. Treat these numbers as provisional until they are re-run on a clean tree._
|
|
58
|
+
|
|
59
|
+
## Results
|
|
60
|
+
|
|
61
|
+
Every cell is the median of the repeats. `ratio` compares the two: “2.00× faster” means mocanvas took half the time tldraw did.
|
|
62
|
+
|
|
63
|
+
### Shape creation (`create(n, kind)`)
|
|
64
|
+
|
|
65
|
+
Wall time of the single `createShapes` call, inside one transaction with history disabled.
|
|
66
|
+
|
|
67
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
68
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
69
|
+
| 1,000 | geo | 17 ms | 44 ms | 2.64× faster |
|
|
70
|
+
| 5,000 | geo | 43 ms | 131 ms | 3.05× faster |
|
|
71
|
+
| 20,000 | geo | 145 ms | 387 ms | 2.67× faster |
|
|
72
|
+
| 1,000 | mixed | 29 ms | 70 ms | 2.43× faster |
|
|
73
|
+
| 5,000 | mixed | 80 ms | 191 ms | 2.38× faster |
|
|
74
|
+
| 20,000 | mixed | 234 ms | 609 ms | 2.61× faster |
|
|
75
|
+
|
|
76
|
+
Time from the start of that call until the second animation frame afterwards (creation + first paint):
|
|
77
|
+
|
|
78
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
79
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
80
|
+
| 1,000 | geo | 35 ms | 125 ms | 3.62× faster |
|
|
81
|
+
| 5,000 | geo | 56 ms | 456 ms | 8.20× faster |
|
|
82
|
+
| 20,000 | geo | 161 ms | 1944 ms | 12.08× faster |
|
|
83
|
+
| 1,000 | mixed | 67 ms | 155 ms | 2.30× faster |
|
|
84
|
+
| 5,000 | mixed | 189 ms | 564 ms | 2.99× faster |
|
|
85
|
+
| 20,000 | mixed | 589 ms | 2340 ms | 3.98× faster |
|
|
86
|
+
|
|
87
|
+
### Pan / zoom frame time (`panZoomRun(120)`)
|
|
88
|
+
|
|
89
|
+
120 frames of the scripted camera animation. Median frame:
|
|
90
|
+
|
|
91
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
92
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
93
|
+
| 1,000 | geo | 8.30 ms | 8.30 ms | 1.00× faster |
|
|
94
|
+
| 5,000 | geo | 8.30 ms | 8.40 ms | 1.01× faster |
|
|
95
|
+
| 20,000 | geo | 8.30 ms | 116.70 ms | 14.06× faster |
|
|
96
|
+
| 1,000 | mixed | 8.30 ms | 8.30 ms | 1.00× faster |
|
|
97
|
+
| 5,000 | mixed | 8.30 ms | 8.40 ms | 1.01× faster |
|
|
98
|
+
| 20,000 | mixed | 8.30 ms | 116.70 ms | 14.06× faster |
|
|
99
|
+
|
|
100
|
+
**6 of these 6 mocanvas cells sit at 8.30 ms, which is this browser's frame cadence, not a measurement of
|
|
101
|
+
mocanvas.** A frame that finishes before the next animation callback is due reports the cadence however
|
|
102
|
+
early it finished, so those cells are an upper bound: mocanvas's real pan cost at those sizes is *somewhere
|
|
103
|
+
below* the number printed, and the ratio against tldraw is a lower bound on how much faster it is. Quote
|
|
104
|
+
them as "at least", never as the measured cost.
|
|
105
|
+
|
|
106
|
+
95th-percentile frame (the stutter you actually feel):
|
|
107
|
+
|
|
108
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
109
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
110
|
+
| 1,000 | geo | 8.80 ms | 8.90 ms | 1.01× faster |
|
|
111
|
+
| 5,000 | geo | 8.90 ms | 25.00 ms | 2.81× faster |
|
|
112
|
+
| 20,000 | geo | 8.40 ms | 233.40 ms | 27.79× faster |
|
|
113
|
+
| 1,000 | mixed | 8.90 ms | 8.80 ms | 1.01× slower |
|
|
114
|
+
| 5,000 | mixed | 8.90 ms | 25.20 ms | 2.83× faster |
|
|
115
|
+
| 20,000 | mixed | 16.60 ms | 249.90 ms | 15.05× faster |
|
|
116
|
+
|
|
117
|
+
Worst single frame:
|
|
118
|
+
|
|
119
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
120
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
121
|
+
| 1,000 | geo | 9.30 ms | 9.30 ms | 1.00× slower |
|
|
122
|
+
| 5,000 | geo | 9.30 ms | 33.30 ms | 3.58× faster |
|
|
123
|
+
| 20,000 | geo | 9.10 ms | 325.20 ms | 35.74× faster |
|
|
124
|
+
| 1,000 | mixed | 16.70 ms | 9.20 ms | 1.82× slower |
|
|
125
|
+
| 5,000 | mixed | 33.40 ms | 40.00 ms | 1.20× faster |
|
|
126
|
+
| 20,000 | mixed | 16.90 ms | 324.90 ms | 19.22× faster |
|
|
127
|
+
|
|
128
|
+
Average frames per second over the run (higher is better):
|
|
129
|
+
|
|
130
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
131
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
132
|
+
| 1,000 | geo | 120.0 | 120.0 | 1.00× slower |
|
|
133
|
+
| 5,000 | geo | 120.0 | 87.3 | 1.38× faster |
|
|
134
|
+
| 20,000 | geo | 120.0 | 8.0 | 14.94× faster |
|
|
135
|
+
| 1,000 | mixed | 118.0 | 120.0 | 1.02× slower |
|
|
136
|
+
| 5,000 | mixed | 114.3 | 75.9 | 1.51× faster |
|
|
137
|
+
| 20,000 | mixed | 110.8 | 7.6 | 14.61× faster |
|
|
138
|
+
|
|
139
|
+
### Select-all + drag frame time (`selectAllDragRun(60)`)
|
|
140
|
+
|
|
141
|
+
Every shape on the page is selected, then nudged once per frame for 60 frames — this exercises
|
|
142
|
+
the write path (store update → geometry invalidation → re-render), not just the camera. Median frame:
|
|
143
|
+
|
|
144
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
145
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
146
|
+
| 1,000 | geo | 9.20 ms | 16.70 ms | 1.82× faster |
|
|
147
|
+
| 5,000 | geo | 58.30 ms | 108.30 ms | 1.86× faster |
|
|
148
|
+
| 20,000 | geo | 233.30 ms | 541.60 ms | 2.32× faster |
|
|
149
|
+
| 1,000 | mixed | 16.60 ms | 16.80 ms | 1.01× faster |
|
|
150
|
+
| 5,000 | mixed | 66.60 ms | 116.40 ms | 1.75× faster |
|
|
151
|
+
| 20,000 | mixed | 280.90 ms | 608.40 ms | 2.17× faster |
|
|
152
|
+
|
|
153
|
+
95th-percentile frame:
|
|
154
|
+
|
|
155
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
156
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
157
|
+
| 1,000 | geo | 16.90 ms | 25.70 ms | 1.52× faster |
|
|
158
|
+
| 5,000 | geo | 66.60 ms | 116.70 ms | 1.75× faster |
|
|
159
|
+
| 20,000 | geo | 249.90 ms | 574.90 ms | 2.30× faster |
|
|
160
|
+
| 1,000 | mixed | 17.10 ms | 33.00 ms | 1.93× faster |
|
|
161
|
+
| 5,000 | mixed | 75.00 ms | 124.90 ms | 1.67× faster |
|
|
162
|
+
| 20,000 | mixed | 300.00 ms | 691.70 ms | 2.31× faster |
|
|
163
|
+
|
|
164
|
+
### Hit testing (`hitTestRun(500)`)
|
|
165
|
+
|
|
166
|
+
500 deterministic `getShapeAtPoint` queries spread over the document bounds, µs per query:
|
|
167
|
+
|
|
168
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
169
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
170
|
+
| 1,000 | geo | 2.0 µs | 30.0 µs | 15.00× faster |
|
|
171
|
+
| 5,000 | geo | 3.2 µs | 116.0 µs | 36.25× faster |
|
|
172
|
+
| 20,000 | geo | 5.0 µs | 477.6 µs | 95.52× faster |
|
|
173
|
+
| 1,000 | mixed | 2.0 µs | 39.8 µs | 19.90× faster |
|
|
174
|
+
| 5,000 | mixed | 3.2 µs | 152.8 µs | 47.75× faster |
|
|
175
|
+
| 20,000 | mixed | 5.8 µs | 580.8 µs | 100.14× faster |
|
|
176
|
+
|
|
177
|
+
### JS heap after the run (`memoryMB()`)
|
|
178
|
+
|
|
179
|
+
`performance.memory.usedJSHeapSize` after a forced GC (`--js-flags=--expose-gc`). This is JS heap only —
|
|
180
|
+
it does not include GPU buffers or **WebAssembly linear memory** (mocanvas), or the DOM/layout memory of
|
|
181
|
+
the render tree (tldraw), so it understates both, and it understates mocanvas by more: mocanvas keeps the
|
|
182
|
+
scene in its Rust core, which is WASM memory and therefore outside this number entirely, while tldraw's
|
|
183
|
+
store is JS objects and is counted in full. Treat it as a rough signal, not a memory benchmark, and do
|
|
184
|
+
not quote the ratio as "mocanvas uses N× less memory".
|
|
185
|
+
|
|
186
|
+
| N | kind | mocanvas | tldraw | ratio |
|
|
187
|
+
| ---: | :--- | ---: | ---: | :--- |
|
|
188
|
+
| 1,000 | geo | 14.5 MB | 51.0 MB | 3.52× less |
|
|
189
|
+
| 5,000 | geo | 48.1 MB | 202.2 MB | 4.21× less |
|
|
190
|
+
| 20,000 | geo | 168.8 MB | 803.0 MB | 4.76× less |
|
|
191
|
+
| 1,000 | mixed | 16.3 MB | 51.0 MB | 3.13× less |
|
|
192
|
+
| 5,000 | mixed | 51.0 MB | 214.6 MB | 4.21× less |
|
|
193
|
+
| 20,000 | mixed | 189.8 MB | 803.0 MB | 4.23× less |
|
|
30
194
|
|
|
31
195
|
## Rendering comparison
|
|
32
196
|
|
|
@@ -36,6 +200,11 @@ geo rectangle with a text label, an ellipse, a star, a triangle, a filled hexago
|
|
|
36
200
|
a bent arrow bound to the rectangle, a straight arrow, a line, a note with text, a text shape, and a frame
|
|
37
201
|
with two children. Both pages load that same file, zoom to fit at 1200×800 and are screenshotted.
|
|
38
202
|
|
|
203
|
+
These screenshots were taken in a **separate software (SwiftShader, CPU)-rasterised** browser, not the one that produced the frame times
|
|
204
|
+
above. The stroke-band metric pairs stroke pixels by colour, and hardware MSAA resolves a thin stroke's edge
|
|
205
|
+
pixels differently from SwiftShader, so a hardware render scores a band difference that is antialiasing rather
|
|
206
|
+
than geometry. Holding the rasteriser fixed here keeps these figures comparable with every earlier run.
|
|
207
|
+
|
|
39
208
|
- `apps/bench/results/compare-tldraw.png` — tldraw (14 shapes from 18 records)
|
|
40
209
|
- `apps/bench/results/compare-mocanvas.png` — mocanvas (14 shapes from 18 records)
|
|
41
210
|
- `apps/bench/results/compare-diff.png` — differing pixels in red
|
|
@@ -45,9 +214,9 @@ looks like it means.
|
|
|
45
214
|
|
|
46
215
|
| | | what it measures |
|
|
47
216
|
| :--- | ---: | :--- |
|
|
48
|
-
| Interior IoU | **99.
|
|
49
|
-
| Stroke band distance | **0.00 px median,
|
|
50
|
-
| Whole-image pixel diff | **2.
|
|
217
|
+
| Interior IoU | **99.3%** | fills, positions and sizes — exact geometry on both sides |
|
|
218
|
+
| Stroke band distance | **0.00 px median, 9.90 px p95** | how far apart the two outlines actually run |
|
|
219
|
+
| Whole-image pixel diff | **2.52% differing, 89.4% painted-pixel IoU** | everything at once, stroke randomness included |
|
|
51
220
|
|
|
52
221
|
Both libraries draw the default `dash: "draw"` style as a genuinely hand-drawn outline — seeded wobble,
|
|
53
222
|
rounded corners, overshoot past the vertex — and neither is trying to reproduce the other's random
|
|
@@ -58,7 +227,7 @@ round. That is measured, not assumed. When mocanvas drew exact polygons with a u
|
|
|
58
227
|
at `83ff957` — 79.1% painted-pixel IoU against 78.2% — while looking visibly wrong. So the whole-image
|
|
59
228
|
row is reported last, and the two above it are the ones to quote.
|
|
60
229
|
|
|
61
|
-
### 1. Interior IoU — 99.
|
|
230
|
+
### 1. Interior IoU — 99.3%
|
|
62
231
|
|
|
63
232
|
Per shape, the region is cut out of both screenshots and the silhouette of whatever was drawn there is
|
|
64
233
|
recovered — paint, plus everything the paint encloses, so a fill the colour of the paper still has an
|
|
@@ -70,53 +239,49 @@ pixel diff uses — the fills.
|
|
|
70
239
|
|
|
71
240
|
| shape | interior px (tldraw) | IoU | colour |
|
|
72
241
|
| :--- | ---: | ---: | ---: |
|
|
73
|
-
| rectangle |
|
|
74
|
-
| ellipse | 21,
|
|
75
|
-
| star | 8,
|
|
76
|
-
| triangle | 8,
|
|
77
|
-
| hexagon | 15,
|
|
78
|
-
| note | 44,
|
|
79
|
-
| frame | 114,091 | 100.0% |
|
|
80
|
-
| rectangle (in frame) | 10,
|
|
81
|
-
| ellipse (in frame) | 12,
|
|
82
|
-
| **whole fixture** | **239,
|
|
242
|
+
| rectangle | 25,960 | 98.4% | 95.9% |
|
|
243
|
+
| ellipse | 21,720 | 99.2% | 99.8% |
|
|
244
|
+
| star | 8,592 | 95.2% | 98.5% |
|
|
245
|
+
| triangle | 8,843 | 98.4% | 98.0% |
|
|
246
|
+
| hexagon | 15,836 | 97.4% | 99.1% |
|
|
247
|
+
| note | 44,386 | 99.6% | 96.2% |
|
|
248
|
+
| frame | 114,091 | 100.0% | 98.1% |
|
|
249
|
+
| rectangle (in frame) | 10,057 | 98.0% | 99.3% |
|
|
250
|
+
| ellipse (in frame) | 12,306 | 97.5% | 99.5% |
|
|
251
|
+
| **whole fixture** | **239,428** | **99.3%** | **97.7%** |
|
|
83
252
|
|
|
84
253
|
The fixture-wide row is one union over every shape's interior, not an average of the rows, so the boxes
|
|
85
254
|
that overlap — the frame and its two children — are not counted twice.
|
|
86
255
|
|
|
87
256
|
**Fills are essentially exact.** Every shape that is only fill agrees on colour over 99% of its shared
|
|
88
|
-
interior, except the star at 98.
|
|
257
|
+
interior, except the star at 98.5% — the smallest interior in the fixture, where the hand-drawn
|
|
89
258
|
outline's wobble reaches proportionally furthest in. The two rows below that are the two carrying
|
|
90
|
-
something other than fill: the rectangle's 95.
|
|
91
|
-
rather than a fill one, and the note's 96.
|
|
259
|
+
something other than fill: the rectangle's 95.9% is its "Hello box" label, a font-weight difference
|
|
260
|
+
rather than a fill one, and the note's 96.2% is its label plus the drop shadow around its body. The
|
|
92
261
|
note's gradient is no longer a difference at all: it reads `#f7dc99` at the top of the body and `#fce19c` at
|
|
93
262
|
the bottom in both renders — identical values, not merely within tolerance.
|
|
94
263
|
|
|
95
264
|
**The two geometry errors this metric was built to find are fixed.** In the previous run the star sat at
|
|
96
265
|
69.9% and the hexagon at 80.1%: mocanvas drew the star with too small an inner radius, so its arms were
|
|
97
266
|
visibly thinner, and it put the hexagon's vertices left and right instead of top and bottom, which made it
|
|
98
|
-
half a box narrower across the flats. `2ef11f1` corrected both, and they now read
|
|
267
|
+
half a box narrower across the flats. `2ef11f1` corrected both, and they now read 95.2% and 97.4%.
|
|
99
268
|
Neither error was visible in the whole-image number, where both were buried under stroke wobble; both were
|
|
100
269
|
obvious the moment the interiors were compared directly.
|
|
101
270
|
|
|
102
|
-
**What is left is not a wrong outline.** The lowest rows are now star (
|
|
103
|
-
have
|
|
104
|
-
|
|
105
|
-
The note is the one large region on that list, and what this metric scores there is not its body but its
|
|
106
|
-
silhouette, which includes the drop shadow. The body matches: 214 px wide in both renders, same gradient
|
|
107
|
-
values at both ends. The shadow does not — mocanvas's spreads about 7 px further on each side and 7 px
|
|
108
|
-
higher than tldraw's — and that spread is most of the missing 10 points.
|
|
271
|
+
**What is left is not a wrong outline.** The lowest rows are now star (95.2%), hexagon (97.4%), ellipse (in frame) (97.5%), and they
|
|
272
|
+
have causes that are not shape geometry.
|
|
109
273
|
|
|
110
|
-
The star
|
|
111
|
-
erosion takes a fixed bite out of every silhouette and
|
|
112
|
-
tldraw's (see Visible differences); both cost a small
|
|
113
|
-
|
|
274
|
+
The star is simply among the smallest interiors in the fixture
|
|
275
|
+
(8,592 px). A 6 px erosion takes a fixed bite out of every silhouette and
|
|
276
|
+
zoom-to-fit lands mocanvas's ink a pixel or two off tldraw's (see Visible differences); both cost a small
|
|
277
|
+
region proportionally far more than they cost a large one. The
|
|
278
|
+
frame, the largest region here, scores 100.0% under exactly the same treatment.
|
|
114
279
|
|
|
115
280
|
Not scored here: the bent arrow, the freehand stroke, the straight arrow, the line and the text shape. An open shape encloses nothing, and its box overlaps shapes that
|
|
116
281
|
do — measuring "its interior" would silently be measuring theirs. The stroke band distance below is the
|
|
117
282
|
metric that covers them.
|
|
118
283
|
|
|
119
|
-
### 2. Stroke band distance — 0.00 px median,
|
|
284
|
+
### 2. Stroke band distance — 0.00 px median, 9.90 px at the 95th percentile
|
|
120
285
|
|
|
121
286
|
The question a hand-drawn outline can fairly be asked is not "do your stroke pixels land on the
|
|
122
287
|
reference's?" but "how far away are they?". For every stroke colour in the reference render, an exact
|
|
@@ -129,39 +294,42 @@ within a few pixels; a genuinely misplaced outline would not.
|
|
|
129
294
|
|
|
130
295
|
| shape | reference stroke px | median | p95 | max |
|
|
131
296
|
| :--- | ---: | ---: | ---: | ---: |
|
|
132
|
-
| rectangle |
|
|
133
|
-
| bent arrow | 1,
|
|
134
|
-
| ellipse | 2,
|
|
135
|
-
| star | 1,
|
|
136
|
-
| triangle | 1,
|
|
137
|
-
| hexagon | 1,
|
|
138
|
-
| freehand stroke | 1,
|
|
139
|
-
| straight arrow |
|
|
140
|
-
| line | 1,
|
|
297
|
+
| rectangle | 2,951 | 0.00 px | 2.00 px | 7.62 px |
|
|
298
|
+
| bent arrow | 1,829 | 3.61 px | 19.72 px | 23.54 px |
|
|
299
|
+
| ellipse | 2,326 | 0.00 px | 1.00 px | 4.24 px |
|
|
300
|
+
| star | 1,837 | 0.00 px | 1.41 px | 3.00 px |
|
|
301
|
+
| triangle | 1,632 | 0.00 px | 1.00 px | 4.24 px |
|
|
302
|
+
| hexagon | 1,701 | 0.00 px | 2.00 px | 3.00 px |
|
|
303
|
+
| freehand stroke | 1,314 | 0.00 px | 2.83 px | 8.94 px |
|
|
304
|
+
| straight arrow | 790 | 0.00 px | 69.89 px | 118.33 px |
|
|
305
|
+
| line | 1,151 | 0.00 px | 1.41 px | 2.83 px |
|
|
141
306
|
| note | 826 | 1.00 px | 3.00 px | 5.00 px |
|
|
142
|
-
| text shape | 1,
|
|
143
|
-
| frame | 4,
|
|
144
|
-
| rectangle (in frame) | 1,
|
|
145
|
-
| ellipse (in frame) | 1,
|
|
146
|
-
| **whole fixture** | — | **0.00 px** | **
|
|
307
|
+
| text shape | 1,637 | 2.00 px | 10.63 px | 26.68 px |
|
|
308
|
+
| frame | 4,546 | 0.00 px | 65.01 px | 189.32 px |
|
|
309
|
+
| rectangle (in frame) | 1,441 | 0.00 px | 2.00 px | 2.00 px |
|
|
310
|
+
| ellipse (in frame) | 1,686 | 0.00 px | 1.00 px | 2.83 px |
|
|
311
|
+
| **whole fixture** | — | **0.00 px** | **9.90 px** | **189.32 px** |
|
|
147
312
|
|
|
148
313
|
**This is the number that says the hand-drawn stroke is working.** Half of mocanvas's stroke pixels are
|
|
149
314
|
within 0.00 px of a reference stroke pixel of the same colour, and the worst pixel anywhere in the fixture is
|
|
150
|
-
|
|
315
|
+
189.32 px out — about 47.3 stroke widths, on the frame. 13 of the 14 scored regions sit at a median of
|
|
151
316
|
half a stroke width or better. Two outlines that a pixel diff scores as largely disjoint are, measured as a
|
|
152
317
|
distance, running within a stroke width of each other nearly everywhere.
|
|
153
318
|
|
|
154
|
-
|
|
319
|
+
One row stands out on the median — the outline itself runs somewhere else, and that is not stroke
|
|
155
320
|
randomness:
|
|
156
321
|
|
|
157
|
-
- **bent arrow,
|
|
158
|
-
to; mocanvas runs it to the shape's edge. A binding difference, and the only region left in the fixture
|
|
159
|
-
whose median is more than half a stroke width out.
|
|
160
|
-
- **text shape, 2.00 px median / 13.04 px p95** — font weight: tldraw's face is heavier and slightly wider, so the
|
|
161
|
-
glyphs drift apart along the line even though the baseline and size agree. It also owns the fixture's
|
|
162
|
-
worst single pixel.
|
|
322
|
+
- **bent arrow, 3.61 px median / 19.72 px p95** — tldraw stops the arrow short of the rectangle it is bound to; mocanvas runs it to the shape's edge. A binding difference, not a stroke one.
|
|
163
323
|
|
|
164
|
-
|
|
324
|
+
Two rows have a median at or below half a stroke width but a 95th percentile many stroke widths out
|
|
325
|
+
(**straight arrow**, 0.00 px median / 69.89 px p95; **frame**, 0.00 px median / 65.01 px p95). That profile is
|
|
326
|
+
not a displaced outline — almost every pixel coincides *exactly*, and then a minority are stranded. It is the
|
|
327
|
+
signature of a colour class present in one render and absent from the other: this metric pairs stroke pixels
|
|
328
|
+
by colour, so an antialiased edge that quantises into a different bucket on one side has no partner at any
|
|
329
|
+
distance, and sets the percentile by itself. Read those two numbers as a question to investigate against the
|
|
330
|
+
screenshots, not as a measured distance between two outlines.
|
|
331
|
+
|
|
332
|
+
The star (0.00 px median / 1.41 px p95) and the hexagon (0.00 px median / 2.00 px p95) were on this list in the previous run, at 2.83 px
|
|
165
333
|
and 6.71 px median against a ~4 px stroke. `2ef11f1` corrected the outlines behind both, and they now sit at
|
|
166
334
|
the fixture median.
|
|
167
335
|
|
|
@@ -171,15 +339,15 @@ inside the frame's box and is not a rendering difference. And a colour class wit
|
|
|
171
339
|
150 pixels in a region is the antialiased skirt of a neighbouring colour rather than a stroke of its own,
|
|
172
340
|
so it is skipped rather than allowed to set that region's 95th percentile.
|
|
173
341
|
|
|
174
|
-
### 3. Whole-image pixel diff — 2.
|
|
342
|
+
### 3. Whole-image pixel diff — 2.52% differing, 89.4% painted-pixel IoU
|
|
175
343
|
|
|
176
344
|
| | |
|
|
177
345
|
| :--- | ---: |
|
|
178
|
-
| Differing pixels | **2.
|
|
346
|
+
| Differing pixels | **2.52%** (24,168 of 960,000) |
|
|
179
347
|
| Tolerance | any channel differing by more than 24/255 |
|
|
180
|
-
| Painted (non-white) pixels, mocanvas |
|
|
181
|
-
| Painted (non-white) pixels, tldraw | 114,
|
|
182
|
-
| Painted-pixel overlap (IoU) | **
|
|
348
|
+
| Painted (non-white) pixels, mocanvas | 112,919 |
|
|
349
|
+
| Painted (non-white) pixels, tldraw | 114,290 |
|
|
350
|
+
| Painted-pixel overlap (IoU) | **89.4%** |
|
|
183
351
|
|
|
184
352
|
**Both rows understate the agreement, and the differing-pixels row is the worse of the two.** tldraw inks
|
|
185
353
|
only about 12% of the canvas, so a render that draws too little scores well on it: mocanvas painted
|
|
@@ -193,14 +361,14 @@ Quote it as an upper bound on how much of the render is pixel-identical, not as
|
|
|
193
361
|
|
|
194
362
|
### How the comparison has moved
|
|
195
363
|
|
|
196
|
-
| | `32ac776-dirty` | `15b670a-dirty` | `ef4d079` | `83ff957` | `
|
|
364
|
+
| | `32ac776-dirty` | `15b670a-dirty` | `ef4d079` | `83ff957` | `c78401a-dirty` |
|
|
197
365
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
198
366
|
| | before the `.tldr` load fix | drawn, wrong fill ramp | correct fills, exact outlines | hand-drawn outlines | star and hexagon corrected (this run) |
|
|
199
367
|
| mocanvas loaded the file | no — threw on `props.richText` | yes | yes | yes | yes |
|
|
200
|
-
| Interior IoU | not measured yet | not measured yet | not measured yet | **94.1%** | **99.
|
|
368
|
+
| Interior IoU | not measured yet | not measured yet | not measured yet | **94.1%** | **99.3%** |
|
|
201
369
|
| Stroke band, median | not measured yet | not measured yet | not measured yet | **1.00 px** | **0.00 px** |
|
|
202
|
-
| Painted-pixel IoU | 0.0% | 56.4% | 79.1% | 78.2% |
|
|
203
|
-
| Differing pixels | 11.84% | 12.14% | 3.79% | 3.82% | 2.
|
|
370
|
+
| Painted-pixel IoU | 0.0% | 56.4% | 79.1% | 78.2% | 89.4% |
|
|
371
|
+
| Differing pixels | 11.84% | 12.14% | 3.79% | 3.82% | 2.52% |
|
|
204
372
|
|
|
205
373
|
The first two columns are why the whole-image rows are reported last: they barely move across the change
|
|
206
374
|
that took mocanvas from drawing nothing at all to drawing the entire document. The fill-ramp fix in
|
|
@@ -215,8 +383,8 @@ IoU, 3.79% → 3.82% differing). A pixel diff cannot tell a stroke in the wrong
|
|
|
215
383
|
drawn with different random numbers, and it charges twice for the second.
|
|
216
384
|
|
|
217
385
|
`2ef11f1` then fixed two outlines that were genuinely the wrong shape — the star's inner radius and the
|
|
218
|
-
hexagon's orientation — and *every* metric improved, whole-image rows included (78.2% →
|
|
219
|
-
IoU, 3.82% → 2.
|
|
386
|
+
hexagon's orientation — and *every* metric improved, whole-image rows included (78.2% → 89.4% painted-pixel
|
|
387
|
+
IoU, 3.82% → 2.52% differing, interior IoU 94.1% → 99.3%). That is the distinction the ordering
|
|
220
388
|
encodes: a wrong shape is wrong in all three, while a differently-seeded stroke only looks wrong to the
|
|
221
389
|
third. Optimise against the first two; read the third as a consequence.
|
|
222
390
|
|
|
@@ -284,6 +452,8 @@ The remaining warnings are benign: extra props mocanvas does not model
|
|
|
284
452
|
_Written by hand from looking at the two screenshots, and kept in
|
|
285
453
|
`apps/bench/results/visible-differences.md` so that re-running the bench does not overwrite it._
|
|
286
454
|
|
|
455
|
+
> **This section is not regenerated.** It was written against an earlier run, so where a figure here disagrees with the measured tables above (interior IoU **99.3%**, stroke band **0.00 px median / 9.90 px p95**, whole-image diff **2.52%**), the tables are the measurement and this is the commentary. Re-review it against the current screenshots before quoting it.
|
|
456
|
+
|
|
287
457
|
Comparing `compare-tldraw.png` with `compare-mocanvas.png`. Both are the same unmodified fixture
|
|
288
458
|
rendered by each library. Re-measured on `2ef11f1`, in the same run that produced the numbers above.
|
|
289
459
|
|
|
@@ -443,18 +613,8 @@ rectangle, the line's spline, the frame and both of its children, and the text s
|
|
|
443
613
|
|
|
444
614
|
## Caveats — please read before quoting these numbers
|
|
445
615
|
|
|
446
|
-
- **
|
|
447
|
-
- **
|
|
448
|
-
the engine.** A probe in this same environment (`glCostProbe` in `apps/bench/results/panzoom-after.json`)
|
|
449
|
-
draws 50,000 triangles (5.15 MB of vertex data) into a context configured exactly like the
|
|
450
|
-
WebGL2 backend's, and reads one pixel back so the GPU process has to finish before the clock stops.
|
|
451
|
-
Uploading the buffers costs 0.39 ms and clearing costs 3.59 ms, but clear-plus-draw costs
|
|
452
|
-
**35.9 ms with `antialias: true` against 10.8 ms with it off** — roughly
|
|
453
|
-
70% of the frame is multisample resolve on the CPU. On a real GPU MSAA is close to free, so the
|
|
454
|
-
absolute mocanvas frame times above are largely a property of this rasteriser rather than of the scene.
|
|
455
|
-
That probe file was recorded in this same environment at an earlier revision. It is quoted here because
|
|
456
|
-
it measures the rasteriser rather than mocanvas — no mocanvas code runs in it — so it does not need
|
|
457
|
-
re-measuring with the rest of the report.
|
|
616
|
+
- **Hardware GPU, headless Chromium.** The frame times above were rasterised by this machine's own GPU (`ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro, Unspecified Version)`), not by SwiftShader. Headless Chromium with the platform's ANGLE backend named explicitly (`--use-angle=metal`; `--ignore-gpu-blocklist` alone is not enough and still lands on SwiftShader) gets the real device. It is still an automated browser rather than a user's, and the GPU process is shared with nothing else, which a real desktop's is not.
|
|
617
|
+
- **Frame times and pixel metrics come from two different browsers, on purpose.** The rasteriser is not neutral for the pixel comparison: hardware MSAA resolves a thin stroke's edge pixels differently from SwiftShader, and the stroke-band metric pairs stroke pixels *by colour*, so a hardware render scores a band difference that is antialiasing rather than geometry. The frame-time tables therefore come from the hardware browser and the [rendering comparison](#rendering-comparison) from a software one; both are named in the Environment table. Do not read a row from one as if it had been measured in the other.
|
|
458
618
|
- **Default settings on both sides.** No tuning, no custom shape utils, no culling or LOD flags flipped, no
|
|
459
619
|
tldraw performance options enabled. Both libraries are used the way the docs show. Either could likely be
|
|
460
620
|
made faster by someone who knows its knobs; that is a different benchmark.
|
package/README.md
CHANGED
|
@@ -78,6 +78,4 @@ To arrange one, or if you are unsure which side of the line you are on, write to
|
|
|
78
78
|
**mocanvas@symbio.agency** — we would rather answer the question than have you
|
|
79
79
|
guess.
|
|
80
80
|
|
|
81
|
-
The full terms are in `LICENSE`, shipped in this package.
|
|
82
|
-
earlier under MIT stay available under MIT, on the terms they were released
|
|
83
|
-
with.
|
|
81
|
+
The full terms are in `LICENSE`, shipped in this package.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mocanvas/editor",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"description": "The mocanvas editor core: Editor, ShapeUtil, StateNode, geometry, bindings and the <Canvas /> renderer.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Symbio Digital",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@mocanvas/state": "4.0.
|
|
49
|
-
"@mocanvas/
|
|
50
|
-
"@mocanvas/
|
|
48
|
+
"@mocanvas/state": "4.0.2",
|
|
49
|
+
"@mocanvas/wasm": "4.0.2",
|
|
50
|
+
"@mocanvas/store": "4.0.2"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/react": "^19.1.0",
|