G2: improve editing reliability and Windows rendering fidelity

This commit is contained in:
Dexter
2026-10-06 00:00:31 -05:00
parent b46be83ab4
commit 39e6907b5f
101 changed files with 1727 additions and 351 deletions
+10 -4
View File
@@ -387,9 +387,11 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
if cb.len() != (s.width * s.height * 8) as usize || zb.len() != (s.width * s.height * 4) as usize {
return None;
}
let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
let color = cb.as_chunks::<8>().0.iter().map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
let depth = zb
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
if z < 0.0 { f32::INFINITY } else { z }
@@ -765,7 +767,9 @@ pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendere
let px = r.width as u64 * r.height as u64;
let db = read_buffer(&mut e, &r.depth, px * 4)?;
let depth = db
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
if z >= BIG * 0.5 { f32::INFINITY } else { z }
@@ -812,7 +816,9 @@ fn render_prepared_deferred(g: &GpuContext, d: &std::sync::Arc<crate::deferred::
dst.copy_from_slice(&a.bytes);
let depth = b
.bytes
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.map(|c| {
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
if z >= BIG * 0.5 { f32::INFINITY } else { z }
+6
View File
@@ -509,6 +509,12 @@ impl GpuContext {
pub async fn request() -> Result<GpuContext, String> {
#[allow(unused_mut)]
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
#[cfg(not(target_arch = "wasm32"))]
{
// Match eframe's backend override so CLI and headless acceptance tests
// exercise the backend requested for the native viewer.
desc.backends = wgpu::Backends::from_env().unwrap_or(desc.backends);
}
#[cfg(target_arch = "wasm32")]
{
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
+3 -2
View File
@@ -97,7 +97,7 @@ pub fn warp_q(
p.f[0] = r[0];
p.f[1] = r[1];
p.f[2] = r[2];
p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), 0.0];
p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v)];
p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32];
let mut out = e.scratch(dst.width, dst.height);
e.pixels("warp_blend", &p, &img, Some(dst), &out, None);
@@ -110,7 +110,7 @@ pub fn warp_q(
pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32, levels: Option<f32>) -> GpuImage {
let mut p = Params::default();
p.u[0] = [mode_id(mode), 0, seed, 0];
p.f[0] = [opacity, levels.unwrap_or(0.0), 0.0, 0.0];
p.f[0] = [opacity, levels.unwrap_or(0.0), levels.map_or(0.0, |v| 1.0 / v), 0.0];
let mut out = e.scratch(dst.width, dst.height);
e.pixels("blend_full", &p, src, Some(dst), &out, None);
out.levels = levels;
@@ -174,6 +174,7 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
}
let mut p = Params::default();
p.f[0][0] = levels;
p.f[0][1] = 1.0 / levels;
let mut out = e.scratch(img.width, img.height);
e.pixels("quantize", &p, img, None, &out, None);
out.levels = Some(levels);
+4 -2
View File
@@ -233,8 +233,10 @@ fn sim_key(req: &SimRequest) -> u128 {
/// Live particles of a state buffer's bytes.
fn parse(bytes: &[u8]) -> Vec<SimParticle> {
let f: Vec<f32> = bytes.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
f.chunks_exact(12)
let f: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
f.as_chunks::<12>()
.0
.iter()
.enumerate()
.filter(|(_, s)| s[9] != 0.0)
.map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 })
+9 -2
View File
@@ -2,10 +2,17 @@
// the CPU effect in effectcraft-effects operation for operation. Every name here is prefixed
// `fxk_` (the family files share one module).
// Correct the approximate quotient before bitwise/threshold operations.
fn fxk_div3(a: vec3<f32>, b: f32) -> vec3<f32> {
let q = a / b;
let r = fma(-q, vec3<f32>(b), a);
return fma(r, vec3<f32>(1.0 / b), q);
}
// util::unpremul: (straight colour, alpha); colour 0 when alpha ≤ 1e-6.
fn fxk_straight(px: vec4<f32>) -> vec3<f32> {
if (px.w > 1e-6) {
return px.xyz / px.w;
return fxk_div3(px.xyz, px.w);
}
return vec3<f32>(0.0);
}
@@ -457,7 +464,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3<u32>) {
textureStore(out, p, px);
return;
}
let s = px.xyz / px.w;
let s = fxk_div3(px.xyz, px.w);
var r = vec3<f32>(0.0);
for (var i = 0u; i < 3u; i++) {
let v = s[i];
+5 -2
View File
@@ -15,11 +15,14 @@ fn fxn_pixel(gid: vec3<u32>) -> vec2<i32> {
}
// a / b rounded like the CPU's IEEE division (GPU division may be approximate): one
// residual correction with fma. Used where a threshold follows the quotient.
// residual correction with fma, followed by a second refinement for half-step cases.
// Used where a strict threshold follows the quotient.
fn fxn_div4(a: vec4<f32>, b: vec4<f32>) -> vec4<f32> {
let q = a / b;
let r = fma(-q, b, a);
return fma(r, 1.0 / b, q);
let corrected = fma(r, 1.0 / b, q);
let residual = fma(-corrected, b, a);
return fma(residual, 1.0 / b, corrected);
}
// util::unpremul: (straight colour, alpha).
+6 -1
View File
@@ -453,10 +453,15 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
u = bu + (u - bu) * persp;
v = bv + (v - bv) * persp;
}
if (u < -1e-9 || u > 1.0 + 1e-9 || v < -1e-9 || v > 1.0 + 1e-9) {
// The CPU's 1e-9 edge allowance disappears in f32 at 1.0. Allow eight
// f32 ULPs for the inverse solve so exact quad-edge pixels remain covered.
let edge = 0.00000095367431640625;
if (u < -edge || u > 1.0 + edge || v < -edge || v > 1.0 + edge) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
u = clamp(u, 0.0, 1.0);
v = clamp(v, 0.0, 1.0);
let rc = P.f[5];
let lx = rc.x + u * rc.z;
let ly = rc.y + v * rc.w;
+16 -16
View File
@@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if ((flags & 2u) != 0u) {
d = vec4<f32>(0.0);
}
store_q(vec2<i32>(x, y), d, P.f[3].z);
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
return;
}
let r0 = P.f[0];
@@ -52,7 +52,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (stencil && (flags & 1u) == 0u) {
d = blend_pixel(mode, d, vec4<f32>(0.0), 0.5);
}
store_q(vec2<i32>(x, y), d, P.f[3].z);
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
return;
}
var s = sample(src, sampling, px, py);
@@ -60,11 +60,11 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (s.w > 0.0) {
d = d + s * P.f[3].y;
}
store_q(vec2<i32>(x, y), d, P.f[3].z);
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
return;
}
if (s.w <= 0.0 && !stencil) {
store_q(vec2<i32>(x, y), d, P.f[3].z);
store_q(vec2<i32>(x, y), d, P.f[3].z, P.f[3].w);
return;
}
let op = P.f[3].x;
@@ -75,7 +75,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (mode == 1u || mode == 2u) {
n = hash_noise(u32(x), u32(y), seed);
}
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z);
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z, P.f[3].w);
}
// Blend `src` (same size) onto `aux` (Image::blend_from).
@@ -91,7 +91,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
let d = textureLoad(aux, p, 0);
var s = textureLoad(src, p, 0);
if (s.w <= 0.0 && !is_stencil(mode)) {
store_q(p, d, P.f[0].y);
store_q(p, d, P.f[0].y, P.f[0].z);
return;
}
s = s * P.f[0].x;
@@ -99,7 +99,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
if (mode == 1u || mode == 2u) {
n = hash_noise(gid.x, gid.y, P.u[0].z);
}
store_q(p, blend_pixel(mode, d, s, n), P.f[0].y);
store_q(p, blend_pixel(mode, d, s, n), P.f[0].y, P.f[0].z);
}
// Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma,
@@ -179,24 +179,24 @@ fn quantize(@builtin(global_invocation_id) gid: vec3<u32>) {
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x));
textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x, P.f[0].y));
}
fn quantize_px(s: vec4<f32>, levels: f32) -> vec4<f32> {
let inv = 1.0 / levels;
let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv;
fn quantize_px(s: vec4<f32>, levels: f32, inv: f32) -> vec4<f32> {
// Use the CPU-rounded reciprocal to avoid backend-dependent division.
let a = floor(clamp(s.w, 0.0, 1.0) * levels + 0.5) * inv;
var o = vec4<f32>(0.0, 0.0, 0.0, a);
o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv;
o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv;
o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv;
o.x = floor(clamp(s.x, 0.0, a) * levels + 0.5) * inv;
o.y = floor(clamp(s.y, 0.0, a) * levels + 0.5) * inv;
o.z = floor(clamp(s.z, 0.0, a) * levels + 0.5) * inv;
return o;
}
// Store a compositing result, clamped and quantised to `levels` when > 0 (8/16 bpc: the comp
// after a layer, fused into the layer's composite).
fn store_q(p: vec2<i32>, v: vec4<f32>, levels: f32) {
fn store_q(p: vec2<i32>, v: vec4<f32>, levels: f32, inv: f32) {
if (levels > 0.0) {
textureStore(out, p, quantize_px(v, levels));
textureStore(out, p, quantize_px(v, levels, inv));
} else {
textureStore(out, p, v);
}
+25 -1
View File
@@ -580,6 +580,30 @@ fn registry_badges_match_the_gpu_implementation() {
}
}
#[test]
fn quantization_preserves_cpu_levels_and_half_step_neighbors() {
let Some(g) = gpu() else { return };
for levels in [255.0_f32, 32768.0] {
let mut values = vec![0.0, 1.0];
for i in 0..255 {
let half = (i as f32 + 0.5) / levels;
values.extend([half.next_down(), half, half.next_up()]);
}
let mut cpu = Image::new(values.len() as u32, 1);
for (p, v) in cpu.data.iter_mut().zip(values) {
*p = [v, v, v, 1.0];
}
let input = g.ctx.upload_image(&cpu).unwrap();
effectcraft_render::color::quantize(&mut cpu, levels);
let mut e = crate::context::Enc::new(&g.ctx);
let out = crate::ops::quantize(&mut e, &input, levels);
let bytes = e.read_texture(&out.texture, out.width, out.height, 16).unwrap();
for (i, (want, got)) in cpu.data.iter().flatten().zip(bytes.as_chunks::<4>().0).enumerate() {
let actual = f32::from_le_bytes(*got);
assert_eq!(actual, *want, "levels {levels}, component {i}");
}
}
}
#[test]
fn backend_selection_and_display_frames() {
let Some(g) = gpu() else { return };
@@ -600,7 +624,7 @@ fn backend_selection_and_display_frames() {
let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame");
let bytes = g.read_display(&f).expect("readback");
let cpu = Renderer::new(&s.p, &Pattern, opts()).comp_frame_cpu(s.cid, Tick::ZERO);
for (p, q) in cpu.data.iter().zip(bytes.chunks_exact(4)) {
for (p, q) in cpu.data.iter().zip(bytes.as_chunks::<4>().0.iter()) {
let a = p[3].clamp(0.0, 1.0);
let want = [p[0].clamp(0.0, a), p[1].clamp(0.0, a), p[2].clamp(0.0, a), a].map(|v| (v * 255.0 + 0.5) as u8);
for k in 0..4 {
+21
View File
@@ -228,3 +228,24 @@ fn layer_inputs() {
}
}
}
#[test]
fn arithmetic_preserves_positive_subepsilon_alpha() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.channel.arithmetic").unwrap();
let size = [1.0, 1.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
params.values.insert("operator".into(), Value::Enum(3));
for key in ["redValue", "greenValue", "blueValue"] {
params.values.insert(key.into(), n(0.0));
}
params.values.insert("clip".into(), Value::Bool(false));
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: size, seed: 0, adjustment: false, env: Default::default() };
let img = effectcraft_raster::Image::filled(1, 1, [1.0, 0.0, 0.0, 5e-7]);
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
assert_eq!(cpu.img.get(0, 0), [1.0, 0.0, 0.0, 5e-7]);
assert_eq!(out.img.get(0, 0), cpu.img.get(0, 0));
}
+23
View File
@@ -202,3 +202,26 @@ fn echo_and_posterize_time() {
}
}
}
#[test]
fn dust_threshold_on_quantized_input() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.noise.dustscratches").unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
params.values.insert("radius".into(), n(3.0));
params.values.insert("threshold".into(), n(10.0));
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
for seed in 1..32 {
let mut img = crate::tests::pattern(seed, 70, 44);
effectcraft_render::color::quantize(&mut img, 255.0);
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
let cpu = (spec.render)(&ctx(), buf.clone());
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
for (i, (a, b)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
let d = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0_f32, f32::max);
assert!(d < 1e-6, "seed {seed} pixel {i} input {:?} CPU {a:?} GPU {b:?}", buf.img.data[i]);
}
}
}