From ddfa566d7d5ac08692b64a808cfc39556a58aa26 Mon Sep 17 00:00:00 2001 From: daijro Date: Fri, 9 Oct 2026 11:47:34 -0500 Subject: [PATCH] Render: 8 and 16 bpc quantisation rounds with floor in the browser Cause: quantize_px rounded with `(v + 0.5) as u32 as f32`. Converting back from u32 is several instructions in V8's wasm code on x86 (no unsigned conversion), so quantising a 1080p layer took about 15 ms in the browser: every 8/16 bpc layer is quantised after its source and every effect, and the CPU compositor quantises the comp after every layer. Fix: round_half_up uses floor in wasm (one instruction there) and keeps the integer conversion elsewhere (x86-64's baseline has no floor instruction, where floor is slower). The values are the same: both round non-negative values half up. About 1.5x faster quantising in the browser (15 -> 10 ms per 1080p layer in a wasm micro-benchmark), unchanged on the desktop. Tests: color::tests::rounding_forms_agree: both forms agree on 0..=levels in sixteenths (exact halves included) and on 100 001 products, at 8 and 16 bpc. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/color.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 91abef27..19785722 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -195,14 +195,22 @@ pub fn quantize(img: &mut Image, levels: f32) { #[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN #[inline(always)] fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) { - let a = (p[3].max(0.0).min(1.0) * levels + 0.5) as u32 as f32 * inv; + let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; let hi = [a, a, a, a]; for c in 0..3 { - p[c] = (p[c].max(0.0).min(hi[c]) * levels + 0.5) as u32 as f32 * inv; + p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; } p[3] = a; } +/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's +/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm +/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). +#[inline(always)] +fn round_half_up(v: f32) -> f32 { + if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } +} + /// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel. fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { if r.x0 >= r.x1 || r.y0 >= r.y1 { @@ -268,3 +276,22 @@ pub fn output_hdr(img: &mut Image, to_linear: Option, mode: HdrMode, *p = [o[0] * a, o[1] * a, o[2] * a, a]; }); } + +#[cfg(test)] +mod tests { + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: + /// 0..=levels, exact halves included. + #[test] + fn rounding_forms_agree() { + for levels in [255.0f32, 32_768.0] { + for i in 0..=(levels as u32 * 16) { + let v = i as f32 / 16.0; + assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}"); + } + for i in 0..=100_000 { + let v = (i as f32 / 100_000.0) * levels; + assert_eq!((v + 0.5).floor(), (v + 0.5) as u32 as f32, "{v}"); + } + } + } +}