"""TorchBackend -- differentiable PyTorch backend for Non-Sequential Raytracing.
Implements a fixed-depth wavefront megakernel loop with autograd support.
Compaction is disabled to keep fixed-shape tensors for the autograd graph.
Memory scaling: O(num_rays x max_depth) activations when gradient_mode is
"autograd". The recommended envelope is ~1e5 rays at depth 16 on a single
GPU.
Kramer Harrison, 2026
"""
from __future__ import annotations
import time
import warnings
from typing import TYPE_CHECKING, Literal
import numpy as np
import optiland.backend as be
from optiland.backend.utils import to_numpy
from optiland.nonsequential._utils import (
DEFAULT_BATCH_SIZE,
distribute_ray_budget,
estimate_bounding_scale,
get_detector_names,
)
from optiland.nonsequential.backends.base import TracerBackend
from optiland.nonsequential.detectors.dispatch import (
detector_absorb_mask,
intersect_detectors,
)
from optiland.nonsequential.diagnostics import build_diagnostics
from optiland.nonsequential.ir.interpreter import apply_primitive_interactions
from optiland.nonsequential.ir.lower import lower
from optiland.nonsequential.path_recording import PathRecorder
from optiland.nonsequential.rng import NSQRng
from optiland.nonsequential.sampling import russian_roulette
if TYPE_CHECKING:
from optiland.nonsequential.components.base import BaseComponent
from optiland.nonsequential.ray_bundle import NSQRayBundle
from optiland.nonsequential.scene import NSQScene
from optiland.nonsequential.tracer import SimulationResult
[docs]
class TorchBackend(TracerBackend):
"""Differentiable PyTorch backend for NSQ raytracing.
Uses ``optiland.backend`` (configured to torch) for all computation.
The fixed-depth wavefront loop lets PyTorch build an autograd graph
through the entire trace so that ``result.detectors[name].data.backward()``
propagates gradients to scene parameters.
Compaction is disabled: dead rays (``alive=False``) carry zero throughput
and participate in all operations as no-ops; the tensor shape stays fixed
across bounces so the graph remains clean.
Gradient strategy is "autograd" (naive attached graph) in v1. A pluggable
``gradient_mode`` seam is provided for future Path Replay Backpropagation.
Attributes:
seed: RNG seed.
gradient_mode: Gradient strategy (currently only "autograd").
rng: Keyed PCG32 RNG for detached sampling decisions (see
:mod:`optiland.nonsequential.rng`).
"""
def __init__(
self,
seed: int | None = None,
gradient_mode: Literal["autograd"] = "autograd",
) -> None:
"""Initialize TorchBackend.
Args:
seed: Optional random seed for reproducibility.
gradient_mode: Gradient computation strategy. Currently only
``"autograd"`` is supported; "prb" is the planned follow-up.
"""
self.seed = seed
self.gradient_mode = gradient_mode
# Detached sampling uses a keyed RNG (sampling decisions are detached)
self.rng = NSQRng(seed)
[docs]
def intersect_scene(
self,
rays: NSQRayBundle,
components: list[BaseComponent],
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Find nearest intersection of each ray with all scene components.
t_min and hit_normals stay in the torch graph (attached to geometry
parameters). comp_indices are numpy ints (no grad needed). n_geom is
purely geometric (never a function of a differentiable material
parameter), but is still built via be.where to stay consistent with
the rest of this method and to support a differentiable geometry
(radius, conic, ...) tilting n_geom itself.
Args:
rays: Current ray bundle.
components: List of scene components.
Returns:
``(t_min, hit_normals, component_indices, hit_n_geom)``.
"""
N = rays.num_rays
t_min = be.ones(N) * be.inf
hit_normals = be.zeros((N, 3))
hit_n_geom = be.zeros((N, 3))
comp_indices = np.full(N, -1, dtype=np.int32)
for i, comp in enumerate(components):
t_c, normals_c, hit_c, n_geom_c = comp.intersect(rays)
hit_c_np = to_numpy(hit_c).astype(bool)
t_c_np = to_numpy(t_c)
better_np = hit_c_np & (t_c_np < to_numpy(t_min))
# Update t_min and hit_normals via be.where (stays in graph)
better = be.array(better_np)
t_min = be.where(better, t_c, t_min)
hit_normals = be.where(better[:, None], normals_c, hit_normals)
hit_n_geom = be.where(better[:, None], n_geom_c, hit_n_geom)
comp_indices = np.where(better_np, i, comp_indices)
return t_min, hit_normals, comp_indices, hit_n_geom
[docs]
def trace(
self,
scene: NSQScene,
num_rays: int,
max_depth: int = 16,
min_flux_fraction: float = 1e-6,
batch_size: int = DEFAULT_BATCH_SIZE,
seed: int | None = None,
record_paths: bool | int = False,
) -> SimulationResult:
"""Run the differentiable fixed-depth trace.
Args:
scene: NSQScene to simulate.
num_rays: Total rays to launch.
max_depth: Fixed number of bounces. Rays exceeding this are
depth-killed. Memory scales O(num_rays × max_depth).
min_flux_fraction: Russian-roulette threshold, relative to
per-ray initial flux -- combined with the scene's
``sampling_policy.rr_start_flux`` (the larger of the two
wins). Below threshold, rays are killed with an unbiased
probability and survivors' flux is boosted accordingly,
rather than truncated outright.
batch_size: Rays per processing batch (forward pass only). Does not
change the result, only the speed; see ``DEFAULT_BATCH_SIZE``.
seed: RNG seed override (overrides constructor seed if provided).
record_paths: ``False`` records nothing, ``True`` records every
ray's path (numpy, detached), and a positive ``int`` records
an approximately that-many-ray subset selected
deterministically by ``ray_id`` hash -- see
:mod:`optiland.nonsequential.path_recording`.
Returns:
SimulationResult with differentiable detector ``data`` tensors.
"""
from optiland.nonsequential.components.absorbing import ( # noqa: PLC0415
AbsorbingComponent,
)
from optiland.nonsequential.tracer import ( # noqa: PLC0415, I001
SimulationResult,
)
if seed is not None:
self.rng = NSQRng(seed)
# Reset detectors and absorber stats
for det in scene.detectors:
det.reset()
for comp in scene.surfaces:
if isinstance(comp, AbsorbingComponent):
comp.reset_stats()
# The per-bounce interaction loop below is driven by this IR, not by
# iterating scene.surfaces and branching on Python class identity.
ir = lower(scene, strict=False)
# Bounded splitting is the NumPy forward engine only: it
# grows the live ray bundle, which conflicts with the fixed tensor
# shapes this backend's autograd graph requires. Never silently
# ignored (D-14-class failure mode) -- warn and fall back to
# importance-biased single-branch sampling, which this backend
# always uses regardless of split_depth.
if ir.sampling.split_depth > 0:
warnings.warn(
f"TorchBackend does not support bounded splitting "
f"(sampling_policy.split_depth={ir.sampling.split_depth}); "
"fixed tensor shapes are required for the autograd graph. "
"Falling back to importance-biased single-branch sampling "
"(split_depth is ignored). Use NumpyBackend for bounded "
"splitting.",
stacklevel=2,
)
t_start = time.perf_counter()
sources = scene.sources
# Float-cast for stats / kill-threshold; source.generate() uses the
# raw total_flux (may be a torch Tensor for autograd).
total_flux_in = sum(float(s.total_flux) for s in sources)
num_rays_total = int(num_rays)
flux_per_ray = total_flux_in / num_rays_total if num_rays_total > 0 else 1.0
num_rays_absorbed = 0
num_rays_escaped = 0
num_rays_flux_killed = 0
num_rays_depth_killed = 0
total_flux_escaped = 0.0
total_flux_bulk_absorbed = 0.0
# Tracked separately for Diagnostics -- see the
# matching comment in ArrayBackend.trace().
total_flux_depth_killed = 0.0
total_flux_rr_killed = 0.0
hit_component_ids: set[int] = set()
total_medium_stack_underflows = 0
# Distribute ray budget across sources proportional to flux
rays_per_source = distribute_ray_budget(
num_rays_total, [float(s.total_flux) for s in sources]
)
# Vectorised columnar path recording: PathRecorder
# replaces the old birth-only per-event Python dict closure with
# preallocated array writes, and adds hit/death recording (the
# array backend already had both; this backend previously recorded
# only birth events -- an existing parity gap this PR also closes)
# plus the record_paths: int subset contract.
path_recorder = PathRecorder(record_paths, num_rays_total, self.rng.seed)
_next_ray_id: list[int] = [0]
# Main trace loop (no compaction -- fixed-shape for autograd)
for source_idx, (source, source_num_rays) in enumerate(
zip(sources, rays_per_source, strict=False)
):
source_name = getattr(source, "name", f"source_{source_idx}")
source_remaining = source_num_rays
while source_remaining > 0:
batch = min(batch_size, source_remaining)
ray_id = np.arange(
_next_ray_id[0], _next_ray_id[0] + batch, dtype=np.int64
)
_next_ray_id[0] += batch
rays = source.generate(ray_id, self.rng)
# source.generate() spreads the source's whole total_flux over
# the rays it is asked for, so a batched source would re-emit
# the full flux once per batch. Rescale to this batch's share
# of the source's ray budget. A no-op when batch == the budget.
if batch != source_num_rays:
rays.flux = rays.flux * (batch / source_num_rays)
# Ensure all physics arrays are torch tensors. Sources
# produce numpy arrays by default; NumPy 2.0 disallows
# mixed numpy/torch arithmetic, so we promote upfront.
rays = self._ensure_torch_bundle(rays)
path_recorder.log_birth(rays, source_name)
# Fixed-depth loop (no compaction)
for _depth in range(max_depth):
alive_np = to_numpy(rays.alive).astype(bool)
if not alive_np.any():
break
# Component intersections
t_min, hit_normals, comp_idx, hit_n_geom = self.intersect_scene(
rays, scene.surfaces
)
# Detector intersections. Dispatch is numpy; t stays
# attached (shared with ArrayBackend/NumpyBackend; D-10).
det_t_min, _det_normals, det_idx_np = intersect_detectors(
rays, scene.detectors
)
det_t_min_np = to_numpy(det_t_min)
t_min_np = to_numpy(t_min)
# Nearest hit: component vs detector
comp_closer_np = t_min_np <= det_t_min_np
any_comp_hit_np = comp_idx >= 0
any_det_hit_np = det_idx_np >= 0
det_first_np = any_det_hit_np & (~comp_closer_np | ~any_comp_hit_np)
comp_first_np = any_comp_hit_np & (~det_first_np)
# unreached_geometry: cheap running set of
# every primitive that was ever the nearest hit.
if comp_first_np.any():
hit_component_ids.update(
np.unique(comp_idx[comp_first_np]).tolist()
)
det_first = be.array(det_first_np)
# Rays that hit no detector carry t = inf. Zero those before
# any multiplication: the discarded branch of be.where still
# backpropagates 0 * inf = NaN into the ray directions.
det_t_safe = be.where(
det_first, det_t_min, be.zeros_like(det_t_min)
)
# Beer-Lambert bulk absorption: attenuate flux over
# the segment each ray just travelled through its
# *current* medium (rays.k_current, set at its last
# crossing or its source's ambient medium) before this
# bounce's nearest hit -- component or detector,
# whichever is closer. Applied before interact()/detector
# recording touch flux or k_current so both see the
# already-attenuated value; k_current itself is only
# updated afterwards, by RefractiveComponent.interact(),
# for the medium the ray is now entering.
hit_first_np = comp_first_np | det_first_np
if hit_first_np.any():
hit_first = be.array(hit_first_np)
comp_first = be.array(comp_first_np)
comp_t_safe = be.where(comp_first, t_min, be.zeros_like(t_min))
hit_t = be.where(comp_first, comp_t_safe, det_t_safe)
alpha = 4.0 * be.pi * rays.k_current / rays.wavelength
# hit_t is in mm; alpha is in 1/um -> convert to um.
transmittance = be.exp(-alpha * hit_t * 1e3)
flux_before = rays.flux
rays.flux = flux_before * be.where(
hit_first, transmittance, be.ones_like(rays.flux)
)
total_flux_bulk_absorbed += float(
to_numpy(flux_before - rays.flux).sum()
)
# Record detector hits
for di, det in enumerate(scene.detectors):
mask_di_np = det_first_np & (det_idx_np == di)
if mask_di_np.any():
mask_di = be.array(mask_di_np)
det_name = getattr(det, "name", f"detector_{di}")
path_recorder.log_hits(
rays, mask_di_np, det_name, t_offset=det_t_safe
)
det.record(rays, det_t_safe, mask_di)
# Advance detector-hit rays. Absorbing detectors
# terminate the ray; absorb=False detectors are
# transmissive: the hit is recorded (above) and the ray
# continues on its unchanged direction.
if det_first_np.any():
dx_det = det_t_safe * rays.L
dy_det = det_t_safe * rays.M
dz_det = det_t_safe * rays.N
rays.x = be.where(det_first, rays.x + dx_det, rays.x)
rays.y = be.where(det_first, rays.y + dy_det, rays.y)
rays.z = be.where(det_first, rays.z + dz_det, rays.z)
rays.bounce = be.where(det_first, rays.bounce + 1, rays.bounce)
absorb_np = detector_absorb_mask(det_idx_np, scene.detectors)
kill_np = det_first_np & absorb_np
rays.alive = rays.alive & ~be.array(kill_np)
# Apply component interactions, dispatched from the IR
# (ir.primitives[i].component_kind / .bsdf.kind) rather
# than by iterating scene.surfaces and checking isinstance.
apply_primitive_interactions(
rays,
ir,
scene.surfaces,
t_min,
hit_normals,
hit_n_geom,
comp_idx,
comp_first_np,
self.rng,
log_hit_fn=path_recorder.log_hits,
)
# D1: flush this bounce's medium-stack underflow counts
# (see RefractiveComponent.interact) into the running
# total, then reset -- counted exactly once per bounce.
total_medium_stack_underflows += int(
rays.medium_stack_underflows.sum()
)
rays.medium_stack_underflows[:] = 0
# Kill escaped rays
no_hit_np = ~any_comp_hit_np & ~any_det_hit_np
escaped_np = no_hit_np & alive_np
if escaped_np.any():
num_rays_escaped += int(escaped_np.sum())
total_flux_escaped += float(
to_numpy(rays.flux)[escaped_np].sum()
)
path_recorder.log_deaths(rays, escaped_np, "escaped")
escaped = be.array(escaped_np)
bs = estimate_bounding_scale(scene)
rays.x = be.where(escaped, rays.x + bs * rays.L, rays.x)
rays.y = be.where(escaped, rays.y + bs * rays.M, rays.y)
rays.z = be.where(escaped, rays.z + bs * rays.N, rays.z)
rays.alive = rays.alive & ~be.array(no_hit_np)
# Depth truncation: hard kill (inherent, reported bias --
# unlike the flux threshold below, not replaced by
# roulette; there is no unbiased way to "continue" a ray
# past a hard bounce-count cap).
alive_np_now = to_numpy(rays.alive).astype(bool)
bounce_np = to_numpy(rays.bounce)
alive_depth_np = bounce_np < max_depth
newly_depth_killed = alive_np_now & ~alive_depth_np
if newly_depth_killed.any():
num_rays_depth_killed += int(newly_depth_killed.sum())
total_flux_depth_killed += float(
to_numpy(rays.flux)[newly_depth_killed].sum()
)
path_recorder.log_deaths(
rays, newly_depth_killed, "depth_killed"
)
rays.alive = rays.alive & be.array(alive_depth_np)
# Russian roulette replaces the old biased hard
# kill below min_flux: unbiased stochastic termination
# (kill with probability p, boost survivors by
# 1/(1-p)), so total_flux_lost reports a genuine
# diagnostic (~0 for a well-configured scene) instead of
# an expected bookkeeping entry. Same mechanism as the
# NumPy backend (optiland.nonsequential.sampling) -- no
# shape change is needed here, since a killed ray simply
# gets alive=False like any other kill on this
# fixed-shape backend.
rr_threshold_fraction = max(
min_flux_fraction, ir.sampling.rr_start_flux
)
flux_before_rr = rays.flux
rays.flux, rays.alive, rr_killed_np = russian_roulette(
rays.flux,
rays.alive,
rr_threshold_fraction,
flux_per_ray,
self.rng,
to_numpy(rays.ray_id),
to_numpy(rays.bounce),
)
if rr_killed_np.any():
num_rays_flux_killed += int(rr_killed_np.sum())
total_flux_rr_killed += float(
to_numpy(flux_before_rr)[rr_killed_np].sum()
)
path_recorder.log_deaths(rays, rr_killed_np, "flux_killed")
source_remaining -= batch
t_end = time.perf_counter()
# Collect absorbed stats
total_flux_absorbed = sum(
c._absorbed_flux
for c in scene.surfaces
if isinstance(c, AbsorbingComponent)
)
for comp in scene.surfaces:
if isinstance(comp, AbsorbingComponent):
num_rays_absorbed += comp._absorbed_count
# Collect detector results
detector_results: dict[str, object] = {}
total_flux_detected = 0.0
det_names = get_detector_names(scene)
for i, det in enumerate(scene.detectors):
name = det_names[i] if i < len(det_names) else (det.name or f"detector_{i}")
result = det.get_result()
detector_results[name] = result
if hasattr(result, "total_flux"):
# IrradianceMap.total_flux may be an attached backend array;
# SimulationResult's aggregate stays a plain float.
total_flux_detected += float(to_numpy(result.total_flux))
total_flux_lost = total_flux_depth_killed + total_flux_rr_killed
# Every launched watt ends up detected, absorbed, escaped, or killed
# by the flux/depth cutoffs. Omitting total_flux_lost makes the metric
# report a large error for any scene that depth-kills rays, which is
# exactly the stray-light case this diagnostic exists to serve.
flux_err = (
abs(
total_flux_in
- total_flux_detected
- total_flux_absorbed
- total_flux_bulk_absorbed
- total_flux_escaped
- total_flux_lost
)
/ total_flux_in
if total_flux_in > 0
else 0.0
)
# Vectorised: single conversion of the columnar buffers
# to the structured-array format (numpy, detached), done once here
# rather than incrementally per event.
ray_paths = path_recorder.finalize()
# split_budget_saturated is always False here: bounded splitting
# is the NumPy forward engine only.
diagnostics = build_diagnostics(
scene,
hit_component_ids,
num_rays_total,
total_flux_in,
total_flux_depth_killed,
total_flux_rr_killed,
flux_err,
False,
detector_results,
medium_stack_underflows=total_medium_stack_underflows,
)
return SimulationResult(
detectors=detector_results,
num_rays_total=num_rays_total,
num_rays_absorbed=num_rays_absorbed,
num_rays_escaped=num_rays_escaped,
num_rays_flux_killed=num_rays_flux_killed,
num_rays_depth_killed=num_rays_depth_killed,
total_flux_in=total_flux_in,
total_flux_detected=total_flux_detected,
total_flux_absorbed=total_flux_absorbed,
total_flux_bulk_absorbed=total_flux_bulk_absorbed,
total_flux_escaped=total_flux_escaped,
total_flux_lost=total_flux_lost,
flux_conservation_error=flux_err,
trace_time_sec=t_end - t_start,
ray_paths=ray_paths,
diagnostics=diagnostics,
)
def _ensure_torch_bundle(self, rays: NSQRayBundle) -> NSQRayBundle:
"""Convert all NSQRayBundle float arrays to torch tensors.
Sources produce numpy arrays by default. NumPy 2.0 disallows mixed
numpy/torch arithmetic, so we promote every field to the current
backend format at batch start. Gradient-carrying fields (flux)
are left untouched if already a Tensor.
Args:
rays: Ray bundle from source.generate().
Returns:
Same ray bundle with all physics arrays as torch Tensors.
"""
import torch as _torch # noqa: PLC0415
def _to_float(x: object) -> _torch.Tensor:
if isinstance(x, _torch.Tensor):
return x
return be.array(x)
def _to_bool(x: object) -> _torch.Tensor:
if isinstance(x, _torch.Tensor) and x.dtype == _torch.bool:
return x
arr = to_numpy(x) if isinstance(x, _torch.Tensor) else x
return _torch.from_numpy(np.asarray(arr, dtype=bool).copy())
rays.x = _to_float(rays.x)
rays.y = _to_float(rays.y)
rays.z = _to_float(rays.z)
rays.L = _to_float(rays.L)
rays.M = _to_float(rays.M)
rays.N = _to_float(rays.N)
rays.flux = _to_float(rays.flux)
rays.wavelength = _to_float(rays.wavelength)
rays.n_current = _to_float(rays.n_current)
rays.k_current = _to_float(rays.k_current)
rays.alive = _to_bool(rays.alive)
# bounce: keep as int32 tensor (used for depth comparisons)
if not isinstance(rays.bounce, _torch.Tensor):
rays.bounce = _torch.from_numpy(
np.asarray(rays.bounce, dtype=np.int32).copy()
)
return rays