M7.5: advanced 3D renderer

Advanced 3D comps render their 3D runs as one scene of world-space triangles: model and
primitive layers (meshes, skinning, animation clips by layer time), extruded text/shape
layers, and textured cards for every other layer with their Material Options. Shading is
physically based in linear light: Cook-Torrance GGX + Lambert for parallel/spot/point lights
(AE falloff and cone), ambient lights, image-based light from an Environment light's layer
(equirectangular; cosine irradiance map + roughness mip chain + split-sum BRDF fit), and
PCF shadow maps (spot/parallel/point cube faces). 2x2 SSAA, depth of field as a gather blur
by circle of confusion, back to the working encoding.

The software rasteriser (depth buffer, top-left rule, perspective-correct, opaque visibility
then sorted transparent blending) is the reference and CI path; effectcraft-gpu implements
Accelerator::raster_3d with a wgpu render pipeline (advanced3d.wgsl) and matches it within
tolerance. Environment layers are hidden from both renderers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 21:27:59 -04:00
parent 83c7e14b94
commit dec091e575
17 changed files with 3259 additions and 3 deletions

View File

@ -349,7 +349,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id|name (a 3D model footage item) | path?: .gltf|.glb|.obj file, name?, time? (s)}",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s)}",
has_comp,
new_model
),

318
crates/gpu/src/adv3d.rs Normal file
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 })
}

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(),
})
}

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;

View File

@ -0,0 +1,429 @@
// 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 bias = extra.x * (1.0 + 2.0 * (1.0 - clamp(n_l, 0.0, 1.0))) + abs(z) * 0.0015;
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;
}

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);
}

View File

