Skip to content

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

Open
qianiaoo wants to merge 6 commits 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 6 commits into
pocket-stack:mainfrom
qianiaoo:feat/poly-op-coverage

Conversation

@qianiaoo

@qianiaoo qianiaoo commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Updated 2026-08-25 after @doodlewind's review. Nothing here is deleted:
what turned out to be wrong is struck through with the correction beside it,
and superseded measurements are folded into <details> rather than removed.
Two of the corrections are claims I got wrong, not things the review asked for.

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.

Correction. It is not in that list. draw.rs:10-18 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 among them — only the mechanism
is stated, and the consequence follows only if you already know TRI has no
coverage field. Measured on main, one white box at rotate-20 on black:
2 grey levels and 0 partially-covered pixels at 480×272, 1280×720,
1920×1080 and 3840×2160 alike.

This adds POLY (opcode 10 11, 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 Where it is faster, and where it is not

Correction. "It is not slower" was unconditional and it is false. The
original numbers came from a standalone benchmark with the inner loops lifted
out of raster.rs; these come from the crate itself, interleaved in one
session against the current build.

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) — 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.

scene (1920×1080, Apple M4) main this PR
24 × 400×400 rotated 7.236 ms 2.092 ms (0.29×)
8 × 120×800 at 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×)

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) costs
the 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 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.
  • PSP GE — draws raster::poly_spans, the same solve raster.rs fills for
    itself, 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_bound sizes the vertex buffer without sampling, so the decoder
    solves once rather than twice.
  • wgpu, Vita GXM, Symbian GLES2not migrated yet. Still a triangle fan
    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.
  • 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.

Correction — this is what the review changed. PSP no longer pays for
nothing: it draws the core's coverage spans, so a rotated box on hardware goes
from 2 grey levels to 17, the same picture the software rasterizer draws.
Measured on 240 frames of apps/motions, the cost is vertices rather than
draw calls — mean 607 → 869, peak frame 992 → 2214, one flush per polygon
either way
. avg_render_us on real hardware is the one number this branch
cannot produce.

Vita and Symbian still pay for nothing, along with wgpu, until their
decoders are migrated too.

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 new134 pass, ten
    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, plus five added for the
    review: 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_coverage and
    rotated_3d_solid_face_raster_has_coverage_levels.
  • 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.
  • The s < 0 span solve was wrong and is fixed: div_euclid ceils on a
    negative divisor, the code treated it as floor, and the interior run went one
    column long — handing a boundary pixel to fill_opaque. Brute-forced against
    ground 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_oracle
    is 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.
  • Goldens: 52 of 54 frames byte-identical to main; motions-main.60 and
    .236 change by 558 pixels total, and both are the anti-aliasing
    appearing — on .236 the changed region goes from 22 distinct grey levels to
    51 while total luminance moves +0.005%.
  • PSP, verified as far as it can be without hardware: engine/core
    cross-compiles to mipsel-sony-psp (nightly + build-std), and the ge.rs
    POLY 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 for
    byte
    across 27 rotations and box sizes. Not verified: hosts/psp as a
    whole (libquickjs-sys needs PSPSDK C headers unavailable here), the GE's own
    blend rounding, and real PSP timing.
  • Vita and Symbian still need cross targets not installed locally; those two
    decoders, and wgpu, still emit a fan. ESP-IDF release/v6.0 and
    Rust renderer pass.

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.

@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

