Skip to content

feat(draw): a POLY op, so rotated solid geometry carries coverage - #301

Open
qianiaoo wants to merge 1 commit into
pocket-stack:mainfrom
qianiaoo:feat/poly-op-coverage
Open

feat(draw): a POLY op, so rotated solid geometry carries coverage#301
qianiaoo wants to merge 1 commit into
pocket-stack:mainfrom
qianiaoo:feat/poly-op-coverage

Conversation

@qianiaoo

@qianiaoo qianiaoo commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Rotated solid geometry has binary edges today, and the reason is in the op set
rather than in any rasterizer: TRI has no coverage field.

draw.rs computes fractional coverage for axis-aligned content only —
scale_alpha_coverage / pixel_interval_coverage on RECT spans, rounded
corners and shadows. A rotated box takes the other path, emit_box
Sutherland-Hodgman → emit_tri, which rounds every vertex to an integer
pixel
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 at draw.rs:10-18 as a v1
degradation, so it is a known limitation, not a regression; what was missing was
the price of removing it.

This adds POLY (opcode 10, 3 + N words: op, vertex count N in 3..=8,
one packed flat colour, N × xy_word). emit_box emits it for Fill::Flat
and the Item3::Quad 3D-face path emits it after projection; both after
Sutherland-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. A
rotated 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:

grey levels interior partial px
today — binary TRI 2 0
coverage per TRI 21 68
coverage per POLY 17 0

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_pixels is that regression as a
guard: fan the polygon back into emit_poly calls per triangle and it fails at
exactly 68.

It is not slower

poly() solves each scanline for the fully-interior x-range, fills that as one
run, 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 i64
multiplies — 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.rs so they match, five
repetitions, 1920×1080, eight 120×800 bars at 4°–11°, Apple M4 native
(opt-level=3 lto=true codegen-units=1):

grey levels ms/frame (median) vs today
binary TRI 2 0.865 1.00×
POLY, 4×4 17 0.858 0.99×
POLY, 8×8 65 3.156 3.65×
POLY, 16×16 256 11.882 13.7×

End to end through the wasm host, whole frames including tick and draw:

scene before after
1080p, eight rotated bars, no supersampling 10.22 ms/frame 7.99 ms/frame
3840×2160 composition, 2× supersampled 96.96 ms/frame 88.43 ms/frame

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_euclid for the
span 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

  • software raster (raster.rs) — the coverage implementation. esp32p4-ppa
    delegates through software_op, so one change covers both.
  • wgpu, Symbian GLES2, PSP GE, Vita GXM — no per-pixel coverage in hardware,
    so POLY decodes to a triangle fan: today's binary fill of the same convex
    polygon, byte-identical output, no regression and no benefit.
  • gpui — flat solid POLYs stay vector paths alongside flat TRIs; a batch
    containing any gouraud or textured member still goes through the core
    rasterizer whole, so painter order inside a depth-sorted 3D subtree is
    preserved. POLY is flat-coloured by construction.
  • damage.rs — stride and bounds, with the same 3..=8 validation.

Decoders return or break on a malformed POLY rather than guessing, matching
how the closed op set is handled elsewhere.

Scope

  • Fill::Grad keeps its TRI fan. Gradient corners interpolate per vertex and
    the fan is where that happens; rotated gradient boxes still have binary edges.
    rotated_flat_box_emits_one_poly_gradient_stays_tri pins the split.
  • N > 8 falls back to a fan. Sutherland-Hodgman of a quad against a rect
    yields at most 8 vertices, so the bound is the geometry's, not a budget.
  • TEX_TRI is 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-ppa inherits it by delegating to the same
rasterizer.

Not PSP, Vita or Symbian. Their hardware has no per-pixel coverage, so
POLY decodes 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-N appears seven times in
the whole tree, all of them in apps/motions, and every one on a rounded
box — rounded-[999px] pills at rotate-28/rotate-332 inside rotate-140
and rotate-320 containers, and a rounded-[5px] card at rotate-8.

Rounded is not a separate path: draw.rs:1999 sends any non-axis-aligned
rounded box to emit_box, dropping the radius. All seven therefore reach
POLY, and all seven have binary edges today. apps/motions is 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 builds
    and the launcher sim.
  • engine/core: 126 tests pass, five of 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.
  • The seam guard was checked by reintroducing its regression: fanning the
    polygon into per-triangle emit_poly calls fails it at exactly 68, the
    same 68 the standalone benchmark counts.
  • engine/core/src/spec.rs regenerates identically under bun run gen.
  • cargo check clean on pocket-ui-wgpu, esp32p4-ppa and engine/wasm.
  • CI covers the gap this PR opened with: engine/backends/gpui does not
    build on the machine this was written on — nor does it on clean main, the
    gpui 0.2.2 build script fails Metal shader compilation there — so the gpui
    decoder was pushed unverified by compilation. macOS gpui backend now passes,
    which is that verification. It first went red on
    clippy::manual_range_contains: six decoders wrote n < 3 || n > 8 where
    damage.rs wrote !(3..=8).contains(&n), and upstream lints with
    -D warnings. All six now read the idiomatic form. Clippy is not installed
    under 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 three
    decoders emit a fan; ESP-IDF release/v6.0 and Rust renderer pass.

Found while driving the DrawList from a headless deterministic video renderer,
where rotated bars over a dark field make the missing coverage impossible to
miss.

`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>
@qianiaoo
qianiaoo force-pushed the feat/poly-op-coverage branch from 22e3d98 to 894603b Compare August 19, 2026 07:14
@qianiaoo
qianiaoo marked this pull request as ready for review August 19, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant