mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
Use symmetric HexTile cell coordinates on CPU and GPU
Keep the existing regression and tolerance while preserving upstream WireRemoval and test-service recovery changes. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
|
||||
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
|
||||
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
|
||||
let mats = s.materials.iter().flat_map(|m| {
|
||||
let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 | (m.accepts_lights as u32) << 2 | (m.receives_shadows as u32) << 3 | (m.alpha_mode << 8);
|
||||
let flags = (m.double_sided as u32)
|
||||
| (m.unlit as u32) << 1
|
||||
| (m.accepts_lights as u32) << 2
|
||||
| (m.receives_shadows as u32) << 3
|
||||
| (m.shadows_only as u32) << 4
|
||||
| (m.alpha_mode << 8);
|
||||
[
|
||||
m.base[0],
|
||||
m.base[1],
|
||||
|
||||
+151
-8
@@ -140,6 +140,74 @@ pub struct TransferStats {
|
||||
pub readback_bytes: u64,
|
||||
}
|
||||
|
||||
/// `WGPU_ADAPTER_NAME` trimmed; empty or whitespace-only counts as unset (an empty
|
||||
/// substring would otherwise match the first adapter and skip the power preference).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn wanted_adapter_name(var: Option<&str>) -> Option<&str> {
|
||||
var.map(str::trim).filter(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// Index of the first adapter whose name contains `want`, compared case-insensitively.
|
||||
/// Only the name is matched, as in wgpu (the backend is chosen with `WGPU_BACKEND`).
|
||||
/// `Err` lists each distinct adapter once as `name (backend)`, so a typo or an
|
||||
/// OS/driver-specific name is easy to spot.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn match_adapter_name(want: &str, adapters: &[(String, String)]) -> Result<usize, String> {
|
||||
if adapters.is_empty() {
|
||||
return Err(format!("WGPU_ADAPTER_NAME={want:?}: no adapters on the enabled backends"));
|
||||
}
|
||||
let w = want.to_lowercase();
|
||||
adapters.iter().position(|(n, _)| n.to_lowercase().contains(&w)).ok_or_else(|| {
|
||||
let mut list: Vec<String> = Vec::new();
|
||||
for l in adapters.iter().map(|(n, b)| format!("{n} ({b})")) {
|
||||
if !list.contains(&l) {
|
||||
list.push(l);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"no adapter name matches WGPU_ADAPTER_NAME={want:?} (matched against the name only; \
|
||||
select a backend with WGPU_BACKEND); available: [{}]",
|
||||
list.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter named by `WGPU_ADAPTER_NAME` (case-insensitive substring, like wgpu's
|
||||
/// `initialize_adapter_from_env`, which panics when none matches). `None` when unset or empty.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn adapter_by_name(instance: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
let var = std::env::var("WGPU_ADAPTER_NAME").ok();
|
||||
let want = wanted_adapter_name(var.as_deref())?;
|
||||
let mut all = instance.enumerate_adapters(wgpu::Backends::all()).await;
|
||||
let adapters: Vec<(String, String)> = all
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
(i.name, format!("{:?}", i.backend))
|
||||
})
|
||||
.collect();
|
||||
Some(match_adapter_name(want, &adapters).map(|i| all.swap_remove(i)))
|
||||
}
|
||||
/// `WGPU_POWER_PREF` (`low` / `high` / `none`), default HighPerformance. wgpu ignores an
|
||||
/// unknown value silently; warn so a typo is noticed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::from_env().unwrap_or_else(|| {
|
||||
if let Ok(v) = std::env::var("WGPU_POWER_PREF") {
|
||||
log::warn!("gpu: ignoring WGPU_POWER_PREF={v:?} (expected low, high or none)");
|
||||
}
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
})
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn power_pref_from_env() -> wgpu::PowerPreference {
|
||||
wgpu::PowerPreference::HighPerformance
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn adapter_by_name(_: &wgpu::Instance) -> Option<Result<wgpu::Adapter, String>> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Transfers {
|
||||
fn count(&self, up: bool, bytes: usize) {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
@@ -774,19 +842,28 @@ impl GpuContext {
|
||||
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);
|
||||
// Honour the same wgpu env vars as the viewer (WGPU_BACKEND, WGPU_DX12_COMPILER,
|
||||
// WGPU_VALIDATION, WGPU_DEBUG, WGPU_POWER_PREF), plus WGPU_ADAPTER_NAME (CLI/headless
|
||||
// only for now; the viewer ignores it). Not full parity: the viewer also changes
|
||||
// instance flags and has a GL fallback.
|
||||
// Note: with_env() also resets memory_budget_thresholds to their default (unused here).
|
||||
desc = desc.with_env();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
desc.backends = wgpu::Backends::BROWSER_WEBGPU;
|
||||
}
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?;
|
||||
let (adapter, by_name) = match adapter_by_name(&instance).await {
|
||||
Some(a) => (a?, true),
|
||||
None => (
|
||||
instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_pref_from_env(), ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no adapter: {e}"))?,
|
||||
false,
|
||||
),
|
||||
};
|
||||
let limits = adapter.limits();
|
||||
let required_limits = wgpu::Limits {
|
||||
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
|
||||
@@ -796,7 +873,10 @@ impl GpuContext {
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() })
|
||||
.await
|
||||
.map_err(|e| format!("no device: {e}"))?;
|
||||
.map_err(|e| match by_name {
|
||||
true => format!("no device on {} (picked by WGPU_ADAPTER_NAME): {e}", adapter.get_info().name),
|
||||
false => format!("no device: {e}"),
|
||||
})?;
|
||||
let context = GpuContext::new(&adapter, device, queue)?;
|
||||
// This path owns the device; install handlers only after construction succeeds.
|
||||
crate::readback::install(&context.device, &context.readbacks);
|
||||
@@ -1392,3 +1472,66 @@ impl<'g> Enc<'g> {
|
||||
pub(crate) fn mip_levels(w: u32, h: u32) -> u32 {
|
||||
u32::BITS - w.max(h).max(1).leading_zeros()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod adapter_name_tests {
|
||||
use super::{match_adapter_name, wanted_adapter_name};
|
||||
|
||||
fn adapters() -> Vec<(String, String)> {
|
||||
[("NVIDIA GeForce GTX 1070", "Vulkan"), ("Intel(R) UHD Graphics 630", "Dx12"), ("llvmpipe (LLVM 19.1.7, 256 bits)", "Gl")]
|
||||
.map(|(n, b)| (n.into(), b.into()))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_case_insensitive_substring() {
|
||||
assert_eq!(match_adapter_name("intel", &adapters()), Ok(1));
|
||||
assert_eq!(match_adapter_name("GTX 1070", &adapters()), Ok(0));
|
||||
assert_eq!(match_adapter_name("LLVMPIPE", &adapters()), Ok(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_match_wins() {
|
||||
assert_eq!(match_adapter_name("i", &adapters()), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_blank_name_is_unset_and_value_is_trimmed() {
|
||||
assert_eq!(wanted_adapter_name(None), None);
|
||||
assert_eq!(wanted_adapter_name(Some("")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" ")), None);
|
||||
assert_eq!(wanted_adapter_name(Some(" intel ")), Some("intel"));
|
||||
assert_eq!(match_adapter_name(wanted_adapter_name(Some(" intel ")).unwrap(), &adapters()), Ok(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_name_and_backend() {
|
||||
let e = match_adapter_name("NoSuch", &adapters()).unwrap_err();
|
||||
assert!(e.contains("\"NoSuch\""), "{e}");
|
||||
assert!(e.contains("Intel(R) UHD Graphics 630 (Dx12)"), "{e}");
|
||||
assert!(e.contains("NVIDIA GeForce GTX 1070 (Vulkan)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_is_not_part_of_the_match_and_message_says_so() {
|
||||
let e = match_adapter_name("dx12", &adapters()).unwrap_err();
|
||||
assert!(e.contains("name only"), "{e}");
|
||||
assert!(e.contains("WGPU_BACKEND"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_lists_identical_name_and_backend_once() {
|
||||
let mut a = adapters();
|
||||
a.insert(2, ("Intel(R) UHD Graphics 630".into(), "Dx12".into()));
|
||||
a.push(("Intel(R) UHD Graphics 630".into(), "Vulkan".into()));
|
||||
let e = match_adapter_name("NoSuch", &a).unwrap_err();
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Dx12)").count(), 1, "{e}");
|
||||
assert_eq!(e.matches("Intel(R) UHD Graphics 630 (Vulkan)").count(), 1, "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_adapters_has_its_own_message() {
|
||||
let e = match_adapter_name("x", &[]).unwrap_err();
|
||||
assert!(e.contains("no adapters on the enabled backends"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
|
||||
let dir = ctx.params.f("direction").to_radians();
|
||||
let speed = ctx.params.f("speed");
|
||||
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
|
||||
let (dx, dy) = (dir.sin(), -dir.cos());
|
||||
// Cardinal directions must not acquire a tiny perpendicular slope: at a square-wave
|
||||
// boundary cos(90°)'s rounding residue can flip a whole column on CPU but not GPU.
|
||||
let axis = |v: f64| if v.abs() < 4.0 * f64::EPSILON { 0.0 } else { v };
|
||||
let (dx, dy) = (axis(dir.sin()), -axis(dir.cos()));
|
||||
if !ctx.adjustment {
|
||||
b.pad(e, h.abs().ceil() as u32 + 1)?;
|
||||
}
|
||||
|
||||
@@ -493,7 +493,7 @@ fn wire_removal(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let mut p = Params::default();
|
||||
p.u[0][0] = ctx.params.e("removalStyle");
|
||||
p.f[0] = [a.0 as f32, a.1 as f32, -ty as f32, tx as f32];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, 0.0];
|
||||
p.f[1] = [half as f32, (half * slope as f64) as f32, mirror, len as f32];
|
||||
run(e, "fp2_wire", &p, b, None, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,53 @@ struct FOut {
|
||||
@location(1) depth: u32,
|
||||
};
|
||||
|
||||
// shade::to_light: unit direction to the light (xyz) and its distance (w).
|
||||
fn to_light(kind: u32, lpos: vec3<f32>, ldir: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
|
||||
if (kind == 0u) {
|
||||
return vec4<f32>(-ldir, 1e30);
|
||||
}
|
||||
let d = lpos - pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
|
||||
}
|
||||
return vec4<f32>(d / len, len);
|
||||
}
|
||||
|
||||
// shade::shadow_only: the strongest shadow any light casts on the fragment.
|
||||
fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32>) -> f32 {
|
||||
var amount = 0.0;
|
||||
if (unlit) {
|
||||
return amount;
|
||||
}
|
||||
var n = norm3(normal);
|
||||
if (!front) {
|
||||
n = -n;
|
||||
}
|
||||
let nl_count = u32(g.fwd.w);
|
||||
for (var li = 0u; li < nl_count; li = li + 1u) {
|
||||
let lb = li * LIGHT_STRIDE;
|
||||
let l0 = lights[lb];
|
||||
let l1 = lights[lb + 1u];
|
||||
let l2 = lights[lb + 2u];
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
if (attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist) <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
let n_l = dot(n, to_l);
|
||||
if (n_l <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let mb = i.material * MAT_STRIDE;
|
||||
@@ -302,6 +349,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let unlit = (flags & 2u) != 0u;
|
||||
let accepts_lights = (flags & 4u) != 0u;
|
||||
let receives_shadows = (flags & 8u) != 0u;
|
||||
let shadows_only = (flags & 16u) != 0u;
|
||||
let alpha_mode = (flags >> 8u) & 3u;
|
||||
if (!front && !double_sided) {
|
||||
discard;
|
||||
@@ -330,6 +378,10 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
}
|
||||
var out: FOut;
|
||||
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
|
||||
if (shadows_only) {
|
||||
out.color = vec4<f32>(0.0, 0.0, 0.0, alpha * shadow_only_amount(unlit || !accepts_lights, i.normal, front, i.pos));
|
||||
return out;
|
||||
}
|
||||
if (unlit || !accepts_lights || g.env2.y < 0.5) {
|
||||
let c = rgb + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
@@ -381,22 +433,9 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
let l3 = lights[lb + 3u];
|
||||
let l4 = lights[lb + 4u];
|
||||
let kind = u32(l0.w);
|
||||
var to_l: vec3<f32>;
|
||||
var dist: f32;
|
||||
if (kind == 0u) {
|
||||
to_l = -l1.xyz;
|
||||
dist = 1e30;
|
||||
} else {
|
||||
let d = l0.xyz - i.pos;
|
||||
let len = sqrt(dot(d, d));
|
||||
if (len < 1e-6) {
|
||||
to_l = vec3<f32>(0.0, 0.0, -1.0);
|
||||
dist = 0.0;
|
||||
} else {
|
||||
to_l = d / len;
|
||||
dist = len;
|
||||
}
|
||||
}
|
||||
let tl = to_light(kind, l0.xyz, l1.xyz, i.pos);
|
||||
let to_l = tl.xyz;
|
||||
let dist = tl.w;
|
||||
let k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
|
||||
if (k <= 0.0) {
|
||||
continue;
|
||||
|
||||
@@ -830,7 +830,7 @@ fn fp2_io_final(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CC Simple Wire Removal (keying2::wire_removal)
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror).
|
||||
// u[0].x = style; f[0] = (ax, ay, nx, ny); f[1] = (hw thickness, soft width, mirror, segment length).
|
||||
|
||||
fn fp2_wire_pos(base: vec2<f32>, nrm: vec2<f32>, s: f32) -> vec4<f32> {
|
||||
return sample_bilinear_clamped(src, base.x + nrm.x * s, base.y + nrm.y * s);
|
||||
@@ -853,7 +853,9 @@ fn fp2_wire(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let q = vec2<f32>(fx - a.x, fy - a.y);
|
||||
let d = q.x * nrm.x + q.y * nrm.y;
|
||||
let ad = abs(d);
|
||||
if (ad > hw + soft_w) {
|
||||
// The wire is the segment A to B: the tangent is the normal turned back by 90 degrees.
|
||||
let along = q.x * nrm.y - q.y * nrm.x;
|
||||
if (along < 0.0 || along > P.f[1].w || ad > hw + soft_w) {
|
||||
textureStore(out, p, px);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,18 @@ fn gpu_matches_cpu_unlit_and_full_frames() {
|
||||
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_matches_cpu_shadow_catcher() {
|
||||
// #1691: the floor plane as a shadow catcher ("Accepts Shadows: Only").
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = scene_project();
|
||||
let floor = p.comp_mut(cid).unwrap().layers.last_mut().unwrap();
|
||||
set(floor, "materialOptions/acceptsShadows", Value::Enum(2));
|
||||
let s = scene(&p, cid);
|
||||
assert!(s.materials.iter().any(|m| m.shadows_only) && !s.shadows.is_empty());
|
||||
compare(&s, &g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_raster_perf_smoke() {
|
||||
let Some(g) = gpu() else { return };
|
||||
|
||||
@@ -28,6 +28,11 @@ fn twirl_bulge_ripple_wave_warp() {
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
|
||||
// #1181: the single-edge pinning paths, with the default vertical displacement direction.
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]);
|
||||
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -79,6 +84,9 @@ fn shape_case(id: &str, vals: &[(&str, Value)]) {
|
||||
fn edge_pinning_on_a_shape_layer() {
|
||||
shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
// #1181: single-edge pinning over the layer rect, not just All Edges.
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
|
||||
shape_case(
|
||||
"ec.distort.bulge",
|
||||
&[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))],
|
||||
|
||||
Reference in New Issue
Block a user