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,
environment::environment_white_balance, illuminant_temperature_k,
},
},
renderer::{
buffers::{
GpuCameraParams, GpuGemMaterial, GpuTransportParams, GpuWavefrontParams,
encode_facet_finishes, transport_env_mode,
},
env_map_gpu::HdrEnvGpuData,
gpu::{
GpuAcquireError, GpuContext, MEGAKERNEL_STORAGE_BUFFERS, WAVEFRONT_STORAGE_BUFFERS,
compute,
},
},
};
pub(crate) const SHADER_SRC: &str = include_str!(concat!(
env!("OUT_DIR"),
"/spectral_transport.generated.wgsl"
));
const REDUCE_SHADER_SRC: &str = include_str!("../shaders/reduce_xyz.wgsl");
const WAVEFRONT_SHADER_SRC: &str = include_str!(concat!(
env!("OUT_DIR"),
"/wavefront_transport.generated.wgsl"
));
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct GpuReduceParams {
num_pixels: u32,
num_samples: u32,
_pad0: u32,
_pad1: u32,
}
const FLOATS_PER_TUPLE: usize = 3;
pub use crate::renderer::gpu_backend::GpuPipelineKind;
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,
DeviceLimits { needed: u32, available: u32 },
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::DeviceLimits { needed, available } => write!(
f,
"the GPU device allows {available} storage buffers per compute stage; the transport megakernel needs {needed}"
),
Self::UnsupportedEnvironment => {
write!(f, "the GPU megakernel has no env_mode for this environment")
}
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,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ChunkCursor {
first_pixel: usize,
chunk_index: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ChunkTurnOutcome {
Done,
Cancelled,
MoreWork,
}
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
}
}
struct PixelXyzOutput {
buffer: wgpu::Buffer,
capacity: usize,
}
impl PixelXyzOutput {
fn new(device: &wgpu::Device, capacity: usize) -> Self {
let usage = BufferUsages::STORAGE | BufferUsages::COPY_SRC;
Self {
buffer: compute::zeroed_buffer::<f32>(
device,
"transport out pixel xyz (GPU-reduced)",
capacity * 3,
usage,
),
capacity,
}
}
}
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,
pub(crate) hdr_env: &'a HdrEnvGpuData,
}
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),
(10, &args.hdr_env.texels),
(11, &args.hdr_env.dims),
(12, &args.hdr_env.dist_func),
(13, &args.hdr_env.dist_cdf),
(14, &args.hdr_env.dist_dims),
],
)
}
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,
planes_capacity: usize,
facet_finishes: wgpu::Buffer,
facet_finishes_capacity: usize,
hdr_env: HdrEnvGpuData,
hdr_env_identity: Option<usize>,
}
fn build_hdr_env(
device: &wgpu::Device,
environment: EnvironmentSource<'_>,
) -> (HdrEnvGpuData, Option<usize>) {
match environment {
EnvironmentSource::HdrMap(map) => {
let identity = std::ptr::from_ref(map) as usize;
(HdrEnvGpuData::upload(device, map), Some(identity))
}
EnvironmentSource::Studio { .. } => (HdrEnvGpuData::dummy(device), None),
}
}
#[derive(Clone, Copy)]
struct SceneBufferInputs<'a> {
camera_params: &'a GpuCameraParams,
material: &'a GpuGemMaterial,
planes: &'a [GpuFacetPlane],
facet_finishes: &'a [u32],
environment: EnvironmentSource<'a>,
}
impl FrameSceneBuffers {
fn new(device: &wgpu::Device, inputs: &SceneBufferInputs<'_>) -> Self {
let (hdr_env, hdr_env_identity) = build_hdr_env(device, inputs.environment);
Self {
camera: compute::upload(
device,
"transport camera (persistent)",
std::slice::from_ref(inputs.camera_params),
BufferUsages::UNIFORM | BufferUsages::COPY_DST,
),
material: compute::upload(
device,
"transport material (persistent)",
std::slice::from_ref(inputs.material),
BufferUsages::STORAGE | BufferUsages::COPY_DST,
),
planes: compute::upload(
device,
"transport planes (persistent)",
inputs.planes,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
),
planes_capacity: inputs.planes.len(),
facet_finishes: compute::upload(
device,
"transport facet finishes (persistent)",
inputs.facet_finishes,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
),
facet_finishes_capacity: inputs.facet_finishes.len(),
hdr_env,
hdr_env_identity,
}
}
fn update(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
inputs: &SceneBufferInputs<'_>,
) {
let SceneBufferInputs {
camera_params,
material,
planes,
facet_finishes,
environment,
} = *inputs;
queue.write_buffer(&self.camera, 0, bytemuck::bytes_of(camera_params));
queue.write_buffer(&self.material, 0, bytemuck::bytes_of(material));
if planes.len() > self.planes_capacity {
self.planes = compute::upload(
device,
"transport planes (persistent, grown)",
planes,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
);
self.planes_capacity = planes.len();
} else {
queue.write_buffer(&self.planes, 0, bytemuck::cast_slice(planes));
}
if facet_finishes.len() > self.facet_finishes_capacity {
self.facet_finishes = compute::upload(
device,
"transport facet finishes (persistent, grown)",
facet_finishes,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
);
self.facet_finishes_capacity = facet_finishes.len();
} else {
queue.write_buffer(
&self.facet_finishes,
0,
bytemuck::cast_slice(facet_finishes),
);
}
let new_identity = match environment {
EnvironmentSource::HdrMap(map) => Some(std::ptr::from_ref(map) as usize),
EnvironmentSource::Studio { .. } => None,
};
if new_identity != self.hdr_env_identity {
let (hdr_env, hdr_env_identity) = build_hdr_env(device, environment);
self.hdr_env = hdr_env;
self.hdr_env_identity = hdr_env_identity;
}
}
fn ensure(existing: Option<Self>, ctx: &GpuContext, inputs: &SceneBufferInputs<'_>) -> Self {
existing.map_or_else(
|| Self::new(&ctx.device, inputs),
|mut existing| {
existing.update(&ctx.device, &ctx.queue, inputs);
existing
},
)
}
}
fn build_chunk_bind_group(
ctx: &GpuContext,
pipeline: &wgpu::ComputePipeline,
frame_buffers: &FrameSceneBuffers,
params_buf: &wgpu::Buffer,
planes_len: usize,
outputs: &TransportOutputs,
) -> wgpu::BindGroup {
let planes_bytes = (planes_len * size_of::<GpuFacetPlane>()) as wgpu::BufferAddress;
let finishes_bytes = (planes_len * size_of::<u32>()) as wgpu::BufferAddress;
compute::bind_buffers_sized(
&ctx.device,
"transport bind group (pipelined production)",
pipeline,
&[
(0, &frame_buffers.camera, None),
(1, params_buf, None),
(2, &frame_buffers.material, None),
(3, &frame_buffers.planes, Some(planes_bytes)),
(4, outputs.xyz(), None),
(5, outputs.radiance(), None),
(6, outputs.lambdas(), None),
(7, outputs.path_pdf(), None),
(8, &frame_buffers.facet_finishes, Some(finishes_bytes)),
(9, outputs.compat(), None),
(10, &frame_buffers.hdr_env.texels, None),
(11, &frame_buffers.hdr_env.dims, None),
(12, &frame_buffers.hdr_env.dist_func, None),
(13, &frame_buffers.hdr_env.dist_cdf, None),
(14, &frame_buffers.hdr_env.dist_dims, None),
],
)
}
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>,
reduce_pipeline: wgpu::ComputePipeline,
adapter_label: String,
outputs: [Option<TransportOutputs>; 2],
pixel_outputs: [Option<PixelXyzOutput>; 2],
staging: [Option<StagingSlot>; 2],
params_buffers: [Option<wgpu::Buffer>; 2],
reduce_params_buffers: [Option<wgpu::Buffer>; 2],
scene_buffers: Option<FrameSceneBuffers>,
xyz_scratch: Vec<f32>,
chunk_budget_bytes: usize,
ns_per_tuple_ema: Option<f64>,
pipeline_kind: GpuPipelineKind,
wavefront_pipelines: Option<WavefrontPipelines>,
}
struct WavefrontPipelines {
generate: wgpu::ComputePipeline,
bounce: wgpu::ComputePipeline,
compact_scan: wgpu::ComputePipeline,
compact_scatter: wgpu::ComputePipeline,
finalize_survivors: wgpu::ComputePipeline,
}
impl WavefrontPipelines {
fn new(device: &wgpu::Device) -> Self {
Self {
generate: compute::create_compute_pipeline(
device,
"wavefront_generate",
WAVEFRONT_SHADER_SRC,
"wavefront_generate",
),
bounce: compute::create_compute_pipeline(
device,
"wavefront_bounce",
WAVEFRONT_SHADER_SRC,
"wavefront_bounce",
),
compact_scan: compute::create_compute_pipeline(
device,
"wavefront_compact_scan",
WAVEFRONT_SHADER_SRC,
"wavefront_compact_scan",
),
compact_scatter: compute::create_compute_pipeline(
device,
"wavefront_compact_scatter",
WAVEFRONT_SHADER_SRC,
"wavefront_compact_scatter",
),
finalize_survivors: compute::create_compute_pipeline(
device,
"wavefront_finalize_survivors",
WAVEFRONT_SHADER_SRC,
"wavefront_finalize_survivors",
),
}
}
}
struct WavefrontRayBuffers {
origin: wgpu::Buffer,
dir: wgpu::Buffer,
k: wgpu::Buffer,
prev_plane_normal: wgpu::Buffer,
flags: wgpu::Buffer,
stokes: wgpu::Buffer,
radiance: wgpu::Buffer,
path_pdf: wgpu::Buffer,
split_radiance: wgpu::Buffer,
compat: wgpu::Buffer,
pending_light_mis: wgpu::Buffer,
lambdas: wgpu::Buffer,
seed: wgpu::Buffer,
active_in: wgpu::Buffer,
active_out: wgpu::Buffer,
local_offset: wgpu::Buffer,
block_alive_count: wgpu::Buffer,
block_offset: wgpu::Buffer,
}
impl WavefrontRayBuffers {
fn new(device: &wgpu::Device, tuples: usize) -> Self {
let storage_rw = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST;
let storage_rw_src = storage_rw | wgpu::BufferUsages::COPY_SRC;
let workgroups = tuples.div_ceil(WORKGROUP_SIZE);
Self {
origin: compute::zeroed_buffer::<[f32; 4]>(device, "wf ray_origin", tuples, storage_rw),
dir: compute::zeroed_buffer::<[f32; 4]>(device, "wf ray_dir", tuples, storage_rw),
k: compute::zeroed_buffer::<[f32; 4]>(device, "wf ray_k", tuples, storage_rw),
prev_plane_normal: compute::zeroed_buffer::<[f32; 4]>(
device,
"wf ray_prev_plane_normal",
tuples,
storage_rw,
),
flags: compute::zeroed_buffer::<u32>(device, "wf ray_flags", tuples, storage_rw),
stokes: compute::zeroed_buffer::<[f32; 4]>(
device,
"wf ray_stokes",
tuples * 8,
storage_rw,
),
radiance: compute::zeroed_buffer::<f32>(
device,
"wf ray_radiance",
tuples * 8,
storage_rw,
),
path_pdf: compute::zeroed_buffer::<f32>(
device,
"wf ray_path_pdf",
tuples * 8,
storage_rw,
),
split_radiance: compute::zeroed_buffer::<f32>(
device,
"wf ray_split_radiance",
tuples * 8,
storage_rw,
),
compat: compute::zeroed_buffer::<u32>(device, "wf ray_compat", tuples * 8, storage_rw),
pending_light_mis: compute::zeroed_buffer::<f32>(
device,
"wf ray_pending_light_mis",
tuples,
storage_rw,
),
lambdas: compute::zeroed_buffer::<f32>(
device,
"wf ray_lambdas",
tuples * 8,
storage_rw,
),
seed: compute::zeroed_buffer::<u32>(device, "wf ray_seed", tuples, storage_rw),
active_in: compute::zeroed_buffer::<u32>(
device,
"wf active_ray_indices",
tuples,
storage_rw,
),
active_out: compute::zeroed_buffer::<u32>(
device,
"wf active_ray_indices_next",
tuples,
storage_rw,
),
local_offset: compute::zeroed_buffer::<u32>(
device,
"wf compact_local_offset",
tuples,
storage_rw,
),
block_alive_count: compute::zeroed_buffer::<u32>(
device,
"wf compact_block_alive_count",
workgroups.max(1),
storage_rw_src,
),
block_offset: compute::zeroed_buffer::<u32>(
device,
"wf compact_block_offset",
workgroups.max(1),
storage_rw,
),
}
}
}
fn exclusive_prefix_sum(counts: &[u32]) -> (Vec<u32>, u32) {
let mut offsets = Vec::with_capacity(counts.len());
let mut running = 0u32;
for &c in counts {
offsets.push(running);
running += c;
}
(offsets, running)
}
const fn wavefront_generate_bindings<'a>(
camera: &'a wgpu::Buffer,
params_buf: &'a wgpu::Buffer,
wf_params_buf: &'a wgpu::Buffer,
rays: &'a WavefrontRayBuffers,
) -> [(u32, &'a wgpu::Buffer); 17] {
[
(0, camera),
(1, params_buf),
(15, wf_params_buf),
(16, &rays.origin),
(17, &rays.dir),
(18, &rays.k),
(19, &rays.prev_plane_normal),
(20, &rays.flags),
(21, &rays.stokes),
(22, &rays.radiance),
(23, &rays.path_pdf),
(24, &rays.split_radiance),
(25, &rays.compat),
(26, &rays.pending_light_mis),
(27, &rays.lambdas),
(28, &rays.seed),
(29, &rays.active_in),
]
}
struct WavefrontRoundBindGroups {
bounce: wgpu::BindGroup,
compact_scan: wgpu::BindGroup,
compact_scatter: wgpu::BindGroup,
finalize: wgpu::BindGroup,
}
#[expect(
clippy::too_many_arguments,
reason = "one parameter per distinct buffer source this chunk's four bind groups draw \
from (device, pipelines, scene buffers, params, geometry length, transport \
outputs, wavefront params, ray state) -- bundling them into a context struct \
here would just re-introduce this exact parameter list one level removed, the \
same reasoning ChunkFrameState/TransportDispatchArgs/TurnSetup already \
document elsewhere in this file"
)]
fn build_wavefront_round_bind_groups(
device: &wgpu::Device,
pipelines: &WavefrontPipelines,
frame_buffers: &FrameSceneBuffers,
params_buf: &wgpu::Buffer,
planes_len: usize,
outputs: &TransportOutputs,
wf_params_buf: &wgpu::Buffer,
rays: &WavefrontRayBuffers,
) -> WavefrontRoundBindGroups {
WavefrontRoundBindGroups {
bounce: wavefront_bounce_bind_group(
device,
pipelines,
frame_buffers,
params_buf,
planes_len,
outputs,
wf_params_buf,
rays,
),
compact_scan: wavefront_compact_scan_bind_group(device, pipelines, wf_params_buf, rays),
compact_scatter: wavefront_compact_scatter_bind_group(
device,
pipelines,
wf_params_buf,
rays,
),
finalize: wavefront_finalize_bind_group(
device,
pipelines,
params_buf,
outputs,
wf_params_buf,
rays,
),
}
}
#[expect(
clippy::too_many_arguments,
reason = "see build_wavefront_round_bind_groups' own #[expect] just above"
)]
fn wavefront_bounce_bind_group(
device: &wgpu::Device,
pipelines: &WavefrontPipelines,
frame_buffers: &FrameSceneBuffers,
params_buf: &wgpu::Buffer,
planes_len: usize,
outputs: &TransportOutputs,
wf_params_buf: &wgpu::Buffer,
rays: &WavefrontRayBuffers,
) -> wgpu::BindGroup {
let planes_bytes = (planes_len * size_of::<GpuFacetPlane>()) as wgpu::BufferAddress;
let finishes_bytes = (planes_len * size_of::<u32>()) as wgpu::BufferAddress;
compute::bind_buffers_sized(
device,
"wavefront bounce bind group",
&pipelines.bounce,
&[
(1, params_buf, None),
(2, &frame_buffers.material, None),
(3, &frame_buffers.planes, Some(planes_bytes)),
(4, outputs.xyz(), None),
(5, outputs.radiance(), None),
(6, outputs.lambdas(), None),
(7, outputs.path_pdf(), None),
(8, &frame_buffers.facet_finishes, Some(finishes_bytes)),
(9, outputs.compat(), None),
(10, &frame_buffers.hdr_env.texels, None),
(11, &frame_buffers.hdr_env.dims, None),
(12, &frame_buffers.hdr_env.dist_func, None),
(13, &frame_buffers.hdr_env.dist_cdf, None),
(14, &frame_buffers.hdr_env.dist_dims, None),
(15, wf_params_buf, None),
(16, &rays.origin, None),
(17, &rays.dir, None),
(18, &rays.k, None),
(19, &rays.prev_plane_normal, None),
(20, &rays.flags, None),
(21, &rays.stokes, None),
(22, &rays.radiance, None),
(23, &rays.path_pdf, None),
(24, &rays.split_radiance, None),
(25, &rays.compat, None),
(26, &rays.pending_light_mis, None),
(27, &rays.lambdas, None),
(28, &rays.seed, None),
(29, &rays.active_in, None),
],
)
}
fn wavefront_compact_scan_bind_group(
device: &wgpu::Device,
pipelines: &WavefrontPipelines,
wf_params_buf: &wgpu::Buffer,
rays: &WavefrontRayBuffers,
) -> wgpu::BindGroup {
compute::bind_buffers(
device,
"wavefront compact scan bind group",
&pipelines.compact_scan,
&[
(15, wf_params_buf),
(20, &rays.flags),
(29, &rays.active_in),
(31, &rays.local_offset),
(32, &rays.block_alive_count),
],
)
}
fn wavefront_compact_scatter_bind_group(
device: &wgpu::Device,
pipelines: &WavefrontPipelines,
wf_params_buf: &wgpu::Buffer,
rays: &WavefrontRayBuffers,
) -> wgpu::BindGroup {
compute::bind_buffers(
device,
"wavefront compact scatter bind group",
&pipelines.compact_scatter,
&[
(15, wf_params_buf),
(20, &rays.flags),
(29, &rays.active_in),
(30, &rays.active_out),
(31, &rays.local_offset),
(33, &rays.block_offset),
],
)
}
fn wavefront_finalize_bind_group(
device: &wgpu::Device,
pipelines: &WavefrontPipelines,
params_buf: &wgpu::Buffer,
outputs: &TransportOutputs,
wf_params_buf: &wgpu::Buffer,
rays: &WavefrontRayBuffers,
) -> wgpu::BindGroup {
compute::bind_buffers(
device,
"wavefront finalize survivors bind group",
&pipelines.finalize_survivors,
&[
(1, params_buf),
(4, outputs.xyz()),
(5, outputs.radiance()),
(6, outputs.lambdas()),
(7, outputs.path_pdf()),
(9, outputs.compat()),
(15, wf_params_buf),
(20, &rays.flags),
(22, &rays.radiance),
(23, &rays.path_pdf),
(24, &rays.split_radiance),
(25, &rays.compat),
(27, &rays.lambdas),
(29, &rays.active_in),
],
)
}
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,
studio_model: u32,
backdrop: f32,
}
pub(crate) struct TurnRequest<'a> {
pub(crate) scene: &'a GpuFrameScene<'a>,
pub(crate) pipeline_class: u32,
pub(crate) sample_offset: u32,
pub(crate) spp: u32,
pub(crate) cancel: Option<&'a AtomicBool>,
pub(crate) max_chunks: usize,
}
struct TurnSetup<'a> {
state: ChunkFrameState<'a>,
frame_buffers: FrameSceneBuffers,
byte_budget_pixels: usize,
}
const fn build_chunk_params(
state: &ChunkFrameState<'_>,
first_pixel: usize,
pixels_this_chunk: usize,
) -> GpuTransportParams {
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)
.with_studio_model(state.studio_model)
.with_backdrop(state.backdrop)
}
struct ChunkSlotResources<'a> {
outputs: &'a TransportOutputs,
pixel_output: &'a PixelXyzOutput,
staging_buffer: &'a wgpu::Buffer,
params_buf: &'a wgpu::Buffer,
}
impl GpuFrameRenderer {
pub fn new() -> Result<Self, GpuFrameError> {
let ctx = GpuContext::acquire().map_err(GpuFrameError::Acquire)?;
let available = ctx.device.limits().max_storage_buffers_per_shader_stage;
if available < MEGAKERNEL_STORAGE_BUFFERS {
return Err(GpuFrameError::DeviceLimits {
needed: MEGAKERNEL_STORAGE_BUFFERS,
available,
});
}
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",
);
let reduce_pipeline = compute::create_compute_pipeline(
&ctx.device,
"reduce_xyz_main",
REDUCE_SHADER_SRC,
"reduce_xyz_main",
);
Ok(Self {
ctx,
pipeline,
pipeline_isotropic: None,
pipeline_uniaxial: None,
pipeline_biaxial: None,
reduce_pipeline,
adapter_label,
outputs: [None, None],
pixel_outputs: [None, None],
staging: [None, None],
params_buffers: [None, None],
reduce_params_buffers: [None, None],
scene_buffers: None,
xyz_scratch: Vec::new(),
chunk_budget_bytes: CHUNK_BUDGET_BYTES,
ns_per_tuple_ema: None,
pipeline_kind: GpuPipelineKind::default(),
wavefront_pipelines: None,
})
}
pub const fn set_chunk_budget_bytes(&mut self, bytes: usize) {
self.chunk_budget_bytes = bytes;
}
pub fn set_pipeline_kind(&mut self, kind: GpuPipelineKind) {
let available = self
.ctx
.device
.limits()
.max_storage_buffers_per_shader_stage;
if kind == GpuPipelineKind::Wavefront && available < WAVEFRONT_STORAGE_BUFFERS {
tracing::warn!(
available,
needed = WAVEFRONT_STORAGE_BUFFERS,
"device cannot bind enough storage buffers for the wavefront pipeline; staying on the megakernel"
);
self.pipeline_kind = GpuPipelineKind::Megakernel;
return;
}
self.pipeline_kind = kind;
if kind == GpuPipelineKind::Wavefront && self.wavefront_pipelines.is_none() {
self.wavefront_pipelines = Some(WavefrontPipelines::new(&self.ctx.device));
}
}
#[must_use]
pub const fn pipeline_kind(&self) -> GpuPipelineKind {
self.pipeline_kind
}
#[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 mut cursor = ChunkCursor::default();
let request = TurnRequest {
scene,
pipeline_class,
sample_offset,
spp,
cancel,
max_chunks: usize::MAX,
};
loop {
match self.accumulate_turn(&request, accum, &mut cursor)? {
ChunkTurnOutcome::Done => return Ok(AccumulateOutcome::Done),
ChunkTurnOutcome::Cancelled => return Ok(AccumulateOutcome::Cancelled),
ChunkTurnOutcome::MoreWork => {}
}
}
}
pub(crate) fn accumulate_turn(
&mut self,
request: &TurnRequest<'_>,
accum: &mut [Vec3],
cursor: &mut ChunkCursor,
) -> Result<ChunkTurnOutcome, GpuFrameError> {
let scene = request.scene;
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 request.spp == 0 || num_pixels == 0 {
return Ok(ChunkTurnOutcome::Done);
}
if !scene.material.gpu_supported() {
return Err(GpuFrameError::UnsupportedMaterial(
scene.material.name.clone(),
));
}
let TurnSetup {
state,
frame_buffers,
byte_budget_pixels,
} = self.prepare_turn(request);
let (frame_buffers, cancelled) = self.run_turn_chunks(
request,
&state,
frame_buffers,
byte_budget_pixels,
accum,
cursor,
)?;
self.scene_buffers = Some(frame_buffers);
if cancelled {
return Ok(ChunkTurnOutcome::Cancelled);
}
if cursor.first_pixel >= num_pixels {
return Ok(ChunkTurnOutcome::Done);
}
Ok(ChunkTurnOutcome::MoreWork)
}
fn prepare_turn<'a>(&mut self, request: &TurnRequest<'a>) -> TurnSetup<'a> {
let scene = request.scene;
let num_pixels = scene.width as usize * scene.height as usize;
let (
env_mode,
temp_k,
spot_mult,
exposure,
light_yaw,
light_pitch,
use_d65,
studio_model,
backdrop,
) = environment_params(scene.environment);
let white_balance = environment_white_balance(scene.environment);
let gpu_material = GpuGemMaterial::encode(scene.material);
let gpu_finishes = encode_facet_finishes(scene.facet_finishes, scene.planes.len());
self.ensure_specialized_pipeline(request.pipeline_class);
let byte_budget_pixels = chunk_pixels_for(self.chunk_budget_bytes, request.spp, num_pixels);
self.ensure_capacity(byte_budget_pixels * request.spp as usize);
self.ensure_pixel_capacity(byte_budget_pixels);
self.ensure_staging_capacity(byte_budget_pixels);
self.ensure_params_buffers();
self.ensure_reduce_params_buffers();
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: request.spp,
};
let frame_buffers = FrameSceneBuffers::ensure(
self.scene_buffers.take(),
&self.ctx,
&SceneBufferInputs {
camera_params: &camera_params,
material: &gpu_material,
planes: scene.planes,
facet_finishes: &gpu_finishes,
environment: scene.environment,
},
);
let state = ChunkFrameState {
scene,
pipeline_class: request.pipeline_class,
sample_offset: request.sample_offset,
spp: request.spp,
env_mode,
temp_k,
spot_mult,
exposure,
light_yaw,
light_pitch,
white_balance,
use_d65,
studio_model,
backdrop,
};
TurnSetup {
state,
frame_buffers,
byte_budget_pixels,
}
}
fn run_turn_chunks(
&mut self,
request: &TurnRequest<'_>,
state: &ChunkFrameState<'_>,
frame_buffers: FrameSceneBuffers,
byte_budget_pixels: usize,
accum: &mut [Vec3],
cursor: &mut ChunkCursor,
) -> Result<(FrameSceneBuffers, bool), GpuFrameError> {
let num_pixels = state.scene.width as usize * state.scene.height as usize;
let mut pending: Option<PendingChunk> = None;
let mut first_pixel = cursor.first_pixel;
let mut chunk_index = cursor.chunk_index;
let mut cancelled = false;
let mut chunks_this_turn = 0usize;
while first_pixel < num_pixels {
if request
.cancel
.is_some_and(|flag| flag.load(Ordering::Relaxed))
{
cancelled = true;
break;
}
if chunks_this_turn >= request.max_chunks {
break;
}
let chunk_pixels = Self::next_chunk_pixels(
self.ns_per_tuple_ema,
byte_budget_pixels,
state.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, state.spp, Some(&mut *accum))?;
}
pending = Some(new_pending);
first_pixel += pixels_this_chunk;
chunk_index += 1;
chunks_this_turn += 1;
}
if let Some(chunk) = pending.take() {
if cancelled {
self.drain_pending_chunk(chunk, state.spp, None)?;
} else {
self.drain_pending_chunk(chunk, state.spp, Some(&mut *accum))?;
}
}
cursor.first_pixel = first_pixel;
cursor.chunk_index = chunk_index;
Ok((frame_buffers, cancelled))
}
fn resolve_chunk_slot_resources(
&self,
staging_slot: usize,
tuples: usize,
pixels_this_chunk: usize,
) -> ChunkSlotResources<'_> {
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 pixel_output = self.pixel_outputs[staging_slot]
.as_ref()
.expect("ensure_pixel_capacity just populated both slots");
assert!(
pixels_this_chunk <= pixel_output.capacity,
"reduced-pixel dispatch of {pixels_this_chunk} pixels exceeds pixel output \
capacity {}",
pixel_output.capacity
);
let staging_buffer = &self.staging[staging_slot]
.as_ref()
.expect("ensure_staging_capacity just populated both slots")
.buffer;
let params_buf = self.params_buffers[staging_slot]
.as_ref()
.expect("ensure_params_buffers just populated both slots");
ChunkSlotResources {
outputs,
pixel_output,
staging_buffer,
params_buf,
}
}
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 planes_len = state.scene.planes.len();
let params = build_chunk_params(state, first_pixel, pixels_this_chunk);
let staging_slot = chunk_index % 2;
let ChunkSlotResources {
outputs,
pixel_output,
staging_buffer,
params_buf,
} = self.resolve_chunk_slot_resources(staging_slot, tuples, pixels_this_chunk);
self.ctx
.queue
.write_buffer(params_buf, 0, bytemuck::bytes_of(¶ms));
let submitted_at = Instant::now();
match self.pipeline_kind {
GpuPipelineKind::Megakernel => {
let pipeline = self.pipeline_for_class(state.pipeline_class);
let bind_group = build_chunk_bind_group(
&self.ctx,
pipeline,
frame_buffers,
params_buf,
planes_len,
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),
);
}
GpuPipelineKind::Wavefront => {
self.dispatch_chunk_wavefront(
state,
frame_buffers,
params_buf,
planes_len,
outputs,
tuples,
);
}
}
self.dispatch_reduce(
state.spp,
staging_slot,
pixels_this_chunk,
outputs,
pixel_output,
);
let copy_index = copy_to_existing_staging::<f32>(
&self.ctx.device,
&self.ctx.queue,
&pixel_output.buffer,
staging_buffer,
pixels_this_chunk * 3,
);
let rx = compute::begin_map_read(staging_buffer);
PendingChunk {
staging_slot,
copy_index,
rx,
first_pixel,
pixels_this_chunk,
submitted_at,
}
}
fn dispatch_reduce(
&self,
num_samples: u32,
staging_slot: usize,
pixels_this_chunk: usize,
outputs: &TransportOutputs,
pixel_output: &PixelXyzOutput,
) {
let reduce_params_buf = self.reduce_params_buffers[staging_slot]
.as_ref()
.expect("ensure_reduce_params_buffers just populated both slots");
let reduce_params = GpuReduceParams {
num_pixels: pixels_this_chunk as u32,
num_samples,
_pad0: 0,
_pad1: 0,
};
self.ctx
.queue
.write_buffer(reduce_params_buf, 0, bytemuck::bytes_of(&reduce_params));
let reduce_bind_group = compute::bind_buffers(
&self.ctx.device,
"reduce xyz bind group",
&self.reduce_pipeline,
&[
(0, reduce_params_buf),
(1, outputs.xyz()),
(2, &pixel_output.buffer),
],
);
let reduce_workgroups = (pixels_this_chunk as u32).div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
&self.ctx.device,
&self.ctx.queue,
&self.reduce_pipeline,
&reduce_bind_group,
(reduce_workgroups, 1, 1),
);
}
fn dispatch_chunk_wavefront(
&self,
state: &ChunkFrameState<'_>,
frame_buffers: &FrameSceneBuffers,
params_buf: &wgpu::Buffer,
planes_len: usize,
outputs: &TransportOutputs,
tuples: usize,
) {
let pipelines = self
.wavefront_pipelines
.as_ref()
.expect("set_pipeline_kind(Wavefront) must be called before dispatching it");
let device = &self.ctx.device;
let queue = &self.ctx.queue;
let rays = WavefrontRayBuffers::new(device, tuples);
let wf_params_buf = compute::upload(
device,
"wavefront params",
&[GpuWavefrontParams {
chunk_rays: tuples as u32,
active_count: tuples as u32,
bounce: 0,
workgroup_count: (tuples as u32).div_ceil(WORKGROUP_SIZE as u32),
}],
wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
);
let generate_bind_group = compute::bind_buffers(
device,
"wavefront generate bind group",
&pipelines.generate,
&wavefront_generate_bindings(&frame_buffers.camera, params_buf, &wf_params_buf, &rays),
);
let generate_workgroups = (tuples as u32).div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
device,
queue,
&pipelines.generate,
&generate_bind_group,
(generate_workgroups, 1, 1),
);
let bind_groups = build_wavefront_round_bind_groups(
device,
pipelines,
frame_buffers,
params_buf,
planes_len,
outputs,
&wf_params_buf,
&rays,
);
let (active_count, bounce) = self.run_wavefront_bounce_rounds(
state.scene.max_bounces,
tuples as u32,
&rays,
&wf_params_buf,
&bind_groups,
);
if active_count > 0 {
queue.write_buffer(
&wf_params_buf,
0,
bytemuck::bytes_of(&GpuWavefrontParams {
chunk_rays: tuples as u32,
active_count,
bounce,
workgroup_count: active_count.div_ceil(WORKGROUP_SIZE as u32),
}),
);
let finalize_workgroups = active_count.div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
device,
queue,
&pipelines.finalize_survivors,
&bind_groups.finalize,
(finalize_workgroups, 1, 1),
);
}
}
fn run_wavefront_bounce_rounds(
&self,
max_bounces: u32,
chunk_rays: u32,
rays: &WavefrontRayBuffers,
wf_params_buf: &wgpu::Buffer,
bind_groups: &WavefrontRoundBindGroups,
) -> (u32, u32) {
let device = &self.ctx.device;
let queue = &self.ctx.queue;
let pipelines = self
.wavefront_pipelines
.as_ref()
.expect("set_pipeline_kind(Wavefront) must be called before dispatching it");
let mut active_count = chunk_rays;
let mut bounce = 0u32;
while active_count > 0 && bounce < max_bounces {
let workgroup_count = active_count.div_ceil(WORKGROUP_SIZE as u32);
queue.write_buffer(
wf_params_buf,
0,
bytemuck::bytes_of(&GpuWavefrontParams {
chunk_rays,
active_count,
bounce,
workgroup_count,
}),
);
compute::dispatch_and_wait(
device,
queue,
&pipelines.bounce,
&bind_groups.bounce,
(workgroup_count, 1, 1),
);
compute::dispatch_and_wait(
device,
queue,
&pipelines.compact_scan,
&bind_groups.compact_scan,
(workgroup_count, 1, 1),
);
let block_counts: Vec<u32> = compute::readback(
device,
queue,
&rays.block_alive_count,
workgroup_count as usize,
);
let (block_offsets, new_active_count) = exclusive_prefix_sum(&block_counts);
queue.write_buffer(&rays.block_offset, 0, bytemuck::cast_slice(&block_offsets));
compute::dispatch_and_wait(
device,
queue,
&pipelines.compact_scatter,
&bind_groups.compact_scatter,
(workgroup_count, 1, 1),
);
if new_active_count > 0 {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("wavefront active-list copy encoder"),
});
encoder.copy_buffer_to_buffer(
&rays.active_out,
0,
&rays.active_in,
0,
u64::from(new_active_count) * size_of::<u32>() as u64,
);
queue.submit(Some(encoder.finish()));
}
active_count = new_active_count;
bounce += 1;
}
(active_count, bounce)
}
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;
compute::finish_map_read_into(
&self.ctx.device,
staging_buffer,
chunk.copy_index,
&chunk.rx,
&mut self.xyz_scratch,
)
.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 {
let xyz = &self.xyz_scratch;
for local_pixel in 0..chunk.pixels_this_chunk {
let base = local_pixel * 3;
accum[chunk.first_pixel + local_pixel] +=
Vec3::new(xyz[base], xyz[base + 1], xyz[base + 2]);
}
}
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, pixels: 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, pixels) {
*slot = Some(StagingSlot::new(
&self.ctx.device,
"transport out xyz staging (persistent)",
pixels,
));
}
}
}
fn ensure_pixel_capacity(&mut self, pixels: usize) {
for slot in &mut self.pixel_outputs {
let big_enough = slot.as_ref().is_some_and(|o| o.capacity >= pixels);
if !big_enough {
*slot = Some(PixelXyzOutput::new(&self.ctx.device, pixels));
}
}
}
fn ensure_params_buffers(&mut self) {
for slot in &mut self.params_buffers {
if slot.is_none() {
*slot = Some(self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("transport params (persistent)"),
size: size_of::<GpuTransportParams>() as wgpu::BufferAddress,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
}
}
fn ensure_reduce_params_buffers(&mut self) {
for slot in &mut self.reduce_params_buffers {
if slot.is_none() {
*slot = Some(self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("reduce xyz params (persistent)"),
size: size_of::<GpuReduceParams>() as wgpu::BufferAddress,
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
}
}
}
#[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 available = ctx.device.limits().max_storage_buffers_per_shader_stage;
if available < MEGAKERNEL_STORAGE_BUFFERS {
return Err(GpuFrameError::DeviceLimits {
needed: MEGAKERNEL_STORAGE_BUFFERS,
available,
});
}
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",
);
let reduce_pipeline = compute::create_compute_pipeline(
&ctx.device,
"reduce_xyz_main",
REDUCE_SHADER_SRC,
"reduce_xyz_main",
);
Ok(Self {
ctx,
pipeline,
pipeline_isotropic: None,
pipeline_uniaxial: None,
pipeline_biaxial: None,
reduce_pipeline,
adapter_label,
outputs: [None, None],
pixel_outputs: [None, None],
staging: [None, None],
params_buffers: [None, None],
reduce_params_buffers: [None, None],
scene_buffers: None,
xyz_scratch: Vec::new(),
chunk_budget_bytes: CHUNK_BUDGET_BYTES,
ns_per_tuple_ema: None,
pipeline_kind: GpuPipelineKind::default(),
wavefront_pipelines: 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,
studio_model,
backdrop,
) = environment_params(scene.environment);
let white_balance = environment_white_balance(scene.environment);
let gpu_material = GpuGemMaterial::encode(scene.material);
let gpu_finishes = encode_facet_finishes(scene.facet_finishes, scene.planes.len());
let hdr_env = match scene.environment {
EnvironmentSource::HdrMap(map) => HdrEnvGpuData::upload(&self.ctx.device, map),
EnvironmentSource::Studio { .. } => HdrEnvGpuData::dummy(&self.ctx.device),
};
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);
self.ensure_pixel_capacity(chunk_pixels);
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)
.with_studio_model(studio_model)
.with_backdrop(backdrop);
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,
hdr_env: &hdr_env,
};
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 pixel_output = self.pixel_outputs[chunk_index % 2]
.as_ref()
.expect("ensure_pixel_capacity just populated both slots");
let reduce_params = GpuReduceParams {
num_pixels: pixels_this_chunk as u32,
num_samples: spp,
_pad0: 0,
_pad1: 0,
};
let reduce_params_buf = compute::upload(
&self.ctx.device,
"reduce xyz params (wasm32 async)",
std::slice::from_ref(&reduce_params),
BufferUsages::UNIFORM,
);
let reduce_bind_group = compute::bind_buffers(
&self.ctx.device,
"reduce xyz bind group (wasm32 async)",
&self.reduce_pipeline,
&[
(0, &reduce_params_buf),
(1, outputs.xyz()),
(2, &pixel_output.buffer),
],
);
let reduce_workgroups = (pixels_this_chunk as u32).div_ceil(WORKGROUP_SIZE as u32);
let _ = compute::dispatch(
&self.ctx.device,
&self.ctx.queue,
&self.reduce_pipeline,
&reduce_bind_group,
(reduce_workgroups, 1, 1),
);
let (staging, _copy_index) = compute::copy_to_staging::<f32>(
&self.ctx.device,
&self.ctx.queue,
&pixel_output.buffer,
pixels_this_chunk * 3,
"transport out pixel xyz staging (wasm32 async)",
);
let xyz: Vec<f32> = map_read_async(&staging, pixels_this_chunk * 3).await?;
for local_pixel in 0..pixels_this_chunk {
let base = local_pixel * 3;
accum[first_pixel + local_pixel] +=
Vec3::new(xyz[base], xyz[base + 1], xyz[base + 2]);
}
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, u32, f32);
const fn environment_params(environment: EnvironmentSource<'_>) -> EnvironmentParams {
match environment {
EnvironmentSource::Studio {
preset,
exposure,
light_yaw,
light_pitch,
backdrop,
} => (
transport_env_mode::STUDIO_RIG,
illuminant_temperature_k(preset),
preset.params().spot_mult,
exposure,
light_yaw,
light_pitch,
preset.uses_d65(),
preset.model().gpu_id(),
backdrop,
),
EnvironmentSource::HdrMap(_) => (
transport_env_mode::HDR_MAP,
0.0,
0.0,
0.0,
0.0,
0.0,
false,
0,
0.0,
),
}
}
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,
}
}
pub struct PipelineEquivalenceResult {
pub differing_pixels: usize,
pub total_pixels: usize,
pub max_abs_diff: f32,
}
impl PipelineEquivalenceResult {
#[must_use]
pub const fn passed(&self) -> bool {
self.differing_pixels == 0
}
}
#[must_use]
pub fn run_pipeline_equivalence(renderer: &mut GpuFrameRenderer) -> PipelineEquivalenceResult {
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) = (32u32, 32u32);
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),
};
renderer.set_pipeline_kind(GpuPipelineKind::Megakernel);
let mut megakernel = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate(&scene, 0, spp, &mut megakernel)
.expect("a cubic material under the studio rig is GPU-supported");
renderer.set_pipeline_kind(GpuPipelineKind::Wavefront);
let mut wavefront = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate(&scene, 0, spp, &mut wavefront)
.expect("a cubic material under the studio rig is GPU-supported");
renderer.set_pipeline_kind(GpuPipelineKind::Megakernel);
let mut differing_pixels = 0usize;
let mut max_abs_diff = 0.0f32;
for (a, b) in megakernel.iter().zip(&wavefront) {
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());
}
}
PipelineEquivalenceResult {
differing_pixels,
total_pixels: num_pixels,
max_abs_diff,
}
}
#[must_use]
pub fn run_wavefront_determinism(renderer: &mut GpuFrameRenderer) -> PipelineEquivalenceResult {
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) = (32u32, 32u32);
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),
};
renderer.set_pipeline_kind(GpuPipelineKind::Wavefront);
let mut first = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate(&scene, 0, spp, &mut first)
.expect("a cubic material under the studio rig is GPU-supported");
let mut second = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate(&scene, 0, spp, &mut second)
.expect("a cubic material under the studio rig is GPU-supported");
renderer.set_pipeline_kind(GpuPipelineKind::Megakernel);
let mut differing_pixels = 0usize;
let mut max_abs_diff = 0.0f32;
for (a, b) in first.iter().zip(&second) {
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());
}
}
PipelineEquivalenceResult {
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, studio_model, backdrop) =
environment_params(LightingPreset::Daylight.studio(1.5, 0.4, 0.35));
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);
assert_eq!(studio_model, 0);
assert_eq!(backdrop, 0.0);
}
#[test]
fn hdr_environments_map_onto_the_hdr_map_mode() {
let map = crate::renderer::env_map::EnvironmentMap::uniform(4, 2, [1.0, 1.0, 1.0]);
let (mode, temp_k, spot_mult, exposure, yaw, pitch, use_d65, studio_model, backdrop) =
environment_params(EnvironmentSource::HdrMap(&map));
assert_eq!(mode, transport_env_mode::HDR_MAP);
assert_eq!(
(temp_k, spot_mult, exposure, yaw, pitch),
(0.0, 0.0, 0.0, 0.0, 0.0)
);
assert!(!use_d65);
assert_eq!(studio_model, 0);
assert_eq!(backdrop, 0.0);
}
#[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
);
}
}
}