@ -74,6 +74,39 @@ mod tests {
assert!(img.data.iter().any(|p| p[3] > 0.5));
}
/// File ▸ Import of glTF/GLB/OBJ models through the media layer, placed as model layers and
/// drawn by the Advanced 3D renderer.
#[test]
fn imported_models_render_in_advanced_3d() {
let fx = |n: &str| format!("{}/../model/tests/fixtures/{n}", env!("CARGO_MANIFEST_DIR"));
let mut s = super::session();
s.execute("comp.new", json!({"name": "M", "width": 160, "height": 120, "renderer": "advanced3d"})).unwrap();
let r = s.execute("file.import", json!({"paths": [fx("cube.obj"), fx("quad.glb"), fx("quad.gltf")]})).unwrap();
assert_eq!(r["errors"], json!([]), "{r}");
let items: Vec<u64> = r["items"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(items.len(), 3);
let cube = s.execute("layer.addItem", json!({"item": items[0]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": cube, "path": "transform/rotationY", "value": 30})).unwrap();
s.execute("prop.set", json!({"layer": cube, "path": "transform/rotationX", "value": 20})).unwrap();
let cid = s.active_comp_id().unwrap();
let opts = effectcraft_engine::render::RenderOpts::default();
let img = s.render(cid, s.time(), opts);
let covered = img.data.iter().filter(|p| p[3] > 0.99).count();
assert!(covered > 1000, "the cube covers part of the frame: {covered}");
// Red faces (material Red) are visible.
assert!(img.data.iter().any(|p| p[3] > 0.99 && p[0] > 0.5 && p[1] < 0.2));
// The textured binary glTF quad.
for _ in 0..3 {
s.execute("edit.undo", json!({})).unwrap();
}
s.execute("layer.addItem", json!({"item": items[1]})).unwrap();
let img = s.render(cid, s.time(), opts);
assert!(img.data.iter().filter(|p| p[3] > 0.99).count() > 1000);
// Classic 3D doesn't draw models.
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
assert!(s.render(cid, s.time(), opts).data.iter().all(|p| p[3] == 0.0));
}
/// The web app's path: outputs go to a sink (downloads), never to the file system.
#[test]
fn render_queue_exports_to_a_sink() {

View File

@ -178,6 +178,12 @@ pub trait Accelerator: Send + Sync {
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
/// every effect when set (8/16 bpc). `None` = not handled.
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf>;
/// Rasterise and shade an Advanced 3D scene at its raster size (depth buffer, PBR, image
/// based light, shadow maps), with the CPU rasteriser's semantics
/// ([`three_d::adv::raster::render`]). `None` = not handled (the CPU renders it).
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
None
}
}
#[derive(Clone, Copy, Debug)]
@ -1185,7 +1191,7 @@ impl<'a> Renderer<'a> {
comp.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.filter(|l| l.is_active_at(t) && l.has_video() && !l.environment && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect()
}

View File

@ -508,6 +508,27 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
#[cfg(test)]
mod tests;
/// The filled outlines of a shape layer in layer space with their straight colours (gradient
/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded.
pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.rev()
.filter(|d| d.opacity > 0.0)
.filter_map(|d| {
let c = match &d.paint {
Paint::Fill { color, .. } => *color,
Paint::Gradient { g, stroke: None, .. } => g.sample(0.5),
_ => return None,
};
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
(!paths.is_empty()).then_some((paths, [c[0], c[1], c[2], c[3] * d.opacity]))
})
.collect()
}
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {

View File

@ -0,0 +1,165 @@
//! The Advanced 3D renderer (Composition Settings ▸ 3D Renderer ▸ Advanced 3D).
//!
//! A run of 3D layers becomes one [`Scene`] of world-space triangles (models, primitives,
//! extruded text and shapes, and textured cards for every other layer), lit physically
//! ([`shade`]) by the comp's lights, an Environment light's image and shadow maps. The scene
//! is rasterised with a depth buffer on the GPU when an [`crate::Accelerator`] implements
//! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2
//! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's
//! circle of confusion) and the conversion back to the working encoding run on the CPU for
//! both paths, and the result is composited over the layers below the run.
pub mod raster;
pub mod scene;
pub mod shade;
use effectcraft_project::Layer;
use rayon::prelude::*;
pub use raster::Target;
pub use scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo, Vertex};
use super::camera::Dof;
use super::compose::camera_for;
use crate::{EvalCtx, Image, Renderer};
/// Box-filter a supersampled target down by `k`: (premultiplied colour, mean depth of the
/// covered subsamples, ∞ where none).
pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
let (w, h) = (t.width / k, t.height / k);
let mut img = Image::new(w, h);
let mut depth = vec![f32::INFINITY; (w * h) as usize];
let n = (k * k) as f32;
img.data.par_chunks_mut(w as usize).zip(depth.par_chunks_mut(w as usize)).enumerate().for_each(|(y, (row, drow))| {
for x in 0..w as usize {
let mut acc = [0.0f32; 4];
let (mut dz, mut dn) = (0.0f32, 0.0f32);
for j in 0..k as usize {
for i in 0..k as usize {
let s = (y * k as usize + j) * t.width as usize + x * k as usize + i;
let c = t.color[s];
for q in 0..4 {
acc[q] += c[q];
}
if t.depth[s].is_finite() {
dz += t.depth[s];
dn += 1.0;
}
}
}
row[x] = acc.map(|v| v / n);
if dn > 0.0 {
drow[x] = dz / dn;
}
}
});
(img, depth)
}
/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of
/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not
/// smeared by the blur of what is behind them.
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
let (w, h) = (img.width as i64, img.height as i64);
let max_r = 48.0f32;
let radius: Vec<f32> = depth
.iter()
.map(|&z| {
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
((dof.coc(z) * scale * 0.5) as f32).min(max_r)
})
.collect();
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
if rmax < 0.5 {
return img.clone();
}
// Disk samples (golden-angle spiral) over the largest radius.
const N: usize = 64;
let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt());
let taps: Vec<(f32, f32, f32)> = (1..=N)
.map(|i| {
let r = (i as f32 / N as f32).sqrt() * rmax;
let a = i as f32 * golden;
(r * a.cos(), r * a.sin(), r)
})
.collect();
let mut out = Image::new(img.width, img.height);
out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| {
for x in 0..w as usize {
let i = y * w as usize + x;
let (rp, zp) = (radius[i], depth[i]);
let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0);
let c = img.data[i];
let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw];
let mut wsum = cw;
for &(dx, dy, d) in &taps {
let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64);
if sx < 0 || sy < 0 || sx >= w || sy >= h {
continue;
}
let j = (sy * w + sx) as usize;
// A sample behind this pixel blurs no further than this pixel's own circle.
let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] };
let cov = (rq - d + 0.5).clamp(0.0, 1.0);
if cov <= 0.0 {
continue;
}
// Each tap stands for an annulus of area ≈ π·rmax²/N.
let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32);
let q = img.data[j];
for k in 0..4 {
acc[k] += q[k] * wq;
}
wsum += wq;
}
row[x] = acc.map(|v| v / wsum);
}
});
out
}
/// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects).
pub fn encode(img: &mut Image) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a > 1e-6 {
for c in 0..3 {
p[c] = shade::linear_to_srgb(p[c] / a) * a;
}
}
});
}
/// Render a scene on the CPU, resolved: (premultiplied image at output size, depth).
pub fn render_cpu(s: &Scene) -> (Image, Vec<f32>) {
resolve(&raster::render(s), s.ssaa.max(1))
}
/// Build the scene of a run (for tests and tools).
pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
scene::build(r, ctx, run, out)
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
if s.indices.is_empty() {
return;
}
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
let cam = camera_for(r, ctx);
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
}
if !s.linear_io {
encode(&mut img);
}
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,492 @@
//! The software rasteriser of the Advanced 3D renderer (the CPU reference and the fallback
//! when no GPU is available) and the shadow-map rasteriser shared by both paths.
//!
//! Triangles are clipped against the depth range (`0 ≤ z ≤ w`, as GPUs do), set up in screen
//! space and scanned per band of rows in parallel with edge functions (pixel centres, top-left
//! fill rule) and perspective-correct barycentrics. Opaque triangles resolve visibility first
//! (reversed depth: larger ndc z is nearer) and each pixel is shaded once; transparent triangles
//! (already sorted back to front) are then shaded per fragment and blended over, depth-tested
//! against the opaque surfaces.
use rayon::prelude::*;
use super::scene::{Scene, ShadowMap, Vertex};
use super::shade::{Frag, sample, shade};
/// A rendered target at raster size.
pub struct Target {
pub width: u32,
pub height: u32,
/// Premultiplied linear RGBA.
pub color: Vec<[f32; 4]>,
/// Camera-space depth of the nearest opaque surface (∞ where empty).
pub depth: Vec<f32>,
}
/// A clipped triangle in screen space; `w` holds, per corner, its weights over the original
/// triangle's three vertices.
#[derive(Clone, Copy)]
struct STri {
p: [[f64; 2]; 3],
z: [f32; 3],
inv_w: [f32; 3],
w: [[f32; 3]; 3],
tri: u32,
front: bool,
bbox: [i32; 4],
}
#[inline]
fn mat_mul(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 4] {
let mut o = [0.0; 4];
for r in 0..4 {
o[r] = m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3];
}
o
}
/// Clip a polygon (clip coords + original weights) against `0 ≤ z ≤ w`.
fn clip_poly(poly: Vec<([f32; 4], [f32; 3])>) -> Vec<([f32; 4], [f32; 3])> {
let mut p = poly;
for plane in 0..2 {
if p.is_empty() {
break;
}
let d = |c: &[f32; 4]| if plane == 0 { c[2] } else { c[3] - c[2] };
let mut out = Vec::with_capacity(p.len() + 2);
for i in 0..p.len() {
let (a, b) = (p[i], p[(i + 1) % p.len()]);
let (da, db) = (d(&a.0), d(&b.0));
if da >= 0.0 {
out.push(a);
}
if (da >= 0.0) != (db >= 0.0) {
let t = da / (da - db);
let mut c = [0.0; 4];
let mut w = [0.0; 3];
for k in 0..4 {
c[k] = a.0[k] + (b.0[k] - a.0[k]) * t;
}
for k in 0..3 {
w[k] = a.1[k] + (b.1[k] - a.1[k]) * t;
}
out.push((c, w));
}
}
p = out;
}
p
}
/// Set up the screen triangles of index range `range`.
fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range<usize>) -> Vec<STri> {
let (w, h) = (s.width as f64, s.height as f64);
let mut out = vec![];
for t in range.step_by(3) {
let ids = [s.indices[t] as usize, s.indices[t + 1] as usize, s.indices[t + 2] as usize];
let mat = s.vertices[ids[0]].material as usize;
if !s.materials.get(mat).is_some_and(|m| m.visible) {
continue;
}
let poly = vec![(clip[ids[0]], [1.0, 0.0, 0.0]), (clip[ids[1]], [0.0, 1.0, 0.0]), (clip[ids[2]], [0.0, 0.0, 1.0])];
let poly = clip_poly(poly);
if poly.len() < 3 {
continue;
}
let scr: Vec<([f64; 2], f32, f32, [f32; 3])> = poly
.iter()
.map(|(c, wt)| {
let iw = 1.0 / c[3];
let (nx, ny) = (c[0] * iw, c[1] * iw);
([(nx as f64 + 1.0) * 0.5 * w, (1.0 - ny as f64) * 0.5 * h], c[2] * iw, iw, *wt)
})
.collect();
// Facing from the polygon's screen-space winding (y down: negative = front).
let mut area = 0.0;
for i in 0..scr.len() {
let (a, b) = (scr[i].0, scr[(i + 1) % scr.len()].0);
area += a[0] * b[1] - b[0] * a[1];
}
if area == 0.0 || !area.is_finite() {
continue;
}
let front = area < 0.0;
for k in 1..scr.len() - 1 {
let c = [scr[0], scr[k], scr[k + 1]];
let mut x0 = f64::INFINITY;
let mut y0 = f64::INFINITY;
let mut x1 = f64::NEG_INFINITY;
let mut y1 = f64::NEG_INFINITY;
for v in &c {
x0 = x0.min(v.0[0]);
y0 = y0.min(v.0[1]);
x1 = x1.max(v.0[0]);
y1 = y1.max(v.0[1]);
}
let bbox = [
(x0.floor() as i64).max(0) as i32,
(y0.floor() as i64).max(0) as i32,
(x1.ceil() as i64).min(s.width as i64) as i32,
(y1.ceil() as i64).min(s.height as i64) as i32,
];
if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] {
continue;
}
out.push(STri {
p: [c[0].0, c[1].0, c[2].0],
z: [c[0].1, c[1].1, c[2].1],
inv_w: [c[0].2, c[1].2, c[2].2],
w: [c[0].3, c[1].3, c[2].3],
tri: (t / 3) as u32,
front,
bbox,
});
}
}
out
}
/// Coverage of a pixel centre: (ndc z, perspective-correct weights over the screen corners).
#[inline]
fn cover(t: &STri, px: f64, py: f64) -> Option<(f32, [f32; 3])> {
let [a, b, c] = t.p;
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
// Orient so the area is positive.
let (b, c, swap) = if area < 0.0 { (c, b, true) } else { (b, c, false) };
let area = area.abs();
let edge = |p: [f64; 2], q: [f64; 2]| -> f64 { (q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) };
let top_left = |p: [f64; 2], q: [f64; 2]| -> bool {
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
dy < 0.0 || (dy == 0.0 && dx > 0.0)
};
let e0 = edge(b, c); // opposite a
let e1 = edge(c, a); // opposite b
let e2 = edge(a, b); // opposite c
let inside = |e: f64, p: [f64; 2], q: [f64; 2]| e > 0.0 || (e == 0.0 && top_left(p, q));
if !(inside(e0, b, c) && inside(e1, c, a) && inside(e2, a, b)) {
return None;
}
let (l0, l1, l2) = ((e0 / area) as f32, (e1 / area) as f32, (e2 / area) as f32);
// Back to the stored corner order.
let l = if swap { [l0, l2, l1] } else { [l0, l1, l2] };
let z = l[0] * t.z[0] + l[1] * t.z[1] + l[2] * t.z[2];
let q = [l[0] * t.inv_w[0], l[1] * t.inv_w[1], l[2] * t.inv_w[2]];
let sum = q[0] + q[1] + q[2];
if sum <= 0.0 {
return None;
}
Some((z, [q[0] / sum, q[1] / sum, q[2] / sum]))
}
/// Interpolate the original triangle's vertex attributes at screen-corner weights `l`.
fn frag(s: &Scene, t: &STri, l: [f32; 3]) -> Frag {
let base = t.tri as usize * 3;
let v: [&Vertex; 3] = [0, 1, 2].map(|k| &s.vertices[s.indices[base + k] as usize]);
// Weights over the original vertices.
let mut wv = [0.0f32; 3];
for c in 0..3 {
for k in 0..3 {
wv[k] += l[c] * t.w[c][k];
}
}
let mut f = Frag { pos: [0.0; 3], normal: [0.0; 3], uv: [0.0; 2], tangent: [0.0; 4], material: v[0].material, front: t.front };
for k in 0..3 {
let x = wv[k];
for i in 0..3 {
f.pos[i] += v[k].pos[i] * x;
f.normal[i] += v[k].normal[i] * x;
}
for i in 0..2 {
f.uv[i] += v[k].uv[i] * x;
}
for i in 0..4 {
f.tangent[i] += v[k].tangent[i] * x;
}
}
f.tangent[3] = v[0].tangent[3];
f
}
const BAND: i32 = 16;
/// Rasterise and shade the scene.
pub fn render(s: &Scene) -> Target {
let (w, h) = (s.width as usize, s.height as usize);
let clip: Vec<[f32; 4]> = s.vertices.iter().map(|v| mat_mul(&s.clip, v.pos)).collect();
let opaque = setup(s, &clip, 0..s.opaque_count as usize);
let trans = setup(s, &clip, s.opaque_count as usize..s.indices.len());
let bands = (h as i32 + BAND - 1) / BAND;
let bin = |ts: &[STri]| -> Vec<Vec<u32>> {
let mut b = vec![vec![]; bands.max(0) as usize];
for (i, t) in ts.iter().enumerate() {
for band in (t.bbox[1] / BAND)..((t.bbox[3] + BAND - 1) / BAND).min(bands) {
b[band as usize].push(i as u32);
}
}
b
};
let ob = bin(&opaque);
let tb = bin(&trans);
let view = s.view;
let cam_z = |p: [f32; 3]| view[2][0] * p[0] + view[2][1] * p[1] + view[2][2] * p[2] + view[2][3];
let mut color = vec![[0.0f32; 4]; w * h];
let mut depth = vec![f32::INFINITY; w * h];
color.par_chunks_mut(w * BAND as usize).zip(depth.par_chunks_mut(w * BAND as usize)).enumerate().for_each(|(band, (crow, drow))| {
let y0 = band as i32 * BAND;
let rows = crow.len() / w;
// Visibility: (screen tri, ndc z, weights).
let mut vis: Vec<(u32, f32, [f32; 3])> = vec![(u32::MAX, 0.0, [0.0; 3]); rows * w];
for &ti in &ob[band] {
let t = &opaque[ti as usize];
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
let r = (y - y0) as usize * w;
for x in t.bbox[0]..t.bbox[2] {
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5) {
let v = &mut vis[r + x as usize];
if z > v.1 && z <= 1.0 {
*v = (ti, z, l);
}
}
}
}
}
// A discarded opaque fragment (alpha test, back face) lets the next nearer-than-nothing
// surface through only on a second look; keep it simple: resolve again without it.
for i in 0..rows * w {
let (ti, _, l) = vis[i];
if ti == u32::MAX {
continue;
}
let t = &opaque[ti as usize];
let f = frag(s, t, l);
match shade(s, &f) {
Some(c) => {
crow[i] = c;
drow[i] = cam_z(f.pos);
}
None => {
// Find the nearest surviving fragment under this pixel.
let (x, y) = ((i % w) as f64 + 0.5, (y0 as usize + i / w) as f64 + 0.5);
let mut best: Option<(f32, [f32; 4], f32)> = None;
for &tj in &ob[band] {
if tj == ti {
continue;
}
let t2 = &opaque[tj as usize];
if (x as i32) < t2.bbox[0] || (x as i32) >= t2.bbox[2] || (y as i32) < t2.bbox[1] || (y as i32) >= t2.bbox[3] {
continue;
}
if let Some((z, l2)) = cover(t2, x, y)
&& best.is_none_or(|b| z > b.0)
{
let f2 = frag(s, t2, l2);
if let Some(c) = shade(s, &f2) {
best = Some((z, c, cam_z(f2.pos)));
}
}
}
if let Some((z, c, d)) = best {
crow[i] = c;
drow[i] = d;
vis[i].1 = z;
} else {
vis[i].1 = 0.0;
}
}
}
}
// Transparent fragments in order, over the opaque result.
for &ti in &tb[band] {
let t = &trans[ti as usize];
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
let r = (y - y0) as usize * w;
for x in t.bbox[0]..t.bbox[2] {
let i = r + x as usize;
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5)
&& z > vis[i].1
&& z <= 1.0
&& let Some(c) = shade(s, &frag(s, t, l))
{
let d = crow[i];
let k = 1.0 - c[3];
crow[i] = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k];
}
}
}
}
});
Target { width: s.width, height: s.height, color, depth }
}
// ---------------------------------------------------------------- shadow maps
fn basis(eye: [f64; 3], fwd: [f64; 3], down: [f64; 3]) -> [[f32; 4]; 3] {
use effectcraft_geom::vec3;
let m = crate::three_d::camera::basis_view(vec3(eye[0], eye[1], eye[2]), vec3(fwd[0], fwd[1], fwd[2]), vec3(down[0], down[1], down[2]));
[0, 1, 2].map(|r| m.0[r].map(|v| v as f32))
}
/// Rasterise the shadow-casting triangles' light-space depth into `m`'s texels.
fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec<f32> {
let n = m.size as usize;
let mut d = vec![f32::INFINITY; n * n];
let c = n as f32 * 0.5;
const NEAR: f32 = 1.0;
for &t in casters {
let ids = [s.indices[3 * t] as usize, s.indices[3 * t + 1] as usize, s.indices[3 * t + 2] as usize];
let mat = &s.materials[s.vertices[ids[0]].material as usize];
let alpha_tex = (mat.alpha_mode != 0 && mat.tex_base >= 0).then(|| &s.textures[mat.tex_base as usize]);
// Light space, clipped against the near plane (perspective).
let q: Vec<([f32; 3], [f32; 2])> = ids
.iter()
.map(|&i| {
let p = s.vertices[i].pos;
let v = &m.view;
([0, 1, 2].map(|r| v[r][0] * p[0] + v[r][1] * p[1] + v[r][2] * p[2] + v[r][3]), s.vertices[i].uv)
})
.collect();
let mut poly = q;
if !m.ortho {
let mut out = vec![];
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
let (da, db) = (a.0[2] - NEAR, b.0[2] - NEAR);
if da >= 0.0 {
out.push(a);
}
if (da >= 0.0) != (db >= 0.0) {
let k = da / (da - db);
let lerp = |x: f32, y: f32| x + (y - x) * k;
out.push(([lerp(a.0[0], b.0[0]), lerp(a.0[1], b.0[1]), lerp(a.0[2], b.0[2])], [lerp(a.1[0], b.1[0]), lerp(a.1[1], b.1[1])]));
}
}
poly = out;
}
if poly.len() < 3 {
continue;
}
// Screen: (x, y, 1/z or 1, depth) per corner.
let scr: Vec<([f64; 2], f32, f32, [f32; 2])> = poly
.iter()
.map(|(p, uv)| {
if m.ortho {
([(p[0] * m.focal + c) as f64, (p[1] * m.focal + c) as f64], 1.0, p[2], *uv)
} else {
let iz = 1.0 / p[2];
([(p[0] * m.focal * iz + c) as f64, (p[1] * m.focal * iz + c) as f64], iz, p[2], *uv)
}
})
.collect();
for k in 1..scr.len() - 1 {
let tri = [scr[0], scr[k], scr[k + 1]];
let (a, b, cc) = (tri[0].0, tri[1].0, tri[2].0);
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
if area.abs() < 1e-12 {
continue;
}
let x0 = a[0].min(b[0]).min(cc[0]).floor().max(0.0) as usize;
let x1 = (a[0].max(b[0]).max(cc[0]).ceil().max(0.0) as usize).min(n);
let y0 = a[1].min(b[1]).min(cc[1]).floor().max(0.0) as usize;
let y1 = (a[1].max(b[1]).max(cc[1]).ceil().max(0.0) as usize).min(n);
for y in y0..y1 {
for x in x0..x1 {
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let e0 = ((cc[0] - b[0]) * (py - b[1]) - (cc[1] - b[1]) * (px - b[0])) / area;
let e1 = ((a[0] - cc[0]) * (py - cc[1]) - (a[1] - cc[1]) * (px - cc[0])) / area;
let e2 = 1.0 - e0 - e1;
if e0 < 0.0 || e1 < 0.0 || e2 < 0.0 {
continue;
}
let l = [e0 as f32, e1 as f32, e2 as f32];
let (z, uv) = if m.ortho {
(l[0] * tri[0].2 + l[1] * tri[1].2 + l[2] * tri[2].2, [0, 1].map(|i| l[0] * tri[0].3[i] + l[1] * tri[1].3[i] + l[2] * tri[2].3[i]))
} else {
let iz = l[0] * tri[0].1 + l[1] * tri[1].1 + l[2] * tri[2].1;
let z = 1.0 / iz;
(z, [0, 1].map(|i| (l[0] * tri[0].3[i] * tri[0].1 + l[1] * tri[1].3[i] * tri[1].1 + l[2] * tri[2].3[i] * tri[2].1) * z))
};
let px = &mut d[y * n + x];
if z >= *px {
continue;
}
if let Some(t) = alpha_tex
&& sample(t, &s.texels, uv[0], uv[1])[3] * mat.base[3] * mat.opacity < 0.5
{
continue;
}
*px = z;
}
}
}
}
d
}
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
.filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))
.collect();
if casters.is_empty() {
for l in &mut s.lights {
l.shadow = -1;
}
return;
}
// Bounding sphere of the scene (parallel lights' orthographic frustum).
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for v in &s.vertices {
for k in 0..3 {
lo[k] = lo[k].min(v.pos[k] as f64);
hi[k] = hi[k].max(v.pos[k] as f64);
}
}
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5);
let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0;
let size = if draft { 512 } else { 1024 };
let mut maps: Vec<ShadowMap> = vec![];
for li in 0..s.lights.len() {
if s.lights[li].shadow != 0 {
s.lights[li].shadow = -1;
continue;
}
let l = s.lights[li];
let first = maps.len() as i32;
let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0);
let pos = l.pos.map(|v| v as f64);
let dir = l.dir.map(|v| v as f64);
let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
match l.kind {
0 => {
let eye = [centre[0] - dir[0] * radius * 2.0, centre[1] - dir[1] * radius * 2.0, centre[2] - dir[2] * radius * 2.0];
let focal = (size as f64 * 0.5 / radius) as f32;
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5 / focal, radius: soft });
}
1 => {
let half = (l.cos_outer.clamp(-1.0, 1.0).acos() as f64 + 0.05).min(1.45);
let focal = (size as f64 * 0.5 / half.tan()) as f32;
maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.0, radius: soft });
}
_ => {
let fs = size / 2;
for (f, d) in [
([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
] {
maps.push(ShadowMap { view: basis(pos, f, d), focal: fs as f32 * 0.5, ortho: false, size: fs, offset: 0, bias: 1.0, radius: soft });
}
}
}
s.lights[li].shadow = first;
}
let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();
for (m, t) in maps.iter_mut().zip(texels) {
m.offset = s.shadow_texels.len() as u32;
s.shadow_texels.extend(t);
}
s.shadows = maps;
}

