use super::{
ExportOutcome, ExportProgress,
batch::{ExportCtx, HYBRID_MIN_SPP, calibrate_split, local_chunk_size, run_local_batches},
params::{ComputeTarget, ExportParams},
preview::PreviewThrottle,
remote::{
self, REMOTE_CALIBRATION_SAMPLES, REMOTE_MIN_SPP, RemoteCalibration, RemoteCapability,
RemoteProgress, calibrate_remote_rate, exceeds_pixel_cap, probe_remote, run_remote_lane,
},
sample_cursor::SampleCursor,
scene_snapshot::SceneSnapshot,
tonemap_png::{save_png, tonemap_to_rgba, tonemap_wide_gamut},
};
use crate::settings::{LocalComputeTarget, WorkerSettings};
use glam::Vec3;
use indicatrix::{
color::ColorSpace,
optics::raytracer::{Camera, EnvironmentSource},
renderer::gpu_backend::{GpuBackend, GpuSceneRef},
};
use std::{
path::Path,
sync::atomic::{AtomicBool, Ordering},
thread,
time::Duration,
};
const DEFAULT_REMOTE_RATE_GUESS: f64 = 1000.0;
#[expect(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "this is the export worker's own top-level orchestration -- scene/output \
params plus the compute-target/workers choice -- and its length is the \
real local+remote concurrent-dispatch/fallback/merge logic the task \
asked for, not padding; it's already split across `batch`/`remote`'s own \
helper functions (`run_local_batches`, `calibrate_remote_rate`, \
`run_remote_lane`) everywhere that split doesn't fight the shared \
`SampleCursor` this function's whole correctness argument depends on \
both lanes drawing from"
)]
pub(super) fn run_export(
scene: &SceneSnapshot,
params: ExportParams,
color_space: ColorSpace,
output_path: &Path,
compute_target: ComputeTarget,
workers: &[WorkerSettings],
local_compute: LocalComputeTarget,
cancel: &AtomicBool,
mut report_progress: impl FnMut(ExportProgress),
) -> ExportOutcome {
let ExportParams {
width,
height,
samples_per_pixel,
max_bounces: _,
} = params;
let mut accum = vec![Vec3::ZERO; (width as usize) * (height as usize)];
let camera = Camera::new(scene.yaw, scene.pitch, scene.distance, 42.0);
let gpu = match local_compute {
LocalComputeTarget::Cpu => GpuBackend::disabled(),
LocalComputeTarget::CpuGpu | LocalComputeTarget::Gpu => GpuBackend::acquire(),
};
let environment = scene.env_map.as_deref().map_or_else(
|| {
scene
.lighting_preset
.studio(scene.exposure, scene.light_yaw, scene.light_pitch)
},
EnvironmentSource::HdrMap,
);
let gpu_scene = GpuSceneRef {
camera: &camera,
width,
height,
planes: &scene.active_planes,
facet_finishes: &scene.facet_finishes,
material: &scene.material,
max_bounces: scene.max_bounces,
environment,
};
let ctx = ExportCtx {
width,
height,
camera: &camera,
scene,
gpu: &gpu,
gpu_scene: &gpu_scene,
};
let mut samples_done = 0u32;
let mut pending_note: Option<String> = None;
let remote_capability: Option<RemoteCapability> =
if matches!(compute_target, ComputeTarget::LocalOnly) {
None
} else if scene.env_map.is_some() {
pending_note = Some(
"This export uses an HDR environment map, which the remote render \
protocol cannot carry -- rendering locally only so the whole image is \
lit by the same environment."
.to_string(),
);
None
} else {
match probe_remote(workers) {
Ok(cap) => {
if exceeds_pixel_cap(width, height, &cap) {
let pixels = u64::from(width) * u64::from(height);
let message = format!(
"This export ({pixels} px) exceeds the remote worker's \
maximum of {} px.",
cap.max_pixels
);
if matches!(compute_target, ComputeTarget::RemoteOnly) {
return ExportOutcome::Failed(message);
}
pending_note = Some(format!("{message} Rendering locally only."));
None
} else {
Some(cap)
}
}
Err(reason) => {
if matches!(compute_target, ComputeTarget::RemoteOnly) {
return ExportOutcome::Failed(reason.message());
}
pending_note = Some(format!("{} Rendering locally only.", reason.message()));
None
}
}
};
let scene_state = remote_capability
.as_ref()
.map(|_| remote::scene_state_from_snapshot(scene, width, height));
let mut gpu_accum = vec![Vec3::ZERO; accum.len()];
let mut remote_rate: Option<f64> = None;
if let (Some(cap), Some(state)) = (&remote_capability, &scene_state) {
let remaining = samples_per_pixel - samples_done;
let worth_attempting =
remaining >= REMOTE_MIN_SPP || matches!(compute_target, ComputeTarget::RemoteOnly);
if worth_attempting && remaining > 0 {
let calibration = if remaining >= REMOTE_CALIBRATION_SAMPLES {
calibrate_remote_rate(
cap,
state,
width,
height,
&mut samples_done,
&mut accum,
cancel,
)
} else {
RemoteCalibration::Ready(DEFAULT_REMOTE_RATE_GUESS)
};
match calibration {
RemoteCalibration::Ready(rate) => remote_rate = Some(rate),
RemoteCalibration::Cancelled => {}
RemoteCalibration::Failed(message) => {
let note = format!("Remote worker rejected this export ({message}).");
if matches!(compute_target, ComputeTarget::RemoteOnly) {
return ExportOutcome::Failed(note);
}
pending_note = Some(format!("{note} Rendering locally only."));
}
RemoteCalibration::Short { done, expected } => {
let note = format!(
"Remote worker's calibration probe only completed {done} of \
{expected} samples before ending."
);
if matches!(compute_target, ComputeTarget::RemoteOnly) {
return ExportOutcome::Failed(note);
}
pending_note = Some(format!("{note} Rendering locally only."));
}
}
}
}
if cancel.load(Ordering::Relaxed) {
return ExportOutcome::Cancelled;
}
let mut gpu_frac: Option<f64> = if local_compute == LocalComputeTarget::CpuGpu
&& samples_per_pixel - samples_done >= HYBRID_MIN_SPP
{
calibrate_split(&ctx, &mut samples_done, &mut accum, &mut gpu_accum, cancel)
} else {
None
};
if cancel.load(Ordering::Relaxed) {
return ExportOutcome::Cancelled;
}
let cursor = SampleCursor::new(samples_done, samples_per_pixel);
let local_claim_size = local_chunk_size(samples_per_pixel);
let mut preview_throttle = PreviewThrottle::new();
let remote_progress = remote_rate.map(|_| RemoteProgress::new(accum.len()));
let fallback_to_local = matches!(compute_target, ComputeTarget::Both);
let run_local = !matches!(compute_target, ComputeTarget::RemoteOnly);
let mut on_local_batch = |local_done: u32, local_accum: &[Vec3], local_gpu_accum: &[Vec3]| {
let (remote_done_so_far, remote_preview) = remote_progress
.as_ref()
.map_or((0, None), |p| (p.samples_done(), Some(p.preview_buffer())));
let total_done = (local_done + remote_done_so_far).min(samples_per_pixel);
let preview = preview_throttle.maybe_generate(
width,
height,
local_accum,
local_gpu_accum,
remote_preview.as_deref(),
total_done,
);
let note = pending_note
.take()
.or_else(|| remote_progress.as_ref().and_then(RemoteProgress::take_note));
report_progress(ExportProgress {
fraction: total_done as f32 / samples_per_pixel as f32,
samples_done: total_done,
samples_total: samples_per_pixel,
preview,
note,
});
};
let mut remote_fatal: Option<String> = None;
if let Some(rate) = remote_rate {
let cap = remote_capability
.as_ref()
.expect("remote_rate implies remote_capability");
let state = scene_state
.as_ref()
.expect("remote_rate implies scene_state");
let progress = remote_progress
.as_ref()
.expect("remote_rate implies remote_progress");
let remote_lane_done = AtomicBool::new(false);
thread::scope(|s| {
let remote_thread = s.spawn(|| {
run_remote_lane(
&cursor,
cap,
state,
width,
height,
rate,
progress,
fallback_to_local,
cancel,
&remote_lane_done,
)
});
if run_local {
run_local_batches(
&ctx,
&cursor,
local_claim_size,
&remote_lane_done,
&mut gpu_frac,
&mut accum,
&mut gpu_accum,
cancel,
&mut on_local_batch,
);
} else {
while !remote_thread.is_finished() {
on_local_batch(0, &accum, &gpu_accum);
if cancel.load(Ordering::Relaxed) {
break;
}
thread::sleep(Duration::from_millis(200));
}
}
let outcome = remote_thread
.join()
.unwrap_or_else(|_| remote::RemoteLaneOutcome {
fatal: Some("remote render worker thread panicked".to_string()),
});
remote_fatal = outcome.fatal;
});
} else {
let no_remote_lane = AtomicBool::new(true);
run_local_batches(
&ctx,
&cursor,
local_claim_size,
&no_remote_lane,
&mut gpu_frac,
&mut accum,
&mut gpu_accum,
cancel,
&mut on_local_batch,
);
}
if cancel.load(Ordering::Relaxed) {
return ExportOutcome::Cancelled;
}
if let Some(message) = remote_fatal {
return ExportOutcome::Failed(message);
}
if let Some(progress) = remote_progress {
let remote_buf = progress.into_buffer();
for (dst, src) in accum.iter_mut().zip(&remote_buf) {
*dst += *src;
}
}
for (px, gpu_px) in accum.iter_mut().zip(&gpu_accum) {
*px += *gpu_px;
}
if cancel.load(Ordering::Relaxed) {
return ExportOutcome::Cancelled;
}
let rgba = if color_space == ColorSpace::Srgb {
tonemap_to_rgba(width, height, samples_per_pixel, &accum)
} else {
tonemap_wide_gamut(width, height, samples_per_pixel, &accum, color_space)
};
if let Some(parent) = output_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return ExportOutcome::Failed(format!("Could not create output directory: {e}"));
}
match save_png(output_path, width, height, &rgba, color_space) {
Ok(()) => ExportOutcome::Completed(output_path.to_path_buf()),
Err(e) => ExportOutcome::Failed(format!("Failed to write PNG: {e}")),
}
}