From b87f0d9c803e019f7c05b87dd8fea3297ddc3d59 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 16:42:31 +0800 Subject: [PATCH 1/9] feat(textures): add opaque PSM5650 support --- contracts/spec/spec.ts | 5 ++-- engine/core/src/lib.rs | 2 +- engine/core/src/spec.rs | 1 + engine/crates/pocket-ui-wgpu/src/render.rs | 19 ++++++++++++- framework/compiler/pak.ts | 32 ++++++++++++++++++---- hosts/psp/src/ge.rs | 1 + hosts/vita/src/graphics.rs | 15 ++++++++++ tests/renderer.test.ts | 31 +++++++++++++++++++++ 8 files changed, 96 insertions(+), 10 deletions(-) diff --git a/contracts/spec/spec.ts b/contracts/spec/spec.ts index 11f32bb6..c3080229 100644 --- a/contracts/spec/spec.ts +++ b/contracts/spec/spec.ts @@ -496,10 +496,11 @@ export const ENUMS = { // --------------------------------------------------------------------------- // Values MUST equal rust-psp's TexturePixelFormat (sceGuTexMode arg), as used // by the dreamcart runtime — verified against rust-psp/psp/src/sys/gu.rs -// (Psm4444 = 2, Psm8888 = 3) and runtime/src/gfx3d.rs psm_for(). v1 supports -// only these two (4444 for baked corner/shadow alpha sprites, 8888 for images). +// (Psm5650 = 0, Psm4444 = 2, Psm8888 = 3) and runtime/src/gfx3d.rs +// psm_for(). export const PSM = { + PSM_5650: 0, // RGB 565, 16-bit (B5:G6:R5 bit order) PSM_4444: 2, // RGBA 4444, 16-bit PSM_8888: 3, // RGBA 8888, 32-bit PSM_T8: 5, // CLUT8: one palette index/px + a 256 x u32 ABGR palette diff --git a/engine/core/src/lib.rs b/engine/core/src/lib.rs index a7514252..88859aea 100644 --- a/engine/core/src/lib.rs +++ b/engine/core/src/lib.rs @@ -468,7 +468,7 @@ impl Ui { /// EXACTLY w*h*bpp bytes; FLAG_LINEAR requests bilinear sampling. pub fn upload_texture_flags(&mut self, data: &[u8], w: u32, h: u32, psm: u32, flags: u8) -> i32 { let bpp = match psm { - spec::psm::PSM_4444 => 2usize, + spec::psm::PSM_5650 | spec::psm::PSM_4444 => 2usize, spec::psm::PSM_8888 => 4usize, spec::psm::PSM_T8 => 1usize, _ => return -1, diff --git a/engine/core/src/spec.rs b/engine/core/src/spec.rs index 6d19cc6c..4017d1bc 100644 --- a/engine/core/src/spec.rs +++ b/engine/core/src/spec.rs @@ -316,6 +316,7 @@ pub enum Easing { /// PSM_T8 (CLUT8) uploads as: 1024-byte palette (256 x u32 ABGR), then /// w*h index bytes. pub mod psm { + pub const PSM_5650: u32 = 0; pub const PSM_4444: u32 = 2; pub const PSM_8888: u32 = 3; pub const PSM_T8: u32 = 5; diff --git a/engine/crates/pocket-ui-wgpu/src/render.rs b/engine/crates/pocket-ui-wgpu/src/render.rs index 28938d81..51cf0f44 100644 --- a/engine/crates/pocket-ui-wgpu/src/render.rs +++ b/engine/crates/pocket-ui-wgpu/src/render.rs @@ -825,11 +825,28 @@ impl UiRenderer { } } -/// Expand a core texture (PSM 8888/4444/T8) to tightly-packed RGBA8. +/// Expand a core texture (PSM 5650/8888/4444/T8) to tightly-packed RGBA8. fn to_rgba8(view: &TexView) -> Option> { let count = (view.w * view.h) as usize; let pixels = view.pixels; match view.psm { + spec::psm::PSM_5650 => { + if pixels.len() < count * 2 { + return None; + } + let mut out = Vec::with_capacity(count * 4); + for px in pixels[..count * 2].as_chunks::<2>().0 { + let v = u16::from_le_bytes([px[0], px[1]]) as u32; + let r = v & 0x1f; + let g = (v >> 5) & 0x3f; + let b = (v >> 11) & 0x1f; + out.push(((r << 3) | (r >> 2)) as u8); + out.push(((g << 2) | (g >> 4)) as u8); + out.push(((b << 3) | (b >> 2)) as u8); + out.push(255); + } + Some(out) + } spec::psm::PSM_8888 => { let bytes = count * 4; (pixels.len() >= bytes).then(|| pixels[..bytes].to_vec()) diff --git a/framework/compiler/pak.ts b/framework/compiler/pak.ts index 01617ac1..be1490df 100644 --- a/framework/compiler/pak.ts +++ b/framework/compiler/pak.ts @@ -10,11 +10,11 @@ // IMG entry layout (PocketJS addition — the container itself is unchanged): // off 0 u16 width (texture dims; pow2, <= TEX_MAX_DIM) // off 2 u16 height -// off 4 u8 psm (spec.ts PSM: 2 = 4444, 3 = 8888) +// off 4 u8 psm (spec.ts PSM: 0 = 5650, 2 = 4444, 3 = 8888) // off 5 u8 reserved (0) // off 6 u16 reserved (0) -// off 8 ... raw pixel rows (8888: RGBA bytes == ABGR u32 LE; 4444: u16 LE -// with nibbles A<<12|B<<8|G<<4|R, the GE ABGR4444 layout) +// off 8 ... raw pixel rows (8888: RGBA bytes == ABGR u32 LE; 5650: u16 LE +// B5:G6:R5; 4444: u16 LE A4:B4:G4:R4) // // Also here: a minimal PNG decoder (8-bit RGB/RGBA/gray, non-interlaced — // zlib via node:zlib inflateSync) for baking demo images. Palette/16-bit/ @@ -172,6 +172,16 @@ export function encodeImageEntry( out[5] = flags & 0xff; if (psm === PSM.PSM_8888) { out.set(rgba, 8); // RGBA byte order IS the ABGR u32 LE layout + } else if (psm === PSM.PSM_5650) { + for (let i = 0, n = w * h; i < n; i++) { + if (rgba[i * 4 + 3] !== 255) { + throw new Error("pak image: PSM_5650 requires fully opaque pixels"); + } + const r = rgba[i * 4] >> 3; + const g = rgba[i * 4 + 1] >> 2; + const b = rgba[i * 4 + 2] >> 3; + dv.setUint16(8 + i * 2, (b << 11) | (g << 5) | r, true); + } } else if (psm === PSM.PSM_4444) { for (let i = 0, n = w * h; i < n; i++) { const r = rgba[i * 4] >> 4; @@ -220,8 +230,8 @@ export function encodeSpriteEntry(a: SpriteAtlas, psm: number = PSM.PSM_8888): U if (!pow2(w) || !pow2(h) || w > TEX_MAX_DIM || h > TEX_MAX_DIM) { throw new Error(`pak sprite: atlas dims must be pow2 <= ${TEX_MAX_DIM}, got ${w}x${h}`); } - if (psm !== PSM.PSM_8888 && psm !== PSM.PSM_4444) { - throw new Error(`pak sprite: unsupported psm ${psm} (8888 or 4444)`); + if (psm !== PSM.PSM_8888 && psm !== PSM.PSM_4444 && psm !== PSM.PSM_5650) { + throw new Error(`pak sprite: unsupported psm ${psm} (5650, 4444, or 8888)`); } if (rgba.length !== w * h * 4) throw new Error("pak sprite: rgba length mismatch"); if (a.frameCount < 1 || a.cols < 1 || a.frameStep < 1) { @@ -250,7 +260,7 @@ export function encodeSpriteEntry(a: SpriteAtlas, psm: number = PSM.PSM_8888): U dv.setUint16(10, a.frameStep, true); if (psm === PSM.PSM_8888) { out.set(rgba, 16); // RGBA byte order IS the ABGR u32 LE layout - } else { + } else if (psm === PSM.PSM_4444) { // PSM_4444 (16-bit) — halves texmem for PSP; the GE ABGR4444 nibble layout. for (let i = 0, n = w * h; i < n; i++) { const r = rgba[i * 4] >> 4; @@ -259,6 +269,16 @@ export function encodeSpriteEntry(a: SpriteAtlas, psm: number = PSM.PSM_8888): U const av = rgba[i * 4 + 3] >> 4; dv.setUint16(16 + i * 2, (av << 12) | (b << 8) | (g << 4) | r, true); } + } else { + for (let i = 0, n = w * h; i < n; i++) { + if (rgba[i * 4 + 3] !== 255) { + throw new Error("pak sprite: PSM_5650 requires fully opaque pixels"); + } + const r = rgba[i * 4] >> 3; + const g = rgba[i * 4 + 1] >> 2; + const b = rgba[i * 4 + 2] >> 3; + dv.setUint16(16 + i * 2, (b << 11) | (g << 5) | r, true); + } } return out; } diff --git a/hosts/psp/src/ge.rs b/hosts/psp/src/ge.rs index 7edc976c..c41a02b7 100644 --- a/hosts/psp/src/ge.rs +++ b/hosts/psp/src/ge.rs @@ -363,6 +363,7 @@ unsafe fn apply_texture(view: &TexView) { } sys::sceGuTexMode(TexturePixelFormat::PsmT8, 0, 0, 0); } + spec::psm::PSM_5650 => sys::sceGuTexMode(TexturePixelFormat::Psm5650, 0, 0, 0), spec::psm::PSM_4444 => sys::sceGuTexMode(TexturePixelFormat::Psm4444, 0, 0, 0), _ => sys::sceGuTexMode(TexturePixelFormat::Psm8888, 0, 0, 0), } diff --git a/hosts/vita/src/graphics.rs b/hosts/vita/src/graphics.rs index 9ef26732..02e26f71 100644 --- a/hosts/vita/src/graphics.rs +++ b/hosts/vita/src/graphics.rs @@ -169,6 +169,21 @@ fn texture_rgba(view: pocketjs_core::TexView<'_>) -> Option> { let pixels = (view.w as usize).checked_mul(view.h as usize)?; let mut out = vec![0u8; pixels.checked_mul(4)?]; match view.psm { + spec::psm::PSM_5650 => { + if view.pixels.len() < pixels * 2 { + return None; + } + for (i, src) in view.pixels[..pixels * 2].chunks_exact(2).enumerate() { + let px = u16::from_le_bytes([src[0], src[1]]) as u32; + let r = px & 0x1f; + let g = (px >> 5) & 0x3f; + let b = (px >> 11) & 0x1f; + out[i * 4] = ((r << 3) | (r >> 2)) as u8; + out[i * 4 + 1] = ((g << 2) | (g >> 4)) as u8; + out[i * 4 + 2] = ((b << 3) | (b >> 2)) as u8; + out[i * 4 + 3] = 255; + } + } spec::psm::PSM_8888 => { let len = out.len(); if view.pixels.len() < len { diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 27051b1b..3fbb231b 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -174,6 +174,37 @@ beforeEach(() => { // --------------------------------------------------------------------------- +describe("pak image formats", () => { + test("PSM 5650 packs opaque RGBA as little-endian B5:G6:R5", () => { + const image = encodeImageEntry( + { + width: 2, + height: 1, + rgba: new Uint8Array([ + 255, 0, 0, 255, + 0, 0, 255, 255, + ]), + }, + PSM.PSM_5650, + ); + expect(Array.from(image)).toEqual([ + 2, 0, 1, 0, PSM.PSM_5650, 0, 0, 0, + 0x1f, 0x00, 0x00, 0xf8, + ]); + + expect(() => + encodeImageEntry( + { + width: 1, + height: 1, + rgba: new Uint8Array([255, 255, 255, 254]), + }, + PSM.PSM_5650, + ), + ).toThrow(/fully opaque/); + }); +}); + describe("basic mount", () => { test("static view with text child produces create/setText/insert ops", () => { const dispose = render(() => { From b443e0cd391bd85f5e6971ced1a18fd2641f6eea Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 16:42:34 +0800 Subject: [PATCH 2/9] feat(core): add native RGB565 raster target --- engine/core/src/raster.rs | 417 +++++++++++++++++++++++++++++--------- 1 file changed, 321 insertions(+), 96 deletions(-) diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index 83b5c9d1..c0ff0632 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -1,5 +1,5 @@ //! Deterministic software rasterizer: executes the core DrawList (spec.ts -//! "DRAWLIST op format") over an RGBA8 framebuffer. DrawList coordinates +//! "DRAWLIST op format") over an RGBA8, PPA BGRA32, or RGB565 framebuffer. DrawList coordinates //! remain logical; [`render_scaled`] maps them directly onto an integer-scaled //! physical surface without first rasterizing a low-resolution image. //! @@ -20,15 +20,20 @@ //! partial overlap at clip edges — so we keep a scissor stack and pixel-clip //! EVERY op against the current rect (a no-op for the pre-clipped ones). //! -//! Framebuffer layout: row-major, top-left origin, 4 bytes/px R,G,B,A — +//! RGBA framebuffer layout: row-major, top-left origin, 4 bytes/px R,G,B,A — //! which is exactly a little-endian ABGR u32 (0xAABBGGRR), the spec color //! format, so channel bytes map 1:1. The buffer is treated as opaque: the //! destination alpha is always written back as 255. //! -//! [`render_scaled_argb`] emits the same pixels as A,R,G,B bytes instead — -//! the byte order the ESP32-P4 PPA SRM consumes as ARGB8888. That lets the -//! ESP firmware present the framebuffer without a per-frame reorder pass: -//! byte placement is fused into the pixel writes, so it costs nothing extra. +//! [`render_scaled_argb`] emits the same pixels as B,G,R,A bytes instead — +//! the little-endian memory layout used by the ESP32-P4 PPA SRM's ARGB8888 +//! mode. That lets the ESP firmware present the framebuffer without a +//! per-frame reorder pass: byte placement is fused into the pixel writes, so +//! it costs nothing extra. +//! +//! [`render_scaled_rgb565`] writes native little-endian RGB565 words. It is +//! the low-bandwidth opaque surface used by the ESP32-P4 PPA backend and also +//! serves as that backend's ordered software fallback for unsupported ops. use crate::spec::{self, draw_op}; use crate::{TexView, Ui}; @@ -81,53 +86,163 @@ fn channels(color: u32) -> (u32, u32, u32, u32) { ) } -// ---- pixel ops ----------------------------------------------------------------- +#[inline] +fn pixel_bytes(r: u32, g: u32, b: u32) -> [u8; 4] { + if ARGB { + [b as u8, g as u8, r as u8, 255] + } else { + [r as u8, g as u8, b as u8, 255] + } +} -/// src-over blend one pixel (integer, round-to-nearest). Caller guarantees -/// (x, y) inside the framebuffer. Destination treated as opaque. -/// ARGB=false writes bytes [R,G,B,A] (spec layout, golden-pinned); -/// ARGB=true writes [A,R,G,B] for the ESP32-P4 PPA. Only byte placement -/// differs — the blend math and the opaque destination are identical. +/// Fill an opaque, whole-pixel byte span. ESP framebuffers are at least +/// 4-byte aligned, so the hot path emits one native word per pixel instead of +/// routing every pixel through alpha blending and four bounds-checked stores. +/// The byte fallback keeps the generic host/test API valid for odd slices. #[inline] -fn blend_px( - fb: &mut [u8], - stride: i32, - x: i32, - y: i32, - r: u32, - g: u32, - b: u32, - a: u32, -) { - let o = ((y * stride + x) * 4) as usize; - let (ri, gi, bi, ai) = if ARGB { (1, 2, 3, 0) } else { (0, 1, 2, 3) }; - if a >= 255 { - fb[o + ri] = r as u8; - fb[o + gi] = g as u8; - fb[o + bi] = b as u8; - fb[o + ai] = 255; - return; +fn fill_opaque_span(span: &mut [u8], r: u32, g: u32, b: u32) { + debug_assert_eq!(span.len() & 3, 0); + let bytes = pixel_bytes::(r, g, b); + if (span.as_ptr() as usize) & 3 == 0 { + let pixel = u32::from_ne_bytes(bytes); + // SAFETY: alignment is checked above, the span length is a multiple + // of four, and the temporary word slice covers exactly this span. + let words = unsafe { + core::slice::from_raw_parts_mut(span.as_mut_ptr().cast::(), span.len() / 4) + }; + words.fill(pixel); + } else { + for px in span.chunks_exact_mut(4) { + px.copy_from_slice(&bytes); + } } - if a == 0 { - return; +} + +// ---- pixel targets -------------------------------------------------------------- + +/// Opaque framebuffer target. `blend` performs integer src-over compositing; +/// concrete targets differ only in storage format. +trait RenderTarget { + fn pixel_len(&self) -> usize; + fn blend(&mut self, offset: usize, r: u32, g: u32, b: u32, a: u32); + fn fill_opaque(&mut self, start: usize, len: usize, r: u32, g: u32, b: u32); + + #[inline] + fn clear_black(&mut self) { + let len = self.pixel_len(); + self.fill_opaque(0, len, 0, 0, 0); + } +} + +struct RgbaTarget<'a, const ARGB: bool> { + bytes: &'a mut [u8], +} + +impl RenderTarget for RgbaTarget<'_, ARGB> { + #[inline] + fn pixel_len(&self) -> usize { + assert_eq!( + self.bytes.len() & 3, + 0, + "scaled framebuffer byte length must be a multiple of four" + ); + self.bytes.len() / 4 + } + + #[inline] + fn blend(&mut self, offset: usize, r: u32, g: u32, b: u32, a: u32) { + let o = offset * 4; + let (ri, gi, bi, ai) = if ARGB { (2, 1, 0, 3) } else { (0, 1, 2, 3) }; + if a >= 255 { + self.bytes[o + ri] = r as u8; + self.bytes[o + gi] = g as u8; + self.bytes[o + bi] = b as u8; + self.bytes[o + ai] = 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; + self.bytes[o + ri] = mix(r, self.bytes[o + ri]); + self.bytes[o + gi] = mix(g, self.bytes[o + gi]); + self.bytes[o + bi] = mix(b, self.bytes[o + bi]); + self.bytes[o + ai] = 255; + } + + #[inline] + fn fill_opaque(&mut self, start: usize, len: usize, r: u32, g: u32, b: u32) { + let byte_start = start * 4; + fill_opaque_span::(&mut self.bytes[byte_start..byte_start + len * 4], r, g, b); + } +} + +struct Rgb565Target<'a> { + pixels: &'a mut [u16], +} + +#[inline] +pub const fn pack_rgb565(r: u32, g: u32, b: u32) -> u16 { + (((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3)) as u16 +} + +#[inline] +fn unpack_rgb565(pixel: u16) -> (u32, u32, u32) { + let r5 = (pixel as u32 >> 11) & 0x1f; + let g6 = (pixel as u32 >> 5) & 0x3f; + let b5 = pixel as u32 & 0x1f; + ( + (r5 << 3) | (r5 >> 2), + (g6 << 2) | (g6 >> 4), + (b5 << 3) | (b5 >> 2), + ) +} + +impl RenderTarget for Rgb565Target<'_> { + #[inline] + fn pixel_len(&self) -> usize { + self.pixels.len() + } + + #[inline] + fn blend(&mut self, offset: usize, r: u32, g: u32, b: u32, a: u32) { + if a >= 255 { + self.pixels[offset] = pack_rgb565(r, g, b); + return; + } + if a == 0 { + return; + } + let (dr, dg, db) = unpack_rgb565(self.pixels[offset]); + let ia = 255 - a; + let mix = |s: u32, d: u32| (s * a + d * ia + 127) / 255; + self.pixels[offset] = pack_rgb565(mix(r, dr), mix(g, dg), mix(b, db)); + } + + #[inline] + fn fill_opaque(&mut self, start: usize, len: usize, r: u32, g: u32, b: u32) { + self.pixels[start..start + len].fill(pack_rgb565(r, g, b)); } - let ia = 255 - a; - let mix = |s: u32, d: u8| ((s * a + d as u32 * ia + 127) / 255) as u8; - fb[o + ri] = mix(r, fb[o + ri]); - fb[o + gi] = mix(g, fb[o + gi]); - fb[o + bi] = mix(b, fb[o + bi]); - fb[o + ai] = 255; } /// Fill an already-clipped span rect with one flat color. -fn fill_rect(fb: &mut [u8], stride: i32, c: Clip, color: u32) { +fn fill_rect(target: &mut T, stride: i32, c: Clip, color: u32) { let (r, g, b, a) = channels(color); if a == 0 { return; } + if a >= 255 { + let row_pixels = (c.x1 - c.x0) as usize; + for y in c.y0..c.y1 { + let start = (y * stride + c.x0) as usize; + target.fill_opaque(start, row_pixels, r, g, b); + } + return; + } for y in c.y0..c.y1 { for x in c.x0..c.x1 { - blend_px::(fb, stride, x, y, r, g, b, a); + target.blend((y * stride + x) as usize, r, g, b, a); } } } @@ -162,19 +277,40 @@ pub fn render(ui: &Ui, words: &[u32], fb: &mut [u8]) { /// `ui` supplies font atlases and textures. The framebuffer is cleared to /// opaque black first (the PSP host clears the draw buffer the same way). pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { - render_scaled_impl::(ui, words, fb, scale); + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_impl(ui, words, &mut target, scale, true); } -/// Same as [`render_scaled`] but emits A,R,G,B bytes per pixel — the in-memory +/// Same as [`render_scaled`] but emits B,G,R,A bytes per pixel — the in-memory /// order the ESP32-P4 PPA SRM expects for ARGB8888 input. Byte-identical to /// shuffling the RGBA output, but fused into the rasterizer so the firmware /// skips its per-frame reorder copy. Output determinism matches the RGBA path /// pixel-for-pixel; only the byte placement differs. pub fn render_scaled_argb(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { - render_scaled_impl::(ui, words, fb, scale); + let mut target = RgbaTarget:: { bytes: fb }; + render_scaled_impl(ui, words, &mut target, scale, true); +} + +/// Execute a complete DrawList into a little-endian RGB565 framebuffer. +pub fn render_scaled_rgb565(ui: &Ui, words: &[u32], fb: &mut [u16], scale: u32) { + let mut target = Rgb565Target { pixels: fb }; + render_scaled_impl(ui, words, &mut target, scale, true); } -fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { +/// Execute DrawList words over an existing RGB565 framebuffer without +/// clearing it. Hardware backends use this for ordered fallback segments. +pub fn render_scaled_rgb565_over(ui: &Ui, words: &[u32], fb: &mut [u16], scale: u32) { + let mut target = Rgb565Target { pixels: fb }; + render_scaled_impl(ui, words, &mut target, scale, false); +} + +fn render_scaled_impl( + ui: &Ui, + words: &[u32], + target: &mut T, + scale: u32, + clear: bool, +) { assert!( (1..=MAX_RENDER_SCALE).contains(&scale), "render scale must be 1 through 4" @@ -183,10 +319,13 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s let (viewport_w, viewport_h) = ui.viewport(); let width = viewport_w as i32 * scale; let height = viewport_h as i32 * scale; - assert!(width > 0 && height > 0, "viewport must have positive dimensions"); - let expected = width as usize * height as usize * 4; + assert!( + width > 0 && height > 0, + "viewport must have positive dimensions" + ); + let expected = width as usize * height as usize; assert_eq!( - fb.len(), + target.pixel_len(), expected, "scaled framebuffer has the wrong byte length" ); @@ -196,19 +335,8 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s x1: width, y1: height, }; - // Clear: opaque black (alpha first in ARGB mode). - for px in fb.chunks_exact_mut(4) { - if ARGB { - px[0] = 255; - px[1] = 0; - px[2] = 0; - px[3] = 0; - } else { - px[0] = 0; - px[1] = 0; - px[2] = 0; - px[3] = 255; - } + if clear { + target.clear_black(); } let mut stack: [Clip; 32] = [screen; 32]; @@ -231,7 +359,7 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s y1: y + h, }); if c.x0 < c.x1 && c.y0 < c.y1 { - fill_rect::(fb, width, c, words[i + 3]); + fill_rect(target, width, c, words[i + 3]); } i += 4; } @@ -241,8 +369,8 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s } let (x, y) = xy(words[i + 1], scale); let (w, h) = wh(words[i + 2], scale); - grad_rect::( - fb, + grad_rect( + target, width, clip, x, @@ -265,9 +393,9 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s if i + 3 + 2 * n > words.len() { return; } - glyph_run::( + glyph_run( ui, - fb, + target, width, scale, clip, @@ -281,7 +409,7 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s if i + 9 > words.len() { return; } - tex_quad::(ui, fb, width, scale, clip, &words[i + 1..i + 9]); + tex_quad(ui, target, width, scale, clip, &words[i + 1..i + 9]); i += 9; } draw_op::SCISSOR => { @@ -315,14 +443,14 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s if i + 7 > words.len() { return; } - tri::(fb, width, scale, clip, &words[i + 1..i + 7]); + tri(target, width, scale, clip, &words[i + 1..i + 7]); i += 7; } draw_op::TEX_TRI => { if i + 12 > words.len() { return; } - tex_tri::(ui, fb, width, scale, clip, &words[i + 1..i + 12]); + tex_tri(ui, target, width, scale, clip, &words[i + 1..i + 12]); i += 12; } // The op set is closed per DrawList version; anything else means @@ -335,8 +463,8 @@ fn render_scaled_impl(ui: &Ui, words: &[u32], fb: &mut [u8], s // ---- GRAD_RECT: per-axis gouraud lerp --------------------------------------------- #[allow(clippy::too_many_arguments)] -fn grad_rect( - fb: &mut [u8], +fn grad_rect( + target: &mut T, stride: i32, clip: Clip, x: i32, @@ -374,7 +502,7 @@ fn grad_rect( continue; } for py in c.y0..c.y1 { - blend_px::(fb, stride, px, py, r, g, bb, al); + target.blend((py * stride + px) as usize, r, g, bb, al); } } } else { @@ -386,8 +514,13 @@ fn grad_rect( if al == 0 { continue; } + if al >= 255 { + let start = (py * stride + c.x0) as usize; + target.fill_opaque(start, (c.x1 - c.x0) as usize, r, g, bb); + continue; + } for px in c.x0..c.x1 { - blend_px::(fb, stride, px, py, r, g, bb, al); + target.blend((py * stride + px) as usize, r, g, bb, al); } } } @@ -402,7 +535,7 @@ fn orient(ax: i64, ay: i64, bx: i64, by: i64, px: i64, py: i64) -> i64 { (bx - ax) * (py - ay) - (by - ay) * (px - ax) } -fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: &[u32]) { +fn tri(target: &mut T, stride: i32, scale: i32, clip: Clip, p: &[u32]) { let (x0, y0) = xy(p[0], scale); let (x1, y1) = xy(p[1], scale); let (x2, y2) = xy(p[2], scale); @@ -431,6 +564,7 @@ fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: let (r1, g1, b1, a1) = channels(c1); let (r2, g2, b2, a2) = channels(c2); let flat = c0 == c1 && c1 == c2; + let flat_opaque = flat && a0 >= 255; let half = area / 2; for py in min_y..max_y { let sy = 2 * py as i64 + 1; @@ -443,18 +577,17 @@ fn tri(fb: &mut [u8], stride: i32, scale: i32, clip: Clip, p: if w0 < 0 || w1 < 0 || w2 < 0 { continue; } - if flat { - blend_px::(fb, stride, px, py, r0, g0, b0, a0); + if flat_opaque { + target.fill_opaque((py * stride + px) as usize, 1, r0, g0, b0); + } else if flat { + target.blend((py * stride + px) as usize, r0, g0, b0, a0); } else { // Integer barycentric interpolation, round-to-nearest. let mix = |v0: u32, v1: u32, v2: u32| { ((v0 as i64 * w0 + v1 as i64 * w1 + v2 as i64 * w2 + half) / area) as u32 }; - blend_px::( - fb, - stride, - px, - py, + target.blend( + (py * stride + px) as usize, mix(r0, r1, r2), mix(g0, g1, g2), mix(b0, b1, b2), @@ -477,9 +610,9 @@ fn coverage_index(destination_px: i32, output_scale: i32, atlas_density: i32, li } #[allow(clippy::too_many_arguments)] -fn glyph_run( +fn glyph_run( ui: &Ui, - fb: &mut [u8], + target: &mut T, stride: i32, output_scale: i32, clip: Clip, @@ -522,7 +655,7 @@ fn glyph_run( let cx = coverage_index(px - gx, output_scale, atlas_density, coverage_w); let cov = row[cx] as u32; if cov != 0 { - blend_px::(fb, stride, px, py, r, g, b, (a * cov + 127) / 255); + target.blend((py * stride + px) as usize, r, g, b, (a * cov + 127) / 255); } } } @@ -537,6 +670,21 @@ fn glyph_run( #[inline] fn texel(view: &TexView, idx: usize) -> Option<(u32, u32, u32, u32)> { match view.psm { + spec::psm::PSM_5650 => { + // PSP PSM 5650: u16 LE, B5:G6:R5. It is opaque. ESP32-P4 PPA + // consumes the same words with its RGB-swap input bit enabled. + let o = idx * 2; + let px16 = view.pixels[o] as u32 | ((view.pixels[o + 1] as u32) << 8); + let r5 = px16 & 0x1f; + let g6 = (px16 >> 5) & 0x3f; + let b5 = (px16 >> 11) & 0x1f; + Some(( + (r5 << 3) | (r5 >> 2), + (g6 << 2) | (g6 >> 4), + (b5 << 3) | (b5 >> 2), + 255, + )) + } spec::psm::PSM_8888 => { let o = idx * 4; Some(( @@ -607,9 +755,9 @@ fn sample_linear(view: &TexView, u: f32, v: f32) -> Option<(u32, u32, u32, u32)> // ---- TEX_TRI: barycentric textured triangle (affine UV; nearest, or integer // bilinear when the texture carries the linear flag) -------------------- -fn tex_tri( +fn tex_tri( ui: &Ui, - fb: &mut [u8], + target: &mut T, stride: i32, scale: i32, clip: Clip, @@ -684,7 +832,7 @@ fn tex_tri( b = (b * mb + 127) / 255; a = (a * ma + 127) / 255; } - blend_px::(fb, stride, px, py, r, g, b, a); + target.blend((py * stride + px) as usize, r, g, b, a); } } } @@ -692,9 +840,9 @@ fn tex_tri( // ---- TEX_QUAD: textured rect (nearest, or integer bilinear when the texture // carries the linear flag) -------------------------------------------------------- -fn tex_quad( +fn tex_quad( ui: &Ui, - fb: &mut [u8], + target: &mut T, stride: i32, scale: i32, clip: Clip, @@ -752,7 +900,7 @@ fn tex_quad( b = (b * mb + 127) / 255; a = (a * ma + 127) / 255; } - blend_px::(fb, stride, px, py, r, g, b, a); + target.blend((py * stride + px) as usize, r, g, b, a); } } } @@ -770,7 +918,10 @@ mod tests { } fn framebuffer(scale: u32) -> Vec { - vec![0; spec::SCREEN_W as usize * scale as usize * spec::SCREEN_H as usize * scale as usize * 4] + vec![ + 0; + spec::SCREEN_W as usize * scale as usize * spec::SCREEN_H as usize * scale as usize * 4 + ] } fn rgba(fb: &[u8], scale: u32, x: usize, y: usize) -> [u8; 4] { @@ -809,7 +960,7 @@ mod tests { } #[test] - fn argb_output_is_rgba_with_channels_reordered() { + fn argb_output_uses_ppa_bgra_memory_layout() { let ui = Ui::new(); let words = vec![ draw_op::RECT, @@ -817,7 +968,7 @@ mod tests { wh_word(7, 5), 0xff33_2211, // Semi-transparent rect exercises the dst-read blend path, which - // must read A,R,G,B offsets in ARGB mode. + // must read B,G,R,A offsets in PPA ARGB8888 mode. draw_op::RECT, xy_word(4, 5), wh_word(5, 3), @@ -842,13 +993,87 @@ mod tests { render_scaled_argb(&ui, &words, &mut argb_fb, 1); for px in 0..rgba_fb.len() / 4 { let o = px * 4; - assert_eq!(argb_fb[o], rgba_fb[o + 3], "alpha at pixel {px}"); - assert_eq!(argb_fb[o + 1], rgba_fb[o], "red at pixel {px}"); - assert_eq!(argb_fb[o + 2], rgba_fb[o + 1], "green at pixel {px}"); - assert_eq!(argb_fb[o + 3], rgba_fb[o + 2], "blue at pixel {px}"); + assert_eq!(argb_fb[o], rgba_fb[o + 2], "blue at pixel {px}"); + assert_eq!(argb_fb[o + 1], rgba_fb[o + 1], "green at pixel {px}"); + assert_eq!(argb_fb[o + 2], rgba_fb[o], "red at pixel {px}"); + assert_eq!(argb_fb[o + 3], rgba_fb[o + 3], "alpha at pixel {px}"); } } + #[test] + fn rgb565_output_is_native_and_ordered_fallback_preserves_existing_pixels() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 2.0); + let red = 0xff00_00ff; + let green = 0xff00_ff00; + let words = vec![ + draw_op::RECT, + xy_word(0, 0), + wh_word(4, 2), + red, + draw_op::RECT, + xy_word(1, 0), + wh_word(2, 2), + green, + ]; + let mut fb = vec![0u16; 8]; + render_scaled_rgb565(&ui, &words, &mut fb, 1); + assert_eq!( + fb, + [ + pack_rgb565(255, 0, 0), + pack_rgb565(0, 255, 0), + pack_rgb565(0, 255, 0), + pack_rgb565(255, 0, 0), + pack_rgb565(255, 0, 0), + pack_rgb565(0, 255, 0), + pack_rgb565(0, 255, 0), + pack_rgb565(255, 0, 0), + ] + ); + + let blue = pack_rgb565(0, 0, 255); + fb.fill(blue); + let overlay = [draw_op::RECT, xy_word(1, 0), wh_word(2, 1), red]; + render_scaled_rgb565_over(&ui, &overlay, &mut fb, 1); + assert_eq!( + fb, + [ + blue, + pack_rgb565(255, 0, 0), + pack_rgb565(255, 0, 0), + blue, + blue, + blue, + blue, + blue, + ] + ); + } + + #[test] + fn psm5650_textures_decode_into_native_rgb565() { + let mut ui = Ui::new(); + ui.set_viewport(2.0, 1.0); + // PSP PSM 5650 is B5:G6:R5: 0x001f is red and 0xf800 is blue. + let handle = ui.upload_texture(&[0x1f, 0x00, 0x00, 0xf8], 2, 1, spec::psm::PSM_5650); + assert!(handle >= 0); + let words = [ + draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(2, 1), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut fb = vec![0u16; 2]; + render_scaled_rgb565(&ui, &words, &mut fb, 1); + assert_eq!(fb, [pack_rgb565(255, 0, 0), pack_rgb565(0, 0, 255)]); + } + #[test] fn custom_viewport_controls_framebuffer_dimensions() { let mut ui = Ui::new(); From be66a66f281034182cabb6ffa8b3727c2e8b2c52 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 16:42:39 +0800 Subject: [PATCH 3/9] feat(esp32p4): add hybrid PPA DrawList backend --- engine/Cargo.toml | 6 +- engine/backends/esp32p4-ppa/Cargo.lock | 121 +++ engine/backends/esp32p4-ppa/Cargo.toml | 18 + engine/backends/esp32p4-ppa/README.md | 23 + engine/backends/esp32p4-ppa/src/lib.rs | 1186 ++++++++++++++++++++++++ package.json | 4 + 6 files changed, 1356 insertions(+), 2 deletions(-) create mode 100644 engine/backends/esp32p4-ppa/Cargo.lock create mode 100644 engine/backends/esp32p4-ppa/Cargo.toml create mode 100644 engine/backends/esp32p4-ppa/README.md create mode 100644 engine/backends/esp32p4-ppa/src/lib.rs diff --git a/engine/Cargo.toml b/engine/Cargo.toml index beb9a88b..f09eaf60 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -2,8 +2,9 @@ # (docs/RUNTIMES.md: these are the Cores plus their desktop surfaces). # # Deliberately EXCLUDED and standalone (each with its own Cargo.lock): -# core/, wasm/ — cargo-psp needs hosts/psp to consume core as a -# lone standalone crate (see core/Cargo.toml) +# core/, wasm/, backends/esp32p4-ppa/ +# — cargo-psp and ESP-IDF need to consume these as +# lone standalone crates (see their Cargo.toml) # pocket3d/crates/pocket3d-gu, gu-demo — PSP-only (cargo-psp toolchain) # pocket3d/crates/pocket3d-vita — Vita-only (vitasdk toolchain) [workspace] @@ -21,6 +22,7 @@ members = [ "pocket3d/examples/uihost", ] exclude = [ + "backends/esp32p4-ppa", "core", "wasm", "pocket3d/crates/pocket3d-gu", diff --git a/engine/backends/esp32p4-ppa/Cargo.lock b/engine/backends/esp32p4-ppa/Cargo.lock new file mode 100644 index 00000000..29bc5325 --- /dev/null +++ b/engine/backends/esp32p4-ppa/Cargo.lock @@ -0,0 +1,121 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "pocketjs-core" +version = "0.1.0" +dependencies = [ + "taffy", +] + +[[package]] +name = "pocketjs-esp32p4-ppa" +version = "0.1.0" +dependencies = [ + "pocketjs-core", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "taffy" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfde4e2f8595f222ceaae1fb16b4963952e9b33e358869dc4cd6316b0e0790cd" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/engine/backends/esp32p4-ppa/Cargo.toml b/engine/backends/esp32p4-ppa/Cargo.toml new file mode 100644 index 00000000..985375bd --- /dev/null +++ b/engine/backends/esp32p4-ppa/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pocketjs-esp32p4-ppa" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[dependencies] +pocketjs-core = { path = "../../core" } + +[dev-dependencies] +pocketjs-core = { path = "../../core", features = ["std"] } + +[features] +default = [] +std = ["pocketjs-core/std"] + +[workspace] diff --git a/engine/backends/esp32p4-ppa/README.md b/engine/backends/esp32p4-ppa/README.md new file mode 100644 index 00000000..70efd521 --- /dev/null +++ b/engine/backends/esp32p4-ppa/README.md @@ -0,0 +1,23 @@ +# PocketJS ESP32-P4 PPA backend + +This `no_std` crate interprets PocketJS DrawLists directly into an RGB565 +surface. It accelerates operations that map exactly to the ESP32-P4 Pixel +Processing Accelerator and preserves DrawList order with an RGB565 software +fallback for everything else. + +The crate deliberately does not depend on ESP-IDF or a board support package. +An ESP-IDF host implements `PpaOps` with `ppa_do_fill`, `ppa_do_blend`, and +`ppa_do_scale_rotate_mirror`. Board-specific display presentation remains in +the BSP. + +Accelerated paths: + +- opaque and translucent flat rectangles (`FILL` or A8 `BLEND`); +- antialiased font runs (`A8` coverage composed once, then `BLEND`); +- single-color alpha textures such as PocketJS rounded-corner masks (`A8` + `BLEND`); +- opaque PSM 5650 texture quads (`SRM`) when scaling semantics are compatible. + +Gradients, arbitrary triangles, textured triangles, and unsupported texture +formats fall back to `pocketjs_core::raster::render_scaled_rgb565_over`. +No full-frame RGB888 or ARGB8888 surface is allocated. diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs new file mode 100644 index 00000000..a2daa2ac --- /dev/null +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -0,0 +1,1186 @@ +//! Hybrid PocketJS DrawList backend for the ESP32-P4 PPA. +//! +//! The render target is always opaque RGB565. Hardware-friendly operations +//! are submitted through [`PpaOps`]; unsupported operations are executed in +//! order by the core's RGB565 software rasterizer. A single aligned A8 scratch +//! plane batches glyphs and alpha-only quads, so antialiasing never requires +//! a 32-bit color framebuffer. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use alloc::vec::Vec; + +use pocketjs_core::raster::{pack_rgb565, render_scaled_rgb565_over}; +use pocketjs_core::{spec, TexView, Ui}; + +const MASK_ALIGNMENT: usize = 128; +const CLIP_DEPTH: usize = 32; +const TEXTURE_CLASS_CACHE_LEN: usize = 64; + +/// Integer pixel rectangle, using half-open bounds. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Rect { + pub x: u32, + pub y: u32, + pub w: u32, + pub h: u32, +} + +impl Rect { + #[inline] + pub const fn area(self) -> u32 { + self.w.saturating_mul(self.h) + } + + #[inline] + const fn is_empty(self) -> bool { + self.w == 0 || self.h == 0 + } +} + +/// Quarter-turn transform supported by the PPA SRM engine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum QuarterTurn { + #[default] + None, + Ccw90, + Ccw180, + Ccw270, +} + +/// Scale/rotate/mirror controls passed to the host's PPA SRM implementation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SrmTransform { + pub rotation: QuarterTurn, + pub mirror_x: bool, + pub mirror_y: bool, +} + +/// Narrow hardware surface implemented by an ESP-IDF host. +/// +/// All calls are synchronous from the DrawList interpreter's perspective: +/// once a method returns `true`, the destination pixels must be visible to +/// subsequent CPU or PPA operations. Returning `false` requests the ordered +/// software fallback. +pub trait PpaOps { + /// Fill `rect` in the full RGB565 destination. + fn fill_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + rect: Rect, + color: u16, + ) -> bool; + + /// Blend an A8 plane over the full RGB565 destination with one fixed RGB + /// color. `global_alpha` scales every coverage byte. + fn blend_a8_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + mask: &[u8], + rect: Rect, + color: [u8; 3], + global_alpha: u8, + ) -> bool; + + /// Copy an opaque PSP PSM 5650 texture into the RGB565 destination with + /// PPA SRM. PSM 5650 stores R and B opposite to ESP RGB565, so the host + /// must enable the PPA input RGB swap. + fn srm_psm5650_to_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_rect: Rect, + destination_rect: Rect, + transform: SrmTransform, + ) -> bool; +} + +/// Heuristics controlling when transaction overhead is worth paying. +#[derive(Clone, Copy, Debug)] +pub struct RendererConfig { + pub scale: u32, + pub min_fill_pixels: u32, + pub min_blend_pixels: u32, + pub min_srm_pixels: u32, +} + +impl Default for RendererConfig { + fn default() -> Self { + Self { + scale: 1, + min_fill_pixels: 1024, + min_blend_pixels: 256, + min_srm_pixels: 256, + } + } +} + +/// Per-frame backend accounting for profiling and regression logs. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RenderStats { + pub ppa_fills: u32, + pub ppa_blends: u32, + pub ppa_srm: u32, + pub software_ops: u32, + pub software_words: u32, +} + +/// Persistent DrawList renderer. The A8 plane and fallback word buffer are +/// reused across frames. +pub struct Renderer { + config: RendererConfig, + mask_storage: Vec, + mask_offset: usize, + mask_len: usize, + fallback_words: Vec, + alpha_texture_cache: Vec<(i32, bool)>, +} + +impl Renderer { + pub fn new(config: RendererConfig) -> Option { + if !(1..=pocketjs_core::raster::MAX_RENDER_SCALE).contains(&config.scale) { + return None; + } + Some(Self { + config, + mask_storage: Vec::new(), + mask_offset: 0, + mask_len: 0, + fallback_words: Vec::with_capacity(16), + alpha_texture_cache: Vec::new(), + }) + } + + pub fn config(&self) -> RendererConfig { + self.config + } + + /// Render a complete DrawList. `destination` dimensions must equal the + /// UI viewport multiplied by `config.scale`. + pub fn render( + &mut self, + ui: &Ui, + words: &[u32], + destination: &mut [u16], + width: u32, + height: u32, + ppa: &mut O, + ) -> Option { + let scale = self.config.scale; + let (viewport_w, viewport_h) = ui.viewport(); + if viewport_w as u32 * scale != width + || viewport_h as u32 * scale != height + || destination.len() != width as usize * height as usize + { + return None; + } + self.ensure_mask(destination.len()); + let screen = Clip { + x0: 0, + y0: 0, + x1: viewport_w as i32, + y1: viewport_h as i32, + }; + let mut stats = RenderStats::default(); + + let full = Rect { + x: 0, + y: 0, + w: width, + h: height, + }; + if ppa.fill_rgb565(destination, width, height, full, 0) { + stats.ppa_fills += 1; + } else { + destination.fill(0); + } + + let mut stack = [screen; CLIP_DEPTH]; + let mut depth = 0usize; + let mut clip = screen; + let mut i = 0usize; + while i < words.len() { + match words[i] { + spec::draw_op::RECT if i + 4 <= words.len() => { + let rect = logical_rect(words[i + 1], words[i + 2]).intersect(clip); + let op = &words[i..i + 4]; + if !rect.is_empty() + && self.try_rect( + destination, + width, + height, + rect, + words[i + 3], + ppa, + &mut stats, + ) + { + i += 4; + } else { + self.software_op(ui, destination, clip, op, &mut stats); + i += 4; + } + } + spec::draw_op::GRAD_RECT if i + 6 <= words.len() => { + self.software_op(ui, destination, clip, &words[i..i + 6], &mut stats); + i += 6; + } + spec::draw_op::GLYPH_RUN if i + 3 <= words.len() => { + let count = (words[i + 1] >> 16) as usize; + let next = i.checked_add(3 + count * 2)?; + if next > words.len() { + return None; + } + if !self.try_glyph_run( + ui, + destination, + width, + height, + clip, + &words[i..next], + ppa, + &mut stats, + ) { + self.software_op(ui, destination, clip, &words[i..next], &mut stats); + } + i = next; + } + spec::draw_op::TEX_QUAD if i + 9 <= words.len() => { + let next = self.try_tex_quad_run( + ui, + words, + i, + destination, + width, + height, + clip, + ppa, + &mut stats, + ); + if let Some(next) = next { + i = next; + } else { + self.software_op(ui, destination, clip, &words[i..i + 9], &mut stats); + i += 9; + } + } + spec::draw_op::SCISSOR if i + 3 <= words.len() => { + if depth >= stack.len() { + return None; + } + stack[depth] = clip; + depth += 1; + clip = screen.intersect(logical_rect(words[i + 1], words[i + 2])); + i += 3; + } + spec::draw_op::SCISSOR_POP => { + if depth > 0 { + depth -= 1; + clip = stack[depth]; + } else { + clip = screen; + } + i += 1; + } + spec::draw_op::TRI if i + 7 <= words.len() => { + self.software_op(ui, destination, clip, &words[i..i + 7], &mut stats); + i += 7; + } + spec::draw_op::TEX_TRI if i + 12 <= words.len() => { + self.software_op(ui, destination, clip, &words[i..i + 12], &mut stats); + i += 12; + } + _ => return None, + } + } + Some(stats) + } + + fn try_rect( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + logical: Clip, + color: u32, + ppa: &mut O, + stats: &mut RenderStats, + ) -> bool { + let (r, g, b, a) = channels(color); + if a == 0 { + return true; + } + let rect = physical_rect(logical, self.config.scale); + if a == 255 && rect.area() >= self.config.min_fill_pixels { + if ppa.fill_rgb565(destination, width, height, rect, pack_rgb565(r, g, b)) { + stats.ppa_fills += 1; + return true; + } + } else if rect.area() >= self.config.min_blend_pixels { + let mask = self.mask_mut(); + fill_mask_rect(mask, width, rect, a as u8); + if ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) { + stats.ppa_blends += 1; + return true; + } + } + false + } + + #[allow(clippy::too_many_arguments)] + fn try_glyph_run( + &mut self, + ui: &Ui, + destination: &mut [u16], + width: u32, + height: u32, + clip: Clip, + op: &[u32], + ppa: &mut O, + stats: &mut RenderStats, + ) -> bool { + let slot = (op[1] & 0xff) as u8; + let color = op[2]; + let (r, g, b, a) = channels(color); + if a == 0 { + return true; + } + let Some(atlas) = ui.font_atlas(slot) else { + return true; + }; + let scale = self.config.scale as i32; + let cell_w = atlas.cell_w as i32; + let cell_h = atlas.cell_h as i32; + let mut bounds = Clip::empty(); + for glyph in op[3..].chunks_exact(2) { + let (x, y) = xy(glyph[0]); + let gid = (glyph[1] & 0xffff) as u16; + if gid < atlas.glyph_count { + bounds = bounds.union(Clip { + x0: x, + y0: y, + x1: x + cell_w, + y1: y + cell_h, + }); + } + } + bounds = bounds.intersect(clip); + let rect = physical_rect(bounds, self.config.scale); + if rect.is_empty() || rect.area() < self.config.min_blend_pixels { + return false; + } + let mask = self.mask_mut(); + fill_mask_rect(mask, width, rect, 0); + let density = atlas.raster_density as i32; + let coverage_w = atlas.coverage_width() as i32; + let coverage_h = atlas.coverage_height() as i32; + let bpr = atlas.bytes_per_row(); + for glyph in op[3..].chunks_exact(2) { + let (gx, gy) = xy(glyph[0]); + let gid = (glyph[1] & 0xffff) as u16; + if gid >= atlas.glyph_count { + continue; + } + let rows = atlas.glyph_rows(gid); + let x0 = (gx * scale).max(rect.x as i32); + let y0 = (gy * scale).max(rect.y as i32); + let x1 = ((gx + cell_w) * scale).min((rect.x + rect.w) as i32); + let y1 = ((gy + cell_h) * scale).min((rect.y + rect.h) as i32); + for py in y0..y1 { + let sy = coverage_index(py - gy * scale, scale, density, coverage_h); + let row = &rows[sy * bpr..]; + for px in x0..x1 { + let sx = coverage_index(px - gx * scale, scale, density, coverage_w); + composite_mask( + &mut mask[py as usize * width as usize + px as usize], + ((row[sx] as u32 * a + 127) / 255) as u8, + ); + } + } + } + if ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) { + stats.ppa_blends += 1; + true + } else { + false + } + } + + #[allow(clippy::too_many_arguments)] + fn try_tex_quad_run( + &mut self, + ui: &Ui, + words: &[u32], + start: usize, + destination: &mut [u16], + width: u32, + height: u32, + clip: Clip, + ppa: &mut O, + stats: &mut RenderStats, + ) -> Option { + let op = &words[start..start + 9]; + let handle = op[1] as i32; + let view = ui.texture(handle)?; + + if view.psm == spec::psm::PSM_5650 { + let logical = logical_rect(op[2], op[3]).intersect(clip); + let destination_rect = physical_rect(logical, self.config.scale); + if destination_rect.area() >= self.config.min_srm_pixels { + let (source_rect, mirror_x, mirror_y) = texture_source_rect(&view, op, logical)?; + let one_to_one = + source_rect.w == destination_rect.w && source_rect.h == destination_rect.h; + if (one_to_one || view.linear) && op[8] == 0xffff_ffff { + let transform = SrmTransform { + mirror_x, + mirror_y, + ..SrmTransform::default() + }; + if ppa.srm_psm5650_to_rgb565( + destination, + width, + height, + view.pixels, + view.w, + view.h, + source_rect, + destination_rect, + transform, + ) { + stats.ppa_srm += 1; + return Some(start + 9); + } + } + } + return None; + } + + if !self.is_white_alpha_texture(handle, &view) { + return None; + } + let modulate = op[8]; + let mut end = start; + let mut bounds = Clip::empty(); + while end + 9 <= words.len() + && words[end] == spec::draw_op::TEX_QUAD + && words[end + 1] == handle as u32 + && words[end + 8] == modulate + { + bounds = bounds.union(logical_rect(words[end + 2], words[end + 3]).intersect(clip)); + end += 9; + } + let (r, g, b, a) = channels(modulate); + if a == 0 { + return Some(end); + } + let rect = physical_rect(bounds, self.config.scale); + if rect.is_empty() || rect.area() < self.config.min_blend_pixels { + return None; + } + let scale = self.config.scale; + let mask = self.mask_mut(); + fill_mask_rect(mask, width, rect, 0); + let mut cursor = start; + while cursor < end { + alpha_quad_into_mask( + &view, + &words[cursor..cursor + 9], + clip, + scale, + mask, + width, + a as u8, + ); + cursor += 9; + } + if ppa.blend_a8_rgb565( + destination, + width, + height, + mask, + rect, + [r as u8, g as u8, b as u8], + 255, + ) { + stats.ppa_blends += 1; + Some(end) + } else { + None + } + } + + fn software_op( + &mut self, + ui: &Ui, + destination: &mut [u16], + clip: Clip, + op: &[u32], + stats: &mut RenderStats, + ) { + if clip.is_empty() { + return; + } + self.fallback_words.clear(); + self.fallback_words.push(spec::draw_op::SCISSOR); + self.fallback_words.push(pack_xy(clip.x0, clip.y0)); + self.fallback_words + .push(pack_wh(clip.x1 - clip.x0, clip.y1 - clip.y0)); + self.fallback_words.extend_from_slice(op); + render_scaled_rgb565_over(ui, &self.fallback_words, destination, self.config.scale); + stats.software_ops += 1; + stats.software_words += op.len() as u32; + } + + fn ensure_mask(&mut self, len: usize) { + if self.mask_len >= len { + return; + } + let bytes = len + MASK_ALIGNMENT - 1; + self.mask_storage = alloc::vec![0u128; bytes.div_ceil(16)]; + let base = self.mask_storage.as_ptr() as usize; + self.mask_offset = (MASK_ALIGNMENT - base % MASK_ALIGNMENT) % MASK_ALIGNMENT; + self.mask_len = len; + } + + fn mask_mut(&mut self) -> &mut [u8] { + unsafe { + core::slice::from_raw_parts_mut( + (self.mask_storage.as_mut_ptr() as *mut u8).add(self.mask_offset), + self.mask_len, + ) + } + } + + fn is_white_alpha_texture(&mut self, handle: i32, view: &TexView<'_>) -> bool { + // T8 video planes can replace their palette and indices in place, so + // their classification is intentionally never cached. + if view.psm == spec::psm::PSM_T8 { + return is_white_alpha_texture(view); + } + if let Some((_, result)) = self + .alpha_texture_cache + .iter() + .find(|(cached, _)| *cached == handle) + { + return *result; + } + let result = is_white_alpha_texture(view); + if self.alpha_texture_cache.len() == TEXTURE_CLASS_CACHE_LEN { + self.alpha_texture_cache.remove(0); + } + self.alpha_texture_cache.push((handle, result)); + result + } +} + +#[derive(Clone, Copy, Debug)] +struct Clip { + x0: i32, + y0: i32, + x1: i32, + y1: i32, +} + +impl Clip { + const fn empty() -> Self { + Self { + x0: i32::MAX, + y0: i32::MAX, + x1: i32::MIN, + y1: i32::MIN, + } + } + + fn intersect(self, other: Self) -> Self { + Self { + x0: self.x0.max(other.x0), + y0: self.y0.max(other.y0), + x1: self.x1.min(other.x1), + y1: self.y1.min(other.y1), + } + } + + fn union(self, other: Self) -> Self { + if self.is_empty() { + return other; + } + if other.is_empty() { + return self; + } + Self { + x0: self.x0.min(other.x0), + y0: self.y0.min(other.y0), + x1: self.x1.max(other.x1), + y1: self.y1.max(other.y1), + } + } + + fn is_empty(self) -> bool { + self.x0 >= self.x1 || self.y0 >= self.y1 + } +} + +#[inline] +fn xy(word: u32) -> (i32, i32) { + ( + (word & 0xffff) as u16 as i16 as i32, + (word >> 16) as u16 as i16 as i32, + ) +} + +#[inline] +fn wh(word: u32) -> (i32, i32) { + ((word & 0xffff) as i32, (word >> 16) as i32) +} + +#[inline] +fn logical_rect(xy_word: u32, wh_word: u32) -> Clip { + let (x, y) = xy(xy_word); + let (w, h) = wh(wh_word); + Clip { + x0: x, + y0: y, + x1: x + w, + y1: y + h, + } +} + +#[inline] +fn physical_rect(clip: Clip, scale: u32) -> Rect { + if clip.is_empty() { + return Rect::default(); + } + Rect { + x: clip.x0 as u32 * scale, + y: clip.y0 as u32 * scale, + w: (clip.x1 - clip.x0) as u32 * scale, + h: (clip.y1 - clip.y0) as u32 * scale, + } +} + +#[inline] +fn channels(color: u32) -> (u32, u32, u32, u32) { + ( + color & 0xff, + (color >> 8) & 0xff, + (color >> 16) & 0xff, + color >> 24, + ) +} + +#[inline] +fn pack_xy(x: i32, y: i32) -> u32 { + x as i16 as u16 as u32 | ((y as i16 as u16 as u32) << 16) +} + +#[inline] +fn pack_wh(w: i32, h: i32) -> u32 { + w as u16 as u32 | ((h as u16 as u32) << 16) +} + +#[inline] +fn fill_mask_rect(mask: &mut [u8], stride: u32, rect: Rect, value: u8) { + for y in rect.y..rect.y + rect.h { + let start = y as usize * stride as usize + rect.x as usize; + mask[start..start + rect.w as usize].fill(value); + } +} + +#[inline] +fn composite_mask(destination: &mut u8, source: u8) { + let d = *destination as u32; + let s = source as u32; + *destination = (s + (d * (255 - s) + 127) / 255) as u8; +} + +#[inline] +fn coverage_index(destination_px: i32, output_scale: i32, atlas_density: i32, limit: i32) -> usize { + ((((2 * destination_px + 1) * atlas_density) / (2 * output_scale)).clamp(0, limit - 1)) as usize +} + +fn texture_source_rect( + view: &TexView<'_>, + op: &[u32], + clipped_destination: Clip, +) -> Option<(Rect, bool, bool)> { + let destination = logical_rect(op[2], op[3]); + if destination.is_empty() || clipped_destination.is_empty() { + return None; + } + let u0 = f32::from_bits(op[4]); + let v0 = f32::from_bits(op[5]); + let u1 = f32::from_bits(op[6]); + let v1 = f32::from_bits(op[7]); + if !u0.is_finite() || !v0.is_finite() || !u1.is_finite() || !v1.is_finite() { + return None; + } + let destination_w = (destination.x1 - destination.x0) as f32; + let destination_h = (destination.y1 - destination.y0) as f32; + let map_u = |x: i32| u0 + (u1 - u0) * (x - destination.x0) as f32 / destination_w; + let map_v = |y: i32| v0 + (v1 - v0) * (y - destination.y0) as f32 / destination_h; + let source_u0 = exact_texel_edge(map_u(clipped_destination.x0), view.w)?; + let source_v0 = exact_texel_edge(map_v(clipped_destination.y0), view.h)?; + let source_u1 = exact_texel_edge(map_u(clipped_destination.x1), view.w)?; + let source_v1 = exact_texel_edge(map_v(clipped_destination.y1), view.h)?; + let x0 = source_u0.min(source_u1); + let y0 = source_v0.min(source_v1); + let x1 = source_u0.max(source_u1); + let y1 = source_v0.max(source_v1); + (x1 > x0 && y1 > y0).then_some(( + Rect { + x: x0, + y: y0, + w: x1 - x0, + h: y1 - y0, + }, + source_u0 > source_u1, + source_v0 > source_v1, + )) +} + +/// PPA source block offsets are integral texels. Refuse fractional UV edges +/// instead of expanding them and changing the sampling transform. The small +/// tolerance only absorbs normal f32 interpolation error for exact atlas +/// boundaries. +#[inline] +fn exact_texel_edge(uv: f32, extent: u32) -> Option { + let value = uv * extent as f32; + if !value.is_finite() || value < 0.0 || value > extent as f32 { + return None; + } + let rounded = (value + 0.5) as u32; + let rounded_value = rounded as f32; + let difference = if value >= rounded_value { + value - rounded_value + } else { + rounded_value - value + }; + (rounded <= extent && difference <= 0.0001).then_some(rounded) +} + +fn is_white_alpha_texture(view: &TexView<'_>) -> bool { + let count = view.w as usize * view.h as usize; + match view.psm { + spec::psm::PSM_8888 => view.pixels[..count * 4] + .chunks_exact(4) + .all(|p| p[3] == 0 || (p[0] == 255 && p[1] == 255 && p[2] == 255)), + spec::psm::PSM_4444 => view.pixels[..count * 2].chunks_exact(2).all(|p| { + let pixel = u16::from_le_bytes([p[0], p[1]]); + pixel >> 12 == 0 || pixel & 0x0fff == 0x0fff + }), + spec::psm::PSM_T8 => { + let Some(palette) = view.palette else { + return false; + }; + view.pixels[..count].iter().all(|&index| { + let p = index as usize * 4; + palette[p + 3] == 0 + || (palette[p] == 255 && palette[p + 1] == 255 && palette[p + 2] == 255) + }) + } + _ => false, + } +} + +#[inline] +fn texel_alpha(view: &TexView<'_>, x: i32, y: i32) -> u32 { + let x = x.clamp(0, view.w as i32 - 1) as usize; + let y = y.clamp(0, view.h as i32 - 1) as usize; + let index = y * view.w as usize + x; + match view.psm { + spec::psm::PSM_8888 => view.pixels[index * 4 + 3] as u32, + spec::psm::PSM_4444 => { + let o = index * 2; + ((u16::from_le_bytes([view.pixels[o], view.pixels[o + 1]]) >> 12) as u32) * 17 + } + spec::psm::PSM_T8 => { + let palette = view.palette.unwrap(); + palette[view.pixels[index] as usize * 4 + 3] as u32 + } + _ => 0, + } +} + +fn sample_alpha(view: &TexView<'_>, u: f32, v: f32) -> u8 { + if !view.linear { + return texel_alpha(view, (u * view.w as f32) as i32, (v * view.h as f32) as i32) as u8; + } + let uf = (u * view.w as f32 * 256.0) as i32 - 128; + let vf = (v * view.h as f32 * 256.0) as i32 - 128; + let x0 = uf >> 8; + let y0 = vf >> 8; + let fx = (uf & 255) as u32; + let fy = (vf & 255) as u32; + let lerp = |a: u32, b: u32, f: u32| (a * (256 - f) + b * f) >> 8; + let top = lerp(texel_alpha(view, x0, y0), texel_alpha(view, x0 + 1, y0), fx); + let bottom = lerp( + texel_alpha(view, x0, y0 + 1), + texel_alpha(view, x0 + 1, y0 + 1), + fx, + ); + lerp(top, bottom, fy) as u8 +} + +#[allow(clippy::too_many_arguments)] +fn alpha_quad_into_mask( + view: &TexView<'_>, + op: &[u32], + clip: Clip, + scale: u32, + mask: &mut [u8], + stride: u32, + global_alpha: u8, +) { + let logical = logical_rect(op[2], op[3]).intersect(clip); + if logical.is_empty() { + return; + } + let (x, y) = xy(op[2]); + let (w, h) = wh(op[3]); + let scale_i = scale as i32; + let physical = physical_rect(logical, scale); + let u0 = f32::from_bits(op[4]); + let v0 = f32::from_bits(op[5]); + let u1 = f32::from_bits(op[6]); + let v1 = f32::from_bits(op[7]); + for py in physical.y..physical.y + physical.h { + let v = v0 + (v1 - v0) * ((py as i32 - y * scale_i) as f32 + 0.5) / (h * scale_i) as f32; + for px in physical.x..physical.x + physical.w { + let u = + u0 + (u1 - u0) * ((px as i32 - x * scale_i) as f32 + 0.5) / (w * scale_i) as f32; + let alpha = (sample_alpha(view, u, v) as u32 * global_alpha as u32 + 127) / 255; + composite_mask( + &mut mask[py as usize * stride as usize + px as usize], + alpha as u8, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[derive(Default)] + struct MockPpa { + fills: u32, + blends: u32, + srm: u32, + last_mask_max: u8, + last_global_alpha: u8, + last_source_rect: Rect, + last_destination_rect: Rect, + last_transform: SrmTransform, + } + + impl PpaOps for MockPpa { + fn fill_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + _height: u32, + rect: Rect, + color: u16, + ) -> bool { + self.fills += 1; + for y in rect.y..rect.y + rect.h { + let start = y as usize * width as usize + rect.x as usize; + destination[start..start + rect.w as usize].fill(color); + } + true + } + + fn blend_a8_rgb565( + &mut self, + _destination: &mut [u16], + _width: u32, + _height: u32, + mask: &[u8], + _rect: Rect, + _color: [u8; 3], + global_alpha: u8, + ) -> bool { + self.blends += 1; + self.last_mask_max = mask.iter().copied().max().unwrap_or(0); + self.last_global_alpha = global_alpha; + true + } + + fn srm_psm5650_to_rgb565( + &mut self, + _destination: &mut [u16], + _width: u32, + _height: u32, + _source: &[u8], + _source_width: u32, + _source_height: u32, + source_rect: Rect, + destination_rect: Rect, + transform: SrmTransform, + ) -> bool { + self.srm += 1; + self.last_source_rect = source_rect; + self.last_destination_rect = destination_rect; + self.last_transform = transform; + true + } + } + + fn xy_word(x: i16, y: i16) -> u32 { + x as u16 as u32 | ((y as u16 as u32) << 16) + } + + fn wh_word(w: u16, h: u16) -> u32 { + w as u32 | ((h as u32) << 16) + } + + fn renderer() -> Renderer { + Renderer::new(RendererConfig { + scale: 1, + min_fill_pixels: 1, + min_blend_pixels: 1, + min_srm_pixels: 1, + }) + .unwrap() + } + + #[test] + fn accelerates_fills_and_keeps_gradient_fallback_in_order() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let words = vec![ + spec::draw_op::RECT, + xy_word(0, 0), + wh_word(8, 8), + 0xff00_00ff, + spec::draw_op::GRAD_RECT, + xy_word(2, 2), + wh_word(4, 4), + 0xff00_0000, + 0xffff_ffff, + spec::GradDir::ToRight as u32, + ]; + let mut output = vec![0u16; 64]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_fills, 2, "clear plus the red rectangle"); + assert_eq!(stats.software_ops, 1); + assert_eq!(ppa.fills, 2); + assert_eq!(output[0], pack_rgb565(255, 0, 0)); + assert_ne!(output[2 * 8 + 2], output[2 * 8 + 5]); + } + + #[test] + fn batches_white_alpha_quads_into_one_a8_blend() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let mut texture = vec![0u8; 4 * 4 * 4]; + for (i, pixel) in texture.chunks_exact_mut(4).enumerate() { + pixel.copy_from_slice(&[255, 255, 255, (i * 17) as u8]); + } + let handle = ui.upload_texture(&texture, 4, 4, spec::psm::PSM_8888); + let quad = |x: i16| { + [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(x, 0), + wh_word(4, 4), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xff00_ffff, + ] + }; + let words = [quad(0), quad(4)].concat(); + let mut output = vec![0u16; 64]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_blends, 1); + assert_eq!(stats.software_ops, 0); + assert_eq!(ppa.blends, 1); + } + + #[test] + fn folds_global_alpha_into_a8_before_batching_overlaps() { + let mut ui = Ui::new(); + ui.set_viewport(2.0, 2.0); + let handle = ui.upload_texture(&[255, 255, 255, 255], 1, 1, spec::psm::PSM_8888); + let quad = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(2, 2), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0x80ff_ffff, + ]; + let words = [quad, quad].concat(); + let mut output = vec![0u16; 4]; + let mut ppa = MockPpa::default(); + renderer() + .render(&ui, &words, &mut output, 2, 2, &mut ppa) + .unwrap(); + + assert_eq!(ppa.blends, 1); + assert_eq!(ppa.last_global_alpha, 255); + assert_eq!(ppa.last_mask_max, 192); + } + + #[test] + fn routes_opaque_psm5650_texture_to_srm() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let pixels = vec![0x1f, 0x00].repeat(64); + let handle = ui.upload_texture(&pixels, 8, 8, spec::psm::PSM_5650); + assert!(handle >= 0); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(8, 8), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut output = vec![0u16; 64]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_srm, 1); + assert_eq!(stats.software_ops, 0); + assert_eq!(ppa.srm, 1); + } + + #[test] + fn clips_psm5650_source_and_destination_without_rescaling() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let pixels = vec![0x1f, 0x00].repeat(64); + let handle = ui.upload_texture(&pixels, 8, 8, spec::psm::PSM_5650); + let words = [ + spec::draw_op::SCISSOR, + xy_word(2, 1), + wh_word(4, 6), + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(8, 8), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + spec::draw_op::SCISSOR_POP, + ]; + let mut output = vec![0u16; 64]; + let mut ppa = MockPpa::default(); + renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!( + ppa.last_source_rect, + Rect { + x: 2, + y: 1, + w: 4, + h: 6, + } + ); + assert_eq!(ppa.last_destination_rect, ppa.last_source_rect); + assert_eq!(ppa.last_transform, SrmTransform::default()); + } + + #[test] + fn fractional_psm5650_uv_edges_use_ordered_software_fallback() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let pixels = vec![0x1f, 0x00].repeat(64); + let handle = ui.upload_texture(&pixels, 8, 8, spec::psm::PSM_5650); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(8, 8), + 0.1f32.to_bits(), + 0.0f32.to_bits(), + 0.9f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut output = vec![0u16; 64]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_srm, 0); + assert_eq!(stats.software_ops, 1); + } + + #[test] + fn empty_scissors_skip_software_fallbacks() { + let mut ui = Ui::new(); + ui.set_viewport(8.0, 8.0); + let words = [ + spec::draw_op::SCISSOR, + xy_word(20, 20), + wh_word(4, 4), + spec::draw_op::GRAD_RECT, + xy_word(0, 0), + wh_word(8, 8), + 0xff00_0000, + 0xffff_ffff, + spec::GradDir::ToRight as u32, + spec::draw_op::SCISSOR_POP, + ]; + let mut output = vec![pack_rgb565(255, 0, 0); 64]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 8, 8, &mut ppa) + .unwrap(); + + assert_eq!(stats.software_ops, 0); + assert!(output.iter().all(|&pixel| pixel == 0)); + } +} diff --git a/package.json b/package.json index d0ba6a4e..26ffa475 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "hosts/web", "assets/brand", "assets/fonts", + "engine/backends/esp32p4-ppa/src", + "engine/backends/esp32p4-ppa/Cargo.toml", + "engine/backends/esp32p4-ppa/Cargo.lock", + "engine/backends/esp32p4-ppa/README.md", "engine/core/src", "engine/core/Cargo.toml", "engine/wasm/src", From 5a5ffc78355aeee906c978884d6d5b83d6386b74 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 16:42:44 +0800 Subject: [PATCH 4/9] docs(esp32p4): describe the RGB565 PPA path --- docs/DESIGN.md | 5 +++++ docs/STRUCTURE.md | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 8aee2315..f543eae3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -60,6 +60,11 @@ artifacts: `$JOB_TMP/map-*.json`). PSP bin wraps it with QuickJS + sceGu; `wasm32-unknown-unknown` build wraps it with a deterministic software rasterizer used by BOTH the browser dev host and headless Bun goldens. One layout engine everywhere. +- **ESP32-P4 stays 16-bit**: `engine/backends/esp32p4-ppa/` consumes the same + DrawList into an opaque RGB565 target. It maps flat fills, A8 coverage + blending, and compatible PSM 5650 texture transforms to the PPA, then + preserves ordering with the core RGB565 rasterizer for unsupported ops. + It never allocates a full-frame RGB888/ARGB8888 intermediate. - **Native animation**: tweens/springs tick in Rust per vblank with **fixed dt = 1/60 s** (frame content is a pure function of frame index — this is what makes byte-exact goldens possible **[R]**). JS only declares motion. diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 955d408d..c99823bf 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -9,6 +9,7 @@ directory; nothing else gets a top-level name. pocketjs/ ├─ engine/ Cores: the Rust simulation cores │ ├─ core/ pocketjs-core — retained UI tree, taffy layout, raster (standalone crate) +│ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate) │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) │ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples │ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-wgpu, pocket-vrm, pocket-widget @@ -60,7 +61,7 @@ New things go where the axis says — never invent a top-level directory: - **npm surface is frozen**: `@pocketjs/framework/*` export *keys* never change; the `exports`/`files` maps in package.json absorb internal moves. - **Cargo stays non-workspace where toolchains demand it**: `engine/core`, - `engine/wasm`, `hosts/psp`, `hosts/vita`, and the gu/vita 3D crates each - stand alone with their own lockfiles. `engine/Cargo.toml` is the one - desktop workspace. + `engine/wasm`, `engine/backends/esp32p4-ppa`, `hosts/psp`, `hosts/vita`, + and the gu/vita 3D crates each stand alone with their own lockfiles. + `engine/Cargo.toml` is the one desktop workspace. - **Moves are `git mv`** — history stays traceable. From a2df6d2f4e32199b8e6ac0c96731c83dedf783d3 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 17:25:28 +0800 Subject: [PATCH 5/9] fix(esp32p4): align linear alpha texture sampling --- engine/backends/esp32p4-ppa/src/lib.rs | 236 ++++++++++++++++++++++--- engine/core/src/raster.rs | 90 ++++++++-- 2 files changed, 281 insertions(+), 45 deletions(-) diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index a2daa2ac..dcf3cfce 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -12,7 +12,7 @@ extern crate alloc; use alloc::vec::Vec; -use pocketjs_core::raster::{pack_rgb565, render_scaled_rgb565_over}; +use pocketjs_core::raster::{linear_sample_coordinates, pack_rgb565, render_scaled_rgb565_over}; use pocketjs_core::{spec, TexView, Ui}; const MASK_ALIGNMENT: usize = 128; @@ -787,12 +787,13 @@ fn exact_texel_edge(uv: f32, extent: u32) -> Option { fn is_white_alpha_texture(view: &TexView<'_>) -> bool { let count = view.w as usize * view.h as usize; match view.psm { - spec::psm::PSM_8888 => view.pixels[..count * 4] - .chunks_exact(4) - .all(|p| p[3] == 0 || (p[0] == 255 && p[1] == 255 && p[2] == 255)), + spec::psm::PSM_8888 => view.pixels[..count * 4].chunks_exact(4).all(|p| { + let white = p[0] == 255 && p[1] == 255 && p[2] == 255; + white || (!view.linear && p[3] == 0) + }), spec::psm::PSM_4444 => view.pixels[..count * 2].chunks_exact(2).all(|p| { let pixel = u16::from_le_bytes([p[0], p[1]]); - pixel >> 12 == 0 || pixel & 0x0fff == 0x0fff + pixel & 0x0fff == 0x0fff || (!view.linear && pixel >> 12 == 0) }), spec::psm::PSM_T8 => { let Some(palette) = view.palette else { @@ -800,8 +801,8 @@ fn is_white_alpha_texture(view: &TexView<'_>) -> bool { }; view.pixels[..count].iter().all(|&index| { let p = index as usize * 4; - palette[p + 3] == 0 - || (palette[p] == 255 && palette[p + 1] == 255 && palette[p + 2] == 255) + let white = palette[p] == 255 && palette[p + 1] == 255 && palette[p + 2] == 255; + white || (!view.linear && palette[p + 3] == 0) }) } _ => false, @@ -831,20 +832,21 @@ fn sample_alpha(view: &TexView<'_>, u: f32, v: f32) -> u8 { if !view.linear { return texel_alpha(view, (u * view.w as f32) as i32, (v * view.h as f32) as i32) as u8; } - let uf = (u * view.w as f32 * 256.0) as i32 - 128; - let vf = (v * view.h as f32 * 256.0) as i32 - 128; - let x0 = uf >> 8; - let y0 = vf >> 8; - let fx = (uf & 255) as u32; - let fy = (vf & 255) as u32; + let Some(sample) = linear_sample_coordinates(view.w, view.h, u, v) else { + return 0; + }; let lerp = |a: u32, b: u32, f: u32| (a * (256 - f) + b * f) >> 8; - let top = lerp(texel_alpha(view, x0, y0), texel_alpha(view, x0 + 1, y0), fx); + let top = lerp( + texel_alpha(view, sample.x0 as i32, sample.y0 as i32), + texel_alpha(view, sample.x1 as i32, sample.y0 as i32), + sample.fx, + ); let bottom = lerp( - texel_alpha(view, x0, y0 + 1), - texel_alpha(view, x0 + 1, y0 + 1), - fx, + texel_alpha(view, sample.x0 as i32, sample.y1 as i32), + texel_alpha(view, sample.x1 as i32, sample.y1 as i32), + sample.fx, ); - lerp(top, bottom, fy) as u8 + lerp(top, bottom, sample.fy) as u8 } #[allow(clippy::too_many_arguments)] @@ -894,6 +896,7 @@ mod tests { blends: u32, srm: u32, last_mask_max: u8, + last_mask: Vec, last_global_alpha: u8, last_source_rect: Rect, last_destination_rect: Rect, @@ -919,27 +922,36 @@ mod tests { fn blend_a8_rgb565( &mut self, - _destination: &mut [u16], - _width: u32, + destination: &mut [u16], + width: u32, _height: u32, mask: &[u8], - _rect: Rect, - _color: [u8; 3], + rect: Rect, + color: [u8; 3], global_alpha: u8, ) -> bool { self.blends += 1; self.last_mask_max = mask.iter().copied().max().unwrap_or(0); + self.last_mask.clear(); + self.last_mask.extend_from_slice(mask); self.last_global_alpha = global_alpha; + for y in rect.y..rect.y + rect.h { + for x in rect.x..rect.x + rect.w { + let index = y as usize * width as usize + x as usize; + let alpha = (mask[index] as u32 * global_alpha as u32 + 127) / 255; + blend_rgb565(&mut destination[index], color, alpha); + } + } true } fn srm_psm5650_to_rgb565( &mut self, - _destination: &mut [u16], - _width: u32, + destination: &mut [u16], + width: u32, _height: u32, - _source: &[u8], - _source_width: u32, + source: &[u8], + source_width: u32, _source_height: u32, source_rect: Rect, destination_rect: Rect, @@ -949,10 +961,66 @@ mod tests { self.last_source_rect = source_rect; self.last_destination_rect = destination_rect; self.last_transform = transform; + if transform.rotation != QuarterTurn::None + || source_rect.w != destination_rect.w + || source_rect.h != destination_rect.h + { + return false; + } + for dy in 0..destination_rect.h { + let sy = if transform.mirror_y { + source_rect.y + source_rect.h - 1 - dy + } else { + source_rect.y + dy + }; + for dx in 0..destination_rect.w { + let sx = if transform.mirror_x { + source_rect.x + source_rect.w - 1 - dx + } else { + source_rect.x + dx + }; + let source_index = (sy * source_width + sx) as usize * 2; + let psm5650 = + u16::from_le_bytes([source[source_index], source[source_index + 1]]); + let rgb565 = ((psm5650 & 0x001f) << 11) + | (psm5650 & 0x07e0) + | ((psm5650 & 0xf800) >> 11); + let destination_index = (destination_rect.y + dy) as usize * width as usize + + (destination_rect.x + dx) as usize; + destination[destination_index] = rgb565; + } + } true } } + fn blend_rgb565(destination: &mut u16, color: [u8; 3], alpha: u32) { + if alpha == 0 { + return; + } + if alpha >= 255 { + *destination = pack_rgb565(color[0] as u32, color[1] as u32, color[2] as u32); + return; + } + let r5 = (*destination as u32 >> 11) & 0x1f; + let g6 = (*destination as u32 >> 5) & 0x3f; + let b5 = *destination as u32 & 0x1f; + let destination_color = [ + (r5 << 3) | (r5 >> 2), + (g6 << 2) | (g6 >> 4), + (b5 << 3) | (b5 >> 2), + ]; + let inverse_alpha = 255 - alpha; + let mix = |source: u8, destination: u32| { + (source as u32 * alpha + destination * inverse_alpha + 127) / 255 + }; + *destination = pack_rgb565( + mix(color[0], destination_color[0]), + mix(color[1], destination_color[1]), + mix(color[2], destination_color[2]), + ); + } + fn xy_word(x: i16, y: i16) -> u32 { x as u16 as u32 | ((y as u16 as u32) << 16) } @@ -1034,6 +1102,121 @@ mod tests { assert_eq!(ppa.blends, 1); } + #[test] + fn linear_alpha_mask_matches_core_edge_sampling() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 1.0); + let handle = ui.upload_texture_flags( + &[ + 255, 255, 255, 0, // + 255, 255, 255, 255, + ], + 2, + 1, + spec::psm::PSM_8888, + spec::img::FLAG_LINEAR, + ); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(4, 1), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xff00_ffff, + ]; + let mut expected = vec![0u16; 4]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &words, &mut expected, 1); + let mut output = vec![0u16; 4]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 4, 1, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_blends, 1); + assert_eq!(stats.software_ops, 0); + assert_eq!(&ppa.last_mask[..4], &[191, 63, 191, 255]); + assert_eq!(output, expected); + } + + #[test] + fn linear_transparent_color_uses_software_fallback() { + let mut ui = Ui::new(); + ui.set_viewport(4.0, 1.0); + let handle = ui.upload_texture_flags( + &[ + 0, 0, 0, 0, // + 255, 255, 255, 255, + ], + 2, + 1, + spec::psm::PSM_8888, + spec::img::FLAG_LINEAR, + ); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(4, 1), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut expected = vec![0u16; 4]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &words, &mut expected, 1); + let mut output = vec![0u16; 4]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 4, 1, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_blends, 0); + assert_eq!(stats.software_ops, 1); + assert_eq!(ppa.blends, 0); + assert_eq!(output, expected); + } + + #[test] + fn nearest_transparent_color_remains_alpha_only() { + let mut ui = Ui::new(); + ui.set_viewport(2.0, 1.0); + let handle = ui.upload_texture( + &[ + 0, 0, 0, 0, // + 255, 255, 255, 255, + ], + 2, + 1, + spec::psm::PSM_8888, + ); + let words = [ + spec::draw_op::TEX_QUAD, + handle as u32, + xy_word(0, 0), + wh_word(2, 1), + 0.0f32.to_bits(), + 0.0f32.to_bits(), + 1.0f32.to_bits(), + 1.0f32.to_bits(), + 0xffff_ffff, + ]; + let mut expected = vec![0u16; 2]; + pocketjs_core::raster::render_scaled_rgb565(&ui, &words, &mut expected, 1); + let mut output = vec![0u16; 2]; + let mut ppa = MockPpa::default(); + let stats = renderer() + .render(&ui, &words, &mut output, 2, 1, &mut ppa) + .unwrap(); + + assert_eq!(stats.ppa_blends, 1); + assert_eq!(stats.software_ops, 0); + assert_eq!(output, expected); + } + #[test] fn folds_global_alpha_into_a8_before_batching_overlaps() { let mut ui = Ui::new(); @@ -1089,6 +1272,7 @@ mod tests { assert_eq!(stats.ppa_srm, 1); assert_eq!(stats.software_ops, 0); assert_eq!(ppa.srm, 1); + assert!(output.iter().all(|&pixel| pixel == pack_rgb565(255, 0, 0))); } #[test] diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index c0ff0632..fd8dcb82 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -722,28 +722,64 @@ fn texel(view: &TexView, idx: usize) -> Option<(u32, u32, u32, u32)> { } } -/// Deterministic integer bilinear sample at normalized (u, v). Texel coords -/// are 24.8 fixed point centered on texel centers (`uf = u*w*256 - 128`); -/// the 4 clamp-addressed neighbors blend with 8-bit weights, horizontal -/// first then vertical — integer math only, so byte-exact on every host. +/// Coordinate selection shared by software and hardware-assisted texture +/// paths. Texel coordinates are 24.8 fixed point centered on texel centers +/// (`uf = u*w*256 - 128`). Clamp the base coordinate before selecting its +/// second neighbor so every backend preserves the core's edge sampling. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LinearSample { + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, + pub fx: u32, + pub fy: u32, +} + +#[inline] +pub fn linear_sample_coordinates( + width: u32, + height: u32, + u: f32, + v: f32, +) -> Option { + if width == 0 || height == 0 { + return None; + } + let (tw_max, th_max) = (width as i32 - 1, height as i32 - 1); + let uf = (u * width as f32 * 256.0) as i32 - 128; + let vf = (v * height as f32 * 256.0) as i32 - 128; + let x0 = (uf >> 8).clamp(0, tw_max); + let y0 = (vf >> 8).clamp(0, th_max); + Some(LinearSample { + x0: x0 as u32, + y0: y0 as u32, + x1: (x0 + 1).min(tw_max) as u32, + y1: (y0 + 1).min(th_max) as u32, + fx: (uf & 255) as u32, + fy: (vf & 255) as u32, + }) +} + +/// Deterministic integer bilinear sample at normalized (u, v). The four +/// clamp-addressed neighbors blend with 8-bit weights, horizontal first then +/// vertical — integer math only, so byte-exact on every host. #[inline] fn sample_linear(view: &TexView, u: f32, v: f32) -> Option<(u32, u32, u32, u32)> { - let (tw_max, th_max) = (view.w as i32 - 1, view.h as i32 - 1); - let uf = (u * view.w as f32 * 256.0) as i32 - 128; - let vf = (v * view.h as f32 * 256.0) as i32 - 128; - let tx0 = (uf >> 8).clamp(0, tw_max); - let ty0 = (vf >> 8).clamp(0, th_max); - let tx1 = (tx0 + 1).min(tw_max); - let ty1 = (ty0 + 1).min(th_max); - let fx = (uf & 255) as u32; - let fy = (vf & 255) as u32; - let w = view.w as i32; - let c00 = texel(view, (ty0 * w + tx0) as usize)?; - let c01 = texel(view, (ty0 * w + tx1) as usize)?; - let c10 = texel(view, (ty1 * w + tx0) as usize)?; - let c11 = texel(view, (ty1 * w + tx1) as usize)?; + let sample = linear_sample_coordinates(view.w, view.h, u, v)?; + let w = view.w as usize; + let c00 = texel(view, sample.y0 as usize * w + sample.x0 as usize)?; + let c01 = texel(view, sample.y0 as usize * w + sample.x1 as usize)?; + let c10 = texel(view, sample.y1 as usize * w + sample.x0 as usize)?; + let c11 = texel(view, sample.y1 as usize * w + sample.x1 as usize)?; let lerp8 = |a: u32, b: u32, f: u32| (a * (256 - f) + b * f) >> 8; - let mix = |c0: u32, c1: u32, c2: u32, c3: u32| lerp8(lerp8(c0, c1, fx), lerp8(c2, c3, fx), fy); + let mix = |c0: u32, c1: u32, c2: u32, c3: u32| { + lerp8( + lerp8(c0, c1, sample.fx), + lerp8(c2, c3, sample.fx), + sample.fy, + ) + }; Some(( mix(c00.0, c01.0, c10.0, c11.0), mix(c00.1, c01.1, c10.1, c11.1), @@ -930,6 +966,22 @@ mod tests { fb[offset..offset + 4].try_into().unwrap() } + #[test] + fn linear_sample_coordinates_pin_clamped_edge_semantics() { + assert_eq!( + linear_sample_coordinates(2, 1, 0.125, 0.5), + Some(LinearSample { + x0: 0, + y0: 0, + x1: 1, + y1: 0, + fx: 192, + fy: 0, + }) + ); + assert_eq!(linear_sample_coordinates(0, 1, 0.5, 0.5), None); + } + #[test] fn scale_one_wrapper_is_byte_exact() { let ui = Ui::new(); From 335fd347a7f2cf6da6b64f9dfd4191bbc0c2f56b Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 17:40:09 +0800 Subject: [PATCH 6/9] feat(esp32p4): add reusable ESP-IDF PPA adapter --- engine/backends/esp32p4-ppa/Cargo.toml | 1 + engine/backends/esp32p4-ppa/src/esp_idf.rs | 201 ++++++++ engine/backends/esp32p4-ppa/src/lib.rs | 5 + .../components/pocketjs_ppa/CMakeLists.txt | 17 + .../components/pocketjs_ppa/idf_component.yml | 9 + .../pocketjs_ppa/include/pocketjs_ppa.h | 89 ++++ .../pocketjs_ppa/src/pocketjs_ppa.c | 468 ++++++++++++++++++ hosts/esp32p4/examples/ppa-smoke/.gitignore | 4 + .../esp32p4/examples/ppa-smoke/CMakeLists.txt | 7 + .../examples/ppa-smoke/main/CMakeLists.txt | 4 + hosts/esp32p4/examples/ppa-smoke/main/main.c | 9 + 11 files changed, 814 insertions(+) create mode 100644 engine/backends/esp32p4-ppa/src/esp_idf.rs create mode 100644 hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt create mode 100644 hosts/esp32p4/components/pocketjs_ppa/idf_component.yml create mode 100644 hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h create mode 100644 hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c create mode 100644 hosts/esp32p4/examples/ppa-smoke/.gitignore create mode 100644 hosts/esp32p4/examples/ppa-smoke/CMakeLists.txt create mode 100644 hosts/esp32p4/examples/ppa-smoke/main/CMakeLists.txt create mode 100644 hosts/esp32p4/examples/ppa-smoke/main/main.c diff --git a/engine/backends/esp32p4-ppa/Cargo.toml b/engine/backends/esp32p4-ppa/Cargo.toml index 985375bd..fbc47044 100644 --- a/engine/backends/esp32p4-ppa/Cargo.toml +++ b/engine/backends/esp32p4-ppa/Cargo.toml @@ -13,6 +13,7 @@ pocketjs-core = { path = "../../core", features = ["std"] } [features] default = [] +esp-idf = [] std = ["pocketjs-core/std"] [workspace] diff --git a/engine/backends/esp32p4-ppa/src/esp_idf.rs b/engine/backends/esp32p4-ppa/src/esp_idf.rs new file mode 100644 index 00000000..f00b92c9 --- /dev/null +++ b/engine/backends/esp32p4-ppa/src/esp_idf.rs @@ -0,0 +1,201 @@ +//! ESP-IDF implementation of [`PpaOps`]. +//! +//! Enable the `esp-idf` feature and link the `pocketjs_ppa` ESP-IDF component +//! from `hosts/esp32p4/components`. The C component owns the PPA clients and +//! keeps ESP-IDF headers out of this portable Rust crate. + +use core::ffi::c_void; +use core::ptr; + +use crate::{PpaOps, QuarterTurn, Rect, SrmTransform}; + +unsafe extern "C" { + fn pocketjs_ppa_create(out_handle: *mut *mut c_void) -> i32; + fn pocketjs_ppa_destroy(handle: *mut c_void); + fn pocketjs_ppa_fill_rgb565( + handle: *mut c_void, + destination: *mut u16, + destination_pixels: usize, + width: u32, + height: u32, + x: u32, + y: u32, + rect_width: u32, + rect_height: u32, + color: u16, + ) -> i32; + fn pocketjs_ppa_blend_a8_rgb565( + handle: *mut c_void, + destination: *mut u16, + destination_pixels: usize, + width: u32, + height: u32, + mask: *const u8, + mask_len: usize, + x: u32, + y: u32, + rect_width: u32, + rect_height: u32, + red: u8, + green: u8, + blue: u8, + global_alpha: u8, + ) -> i32; + fn pocketjs_ppa_srm_psm5650_rgb565( + handle: *mut c_void, + destination: *mut u16, + destination_pixels: usize, + width: u32, + height: u32, + source: *const u8, + source_len: usize, + source_width: u32, + source_height: u32, + source_x: u32, + source_y: u32, + source_rect_width: u32, + source_rect_height: u32, + destination_x: u32, + destination_y: u32, + destination_rect_width: u32, + destination_rect_height: u32, + quarter_turn: u32, + mirror_x: i32, + mirror_y: i32, + ) -> i32; +} + +/// Blocking ESP-IDF implementation backed by one FILL, BLEND, and SRM client. +/// +/// Construction registers the three clients. Dropping the value unregisters +/// them, so it must be dropped before the ESP-IDF PPA driver is torn down. +pub struct EspIdfPpaOps { + handle: *mut c_void, +} + +impl EspIdfPpaOps { + /// Register the PPA clients. + /// + /// The error value is the `esp_err_t` returned by the ESP-IDF component. + pub fn new() -> Result { + let mut handle = ptr::null_mut(); + let result = unsafe { pocketjs_ppa_create(&mut handle) }; + if result == 0 && !handle.is_null() { + Ok(Self { handle }) + } else if result != 0 { + Err(result) + } else { + Err(-1) + } + } +} + +impl Drop for EspIdfPpaOps { + fn drop(&mut self) { + unsafe { + pocketjs_ppa_destroy(self.handle); + } + self.handle = ptr::null_mut(); + } +} + +impl PpaOps for EspIdfPpaOps { + fn fill_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + rect: Rect, + color: u16, + ) -> bool { + unsafe { + pocketjs_ppa_fill_rgb565( + self.handle, + destination.as_mut_ptr(), + destination.len(), + width, + height, + rect.x, + rect.y, + rect.w, + rect.h, + color, + ) != 0 + } + } + + fn blend_a8_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + mask: &[u8], + rect: Rect, + color: [u8; 3], + global_alpha: u8, + ) -> bool { + unsafe { + pocketjs_ppa_blend_a8_rgb565( + self.handle, + destination.as_mut_ptr(), + destination.len(), + width, + height, + mask.as_ptr(), + mask.len(), + rect.x, + rect.y, + rect.w, + rect.h, + color[0], + color[1], + color[2], + global_alpha, + ) != 0 + } + } + + fn srm_psm5650_to_rgb565( + &mut self, + destination: &mut [u16], + width: u32, + height: u32, + source: &[u8], + source_width: u32, + source_height: u32, + source_rect: Rect, + destination_rect: Rect, + transform: SrmTransform, + ) -> bool { + let quarter_turn = match transform.rotation { + QuarterTurn::None => 0, + QuarterTurn::Ccw90 => 1, + QuarterTurn::Ccw180 => 2, + QuarterTurn::Ccw270 => 3, + }; + unsafe { + pocketjs_ppa_srm_psm5650_rgb565( + self.handle, + destination.as_mut_ptr(), + destination.len(), + width, + height, + source.as_ptr(), + source.len(), + source_width, + source_height, + source_rect.x, + source_rect.y, + source_rect.w, + source_rect.h, + destination_rect.x, + destination_rect.y, + destination_rect.w, + destination_rect.h, + quarter_turn, + transform.mirror_x as i32, + transform.mirror_y as i32, + ) != 0 + } + } +} diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index dcf3cfce..0eb545e9 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -15,6 +15,11 @@ use alloc::vec::Vec; use pocketjs_core::raster::{linear_sample_coordinates, pack_rgb565, render_scaled_rgb565_over}; use pocketjs_core::{spec, TexView, Ui}; +#[cfg(feature = "esp-idf")] +mod esp_idf; +#[cfg(feature = "esp-idf")] +pub use esp_idf::EspIdfPpaOps; + const MASK_ALIGNMENT: usize = 128; const CLIP_DEPTH: usize = 32; const TEXTURE_CLASS_CACHE_LEN: usize = 64; diff --git a/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt b/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt new file mode 100644 index 00000000..1e679f67 --- /dev/null +++ b/hosts/esp32p4/components/pocketjs_ppa/CMakeLists.txt @@ -0,0 +1,17 @@ +idf_component_register( + SRCS "src/pocketjs_ppa.c" + INCLUDE_DIRS "include" + REQUIRES esp_driver_ppa + PRIV_REQUIRES log +) + +# EspIdfPpaOps normally references this C ABI from a Rust static archive. +# Root the symbols so the one-pass linker extracts this component regardless +# of archive ordering. +target_link_options(${COMPONENT_LIB} INTERFACE + "-Wl,--undefined=pocketjs_ppa_create" + "-Wl,--undefined=pocketjs_ppa_destroy" + "-Wl,--undefined=pocketjs_ppa_fill_rgb565" + "-Wl,--undefined=pocketjs_ppa_blend_a8_rgb565" + "-Wl,--undefined=pocketjs_ppa_srm_psm5650_rgb565" +) diff --git a/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml b/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml new file mode 100644 index 00000000..7d77939d --- /dev/null +++ b/hosts/esp32p4/components/pocketjs_ppa/idf_component.yml @@ -0,0 +1,9 @@ +version: "0.1.0" +description: ESP-IDF PPA operations for the PocketJS ESP32-P4 RGB565 renderer +license: MIT +repository: https://github.com/pocket-stack/pocketjs +documentation: https://github.com/pocket-stack/pocketjs/tree/main/hosts/esp32p4 +targets: + - esp32p4 +dependencies: + idf: ">=6.0,<6.2" diff --git a/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h b/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h new file mode 100644 index 00000000..b48fd919 --- /dev/null +++ b/hosts/esp32p4/components/pocketjs_ppa/include/pocketjs_ppa.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct pocketjs_ppa_context *pocketjs_ppa_handle_t; + +/** + * Register one blocking FILL, BLEND, and SRM client for a renderer. + * + * ESP-IDF recommends that each task own its clients. The returned handle must + * therefore be used and destroyed by the same rendering task. + */ +esp_err_t pocketjs_ppa_create(pocketjs_ppa_handle_t *out_handle); + +/** + * Unregister all clients and release the handle. A NULL handle is accepted. + */ +void pocketjs_ppa_destroy(pocketjs_ppa_handle_t handle); + +/** + * The following narrow C ABI is consumed by EspIdfPpaOps in the + * pocketjs-esp32p4-ppa crate. Each function returns 1 after a completed + * blocking transaction or 0 when the renderer must use its ordered software + * fallback. + */ +int pocketjs_ppa_fill_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + uint32_t x, + uint32_t y, + uint32_t rect_width, + uint32_t rect_height, + uint16_t color +); + +int pocketjs_ppa_blend_a8_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *mask, + size_t mask_len, + uint32_t x, + uint32_t y, + uint32_t rect_width, + uint32_t rect_height, + uint8_t red, + uint8_t green, + uint8_t blue, + uint8_t global_alpha +); + +int pocketjs_ppa_srm_psm5650_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *source, + size_t source_len, + uint32_t source_width, + uint32_t source_height, + uint32_t source_x, + uint32_t source_y, + uint32_t source_rect_width, + uint32_t source_rect_height, + uint32_t destination_x, + uint32_t destination_y, + uint32_t destination_rect_width, + uint32_t destination_rect_height, + uint32_t quarter_turn, + int mirror_x, + int mirror_y +); + +#ifdef __cplusplus +} +#endif diff --git a/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c b/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c new file mode 100644 index 00000000..ea91e782 --- /dev/null +++ b/hosts/esp32p4/components/pocketjs_ppa/src/pocketjs_ppa.c @@ -0,0 +1,468 @@ +#include "pocketjs_ppa.h" + +#include +#include +#include + +#include "driver/ppa.h" +#include "esp_log.h" + +static const char *TAG = "pocketjs_ppa"; + +struct pocketjs_ppa_context { + ppa_client_handle_t fill; + ppa_client_handle_t blend; + ppa_client_handle_t srm; + bool fill_error_logged; + bool blend_error_logged; + bool srm_error_logged; +}; + +static bool surface_is_valid( + const void *buffer, + size_t pixels, + uint32_t width, + uint32_t height, + size_t bytes_per_pixel +) +{ + if (buffer == NULL || width == 0 || height == 0 || bytes_per_pixel == 0) { + return false; + } + return (size_t)width <= SIZE_MAX / (size_t)height && + pixels == (size_t)width * (size_t)height && + pixels <= SIZE_MAX / bytes_per_pixel; +} + +static bool rect_is_valid( + uint32_t surface_width, + uint32_t surface_height, + uint32_t x, + uint32_t y, + uint32_t width, + uint32_t height +) +{ + return width > 0 && + height > 0 && + x <= surface_width && + y <= surface_height && + width <= surface_width - x && + height <= surface_height - y; +} + +static bool byte_ranges_overlap( + const void *first, + size_t first_size, + const void *second, + size_t second_size +) +{ + const uintptr_t first_start = (uintptr_t)first; + const uintptr_t second_start = (uintptr_t)second; + if (first_size > UINTPTR_MAX - first_start || + second_size > UINTPTR_MAX - second_start) { + return true; + } + const uintptr_t first_end = first_start + first_size; + const uintptr_t second_end = second_start + second_size; + return first_start < second_end && second_start < first_end; +} + +static color_pixel_argb8888_data_t rgb565_to_argb8888(uint16_t color) +{ + const uint8_t red5 = (uint8_t)((color >> 11) & 0x1FU); + const uint8_t green6 = (uint8_t)((color >> 5) & 0x3FU); + const uint8_t blue5 = (uint8_t)(color & 0x1FU); + const color_pixel_argb8888_data_t expanded = { + .r = (uint8_t)((red5 << 3) | (red5 >> 2)), + .g = (uint8_t)((green6 << 2) | (green6 >> 4)), + .b = (uint8_t)((blue5 << 3) | (blue5 >> 2)), + .a = UINT8_MAX, + }; + return expanded; +} + +static void log_operation_failure_once( + const char *operation, + esp_err_t error, + bool *already_logged +) +{ + if (!*already_logged) { + ESP_LOGW( + TAG, + "PPA %s failed (%s); using ordered RGB565 software fallback", + operation, + esp_err_to_name(error) + ); + *already_logged = true; + } +} + +static esp_err_t register_client( + ppa_operation_t operation, + ppa_client_handle_t *out_client +) +{ + const ppa_client_config_t config = { + .oper_type = operation, + .max_pending_trans_num = 1, + .data_burst_length = PPA_DATA_BURST_LENGTH_128, + }; + return ppa_register_client(&config, out_client); +} + +esp_err_t pocketjs_ppa_create(pocketjs_ppa_handle_t *out_handle) +{ + if (out_handle == NULL) { + return ESP_ERR_INVALID_ARG; + } + *out_handle = NULL; + + pocketjs_ppa_handle_t handle = calloc(1, sizeof(*handle)); + if (handle == NULL) { + return ESP_ERR_NO_MEM; + } + + esp_err_t result = register_client(PPA_OPERATION_FILL, &handle->fill); + if (result == ESP_OK) { + result = register_client(PPA_OPERATION_BLEND, &handle->blend); + } + if (result == ESP_OK) { + result = register_client(PPA_OPERATION_SRM, &handle->srm); + } + if (result != ESP_OK) { + ESP_LOGE( + TAG, + "failed to register PocketJS PPA clients: %s", + esp_err_to_name(result) + ); + pocketjs_ppa_destroy(handle); + return result; + } + + *out_handle = handle; + ESP_LOGI(TAG, "RGB565 backend ready: FILL + A8 BLEND + SRM"); + return ESP_OK; +} + +void pocketjs_ppa_destroy(pocketjs_ppa_handle_t handle) +{ + if (handle == NULL) { + return; + } + if (handle->srm != NULL) { + (void)ppa_unregister_client(handle->srm); + } + if (handle->blend != NULL) { + (void)ppa_unregister_client(handle->blend); + } + if (handle->fill != NULL) { + (void)ppa_unregister_client(handle->fill); + } + free(handle); +} + +int pocketjs_ppa_fill_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + uint32_t x, + uint32_t y, + uint32_t rect_width, + uint32_t rect_height, + uint16_t color +) +{ + if (handle == NULL || + handle->fill == NULL || + !surface_is_valid( + destination, + destination_pixels, + width, + height, + sizeof(*destination) + ) || + !rect_is_valid(width, height, x, y, rect_width, rect_height)) { + return 0; + } + + const ppa_fill_oper_config_t operation = { + .out = { + .buffer = destination, + .buffer_size = destination_pixels * sizeof(*destination), + .pic_w = width, + .pic_h = height, + .block_offset_x = x, + .block_offset_y = y, + .fill_cm = PPA_FILL_COLOR_MODE_RGB565, + }, + .fill_block_w = rect_width, + .fill_block_h = rect_height, + // The fixed fill pixel is supplied as ARGB components even when the + // output mode is RGB565. A packed RGB565 word produces wrong colors. + .fill_argb_color = rgb565_to_argb8888(color), + .mode = PPA_TRANS_MODE_BLOCKING, + }; + const esp_err_t result = ppa_do_fill(handle->fill, &operation); + if (result != ESP_OK) { + log_operation_failure_once( + "fill", + result, + &handle->fill_error_logged + ); + return 0; + } + return 1; +} + +int pocketjs_ppa_blend_a8_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *mask, + size_t mask_len, + uint32_t x, + uint32_t y, + uint32_t rect_width, + uint32_t rect_height, + uint8_t red, + uint8_t green, + uint8_t blue, + uint8_t global_alpha +) +{ + if (global_alpha == 0) { + return 1; + } + if (handle == NULL || + handle->blend == NULL || + !surface_is_valid( + destination, + destination_pixels, + width, + height, + sizeof(*destination) + ) || + !surface_is_valid(mask, mask_len, width, height, sizeof(*mask)) || + !rect_is_valid(width, height, x, y, rect_width, rect_height)) { + return 0; + } + + ppa_blend_oper_config_t operation = { + .in_bg = { + .buffer = destination, + .pic_w = width, + .pic_h = height, + .block_w = rect_width, + .block_h = rect_height, + .block_offset_x = x, + .block_offset_y = y, + .blend_cm = PPA_BLEND_COLOR_MODE_RGB565, + }, + .in_fg = { + .buffer = mask, + .pic_w = width, + .pic_h = height, + .block_w = rect_width, + .block_h = rect_height, + .block_offset_x = x, + .block_offset_y = y, + .blend_cm = PPA_BLEND_COLOR_MODE_A8, + }, + .out = { + .buffer = destination, + .buffer_size = destination_pixels * sizeof(*destination), + .pic_w = width, + .pic_h = height, + .block_offset_x = x, + .block_offset_y = y, + .blend_cm = PPA_BLEND_COLOR_MODE_RGB565, + }, + .bg_alpha_update_mode = PPA_ALPHA_NO_CHANGE, + .fg_alpha_update_mode = PPA_ALPHA_NO_CHANGE, + .fg_fix_rgb_val = { + .r = red, + .g = green, + .b = blue, + }, + .mode = PPA_TRANS_MODE_BLOCKING, + }; + if (global_alpha < UINT8_MAX) { + const uint32_t fixed_alpha = + ((uint32_t)global_alpha * 256U + 127U) / 255U; + operation.fg_alpha_update_mode = PPA_ALPHA_SCALE; + operation.fg_alpha_scale_ratio = (float)fixed_alpha / 256.0f; + } + + const esp_err_t result = ppa_do_blend(handle->blend, &operation); + if (result != ESP_OK) { + log_operation_failure_once( + "blend", + result, + &handle->blend_error_logged + ); + return 0; + } + return 1; +} + +static bool exact_scale( + uint32_t source_extent, + uint32_t destination_extent, + float *out_scale +) +{ + if (source_extent == 0 || destination_extent > UINT32_MAX / 16U) { + return false; + } + const uint32_t sixteenths_numerator = destination_extent * 16U; + if ((sixteenths_numerator % source_extent) != 0) { + return false; + } + const uint32_t sixteenths = sixteenths_numerator / source_extent; + if (sixteenths == 0 || sixteenths >= 256U * 16U) { + return false; + } + *out_scale = (float)sixteenths / 16.0f; + return true; +} + +int pocketjs_ppa_srm_psm5650_rgb565( + pocketjs_ppa_handle_t handle, + uint16_t *destination, + size_t destination_pixels, + uint32_t width, + uint32_t height, + const uint8_t *source, + size_t source_len, + uint32_t source_width, + uint32_t source_height, + uint32_t source_x, + uint32_t source_y, + uint32_t source_rect_width, + uint32_t source_rect_height, + uint32_t destination_x, + uint32_t destination_y, + uint32_t destination_rect_width, + uint32_t destination_rect_height, + uint32_t quarter_turn, + int mirror_x, + int mirror_y +) +{ + if (handle == NULL || + handle->srm == NULL || + !surface_is_valid( + destination, + destination_pixels, + width, + height, + sizeof(*destination) + ) || + source == NULL || + source_width == 0 || + source_height == 0 || + (size_t)source_width > SIZE_MAX / (size_t)source_height || + (size_t)source_width * (size_t)source_height > SIZE_MAX / 2U) { + return 0; + } + + const size_t source_required = + (size_t)source_width * (size_t)source_height * 2U; + const size_t destination_size = + destination_pixels * sizeof(*destination); + if (source_len < source_required || + byte_ranges_overlap( + source, + source_required, + destination, + destination_size + ) || + !rect_is_valid( + source_width, + source_height, + source_x, + source_y, + source_rect_width, + source_rect_height + ) || + !rect_is_valid( + width, + height, + destination_x, + destination_y, + destination_rect_width, + destination_rect_height + ) || + quarter_turn > 3U) { + return 0; + } + + const bool swaps_axes = quarter_turn == 1U || quarter_turn == 3U; + const uint32_t scaled_width = swaps_axes + ? destination_rect_height + : destination_rect_width; + const uint32_t scaled_height = swaps_axes + ? destination_rect_width + : destination_rect_height; + float scale_x = 0.0f; + float scale_y = 0.0f; + if (!exact_scale(source_rect_width, scaled_width, &scale_x) || + !exact_scale(source_rect_height, scaled_height, &scale_y)) { + return 0; + } + + const ppa_srm_rotation_angle_t rotations[] = { + PPA_SRM_ROTATION_ANGLE_0, + PPA_SRM_ROTATION_ANGLE_90, + PPA_SRM_ROTATION_ANGLE_180, + PPA_SRM_ROTATION_ANGLE_270, + }; + const ppa_srm_oper_config_t operation = { + .in = { + .buffer = source, + .pic_w = source_width, + .pic_h = source_height, + .block_w = source_rect_width, + .block_h = source_rect_height, + .block_offset_x = source_x, + .block_offset_y = source_y, + .srm_cm = PPA_SRM_COLOR_MODE_RGB565, + }, + .out = { + .buffer = destination, + .buffer_size = destination_size, + .pic_w = width, + .pic_h = height, + .block_offset_x = destination_x, + .block_offset_y = destination_y, + .srm_cm = PPA_SRM_COLOR_MODE_RGB565, + }, + .rotation_angle = rotations[quarter_turn], + .scale_x = scale_x, + .scale_y = scale_y, + .mirror_x = mirror_x != 0, + .mirror_y = mirror_y != 0, + .rgb_swap = true, + .byte_swap = false, + .alpha_update_mode = PPA_ALPHA_NO_CHANGE, + .mode = PPA_TRANS_MODE_BLOCKING, + }; + const esp_err_t result = + ppa_do_scale_rotate_mirror(handle->srm, &operation); + if (result != ESP_OK) { + log_operation_failure_once( + "SRM", + result, + &handle->srm_error_logged + ); + return 0; + } + return 1; +} diff --git a/hosts/esp32p4/examples/ppa-smoke/.gitignore b/hosts/esp32p4/examples/ppa-smoke/.gitignore new file mode 100644 index 00000000..dbc242c4 --- /dev/null +++ b/hosts/esp32p4/examples/ppa-smoke/.gitignore @@ -0,0 +1,4 @@ +build/ +dependencies.lock +sdkconfig +sdkconfig.old diff --git a/hosts/esp32p4/examples/ppa-smoke/CMakeLists.txt b/hosts/esp32p4/examples/ppa-smoke/CMakeLists.txt new file mode 100644 index 00000000..134277fb --- /dev/null +++ b/hosts/esp32p4/examples/ppa-smoke/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) + +set(EXTRA_COMPONENT_DIRS "../../components") +set(COMPONENTS main) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(pocketjs_ppa_smoke) diff --git a/hosts/esp32p4/examples/ppa-smoke/main/CMakeLists.txt b/hosts/esp32p4/examples/ppa-smoke/main/CMakeLists.txt new file mode 100644 index 00000000..9d1ef886 --- /dev/null +++ b/hosts/esp32p4/examples/ppa-smoke/main/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "main.c" + REQUIRES pocketjs_ppa +) diff --git a/hosts/esp32p4/examples/ppa-smoke/main/main.c b/hosts/esp32p4/examples/ppa-smoke/main/main.c new file mode 100644 index 00000000..96112f87 --- /dev/null +++ b/hosts/esp32p4/examples/ppa-smoke/main/main.c @@ -0,0 +1,9 @@ +#include "esp_err.h" +#include "pocketjs_ppa.h" + +void app_main(void) +{ + pocketjs_ppa_handle_t ppa = NULL; + ESP_ERROR_CHECK(pocketjs_ppa_create(&ppa)); + pocketjs_ppa_destroy(ppa); +} From a3ebeb0c28b182de10496ccb9b3d9d051e4cd3fc Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 17:40:15 +0800 Subject: [PATCH 7/9] ci(esp32p4): test ESP-IDF v6.0 baseline --- .github/workflows/esp32p4.yml | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/esp32p4.yml diff --git a/.github/workflows/esp32p4.yml b/.github/workflows/esp32p4.yml new file mode 100644 index 00000000..e8028542 --- /dev/null +++ b/.github/workflows/esp32p4.yml @@ -0,0 +1,53 @@ +name: ESP32-P4 backend + +on: + pull_request: + paths: + - ".github/workflows/esp32p4.yml" + - "engine/core/**" + - "engine/backends/esp32p4-ppa/**" + - "hosts/esp32p4/**" + push: + branches: [main] + paths: + - ".github/workflows/esp32p4.yml" + - "engine/core/**" + - "engine/backends/esp32p4-ppa/**" + - "hosts/esp32p4/**" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + rust: + name: Rust renderer + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Test RGB565 core rasterizer + run: cargo test --locked --manifest-path engine/core/Cargo.toml + + - name: Test ESP32-P4 DrawList backend + run: cargo test --locked --manifest-path engine/backends/esp32p4-ppa/Cargo.toml --features std + + - name: Check ESP-IDF Rust adapter + run: cargo check --locked --manifest-path engine/backends/esp32p4-ppa/Cargo.toml --features esp-idf + + esp-idf: + name: ESP-IDF release/v6.0 + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Build ESP32-P4 component smoke app + uses: espressif/esp-idf-ci-action@v1 + with: + esp_idf_version: release-v6.0 + target: esp32p4 + path: hosts/esp32p4/examples/ppa-smoke From 89ef951db7ad27916ca1f1cd03365dd985708450 Mon Sep 17 00:00:00 2001 From: halfsweet Date: Thu, 23 Jul 2026 17:40:23 +0800 Subject: [PATCH 8/9] docs(esp32p4): document ESP-IDF host integration --- docs/STRUCTURE.md | 1 + engine/backends/esp32p4-ppa/README.md | 27 +++- hosts/esp32p4/README.md | 116 ++++++++++++++++++ .../esp32p4/components/pocketjs_ppa/README.md | 8 ++ 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 hosts/esp32p4/README.md create mode 100644 hosts/esp32p4/components/pocketjs_ppa/README.md diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index c99823bf..7dffc47b 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -19,6 +19,7 @@ pocketjs/ ├─ hosts/ Surfaces: every embedding of the cores │ ├─ psp/ QuickJS + rust-psp EBOOT host │ ├─ vita/ Vita host +│ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build │ ├─ web/ browser dev host (wasm core) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) ├─ framework/ Guest: @pocketjs/framework diff --git a/engine/backends/esp32p4-ppa/README.md b/engine/backends/esp32p4-ppa/README.md index 70efd521..405e367e 100644 --- a/engine/backends/esp32p4-ppa/README.md +++ b/engine/backends/esp32p4-ppa/README.md @@ -5,10 +5,12 @@ surface. It accelerates operations that map exactly to the ESP32-P4 Pixel Processing Accelerator and preserves DrawList order with an RGB565 software fallback for everything else. -The crate deliberately does not depend on ESP-IDF or a board support package. -An ESP-IDF host implements `PpaOps` with `ppa_do_fill`, `ppa_do_blend`, and -`ppa_do_scale_rotate_mirror`. Board-specific display presentation remains in -the BSP. +The default crate deliberately does not depend on ESP-IDF or a board support +package. Hosts can implement `PpaOps` for another driver or test double. +Enabling the `esp-idf` feature exposes the concrete `EspIdfPpaOps` +implementation, backed by the reusable ESP-IDF component under +[`hosts/esp32p4`](../../../hosts/esp32p4/README.md). Board-specific display +presentation remains in the BSP. Accelerated paths: @@ -21,3 +23,20 @@ Accelerated paths: Gradients, arbitrary triangles, textured triangles, and unsupported texture formats fall back to `pocketjs_core::raster::render_scaled_rgb565_over`. No full-frame RGB888 or ARGB8888 surface is allocated. + +## Test + +Run the portable renderer and pixel-parity tests on the host: + +```sh +cargo test --locked --manifest-path engine/backends/esp32p4-ppa/Cargo.toml \ + --features std +``` + +Compile-check the Rust side of the ESP-IDF adapter without linking an IDF +application: + +```sh +cargo check --locked --manifest-path engine/backends/esp32p4-ppa/Cargo.toml \ + --features esp-idf +``` diff --git a/hosts/esp32p4/README.md b/hosts/esp32p4/README.md new file mode 100644 index 00000000..ddf302f4 --- /dev/null +++ b/hosts/esp32p4/README.md @@ -0,0 +1,116 @@ +# PocketJS on ESP32-P4 + +This directory contains the reusable ESP-IDF half of the PocketJS ESP32-P4 +RGB565 renderer. Together with +[`engine/backends/esp32p4-ppa`](../../engine/backends/esp32p4-ppa/README.md), +it is a concrete PPA backend: + +- the `no_std` Rust crate interprets DrawLists, batches A8 coverage, selects + hardware-compatible operations, and preserves order with its RGB565 + software fallback; +- `components/pocketjs_ppa` registers one ESP-IDF client each for FILL, BLEND, + and SRM and executes blocking transactions; +- the product BSP owns display initialization, native presentation buffers, + rotation into panel scan order, and vblank/present scheduling. + +No full-frame RGB888 or ARGB8888 intermediate is required. + +## Compatibility + +The adapter is supported and build-tested with the ESP-IDF `release/v6.0` and +`release/v6.1` branches. Versions older than v6.0 have not been tested. CI +builds `release/v6.0` as the minimum supported baseline. + +Only the `esp32p4` target is supported. The adapter does not select a silicon +revision, CPU frequency, PSRAM mode, display controller, or panel timing; +those remain product/BSP configuration. + +## Add the ESP-IDF component + +Add this repository's component directory to the host project before loading +ESP-IDF's project support: + +```cmake +list(APPEND EXTRA_COMPONENT_DIRS + "/path/to/pocketjs/hosts/esp32p4/components" +) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(my_pocketjs_host) +``` + +The host component that links the Rust archive should declare +`REQUIRES pocketjs_ppa`. The component roots its C ABI symbols for the final +link, so archive ordering does not require additional linker flags. + +## Enable the Rust adapter + +Depend on the renderer with its `esp-idf` feature: + +```toml +[dependencies] +pocketjs-core = { path = "/path/to/pocketjs/engine/core" } +pocketjs-esp32p4-ppa = { path = "/path/to/pocketjs/engine/backends/esp32p4-ppa", features = ["esp-idf"] } +``` + +Create one `EspIdfPpaOps` on the rendering task and pass it to the persistent +renderer: + +```rust +use pocketjs_esp32p4_ppa::{ + EspIdfPpaOps, Renderer, RendererConfig, +}; + +let mut ppa = EspIdfPpaOps::new().expect("PPA clients"); +let mut renderer = Renderer::new(RendererConfig::default()) + .expect("valid renderer configuration"); + +let stats = renderer.render( + &ui, + draw_list_words, + rgb565_framebuffer, + framebuffer_width, + framebuffer_height, + &mut ppa, +); +``` + +Dropping `EspIdfPpaOps` unregisters its clients. Drop it before shutting down +the platform PPA/display resources. + +## Buffer contract + +The caller owns all image memory and must obey the ESP-IDF PPA DMA/cache +contract: + +- allocate RGB565 output buffers with DMA-capable memory; +- align every output address and byte size to the platform cache-line size; + 128-byte alignment is a safe ESP32-P4 host policy; +- keep SRM input and output ranges distinct; +- do not modify or reuse a buffer while any nonblocking presentation + transaction is reading or writing it. + +The DrawList transactions in this adapter are blocking. This is intentional: +when an operation returns, subsequent PPA operations and ordered CPU fallback +segments must see its completed pixels. Display presentation can use a +separate nonblocking PPA client in the board BSP. + +FILL colors are passed through `fill_argb_color` after expanding RGB565 to +8-bit channels. Passing a packed RGB565 word through `fill_color_val` produces +incorrect colors. SRM is accepted only when both scale factors are represented +exactly in the PPA's 1/16 increments; otherwise the Rust renderer uses the +software fallback. + +## Build smoke test + +With an activated ESP-IDF environment: + +```sh +cd hosts/esp32p4/examples/ppa-smoke +idf.py set-target esp32p4 +idf.py build +``` + +This verifies the component API and final link. It does not exercise pixels +on hardware; visual and performance verification still belongs to a product +host with a display BSP. diff --git a/hosts/esp32p4/components/pocketjs_ppa/README.md b/hosts/esp32p4/components/pocketjs_ppa/README.md new file mode 100644 index 00000000..eba7b72a --- /dev/null +++ b/hosts/esp32p4/components/pocketjs_ppa/README.md @@ -0,0 +1,8 @@ +# pocketjs_ppa + +ESP-IDF component implementing blocking RGB565 FILL, A8-over-RGB565 BLEND, and +PSP PSM5650-to-RGB565 SRM operations for PocketJS on ESP32-P4. + +The public C ABI is normally consumed by the `EspIdfPpaOps` Rust type. See the +[ESP32-P4 host documentation](../../README.md) for integration, compatibility, +buffer ownership, and build instructions. From 7975a29a1484f7af29cb444864f79dfc7956ba3a Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:39:40 +0800 Subject: [PATCH 9/9] refactor(core): keep the shared rasterizer platform-neutral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrub ESP32-P4/PPA-specific wording from engine/core docs and comments — the core raster targets are format contracts (RGBA8, LE-ARGB8888 words, RGB565), not properties of one host. Export coverage_index alongside linear_sample_coordinates and drop the backend's byte-for-byte duplicate, so hardware glyph masks can never drift from the software fallback. Also fix the pixel-count assert message left over from the byte-length days. Co-Authored-By: Claude Fable 5 --- engine/backends/esp32p4-ppa/src/lib.rs | 9 ++-- engine/core/src/raster.rs | 64 ++++++++++++++------------ 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/engine/backends/esp32p4-ppa/src/lib.rs b/engine/backends/esp32p4-ppa/src/lib.rs index 0eb545e9..bf3b05d5 100644 --- a/engine/backends/esp32p4-ppa/src/lib.rs +++ b/engine/backends/esp32p4-ppa/src/lib.rs @@ -12,7 +12,9 @@ extern crate alloc; use alloc::vec::Vec; -use pocketjs_core::raster::{linear_sample_coordinates, pack_rgb565, render_scaled_rgb565_over}; +use pocketjs_core::raster::{ + coverage_index, linear_sample_coordinates, pack_rgb565, render_scaled_rgb565_over, +}; use pocketjs_core::{spec, TexView, Ui}; #[cfg(feature = "esp-idf")] @@ -724,11 +726,6 @@ fn composite_mask(destination: &mut u8, source: u8) { *destination = (s + (d * (255 - s) + 127) / 255) as u8; } -#[inline] -fn coverage_index(destination_px: i32, output_scale: i32, atlas_density: i32, limit: i32) -> usize { - ((((2 * destination_px + 1) * atlas_density) / (2 * output_scale)).clamp(0, limit - 1)) as usize -} - fn texture_source_rect( view: &TexView<'_>, op: &[u32], diff --git a/engine/core/src/raster.rs b/engine/core/src/raster.rs index fd8dcb82..f7f74a39 100644 --- a/engine/core/src/raster.rs +++ b/engine/core/src/raster.rs @@ -1,7 +1,8 @@ //! Deterministic software rasterizer: executes the core DrawList (spec.ts -//! "DRAWLIST op format") over an RGBA8, PPA BGRA32, or RGB565 framebuffer. DrawList coordinates -//! remain logical; [`render_scaled`] maps them directly onto an integer-scaled -//! physical surface without first rasterizing a low-resolution image. +//! "DRAWLIST op format") over an RGBA8, BGRA8, or RGB565 framebuffer. +//! DrawList coordinates remain logical; [`render_scaled`] maps them directly +//! onto an integer-scaled physical surface without first rasterizing a +//! low-resolution image. //! //! Determinism rules (byte-exact goldens depend on this): //! - Color math is INTEGER (u32/i64) everywhere: blending, gouraud triangle @@ -26,14 +27,15 @@ //! destination alpha is always written back as 255. //! //! [`render_scaled_argb`] emits the same pixels as B,G,R,A bytes instead — -//! the little-endian memory layout used by the ESP32-P4 PPA SRM's ARGB8888 -//! mode. That lets the ESP firmware present the framebuffer without a +//! the little-endian memory layout of an ARGB8888 u32 word. Hosts whose blit +//! engines consume ARGB8888 words can present the framebuffer without a //! per-frame reorder pass: byte placement is fused into the pixel writes, so //! it costs nothing extra. //! -//! [`render_scaled_rgb565`] writes native little-endian RGB565 words. It is -//! the low-bandwidth opaque surface used by the ESP32-P4 PPA backend and also -//! serves as that backend's ordered software fallback for unsupported ops. +//! [`render_scaled_rgb565`] writes native little-endian RGB565 words — the +//! low-bandwidth opaque target for 16-bit hosts. Hardware DrawList backends +//! reuse it (via [`render_scaled_rgb565_over`]) as their ordered software +//! fallback for ops their accelerator cannot express. use crate::spec::{self, draw_op}; use crate::{TexView, Ui}; @@ -95,10 +97,10 @@ fn pixel_bytes(r: u32, g: u32, b: u32) -> [u8; 4] { } } -/// Fill an opaque, whole-pixel byte span. ESP framebuffers are at least -/// 4-byte aligned, so the hot path emits one native word per pixel instead of -/// routing every pixel through alpha blending and four bounds-checked stores. -/// The byte fallback keeps the generic host/test API valid for odd slices. +/// Fill an opaque, whole-pixel byte span. Host framebuffers are in practice +/// at least 4-byte aligned, so the hot path emits one native word per pixel +/// instead of routing every pixel through alpha blending and four +/// bounds-checked stores. The byte fallback keeps the API valid for any slice. #[inline] fn fill_opaque_span(span: &mut [u8], r: u32, g: u32, b: u32) { debug_assert_eq!(span.len() & 3, 0); @@ -281,11 +283,12 @@ pub fn render_scaled(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { render_scaled_impl(ui, words, &mut target, scale, true); } -/// Same as [`render_scaled`] but emits B,G,R,A bytes per pixel — the in-memory -/// order the ESP32-P4 PPA SRM expects for ARGB8888 input. Byte-identical to -/// shuffling the RGBA output, but fused into the rasterizer so the firmware -/// skips its per-frame reorder copy. Output determinism matches the RGBA path -/// pixel-for-pixel; only the byte placement differs. +/// Same as [`render_scaled`] but emits B,G,R,A bytes per pixel — the +/// little-endian in-memory layout of an ARGB8888 u32 word, for hosts that +/// present ARGB8888 directly. Byte-identical to shuffling the RGBA output, +/// but fused into the rasterizer so hosts skip a per-frame reorder copy. +/// Output determinism matches the RGBA path pixel-for-pixel; only the byte +/// placement differs. pub fn render_scaled_argb(ui: &Ui, words: &[u32], fb: &mut [u8], scale: u32) { let mut target = RgbaTarget:: { bytes: fb }; render_scaled_impl(ui, words, &mut target, scale, true); @@ -327,7 +330,7 @@ fn render_scaled_impl( assert_eq!( target.pixel_len(), expected, - "scaled framebuffer has the wrong byte length" + "scaled framebuffer has the wrong pixel count" ); let screen = Clip { x0: 0, @@ -600,8 +603,16 @@ fn tri(target: &mut T, stride: i32, scale: i32, clip: Clip, p: // ---- GLYPH_RUN: coverage atlas cells ----------------------------------------------- +/// Map a scaled destination pixel (relative to its glyph cell origin) to the +/// atlas coverage row/column it samples. Shared with hardware DrawList +/// backends so their glyph masks reproduce the software fallback exactly. #[inline] -fn coverage_index(destination_px: i32, output_scale: i32, atlas_density: i32, limit: i32) -> usize { +pub fn coverage_index( + destination_px: i32, + output_scale: i32, + atlas_density: i32, + limit: i32, +) -> usize { // Nearest-neighbour at destination pixel centers. This is identical to a // 1:1 lookup when output_scale == atlas_density, duplicates coverage when // the output is denser, and samples the center of each source interval @@ -671,8 +682,8 @@ fn glyph_run( fn texel(view: &TexView, idx: usize) -> Option<(u32, u32, u32, u32)> { match view.psm { spec::psm::PSM_5650 => { - // PSP PSM 5650: u16 LE, B5:G6:R5. It is opaque. ESP32-P4 PPA - // consumes the same words with its RGB-swap input bit enabled. + // PSP PSM 5650: u16 LE, B5:G6:R5 (red in the low bits). Always + // opaque — there is no alpha channel to expand. let o = idx * 2; let px16 = view.pixels[o] as u32 | ((view.pixels[o + 1] as u32) << 8); let r5 = px16 & 0x1f; @@ -737,12 +748,7 @@ pub struct LinearSample { } #[inline] -pub fn linear_sample_coordinates( - width: u32, - height: u32, - u: f32, - v: f32, -) -> Option { +pub fn linear_sample_coordinates(width: u32, height: u32, u: f32, v: f32) -> Option { if width == 0 || height == 0 { return None; } @@ -1012,7 +1018,7 @@ mod tests { } #[test] - fn argb_output_uses_ppa_bgra_memory_layout() { + fn argb_output_uses_le_argb8888_memory_layout() { let ui = Ui::new(); let words = vec![ draw_op::RECT, @@ -1020,7 +1026,7 @@ mod tests { wh_word(7, 5), 0xff33_2211, // Semi-transparent rect exercises the dst-read blend path, which - // must read B,G,R,A offsets in PPA ARGB8888 mode. + // must read B,G,R,A offsets in ARGB mode. draw_op::RECT, xy_word(4, 5), wh_word(5, 3),