View File

@ -0,0 +1,872 @@
//! The Advanced 3D scene: everything a rasteriser needs, flattened into world-space triangles
//! and plain arrays (identical input for the CPU rasteriser and the GPU pipeline).
//!
//! - Model and primitive layers: their meshes (glTF/OBJ models are +Y up in model units and
//! are mapped into the layer by `Model Scale` with Y and Z flipped, a 180° turn about X).
//! - Extruded text and shape layers (Geometry Options ▸ Extrusion Depth > 0): bevelled meshes.
//! - Every other 3D layer (footage, solids, comps, flat text and shapes): a textured card of
//! its processed buffer, lit with its Material Options.
//! - Lights from the comp's light layers, the Environment light's image (prefiltered for image
//! based lighting), and shadow maps of the shadow-casting lights.
//!
//! Colours are linear light; textures and layer buffers are linearised from the sRGB curve
//! unless the project already blends in linear light.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_effects::Buf;
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_model::{self as model, Primitive};
use effectcraft_project::{ItemKind, Layer, LayerSource, LightKind, PrimitiveKind};
use super::shade::srgb_to_linear;
use crate::three_d::camera::{CameraState, NEAR};
use crate::three_d::compose::camera_for;
use crate::three_d::light::{LightState, Material as ClassicMaterial};
use crate::{EvalCtx, Renderer};
/// One vertex in world space.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[repr(C)]
pub struct Vertex {
pub pos: [f32; 3],
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4],
pub material: u32,
}
/// A material, resolved for shading.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Material {
/// Linear RGBA factor.
pub base: [f32; 4],
pub metallic: f32,
pub roughness: f32,
pub emissive: [f32; 3],
pub normal_scale: f32,
pub occlusion_strength: f32,
/// 0 opaque, 1 mask, 2 blend.
pub alpha_mode: u32,
pub alpha_cutoff: f32,
/// Layer opacity (multiplies alpha).
pub opacity: f32,
/// Texture indices (−1 = none).
pub tex_base: i32,
pub tex_mr: i32,
pub tex_normal: i32,
pub tex_occlusion: i32,
pub tex_emissive: i32,
pub double_sided: bool,
pub unlit: bool,
pub accepts_lights: bool,
pub receives_shadows: bool,
pub casts_shadows: bool,
/// Drawn (false for "Casts Shadows: Only").
pub visible: bool,
/// Material Options weights (cards: Diffuse, Specular Intensity / 50%, Ambient).
pub diffuse_k: f32,
pub specular_k: f32,
pub ambient_k: f32,
}
impl Default for Material {
fn default() -> Self {
Material {
base: [1.0; 4],
metallic: 0.0,
roughness: 0.5,
emissive: [0.0; 3],
normal_scale: 1.0,
occlusion_strength: 1.0,
alpha_mode: 0,
alpha_cutoff: 0.5,
opacity: 1.0,
tex_base: -1,
tex_mr: -1,
tex_normal: -1,
tex_occlusion: -1,
tex_emissive: -1,
double_sided: false,
unlit: false,
accepts_lights: true,
receives_shadows: true,
casts_shadows: false,
visible: true,
diffuse_k: 1.0,
specular_k: 1.0,
ambient_k: 1.0,
}
}
}
impl Material {
/// Drawn in the transparent (sorted, blended) pass.
pub fn transparent(&self) -> bool {
self.alpha_mode == 2 || self.opacity < 0.999
}
}
/// A texture in [`Scene::texels`]: linear straight RGBA.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct TexInfo {
pub offset: u32,
pub width: u32,
pub height: u32,
/// 0 repeat, 1 clamp, 2 mirror.
pub wrap_u: u32,
pub wrap_v: u32,
}
/// A light (ambient lights are summed into [`Scene::ambient`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Light {
/// 0 parallel, 1 spot, 2 point.
pub kind: u32,
pub pos: [f32; 3],
/// Direction the light travels.
pub dir: [f32; 3],
/// Linear colour × intensity.
pub color: [f32; 3],
pub cos_inner: f32,
pub cos_outer: f32,
/// 0 none, 1 smooth, 2 inverse square clamped.
pub falloff: u32,
pub radius: f32,
pub falloff_distance: f32,
/// First shadow map (point lights use six), −1 = none.
pub shadow: i32,
pub shadow_darkness: f32,
/// Shadow Diffusion (pixels).
pub shadow_diffusion: f32,
}
/// A depth map seen from a light: `view` maps world → light space (+z forward); pixels
/// `u = focal·x/z + size/2` (perspective) or `focal·x + size/2` (orthographic); texels store
/// the light-space depth of the nearest caster.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadowMap {
pub view: [[f32; 4]; 3],
pub focal: f32,
pub ortho: bool,
pub size: u32,
pub offset: u32,
/// Depth bias (world units).
pub bias: f32,
/// PCF spread (texels).
pub radius: f32,
}
/// The environment image: radiance mip levels `radiance..radiance + mips` and an irradiance
/// map, in [`Scene::textures`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EnvInfo {
pub radiance: u32,
pub mips: u32,
pub irradiance: u32,
pub intensity: f32,
/// Radians about the vertical axis.
pub rotation: f32,
}
#[derive(Clone, Debug, Default)]
pub struct Scene {
/// Raster size (output × `ssaa`).
pub width: u32,
pub height: u32,
pub ssaa: u32,
/// World → clip (row-major; reversed depth: ndc z = near / camera z).
pub clip: [[f32; 4]; 4],
/// World → camera (row-major; camera z = depth for depth of field).
pub view: [[f32; 4]; 4],
pub eye: [f32; 3],
pub cam_fwd: [f32; 3],
pub ortho: bool,
pub vertices: Vec<Vertex>,
/// Triangles: the first `opaque_count` indices are opaque, the rest transparent, sorted
/// back to front.
pub indices: Vec<u32>,
pub opaque_count: u32,
pub materials: Vec<Material>,
pub textures: Vec<TexInfo>,
pub texels: Vec<[f32; 4]>,
pub lights: Vec<Light>,
pub shadows: Vec<ShadowMap>,
pub shadow_texels: Vec<f32>,
pub env: Option<EnvInfo>,
pub ambient: [f32; 3],
/// Shading inputs and outputs are linear (no sRGB conversion needed).
pub linear_io: bool,
}
/// Row-major f32 copy of a matrix.
pub fn m32(m: &Mat4) -> [[f32; 4]; 4] {
m.0.map(|r| r.map(|v| v as f32))
}
/// Builds a [`Scene`]: texture uploads deduplicated by source.
pub(crate) struct Builder {
pub s: Scene,
tex_keys: HashMap<(usize, bool), i32>,
/// Per triangle: transparent and its sort depth.
tri_depth: Vec<(bool, f32)>,
cam: CameraState,
}
impl Builder {
fn texture_from(&mut self, w: u32, h: u32, wrap: [u32; 2], data: impl Iterator<Item = [f32; 4]>) -> i32 {
let offset = self.s.texels.len() as u32;
self.s.texels.extend(data);
self.s.textures.push(TexInfo { offset, width: w, height: h, wrap_u: wrap[0], wrap_v: wrap[1] });
(self.s.textures.len() - 1) as i32
}
/// Upload a model texture (`srgb`: colour data to linearise).
fn model_texture(&mut self, t: &Arc<model::Texture>, srgb: bool) -> i32 {
let key = (Arc::as_ptr(t) as usize, srgb);
if let Some(&i) = self.tex_keys.get(&key) {
return i;
}
let lin = srgb && !self.s.linear_io;
let wrap = t.wrap.map(|w| match w {
model::Wrap::Repeat => 0,
model::Wrap::Clamp => 1,
model::Wrap::Mirror => 2,
});
let i = self.texture_from(
t.width,
t.height,
wrap,
t.data.iter().map(|p| if lin { [srgb_to_linear(p[0]), srgb_to_linear(p[1]), srgb_to_linear(p[2]), p[3]] } else { *p }),
);
self.tex_keys.insert(key, i);
i
}
/// Upload a premultiplied layer buffer as a linear straight-alpha texture (clamped edges).
fn buf_texture(&mut self, b: &Buf) -> (i32, bool) {
let lin = !self.s.linear_io;
let mut opaque = true;
let data: Vec<[f32; 4]> = b
.img
.data
.iter()
.map(|p| {
let a = p[3];
if a < 0.999 {
opaque = false;
}
if a <= 1e-6 {
return [0.0; 4];
}
let s = [p[0] / a, p[1] / a, p[2] / a];
if lin { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), a] } else { [s[0], s[1], s[2], a] }
})
.collect();
(self.texture_from(b.img.width, b.img.height, [1, 1], data.into_iter()), opaque)
}
fn material(&mut self, m: Material) -> u32 {
self.s.materials.push(m);
(self.s.materials.len() - 1) as u32
}
/// Append a primitive with per-vertex matrices (model/layer space → world).
fn emit(&mut self, p: &Primitive, mat: u32, xf: &dyn Fn(usize) -> Mat4) {
if p.positions.is_empty() || p.indices.len() < 3 {
return;
}
let base = self.s.vertices.len() as u32;
let transparent = self.s.materials[mat as usize].transparent();
let uniform = xf(usize::MAX);
let normal_of = |m: &Mat4| -> (Mat4, f64) {
let a = &m.0;
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
let inv = Mat4([[a[0][0], a[0][1], a[0][2], 0.0], [a[1][0], a[1][1], a[1][2], 0.0], [a[2][0], a[2][1], a[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]])
.inverse()
.unwrap_or(Mat4::IDENTITY);
(inv.transpose(), det)
};
let (un, udet) = normal_of(&uniform);
let mut flip_count = 0usize;
for k in 0..p.positions.len() {
let (m, n, det) = if p.joints.is_empty() {
(uniform, un, udet)
} else {
let m = xf(k);
let (n, d) = normal_of(&m);
(m, n, d)
};
if det < 0.0 {
flip_count += 1;
}
let q = p.positions[k];
let w = m.apply(vec3(q[0] as f64, q[1] as f64, q[2] as f64));
let nn = p.normals.get(k).copied().unwrap_or([0.0, 0.0, 1.0]);
let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize();
let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]);
let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize();
self.s.vertices.push(Vertex {
pos: [w.x as f32, w.y as f32, w.z as f32],
normal: [wn.x as f32, wn.y as f32, wn.z as f32],
uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]),
tangent: [wt.x as f32, wt.y as f32, wt.z as f32, if det < 0.0 { -t[3] } else { t[3] }],
material: mat,
});
}
// Mirroring transforms reverse the winding.
let flip = flip_count * 2 > p.positions.len();
for t in p.indices.chunks_exact(3) {
let (a, b, c) = (t[0] + base, t[1] + base, t[2] + base);
if flip {
self.s.indices.extend([a, c, b])
} else {
self.s.indices.extend([a, b, c])
}
let z = [a, b, c].iter().map(|&i| self.cam.depth(Vec3::from(self.s.vertices[i as usize].pos.map(|v| v as f64)))).sum::<f64>() / 3.0;
self.tri_depth.push((transparent, z as f32));
}
}
/// Opaque triangles first, then transparent ones back to front.
fn sort_triangles(&mut self) {
let tris: Vec<[u32; 3]> = self.s.indices.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect();
let mut order: Vec<usize> = (0..tris.len()).collect();
order.sort_by(|&a, &b| {
let (ta, za) = self.tri_depth[a];
let (tb, zb) = self.tri_depth[b];
ta.cmp(&tb).then_with(|| if ta { zb.total_cmp(&za) } else { std::cmp::Ordering::Equal })
});
self.s.opaque_count = 3 * self.tri_depth.iter().filter(|t| !t.0).count() as u32;
self.s.indices = order.into_iter().flat_map(|i| tris[i]).collect();
}
}
/// The Material Options of a card (classic properties).
fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material {
let c = ClassicMaterial::of(ctx, layer);
Material {
roughness: (1.0 - c.shininess).clamp(0.05, 1.0),
double_sided: true,
accepts_lights: c.accepts_lights,
receives_shadows: c.accepts_shadows != 0,
casts_shadows: c.casts_shadows != 0,
visible: c.casts_shadows != 2,
diffuse_k: c.diffuse,
specular_k: c.specular * 2.0,
ambient_k: c.ambient,
..Material::default()
}
}
/// Shadow/light switches and the primitive material of a model or primitive layer.
fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option<model::Material>) {
let g = layer.props.sub("materialOptions");
let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum());
let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool());
let casts = e("castsShadows", 0);
let opts = Material {
accepts_lights: b("acceptsLights"),
receives_shadows: e("acceptsShadows", 1) != 0,
casts_shadows: casts != 0,
visible: casts != 2,
..Material::default()
};
let prim = match (layer.source.clone(), g) {
(LayerSource::Primitive { .. }, Some(g)) => {
let c = ctx.color(layer, g, "baseColor");
let em = ctx.color(layer, g, "emissive");
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
let mut m = model::Material::plain(
[l(c[0]), l(c[1]), l(c[2]), 1.0],
(ctx.f(layer, g, "metallic", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
(ctx.f(layer, g, "roughness", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
);
m.emissive = [l(em[0]), l(em[1]), l(em[2])];
Some(m)
}
_ => None,
};
(opts, prim)
}
fn prim_kind(k: PrimitiveKind) -> model::prim::PrimitiveKind {
use model::prim::PrimitiveKind as M;
match k {
PrimitiveKind::Cube => M::Cube,
PrimitiveKind::Sphere => M::Sphere,
PrimitiveKind::Plane => M::Plane,
PrimitiveKind::Torus => M::Torus,
PrimitiveKind::Cone => M::Cone,
PrimitiveKind::Cylinder => M::Cylinder,
}
}
/// The primitive model of a primitive layer at the context time.
pub fn primitive_model(ctx: &EvalCtx, layer: &Layer, kind: PrimitiveKind, mat: model::Material) -> model::Model {
let g = layer.props.sub("geometryOptions");
let f = |m: &str, d: f64| g.map_or(d, |g| ctx.f(layer, g, m, d));
let pr = model::prim::PrimParams {
size: [f("width", 300.0) as f32, f("height", 300.0) as f32, f("depth", 300.0) as f32],
radius: f("radius", 150.0) as f32,
tube: f("tubeRadius", 50.0) as f32,
height: f("height", 300.0) as f32,
segments: f("segments", 48.0).round().clamp(3.0, 512.0) as u32,
rings: f("rings", 24.0).round().clamp(2.0, 512.0) as u32,
};
model::prim::model(prim_kind(kind), &pr, mat)
}
/// Model space → layer space: model units to pixels, Y up → Y down (a 180° turn about X).
pub fn model_to_layer(unit_scale: f64) -> Mat4 {
Mat4::scale(vec3(unit_scale, -unit_scale, -unit_scale))
}
fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
let lin = b.s.linear_io;
let (opts, prim_mat) = model_options(ctx, layer, lin);
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let geo = layer.props.sub("geometryOptions");
let (mdl, unit, clip, t): (Arc<model::Model>, f64, Option<usize>, f64) = match &layer.source {
LayerSource::Primitive { kind } => (Arc::new(primitive_model(ctx, layer, *kind, prim_mat.unwrap_or_default())), 1.0, None, 0.0),
LayerSource::Model { item } => {
let Some(it) = r.project.item(*item) else { return };
let ItemKind::Footage(f) = &it.kind else { return };
let Some(m) = r.footage.model(*item, f) else { return };
let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0));
let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize;
let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len());
let speed = geo.map_or(100.0, |g| ctx.f(layer, g, "animationSpeed", 100.0)) / 100.0;
let mut t = layer.layer_time(ctx.time).seconds() * speed;
if let Some(c) = clip {
let d = m.animations[c].duration();
let looping = geo.is_none_or(|g| ctx.b(layer, g, "loopAnimation"));
if looping && d > 0.0 {
t = t.rem_euclid(d);
}
}
(m, unit, clip, t)
}
_ => return,
};
let world = ctx.world_matrix(layer) * model_to_layer(unit);
// Materials of the model (one default when it has none).
let mut mats: Vec<u32> = vec![];
let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)];
let src: &[model::Material] = if mdl.materials.is_empty() { &defaults } else { &mdl.materials };
for mm in src {
let tex = |b: &mut Builder, t: Option<model::TexRef>, srgb: bool| -> i32 {
t.and_then(|t| mdl.textures.get(t.texture)).map_or(-1, |t| b.model_texture(t, srgb))
};
let m = Material {
base: mm.base_color,
metallic: mm.metallic,
roughness: mm.roughness,
emissive: mm.emissive,
normal_scale: mm.normal_scale,
occlusion_strength: mm.occlusion_strength,
alpha_mode: match mm.alpha_mode {
model::AlphaMode::Opaque => 0,
model::AlphaMode::Mask => 1,
model::AlphaMode::Blend => 2,
},
alpha_cutoff: mm.alpha_cutoff,
opacity,
tex_base: tex(b, mm.base_color_tex, true),
tex_mr: tex(b, mm.metallic_roughness_tex, false),
tex_normal: tex(b, mm.normal_tex, false),
tex_occlusion: tex(b, mm.occlusion_tex, false),
tex_emissive: tex(b, mm.emissive_tex, true),
double_sided: mm.double_sided,
unlit: mm.unlit,
..opts
};
mats.push(b.material(m));
}
for inst in mdl.instances(clip, t) {
for p in &inst.mesh.primitives {
let mat = mats[p.material.unwrap_or(0).min(mats.len() - 1)];
b.emit(p, mat, &|k| if k == usize::MAX { world * inst.world } else { world * model::vertex_matrix(&inst, p, k) });
}
}
}
/// Closed polylines of a path (flattened to `tol` pixels).
pub fn contours(path: &kurbo::BezPath, tol: f64) -> Vec<Vec<[f64; 2]>> {
let mut out: Vec<Vec<[f64; 2]>> = vec![];
let mut cur: Vec<[f64; 2]> = vec![];
kurbo::flatten(path, tol, |el| match el {
kurbo::PathEl::MoveTo(p) => {
if cur.len() >= 3 {
out.push(std::mem::take(&mut cur));
}
cur = vec![[p.x, p.y]];
}
kurbo::PathEl::LineTo(p) => cur.push([p.x, p.y]),
kurbo::PathEl::ClosePath => {
if cur.len() >= 3 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
}
_ => {}
});
if cur.len() >= 3 {
out.push(cur);
}
out
}
/// Geometry Options of a text/shape layer when it is extruded.
pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::ExtrudeParams> {
let g = layer.props.sub("geometryOptions")?;
let depth = ctx.f(layer, g, "extrusionDepth", 0.0);
(depth > 0.0).then(|| model::extrude::ExtrudeParams {
bevel: model::extrude::BevelStyle::from_index(ctx.e(layer, g, "bevelStyle")),
bevel_depth: ctx.f(layer, g, "bevelDepth", 2.0).max(0.0),
hole_bevel: (ctx.f(layer, g, "holeBevelDepth", 100.0) / 100.0).clamp(0.0, 1.0),
depth,
})
}
/// Extruded meshes of a text or shape layer: (mesh in layer space, straight colour).
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4])> {
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4])> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
for g in tg.glyphs.iter().filter(|g| g.apply_fill && g.fill[3] > 0.0) {
groups.push((vec![g.path()], g.fill));
}
}
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::fill_outlines(ctx, layer, c);
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
}
Some((model::extrude::extrude(&outlines, p), color))
})
.collect()
}
fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) {
let base = card_material(ctx, layer);
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
let lin = b.s.linear_io;
for (mesh, c) in extruded_meshes(ctx, layer, p) {
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
let m = b.material(Material {
base: [l(c[0]), l(c[1]), l(c[2]), c[3]],
alpha_mode: if c[3] < 0.999 { 2 } else { 0 },
opacity,
double_sided: false,
..base
});
b.emit(&mesh, m, &|_| world);
}
}
fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) {
if buf.img.width == 0 || buf.img.height == 0 {
return;
}
let (tex, opaque) = b.buf_texture(buf);
mat.tex_base = tex;
mat.alpha_mode = if opaque { 0 } else { 2 };
let m = b.material(mat);
let s = buf.scale.max(1e-9);
let (x0, y0) = (-buf.offset[0] / s, -buf.offset[1] / s);
let (x1, y1) = ((buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s);
// Faces the camera (−z) in layer space; uv (0,0) at the buffer's top-left texel corner.
let p = Primitive {
positions: vec![[x0 as f32, y0 as f32, 0.0], [x1 as f32, y0 as f32, 0.0], [x1 as f32, y1 as f32, 0.0], [x0 as f32, y1 as f32, 0.0]],
normals: vec![[0.0, 0.0, -1.0]; 4],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
tangents: vec![[1.0, 0.0, 0.0, 1.0]; 4],
indices: vec![0, 2, 1, 0, 3, 2],
material: Some(0),
..Default::default()
};
b.emit(&p, m, &|_| world);
}
fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
let mut mat = card_material(ctx, layer);
mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
let world = ctx.world_matrix(layer);
if crate::text::per_char_3d(ctx, layer) {
for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) {
let buf = r.to_blend(Arc::new(buf), None);
card(b, &buf, world * m, mat);
}
return;
}
if let Some(buf) = r.blend_layer_buf(ctx, layer) {
card(b, &buf, world, mat);
}
}
/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,
/// rotation)).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {
let mut out = vec![];
let mut ambient = [0.0f32; 3];
let mut env = None;
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
for layer in ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)) {
let LayerSource::Light { kind } = layer.source else { continue };
let Some(g) = layer.props.sub("lightOptions") else { continue };
let k = (ctx.f(layer, g, "intensity", 100.0) / 100.0) as f32;
let c = ctx.color(layer, g, "color");
let color = [l(c[0]) * k, l(c[1]) * k, l(c[2]) * k];
match kind {
LightKind::Ambient => {
for i in 0..3 {
ambient[i] += color[i];
}
}
LightKind::Environment => {
if env.is_none() {
let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());
let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;
env = Some((src, color, rot));
}
}
_ => {
let Some(st) = LightState::from_layer(ctx, layer) else { continue };
let f = |v: Vec3| [v.x as f32, v.y as f32, v.z as f32];
out.push(Light {
kind: match kind {
LightKind::Parallel => 0,
LightKind::Spot => 1,
_ => 2,
},
pos: f(st.pos),
dir: f(st.dir.normalize()),
color,
cos_inner: (st.cone_half * (1.0 - st.feather)).cos() as f32,
cos_outer: st.cone_half.cos() as f32,
falloff: st.falloff,
radius: st.radius as f32,
falloff_distance: st.falloff_distance as f32,
// Marked; `raster::shadow_maps` assigns the maps.
shadow: if st.casts_shadows { 0 } else { -1 },
shadow_darkness: st.shadow_darkness as f32,
shadow_diffusion: st.shadow_diffusion as f32,
});
}
}
}
(out, ambient, env)
}
/// Box-downsample a straight RGBA image by 2 (odd sizes round up).
fn half(w: u32, h: u32, d: &[[f32; 4]]) -> (u32, u32, Vec<[f32; 4]>) {
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
let mut out = vec![[0.0f32; 4]; (nw * nh) as usize];
for y in 0..nh {
for x in 0..nw {
let mut acc = [0.0f32; 4];
let mut n = 0.0;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (sx, sy) = (2 * x + dx, 2 * y + dy);
if sx < w && sy < h {
let p = d[(sy * w + sx) as usize];
for k in 0..4 {
acc[k] += p[k];
}
n += 1.0;
}
}
out[(y * nw + x) as usize] = acc.map(|v| v / n);
}
}
(nw, nh, out)
}
/// Cosine-weighted irradiance (`w`×`h` equirectangular) of an equirectangular radiance image:
/// each texel is the cosine-weighted mean radiance over the hemisphere around its direction
/// (a constant environment gives the same constant).
pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec<[f32; 4]> {
use super::shade::{dot, equirect_dir};
// Source directions and solid-angle weights (∝ sin θ).
let dirs: Vec<([f32; 3], f32, [f32; 3])> = (0..sh)
.flat_map(|y| (0..sw).map(move |x| (x, y)))
.map(|(x, y)| {
let (u, v) = ((x as f32 + 0.5) / sw as f32, (y as f32 + 0.5) / sh as f32);
let p = src[(y * sw + x) as usize];
(equirect_dir(u, v, 0.0), (v * std::f32::consts::PI).sin(), [p[0], p[1], p[2]])
})
.collect();
let mut out = vec![[0.0f32; 4]; (w * h) as usize];
for y in 0..h {
for x in 0..w {
let n = equirect_dir((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32, 0.0);
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
for (d, sa, c) in &dirs {
let k = dot(n, *d).max(0.0) * sa;
if k > 0.0 {
for i in 0..3 {
acc[i] += c[i] * k;
}
wsum += k;
}
}
let inv = if wsum > 0.0 { 1.0 / wsum } else { 0.0 };
out[(y * w + x) as usize] = [acc[0] * inv, acc[1] * inv, acc[2] * inv, 1.0];
}
}
out
}
/// Prefilter an environment image into the scene's textures.
pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {
// Radiance level 0 at most 1024 wide.
let (mut w, mut h, mut d) = (w, h, data);
while w > 1024 {
(w, h, d) = half(w, h, &d);
}
let first = b.s.textures.len() as u32;
let mut levels = 0;
let mut small: Option<(u32, u32, Vec<[f32; 4]>)> = None;
loop {
b.texture_from(w, h, [0, 1], d.iter().copied());
levels += 1;
if w <= 64 && small.is_none() {
small = Some((w, h, d.clone()));
}
if w <= 8 || h <= 4 {
break;
}
(w, h, d) = half(w, h, &d);
}
let (sw, sh, sd) = small.unwrap_or((w, h, d));
let irr = irradiance_map(sw, sh, &sd, 32, 16);
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
let tint = intensity.map(|c| c / k);
for t in first..=ii {
let ti = b.s.textures[t as usize];
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
for c in 0..3 {
p[c] *= tint[c];
}
}
}
}
b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });
}
/// The environment layer's pixels as linear straight RGBA.
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
let layer = match src {
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
};
let buf = r.blend_layer_buf(ctx, layer)?;
let img = &buf.img;
let data = img
.data
.iter()
.map(|p| {
let a = p[3];
if a <= 1e-6 {
return [0.0, 0.0, 0.0, 1.0];
}
let s = [p[0] / a, p[1] / a, p[2] / a];
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
})
.collect();
Some((img.width, img.height, data))
}
/// Supersampling factor: 2×2 unless draft or very large.
fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 }
}
/// Build the scene of a run of 3D layers.
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
let cam = camera_for(r, ctx);
let lin = r.pipe.linear || r.pipe.linear_blend;
let ssaa = ssaa_for(r, out);
let mut b = Builder {
s: Scene { width: out.0 * ssaa, height: out.1 * ssaa, ssaa, linear_io: lin, ..Scene::default() },
tex_keys: HashMap::new(),
tri_depth: vec![],
cam,
};
for layer in run {
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
continue;
}
if layer.source.is_model() {
model_layer(r, ctx, layer, &mut b);
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
&& let Some(p) = extrusion_params(ctx, layer)
{
extruded_layer(r, ctx, layer, &p, &mut b);
} else {
card_layer(r, ctx, layer, &mut b);
}
}
b.sort_triangles();
// Lights, environment, shadows (Draft 3D: unlit, like After Effects).
if !ctx.comp.draft_3d {
let (ls, amb, env) = lights(ctx, lin);
b.s.lights = ls;
b.s.ambient = amb;
if let Some((src, k, rot)) = env
&& let Some((w, h, d)) = env_pixels(r, ctx, src, lin)
{
add_environment(&mut b, w, h, d, k, rot);
}
super::raster::shadow_maps(&mut b.s, r.opts.draft);
}
// Camera matrices.
let s = r.opts.scale;
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (ow, oh) = (out.0 as f64, out.1 as f64);
let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0);
let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0);
let proj = if cam.ortho {
let (mut zmin, mut zmax) = (f64::INFINITY, f64::NEG_INFINITY);
for v in &b.s.vertices {
let z = cam.depth(Vec3::from(v.pos.map(|x| x as f64)));
zmin = zmin.min(z);
zmax = zmax.max(z);
}
if !zmin.is_finite() {
(zmin, zmax) = (-1.0, 1.0);
}
let pad = (zmax - zmin).abs() * 0.01 + 1.0;
let (zmin, zmax) = (zmin - pad, zmax + pad);
let zr = zmax - zmin;
Mat4([[a, 0.0, 0.0, bb], [0.0, -c, 0.0, -d], [0.0, 0.0, -1.0 / zr, zmax / zr], [0.0, 0.0, 0.0, 1.0]])
} else {
Mat4([[a, 0.0, bb, 0.0], [0.0, -c, -d, 0.0], [0.0, 0.0, 0.0, NEAR], [0.0, 0.0, 1.0, 0.0]])
};
b.s.clip = m32(&(proj * cam.view));
b.s.view = m32(&cam.view);
let f = cam.forward();
b.s.cam_fwd = [f.x as f32, f.y as f32, f.z as f32];
b.s.eye = [cam.eye.x as f32, cam.eye.y as f32, cam.eye.z as f32];
b.s.ortho = cam.ortho;
b.s
}

