From 8803abfef367a4f6ca592242f5d903463c9c3c26 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:19:17 +0000 Subject: [PATCH 01/11] perf(quantize): scan color boxes once --- crates/maple-render-core/src/quantize.rs | 130 ++++++----------------- 1 file changed, 32 insertions(+), 98 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index 35ba9ad..5c6a064 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -197,113 +197,47 @@ impl Quantizer { } fn update_box(&self, boxp: &mut ColorBox) { - let mut c0min = boxp.c0min; - let mut c0max = boxp.c0max; - let mut c1min = boxp.c1min; - let mut c1max = boxp.c1max; - let mut c2min = boxp.c2min; - let mut c2max = boxp.c2max; - - if c0max > c0min { - 'outer: for c0 in c0min..=c0max { - for c1 in c1min..=c1max { - for c2 in c2min..=c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c0min = c0; - c0min = c0; - break 'outer; - } + let original = *boxp; + let mut occupied_c0min = original.c0max; + let mut occupied_c0max = original.c0min; + let mut occupied_c1min = original.c1max; + let mut occupied_c1max = original.c1min; + let mut occupied_c2min = original.c2max; + let mut occupied_c2max = original.c2min; + let mut colorcount = 0; + + for c0 in original.c0min..=original.c0max { + for c1 in original.c1min..=original.c1max { + for c2 in original.c2min..=original.c2max { + if self.histogram[c0 as usize][c1 as usize][c2 as usize] == 0 { + continue; } - } - } - } - if c0max > c0min { - 'outer: for c0 in (c0min..=c0max).rev() { - for c1 in c1min..=c1max { - for c2 in c2min..=c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c0max = c0; - c0max = c0; - break 'outer; - } - } + occupied_c0min = occupied_c0min.min(c0); + occupied_c0max = occupied_c0max.max(c0); + occupied_c1min = occupied_c1min.min(c1); + occupied_c1max = occupied_c1max.max(c1); + occupied_c2min = occupied_c2min.min(c2); + occupied_c2max = occupied_c2max.max(c2); + colorcount += 1; } } } - if c1max > c1min { - 'outer: for c1 in c1min..=c1max { - for c0 in c0min..=c0max { - for c2 in c2min..=c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c1min = c1; - c1min = c1; - break 'outer; - } - } - } - } - } - - if c1max > c1min { - 'outer: for c1 in (c1min..=c1max).rev() { - for c0 in c0min..=c0max { - for c2 in c2min..=c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c1max = c1; - c1max = c1; - break 'outer; - } - } - } - } + if colorcount != 0 { + boxp.c0min = occupied_c0min; + boxp.c0max = occupied_c0max; + boxp.c1min = occupied_c1min; + boxp.c1max = occupied_c1max; + boxp.c2min = occupied_c2min; + boxp.c2max = occupied_c2max; } - if c2max > c2min { - 'outer: for c2 in c2min..=c2max { - for c0 in c0min..=c0max { - for c1 in c1min..=c1max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c2min = c2; - c2min = c2; - break 'outer; - } - } - } - } - } - - if c2max > c2min { - 'outer: for c2 in (c2min..=c2max).rev() { - for c0 in c0min..=c0max { - for c1 in c1min..=c1max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - boxp.c2max = c2; - c2max = c2; - break 'outer; - } - } - } - } - } - - let dist0 = ((c0max - c0min) << C0_SHIFT) as i64 * C0_SCALE as i64; - let dist1 = ((c1max - c1min) << C1_SHIFT) as i64 * C1_SCALE as i64; - let dist2 = ((c2max - c2min) << C2_SHIFT) as i64 * C2_SCALE as i64; + let dist0 = ((boxp.c0max - boxp.c0min) << C0_SHIFT) as i64 * C0_SCALE as i64; + let dist1 = ((boxp.c1max - boxp.c1min) << C1_SHIFT) as i64 * C1_SCALE as i64; + let dist2 = ((boxp.c2max - boxp.c2min) << C2_SHIFT) as i64 * C2_SCALE as i64; boxp.volume = dist0 * dist0 + dist1 * dist1 + dist2 * dist2; - - let mut ccount: i64 = 0; - for c0 in c0min..=c0max { - for c1 in c1min..=c1max { - for c2 in c2min..=c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] != 0 { - ccount += 1; - } - } - } - } - boxp.colorcount = ccount; + boxp.colorcount = colorcount; } fn median_cut( From abb762ed32d5ef56600ba283a8ddb411394b106a Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:20:30 +0000 Subject: [PATCH 02/11] perf(quantize): flatten histogram storage --- crates/maple-render-core/src/quantize.rs | 50 +++++++++++++++--------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index 5c6a064..b39b5f3 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -8,6 +8,7 @@ const HIST_C2_BITS: usize = 5; // B const HIST_C0_ELEMS: usize = 1 << HIST_C0_BITS; const HIST_C1_ELEMS: usize = 1 << HIST_C1_BITS; const HIST_C2_ELEMS: usize = 1 << HIST_C2_BITS; +const HIST_ELEMS: usize = HIST_C0_ELEMS * HIST_C1_ELEMS * HIST_C2_ELEMS; const C0_SHIFT: usize = 8 - HIST_C0_BITS; const C1_SHIFT: usize = 8 - HIST_C1_BITS; @@ -85,7 +86,7 @@ struct ColorBox { } pub struct Quantizer { - histogram: Box<[[[u16; HIST_C2_ELEMS]; HIST_C1_ELEMS]; HIST_C0_ELEMS]>, + histogram: Box<[u16; HIST_ELEMS]>, fserrors: Vec, error_limiter: Vec, on_odd_row: bool, @@ -95,7 +96,7 @@ pub struct Quantizer { impl Quantizer { pub fn new(reference: &RgbaImage) -> Self { let mut q = Quantizer { - histogram: Box::new([[[0u16; HIST_C2_ELEMS]; HIST_C1_ELEMS]; HIST_C0_ELEMS]), + histogram: Box::new([0; HIST_ELEMS]), fserrors: Vec::new(), error_limiter: Vec::new(), on_odd_row: false, @@ -146,13 +147,12 @@ impl Quantizer { } fn zero_histogram(&mut self) { - for c0 in 0..HIST_C0_ELEMS { - for c1 in 0..HIST_C1_ELEMS { - for c2 in 0..HIST_C2_ELEMS { - self.histogram[c0][c1][c2] = 0; - } - } - } + self.histogram.fill(0); + } + + #[inline(always)] + const fn histogram_index(c0: usize, c1: usize, c2: usize) -> usize { + (c0 * HIST_C1_ELEMS + c1) * HIST_C2_ELEMS + c2 } fn prescan_quantize(&mut self, img: &RgbaImage) { @@ -161,7 +161,7 @@ impl Quantizer { let g = (pixel[1] as usize) >> C1_SHIFT; let b = (pixel[2] as usize) >> C2_SHIFT; - let cell = &mut self.histogram[r][g][b]; + let cell = &mut self.histogram[Self::histogram_index(r, g, b)]; if *cell < u16::MAX { *cell += 1; } @@ -206,13 +206,20 @@ impl Quantizer { let mut occupied_c2max = original.c2min; let mut colorcount = 0; + let c2min = original.c2min as usize; + let c2_len = (original.c2max - original.c2min + 1) as usize; + for c0 in original.c0min..=original.c0max { for c1 in original.c1min..=original.c1max { - for c2 in original.c2min..=original.c2max { - if self.histogram[c0 as usize][c1 as usize][c2 as usize] == 0 { + let row_start = Self::histogram_index(c0 as usize, c1 as usize, c2min); + let row = &self.histogram[row_start..row_start + c2_len]; + + for (c2_offset, &count) in row.iter().enumerate() { + if count == 0 { continue; } + let c2 = original.c2min + c2_offset as i32; occupied_c0min = occupied_c0min.min(c0); occupied_c0max = occupied_c0max.max(c0); occupied_c1min = occupied_c1min.min(c1); @@ -313,7 +320,9 @@ impl Quantizer { for c0 in boxp.c0min..=boxp.c0max { for c1 in boxp.c1min..=boxp.c1max { for c2 in boxp.c2min..=boxp.c2max { - let count = self.histogram[c0 as usize][c1 as usize][c2 as usize] as i64; + let count = self.histogram + [Self::histogram_index(c0 as usize, c1 as usize, c2 as usize)] + as i64; if count != 0 { total += count; c0total += ((c0 << C0_SHIFT) + (1 << (C0_SHIFT - 1))) as i64 * count; @@ -531,8 +540,9 @@ impl Quantizer { for ic0 in 0..BOX_C0_ELEMS { for ic1 in 0..BOX_C1_ELEMS { for ic2 in 0..BOX_C2_ELEMS { - self.histogram[base_c0 + ic0][base_c1 + ic1][base_c2 + ic2] = - bestcolor[cptr_idx] as u16 + 1; + let histogram_index = + Self::histogram_index(base_c0 + ic0, base_c1 + ic1, base_c2 + ic2); + self.histogram[histogram_index] = bestcolor[cptr_idx] as u16 + 1; cptr_idx += 1; } } @@ -554,10 +564,11 @@ impl Quantizer { let c1 = g >> C1_SHIFT; let c2 = b >> C2_SHIFT; - let mut cached = self.histogram[c0][c1][c2]; + let histogram_index = Self::histogram_index(c0, c1, c2); + let mut cached = self.histogram[histogram_index]; if cached == 0 { self.fill_inverse_cmap(c0 as i32, c1 as i32, c2 as i32); - cached = self.histogram[c0][c1][c2]; + cached = self.histogram[histogram_index]; } output[y * width + x] = (cached - 1) as u8; @@ -623,10 +634,11 @@ impl Quantizer { let c1 = (cur1 as usize) >> C1_SHIFT; let c2 = (cur2 as usize) >> C2_SHIFT; - let mut cached = self.histogram[c0][c1][c2]; + let histogram_index = Self::histogram_index(c0, c1, c2); + let mut cached = self.histogram[histogram_index]; if cached == 0 { self.fill_inverse_cmap(c0 as i32, c1 as i32, c2 as i32); - cached = self.histogram[c0][c1][c2]; + cached = self.histogram[histogram_index]; } let pixcode = (cached - 1) as usize; From 040cf7a8cd368b0166baff3da592a2a6dd77cf8e Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:22:02 +0000 Subject: [PATCH 03/11] perf(render): keep sampled colors premultiplied --- crates/maple-render-core/src/pixer.rs | 78 ++++++++++++++++++++++++++ crates/maple-render-core/src/render.rs | 61 +++++++++----------- 2 files changed, 106 insertions(+), 33 deletions(-) diff --git a/crates/maple-render-core/src/pixer.rs b/crates/maple-render-core/src/pixer.rs index 0deb192..1b2ef79 100644 --- a/crates/maple-render-core/src/pixer.rs +++ b/crates/maple-render-core/src/pixer.rs @@ -231,6 +231,84 @@ pub fn sample_linear(img: &RgbaImage, x: f64, y: f64) -> Pixer { } } +/// Bilinear interpolation in premultiplied-alpha space. +/// +/// The sampled compositor combines several neighboring samples before +/// converting back to straight alpha. Keeping RGB premultiplied here avoids a +/// divide followed immediately by a multiply for every tap. +#[inline(always)] +pub(crate) fn sample_linear_premultiplied(img: &RgbaImage, x: f64, y: f64) -> Pixer { + let xx = x.floor() as i32; + let yy = y.floor() as i32; + let fx = x - xx as f64; + let fy = y - yy as f64; + + let w00 = ((1.0 - fx) * (1.0 - fy)) as f32; + let w10 = (fx * (1.0 - fy)) as f32; + let w01 = ((1.0 - fx) * fy) as f32; + let w11 = (fx * fy) as f32; + + let w = img.width() as i32; + let h = img.height() as i32; + + if xx >= 0 && yy >= 0 && xx + 1 < w && yy + 1 < h { + let raw = img.as_raw(); + let row_stride = w as usize * 4; + + let i00 = yy as usize * row_stride + xx as usize * 4; + let i10 = i00 + 4; + let i01 = i00 + row_stride; + let i11 = i01 + 4; + + let a00 = raw[i00 + 3] as f32; + let a10 = raw[i10 + 3] as f32; + let a01 = raw[i01 + 3] as f32; + let a11 = raw[i11 + 3] as f32; + + return Pixer { + r: w00 * raw[i00] as f32 * a00 + + w10 * raw[i10] as f32 * a10 + + w01 * raw[i01] as f32 * a01 + + w11 * raw[i11] as f32 * a11, + g: w00 * raw[i00 + 1] as f32 * a00 + + w10 * raw[i10 + 1] as f32 * a10 + + w01 * raw[i01 + 1] as f32 * a01 + + w11 * raw[i11 + 1] as f32 * a11, + b: w00 * raw[i00 + 2] as f32 * a00 + + w10 * raw[i10 + 2] as f32 * a10 + + w01 * raw[i01 + 2] as f32 * a01 + + w11 * raw[i11 + 2] as f32 * a11, + a: w00 * a00 + w10 * a10 + w01 * a01 + w11 * a11, + }; + } + + let p00 = safe_pixel(img, xx, yy); + let p10 = safe_pixel(img, xx + 1, yy); + let p01 = safe_pixel(img, xx, yy + 1); + let p11 = safe_pixel(img, xx + 1, yy + 1); + + let a00 = p00[3] as f32; + let a10 = p10[3] as f32; + let a01 = p01[3] as f32; + let a11 = p11[3] as f32; + + Pixer { + r: w00 * p00[0] as f32 * a00 + + w10 * p10[0] as f32 * a10 + + w01 * p01[0] as f32 * a01 + + w11 * p11[0] as f32 * a11, + g: w00 * p00[1] as f32 * a00 + + w10 * p10[1] as f32 * a10 + + w01 * p01[1] as f32 * a01 + + w11 * p11[1] as f32 * a11, + b: w00 * p00[2] as f32 * a00 + + w10 * p10[2] as f32 * a10 + + w01 * p01[2] as f32 * a01 + + w11 * p11[2] as f32 * a11, + a: w00 * a00 + w10 * a10 + w01 * a01 + w11 * a11, + } +} + pub fn sample_weakly(img: &RgbaImage, x: f64, y: f64) -> Pixer { Pixer::from_rgba(&safe_pixel(img, x as i32, y as i32)) } diff --git a/crates/maple-render-core/src/render.rs b/crates/maple-render-core/src/render.rs index 98e63dd..3a670c3 100644 --- a/crates/maple-render-core/src/render.rs +++ b/crates/maple-render-core/src/render.rs @@ -8,7 +8,7 @@ use crate::{ error::{Error, Result}, input::{Input, Inputs}, mapping::Mapping, - pixer::{Pixer, sample_linear}, + pixer::{Pixer, sample_linear_premultiplied}, }; static RENDER_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -202,44 +202,39 @@ impl Render { let xxb = input.in_x0 + active_scale * (xxb_rot + RR + input.xo) / RR - xx; let yyb = input.in_y0 + active_scale * (yyb_rot + RR + input.yo) / RR - yy; - let mut mo = sample_linear(input_img, xx, yy); - let m2 = sample_linear(input_img, xx + xxa / 2.0, yy + yya / 2.0); - let m3 = sample_linear(input_img, xx - xxa / 2.0, yy - yya / 2.0); - let m4 = sample_linear(input_img, xx + xxb / 2.0, yy + yyb / 2.0); - let m5 = sample_linear(input_img, xx - xxb / 2.0, yy - yyb / 2.0); - let m2b = sample_linear(input_img, xx + (xxa + xxb) / 2.0, yy + (yya + yyb) / 2.0); - let m3b = sample_linear(input_img, xx + (xxa - xxb) / 2.0, yy + (yya - yyb) / 2.0); - let m4b = sample_linear(input_img, xx - (xxa + xxb) / 2.0, yy - (yya + yyb) / 2.0); - let m5b = sample_linear(input_img, xx - (xxa - xxb) / 2.0, yy - (yya - yyb) / 2.0); - - let mut mo_p = mo; - mo_p.preblend(); - let mut m2_p = m2; - m2_p.preblend(); - let mut m3_p = m3; - m3_p.preblend(); - let mut m4_p = m4; - m4_p.preblend(); - let mut m5_p = m5; - m5_p.preblend(); - let mut m2b_p = m2b; - m2b_p.preblend(); - let mut m3b_p = m3b; - m3b_p.preblend(); - let mut m4b_p = m4b; - m4b_p.preblend(); - let mut m5b_p = m5b; - m5b_p.preblend(); + let mo = sample_linear_premultiplied(input_img, xx, yy); + let m2 = sample_linear_premultiplied(input_img, xx + xxa / 2.0, yy + yya / 2.0); + let m3 = sample_linear_premultiplied(input_img, xx - xxa / 2.0, yy - yya / 2.0); + let m4 = sample_linear_premultiplied(input_img, xx + xxb / 2.0, yy + yyb / 2.0); + let m5 = sample_linear_premultiplied(input_img, xx - xxb / 2.0, yy - yyb / 2.0); + let m2b = sample_linear_premultiplied( + input_img, + xx + (xxa + xxb) / 2.0, + yy + (yya + yyb) / 2.0, + ); + let m3b = sample_linear_premultiplied( + input_img, + xx + (xxa - xxb) / 2.0, + yy + (yya - yyb) / 2.0, + ); + let m4b = sample_linear_premultiplied( + input_img, + xx - (xxa + xxb) / 2.0, + yy - (yya + yyb) / 2.0, + ); + let m5b = sample_linear_premultiplied( + input_img, + xx - (xxa - xxb) / 2.0, + yy - (yya - yyb) / 2.0, + ); let sc = (mo.a * 4.0 + (m2.a + m3.a + m4.a + m5.a) * 2.0 + (m2b.a + m3b.a + m4b.a + m5b.a)) / 16.0; - mo = (mo_p * 4.0 - + (m2_p + m3_p + m4_p + m5_p) * 2.0 - + (m2b_p + m3b_p + m4b_p + m5b_p)) - / 16.0; + let mut mo = + (mo * 4.0 + (m2 + m3 + m4 + m5) * 2.0 + (m2b + m3b + m4b + m5b)) / 16.0; if sc > 0.0001 { mo.postblend(sc); From 4723039243cbd937d4603947fd516db711291413 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:24:18 +0000 Subject: [PATCH 04/11] perf(render): specialize sampling for opaque inputs --- crates/maple-render-core/src/input.rs | 31 +++++++- crates/maple-render-core/src/pixer.rs | 100 +++++++++++++++++++++++++ crates/maple-render-core/src/render.rs | 85 ++++++++++++++------- 3 files changed, 188 insertions(+), 28 deletions(-) diff --git a/crates/maple-render-core/src/input.rs b/crates/maple-render-core/src/input.rs index ccc7796..a056298 100644 --- a/crates/maple-render-core/src/input.rs +++ b/crates/maple-render-core/src/input.rs @@ -28,6 +28,7 @@ impl Default for TextOptions { #[derive(Clone)] pub struct Input { image: RgbaImage, + opaque: bool, pub layer: u8, pub xs: f64, pub ys: f64, @@ -44,6 +45,7 @@ impl Default for Input { fn default() -> Self { Input { image: RgbaImage::new(1, 1), + opaque: false, layer: 1, xs: 1.0, ys: 1.0, @@ -75,9 +77,10 @@ impl Input { } let img = image::open(path)?; + let opaque = !img.color().has_alpha(); let rgba = img.to_rgba8(); - let mut input = Input { image: rgba, layer, ..Default::default() }; + let mut input = Input { image: rgba, opaque, layer, ..Default::default() }; input.compute_scale_params(); @@ -85,7 +88,8 @@ impl Input { } pub fn from_image(image: RgbaImage, layer: u8) -> Self { - let mut input = Input { image, layer, ..Default::default() }; + let opaque = image.pixels().all(|pixel| pixel[3] == 255); + let mut input = Input { image, opaque, layer, ..Default::default() }; input.compute_scale_params(); input } @@ -146,9 +150,14 @@ impl Input { } pub fn get_mut(&mut self) -> &mut RgbaImage { + self.opaque = false; &mut self.image } + pub fn is_opaque(&self) -> bool { + self.opaque + } + pub fn width(&self) -> u32 { self.image.width() } @@ -231,3 +240,21 @@ impl std::ops::IndexMut for Inputs { &mut self.data[index] } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opacity_is_detected_and_invalidated_by_mutable_access() { + let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 255])); + let mut input = Input::from_image(image, 1); + assert!(input.is_opaque()); + + input.get_mut().put_pixel(0, 0, Rgba([10, 20, 30, 255])); + assert!(!input.is_opaque()); + + let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 254])); + assert!(!Input::from_image(image, 1).is_opaque()); + } +} diff --git a/crates/maple-render-core/src/pixer.rs b/crates/maple-render-core/src/pixer.rs index 1b2ef79..1d5d783 100644 --- a/crates/maple-render-core/src/pixer.rs +++ b/crates/maple-render-core/src/pixer.rs @@ -309,6 +309,79 @@ pub(crate) fn sample_linear_premultiplied(img: &RgbaImage, x: f64, y: f64) -> Pi } } +/// Bilinear interpolation for an image known to contain only opaque pixels. +/// +/// This preserves the general sampler's floating-point operation order while +/// replacing four alpha loads with the known value `255`. +#[inline(always)] +pub(crate) fn sample_linear_opaque(img: &RgbaImage, x: f64, y: f64) -> Pixer { + let xx = x.floor() as i32; + let yy = y.floor() as i32; + let fx = x - xx as f64; + let fy = y - yy as f64; + + let w00 = ((1.0 - fx) * (1.0 - fy)) as f32; + let w10 = (fx * (1.0 - fy)) as f32; + let w01 = ((1.0 - fx) * fy) as f32; + let w11 = (fx * fy) as f32; + + let w = img.width() as i32; + let h = img.height() as i32; + + if xx >= 0 && yy >= 0 && xx + 1 < w && yy + 1 < h { + let raw = img.as_raw(); + let row_stride = w as usize * 4; + + let i00 = yy as usize * row_stride + xx as usize * 4; + let i10 = i00 + 4; + let i01 = i00 + row_stride; + let i11 = i01 + 4; + + let alpha = 255.0; + + return Pixer { + r: w00 * raw[i00] as f32 * alpha + + w10 * raw[i10] as f32 * alpha + + w01 * raw[i01] as f32 * alpha + + w11 * raw[i11] as f32 * alpha, + g: w00 * raw[i00 + 1] as f32 * alpha + + w10 * raw[i10 + 1] as f32 * alpha + + w01 * raw[i01 + 1] as f32 * alpha + + w11 * raw[i11 + 1] as f32 * alpha, + b: w00 * raw[i00 + 2] as f32 * alpha + + w10 * raw[i10 + 2] as f32 * alpha + + w01 * raw[i01 + 2] as f32 * alpha + + w11 * raw[i11 + 2] as f32 * alpha, + a: w00 * alpha + w10 * alpha + w01 * alpha + w11 * alpha, + }; + } + + let p00 = safe_pixel(img, xx, yy); + let p10 = safe_pixel(img, xx + 1, yy); + let p01 = safe_pixel(img, xx, yy + 1); + let p11 = safe_pixel(img, xx + 1, yy + 1); + let a00 = p00[3] as f32; + let a10 = p10[3] as f32; + let a01 = p01[3] as f32; + let a11 = p11[3] as f32; + + Pixer { + r: w00 * p00[0] as f32 * a00 + + w10 * p10[0] as f32 * a10 + + w01 * p01[0] as f32 * a01 + + w11 * p11[0] as f32 * a11, + g: w00 * p00[1] as f32 * a00 + + w10 * p10[1] as f32 * a10 + + w01 * p01[1] as f32 * a01 + + w11 * p11[1] as f32 * a11, + b: w00 * p00[2] as f32 * a00 + + w10 * p10[2] as f32 * a10 + + w01 * p01[2] as f32 * a01 + + w11 * p11[2] as f32 * a11, + a: w00 * a00 + w10 * a10 + w01 * a01 + w11 * a11, + } +} + pub fn sample_weakly(img: &RgbaImage, x: f64, y: f64) -> Pixer { Pixer::from_rgba(&safe_pixel(img, x as i32, y as i32)) } @@ -337,4 +410,31 @@ mod tests { assert_eq!(clamp_u8(128.0), 128); assert_eq!(clamp_u8(300.0), 255); } + + #[test] + fn opaque_sampler_matches_general_premultiplied_sampler() { + let mut image = RgbaImage::new(3, 2); + for (i, pixel) in image.pixels_mut().enumerate() { + let value = u8::try_from(i * 31).expect("test value fits in u8"); + *pixel = Rgba([value, value.wrapping_add(17), value.wrapping_add(83), 255]); + } + + for (x, y) in [ + (-1.25, -0.5), + (-0.25, 0.25), + (0.0, 0.0), + (0.3, 0.7), + (1.5, 0.25), + (2.0, 1.0), + (2.75, 1.75), + (4.0, 4.0), + ] { + let general = sample_linear_premultiplied(&image, x, y); + let opaque = sample_linear_opaque(&image, x, y); + assert_eq!(general.r.to_bits(), opaque.r.to_bits()); + assert_eq!(general.g.to_bits(), opaque.g.to_bits()); + assert_eq!(general.b.to_bits(), opaque.b.to_bits()); + assert_eq!(general.a.to_bits(), opaque.a.to_bits()); + } + } } diff --git a/crates/maple-render-core/src/render.rs b/crates/maple-render-core/src/render.rs index 3a670c3..b91456c 100644 --- a/crates/maple-render-core/src/render.rs +++ b/crates/maple-render-core/src/render.rs @@ -8,7 +8,7 @@ use crate::{ error::{Error, Result}, input::{Input, Inputs}, mapping::Mapping, - pixer::{Pixer, sample_linear_premultiplied}, + pixer::{Pixer, sample_linear_opaque, sample_linear_premultiplied}, }; static RENDER_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -90,6 +90,7 @@ impl Render { let out_w = self.out.width() as usize; let _out_h = self.out.height() as usize; let input_img = input.get(); + let input_opaque = input.is_opaque(); let light_raw = mapping.light.as_raw(); let dark_raw = mapping.dark.as_raw(); @@ -202,31 +203,63 @@ impl Render { let xxb = input.in_x0 + active_scale * (xxb_rot + RR + input.xo) / RR - xx; let yyb = input.in_y0 + active_scale * (yyb_rot + RR + input.yo) / RR - yy; - let mo = sample_linear_premultiplied(input_img, xx, yy); - let m2 = sample_linear_premultiplied(input_img, xx + xxa / 2.0, yy + yya / 2.0); - let m3 = sample_linear_premultiplied(input_img, xx - xxa / 2.0, yy - yya / 2.0); - let m4 = sample_linear_premultiplied(input_img, xx + xxb / 2.0, yy + yyb / 2.0); - let m5 = sample_linear_premultiplied(input_img, xx - xxb / 2.0, yy - yyb / 2.0); - let m2b = sample_linear_premultiplied( - input_img, - xx + (xxa + xxb) / 2.0, - yy + (yya + yyb) / 2.0, - ); - let m3b = sample_linear_premultiplied( - input_img, - xx + (xxa - xxb) / 2.0, - yy + (yya - yyb) / 2.0, - ); - let m4b = sample_linear_premultiplied( - input_img, - xx - (xxa + xxb) / 2.0, - yy - (yya + yyb) / 2.0, - ); - let m5b = sample_linear_premultiplied( - input_img, - xx - (xxa - xxb) / 2.0, - yy - (yya - yyb) / 2.0, - ); + let (mo, m2, m3, m4, m5, m2b, m3b, m4b, m5b) = if input_opaque { + ( + sample_linear_opaque(input_img, xx, yy), + sample_linear_opaque(input_img, xx + xxa / 2.0, yy + yya / 2.0), + sample_linear_opaque(input_img, xx - xxa / 2.0, yy - yya / 2.0), + sample_linear_opaque(input_img, xx + xxb / 2.0, yy + yyb / 2.0), + sample_linear_opaque(input_img, xx - xxb / 2.0, yy - yyb / 2.0), + sample_linear_opaque( + input_img, + xx + (xxa + xxb) / 2.0, + yy + (yya + yyb) / 2.0, + ), + sample_linear_opaque( + input_img, + xx + (xxa - xxb) / 2.0, + yy + (yya - yyb) / 2.0, + ), + sample_linear_opaque( + input_img, + xx - (xxa + xxb) / 2.0, + yy - (yya + yyb) / 2.0, + ), + sample_linear_opaque( + input_img, + xx - (xxa - xxb) / 2.0, + yy - (yya - yyb) / 2.0, + ), + ) + } else { + ( + sample_linear_premultiplied(input_img, xx, yy), + sample_linear_premultiplied(input_img, xx + xxa / 2.0, yy + yya / 2.0), + sample_linear_premultiplied(input_img, xx - xxa / 2.0, yy - yya / 2.0), + sample_linear_premultiplied(input_img, xx + xxb / 2.0, yy + yyb / 2.0), + sample_linear_premultiplied(input_img, xx - xxb / 2.0, yy - yyb / 2.0), + sample_linear_premultiplied( + input_img, + xx + (xxa + xxb) / 2.0, + yy + (yya + yyb) / 2.0, + ), + sample_linear_premultiplied( + input_img, + xx + (xxa - xxb) / 2.0, + yy + (yya - yyb) / 2.0, + ), + sample_linear_premultiplied( + input_img, + xx - (xxa + xxb) / 2.0, + yy - (yya + yyb) / 2.0, + ), + sample_linear_premultiplied( + input_img, + xx - (xxa - xxb) / 2.0, + yy - (yya - yyb) / 2.0, + ), + ) + }; let sc = (mo.a * 4.0 + (m2.a + m3.a + m4.a + m5.a) * 2.0 From e52332aff3da7d526360b0e71b2db0c68f29b416 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:24:39 +0000 Subject: [PATCH 05/11] perf(input): skip opacity scan for opaque text --- crates/maple-render-core/src/input.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/maple-render-core/src/input.rs b/crates/maple-render-core/src/input.rs index a056298..296a3d6 100644 --- a/crates/maple-render-core/src/input.rs +++ b/crates/maple-render-core/src/input.rs @@ -123,7 +123,11 @@ impl Input { draw_text_mut(&mut image, options.color, x, y, scale, &font, text); - Ok(Input::from_image(image, layer)) + let opaque = options.color[3] == 255 && options.background[3] == 255; + let mut input = Input { image, opaque, layer, ..Default::default() }; + input.compute_scale_params(); + + Ok(input) } fn compute_scale_params(&mut self) { @@ -257,4 +261,21 @@ mod tests { let image = RgbaImage::from_pixel(2, 2, Rgba([10, 20, 30, 254])); assert!(!Input::from_image(image, 1).is_opaque()); } + + #[test] + fn text_opacity_follows_foreground_and_background_alpha() { + let font = include_bytes!("../../../fonts/DejaVuSans-Bold.ttf"); + let opaque = Input::from_text("opaque", 1, &TextOptions::default(), font) + .expect("render opaque text"); + assert!(opaque.is_opaque()); + + for options in [ + TextOptions { color: Rgba([0, 0, 0, 254]), ..Default::default() }, + TextOptions { background: Rgba([255, 255, 255, 254]), ..Default::default() }, + ] { + let transparent = + Input::from_text("transparent", 1, &options, font).expect("render text"); + assert!(!transparent.is_opaque()); + } + } } From 998b3452d30e7e1be4f2700028632c529fa02f8a Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 04:31:36 +0000 Subject: [PATCH 06/11] perf(quantize): hoist box bounds out of the histogram scan --- crates/maple-render-core/src/quantize.rs | 45 ++++++++++++++++-------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index b39b5f3..a85ecad 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -206,27 +206,44 @@ impl Quantizer { let mut occupied_c2max = original.c2min; let mut colorcount = 0; - let c2min = original.c2min as usize; let c2_len = (original.c2max - original.c2min + 1) as usize; for c0 in original.c0min..=original.c0max { for c1 in original.c1min..=original.c1max { - let row_start = Self::histogram_index(c0 as usize, c1 as usize, c2min); + let row_start = + Self::histogram_index(c0 as usize, c1 as usize, original.c2min as usize); let row = &self.histogram[row_start..row_start + c2_len]; - for (c2_offset, &count) in row.iter().enumerate() { - if count == 0 { - continue; + // Counting the occupied cells is the whole hot loop of the + // palette build, so it stays a plain reduction the compiler can + // vectorize. Widening the six bounds cell by cell instead chains + // as many dependent min/max on every occupied cell, which is + // what made this scan slower than the passes it replaced. + let occupied = row.iter().filter(|&&count| count != 0).count(); + if occupied == 0 { + continue; + } + colorcount += occupied as i64; + + // A row contributes the same c0/c1 whatever its occupancy, so + // those bounds only have to be widened once per non-empty row. + occupied_c0min = occupied_c0min.min(c0); + occupied_c0max = occupied_c0max.max(c0); + occupied_c1min = occupied_c1min.min(c1); + occupied_c1max = occupied_c1max.max(c1); + + // Only the first and last occupied cell of the row can move the + // c2 bounds, and neither is worth looking for once the bound + // already reaches the edge of the box. + if occupied_c2min > original.c2min { + if let Some(first) = row.iter().position(|&count| count != 0) { + occupied_c2min = occupied_c2min.min(original.c2min + first as i32); + } + } + if occupied_c2max < original.c2max { + if let Some(last) = row.iter().rposition(|&count| count != 0) { + occupied_c2max = occupied_c2max.max(original.c2min + last as i32); } - - let c2 = original.c2min + c2_offset as i32; - occupied_c0min = occupied_c0min.min(c0); - occupied_c0max = occupied_c0max.max(c0); - occupied_c1min = occupied_c1min.min(c1); - occupied_c1max = occupied_c1max.max(c1); - occupied_c2min = occupied_c2min.min(c2); - occupied_c2max = occupied_c2max.max(c2); - colorcount += 1; } } } From fb64ae6c2476bbb6e036193601c118609efc71ec Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 06:26:08 +0000 Subject: [PATCH 07/11] perf(quantize): drive the median cut from an occupancy bitmap HIST_C2_ELEMS is exactly 32, so the occupancy of one (c0, c1) histogram row fits in a single u32 and the whole 128 KB histogram condenses to an 8 KB bitmap that stays in L1 for the entire median cut. The histogram is immutable while boxes are split, so the bitmap is built once after the prescan and stays valid across all 255 splits. update_box now scans a box with one masked load, a popcount and two ORs per row instead of walking up to 32 histogram cells, and reads the six box bounds off c0/c1/c2 occupancy words at the end rather than widening them as it goes. compute_color walks the set bits of each row, so it never touches an empty cell. Palette output is unchanged. build_palette: 284.7 us -> 217.2 us median. --- crates/maple-render-core/src/quantize.rs | 152 +++++++++++++---------- 1 file changed, 89 insertions(+), 63 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index a85ecad..9e4eaf5 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -73,6 +73,48 @@ impl Palette { } } +/// Bitmap of the occupied histogram cells, one `u32` per `(c0, c1)` row. +/// +/// `HIST_C2_ELEMS` is exactly 32, so a whole row of the histogram fits in a +/// single word and the 128 KB histogram condenses to 8 KB that stays in L1 for +/// the entire median cut. The histogram is immutable while boxes are being +/// split, so the bitmap is built once and stays valid for every split. +struct Occupancy { + rows: Box<[u32; HIST_C0_ELEMS * HIST_C1_ELEMS]>, +} + +const _: () = assert!(HIST_C2_ELEMS == u32::BITS as usize); +const _: () = assert!(HIST_C0_ELEMS <= u32::BITS as usize); +const _: () = assert!(HIST_C1_ELEMS <= u64::BITS as usize); + +impl Occupancy { + fn from_histogram(histogram: &[u16; HIST_ELEMS]) -> Self { + let mut rows = Box::new([0u32; HIST_C0_ELEMS * HIST_C1_ELEMS]); + + for (bits, cells) in rows.iter_mut().zip(histogram.chunks_exact(HIST_C2_ELEMS)) { + let mut row = 0u32; + for (c2, &count) in cells.iter().enumerate() { + row |= ((count != 0) as u32) << c2; + } + *bits = row; + } + + Occupancy { rows } + } + + #[inline(always)] + fn row(&self, c0: i32, c1: i32) -> u32 { + self.rows[c0 as usize * HIST_C1_ELEMS + c1 as usize] + } + + /// Mask of the `c2` bits a box spans. + #[inline(always)] + fn c2_mask(c2min: i32, c2max: i32) -> u32 { + let width = (c2max - c2min + 1) as u32; + if width >= u32::BITS { u32::MAX } else { ((1u32 << width) - 1) << c2min } + } +} + #[derive(Clone, Copy, Default)] struct ColorBox { c0min: i32, @@ -196,65 +238,40 @@ impl Quantizer { which } - fn update_box(&self, boxp: &mut ColorBox) { + fn update_box(&self, occupancy: &Occupancy, boxp: &mut ColorBox) { let original = *boxp; - let mut occupied_c0min = original.c0max; - let mut occupied_c0max = original.c0min; - let mut occupied_c1min = original.c1max; - let mut occupied_c1max = original.c1min; - let mut occupied_c2min = original.c2max; - let mut occupied_c2max = original.c2min; - let mut colorcount = 0; + let mask = Occupancy::c2_mask(original.c2min, original.c2max); - let c2_len = (original.c2max - original.c2min + 1) as usize; + // The scan is entirely branch-free: every row of the box is one masked + // load, a popcount and two ORs. Rather than widening six bounds as it + // goes, it collects which c0/c1/c2 coordinates are occupied as bitmaps + // and reads the bounds off them once at the end. + let mut colorcount: i64 = 0; + let mut c0_used: u32 = 0; + let mut c1_used: u64 = 0; + let mut c2_used: u32 = 0; for c0 in original.c0min..=original.c0max { + let mut plane: u32 = 0; + for c1 in original.c1min..=original.c1max { - let row_start = - Self::histogram_index(c0 as usize, c1 as usize, original.c2min as usize); - let row = &self.histogram[row_start..row_start + c2_len]; - - // Counting the occupied cells is the whole hot loop of the - // palette build, so it stays a plain reduction the compiler can - // vectorize. Widening the six bounds cell by cell instead chains - // as many dependent min/max on every occupied cell, which is - // what made this scan slower than the passes it replaced. - let occupied = row.iter().filter(|&&count| count != 0).count(); - if occupied == 0 { - continue; - } - colorcount += occupied as i64; - - // A row contributes the same c0/c1 whatever its occupancy, so - // those bounds only have to be widened once per non-empty row. - occupied_c0min = occupied_c0min.min(c0); - occupied_c0max = occupied_c0max.max(c0); - occupied_c1min = occupied_c1min.min(c1); - occupied_c1max = occupied_c1max.max(c1); - - // Only the first and last occupied cell of the row can move the - // c2 bounds, and neither is worth looking for once the bound - // already reaches the edge of the box. - if occupied_c2min > original.c2min { - if let Some(first) = row.iter().position(|&count| count != 0) { - occupied_c2min = occupied_c2min.min(original.c2min + first as i32); - } - } - if occupied_c2max < original.c2max { - if let Some(last) = row.iter().rposition(|&count| count != 0) { - occupied_c2max = occupied_c2max.max(original.c2min + last as i32); - } - } + let row = occupancy.row(c0, c1) & mask; + colorcount += row.count_ones() as i64; + plane |= row; + c1_used |= ((row != 0) as u64) << c1; } + + c2_used |= plane; + c0_used |= ((plane != 0) as u32) << c0; } if colorcount != 0 { - boxp.c0min = occupied_c0min; - boxp.c0max = occupied_c0max; - boxp.c1min = occupied_c1min; - boxp.c1max = occupied_c1max; - boxp.c2min = occupied_c2min; - boxp.c2max = occupied_c2max; + boxp.c0min = c0_used.trailing_zeros() as i32; + boxp.c0max = (u32::BITS - 1 - c0_used.leading_zeros()) as i32; + boxp.c1min = c1_used.trailing_zeros() as i32; + boxp.c1max = (u64::BITS - 1 - c1_used.leading_zeros()) as i32; + boxp.c2min = c2_used.trailing_zeros() as i32; + boxp.c2max = (u32::BITS - 1 - c2_used.leading_zeros()) as i32; } let dist0 = ((boxp.c0max - boxp.c0min) << C0_SHIFT) as i64 * C0_SCALE as i64; @@ -266,6 +283,7 @@ impl Quantizer { fn median_cut( &self, + occupancy: &Occupancy, boxlist: &mut [ColorBox], mut numboxes: usize, desired_colors: usize, @@ -320,32 +338,38 @@ impl Quantizer { _ => unreachable!(), } - self.update_box(&mut boxlist[b1_idx]); - self.update_box(&mut boxlist[b2_idx]); + self.update_box(occupancy, &mut boxlist[b1_idx]); + self.update_box(occupancy, &mut boxlist[b2_idx]); numboxes += 1; } numboxes } - fn compute_color(&self, boxp: &ColorBox) -> (u8, u8, u8) { + fn compute_color(&self, occupancy: &Occupancy, boxp: &ColorBox) -> (u8, u8, u8) { let mut total: i64 = 0; let mut c0total: i64 = 0; let mut c1total: i64 = 0; let mut c2total: i64 = 0; + let mask = Occupancy::c2_mask(boxp.c2min, boxp.c2max); + for c0 in boxp.c0min..=boxp.c0max { for c1 in boxp.c1min..=boxp.c1max { - for c2 in boxp.c2min..=boxp.c2max { + // Walking the set bits visits only the occupied cells, so the + // empty ones never reach the histogram at all. + let mut row = occupancy.row(c0, c1) & mask; + while row != 0 { + let c2 = row.trailing_zeros() as i32; + row &= row - 1; + let count = self.histogram [Self::histogram_index(c0 as usize, c1 as usize, c2 as usize)] as i64; - if count != 0 { - total += count; - c0total += ((c0 << C0_SHIFT) + (1 << (C0_SHIFT - 1))) as i64 * count; - c1total += ((c1 << C1_SHIFT) + (1 << (C1_SHIFT - 1))) as i64 * count; - c2total += ((c2 << C2_SHIFT) + (1 << (C2_SHIFT - 1))) as i64 * count; - } + total += count; + c0total += ((c0 << C0_SHIFT) + (1 << (C0_SHIFT - 1))) as i64 * count; + c1total += ((c1 << C1_SHIFT) + (1 << (C1_SHIFT - 1))) as i64 * count; + c2total += ((c2 << C2_SHIFT) + (1 << (C2_SHIFT - 1))) as i64 * count; } } } @@ -376,11 +400,13 @@ impl Quantizer { colorcount: 0, }; - self.update_box(&mut boxlist[0]); - let numboxes = self.median_cut(&mut boxlist, 1, desired_colors); + let occupancy = Occupancy::from_histogram(&self.histogram); + + self.update_box(&occupancy, &mut boxlist[0]); + let numboxes = self.median_cut(&occupancy, &mut boxlist, 1, desired_colors); for i in 0..numboxes { - let (r, g, b) = self.compute_color(&boxlist[i]); + let (r, g, b) = self.compute_color(&occupancy, &boxlist[i]); self.palette.set(i, r, g, b); } self.palette.colors_total = numboxes; From 771763eff42fd1dff7741a3722a16e7e221f02bc Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 06:54:53 +0000 Subject: [PATCH 08/11] perf(quantize): split the histogram scatter into two lanes The per-pixel arithmetic of the prescan is trivial; the cost is the scatter into the histogram. Neighbouring pixels of a photo overwhelmingly land in the same cell, so a single table turns the whole scan into one long chain of store-to-load forwarded increments. Scattering even and odd pixels into two separate tables halves that chain, and folding the two tables back together afterwards is a linear, vectorizable pass. Four lanes was measured too: the larger working set costs more than the shorter chain wins. Batching the index computation ahead of the increments (so the loads overlap) was measured as well and is a regression here - the increments still serialize because the compiler cannot prove the batched indices are distinct, so it only adds the cost of staging the indices. Counts are unchanged: each lane saturates at u16::MAX and so does the fold, so a cell that would have saturated still reads u16::MAX. build_palette: 217.2 us -> 202.1 us median. --- crates/maple-render-core/src/quantize.rs | 49 +++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index 9e4eaf5..d79abc8 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -197,17 +197,56 @@ impl Quantizer { (c0 * HIST_C1_ELEMS + c1) * HIST_C2_ELEMS + c2 } + #[inline(always)] + fn histogram_index_of(pixel: &[u8; 4]) -> usize { + Self::histogram_index( + (pixel[0] as usize) >> C0_SHIFT, + (pixel[1] as usize) >> C1_SHIFT, + (pixel[2] as usize) >> C2_SHIFT, + ) + } + fn prescan_quantize(&mut self, img: &RgbaImage) { - for pixel in img.pixels() { - let r = (pixel[0] as usize) >> C0_SHIFT; - let g = (pixel[1] as usize) >> C1_SHIFT; - let b = (pixel[2] as usize) >> C2_SHIFT; + // The arithmetic per pixel is trivial; what costs is the scatter into + // the histogram. Neighbouring pixels of a photo usually land in the + // same cell, so a single table turns the scan into one long chain of + // store-to-load forwarded increments. Scattering even and odd pixels + // into two tables halves that chain; the tables are folded back + // together afterwards in one linear pass. + // + // Two lanes is the sweet spot: four makes the working set larger than + // the level of cache that keeps up, and loses more than the shorter + // chain wins. + let (pairs, tail) = img.as_raw().as_chunks::<8>(); + let mut odd_counts = vec![0u16; HIST_ELEMS]; + + for pair in pairs { + let (pixels, _) = pair.as_chunks::<4>(); + let even = Self::histogram_index_of(&pixels[0]); + let odd = Self::histogram_index_of(&pixels[1]); + + let cell = &mut self.histogram[even]; + if *cell < u16::MAX { + *cell += 1; + } - let cell = &mut self.histogram[Self::histogram_index(r, g, b)]; + let cell = &mut odd_counts[odd]; if *cell < u16::MAX { *cell += 1; } } + + for pixel in tail.as_chunks::<4>().0 { + let cell = &mut self.histogram[Self::histogram_index_of(pixel)]; + if *cell < u16::MAX { + *cell += 1; + } + } + + // Folding the two halves back together is a linear, vectorizable pass. + for (cell, odd) in self.histogram.iter_mut().zip(odd_counts.iter()) { + *cell = cell.saturating_add(*odd); + } } fn find_biggest_color_pop(boxlist: &[ColorBox], numboxes: usize) -> Option { From 7545ee6a8838804a0d70f504fdc3239afc3e3e31 Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 07:17:08 +0000 Subject: [PATCH 09/11] perf(quantize): build the inverse colormap in i32 with stack scratch A weighted squared distance in this quantizer never exceeds (255*3)^2 * 3, so the whole inverse-colormap computation fits in an i32. Running it in i64 was doubling the traffic on the 128-entry min-reduce for nothing. The winning color is kept as an i32 alongside the distance so the compare-and-select works on two same-width lanes, and is narrowed back to u8 once at the end. fill_inverse_cmap also heap-allocated and zeroed four buffers on every call - colorlist, bestcolor, bestdist and mindist. They are small, fixed-size and dead by the end of the call, so they now live on the stack. Colormap output is unchanged. map_frame_cold_cache: 489.6 us -> 421.2 us median. --- crates/maple-render-core/src/quantize.rs | 75 ++++++++++++++---------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index d79abc8..5e0e37f 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -28,6 +28,9 @@ const BOX_C0_ELEMS: usize = 1 << BOX_C0_LOG; const BOX_C1_ELEMS: usize = 1 << BOX_C1_LOG; const BOX_C2_ELEMS: usize = 1 << BOX_C2_LOG; +/// Number of histogram cells one `fill_inverse_cmap` call resolves. +const BOX_ELEMS: usize = BOX_C0_ELEMS * BOX_C1_ELEMS * BOX_C2_ELEMS; + const BOX_C0_SHIFT: usize = C0_SHIFT + BOX_C0_LOG; const BOX_C1_SHIFT: usize = C1_SHIFT + BOX_C1_LOG; const BOX_C2_SHIFT: usize = C2_SHIFT + BOX_C2_LOG; @@ -464,7 +467,7 @@ impl Quantizer { minc0: i32, minc1: i32, minc2: i32, - colorlist: &mut [u8], + colorlist: &mut [u8; MAXNUMCOLORS], ) -> usize { let numcolors = self.palette.colors_total; @@ -475,8 +478,11 @@ impl Quantizer { let maxc2 = minc2 + ((1 << BOX_C2_SHIFT) - (1 << C2_SHIFT)); let centerc2 = (minc2 + maxc2) >> 1; - let mut mindist = vec![0i64; MAXNUMCOLORS]; - let mut minmaxdist: i64 = i64::MAX; + // A weighted squared distance never exceeds (255 * 3)^2 * 3, so the + // whole computation fits in an i32 - half the traffic of the i64 it + // used to run in, and a min-reduce the compiler can vectorize. + let mut mindist = [0i32; MAXNUMCOLORS]; + let mut minmaxdist: i32 = i32::MAX; for i in 0..numcolors { let x0 = self.palette.red[i] as i32; @@ -515,25 +521,21 @@ impl Quantizer { maxc: i32, centerc: i32, scale: i32, - ) -> (i64, i64) { + ) -> (i32, i32) { if x < minc { - let tdist = (x - minc) as i64 * scale as i64; + let tdist = (x - minc) * scale; let min_dist = tdist * tdist; - let tdist = (x - maxc) as i64 * scale as i64; + let tdist = (x - maxc) * scale; let max_dist = tdist * tdist; (min_dist, max_dist) } else if x > maxc { - let tdist = (x - maxc) as i64 * scale as i64; + let tdist = (x - maxc) * scale; let min_dist = tdist * tdist; - let tdist = (x - minc) as i64 * scale as i64; + let tdist = (x - minc) * scale; let max_dist = tdist * tdist; (min_dist, max_dist) } else { - let tdist = if x <= centerc { - (x - maxc) as i64 * scale as i64 - } else { - (x - minc) as i64 * scale as i64 - }; + let tdist = if x <= centerc { (x - maxc) * scale } else { (x - minc) * scale }; (0, tdist * tdist) } } @@ -544,26 +546,31 @@ impl Quantizer { minc1: i32, minc2: i32, numcolors: usize, - colorlist: &[u8], - bestcolor: &mut [u8], + colorlist: &[u8; MAXNUMCOLORS], + bestcolor: &mut [u8; BOX_ELEMS], ) { - let mut bestdist = vec![i64::MAX; BOX_C0_ELEMS * BOX_C1_ELEMS * BOX_C2_ELEMS]; + // Same i32 range argument as `find_nearby_colors`. Keeping the winning + // color as an i32 next to the distance lets the compare-and-select run + // on two same-width lanes instead of mixing an i64 compare with a byte + // store; it is narrowed back to u8 once, at the end. + let mut bestdist = [i32::MAX; BOX_ELEMS]; + let mut bestindex = [0i32; BOX_ELEMS]; - const STEP_C0: i64 = ((1 << C0_SHIFT) * C0_SCALE) as i64; - const STEP_C1: i64 = ((1 << C1_SHIFT) * C1_SCALE) as i64; - const STEP_C2: i64 = ((1 << C2_SHIFT) * C2_SCALE) as i64; + const STEP_C0: i32 = ((1 << C0_SHIFT) * C0_SCALE) as i32; + const STEP_C1: i32 = ((1 << C1_SHIFT) * C1_SCALE) as i32; + const STEP_C2: i32 = ((1 << C2_SHIFT) * C2_SCALE) as i32; for i in 0..numcolors { - let icolor = colorlist[i] as usize; - let r = self.palette.red[icolor] as i32; - let g = self.palette.green[icolor] as i32; - let b = self.palette.blue[icolor] as i32; + let icolor = colorlist[i] as i32; + let r = self.palette.red[icolor as usize] as i32; + let g = self.palette.green[icolor as usize] as i32; + let b = self.palette.blue[icolor as usize] as i32; - let mut inc0 = (minc0 - r) as i64 * C0_SCALE as i64; + let mut inc0 = (minc0 - r) * C0_SCALE; let mut dist0 = inc0 * inc0; - let mut inc1 = (minc1 - g) as i64 * C1_SCALE as i64; + let mut inc1 = (minc1 - g) * C1_SCALE; dist0 += inc1 * inc1; - let mut inc2 = (minc2 - b) as i64 * C2_SCALE as i64; + let mut inc2 = (minc2 - b) * C2_SCALE; dist0 += inc2 * inc2; inc0 = inc0 * (2 * STEP_C0) + STEP_C0 * STEP_C0; @@ -582,9 +589,10 @@ impl Quantizer { let mut xx2 = inc2; for _ic2 in 0..BOX_C2_ELEMS { - if dist2 < bestdist[bptr_idx] { + let closer = dist2 < bestdist[bptr_idx]; + if closer { bestdist[bptr_idx] = dist2; - bestcolor[bptr_idx] = icolor as u8; + bestindex[bptr_idx] = icolor; } dist2 += xx2; xx2 += 2 * STEP_C2 * STEP_C2; @@ -597,11 +605,18 @@ impl Quantizer { xx0 += 2 * STEP_C0 * STEP_C0; } } + + for (out, &best) in bestcolor.iter_mut().zip(bestindex.iter()) { + *out = best as u8; + } } fn fill_inverse_cmap(&mut self, c0: i32, c1: i32, c2: i32) { - let mut colorlist = vec![0u8; MAXNUMCOLORS]; - let mut bestcolor = vec![0u8; BOX_C0_ELEMS * BOX_C1_ELEMS * BOX_C2_ELEMS]; + // These are small, fixed-size and dead by the end of the call, so they + // live on the stack. Heap-allocating and zeroing them on every call + // was pure overhead on the cold-cache path. + let mut colorlist = [0u8; MAXNUMCOLORS]; + let mut bestcolor = [0u8; BOX_ELEMS]; let bc0 = c0 >> BOX_C0_LOG as i32; let bc1 = c1 >> BOX_C1_LOG as i32; From 267febd43f3b4f669d9a6f68de7e30b7ac610618 Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 07:32:56 +0000 Subject: [PATCH 10/11] perf(quantize): walk the mapping loops over row slices Both mapping loops indexed their way through the frame a pixel at a time: quantize_fs_dither called img.get_pixel per pixel and wrote output[row * width + x], and each of the seven per-pixel accesses to the error buffer went through self with its own bounds check. quantize_no_dither did the same on the output side. Both now take a source row and an output row up front, so there is one bounds check per row instead of several per pixel, and the error buffer is moved out of self for the length of the scan - which keeps a plain &mut [i16] available while colormap misses still go through &mut self, and reuses the previous frame's allocation instead of allocating one per call. Hoisting the error limiter table and the palette out of self the same way was measured too and made no difference, so they are left alone. Output is unchanged. map_frame_warm_cache: 148.2 us -> 141.1 us median, map_frame_dithered: 1.346 ms -> 1.323 ms median. --- crates/maple-render-core/src/quantize.rs | 57 +++++++++++++++--------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index 5e0e37f..72be15f 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -651,15 +651,13 @@ impl Quantizer { let height = img.height() as usize; let mut output = vec![0u8; width * height]; - for (y, row) in img.rows().enumerate() { - for (x, pixel) in row.enumerate() { - let r = pixel[0] as usize; - let g = pixel[1] as usize; - let b = pixel[2] as usize; + let (pixels, _) = img.as_raw().as_chunks::<4>(); - let c0 = r >> C0_SHIFT; - let c1 = g >> C1_SHIFT; - let c2 = b >> C2_SHIFT; + for (out_row, src_row) in output.chunks_exact_mut(width).zip(pixels.chunks_exact(width)) { + for (out, pixel) in out_row.iter_mut().zip(src_row) { + let c0 = (pixel[0] as usize) >> C0_SHIFT; + let c1 = (pixel[1] as usize) >> C1_SHIFT; + let c2 = (pixel[2] as usize) >> C2_SHIFT; let histogram_index = Self::histogram_index(c0, c1, c2); let mut cached = self.histogram[histogram_index]; @@ -668,7 +666,7 @@ impl Quantizer { cached = self.histogram[histogram_index]; } - output[y * width + x] = (cached - 1) as u8; + *out = (cached - 1) as u8; } } @@ -680,12 +678,25 @@ impl Quantizer { let height = img.height() as usize; let mut output = vec![0u8; width * height]; - self.fserrors = vec![0i16; (width + 2) * 3]; + // Taking the error buffer out of `self` for the length of the scan + // lets the column loop work on a plain `&mut [i16]` while still being + // able to call `fill_inverse_cmap` on `self` when the colormap misses. + // It also reuses the previous frame's allocation. + let mut fserrors = std::mem::take(&mut self.fserrors); + fserrors.clear(); + fserrors.resize((width + 2) * 3, 0); self.on_odd_row = false; let table_offset = MAXJSAMPLE as usize; + let (pixels, _) = img.as_raw().as_chunks::<4>(); for row in 0..height { + // One bounds check per row instead of a bounds-checked + // `get_pixel` and a bounds-checked `output[row * width + x]` per + // pixel. + let src_row = &pixels[row * width..row * width + width]; + let out_row = &mut output[row * width..row * width + width]; + let (dir, start_col, end_col, errorptr_start) = if self.on_odd_row { (-1i32, width as i32 - 1, -1i32, (width + 1) * 3) } else { @@ -708,13 +719,14 @@ impl Quantizer { while col != end_col { let x = col as usize; - let pixel = img.get_pixel(x as u32, row as u32); + let pixel = &src_row[x]; // Add error from previous and below let ep_idx = (errorptr + dir3) as usize; - cur0 = (cur0 + self.fserrors[ep_idx] as i32 + 8) >> 4; - cur1 = (cur1 + self.fserrors[ep_idx + 1] as i32 + 8) >> 4; - cur2 = (cur2 + self.fserrors[ep_idx + 2] as i32 + 8) >> 4; + let below = &fserrors[ep_idx..ep_idx + 3]; + cur0 = (cur0 + below[0] as i32 + 8) >> 4; + cur1 = (cur1 + below[1] as i32 + 8) >> 4; + cur2 = (cur2 + below[2] as i32 + 8) >> 4; cur0 = self.error_limiter[table_offset.wrapping_add(cur0 as usize)]; cur1 = self.error_limiter[table_offset.wrapping_add(cur1 as usize)]; @@ -739,16 +751,18 @@ impl Quantizer { } let pixcode = (cached - 1) as usize; - output[row * width + x] = pixcode as u8; + out_row[x] = pixcode as u8; cur0 -= self.palette.red[pixcode] as i32; cur1 -= self.palette.green[pixcode] as i32; cur2 -= self.palette.blue[pixcode] as i32; + let here = &mut fserrors[errorptr as usize..errorptr as usize + 3]; + let mut bnexterr = cur0; let mut delta = cur0 * 2; cur0 += delta; // 3x - self.fserrors[errorptr as usize] = (bpreverr0 + cur0) as i16; + here[0] = (bpreverr0 + cur0) as i16; cur0 += delta; // 5x bpreverr0 = belowerr0 + cur0; belowerr0 = bnexterr; @@ -757,7 +771,7 @@ impl Quantizer { bnexterr = cur1; delta = cur1 * 2; cur1 += delta; - self.fserrors[errorptr as usize + 1] = (bpreverr1 + cur1) as i16; + here[1] = (bpreverr1 + cur1) as i16; cur1 += delta; bpreverr1 = belowerr1 + cur1; belowerr1 = bnexterr; @@ -766,7 +780,7 @@ impl Quantizer { bnexterr = cur2; delta = cur2 * 2; cur2 += delta; - self.fserrors[errorptr as usize + 2] = (bpreverr2 + cur2) as i16; + here[2] = (bpreverr2 + cur2) as i16; cur2 += delta; bpreverr2 = belowerr2 + cur2; belowerr2 = bnexterr; @@ -776,12 +790,15 @@ impl Quantizer { errorptr += dir3; } - self.fserrors[errorptr as usize + 1] = bpreverr1 as i16; - self.fserrors[errorptr as usize + 2] = bpreverr2 as i16; + let tail = &mut fserrors[errorptr as usize..errorptr as usize + 3]; + tail[1] = bpreverr1 as i16; + tail[2] = bpreverr2 as i16; self.on_odd_row = !self.on_odd_row; } + self.fserrors = fserrors; + output } From c46a929d8ff8685b5e573613a132478e0672982a Mon Sep 17 00:00:00 2001 From: CodSpeed Bot Date: Fri, 14 Aug 2026 08:53:36 +0000 Subject: [PATCH 11/11] perf(quantize): keep the inverse colormap min-reduce scalar find_best_colors compares every one of the 128 cells of a box against the running best distance, and after the first candidate color that compare almost never wins. Running the distances in i32 and staging the winning color in a second i32 array next to them let LLVM vectorize the update: the branch becomes an unconditional load, compare, select and store on every cell, for both arrays. That is roughly free on an x86 dev box, which is where it was measured, but it is a large loss on the aarch64 macro runners the benchmarks actually run on - find_best_colors went from 842 us to 1.3 ms there, which is the whole map_frame_cold_cache regression. The distances go back to i64 and the winner is stored straight into the u8 output again, which keeps the loop scalar and branchy on both targets; the comment records why, so the i32 rewrite does not come back. The i32 rewrite of find_nearby_colors is kept - that one is a measured win on the macro runners (140 us -> 60 us) - as is the stack scratch, so bestdist no longer allocates per call. Output is unchanged: palette, no-dither, warm-cache and dithered bytes are identical for toaster frames 11/0/3 over frog/monkey/dolphin. map_frame_cold_cache locally: 386.1 us -> 393.8 us median, i.e. within noise on x86. --- crates/maple-render-core/src/quantize.rs | 43 ++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/crates/maple-render-core/src/quantize.rs b/crates/maple-render-core/src/quantize.rs index 72be15f..a017893 100644 --- a/crates/maple-render-core/src/quantize.rs +++ b/crates/maple-render-core/src/quantize.rs @@ -549,28 +549,34 @@ impl Quantizer { colorlist: &[u8; MAXNUMCOLORS], bestcolor: &mut [u8; BOX_ELEMS], ) { - // Same i32 range argument as `find_nearby_colors`. Keeping the winning - // color as an i32 next to the distance lets the compare-and-select run - // on two same-width lanes instead of mixing an i64 compare with a byte - // store; it is narrowed back to u8 once, at the end. - let mut bestdist = [i32::MAX; BOX_ELEMS]; - let mut bestindex = [0i32; BOX_ELEMS]; - - const STEP_C0: i32 = ((1 << C0_SHIFT) * C0_SCALE) as i32; - const STEP_C1: i32 = ((1 << C1_SHIFT) * C1_SCALE) as i32; - const STEP_C2: i32 = ((1 << C2_SHIFT) * C2_SCALE) as i32; + // The distances here stay in i64 on purpose, even though they would fit + // in an i32. Every cell is a compare against the running best that + // almost never wins after the first candidate color, so the branchy + // scalar loop below is the shape we want. With i32 distances - and in + // particular with the winning color staged in a second i32 array next + // to it - LLVM instead turns the whole 128-cell update into an + // unconditional vector compare-and-select. That trades a + // near-perfectly-predicted branch for a load/select/store on every + // cell, which is a large loss on the aarch64 macro runners the + // benchmarks are measured on (`find_best_colors` 842 us -> 1.3 ms), + // however it may look on an x86 dev box. Keep this loop scalar. + let mut bestdist = [i64::MAX; BOX_ELEMS]; + + const STEP_C0: i64 = ((1 << C0_SHIFT) * C0_SCALE) as i64; + const STEP_C1: i64 = ((1 << C1_SHIFT) * C1_SCALE) as i64; + const STEP_C2: i64 = ((1 << C2_SHIFT) * C2_SCALE) as i64; for i in 0..numcolors { - let icolor = colorlist[i] as i32; + let icolor = colorlist[i]; let r = self.palette.red[icolor as usize] as i32; let g = self.palette.green[icolor as usize] as i32; let b = self.palette.blue[icolor as usize] as i32; - let mut inc0 = (minc0 - r) * C0_SCALE; + let mut inc0 = (minc0 - r) as i64 * C0_SCALE as i64; let mut dist0 = inc0 * inc0; - let mut inc1 = (minc1 - g) * C1_SCALE; + let mut inc1 = (minc1 - g) as i64 * C1_SCALE as i64; dist0 += inc1 * inc1; - let mut inc2 = (minc2 - b) * C2_SCALE; + let mut inc2 = (minc2 - b) as i64 * C2_SCALE as i64; dist0 += inc2 * inc2; inc0 = inc0 * (2 * STEP_C0) + STEP_C0 * STEP_C0; @@ -589,10 +595,9 @@ impl Quantizer { let mut xx2 = inc2; for _ic2 in 0..BOX_C2_ELEMS { - let closer = dist2 < bestdist[bptr_idx]; - if closer { + if dist2 < bestdist[bptr_idx] { bestdist[bptr_idx] = dist2; - bestindex[bptr_idx] = icolor; + bestcolor[bptr_idx] = icolor; } dist2 += xx2; xx2 += 2 * STEP_C2 * STEP_C2; @@ -605,10 +610,6 @@ impl Quantizer { xx0 += 2 * STEP_C0 * STEP_C0; } } - - for (out, &best) in bestcolor.iter_mut().zip(bestindex.iter()) { - *out = best as u8; - } } fn fill_inverse_cmap(&mut self, c0: i32, c1: i32, c2: i32) {