use crate::{
bridge::{
export_thread::{self, SceneSnapshot},
preview_wait::{
PREVIEW_CANCEL_ACK_WAIT, PREVIEW_PROGRESS_STALL, PREVIEW_REMOTE_MAX_WALL,
ProgressWatch, RemoteShortfall,
},
remote::{
remote_can_render,
remote_render::{self, RemoteRenderHandle, RemoteRenderRequest, RemoteUpdate},
},
},
settings::WorkerSettings,
};
use glam::Vec3;
use indicatrix::{
geometry::plane::GpuFacetPlane,
optics::{
materials::GemMaterial,
raytracer::{Camera, DEFAULT_FOV_DEG, DEFAULT_POSE, LightingPreset},
},
renderer::{
env_map::EnvironmentMap,
gpu_backend::{GpuBackend, GpuSceneRef},
tonemap::tonemap_to_rgba,
},
};
use indicatrix_net::{SceneState, client::Accumulator};
use indicatrix_vault::model::material_match::RiPresetCandidate;
use std::{
io::Cursor,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{self, RecvTimeoutError},
},
time::{Duration, Instant},
};
mod fingerprint;
mod solid;
pub use fingerprint::{CacheKind, cache_fingerprint};
pub use solid::render_view_solid;
pub const SODIUM_D_NM: f32 = 589.3;
#[must_use]
pub fn material_ri_at_sodium_d(material: &GemMaterial) -> f64 {
f64::from(material.dispersion.evaluate(SODIUM_D_NM))
}
const PREVIEW_YAW: f32 = DEFAULT_POSE.yaw;
const PREVIEW_DISTANCE: f32 = DEFAULT_POSE.distance;
pub const PREVIEW_LIGHT_YAW: f32 = 0.85;
pub const PREVIEW_LIGHT_PITCH: f32 = 0.95;
const PREVIEW_EXPOSURE: f32 = 1.0;
const PREVIEW_LIGHTING_PRESET: LightingPreset = LightingPreset::LightTent;
const PREVIEW_BACKDROP: f32 = indicatrix::optics::raytracer::BACKDROP_GREY;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreviewView {
Front,
Top,
}
impl PreviewView {
#[must_use]
const fn pitch(self) -> f32 {
match self {
Self::Front => 0.0,
Self::Top => std::f32::consts::FRAC_PI_2,
}
}
}
#[must_use]
pub fn ri_candidates() -> Vec<RiPresetCandidate> {
GemMaterial::all_materials()
.into_iter()
.map(|material| {
let refractive_index = material_ri_at_sodium_d(&material);
RiPresetCandidate {
name: material.name,
refractive_index,
}
})
.collect()
}
pub struct PreviewJob<'a> {
pub planes: &'a [GpuFacetPlane],
pub material: &'a GemMaterial,
pub size: u32,
pub spp: u32,
pub max_bounces: u32,
}
#[must_use]
pub fn render_view(job: &PreviewJob<'_>, view: PreviewView, gpu: &GpuBackend) -> Option<Vec<u8>> {
let scene = SceneSnapshot {
yaw: PREVIEW_YAW,
pitch: view.pitch(),
distance: PREVIEW_DISTANCE,
light_yaw: PREVIEW_LIGHT_YAW,
light_pitch: PREVIEW_LIGHT_PITCH,
material: job.material.clone(),
lighting_preset: PREVIEW_LIGHTING_PRESET,
max_bounces: job.max_bounces,
exposure: PREVIEW_EXPOSURE,
backdrop: PREVIEW_BACKDROP,
active_planes: job.planes.to_vec(),
facet_finishes: Vec::new(),
env_map: None,
surface_glare: 1.0,
};
let camera = Camera::new(scene.yaw, scene.pitch, scene.distance, DEFAULT_FOV_DEG);
let environment = scene
.lighting_preset
.studio(scene.exposure, scene.light_yaw, scene.light_pitch)
.with_backdrop(scene.backdrop);
let gpu_scene = GpuSceneRef {
camera: &camera,
width: job.size,
height: job.size,
planes: &scene.active_planes,
facet_finishes: &scene.facet_finishes,
material: &scene.material,
max_bounces: scene.max_bounces,
environment,
};
let pixel_count = (job.size as usize) * (job.size as usize);
let mut accum = vec![Vec3::ZERO; pixel_count];
if !gpu.try_accumulate(&gpu_scene, 0, job.spp, &mut accum) {
export_thread::batch::render_batch(
job.size, job.size, job.spp, 0, &camera, &scene, &mut accum,
);
}
let rgba = tonemap_to_rgba(&accum, 1.0 / job.spp as f32);
encode_png(job.size, job.size, &rgba)
}
#[must_use]
pub fn render_rgba_at_pose(
planes: &[GpuFacetPlane],
material: &GemMaterial,
camera_pose: (f32, f32, f32),
size: (u32, u32),
spp: u32,
max_bounces: u32,
) -> Vec<u8> {
let (width, height) = size;
if width == 0 || height == 0 {
return Vec::new();
}
let (yaw, pitch, distance) = camera_pose;
let spp = spp.max(1);
let scene = SceneSnapshot {
yaw,
pitch,
distance,
light_yaw: PREVIEW_LIGHT_YAW,
light_pitch: PREVIEW_LIGHT_PITCH,
material: material.clone(),
lighting_preset: PREVIEW_LIGHTING_PRESET,
max_bounces,
exposure: PREVIEW_EXPOSURE,
backdrop: PREVIEW_BACKDROP,
active_planes: planes.to_vec(),
facet_finishes: Vec::new(),
env_map: None,
surface_glare: 1.0,
};
let camera = Camera::new(yaw, pitch, distance, DEFAULT_FOV_DEG);
let mut accum = vec![Vec3::ZERO; (width as usize) * (height as usize)];
export_thread::batch::render_batch(width, height, spp, 0, &camera, &scene, &mut accum);
tonemap_to_rgba(&accum, 1.0 / spp as f32)
}
const PREVIEW_REQUEST_ID: u32 = 1;
const PREVIEW_ENV_MAP: Option<&Arc<EnvironmentMap>> = None;
pub fn render_view_remote_checked(
job: &PreviewJob<'_>,
view: PreviewView,
worker: &WorkerSettings,
cancel: &AtomicBool,
) -> Result<Vec<u8>, RemoteShortfall> {
let result = dispatch_remote_view(job, view, worker, cancel);
if let Err(shortfall) = &result {
tracing::warn!(?view, %shortfall, "remote preview render fell short");
}
result
}
fn dispatch_remote_view(
job: &PreviewJob<'_>,
view: PreviewView,
worker: &WorkerSettings,
cancel: &AtomicBool,
) -> Result<Vec<u8>, RemoteShortfall> {
if let Err(refusal) = remote_can_render(PREVIEW_ENV_MAP, false) {
return Err(RemoteShortfall::Failed(format!(
"remote render refused: {refusal:?}"
)));
}
let scene = SceneState {
width: job.size,
height: job.size,
yaw: PREVIEW_YAW,
pitch: view.pitch(),
distance: PREVIEW_DISTANCE,
light_yaw: PREVIEW_LIGHT_YAW,
light_pitch: PREVIEW_LIGHT_PITCH,
exposure: PREVIEW_EXPOSURE,
max_bounces: job.max_bounces,
lighting_preset: PREVIEW_LIGHTING_PRESET,
material: job.material.clone(),
planes: job.planes.to_vec(),
girdle_frosted: false,
backdrop: PREVIEW_BACKDROP,
environment: indicatrix_net::scene::SceneEnvironment::Studio,
surface_glare: 1.0,
};
let accumulator = Arc::new(Mutex::new(Accumulator::new(job.size, job.size)));
let (tx, rx) = mpsc::channel::<RemoteUpdate>();
let handle = remote_render::spawn_remote_render(
RemoteRenderRequest {
worker: worker.clone(),
request_id: PREVIEW_REQUEST_ID,
scene,
first_sample: 0,
samples: job.spp,
width: job.size,
height: job.size,
intent: indicatrix_net::messages::RequestIntent::Batch,
display_only: false,
},
Arc::clone(&accumulator),
move |update| {
let _ = tx.send(update);
},
);
wait_for_remote(&rx, &handle, cancel)?;
let acc = accumulator
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let done = acc.samples_done();
if done < job.spp {
return Err(RemoteShortfall::ShortSamples {
done,
wanted: job.spp,
});
}
let rgba = tonemap_to_rgba(acc.buffer(), 1.0 / job.spp as f32);
drop(acc);
encode_png(job.size, job.size, &rgba)
.ok_or_else(|| RemoteShortfall::Failed("could not encode the preview PNG".to_owned()))
}
enum Step {
Terminal(Result<(), RemoteShortfall>),
Progress(u32),
Ignore,
}
fn classify(update: RemoteUpdate) -> Step {
match update {
RemoteUpdate::Done {
cancelled: true, ..
} => Step::Terminal(Err(RemoteShortfall::Cancelled)),
RemoteUpdate::Done {
cancelled: false, ..
} => Step::Terminal(Ok(())),
RemoteUpdate::Failed { message, .. } => {
Step::Terminal(Err(RemoteShortfall::Failed(message)))
}
RemoteUpdate::Unsupported { message, .. } => {
Step::Terminal(Err(RemoteShortfall::Unsupported(message)))
}
RemoteUpdate::Progress { samples_done, .. } | RemoteUpdate::Frame { samples_done, .. } => {
Step::Progress(samples_done)
}
RemoteUpdate::Connected { .. }
| RemoteUpdate::Preview { .. }
| RemoteUpdate::DisplayFrame { .. }
| RemoteUpdate::FinalImage { .. }
| RemoteUpdate::CapabilityChanged { .. } => Step::Ignore,
}
}
fn wait_for_remote(
rx: &mpsc::Receiver<RemoteUpdate>,
handle: &RemoteRenderHandle,
cancel: &AtomicBool,
) -> Result<(), RemoteShortfall> {
let mut watch = ProgressWatch::new(Instant::now());
let mut cancelled_at: Option<Instant> = None;
loop {
if cancelled_at.is_none() && cancel.load(Ordering::Relaxed) {
handle.cancel();
cancelled_at = Some(Instant::now());
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(update) => match classify(update) {
Step::Terminal(outcome) => return outcome,
Step::Progress(samples_done) => watch.observe(samples_done, Instant::now()),
Step::Ignore => {}
},
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return Err(RemoteShortfall::Disconnected),
}
let now = Instant::now();
if cancelled_at.is_some_and(|at| now.duration_since(at) > PREVIEW_CANCEL_ACK_WAIT) {
return Err(RemoteShortfall::Cancelled);
}
if let Some(shortfall) = watch.verdict(now, PREVIEW_PROGRESS_STALL, PREVIEW_REMOTE_MAX_WALL)
{
handle.cancel();
await_cancel_ack(rx);
return Err(shortfall);
}
}
}
fn await_cancel_ack(rx: &mpsc::Receiver<RemoteUpdate>) {
let deadline = Instant::now() + PREVIEW_CANCEL_ACK_WAIT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return;
}
match rx.recv_timeout(remaining) {
Ok(update) => {
if matches!(classify(update), Step::Terminal(_)) {
return;
}
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return,
}
}
}
fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let image = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
let mut bytes = Vec::new();
image
.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)
.ok()?;
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_maps_terminal_and_progress_updates() {
assert!(matches!(
classify(RemoteUpdate::Done {
request_id: 1,
cancelled: false
}),
Step::Terminal(Ok(()))
));
assert!(matches!(
classify(RemoteUpdate::Done {
request_id: 1,
cancelled: true
}),
Step::Terminal(Err(RemoteShortfall::Cancelled))
));
assert!(matches!(
classify(RemoteUpdate::Failed {
request_id: 1,
message: "x".to_owned()
}),
Step::Terminal(Err(RemoteShortfall::Failed(_)))
));
assert!(matches!(
classify(RemoteUpdate::Progress {
request_id: 1,
samples_done: 7
}),
Step::Progress(7)
));
assert!(matches!(
classify(RemoteUpdate::Preview { request_id: 1 }),
Step::Ignore
));
}
#[test]
fn preview_view_pitch_matches_the_documented_front_and_top_convention() {
assert_eq!(PreviewView::Front.pitch(), 0.0);
assert!((PreviewView::Top.pitch() - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
}
#[test]
fn top_pitch_places_the_camera_directly_overhead() {
let camera = Camera::new(
0.0,
PreviewView::Top.pitch(),
PREVIEW_DISTANCE,
DEFAULT_FOV_DEG,
);
assert!((camera.origin.x).abs() < 1e-3);
assert!((camera.origin.y - PREVIEW_DISTANCE).abs() < 1e-3);
assert!((camera.origin.z).abs() < 1e-3);
}
#[test]
fn front_pitch_places_the_camera_on_the_horizontal_plane() {
for yaw in [0.0, 0.6, 3.0] {
let camera = Camera::new(
yaw,
PreviewView::Front.pitch(),
PREVIEW_DISTANCE,
DEFAULT_FOV_DEG,
);
assert!((camera.origin.y).abs() < 1e-3, "yaw={yaw}");
}
}
#[test]
fn ri_candidates_covers_every_built_in_material_with_a_finite_ri() {
let candidates = ri_candidates();
assert_eq!(candidates.len(), GemMaterial::all_materials().len());
for c in &candidates {
assert!(
c.refractive_index.is_finite() && c.refractive_index > 1.0,
"material {:?} produced an implausible RI {}",
c.name,
c.refractive_index
);
}
}
#[test]
fn ri_candidates_evaluates_at_the_sodium_d_line() {
let candidates = ri_candidates();
let diamond = candidates
.iter()
.find(|c| c.name == "Diamond")
.expect("Diamond is a built-in material");
assert!(
(diamond.refractive_index - 2.417).abs() < 0.01,
"got {}",
diamond.refractive_index
);
}
#[test]
fn render_view_produces_a_decodable_png_at_the_requested_size() {
use indicatrix::geometry::cuts::StandardGemCuts;
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::diamond();
let job = PreviewJob {
planes: &planes,
material: &material,
size: 8,
spp: 1,
max_bounces: 4,
};
let gpu = GpuBackend::disabled();
let png = render_view(&job, PreviewView::Top, &gpu).expect("render must succeed");
let decoded = image::load_from_memory(&png).expect("must be a valid PNG");
assert_eq!(decoded.width(), 8);
assert_eq!(decoded.height(), 8);
}
}