View File

@ -0,0 +1,408 @@
//! Physically based shading for the Advanced 3D renderer (CPU reference; `crates/gpu`'s
//! `advanced3d.wgsl` repeats it step for step).
//!
//! - Direct light: Cook–Torrance microfacet specular with the GGX/Trowbridge–Reitz normal
//! distribution, the Smith–Schlick-GGX geometry term (`k = (r + 1)² / 8`) and Schlick's
//! Fresnel (`F0` = 0.04 for dielectrics, the base colour for metals), plus a Lambertian
//! diffuse lobe weighted by `(1 − F)(1 − metallic)` (Karis 2013, "Real Shading in Unreal
//! Engine 4"; the glTF 2.0 metallic-roughness model). Light colours are the irradiance on a
//! surface facing the light (a white 100% light shows a white diffuse surface at its base
//! colour), i.e. π is folded into the light intensity.
//! - Image-based light: a cosine-convolved irradiance map for diffuse, a box-filtered mip
//! chain of the equirectangular environment sampled by roughness for specular, and the
//! analytic split-sum environment BRDF fit (Karis 2014, "Physically Based Shading on
//! Mobile").
//! - Shadows: depth maps per light, percentage-closer filtered.
use super::scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo};
pub const PI: f32 = std::f32::consts::PI;
#[inline]
pub fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
pub fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
#[inline]
pub fn norm(a: [f32; 3]) -> [f32; 3] {
let l = dot(a, a).sqrt();
if l > 1e-20 { [a[0] / l, a[1] / l, a[2] / l] } else { [0.0, 0.0, -1.0] }
}
#[inline]
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
#[inline]
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn scale(a: [f32; 3], k: f32) -> [f32; 3] {
[a[0] * k, a[1] * k, a[2] * k]
}
#[inline]
fn mix(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
/// sRGB-encoded → linear light.
#[inline]
pub fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// Linear light → sRGB-encoded (over-range values continue the curve).
#[inline]
pub fn linear_to_srgb(c: f32) -> f32 {
if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
}
/// GGX / Trowbridge–Reitz normal distribution, `a = roughness²`.
#[inline]
pub fn d_ggx(n_h: f32, a: f32) -> f32 {
let a2 = a * a;
let d = n_h * n_h * (a2 - 1.0) + 1.0;
a2 / (PI * d * d).max(1e-12)
}
/// Smith–Schlick-GGX geometry term for direct light (`k = (r + 1)² / 8`).
#[inline]
pub fn g_smith(n_v: f32, n_l: f32, rough: f32) -> f32 {
let k = (rough + 1.0) * (rough + 1.0) / 8.0;
let g1 = |x: f32| x / (x * (1.0 - k) + k);
g1(n_v) * g1(n_l)
}
/// Schlick's Fresnel approximation.
#[inline]
pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] {
let k = (1.0 - v_h).clamp(0.0, 1.0).powi(5);
[f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k]
}
/// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour).
/// Returned as the reflected fraction of the light's irradiance.
pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] {
let n_l = dot(n, l);
if n_l <= 0.0 {
return [0.0; 3];
}
let n_v = dot(n, v).max(1e-4);
let h = norm(add(v, l));
let n_h = dot(n, h).max(0.0);
let v_h = dot(v, h).max(0.0);
let r = rough.clamp(0.03, 1.0);
let f0 = [mix(0.04 * spec_k, base[0], metallic), mix(0.04 * spec_k, base[1], metallic), mix(0.04 * spec_k, base[2], metallic)];
let f = f_schlick(f0, v_h);
let d = d_ggx(n_h, r * r);
let g = g_smith(n_v, n_l, r);
let spec = d * g / (4.0 * n_v * n_l).max(1e-4);
let mut out = [0.0; 3];
for c in 0..3 {
let kd = (1.0 - f[c]) * (1.0 - metallic);
out[c] = (kd * base[c] / PI * diff_k + f[c] * spec) * n_l * PI;
}
out
}
/// Split-sum environment BRDF (scale, bias) for `F0·A + B` (analytic fit).
#[inline]
pub fn env_brdf(n_v: f32, rough: f32) -> (f32, f32) {
let c0 = [-1.0, -0.0275, -0.572, 0.022];
let c1 = [1.0, 0.0425, 1.04, -0.04];
let r = [rough * c0[0] + c1[0], rough * c0[1] + c1[1], rough * c0[2] + c1[2], rough * c0[3] + c1[3]];
let a004 = (r[0] * r[0]).min((-9.28 * n_v).exp2()) * r[0] + r[1];
(-1.04 * a004 + r[2], 1.04 * a004 + r[3])
}
/// Wrap a texel index.
#[inline]
fn wrap(i: i64, n: i64, mode: u32) -> i64 {
match mode {
1 => i.clamp(0, n - 1),
2 => {
let m = i.rem_euclid(2 * n);
if m >= n { 2 * n - 1 - m } else { m }
}
_ => i.rem_euclid(n),
}
}
/// Bilinear texture sample at (u, v) (0..1 across the texture, texel centres at +0.5).
pub fn sample(t: &TexInfo, texels: &[[f32; 4]], u: f32, v: f32) -> [f32; 4] {
let (w, h) = (t.width as i64, t.height as i64);
if w == 0 || h == 0 {
return [1.0; 4];
}
let x = u * w as f32 - 0.5;
let y = v * h as f32 - 0.5;
let (x0, y0) = (x.floor(), y.floor());
let (fx, fy) = (x - x0, y - y0);
let (x0, y0) = (x0 as i64, y0 as i64);
let get = |xi: i64, yi: i64| -> [f32; 4] {
let xi = wrap(xi, w, t.wrap_u);
let yi = wrap(yi, h, t.wrap_v);
texels.get(t.offset as usize + (yi * w + xi) as usize).copied().unwrap_or([0.0; 4])
};
let (a, b, c, d) = (get(x0, y0), get(x0 + 1, y0), get(x0, y0 + 1), get(x0 + 1, y0 + 1));
let mut o = [0.0; 4];
for k in 0..4 {
let top = a[k] + (b[k] - a[k]) * fx;
let bot = c[k] + (d[k] - c[k]) * fx;
o[k] = top + (bot - top) * fy;
}
o
}
/// Equirectangular coordinates of a world direction (AE world: +Y down; the environment's up
/// is −Y), rotated about the vertical axis by `rot` radians.
#[inline]
pub fn equirect_uv(d: [f32; 3], rot: f32) -> (f32, f32) {
let u = 0.5 + (d[0].atan2(d[2]) + rot) / (2.0 * PI);
let v = (-d[1]).clamp(-1.0, 1.0).acos() / PI;
(u - u.floor(), v)
}
/// Inverse of [`equirect_uv`] (texel centre → direction).
pub fn equirect_dir(u: f32, v: f32, rot: f32) -> [f32; 3] {
let phi = (u - 0.5) * 2.0 * PI - rot;
let th = v * PI;
[th.sin() * phi.sin(), -th.cos(), th.sin() * phi.cos()]
}
/// Diffuse (irradiance) and roughness-filtered specular radiance of the environment.
pub fn env_light(s: &Scene, e: &EnvInfo, n: [f32; 3], r: [f32; 3], rough: f32) -> ([f32; 3], [f32; 3]) {
let (u, v) = equirect_uv(n, e.rotation);
let irr = s.textures.get(e.irradiance as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let (u, v) = equirect_uv(r, e.rotation);
let lv = rough.clamp(0.0, 1.0) * (e.mips.saturating_sub(1)) as f32;
let l0 = lv.floor() as u32;
let l1 = (l0 + 1).min(e.mips.saturating_sub(1));
let f = lv - l0 as f32;
let a = s.textures.get((e.radiance + l0) as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let b = s.textures.get((e.radiance + l1) as usize).map_or([0.0; 4], |t| sample(t, &s.texels, u, v));
let spec = [mix(a[0], b[0], f), mix(a[1], b[1], f), mix(a[2], b[2], f)];
(scale([irr[0], irr[1], irr[2]], e.intensity), scale(spec, e.intensity))
}
/// Light-space position (u, v in shadow-map pixels, depth) of a world point.
#[inline]
pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> {
let v = &m.view;
let q = [
v[0][0] * p[0] + v[0][1] * p[1] + v[0][2] * p[2] + v[0][3],
v[1][0] * p[0] + v[1][1] * p[1] + v[1][2] * p[2] + v[1][3],
v[2][0] * p[0] + v[2][1] * p[1] + v[2][2] * p[2] + v[2][3],
];
let c = m.size as f32 * 0.5;
if m.ortho {
Some((q[0] * m.focal + c, q[1] * m.focal + c, q[2]))
} else if q[2] > 1e-3 {
Some((q[0] * m.focal / q[2] + c, q[1] * m.focal / q[2] + c, q[2]))
} else {
None
}
}
/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid
/// spread over `radius` texels.
pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 {
let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 };
let size = m.size as i64;
// Slope-scaled bias (world units are pixels).
let bias = m.bias * (1.0 + 2.0 * (1.0 - n_l.clamp(0.0, 1.0))) + z.abs() * 0.0015;
let step = m.radius / 2.0;
let mut lit = 0.0;
for j in -2..=2 {
for i in -2..=2 {
let x = (u + i as f32 * step).floor() as i64;
let y = (v + j as f32 * step).floor() as i64;
if x < 0 || y < 0 || x >= size || y >= size {
lit += 1.0;
continue;
}
let d = texels[m.offset as usize + (y * size + x) as usize];
if z - bias <= d {
lit += 1.0;
}
}
}
lit / 25.0
}
/// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness.
pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
if l.shadow < 0 {
return 1.0;
}
let first = l.shadow as usize;
let m = if l.kind == 2 {
// Point light: the cube face of the major axis of the light → point direction.
let d = sub(p, l.pos);
let (ax, ay, az) = (d[0].abs(), d[1].abs(), d[2].abs());
let face = if ax >= ay && ax >= az {
if d[0] > 0.0 { 0 } else { 1 }
} else if ay >= az {
if d[1] > 0.0 { 2 } else { 3 }
} else if d[2] > 0.0 {
4
} else {
5
};
s.shadows.get(first + face)
} else {
s.shadows.get(first)
};
let Some(m) = m else { return 1.0 };
let f = shadow_factor(m, &s.shadow_texels, p, n_l);
1.0 - l.shadow_darkness * (1.0 - f)
}
/// Distance attenuation × spot cone of a light at `p` (AE falloff modes).
pub fn attenuation(l: &Light, p: [f32; 3], to_l: [f32; 3], dist: f32) -> f32 {
let mut k = match l.falloff {
1 if l.kind != 0 => {
if dist <= l.radius {
1.0
} else if l.falloff_distance <= 0.0 {
0.0
} else {
let t = ((dist - l.radius) / l.falloff_distance).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
}
2 if l.kind != 0 => {
if dist <= l.radius || (l.radius <= 0.0 && dist <= 1.0) {
1.0
} else {
(l.radius.max(1.0) / dist).powi(2)
}
}
_ => 1.0,
};
let _ = p;
if l.kind == 1 {
let cos_a = dot(scale(to_l, -1.0), l.dir).clamp(-1.0, 1.0);
k *= if cos_a >= l.cos_inner {
1.0
} else if cos_a <= l.cos_outer {
0.0
} else {
// Smoothstep across the feather in angle.
let a = cos_a.acos();
let (ai, ao) = (l.cos_inner.acos(), l.cos_outer.acos());
let t = ((a - ai) / (ao - ai).max(1e-6)).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
};
}
k
}
/// Inputs of one fragment.
#[derive(Clone, Copy, Debug)]
pub struct Frag {
pub pos: [f32; 3],
/// Interpolated (unnormalised) vertex normal.
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4],
pub material: u32,
pub front: bool,
}
/// Shade a fragment: premultiplied linear RGBA, or `None` when discarded (alpha test, back
/// face of a single-sided material).
pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
let m: &Material = s.materials.get(f.material as usize)?;
if !f.front && !m.double_sided {
return None;
}
let tex = |i: i32| -> Option<[f32; 4]> { (i >= 0).then(|| s.textures.get(i as usize).map(|t| sample(t, &s.texels, f.uv[0], f.uv[1]))).flatten() };
let mut base = m.base;
if let Some(t) = tex(m.tex_base) {
for k in 0..4 {
base[k] *= t[k];
}
}
let alpha = base[3] * m.opacity;
if m.alpha_mode == 1 && alpha < m.alpha_cutoff {
return None;
}
if alpha <= 1e-5 && m.alpha_mode != 0 {
return None;
}
let alpha = if m.alpha_mode == 0 { m.opacity } else { alpha };
let rgb = [base[0], base[1], base[2]];
let mut emissive = m.emissive;
if let Some(t) = tex(m.tex_emissive) {
emissive = [emissive[0] * t[0], emissive[1] * t[1], emissive[2] * t[2]];
}
if m.unlit || !m.accepts_lights || (s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]) {
let c = add(rgb, emissive);
return Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha]);
}
let (mut metallic, mut rough) = (m.metallic, m.roughness);
if let Some(t) = tex(m.tex_mr) {
rough *= t[1];
metallic *= t[2];
}
let mut n = norm(f.normal);
if !f.front {
n = scale(n, -1.0);
}
if let Some(t) = tex(m.tex_normal) {
let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0];
let tg = [f.tangent[0], f.tangent[1], f.tangent[2]];
// Gram-Schmidt against the shading normal.
let tg = norm(sub(tg, scale(n, dot(n, tg))));
let sign = if f.front { f.tangent[3] } else { -f.tangent[3] };
let bt = scale(cross(n, tg), sign);
n = norm(add(add(scale(tg, tn[0]), scale(bt, tn[1])), scale(n, tn[2])));
}
let ao = match tex(m.tex_occlusion) {
Some(t) => 1.0 + m.occlusion_strength * (t[0] - 1.0),
None => 1.0,
};
let v = if s.ortho { scale(s.cam_fwd, -1.0) } else { norm(sub(s.eye, f.pos)) };
let mut out = [0.0f32; 3];
for l in &s.lights {
let (to_l, dist) = if l.kind == 0 {
(scale(l.dir, -1.0), f32::INFINITY)
} else {
let d = sub(l.pos, f.pos);
let len = dot(d, d).sqrt();
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
};
let k = attenuation(l, f.pos, to_l, dist);
if k <= 0.0 {
continue;
}
let n_l = dot(n, to_l);
if n_l <= 0.0 {
continue;
}
let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 };
let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l);
for c in 0..3 {
out[c] += b[c] * l.color[c] * k * sh;
}
}
for c in 0..3 {
out[c] += s.ambient[c] * rgb[c] * m.ambient_k * ao;
}
if let Some(e) = &s.env {
let n_v = dot(n, v).clamp(1e-4, 1.0);
let r = sub(scale(n, 2.0 * dot(n, v)), v);
let (irr, spec) = env_light(s, e, n, r, rough);
let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)];
let (a, b) = env_brdf(n_v, rough);
for c in 0..3 {
let fs = f0[c] * a + b;
out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao;
}
}
let c = add(out, emissive);
Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha])
}

