Merge main into IO-2/SHP-5/OUT-2/PRV-2 work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 22:39:47 -04:00
co-authored by Claude Opus 5.5
104 changed files with 18573 additions and 73 deletions
+318
View File
@@ -0,0 +1,318 @@
//! The Advanced 3D rasteriser on wgpu: a render pipeline with a reversed-Z depth buffer
//! (`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`) are read back for the CPU's resolve,
//! depth of field and encoding.
use effectcraft_render::three_d::adv::{Scene, Target};
use wgpu::util::DeviceExt;
use crate::context::{Enc, GpuContext};
/// Pipelines (built on first use).
pub(crate) struct Pipes {
bgl: wgpu::BindGroupLayout,
opaque: wgpu::RenderPipeline,
transparent: wgpu::RenderPipeline,
}
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
const DEPTH_OUT: wgpu::TextureFormat = wgpu::TextureFormat::R32Float;
const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
const VERTEX_SIZE: u64 = 52;
fn storage(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None },
count: None,
}
}
impl Pipes {
fn new(device: &wgpu::Device) -> Pipes {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effectcraft advanced 3d"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/advanced3d.wgsl").into()),
});
let mut entries = vec![wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
}];
entries.extend((1..=6).map(storage));
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("advanced 3d"), entries: &entries });
let layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("advanced 3d"), bind_group_layouts: &[Some(&bgl)], immediate_size: 0 });
let attrs = [
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 12, shader_location: 1 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 24, shader_location: 2 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 32, shader_location: 3 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Uint32, offset: 48, shader_location: 4 },
];
let make = |transparent: bool| {
let blend = transparent.then_some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(if transparent { "advanced 3d transparent" } else { "advanced 3d opaque" }),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout { array_stride: VERTEX_SIZE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &attrs })],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH,
depth_write_enabled: Some(!transparent),
depth_compare: Some(wgpu::CompareFunction::Greater),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[
Some(wgpu::ColorTargetState { format: COLOR, blend, write_mask: wgpu::ColorWrites::ALL }),
Some(wgpu::ColorTargetState {
format: DEPTH_OUT,
blend: None,
write_mask: if transparent { wgpu::ColorWrites::empty() } else { wgpu::ColorWrites::ALL },
}),
],
}),
multiview_mask: None,
cache: None,
})
};
Pipes { opaque: make(false), transparent: make(true), bgl }
}
}
fn f32s(v: impl IntoIterator<Item = f32>) -> Vec<u8> {
let mut b: Vec<u8> = v.into_iter().flat_map(f32::to_le_bytes).collect();
while b.len() < 16 {
b.push(0);
}
b
}
/// Pack the scene's arrays as the shader's storage buffers.
fn pack(s: &Scene) -> [Vec<u8>; 7] {
let mut globals: Vec<f32> = vec![];
// Column-major clip matrix.
for c in 0..4 {
for r in 0..4 {
globals.push(s.clip[r][c]);
}
}
globals.extend(s.view[2]);
globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]);
globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]);
globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]);
let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 });
globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]);
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);
[
m.base[0],
m.base[1],
m.base[2],
m.base[3],
m.emissive[0],
m.emissive[1],
m.emissive[2],
m.metallic,
m.roughness,
m.normal_scale,
m.occlusion_strength,
m.alpha_cutoff,
m.tex_base as f32,
m.tex_mr as f32,
m.tex_normal as f32,
m.tex_occlusion as f32,
m.tex_emissive as f32,
flags as f32,
m.diffuse_k,
m.specular_k,
m.ambient_k,
m.opacity,
0.0,
0.0,
]
});
let mut tex: Vec<u8> = s.textures.iter().flat_map(|t| [t.offset, t.width, t.height, t.wrap_u | t.wrap_v << 8]).flat_map(u32::to_le_bytes).collect();
while tex.len() < 16 {
tex.push(0);
}
let texels = f32s(s.texels.iter().flatten().copied());
let lights = s.lights.iter().flat_map(|l| {
[
l.pos[0],
l.pos[1],
l.pos[2],
l.kind as f32,
l.dir[0],
l.dir[1],
l.dir[2],
l.cos_outer,
l.color[0],
l.color[1],
l.color[2],
l.cos_inner,
l.falloff as f32,
l.radius,
l.falloff_distance,
l.shadow as f32,
l.shadow_darkness,
0.0,
0.0,
0.0,
]
});
let shadows = s.shadows.iter().flat_map(|m| {
let mut v: Vec<f32> = m.view.iter().flatten().copied().collect();
v.extend([m.focal, m.ortho as u32 as f32, f32::from_bits(m.size), f32::from_bits(m.offset), m.bias, m.radius, 0.0, 0.0]);
v
});
[f32s(globals), f32s(mats), tex, texels, f32s(lights), f32s(shadows), f32s(s.shadow_texels.iter().copied())]
}
fn half_to_f32(h: u16) -> f32 {
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let e = ((h >> 10) & 0x1f) as i32;
let m = (h & 0x3ff) as f32;
match e {
0 => s * m * 2f32.powi(-24),
31 => {
if m == 0.0 {
s * f32::INFINITY
} else {
f32::NAN
}
}
_ => s * (1.0 + m / 1024.0) * 2f32.powi(e - 15),
}
}
/// Rasterise a scene. `None` when the device can't (no readback, size or buffer limits).
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
if !g.can_readback() || !g.fits(s.width, s.height) || s.indices.is_empty() {
return None;
}
let limits = g.device.limits();
let bufs = pack(s);
if bufs.iter().any(|b| b.len() as u64 > limits.max_storage_buffer_binding_size) {
return None;
}
let p = g.adv3d.get_or_init(|| Pipes::new(&g.device));
let dev = &g.device;
let vb: Vec<u8> = s
.vertices
.iter()
.flat_map(|v| {
let mut b = Vec::with_capacity(VERTEX_SIZE as usize);
for x in v.pos.iter().chain(&v.normal).chain(&v.uv).chain(&v.tangent) {
b.extend_from_slice(&x.to_le_bytes());
}
b.extend_from_slice(&v.material.to_le_bytes());
b
})
.collect();
let vbuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d vertices"), contents: &vb, usage: wgpu::BufferUsages::VERTEX });
let ib: Vec<u8> = s.indices.iter().flat_map(|i| i.to_le_bytes()).collect();
let ibuf = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d indices"), contents: &ib, usage: wgpu::BufferUsages::INDEX });
let ub = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d globals"), contents: &bufs[0], usage: wgpu::BufferUsages::UNIFORM });
let sbs: Vec<wgpu::Buffer> = bufs[1..]
.iter()
.map(|b| dev.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adv3d data"), contents: b, usage: wgpu::BufferUsages::STORAGE }))
.collect();
let mut entries = vec![wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }];
for (i, b) in sbs.iter().enumerate() {
entries.push(wgpu::BindGroupEntry { binding: i as u32 + 1, resource: b.as_entire_binding() });
}
let bg = dev.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("adv3d"), layout: &p.bgl, entries: &entries });
let tex = |format: wgpu::TextureFormat, usage: wgpu::TextureUsages| {
dev.create_texture(&wgpu::TextureDescriptor {
label: Some("adv3d target"),
size: wgpu::Extent3d { width: s.width, height: s.height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
let color = tex(COLOR, rt);
let zout = tex(DEPTH_OUT, rt);
let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default()));
let mut enc = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adv3d") });
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("adv3d"),
color_attachments: &[
Some(wgpu::RenderPassColorAttachment {
view: &cv,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
}),
Some(wgpu::RenderPassColorAttachment {
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 },
}),
],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &dv,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, &bg, &[]);
pass.set_vertex_buffer(0, vbuf.slice(..));
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.draw_indexed(0..s.opaque_count, 0, 0..1);
}
if n > s.opaque_count {
pass.set_pipeline(&p.transparent);
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
}
}
g.queue.submit([enc.finish()]);
let mut e = Enc::new(g);
let cb = e.read_texture(&color, s.width, s.height, 8)?;
let zb = e.read_texture(&zout, s.width, s.height, 4)?;
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 depth = zb
.chunks_exact(4)
.map(|c| {
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
if z < 0.0 { f32::INFINITY } else { z }
})
.collect();
Some(Target { width: s.width, height: s.height, color, depth })
}
+3
View File
@@ -94,6 +94,8 @@ pub struct GpuContext {
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
/// Advanced 3D render pipelines (built on first use).
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
@@ -225,6 +227,7 @@ impl GpuContext {
dummy_tex: dummy.create_view(&Default::default()),
dummy_buf,
uploads: Mutex::new(Uploads::default()),
adv3d: std::sync::OnceLock::new(),
})
}
+10
View File
@@ -9,6 +9,9 @@
//! clamping and quantisation, and converts colour spaces. 3D runs, adjustment layers and
//! wireframes run on the CPU between GPU steps (read back, draw, upload).
//!
//! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR,
//! image-based light, shadow maps), see [`Accelerator::raster_3d`].
//!
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
//! uploaded and read back once.
@@ -18,6 +21,7 @@
//! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for
//! egui-wgpu to draw.
mod adv3d;
mod context;
mod effects;
mod ops;
@@ -121,7 +125,13 @@ impl Accelerator for Gpu {
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels)
}
fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option<effectcraft_render::three_d::adv::Target> {
adv3d::render(&self.ctx, scene)
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_adv3d;
+430
View File
@@ -0,0 +1,430 @@
// Advanced 3D: rasterised meshes with physically based shading. A step-for-step port of
// `effectcraft_render::three_d::adv::shade` (Cook–Torrance GGX + Lambert, image-based light
// from an equirectangular environment, PCF shadow maps); the CPU rasteriser is the reference.
struct Globals {
clip: mat4x4<f32>,
// Camera-space depth row (x, y, z, w).
view_z: vec4<f32>,
// eye.xyz, ortho (0/1)
eye: vec4<f32>,
// cam_fwd.xyz, light count
fwd: vec4<f32>,
// ambient.rgb, environment present (0/1)
ambient: vec4<f32>,
// radiance first texture, mip count, irradiance texture, intensity
env: vec4<f32>,
// rotation, any lighting (0/1), -, -
env2: vec4<f32>,
};
@group(0) @binding(0) var<uniform> g: Globals;
@group(0) @binding(1) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> texinfo: array<vec4<u32>>;
@group(0) @binding(3) var<storage, read> texels: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> lights: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read> shadows: array<vec4<f32>>;
@group(0) @binding(6) var<storage, read> shadow_texels: array<f32>;
const PI: f32 = 3.14159265358979;
const MAT_STRIDE: u32 = 6u;
const LIGHT_STRIDE: u32 = 5u;
const SHADOW_STRIDE: u32 = 5u;
struct VIn {
@location(0) pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
@location(4) material: u32,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) tangent: vec4<f32>,
@location(4) @interpolate(flat) material: u32,
};
@vertex
fn vs(v: VIn) -> VOut {
var o: VOut;
o.clip = g.clip * vec4<f32>(v.pos, 1.0);
o.pos = v.pos;
o.normal = v.normal;
o.uv = v.uv;
o.tangent = v.tangent;
o.material = v.material;
return o;
}
fn norm3(a: vec3<f32>) -> vec3<f32> {
let l = sqrt(dot(a, a));
if (l > 1e-20) {
return a / l;
}
return vec3<f32>(0.0, 0.0, -1.0);
}
fn srgb_to_linear(c: f32) -> f32 {
if (c <= 0.04045) {
return c / 12.92;
}
return pow((c + 0.055) / 1.055, 2.4);
}
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;
}
if (m >= n) {
return p - 1 - m;
}
return m;
}
var m = i % n;
if (m < 0) {
m = m + n;
}
return m;
}
fn texel(t: vec4<u32>, x: i32, y: i32) -> vec4<f32> {
let w = i32(t.y);
let h = i32(t.z);
let xi = wrap_i(x, w, t.w & 255u);
let yi = wrap_i(y, h, (t.w >> 8u) & 255u);
return texels[t.x + u32(yi * w + xi)];
}
fn sample_tex(i: i32, uv: vec2<f32>) -> vec4<f32> {
let t = texinfo[u32(i)];
if (t.y == 0u || t.z == 0u) {
return vec4<f32>(1.0);
}
let x = uv.x * f32(t.y) - 0.5;
let y = uv.y * f32(t.z) - 0.5;
let x0 = floor(x);
let y0 = floor(y);
let fx = x - x0;
let fy = y - y0;
let xi = i32(x0);
let yi = i32(y0);
let a = texel(t, xi, yi);
let b = texel(t, xi + 1, yi);
let c = texel(t, xi, yi + 1);
let d = texel(t, xi + 1, yi + 1);
let top = a + (b - a) * fx;
let bot = c + (d - c) * fx;
return top + (bot - top) * fy;
}
fn d_ggx(n_h: f32, a: f32) -> f32 {
let a2 = a * a;
let d = n_h * n_h * (a2 - 1.0) + 1.0;
return a2 / max(PI * d * d, 1e-12);
}
fn g1(x: f32, k: f32) -> f32 {
return x / (x * (1.0 - k) + k);
}
fn brdf(base: vec3<f32>, metallic: f32, rough_in: f32, spec_k: f32, diff_k: f32, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let n_l = dot(n, l);
if (n_l <= 0.0) {
return vec3<f32>(0.0);
}
let n_v = max(dot(n, v), 1e-4);
let h = norm3(v + l);
let n_h = max(dot(n, h), 0.0);
let v_h = max(dot(v, h), 0.0);
let r = clamp(rough_in, 0.03, 1.0);
let f0 = mix(vec3<f32>(0.04 * spec_k), base, metallic);
let fk = pow(clamp(1.0 - v_h, 0.0, 1.0), 5.0);
let f = f0 + (vec3<f32>(1.0) - f0) * fk;
let d = d_ggx(n_h, r * r);
let k = (r + 1.0) * (r + 1.0) / 8.0;
let gg = g1(n_v, k) * g1(n_l, k);
let spec = d * gg / max(4.0 * n_v * n_l, 1e-4);
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
return (kd * base / PI * diff_k + f * spec) * n_l * PI;
}
fn env_brdf(n_v: f32, rough: f32) -> vec2<f32> {
let c0 = vec4<f32>(-1.0, -0.0275, -0.572, 0.022);
let c1 = vec4<f32>(1.0, 0.0425, 1.04, -0.04);
let r = rough * c0 + c1;
let a004 = min(r.x * r.x, exp2(-9.28 * n_v)) * r.x + r.y;
return vec2<f32>(-1.04 * a004 + r.z, 1.04 * a004 + r.w);
}
fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
let u = 0.5 + (atan2(d.x, d.z) + rot) / (2.0 * PI);
let v = acos(clamp(-d.y, -1.0, 1.0)) / PI;
return vec2<f32>(u - floor(u), v);
}
fn shadow_factor(m: u32, p: vec3<f32>, n_l: f32) -> f32 {
let r0 = shadows[m];
let r1 = shadows[m + 1u];
let r2 = shadows[m + 2u];
let info = shadows[m + 3u];
let extra = shadows[m + 4u];
let q = vec3<f32>(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w);
let focal = info.x;
let ortho = info.y > 0.5;
let size = bitcast<u32>(info.z);
let offset = bitcast<u32>(info.w);
let c = f32(size) * 0.5;
var u: f32;
var v: f32;
if (ortho) {
u = q.x * focal + c;
v = q.y * focal + c;
} else {
if (q.z <= 1e-3) {
return 1.0;
}
u = q.x * focal / q.z + c;
v = q.y * focal / q.z + c;
}
let z = q.z;
let texel = select(abs(z) / focal, 1.0 / focal, ortho);
let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0)));
let step = extra.y / 2.0;
let sz = i32(size);
var lit = 0.0;
for (var j = -2; j <= 2; j = j + 1) {
for (var i = -2; i <= 2; i = i + 1) {
let x = i32(floor(u + f32(i) * step));
let y = i32(floor(v + f32(j) * step));
if (x < 0 || y < 0 || x >= sz || y >= sz) {
lit = lit + 1.0;
continue;
}
let d = shadow_texels[offset + u32(y * sz + x)];
if (z - bias <= d) {
lit = lit + 1.0;
}
}
}
return lit / 25.0;
}
fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32, p: vec3<f32>, n_l: f32) -> f32 {
if (shadow < 0) {
return 1.0;
}
var face = 0u;
if (kind == 2u) {
let d = p - lpos;
let a = abs(d);
if (a.x >= a.y && a.x >= a.z) {
face = select(1u, 0u, d.x > 0.0);
} else if (a.y >= a.z) {
face = select(3u, 2u, d.y > 0.0);
} else {
face = select(5u, 4u, d.z > 0.0);
}
}
let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l);
return 1.0 - darkness * (1.0 - f);
}
fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3<f32>, to_l: vec3<f32>, dist: f32) -> f32 {
var k = 1.0;
if (falloff == 1u && kind != 0u) {
if (dist <= radius) {
k = 1.0;
} else if (falloff_distance <= 0.0) {
k = 0.0;
} else {
let t = clamp((dist - radius) / falloff_distance, 0.0, 1.0);
k = 1.0 - t * t * (3.0 - 2.0 * t);
}
} else if (falloff == 2u && kind != 0u) {
if (dist <= radius || (radius <= 0.0 && dist <= 1.0)) {
k = 1.0;
} else {
let q = max(radius, 1.0) / dist;
k = q * q;
}
}
if (kind == 1u) {
let cos_a = clamp(dot(-to_l, dir), -1.0, 1.0);
if (cos_a >= cos_inner) {
k = k * 1.0;
} else if (cos_a <= cos_outer) {
k = 0.0;
} else {
let a = acos(cos_a);
let ai = acos(cos_inner);
let ao = acos(cos_outer);
let t = clamp((a - ai) / max(ao - ai, 1e-6), 0.0, 1.0);
k = k * (1.0 - t * t * (3.0 - 2.0 * t));
}
}
return k;
}
struct FOut {
@location(0) color: vec4<f32>,
@location(1) depth: f32,
};
@fragment
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let mb = i.material * MAT_STRIDE;
let m0 = materials[mb];
let m1 = materials[mb + 1u];
let m2 = materials[mb + 2u];
let m3 = materials[mb + 3u];
let m4 = materials[mb + 4u];
let m5 = materials[mb + 5u];
let flags = u32(m4.y);
let double_sided = (flags & 1u) != 0u;
let unlit = (flags & 2u) != 0u;
let accepts_lights = (flags & 4u) != 0u;
let receives_shadows = (flags & 8u) != 0u;
let alpha_mode = (flags >> 8u) & 3u;
if (!front && !double_sided) {
discard;
}
var base = m0;
let tex_base = i32(m3.x);
if (tex_base >= 0) {
base = base * sample_tex(tex_base, i.uv);
}
let opacity = m5.y;
var alpha = base.a * opacity;
if (alpha_mode == 1u && alpha < m2.w) {
discard;
}
if (alpha <= 1e-5 && alpha_mode != 0u) {
discard;
}
if (alpha_mode == 0u) {
alpha = opacity;
}
let rgb = base.rgb;
var emissive = m1.xyz;
let tex_em = i32(m4.x);
if (tex_em >= 0) {
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;
if (unlit || !accepts_lights || g.env2.y < 0.5) {
let c = rgb + emissive;
out.color = vec4<f32>(c * alpha, alpha);
return out;
}
var metallic = m1.w;
var rough = m2.x;
let tex_mr = i32(m3.y);
if (tex_mr >= 0) {
let t = sample_tex(tex_mr, i.uv);
rough = rough * t.y;
metallic = metallic * t.z;
}
var n = norm3(i.normal);
if (!front) {
n = -n;
}
let tex_n = i32(m3.z);
if (tex_n >= 0) {
let t = sample_tex(tex_n, i.uv);
let ns = m2.y;
let tn = vec3<f32>((t.x * 2.0 - 1.0) * ns, (t.y * 2.0 - 1.0) * ns, t.z * 2.0 - 1.0);
let tg = norm3(i.tangent.xyz - n * dot(n, i.tangent.xyz));
let sign = select(-i.tangent.w, i.tangent.w, front);
let bt = cross(n, tg) * sign;
n = norm3(tg * tn.x + bt * tn.y + n * tn.z);
}
var ao = 1.0;
let tex_ao = i32(m3.w);
if (tex_ao >= 0) {
ao = 1.0 + m2.z * (sample_tex(tex_ao, i.uv).x - 1.0);
}
var v: vec3<f32>;
if (g.eye.w > 0.5) {
v = -g.fwd.xyz;
} else {
v = norm3(g.eye.xyz - i.pos);
}
let diff_k = m4.z;
let spec_k = m4.w;
let amb_k = m5.x;
var acc = vec3<f32>(0.0);
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);
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 k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
if (k <= 0.0) {
continue;
}
let n_l = dot(n, to_l);
if (n_l <= 0.0) {
continue;
}
var sh = 1.0;
if (receives_shadows) {
sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l);
}
let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l);
acc = acc + b * l2.xyz * k * sh;
}
acc = acc + g.ambient.xyz * rgb * amb_k * ao;
if (g.ambient.w > 0.5) {
let n_v = clamp(dot(n, v), 1e-4, 1.0);
let r = n * (2.0 * dot(n, v)) - v;
let rot = g.env2.x;
let irr = sample_tex(i32(g.env.z), equirect_uv(n, rot)).rgb * g.env.w;
let mips = u32(g.env.y);
let lv = clamp(rough, 0.0, 1.0) * f32(max(mips, 1u) - 1u);
let lo = u32(floor(lv));
let hi = min(lo + 1u, max(mips, 1u) - 1u);
let f = lv - f32(lo);
let ruv = equirect_uv(r, rot);
let sa = sample_tex(i32(g.env.x) + i32(lo), ruv).rgb;
let sb = sample_tex(i32(g.env.x) + i32(hi), ruv).rgb;
let spec = (sa + (sb - sa) * f) * g.env.w;
let f0 = mix(vec3<f32>(0.04 * spec_k), rgb, metallic);
let ab = env_brdf(n_v, rough);
let fs = f0 * ab.x + vec3<f32>(ab.y);
acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao;
}
let c = acc + emissive;
out.color = vec4<f32>(c * alpha, alpha);
return out;
}
+167
View File
@@ -0,0 +1,167 @@
//! Advanced 3D: the GPU rasteriser against the CPU reference on the same scenes.
//!
//! Tolerances: the GPU's colour target is half float (relative error ≤ 1e-3) and its
//! rasteriser and interpolators differ from the CPU's in the last bits, so pixels on triangle
//! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid, build};
use effectcraft_render::three_d::adv::{self, Scene};
use effectcraft_render::{Accelerator, EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
fn gpu() -> Option<Gpu> {
let g = Gpu::headless();
if g.is_none() {
eprintln!("effectcraft-gpu adv3d tests: no GPU adapter, skipping");
}
g
}
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
let comp = p.comp(cid).unwrap().clone();
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
edit(&mut l);
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
}
/// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,
/// an environment light, a semi-transparent textured card.
fn scene_project() -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(900.0));
set(l, "geometryOptions/height", Value::Scalar(900.0));
set(l, "transform/position", Value::Vec3([120.0, 130.0, 0.0]));
set(l, "transform/rotationX", Value::Scalar(90.0));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(35.0));
set(l, "transform/position", Value::Vec3([80.0, 95.0, 0.0]));
set(l, "materialOptions/baseColor", Value::Color([0.9, 0.2, 0.2, 1.0]));
set(l, "materialOptions/roughness", Value::Scalar(35.0));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Torus }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(35.0));
set(l, "geometryOptions/tubeRadius", Value::Scalar(12.0));
set(l, "transform/position", Value::Vec3([165.0, 90.0, 0.0]));
set(l, "transform/rotationX", Value::Scalar(60.0));
set(l, "materialOptions/metallic", Value::Scalar(100.0));
set(l, "materialOptions/roughness", Value::Scalar(20.0));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.6, 0.9], width: 60, height: 40, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| {
l.switches.three_d = true;
set(l, "transform/opacity", Value::Scalar(60.0));
set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0]));
set(l, "transform/rotationY", Value::Scalar(30.0));
});
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0)));
add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| {
set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0]));
set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
set(l, "lightOptions/coneAngle", Value::Scalar(110.0));
set(l, "lightOptions/shadowDiffusion", Value::Scalar(4.0));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([200.0, 20.0, -100.0]));
set(l, "lightOptions/intensity", Value::Scalar(40.0));
set(l, "lightOptions/falloff", Value::Enum(1));
});
(p, cid)
}
fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}
fn compare(s: &Scene, g: &Gpu) {
let cpu = adv::raster::render(s);
let gt = g.raster_3d(s).expect("gpu raster");
assert_eq!((gt.width, gt.height), (cpu.width, cpu.height));
let n = cpu.color.len();
let mut bad = 0;
let mut sum = 0.0f64;
for (a, b) in cpu.color.iter().zip(&gt.color) {
let d = (0..4).map(|k| (a[k] - b[k]).abs() / a[k].abs().max(1.0)).fold(0.0f32, f32::max);
sum += d as f64;
if d > 2.0 / 255.0 {
bad += 1;
}
}
let frac = bad as f64 / n as f64;
eprintln!("adv3d gpu vs cpu: {bad}/{n} pixels beyond 2/255 ({:.3} %), mean {:.5}", frac * 100.0, sum / n as f64);
assert!(frac < 0.015, "{:.3} % of pixels differ", frac * 100.0);
assert!(sum / (n as f64) < 0.002);
// Depth agrees where both see a surface.
let mut dz = 0;
for (a, b) in cpu.depth.iter().zip(&gt.depth) {
if a.is_finite() != b.is_finite() || (a.is_finite() && (a - b).abs() > 0.01 * a.abs().max(1.0)) {
dz += 1;
}
}
assert!((dz as f64) < 0.015 * n as f64, "{dz} depth mismatches");
}
#[test]
fn gpu_matches_cpu_lit_scene() {
let Some(g) = gpu() else { return };
let (p, cid) = scene_project();
let s = scene(&p, cid);
assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32);
compare(&s, &g);
}
#[test]
fn gpu_matches_cpu_unlit_and_full_frames() {
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
// Unlit: remove the lights.
p.comp_mut(cid).unwrap().layers.retain(|l| !l.is_light());
compare(&scene(&p, cid), &g);
// Whole frames through the renderer with the GPU attached.
let (p, cid) = scene_project();
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 diff = cpu.data.iter().zip(&gimg.data).filter(|(a, b)| (0..4).any(|k| (a[k] - b[k]).abs() > 3.0 / 255.0)).count();
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
}
#[test]
fn gpu_raster_perf_smoke() {
let Some(g) = gpu() else { return };
let (p, cid) = scene_project();
let s = scene(&p, cid);
let t0 = std::time::Instant::now();
let _ = adv::raster::render(&s);
let cpu = t0.elapsed();
// First use compiles the pipelines.
let _ = g.raster_3d(&s);
let t0 = std::time::Instant::now();
let _ = g.raster_3d(&s);
let gpu = t0.elapsed();
eprintln!("adv3d 480x320 (2x SSAA of 240x160), {} triangles: cpu {:?}, gpu {:?}", s.indices.len() / 3, cpu, gpu);
}