use std::{
sync::atomic::{AtomicBool, Ordering},
time::Instant,
};
use glam::Vec3;
use wgpu::BufferUsages;
use crate::{
geometry::GpuFacetPlane,
optics::{
materials::{CrystalSystem, GemMaterial},
raytracer::{
Camera, EnvironmentSource, FacetFinish, LightingPreset,
compute_illuminant_white_balance, illuminant_temperature_k,
},
},
renderer::{
buffers::{
GpuCameraParams, GpuGemMaterial, GpuTransportParams, encode_facet_finishes,
transport_env_mode,
},
gpu::{GpuAcquireError, GpuContext, compute},
},
};
pub(crate) const SHADER_SRC: &str = include_str!(concat!(
env!("OUT_DIR"),
"/spectral_transport.generated.wgsl"
));
const FLOATS_PER_TUPLE: usize = 3;
const DEBUG_BUFFER_FLOATS: usize = 8;
pub const CHUNK_BUDGET_BYTES: usize = 64 * 1024 * 1024;
const TARGET_CHUNK_MS: f64 = 150.0;
const MIN_CHUNK_BYTES: usize = 64 * 1024;
const FIRST_DISPATCH_MAX_TUPLES: usize = 1_000_000;
const CHUNK_TIMING_EMA_ALPHA: f64 = 0.3;
#[derive(Debug)]
pub enum GpuFrameError {
Acquire(GpuAcquireError),
UnsupportedEnvironment,
UnsupportedMaterial(String),
DeviceLost(String),
}
impl std::fmt::Display for GpuFrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Acquire(e) => write!(f, "no usable GPU adapter or device: {e:?}"),
Self::UnsupportedEnvironment => write!(
f,
"the GPU megakernel has no env_mode for this environment (HDR maps are CPU-only)"
),
Self::UnsupportedMaterial(name) => write!(
f,
"{name} is not GPU-supported (GemMaterial::gpu_supported), so it renders on the CPU"
),
Self::DeviceLost(why) => write!(
f,
"GPU device lost or unresponsive, disabling GPU rendering for the rest of this process: {why}"
),
}
}
}
impl std::error::Error for GpuFrameError {}
pub struct GpuFrameScene<'a> {
pub camera: &'a Camera,
pub width: u32,
pub height: u32,
pub planes: &'a [GpuFacetPlane],
pub facet_finishes: &'a [FacetFinish],
pub material: &'a GemMaterial,
pub max_bounces: u32,
pub environment: EnvironmentSource<'a>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccumulateOutcome {
Done,
Cancelled,
}
pub(crate) struct TransportOutputs {
xyz: wgpu::Buffer,
radiance: wgpu::Buffer,
lambdas: wgpu::Buffer,
path_pdf: wgpu::Buffer,
compat: wgpu::Buffer,
capacity: usize,
}
impl TransportOutputs {
pub(crate) fn new(device: &wgpu::Device, capacity: usize) -> Self {
let usage = BufferUsages::STORAGE | BufferUsages::COPY_SRC;
Self {
xyz: compute::zeroed_buffer::<f32>(device, "transport out xyz", capacity * 3, usage),
radiance: compute::zeroed_buffer::<f32>(
device,
"transport out radiance",
capacity * 8,
usage,
),
lambdas: compute::zeroed_buffer::<f32>(
device,
"transport out lambdas",
capacity * 8,
usage,
),
path_pdf: compute::zeroed_buffer::<f32>(
device,
"transport out path_pdf",
capacity * 8,
usage,
),
compat: compute::zeroed_buffer::<u32>(
device,
"transport out compat",
capacity * 8,
usage,
),
capacity,
}
}
pub(crate) fn new_production(device: &wgpu::Device, capacity: usize) -> Self {
let usage = BufferUsages::STORAGE | BufferUsages::COPY_SRC;
Self {
xyz: compute::zeroed_buffer::<f32>(
device,
"transport out xyz (production)",
capacity * 3,
usage,
),
radiance: compute::zeroed_buffer::<f32>(
device,
"transport out radiance (production, write_debug_buffers=0, unused)",
DEBUG_BUFFER_FLOATS,
usage,
),
lambdas: compute::zeroed_buffer::<f32>(
device,
"transport out lambdas (production, write_debug_buffers=0, unused)",
DEBUG_BUFFER_FLOATS,
usage,
),
path_pdf: compute::zeroed_buffer::<f32>(
device,
"transport out path_pdf (production, write_debug_buffers=0, unused)",
DEBUG_BUFFER_FLOATS,
usage,
),
compat: compute::zeroed_buffer::<u32>(
device,
"transport out compat (production, write_debug_buffers=0, unused)",
DEBUG_BUFFER_FLOATS,
usage,
),
capacity,
}
}
pub(crate) const fn xyz(&self) -> &wgpu::Buffer {
&self.xyz
}
pub(crate) const fn radiance(&self) -> &wgpu::Buffer {
&self.radiance
}
pub(crate) const fn lambdas(&self) -> &wgpu::Buffer {
&self.lambdas
}
pub(crate) const fn path_pdf(&self) -> &wgpu::Buffer {
&self.path_pdf
}
pub(crate) const fn compat(&self) -> &wgpu::Buffer {
&self.compat
}
}
pub(crate) struct TransportDispatchArgs<'a> {
pub(crate) ctx: &'a GpuContext,
pub(crate) pipeline: &'a wgpu::ComputePipeline,
pub(crate) camera_params: &'a GpuCameraParams,
pub(crate) params: &'a GpuTransportParams,
pub(crate) material: &'a GpuGemMaterial,
pub(crate) planes: &'a [GpuFacetPlane],
pub(crate) facet_finishes: &'a [u32],
pub(crate) outputs: &'a TransportOutputs,
}
fn build_bind_group(args: &TransportDispatchArgs<'_>) -> wgpu::BindGroup {
let camera_buf = compute::upload(
&args.ctx.device,
"transport camera",
std::slice::from_ref(args.camera_params),
BufferUsages::UNIFORM,
);
let params_buf = compute::upload(
&args.ctx.device,
"transport params",
std::slice::from_ref(args.params),
BufferUsages::UNIFORM,
);
let material_buf = compute::upload(
&args.ctx.device,
"transport material",
std::slice::from_ref(args.material),
BufferUsages::STORAGE,
);
let planes_buf = compute::upload(
&args.ctx.device,
"transport planes",
args.planes,
BufferUsages::STORAGE,
);
let facet_finishes_buf = compute::upload(
&args.ctx.device,
"transport facet finishes",
args.facet_finishes,
BufferUsages::STORAGE,
);
compute::bind_buffers(
&args.ctx.device,
"transport bind group",
args.pipeline,
&[
(0, &camera_buf),
(1, ¶ms_buf),
(2, &material_buf),
(3, &planes_buf),
(4, &args.outputs.xyz),
(5, &args.outputs.radiance),
(6, &args.outputs.lambdas),
(7, &args.outputs.path_pdf),
(8, &facet_finishes_buf),
(9, &args.outputs.compat),
],
)
}
pub(crate) fn encode_and_dispatch(args: &TransportDispatchArgs<'_>, total_tuples: usize) {
assert!(
total_tuples <= args.outputs.capacity,
"dispatch of {total_tuples} tuples exceeds output capacity {}",
args.outputs.capacity
);
let bind_group = build_bind_group(args);
let workgroups = (total_tuples as u32).div_ceil(64);
compute::dispatch_and_wait(
&args.ctx.device,
&args.ctx.queue,
args.pipeline,
&bind_group,
(workgroups, 1, 1),
);
}
struct FrameSceneBuffers {
camera: wgpu::Buffer,
material: wgpu::Buffer,
planes: wgpu::Buffer,
facet_finishes: wgpu::Buffer,
}
impl FrameSceneBuffers {
fn upload(
ctx: &GpuContext,
camera_params: &GpuCameraParams,
material: &GpuGemMaterial,
planes: &[GpuFacetPlane],
facet_finishes: &[u32],
) -> Self {
Self {
camera: compute::upload(
&ctx.device,
"transport camera (per-frame)",
std::slice::from_ref(camera_params),
BufferUsages::UNIFORM,
),
material: compute::upload(
&ctx.device,
"transport material (per-frame)",
std::slice::from_ref(material),
BufferUsages::STORAGE,
),
planes: compute::upload(
&ctx.device,
"transport planes (per-frame)",
planes,
BufferUsages::STORAGE,
),
facet_finishes: compute::upload(
&ctx.device,
"transport facet finishes (per-frame)",
facet_finishes,
BufferUsages::STORAGE,
),
}
}
}
fn build_chunk_bind_group(
ctx: &GpuContext,
pipeline: &wgpu::ComputePipeline,
frame_buffers: &FrameSceneBuffers,
params: &GpuTransportParams,
outputs: &TransportOutputs,
) -> wgpu::BindGroup {
let params_buf = compute::upload(
&ctx.device,
"transport params",
std::slice::from_ref(params),
BufferUsages::UNIFORM,
);
compute::bind_buffers(
&ctx.device,
"transport bind group (pipelined production)",
pipeline,
&[
(0, &frame_buffers.camera),
(1, ¶ms_buf),
(2, &frame_buffers.material),
(3, &frame_buffers.planes),
(4, outputs.xyz()),
(5, outputs.radiance()),
(6, outputs.lambdas()),
(7, outputs.path_pdf()),
(8, &frame_buffers.facet_finishes),
(9, outputs.compat()),
],
)
}
struct StagingSlot {
buffer: wgpu::Buffer,
capacity: usize,
}
impl StagingSlot {
fn new(device: &wgpu::Device, label: &str, capacity: usize) -> Self {
let byte_len = (capacity * FLOATS_PER_TUPLE * size_of::<f32>()) as wgpu::BufferAddress;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: byte_len,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
Self { buffer, capacity }
}
}
const fn staging_needs_growth(current_capacity: usize, required: usize) -> bool {
current_capacity < required
}
fn copy_to_existing_staging<T: bytemuck::Pod>(
device: &wgpu::Device,
queue: &wgpu::Queue,
source: &wgpu::Buffer,
staging: &wgpu::Buffer,
count: usize,
) -> wgpu::SubmissionIndex {
let byte_len = (count * size_of::<T>()) as wgpu::BufferAddress;
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("indicatrix gpu pipelined readback copy encoder (persistent staging)"),
});
encoder.copy_buffer_to_buffer(source, 0, staging, 0, byte_len);
queue.submit(Some(encoder.finish()))
}
pub(crate) mod material_class {
pub const GENERIC: u32 = 0;
pub const ISOTROPIC: u32 = 1;
pub const UNIAXIAL: u32 = 2;
pub const BIAXIAL: u32 = 3;
}
#[must_use]
pub(crate) fn classify_material(material: &GemMaterial) -> u32 {
if material.biaxial_delta_beta_alpha.is_some() {
material_class::BIAXIAL
} else if material.crystal_system != CrystalSystem::Cubic
&& material.birefringence_delta.abs() > 1e-4
{
material_class::UNIAXIAL
} else {
material_class::ISOTROPIC
}
}
pub struct GpuFrameRenderer {
ctx: GpuContext,
pipeline: wgpu::ComputePipeline,
pipeline_isotropic: Option<wgpu::ComputePipeline>,
pipeline_uniaxial: Option<wgpu::ComputePipeline>,
pipeline_biaxial: Option<wgpu::ComputePipeline>,
adapter_label: String,
outputs: [Option<TransportOutputs>; 2],
staging: [Option<StagingSlot>; 2],
chunk_budget_bytes: usize,
ns_per_tuple_ema: Option<f64>,
}
struct PendingChunk {
staging_slot: usize,
copy_index: wgpu::SubmissionIndex,
rx: std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
first_pixel: usize,
pixels_this_chunk: usize,
submitted_at: Instant,
}
struct ChunkFrameState<'a> {
scene: &'a GpuFrameScene<'a>,
pipeline_class: u32,
sample_offset: u32,
spp: u32,
env_mode: u32,
temp_k: f32,
spot_mult: f32,
exposure: f32,
light_yaw: f32,
light_pitch: f32,
white_balance: Vec3,
use_d65: bool,
}
impl GpuFrameRenderer {
pub fn new() -> Result<Self, GpuFrameError> {
let ctx = GpuContext::acquire().map_err(GpuFrameError::Acquire)?;
let info = ctx.adapter.get_info();
let adapter_label = format!("{} ({:?})", info.name, info.backend);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"transport_main",
SHADER_SRC,
"transport_main",
);
Ok(Self {
ctx,
pipeline,
pipeline_isotropic: None,
pipeline_uniaxial: None,
pipeline_biaxial: None,
adapter_label,
outputs: [None, None],
staging: [None, None],
chunk_budget_bytes: CHUNK_BUDGET_BYTES,
ns_per_tuple_ema: None,
})
}
pub const fn set_chunk_budget_bytes(&mut self, bytes: usize) {
self.chunk_budget_bytes = bytes;
}
#[must_use]
pub fn adapter_label(&self) -> &str {
&self.adapter_label
}
pub fn accumulate(
&mut self,
scene: &GpuFrameScene<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), GpuFrameError> {
let pipeline_class = classify_material(scene.material);
self.accumulate_via_pipeline(scene, pipeline_class, sample_offset, spp, accum, None)?;
Ok(())
}
pub fn accumulate_cancellable(
&mut self,
scene: &GpuFrameScene<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
cancel: &AtomicBool,
) -> Result<AccumulateOutcome, GpuFrameError> {
let pipeline_class = classify_material(scene.material);
self.accumulate_via_pipeline(
scene,
pipeline_class,
sample_offset,
spp,
accum,
Some(cancel),
)
}
fn accumulate_via_pipeline(
&mut self,
scene: &GpuFrameScene<'_>,
pipeline_class: u32,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
cancel: Option<&AtomicBool>,
) -> Result<AccumulateOutcome, GpuFrameError> {
let num_pixels = scene.width as usize * scene.height as usize;
assert_eq!(
accum.len(),
num_pixels,
"accumulation buffer must have one entry per pixel"
);
if spp == 0 || num_pixels == 0 {
return Ok(AccumulateOutcome::Done);
}
if !scene.material.gpu_supported() {
return Err(GpuFrameError::UnsupportedMaterial(
scene.material.name.clone(),
));
}
let (env_mode, temp_k, spot_mult, exposure, light_yaw, light_pitch, use_d65) =
environment_params(scene.environment)?;
let white_balance = compute_illuminant_white_balance(temp_k);
let gpu_material = GpuGemMaterial::encode(scene.material);
let gpu_finishes = encode_facet_finishes(scene.facet_finishes, scene.planes.len());
self.ensure_specialized_pipeline(pipeline_class);
let byte_budget_pixels = chunk_pixels_for(self.chunk_budget_bytes, spp, num_pixels);
self.ensure_capacity(byte_budget_pixels * spp as usize);
self.ensure_staging_capacity(byte_budget_pixels * spp as usize);
let camera_params = GpuCameraParams {
origin: scene.camera.origin.to_array(),
fov_tan: scene.camera.fov_tan,
forward: scene.camera.forward.to_array(),
width: scene.width as f32,
right: scene.camera.right.to_array(),
height: scene.height as f32,
up: scene.camera.up.to_array(),
num_samples: spp,
};
let frame_buffers = FrameSceneBuffers::upload(
&self.ctx,
&camera_params,
&gpu_material,
scene.planes,
&gpu_finishes,
);
let state = ChunkFrameState {
scene,
pipeline_class,
sample_offset,
spp,
env_mode,
temp_k,
spot_mult,
exposure,
light_yaw,
light_pitch,
white_balance,
use_d65,
};
let mut pending: Option<PendingChunk> = None;
let mut first_pixel = 0usize;
let mut chunk_index = 0usize;
let mut cancelled = false;
while first_pixel < num_pixels {
if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
cancelled = true;
break;
}
let chunk_pixels =
Self::next_chunk_pixels(self.ns_per_tuple_ema, byte_budget_pixels, spp, num_pixels);
let pixels_this_chunk = chunk_pixels.min(num_pixels - first_pixel);
let new_pending = self.dispatch_chunk(
&state,
&frame_buffers,
first_pixel,
pixels_this_chunk,
chunk_index,
);
if let Some(prev) = pending.take() {
self.drain_pending_chunk(prev, spp, Some(&mut *accum))?;
}
pending = Some(new_pending);
first_pixel += pixels_this_chunk;
chunk_index += 1;
}
if cancelled {
if let Some(chunk) = pending.take() {
self.drain_pending_chunk(chunk, spp, None)?;
}
return Ok(AccumulateOutcome::Cancelled);
}
if let Some(last) = pending.take() {
self.drain_pending_chunk(last, spp, Some(accum))?;
}
Ok(AccumulateOutcome::Done)
}
fn dispatch_chunk(
&self,
state: &ChunkFrameState<'_>,
frame_buffers: &FrameSceneBuffers,
first_pixel: usize,
pixels_this_chunk: usize,
chunk_index: usize,
) -> PendingChunk {
let tuples = pixels_this_chunk * state.spp as usize;
let params = GpuTransportParams::new(
pixels_this_chunk as u32,
state.scene.max_bounces,
state.sample_offset,
state.env_mode,
0.0,
state.temp_k,
state.spot_mult,
state.exposure,
state.light_yaw,
state.light_pitch,
state.white_balance.to_array(),
)
.with_pixel_offset(first_pixel as u32)
.with_debug_buffers_disabled()
.with_studio_use_d65(state.use_d65);
let staging_slot = chunk_index % 2;
let outputs = self.outputs[staging_slot]
.as_ref()
.expect("ensure_capacity just populated both slots");
assert!(
tuples <= outputs.capacity,
"dispatch of {tuples} tuples exceeds output capacity {}",
outputs.capacity
);
let staging_buffer = &self.staging[staging_slot]
.as_ref()
.expect("ensure_staging_capacity just populated both slots")
.buffer;
let submitted_at = Instant::now();
let pipeline = self.pipeline_for_class(state.pipeline_class);
let bind_group =
build_chunk_bind_group(&self.ctx, pipeline, frame_buffers, ¶ms, outputs);
let workgroups = (tuples as u32).div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
&self.ctx.device,
&self.ctx.queue,
pipeline,
&bind_group,
(workgroups, 1, 1),
);
let copy_index = copy_to_existing_staging::<f32>(
&self.ctx.device,
&self.ctx.queue,
outputs.xyz(),
staging_buffer,
tuples * 3,
);
let rx = compute::begin_map_read(staging_buffer);
PendingChunk {
staging_slot,
copy_index,
rx,
first_pixel,
pixels_this_chunk,
submitted_at,
}
}
fn next_chunk_pixels(
ema_ns_per_tuple: Option<f64>,
byte_budget_pixels: usize,
spp: u32,
num_pixels: usize,
) -> usize {
let spp_usize = (spp as usize).max(1);
let Some(ns_per_tuple) = ema_ns_per_tuple.filter(|v| *v > 0.0) else {
let first_dispatch_pixels = (FIRST_DISPATCH_MAX_TUPLES / spp_usize).max(1);
return byte_budget_pixels
.min(first_dispatch_pixels)
.min(num_pixels)
.max(1);
};
let target_tuples = (TARGET_CHUNK_MS * 1.0e6 / ns_per_tuple).max(1.0);
let target_pixels = ((target_tuples / spp_usize as f64).floor() as usize).max(1);
let min_pixels = chunk_pixels_for(MIN_CHUNK_BYTES, spp, num_pixels).min(byte_budget_pixels);
target_pixels.clamp(min_pixels, byte_budget_pixels)
}
fn drain_pending_chunk(
&mut self,
chunk: PendingChunk,
spp: u32,
accum: Option<&mut [Vec3]>,
) -> Result<(), GpuFrameError> {
let staging_buffer = &self.staging[chunk.staging_slot]
.as_ref()
.expect("dispatch_chunk populated this staging slot")
.buffer;
let xyz: Vec<f32> = compute::finish_map_read(
&self.ctx.device,
staging_buffer,
chunk.copy_index,
&chunk.rx,
)
.map_err(|e| GpuFrameError::DeviceLost(e.to_string()))?;
let tuples_measured = chunk.pixels_this_chunk * spp as usize;
if tuples_measured > 0 {
let elapsed_ns = chunk.submitted_at.elapsed().as_nanos() as f64;
let measured_ns_per_tuple = elapsed_ns / tuples_measured as f64;
self.ns_per_tuple_ema =
Some(self.ns_per_tuple_ema.map_or(measured_ns_per_tuple, |prev| {
CHUNK_TIMING_EMA_ALPHA.mul_add(measured_ns_per_tuple - prev, prev)
}));
}
if let Some(accum) = accum {
for local_pixel in 0..chunk.pixels_this_chunk {
let mut sum = Vec3::ZERO;
for s in 0..spp as usize {
let base = (local_pixel * spp as usize + s) * 3;
sum += Vec3::new(xyz[base], xyz[base + 1], xyz[base + 2]);
}
accum[chunk.first_pixel + local_pixel] += sum;
}
}
Ok(())
}
fn ensure_specialized_pipeline(&mut self, class: u32) {
let (slot, label) = match class {
material_class::ISOTROPIC => (
&mut self.pipeline_isotropic,
"transport_main (MATERIAL_CLASS=isotropic)",
),
material_class::UNIAXIAL => (
&mut self.pipeline_uniaxial,
"transport_main (MATERIAL_CLASS=uniaxial)",
),
material_class::BIAXIAL => (
&mut self.pipeline_biaxial,
"transport_main (MATERIAL_CLASS=biaxial)",
),
_ => return,
};
if slot.is_none() {
*slot = Some(compute::create_compute_pipeline_with_constants(
&self.ctx.device,
label,
SHADER_SRC,
"transport_main",
&[("MATERIAL_CLASS", f64::from(class))],
));
}
}
const fn pipeline_for_class(&self, class: u32) -> &wgpu::ComputePipeline {
match class {
material_class::ISOTROPIC => self.pipeline_isotropic.as_ref(),
material_class::UNIAXIAL => self.pipeline_uniaxial.as_ref(),
material_class::BIAXIAL => self.pipeline_biaxial.as_ref(),
_ => Some(&self.pipeline),
}
.expect("ensure_specialized_pipeline must be called for this class before dispatching")
}
fn ensure_capacity(&mut self, tuples: usize) {
for slot in &mut self.outputs {
let big_enough = slot.as_ref().is_some_and(|o| o.capacity >= tuples);
if !big_enough {
*slot = Some(TransportOutputs::new_production(&self.ctx.device, tuples));
}
}
}
fn ensure_staging_capacity(&mut self, tuples: usize) {
for slot in &mut self.staging {
let current_capacity = slot.as_ref().map_or(0, |s| s.capacity);
if staging_needs_growth(current_capacity, tuples) {
*slot = Some(StagingSlot::new(
&self.ctx.device,
"transport out xyz staging (persistent)",
tuples,
));
}
}
}
}
#[allow(
clippy::future_not_send,
reason = "wasm32-unknown-unknown has no second thread to send a future to; see the \
module-level comment above this impl block"
)]
#[cfg(target_arch = "wasm32")]
impl GpuFrameRenderer {
pub async fn new_async() -> Result<Self, GpuFrameError> {
let ctx = GpuContext::acquire_async()
.await
.map_err(GpuFrameError::Acquire)?;
let info = ctx.adapter.get_info();
let adapter_label = format!("{} ({:?})", info.name, info.backend);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"transport_main",
SHADER_SRC,
"transport_main",
);
Ok(Self {
ctx,
pipeline,
pipeline_isotropic: None,
pipeline_uniaxial: None,
pipeline_biaxial: None,
adapter_label,
outputs: [None, None],
staging: [None, None],
chunk_budget_bytes: CHUNK_BUDGET_BYTES,
ns_per_tuple_ema: None,
})
}
pub async fn accumulate_async(
&mut self,
scene: &GpuFrameScene<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), GpuFrameError> {
let pipeline_class = classify_material(scene.material);
let num_pixels = scene.width as usize * scene.height as usize;
assert_eq!(
accum.len(),
num_pixels,
"accumulation buffer must have one entry per pixel"
);
if spp == 0 || num_pixels == 0 {
return Ok(());
}
if !scene.material.gpu_supported() {
return Err(GpuFrameError::UnsupportedMaterial(
scene.material.name.clone(),
));
}
let (env_mode, temp_k, spot_mult, exposure, light_yaw, light_pitch, use_d65) =
environment_params(scene.environment)?;
let white_balance = compute_illuminant_white_balance(temp_k);
let gpu_material = GpuGemMaterial::encode(scene.material);
let gpu_finishes = encode_facet_finishes(scene.facet_finishes, scene.planes.len());
self.ensure_specialized_pipeline(pipeline_class);
let chunk_pixels = chunk_pixels_for(self.chunk_budget_bytes, spp, num_pixels);
self.ensure_capacity(chunk_pixels * spp as usize);
let mut first_pixel = 0usize;
let mut chunk_index = 0usize;
while first_pixel < num_pixels {
let pixels_this_chunk = chunk_pixels.min(num_pixels - first_pixel);
let tuples = pixels_this_chunk * spp as usize;
let camera_params = GpuCameraParams {
origin: scene.camera.origin.to_array(),
fov_tan: scene.camera.fov_tan,
forward: scene.camera.forward.to_array(),
width: scene.width as f32,
right: scene.camera.right.to_array(),
height: scene.height as f32,
up: scene.camera.up.to_array(),
num_samples: spp,
};
let params = GpuTransportParams::new(
pixels_this_chunk as u32,
scene.max_bounces,
sample_offset,
env_mode,
0.0,
temp_k,
spot_mult,
exposure,
light_yaw,
light_pitch,
white_balance.to_array(),
)
.with_pixel_offset(first_pixel as u32)
.with_debug_buffers_disabled()
.with_studio_use_d65(use_d65);
let outputs = self.outputs[chunk_index % 2]
.as_ref()
.expect("ensure_capacity just populated both slots");
let bind_args = TransportDispatchArgs {
ctx: &self.ctx,
pipeline: self.pipeline_for_class(pipeline_class),
camera_params: &camera_params,
params: ¶ms,
material: &gpu_material,
planes: scene.planes,
facet_finishes: &gpu_finishes,
outputs,
};
let bind_group = build_bind_group(&bind_args);
let workgroups = (tuples as u32).div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
&self.ctx.device,
&self.ctx.queue,
bind_args.pipeline,
&bind_group,
(workgroups, 1, 1),
);
let (staging, _copy_index) = compute::copy_to_staging::<f32>(
&self.ctx.device,
&self.ctx.queue,
outputs.xyz(),
tuples * 3,
"transport out xyz staging (wasm32 async)",
);
let xyz: Vec<f32> = map_read_async(&staging, tuples * 3).await?;
for local_pixel in 0..pixels_this_chunk {
let mut sum = Vec3::ZERO;
for s in 0..spp as usize {
let base = (local_pixel * spp as usize + s) * 3;
sum += Vec3::new(xyz[base], xyz[base + 1], xyz[base + 2]);
}
accum[first_pixel + local_pixel] += sum;
}
first_pixel += pixels_this_chunk;
chunk_index += 1;
}
Ok(())
}
}
#[cfg(target_arch = "wasm32")]
#[allow(
clippy::future_not_send,
reason = "wgpu's web backend's buffer-mapping state is inherently !Send (browser-side \
Rc handles); wasm32-unknown-unknown has no second thread to send this future \
to regardless -- see GpuContext::acquire_async's identical allow"
)]
async fn map_read_async<T: bytemuck::Pod>(
staging: &wgpu::Buffer,
count: usize,
) -> Result<Vec<T>, GpuFrameError> {
let (tx, rx) = futures_channel::oneshot::channel();
staging
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
rx.await
.map_err(|_| {
GpuFrameError::DeviceLost(
"wgpu buffer-mapping callback was dropped before it fired".to_string(),
)
})?
.map_err(|e| GpuFrameError::DeviceLost(format!("wgpu buffer mapping failed: {e}")))?;
let out = {
let data = staging.slice(..).get_mapped_range().map_err(|e| {
GpuFrameError::DeviceLost(format!("mapped buffer range could not be read: {e}"))
})?;
bytemuck::cast_slice::<u8, T>(&data).to_vec()
};
debug_assert_eq!(
out.len(),
count,
"staging buffer length must match copy_to_staging's count"
);
staging.unmap();
Ok(out)
}
const WORKGROUP_SIZE: usize = 64;
const MAX_WORKGROUPS_PER_DIMENSION: usize = 65_535;
fn chunk_pixels_for(budget_bytes: usize, spp: u32, num_pixels: usize) -> usize {
let budget_tuples = budget_bytes / (FLOATS_PER_TUPLE * size_of::<f32>());
let dispatch_limited_tuples = MAX_WORKGROUPS_PER_DIMENSION * WORKGROUP_SIZE;
let tuples_per_chunk = budget_tuples.min(dispatch_limited_tuples);
(tuples_per_chunk / spp as usize).max(1).min(num_pixels)
}
type EnvironmentParams = (u32, f32, f32, f32, f32, f32, bool);
const fn environment_params(
environment: EnvironmentSource<'_>,
) -> Result<EnvironmentParams, GpuFrameError> {
match environment {
EnvironmentSource::Studio {
preset,
exposure,
light_yaw,
light_pitch,
} => Ok((
transport_env_mode::STUDIO_RIG,
illuminant_temperature_k(preset),
preset.params().spot_mult,
exposure,
light_yaw,
light_pitch,
matches!(preset, LightingPreset::Daylight),
)),
EnvironmentSource::HdrMap(_) => Err(GpuFrameError::UnsupportedEnvironment),
}
}
pub struct ChunkEquivalenceResult {
pub chunks_forced: usize,
pub differing_pixels: usize,
pub total_pixels: usize,
pub max_abs_diff: f32,
}
impl ChunkEquivalenceResult {
#[must_use]
pub const fn passed(&self) -> bool {
self.chunks_forced > 1 && self.differing_pixels == 0
}
}
#[must_use]
pub fn run_chunk_equivalence(renderer: &mut GpuFrameRenderer) -> ChunkEquivalenceResult {
use crate::geometry::cuts::StandardGemCuts;
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let (width, height) = (64u32, 64u32);
let spp = 2u32;
let num_pixels = (width * height) as usize;
let scene = GpuFrameScene {
camera: &camera,
width,
height,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 8,
environment: LightingPreset::Daylight.studio(1.0, 0.4, 0.35),
};
let mut whole = vec![Vec3::ZERO; num_pixels];
renderer.set_chunk_budget_bytes(CHUNK_BUDGET_BYTES);
renderer
.accumulate(&scene, 0, spp, &mut whole)
.expect("a cubic material under the studio rig is GPU-supported");
let budget = 700 * FLOATS_PER_TUPLE * size_of::<f32>();
let chunk_pixels = chunk_pixels_for(budget, spp, num_pixels);
let chunks_forced = num_pixels.div_ceil(chunk_pixels);
let mut chunked = vec![Vec3::ZERO; num_pixels];
renderer.set_chunk_budget_bytes(budget);
renderer
.accumulate(&scene, 0, spp, &mut chunked)
.expect("a cubic material under the studio rig is GPU-supported");
renderer.set_chunk_budget_bytes(CHUNK_BUDGET_BYTES);
let mut differing_pixels = 0usize;
let mut max_abs_diff = 0.0f32;
for (a, b) in whole.iter().zip(&chunked) {
if a.to_array()
.iter()
.zip(b.to_array().iter())
.any(|(x, y)| x.to_bits() != y.to_bits())
{
differing_pixels += 1;
max_abs_diff = max_abs_diff.max((*a - *b).abs().max_element());
}
}
ChunkEquivalenceResult {
chunks_forced,
differing_pixels,
total_pixels: num_pixels,
max_abs_diff,
}
}
#[derive(Debug, Clone)]
pub struct SpecialisationCaseResult {
pub material_name: String,
pub material_class: u32,
pub self_determinism_differing_pixels: usize,
pub generic_vs_specialised_differing_pixels: usize,
pub total_pixels: usize,
pub generic_vs_specialised_max_abs_diff: f32,
}
impl SpecialisationCaseResult {
#[must_use]
pub const fn passed(&self) -> bool {
self.self_determinism_differing_pixels == 0
}
}
pub struct SpecialisationEquivalenceResult {
pub cases: Vec<SpecialisationCaseResult>,
}
impl SpecialisationEquivalenceResult {
#[must_use]
pub fn passed(&self) -> bool {
!self.cases.is_empty() && self.cases.iter().all(SpecialisationCaseResult::passed)
}
}
#[must_use]
pub fn run_specialisation_equivalence(
renderer: &mut GpuFrameRenderer,
) -> SpecialisationEquivalenceResult {
use crate::geometry::cuts::StandardGemCuts;
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let planes = StandardGemCuts::standard_round_brilliant();
let (width, height) = (32u32, 32u32);
let spp = 3u32;
let num_pixels = (width * height) as usize;
let environment = LightingPreset::Daylight.studio(1.0, 0.4, 0.35);
let representative_materials = ["Diamond", "Zircon", "Alexandrite"];
let mut cases = Vec::with_capacity(representative_materials.len());
for name in representative_materials {
let material = GemMaterial::by_name(name).unwrap_or_else(|| {
panic!("{name:?} is a built-in material in GemMaterial::all_materials()")
});
let scene = GpuFrameScene {
camera: &camera,
width,
height,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 8,
environment,
};
let class = classify_material(&material);
let mut run1 = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate_via_pipeline(&scene, class, 0, spp, &mut run1, None)
.expect("every representative material here is GPU-supported under the studio rig");
let mut run2 = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate_via_pipeline(&scene, class, 0, spp, &mut run2, None)
.expect("every representative material here is GPU-supported under the studio rig");
let self_determinism_differing_pixels = run1
.iter()
.zip(&run2)
.filter(|(a, b)| {
a.to_array()
.iter()
.zip(b.to_array().iter())
.any(|(x, y)| x.to_bits() != y.to_bits())
})
.count();
let mut generic = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate_via_pipeline(&scene, material_class::GENERIC, 0, spp, &mut generic, None)
.expect("every representative material here is GPU-supported under the studio rig");
let mut generic_vs_specialised_differing_pixels = 0usize;
let mut generic_vs_specialised_max_abs_diff = 0.0f32;
for (a, b) in generic.iter().zip(&run1) {
if a.to_array()
.iter()
.zip(b.to_array().iter())
.any(|(x, y)| x.to_bits() != y.to_bits())
{
generic_vs_specialised_differing_pixels += 1;
generic_vs_specialised_max_abs_diff =
generic_vs_specialised_max_abs_diff.max((*a - *b).abs().max_element());
}
}
cases.push(SpecialisationCaseResult {
material_name: name.to_string(),
material_class: class,
self_determinism_differing_pixels,
generic_vs_specialised_differing_pixels,
total_pixels: num_pixels,
generic_vs_specialised_max_abs_diff,
});
}
SpecialisationEquivalenceResult { cases }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staging_needs_growth_only_when_undersized() {
assert!(staging_needs_growth(0, 1));
assert!(staging_needs_growth(99, 100));
assert!(!staging_needs_growth(100, 100));
assert!(!staging_needs_growth(1_000, 100));
}
#[test]
fn chunking_divides_the_budget_by_samples_per_pixel() {
let budget = 10 * FLOATS_PER_TUPLE * size_of::<f32>();
assert_eq!(chunk_pixels_for(budget, 1, 10_000), 10);
assert_eq!(chunk_pixels_for(budget, 2, 10_000), 5);
assert_eq!(chunk_pixels_for(budget, 3, 10_000), 3);
}
#[test]
fn a_frame_smaller_than_the_budget_is_one_chunk() {
let pixels = 800 * 600;
assert_eq!(chunk_pixels_for(CHUNK_BUDGET_BYTES, 1, pixels), pixels);
}
#[test]
fn a_budget_below_one_tuple_still_yields_a_chunk() {
assert_eq!(chunk_pixels_for(0, 4, 10_000), 1);
assert_eq!(chunk_pixels_for(8, 1, 10_000), 1);
}
#[test]
fn first_dispatch_is_capped_regardless_of_byte_budget() {
let byte_budget_pixels = chunk_pixels_for(CHUNK_BUDGET_BYTES, 4, 10_000_000);
let picked = GpuFrameRenderer::next_chunk_pixels(None, byte_budget_pixels, 4, 10_000_000);
assert!(picked <= FIRST_DISPATCH_MAX_TUPLES / 4);
assert!(picked <= byte_budget_pixels);
}
#[test]
fn time_budgeted_sizing_never_exceeds_the_byte_budget() {
let byte_budget_pixels = chunk_pixels_for(CHUNK_BUDGET_BYTES, 1, 10_000_000);
let picked = GpuFrameRenderer::next_chunk_pixels(
Some(1.0e-6), byte_budget_pixels,
1,
10_000_000,
);
assert!(picked <= byte_budget_pixels);
}
#[test]
fn time_budgeted_sizing_never_shrinks_below_the_floor() {
let num_pixels = 10_000_000;
let byte_budget_pixels = chunk_pixels_for(CHUNK_BUDGET_BYTES, 1, num_pixels);
let min_pixels = chunk_pixels_for(MIN_CHUNK_BYTES, 1, num_pixels);
let picked = GpuFrameRenderer::next_chunk_pixels(
Some(1.0e9), byte_budget_pixels,
1,
num_pixels,
);
assert_eq!(picked, min_pixels);
}
#[test]
fn a_byte_budget_smaller_than_the_timing_floor_still_wins() {
let num_pixels = 10_000;
let tiny_budget_bytes = 700 * FLOATS_PER_TUPLE * size_of::<f32>();
let byte_budget_pixels = chunk_pixels_for(tiny_budget_bytes, 2, num_pixels);
for ema in [Some(1.0e-6), Some(1.0e9), None] {
let picked =
GpuFrameRenderer::next_chunk_pixels(ema, byte_budget_pixels, 2, num_pixels);
assert!(
picked <= byte_budget_pixels,
"byte budget must never be exceeded (ema={ema:?}, picked={picked}, \
byte_budget_pixels={byte_budget_pixels})"
);
}
}
#[test]
fn studio_environments_map_onto_the_studio_rig_mode() {
let (mode, temp_k, spot_mult, exposure, yaw, pitch, use_d65) =
environment_params(LightingPreset::Daylight.studio(1.5, 0.4, 0.35))
.expect("the studio rig is a supported environment");
assert_eq!(mode, transport_env_mode::STUDIO_RIG);
assert_eq!(temp_k, illuminant_temperature_k(LightingPreset::Daylight));
assert_eq!(spot_mult, LightingPreset::Daylight.params().spot_mult);
assert_eq!((exposure, yaw, pitch), (1.5, 0.4, 0.35));
assert!(use_d65);
}
#[test]
fn hdr_environment_maps_are_declined() {
let map = crate::renderer::env_map::EnvironmentMap::uniform(4, 2, [1.0, 1.0, 1.0]);
assert!(matches!(
environment_params(EnvironmentSource::HdrMap(&map)),
Err(GpuFrameError::UnsupportedEnvironment)
));
}
#[test]
fn every_builtin_routes_the_way_gpu_supported_says() {
for material in GemMaterial::all_materials() {
assert!(
material.gpu_supported(),
"{} is not GPU-supported, but the megakernel claims to cover every built-in since the Phase 4 biaxial port -- if this is a deliberate new exclusion, `accumulate`'s decline path must cover it too",
material.name
);
}
}
}