View File

@ -0,0 +1,314 @@
use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
use effectcraft_time::{FrameRate, Tick};
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
use super::shade::{self, PI};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer};
// ---------------------------------------------------------------- shading
#[test]
fn brdf_matches_cook_torrance_formula() {
// Reference: f = kd·c/π + D·G·F/(4·NV·NL), returned × NL × π.
let n = [0.0, 0.0, 1.0];
let v = shade::norm([0.3, 0.1, 1.0]);
let l = shade::norm([-0.4, 0.2, 1.0]);
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
let a2 = (rough * rough).powi(2);
let d = a2 / (PI * ((nh * nh) * (a2 - 1.0) + 1.0).powi(2));
let k = (rough + 1.0).powi(2) / 8.0;
let g = nv / (nv * (1.0 - k) + k) * nl / (nl * (1.0 - k) + k);
for c in 0..3 {
let f0 = 0.04 + (base[c] - 0.04) * metal;
let f = f0 + (1.0 - f0) * (1.0 - vh).powi(5);
let expect = ((1.0 - f) * (1.0 - metal) * base[c] / PI + d * g * f / (4.0 * nv * nl)) * nl * PI;
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
}
// No light from behind the surface.
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
}
#[test]
fn ggx_distribution_is_normalised() {
// ∫ D(h)·(n·h) dω = 1 over the hemisphere.
for &rough in &[0.2f32, 0.5, 0.9] {
let a = rough * rough;
let n = 4000;
let mut sum = 0.0f64;
for i in 0..n {
let th = (i as f64 + 0.5) / n as f64 * std::f64::consts::FRAC_PI_2;
let c = th.cos() as f32;
sum += shade::d_ggx(c, a) as f64 * c as f64 * th.sin() * std::f64::consts::FRAC_PI_2 / n as f64 * 2.0 * std::f64::consts::PI;
}
assert!((sum - 1.0).abs() < 0.02, "roughness {rough}: {sum}");
}
}
#[test]
fn constant_environment_has_constant_irradiance() {
let src = vec![[0.25, 0.5, 0.75, 1.0]; 64 * 32];
let irr = irradiance_map(64, 32, &src, 32, 16);
for p in &irr {
for c in 0..3 {
assert!((p[c] - src[0][c]).abs() < 1e-4, "{p:?}");
}
}
// And a sky brighter than the ground lights upward-facing normals more.
let sky: Vec<[f32; 4]> = (0..32).flat_map(|y| (0..64).map(move |_| if y < 16 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] })).collect();
let irr = irradiance_map(64, 32, &sky, 32, 16);
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
let d = shade::equirect_dir(u, v, 0.3);
let (u2, v2) = shade::equirect_uv(d, 0.3);
assert!((u - u2).abs() < 1e-4 && (v - v2).abs() < 1e-4);
}
// Up (−Y in AE space) is the top row.
assert!(shade::equirect_uv([0.0, -1.0, 0.0], 0.0).1 < 1e-3);
let (a, b) = shade::env_brdf(1.0, 0.0);
assert!(a + b > 0.9 && a + b <= 1.05);
let (a, b) = shade::env_brdf(0.1, 1.0);
assert!(a >= 0.0 && b > -0.01 && a + b < 1.0);
}
// ---------------------------------------------------------------- rasteriser
/// A scene of axis-aligned quads (z = camera depth) seen by an orthographic identity camera
/// mapping world x/y straight to raster pixels.
fn quad_scene(w: u32, h: u32, quads: &[([f32; 4], f32, [f32; 4])]) -> Scene {
let mut s = Scene { width: w, height: h, ssaa: 1, linear_io: true, ..Scene::default() };
// clip: x → 2x/w − 1, y → 1 − 2y/h, z: depth 0..1000 → 1..0 (nearer = larger).
s.clip = [[2.0 / w as f32, 0.0, 0.0, -1.0], [0.0, -2.0 / h as f32, 0.0, 1.0], [0.0, 0.0, -0.001, 1.0], [0.0, 0.0, 0.0, 1.0]];
s.view = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
s.ortho = true;
s.cam_fwd = [0.0, 0.0, 1.0];
for (rect, z, color) in quads {
let m = s.materials.len() as u32;
s.materials.push(Material { base: *color, unlit: true, double_sided: true, alpha_mode: if color[3] < 1.0 { 2 } else { 0 }, ..Material::default() });
let b = s.vertices.len() as u32;
for (x, y) in [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])] {
s.vertices.push(Vertex { pos: [x, y, *z], normal: [0.0, 0.0, -1.0], uv: [0.0; 2], tangent: [1.0, 0.0, 0.0, 1.0], material: m });
}
s.indices.extend([b, b + 2, b + 1, b, b + 3, b + 2]);
}
// Opaque first, transparent (in given order) after.
let tris: Vec<[u32; 3]> = s.indices.chunks(3).map(|t| [t[0], t[1], t[2]]).collect();
let (o, t): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| !s.materials[s.vertices[t[0] as usize].material as usize].transparent());
s.opaque_count = 3 * o.len() as u32;
s.indices = o.into_iter().chain(t).flatten().collect();
s
}
#[test]
fn depth_buffer_keeps_the_nearest_surface() {
let red = [1.0, 0.0, 0.0, 1.0];
let blue = [0.0, 0.0, 1.0, 1.0];
// Blue is listed first but is farther away.
let s = quad_scene(40, 30, &[([0.0, 0.0, 30.0, 20.0], 500.0, blue), ([10.0, 10.0, 40.0, 30.0], 100.0, red)]);
let t = raster::render(&s);
let px = |x: usize, y: usize| t.color[y * 40 + x];
assert_eq!(px(5, 5), blue);
assert_eq!(px(15, 15), red, "overlap: the nearer quad wins");
assert_eq!(px(35, 25), red);
assert_eq!(px(35, 5), [0.0; 4]);
assert_eq!(t.depth[15 * 40 + 15], 100.0);
assert!(t.depth[5 * 40 + 35].is_infinite());
}
#[test]
fn coverage_is_exact_for_pixel_aligned_quads() {
// Shared diagonal edges are filled once (top-left rule): exact pixel counts.
let s = quad_scene(32, 32, &[([4.0, 4.0, 20.0, 12.0], 10.0, [1.0, 1.0, 1.0, 0.5])]);
let t = raster::render(&s);
let covered = t.color.iter().filter(|c| c[3] > 0.0).count();
assert_eq!(covered, 16 * 8);
// Blended once (no double coverage along the diagonal).
assert!(t.color.iter().all(|c| c[3] == 0.0 || (c[3] - 0.5).abs() < 1e-6));
// Two transparent layers composite in order.
let s = quad_scene(8, 8, &[([0.0, 0.0, 8.0, 8.0], 100.0, [1.0, 0.0, 0.0, 0.5]), ([0.0, 0.0, 8.0, 8.0], 50.0, [0.0, 0.0, 1.0, 0.5])]);
let c = raster::render(&s).color[0];
assert!((c[0] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn textures_sample_bilinear_with_wrap_modes() {
let t = TexInfo { offset: 0, width: 2, height: 1, wrap_u: 0, wrap_v: 1 };
let texels = vec![[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]];
assert_eq!(shade::sample(&t, &texels, 0.25, 0.5)[0], 0.0);
assert_eq!(shade::sample(&t, &texels, 0.5, 0.5)[0], 0.5);
// Repeat wraps the right edge back to texel 0.
assert!((shade::sample(&t, &texels, 1.0, 0.5)[0] - 0.5).abs() < 1e-6);
let c = TexInfo { wrap_u: 1, ..t };
assert_eq!(shade::sample(&c, &texels, 1.0, 0.5)[0], 1.0);
}
// ---------------------------------------------------------------- full renderer
fn project(w: u32, h: u32) -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let id = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
(p, id)
}
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;
}
fn render(p: &Project, cid: ItemId) -> crate::Image {
Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO)
}
#[test]
fn lit_cube_renders_with_depth_and_shading() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(60.0));
set(l, "geometryOptions/height", Value::Scalar(60.0));
set(l, "geometryOptions/depth", Value::Scalar(60.0));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
let img = render(&p, cid);
let centre = img.data[60 * 160 + 80];
assert!(centre[3] > 0.99, "cube covers the centre: {centre:?}");
assert_eq!(img.data[2 * 160 + 2][3], 0.0, "corner is empty");
// No lights: unlit (base colour) — add a light and faces get different shades.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
let lit = render(&p, cid);
let mut shades: Vec<i32> = lit.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
shades.sort();
shades.dedup();
assert!(shades.len() >= 3, "faces are shaded differently: {shades:?}");
// Classic 3D draws no meshes.
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
}
#[test]
fn shadows_darken_the_receiver() {
let (mut p, cid) = project(200, 150);
// A floor plane facing the camera, a sphere in front of it, a spot light behind the camera.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let img = render(&p, cid);
// The sphere casts only (invisible); its shadow lands on the plane behind it.
let mid = img.data[75 * 200 + 100];
let side = img.data[75 * 200 + 20];
assert!(mid[3] > 0.99 && side[3] > 0.99);
assert!(mid[0] < side[0] * 0.5, "shadowed centre {mid:?} vs lit side {side:?}");
// Without shadow casting the centre is lit.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
}
#[test]
fn cards_and_environment_light() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
let c = unlit.data[20 * 120 + 30];
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
assert_eq!(unlit.data[80 * 120 + 110][3], 0.0);
// An Environment light sourcing a white environment layer brightens the card.
let env = p.add_item(
"E",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, 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 }, |_| {});
let lit = render(&p, cid);
let c2 = lit.data[20 * 120 + 30];
assert!(c2[0] > 0.3, "{c2:?}");
// The environment layer itself is not drawn.
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
}
#[test]
fn depth_of_field_blurs_out_of_focus_surfaces() {
let mut img = Image::new(64, 64);
for y in 0..64 {
for x in 0..64 {
let v = if (x / 4 + y / 4) % 2 == 0 { 1.0 } else { 0.0 };
img.data[y * 64 + x] = [v, v, v, 1.0];
}
}
let var = |im: &Image| {
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
};
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
assert!(var(&blurred) < var(&img) * 0.3, "out of focus: blurred");
}
#[test]
fn extruded_text_renders_as_a_mesh() {
let (mut p, cid) = project(200, 100);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert!(!meshes.is_empty());
assert!(meshes.iter().all(|(m, _)| m.positions.iter().any(|q| (q[2] - 30.0).abs() < 1e-3)));
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}

