mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
G2: improve editing reliability and Windows rendering fidelity
This commit is contained in:
+10
-4
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: ¶ms, 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));
|
||||
}
|
||||
|
||||
@@ -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: ¶ms, 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user