From 759ffff1fb3868192e936f13a830fb41604eb4b6 Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Wed, 19 Aug 2026 09:50:58 +0800 Subject: [PATCH 1/6] feat(draw): a POLY op, so rotated solid geometry carries coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- contracts/spec/gen-rust.ts | 2 +- contracts/spec/spec.ts | 18 +- engine/backends/esp32p4-ppa/src/lib.rs | 41 ++++ engine/backends/gpui/src/render.rs | 124 +++++++--- engine/core/src/damage.rs | 26 +++ engine/core/src/draw.rs | 72 +++++- engine/core/src/raster.rs | 193 ++++++++++++++- engine/core/src/spec.rs | 3 +- engine/core/src/tests.rs | 258 ++++++++++++++++++++- engine/crates/pocket-ui-wgpu/src/render.rs | 44 +++- engine/symbian/src/gl/mod.rs | 33 +++ hosts/psp/src/ge.rs | 18 ++ hosts/vita/src/graphics.rs | 33 +++ 13 files changed, 801 insertions(+), 64 deletions(-) diff --git a/contracts/spec/gen-rust.ts b/contracts/spec/gen-rust.ts index 33241235..a2c98aa4 100644 --- a/contracts/spec/gen-rust.ts +++ b/contracts/spec/gen-rust.ts @@ -431,7 +431,7 @@ export function generateRust(): string { put("/// DrawList op codes (core -> backend Vec words; layout in spec.ts)."); put("/// Word counts incl. header: RECT 4, GRAD_RECT 6, GLYPH_RUN 3+2n,"); put("/// TEX_QUAD 9, SCISSOR 3, SCISSOR_POP 1, TRI 7, TEX_TRI 12,"); - put("/// TEXT_RUN 8+ceil(bytes/4), SURFACE_QUAD 9."); + put("/// TEXT_RUN 8+ceil(bytes/4), SURFACE_QUAD 9, POLY 3+N."); put("pub mod draw_op {"); for (const [name, v] of Object.entries(DRAW_OP)) { put(` pub const ${screaming(name)}: u32 = ${v};`); diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index d58850f6..5d4c2817 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1386,9 +1386,11 @@ export const FONT_FLAG_BOLD = 1 << 0; // TRI (7 words): op, xy0, xy1, xy2, color0, color1, color2 — one // CPU-clipped screen-space triangle (gouraud when the // corner colors differ, flat otherwise). The core -// emits these only for ROTATED solid/gradient boxes -// after Sutherland-Hodgman clipping; axis-aligned -// content always uses RECT/GRAD_RECT. +// emits these for ROTATED gradient boxes after +// Sutherland-Hodgman clipping, and as a fan when a +// clipped polygon somehow exceeds 8 vertices. +// Axis-aligned content always uses RECT/GRAD_RECT; +// ROTATED solid boxes use POLY. // TEX_TRI (12 words): op, texHandle, then 3 x { xy, u, v } (u/v = f32 // bits, normalized 0..1), color (modulate; // 0xFFFFFFFF = none). One CPU-clipped textured @@ -1402,6 +1404,15 @@ export const FONT_FLAG_BOLD = 1 << 0; // perspective variation (projectively correct UVs // at every cell corner), so interior texture lines // do not kink at triangle diagonals. +// POLY (3 + N): op, N (3..=8), color, then N x xy — one +// CPU-clipped screen-space convex polygon, one flat +// colour, vertices CCW after raster setup. The core +// emits these for ROTATED solid boxes and for +// projected 3D faces after Sutherland-Hodgman +// clipping. Coverage is 4×4 samples over the whole +// polygon (interior run + boundary pixels) so a +// box's shared diagonal is not an interior edge. +// N > 8 falls back to a TRI fan. // TEXT_RUN (8 + ceil(n/4) words): // op, // word1: bits 0-7 fontSlot, @@ -1448,6 +1459,7 @@ export const DRAW_OP = { texTri: 8, textRun: 9, surfaceQuad: 10, + poly: 11, } as const; // --------------------------------------------------------------------------- diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index 763e6186..afc1732e 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -601,6 +601,23 @@ impl Renderer { } i += 7; } + spec::draw_op::POLY if i + 3 <= words.len() => { + let n = words[i + 1] as usize; + if !(3..=8).contains(&n) || i + 3 + n > words.len() { + return None; + } + if !polygon_bounds(&words[i + 3..i + 3 + n], clip).is_empty() { + self.software_op( + ui, + destination, + surface, + clip, + &words[i..i + 3 + n], + stats, + ); + } + i += 3 + n; + } spec::draw_op::TEX_TRI if i + 12 <= words.len() => { if !triangle_bounds([words[i + 2], words[i + 5], words[i + 8]], clip).is_empty() { @@ -975,6 +992,30 @@ fn triangle_bounds(vertices: [u32; 3], clip: Clip) -> Clip { .intersect(clip) } +fn polygon_bounds(vertices: &[u32], clip: Clip) -> Clip { + if vertices.is_empty() { + return Clip::empty(); + } + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for &word in vertices { + let (x, y) = xy(word); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + Clip { + x0: min_x, + y0: min_y, + x1: max_x, + y1: max_y, + } + .intersect(clip) +} + #[inline] fn xy(word: u32) -> (i32, i32) { ( diff --git a/engine/backends/gpui/src/render.rs b/engine/backends/gpui/src/render.rs index 556c3146..ac36374e 100644 --- a/engine/backends/gpui/src/render.rs +++ b/engine/backends/gpui/src/render.rs @@ -313,7 +313,7 @@ impl GpuiRenderer { *i += 1; return; } - spec::draw_op::TRI | spec::draw_op::TEX_TRI => { + spec::draw_op::TRI | spec::draw_op::TEX_TRI | spec::draw_op::POLY => { self.paint_tri_batch(ui, words, i, origin, window, cx); } spec::draw_op::TEXT_RUN => { @@ -546,10 +546,11 @@ impl GpuiRenderer { // ---- triangle batches (raster fallback) ----------------------------------- - /// Paint one consecutive TRI/TEX_TRI batch starting at `*i`. Flat solid - /// TRIs alone stay vector paths; any gouraud or textured member sends - /// the WHOLE batch through the core software rasterizer so painter - /// order inside the batch (3D subtrees sort by depth) is preserved. + /// Paint one consecutive TRI/TEX_TRI/POLY batch starting at `*i`. Flat + /// solid TRIs and POLYs stay vector paths; any gouraud or textured + /// member sends the WHOLE batch through the core software rasterizer so + /// painter order inside the batch (3D subtrees sort by depth) is + /// preserved. POLY is flat-coloured by construction. fn paint_tri_batch( &mut self, ui: &Ui, @@ -574,21 +575,50 @@ impl GpuiRenderer { needs_raster = true; end += 12; } + spec::draw_op::POLY => { + if end + 3 > words.len() { + break; + } + let n = words[end + 1] as usize; + if !(3..=8).contains(&n) || end + 3 + n > words.len() { + break; + } + end += 3 + n; + } _ => break, } } *i = end; let batch = &words[start..end]; if !needs_raster { - for tri in batch.as_chunks::<7>().0 { - let color = abgr(tri[4]); - let (x0, y0) = decode_xy(tri[1]); - let (x1, y1) = decode_xy(tri[2]); - let (x2, y2) = decode_xy(tri[3]); - let mut path = Path::new(point(px(x0) + origin.x, px(y0) + origin.y)); - path.line_to(point(px(x1) + origin.x, px(y1) + origin.y)); - path.line_to(point(px(x2) + origin.x, px(y2) + origin.y)); - window.paint_path(path, color); + let mut j = 0usize; + while j < batch.len() { + match batch[j] { + spec::draw_op::TRI => { + let color = abgr(batch[j + 4]); + let (x0, y0) = decode_xy(batch[j + 1]); + let (x1, y1) = decode_xy(batch[j + 2]); + let (x2, y2) = decode_xy(batch[j + 3]); + let mut path = Path::new(point(px(x0) + origin.x, px(y0) + origin.y)); + path.line_to(point(px(x1) + origin.x, px(y1) + origin.y)); + path.line_to(point(px(x2) + origin.x, px(y2) + origin.y)); + window.paint_path(path, color); + j += 7; + } + spec::draw_op::POLY => { + let n = batch[j + 1] as usize; + let color = abgr(batch[j + 2]); + let (x0, y0) = decode_xy(batch[j + 3]); + let mut path = Path::new(point(px(x0) + origin.x, px(y0) + origin.y)); + for k in 1..n { + let (x, y) = decode_xy(batch[j + 3 + k]); + path.line_to(point(px(x) + origin.x, px(y) + origin.y)); + } + window.paint_path(path, color); + j += 3 + n; + } + _ => break, + } } return; } @@ -607,15 +637,17 @@ impl GpuiRenderer { let mut key_words: Vec = batch.to_vec(); let mut j = 0usize; while j < batch.len() { - if batch[j] == spec::draw_op::TEX_TRI { - let slot = batch[j + 1] & spec::TEX_SLOT_MASK; - if let Some((_, revision, _)) = ui.texture_at_versioned(slot) { - key_words.push(revision as u32); - key_words.push((revision >> 32) as u32); + match batch[j] { + spec::draw_op::TEX_TRI => { + let slot = batch[j + 1] & spec::TEX_SLOT_MASK; + if let Some((_, revision, _)) = ui.texture_at_versioned(slot) { + key_words.push(revision as u32); + key_words.push((revision >> 32) as u32); + } + j += 12; } - j += 12; - } else { - j += 7; + spec::draw_op::POLY => j += 3 + batch[j + 1] as usize, + _ => j += 7, } } let hash = fnv64(&key_words); @@ -634,23 +666,39 @@ impl GpuiRenderer { let (mut min_x, mut min_y, mut max_x, mut max_y) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); let mut j = 0usize; while j < batch.len() { - let idxs: &[usize] = if batch[j] == spec::draw_op::TEX_TRI { - &[j + 2, j + 5, j + 8] - } else { - &[j + 1, j + 2, j + 3] - }; - for &k in idxs { - let (x, y) = decode_xy(batch[k]); - min_x = min_x.min(x as i32); - min_y = min_y.min(y as i32); - max_x = max_x.max(x.ceil() as i32); - max_y = max_y.max(y.ceil() as i32); + match batch[j] { + spec::draw_op::TEX_TRI => { + for &k in &[j + 2, j + 5, j + 8] { + let (x, y) = decode_xy(batch[k]); + min_x = min_x.min(x as i32); + min_y = min_y.min(y as i32); + max_x = max_x.max(x.ceil() as i32); + max_y = max_y.max(y.ceil() as i32); + } + j += 12; + } + spec::draw_op::POLY => { + let n = batch[j + 1] as usize; + for k in 0..n { + let (x, y) = decode_xy(batch[j + 3 + k]); + min_x = min_x.min(x as i32); + min_y = min_y.min(y as i32); + max_x = max_x.max(x.ceil() as i32); + max_y = max_y.max(y.ceil() as i32); + } + j += 3 + n; + } + _ => { + for &k in &[j + 1, j + 2, j + 3] { + let (x, y) = decode_xy(batch[k]); + min_x = min_x.min(x as i32); + min_y = min_y.min(y as i32); + max_x = max_x.max(x.ceil() as i32); + max_y = max_y.max(y.ceil() as i32); + } + j += 7; + } } - j += if batch[j] == spec::draw_op::TEX_TRI { - 12 - } else { - 7 - }; } if min_x >= max_x || min_y >= max_y { return; diff --git a/engine/core/src/damage.rs b/engine/core/src/damage.rs index e5d6a9c9..46c7948f 100644 --- a/engine/core/src/damage.rs +++ b/engine/core/src/damage.rs @@ -424,6 +424,13 @@ impl<'a> DamageDecoder<'a> { spec::draw_op::SCISSOR_POP => 1, spec::draw_op::TRI => 7, spec::draw_op::TEX_TRI => 12, + spec::draw_op::POLY => { + let n = self.words.get(start + 1).copied().ok_or(())? as usize; + if !(3..=8).contains(&n) { + return Err(()); + } + 3usize.checked_add(n).ok_or(())? + } spec::draw_op::TEXT_RUN => { // 8 header words + ceil(byteLen/4) packed UTF-8 words. let bytes = *self.words.get(start + 7).ok_or(())? as usize; @@ -461,6 +468,7 @@ impl<'a> DamageDecoder<'a> { } spec::draw_op::TRI => triangle_bounds([words[1], words[2], words[3]], self.clip), spec::draw_op::TEX_TRI => triangle_bounds([words[2], words[5], words[8]], self.clip), + spec::draw_op::POLY => polygon_bounds(&words[3..], self.clip), // Native-text runs carry no glyph geometry the tracker can // measure; the core keeps every partially-clipped run inside a // scissor, so the current clip is a sound (conservative) bound. @@ -563,6 +571,24 @@ fn triangle_bounds(vertices: [u32; 3], clip: DamageRect) -> DamageRect { .intersect(clip) } +fn polygon_bounds(vertices: &[u32], clip: DamageRect) -> DamageRect { + if vertices.is_empty() { + return DamageRect::empty(); + } + let mut min_x = i32::MAX; + let mut min_y = i32::MAX; + let mut max_x = i32::MIN; + let mut max_y = i32::MIN; + for &word in vertices { + let (x, y) = xy(word); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + DamageRect::new(min_x, min_y, max_x, max_y).intersect(clip) +} + #[inline] fn xy(word: u32) -> (i32, i32) { ( diff --git a/engine/core/src/draw.rs b/engine/core/src/draw.rs index 7ee55bf3..164861e1 100644 --- a/engine/core/src/draw.rs +++ b/engine/core/src/draw.rs @@ -7,9 +7,10 @@ //! (TEX_QUAD) and gradient endpoint colors (GRAD_RECT). //! //! Transforms (translate/scale/rotate) compose down the walk as 2D affines. -//! Axis-aligned content uses RECT/GRAD_RECT/TEX_QUAD; ROTATED solid/gradient -//! boxes are corner-transformed, Sutherland-Hodgman-clipped and emitted as -//! TRI ops. v1 degradations (documented): +//! Axis-aligned content uses RECT/GRAD_RECT/TEX_QUAD; ROTATED solid boxes +//! are corner-transformed, Sutherland-Hodgman-clipped and emitted as one +//! POLY op (coverage over the whole clipped polygon). ROTATED gradient +//! boxes still fan into TRI ops. v1 degradations (documented): //! - rotated IMAGE quads are conservatively culled (no textured-tri op); //! - glyph cells position along the rotated/scaled frame but stay upright //! and unscaled (bitmap cells); glyphs whose cell top-left leaves the @@ -1201,9 +1202,7 @@ impl<'a> Walker<'a> { .map(|&(x, y)| ClipVert { x, y, color: unpack(color), u: 0.0, v: 0.0 }) .collect(); let clipped = sutherland_hodgman(&poly, clip); - for i in 1..clipped.len().saturating_sub(1) { - emit_tri(dl, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); - } + emit_poly(dl, &clipped, color, clip, self.screen); } Item3::TexMesh { cell_start, cell_end, tex, modulate } => { for cell in &tex_cells[cell_start..cell_end] { @@ -1575,7 +1574,7 @@ impl<'a> Walker<'a> { /// Emit a solid/gradient local-space rect under `world`: axis-aligned /// path (RECT/GRAD_RECT, clipped with color re-interpolation) or the - /// rotated path (Sutherland-Hodgman -> TRI ops). + /// rotated path (Sutherland-Hodgman -> POLY for flat, TRI fan for gradient). #[allow(clippy::too_many_arguments)] fn emit_box(&self, dl: &mut DrawList, world: &Affine, x0: f32, y0: f32, x1: f32, y1: f32, fill: Fill, clip: &Clip) { if x1 <= x0 || y1 <= y0 { @@ -1661,8 +1660,10 @@ impl<'a> Walker<'a> { } } } else { - // Rotated: transform corners, Sutherland-Hodgman clip, fan into - // TRI ops (gouraud carries any gradient through the clip). + // Rotated: transform corners, Sutherland-Hodgman clip. Flat fills + // emit one POLY (coverage over the whole clipped polygon — a TRI + // fan would double-blend the shared diagonal). Gradients still + // fan into TRI ops (gouraud carries the endpoint colours). let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]; let mut poly: Vec = Vec::with_capacity(8); for (i, &(lx, ly)) in corners.iter().enumerate() { @@ -1673,8 +1674,13 @@ impl<'a> Walker<'a> { if clipped.len() < 3 { return; } - for i in 1..clipped.len() - 1 { - emit_tri(dl, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + match fill { + Fill::Flat(color) => emit_poly(dl, &clipped, color, clip, self.screen), + Fill::Grad { .. } => { + for i in 1..clipped.len() - 1 { + emit_tri(dl, &clipped[0], &clipped[i], &clipped[i + 1], clip, self.screen); + } + } } } } @@ -2661,6 +2667,50 @@ fn sutherland_hodgman(poly: &[ClipVert], clip: &Clip) -> Vec { cur } +/// Emit one POLY op (flat colour). N is capped at 8 — Sutherland-Hodgman +/// clipping a quad against a rect yields at most 8 vertices; anything +/// larger falls back to the TRI fan. Degenerate polygons after rounding +/// are dropped, matching `emit_tri`. +fn emit_poly( + dl: &mut DrawList, + verts: &[ClipVert], + color: u32, + clip: &Clip, + screen: (f32, f32), +) { + if verts.len() < 3 { + return; + } + if verts.len() > 8 { + for i in 1..verts.len() - 1 { + emit_tri(dl, &verts[0], &verts[i], &verts[i + 1], clip, screen); + } + return; + } + let px = |v: &ClipVert| { + ( + clampf(roundf(clampf(v.x, clip.x0, clip.x1)), 0.0, screen.0), + clampf(roundf(clampf(v.y, clip.y0, clip.y1)), 0.0, screen.1), + ) + }; + let mut area2 = 0.0f32; + for i in 0..verts.len() { + let (x0, y0) = px(&verts[i]); + let (x1, y1) = px(&verts[(i + 1) % verts.len()]); + area2 += x0 * y1 - x1 * y0; + } + if area2 == 0.0 { + return; + } + dl.words.push(spec::draw_op::POLY); + dl.words.push(verts.len() as u32); + dl.words.push(color); + for v in verts { + let (x, y) = px(v); + dl.words.push(xy_word(x, y)); + } +} + /// Emit one TRI op (degenerate triangles after rounding are dropped). fn emit_tri( dl: &mut DrawList, diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index 7ba3edbb..b7eb0a59 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -9,7 +9,9 @@ //! interpolation, texture modulation. //! - Triangle coverage uses exact integer edge functions evaluated at //! doubled pixel-center coordinates (vertex coords are i16 integers, so -//! nothing ever rounds). +//! nothing ever rounds). Convex POLY coverage uses the same edge +//! functions in 4·F fixed point (quarter-pixel sample offsets stay +//! integral); no float enters that inner loop. //! - The only f32 involved is gradient/texture-coordinate interpolation — //! plain IEEE-754 add/mul/div on finite values (identical on every //! platform; no transcendental calls, no NaN paths: every divisor is @@ -806,6 +808,17 @@ fn render_scaled_clipped( tex_tri(ui, target, width, scale, clip, &words[i + 1..i + 12]); i += 12; } + draw_op::POLY => { + if i + 3 > words.len() { + return; + } + let n = words[i + 1] as usize; + if n < 3 || n > POLY_MAX_VERTS || i + 3 + n > words.len() { + return; + } + poly(target, width, scale, clip, words[i + 2], &words[i + 3..i + 3 + n]); + i += 3 + n; + } draw_op::TEXT_RUN => { // Native-text op (host text system shapes the run); the // software rasterizer has no shaper, so hosts that raster run @@ -963,6 +976,184 @@ fn tri(target: &mut T, stride: i32, scale: i32, clip: Clip, p: } } +// ---- POLY: convex polygon, 4×4 coverage over the whole shape ---------------------- + +/// Sutherland-Hodgman clipping a quad against a rect yields at most 8 vertices. +const POLY_MAX_VERTS: usize = 8; + +/// 4×4 sample offsets in 4·F units: ±1, ±3 so quarter-pixel positions stay integral. +const POLY_SAMPLE_OFF: [i64; 4] = [-3, -1, 1, 3]; + +#[inline] +fn poly_hits( + n: usize, + f0: &[i64; POLY_MAX_VERTS], + step: &[i64; POLY_MAX_VERTS], + dx: &[i64; POLY_MAX_VERTS], + dy: &[i64; POLY_MAX_VERTS], + px: i32, +) -> u32 { + let mut hits = 0u32; + for &oy in &POLY_SAMPLE_OFF { + for &ox in &POLY_SAMPLE_OFF { + let mut inside = true; + for e in 0..n { + if f0[e] + step[e] * px as i64 + dx[e] * ox + dy[e] * oy < 0 { + inside = false; + break; + } + } + if inside { + hits += 1; + } + } + } + hits +} + +fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, color: u32, verts: &[u32]) { + let n = verts.len(); + if n < 3 || n > POLY_MAX_VERTS { + return; + } + let (r, g, b, a) = channels(color); + if a == 0 { + return; + } + + let mut xs = [0i32; POLY_MAX_VERTS]; + let mut ys = [0i32; POLY_MAX_VERTS]; + let mut min_x = i32::MAX; + let mut max_x = i32::MIN; + let mut min_y = i32::MAX; + let mut max_y = i32::MIN; + for i in 0..n { + let (x, y) = xy(verts[i], scale); + xs[i] = x; + ys[i] = y; + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + min_x = min_x.max(clip.x0); + max_x = max_x.min(clip.x1); + min_y = min_y.max(clip.y0); + max_y = max_y.min(clip.y1); + if min_x >= max_x || min_y >= max_y { + return; + } + + // Doubled screen coords. F(px,py) = c + dx·px + dy·py, wound so inside is F >= 0. + let mut vx = [0i64; POLY_MAX_VERTS]; + let mut vy = [0i64; POLY_MAX_VERTS]; + for i in 0..n { + vx[i] = 2 * xs[i] as i64; + vy[i] = 2 * ys[i] as i64; + } + let mut area = 0i64; + for i in 0..n { + let j = if i + 1 == n { 0 } else { i + 1 }; + area += vx[i] * vy[j] - vx[j] * vy[i]; + } + if area == 0 { + return; + } + let ccw = area > 0; + + let mut e_c = [0i64; POLY_MAX_VERTS]; + let mut e_dx = [0i64; POLY_MAX_VERTS]; + let mut e_dy = [0i64; POLY_MAX_VERTS]; + for i in 0..n { + let (ia, ib) = if ccw { + (i, if i + 1 == n { 0 } else { i + 1 }) + } else { + (if i + 1 == n { 0 } else { i + 1 }, i) + }; + let (ax, ay, bx, by) = (vx[ia], vy[ia], vx[ib], vy[ib]); + let dx = -(by - ay); + let dy = bx - ax; + e_c[i] = -(dx * ax + dy * ay); + e_dx[i] = dx; + e_dy[i] = dy; + } + + // Work in 4·F so the ±1/±3 sample offsets stay integral. + let mut bound = [0i64; POLY_MAX_VERTS]; + let mut step = [0i64; POLY_MAX_VERTS]; + for i in 0..n { + bound[i] = 3 * (e_dx[i].abs() + e_dy[i].abs()); + step[i] = 8 * e_dx[i]; + } + let opaque = a >= 255; + let mut f0 = [0i64; POLY_MAX_VERTS]; + for row in min_y..max_y { + let sy = 2 * row as i64 + 1; + let sx = 2 * min_x as i64 + 1; + for i in 0..n { + f0[i] = 4 * (e_c[i] + e_dx[i] * sx + e_dy[i] * sy); + } + let span = max_x - min_x; + let solve = |e: usize, thr: i64| -> (i32, i32) { + let (f, s) = (f0[e], step[e]); + if s == 0 { + return if f >= thr { (0, span) } else { (0, 0) }; + } + let k = thr - f; + if s > 0 { + (((k + s - 1).div_euclid(s)).clamp(0, span as i64) as i32, span) + } else { + (0, ((k.div_euclid(s)) + 1).clamp(0, span as i64) as i32) + } + }; + let (mut il, mut ih, mut tl, mut th) = (0, span, 0, span); + for e in 0..n { + let (l, h) = solve(e, bound[e]); + il = il.max(l); + ih = ih.min(h); + let (l, h) = solve(e, -bound[e]); + tl = tl.max(l); + th = th.min(h); + } + if th <= tl { + continue; + } + if ih < il { + il = tl; + ih = tl; + } + for col in tl..il { + let hits = poly_hits(n, &f0, &step, &e_dx, &e_dy, col); + if hits == 0 { + continue; + } + target.blend((row * stride + min_x + col) as usize, r, g, b, a * hits / 16); + } + if ih > il { + if opaque { + target.fill_opaque( + (row * stride + min_x + il) as usize, + (ih - il) as usize, + r, + g, + b, + ); + } else { + for col in il..ih { + target.blend((row * stride + min_x + col) as usize, r, g, b, a); + } + } + } + for col in ih..th { + let hits = poly_hits(n, &f0, &step, &e_dx, &e_dy, col); + if hits == 0 { + continue; + } + target.blend((row * stride + min_x + col) as usize, r, g, b, a * hits / 16); + } + } +} + // ---- GLYPH_RUN: coverage atlas cells ----------------------------------------------- /// Map a scaled destination pixel (relative to its glyph cell origin) to the diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index c28d33e4..3fc89b73 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -426,7 +426,7 @@ pub mod font_atlas { /// DrawList op codes (core -> backend Vec words; layout in spec.ts). /// Word counts incl. header: RECT 4, GRAD_RECT 6, GLYPH_RUN 3+2n, /// TEX_QUAD 9, SCISSOR 3, SCISSOR_POP 1, TRI 7, TEX_TRI 12, -/// TEXT_RUN 8+ceil(bytes/4), SURFACE_QUAD 9. +/// TEXT_RUN 8+ceil(bytes/4), SURFACE_QUAD 9, POLY 3+N. pub mod draw_op { pub const RECT: u32 = 1; pub const GRAD_RECT: u32 = 2; @@ -438,6 +438,7 @@ pub mod draw_op { pub const TEX_TRI: u32 = 8; pub const TEXT_RUN: u32 = 9; pub const SURFACE_QUAD: u32 = 10; + pub const POLY: u32 = 11; } /// .pak container constants (byte-compatible with dreamcart's format; diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index 1dafe31e..8265ab57 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -280,6 +280,15 @@ fn validate_drawlist(words: &[u32]) -> [u32; 11] { } i += 12; } + spec::draw_op::POLY => { + let n = words[i + 1] as usize; + assert!((3..=8).contains(&n), "POLY vertex count {n} not in 3..=8"); + assert!(i + 3 + n <= words.len(), "truncated POLY"); + for k in 0..n { + xy_ok(words[i + 3 + k]); + } + i += 3 + n; + } spec::draw_op::TEXT_RUN => { // Native-text op: origin is f32 (exempt from the i16 clip // guarantee), box width is finite and non-negative, and the @@ -329,6 +338,10 @@ fn tex_tri_runs(words: &[u32]) -> Vec<(u32, usize)> { spec::draw_op::SCISSOR => { previous_was_tex_tri = false; i += 3; } spec::draw_op::SCISSOR_POP => { previous_was_tex_tri = false; i += 1; } spec::draw_op::TRI => { previous_was_tex_tri = false; i += 7; } + spec::draw_op::POLY => { + previous_was_tex_tri = false; + i += 3 + words[i + 1] as usize; + } spec::draw_op::TEXT_RUN => { previous_was_tex_tri = false; i += 8 + (words[i + 7] as usize).div_ceil(4); @@ -577,12 +590,12 @@ fn fixed_dt_animation_is_deterministic() { for f in &a { validate_drawlist(f); } - // The rotated frames must actually exercise the TRI path. - let tri_frames = a + // The rotated frames must actually exercise the POLY path. + let poly_frames = a .iter() - .filter(|f| validate_drawlist(f)[spec::draw_op::TRI as usize] > 0) + .filter(|f| validate_drawlist(f)[spec::draw_op::POLY as usize] > 0) .count(); - assert!(tri_frames > 0, "rotation should emit TRI ops"); + assert!(poly_frames > 0, "rotation should emit POLY ops"); } #[test] @@ -712,7 +725,7 @@ fn drawlist_clip_invariant_offscreen_rects() { let words = ui.draw().words.clone(); let counts = validate_drawlist(&words); assert!(counts[spec::draw_op::RECT as usize] > 0); - assert!(counts[spec::draw_op::TRI as usize] > 0, "rotated offscreen boxes clip into TRIs"); + assert!(counts[spec::draw_op::POLY as usize] > 0, "rotated offscreen boxes clip into POLY"); assert!(counts[spec::draw_op::GRAD_RECT as usize] > 0); // Find the gradient and check the endpoint re-interpolation: the rect // spans x 380..580, the clip keeps 380..480 = fractions 0.0..0.5, so the @@ -733,6 +746,7 @@ fn drawlist_clip_invariant_offscreen_rects() { i += 6; } spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => i += 9, spec::draw_op::SCISSOR => i += 3, @@ -813,6 +827,7 @@ fn rounded_boxes_emit_subpixel_edge_coverage() { spec::draw_op::RECT => i += 4, spec::draw_op::GRAD_RECT => i += 6, spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => { corner_quads += 1; @@ -984,6 +999,7 @@ fn transparent_rounded_border_draws_an_outline_not_square_strips() { } spec::draw_op::GRAD_RECT => i += 6, spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => i += 9, spec::draw_op::SCISSOR => i += 3, @@ -1085,6 +1101,7 @@ fn overflow_hidden_emits_balanced_intersected_scissors() { spec::draw_op::RECT => i += 4, spec::draw_op::GRAD_RECT => i += 6, spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => i += 9, _ => i += 1, @@ -1115,6 +1132,7 @@ fn overflow_hidden_emits_balanced_intersected_scissors() { } spec::draw_op::GRAD_RECT => i += 6, spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => i += 9, _ => i += 1, @@ -1436,6 +1454,7 @@ fn zindex_orders_siblings_stably() { } spec::draw_op::GRAD_RECT => i += 6, spec::draw_op::TRI => i += 7, + spec::draw_op::POLY => i += 3 + words[i + 1] as usize, spec::draw_op::GLYPH_RUN => i += 3 + 2 * ((words[i + 1] >> 16) as usize), spec::draw_op::TEX_QUAD => i += 9, _ => i += 1, @@ -2053,9 +2072,10 @@ fn perspective_subtree_emits_depth_sorted_tris() { ui.set_style(f, 1); ui.tick(); let words = ui.draw().words.clone(); - // A rotateY'd face must land on the TRI path (perspective projection). + // A rotateY'd face must land on the POLY path (perspective projection). let mut i = 0; let mut tris = 0; + let mut polys = 0; while i < words.len() { let op = words[i]; i += match op { @@ -2065,6 +2085,10 @@ fn perspective_subtree_emits_depth_sorted_tris() { tris += 1; 7 } + x if x == spec::draw_op::POLY => { + polys += 1; + 3 + words[i + 1] as usize + } x if x == spec::draw_op::GLYPH_RUN => { let n = (words[i + 1] >> 16) as usize; 3 + 2 * n @@ -2074,7 +2098,8 @@ fn perspective_subtree_emits_depth_sorted_tris() { _ => 1, // SCISSOR_POP }; } - assert!(tris >= 2, "expected projected face triangles, got {tris}"); + assert_eq!(tris, 0, "flat 3D face must not fan into TRI"); + assert!(polys >= 1, "expected projected face polygon, got {polys}"); } #[test] @@ -2160,6 +2185,7 @@ fn arc_primitive_emits_coverage_rects() { } x if x == spec::draw_op::GRAD_RECT => 6, x if x == spec::draw_op::TRI => 7, + x if x == spec::draw_op::POLY => 3 + words[i + 1] as usize, x if x == spec::draw_op::GLYPH_RUN => { let c = (words[i + 1] >> 16) as usize; 3 + 2 * c @@ -2173,6 +2199,224 @@ fn arc_primitive_emits_coverage_rects() { assert!(rects > 20, "expected arc coverage runs, got {rects}"); } +// ---- POLY: convex coverage over a clipped rotated box ---------------------------- + +fn place_box(ui: &mut Ui, w: f64, h: f64, x: f64, y: f64) -> i32 { + let n = ui.create_node(0); + ui.set_prop(n, spec::prop::WIDTH, w); + ui.set_prop(n, spec::prop::HEIGHT, h); + ui.set_prop(n, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(n, spec::prop::INSET_L, x); + ui.set_prop(n, spec::prop::INSET_T, y); + ui.insert_before(spec::ROOT_ID, n, 0); + n +} + +/// Distinct R-channel values, and how many partial pixels have all eight +/// neighbours non-background. That second count is the diagonal-seam +/// regression: per-triangle coverage leaves it non-zero inside one box. +/// Render the current DrawList into a fresh screen-sized RGBA buffer. +fn raster_fb(ui: &mut Ui) -> alloc::vec::Vec { + let words = ui.draw().words.clone(); + let mut fb = alloc::vec![0u8; spec::SCREEN_W as usize * spec::SCREEN_H as usize * 4]; + crate::raster::render(ui, &words, &mut fb); + fb +} + +fn poly_luminance_stats(fb: &[u8]) -> (usize, u32) { + let w = spec::SCREEN_W as usize; + let h = spec::SCREEN_H as usize; + let mut seen = [false; 256]; + let mut interior_partial = 0u32; + for y in 0..h { + for x in 0..w { + let v = fb[(y * w + x) * 4]; + seen[v as usize] = true; + if v > 0 && v < 255 && x > 0 && y > 0 && x + 1 < w && y + 1 < h { + let nb = [ + (-1isize, -1), + (0, -1), + (1, -1), + (-1, 0), + (1, 0), + (-1, 1), + (0, 1), + (1, 1), + ]; + if nb.iter().all(|&(dx, dy)| { + fb[(((y as isize + dy) as usize) * w + (x as isize + dx) as usize) * 4] > 0 + }) { + interior_partial += 1; + } + } + } + } + (seen.iter().filter(|&&on| on).count(), interior_partial) +} + +#[test] +fn rotated_flat_box_emits_one_poly_gradient_stays_tri() { + let mut ui = Ui::new(); + let flat = place_box(&mut ui, 80.0, 50.0, 100.0, 80.0); + ui.set_prop(flat, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(flat, spec::prop::ROTATE, 20.0); + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert_eq!(counts[spec::draw_op::POLY as usize], 1, "one POLY for the whole box"); + assert_eq!(counts[spec::draw_op::TRI as usize], 0, "flat fill must not fan into TRI"); + + let mut ui = Ui::new(); + let grad = place_box(&mut ui, 80.0, 50.0, 100.0, 80.0); + ui.set_prop(grad, spec::prop::GRAD_FROM, abgr(255, 0, 0, 255) as f64); + ui.set_prop(grad, spec::prop::GRAD_TO, abgr(0, 0, 255, 255) as f64); + ui.set_prop(grad, spec::prop::GRAD_DIR, spec::GradDir::ToRight as u32 as f64); + ui.set_prop(grad, spec::prop::ROTATE, 20.0); + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert!(counts[spec::draw_op::TRI as usize] >= 2, "rotated gradient still fans TRI"); + assert_eq!(counts[spec::draw_op::POLY as usize], 0, "gradient must not emit POLY"); +} + +#[test] +fn rotated_flat_box_raster_has_coverage_levels() { + let mut ui = Ui::new(); + let n = place_box(&mut ui, 240.0, 160.0, 120.0, 56.0); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::ROTATE, 20.0); + ui.tick(); + let fb = raster_fb(&mut ui); + let (levels, _) = poly_luminance_stats(&fb); + assert!( + levels > 2, + "4×4 coverage must produce more than binary edges, got {levels} levels" + ); +} + +#[test] +fn rotated_flat_box_has_no_interior_partial_pixels() { + let mut ui = Ui::new(); + let n = place_box(&mut ui, 240.0, 160.0, 120.0, 56.0); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::ROTATE, 20.0); + ui.tick(); + let fb = raster_fb(&mut ui); + let (_, interior_partial) = poly_luminance_stats(&fb); + assert_eq!( + interior_partial, 0, + "coverage over the whole polygon must not leave a seam" + ); +} + +#[test] +fn clipped_polygon_closes_against_the_screen_edge() { + let mut ui = Ui::new(); + let n = place_box(&mut ui, 80.0, 60.0, 0.0, 80.0); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::TRANSLATE_X, -25.0); + ui.set_prop(n, spec::prop::ROTATE, 25.0); + ui.tick(); + let words = ui.draw().words.clone(); + let counts = validate_drawlist(&words); + assert_eq!(counts[spec::draw_op::POLY as usize], 1); + let mut i = 0usize; + let mut nverts = 0usize; + let mut on_edge = false; + while i < words.len() { + if words[i] == spec::draw_op::POLY { + nverts = words[i + 1] as usize; + for k in 0..nverts { + let (x, _) = decode_xy(words[i + 3 + k]); + if x == 0 { + on_edge = true; + } + } + break; + } + i += match words[i] { + spec::draw_op::RECT => 4, + spec::draw_op::GRAD_RECT => 6, + spec::draw_op::TRI => 7, + spec::draw_op::POLY => 3 + words[i + 1] as usize, + spec::draw_op::GLYPH_RUN => 3 + 2 * ((words[i + 1] >> 16) as usize), + spec::draw_op::TEX_QUAD => 9, + spec::draw_op::TEX_TRI => 12, + spec::draw_op::SCISSOR => 3, + _ => 1, + }; + } + assert!((3..=8).contains(&nverts), "clipped POLY N={nverts}"); + assert!(on_edge, "clip against x=0 must leave a vertex on that edge"); + + let fb = raster_fb(&mut ui); + let w = spec::SCREEN_W as usize; + let h = spec::SCREEN_H as usize; + let mut edge_hits = 0u32; + let mut white = 0u32; + for y in 0..h { + if fb[y * w * 4] > 0 { + edge_hits += 1; + } + for x in 0..w { + if fb[(y * w + x) * 4] == 255 { + white += 1; + } + } + } + assert!(edge_hits > 0, "the clipped edge must paint x=0, not leave a hole"); + assert!(white > 0, "the clipped polygon must still have an interior"); + let (_, interior_partial) = poly_luminance_stats(&fb); + assert_eq!(interior_partial, 0, "clip must not open an interior seam"); +} + +#[test] +fn rotated_3d_face_emits_poly_textured_still_tex_tri() { + let mut ui = Ui::new(); + let pixels = alloc::vec![0xffu8; 8 * 8 * 4]; + let tex = ui.upload_texture(&pixels, 8, 8, spec::psm::PSM_8888); + assert!(tex >= 0); + + let stage = place_box(&mut ui, 200.0, 200.0, 40.0, 36.0); + ui.set_prop(stage, spec::prop::PERSPECTIVE, 400.0); + + let face = ui.create_node(0); + ui.set_prop(face, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(face, spec::prop::WIDTH, 80.0); + ui.set_prop(face, spec::prop::HEIGHT, 80.0); + ui.set_prop(face, spec::prop::INSET_L, 20.0); + ui.set_prop(face, spec::prop::INSET_T, 20.0); + ui.set_prop(face, spec::prop::BG_COLOR, abgr(200, 200, 200, 255) as f64); + ui.set_prop(face, spec::prop::ROTATE_Y, 40.0); + ui.insert_before(stage, face, 0); + + let card = ui.create_node(0); + ui.set_prop(card, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(card, spec::prop::WIDTH, 80.0); + ui.set_prop(card, spec::prop::HEIGHT, 80.0); + ui.set_prop(card, spec::prop::INSET_L, 110.0); + ui.set_prop(card, spec::prop::INSET_T, 20.0); + ui.set_prop(card, spec::prop::ROTATE_Y, 40.0); + ui.insert_before(stage, card, 0); + + let img = ui.create_node(spec::NodeType::Image as u8); + ui.set_prop(img, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(img, spec::prop::WIDTH, 80.0); + ui.set_prop(img, spec::prop::HEIGHT, 80.0); + ui.set_image(img, tex); + ui.insert_before(card, img, 0); + + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert!( + counts[spec::draw_op::POLY as usize] >= 1, + "solid 3D face must emit POLY, got {}", + counts[spec::draw_op::POLY as usize] + ); + assert!( + counts[spec::draw_op::TEX_TRI as usize] > 0, + "textured 3D face must still emit TEX_TRI" + ); +} + // ---- DevTools ops (spec ops 18..22, docs/DEVTOOLS.md) ---------------------------- #[test] diff --git a/engine/crates/pocket-ui-wgpu/src/render.rs b/engine/crates/pocket-ui-wgpu/src/render.rs index aa74e05e..e220f192 100644 --- a/engine/crates/pocket-ui-wgpu/src/render.rs +++ b/engine/crates/pocket-ui-wgpu/src/render.rs @@ -1,6 +1,7 @@ //! DrawList → wgpu. The third DrawList backend (after the PSP GE and the -//! wasm software rasterizer), executing the closed 7-op set pinned in -//! spec.ts "DRAWLIST op format". +//! wasm software rasterizer), executing the closed DrawList op set pinned +//! in spec.ts "DRAWLIST op format". Hardware has no per-pixel coverage, so +//! POLY degrades to a triangle fan — today's binary fill. //! //! The core's CPU clip stage guarantees every coordinate is inside //! [0, viewport] — this backend only batches: one vertex stream, draw calls @@ -640,6 +641,45 @@ impl UiRenderer { self.verts.extend_from_slice(&v); i += 7; } + spec::draw_op::POLY => { + if i + 3 > words.len() { + break; + } + let n = words[i + 1] as usize; + if !(3..=8).contains(&n) || i + 3 + n > words.len() { + break; + } + if cur_tex != TexBind::White { + flush!(TexBind::White, scissor); + } + let color = words[i + 2]; + let (x0, y0) = xy(words[i + 3]); + for k in 1..n - 1 { + let (x1, y1) = xy(words[i + 3 + k]); + let (x2, y2) = xy(words[i + 3 + k + 1]); + self.verts.extend_from_slice(&[ + UiVertex { + pos: ndc(x0, y0), + uv: [0.0, 0.0], + color, + mode: MODE_SOLID, + }, + UiVertex { + pos: ndc(x1, y1), + uv: [0.0, 0.0], + color, + mode: MODE_SOLID, + }, + UiVertex { + pos: ndc(x2, y2), + uv: [0.0, 0.0], + color, + mode: MODE_SOLID, + }, + ]); + } + i += 3 + n; + } spec::draw_op::TEXT_RUN => { // Native-text op: emitted only when the host installed a // native measurer, which the portable wgpu backend never diff --git a/engine/symbian/src/gl/mod.rs b/engine/symbian/src/gl/mod.rs index 56f82e87..898f4c41 100644 --- a/engine/symbian/src/gl/mod.rs +++ b/engine/symbian/src/gl/mod.rs @@ -791,6 +791,39 @@ impl Renderer { } index += 7; } + spec::draw_op::POLY if index + 3 <= words.len() => { + let n = words[index + 1] as usize; + let next = index + 3 + n; + if !(3..=8).contains(&n) || next > words.len() { + break; + } + if texture != self.white { + self.flush(texture, clip, &mut start); + texture = self.white; + } + let color = words[index + 2]; + let (x0, y0) = xy(words[index + 3]); + for k in 1..n - 1 { + let (x1, y1) = xy(words[index + 3 + k]); + let (x2, y2) = xy(words[index + 3 + k + 1]); + self.vertices.push(Vertex { + position: [x0, y0], + uv: [0.0, 0.0], + color, + }); + self.vertices.push(Vertex { + position: [x1, y1], + uv: [0.0, 0.0], + color, + }); + self.vertices.push(Vertex { + position: [x2, y2], + uv: [0.0, 0.0], + color, + }); + } + index = next; + } spec::draw_op::SCISSOR if index + 3 <= words.len() => { self.flush(texture, clip, &mut start); clip_stack.push(clip); diff --git a/hosts/psp/src/ge.rs b/hosts/psp/src/ge.rs index 548f1bdc..5e074016 100644 --- a/hosts/psp/src/ge.rs +++ b/hosts/psp/src/ge.rs @@ -552,6 +552,24 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { flush(GuPrimitive::Triangles, VTYPE_C, (count * 3) as i32, verts as *const c_void, bytes); i = end; } + spec::draw_op::POLY if i + 3 <= n => { + // GE has no per-pixel coverage; a triangle fan is today's + // binary fill of the same convex polygon. + let nverts = words[i + 1] as usize; + let next = i + 3 + nverts; + if !(3..=8).contains(&nverts) || next > n { + break; + } + let color = words[i + 2]; + let bytes = nverts * core::mem::size_of::(); + let verts = pool_alloc(bytes) as *mut VertC; + for k in 0..nverts { + let (x, y) = xy(words[i + 3 + k]); + *verts.add(k) = VertC { color, x, y, z: 0, _pad: 0 }; + } + flush(GuPrimitive::TriangleFan, VTYPE_C, nverts as i32, verts as *const c_void, bytes); + i = next; + } spec::draw_op::GLYPH_RUN if i + 3 <= n => { let w1 = words[i + 1]; let slot = (w1 & 0xff) as u8; diff --git a/hosts/vita/src/graphics.rs b/hosts/vita/src/graphics.rs index 8db983ac..aa57f67c 100644 --- a/hosts/vita/src/graphics.rs +++ b/hosts/vita/src/graphics.rs @@ -627,6 +627,36 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { color_vertices(&vertices, SceGxmPrimitiveType_SCE_GXM_PRIMITIVE_TRIANGLES); i += 7; } + spec::draw_op::POLY if i + 3 <= words.len() => { + // GXM has no per-pixel coverage; a triangle fan is today's + // binary fill of the same convex polygon. + let nverts = words[i + 1] as usize; + let next = i + 3 + nverts; + if !(3..=8).contains(&nverts) || next > words.len() { + break; + } + let color = words[i + 2]; + let mut vertices = [vita2d_color_vertex { + x: 0.0, + y: 0.0, + z: 0.5, + color: 0, + }; 8]; + for k in 0..nverts { + let (x, y) = xy(words[i + 3 + k]); + vertices[k] = vita2d_color_vertex { + x, + y, + z: 0.5, + color, + }; + } + color_vertices( + &vertices[..nverts], + SceGxmPrimitiveType_SCE_GXM_PRIMITIVE_TRIANGLE_FAN, + ); + i = next; + } spec::draw_op::GLYPH_RUN if i + 3 <= words.len() => { let meta = words[i + 1]; let slot = (meta & 0xff) as u8; @@ -767,6 +797,9 @@ fn validate_texture_residency(ui: &Ui, words: &[u32]) -> io::Result<()> { spec::draw_op::RECT => i.checked_add(4), spec::draw_op::GRAD_RECT => i.checked_add(6), spec::draw_op::TRI => i.checked_add(7), + spec::draw_op::POLY if i + 1 < words.len() => { + i.checked_add(3 + words[i + 1] as usize) + } spec::draw_op::GLYPH_RUN if i + 2 < words.len() => { let slot = (words[i + 1] & 0xff) as u8; if ui.font_atlas(slot).is_none() { From 1c7c9149ced45a04a94111397f6d511e2dc0bad6 Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Tue, 25 Aug 2026 11:20:10 +0800 Subject: [PATCH 2/6] =?UTF-8?q?test(raster):=20lock=20POLY=20spans=20again?= =?UTF-8?q?st=20a=204=C3=974=20sample=20oracle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- engine/core/src/raster.rs | 7 +- engine/core/src/tests.rs | 382 +++++++++++++++++++++++++++++++++++++- 2 files changed, 386 insertions(+), 3 deletions(-) diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index b7eb0a59..a5029717 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -1101,9 +1101,14 @@ fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, co } let k = thr - f; if s > 0 { + // First col with f >= thr is ceil(k/s); div_euclid floors for s > 0. (((k + s - 1).div_euclid(s)).clamp(0, span as i64) as i32, span) } else { - (0, ((k.div_euclid(s)) + 1).clamp(0, span as i64) as i32) + // s < 0 flips the inequality: cols satisfying it are 0..=floor(k/s). + // div_euclid CEILS for a negative divisor, so it must not be used + // here — it returned floor+1 and handed one boundary column to + // fill_opaque. floor(k/s) == (-k).div_euclid(-s), with -s > 0. + (0, (((-k).div_euclid(-s)) + 1).clamp(0, span as i64) as i32) } }; let (mut il, mut ih, mut tl, mut th) = (0, span, 0, span); diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index 8265ab57..4b29f5db 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -198,10 +198,15 @@ fn decode_wh(word: u32) -> (i32, i32) { ((word & 0xffff) as i32, (word >> 16) as i32) } +/// One slot per opcode, so the counts array is indexed by op code directly. +/// Tied to the highest opcode rather than written out, because a bare literal +/// is what silently went stale when SURFACE_QUAD took 10. +const DRAW_OP_SLOTS: usize = spec::draw_op::POLY as usize + 1; + /// Walk a DrawList asserting the pinned CPU-clip invariant: every coordinate /// in [0, SCREEN_W] x [0, SCREEN_H], rect extents in range, scissors /// balanced, only known ops. Returns per-op counts (indexed by op code). -fn validate_drawlist(words: &[u32]) -> [u32; 11] { +fn validate_drawlist(words: &[u32]) -> [u32; DRAW_OP_SLOTS] { let (sw, sh) = (spec::SCREEN_W as i32, spec::SCREEN_H as i32); let xy_ok = |w: u32| { let (x, y) = decode_xy(w); @@ -213,7 +218,7 @@ fn validate_drawlist(words: &[u32]) -> [u32; 11] { let (w, h) = decode_wh(whw); assert!(x + w <= sw && y + h <= sh, "rect exceeds screen: {x},{y} {w}x{h}"); }; - let mut counts = [0u32; 11]; + let mut counts = [0u32; DRAW_OP_SLOTS]; let mut depth = 0i32; let mut i = 0usize; while i < words.len() { @@ -2254,6 +2259,195 @@ fn poly_luminance_stats(fb: &[u8]) -> (usize, u32) { (seen.iter().filter(|&&on| on).count(), interior_partial) } +/// Word count for the op at `i`. Same lengths the rasterizer walks. +fn draw_op_word_count(words: &[u32], i: usize) -> usize { + match words[i] { + spec::draw_op::RECT => 4, + spec::draw_op::GRAD_RECT => 6, + spec::draw_op::GLYPH_RUN => 3 + 2 * ((words[i + 1] >> 16) as usize), + spec::draw_op::TEX_QUAD | spec::draw_op::SURFACE_QUAD => 9, + spec::draw_op::SCISSOR => 3, + spec::draw_op::SCISSOR_POP => 1, + spec::draw_op::TRI => 7, + spec::draw_op::TEX_TRI => 12, + spec::draw_op::POLY => 3 + words[i + 1] as usize, + spec::draw_op::TEXT_RUN => 8 + (words[i + 7] as usize).div_ceil(4), + other => panic!("unknown draw op {other} at word {i}"), + } +} + +fn find_poly(words: &[u32]) -> Option<(u32, Vec)> { + let mut i = 0usize; + while i < words.len() { + if words[i] == spec::draw_op::POLY { + let n = words[i + 1] as usize; + assert!((3..=8).contains(&n), "POLY vertex count {n} not in 3..=8"); + return Some((words[i + 2], words[i + 3..i + 3 + n].to_vec())); + } + i += draw_op_word_count(words, i); + } + None +} + +fn strip_poly_ops(words: &[u32]) -> Vec { + let mut out = Vec::new(); + let mut i = 0usize; + while i < words.len() { + let n = draw_op_word_count(words, i); + if words[i] != spec::draw_op::POLY { + out.extend_from_slice(&words[i..i + n]); + } + i += n; + } + out +} + +/// Integer src-over matching `RgbaTarget::blend` (RGBA8, dest alpha forced 255). +fn blend_rgba8(fb: &mut [u8], offset: usize, r: u32, g: u32, b: u32, a: u32) { + let o = offset * 4; + if a >= 255 { + fb[o] = r as u8; + fb[o + 1] = g as u8; + fb[o + 2] = b as u8; + fb[o + 3] = 255; + return; + } + if a == 0 { + return; + } + let ia = 255 - a; + let mix = |s: u32, d: u8| ((s * a + d as u32 * ia + 127) / 255) as u8; + fb[o] = mix(r, fb[o]); + fb[o + 1] = mix(g, fb[o + 1]); + fb[o + 2] = mix(b, fb[o + 2]); + fb[o + 3] = 255; +} + +/// 4×4 coverage over a convex POLY using the same edge functions as +/// `raster::poly`, without the interior-span shortcut. Pixel writes match +/// `poly`: `hits == 16 && a >= 255` uses the `fill_opaque` equivalent, +/// otherwise `blend(r, g, b, a * hits / 16)`; `hits == 0` is skipped. +fn poly_4x4_oracle(fb: &mut [u8], color: u32, verts: &[u32]) { + const MAX: usize = 8; + const OFF: [i64; 4] = [-3, -1, 1, 3]; + let n = verts.len(); + if n < 3 || n > MAX { + return; + } + let r = color & 0xff; + let g = (color >> 8) & 0xff; + let b = (color >> 16) & 0xff; + let a = color >> 24; + if a == 0 { + return; + } + let stride = spec::SCREEN_W as i32; + let (clip_x0, clip_y0) = (0i32, 0i32); + let (clip_x1, clip_y1) = (spec::SCREEN_W as i32, spec::SCREEN_H as i32); + + let mut xs = [0i32; MAX]; + let mut ys = [0i32; MAX]; + let mut min_x = i32::MAX; + let mut max_x = i32::MIN; + let mut min_y = i32::MAX; + let mut max_y = i32::MIN; + for i in 0..n { + let (x, y) = decode_xy(verts[i]); + xs[i] = x; + ys[i] = y; + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + min_x = min_x.max(clip_x0); + max_x = max_x.min(clip_x1); + min_y = min_y.max(clip_y0); + max_y = max_y.min(clip_y1); + if min_x >= max_x || min_y >= max_y { + return; + } + + // Doubled screen coords. F(px,py) = c + dx·px + dy·py, wound so inside is F >= 0. + let mut vx = [0i64; MAX]; + let mut vy = [0i64; MAX]; + for i in 0..n { + vx[i] = 2 * xs[i] as i64; + vy[i] = 2 * ys[i] as i64; + } + let mut area = 0i64; + for i in 0..n { + let j = if i + 1 == n { 0 } else { i + 1 }; + area += vx[i] * vy[j] - vx[j] * vy[i]; + } + if area == 0 { + return; + } + let ccw = area > 0; + + let mut e_c = [0i64; MAX]; + let mut e_dx = [0i64; MAX]; + let mut e_dy = [0i64; MAX]; + for i in 0..n { + let (ia, ib) = if ccw { + (i, if i + 1 == n { 0 } else { i + 1 }) + } else { + (if i + 1 == n { 0 } else { i + 1 }, i) + }; + let (ax, ay, bx, by) = (vx[ia], vy[ia], vx[ib], vy[ib]); + let dx = -(by - ay); + let dy = bx - ax; + e_c[i] = -(dx * ax + dy * ay); + e_dx[i] = dx; + e_dy[i] = dy; + } + + let mut step = [0i64; MAX]; + for i in 0..n { + step[i] = 8 * e_dx[i]; + } + let opaque = a >= 255; + let mut f0 = [0i64; MAX]; + for row in min_y..max_y { + let sy = 2 * row as i64 + 1; + let sx = 2 * min_x as i64 + 1; + for i in 0..n { + f0[i] = 4 * (e_c[i] + e_dx[i] * sx + e_dy[i] * sy); + } + let span = max_x - min_x; + for col in 0..span { + let mut hits = 0u32; + for &oy in &OFF { + for &ox in &OFF { + let mut inside = true; + for e in 0..n { + if f0[e] + step[e] * i64::from(col) + e_dx[e] * ox + e_dy[e] * oy < 0 { + inside = false; + break; + } + } + if inside { + hits += 1; + } + } + } + if hits == 0 { + continue; + } + let offset = (row * stride + min_x + col) as usize; + if hits == 16 && opaque { + let o = offset * 4; + fb[o] = r as u8; + fb[o + 1] = g as u8; + fb[o + 2] = b as u8; + fb[o + 3] = 255; + } else { + blend_rgba8(fb, offset, r, g, b, a * hits / 16); + } + } + } +} + #[test] fn rotated_flat_box_emits_one_poly_gradient_stays_tri() { let mut ui = Ui::new(); @@ -2417,6 +2611,190 @@ fn rotated_3d_face_emits_poly_textured_still_tex_tri() { ); } +#[test] +fn poly_row_spans_match_naive_4x4_sample_oracle() { + // Interior-span solving used to treat div_euclid as floor for s < 0 + // (it ceils on a negative divisor) and handed one extra boundary + // column to fill_opaque. Counting interior partial pixels cannot + // catch that: the failure turns a partial pixel solid. This oracle + // compares every coverage sample instead. + let mut angles = Vec::new(); + let mut deg = 0i32; + while deg < 360 { + angles.push(deg as f64); + deg += 7; + } + for extra in [45.0, 90.0, 135.0, 180.0] { + if !angles.iter().any(|&a| a == extra) { + angles.push(extra); + } + } + + // (name, w, h, inset_l, inset_t, translate_x, translate_y). + // Edge/corner translates hang the 80×50 box past the viewport so + // Sutherland-Hodgman inserts clip-boundary vertices (N in 5..=8). + // all-edges is a covering quad whose four original vertices sit + // outside the screen, which is how N reaches 8. + let scenes: [(&str, f64, f64, f64, f64, f64, f64); 10] = [ + ("center", 80.0, 50.0, 200.0, 110.0, 0.0, 0.0), + ("left", 80.0, 50.0, 200.0, 110.0, -230.0, 0.0), + ("right", 80.0, 50.0, 200.0, 110.0, 230.0, 0.0), + ("top", 80.0, 50.0, 200.0, 110.0, 0.0, -140.0), + ("bottom", 80.0, 50.0, 200.0, 110.0, 0.0, 140.0), + ("top-left", 80.0, 50.0, 200.0, 110.0, -230.0, -140.0), + ("top-right", 80.0, 50.0, 200.0, 110.0, 230.0, -140.0), + ("bottom-left", 80.0, 50.0, 200.0, 110.0, -230.0, 140.0), + ("bottom-right", 80.0, 50.0, 200.0, 110.0, 230.0, 140.0), + ("all-edges", 520.0, 320.0, -20.0, -24.0, 0.0, 0.0), + ]; + + let mut nvert_seen = [0u32; 9]; + let mut compared = 0u32; + for &angle in &angles { + for &(clip_name, w, h, x, y, tx, ty) in &scenes { + // Fresh tree per case so a previous 520×320 layout cannot + // leak into the next 80×50 polygon. + let mut ui = Ui::new(); + let n = place_box(&mut ui, w, h, x, y); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::TRANSLATE_X, tx); + ui.set_prop(n, spec::prop::TRANSLATE_Y, ty); + ui.set_prop(n, spec::prop::ROTATE, angle); + ui.tick(); + + let words = ui.draw().words.clone(); + let counts = validate_drawlist(&words); + let n_poly = counts[spec::draw_op::POLY as usize]; + if n_poly == 0 { + // 0° stays axis-aligned: emit_box writes RECT, not POLY. + assert_eq!( + angle % 360.0, + 0.0, + "expected POLY at rotate={angle} clip={clip_name}" + ); + continue; + } + assert_eq!( + n_poly, 1, + "one box must emit one POLY at rotate={angle} clip={clip_name}" + ); + let (color, verts) = find_poly(&words).expect("POLY words"); + nvert_seen[verts.len()] += 1; + + let actual = raster_fb(&mut ui); + let rest = strip_poly_ops(&words); + let mut expected = + alloc::vec![0u8; spec::SCREEN_W as usize * spec::SCREEN_H as usize * 4]; + crate::raster::render(&ui, &rest, &mut expected); + poly_4x4_oracle(&mut expected, color, &verts); + + if actual != expected { + let width = spec::SCREEN_W as usize; + let mut diffs = 0u32; + let mut first = None; + for i in 0..width * spec::SCREEN_H as usize { + let a = &actual[i * 4..i * 4 + 4]; + let e = &expected[i * 4..i * 4 + 4]; + if a != e { + diffs += 1; + if first.is_none() { + first = Some(( + i % width, + i / width, + [a[0], a[1], a[2], a[3]], + [e[0], e[1], e[2], e[3]], + )); + } + } + } + let (px, py, a, e) = first.unwrap(); + panic!( + "POLY span fill != 4×4 oracle at rotate={angle} clip={clip_name} nverts={} — {diffs} pixels differ; first ({px},{py}) actual={a:?} oracle={e:?}", + verts.len() + ); + } + compared += 1; + } + } + + assert!(compared > 0, "oracle must have compared at least one POLY"); + let clipped_high: u32 = (5..=8).map(|k| nvert_seen[k]).sum(); + assert!( + clipped_high > 0, + "Sutherland-Hodgman clip cases must produce 5..=8-gon POLY, nvert histogram={nvert_seen:?}" + ); +} + +#[test] +fn rotated_rounded_flat_box_emits_poly_not_tri_and_has_coverage() { + // Motions Modal30 is rounded-[999px] + rotate-28. draw.rs drops the + // radius on a non-axis-aligned box and emit_box writes POLY, not the + // axis-aligned disc TEX_QUAD path and not a TRI fan. + let mut ui = Ui::new(); + let n = place_box(&mut ui, 240.0, 160.0, 120.0, 56.0); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::RADIUS, 999.0); + ui.set_prop(n, spec::prop::ROTATE, 28.0); + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert_eq!(counts[spec::draw_op::POLY as usize], 1, "rotated rounded box must emit POLY"); + assert_eq!(counts[spec::draw_op::TRI as usize], 0, "flat fill must not fan into TRI"); + assert_eq!( + counts[spec::draw_op::TEX_QUAD as usize], 0, + "rotation drops the baked-disc corner path" + ); + let fb = raster_fb(&mut ui); + let (levels, interior_partial) = poly_luminance_stats(&fb); + assert!( + levels > 2, + "4×4 coverage must produce more than binary edges, got {levels} levels" + ); + assert_eq!( + interior_partial, 0, + "coverage over the whole polygon must not leave a seam" + ); +} + +#[test] +fn rotated_3d_solid_face_raster_has_coverage_levels() { + // Motions CubeFaces: perspective root + rotate-x/y solid faces project + // as Item3::Quad and emit POLY. Existing tests only check the op; + // this checks the rasterized coverage. + let mut ui = Ui::new(); + let stage = place_box(&mut ui, 200.0, 200.0, 40.0, 36.0); + ui.set_prop(stage, spec::prop::PERSPECTIVE, 400.0); + + let face = ui.create_node(0); + ui.set_prop(face, spec::prop::POS_TYPE, spec::PosType::Absolute as u32 as f64); + ui.set_prop(face, spec::prop::WIDTH, 80.0); + ui.set_prop(face, spec::prop::HEIGHT, 80.0); + ui.set_prop(face, spec::prop::INSET_L, 20.0); + ui.set_prop(face, spec::prop::INSET_T, 20.0); + ui.set_prop(face, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(face, spec::prop::ROTATE_X, -40.0); + ui.set_prop(face, spec::prop::ROTATE_Y, 40.0); + ui.insert_before(stage, face, 0); + + ui.tick(); + let counts = validate_drawlist(&ui.draw().words.clone()); + assert!( + counts[spec::draw_op::POLY as usize] >= 1, + "solid 3D face must emit POLY, got {}", + counts[spec::draw_op::POLY as usize] + ); + assert_eq!(counts[spec::draw_op::TRI as usize], 0, "flat 3D face must not fan into TRI"); + let fb = raster_fb(&mut ui); + let (levels, interior_partial) = poly_luminance_stats(&fb); + assert!( + levels > 2, + "4×4 coverage must produce more than binary edges, got {levels} levels" + ); + assert_eq!( + interior_partial, 0, + "coverage over the whole polygon must not leave a seam" + ); +} + // ---- DevTools ops (spec ops 18..22, docs/DEVTOOLS.md) ---------------------------- #[test] From 4fe1708eb79f7eccc2b48dbb0e0a30c254965172 Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Tue, 25 Aug 2026 11:38:57 +0800 Subject: [PATCH 3/6] docs(spec): say that POLY's fill is per-backend, not a 4x4 guarantee 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 --- contracts/spec/spec.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 5d4c2817..e711a7d6 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1409,10 +1409,20 @@ export const FONT_FLAG_BOLD = 1 << 0; // colour, vertices CCW after raster setup. The core // emits these for ROTATED solid boxes and for // projected 3D faces after Sutherland-Hodgman -// clipping. Coverage is 4×4 samples over the whole -// polygon (interior run + boundary pixels) so a -// box's shared diagonal is not an interior edge. -// N > 8 falls back to a TRI fan. +// clipping. N > 8 falls back to a TRI fan. +// THE OP CARRIES GEOMETRY ONLY. Unlike rounded +// corners, shadows and arcs — which bake coverage +// into alpha RECT spans in the core, so every +// backend draws the same pixels — how a POLY is +// filled is left to the backend, and the backends +// differ. engine/core/src/raster.rs samples 4x4 +// over the whole polygon, which is what keeps a +// box's shared diagonal from reading as an interior +// edge; esp32p4-ppa inherits that by delegating to +// it. wgpu, PSP GE, Vita GXM and Symbian GLES2 have +// no per-pixel coverage, decode POLY as a triangle +// fan, and fill it binary — the same picture they +// draw for TRI today. // TEXT_RUN (8 + ceil(n/4) words): // op, // word1: bits 0-7 fontSlot, From 6276516af1a0d310477f4ca41764636571479a4d Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Tue, 25 Aug 2026 14:34:27 +0800 Subject: [PATCH 4/6] refactor(raster): make POLY coverage a value the core hands out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- engine/core/src/raster.rs | 76 +++++++++++++++++++++++++++----------- engine/core/src/tests.rs | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 21 deletions(-) diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index a5029717..39b8f888 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -1011,15 +1011,43 @@ fn poly_hits( hits } -fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, color: u32, verts: &[u32]) { +/// One horizontal run of constant coverage inside a POLY: `len` pixels +/// starting at (`x`, `y`), covered by `hits` of the 16 samples. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PolySpan { + pub x: i32, + pub y: i32, + pub len: i32, + pub hits: u32, +} + +/// Decide a POLY's coverage and hand it out as spans. +/// +/// This is the only place a polygon's edge softness is computed. The software +/// rasterizer below fills the spans itself; a backend whose hardware has no +/// per-pixel coverage can draw the same spans as alpha sprites and land on the +/// same pixels, because both are consuming one function's numbers rather than +/// each deciding what a polygon edge looks like. +/// +/// `clip` is (x0, y0, x1, y1) in destination pixels, x1/y1 exclusive. +/// Spans arrive in scanline order, left to right, and never overlap. +pub fn poly_spans( + scale: i32, + clip: (i32, i32, i32, i32), + verts: &[u32], + mut emit: F, +) { + poly_spans_impl(scale, Clip { x0: clip.0, y0: clip.1, x1: clip.2, y1: clip.3 }, verts, &mut emit); +} + +/// Generic, not `&mut dyn FnMut`: the boxed call cost ~30% of a frame on a +/// scene of large rotated bars, which is the whole margin this op has. +#[inline] +fn poly_spans_impl(scale: i32, clip: Clip, verts: &[u32], emit: &mut F) { let n = verts.len(); if n < 3 || n > POLY_MAX_VERTS { return; } - let (r, g, b, a) = channels(color); - if a == 0 { - return; - } let mut xs = [0i32; POLY_MAX_VERTS]; let mut ys = [0i32; POLY_MAX_VERTS]; @@ -1085,7 +1113,6 @@ fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, co bound[i] = 3 * (e_dx[i].abs() + e_dy[i].abs()); step[i] = 8 * e_dx[i]; } - let opaque = a >= 255; let mut f0 = [0i64; POLY_MAX_VERTS]; for row in min_y..max_y { let sy = 2 * row as i64 + 1; @@ -1132,33 +1159,40 @@ fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, co if hits == 0 { continue; } - target.blend((row * stride + min_x + col) as usize, r, g, b, a * hits / 16); + emit(PolySpan { x: min_x + col, y: row, len: 1, hits }); } if ih > il { - if opaque { - target.fill_opaque( - (row * stride + min_x + il) as usize, - (ih - il) as usize, - r, - g, - b, - ); - } else { - for col in il..ih { - target.blend((row * stride + min_x + col) as usize, r, g, b, a); - } - } + emit(PolySpan { x: min_x + il, y: row, len: ih - il, hits: 16 }); } for col in ih..th { let hits = poly_hits(n, &f0, &step, &e_dx, &e_dy, col); if hits == 0 { continue; } - target.blend((row * stride + min_x + col) as usize, r, g, b, a * hits / 16); + emit(PolySpan { x: min_x + col, y: row, len: 1, hits }); } } } +fn poly(target: &mut T, stride: i32, scale: i32, clip: Clip, color: u32, verts: &[u32]) { + let (r, g, b, a) = channels(color); + if a == 0 { + return; + } + let opaque = a >= 255; + poly_spans_impl(scale, clip, verts, &mut |s: PolySpan| { + let off = (s.y * stride + s.x) as usize; + if s.hits == 16 && opaque { + target.fill_opaque(off, s.len as usize, r, g, b); + return; + } + let cov = a * s.hits / 16; + for i in 0..s.len as usize { + target.blend(off + i, r, g, b, cov); + } + }); +} + // ---- GLYPH_RUN: coverage atlas cells ----------------------------------------------- /// Map a scaled destination pixel (relative to its glyph cell origin) to the diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index 4b29f5db..fdcf612c 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -4484,3 +4484,81 @@ fn clearing_native_measure_restores_baked_goldens_path() { assert_eq!(counts[spec::draw_op::TEXT_RUN as usize], 0); assert_eq!(counts[spec::draw_op::GLYPH_RUN as usize], 1); } + +#[test] +fn poly_spans_drawn_as_alpha_rects_match_the_rasterized_poly() { + // The backend-agnostic half of POLY. A host with no per-pixel coverage can + // ask the core for the polygon's spans and draw them as alpha sprites — + // the RECT path every backend already runs for rounded corners and + // shadows. If that lands on the same bytes as raster.rs filling the spans + // itself, then coverage stops being a property of which backend decoded + // the op. + let mut checked = 0u32; + for ° in &[7.0f64, 20.0, 33.0, 45.0, 61.0, 118.0, 200.0, 289.0, 340.0] { + for &(w, h, x, y) in &[ + (240.0f64, 160.0, 120.0, 56.0), + (11.0, 4.0, 48.0, 59.0), + (73.0, 48.0, 59.0, 26.0), + ] { + let mut ui = Ui::new(); + let n = place_box(&mut ui, w, h, x, y); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::ROTATE, deg); + ui.tick(); + + let words = ui.draw().words.clone(); + let Some((color, verts)) = find_poly(&words) else { continue }; + let actual = raster_fb(&mut ui); + + // What a fixed-function backend would submit: the same spans, as + // whole-pixel RECTs whose alpha is the span's coverage. + let (r, g, b, a) = ( + color & 0xff, + (color >> 8) & 0xff, + (color >> 16) & 0xff, + color >> 24, + ); + let mut span_words = strip_poly_ops(&words); + crate::raster::poly_spans( + 1, + (0, 0, spec::SCREEN_W as i32, spec::SCREEN_H as i32), + &verts, + |s| { + let cov = a * s.hits / 16; + if cov == 0 { + return; + } + span_words.push(spec::draw_op::RECT); + span_words.push((s.x as u16 as u32) | ((s.y as u16 as u32) << 16)); + span_words.push((s.len as u16 as u32) | (1u32 << 16)); + span_words.push((cov << 24) | (b << 16) | (g << 8) | r); + }, + ); + + let mut expected = + alloc::vec![0u8; spec::SCREEN_W as usize * spec::SCREEN_H as usize * 4]; + crate::raster::render(&ui, &span_words, &mut expected); + + if actual != expected { + let width = spec::SCREEN_W as usize; + let mut diffs = 0u32; + let mut first = None; + for i in 0..width * spec::SCREEN_H as usize { + let (p, q) = (&actual[i * 4..i * 4 + 4], &expected[i * 4..i * 4 + 4]); + if p != q { + diffs += 1; + if first.is_none() { + first = Some((i % width, i / width, [p[0], p[1], p[2]], [q[0], q[1], q[2]])); + } + } + } + let (px, py, pa, pe) = first.unwrap(); + panic!( + "span replay != rasterized POLY at rotate={deg} box={w}x{h} — {diffs} px differ; first ({px},{py}) raster={pa:?} spans={pe:?}" + ); + } + checked += 1; + } + } + assert!(checked > 20, "expected many cases, checked {checked}"); +} From 99e4d025623a500a9abe0e88e802724150abd85c Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Tue, 25 Aug 2026 15:49:12 +0800 Subject: [PATCH 5/6] feat(psp): draw POLY from the core's coverage spans, not a binary fan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- engine/core/src/raster.rs | 101 +++++++++++++++++++++++++++++++------- engine/core/src/tests.rs | 51 +++++++++++++++++++ hosts/psp/src/ge.rs | 50 ++++++++++++++++--- 3 files changed, 176 insertions(+), 26 deletions(-) diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index 39b8f888..ac0843ff 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -1040,10 +1040,79 @@ pub fn poly_spans( poly_spans_impl(scale, Clip { x0: clip.0, y0: clip.1, x1: clip.2, y1: clip.3 }, verts, &mut emit); } -/// Generic, not `&mut dyn FnMut`: the boxed call cost ~30% of a frame on a +/// How many spans `poly_spans` can emit, without sampling any coverage. +/// +/// A backend that must size a vertex buffer before it can fill one needs a +/// number up front, and running the whole solve twice to get an exact count +/// costs more than over-allocating by the boundary columns whose coverage +/// turns out to be zero. Never less than the count `poly_spans` produces. +pub fn poly_span_bound(scale: i32, clip: (i32, i32, i32, i32), verts: &[u32]) -> usize { + let mut n = 0usize; + poly_rows( + scale, + Clip { x0: clip.0, y0: clip.1, x1: clip.2, y1: clip.3 }, + verts, + &mut |r: &PolyRow| { + n += (r.il - r.tl) as usize + (r.th - r.ih) as usize; + if r.ih > r.il { + n += 1; + } + }, + ); + n +} + +/// Generic, not `&mut dyn FnMut`: the boxed call cost ~a fifth of a frame on a /// scene of large rotated bars, which is the whole margin this op has. #[inline] fn poly_spans_impl(scale: i32, clip: Clip, verts: &[u32], emit: &mut F) { + poly_rows(scale, clip, verts, &mut |r: &PolyRow| { + for col in r.tl..r.il { + let hits = r.hits(col); + if hits == 0 { + continue; + } + emit(PolySpan { x: r.min_x + col, y: r.y, len: 1, hits }); + } + if r.ih > r.il { + emit(PolySpan { x: r.min_x + r.il, y: r.y, len: r.ih - r.il, hits: 16 }); + } + for col in r.ih..r.th { + let hits = r.hits(col); + if hits == 0 { + continue; + } + emit(PolySpan { x: r.min_x + col, y: r.y, len: 1, hits }); + } + }); +} + +/// One scanline of the span solve. `tl..il` and `ih..th` are the columns whose +/// coverage has to be sampled; `il..ih` is the run every sample falls inside. +/// Columns are relative to `min_x`. +struct PolyRow<'a> { + y: i32, + min_x: i32, + tl: i32, + il: i32, + ih: i32, + th: i32, + n: usize, + f0: &'a [i64; POLY_MAX_VERTS], + step: &'a [i64; POLY_MAX_VERTS], + e_dx: &'a [i64; POLY_MAX_VERTS], + e_dy: &'a [i64; POLY_MAX_VERTS], +} + +impl PolyRow<'_> { + #[inline] + fn hits(&self, col: i32) -> u32 { + poly_hits(self.n, self.f0, self.step, self.e_dx, self.e_dy, col) + } +} + +#[inline] +fn poly_rows(scale: i32, clip: Clip, verts: &[u32], row_fn: &mut F) { let n = verts.len(); if n < 3 || n > POLY_MAX_VERTS { return; @@ -1154,23 +1223,19 @@ fn poly_spans_impl(scale: i32, clip: Clip, verts: &[u32], em il = tl; ih = tl; } - for col in tl..il { - let hits = poly_hits(n, &f0, &step, &e_dx, &e_dy, col); - if hits == 0 { - continue; - } - emit(PolySpan { x: min_x + col, y: row, len: 1, hits }); - } - if ih > il { - emit(PolySpan { x: min_x + il, y: row, len: ih - il, hits: 16 }); - } - for col in ih..th { - let hits = poly_hits(n, &f0, &step, &e_dx, &e_dy, col); - if hits == 0 { - continue; - } - emit(PolySpan { x: min_x + col, y: row, len: 1, hits }); - } + row_fn(&PolyRow { + y: row, + min_x, + tl, + il, + ih, + th, + n, + f0: &f0, + step: &step, + e_dx: &e_dx, + e_dy: &e_dy, + }); } } diff --git a/engine/core/src/tests.rs b/engine/core/src/tests.rs index fdcf612c..aaf03510 100644 --- a/engine/core/src/tests.rs +++ b/engine/core/src/tests.rs @@ -4562,3 +4562,54 @@ fn poly_spans_drawn_as_alpha_rects_match_the_rasterized_poly() { } assert!(checked > 20, "expected many cases, checked {checked}"); } + +#[test] +fn poly_span_bound_covers_every_span_without_sampling() { + // A backend sizes its vertex buffer from this before it can fill one, so a + // bound below the real count would truncate the polygon. It over-counts + // only the boundary columns whose coverage turns out to be zero, so it + // must also stay close enough to be worth using instead of a second solve. + let mut worst_slack = 0f64; + let mut cases = 0u32; + for ° in &[3.0f64, 17.0, 28.0, 45.0, 76.0, 122.0, 181.0, 244.0, 311.0, 355.0] { + for &(w, h, x, y, tx, ty) in &[ + (240.0f64, 160.0, 120.0, 56.0, 0.0, 0.0), + (11.0, 4.0, 48.0, 59.0, 0.0, 0.0), + (73.0, 48.0, 59.0, 26.0, 0.0, 0.0), + (200.0, 140.0, 200.0, 110.0, 230.0, 0.0), + (200.0, 140.0, 200.0, 110.0, -230.0, -140.0), + ] { + let mut ui = Ui::new(); + let n = place_box(&mut ui, w, h, x, y); + ui.set_prop(n, spec::prop::BG_COLOR, abgr(255, 255, 255, 255) as f64); + ui.set_prop(n, spec::prop::TRANSLATE_X, tx); + ui.set_prop(n, spec::prop::TRANSLATE_Y, ty); + ui.set_prop(n, spec::prop::ROTATE, deg); + ui.tick(); + let words = ui.draw().words.clone(); + let Some((_, verts)) = find_poly(&words) else { continue }; + let clip = (0, 0, spec::SCREEN_W as i32, spec::SCREEN_H as i32); + + let mut actual = 0usize; + crate::raster::poly_spans(1, clip, &verts, |_| actual += 1); + let bound = crate::raster::poly_span_bound(1, clip, &verts); + + assert!( + bound >= actual, + "bound {bound} < actual {actual} at rotate={deg} box={w}x{h} — a backend would truncate" + ); + if actual > 0 { + let slack = bound as f64 / actual as f64; + if slack > worst_slack { + worst_slack = slack; + } + cases += 1; + } + } + } + assert!(cases > 30, "expected many cases, got {cases}"); + assert!( + worst_slack < 1.5, + "bound is {worst_slack:.2}x the real span count — too loose to allocate from" + ); +} diff --git a/hosts/psp/src/ge.rs b/hosts/psp/src/ge.rs index 5e074016..3e41152e 100644 --- a/hosts/psp/src/ge.rs +++ b/hosts/psp/src/ge.rs @@ -36,6 +36,7 @@ use psp::sys::{ TextureColorComponent, TextureEffect, TextureFilter, TexturePixelFormat, VertexType, }; use psp::{SCREEN_HEIGHT, SCREEN_WIDTH}; +use pocketjs_core::raster::{poly_span_bound, poly_spans, PolySpan}; use pocketjs_core::{spec, text::Atlas, TexView, Ui}; // --------------------------------------------------------------------------- @@ -553,21 +554,54 @@ pub unsafe fn render_over(ui: &Ui, words: &[u32]) { i = end; } spec::draw_op::POLY if i + 3 <= n => { - // GE has no per-pixel coverage; a triangle fan is today's - // binary fill of the same convex polygon. + // The GE cannot compute per-pixel coverage, so it does not: + // `poly_spans` is the same solve engine/core/src/raster.rs + // fills for itself, and this draws what it returns as alpha + // sprites — the path already used for rounded corners and + // shadows. A triangle fan here would put a binary edge on + // hardware and a sampled one everywhere else, which is the one + // thing an op shared by six backends must not do. let nverts = words[i + 1] as usize; let next = i + 3 + nverts; if !(3..=8).contains(&nverts) || next > n { break; } let color = words[i + 2]; - let bytes = nverts * core::mem::size_of::(); - let verts = pool_alloc(bytes) as *mut VertC; - for k in 0..nverts { - let (x, y) = xy(words[i + 3 + k]); - *verts.add(k) = VertC { color, x, y, z: 0, _pad: 0 }; + let (cr, cg, cb, ca) = + (color & 0xff, (color >> 8) & 0xff, (color >> 16) & 0xff, color >> 24); + let poly = &words[i + 3..next]; + // Full viewport: the GE scissors, exactly as it does for the + // rects the core has already intersected. + let clip = (0, 0, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + let bound = poly_span_bound(1, clip, poly); + if bound > 0 { + let vbuf = pool_alloc(bound * 2 * core::mem::size_of::()) as *mut VertC; + let mut k = 0usize; + poly_spans(1, clip, poly, |s: PolySpan| { + let cov = ca * s.hits / 16; + if cov == 0 { + return; + } + let c = (cov << 24) | (cb << 16) | (cg << 8) | cr; + // SAFETY: `bound` is an upper bound on the spans this + // call emits, and the buffer was sized for two + // vertices each. + unsafe { + *vbuf.add(k * 2) = + VertC { color: c, x: s.x as i16, y: s.y as i16, z: 0, _pad: 0 }; + *vbuf.add(k * 2 + 1) = VertC { + color: c, + x: (s.x + s.len) as i16, + y: (s.y + 1) as i16, + z: 0, + _pad: 0, + }; + } + k += 1; + }); + let used = k * 2 * core::mem::size_of::(); + flush(GuPrimitive::Sprites, VTYPE_C, (k * 2) as i32, vbuf as *const c_void, used); } - flush(GuPrimitive::TriangleFan, VTYPE_C, nverts as i32, verts as *const c_void, bytes); i = next; } spec::draw_op::GLYPH_RUN if i + 3 <= n => { From 20b663c159f4c195bffc5ca9c0824f18cdad2897 Mon Sep 17 00:00:00 2001 From: qianiaoo Date: Tue, 25 Aug 2026 15:51:30 +0800 Subject: [PATCH 6/6] docs(spec): POLY coverage is decided once in the core 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 --- contracts/spec/spec.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index e711a7d6..12e38e3f 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -1410,19 +1410,19 @@ export const FONT_FLAG_BOLD = 1 << 0; // emits these for ROTATED solid boxes and for // projected 3D faces after Sutherland-Hodgman // clipping. N > 8 falls back to a TRI fan. -// THE OP CARRIES GEOMETRY ONLY. Unlike rounded -// corners, shadows and arcs — which bake coverage -// into alpha RECT spans in the core, so every -// backend draws the same pixels — how a POLY is -// filled is left to the backend, and the backends -// differ. engine/core/src/raster.rs samples 4x4 -// over the whole polygon, which is what keeps a -// box's shared diagonal from reading as an interior -// edge; esp32p4-ppa inherits that by delegating to -// it. wgpu, PSP GE, Vita GXM and Symbian GLES2 have -// no per-pixel coverage, decode POLY as a triangle -// fan, and fill it binary — the same picture they -// draw for TRI today. +// Coverage is decided ONCE, in the core: +// raster::poly_spans samples 4x4 over the whole +// polygon and returns scanline runs, which is what +// keeps a box's shared diagonal from reading as an +// interior edge. raster.rs fills those runs itself; +// esp32p4-ppa inherits that by delegating to it; the +// PSP GE has no per-pixel coverage and does not need +// it, drawing the same runs as alpha sprites the way +// it already draws rounded corners and shadows. +// wgpu, Vita GXM and Symbian GLES2 still decode POLY +// as a triangle fan and fill it binary, so on those +// three a rotated edge is the picture TRI draws +// today rather than the one the core computed. // TEXT_RUN (8 + ceil(n/4) words): // op, // word1: bits 0-7 fontSlot,