Merge pull request #2534 from klosixgaming-arch/fix/2022-render-banding

export: dither 16/32 bpc renders on the way to 8 bits, and let Render Settings Color Depth set the render's depth (#2022)
This commit is contained in:
BFlat
2026-10-30 05:09:48 +07:00
committed by GitHub
6 changed files with 299 additions and 35 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_
let end = (i + batch).min(st.total);
let frames: Vec<gif::Frame<'static>> = job
.frames(comp, (i..end).collect(), |_, img| {
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false);
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
})
.await;
+53 -26
View File
@@ -4,10 +4,8 @@
use std::sync::Mutex;
use effectcraft_project::render_queue::{
AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
};
use effectcraft_project::{Comp, Project};
use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};
use effectcraft_project::{BitDepth, Comp, Project};
use effectcraft_raster::Image;
use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
@@ -220,6 +218,9 @@ impl<'a> Cx<'a> {
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
let mut project = job.project.clone();
apply_overrides(&mut project, job);
// Render Settings ▸ Color Depth overrides the project's depth for the whole render (every
// layer and effect quantises at it), not just the finished frame (#2022).
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
let cache = LayerCache::default();
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
@@ -360,14 +361,6 @@ impl<'a> Cx<'a> {
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
}
};
let mut img = img;
// Color Depth: renders run at the project's depth; another depth quantises the frame.
let depth = self.job.settings.color_depth;
if depth != ColorDepth::Current
&& let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
{
img.quantize(levels);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
@@ -384,8 +377,10 @@ impl<'a> Cx<'a> {
return None;
}
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
let depth = self.job.settings.color_depth;
if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() {
// Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the
// accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't
// band (#2022). The project's depth already includes Render Settings ▸ Color Depth.
if self.project.settings.bit_depth != BitDepth::Bpc8 {
return None;
}
let om = self.job.output;
@@ -453,12 +448,24 @@ impl<'a> Cx<'a> {
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
/// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After
/// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients
/// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to
/// dither, so its output stays byte for byte what it was.
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
let dither = self.project.settings.bit_depth != BitDepth::Bpc8;
self.pixels_with(img, comp, channels, w, h, dither)
}
/// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise).
pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec<u8> {
let px = match channels {
Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background),
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => match self.job.output.alpha_mode {
AlphaMode::Straight => img.to_rgba8(),
AlphaMode::Premultiplied => premultiplied8(img),
Channels::Rgba => match (self.job.output.alpha_mode, dither) {
(AlphaMode::Straight, true) => img.to_rgba8_dithered(),
(AlphaMode::Straight, false) => img.to_rgba8(),
(AlphaMode::Premultiplied, _) => premultiplied8(img, dither),
},
Channels::Alpha => img
.data
@@ -619,15 +626,22 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image {
out
}
/// Premultiplied 8-bit RGBA (colour matted with black).
fn premultiplied8(img: &Image) -> Vec<u8> {
img.data
.iter()
.flat_map(|p| {
let a = p[3].clamp(0.0, 1.0);
[0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
})
.collect()
/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`.
fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
let w = img.width.max(1) as usize;
let mut out = Vec::with_capacity(img.data.len() * 4);
for (i, p) in img.data.iter().enumerate() {
let a = p[3].clamp(0.0, 1.0);
let a8 = (a * 255.0 + 0.5) as u8;
let (x, y) = ((i % w) as u32, (i / w) as u32);
for c in 0..3 {
let v = p[c].clamp(0.0, a);
// Dither may round up past the rounded alpha; premultiplied colour never exceeds it.
out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 });
}
out.push(a8);
}
out
}
#[cfg(test)]
@@ -636,6 +650,19 @@ mod tests {
use rayon::prelude::*;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or
/// below the written alpha at every pixel.
#[test]
fn dithered_premultiplied_colour_never_exceeds_alpha() {
let v = 100.25 / 255.0;
let img = Image::filled(64, 64, [v, v, v, v]);
let px = premultiplied8(&img, true);
assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha");
assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100));
let plain = premultiplied8(&img, false);
assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100]));
}
/// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once.
fn run_capped(n: u64, cap: Option<usize>) -> (Vec<u64>, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};
+156
View File
@@ -0,0 +1,156 @@
//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the
//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the
//! render itself.
use std::sync::{Arc, Mutex};
use effectcraft_color::Label;
use effectcraft_export::render_queue::*;
use effectcraft_export::{Job, JobOptions, Sink, export};
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 1024;
const H: u32 = 16;
/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px,
/// so plain rounding makes bands about 40 px wide.
const LO: f64 = 0.20;
const HI: f64 = 0.30;
/// Width of the column blocks whose mean is compared with the ideal ramp.
const WINDOW: u32 = 8;
/// A grey horizontal ramp over a comp of the given project depth.
fn ramp_project(depth: BitDepth) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = depth;
let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
comp.background = [0.0, 0.0, 0.0];
let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 }));
let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None);
let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect");
let mut next = p.next_id;
let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
for (k, v) in [
("start", Value::Vec2([0.0, 0.0])),
("end", Value::Vec2([W as f64, 0.0])),
("startColor", Value::Color([LO, LO, LO, 1.0])),
("endColor", Value::Color([HI, HI, HI, 1.0])),
] {
group.get_mut(k).expect("ramp parameter").value = v;
}
layer.props.sub_mut("effects").expect("effects group").children.push(group.into());
comp.layers.push(layer);
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of
/// every row.
fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec<Vec<u8>> {
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
let f = files.clone();
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
let settings =
RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() };
let om = OutputModule::for_format(OutputFormat::PngSequence);
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &settings,
output: &om,
path: "/banding/f_[#####].png",
sink: Some(&*sink),
options: JobOptions::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
let files = files.lock().unwrap();
let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG");
let img = image::load_from_memory(png).expect("png").to_rgba8();
(0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect()
}
/// The ideal ramp at column `x`, in 8-bit codes.
fn ideal(x: f64) -> f64 {
255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64)
}
/// Longest run of equal neighbours in the row: a band's width.
fn longest_run(row: &[u8]) -> usize {
let (mut best, mut run) = (1, 1);
for w in row.windows(2) {
run = if w[0] == w[1] { run + 1 } else { 1 };
best = best.max(run);
}
best
}
/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a
/// flat step, hundreds when the steps are dithered.
fn transitions(rows: &[Vec<u8>]) -> usize {
rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum()
}
/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows
/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step
/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05).
fn worst_window_error(rows: &[Vec<u8>]) -> f64 {
(0..W / WINDOW)
.map(|i| {
let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize);
let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum();
let mean = sum / (rows.len() * (x1 - x0)) as f64;
(mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs()
})
.fold(0.0, f64::max)
}
#[test]
fn a_16_bpc_gradient_export_is_dithered() {
let (p, cid) = ramp_project(BitDepth::Bpc16);
let rows = export_rows(&p, cid, ColorDepth::Current);
let (steps, err) = (transitions(&rows), worst_window_error(&rows));
eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
// Undithered there are ~25 flat steps per row (400 transitions) and the block means are off
// by up to ~0.4 codes.
assert!(steps > 2000, "the steps survive: {steps} transitions");
assert!(err < 0.25, "the local mean is off by {err} codes");
// Dither never moves a value a whole code from the ideal ramp.
for row in &rows {
for (x, &v) in row.iter().enumerate() {
let want = ideal(x as f64);
assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}");
}
}
}
#[test]
fn the_export_is_the_same_every_time() {
let (p, cid) = ramp_project(BitDepth::Bpc16);
assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current));
}
#[test]
fn the_render_color_depth_reaches_the_render() {
// An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither.
let (p, cid) = ramp_project(BitDepth::Bpc8);
let banded = export_rows(&p, cid, ColorDepth::Current);
assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export");
assert!(transitions(&banded) < 16 * 30);
// The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision
// until the final conversion, which dithers.
let deep = export_rows(&p, cid, ColorDepth::Bpc16);
let (steps, err) = (transitions(&deep), worst_window_error(&deep));
eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}");
// And 8 bpc on a 16 bpc project renders at 8 bits.
let (p16, cid16) = ramp_project(BitDepth::Bpc16);
assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded);
}
+15 -1
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use effectcraft_color::Label;
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::{Job, export};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_raster::Image;
@@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
assert!(a == c);
}
/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes
/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022).
#[test]
fn renders_above_8_bpc_skip_the_planes() {
let d = out_dir("h264-gpu-yuv-16bpc");
let (p, cid) = project();
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() };
let yuv = Fake::new(true, false, usize::MAX);
let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4"));
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc");
assert!(a == c);
}
/// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never
/// through the blocking per-frame path the planes use.
#[test]
+1
View File
@@ -1,6 +1,7 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod audio_sample_positions;
mod banding;
mod h264_gpu_yuv;
mod hevc_av1_roundtrip;
#[cfg(not(target_arch = "wasm32"))]
+73 -7
View File
@@ -242,7 +242,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 {
@@ -251,9 +251,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;
@@ -261,13 +272,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
@@ -342,6 +363,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 {
@@ -366,6 +403,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];