mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 03:57:44 -06:00
Merge main into IO-2/SHP-5/OUT-2/PRV-2 work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ use effectcraft_raster::Image;
|
||||
const VERSION: &str = "v1";
|
||||
const MAGIC: &[u8; 4] = b"ECDC";
|
||||
/// Pending writes before new ones are dropped.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const QUEUE_LIMIT: usize = 64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -86,6 +87,7 @@ struct Index {
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
enum Job {
|
||||
Write(Kind, u128, Vec<u8>),
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ pub trait FootageSource: Send + Sync {
|
||||
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
/// Auxiliary 3D channels of `item` at source time `t` (multi-layer OpenEXR: depth, IDs,
|
||||
/// Cryptomatte, any named channel), with `scale` relative to the file's pixels. `None` when
|
||||
/// the footage has none.
|
||||
fn aux(&self, _item: ItemId, _footage: &Footage, _t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
None
|
||||
}
|
||||
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
|
||||
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
None
|
||||
}
|
||||
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
|
||||
/// a cache ignore it.
|
||||
fn set_cache_budget(&self, _bytes: usize) {}
|
||||
@@ -118,6 +128,29 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
}
|
||||
|
||||
fn aux(&self) -> Option<Arc<effectcraft_raster::AuxChannels>> {
|
||||
match &self.layer.source {
|
||||
LayerSource::Footage { item } => {
|
||||
let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None };
|
||||
let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?;
|
||||
// Aux pixels per layer pixel (the layer is the footage at its nominal size).
|
||||
if f.width > 0 && a.width > 0 {
|
||||
let mut a = (*a).clone();
|
||||
a.scale = a.width as f64 / f.width as f64;
|
||||
return Some(Arc::new(a));
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
LayerSource::Comp { item } => {
|
||||
if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
self.r.nested().comp_aux(*item, self.ctx.source_time(self.layer)).map(Arc::new)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
@@ -185,6 +218,12 @@ pub trait Accelerator: Send + Sync {
|
||||
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
|
||||
/// every effect when set (8/16 bpc). `None` = not handled.
|
||||
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf>;
|
||||
/// Rasterise and shade an Advanced 3D scene at its raster size (depth buffer, PBR, image
|
||||
/// based light, shadow maps), with the CPU rasteriser's semantics
|
||||
/// ([`three_d::adv::raster::render`]). `None` = not handled (the CPU renders it).
|
||||
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -543,8 +582,15 @@ impl<'a> Renderer<'a> {
|
||||
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
let mask_shapes = masks::shapes(ctx, layer);
|
||||
let host = FxHost { r: self, ctx, layer, index: Default::default() };
|
||||
let env =
|
||||
EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 };
|
||||
let env = EffectEnv {
|
||||
masks: &mask_shapes,
|
||||
host: Some(&host),
|
||||
comp_time: ctx.time.seconds(),
|
||||
frame_rate: ctx.comp.frame_rate.as_f64(),
|
||||
effect_index: 0,
|
||||
working_space: self.pipe.space,
|
||||
working_linear: self.pipe.linear,
|
||||
};
|
||||
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
|
||||
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
|
||||
.groups()
|
||||
@@ -1195,6 +1241,13 @@ impl<'a> Renderer<'a> {
|
||||
self.depth
|
||||
}
|
||||
|
||||
/// The auxiliary 3D channels of comp `comp_id` at comp time `t` (depth, layer IDs, normals,
|
||||
/// UVs, Cryptomatte; see `three_d::compose::aux_pass`), at the renderer's output scale.
|
||||
pub fn comp_aux(&self, comp_id: ItemId, t: Tick) -> Option<effectcraft_raster::AuxChannels> {
|
||||
let ctx = self.eval_ctx(comp_id, t)?;
|
||||
Some(three_d::compose::aux_pass(self, &ctx))
|
||||
}
|
||||
|
||||
/// Evaluation context of comp `comp_id` at comp time `t`.
|
||||
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
|
||||
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
|
||||
@@ -1208,7 +1261,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()
|
||||
}
|
||||
|
||||
@@ -1315,6 +1368,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
|
||||
#[cfg(test)]
|
||||
mod tests_audio_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_aux;
|
||||
#[cfg(test)]
|
||||
mod tests_collapse;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
|
||||
@@ -508,6 +508,27 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The filled outlines of a shape layer in layer space with their straight colours (gradient
|
||||
/// fills: the colour half way along), for Advanced 3D extrusion. Strokes are not extruded.
|
||||
pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
|
||||
let mut arena = Vec::new();
|
||||
let (draws, _) = collect(ctx, layer, contents, &mut arena);
|
||||
draws
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|d| d.opacity > 0.0)
|
||||
.filter_map(|d| {
|
||||
let c = match &d.paint {
|
||||
Paint::Fill { color, .. } => *color,
|
||||
Paint::Gradient { g, stroke: None, .. } => g.sample(0.5),
|
||||
_ => return None,
|
||||
};
|
||||
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
|
||||
(!paths.is_empty()).then_some((paths, [c[0], c[1], c[2], c[3] * d.opacity]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
|
||||
/// rasterising.
|
||||
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {
|
||||
|
||||
@@ -175,7 +175,17 @@ fn blocks_match_one_long_mix() {
|
||||
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
|
||||
(x, -x)
|
||||
}
|
||||
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
|
||||
for id in [
|
||||
"ec.audio.reverb",
|
||||
"ec.audio.basstreble",
|
||||
"ec.audio.flangechorus",
|
||||
"ec.audio.modulator",
|
||||
"ec.audio.delay",
|
||||
"ec.audio.parametriceq",
|
||||
"ec.audio.compressor",
|
||||
"ec.audio.gate",
|
||||
"ec.audio.distortion",
|
||||
] {
|
||||
let vals: Vec<(&str, Value)> = match id {
|
||||
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
|
||||
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Auxiliary 3D channels: the comp's depth / ID / Cryptomatte pass and the 3D Channel effects on
|
||||
//! a precomp of a 3D comp.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid};
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_hash};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
|
||||
|
||||
const W: u32 = 200;
|
||||
const H: u32 = 100;
|
||||
|
||||
fn zoom() -> f64 {
|
||||
W as f64 * 50.0 / 36.0
|
||||
}
|
||||
|
||||
fn solid3(p: &mut Project, comp: &Comp, name: &str, color: [f32; 3], pos: [f64; 3]) -> Layer {
|
||||
let sid = p.add_item(name, Label::Red, None, ItemKind::Solid(Solid { color, width: 60, height: 60, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, name, LayerSource::Solid { item: sid }, (60, 60), None);
|
||||
l.name = name.to_string();
|
||||
l.switches.three_d = true;
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
|
||||
l
|
||||
}
|
||||
|
||||
/// Inner 3D comp: "Near" (red, z 0, left) above "Far" (green, z 500, right). Outer comp: the
|
||||
/// precomp layer, optionally with an effect.
|
||||
fn scene(effect: Option<(&str, Vec<(&str, Value)>)>) -> (Project, ItemId, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
|
||||
let near = solid3(&mut p, &inner, "Near", [1.0, 0.0, 0.0], [60.0, 50.0, 0.0]);
|
||||
let far = solid3(&mut p, &inner, "Far", [0.0, 1.0, 0.0], [140.0, 50.0, 500.0]);
|
||||
p.comp_mut(iid).unwrap().layers.push(near);
|
||||
p.comp_mut(iid).unwrap().layers.push(far);
|
||||
let outer = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let oid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (W, H), None);
|
||||
if let Some((id, vals)) = effect {
|
||||
let spec = effectcraft_effects::find(id).unwrap();
|
||||
let mut next = p.next_id;
|
||||
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
|
||||
p.next_id = next;
|
||||
for (k, v) in vals {
|
||||
g.prop_mut(k).unwrap().value = v.clone();
|
||||
}
|
||||
pre.props.sub_mut("effects").unwrap().children.push(g.into());
|
||||
}
|
||||
p.comp_mut(oid).unwrap().layers.push(pre);
|
||||
(p, iid, oid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_aux_pass_has_depth_ids_and_cryptomatte() {
|
||||
let (p, iid, _) = scene(None);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
|
||||
assert_eq!((a.width, a.height), (W, H));
|
||||
let at = |name: &str, x: usize, y: usize| a.get(name).unwrap()[y * W as usize + x];
|
||||
// Near plane at the camera's zoom distance, the far one 500 px behind it.
|
||||
assert!((at("Z", 60, 50) as f64 - zoom()).abs() < 1e-2, "{}", at("Z", 60, 50));
|
||||
assert!((at("Z", 114, 50) as f64 - (zoom() + 500.0)).abs() < 1e-2, "{}", at("Z", 114, 50));
|
||||
assert_eq!(at("Z", 5, 5), BACKGROUND_DEPTH);
|
||||
assert_eq!((at("ObjectID", 60, 50), at("ObjectID", 114, 50), at("ObjectID", 5, 5)), (1.0, 2.0, 0.0));
|
||||
assert_eq!(at("Coverage", 60, 50), 1.0);
|
||||
// Planes face the camera.
|
||||
assert!((at("N.Z", 60, 50) + 1.0).abs() < 1e-5);
|
||||
// Cryptomatte rank 0 = the layer under the pixel, full coverage; manifest lists names.
|
||||
assert_eq!(at("CryptoObject00.R", 60, 50).to_bits(), crypto_hash("Near"));
|
||||
assert_eq!(at("CryptoObject00.G", 60, 50), 1.0);
|
||||
assert_eq!(a.manifest_name("CryptoObject", crypto_hash("Far")), Some("Far"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_channel_effects_work_on_a_precomp_of_a_3d_comp() {
|
||||
let near = (60, 50);
|
||||
let far = (114, 50);
|
||||
let alpha = |p: &Project, oid: ItemId, (x, y): (i64, i64)| render_frame(p, oid, Tick::ZERO, 1.0).get(x, y)[3];
|
||||
let (p, _, oid) = scene(Some(("ec.3d.idmatte", vec![("idSelection", Value::Scalar(2.0))])));
|
||||
assert_eq!(alpha(&p, oid, near), 0.0);
|
||||
assert!(alpha(&p, oid, far) > 0.99);
|
||||
let (p, _, oid) = scene(Some(("ec.3d.depthmatte", vec![("depth", Value::Scalar(zoom() + 250.0))])));
|
||||
assert_eq!(alpha(&p, oid, near), 0.0);
|
||||
assert!(alpha(&p, oid, far) > 0.99);
|
||||
let (p, _, oid) = scene(Some(("ec.3d.cryptomatte", vec![("selection", Value::Str("Near".into())), ("display", Value::Enum(1))])));
|
||||
assert!(alpha(&p, oid, near) > 0.99);
|
||||
assert_eq!(alpha(&p, oid, far), 0.0);
|
||||
// Half resolution: the pass follows the render scale.
|
||||
let (p, iid, _) = scene(None);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() });
|
||||
let a = r.comp_aux(iid, Tick::ZERO).unwrap();
|
||||
assert_eq!((a.width, a.height, a.scale), (W / 2, H / 2, 0.5));
|
||||
assert_eq!(a.get("ObjectID").unwrap()[25 * 100 + 30], 1.0);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The Advanced 3D renderer (Composition Settings ▸ 3D Renderer ▸ Advanced 3D).
|
||||
//!
|
||||
//! A run of 3D layers becomes one [`Scene`] of world-space triangles (models, primitives,
|
||||
//! extruded text and shapes, and textured cards for every other layer), lit physically
|
||||
//! ([`shade`]) by the comp's lights, an Environment light's image and shadow maps. The scene
|
||||
//! is rasterised with a depth buffer on the GPU when an [`crate::Accelerator`] implements
|
||||
//! [`crate::Accelerator::raster_3d`], otherwise by the software rasteriser ([`raster`]), at 2×2
|
||||
//! supersampling. The resolve (box filter), depth of field (a gather blur by each pixel's
|
||||
//! circle of confusion) and the conversion back to the working encoding run on the CPU for
|
||||
//! both paths, and the result is composited over the layers below the run.
|
||||
|
||||
pub mod raster;
|
||||
pub mod scene;
|
||||
pub mod shade;
|
||||
|
||||
use effectcraft_project::Layer;
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub use raster::Target;
|
||||
pub use scene::{EnvInfo, Light, Material, Scene, ShadowMap, TexInfo, Vertex};
|
||||
|
||||
use super::camera::Dof;
|
||||
use super::compose::camera_for;
|
||||
use crate::{EvalCtx, Image, Renderer};
|
||||
|
||||
/// Box-filter a supersampled target down by `k`: (premultiplied colour, mean depth of the
|
||||
/// covered subsamples, ∞ where none).
|
||||
pub fn resolve(t: &Target, k: u32) -> (Image, Vec<f32>) {
|
||||
let (w, h) = (t.width / k, t.height / k);
|
||||
let mut img = Image::new(w, h);
|
||||
let mut depth = vec![f32::INFINITY; (w * h) as usize];
|
||||
let n = (k * k) as f32;
|
||||
img.data.par_chunks_mut(w as usize).zip(depth.par_chunks_mut(w as usize)).enumerate().for_each(|(y, (row, drow))| {
|
||||
for x in 0..w as usize {
|
||||
let mut acc = [0.0f32; 4];
|
||||
let (mut dz, mut dn) = (0.0f32, 0.0f32);
|
||||
for j in 0..k as usize {
|
||||
for i in 0..k as usize {
|
||||
let s = (y * k as usize + j) * t.width as usize + x * k as usize + i;
|
||||
let c = t.color[s];
|
||||
for q in 0..4 {
|
||||
acc[q] += c[q];
|
||||
}
|
||||
if t.depth[s].is_finite() {
|
||||
dz += t.depth[s];
|
||||
dn += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
row[x] = acc.map(|v| v / n);
|
||||
if dn > 0.0 {
|
||||
drow[x] = dz / dn;
|
||||
}
|
||||
}
|
||||
});
|
||||
(img, depth)
|
||||
}
|
||||
|
||||
/// Depth of field: a gather blur where each pixel collects the neighbours whose circle of
|
||||
/// confusion (from their depth, in output pixels) reaches it; nearer in-focus surfaces are not
|
||||
/// smeared by the blur of what is behind them.
|
||||
pub fn depth_of_field(img: &Image, depth: &[f32], dof: &Dof, scale: f64) -> Image {
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
let max_r = 48.0f32;
|
||||
let radius: Vec<f32> = depth
|
||||
.iter()
|
||||
.map(|&z| {
|
||||
let z = if z.is_finite() { z as f64 } else { 1.0e9 };
|
||||
((dof.coc(z) * scale * 0.5) as f32).min(max_r)
|
||||
})
|
||||
.collect();
|
||||
let rmax = radius.iter().fold(0.0f32, |a, &b| a.max(b));
|
||||
if rmax < 0.5 {
|
||||
return img.clone();
|
||||
}
|
||||
// Disk samples (golden-angle spiral) over the largest radius.
|
||||
const N: usize = 64;
|
||||
let golden = std::f32::consts::PI * (3.0 - 5.0f32.sqrt());
|
||||
let taps: Vec<(f32, f32, f32)> = (1..=N)
|
||||
.map(|i| {
|
||||
let r = (i as f32 / N as f32).sqrt() * rmax;
|
||||
let a = i as f32 * golden;
|
||||
(r * a.cos(), r * a.sin(), r)
|
||||
})
|
||||
.collect();
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
out.data.par_chunks_mut(w as usize).enumerate().for_each(|(y, row)| {
|
||||
for x in 0..w as usize {
|
||||
let i = y * w as usize + x;
|
||||
let (rp, zp) = (radius[i], depth[i]);
|
||||
let cw = 1.0 / (std::f32::consts::PI * rp * rp).max(1.0);
|
||||
let c = img.data[i];
|
||||
let mut acc = [c[0] * cw, c[1] * cw, c[2] * cw, c[3] * cw];
|
||||
let mut wsum = cw;
|
||||
for &(dx, dy, d) in &taps {
|
||||
let (sx, sy) = (x as i64 + dx.round() as i64, y as i64 + dy.round() as i64);
|
||||
if sx < 0 || sy < 0 || sx >= w || sy >= h {
|
||||
continue;
|
||||
}
|
||||
let j = (sy * w + sx) as usize;
|
||||
// A sample behind this pixel blurs no further than this pixel's own circle.
|
||||
let rq = if depth[j] > zp { radius[j].min(rp) } else { radius[j] };
|
||||
let cov = (rq - d + 0.5).clamp(0.0, 1.0);
|
||||
if cov <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Each tap stands for an annulus of area ≈ π·rmax²/N.
|
||||
let wq = cov / (std::f32::consts::PI * rq * rq).max(1.0) * (std::f32::consts::PI * rmax * rmax / N as f32);
|
||||
let q = img.data[j];
|
||||
for k in 0..4 {
|
||||
acc[k] += q[k] * wq;
|
||||
}
|
||||
wsum += wq;
|
||||
}
|
||||
row[x] = acc.map(|v| v / wsum);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Linear → sRGB-encoded premultiplied pixels (the working encoding of non-linear projects).
|
||||
pub fn encode(img: &mut Image) {
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = p[3];
|
||||
if a > 1e-6 {
|
||||
for c in 0..3 {
|
||||
p[c] = shade::linear_to_srgb(p[c] / a) * a;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a scene on the CPU, resolved: (premultiplied image at output size, depth).
|
||||
pub fn render_cpu(s: &Scene) -> (Image, Vec<f32>) {
|
||||
resolve(&raster::render(s), s.ssaa.max(1))
|
||||
}
|
||||
|
||||
/// Build the scene of a run (for tests and tools).
|
||||
pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
scene::build(r, ctx, run, out)
|
||||
}
|
||||
|
||||
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
|
||||
if s.indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
|
||||
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
|
||||
let cam = camera_for(r, ctx);
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
|
||||
}
|
||||
if !s.linear_io {
|
||||
encode(&mut img);
|
||||
}
|
||||
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
|
||||
let k = 1.0 - s[3];
|
||||
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,492 @@
|
||||
//! The software rasteriser of the Advanced 3D renderer (the CPU reference and the fallback
|
||||
//! when no GPU is available) and the shadow-map rasteriser shared by both paths.
|
||||
//!
|
||||
//! Triangles are clipped against the depth range (`0 ≤ z ≤ w`, as GPUs do), set up in screen
|
||||
//! space and scanned per band of rows in parallel with edge functions (pixel centres, top-left
|
||||
//! fill rule) and perspective-correct barycentrics. Opaque triangles resolve visibility first
|
||||
//! (reversed depth: larger ndc z is nearer) and each pixel is shaded once; transparent triangles
|
||||
//! (already sorted back to front) are then shaded per fragment and blended over, depth-tested
|
||||
//! against the opaque surfaces.
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use super::scene::{Scene, ShadowMap, Vertex};
|
||||
use super::shade::{Frag, sample, shade};
|
||||
|
||||
/// A rendered target at raster size.
|
||||
pub struct Target {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Premultiplied linear RGBA.
|
||||
pub color: Vec<[f32; 4]>,
|
||||
/// Camera-space depth of the nearest opaque surface (∞ where empty).
|
||||
pub depth: Vec<f32>,
|
||||
}
|
||||
|
||||
/// A clipped triangle in screen space; `w` holds, per corner, its weights over the original
|
||||
/// triangle's three vertices.
|
||||
#[derive(Clone, Copy)]
|
||||
struct STri {
|
||||
p: [[f64; 2]; 3],
|
||||
z: [f32; 3],
|
||||
inv_w: [f32; 3],
|
||||
w: [[f32; 3]; 3],
|
||||
tri: u32,
|
||||
front: bool,
|
||||
bbox: [i32; 4],
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn mat_mul(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 4] {
|
||||
let mut o = [0.0; 4];
|
||||
for r in 0..4 {
|
||||
o[r] = m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3];
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Clip a polygon (clip coords + original weights) against `0 ≤ z ≤ w`.
|
||||
fn clip_poly(poly: Vec<([f32; 4], [f32; 3])>) -> Vec<([f32; 4], [f32; 3])> {
|
||||
let mut p = poly;
|
||||
for plane in 0..2 {
|
||||
if p.is_empty() {
|
||||
break;
|
||||
}
|
||||
let d = |c: &[f32; 4]| if plane == 0 { c[2] } else { c[3] - c[2] };
|
||||
let mut out = Vec::with_capacity(p.len() + 2);
|
||||
for i in 0..p.len() {
|
||||
let (a, b) = (p[i], p[(i + 1) % p.len()]);
|
||||
let (da, db) = (d(&a.0), d(&b.0));
|
||||
if da >= 0.0 {
|
||||
out.push(a);
|
||||
}
|
||||
if (da >= 0.0) != (db >= 0.0) {
|
||||
let t = da / (da - db);
|
||||
let mut c = [0.0; 4];
|
||||
let mut w = [0.0; 3];
|
||||
for k in 0..4 {
|
||||
c[k] = a.0[k] + (b.0[k] - a.0[k]) * t;
|
||||
}
|
||||
for k in 0..3 {
|
||||
w[k] = a.1[k] + (b.1[k] - a.1[k]) * t;
|
||||
}
|
||||
out.push((c, w));
|
||||
}
|
||||
}
|
||||
p = out;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Set up the screen triangles of index range `range`.
|
||||
fn setup(s: &Scene, clip: &[[f32; 4]], range: std::ops::Range<usize>) -> Vec<STri> {
|
||||
let (w, h) = (s.width as f64, s.height as f64);
|
||||
let mut out = vec![];
|
||||
for t in range.step_by(3) {
|
||||
let ids = [s.indices[t] as usize, s.indices[t + 1] as usize, s.indices[t + 2] as usize];
|
||||
let mat = s.vertices[ids[0]].material as usize;
|
||||
if !s.materials.get(mat).is_some_and(|m| m.visible) {
|
||||
continue;
|
||||
}
|
||||
let poly = vec![(clip[ids[0]], [1.0, 0.0, 0.0]), (clip[ids[1]], [0.0, 1.0, 0.0]), (clip[ids[2]], [0.0, 0.0, 1.0])];
|
||||
let poly = clip_poly(poly);
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let scr: Vec<([f64; 2], f32, f32, [f32; 3])> = poly
|
||||
.iter()
|
||||
.map(|(c, wt)| {
|
||||
let iw = 1.0 / c[3];
|
||||
let (nx, ny) = (c[0] * iw, c[1] * iw);
|
||||
([(nx as f64 + 1.0) * 0.5 * w, (1.0 - ny as f64) * 0.5 * h], c[2] * iw, iw, *wt)
|
||||
})
|
||||
.collect();
|
||||
// Facing from the polygon's screen-space winding (y down: negative = front).
|
||||
let mut area = 0.0;
|
||||
for i in 0..scr.len() {
|
||||
let (a, b) = (scr[i].0, scr[(i + 1) % scr.len()].0);
|
||||
area += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
if area == 0.0 || !area.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let front = area < 0.0;
|
||||
for k in 1..scr.len() - 1 {
|
||||
let c = [scr[0], scr[k], scr[k + 1]];
|
||||
let mut x0 = f64::INFINITY;
|
||||
let mut y0 = f64::INFINITY;
|
||||
let mut x1 = f64::NEG_INFINITY;
|
||||
let mut y1 = f64::NEG_INFINITY;
|
||||
for v in &c {
|
||||
x0 = x0.min(v.0[0]);
|
||||
y0 = y0.min(v.0[1]);
|
||||
x1 = x1.max(v.0[0]);
|
||||
y1 = y1.max(v.0[1]);
|
||||
}
|
||||
let bbox = [
|
||||
(x0.floor() as i64).max(0) as i32,
|
||||
(y0.floor() as i64).max(0) as i32,
|
||||
(x1.ceil() as i64).min(s.width as i64) as i32,
|
||||
(y1.ceil() as i64).min(s.height as i64) as i32,
|
||||
];
|
||||
if bbox[0] >= bbox[2] || bbox[1] >= bbox[3] {
|
||||
continue;
|
||||
}
|
||||
out.push(STri {
|
||||
p: [c[0].0, c[1].0, c[2].0],
|
||||
z: [c[0].1, c[1].1, c[2].1],
|
||||
inv_w: [c[0].2, c[1].2, c[2].2],
|
||||
w: [c[0].3, c[1].3, c[2].3],
|
||||
tri: (t / 3) as u32,
|
||||
front,
|
||||
bbox,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Coverage of a pixel centre: (ndc z, perspective-correct weights over the screen corners).
|
||||
#[inline]
|
||||
fn cover(t: &STri, px: f64, py: f64) -> Option<(f32, [f32; 3])> {
|
||||
let [a, b, c] = t.p;
|
||||
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
// Orient so the area is positive.
|
||||
let (b, c, swap) = if area < 0.0 { (c, b, true) } else { (b, c, false) };
|
||||
let area = area.abs();
|
||||
let edge = |p: [f64; 2], q: [f64; 2]| -> f64 { (q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) };
|
||||
let top_left = |p: [f64; 2], q: [f64; 2]| -> bool {
|
||||
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
|
||||
dy < 0.0 || (dy == 0.0 && dx > 0.0)
|
||||
};
|
||||
let e0 = edge(b, c); // opposite a
|
||||
let e1 = edge(c, a); // opposite b
|
||||
let e2 = edge(a, b); // opposite c
|
||||
let inside = |e: f64, p: [f64; 2], q: [f64; 2]| e > 0.0 || (e == 0.0 && top_left(p, q));
|
||||
if !(inside(e0, b, c) && inside(e1, c, a) && inside(e2, a, b)) {
|
||||
return None;
|
||||
}
|
||||
let (l0, l1, l2) = ((e0 / area) as f32, (e1 / area) as f32, (e2 / area) as f32);
|
||||
// Back to the stored corner order.
|
||||
let l = if swap { [l0, l2, l1] } else { [l0, l1, l2] };
|
||||
let z = l[0] * t.z[0] + l[1] * t.z[1] + l[2] * t.z[2];
|
||||
let q = [l[0] * t.inv_w[0], l[1] * t.inv_w[1], l[2] * t.inv_w[2]];
|
||||
let sum = q[0] + q[1] + q[2];
|
||||
if sum <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((z, [q[0] / sum, q[1] / sum, q[2] / sum]))
|
||||
}
|
||||
|
||||
/// Interpolate the original triangle's vertex attributes at screen-corner weights `l`.
|
||||
fn frag(s: &Scene, t: &STri, l: [f32; 3]) -> Frag {
|
||||
let base = t.tri as usize * 3;
|
||||
let v: [&Vertex; 3] = [0, 1, 2].map(|k| &s.vertices[s.indices[base + k] as usize]);
|
||||
// Weights over the original vertices.
|
||||
let mut wv = [0.0f32; 3];
|
||||
for c in 0..3 {
|
||||
for k in 0..3 {
|
||||
wv[k] += l[c] * t.w[c][k];
|
||||
}
|
||||
}
|
||||
let mut f = Frag { pos: [0.0; 3], normal: [0.0; 3], uv: [0.0; 2], tangent: [0.0; 4], material: v[0].material, front: t.front };
|
||||
for k in 0..3 {
|
||||
let x = wv[k];
|
||||
for i in 0..3 {
|
||||
f.pos[i] += v[k].pos[i] * x;
|
||||
f.normal[i] += v[k].normal[i] * x;
|
||||
}
|
||||
for i in 0..2 {
|
||||
f.uv[i] += v[k].uv[i] * x;
|
||||
}
|
||||
for i in 0..4 {
|
||||
f.tangent[i] += v[k].tangent[i] * x;
|
||||
}
|
||||
}
|
||||
f.tangent[3] = v[0].tangent[3];
|
||||
f
|
||||
}
|
||||
|
||||
const BAND: i32 = 16;
|
||||
|
||||
/// Rasterise and shade the scene.
|
||||
pub fn render(s: &Scene) -> Target {
|
||||
let (w, h) = (s.width as usize, s.height as usize);
|
||||
let clip: Vec<[f32; 4]> = s.vertices.iter().map(|v| mat_mul(&s.clip, v.pos)).collect();
|
||||
let opaque = setup(s, &clip, 0..s.opaque_count as usize);
|
||||
let trans = setup(s, &clip, s.opaque_count as usize..s.indices.len());
|
||||
let bands = (h as i32 + BAND - 1) / BAND;
|
||||
let bin = |ts: &[STri]| -> Vec<Vec<u32>> {
|
||||
let mut b = vec![vec![]; bands.max(0) as usize];
|
||||
for (i, t) in ts.iter().enumerate() {
|
||||
for band in (t.bbox[1] / BAND)..((t.bbox[3] + BAND - 1) / BAND).min(bands) {
|
||||
b[band as usize].push(i as u32);
|
||||
}
|
||||
}
|
||||
b
|
||||
};
|
||||
let ob = bin(&opaque);
|
||||
let tb = bin(&trans);
|
||||
let view = s.view;
|
||||
let cam_z = |p: [f32; 3]| view[2][0] * p[0] + view[2][1] * p[1] + view[2][2] * p[2] + view[2][3];
|
||||
let mut color = vec![[0.0f32; 4]; w * h];
|
||||
let mut depth = vec![f32::INFINITY; w * h];
|
||||
color.par_chunks_mut(w * BAND as usize).zip(depth.par_chunks_mut(w * BAND as usize)).enumerate().for_each(|(band, (crow, drow))| {
|
||||
let y0 = band as i32 * BAND;
|
||||
let rows = crow.len() / w;
|
||||
// Visibility: (screen tri, ndc z, weights).
|
||||
let mut vis: Vec<(u32, f32, [f32; 3])> = vec![(u32::MAX, 0.0, [0.0; 3]); rows * w];
|
||||
for &ti in &ob[band] {
|
||||
let t = &opaque[ti as usize];
|
||||
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
|
||||
let r = (y - y0) as usize * w;
|
||||
for x in t.bbox[0]..t.bbox[2] {
|
||||
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5) {
|
||||
let v = &mut vis[r + x as usize];
|
||||
if z > v.1 && z <= 1.0 {
|
||||
*v = (ti, z, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A discarded opaque fragment (alpha test, back face) lets the next nearer-than-nothing
|
||||
// surface through only on a second look; keep it simple: resolve again without it.
|
||||
for i in 0..rows * w {
|
||||
let (ti, _, l) = vis[i];
|
||||
if ti == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let t = &opaque[ti as usize];
|
||||
let f = frag(s, t, l);
|
||||
match shade(s, &f) {
|
||||
Some(c) => {
|
||||
crow[i] = c;
|
||||
drow[i] = cam_z(f.pos);
|
||||
}
|
||||
None => {
|
||||
// Find the nearest surviving fragment under this pixel.
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (y0 as usize + i / w) as f64 + 0.5);
|
||||
let mut best: Option<(f32, [f32; 4], f32)> = None;
|
||||
for &tj in &ob[band] {
|
||||
if tj == ti {
|
||||
continue;
|
||||
}
|
||||
let t2 = &opaque[tj as usize];
|
||||
if (x as i32) < t2.bbox[0] || (x as i32) >= t2.bbox[2] || (y as i32) < t2.bbox[1] || (y as i32) >= t2.bbox[3] {
|
||||
continue;
|
||||
}
|
||||
if let Some((z, l2)) = cover(t2, x, y)
|
||||
&& best.is_none_or(|b| z > b.0)
|
||||
{
|
||||
let f2 = frag(s, t2, l2);
|
||||
if let Some(c) = shade(s, &f2) {
|
||||
best = Some((z, c, cam_z(f2.pos)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((z, c, d)) = best {
|
||||
crow[i] = c;
|
||||
drow[i] = d;
|
||||
vis[i].1 = z;
|
||||
} else {
|
||||
vis[i].1 = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Transparent fragments in order, over the opaque result.
|
||||
for &ti in &tb[band] {
|
||||
let t = &trans[ti as usize];
|
||||
for y in t.bbox[1].max(y0)..t.bbox[3].min(y0 + rows as i32) {
|
||||
let r = (y - y0) as usize * w;
|
||||
for x in t.bbox[0]..t.bbox[2] {
|
||||
let i = r + x as usize;
|
||||
if let Some((z, l)) = cover(t, x as f64 + 0.5, y as f64 + 0.5)
|
||||
&& z > vis[i].1
|
||||
&& z <= 1.0
|
||||
&& let Some(c) = shade(s, &frag(s, t, l))
|
||||
{
|
||||
let d = crow[i];
|
||||
let k = 1.0 - c[3];
|
||||
crow[i] = [c[0] + d[0] * k, c[1] + d[1] * k, c[2] + d[2] * k, c[3] + d[3] * k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Target { width: s.width, height: s.height, color, depth }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shadow maps
|
||||
|
||||
fn basis(eye: [f64; 3], fwd: [f64; 3], down: [f64; 3]) -> [[f32; 4]; 3] {
|
||||
use effectcraft_geom::vec3;
|
||||
let m = crate::three_d::camera::basis_view(vec3(eye[0], eye[1], eye[2]), vec3(fwd[0], fwd[1], fwd[2]), vec3(down[0], down[1], down[2]));
|
||||
[0, 1, 2].map(|r| m.0[r].map(|v| v as f32))
|
||||
}
|
||||
|
||||
/// Rasterise the shadow-casting triangles' light-space depth into `m`'s texels.
|
||||
fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec<f32> {
|
||||
let n = m.size as usize;
|
||||
let mut d = vec![f32::INFINITY; n * n];
|
||||
let c = n as f32 * 0.5;
|
||||
const NEAR: f32 = 1.0;
|
||||
for &t in casters {
|
||||
let ids = [s.indices[3 * t] as usize, s.indices[3 * t + 1] as usize, s.indices[3 * t + 2] as usize];
|
||||
let mat = &s.materials[s.vertices[ids[0]].material as usize];
|
||||
let alpha_tex = (mat.alpha_mode != 0 && mat.tex_base >= 0).then(|| &s.textures[mat.tex_base as usize]);
|
||||
// Light space, clipped against the near plane (perspective).
|
||||
let q: Vec<([f32; 3], [f32; 2])> = ids
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let p = s.vertices[i].pos;
|
||||
let v = &m.view;
|
||||
([0, 1, 2].map(|r| v[r][0] * p[0] + v[r][1] * p[1] + v[r][2] * p[2] + v[r][3]), s.vertices[i].uv)
|
||||
})
|
||||
.collect();
|
||||
let mut poly = q;
|
||||
if !m.ortho {
|
||||
let mut out = vec![];
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||||
let (da, db) = (a.0[2] - NEAR, b.0[2] - NEAR);
|
||||
if da >= 0.0 {
|
||||
out.push(a);
|
||||
}
|
||||
if (da >= 0.0) != (db >= 0.0) {
|
||||
let k = da / (da - db);
|
||||
let lerp = |x: f32, y: f32| x + (y - x) * k;
|
||||
out.push(([lerp(a.0[0], b.0[0]), lerp(a.0[1], b.0[1]), lerp(a.0[2], b.0[2])], [lerp(a.1[0], b.1[0]), lerp(a.1[1], b.1[1])]));
|
||||
}
|
||||
}
|
||||
poly = out;
|
||||
}
|
||||
if poly.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
// Screen: (x, y, 1/z or 1, depth) per corner.
|
||||
let scr: Vec<([f64; 2], f32, f32, [f32; 2])> = poly
|
||||
.iter()
|
||||
.map(|(p, uv)| {
|
||||
if m.ortho {
|
||||
([(p[0] * m.focal + c) as f64, (p[1] * m.focal + c) as f64], 1.0, p[2], *uv)
|
||||
} else {
|
||||
let iz = 1.0 / p[2];
|
||||
([(p[0] * m.focal * iz + c) as f64, (p[1] * m.focal * iz + c) as f64], iz, p[2], *uv)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for k in 1..scr.len() - 1 {
|
||||
let tri = [scr[0], scr[k], scr[k + 1]];
|
||||
let (a, b, cc) = (tri[0].0, tri[1].0, tri[2].0);
|
||||
let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]);
|
||||
if area.abs() < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let x0 = a[0].min(b[0]).min(cc[0]).floor().max(0.0) as usize;
|
||||
let x1 = (a[0].max(b[0]).max(cc[0]).ceil().max(0.0) as usize).min(n);
|
||||
let y0 = a[1].min(b[1]).min(cc[1]).floor().max(0.0) as usize;
|
||||
let y1 = (a[1].max(b[1]).max(cc[1]).ceil().max(0.0) as usize).min(n);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let e0 = ((cc[0] - b[0]) * (py - b[1]) - (cc[1] - b[1]) * (px - b[0])) / area;
|
||||
let e1 = ((a[0] - cc[0]) * (py - cc[1]) - (a[1] - cc[1]) * (px - cc[0])) / area;
|
||||
let e2 = 1.0 - e0 - e1;
|
||||
if e0 < 0.0 || e1 < 0.0 || e2 < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let l = [e0 as f32, e1 as f32, e2 as f32];
|
||||
let (z, uv) = if m.ortho {
|
||||
(l[0] * tri[0].2 + l[1] * tri[1].2 + l[2] * tri[2].2, [0, 1].map(|i| l[0] * tri[0].3[i] + l[1] * tri[1].3[i] + l[2] * tri[2].3[i]))
|
||||
} else {
|
||||
let iz = l[0] * tri[0].1 + l[1] * tri[1].1 + l[2] * tri[2].1;
|
||||
let z = 1.0 / iz;
|
||||
(z, [0, 1].map(|i| (l[0] * tri[0].3[i] * tri[0].1 + l[1] * tri[1].3[i] * tri[1].1 + l[2] * tri[2].3[i] * tri[2].1) * z))
|
||||
};
|
||||
let px = &mut d[y * n + x];
|
||||
if z >= *px {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = alpha_tex
|
||||
&& sample(t, &s.texels, uv[0], uv[1])[3] * mat.base[3] * mat.opacity < 0.5
|
||||
{
|
||||
continue;
|
||||
}
|
||||
*px = z;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).
|
||||
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
|
||||
let casters: Vec<usize> = (0..s.indices.len() / 3)
|
||||
.filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))
|
||||
.collect();
|
||||
if casters.is_empty() {
|
||||
for l in &mut s.lights {
|
||||
l.shadow = -1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Bounding sphere of the scene (parallel lights' orthographic frustum).
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for v in &s.vertices {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(v.pos[k] as f64);
|
||||
hi[k] = hi[k].max(v.pos[k] as f64);
|
||||
}
|
||||
}
|
||||
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5);
|
||||
let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0;
|
||||
let size = if draft { 512 } else { 1024 };
|
||||
let mut maps: Vec<ShadowMap> = vec![];
|
||||
for li in 0..s.lights.len() {
|
||||
if s.lights[li].shadow != 0 {
|
||||
s.lights[li].shadow = -1;
|
||||
continue;
|
||||
}
|
||||
let l = s.lights[li];
|
||||
let first = maps.len() as i32;
|
||||
let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0);
|
||||
let pos = l.pos.map(|v| v as f64);
|
||||
let dir = l.dir.map(|v| v as f64);
|
||||
let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
|
||||
match l.kind {
|
||||
0 => {
|
||||
let eye = [centre[0] - dir[0] * radius * 2.0, centre[1] - dir[1] * radius * 2.0, centre[2] - dir[2] * radius * 2.0];
|
||||
let focal = (size as f64 * 0.5 / radius) as f32;
|
||||
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, 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.5, 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.5, radius: soft });
|
||||
}
|
||||
}
|
||||
}
|
||||
s.lights[li].shadow = first;
|
||||
}
|
||||
let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();
|
||||
for (m, t) in maps.iter_mut().zip(texels) {
|
||||
m.offset = s.shadow_texels.len() as u32;
|
||||
s.shadow_texels.extend(t);
|
||||
}
|
||||
s.shadows = maps;
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
//! 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 in texels (scaled by the texel footprint at the receiver).
|
||||
pub bias: f32,
|
||||
/// PCF spread (texels).
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
/// The environment image: radiance mip levels `radiance..radiance + mips` and an irradiance
|
||||
/// map, in [`Scene::textures`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct EnvInfo {
|
||||
pub radiance: u32,
|
||||
pub mips: u32,
|
||||
pub irradiance: u32,
|
||||
pub intensity: f32,
|
||||
/// Radians about the vertical axis.
|
||||
pub rotation: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Scene {
|
||||
/// Raster size (output × `ssaa`).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub ssaa: u32,
|
||||
/// World → clip (row-major; reversed depth: ndc z = near / camera z).
|
||||
pub clip: [[f32; 4]; 4],
|
||||
/// World → camera (row-major; camera z = depth for depth of field).
|
||||
pub view: [[f32; 4]; 4],
|
||||
pub eye: [f32; 3],
|
||||
pub cam_fwd: [f32; 3],
|
||||
pub ortho: bool,
|
||||
pub vertices: Vec<Vertex>,
|
||||
/// Triangles: the first `opaque_count` indices are opaque, the rest transparent, sorted
|
||||
/// back to front.
|
||||
pub indices: Vec<u32>,
|
||||
pub opaque_count: u32,
|
||||
pub materials: Vec<Material>,
|
||||
pub textures: Vec<TexInfo>,
|
||||
pub texels: Vec<[f32; 4]>,
|
||||
pub lights: Vec<Light>,
|
||||
pub shadows: Vec<ShadowMap>,
|
||||
pub shadow_texels: Vec<f32>,
|
||||
pub env: Option<EnvInfo>,
|
||||
pub ambient: [f32; 3],
|
||||
/// Shading inputs and outputs are linear (no sRGB conversion needed).
|
||||
pub linear_io: bool,
|
||||
}
|
||||
|
||||
/// Row-major f32 copy of a matrix.
|
||||
pub fn m32(m: &Mat4) -> [[f32; 4]; 4] {
|
||||
m.0.map(|r| r.map(|v| v as f32))
|
||||
}
|
||||
|
||||
/// Builds a [`Scene`]: texture uploads deduplicated by source.
|
||||
pub(crate) struct Builder {
|
||||
pub s: Scene,
|
||||
tex_keys: HashMap<(usize, bool), i32>,
|
||||
/// Per triangle: transparent and its sort depth.
|
||||
tri_depth: Vec<(bool, f32)>,
|
||||
cam: CameraState,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
fn texture_from(&mut self, w: u32, h: u32, wrap: [u32; 2], data: impl Iterator<Item = [f32; 4]>) -> i32 {
|
||||
let offset = self.s.texels.len() as u32;
|
||||
self.s.texels.extend(data);
|
||||
self.s.textures.push(TexInfo { offset, width: w, height: h, wrap_u: wrap[0], wrap_v: wrap[1] });
|
||||
(self.s.textures.len() - 1) as i32
|
||||
}
|
||||
|
||||
/// Upload a model texture (`srgb`: colour data to linearise).
|
||||
fn model_texture(&mut self, t: &Arc<model::Texture>, srgb: bool) -> i32 {
|
||||
let key = (Arc::as_ptr(t) as usize, srgb);
|
||||
if let Some(&i) = self.tex_keys.get(&key) {
|
||||
return i;
|
||||
}
|
||||
let lin = srgb && !self.s.linear_io;
|
||||
let wrap = t.wrap.map(|w| match w {
|
||||
model::Wrap::Repeat => 0,
|
||||
model::Wrap::Clamp => 1,
|
||||
model::Wrap::Mirror => 2,
|
||||
});
|
||||
let i = self.texture_from(
|
||||
t.width,
|
||||
t.height,
|
||||
wrap,
|
||||
t.data.iter().map(|p| if lin { [srgb_to_linear(p[0]), srgb_to_linear(p[1]), srgb_to_linear(p[2]), p[3]] } else { *p }),
|
||||
);
|
||||
self.tex_keys.insert(key, i);
|
||||
i
|
||||
}
|
||||
|
||||
/// Upload a premultiplied layer buffer as a linear straight-alpha texture (clamped edges).
|
||||
fn buf_texture(&mut self, b: &Buf) -> (i32, bool) {
|
||||
let lin = !self.s.linear_io;
|
||||
let mut opaque = true;
|
||||
let data: Vec<[f32; 4]> = b
|
||||
.img
|
||||
.data
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = p[3];
|
||||
if a < 0.999 {
|
||||
opaque = false;
|
||||
}
|
||||
if a <= 1e-6 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let s = [p[0] / a, p[1] / a, p[2] / a];
|
||||
if lin { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), a] } else { [s[0], s[1], s[2], a] }
|
||||
})
|
||||
.collect();
|
||||
(self.texture_from(b.img.width, b.img.height, [1, 1], data.into_iter()), opaque)
|
||||
}
|
||||
|
||||
fn material(&mut self, m: Material) -> u32 {
|
||||
self.s.materials.push(m);
|
||||
(self.s.materials.len() - 1) as u32
|
||||
}
|
||||
|
||||
/// Append a primitive with per-vertex matrices (model/layer space → world).
|
||||
fn emit(&mut self, p: &Primitive, mat: u32, xf: &dyn Fn(usize) -> Mat4) {
|
||||
if p.positions.is_empty() || p.indices.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let base = self.s.vertices.len() as u32;
|
||||
let transparent = self.s.materials[mat as usize].transparent();
|
||||
let uniform = xf(usize::MAX);
|
||||
let normal_of = |m: &Mat4| -> (Mat4, f64) {
|
||||
let a = &m.0;
|
||||
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
|
||||
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
|
||||
let inv = Mat4([[a[0][0], a[0][1], a[0][2], 0.0], [a[1][0], a[1][1], a[1][2], 0.0], [a[2][0], a[2][1], a[2][2], 0.0], [0.0, 0.0, 0.0, 1.0]])
|
||||
.inverse()
|
||||
.unwrap_or(Mat4::IDENTITY);
|
||||
(inv.transpose(), det)
|
||||
};
|
||||
let (un, udet) = normal_of(&uniform);
|
||||
let mut flip_count = 0usize;
|
||||
for k in 0..p.positions.len() {
|
||||
let (m, n, det) = if p.joints.is_empty() {
|
||||
(uniform, un, udet)
|
||||
} else {
|
||||
let m = xf(k);
|
||||
let (n, d) = normal_of(&m);
|
||||
(m, n, d)
|
||||
};
|
||||
if det < 0.0 {
|
||||
flip_count += 1;
|
||||
}
|
||||
let q = p.positions[k];
|
||||
let w = m.apply(vec3(q[0] as f64, q[1] as f64, q[2] as f64));
|
||||
let nn = p.normals.get(k).copied().unwrap_or([0.0, 0.0, 1.0]);
|
||||
let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize();
|
||||
let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]);
|
||||
let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize();
|
||||
self.s.vertices.push(Vertex {
|
||||
pos: [w.x as f32, w.y as f32, w.z as f32],
|
||||
normal: [wn.x as f32, wn.y as f32, wn.z as f32],
|
||||
uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]),
|
||||
tangent: [wt.x as f32, wt.y as f32, wt.z as f32, if det < 0.0 { -t[3] } else { t[3] }],
|
||||
material: mat,
|
||||
});
|
||||
}
|
||||
// Mirroring transforms reverse the winding.
|
||||
let flip = flip_count * 2 > p.positions.len();
|
||||
for t in p.indices.chunks_exact(3) {
|
||||
let (a, b, c) = (t[0] + base, t[1] + base, t[2] + base);
|
||||
if flip {
|
||||
self.s.indices.extend([a, c, b])
|
||||
} else {
|
||||
self.s.indices.extend([a, b, c])
|
||||
}
|
||||
let z = [a, b, c].iter().map(|&i| self.cam.depth(Vec3::from(self.s.vertices[i as usize].pos.map(|v| v as f64)))).sum::<f64>() / 3.0;
|
||||
self.tri_depth.push((transparent, z as f32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque triangles first, then transparent ones back to front.
|
||||
fn sort_triangles(&mut self) {
|
||||
let tris: Vec<[u32; 3]> = self.s.indices.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect();
|
||||
let mut order: Vec<usize> = (0..tris.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
let (ta, za) = self.tri_depth[a];
|
||||
let (tb, zb) = self.tri_depth[b];
|
||||
ta.cmp(&tb).then_with(|| if ta { zb.total_cmp(&za) } else { std::cmp::Ordering::Equal })
|
||||
});
|
||||
self.s.opaque_count = 3 * self.tri_depth.iter().filter(|t| !t.0).count() as u32;
|
||||
self.s.indices = order.into_iter().flat_map(|i| tris[i]).collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// The Material Options of a card (classic properties).
|
||||
fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material {
|
||||
let c = ClassicMaterial::of(ctx, layer);
|
||||
Material {
|
||||
roughness: (1.0 - c.shininess).clamp(0.05, 1.0),
|
||||
double_sided: true,
|
||||
accepts_lights: c.accepts_lights,
|
||||
receives_shadows: c.accepts_shadows != 0,
|
||||
casts_shadows: c.casts_shadows != 0,
|
||||
visible: c.casts_shadows != 2,
|
||||
diffuse_k: c.diffuse,
|
||||
specular_k: c.specular * 2.0,
|
||||
ambient_k: c.ambient,
|
||||
..Material::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shadow/light switches and the primitive material of a model or primitive layer.
|
||||
fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option<model::Material>) {
|
||||
let g = layer.props.sub("materialOptions");
|
||||
let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum());
|
||||
let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool());
|
||||
let casts = e("castsShadows", 0);
|
||||
let opts = Material {
|
||||
accepts_lights: b("acceptsLights"),
|
||||
receives_shadows: e("acceptsShadows", 1) != 0,
|
||||
casts_shadows: casts != 0,
|
||||
visible: casts != 2,
|
||||
..Material::default()
|
||||
};
|
||||
let prim = match (layer.source.clone(), g) {
|
||||
(LayerSource::Primitive { .. }, Some(g)) => {
|
||||
let c = ctx.color(layer, g, "baseColor");
|
||||
let em = ctx.color(layer, g, "emissive");
|
||||
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
|
||||
let mut m = model::Material::plain(
|
||||
[l(c[0]), l(c[1]), l(c[2]), 1.0],
|
||||
(ctx.f(layer, g, "metallic", 0.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
(ctx.f(layer, g, "roughness", 50.0) / 100.0).clamp(0.0, 1.0) as f32,
|
||||
);
|
||||
m.emissive = [l(em[0]), l(em[1]), l(em[2])];
|
||||
Some(m)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
(opts, prim)
|
||||
}
|
||||
|
||||
fn prim_kind(k: PrimitiveKind) -> model::prim::PrimitiveKind {
|
||||
use model::prim::PrimitiveKind as M;
|
||||
match k {
|
||||
PrimitiveKind::Cube => M::Cube,
|
||||
PrimitiveKind::Sphere => M::Sphere,
|
||||
PrimitiveKind::Plane => M::Plane,
|
||||
PrimitiveKind::Torus => M::Torus,
|
||||
PrimitiveKind::Cone => M::Cone,
|
||||
PrimitiveKind::Cylinder => M::Cylinder,
|
||||
}
|
||||
}
|
||||
|
||||
/// The primitive model of a primitive layer at the context time.
|
||||
pub fn primitive_model(ctx: &EvalCtx, layer: &Layer, kind: PrimitiveKind, mat: model::Material) -> model::Model {
|
||||
let g = layer.props.sub("geometryOptions");
|
||||
let f = |m: &str, d: f64| g.map_or(d, |g| ctx.f(layer, g, m, d));
|
||||
let pr = model::prim::PrimParams {
|
||||
size: [f("width", 300.0) as f32, f("height", 300.0) as f32, f("depth", 300.0) as f32],
|
||||
radius: f("radius", 150.0) as f32,
|
||||
tube: f("tubeRadius", 50.0) as f32,
|
||||
height: f("height", 300.0) as f32,
|
||||
segments: f("segments", 48.0).round().clamp(3.0, 512.0) as u32,
|
||||
rings: f("rings", 24.0).round().clamp(2.0, 512.0) as u32,
|
||||
};
|
||||
model::prim::model(prim_kind(kind), &pr, mat)
|
||||
}
|
||||
|
||||
/// Model space → layer space: model units to pixels, Y up → Y down (a 180° turn about X).
|
||||
pub fn model_to_layer(unit_scale: f64) -> Mat4 {
|
||||
Mat4::scale(vec3(unit_scale, -unit_scale, -unit_scale))
|
||||
}
|
||||
|
||||
fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
|
||||
let lin = b.s.linear_io;
|
||||
let (opts, prim_mat) = model_options(ctx, layer, lin);
|
||||
let opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
|
||||
let geo = layer.props.sub("geometryOptions");
|
||||
let (mdl, unit, clip, t): (Arc<model::Model>, f64, Option<usize>, f64) = match &layer.source {
|
||||
LayerSource::Primitive { kind } => (Arc::new(primitive_model(ctx, layer, *kind, prim_mat.unwrap_or_default())), 1.0, None, 0.0),
|
||||
LayerSource::Model { item } => {
|
||||
let Some(it) = r.project.item(*item) else { return };
|
||||
let ItemKind::Footage(f) = &it.kind else { return };
|
||||
let Some(m) = r.footage.model(*item, f) else { return };
|
||||
let unit = geo.map_or(1.0, |g| ctx.f(layer, g, "unitScale", 1.0));
|
||||
let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize;
|
||||
let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len());
|
||||
let speed = geo.map_or(100.0, |g| ctx.f(layer, g, "animationSpeed", 100.0)) / 100.0;
|
||||
let mut t = layer.layer_time(ctx.time).seconds() * speed;
|
||||
if let Some(c) = clip {
|
||||
let d = m.animations[c].duration();
|
||||
let looping = geo.is_none_or(|g| ctx.b(layer, g, "loopAnimation"));
|
||||
if looping && d > 0.0 {
|
||||
t = t.rem_euclid(d);
|
||||
}
|
||||
}
|
||||
(m, unit, clip, t)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let world = ctx.world_matrix(layer) * model_to_layer(unit);
|
||||
// Materials of the model (one default when it has none).
|
||||
let mut mats: Vec<u32> = vec![];
|
||||
let defaults = [model::Material::plain([0.8, 0.8, 0.8, 1.0], 0.0, 0.5)];
|
||||
let src: &[model::Material] = if mdl.materials.is_empty() { &defaults } else { &mdl.materials };
|
||||
for mm in src {
|
||||
let tex = |b: &mut Builder, t: Option<model::TexRef>, srgb: bool| -> i32 {
|
||||
t.and_then(|t| mdl.textures.get(t.texture)).map_or(-1, |t| b.model_texture(t, srgb))
|
||||
};
|
||||
let m = Material {
|
||||
base: mm.base_color,
|
||||
metallic: mm.metallic,
|
||||
roughness: mm.roughness,
|
||||
emissive: mm.emissive,
|
||||
normal_scale: mm.normal_scale,
|
||||
occlusion_strength: mm.occlusion_strength,
|
||||
alpha_mode: match mm.alpha_mode {
|
||||
model::AlphaMode::Opaque => 0,
|
||||
model::AlphaMode::Mask => 1,
|
||||
model::AlphaMode::Blend => 2,
|
||||
},
|
||||
alpha_cutoff: mm.alpha_cutoff,
|
||||
opacity,
|
||||
tex_base: tex(b, mm.base_color_tex, true),
|
||||
tex_mr: tex(b, mm.metallic_roughness_tex, false),
|
||||
tex_normal: tex(b, mm.normal_tex, false),
|
||||
tex_occlusion: tex(b, mm.occlusion_tex, false),
|
||||
tex_emissive: tex(b, mm.emissive_tex, true),
|
||||
double_sided: mm.double_sided,
|
||||
unlit: mm.unlit,
|
||||
..opts
|
||||
};
|
||||
mats.push(b.material(m));
|
||||
}
|
||||
for inst in mdl.instances(clip, t) {
|
||||
for p in &inst.mesh.primitives {
|
||||
let mat = mats[p.material.unwrap_or(0).min(mats.len() - 1)];
|
||||
b.emit(p, mat, &|k| if k == usize::MAX { world * inst.world } else { world * model::vertex_matrix(&inst, p, k) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed polylines of a path (flattened to `tol` pixels).
|
||||
pub fn contours(path: &kurbo::BezPath, tol: f64) -> Vec<Vec<[f64; 2]>> {
|
||||
let mut out: Vec<Vec<[f64; 2]>> = vec![];
|
||||
let mut cur: Vec<[f64; 2]> = vec![];
|
||||
kurbo::flatten(path, tol, |el| match el {
|
||||
kurbo::PathEl::MoveTo(p) => {
|
||||
if cur.len() >= 3 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![[p.x, p.y]];
|
||||
}
|
||||
kurbo::PathEl::LineTo(p) => cur.push([p.x, p.y]),
|
||||
kurbo::PathEl::ClosePath => {
|
||||
if cur.len() >= 3 {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur.clear();
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if cur.len() >= 3 {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Geometry Options of a text/shape layer when it is extruded.
|
||||
pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::ExtrudeParams> {
|
||||
let g = layer.props.sub("geometryOptions")?;
|
||||
let depth = ctx.f(layer, g, "extrusionDepth", 0.0);
|
||||
(depth > 0.0).then(|| model::extrude::ExtrudeParams {
|
||||
bevel: model::extrude::BevelStyle::from_index(ctx.e(layer, g, "bevelStyle")),
|
||||
bevel_depth: ctx.f(layer, g, "bevelDepth", 2.0).max(0.0),
|
||||
hole_bevel: (ctx.f(layer, g, "holeBevelDepth", 100.0) / 100.0).clamp(0.0, 1.0),
|
||||
depth,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extruded meshes of a text or shape layer: (mesh, straight colour, mesh space → layer
|
||||
/// space). Text extrudes each character in its own space, so per-character 3D animation
|
||||
/// (position, rotation, scale in Z) carries over.
|
||||
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
|
||||
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4)> = 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.local.clone()], g.fill, g.m));
|
||||
}
|
||||
}
|
||||
}
|
||||
LayerSource::Shape => {
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
groups = crate::shapes::fill_outlines(ctx, layer, c).into_iter().map(|(ps, c)| (ps, c, Mat4::IDENTITY)).collect();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
groups
|
||||
.into_iter()
|
||||
.filter_map(|(paths, color, m)| {
|
||||
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
|
||||
let outlines = model::extrude::group_contours(cs);
|
||||
if outlines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((model::extrude::extrude(&outlines, p), color, m))
|
||||
})
|
||||
.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, local) 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
|
||||
});
|
||||
let xf = world * local;
|
||||
b.emit(&mesh, m, &|_| xf);
|
||||
}
|
||||
}
|
||||
|
||||
fn card(b: &mut Builder, buf: &Buf, world: Mat4, mut mat: Material) {
|
||||
if buf.img.width == 0 || buf.img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let (tex, opaque) = b.buf_texture(buf);
|
||||
mat.tex_base = tex;
|
||||
mat.alpha_mode = if opaque { 0 } else { 2 };
|
||||
let m = b.material(mat);
|
||||
let s = buf.scale.max(1e-9);
|
||||
let (x0, y0) = (-buf.offset[0] / s, -buf.offset[1] / s);
|
||||
let (x1, y1) = ((buf.img.width as f64 - buf.offset[0]) / s, (buf.img.height as f64 - buf.offset[1]) / s);
|
||||
// Faces the camera (−z) in layer space; uv (0,0) at the buffer's top-left texel corner.
|
||||
let p = Primitive {
|
||||
positions: vec![[x0 as f32, y0 as f32, 0.0], [x1 as f32, y0 as f32, 0.0], [x1 as f32, y1 as f32, 0.0], [x0 as f32, y1 as f32, 0.0]],
|
||||
normals: vec![[0.0, 0.0, -1.0]; 4],
|
||||
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
|
||||
tangents: vec![[1.0, 0.0, 0.0, 1.0]; 4],
|
||||
indices: vec![0, 2, 1, 0, 3, 2],
|
||||
material: Some(0),
|
||||
..Default::default()
|
||||
};
|
||||
b.emit(&p, m, &|_| world);
|
||||
}
|
||||
|
||||
fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
|
||||
let mut mat = card_material(ctx, layer);
|
||||
mat.opacity = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
|
||||
let world = ctx.world_matrix(layer);
|
||||
if crate::text::per_char_3d(ctx, layer) {
|
||||
for (buf, m) in crate::text::per_char_planes(ctx, layer, r.opts.scale) {
|
||||
let buf = r.to_blend(Arc::new(buf), None);
|
||||
card(b, &buf, world * m, mat);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(buf) = r.blend_layer_buf(ctx, layer) {
|
||||
card(b, &buf, world, mat);
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,
|
||||
/// rotation)).
|
||||
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {
|
||||
let mut out = vec![];
|
||||
let mut ambient = [0.0f32; 3];
|
||||
let mut env = None;
|
||||
let l = |v: f32| if lin { v } else { srgb_to_linear(v) };
|
||||
for layer in ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)) {
|
||||
let LayerSource::Light { kind } = layer.source else { continue };
|
||||
let Some(g) = layer.props.sub("lightOptions") else { continue };
|
||||
let k = (ctx.f(layer, g, "intensity", 100.0) / 100.0) as f32;
|
||||
let c = ctx.color(layer, g, "color");
|
||||
let color = [l(c[0]) * k, l(c[1]) * k, l(c[2]) * k];
|
||||
match kind {
|
||||
LightKind::Ambient => {
|
||||
for i in 0..3 {
|
||||
ambient[i] += color[i];
|
||||
}
|
||||
}
|
||||
LightKind::Environment => {
|
||||
if env.is_none() {
|
||||
let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());
|
||||
let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;
|
||||
env = Some((src, color, rot));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let Some(st) = LightState::from_layer(ctx, layer) else { continue };
|
||||
let f = |v: Vec3| [v.x as f32, v.y as f32, v.z as f32];
|
||||
out.push(Light {
|
||||
kind: match kind {
|
||||
LightKind::Parallel => 0,
|
||||
LightKind::Spot => 1,
|
||||
_ => 2,
|
||||
},
|
||||
pos: f(st.pos),
|
||||
dir: f(st.dir.normalize()),
|
||||
color,
|
||||
cos_inner: (st.cone_half * (1.0 - st.feather)).cos() as f32,
|
||||
cos_outer: st.cone_half.cos() as f32,
|
||||
falloff: st.falloff,
|
||||
radius: st.radius as f32,
|
||||
falloff_distance: st.falloff_distance as f32,
|
||||
// Marked; `raster::shadow_maps` assigns the maps.
|
||||
shadow: if st.casts_shadows { 0 } else { -1 },
|
||||
shadow_darkness: st.shadow_darkness as f32,
|
||||
shadow_diffusion: st.shadow_diffusion as f32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, ambient, env)
|
||||
}
|
||||
|
||||
/// Box-downsample a straight RGBA image by 2 (odd sizes round up).
|
||||
fn half(w: u32, h: u32, d: &[[f32; 4]]) -> (u32, u32, Vec<[f32; 4]>) {
|
||||
let (nw, nh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1));
|
||||
let mut out = vec![[0.0f32; 4]; (nw * nh) as usize];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let mut acc = [0.0f32; 4];
|
||||
let mut n = 0.0;
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let (sx, sy) = (2 * x + dx, 2 * y + dy);
|
||||
if sx < w && sy < h {
|
||||
let p = d[(sy * w + sx) as usize];
|
||||
for k in 0..4 {
|
||||
acc[k] += p[k];
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
out[(y * nw + x) as usize] = acc.map(|v| v / n);
|
||||
}
|
||||
}
|
||||
(nw, nh, out)
|
||||
}
|
||||
|
||||
/// Cosine-weighted irradiance (`w`×`h` equirectangular) of an equirectangular radiance image:
|
||||
/// each texel is the cosine-weighted mean radiance over the hemisphere around its direction
|
||||
/// (a constant environment gives the same constant).
|
||||
pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec<[f32; 4]> {
|
||||
use super::shade::{dot, equirect_dir};
|
||||
// Source directions and solid-angle weights (∝ sin θ).
|
||||
let dirs: Vec<([f32; 3], f32, [f32; 3])> = (0..sh)
|
||||
.flat_map(|y| (0..sw).map(move |x| (x, y)))
|
||||
.map(|(x, y)| {
|
||||
let (u, v) = ((x as f32 + 0.5) / sw as f32, (y as f32 + 0.5) / sh as f32);
|
||||
let p = src[(y * sw + x) as usize];
|
||||
(equirect_dir(u, v, 0.0), (v * std::f32::consts::PI).sin(), [p[0], p[1], p[2]])
|
||||
})
|
||||
.collect();
|
||||
let mut out = vec![[0.0f32; 4]; (w * h) as usize];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let n = equirect_dir((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32, 0.0);
|
||||
let (mut acc, mut wsum) = ([0.0f32; 3], 0.0f32);
|
||||
for (d, sa, c) in &dirs {
|
||||
let k = dot(n, *d).max(0.0) * sa;
|
||||
if k > 0.0 {
|
||||
for i in 0..3 {
|
||||
acc[i] += c[i] * k;
|
||||
}
|
||||
wsum += k;
|
||||
}
|
||||
}
|
||||
let inv = if wsum > 0.0 { 1.0 / wsum } else { 0.0 };
|
||||
out[(y * w + x) as usize] = [acc[0] * inv, acc[1] * inv, acc[2] * inv, 1.0];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Prefilter an environment image into the scene's textures.
|
||||
pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {
|
||||
// Radiance level 0 at most 1024 wide.
|
||||
let (mut w, mut h, mut d) = (w, h, data);
|
||||
while w > 1024 {
|
||||
(w, h, d) = half(w, h, &d);
|
||||
}
|
||||
let first = b.s.textures.len() as u32;
|
||||
let mut levels = 0;
|
||||
let mut small: Option<(u32, u32, Vec<[f32; 4]>)> = None;
|
||||
loop {
|
||||
b.texture_from(w, h, [0, 1], d.iter().copied());
|
||||
levels += 1;
|
||||
if w <= 64 && small.is_none() {
|
||||
small = Some((w, h, d.clone()));
|
||||
}
|
||||
if w <= 8 || h <= 4 {
|
||||
break;
|
||||
}
|
||||
(w, h, d) = half(w, h, &d);
|
||||
}
|
||||
let (sw, sh, sd) = small.unwrap_or((w, h, d));
|
||||
let irr = irradiance_map(sw, sh, &sd, 32, 16);
|
||||
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
|
||||
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
|
||||
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
|
||||
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
|
||||
let tint = intensity.map(|c| c / k);
|
||||
for t in first..=ii {
|
||||
let ti = b.s.textures[t as usize];
|
||||
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
|
||||
for c in 0..3 {
|
||||
p[c] *= tint[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });
|
||||
}
|
||||
|
||||
/// The environment layer's pixels as linear straight RGBA.
|
||||
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
|
||||
let layer = match src {
|
||||
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
|
||||
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
|
||||
};
|
||||
let buf = r.blend_layer_buf(ctx, layer)?;
|
||||
let img = &buf.img;
|
||||
let data = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let a = p[3];
|
||||
if a <= 1e-6 {
|
||||
return [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
let s = [p[0] / a, p[1] / a, p[2] / a];
|
||||
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
|
||||
})
|
||||
.collect();
|
||||
Some((img.width, img.height, data))
|
||||
}
|
||||
|
||||
/// Supersampling factor: 2×2 unless draft or very large.
|
||||
fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
|
||||
if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 }
|
||||
}
|
||||
|
||||
/// Build the scene of a run of 3D layers.
|
||||
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
let cam = camera_for(r, ctx);
|
||||
let lin = r.pipe.linear || r.pipe.linear_blend;
|
||||
let ssaa = ssaa_for(r, out);
|
||||
let mut b = Builder {
|
||||
s: Scene { width: out.0 * ssaa, height: out.1 * ssaa, ssaa, linear_io: lin, ..Scene::default() },
|
||||
tex_keys: HashMap::new(),
|
||||
tri_depth: vec![],
|
||||
cam,
|
||||
};
|
||||
for layer in run {
|
||||
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
|
||||
continue;
|
||||
}
|
||||
if layer.source.is_model() {
|
||||
model_layer(r, ctx, layer, &mut b);
|
||||
} else if matches!(layer.source, LayerSource::Text | LayerSource::Shape)
|
||||
&& let Some(p) = extrusion_params(ctx, layer)
|
||||
{
|
||||
extruded_layer(r, ctx, layer, &p, &mut b);
|
||||
} else {
|
||||
card_layer(r, ctx, layer, &mut b);
|
||||
}
|
||||
}
|
||||
b.sort_triangles();
|
||||
// Lights, environment, shadows (Draft 3D: unlit, like After Effects).
|
||||
if !ctx.comp.draft_3d {
|
||||
let (ls, amb, env) = lights(ctx, lin);
|
||||
b.s.lights = ls;
|
||||
b.s.ambient = amb;
|
||||
if let Some((src, k, rot)) = env
|
||||
&& let Some((w, h, d)) = env_pixels(r, ctx, src, lin)
|
||||
{
|
||||
add_environment(&mut b, w, h, d, k, rot);
|
||||
}
|
||||
super::raster::shadow_maps(&mut b.s, r.opts.draft);
|
||||
}
|
||||
// Camera matrices.
|
||||
let s = r.opts.scale;
|
||||
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
|
||||
let (ow, oh) = (out.0 as f64, out.1 as f64);
|
||||
let (a, bb) = (2.0 * s * cam.zoom / ow, s * cw / ow - 1.0);
|
||||
let (c, d) = (2.0 * s * cam.zoom / oh, s * ch / oh - 1.0);
|
||||
let proj = if cam.ortho {
|
||||
let (mut zmin, mut zmax) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for v in &b.s.vertices {
|
||||
let z = cam.depth(Vec3::from(v.pos.map(|x| x as f64)));
|
||||
zmin = zmin.min(z);
|
||||
zmax = zmax.max(z);
|
||||
}
|
||||
if !zmin.is_finite() {
|
||||
(zmin, zmax) = (-1.0, 1.0);
|
||||
}
|
||||
let pad = (zmax - zmin).abs() * 0.01 + 1.0;
|
||||
let (zmin, zmax) = (zmin - pad, zmax + pad);
|
||||
let zr = zmax - zmin;
|
||||
Mat4([[a, 0.0, 0.0, bb], [0.0, -c, 0.0, -d], [0.0, 0.0, -1.0 / zr, zmax / zr], [0.0, 0.0, 0.0, 1.0]])
|
||||
} else {
|
||||
Mat4([[a, 0.0, bb, 0.0], [0.0, -c, -d, 0.0], [0.0, 0.0, 0.0, NEAR], [0.0, 0.0, 1.0, 0.0]])
|
||||
};
|
||||
b.s.clip = m32(&(proj * cam.view));
|
||||
b.s.view = m32(&cam.view);
|
||||
let f = cam.forward();
|
||||
b.s.cam_fwd = [f.x as f32, f.y as f32, f.z as f32];
|
||||
b.s.eye = [cam.eye.x as f32, cam.eye.y as f32, cam.eye.z as f32];
|
||||
b.s.ortho = cam.ortho;
|
||||
b.s
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! 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).
|
||||
// Texel footprint (world units) × PCF reach, steeper for grazing light.
|
||||
let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal };
|
||||
let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0)));
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
|
||||
use super::shade::{self, PI};
|
||||
use super::*;
|
||||
use crate::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
// ---------------------------------------------------------------- shading
|
||||
|
||||
#[test]
|
||||
fn brdf_matches_cook_torrance_formula() {
|
||||
// Reference: f = kd·c/π + D·G·F/(4·NV·NL), returned × NL × π.
|
||||
let n = [0.0, 0.0, 1.0];
|
||||
let v = shade::norm([0.3, 0.1, 1.0]);
|
||||
let l = shade::norm([-0.4, 0.2, 1.0]);
|
||||
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
|
||||
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
|
||||
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
|
||||
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
|
||||
let a2 = (rough * rough).powi(2);
|
||||
let d = a2 / (PI * ((nh * nh) * (a2 - 1.0) + 1.0).powi(2));
|
||||
let k = (rough + 1.0).powi(2) / 8.0;
|
||||
let g = nv / (nv * (1.0 - k) + k) * nl / (nl * (1.0 - k) + k);
|
||||
for c in 0..3 {
|
||||
let f0 = 0.04 + (base[c] - 0.04) * metal;
|
||||
let f = f0 + (1.0 - f0) * (1.0 - vh).powi(5);
|
||||
let expect = ((1.0 - f) * (1.0 - metal) * base[c] / PI + d * g * f / (4.0 * nv * nl)) * nl * PI;
|
||||
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
|
||||
}
|
||||
// No light from behind the surface.
|
||||
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
|
||||
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
|
||||
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
|
||||
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ggx_distribution_is_normalised() {
|
||||
// ∫ D(h)·(n·h) dω = 1 over the hemisphere.
|
||||
for &rough in &[0.2f32, 0.5, 0.9] {
|
||||
let a = rough * rough;
|
||||
let n = 4000;
|
||||
let mut sum = 0.0f64;
|
||||
for i in 0..n {
|
||||
let th = (i as f64 + 0.5) / n as f64 * std::f64::consts::FRAC_PI_2;
|
||||
let c = th.cos() as f32;
|
||||
sum += shade::d_ggx(c, a) as f64 * c as f64 * th.sin() * std::f64::consts::FRAC_PI_2 / n as f64 * 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
assert!((sum - 1.0).abs() < 0.02, "roughness {rough}: {sum}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_environment_has_constant_irradiance() {
|
||||
let src = vec![[0.25, 0.5, 0.75, 1.0]; 64 * 32];
|
||||
let irr = irradiance_map(64, 32, &src, 32, 16);
|
||||
for p in &irr {
|
||||
for c in 0..3 {
|
||||
assert!((p[c] - src[0][c]).abs() < 1e-4, "{p:?}");
|
||||
}
|
||||
}
|
||||
// And a sky brighter than the ground lights upward-facing normals more.
|
||||
let sky: Vec<[f32; 4]> = (0..32).flat_map(|y| (0..64).map(move |_| if y < 16 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] })).collect();
|
||||
let irr = irradiance_map(64, 32, &sky, 32, 16);
|
||||
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equirect_round_trip_and_env_brdf() {
|
||||
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
|
||||
let d = shade::equirect_dir(u, v, 0.3);
|
||||
let (u2, v2) = shade::equirect_uv(d, 0.3);
|
||||
assert!((u - u2).abs() < 1e-4 && (v - v2).abs() < 1e-4);
|
||||
}
|
||||
// Up (−Y in AE space) is the top row.
|
||||
assert!(shade::equirect_uv([0.0, -1.0, 0.0], 0.0).1 < 1e-3);
|
||||
let (a, b) = shade::env_brdf(1.0, 0.0);
|
||||
assert!(a + b > 0.9 && a + b <= 1.05);
|
||||
let (a, b) = shade::env_brdf(0.1, 1.0);
|
||||
assert!(a >= 0.0 && b > -0.01 && a + b < 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rasteriser
|
||||
|
||||
/// A scene of axis-aligned quads (z = camera depth) seen by an orthographic identity camera
|
||||
/// mapping world x/y straight to raster pixels.
|
||||
fn quad_scene(w: u32, h: u32, quads: &[([f32; 4], f32, [f32; 4])]) -> Scene {
|
||||
let mut s = Scene { width: w, height: h, ssaa: 1, linear_io: true, ..Scene::default() };
|
||||
// clip: x → 2x/w − 1, y → 1 − 2y/h, z: depth 0..1000 → 1..0 (nearer = larger).
|
||||
s.clip = [[2.0 / w as f32, 0.0, 0.0, -1.0], [0.0, -2.0 / h as f32, 0.0, 1.0], [0.0, 0.0, -0.001, 1.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
s.view = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
s.ortho = true;
|
||||
s.cam_fwd = [0.0, 0.0, 1.0];
|
||||
for (rect, z, color) in quads {
|
||||
let m = s.materials.len() as u32;
|
||||
s.materials.push(Material { base: *color, unlit: true, double_sided: true, alpha_mode: if color[3] < 1.0 { 2 } else { 0 }, ..Material::default() });
|
||||
let b = s.vertices.len() as u32;
|
||||
for (x, y) in [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])] {
|
||||
s.vertices.push(Vertex { pos: [x, y, *z], normal: [0.0, 0.0, -1.0], uv: [0.0; 2], tangent: [1.0, 0.0, 0.0, 1.0], material: m });
|
||||
}
|
||||
s.indices.extend([b, b + 2, b + 1, b, b + 3, b + 2]);
|
||||
}
|
||||
// Opaque first, transparent (in given order) after.
|
||||
let tris: Vec<[u32; 3]> = s.indices.chunks(3).map(|t| [t[0], t[1], t[2]]).collect();
|
||||
let (o, t): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| !s.materials[s.vertices[t[0] as usize].material as usize].transparent());
|
||||
s.opaque_count = 3 * o.len() as u32;
|
||||
s.indices = o.into_iter().chain(t).flatten().collect();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_buffer_keeps_the_nearest_surface() {
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let blue = [0.0, 0.0, 1.0, 1.0];
|
||||
// Blue is listed first but is farther away.
|
||||
let s = quad_scene(40, 30, &[([0.0, 0.0, 30.0, 20.0], 500.0, blue), ([10.0, 10.0, 40.0, 30.0], 100.0, red)]);
|
||||
let t = raster::render(&s);
|
||||
let px = |x: usize, y: usize| t.color[y * 40 + x];
|
||||
assert_eq!(px(5, 5), blue);
|
||||
assert_eq!(px(15, 15), red, "overlap: the nearer quad wins");
|
||||
assert_eq!(px(35, 25), red);
|
||||
assert_eq!(px(35, 5), [0.0; 4]);
|
||||
assert_eq!(t.depth[15 * 40 + 15], 100.0);
|
||||
assert!(t.depth[5 * 40 + 35].is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_is_exact_for_pixel_aligned_quads() {
|
||||
// Shared diagonal edges are filled once (top-left rule): exact pixel counts.
|
||||
let s = quad_scene(32, 32, &[([4.0, 4.0, 20.0, 12.0], 10.0, [1.0, 1.0, 1.0, 0.5])]);
|
||||
let t = raster::render(&s);
|
||||
let covered = t.color.iter().filter(|c| c[3] > 0.0).count();
|
||||
assert_eq!(covered, 16 * 8);
|
||||
// Blended once (no double coverage along the diagonal).
|
||||
assert!(t.color.iter().all(|c| c[3] == 0.0 || (c[3] - 0.5).abs() < 1e-6));
|
||||
// Two transparent layers composite in order.
|
||||
let s = quad_scene(8, 8, &[([0.0, 0.0, 8.0, 8.0], 100.0, [1.0, 0.0, 0.0, 0.5]), ([0.0, 0.0, 8.0, 8.0], 50.0, [0.0, 0.0, 1.0, 0.5])]);
|
||||
let c = raster::render(&s).color[0];
|
||||
assert!((c[0] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn textures_sample_bilinear_with_wrap_modes() {
|
||||
let t = TexInfo { offset: 0, width: 2, height: 1, wrap_u: 0, wrap_v: 1 };
|
||||
let texels = vec![[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]];
|
||||
assert_eq!(shade::sample(&t, &texels, 0.25, 0.5)[0], 0.0);
|
||||
assert_eq!(shade::sample(&t, &texels, 0.5, 0.5)[0], 0.5);
|
||||
// Repeat wraps the right edge back to texel 0.
|
||||
assert!((shade::sample(&t, &texels, 1.0, 0.5)[0] - 0.5).abs() < 1e-6);
|
||||
let c = TexInfo { wrap_u: 1, ..t };
|
||||
assert_eq!(shade::sample(&c, &texels, 1.0, 0.5)[0], 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- full renderer
|
||||
|
||||
fn project(w: u32, h: u32) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
let mut c = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
c.renderer = R3::Advanced3D;
|
||||
let id = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||||
(p, id)
|
||||
}
|
||||
|
||||
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
|
||||
edit(&mut l);
|
||||
p.comp_mut(cid).unwrap().layers.insert(0, l);
|
||||
}
|
||||
|
||||
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
|
||||
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
|
||||
}
|
||||
|
||||
fn render(p: &Project, cid: ItemId) -> crate::Image {
|
||||
Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lit_cube_renders_with_depth_and_shading() {
|
||||
let (mut p, cid) = project(160, 120);
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(60.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(60.0));
|
||||
set(l, "geometryOptions/depth", Value::Scalar(60.0));
|
||||
set(l, "transform/rotationY", Value::Scalar(35.0));
|
||||
set(l, "transform/rotationX", Value::Scalar(-25.0));
|
||||
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
|
||||
});
|
||||
let img = render(&p, cid);
|
||||
let centre = img.data[60 * 160 + 80];
|
||||
assert!(centre[3] > 0.99, "cube covers the centre: {centre:?}");
|
||||
assert_eq!(img.data[2 * 160 + 2][3], 0.0, "corner is empty");
|
||||
// No lights: unlit (base colour) — add a light and faces get different shades.
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
|
||||
let lit = render(&p, cid);
|
||||
let mut shades: Vec<i32> = lit.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
|
||||
shades.sort();
|
||||
shades.dedup();
|
||||
assert!(shades.len() >= 3, "faces are shaded differently: {shades:?}");
|
||||
// Classic 3D draws no meshes.
|
||||
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
|
||||
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_darken_the_receiver() {
|
||||
let (mut p, cid) = project(200, 150);
|
||||
// A floor plane facing the camera, a sphere in front of it, a spot light behind the camera.
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(400.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(400.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
|
||||
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
|
||||
set(l, "geometryOptions/radius", Value::Scalar(30.0));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
|
||||
set(l, "materialOptions/castsShadows", Value::Enum(2));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
|
||||
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
});
|
||||
let img = render(&p, cid);
|
||||
// The sphere casts only (invisible); its shadow lands on the plane behind it.
|
||||
let mid = img.data[75 * 200 + 100];
|
||||
let side = img.data[75 * 200 + 20];
|
||||
assert!(mid[3] > 0.99 && side[3] > 0.99);
|
||||
assert!(mid[0] < side[0] * 0.5, "shadowed centre {mid:?} vs lit side {side:?}");
|
||||
// Without shadow casting the centre is lit.
|
||||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(false));
|
||||
let img = render(&p, cid);
|
||||
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_and_environment_light() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
let sid = p.add_item(
|
||||
"S",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
|
||||
let unlit = render(&p, cid);
|
||||
let c = unlit.data[20 * 120 + 30];
|
||||
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
|
||||
assert_eq!(unlit.data[80 * 120 + 110][3], 0.0);
|
||||
// An Environment light sourcing a white environment layer brightens the card.
|
||||
let env = p.add_item(
|
||||
"E",
|
||||
Default::default(),
|
||||
None,
|
||||
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||||
l.environment = true;
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
|
||||
let lit = render(&p, cid);
|
||||
let c2 = lit.data[20 * 120 + 30];
|
||||
assert!(c2[0] > 0.3, "{c2:?}");
|
||||
// The environment layer itself is not drawn.
|
||||
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_of_field_blurs_out_of_focus_surfaces() {
|
||||
let mut img = Image::new(64, 64);
|
||||
for y in 0..64 {
|
||||
for x in 0..64 {
|
||||
let v = if (x / 4 + y / 4) % 2 == 0 { 1.0 } else { 0.0 };
|
||||
img.data[y * 64 + x] = [v, v, v, 1.0];
|
||||
}
|
||||
}
|
||||
let var = |im: &Image| {
|
||||
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
|
||||
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
|
||||
};
|
||||
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
|
||||
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
|
||||
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
|
||||
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
|
||||
assert!(var(&blurred) < var(&img) * 0.3, "out of focus: blurred");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extruded_text_renders_as_a_mesh() {
|
||||
let (mut p, cid) = project(200, 100);
|
||||
add(&mut p, cid, LayerSource::Text, |l| {
|
||||
l.switches.three_d = true;
|
||||
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 10_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/rotationY", Value::Scalar(40.0));
|
||||
});
|
||||
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None };
|
||||
let l = &ctx.comp.layers[0];
|
||||
let params = scene::extrusion_params(&ctx, l).unwrap();
|
||||
let meshes = scene::extruded_meshes(&ctx, l, ¶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);
|
||||
}
|
||||
@@ -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));
|
||||
@@ -583,3 +589,182 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ auxiliary render pass
|
||||
|
||||
/// One surface seen through an output pixel (aux pass).
|
||||
#[derive(Clone, Copy)]
|
||||
struct AuxFrag {
|
||||
z: f32,
|
||||
a: f32,
|
||||
obj: f32,
|
||||
mat: f32,
|
||||
n: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
obj_hash: u32,
|
||||
mat_hash: u32,
|
||||
}
|
||||
|
||||
/// The comp's auxiliary channels at the context time, at output resolution: camera-space depth
|
||||
/// (`Z`), `ObjectID` (layer number), `MaterialID` (source item id), `Coverage`, camera-space
|
||||
/// normals (`N.X/Y/Z`), texture coordinates (`UV.U/V`) and Cryptomatte `CryptoObject` (layer
|
||||
/// names) / `CryptoMaterial` (source names) with two ranks each, plus their manifests.
|
||||
///
|
||||
/// Layers are gathered in stack order (top first); a run of 3D layers contributes its planes
|
||||
/// sorted near → far at each pixel, a 2D layer its coverage on the plane of the comp. The
|
||||
/// front-most surface with ≥ 50 % opacity (else the strongest one) provides depth, IDs, normal
|
||||
/// and UV; Cryptomatte coverage is each surface's visible share (`α · Π(1 − α_front)`).
|
||||
pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxChannels {
|
||||
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
|
||||
let s = r.opts.scale;
|
||||
let (w, h) = (((ctx.comp.width as f64 * s).round() as u32).max(1), ((ctx.comp.height as f64 * s).round() as u32).max(1));
|
||||
let out = (w, h);
|
||||
let (wu, n) = (w as usize, w as usize * h as usize);
|
||||
let cam = camera_for(r, ctx);
|
||||
let visible: Vec<&Layer> = r.visible_layers(ctx).into_iter().filter(|l| !l.switches.adjustment).collect();
|
||||
let number = |l: &Layer| ctx.comp.layers.iter().position(|x| x.id == l.id).map_or(0.0, |i| i as f32 + 1.0);
|
||||
let source_name = |l: &Layer| l.source.item().and_then(|i| r.project.item(i)).map(|it| it.name.clone()).unwrap_or_else(|| l.name.clone());
|
||||
let source_id = |l: &Layer| l.source.item().map_or(0.0, |i| i.0 as f32);
|
||||
let mut manifest_obj: Vec<(String, u32)> = vec![];
|
||||
let mut manifest_mat: Vec<(String, u32)> = vec![];
|
||||
for l in &visible {
|
||||
let (on, mn) = (l.name.clone(), source_name(l));
|
||||
if !manifest_obj.iter().any(|(x, _)| *x == on) {
|
||||
manifest_obj.push((on.clone(), crypto_hash(&on)));
|
||||
}
|
||||
if !manifest_mat.iter().any(|(x, _)| *x == mn) {
|
||||
manifest_mat.push((mn.clone(), crypto_hash(&mn)));
|
||||
}
|
||||
}
|
||||
// Groups in stack order: runs of 3D layers, single 2D layers.
|
||||
let mut groups: Vec<&[&Layer]> = vec![];
|
||||
let mut i = 0;
|
||||
while i < visible.len() {
|
||||
let mut j = i + 1;
|
||||
if visible[i].is_3d() {
|
||||
while j < visible.len() && visible[j].is_3d() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
groups.push(&visible[i..j]);
|
||||
i = j;
|
||||
}
|
||||
let mut frags: Vec<Vec<AuxFrag>> = vec![Vec::new(); n];
|
||||
let plane_z = cam.depth(vec3(ctx.comp.width as f64 * 0.5, ctx.comp.height as f64 * 0.5, 0.0)) as f32;
|
||||
for g in groups.iter().rev() {
|
||||
if g[0].is_3d() {
|
||||
let items: Vec<Item> = g.iter().enumerate().flat_map(|(k, l)| prepare_all(r, ctx, l, k, out, true)).collect();
|
||||
let meta: Vec<(f32, f32, u32, u32, [f32; 3], [f64; 2])> = items
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let nrm = it.geos.first().map_or(vec3(0.0, 0.0, -1.0), |gm| cam.view.apply_vec(gm.normal));
|
||||
let mut nv = [nrm.x as f32, nrm.y as f32, nrm.z as f32];
|
||||
if nv[2] > 0.0 {
|
||||
nv = nv.map(|v| -v);
|
||||
}
|
||||
let (sw, sh) = crate::source_size(r.project, it.layer);
|
||||
let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] };
|
||||
(number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size)
|
||||
})
|
||||
.collect();
|
||||
frags.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
|
||||
let yi = y as i64;
|
||||
let mut local: Vec<AuxFrag> = Vec::new();
|
||||
for (x, cell) in row.iter_mut().enumerate() {
|
||||
let xi = x as i64;
|
||||
local.clear();
|
||||
for (it, m) in items.iter().zip(&meta) {
|
||||
if xi < it.bbox[0] || xi >= it.bbox[2] || yi < it.bbox[1] || yi >= it.bbox[3] {
|
||||
continue;
|
||||
}
|
||||
let Some(gm) = it.geos.first() else { continue };
|
||||
let Some((u, v, z)) = unproject(gm, x as f64 + 0.5, y as f64 + 0.5) else { continue };
|
||||
let a = it.texel(u, v)[3] * it.opacity;
|
||||
if a > 1e-4 {
|
||||
let uv = [(u / m.5[0]) as f32, (v / m.5[1]) as f32];
|
||||
local.push(AuxFrag { z: z as f32, a: a.min(1.0), obj: m.0, mat: m.1, n: m.4, uv, obj_hash: m.2, mat_hash: m.3 });
|
||||
}
|
||||
}
|
||||
local.sort_by(|p, q| p.z.total_cmp(&q.z));
|
||||
cell.extend_from_slice(&local);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let l = g[0];
|
||||
let mut img = Image::new(w, h);
|
||||
if let Some(b) = r.layer_buf(ctx, l) {
|
||||
r.place(ctx, l, &b, &mut img, BlendMode::Normal, ctx.opacity(l) as f32);
|
||||
}
|
||||
let (obj, mat, oh, mh) = (number(l), source_id(l), crypto_hash(&l.name), crypto_hash(&source_name(l)));
|
||||
frags.par_chunks_mut(wu).zip(img.data.par_chunks(wu)).enumerate().for_each(|(y, (row, px))| {
|
||||
for (x, (cell, p)) in row.iter_mut().zip(px).enumerate() {
|
||||
if p[3] > 1e-4 {
|
||||
let uv = [(x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32];
|
||||
cell.push(AuxFrag { z: plane_z, a: p[3].min(1.0), obj, mat, n: [0.0, 0.0, -1.0], uv, obj_hash: oh, mat_hash: mh });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve per pixel.
|
||||
const NAMES: [&str; 9] = ["Z", "ObjectID", "MaterialID", "Coverage", "N.X", "N.Y", "N.Z", "UV.U", "UV.V"];
|
||||
const CRYPTO: usize = 16;
|
||||
let resolved: Vec<([f32; 9], [f32; CRYPTO])> = frags
|
||||
.par_iter()
|
||||
.map(|fs| {
|
||||
let mut o = [BACKGROUND_DEPTH, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
let mut crypto = [0.0f32; CRYPTO];
|
||||
if fs.is_empty() {
|
||||
return (o, crypto);
|
||||
}
|
||||
let mut t = 1.0f32;
|
||||
let mut obj: Vec<(u32, f32)> = vec![];
|
||||
let mut mat: Vec<(u32, f32)> = vec![];
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
let mut front: Option<usize> = None;
|
||||
for (k, f) in fs.iter().enumerate() {
|
||||
let c = f.a * t;
|
||||
if front.is_none() && f.a >= 0.5 {
|
||||
front = Some(k);
|
||||
}
|
||||
if best.is_none_or(|(_, bc)| c > bc) {
|
||||
best = Some((k, c));
|
||||
}
|
||||
for (list, hsh) in [(&mut obj, f.obj_hash), (&mut mat, f.mat_hash)] {
|
||||
match list.iter_mut().find(|(x, _)| *x == hsh) {
|
||||
Some(e) => e.1 += c,
|
||||
None => list.push((hsh, c)),
|
||||
}
|
||||
}
|
||||
t *= 1.0 - f.a;
|
||||
if t <= 1e-5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let f = fs[front.or(best.map(|b| b.0)).unwrap_or(0)];
|
||||
o = [f.z, f.obj, f.mat, 1.0 - t, f.n[0], f.n[1], f.n[2], f.uv[0], f.uv[1]];
|
||||
for (base, list) in [(0usize, &mut obj), (8usize, &mut mat)] {
|
||||
list.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
for (rank, (hsh, c)) in list.iter().take(4).enumerate() {
|
||||
crypto[base + rank * 2] = crypto_float(*hsh);
|
||||
crypto[base + rank * 2 + 1] = *c;
|
||||
}
|
||||
}
|
||||
(o, crypto)
|
||||
})
|
||||
.collect();
|
||||
let mut aux = effectcraft_raster::AuxChannels::new(w, h, s);
|
||||
for (k, name) in NAMES.iter().enumerate() {
|
||||
aux.channels.push((name.to_string(), resolved.iter().map(|r| r.0[k]).collect()));
|
||||
}
|
||||
for (base, layer) in [(0usize, "CryptoObject"), (8usize, "CryptoMaterial")] {
|
||||
for rank_pair in 0..2 {
|
||||
for (c, ch) in ["R", "G", "B", "A"].iter().enumerate() {
|
||||
let k = base + rank_pair * 4 + c;
|
||||
aux.channels.push((format!("{layer}{rank_pair:02}.{ch}"), resolved.iter().map(|r| r.1[k]).collect()));
|
||||
}
|
||||
}
|
||||
}
|
||||
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
|
||||
aux
|
||||
}
|
||||
|
||||
@@ -117,7 +117,18 @@ impl LightState {
|
||||
|
||||
/// All lights switched on and active at the context time.
|
||||
pub fn lights_at(ctx: &EvalCtx) -> Vec<LightState> {
|
||||
ctx.comp.layers.iter().filter(|l| l.is_light() && l.switches.video && l.is_active_at(ctx.time)).filter_map(|l| LightState::from_layer(ctx, l)).collect()
|
||||
// Environment lights only light Advanced 3D comps (image-based light).
|
||||
ctx.comp
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.is_light()
|
||||
&& l.switches.video
|
||||
&& l.is_active_at(ctx.time)
|
||||
&& l.source != (effectcraft_project::LayerSource::Light { kind: LightKind::Environment })
|
||||
})
|
||||
.filter_map(|l| LightState::from_layer(ctx, l))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Material Options of a 3D layer.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user