@doodlewind doodlewind left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed PR. I found three issues that should be resolved before merging:

  1. In raster.rs, the s < 0 span calculation uses k.div_euclid(s) + 1, which can extend the interior range by one pixel. Partial-coverage edge pixels are then sent through fill_opaque. Please fix this and add a test comparing the optimized span against a slow 4×4 sampling oracle across rotations and clipping.
  2. 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.
  3. bun tests/golden.ts currently reports 52 passed and 2 failed (motions-main.60 and .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.

@doodlewind

Copy link
Copy Markdown
Collaborator

One additional PSP concern: the old TRI path batches consecutive rotated-box triangles into one sceGuDrawArray call, while the new POLY path submits one triangle fan per polygon. A single quad uses fewer vertices, but several adjacent rotated boxes—such as the motions/56 fan cards—may turn one batched draw into several calls, with no visual benefit on PSP. Could you include a real-PSP comparison of avg_render_us and draw-call count for that case?

`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 894603b to 18e8c58 Compare August 25, 2026 03:22
qianiaoo and others added 5 commits August 25, 2026 11:23
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>
@qianiaoo
qianiaoo force-pushed the feat/poly-op-coverage branch from 18e8c58 to 20b663c Compare August 25, 2026 09:08
@qianiaoo

Copy link
Copy Markdown
Contributor Author

雪碧老师,这个 PR 是为了解决 pocketjs 渲染视频的时候,有倾斜角度的矩形锯齿实在太明显——目的就是抗锯齿。用的场景就是 pocketjs motion,(不好意思想加功能越来越多一直还没开源...)

您提到的四条:1 和 3 修了;4 量了,但我这边给不出真机数据;第 2 条我觉得您是对的,所以没有拿来请您裁决,而是把设计改了——覆盖度现在在 core 里算一次,PSP 也吃同一份数据。

还有一部分我这边确实没办法验证(手边没有 PSP,hosts/psp 在我机器上也编译不了),下面写清楚了哪些验了、哪些没验。

另外关于本项目强调的跨平台像素一致,我实测下来发现现在其实是有漏洞的,也一并放在下面,供您判断。

最后有两处是我自己 PR 描述里写错的,也一起改了。

下面是具体的测量。


2 — the portable-backend contract

You 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, arc. POLY was the first op to decide it inside one rasterizer.

So it no longer does. raster::poly_spans computes the coverage once and returns scanline runs; raster.rs fills them itself, and the PSP GE now draws the same runs as alpha sprites — the path it already runs for rounded corners. Both consume one function's numbers instead of each deciding what a polygon edge looks like.

poly_span_bound sizes the vertex buffer without sampling, so the decoder solves once rather than twice (measured under 1.5× the real span count over 50 shapes).

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 s < 0 span solve

Confirmed, fixed. div_euclid ceils on a negative divisor and the code treated it as floor, so the interior range ran one column long and a boundary pixel went to fill_opaque. Brute-forced against ground truth: 19,794 of 400,000 random inputs over-claimed, always by exactly one column; the fixed form, 0.

The oracle you asked for is poly_row_spans_match_naive_4x4_sample_oracle — 52 rotations × 10 clip scenes, including corner cases that push Sutherland-Hodgman to 5–8 vertices, byte-comparing the framebuffer against a per-pixel 4×4 reference with no span solve at all.

Worth stating why it was needed: restoring the bug fails it at the first angle tested, while all 131 pre-existing tests still pass:

POLY span fill != 4×4 oracle at rotate=7 clip=center nverts=4 — 59 pixels differ;
first (204,105) actual=[255,255,255,255] oracle=[191,191,191,255]

rotated_flat_box_has_no_interior_partial_pixels structurally cannot catch it — it counts partial pixels in the interior, and this bug turns a partial pixel solid.

3 — goldens and the missing coverage

Checked pixel by pixel against current main: 52 of 54 frames are byte-identical; 2 change, 558 pixels total, and both are the anti-aliasing appearing, not a regression.

On motions-main.236 the changed region goes from 22 distinct grey levels to 51 while total luminance moves by +0.005% — ink redistributed, not added. The edges read as:

base | 241 241 241 241 241 241 187     <- hard step
new  | 241 241 241 241 241 221 187     <- one intermediate

Added the two you named: rotated_rounded_flat_box_emits_poly_not_tri_and_has_coverage (page 3 — Modal30 is rounded-[999px] + rotate-28, and draw.rs drops the radius on a non-axis-aligned box) and rotated_3d_solid_face_raster_has_coverage_levels (page 4). Both assert the rasterized coverage, not just the op. Forcing coverage binary fails all four coverage tests.

One thing you may want to know independently of this PR: bun tests/golden.ts reports 54/54 failed on my machine, and zero pixels differ — the PNGs decode identically, only Bun.deflateSync compresses differently across Bun versions. The goldens compare encoded bytes, so the suite is pinned to a Bun build rather than to a picture.

4 — PSP draw calls

Your mechanism is real. Counting sceGuDrawArray calls by following the decode loop in ge.rs, over 240 frames of apps/motions:

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/core cross-compiles to mipsel-sony-psp (nightly + build-std) — the i64 edge math holds on a 32-bit no_std target
  • ✅ The ge.rs POLY arm, its own text extracted verbatim, run on the host against stub GE types: the sprites it submits reproduce engine/core's fill byte for byte, across 27 rotations and box sizes
  • bun run test 11/11, engine/core 134 tests, goldens as above
  • hosts/psp as 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.

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.

2 participants