feat(draw): a POLY op, so rotated solid geometry carries coverage - #301
feat(draw): a POLY op, so rotated solid geometry carries coverage#301qianiaoo wants to merge 6 commits into
Conversation
22e3d98 to
894603b
Compare
doodlewind
left a comment
There was a problem hiding this comment.
Thanks for the detailed PR. I found three issues that should be resolved before merging:
- In
raster.rs, thes < 0span calculation usesk.div_euclid(s) + 1, which can extend the interior range by one pixel. Partial-coverage edge pixels are then sent throughfill_opaque. Please fix this and add a test comparing the optimized span against a slow 4×4 sampling oracle across rotations and clipping. - This changes the portable-backend contract: software/Apple/PocketBook/PPA get 4×4 coverage, while wgpu/PSP/Vita/Symbian keep binary triangle-fan output. That conflicts with the documented same-density pixel-determinism guarantee and with the spec describing 4×4 coverage for
POLY. We need an explicit contract decision here. bun tests/golden.tscurrently reports 52 passed and 2 failed (motions-main.60and.236). These should be visually reviewed and updated after the raster fix; coverage for the page 3 rotated elements and page 4 solid 3D faces is also missing.
Could you share the actual application scenario that motivated this change? What visible problem did you encounter in real use, in which app and on which backend? Understanding the concrete case would help us judge whether a permanent DrawList op and the maintenance cost across every backend are justified.
|
One additional PSP concern: the old |
`TRI` has no coverage field, so a rotated solid box resolves to two grey levels at any resolution — `emit_box` -> Sutherland-Hodgman -> `emit_tri` rounds every vertex to an integer pixel and there is nowhere to put a partial one. Recorded at `draw.rs:10-18` as a v1 degradation; what was missing was the price. `POLY` (opcode 10, `3 + N` words) carries the whole clipped convex polygon and one flat colour, so coverage is computed over the shape rather than per triangle. Per-triangle coverage is the wrong fix and the guard says so: two sequential blends are not one blend, and the shared diagonal of a rotated box keeps 68 interior partial pixels. Over the polygon it keeps none. It is not slower. `poly()` solves each scanline for the fully-interior x-range, fills it as one run and samples 4x4 only at the ends — O(perimeter), not O(area) — against a `tri` that evaluated three `orient()` calls for every pixel of the bounding box with no incremental stepping. Measured at 0.99x on a standalone bench and 22% faster end to end on eight rotated bars at 1080p. The inner loop stays integer: edge functions in 4*F fixed point, quarter-pixel offsets as +/-1 and +/-3, `div_euclid` for the span solve. No float enters it, so the frame-hash contract carries over. Hardware backends without per-pixel coverage decode `POLY` to a triangle fan — today's binary fill, byte-identical output. `Fill::Grad` keeps its TRI fan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
894603b to
18e8c58
Compare
div_euclid ceils on a negative divisor, so the s<0 span solver was handing one extra boundary column to fill_opaque. Compare the optimized fill to a per-pixel 4×4 oracle across rotations and clip cases, and raster the rotated rounded-box and solid 3D-face paths that previously only checked DrawList ops.
The POLY block claimed "Coverage is 4x4 samples over the whole polygon" as if it were the op's contract. Only engine/core/src/raster.rs and the backend that delegates to it honour that; wgpu, PSP GE, Vita GXM and Symbian GLES2 have no per-pixel coverage and fan the polygon into a binary fill. Rounded corners, shadows and arcs bake coverage into alpha RECT spans in the core, so every backend draws the same pixels from them. POLY is the first op whose picture depends on which backend decodes it, and the block now says so instead of promising the software rasterizer's behaviour for all of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POLY decided a polygon's edge softness inside raster.rs, so the backends that cannot compute per-pixel coverage drew a different picture from the ones that can. Every other anti-aliased feature in this engine avoids that by deciding coverage in the core and shipping it as alpha spans; POLY was the exception. `poly_spans` is that decision as a public value: scanline-ordered, non-overlapping runs of constant coverage. `poly` now fills what it returns, and a backend with no per-pixel coverage can draw the same runs as alpha sprites — the RECT path it already runs for rounded corners and shadows. `poly_spans_drawn_as_alpha_rects_match_the_rasterized_poly` replays the spans as RECT ops through the DrawList and byte-compares the framebuffer, over nine rotations and three box sizes. Feeding the spans full alpha instead of their coverage fails it at 668 pixels. The emitter is generic rather than `&mut dyn FnMut`: measured against the direct fill on five scenes, spans cost 0.99x-1.04x, and the boxed call cost about a fifth of a frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The GE has no per-pixel coverage, so POLY decoded to a triangle fan: hardware drew a rotated box with two grey levels while the software rasterizer drew it with seventeen. One op, two pictures. It does not need coverage in hardware. `poly_spans` is the same solve raster.rs fills for itself, and the GE can draw what it returns as alpha sprites — the path it already runs for rounded corners and shadows. Both backends now consume one function's numbers instead of each deciding what a polygon edge looks like. `poly_span_bound` sizes the vertex buffer without sampling any coverage, so the decoder solves once rather than twice; it over-counts only boundary columns whose coverage turns out to be zero, measured under 1.5x the real count over fifty shapes. Verified without a PSP: engine/core cross-compiles to mipsel-sony-psp under build-std, and a host harness includes this arm's own text verbatim against stub GE types, rasterizes the sprites it submits and byte-compares them to engine/core's fill — 27 rotations and box sizes, identical. What that harness cannot reach is the GE's own blend rounding, and the rest of hosts/psp does not build here: libquickjs-sys needs PSPSDK C headers this machine lacks. Cost, measured on apps/motions over 240 frames: mean 133 spans per frame and 928 at the peak, so the vertices a frame submits go from ~610 to ~875 on average and roughly triple on that peak frame. Draw calls are unchanged -- one flush per polygon either way. No committed PSP golden moves: none of the nine apps they cover uses a 2D rotation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block still described every non-software backend fanning POLY into a binary fill. The PSP GE now draws raster::poly_spans as alpha sprites, so the split is no longer software-versus-hardware; it is which decoders have been moved over. wgpu, Vita GXM and Symbian GLES2 have not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
18e8c58 to
20b663c
Compare
|
雪碧老师,这个 PR 是为了解决 pocketjs 渲染视频的时候,有倾斜角度的矩形锯齿实在太明显——目的就是抗锯齿。用的场景就是 pocketjs motion,(不好意思想加功能越来越多一直还没开源...) 您提到的四条:1 和 3 修了;4 量了,但我这边给不出真机数据;第 2 条我觉得您是对的,所以没有拿来请您裁决,而是把设计改了——覆盖度现在在 core 里算一次,PSP 也吃同一份数据。 还有一部分我这边确实没办法验证(手边没有 PSP, 另外关于本项目强调的跨平台像素一致,我实测下来发现现在其实是有漏洞的,也一并放在下面,供您判断。 最后有两处是我自己 PR 描述里写错的,也一起改了。 下面是具体的测量。 2 — the portable-backend contractYou were right, and I don't think the split should be accepted rather than fixed. Every other anti-aliased feature in this engine decides coverage in the core and ships it as alpha spans — rounded corners, shadows, So it no longer does.
wgpu, Vita GXM and Symbian GLES2 are not migrated yet and still fan to a binary fill; the spec block says so explicitly. I stopped at one backend deliberately — I have none of that hardware, and one decoder verified as far as I could take it seemed better than four unverified ones. If you want the op, I'll do the other three. Refactor cost on the software path, measured interleaved in one session: 0.99×–1.04×. It is free. 1 — the
|
| main | this PR | |
|---|---|---|
| rotated-geometry draw calls | 193 | 280 |
| frames that increase | — | 33 / 240 |
| worst single frame | 1 | 4 (+3, on a 39-call frame) |
| peak frame total | 80 | 80 |
gallery-main and cards-main are unchanged — no rotated solids.
The span decoder does not add to that: still one flush per polygon. It moves the cost to vertices instead — mean 607 → 869, peak frame 992 → 2214 (mean 133 spans/frame, peak 928).
I can't give you avg_render_us. No PSP here, and hosts/psp doesn't build on this machine — libquickjs-sys needs PSPSDK C headers I don't have. That number has to come from someone with hardware.
But the trade is no longer the one you described. PSP used to pay a decoder case for zero visual benefit; it now gets the same rotated edge as everything else. A rotated box on hardware today resolves to 2 grey levels; through the spans it is 17.
What the change is actually for
pocketjs-motion has a composition with 288 white bars, 2.8px wide, on black, rotated between 0.1° and 7°, animating. Almost every pixel of those is edge.
Measured on main, one white box at rotate-20 on black:
480x272 -> 2 grey levels, 0 partially-covered pixels
1280x720 -> 2
1920x1080 -> 2
3840x2160 -> 2
Resolution doesn't help: the steps get smaller, not fewer. Rendering that composition at 4K with supersampling still bottoms out, because the core snaps geometry to whole device pixels and the only softness available is what the downscale averages.
The workaround before this op was painting fake edges — flanking each bar with a gradient ramp from the background colour, since a gradient's interior is interpolated per pixel. It worked, and it was wrong in two ways the composition could not fix from inside. It only held against a known background (over anything else it draws a dark halo — it was painting, not anti-aliasing). And it made the line fatter than the line: 12.00px of ink per hairline, identical on every row, for a bar designed at 8.80px. A number that is the same on every row is being painted, not computed. With coverage it measures 9.0–9.2px, varying by row.
What I could and could not verify
- ✅
engine/corecross-compiles tomipsel-sony-psp(nightly +build-std) — the i64 edge math holds on a 32-bitno_stdtarget - ✅ The
ge.rsPOLY arm, its own text extracted verbatim, run on the host against stub GE types: the sprites it submits reproduceengine/core's fill byte for byte, across 27 rotations and box sizes - ✅
bun run test11/11,engine/core134 tests, goldens as above - ❌
hosts/pspas a whole — PSPSDK C headers missing - ❌ The GE's own blend rounding
- ❌ Real PSP timing
One more thing, offered as data rather than as an argument
While checking whether "same content, same pixels" holds today, I compared the committed PSP goldens against the web goldens for the same UI states. They are not close:
| pixels differing | max Δ | |
|---|---|---|
notifications.boot vs notifications-main.2 |
85.1% | 9 |
library.grid vs library-main.2 |
77.8% | 9 |
settings.boot vs settings-main.2 |
40.4% | 8 |
And the direction is systematic — on notifications, 109,683 pixels where PSP is darker against 1,247 where it is lighter. That is the signature of the GE truncating where raster.rs rounds ((s*a + d*ia + 127)/255).
I can't confirm the cause without hardware, and part of it may be the capture pipeline rather than rendering. The frames also don't come from identical input scripts — but differing content produces large localized deltas, not a uniform one-directional Δ1 carpet. tests/goldens/{psp,vita,web} share no filenames, so nothing currently asserts they agree.
I'm not raising this to deflect. It's the scale I think POLY should be judged on: today POLY diverges visibly — 2 levels against 17, two different pictures. Through the spans it diverges by whatever ±1 already exists for every other alpha feature in the engine. Whether that residue is worth closing is your call, not this PR's.
Two corrections to my own description
1. "This is recorded at draw.rs:10-18 as a v1 degradation" — it isn't. That list records rotated IMAGE quads being culled, glyph cells staying upright, rotated rounded boxes degrading to square fills, and opacity multiplying wrongly over overlap. "Rotated solid boxes carry no coverage" is not in it; only the mechanism is stated, and the consequence follows only if you already know TRI has no coverage field. My claim overstated it, and the description is fixed.
2. "It is not slower" — that was unconditional, and it's false. Same session, interleaved, against the current (span) build:
| scene | main | this PR |
|---|---|---|
| 24 × 400×400 rotated | 7.236 ms | 2.092 ms (0.29×) |
| 8 × 120×800 @ 4–11° | 1.540 ms | 0.792 ms (0.51×) |
| 64 × 80×60 | 0.898 ms | 1.057 ms (1.18×) |
| 240 × 24×18 | 0.465 ms | 1.088 ms (2.34×) |
| 480 × 12×10 | 0.381 ms | 1.139 ms (2.99×) |
Large rotated shapes get much faster; many small rotated shapes get up to 3× slower — a small polygon is nearly all boundary, so the span solve saves nothing and the 4×4 sampling is pure cost. End to end on the real demos (motions/gallery/hero/cards) it is 0.995×–1.055× against a ±7.5% process-to-process noise floor, i.e. neutral. The old table came from a standalone benchmark with the inner loops lifted out of raster.rs; these come from the crate itself.
Happy to close this if the answer is that the fixed-function hosts are the centre of gravity. I'd just rather it be decided on the numbers above than on the ones I originally wrote.
Rotated solid geometry has binary edges today, and the reason is in the op set
rather than in any rasterizer:
TRIhas no coverage field.draw.rscomputes fractional coverage for axis-aligned content only —scale_alpha_coverage/pixel_interval_coverageon RECT spans, roundedcorners and shadows. A rotated box takes the other path,
emit_box→Sutherland-Hodgman →
emit_tri, which rounds every vertex to an integerpixel and emits 7 words: opcode, three positions, three vertex colours. There
is nowhere to put a partial pixel. A white box at 20° on black therefore
resolves to exactly 2 grey levels, at any resolution — 4K makes the steps
smaller without making them fewer.
This is recorded atdraw.rs:10-18as a v1degradation, so it is a known limitation, not a regression; what was missing was
the price of removing it.
This adds
POLY(opcode1011,3 + Nwords: op, vertex count N in 3..=8,one packed flat colour, N ×
xy_word).emit_boxemits it forFill::Flatand the
Item3::Quad3D-face path emits it after projection; both afterSutherland-Hodgman, so the op is already clipped and convex.
Per-triangle coverage is the wrong fix, and the test says so
The obvious cheaper change — keep
TRI, give it an alpha — does not work. Arotated rectangle is two triangles sharing a diagonal, and two sequential alpha
blends are not one blend: 0.5 over 0.5 leaves 0.75. Same box (240×160 at 20°),
counting pixels that are neither 0 nor 255 while all eight neighbours are
non-background:
Those 68 are a seam down the shared diagonal of every rotated box, and the 21
levels are that artefact rather than an improvement — 16 samples can only
produce 17, and the extra four are double-blended pixels. Coverage has to be
computed over the whole clipped polygon, which is why this needs an op and not
a field.
rotated_flat_box_has_no_interior_partial_pixelsis that regression as aguard: fan the polygon back into
emit_polycalls per triangle and it fails atexactly 68.
It is not slowerWhere it is faster, and where it is notpoly()solves each scanline for the fully-interior x-range, fills that as onerun, and samples 4×4 only at the two ends. Boundary work is O(perimeter), not
O(area) — which is why large rotated shapes get much faster. It is also why
small ones do not: a small polygon is nearly all boundary, the span solve saves
nothing, and the 4×4 sampling is pure cost.
End to end on the demos in this repository (
motions,gallery,hero,cards), whole frames through the wasm host: 0.995×–1.055× against a±7.5% process-to-process noise floor — neutral, because rotated content is
a small fraction of those frames either way.
Moving coverage into
raster::poly_spans(see What each backend does) coststhe software path 0.99×–1.04×, measured the same way.
For scale, 4× MSAA — the games-industry default — resolves each edge to 5
levels. 17 is what 4×4 sampling can produce.
Superseded — the original benchmark and its tables
poly()solves each scanline for the fully-interior x-range, fills that as onerun, and samples 4×4 only at the two ends. Boundary work is O(perimeter), not
O(area). The op it replaces evaluates three
orient()calls — six i64multiplies — for every pixel in the bounding box, with no incremental
stepping and no run fill, so the span solve buys back more than the coverage
test costs.
Standalone benchmark, inner loops lifted from
raster.rsso they match, fiverepetitions, 1920×1080, eight 120×800 bars at 4°–11°, Apple M4 native
(
opt-level=3 lto=true codegen-units=1):End to end through the wasm host, whole frames including tick and draw:
Only ratios taken inside one process are quoted: absolute times on this machine
drift up to 1.8× between processes.
For scale, 4× MSAA — the games-industry default — resolves each edge to 5
levels. 17 is what 4×4 sampling can produce, and it is the level that comes out
free.
Determinism
The inner loop is integer throughout: edge functions in 4·F fixed point so
the quarter-pixel sample offsets stay integral as ±1/±3,
div_euclidfor thespan solve, winding taken from the doubled-coordinate shoelace area. No float
enters it, so the frame-hash contract carries over unchanged — which is the
constraint that made 4×4 the design rather than an analytic area.
What each backend does
raster.rs) — the coverage implementation.esp32p4-ppadelegates through
software_op, so one change covers both.wgpu, Symbian GLES2, PSP GE, Vita GXM — no per-pixel coverage inhardware, so
POLYdecodes to a triangle fan: today's binary fill of the sameconvex polygon, byte-identical output, no regression and no benefit.
raster::poly_spans, the same solveraster.rsfills foritself, as alpha sprites: the path it already runs for rounded corners and
shadows. Coverage is decided once, in the core, so hardware and software land
on the same picture instead of each deciding what a polygon edge looks like.
poly_span_boundsizes the vertex buffer without sampling, so the decodersolves once rather than twice.
and a binary fill. Stopping at one backend was deliberate: none of that
hardware is available here, and one decoder verified as far as it could be
taken seemed better than four unverified ones.
POLYs stay vector paths alongside flatTRIs; a batchcontaining any gouraud or textured member still goes through the core
rasterizer whole, so painter order inside a depth-sorted 3D subtree is
preserved.
POLYis flat-coloured by construction.damage.rs— stride and bounds, with the same 3..=8 validation.Decoders return or break on a malformed
POLYrather than guessing, matchinghow the closed op set is handled elsewhere.
Scope
Fill::Gradkeeps its TRI fan. Gradient corners interpolate per vertex andthe fan is where that happens; rotated gradient boxes still have binary edges.
rotated_flat_box_emits_one_poly_gradient_stays_tripins the split.yields at most 8 vertices, so the bound is the geometry's, not a budget.
TEX_TRIis untouched — textured meshes still subdivide.Who this helps
Worth stating, because the answer is not "everyone".
The software rasterizer, and gpui. Those are the two backends that can
honour per-pixel coverage;
esp32p4-ppainherits it by delegating to the samerasterizer.
Not PSP, Vita or Symbian. Their hardware has no per-pixel coverage, so
POLYdecodes to a triangle fan and their output is byte-identical to today.They pay a decoder case and get nothing back. That is the trade this PR asks
for, and it should be weighed rather than discovered.
In this repository the demand is one app.
rotate-Nappears seven times inthe whole tree, all of them in
apps/motions, and every one on a roundedbox —
rounded-[999px]pills atrotate-28/rotate-332insiderotate-140and
rotate-320containers, and arounded-[5px]card atrotate-8.Rounded is not a separate path:
draw.rs:1999sends any non-axis-alignedrounded box to
emit_box, dropping the radius. All seven therefore reachPOLY, and all seven have binary edges today.apps/motionsis also the app#296 identified as the first whose DrawList exercises the rotated/3D
raster-fallback path.
Seven usages in one demo app is thin evidence of demand, and it is why this is
a draft rather than a claim that the op is overdue. If the project's centre of
gravity is still the fixed-function hosts, this is 800 lines of permanent
decoder tax for an op most of them cannot honour, and closing it is a
reasonable call.
Verified
bun run test: 11/11 stages green, including the compiler smoke buildsand the launcher sim.
— 134 pass, tenengine/core: 126 tests pass, five of them newof them new:
rotated_flat_box_emits_one_poly_gradient_stays_tri,rotated_flat_box_raster_has_coverage_levels,rotated_flat_box_has_no_interior_partial_pixels,clipped_polygon_closes_against_the_screen_edge,rotated_3d_face_emits_poly_textured_still_tex_tri, plus five added for thereview:
poly_row_spans_match_naive_4x4_sample_oracle,poly_spans_drawn_as_alpha_rects_match_the_rasterized_poly,poly_span_bound_covers_every_span_without_sampling,rotated_rounded_flat_box_emits_poly_not_tri_and_has_coverageandrotated_3d_solid_face_raster_has_coverage_levels.polygon into per-triangle
emit_polycalls fails it at exactly 68, thesame 68 the standalone benchmark counts.
engine/core/src/spec.rsregenerates identically underbun run gen.cargo checkclean onpocket-ui-wgpu,esp32p4-ppaandengine/wasm.engine/backends/gpuidoes notbuild on the machine this was written on — nor does it on clean
main, thegpui 0.2.2build script fails Metal shader compilation there — so the gpuidecoder was pushed unverified by compilation.
macOS gpui backendnow passes,which is that verification. It first went red on
clippy::manual_range_contains: six decoders wroten < 3 || n > 8wheredamage.rswrote!(3..=8).contains(&n), and upstream lints with-D warnings. All six now read the idiomatic form. Clippy is not installedunder rustup on that machine and only Homebrew's is, built against a different
rustc, so the lint is verified here rather than locally.
PSP, Vita and Symbian need cross targets not installed locally. Those threedecoders emit a fan;
ESP-IDF release/v6.0andRust rendererpass.s < 0span solve was wrong and is fixed:div_euclidceils on anegative divisor, the code treated it as floor, and the interior run went one
column long — handing a boundary pixel to
fill_opaque. Brute-forced againstground truth, 19,794 of 400,000 random inputs over-claimed, always by
exactly one column; the fixed form, 0.
poly_row_spans_match_naive_4x4_sample_oracleis the guard: 52 rotations × 10 clip scenes, byte-compared against a per-pixel
4×4 reference with no span solve at all. Restoring the bug fails it at the
first angle tested, while all 131 pre-existing tests still pass.
main;motions-main.60and.236change by 558 pixels total, and both are the anti-aliasingappearing — on
.236the changed region goes from 22 distinct grey levels to51 while total luminance moves +0.005%.
engine/corecross-compiles to
mipsel-sony-psp(nightly +build-std), and thege.rsPOLY arm — its own text extracted verbatim — runs on the host against stub GE
types, where the sprites it submits reproduce
engine/core's fill byte forbyte across 27 rotations and box sizes. Not verified:
hosts/pspas awhole (
libquickjs-sysneeds PSPSDK C headers unavailable here), the GE's ownblend rounding, and real PSP timing.
decoders, and wgpu, still emit a fan.
ESP-IDF release/v6.0andRust rendererpass.Found while driving the DrawList from pocketjs-motion, a headless
deterministic video renderer, where rotated bars over a dark field make the
missing coverage impossible to miss — one composition there is 288 white bars,
2.8px wide, rotated between 0.1° and 7°, animating.