fix(oak-render): composite the CPU track stack bottom-up

The GPU path composites frames bottom (last) to top (first); the CPU
fallback iterated top-first, so on machines without a working adapter
every multi-layer frame had its layering order inverted (the
composite_tracks test caught it as 0.8125 vs the documented 0.625).
Factor the CPU half into composite_tracks_cpu, iterate it in reverse
and pin the math in the test by calling the CPU path directly (the old
assertion silently exercised the GPU path whenever another test had
installed a shared context).
This commit is contained in:
2026-09-24 16:11:10 +08:00
parent 12e7c9b053
commit c50d489dc0
+21 -4
View File
@@ -1876,7 +1876,8 @@ static GPU_COMPOSITE_FAILED: std::sync::atomic::AtomicBool =
/// Compositing always runs — even a single frame passes through the
/// alpha-over over a transparent accumulator, which is the graph's
/// premultiply step (an adjustment sweep's 0.5-opacity result must become
/// 0.25 after its final composite).
/// 0.25 after its final composite). Frames arrive topmost first; both
/// paths composite the stack bottom-up (see [`composite_tracks_gpu`]).
fn composite_tracks(frames: Vec<Texture>, size: (i32, i32)) -> Texture {
let (w, h) = size;
if w <= 0 || h <= 0 {
@@ -1896,12 +1897,25 @@ fn composite_tracks(frames: Vec<Texture>, size: (i32, i32)) -> Texture {
}
}
// CPU fallback: read every GPU frame back (the explicit boundary) and
// composite the CPU stack.
// composite the CPU stack bottom-up.
composite_tracks_cpu(&frames, (w, h))
}
/// The CPU half of [`composite_tracks`]: frames arrive topmost first
/// (the render walk inserts each track's frame at the front), so the
/// stack is composited from the bottom (last) up — exactly like
/// [`composite_tracks_gpu`]. This is the path every machine without a
/// working adapter runs, so the layer order must match the GPU pass.
fn composite_tracks_cpu(frames: &[Texture], size: (i32, i32)) -> Texture {
let (w, h) = size;
if w <= 0 || h <= 0 {
return Texture::dummy();
}
let Ok(mut acc) = generate_frame(Rational::new(0, 1), (w, h), PixelFormat::F32) else {
return Texture::dummy();
};
let acc_stride = acc.linesize_bytes() as i32;
for texture in frames.iter() {
for texture in frames.iter().rev() {
let Ok(frame) = texture.to_frame() else {
continue;
};
@@ -4113,7 +4127,10 @@ mod tests {
// bottom over transparent: (0.75, 0.75, 0.75, 0.75), then top over:
// r = 0.5*0.5 + 0.75*0.5, g = 0.25*0.5 + 0.75*0.5,
// b = 0.125*0.5 + 0.75*0.5, a = 0.5 + 0.75*0.5.
let out = composite_tracks(frames.clone(), (2, 1));
// The CPU fallback must match the GPU math (and the layer
// order); call it directly so the assertion never depends on
// whether some other test installed a shared GPU context.
let out = composite_tracks_cpu(&frames, (2, 1));
let pixel = first_pixel(&out);
for (got, want) in pixel.iter().zip(expected) {
assert!((got - want).abs() < 1e-4, "CPU composite: expected {want}, got {got}");