use std::sync::atomic::AtomicBool;
use glam::Vec3;
use crate::{
geometry::GpuFacetPlane,
optics::{
materials::GemMaterial,
raytracer::{Camera, EnvironmentSource, FacetFinish},
},
};
#[cfg(feature = "gpu")]
use super::gpu::GpuFrameError;
#[cfg(feature = "gpu")]
use super::gpu::frame::{ChunkCursor, ChunkTurnOutcome, TurnRequest, classify_material};
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
pub struct GpuSceneRef<'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 GpuAccumulate {
Done,
Declined,
Cancelled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GpuPipelineKind {
#[default]
Megakernel,
Wavefront,
}
#[cfg(feature = "gpu")]
const CHUNKS_PER_TURN: usize = 2;
#[cfg(feature = "gpu")]
struct Turnstile {
next_ticket: std::sync::atomic::AtomicU64,
now_serving: std::sync::Mutex<u64>,
turn_taken: std::sync::Condvar,
}
#[cfg(feature = "gpu")]
impl Turnstile {
const fn new() -> Self {
Self {
next_ticket: std::sync::atomic::AtomicU64::new(0),
now_serving: std::sync::Mutex::new(0),
turn_taken: std::sync::Condvar::new(),
}
}
fn take_ticket(&self) -> u64 {
self.next_ticket
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
fn wait_for_turn(&self, ticket: u64) -> TurnstileTurn<'_> {
let mut serving = self
.now_serving
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *serving != ticket {
serving = self
.turn_taken
.wait(serving)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
drop(serving);
TurnstileTurn { turnstile: self }
}
}
#[cfg(feature = "gpu")]
struct TurnstileTurn<'a> {
turnstile: &'a Turnstile,
}
#[cfg(feature = "gpu")]
impl Drop for TurnstileTurn<'_> {
fn drop(&mut self) {
let mut serving = self
.turnstile
.now_serving
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*serving += 1;
drop(serving);
self.turnstile.turn_taken.notify_all();
}
}
#[cfg(feature = "gpu")]
pub struct GpuBackend {
renderer: Option<std::sync::Mutex<super::gpu::GpuFrameRenderer>>,
turnstile: Turnstile,
lost: AtomicBool,
last_lost_reason: std::sync::Mutex<Option<String>>,
}
#[cfg(feature = "gpu")]
impl GpuBackend {
#[must_use]
pub fn acquire() -> Self {
let renderer = match super::gpu::GpuFrameRenderer::new() {
Ok(r) => {
tracing::info!(adapter = r.adapter_label(), "GPU render backend active");
Some(std::sync::Mutex::new(r))
}
Err(e) => {
tracing::info!("GPU render backend unavailable, using CPU tracer: {e}");
None
}
};
Self {
renderer,
turnstile: Turnstile::new(),
lost: AtomicBool::new(false),
last_lost_reason: std::sync::Mutex::new(None),
}
}
#[must_use]
pub const fn disabled() -> Self {
Self {
renderer: None,
turnstile: Turnstile::new(),
lost: AtomicBool::new(false),
last_lost_reason: std::sync::Mutex::new(None),
}
}
#[must_use]
pub fn is_lost(&self) -> bool {
self.lost.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn last_lost_reason(&self) -> Option<String> {
self.last_lost_reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
#[must_use]
pub fn adapter_label(&self) -> Option<String> {
if self.lost.load(std::sync::atomic::Ordering::Relaxed) {
return None;
}
self.renderer.as_ref().map(|m| {
m.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.adapter_label()
.to_string()
})
}
pub fn set_pipeline_kind(&self, kind: GpuPipelineKind) {
if let Some(mutex) = &self.renderer {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.set_pipeline_kind(kind);
}
}
pub fn try_accumulate(
&self,
scene: &GpuSceneRef<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
) -> bool {
let never_cancel = AtomicBool::new(false);
matches!(
self.try_accumulate_cancellable(scene, sample_offset, spp, accum, &never_cancel),
GpuAccumulate::Done
)
}
pub fn try_accumulate_cancellable(
&self,
scene: &GpuSceneRef<'_>,
sample_offset: u32,
spp: u32,
accum: &mut [Vec3],
cancel: &AtomicBool,
) -> GpuAccumulate {
if self.lost.load(std::sync::atomic::Ordering::Relaxed) {
return GpuAccumulate::Declined;
}
let Some(mutex) = &self.renderer else {
return GpuAccumulate::Declined;
};
let gpu_scene = super::gpu::GpuFrameScene {
camera: scene.camera,
width: scene.width,
height: scene.height,
planes: scene.planes,
facet_finishes: scene.facet_finishes,
material: scene.material,
max_bounces: scene.max_bounces,
environment: scene.environment,
};
let pipeline_class = classify_material(gpu_scene.material);
let mut cursor = ChunkCursor::default();
let request = TurnRequest {
scene: &gpu_scene,
pipeline_class,
sample_offset,
spp,
cancel: Some(cancel),
max_chunks: CHUNKS_PER_TURN,
};
loop {
let ticket = self.turnstile.take_ticket();
let _turn = self.turnstile.wait_for_turn(ticket);
if self.lost.load(std::sync::atomic::Ordering::Relaxed) {
return GpuAccumulate::Declined;
}
let mut renderer = match mutex.lock() {
Ok(guard) => guard,
Err(_poisoned) => {
tracing::warn!(
"GPU renderer mutex poisoned (a previous turn panicked), permanently \
disabling the GPU backend for the rest of this process"
);
*self
.last_lost_reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some("GPU renderer mutex poisoned (a previous turn panicked)".to_string());
self.lost.store(true, std::sync::atomic::Ordering::Relaxed);
return GpuAccumulate::Declined;
}
};
let outcome = renderer.accumulate_turn(&request, accum, &mut cursor);
drop(renderer);
match outcome {
Ok(ChunkTurnOutcome::Done) => return GpuAccumulate::Done,
Ok(ChunkTurnOutcome::Cancelled) => return GpuAccumulate::Cancelled,
Ok(ChunkTurnOutcome::MoreWork) => {}
Err(GpuFrameError::DeviceLost(why)) => {
tracing::warn!(
"GPU device lost ({why}), permanently disabling the GPU backend for \
the rest of this process -- every later call falls back to the CPU \
tracer"
);
*self
.last_lost_reason
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(why);
self.lost.store(true, std::sync::atomic::Ordering::Relaxed);
return GpuAccumulate::Declined;
}
Err(e) => {
tracing::debug!("GPU declined this dispatch, using CPU tracer: {e}");
return GpuAccumulate::Declined;
}
}
}
}
}
#[cfg(not(feature = "gpu"))]
pub struct GpuBackend;
#[cfg(not(feature = "gpu"))]
impl GpuBackend {
#[must_use]
pub const fn acquire() -> Self {
Self
}
#[must_use]
pub const fn disabled() -> Self {
Self
}
#[must_use]
pub const fn adapter_label(&self) -> Option<String> {
None
}
pub const fn set_pipeline_kind(&self, _kind: GpuPipelineKind) {}
#[must_use]
pub const fn is_lost(&self) -> bool {
false
}
#[must_use]
pub const fn last_lost_reason(&self) -> Option<String> {
None
}
#[allow(
clippy::unused_self,
reason = "signature must match the `gpu`-gated GpuBackend::try_accumulate"
)]
pub const fn try_accumulate(
&self,
_scene: &GpuSceneRef<'_>,
_sample_offset: u32,
_spp: u32,
_accum: &mut [Vec3],
) -> bool {
false
}
pub const fn try_accumulate_cancellable(
&self,
_scene: &GpuSceneRef<'_>,
_sample_offset: u32,
_spp: u32,
_accum: &mut [Vec3],
_cancel: &AtomicBool,
) -> GpuAccumulate {
GpuAccumulate::Declined
}
}
#[cfg(all(test, feature = "gpu"))]
mod tests {
use super::Turnstile;
use std::{sync::Mutex, thread};
use super::{GpuAccumulate, GpuBackend, GpuSceneRef};
use crate::{
geometry::cuts::StandardGemCuts,
optics::{
materials::GemMaterial,
raytracer::{Camera, LightingPreset},
},
};
use glam::Vec3;
use std::sync::atomic::AtomicBool;
#[test]
fn turnstile_serves_tickets_in_order_even_when_threads_queue_out_of_order() {
let turnstile = Turnstile::new();
let served_order: Mutex<Vec<u64>> = Mutex::new(Vec::new());
let t0 = turnstile.take_ticket();
let t1 = turnstile.take_ticket();
let t2 = turnstile.take_ticket();
thread::scope(|scope| {
scope.spawn(|| {
let _turn = turnstile.wait_for_turn(t2);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(2);
});
scope.spawn(|| {
let _turn = turnstile.wait_for_turn(t1);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(1);
});
{
let _turn0 = turnstile.wait_for_turn(t0);
served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(0);
}
});
assert_eq!(
*served_order
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
vec![0, 1, 2],
"tickets must be served in the order they were taken, not the order threads \
happened to start waiting"
);
}
#[test]
fn turnstile_releases_on_an_early_return_out_of_the_turn_scope() {
fn takes_a_turn_then_bails_early(turnstile: &Turnstile, ticket: u64) -> Option<()> {
let _turn = turnstile.wait_for_turn(ticket);
None }
let turnstile = Turnstile::new();
let t0 = turnstile.take_ticket();
let t1 = turnstile.take_ticket();
assert_eq!(takes_a_turn_then_bails_early(&turnstile, t0), None);
let _turn1 = turnstile.wait_for_turn(t1);
}
#[test]
fn poisoned_renderer_mutex_declines_and_sets_lost() {
let backend = GpuBackend::acquire();
let Some(mutex) = &backend.renderer else {
println!("skipping poisoned_renderer_mutex_declines_and_sets_lost: no GPU adapter");
return;
};
let join_result = thread::scope(|scope| {
scope
.spawn(|| {
let _guard = mutex
.lock()
.expect("first lock of a fresh mutex cannot fail");
panic!("deliberate poison for poisoned_renderer_mutex_declines_and_sets_lost");
})
.join()
});
assert!(
join_result.is_err(),
"the scoped thread was expected to panic (that's what poisons the mutex)"
);
assert!(mutex.is_poisoned(), "the mutex should now be poisoned");
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 scene = GpuSceneRef {
camera: &camera,
width: 4,
height: 4,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 2,
environment: LightingPreset::Daylight.studio(1.0, 0.4, 0.35),
};
let mut accum = vec![Vec3::ZERO; 16];
let never_cancel = AtomicBool::new(false);
let outcome = backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel);
assert_eq!(
outcome,
GpuAccumulate::Declined,
"a poisoned renderer mutex must decline, not dispatch into possibly-corrupt state"
);
assert!(
backend.lost.load(std::sync::atomic::Ordering::Relaxed),
"a poisoned renderer mutex must permanently set `lost`, like DeviceLost does"
);
let outcome2 = backend.try_accumulate_cancellable(&scene, 0, 1, &mut accum, &never_cancel);
assert_eq!(outcome2, GpuAccumulate::Declined);
}
#[test]
fn viewport_frames_with_camera_changes_never_poison_the_backend() {
let backend = GpuBackend::acquire();
if backend.renderer.is_none() {
println!(
"skipping viewport_frames_with_camera_changes_never_poison_the_backend: no \
GPU adapter"
);
return;
}
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::by_name("Spinel").expect("Spinel is a built-in cubic material");
let environment = LightingPreset::Daylight.studio(1.0, 0.4, 0.35);
let never_cancel = AtomicBool::new(false);
let (width, height) = (480u32, 360u32);
let spp = 2u32;
let mut accum = vec![Vec3::ZERO; (width * height) as usize];
for frame in 0..12u32 {
#[allow(
clippy::cast_precision_loss,
reason = "frame index is tiny; precision loss is not a concern in this test"
)]
let yaw = (frame as f32).mul_add(0.29, 0.1);
#[allow(
clippy::cast_precision_loss,
reason = "frame index is tiny; precision loss is not a concern in this test"
)]
let pitch = (frame as f32).mul_add(0.13, 0.2).clamp(-1.4, 1.4);
let camera = Camera::new(yaw, pitch, 5.0, 18.0);
let scene = GpuSceneRef {
camera: &camera,
width,
height,
planes: &planes,
facet_finishes: &[],
material: &material,
max_bounces: 4,
environment,
};
let outcome = backend.try_accumulate_cancellable(
&scene,
frame * spp,
spp,
&mut accum,
&never_cancel,
);
assert_eq!(
outcome,
GpuAccumulate::Done,
"frame {frame} (yaw={yaw}, pitch={pitch}) did not complete -- got {outcome:?} \
instead of Done; `backend.lost` = {}",
backend.lost.load(std::sync::atomic::Ordering::Relaxed)
);
}
assert!(
accum.iter().any(|v| v.length_squared() > 0.0),
"a lit studio-rig scene traced over 12 frames must leave SOME nonzero radiance"
);
}
}