use std::sync::atomic::Ordering;
use glam::Vec3;
use crate::{
geometry::{
GpuFacetPlane,
tool::{GPU_MAX_TOOL_PRIMITIVES, ToolPrimitive, pack_planes_and_tools},
},
optics::raytracer::{environment::environment_white_balance, head_shadow_cosines},
render_setup::hash_planes,
renderer::{
buffers::{
EwHit, EwParams, GpuCameraParams, GpuGemMaterial, GpuTransportParams,
encode_facet_finishes,
},
frame_scene::FrameScene,
gpu::{
GpuContext,
event_wavefront::{
EwError,
chunk::{
ChunkRayBuffers, SceneBuffers, bind_kernel, execute_chunk, scene_binding_bytes,
},
clock,
kernels::EwKernel,
pipelines::EwPipelines,
},
frame::{classify_material, environment_params},
},
},
};
#[inline]
fn count_u32(count: usize) -> u32 {
u32::try_from(count).expect("a chunk's pixel and tuple counts fit u32")
}
#[cfg(target_arch = "wasm32")]
pub const TARGET_CHUNK_MS: f64 = 30.0;
#[cfg(not(target_arch = "wasm32"))]
pub const TARGET_CHUNK_MS: f64 = 150.0;
pub const CHUNK_BUDGET_BYTES: usize = 64 * 1024 * 1024;
pub const MIN_CHUNK_BYTES: usize = 64 * 1024;
pub const FIRST_DISPATCH_MAX_TUPLES: usize = 1_000_000;
pub const CHUNK_TIMING_EMA_ALPHA: f64 = 0.3;
pub const BYTES_PER_RAY: usize = 128 + 288 + size_of::<EwHit>() + 12;
pub const MAX_WORKGROUPS_PER_DIMENSION: usize = 65535;
pub const WORKGROUP_SIZE: usize = 64;
#[must_use]
pub fn chunk_pixels_for(
budget_bytes: usize,
spp: u32,
num_pixels: usize,
max_storage_binding: u64,
) -> usize {
let spp_usize = (spp as usize).max(1);
let max_rays_binding = (max_storage_binding / 288) as usize;
let max_rays_budget = budget_bytes / BYTES_PER_RAY;
let max_workgroup_rays = MAX_WORKGROUPS_PER_DIMENSION * WORKGROUP_SIZE;
let tuples_cap = max_rays_binding
.min(max_rays_budget)
.min(max_workgroup_rays);
(tuples_cap / spp_usize).max(1).min(num_pixels)
}
#[must_use]
pub 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);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "target_tuples >= 1.0; truncated to non-negative usize"
)]
let target_pixels = ((target_tuples / spp_usize as f64).floor() as usize).max(1);
let min_pixels = 1.min(byte_budget_pixels).min(num_pixels);
target_pixels
.clamp(min_pixels, byte_budget_pixels)
.min(num_pixels)
}
fn chunk_transport_params(
scene: &FrameScene<'_>,
tool_count: u32,
sample_offset: u32,
pixels_this_chunk: usize,
pixel_offset: usize,
) -> GpuTransportParams {
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);
GpuTransportParams::new(
count_u32(pixels_this_chunk),
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(count_u32(pixel_offset))
.with_debug_buffers_disabled()
.with_studio_use_d65(use_d65)
.with_studio_model(studio_model)
.with_tent_of(scene.environment)
.with_backdrop(backdrop)
.with_surface_glare(scene.environment.surface_glare())
.with_head_shadow(head_shadow_cosines(scene.environment.head_shadow_deg()))
.with_tool_count(tool_count)
}
fn tool_count_u32(tools: &[ToolPrimitive]) -> u32 {
u32::try_from(tools.len()).expect("a stone's tool count is bounded by GPU_MAX_TOOL_PRIMITIVES")
}
pub(super) const fn check_tool_limit(tools: &[ToolPrimitive]) -> Result<(), EwError> {
if tools.len() > GPU_MAX_TOOL_PRIMITIVES {
return Err(EwError::TooManyTools {
tools: tools.len(),
limit: GPU_MAX_TOOL_PRIMITIVES,
});
}
Ok(())
}
pub struct EventWavefrontRenderer {
ctx: GpuContext,
pipelines: EwPipelines,
scene_buffers: Option<SceneBuffers>,
chunk_buffers: Option<ChunkRayBuffers>,
ns_per_tuple_ema: Option<f64>,
last_scene_key: Option<(u32, u32, u64, u32, bool)>,
chunk_budget_bytes: usize,
}
impl EventWavefrontRenderer {
#[must_use]
pub fn new(ctx: GpuContext) -> Self {
let pipelines = EwPipelines::new(&ctx.device);
Self {
ctx,
pipelines,
scene_buffers: None,
chunk_buffers: None,
ns_per_tuple_ema: None,
last_scene_key: None,
chunk_budget_bytes: CHUNK_BUDGET_BYTES,
}
}
pub const fn set_chunk_budget_bytes(&mut self, bytes: usize) {
self.chunk_budget_bytes = bytes;
}
#[must_use]
pub const fn chunk_budget_bytes(&self) -> usize {
self.chunk_budget_bytes
}
#[must_use]
pub const fn context(&self) -> &GpuContext {
&self.ctx
}
#[must_use]
pub const fn ns_per_tuple_ema(&self) -> Option<f64> {
self.ns_per_tuple_ema
}
pub fn compiled_pipelines(&self) -> impl Iterator<Item = (EwKernel, u32)> + '_ {
self.pipelines.compiled()
}
pub fn ensure_pipeline(&mut self, kernel: EwKernel, class: u32) -> &wgpu::ComputePipeline {
self.pipelines.ensure(&self.ctx.device, kernel, class)
}
pub fn test_dispatch_kernel(&mut self, kernel: EwKernel, class: u32) -> Result<(), EwError> {
let dummy_camera: GpuCameraParams = bytemuck::Zeroable::zeroed();
let dummy_params: GpuTransportParams = bytemuck::Zeroable::zeroed();
let dummy_material: GpuGemMaterial = bytemuck::Zeroable::zeroed();
let dummy_planes: [GpuFacetPlane; 1] = [bytemuck::Zeroable::zeroed()];
let dummy_finishes = [0u32];
if self.scene_buffers.is_none() {
self.scene_buffers = Some(SceneBuffers::new(
&self.ctx.device,
&dummy_camera,
&dummy_params,
&dummy_material,
&dummy_planes,
&dummy_finishes,
crate::optics::raytracer::LightingPreset::LightTent.studio(1.0, 0.0, 0.0),
)?);
}
if self.chunk_buffers.is_none() {
self.chunk_buffers = Some(ChunkRayBuffers::new(&self.ctx.device, 1, 64));
}
let chunk_buffers = self
.chunk_buffers
.as_mut()
.expect("chunk buffers initialized");
let ew_params = EwParams {
chunk_rays: 0,
bounce: 0,
finalize_all: 1,
has_scatter: 0,
pad0: 0,
pad1: 0,
pad2: 0,
pad3: 0,
};
self.ctx
.queue
.write_buffer(&chunk_buffers.ew_params, 0, bytemuck::bytes_of(&ew_params));
self.pipelines.ensure(&self.ctx.device, kernel, class);
let pipeline = self.pipelines.get(kernel, class);
let scene_buffers = self
.scene_buffers
.as_ref()
.expect("scene buffers initialized");
let (planes_bytes, finishes_bytes) = scene_binding_bytes(dummy_planes.len());
let bg = bind_kernel(
&self.ctx.device,
pipeline,
kernel,
scene_buffers,
chunk_buffers,
planes_bytes,
finishes_bytes,
);
let mut encoder = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("ew test dispatch"),
});
{
let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("ew test compute pass"),
timestamp_writes: None,
});
compute_pass.set_pipeline(pipeline);
compute_pass.set_bind_group(0, &bg, &[]);
compute_pass.dispatch_workgroups(1, 1, 1);
}
self.ctx.queue.submit(Some(encoder.finish()));
Ok(())
}
#[allow(
clippy::future_not_send,
reason = "wgpu web backend buffer mapping futures are !Send; wasm32 is single-threaded"
)]
pub async fn accumulate(
&mut self,
scene: &FrameScene<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), EwError> {
self.accumulate_with_tools(scene, &[], sample_offset, spp, accum)
.await
}
#[allow(
clippy::future_not_send,
reason = "wgpu web backend buffer mapping futures are !Send; wasm32 is single-threaded"
)]
pub async fn accumulate_with_tools(
&mut self,
scene: &FrameScene<'_>,
tools: &[ToolPrimitive],
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), EwError> {
let class = classify_material(scene.material);
self.accumulate_via_class_with_tools(scene, tools, class, sample_offset, spp, accum)
.await
}
#[allow(
clippy::future_not_send,
reason = "wgpu web backend buffer mapping futures are !Send; wasm32 is single-threaded"
)]
pub async fn accumulate_via_class(
&mut self,
scene: &FrameScene<'_>,
material_class: u32,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), EwError> {
self.accumulate_via_class_with_tools(scene, &[], material_class, sample_offset, spp, accum)
.await
}
#[allow(
clippy::future_not_send,
reason = "wgpu web backend buffer mapping futures are !Send; wasm32 is single-threaded"
)]
pub async fn accumulate_via_class_with_tools(
&mut self,
scene: &FrameScene<'_>,
tools: &[ToolPrimitive],
material_class: u32,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), EwError> {
let num_pixels = (scene.width * scene.height) as usize;
if accum.len() != num_pixels {
return Err(EwError::InvalidBufferSize {
expected: num_pixels,
actual: accum.len(),
});
}
if spp == 0 || num_pixels == 0 {
return Ok(());
}
if !scene.material.gpu_supported() {
return Err(EwError::UnsupportedMaterial(scene.material.name.clone()));
}
check_tool_limit(tools)?;
let packed = pack_planes_and_tools(scene.planes, tools);
let scene_key = (
scene.width,
scene.height,
hash_planes(&packed),
material_class,
scene.material.scattering_sigma_s > 0.0,
);
if self.last_scene_key != Some(scene_key) {
self.ns_per_tuple_ema = None;
self.last_scene_key = Some(scene_key);
}
self.ctx
.validation_error_seen
.store(false, Ordering::Relaxed);
if let Ok(mut last) = self.ctx.last_uncaptured_error.lock() {
*last = None;
}
let result = self
.accumulate_prepared(
scene,
&packed,
tool_count_u32(tools),
material_class,
sample_offset,
spp,
accum,
)
.await;
if result.is_err() {
self.chunk_buffers = None;
}
result
}
pub(super) fn check_uncaptured_error(&self) -> Result<(), EwError> {
if !self
.ctx
.validation_error_seen
.swap(false, Ordering::Relaxed)
{
return Ok(());
}
let detail = self
.ctx
.last_uncaptured_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
Err(EwError::DeviceLost(format!(
"wgpu reported an uncaptured validation/device error during this pass: {}",
detail.unwrap_or_else(|| "(no error text was captured)".to_string())
)))
}
fn upload_scene(
&mut self,
scene: &FrameScene<'_>,
packed: &[GpuFacetPlane],
spp: u32,
) -> Result<(), EwError> {
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 material_params = GpuGemMaterial::encode(scene.material);
let facet_finishes = encode_facet_finishes(scene.facet_finishes, packed.len());
let dummy_transport_params =
GpuTransportParams::new(0, 0, 0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, [0.0; 3]);
if let Some(ref mut sb) = self.scene_buffers {
sb.update(
&self.ctx.device,
&self.ctx.queue,
&camera_params,
&dummy_transport_params,
&material_params,
packed,
&facet_finishes,
scene.environment,
)?;
} else {
self.scene_buffers = Some(SceneBuffers::new(
&self.ctx.device,
&camera_params,
&dummy_transport_params,
&material_params,
packed,
&facet_finishes,
scene.environment,
)?);
}
Ok(())
}
fn ensure_chunk_buffers(&mut self, pixels: usize, rays: usize) {
if let Some(ref mut cb) = self.chunk_buffers {
cb.ensure_capacity(&self.ctx.device, pixels, rays);
} else {
self.chunk_buffers = Some(ChunkRayBuffers::new(&self.ctx.device, pixels, rays));
}
}
#[allow(
clippy::future_not_send,
reason = "wgpu web backend buffer mapping futures are !Send; wasm32 is single-threaded"
)]
#[expect(
clippy::too_many_arguments,
reason = "the packed planes binding and its tool count ride beside the scene so the \
planar callers keep the scene struct they have"
)]
async fn accumulate_prepared(
&mut self,
scene: &FrameScene<'_>,
packed: &[GpuFacetPlane],
tool_count: u32,
material_class: u32,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> Result<(), EwError> {
let num_pixels = accum.len();
self.upload_scene(scene, packed, spp)?;
let max_storage_binding = self.ctx.device.limits().max_storage_buffer_binding_size;
let byte_budget_pixels = chunk_pixels_for(
self.chunk_budget_bytes,
spp,
num_pixels,
max_storage_binding,
);
let mut pixel_offset = 0usize;
while pixel_offset < num_pixels {
let pixels_remaining = num_pixels - pixel_offset;
let pixels_this_chunk = next_chunk_pixels(
self.ns_per_tuple_ema,
byte_budget_pixels,
spp,
pixels_remaining,
)
.min(pixels_remaining);
let chunk_rays = pixels_this_chunk * spp as usize;
self.ensure_chunk_buffers(pixels_this_chunk, chunk_rays);
let chunk_params = chunk_transport_params(
scene,
tool_count,
sample_offset,
pixels_this_chunk,
pixel_offset,
);
clock::set_readback_deadline_ms(clock::chunk_deadline_ms(
self.ns_per_tuple_ema,
chunk_rays,
));
let start = clock::Instant::now();
let result = {
let scene_buffers = self
.scene_buffers
.as_ref()
.expect("scene buffers initialized");
let chunk_buffers = self
.chunk_buffers
.as_ref()
.expect("chunk buffers initialized");
execute_chunk(
&self.ctx.device,
&self.ctx.queue,
&mut self.pipelines,
scene_buffers,
chunk_buffers,
scene,
material_class,
pixel_offset,
pixels_this_chunk,
spp,
&chunk_params,
)
.await?
};
self.check_uncaptured_error()?;
let ns_per_tuple = start.elapsed().as_nanos() as f64 / chunk_rays as f64;
self.ns_per_tuple_ema = Some(clock::blend_ema(self.ns_per_tuple_ema, ns_per_tuple));
for (i, triplet) in result.as_chunks::<3>().0.iter().enumerate() {
accum[pixel_offset + i] += Vec3::new(triplet[0], triplet[1], triplet[2]);
}
pixel_offset += pixels_this_chunk;
}
Ok(())
}
}