#1605: Posterize and CC Grid Wipe match the CPU on AMD and Intel (#1836)

Posterize: round_away decided half-way by subtracting, and drivers may fuse the
product that feeds it into one fma (AMD does), skipping the product's rounding.
A straight value of 5/6 at level 4 gives 5/6 * 3 = 2.5 exactly in f32 on the
CPU but 2.49999994 fused, so the GPU rounded a level down. round_away now
compares x with t +/- 0.5 (exact for integer t), which no driver can fuse, and
the level divide uses an fma-corrected division (div_rn) like the CPU's.

CC Grid Wipe: the cell lookup floor(q / cs) used WGSL's division, which may be
off by 2.5 ULP; at q = 7, cs = 3.5 AMD and Intel return just under 2 and pick
the neighbouring cell. It uses div_rn too.

New test: posterize matches the CPU exactly on every 8 bpc premultiplied colour
and alpha at three levels (59715 of 65280 pairs differed before at level 4).

RX 6900 XT, Vulkan: effectcraft-gpu 222 passed; the one failure left is CC Hex
Tile, fixed by #1776. DX12 + FXC (on #1571): both tests pass.

Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-03 23:56:37 +01:00
1 parent 8d20cf0c3a
commit ff68626597
2 files changed
+37 -4

No files matched your search

+7 -4
View File
@@ -242,13 +242,16 @@ fn powz(x: f32, y: f32) -> f32 {
return pow(x, y);
}
// Rust's f32::round (half away from zero; WGSL's round() is half to even).
// Rust's f32::round (half away from zero; WGSL's round() is half to even). Decided by comparing
// `x` with `t ± 0.5` (exact for integer `t`) rather than subtracting: drivers may fuse a product
// that feeds `x - t` into one fma (AMD does), skipping the product's rounding, so `5/6 · 3`,
// which rounds to exactly 2.5 on the CPU, came out 0.49999994 above 2 and rounded down (#1605).
fn round_away(x: f32) -> f32 {
let t = trunc(x);
if (abs(x - t) >= 0.5) {
return t + sign(x);
if (x >= 0.0) {
return select(t, t + 1.0, x >= t + 0.5);
}
return t;
return select(t, t - 1.0, x <= t - 0.5);
}
// f32::rem_euclid(1.0).
+30
View File
@@ -288,6 +288,36 @@ fn transform_motion_blur() {
}
}
/// #1605: Posterize matches the CPU exactly on every 8 bpc premultiplied colour and alpha. A
/// straight value of 5/6 at level 4 rounds to exactly 2.5 on the CPU; drivers that fused the
/// product into `round_away`'s subtraction (AMD) rounded it down a level.
#[test]
fn posterize_matches_the_cpu_on_every_8bpc_pair() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.stylize.posterize").unwrap();
let size = [256.0, 255.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for level in [4.0, 7.0, 9.0] {
params.values.insert("level".into(), n(level));
// Pixel (x, a - 1): premultiplied colour min(x, a)/255 on alpha a/255.
let mut img = effectcraft_raster::Image::new(256, 255);
for a in 1..=255u32 {
for x in 0..256u32 {
let v = x.min(a) as f32 / 255.0;
img.data[((a - 1) * 256 + x) as usize] = [v, v, v, a as f32 / 255.0];
}
}
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale: 1.0 };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: size, seed: 1, adjustment: false, env: Default::default() };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
let bad: Vec<(usize, usize)> =
cpu.img.data.iter().zip(&out.img.data).enumerate().filter(|(_, (c, o))| c != o).map(|(i, _)| ((i % 256).min(i / 256 + 1), i / 256 + 1)).collect();
assert!(bad.is_empty(), "level {level}: {} (x, a) pairs differ from the CPU, first {:?}", bad.len(), &bad[..bad.len().min(8)]);
}
}
/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the
/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes
/// back onto the canvas as on the CPU (#1727).