View File

@ -323,6 +323,12 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
if run.is_empty() {
return;
}
// Advanced 3D comps: meshes, physically based lights, shadow maps (collapsed precomps keep
// the parent's Classic 3D planes).
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
super::adv::draw_run(r, ctx, run, canvas);
return;
}
let out = (canvas.width, canvas.height);
// A collapsed precomp's 3D layers are lit by the outer comp's lights.
let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent));

View File

@ -117,7 +117,18 @@ impl LightState {
/// All lights switched on and active at the context time.
pub fn lights_at(ctx: &EvalCtx) -> Vec<LightState> {
ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)).filter_map(|l| LightState::from_layer(ctx, l)).collect()
// Environment lights only light Advanced 3D comps (image-based light).
ctx.comp
.layers
.iter()
.filter(|l| {
l.is_light()
&& l.switches.video
&& l.is_active_at(ctx.time)
&& l.source != (effectcraft_project::LayerSource::Light { kind: LightKind::Environment })
})
.filter_map(|l| LightState::from_layer(ctx, l))
.collect()
}
/// Material Options of a 3D layer.

View File

@ -5,6 +5,7 @@
//! consecutive 3D layers is handed to [`compose::draw_run`] as a group (2D layers break 3D
//! groups, as in After Effects).
pub mod adv;
pub mod camera;
pub(crate) mod compose;
pub mod light;