mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
GPU on wgpu's OpenGL backend: renderable depth target, portable shaders, serialised tests
FreeBSD's CI VM (Mesa llvmpipe over GL) failed 22 effectcraft-gpu tests: - Advanced 3D's camera-depth target was R32Float, which GLES can't render to without EXT_color_buffer_float: create_render_pipeline failed validation (release builds only log that and draw nothing). The target is now R32Uint holding the f32 bits (renderable on every backend, lossless), and GpuContext checks the adapter's format features (render attachment, blendable colour) and independent blending up front; when they're missing the render pipelines are never built and Advanced 3D renders on the CPU. - Signed `%` in the shaders: naga emits GLSL `%`, undefined for negative operands, which broke the VR effects' horizontal wrap and Cell Pattern's tiling. A shared `imod` never takes `%` of a negative number. - asin/acos: Mesa's are coarse approximations (~1e-4 rad), visible in every lens/sphere/cylinder mapping. `asin_p`/`acos_p` (Cephes' asinf polynomial) are f32-accurate on every backend. - "Could not lock adapter context": the GL backend has one context lock per adapter with a timeout, so tests driving devices concurrently panic. Every gpu test helper that hands out a device holds one process-wide lock for the calling test; ui_web_job_gpu's two device tests share a lock too. - On GL, up to 1 % of pixels may flip across a decision boundary (division through a reciprocal, unfused fma); the GPU-vs-CPU comparisons allow that there only. Also adds the FreeBSD workflow (from the FreeBSD CI PR) so this branch is checked there. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e2f027f374
commit
6eedf8e2c5
20 files changed
+250
-57
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
+41
-20
@@ -2,7 +2,7 @@
|
||||
//! (`Depth32Float`, Greater), the scene's materials, textures, lights and shadow maps in
|
||||
//! storage buffers, and `advanced3d.wgsl`'s physically based fragment shader. Opaque triangles
|
||||
//! are drawn with depth writes, then the sorted transparent ones blended over (premultiplied).
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Uint` holding f32 bits) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! compute kernels, which run the rest of `effectcraft_render::three_d::adv::render_prepared`:
|
||||
//! the 2×2 supersampling resolve, the motion-blur sub-sample average (nearest depth), the
|
||||
//! depth-based iris depth of field (the Classic 3D bokeh kernel's row spans and prefix-sum
|
||||
@@ -22,20 +22,44 @@ use crate::context::{Enc, GpuContext, GpuImage};
|
||||
/// Pipelines (built on first use).
|
||||
pub(crate) struct Pipes {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
opaque: wgpu::RenderPipeline,
|
||||
transparent: wgpu::RenderPipeline,
|
||||
/// (opaque, transparent); `None` when the adapter can't rasterise ([`raster_unsupported`]):
|
||||
/// scenes then render on the CPU.
|
||||
raster: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
post: Post,
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device))
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device, g.adv3d_raster))
|
||||
}
|
||||
|
||||
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Float;
|
||||
/// Camera depth as the bits of an f32: integer targets render on every backend, while `R32Float`
|
||||
/// needs `EXT_color_buffer_float` on GLES (absent on Mesa's llvmpipe, for one).
|
||||
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
|
||||
/// [`DEPTH_OUT`]'s clear value: the bits of −1.0 ("nothing drawn").
|
||||
const NO_DEPTH: f64 = 0xBF80_0000u32 as f64;
|
||||
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
|
||||
const VERTEX_SIZE: u64 = 52;
|
||||
|
||||
/// Why `adapter` can't run the rasteriser, or `None` when it can. Building the pipelines anyway
|
||||
/// would be a validation error, which release builds only log (and then draw nothing), so the
|
||||
/// caller keeps the scenes on the CPU instead.
|
||||
pub(crate) fn raster_unsupported(adapter: &wgpu::Adapter) -> Option<String> {
|
||||
use wgpu::TextureFormatFeatureFlags as F;
|
||||
let needs = [(COLOR, F::BLENDABLE), (DEPTH_OUT, F::empty()), (DEPTH, F::empty())];
|
||||
for (format, flags) in needs {
|
||||
let f = adapter.get_texture_format_features(format);
|
||||
if !f.allowed_usages.contains(wgpu::TextureUsages::RENDER_ATTACHMENT) || !f.flags.contains(flags) {
|
||||
return Some(format!("{format:?} is not a {}render target", if flags.is_empty() { "" } else { "blendable " }));
|
||||
}
|
||||
}
|
||||
// The transparent pipeline blends colour and masks the depth target off.
|
||||
if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND) {
|
||||
return Some("no independent blending".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -46,7 +70,7 @@ fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
}
|
||||
|
||||
impl Pipes {
|
||||
fn new(device: &wgpu::Device) -> Pipes {
|
||||
fn new(device: &wgpu::Device, raster: bool) -> Pipes {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/advanced3d.wgsl").into()),
|
||||
@@ -110,7 +134,7 @@ impl Pipes {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl, post: Post::new(device) }
|
||||
Pipes { raster: raster.then(|| (make(false), make(true))), bgl, post: Post::new(device) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +246,9 @@ fn half_to_f32(h: u16) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Float`
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Uint`
|
||||
/// with −1 where nothing was drawn) at the scene's raster size. `None` when the device can't
|
||||
/// (size or buffer limits).
|
||||
/// (render targets, size or buffer limits).
|
||||
fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let g = e.g;
|
||||
if !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
@@ -236,6 +260,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
||||
return None;
|
||||
}
|
||||
let p = pipes(g);
|
||||
let Some((opaque, transparent)) = &p.raster else { return None };
|
||||
let dev = &g.device;
|
||||
let vb: Vec<u8> = s
|
||||
.vertices
|
||||
@@ -294,7 +319,7 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
||||
view: &zv,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: -1.0, g: 0.0, b: 0.0, a: 0.0 }), store: wgpu::StoreOp::Store },
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color { r: NO_DEPTH, g: 0.0, b: 0.0, a: 0.0 }), store: wgpu::StoreOp::Store },
|
||||
}),
|
||||
],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
@@ -311,11 +336,11 @@ fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)>
|
||||
pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
|
||||
let n = s.indices.len() as u32;
|
||||
if s.opaque_count > 0 {
|
||||
pass.set_pipeline(&p.opaque);
|
||||
pass.set_pipeline(opaque);
|
||||
pass.draw_indexed(0..s.opaque_count, 0, 0..1);
|
||||
}
|
||||
if n > s.opaque_count {
|
||||
pass.set_pipeline(&p.transparent);
|
||||
pass.set_pipeline(transparent);
|
||||
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -424,14 +449,10 @@ impl Post {
|
||||
label: Some("effectcraft advanced 3d post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/adv3d.wgsl").into()),
|
||||
});
|
||||
let tex = |binding| wgpu::BindGroupLayoutEntry {
|
||||
let tex = |binding, sample_type| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
ty: wgpu::BindingType::Texture { sample_type, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false },
|
||||
count: None,
|
||||
};
|
||||
let buf = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
@@ -447,8 +468,8 @@ impl Post {
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex(1),
|
||||
tex(2),
|
||||
tex(1, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
tex(2, wgpu::TextureSampleType::Uint),
|
||||
buf(3, false),
|
||||
buf(4, false),
|
||||
buf(5, false),
|
||||
|
||||
@@ -259,6 +259,9 @@ pub struct GpuContext {
|
||||
zeros: Mutex<HashMap<(u32, u32), GpuImage>>,
|
||||
/// Advanced 3D render pipelines (built on first use).
|
||||
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
|
||||
/// The adapter can run the Advanced 3D rasteriser (`adv3d::raster_unsupported`); without
|
||||
/// it Advanced 3D scenes render on the CPU.
|
||||
pub(crate) adv3d_raster: bool,
|
||||
/// GPU particle pipeline (built on first use) and simulation checkpoints.
|
||||
pub(crate) particles: crate::particles::PipesCell,
|
||||
pub(crate) particle_states: crate::particles::StatesCell,
|
||||
@@ -303,6 +306,10 @@ impl GpuContext {
|
||||
}
|
||||
}
|
||||
let name = format!("{} ({:?})", info.name, info.backend);
|
||||
let adv3d_unsupported = crate::adv3d::raster_unsupported(adapter);
|
||||
if let Some(why) = &adv3d_unsupported {
|
||||
log::info!("gpu {name}: Advanced 3D renders on the CPU ({why})");
|
||||
}
|
||||
let src = [
|
||||
include_str!("shaders/common.wgsl"),
|
||||
include_str!("shaders/kernels.wgsl"),
|
||||
@@ -466,6 +473,7 @@ impl GpuContext {
|
||||
transfers: Default::default(),
|
||||
zeros: Default::default(),
|
||||
adv3d: std::sync::OnceLock::new(),
|
||||
adv3d_raster: adv3d_unsupported.is_none(),
|
||||
particles: Default::default(),
|
||||
particle_states: Default::default(),
|
||||
deferred: None,
|
||||
|
||||
@@ -25,7 +25,9 @@ struct Post {
|
||||
|
||||
@group(0) @binding(0) var<uniform> P: Post;
|
||||
@group(0) @binding(1) var color_in: texture_2d<f32>;
|
||||
@group(0) @binding(2) var z_in: texture_2d<f32>;
|
||||
// Camera depth as f32 bits (an `R32Uint` target: integer targets render on every backend,
|
||||
// `R32Float` doesn't on GLES without EXT_color_buffer_float).
|
||||
@group(0) @binding(2) var z_in: texture_2d<u32>;
|
||||
@group(0) @binding(3) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> depth: array<f32>;
|
||||
@group(0) @binding(5) var<storage, read_write> src: array<vec4<f32>>;
|
||||
@@ -55,7 +57,7 @@ fn resolve(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
for (var i = 0u; i < k; i = i + 1u) {
|
||||
let p = vec2<i32>(i32(id.x * k + i), i32(id.y * k + j));
|
||||
a = a + textureLoad(color_in, p, 0);
|
||||
let z = textureLoad(z_in, p, 0).x;
|
||||
let z = bitcast<f32>(textureLoad(z_in, p, 0).x);
|
||||
if (z >= 0.0) {
|
||||
dz = dz + z;
|
||||
dn = dn + 1.0;
|
||||
|
||||
@@ -75,26 +75,28 @@ fn srgb_to_linear(c: f32) -> f32 {
|
||||
return pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
// `a` mod `n` in [0, n) for n > 0 (floored). Never `%` on a negative operand: WGSL's `%` truncates,
|
||||
// but naga's GLSL output is plain `%`, undefined for negative operands (wrong on Mesa's GL).
|
||||
fn imod(a: i32, n: i32) -> i32 {
|
||||
if (a >= 0) {
|
||||
return a % n;
|
||||
}
|
||||
return n - 1 - (-(a + 1)) % n;
|
||||
}
|
||||
|
||||
fn wrap_i(i: i32, n: i32, mode: u32) -> i32 {
|
||||
if (mode == 1u) {
|
||||
return clamp(i, 0, n - 1);
|
||||
}
|
||||
if (mode == 2u) {
|
||||
let p = 2 * n;
|
||||
var m = i % p;
|
||||
if (m < 0) {
|
||||
m = m + p;
|
||||
}
|
||||
let m = imod(i, p);
|
||||
if (m >= n) {
|
||||
return p - 1 - m;
|
||||
}
|
||||
return m;
|
||||
}
|
||||
var m = i % n;
|
||||
if (m < 0) {
|
||||
m = m + n;
|
||||
}
|
||||
return m;
|
||||
return imod(i, n);
|
||||
}
|
||||
|
||||
fn texel(t: vec4<u32>, x: i32, y: i32) -> vec4<f32> {
|
||||
@@ -282,7 +284,8 @@ fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_
|
||||
|
||||
struct FOut {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: f32,
|
||||
// Camera depth's f32 bits (an `R32Uint` target, see `adv3d.rs`).
|
||||
@location(1) depth: u32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
@@ -326,7 +329,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
|
||||
emissive = emissive * sample_tex(tex_em, i.uv).rgb;
|
||||
}
|
||||
var out: FOut;
|
||||
out.depth = dot(g.view_z.xyz, i.pos) + g.view_z.w;
|
||||
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
|
||||
if (unlit || !accepts_lights || g.env2.y < 0.5) {
|
||||
let c = rgb + emissive;
|
||||
out.color = vec4<f32>(c * alpha, alpha);
|
||||
|
||||
@@ -186,7 +186,7 @@ fn attenuation(l: u32, p: vec3<f32>) -> f32 {
|
||||
}
|
||||
if (li.ints.x == 3u) {
|
||||
let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0);
|
||||
let a = acos(cos_a);
|
||||
let a = acos_p(cos_a);
|
||||
let outer = li.cone.x;
|
||||
let inner = outer * (1.0 - li.cone.y);
|
||||
if (a <= inner) {
|
||||
|
||||
@@ -16,6 +16,40 @@ struct Params {
|
||||
@group(0) @binding(2) var aux: texture_2d<f32>;
|
||||
@group(0) @binding(4) var<storage, read> data: array<f32>;
|
||||
|
||||
// `a` mod `n` in [0, n) for n > 0 (floored). Never `%` on a negative operand: WGSL's `%` truncates,
|
||||
// but naga's GLSL output is plain `%`, undefined for negative operands (wrong on Mesa's GL).
|
||||
fn imod(a: i32, n: i32) -> i32 {
|
||||
if (a >= 0) {
|
||||
return a % n;
|
||||
}
|
||||
return n - 1 - (-(a + 1)) % n;
|
||||
}
|
||||
|
||||
// asin / acos to f32 precision on every backend (Cephes' asinf polynomial). WGSL leaves their
|
||||
// precision to the implementation, and Mesa's GL drivers use a coarse approximation (errors near
|
||||
// 1e-4 rad), which shows in every effect that maps through a sphere or a lens.
|
||||
fn asin_p(x: f32) -> f32 {
|
||||
let a = abs(x);
|
||||
let big = a > 0.5;
|
||||
let z = select(a * a, 0.5 * (1.0 - a), big);
|
||||
let s = select(a, sqrt(z), big);
|
||||
var p = ((((4.2163199048e-2 * z + 2.4181311049e-2) * z + 4.5470025998e-2) * z + 7.4953002686e-2) * z + 1.6666752422e-1) * z * s + s;
|
||||
if (big) {
|
||||
p = 1.5707963267948966 - 2.0 * p;
|
||||
}
|
||||
return select(p, -p, x < 0.0);
|
||||
}
|
||||
|
||||
fn acos_p(x: f32) -> f32 {
|
||||
if (x > 0.5) {
|
||||
return 2.0 * asin_p(sqrt(0.5 * (1.0 - x)));
|
||||
}
|
||||
if (x < -0.5) {
|
||||
return 3.141592653589793 - 2.0 * asin_p(sqrt(0.5 * (1.0 + x)));
|
||||
}
|
||||
return 1.5707963267948966 - asin_p(x);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sampling (raster::Image)
|
||||
|
||||
fn tex_get(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
|
||||
|
||||
@@ -215,7 +215,7 @@ fn dst_cclens(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
var s = q;
|
||||
if (d >= 1e-9) {
|
||||
let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
|
||||
let g = powz(asin_p(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
|
||||
let k = r * g / d;
|
||||
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
|
||||
}
|
||||
|
||||
@@ -653,7 +653,7 @@ fn fg2_threads(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let fv = v / th - f32(cj);
|
||||
let in_h = abs(fv - 0.5) < coverage * 0.5;
|
||||
let in_v = abs(fu - 0.5) < coverage * 0.5;
|
||||
let h_top = ((ci + cj) % (2 * overlaps) + 2 * overlaps) % (2 * overlaps) < overlaps;
|
||||
let h_top = imod(ci + cj, 2 * overlaps) < overlaps;
|
||||
var horizontal: bool;
|
||||
if (in_h && in_v) {
|
||||
horizontal = h_top;
|
||||
|
||||
@@ -349,8 +349,8 @@ fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3<f32> {
|
||||
var tx = gx;
|
||||
var ty = gy;
|
||||
if (tile) {
|
||||
tx = ((gx % nx) + nx) % nx;
|
||||
ty = ((gy % ny) + ny) % ny;
|
||||
tx = imod(gx, nx);
|
||||
ty = imod(gy, ny);
|
||||
}
|
||||
let hx = bitcast<u32>(tx);
|
||||
let hy = bitcast<u32>(ty);
|
||||
@@ -428,7 +428,7 @@ fn gen_axis(q: f32, size: f32, f: f32) -> f32 {
|
||||
let fr = u - fl;
|
||||
let d = min(fr, 1.0 - fr) * size;
|
||||
let n = i32(fl);
|
||||
let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0);
|
||||
let s = select(-1.0, 1.0, imod(n, 2) == 0);
|
||||
return s * min(d / (f * 0.5), 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@ fn fp2_hextile(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let hy = r * 1.5 * rz;
|
||||
var lu = u - hx;
|
||||
var lv = v - hy;
|
||||
let odd = ((i32(rx) + i32(rz)) % 2 + 2) % 2 == 1;
|
||||
let odd = imod(i32(rx) + i32(rz), 2) == 1;
|
||||
if ((mode == 1u || mode == 2u) && odd) {
|
||||
lu = -lu;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ fn fxs_qdiv(x: vec3<f32>, a: f32) -> vec3<f32> {
|
||||
}
|
||||
|
||||
fn fxs_rem2(i: i32) -> i32 {
|
||||
return ((i % 2) + 2) % 2;
|
||||
return imod(i, 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- per-pixel colour effects
|
||||
@@ -257,7 +257,7 @@ fn fxs_warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let d = sqrt(dx * dx + dy * dy);
|
||||
if (d < r && d != 0.0) {
|
||||
let nd = d / r;
|
||||
let k = (asin(nd) / FXS_HALF_PI) / nd;
|
||||
let k = (asin_p(nd) / FXS_HALF_PI) / nd;
|
||||
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +681,7 @@ fn ftr_cylinder(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
let tau = 6.283185307179586;
|
||||
let th = asin(s);
|
||||
let th = asin_p(s);
|
||||
let cz = cos(th);
|
||||
let spin = P.f[2].x;
|
||||
let front = ftr_shade(ftr_texel((th + spin) / tau + 0.5, v), vec3<f32>(s, 0.0, cz));
|
||||
@@ -695,7 +695,7 @@ fn ftr_sphere_tex(q: vec3<f32>) -> vec4<f32> {
|
||||
let m1 = P.f[8].xyz;
|
||||
let m2 = P.f[9].xyz;
|
||||
let t = m0 * q.x + m1 * q.y + m2 * q.z;
|
||||
let lat = asin(clamp(t.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(t.y, -1.0, 1.0));
|
||||
return ftr_texel(ftr_lon_u(t.x, t.z), lat / 3.141592653589793 + 0.5);
|
||||
}
|
||||
|
||||
@@ -775,7 +775,7 @@ fn ftr_env_uv(mapping: u32, r: vec3<f32>) -> vec2<f32> {
|
||||
}
|
||||
return vec2<f32>((cr.x + (cr.z + 1.0) * 0.5) / 3.0, (cr.y + (cr.w + 1.0) * 0.5) / 4.0);
|
||||
}
|
||||
let lat = asin(clamp(r.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(r.y, -1.0, 1.0));
|
||||
return vec2<f32>(ftr_lon_u(r.x, r.z), 0.5 + lat / 3.141592653589793);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ fn fxv_equi_dir(x: f32, y: f32, w: f32, h: f32) -> vec3<f32> {
|
||||
// vr::equi_px.
|
||||
fn fxv_equi_px(d: vec3<f32>, w: f32, h: f32) -> vec2<f32> {
|
||||
let lon = atan2(d.x, d.z);
|
||||
let lat = asin(clamp(d.y, -1.0, 1.0));
|
||||
let lat = asin_p(clamp(d.y, -1.0, 1.0));
|
||||
return vec2<f32>((lon / (2.0 * FXV_PI) + 0.5) * w, (0.5 - lat / FXV_PI) * h);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fn fxv_equi_at(xi0: i32, yi0: i32, w: i32, h: i32) -> vec4<f32> {
|
||||
yi = 2 * h - 1 - yi;
|
||||
xi += w / 2;
|
||||
}
|
||||
let xm = ((xi % w) + w) % w;
|
||||
let xm = imod(xi, w);
|
||||
return textureLoad(src, vec2<i32>(xm, clamp(yi, 0, h - 1)), 0);
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ fn fxv_proj_sample(kind: u32, fov: f32, d: vec3<f32>) -> vec4<f32> {
|
||||
return sample_bilinear_clamped(src, x, y);
|
||||
}
|
||||
if (kind == 8u) {
|
||||
let th = acos(clamp(d.y, -1.0, 1.0));
|
||||
let th = acos_p(clamp(d.y, -1.0, 1.0));
|
||||
if (th > FXV_PI / 2.0 + 1e-6) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn fxv_proj_sample(kind: u32, fov: f32, d: vec3<f32>) -> vec4<f32> {
|
||||
}
|
||||
if (kind == 4u) {
|
||||
let half = radians(clamp(fov, 1.0, 360.0) / 2.0);
|
||||
let th = acos(clamp(d.z, -1.0, 1.0));
|
||||
let th = acos_p(clamp(d.z, -1.0, 1.0));
|
||||
if (th > half + 1e-6) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
@@ -346,12 +346,12 @@ fn fxv_box_h(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let norm = 1.0 / f32(2 * r + 1);
|
||||
var acc = vec4<f32>(0.0);
|
||||
for (var i = b0 - r; i <= b0 + r; i++) {
|
||||
acc += textureLoad(src, vec2<i32>(((i % w) + w) % w, y), 0);
|
||||
acc += textureLoad(src, vec2<i32>(imod(i, w), y), 0);
|
||||
}
|
||||
for (var x = b0; x < b1; x++) {
|
||||
textureStore(out, vec2<i32>(x, y), acc * norm);
|
||||
let add = textureLoad(src, vec2<i32>((x + r + 1) % w, y), 0);
|
||||
let sub = textureLoad(src, vec2<i32>(((x - r) % w + w) % w, y), 0);
|
||||
let sub = textureLoad(src, vec2<i32>(imod(x - r, w), y), 0);
|
||||
acc += add - sub;
|
||||
}
|
||||
}
|
||||
@@ -362,10 +362,10 @@ fn fxv_pole_at(x0: i32, y0: i32, w: i32, h: i32) -> vec4<f32> {
|
||||
var y = y0;
|
||||
if (y < 0) {
|
||||
y = -1 - y;
|
||||
x = (x + w / 2) % w;
|
||||
x = imod(x + w / 2, w);
|
||||
} else if (y >= h) {
|
||||
y = 2 * h - 1 - y;
|
||||
x = (x + w / 2) % w;
|
||||
x = imod(x + w / 2, w);
|
||||
}
|
||||
return textureLoad(src, vec2<i32>(x, clamp(y, 0, h - 1)), 0);
|
||||
}
|
||||
@@ -413,7 +413,7 @@ fn fxv_wrap_pad(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
sy = 2 * s.y - 1 - sy;
|
||||
sx += s.x / 2;
|
||||
}
|
||||
textureStore(out, p, textureLoad(src, vec2<i32>(((sx % s.x) + s.x) % s.x, clamp(sy, 0, s.y - 1)), 0));
|
||||
textureStore(out, p, textureLoad(src, vec2<i32>(imod(sx, s.x), clamp(sy, 0, s.y - 1)), 0));
|
||||
}
|
||||
|
||||
// Per-pixel steps. u[0].x = 0: VR Glow's highlights (u[0].y tint; f[0] = (threshold,
|
||||
|
||||
@@ -237,7 +237,7 @@ fn fxw_pageturn(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
cand[0] = vec4<f32>(FXW_PI * r - d, 1.0, 0.9, 1.0);
|
||||
cand[3] = vec4<f32>(d, 0.0, 1.0, 1.0);
|
||||
} else {
|
||||
let a = asin(clamp(d / r, -1.0, 1.0));
|
||||
let a = asin_p(clamp(d / r, -1.0, 1.0));
|
||||
cand[1] = vec4<f32>(FXW_PI * r - r * a, 1.0, 0.6 + 0.4 * cos(a), 1.0);
|
||||
let psi = a / (0.5 * FXW_PI);
|
||||
cand[2] = vec4<f32>(r * a, 0.0, 1.0 - 0.4 * psi * (0.5 + 0.5 * ldot), 1.0);
|
||||
|
||||
@@ -21,7 +21,7 @@ fn adv_sky(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// shade::equirect_uv.
|
||||
let pi = 3.141592653589793;
|
||||
var u = 0.5 + (atan2(dir.x, dir.z) + P.f[2].w) / (2.0 * pi);
|
||||
let v = acos(clamp(-dir.y, -1.0, 1.0)) / pi;
|
||||
let v = acos_p(clamp(-dir.y, -1.0, 1.0)) / pi;
|
||||
u = u - floor(u);
|
||||
let iw = P.f[4].x;
|
||||
let ih = P.f[4].y;
|
||||
|
||||
+47
-1
@@ -26,8 +26,31 @@ use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::Gpu;
|
||||
|
||||
/// One GPU test at a time in this process: the calling test thread holds a process-wide lock
|
||||
/// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa
|
||||
/// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a
|
||||
/// thread waits on it for more than a few seconds ("Could not lock adapter context"), and
|
||||
/// concurrent devices on llvmpipe can crash. Every test helper that hands out a device calls this
|
||||
/// first; calling it again on the same thread is a no-op.
|
||||
pub(crate) fn hold_gpu_lock() {
|
||||
use std::cell::RefCell;
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||||
thread_local! {
|
||||
static HELD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
|
||||
}
|
||||
HELD.with(|h| {
|
||||
let mut h = h.borrow_mut();
|
||||
if h.is_none() {
|
||||
*h = Some(GPU_LOCK.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
hold_gpu_lock();
|
||||
G.get_or_init(|| {
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
@@ -38,6 +61,20 @@ pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||||
pub(crate) fn on_gl() -> bool {
|
||||
gpu().is_some_and(|g| g.ctx.name.ends_with("(Gl)"))
|
||||
}
|
||||
|
||||
/// On GL, the fraction of pixels allowed past the tolerance: pixels on a decision boundary (a
|
||||
/// hex cell's edge, a threshold, a corner pin's coverage edge, a median's rank tie) may land on
|
||||
/// the other side, since GL drivers may divide through a reciprocal and GLSL's `fma` need not be
|
||||
/// fused (so `fxs_qdiv`-style corrections don't apply). Measured on llvmpipe: at most 0.53 % (CC
|
||||
/// HexTile at half resolution, many cell edges in a small frame). A wrong wrap (signed `%`) showed
|
||||
/// in 3–70 % of pixels and still fails; a precision loss may show in fewer (Mesa's `asin`: 0.3–3 %),
|
||||
/// so the shaders avoid imprecise builtins themselves (`asin_p`) rather than lean on this.
|
||||
pub(crate) const GL_BOUNDARY_FLIPS: f64 = 0.01;
|
||||
|
||||
/// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and
|
||||
/// fully transparent regions (different per item).
|
||||
pub(crate) struct Pattern;
|
||||
@@ -205,6 +242,7 @@ pub(crate) fn check(label: &str, d: Option<Diff>, allow: f64) {
|
||||
let frac = d.over as f64 / d.total as f64;
|
||||
// Quantised depths: rounding-boundary flips (see the module docs).
|
||||
let (allow, cap) = if d.quantized && allow == 0.0 { (0.003, 4.0 / 255.0 + 1e-6) } else { (allow, f32::INFINITY) };
|
||||
let (allow, cap) = if on_gl() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) };
|
||||
assert!(frac <= allow && d.max <= cap, "{label}: {} of {} pixels over tolerance (max {:.6}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
if d.over > 0 {
|
||||
eprintln!("{label}: {} of {} pixels over tolerance (max {:.6})", d.over, d.total, d.max);
|
||||
@@ -403,7 +441,15 @@ pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool)
|
||||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
|
||||
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
|
||||
let d = diff(&cpu.img, &out.img, 1e-3);
|
||||
assert!(d.over == 0, "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst);
|
||||
let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
|
||||
assert!(
|
||||
d.over <= allow,
|
||||
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
|
||||
d.over,
|
||||
d.total,
|
||||
d.max,
|
||||
d.worst
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::Gpu;
|
||||
|
||||
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
|
||||
fn gpu() -> Option<Gpu> {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = Gpu::headless();
|
||||
if g.is_none() {
|
||||
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
|
||||
@@ -527,3 +529,24 @@ fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
|
||||
assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// An adapter whose render targets don't qualify (`R32Float` was not renderable on GL's llvmpipe;
|
||||
/// the pipelines failed validation, which release builds only log): Advanced 3D renders on the
|
||||
/// CPU, the GPU compositor's frame matches the CPU's, and no render pipeline is built (a
|
||||
/// validation error panics under test).
|
||||
#[test]
|
||||
fn adapters_that_cannot_rasterise_render_advanced_3d_on_the_cpu() {
|
||||
crate::tests::hold_gpu_lock();
|
||||
let Some(mut ctx) = crate::context::GpuContext::headless() else { return };
|
||||
ctx.adv3d_raster = false;
|
||||
let g = Gpu::from_context(ctx);
|
||||
let (p, cid) = scene_project();
|
||||
let s = scene(&p, cid);
|
||||
assert!(g.raster_3d(&s).is_none(), "no GPU raster");
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||||
let worst = cpu.data.iter().zip(&gimg.data).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(worst <= 2.0 / 255.0, "GPU compositor with CPU Advanced 3D vs CPU: worst {worst}");
|
||||
}
|
||||
@@ -19,6 +19,8 @@ use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
|
||||
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
|
||||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
// Also no other GPU test while it runs (see `hold_gpu_lock`).
|
||||
crate::tests::hold_gpu_lock();
|
||||
let g = G.get_or_init(|| pollster::block_on(Gpu::request_deferred()).map_err(|e| eprintln!("deferred gpu tests skipped: {e}")).ok()).as_ref()?;
|
||||
Some((g, LOCK.lock().unwrap_or_else(|e| e.into_inner())))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ use effectcraft_engine::offload::{Inbox, Offload, Post, WorkerReply, WorkerReque
|
||||
use effectcraft_gpu::Gpu;
|
||||
use serde_json::json;
|
||||
|
||||
/// One device at a time in this test binary: wgpu's OpenGL backend (Mesa llvmpipe on FreeBSD,
|
||||
/// Linux without Vulkan) panics or crashes when tests create and drive devices concurrently.
|
||||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Holds offloaded jobs until the test runs them.
|
||||
#[derive(Default)]
|
||||
struct Manual(Mutex<Vec<(WorkerRequest, Arc<Inbox>)>>);
|
||||
@@ -59,6 +63,7 @@ fn page() -> (Session, Arc<Manual>) {
|
||||
|
||||
#[test]
|
||||
fn render_queue_jobs_render_on_the_worker_gpu() {
|
||||
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(g) = Gpu::headless_deferred() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
@@ -97,6 +102,7 @@ fn render_queue_jobs_render_on_the_worker_gpu() {
|
||||
|
||||
#[test]
|
||||
fn analyses_render_their_input_on_the_worker_gpu() {
|
||||
let _gpu = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(g) = Gpu::headless_deferred() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user