G1: dither the final 8-bit conversion so gradients don't band (#2022)

Exports converted the float frame to 8 bits by plain rounding, so a smooth
gradient rendered at 16 or 32 bpc came out as flat bands, the same as at
8 bpc: raising the depth changed nothing visible. After Effects dithers
colours converted to 8 bpc for render and export; do the same.

- raster: dither_u8 and Image::to_rgba8_dithered / to_rgba8_over_dithered
  round each channel up or down against a hash of the pixel position with
  a fixed seed (deterministic, no clock or global RNG). A value already on
  an 8-bit code, as everything rendered at 8 bpc is, is unchanged.
- export: Cx::pixels dithers the colour for every 8-bit output (stills,
  H.264, ProRes, HEVC, AV1, VP9); GIF keeps plain rounding (palette).
- export: the Render Settings' Color Depth now overrides the project's
  depth for the whole render instead of only quantising the finished
  frame, so a 16 bpc render of an 8 bpc project keeps its precision.

Tests: crates/export/tests/it/banding.rs (ramp export at 8/16 bpc and with
Color Depth overrides), raster unit tests for dither_u8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYXhNN7W7nB6sKjwwU7CDM
This commit is contained in:
Claude
2026-10-27 15:38:08 -05:00
parent dd12ed8f9c
commit fa14090e72
5 changed files with 261 additions and 31 deletions
+73 -7
View File
@@ -221,7 +221,7 @@ impl Image {
}
/// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
pub fn to_rgba8(&self) -> Vec<u8> {
self.map_rgba8(|o, p| {
self.map_rgba8(|o, p, _, _| {
let a = p[3].clamp(0.0, 1.0);
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
for c in 0..3 {
@@ -230,9 +230,20 @@ impl Image {
o[3] = (a * 255.0 + 0.5) as u8;
})
}
/// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded.
pub fn to_rgba8_dithered(&self) -> Vec<u8> {
self.map_rgba8(|o, p, x, y| {
let a = p[3].clamp(0.0, 1.0);
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
for c in 0..3 {
o[c] = dither_u8(p[c] * inv, x, y);
}
o[3] = (a * 255.0 + 0.5) as u8;
})
}
/// Composite over an opaque background colour and return opaque 8-bit RGBA.
pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
self.map_rgba8(|o, p| {
self.map_rgba8(|o, p, _, _| {
let k = 1.0 - p[3].clamp(0.0, 1.0);
for c in 0..3 {
o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
@@ -240,13 +251,23 @@ impl Image {
o[3] = 255;
})
}
/// 8-bit RGBA with each pixel written by `f`, row-parallel.
fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec<u8> {
/// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]).
pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec<u8> {
self.map_rgba8(|o, p, x, y| {
let k = 1.0 - p[3].clamp(0.0, 1.0);
for c in 0..3 {
o[c] = dither_u8(p[c] + bg[c] * k, x, y);
}
o[3] = 255;
})
}
/// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel.
fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec<u8> {
let mut out = vec![0u8; self.data.len() * 4];
let w = self.width.max(1) as usize;
out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| {
for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) {
f(o, p);
out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| {
for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() {
f(o, p, x as u32, y as u32);
}
});
out
@@ -321,6 +342,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
(h & 0x00ff_ffff) as f32 / 16_777_216.0
}
/// Seed of the output dither: fixed, so a frame converts to the same bytes every time.
const DITHER_SEED: u32 = 0x5bd1_e995;
/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is
/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of
/// a smooth gradient average to the gradient instead of stepping in bands. A value already on
/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only
/// on the value and the pixel position, never on a clock or a global generator.
#[inline]
pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 {
let s = v.clamp(0.0, 1.0) * 255.0;
// The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it.
let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4;
(s + t).floor() as u8
}
/// A single-channel coverage mask.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mask {
@@ -345,6 +382,35 @@ impl Mask {
mod tests {
use super::*;
#[test]
fn dither_keeps_exact_codes_and_averages_the_rest() {
// A value on an 8-bit code is that code at every position.
for code in 0..=255u32 {
let v = code as f32 / 255.0;
for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] {
assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})");
}
}
// Out-of-range and NaN values saturate instead of wrapping.
assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0));
// A value between two codes lands on both, in proportion: the mean over a block is the value.
let v = (100.0 + 0.3) / 255.0;
let n = 64u32;
let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum();
let mean = sum as f32 / (n * n) as f32;
assert!((mean - 100.3).abs() < 0.03, "{mean}");
// Deterministic.
assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4));
}
#[test]
fn dithered_conversions_match_the_plain_ones_on_exact_codes() {
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255];
let img = Image::from_rgba8(2, 2, &src);
assert_eq!(img.to_rgba8_dithered(), img.to_rgba8());
assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3]));
}
#[test]
fn rgba8_roundtrip() {
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];