mod context;
mod denoise;
mod display_thread;
mod frame_helpers;
mod gpu_backend;
mod live_split;
mod local_preview;
mod metrics;
mod metrics_worker;
mod redraw_gate;
mod scanline;
pub use context::{
ColorTarget, MaterialOverrides, MaterialSources, PlanesOwner, RenderContext,
apply_material_overrides, load_env_map, resolve_material, resolve_material_with_override,
};
pub use denoise::{
DenoiseScratch, FirstHitSnapshot, denoise_and_tonemap_frame, tonemap_running_average,
};
pub use metrics::hash_planes;
pub use redraw_gate::RedrawGate;
use crate::bridge::{
frame_cache::{girdle_finish::GirdleFinishCache, stone_width::StoneWidthCache},
sample_cursor::LiveEpoch,
};
use context::{FrameInputs, resolve_material_and_quality, snapshot_frame_inputs};
use display_thread::spawn_display_thread;
use glam::Vec3;
use gpu_backend::{
BackendFrame, FrameOutputs, HybridPacing, ViewportGpu, accumulate_frame_samples,
};
use indicatrix::optics::{
fluorescence::Fluorescence,
materials::GemMaterial,
raytracer::{Camera, DEFAULT_FOV_DEG, EnvironmentSource, FacetFinish},
};
use metrics_worker::{MetricsWorker, push_if_fresh};
use slint::Weak;
use std::{
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
const DENOISE_MIN_INTERVAL: Duration = Duration::from_millis(120);
const DISPLAY_BUSY_WAIT_TIMEOUT: Duration = Duration::from_secs(5);
const FRAME_PACE: Duration = Duration::from_millis(16);
use frame_helpers::{
AccumulationBuffers, FrameActivityFlags, SuspensionFlags, TraceActivitySink, pacing_sleep,
push_metrics_to_ui, remote_suspends_local, resolve_remote_ownership, should_combine_remote,
update_accumulation_state,
};
#[expect(
clippy::too_many_lines,
reason = "the render worker thread's full loop (resize/dirty handling, sampling, \
progressive accumulation, UI callbacks); splitting it apart risks changing \
GUI render-thread behaviour verifiable only by launching the app"
)]
pub fn spawn_render_thread<T, F, M, S, R>(
ui_weak: Weak<T>,
ctx: Arc<Mutex<RenderContext>>,
update_image: F,
update_metrics: M,
update_gpu_status: S,
update_trace_refusal: R,
) where
T: TraceActivitySink + 'static,
F: Fn(&T, slint::SharedPixelBuffer<slint::Rgba8Pixel>) + Send + 'static + Clone,
M: Fn(&T, f32, f32, f32, f32, f32, [f32; 19], [f32; 19], [f32; 19], f32)
+ Send
+ 'static
+ Clone,
S: Fn(&T, slint::SharedString) + Send + 'static + Clone,
R: Fn(&T, slint::SharedString) + Send + 'static + Clone,
{
thread::spawn(move || {
let mut last_width = 0;
let mut last_height = 0;
let materials = GemMaterial::all_materials();
let mut accum_buffer: Vec<Vec3> = Vec::new();
let mut first_hit_depth: Vec<f32> = Vec::new();
let mut first_hit_normal: Vec<Vec3> = Vec::new();
let mut first_hit_facet_id: Vec<i32> = Vec::new();
let mut first_hit_path_sig: Vec<u32> = Vec::new();
let mut combined_scratch: Vec<Vec3> = Vec::new();
let mut last_displayed_remote_samples: u32 = 0;
let mut accum_samples: u32 = 0;
let mut last_denoise_at: Option<Instant> = None;
let mut metrics_worker = MetricsWorker::spawn();
let mut pushed_metrics_version: u64 = 0;
let mut gpu_backend = ViewportGpu::acquire();
let mut last_gpu_status: Option<String> = None;
let mut last_trace_refusal: Option<String> = None;
let mut hybrid_pacing = HybridPacing::new();
let mut girdle_cache = GirdleFinishCache::new();
let mut stone_width_cache = StoneWidthCache::new();
let ui_weak_metrics_only = ui_weak.clone();
let update_metrics_metrics_only = update_metrics.clone();
let display = spawn_display_thread(ui_weak, update_image, update_metrics);
let mut prev_remote_active = false;
loop {
let iteration_start = Instant::now();
let FrameInputs {
width,
height,
yaw,
pitch,
distance,
light_yaw,
light_pitch,
material_name,
material_override,
material_unresolved,
lighting_preset,
backdrop,
surface_glare,
head_shadow_deg,
target_samples,
max_bounces,
exposure,
inclusion_sigma_s,
c_axis_override,
girdle_frosted,
edge_rounding_radius,
stone_width_mm,
physics_color,
active_planes,
active_tools,
custom_materials,
fluorescence,
running,
dirty,
paused,
tab_visible,
denoise_enabled,
redisplay_requested,
remote_active: remote_active_snapshot,
live_epoch: live_epoch_snapshot,
export_active,
live_compute_target,
local_compute_target,
local_preview_scale,
camera_moving,
env_map,
scene_generation,
} = snapshot_frame_inputs(&ctx);
if !running {
break;
}
let remote_active =
resolve_remote_ownership(dirty, remote_active_snapshot, prev_remote_active);
let scene_mismatch = live_split::epoch_scene_mismatch(
remote_active,
live_epoch_snapshot
.as_deref()
.map(LiveEpoch::scene_generation),
scene_generation,
);
let remote_active = remote_active && !scene_mismatch;
if remote_active != remote_active_snapshot {
let mut guard = ctx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let same_epoch = guard.live_epoch.as_ref().map(Arc::as_ptr)
== live_epoch_snapshot.as_ref().map(Arc::as_ptr);
if same_epoch {
if scene_mismatch {
guard.release_remote();
} else {
guard.clear_remote_state();
}
}
}
prev_remote_active = remote_active;
let live_epoch = if should_combine_remote(remote_active, live_compute_target) {
live_epoch_snapshot
} else {
None
};
let remote_samples_done_now: u32 =
live_epoch.as_ref().map_or(0, |epoch| epoch.remote_done());
if width == 0 || height == 0 {
thread::sleep(std::time::Duration::from_millis(16));
continue;
}
let (width, height) = local_preview::effective_dimensions(
width,
height,
local_preview_scale,
camera_moving && !remote_active,
);
let dimensions_changed = width != last_width || height != last_height;
let accumulation_reset = dirty || dimensions_changed;
update_accumulation_state(
width,
height,
dirty,
&mut AccumulationBuffers {
accum: &mut accum_buffer,
first_hit_depth: &mut first_hit_depth,
first_hit_normal: &mut first_hit_normal,
first_hit_facet_id: &mut first_hit_facet_id,
},
&mut accum_samples,
&mut last_width,
&mut last_height,
);
if dimensions_changed {
gpu_backend.invalidate_guide_cache();
}
if accumulation_reset {
last_denoise_at = None;
last_displayed_remote_samples = 0;
display.bump_generation();
}
let suspension = SuspensionFlags {
paused,
tab_visible,
remote_suspends: remote_suspends_local(remote_active, live_compute_target),
export_active,
material_unresolved: material_unresolved.is_some(),
};
if material_unresolved.as_deref() != last_trace_refusal.as_deref() {
last_trace_refusal.clone_from(&material_unresolved);
let text: slint::SharedString = last_trace_refusal.as_deref().unwrap_or("").into();
let update_trace_refusal = update_trace_refusal.clone();
let _ = ui_weak_metrics_only.upgrade_in_event_loop(move |ui| {
update_trace_refusal(&ui, text);
});
}
if suspension.tracing_suspended() {
if !suspension.metrics_suspended() {
let (current_mat, _spp) = resolve_material_and_quality(
&MaterialSources {
materials: &materials,
custom_materials: &custom_materials,
material_override: material_override.as_ref(),
material_name: &material_name,
physics_color,
},
target_samples,
&MaterialOverrides {
inclusion_sigma_s,
c_axis_override,
edge_rounding_radius,
stone_width_mm,
},
&active_planes,
&mut stone_width_cache,
);
metrics_worker.request(
&active_planes,
&active_tools,
¤t_mat,
[yaw, pitch, light_yaw, light_pitch],
lighting_preset,
env_map.as_ref(),
);
let (version, evaluated) = metrics_worker.latest();
pushed_metrics_version = version;
push_metrics_to_ui(
&ui_weak_metrics_only,
&update_metrics_metrics_only,
evaluated.snapshot(pitch.to_degrees()),
);
}
thread::sleep(std::time::Duration::from_millis(100));
continue;
}
let (current_mat, spp) = resolve_material_and_quality(
&MaterialSources {
materials: &materials,
custom_materials: &custom_materials,
material_override: material_override.as_ref(),
material_name: &material_name,
physics_color,
},
target_samples,
&MaterialOverrides {
inclusion_sigma_s,
c_axis_override,
edge_rounding_radius,
stone_width_mm,
},
&active_planes,
&mut stone_width_cache,
);
metrics_worker.request(
&active_planes,
&active_tools,
¤t_mat,
[yaw, pitch, light_yaw, light_pitch],
lighting_preset,
env_map.as_ref(),
);
let cam_pitch_deg = pitch.to_degrees();
let remote_advanced =
live_epoch.is_some() && remote_samples_done_now > last_displayed_remote_samples;
let local_converged = accum_samples + remote_samples_done_now >= target_samples;
if local_converged && !dirty && !remote_advanced && !redisplay_requested {
push_if_fresh(
&metrics_worker,
&mut pushed_metrics_version,
!display.busy(),
&ui_weak_metrics_only,
&update_metrics_metrics_only,
cam_pitch_deg,
);
thread::sleep(std::time::Duration::from_millis(60));
continue;
}
let local_claim = if local_converged && !dirty {
None
} else {
live_split::local_frame_claim(live_epoch.as_deref(), accum_samples, spp)
};
if local_claim.is_none()
&& !remote_advanced
&& !redisplay_requested
&& !accumulation_reset
{
push_if_fresh(
&metrics_worker,
&mut pushed_metrics_version,
!display.busy(),
&ui_weak_metrics_only,
&update_metrics_metrics_only,
cam_pitch_deg,
);
thread::sleep(std::time::Duration::from_millis(16));
continue;
}
if let Some((_, count)) = local_claim {
accum_samples += count;
}
let camera = Camera::new(yaw, pitch, distance, DEFAULT_FOV_DEG);
let current_sample_count = accum_samples;
let environment = env_map.as_deref().map_or_else(
|| {
lighting_preset
.studio(exposure, light_yaw, light_pitch)
.with_backdrop(backdrop.level())
.with_surface_glare(surface_glare)
.with_head_shadow(head_shadow_deg)
},
EnvironmentSource::HdrMap,
);
let facet_finishes: &[FacetFinish] = if girdle_frosted {
girdle_cache.ensure(&active_planes)
} else {
&[]
};
if let Some((sample_offset, frame_spp)) = local_claim {
let backend_frame = BackendFrame {
width,
height,
yaw,
pitch,
distance,
camera: &camera,
planes: &active_planes,
tools: &active_tools,
fluorescence: fluorescence
.as_deref()
.unwrap_or_else(|| Fluorescence::none()),
facet_finishes,
material: ¤t_mat,
max_bounces,
environment,
spp: frame_spp,
sample_offset,
};
accumulate_frame_samples(
&mut gpu_backend,
&backend_frame,
&mut FrameOutputs {
accum: &mut accum_buffer,
depth: &mut first_hit_depth,
normal: &mut first_hit_normal,
facet_id: &mut first_hit_facet_id,
},
&mut hybrid_pacing,
local_compute_target,
);
gpu_backend.sync_path_sig(
&backend_frame,
&mut first_hit_path_sig,
denoise_enabled && !camera_moving,
);
}
if gpu_backend.status_message() != last_gpu_status.as_deref() {
last_gpu_status = gpu_backend.status_message().map(str::to_string);
let text: slint::SharedString = last_gpu_status.as_deref().unwrap_or("").into();
let update_gpu_status = update_gpu_status.clone();
let _ = ui_weak_metrics_only.upgrade_in_event_loop(move |ui| {
update_gpu_status(&ui, text);
});
}
let converged_now = accum_samples + remote_samples_done_now >= target_samples;
let denoise_due = !denoise_enabled
|| accumulation_reset
|| converged_now
|| last_denoise_at.is_none_or(|t| t.elapsed() >= DENOISE_MIN_INTERVAL);
if denoise_due {
let can_send = if converged_now {
let wait_start = Instant::now();
let mut timed_out = false;
while display.busy() {
if wait_start.elapsed() >= DISPLAY_BUSY_WAIT_TIMEOUT {
timed_out = true;
break;
}
thread::sleep(display_thread::CONVERGENCE_WAIT_POLL);
}
if timed_out {
tracing::warn!(
timeout_secs = DISPLAY_BUSY_WAIT_TIMEOUT.as_secs(),
"display thread still busy after the convergence wait \
timeout; skipping this display cycle instead of blocking \
the render thread forever"
);
}
!timed_out
} else {
!display.busy()
};
if can_send {
let remote_done = live_epoch.as_ref().map(|epoch| {
combined_scratch.clear();
combined_scratch.extend_from_slice(&accum_buffer);
epoch.add_remote_into(&mut combined_scratch)
});
if let Some(done) = remote_done {
last_displayed_remote_samples = done;
}
let (display_accum, display_sample_count): (&[Vec3], u32) = remote_done
.map_or((&accum_buffer, current_sample_count), |done| {
(&combined_scratch, current_sample_count + done)
});
let (metrics_version, evaluated) = metrics_worker.latest();
let mut work = display.reclaim();
work.fill(
display.current_generation(),
denoise_enabled,
FirstHitSnapshot {
width,
height,
current_sample_count: display_sample_count,
accum_buffer: display_accum,
first_hit_depth: &first_hit_depth,
first_hit_normal: &first_hit_normal,
first_hit_facet_id: &first_hit_facet_id,
first_hit_path_sig: &first_hit_path_sig,
},
evaluated.snapshot(cam_pitch_deg),
FrameActivityFlags {
camera_moving,
converged: converged_now,
},
);
display.send(work);
pushed_metrics_version = metrics_version;
last_denoise_at = Some(Instant::now());
}
}
if let Some(rest) = pacing_sleep(
camera_moving || denoise_due,
iteration_start.elapsed(),
FRAME_PACE,
) {
thread::sleep(rest);
}
}
});
}