use crate::{
bridge::{
export_thread::{self, SceneSnapshot},
remote::remote_render::{self, RemoteRenderRequest, RemoteUpdate},
},
settings::WorkerSettings,
};
use glam::Vec3;
use indicatrix::{
geometry::plane::GpuFacetPlane,
optics::{
materials::GemMaterial,
raytracer::{Camera, LightingPreset},
},
renderer::{
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,
};
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 = 0.60;
const PREVIEW_DISTANCE: f32 = 2.4;
pub const PREVIEW_LIGHT_YAW: f32 = 0.85;
pub const PREVIEW_LIGHT_PITCH: f32 = 0.95;
const PREVIEW_EXPOSURE: f32 = 1.0;
const PREVIEW_FOV_DEG: f32 = 42.0;
const PREVIEW_LIGHTING_PRESET: LightingPreset = LightingPreset::RingLights;
#[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,
active_planes: job.planes.to_vec(),
facet_finishes: Vec::new(),
env_map: None,
};
let camera = Camera::new(scene.yaw, scene.pitch, scene.distance, PREVIEW_FOV_DEG);
let environment =
scene
.lighting_preset
.studio(scene.exposure, scene.light_yaw, scene.light_pitch);
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)
}
const PREVIEW_REQUEST_ID: u32 = 1;
#[must_use]
pub fn render_view_remote(
job: &PreviewJob<'_>,
view: PreviewView,
worker: &WorkerSettings,
cancel: &AtomicBool,
) -> Option<Vec<u8>> {
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,
};
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,
},
Arc::clone(&accumulator),
move |update| {
let _ = tx.send(update);
},
);
let mut cancel_sent = false;
loop {
if !cancel_sent && cancel.load(Ordering::Relaxed) {
handle.cancel();
cancel_sent = true;
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(
RemoteUpdate::Done {
cancelled: true, ..
}
| RemoteUpdate::Failed { .. },
) => {
return None;
}
Ok(RemoteUpdate::Done {
cancelled: false, ..
}) => break,
Ok(
RemoteUpdate::Connected { .. }
| RemoteUpdate::Preview { .. }
| RemoteUpdate::Frame { .. }
| RemoteUpdate::Progress { .. },
)
| Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return None,
}
}
let acc = accumulator
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if acc.samples_done() < job.spp {
return None;
}
let rgba = tonemap_to_rgba(acc.buffer(), 1.0 / job.spp as f32);
drop(acc);
encode_png(job.size, job.size, &rgba)
}
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 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,
PREVIEW_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,
PREVIEW_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);
}
}