indicatrix 0.1.0

A physically-based spectral gemstone renderer that turns GemCAD-style cutting schedules into rendered output.
Documentation
//! The decline-and-fall-back GPU wrapper, shared by every application in this
//! workspace.
//!
//! [`GpuBackend`] wraps [`GpuFrameRenderer`](super::gpu::GpuFrameRenderer) with the one
//! policy every caller needs and none of them should re-derive: try the GPU, fall back
//! to the CPU tracer whenever it declines, and never pretend the GPU ran when it didn't.
//!
//! # Why this lives in `indicatrix` rather than in each app
//!
//! It previously existed twice -- once in `apps/indicatrix-cut` and once in
//! `apps/indicatrix-worker` -- as near-verbatim copies, because the worker cannot depend on
//! the GUI. Two copies of a *policy* is worse than two copies of a helper: the three
//! decline reasons below are correctness rules, not conveniences, and a copy that
//! drifted (forgetting the biaxial check, say) would render a plausible-looking wrong
//! image rather than fail. Both apps already depend on this crate, so one copy lives
//! here.
//!
//! # This module is NOT `#[cfg(feature = "gpu")]`
//!
//! Deliberately, and it is the whole point. Everything under [`super::gpu`] is gated on
//! that feature, so a caller written against it directly needs `#[cfg]` at every call
//! site. [`GpuBackend`] instead exists in *both* configurations -- as the real thing
//! with the feature on, and as a stand-in whose [`GpuBackend::try_accumulate`] always
//! declines with it off -- so an application has exactly one call site and no `#[cfg]`
//! anywhere in its own render loop.
//!
//! # Declining is normal, and decided per call
//!
//! A decline is not an error. It happens when:
//!
//! - this build has no `gpu` feature;
//! - [`GpuBackend::disabled`] was chosen explicitly (`indicatrix-worker`'s `--only-cpu`
//!   flag, or a test that wants determinism rather than whatever adapter the machine
//!   happens to have);
//! - the machine has no usable adapter;
//! - the environment is an **HDR map**, which the megakernel has no `env_mode` for.
//!
//! Biaxial materials (Alexandrite, Topaz, Tanzanite) do NOT decline any more: the
//! `BiaxialIndicatrix` machinery is ported to WGSL and verified at the same Tier
//! 2/Tier 3 bar as every other material, so `GemMaterial::gpu_supported()` is now
//! unconditionally `true` -- see that method's own doc comment.
//!
//! Because the decision is re-made on every call rather than once per session, a scene
//! that declines moves only its own samples to the CPU.
//!
//! # Sample-range additivity
//!
//! [`GpuBackend::try_accumulate`] ADDS into the caller's buffer, exactly as the CPU
//! tracers do. `sample_offset` must be the number of samples already folded into those
//! pixels: both the CPU formula and the GPU shader derive each sample's jitter and RNG
//! from the *absolute* sample index, so reusing an offset redraws identical samples and
//! biases the average instead of extending it. Honouring that is what keeps a GPU
//! worker's samples mergeable with a CPU viewer's -- see this crate's Tier 3 check,
//! which validates CPU and GPU tracing *disjoint* ranges of one image and merging them.
//!
//! # Concurrency
//!
//! Acquire once and share. The renderer lives behind a `Mutex` and every method takes
//! `&self`, so a `GpuBackend` can be wrapped in an `Arc` and handed to many threads (as
//! `indicatrix-worker`'s `serve` does, one clone per connection): concurrent dispatches
//! serialize on the single GPU queue rather than racing `GpuFrameRenderer`'s own `&mut
//! self` state (its lazily-grown chunk-output buffers). A single-threaded caller pays
//! only an uncontended lock. Re-acquiring per frame or per connection would repeat
//! adapter acquisition and megakernel compilation, both far too slow for that.

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::{AccumulateOutcome, GpuFrameError};

/// The scene inputs one GPU accumulation call needs.
///
/// Every type here is available whether or not the `gpu` feature is on, which is what
/// lets the stand-in below share this exact signature.
//
// Read only by the `gpu`-gated `try_accumulate`; the stand-in ignores the whole bundle.
// The struct stays defined in both configurations on purpose -- that is what keeps
// callers free of `#[cfg]` -- so its fields being unread in a CPU-only build is the
// expected shape of that build, not an oversight.
#[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],
    /// Per-plane surface finish, indexed in step with `planes`. `&[]` means every facet
    /// is polished -- see `GpuFrameScene::facet_finishes` on how a shorter slice is
    /// padded. A caller with no finish concept at all (`indicatrix-net`'s `SceneState`
    /// carries none) passes `&[]` and gets exactly the behaviour it had before finishes
    /// existed.
    pub facet_finishes: &'a [FacetFinish],
    pub material: &'a GemMaterial,
    pub max_bounces: u32,
    pub environment: EnvironmentSource<'a>,
}

