diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 1ad9cc32..5de5a2c2 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -349,7 +349,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs new file mode 100644 index 00000000..6e428c84 --- /dev/null +++ b/crates/gpu/src/adv3d.rs @@ -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) -> Vec { + let mut b: Vec = 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; 7] { + let mut globals: Vec = 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 = 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 = 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 { + 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 = 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 = 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 = 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 }) +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 9f814377..1b141301 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -94,6 +94,8 @@ pub struct GpuContext { dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, uploads: Mutex, + /// Advanced 3D render pipelines (built on first use). + pub(crate) adv3d: std::sync::OnceLock, } 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(), }) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index e7b54ab2..c299cf5d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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) -> Option { effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels) } + + fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { + adv3d::render(&self.ctx, scene) + } } #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_adv3d; diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl new file mode 100644 index 00000000..9ae81ff5 --- /dev/null +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -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, + // Camera-space depth row (x, y, z, w). + view_z: vec4, + // eye.xyz, ortho (0/1) + eye: vec4, + // cam_fwd.xyz, light count + fwd: vec4, + // ambient.rgb, environment present (0/1) + ambient: vec4, + // radiance first texture, mip count, irradiance texture, intensity + env: vec4, + // rotation, any lighting (0/1), -, - + env2: vec4, +}; + +@group(0) @binding(0) var g: Globals; +@group(0) @binding(1) var materials: array>; +@group(0) @binding(2) var texinfo: array>; +@group(0) @binding(3) var texels: array>; +@group(0) @binding(4) var lights: array>; +@group(0) @binding(5) var shadows: array>; +@group(0) @binding(6) var shadow_texels: array; + +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, + @location(1) normal: vec3, + @location(2) uv: vec2, + @location(3) tangent: vec4, + @location(4) material: u32, +}; + +struct VOut { + @builtin(position) clip: vec4, + @location(0) pos: vec3, + @location(1) normal: vec3, + @location(2) uv: vec2, + @location(3) tangent: vec4, + @location(4) @interpolate(flat) material: u32, +}; + +@vertex +fn vs(v: VIn) -> VOut { + var o: VOut; + o.clip = g.clip * vec4(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) -> vec3 { + let l = sqrt(dot(a, a)); + if (l > 1e-20) { + return a / l; + } + return vec3(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, x: i32, y: i32) -> vec4 { + 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) -> vec4 { + let t = texinfo[u32(i)]; + if (t.y == 0u || t.z == 0u) { + return vec4(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, metallic: f32, rough_in: f32, spec_k: f32, diff_k: f32, n: vec3, v: vec3, l: vec3) -> vec3 { + let n_l = dot(n, l); + if (n_l <= 0.0) { + return vec3(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(0.04 * spec_k), base, metallic); + let fk = pow(clamp(1.0 - v_h, 0.0, 1.0), 5.0); + let f = f0 + (vec3(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(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 { + let c0 = vec4(-1.0, -0.0275, -0.572, 0.022); + let c1 = vec4(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(-1.04 * a004 + r.z, 1.04 * a004 + r.w); +} + +fn equirect_uv(d: vec3, rot: f32) -> vec2 { + 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(u - floor(u), v); +} + +fn shadow_factor(m: u32, p: vec3, 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(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(info.z); + let offset = bitcast(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, shadow: i32, darkness: f32, p: vec3, 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, to_l: vec3, 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, + @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(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((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; + 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(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; + 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(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(0.04 * spec_k), rgb, metallic); + let ab = env_brdf(n_v, rough); + let fs = f0 * ab.x + vec3(ab.y); + acc = acc + (irr * rgb * (vec3(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao; + } + let c = acc + emissive; + out.color = vec4(c * alpha, alpha); + return out; +} diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs new file mode 100644 index 00000000..067e4c8a --- /dev/null +++ b/crates/gpu/src/tests_adv3d.rs @@ -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 { + 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(>.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(>.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); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 16199dfb..d8906a1a 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -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 = 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() { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4ec70cfa..d557357b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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) -> Option; + /// 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 { + 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() } diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 25eb6090..7cb59135 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -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, [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 = 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 { diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs new file mode 100644 index 00000000..04f23885 --- /dev/null +++ b/crates/render/src/three_d/adv/mod.rs @@ -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) { + 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 = 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) { + 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; diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs new file mode 100644 index 00000000..36cec108 --- /dev/null +++ b/crates/render/src/three_d/adv/raster.rs @@ -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, +} + +/// 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) -> Vec { + 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> { + 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 { + 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 = (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 = 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> = 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; +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs new file mode 100644 index 00000000..1d3acc3c --- /dev/null +++ b/crates/render/src/three_d/adv/scene.rs @@ -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, + /// Triangles: the first `opaque_count` indices are opaque, the rest transparent, sorted + /// back to front. + pub indices: Vec, + pub opaque_count: u32, + pub materials: Vec, + pub textures: Vec, + pub texels: Vec<[f32; 4]>, + pub lights: Vec, + pub shadows: Vec, + pub shadow_texels: Vec, + pub env: Option, + 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) -> 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, 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::() / 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 = (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) { + 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, f64, Option, 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 = 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, 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> { + let mut out: Vec> = 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 { + 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, [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> = 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, [f32; 3], Option<(Option, [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, 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 +} diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs new file mode 100644 index 00000000..a8ab5e70 --- /dev/null +++ b/crates/render/src/three_d/adv/shade.rs @@ -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]) +} diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs new file mode 100644 index 00000000..23db2cbc --- /dev/null +++ b/crates/render/src/three_d/adv/tests.rs @@ -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 = 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::() / im.data.len() as f32; + im.data.iter().map(|p| (p[0] - m).powi(2)).sum::() / 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, ¶ms); + 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); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 1ef7412b..e6fc9209 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -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)); diff --git a/crates/render/src/three_d/light.rs b/crates/render/src/three_d/light.rs index 9476f98d..5a7963b4 100644 --- a/crates/render/src/three_d/light.rs +++ b/crates/render/src/three_d/light.rs @@ -117,7 +117,18 @@ impl LightState { /// All lights switched on and active at the context time. pub fn lights_at(ctx: &EvalCtx) -> Vec { - 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. diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index 8bef7360..e97c67fe 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -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;