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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:27:59 -04:00

532 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Device, pipelines, textures, uploads and readback.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, Weak};
use effectcraft_effects::Buf;
use effectcraft_raster::Image;
use wgpu::util::DeviceExt;
/// Compute entry points in `kernels.wgsl` (one pipeline each).
const ENTRIES: &[&str] = &[
"warp_blend",
"blend_full",
"matte",
"preserve",
"knockout",
"channel_mix",
"quantize",
"convert",
"half",
"box_h",
"box_v",
"directional",
"glow_bright",
"glow_combine",
"shadow_make",
"shadow_combine",
"pointwise",
];
/// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom;
/// 8/16 bpc are emulated by clamping and quantising, as on the CPU).
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// Uploaded layer buffers kept on the GPU (bytes).
const UPLOAD_BUDGET: usize = 1536 << 20;
/// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written).
#[derive(Clone, Debug)]
pub struct GpuImage {
pub texture: wgpu::Texture,
pub width: u32,
pub height: u32,
}
/// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`).
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Params {
pub u: [[u32; 4]; 4],
pub f: [[f32; 4]; 12],
}
impl Params {
fn bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(256);
for r in &self.u {
for x in r {
v.extend_from_slice(&x.to_le_bytes());
}
}
for r in &self.f {
for x in r {
v.extend_from_slice(&x.to_le_bytes());
}
}
v
}
}
struct Upload {
buf: Weak<Buf>,
img: GpuImage,
bytes: usize,
last_use: u64,
}
#[derive(Default)]
struct Uploads {
map: HashMap<usize, Upload>,
bytes: usize,
clock: u64,
}
/// The device and everything compiled for it.
pub struct GpuContext {
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub(crate) name: String,
pub(crate) max_dim: u32,
bgl: wgpu::BindGroupLayout,
pipelines: HashMap<&'static str, wgpu::ComputePipeline>,
display_bgl: wgpu::BindGroupLayout,
display: wgpu::ComputePipeline,
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
/// Advanced 3D render pipelines (built on first use).
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format, view_dimension: wgpu::TextureViewDimension::D2 },
count: None,
}
}
impl GpuContext {
/// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the
/// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage
/// textures).
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<GpuContext, String> {
let info = adapter.get_info();
if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) {
return Err(format!("{} ({:?}): no compute shaders", info.name, info.backend));
}
for f in [FORMAT, wgpu::TextureFormat::Rgba8Unorm] {
if !adapter.get_texture_format_features(f).allowed_usages.contains(wgpu::TextureUsages::STORAGE_BINDING) {
return Err(format!("{} ({:?}): {f:?} storage textures unsupported", info.name, info.backend));
}
}
let name = format!("{} ({:?})", info.name, info.backend);
let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")].concat();
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effectcraft kernels"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
tex_entry(1),
tex_entry(2),
storage_tex_entry(3, FORMAT),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft kernels"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let pipelines = ENTRIES
.iter()
.map(|e| {
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&layout),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
cache: None,
});
(*e, p)
})
.collect();
let dmodule = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effectcraft display"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/display.wgsl").into()),
});
let display_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effectcraft display"),
entries: &[tex_entry(1), storage_tex_entry(3, wgpu::TextureFormat::Rgba8Unorm)],
});
let dlayout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft display"),
bind_group_layouts: &[Some(&display_bgl)],
immediate_size: 0,
});
let display = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("display"),
layout: Some(&dlayout),
module: &dmodule,
entry_point: Some("display"),
compilation_options: Default::default(),
cache: None,
});
let dummy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dummy"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let dummy_buf =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE });
let max_dim = device.limits().max_texture_dimension_2d;
Ok(GpuContext {
device,
queue,
name,
max_dim,
bgl,
pipelines,
display_bgl,
display,
dummy_tex: dummy.create_view(&Default::default()),
dummy_buf,
uploads: Mutex::new(Uploads::default()),
adv3d: std::sync::OnceLock::new(),
})
}
/// A device of its own on the best adapter (CLI, tests, benchmarks). `None` when no
/// adapter qualifies.
#[cfg(not(target_arch = "wasm32"))]
pub fn headless() -> Option<GpuContext> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.ok()?;
let limits = adapter.limits();
let required_limits = wgpu::Limits {
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
};
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }))
.ok()?;
device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}")));
GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok()
}
/// Readback (GPU → CPU) waits for the device; impossible on the browser's main thread.
pub fn can_readback(&self) -> bool {
cfg!(not(target_arch = "wasm32"))
}
pub(crate) fn fits(&self, w: u32, h: u32) -> bool {
w > 0 && h > 0 && w <= self.max_dim && h <= self.max_dim
}
/// A new (zeroed) working texture.
pub(crate) fn image(&self, w: u32, h: u32) -> GpuImage {
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
GpuImage { texture, width: w, height: h }
}
/// Upload a CPU image (`None` when it does not fit the device).
pub fn upload_image(&self, img: &Image) -> Option<GpuImage> {
if !self.fits(img.width, img.height) {
return None;
}
let g = self.image(img.width, img.height);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
Some(g)
}
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
/// layers upload once, not every frame.
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
let key = Arc::as_ptr(buf) as usize;
if let Ok(mut u) = self.uploads.lock() {
u.clock += 1;
let now = u.clock;
if let Some(e) = u.map.get_mut(&key)
&& e.buf.upgrade().is_some_and(|b| Arc::ptr_eq(&b, buf))
{
e.last_use = now;
return Some(e.img.clone());
}
}
let img = self.upload_image(&buf.img)?;
if let Ok(mut u) = self.uploads.lock() {
let bytes = buf.img.data.len() * 16;
let now = u.clock;
if let Some(old) = u.map.insert(key, Upload { buf: Arc::downgrade(buf), img: img.clone(), bytes, last_use: now }) {
u.bytes -= old.bytes;
}
u.bytes += bytes;
// Drop textures of freed buffers, then the least recently used over budget.
if u.bytes > UPLOAD_BUDGET {
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
}
while u.bytes > UPLOAD_BUDGET {
let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
}
Some(img)
}
/// Forget uploaded layer textures.
pub fn clear_uploads(&self) {
if let Ok(mut u) = self.uploads.lock() {
u.map.clear();
u.bytes = 0;
}
}
}
/// Records GPU work for one render (a command encoder submitted at readback or the end).
pub(crate) struct Enc<'g> {
pub g: &'g GpuContext,
enc: Option<wgpu::CommandEncoder>,
pending: usize,
}
impl<'g> Enc<'g> {
pub fn new(g: &'g GpuContext) -> Enc<'g> {
Enc { g, enc: None, pending: 0 }
}
fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
let g = self.g;
self.enc.get_or_insert_with(|| g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") }))
}
/// A new zeroed image.
pub fn image(&self, w: u32, h: u32) -> GpuImage {
self.g.image(w, h)
}
/// Run kernel `entry` writing `out` (dispatched over `groups` workgroups).
pub fn dispatch(
&mut self,
entry: &str,
p: &Params,
src: &GpuImage,
aux: Option<&GpuImage>,
out: &GpuImage,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
) {
let g = self.g;
let Some(pipe) = g.pipelines.get(entry) else {
log::error!("gpu: no kernel {entry}");
return;
};
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM });
let sv = src.texture.create_view(&Default::default());
let av = aux.map(|a| a.texture.create_view(&Default::default()));
let ov = out.texture.create_view(&Default::default());
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &g.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(av.as_ref().unwrap_or(&g.dummy_tex)) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) },
wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).as_entire_binding() },
],
});
let enc = self.encoder();
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None });
pass.set_pipeline(pipe);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1);
}
self.pending += 1;
// Keep command buffers (and the transient textures they hold) bounded.
if self.pending >= 256 {
self.submit();
}
}
/// Per-pixel kernel over `out`'s size (16×16 workgroups).
pub fn pixels(&mut self, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, out: &GpuImage, data: Option<&wgpu::Buffer>) {
let groups = (out.width.div_ceil(16), out.height.div_ceil(16));
self.dispatch(entry, p, src, aux, out, data, groups);
}
/// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad).
pub fn copy_into(&mut self, src: &GpuImage, dst: &GpuImage, x: u32, y: u32) {
let enc = self.encoder();
enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: src.width, height: src.height, depth_or_array_layers: 1 },
);
}
/// Submit recorded work.
pub fn submit(&mut self) {
if let Some(enc) = self.enc.take() {
self.g.queue.submit([enc.finish()]);
}
self.pending = 0;
}
/// Read an image back to the CPU (submits and waits). `None` where readback is impossible.
pub fn download(&mut self, img: &GpuImage) -> Option<Image> {
let bytes = self.read_texture(&img.texture, img.width, img.height, 16)?;
let mut out = Image::new(img.width, img.height);
for (p, c) in out.data.iter_mut().zip(bytes.chunks_exact(16)) {
for (k, v) in p.iter_mut().enumerate() {
*v = f32::from_le_bytes([c[4 * k], c[4 * k + 1], c[4 * k + 2], c[4 * k + 3]]);
}
}
Some(out)
}
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
if !self.g.can_readback() {
return None;
}
let row = w * bpp;
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let size = padded as u64 * h as u64;
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.encoder().copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) } },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.submit();
let (tx, rx) = std::sync::mpsc::channel();
buf.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = tx.send(r);
});
if let Err(e) = self.g.device.poll(wgpu::PollType::wait_indefinitely()) {
log::error!("gpu: poll: {e}");
return None;
}
rx.recv().ok()?.ok()?;
let view = buf.get_mapped_range(..).ok()?;
let mut out = Vec::with_capacity((row * h) as usize);
for y in 0..h as usize {
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
}
drop(view);
buf.unmap();
Some(out)
}
/// A data buffer for kernels that read tables (curves).
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
while bytes.len() < 16 {
bytes.push(0);
}
self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE })
}
/// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format).
pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture {
let g = self.g;
let tex = g.device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer frame"),
size: wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let sv = img.texture.create_view(&Default::default());
let ov = tex.create_view(&Default::default());
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &g.display_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) },
],
});
let enc = self.encoder();
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("display"), timestamp_writes: None });
pass.set_pipeline(&g.display);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(img.width.div_ceil(16), img.height.div_ceil(16), 1);
}
tex
}
}