/// Outcome of [`GpuBackend::try_accumulate_cancellable`].
///
/// N3/Q2: exists specifically to give a cancelled dispatch its OWN outcome, distinct
/// from both "done" and "declined" -- a plain `bool` (as [`GpuBackend::try_accumulate`]
/// still returns, for every existing caller) cannot tell those apart, and the
/// difference matters to a caller: a decline means "fall back to the CPU for every
/// requested sample," while a cancellation means "the caller no longer wants this work
/// at all" (e.g. `indicatrix-worker`'s client disconnected mid-frame) and usually calls for
/// neither a GPU nor a CPU retry.
///
/// Defined in both build configurations (like [`GpuSceneRef`] above) so a caller has one
/// return type regardless of whether the `gpu` feature is on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GpuAccumulate {
    /// Every requested sample was traced and added into `accum`.
    Done,
    /// The GPU declined this dispatch before tracing anything -- identical meaning to
    /// [`GpuBackend::try_accumulate`] returning `false`: `accum` is untouched, and the
    /// caller should fall back to the CPU tracer for the FULL `spp` requested. See the
    /// module doc comment's "Declining is normal" section for the reasons this happens
    /// -- now also including a permanently lost device (this build's `gpu` feature is on
    /// but a prior dispatch reported [`GpuFrameError::DeviceLost`], see the
    /// `#[cfg(feature = "gpu")]` `GpuBackend`'s `lost` field).
    Declined,
    /// `cancel` was observed set before every sample was traced.
    ///
    /// `accum` is GUARANTEED untouched -- see
    /// `renderer::gpu::frame::AccumulateOutcome::Cancelled`'s doc comment for the
    /// drain-then-discard mechanism that makes this exact, not merely "probably true."
    /// A caller never has to reason about a partial sample count: either fall back to
    /// the CPU for the full `spp`, or simply stop, exactly as if this call had never
    /// been made.
    Cancelled,
}

#[cfg(feature = "gpu")]
pub struct GpuBackend {
    renderer: Option<std::sync::Mutex<super::gpu::GpuFrameRenderer>>,
    /// Set permanently, once, the first time a dispatch reports
    /// [`GpuFrameError::DeviceLost`] -- review N3/M2: a lost device is the one decline
    /// reason that must never be retried, since every future dispatch against the same
    /// device fails identically (or, before this task, panicked). Both
    /// [`Self::try_accumulate_cancellable`] and [`Self::adapter_label`] check this
    /// before touching `renderer` at all once it is `true`: claiming a GPU we know is
    /// gone would be worse than never claiming one, per the module doc comment's
    /// `adapter_label` note. `AtomicBool` rather than a `Mutex<bool>` because it is read
    /// and written independently of the `renderer` mutex's own lock, from any of the
    /// many threads sharing one `Arc<GpuBackend>` (see the module doc comment's
    /// "Concurrency" section).
    lost: AtomicBool,
}

#[cfg(feature = "gpu")]
impl GpuBackend {
    /// Acquires an adapter and compiles the megakernel.
    ///
    /// Do this once, off the frame or request path: both steps are slow enough to
    /// matter. A machine with no usable GPU is an expected outcome, logged at `info`,
    /// after which every call declines.
    #[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,
            lost: AtomicBool::new(false),
        }
    }

    /// Never acquires an adapter -- every call declines, regardless of what hardware is
    /// present.
    ///
    /// The runtime opt-out (`indicatrix-worker`'s `--only-cpu` flag), and what a test constructs instead of
    /// [`Self::acquire`] so that running with `--features gpu` stays as deterministic as
    /// running without it, rather than tracing on whatever adapter the machine has.
    #[must_use]
    pub const fn disabled() -> Self {
        Self {
            renderer: None,
            lost: AtomicBool::new(false),
        }
    }

    /// This backend's adapter and backend label, if one was genuinely acquired.
    ///
    /// `None` whenever [`Self::disabled`] was chosen, acquisition failed, OR the device
    /// was later found to be lost (see [`Self::lost`]) -- a caller that advertises its
    /// backend to a peer (`indicatrix-worker`'s `WELCOME`) must key on this rather than on
    /// whether the feature is compiled in, and must not keep claiming a GPU this
    /// process already gave up on.
    #[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()
        })
    }

    /// Traces `spp` samples per pixel starting at `sample_offset` and ADDS each pixel's
    /// summed XYZ into `accum`.
    ///
    /// A thin wrapper over [`Self::try_accumulate_cancellable`] with a cancellation flag
    /// that is never set, kept for every caller that predates cooperative cancellation
    /// (Q2) and has no cancellation concept of its own. Returns `false` without touching
    /// `accum` for BOTH of that method's non-`Done` outcomes -- a plain `bool` cannot
    /// distinguish "declined" from "cancelled," but a flag that is never set can also
    /// never actually BE cancelled, so this distinction never arises for this method:
    /// see the module doc comment for the five decline reasons and why none of them is
    /// an error.
    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
        )
    }

    /// Like [`Self::try_accumulate`], but checks `cancel` between chunks (see
    /// `renderer::gpu::frame::GpuFrameRenderer::accumulate_cancellable`'s doc comment
    /// for exactly when) and can stop early, reporting which of three things happened
    /// via [`GpuAccumulate`] rather than a `bool`:
    ///
    /// - [`GpuAccumulate::Done`]: every requested sample was traced and added into
    ///   `accum`.
    /// - [`GpuAccumulate::Declined`]: the GPU never started -- no adapter, `disabled()`,
    ///   an unsupported material/environment, or a device this backend already knows is
    ///   lost (see [`Self::lost`]). `accum` is untouched; fall back to the CPU for the
    ///   full `spp`, exactly as [`Self::try_accumulate`] returning `false` means today.
    /// - [`GpuAccumulate::Cancelled`]: `cancel` was observed set before finishing.
    ///   `accum` is GUARANTEED untouched (drain-then-discard -- see
    ///   `AccumulateOutcome::Cancelled`'s doc comment for the mechanism); there is
    ///   nothing to fall back on because there is nothing left to render.
    ///
    /// A device reported as permanently lost ([`GpuFrameError::DeviceLost`]) is logged
    /// at `warn` (unlike an ordinary decline, which is `debug` -- a lost device is a
    /// real malfunction, not a documented routing limit) and this backend's [`Self::lost`]
    /// flag is set so every LATER call -- from this or any other thread sharing this
    /// `GpuBackend` -- also short-circuits straight to `Declined` without touching the
    /// (possibly now-panicking-on-retry) renderer again.
    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 mut renderer = mutex
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        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,
        };
        match renderer.accumulate_cancellable(&gpu_scene, sample_offset, spp, accum, cancel) {
            Ok(AccumulateOutcome::Done) => GpuAccumulate::Done,
            Ok(AccumulateOutcome::Cancelled) => GpuAccumulate::Cancelled,
            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.lost.store(true, std::sync::atomic::Ordering::Relaxed);
                GpuAccumulate::Declined
            }
            Err(e) => {
                // Not worth `warn`: an unsupported material or environment is a
                // documented limit of the megakernel, not a malfunction, and the CPU
                // path produces the image either way.
                tracing::debug!("GPU declined this dispatch, using CPU tracer: {e}");
                GpuAccumulate::Declined
            }
        }
    }
}

/// Stand-in for a build without the `gpu` feature -- see the `gpu`-gated [`GpuBackend`]
/// above for what it stands in for and why it exists at all.
#[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
    }

    /// Always `None`: with no `gpu` feature there is no adapter to name, so a caller
    /// advertising its backend correctly reports CPU.
    #[must_use]
    pub const fn adapter_label(&self) -> Option<String> {
        None
    }

    /// Always declines, leaving `accum` untouched.
    ///
    /// Ignores every argument only to stay signature-compatible with the real
    /// `try_accumulate` above -- that identical signature is the entire point of this
    /// stand-in, and is what keeps callers free of `#[cfg]`.
    #[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
    }

    /// Always declines, leaving `accum` untouched -- see the `gpu`-gated
    /// [`GpuBackend::try_accumulate_cancellable`] above for what it stands in for.
    /// Ignores `cancel` (and every other argument) for the same reason
    /// [`Self::try_accumulate`] does: signature compatibility is the entire point of
    /// this stand-in.
    pub const fn try_accumulate_cancellable(
        &self,
        _scene: &GpuSceneRef<'_>,
        _sample_offset: u32,
        _spp: u32,
        _accum: &mut [Vec3],
        _cancel: &AtomicBool,
    ) -> GpuAccumulate {
        GpuAccumulate::Declined
    }
}