//! Bounded orthographic production rendering. GPU work runs on the native //! current context, one request per UI update; sampling/filtering/PNG I/O run //! on the worker. The worker never waits for a preview acknowledgment. use crate::{ equipment, preview::{self, Mat4, PreviewCharacter, PreviewControls}, project::CameraFraming, render_job::{ImageBuf, RenderEvent}, }; use glow::HasContext; use std::{ path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, thread, }; pub const SCALE: u32 = 8; pub const MAX_HIGHRES: u32 = 2048; pub const PASSES: [&str; 6] = [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]; pub const NEAR: f32 = 0.05; pub const FAR: f32 = 40.; #[derive(Clone)] pub struct Draw { pub positions: Arc>, pub normals: Arc>, pub uvs: Arc>, pub vertex_colors: Arc>, pub indices: Vec, pub color: [f32; 4], pub albedo: Option>, pub shiny: Option>, pub specular: f32, pub cutoff: f32, pub flat_normals: bool, } #[derive(Clone)] pub struct Scene { pub draws: Vec, pub view: Mat4, pub projection: Mat4, pub width: u32, pub height: u32, } #[derive(Debug)] pub struct Buffers { pub width: u32, pub height: u32, pub diffuse: Vec, pub normal: Vec, pub specular: Vec, pub depth: Vec, } #[derive(Debug)] pub struct Target { pub diffuse: ImageBuf, pub normal: ImageBuf, pub specular: ImageBuf, pub depth: Vec, pub depth_preview: ImageBuf, } #[derive(Clone)] pub struct RenderJob { pub character: PreviewCharacter, pub sex: String, pub preset: Option, pub target_width: u32, pub target_height: u32, pub fps: u32, pub framing: CameraFraming, pub output: PathBuf, } #[derive(Debug, Clone, PartialEq)] pub struct ClipPlan { pub source_name: String, pub token: String, pub frames: u32, } #[derive(Debug, Clone, PartialEq)] pub struct Plan { pub clips: Vec, pub images: u32, pub passes: u32, pub files: u32, } pub fn plan(body: &PreviewCharacter, fps: u32) -> Result { if fps == 0 || fps > 240 { return Err("fps moet 1–240 zijn".into()); } let clips = if body.clips.is_empty() { vec![ClipPlan { source_name: String::new(), token: "clip000-bind".into(), frames: 1, }] } else { body.clips .iter() .enumerate() .map(|(i, c)| { let duration = c.duration; if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. { return Err("ongeldige of te lange animatie".into()); } // Exporter float timestamps commonly put a nominal 1-second // endpoint one ULP above 1. Do not duplicate that terminal pose. let samples = f64::from(duration) * f64::from(fps); let nearest = samples.round(); let samples = if (samples - nearest).abs() <= 4. * f64::from(f32::EPSILON) * samples.max(1.) { nearest } else { samples }; Ok(ClipPlan { source_name: c.name.clone(), token: clip_token(i, &c.name), frames: samples.ceil().max(1.) as u32, }) }) .collect::, String>>()? }; let frames = clips .iter() .try_fold(0u32, |n, c| n.checked_add(c.frames)) .ok_or("te veel frames")?; let images = frames.checked_mul(36).ok_or("te veel beelden")?; let passes = images.checked_mul(6).ok_or("te veel passes")?; let files = images.checked_mul(4).ok_or("te veel bestanden")?; if files > 500000 { return Err("batch groter dan 500000 uitvoerbestanden".into()); } Ok(Plan { clips, images, passes, files, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Sample { pub clip: usize, pub frame: u32, pub pitch: i32, pub yaw: u32, } pub fn samples(plan: &Plan) -> impl Iterator + '_ { plan.clips.iter().enumerate().flat_map(|(clip, c)| { (0..c.frames).flat_map(move |frame| { [-30, 0, 30].into_iter().flat_map(move |pitch| { (0..12).map(move |y| Sample { clip, frame, pitch, yaw: y * 30, }) }) }) }) } pub fn clip_token(index: usize, name: &str) -> String { let slug: String = name .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .take(48) .collect(); format!( "clip{index:03}-{}", if slug.is_empty() { "animation" } else { &slug } ) } pub fn output_path( sex: &str, set: &str, clip: &str, frame: u32, pitch: i32, yaw: u32, map: &str, ) -> Result { if !["male", "female"].contains(&sex) || !(set == "body" || equipment::valid_name(set)) || !equipment::valid_name(clip) || ![-30, 0, 30].contains(&pitch) || yaw >= 360 || !yaw.is_multiple_of(30) || !["diffuse", "normal", "depth", "specular"].contains(&map) { return Err("ongeldig uitvoerpad/token".into()); } Ok(PathBuf::from(sex) .join(set) .join(clip) .join(format!("f{frame:06}")) .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png"))) } pub fn camera(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) { let target = [0., framing.center_y_milli as f32 / 1000., 0.]; let d = preview::eye_offset(yaw, pitch, 10.); let eye = [d[0], target[1] + d[1], d[2]]; let half = framing.height_milli as f32 / 2000.; let mut p = preview::identity(); p[0] = 1. / (half * aspect); p[5] = 1. / half; p[10] = -2. / (FAR - NEAR); p[14] = -(FAR + NEAR) / (FAR - NEAR); (preview::view_matrix(eye, target), p) } fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] { std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12]) } fn normalize(v: [f32; 3]) -> [f32; 3] { let l = v.iter().map(|x| x * x).sum::().sqrt(); if l > 1e-10 { v.map(|x| x / l) } else { [0., 0., 1.] } } fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] { let a = [m[0], m[1], m[2]]; let b = [m[4], m[5], m[6]]; let c = [m[8], m[9], m[10]]; let cross = |a: [f32; 3], b: [f32; 3]| { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ] }; let x = cross(b, c); let y = cross(c, a); let z = cross(a, b); let det = a.iter().zip(x).map(|(a, b)| a * b).sum::(); if det.abs() < 1e-12 { return normalize(n); } normalize(std::array::from_fn(|i| { (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det })) } pub fn scene( job: &RenderJob, clip: &str, time: f32, pitch: f32, yaw: f32, cache: &mut equipment::TextureCache, ) -> Result { let body = &job.character; let mut draws = Vec::new(); if let Some(preset) = &job.preset { let controls = PreviewControls { clip_name: clip.into(), time, ..Default::default() }; for layer in equipment::layers_cached( preset, usize::from(job.sex == "female"), body, &controls, cache, )? { let m = layer.model; draws.push(Draw { positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()), normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()), uvs: Arc::new( layer .mesh .uvs .into_iter() .map(|[u, v]| [u, 1. - v]) .collect(), ), vertex_colors: Arc::new(Vec::new()), indices: layer.mesh.indices, color: layer.mesh.color, albedo: layer.albedo, shiny: layer.shiny, specular: layer.specular, cutoff: 0.5, flat_normals: false, }); } } else { let bones = preview::sample_skin_matrices(body, clip, time); let mut positions = Vec::with_capacity(body.positions.len()); let mut normals = Vec::with_capacity(body.positions.len()); for (i, p) in body.positions.iter().enumerate() { let mut m = preview::identity(); if !bones.is_empty() && body.weights.len() == body.positions.len() { m = [0.; 16]; let joints = body.joints.get(i).copied().unwrap_or([0; 4]); for (j, w) in joints.into_iter().zip(body.weights[i]) { let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?; for k in 0..16 { m[k] += b[k] * w; } } } positions.push(point(&m, *p)); normals.push(normal( &m, body.normals.get(i).copied().unwrap_or([0., 1., 0.]), )); } let positions = Arc::new(positions); let normals = Arc::new(normals); let uvs = Arc::new(body.uvs.clone()); let colors = Arc::new(body.colors.clone()); if body.surfaces.is_empty() { draws.push(Draw { positions, normals, uvs, vertex_colors: colors, indices: body.indices.clone(), color: body.base_color, albedo: None, shiny: None, specular: 0., cutoff: 0.001, flat_normals: !body.source_normals_complete, }); } else { for surface in &body.surfaces { draws.push(Draw { positions: positions.clone(), normals: normals.clone(), uvs: uvs.clone(), vertex_colors: colors.clone(), indices: body.indices[surface.first..surface.first + surface.count].to_vec(), color: surface.color, albedo: surface.albedo.clone(), shiny: None, specular: 0., cutoff: surface.cutoff, flat_normals: surface.flat_normals, }); } } } let (view, projection) = camera( job.framing, yaw, pitch, job.target_width as f32 / job.target_height as f32, ); Ok(Scene { draws, view, projection, width: job.target_width * SCALE, height: job.target_height * SCALE, }) } impl Scene { pub fn validate(&self) -> Result<(), String> { if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.draws.len() > 4096 || self .view .iter() .chain(&self.projection) .any(|v| !v.is_finite()) { return Err("ongeldige GPU-scène/afmetingen".into()); } for d in &self.draws { if d.positions.len() > 1_000_000 || d.positions.len() != d.normals.len() || d.indices.len() > 3_000_000 || !d.indices.len().is_multiple_of(3) || d.indices.iter().any(|i| *i as usize >= d.positions.len()) || d.positions .iter() .chain(d.normals.iter()) .flatten() .chain(d.uvs.iter().flatten()) .chain(d.vertex_colors.iter().flatten()) .chain(d.color.iter()) .any(|v| !v.is_finite()) || !d.specular.is_finite() || !d.cutoff.is_finite() { return Err("ongeldige of te grote GPU-geometrie".into()); } for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() { if t.width == 0 || t.height == 0 || t.width > 2048 || t.height > 2048 || t.rgba.len() != t.width as usize * t.height as usize * 4 { return Err("ongeldige GPU-texture".into()); } } } Ok(()) } } #[derive(Default)] pub struct GpuRenderer { program: Option, vao: Option, vbo: Option, ebo: Option, fbo: Option, colors: Vec, depth: Option, extent: [u32; 2], } impl GpuRenderer { /// # Safety /// A native OpenGL 3.3 context must be current on this thread. pub unsafe fn destroy(&mut self, gl: &glow::Context) { self.destroy_target(gl); if let Some(x) = self.program.take() { gl.delete_program(x); } if let Some(x) = self.vao.take() { gl.delete_vertex_array(x); } if let Some(x) = self.vbo.take() { gl.delete_buffer(x); } if let Some(x) = self.ebo.take() { gl.delete_buffer(x); } } unsafe fn destroy_target(&mut self, gl: &glow::Context) { for x in self.colors.drain(..) { gl.delete_texture(x); } if let Some(x) = self.depth.take() { gl.delete_renderbuffer(x); } if let Some(x) = self.fbo.take() { gl.delete_framebuffer(x); } self.extent = [0, 0]; } unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> { if self.program.is_some() { return Ok(()); } let p = gl.create_program()?; let mut shaders = Vec::new(); for (kind, source) in [ (glow::VERTEX_SHADER, VERTEX), (glow::FRAGMENT_SHADER, FRAGMENT), ] { let shader = gl.create_shader(kind)?; gl.shader_source(shader, source); gl.compile_shader(shader); if !gl.get_shader_compile_status(shader) { let error = gl.get_shader_info_log(shader); gl.delete_shader(shader); for s in shaders { gl.delete_shader(s); } gl.delete_program(p); return Err(error); } gl.attach_shader(p, shader); shaders.push(shader); } gl.link_program(p); let result = if gl.get_program_link_status(p) { Ok(()) } else { Err(gl.get_program_info_log(p)) }; for s in shaders { gl.detach_shader(p, s); gl.delete_shader(s); } if let Err(e) = result { gl.delete_program(p); return Err(e); } self.program = Some(p); self.vao = Some(gl.create_vertex_array()?); self.vbo = Some(gl.create_buffer()?); self.ebo = Some(gl.create_buffer()?); Ok(()) } unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> { if self.extent == [w, h] { return Ok(()); } self.destroy_target(gl); self.fbo = Some(gl.create_framebuffer()?); gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); for slot in 0..3 { let t = gl.create_texture()?; self.colors.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, glow::RGBA8 as i32, w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, None, ); gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0 + slot, glow::TEXTURE_2D, Some(t), 0, ); } let d = gl.create_renderbuffer()?; self.depth = Some(d); gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d)); gl.renderbuffer_storage( glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, w as i32, h as i32, ); gl.framebuffer_renderbuffer( glow::FRAMEBUFFER, glow::DEPTH_ATTACHMENT, glow::RENDERBUFFER, Some(d), ); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE { return Err("productie-FBO onvolledig".into()); } self.extent = [w, h]; Ok(()) } /// Render and read back unlit color, view-space normals, scalar specular and /// the actual depth attachment. All four use the same winning surface. /// # Safety /// `gl` is current on the UI thread. GL state is reset for egui painting. pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result { if scene.width == 0 || scene.height == 0 || scene.width > MAX_HIGHRES || scene.height > MAX_HIGHRES { return Err("highres moet 1–2048 pixels zijn (8× target)".into()); } scene.validate()?; let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING); let mut vp = [0; 4]; gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp); let result = (|| { self.init(gl)?; self.target(gl, scene.width, scene.height)?; gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); gl.viewport(0, 0, scene.width as i32, scene.height as i32); gl.disable(glow::SCISSOR_TEST); gl.disable(glow::CULL_FACE); gl.disable(glow::BLEND); gl.enable(glow::DEPTH_TEST); gl.depth_mask(true); gl.depth_func(glow::LESS); gl.clear_depth_f32(1.); gl.clear_color(0., 0., 0., 0.); gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); let p = self.program.ok_or("shader ontbreekt")?; gl.use_program(Some(p)); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_view").as_ref(), false, &scene.view, ); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_projection").as_ref(), false, &scene.projection, ); gl.bind_vertex_array(self.vao); gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo); gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo); for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] { gl.enable_vertex_attrib_array(slot); gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4); } for draw in &scene.draws { if draw.positions.len() != draw.normals.len() || draw .indices .iter() .any(|i| *i as usize >= draw.positions.len()) || draw.indices.len() > i32::MAX as usize { return Err("ongeldige GPU-geometrie".into()); } let mut data = Vec::with_capacity(draw.positions.len() * 12); for (i, pos) in draw.positions.iter().enumerate() { data.extend_from_slice(pos); data.extend_from_slice(&draw.normals[i]); data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2])); data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4])); } gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4), glow::STREAM_DRAW, ); gl.buffer_data_u8_slice( glow::ELEMENT_ARRAY_BUFFER, std::slice::from_raw_parts( draw.indices.as_ptr().cast(), draw.indices.len() * 4, ), glow::STREAM_DRAW, ); gl.uniform_4_f32( gl.get_uniform_location(p, "u_color").as_ref(), draw.color[0], draw.color[1], draw.color[2], draw.color[3], ); gl.uniform_1_f32( gl.get_uniform_location(p, "u_specular").as_ref(), draw.specular, ); gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff); gl.uniform_1_i32( gl.get_uniform_location(p, "u_flat").as_ref(), i32::from(draw.flat_normals), ); let mut temp = Vec::new(); for (unit, name, texture) in [ (0, "u_albedo", draw.albedo.as_ref()), (1, "u_shiny", draw.shiny.as_ref()), ] { gl.active_texture(glow::TEXTURE0 + unit); gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32); gl.uniform_1_i32( gl.get_uniform_location(p, &format!("{name}_enabled")) .as_ref(), i32::from(texture.is_some()), ); if let Some(image) = texture { let t = gl.create_texture()?; temp.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, if unit == 0 { glow::SRGB8_ALPHA8 as i32 } else { glow::RGBA8 as i32 }, image.width as i32, image.height as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, Some(&image.rgba), ); } else { gl.bind_texture(glow::TEXTURE_2D, None); } } gl.draw_elements( glow::TRIANGLES, draw.indices.len() as i32, glow::UNSIGNED_INT, 0, ); for t in temp { gl.delete_texture(t); } } let pixels = scene.width as usize * scene.height as usize; let mut maps = Vec::new(); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1); for slot in 0..3 { let mut data = vec![0; pixels * 4]; gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot); gl.read_pixels( 0, 0, scene.width as i32, scene.height as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::Slice(&mut data), ); flip_rows(&mut data, scene.width as usize * 4, scene.height as usize); maps.push(data); } let mut depth = vec![1f32; pixels]; let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4); gl.read_pixels( 0, 0, scene.width as i32, scene.height as i32, glow::DEPTH_COMPONENT, glow::FLOAT, glow::PixelPackData::Slice(bytes), ); flip_rows(&mut depth, scene.width as usize, scene.height as usize); let error = gl.get_error(); if error != glow::NO_ERROR { return Err(format!("productie-GL fout 0x{error:x}")); } let specular = maps.pop().unwrap(); let normal = maps.pop().unwrap(); let diffuse = maps.pop().unwrap(); Ok(Buffers { width: scene.width, height: scene.height, diffuse, normal, specular, depth, }) })(); gl.bind_framebuffer( glow::FRAMEBUFFER, std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer), ); gl.viewport(vp[0], vp[1], vp[2], vp[3]); gl.bind_vertex_array(None); gl.use_program(None); gl.active_texture(glow::TEXTURE0); gl.disable(glow::DEPTH_TEST); gl.disable(glow::SCISSOR_TEST); gl.enable(glow::BLEND); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4); result } } fn flip_rows(data: &mut [T], stride: usize, height: usize) { for y in 0..height / 2 { let split = (height - 1 - y) * stride; let (a, b) = data.split_at_mut(split); a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]); } } const VERTEX: &str = r#"#version 330 layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color; uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color; void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;} "#; const FRAGMENT: &str = r#"#version 330 in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit uniform float u_specular; uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled; layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular; vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));} void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a) -> ImageBuf { ImageBuf { width: w, height: h, rgba, } } fn linear(x: u8) -> f32 { static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new(); LUT.get_or_init(|| { std::array::from_fn(|i| { let v = i as f32 / 255.; if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }) })[x as usize] } fn srgb(x: f32) -> u8 { let v = if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf(1. / 2.4) - 0.055 }; (v * 255.).round().clamp(0., 255.) as u8 } impl Buffers { pub fn validate(&self) -> Result<(), String> { let pixels = (self.width as usize) .checked_mul(self.height as usize) .ok_or("te veel pixels")?; if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.diffuse.len() != pixels * 4 || self.normal.len() != pixels * 4 || self.specular.len() != pixels * 4 || self.depth.len() != pixels || self .depth .iter() .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d)) { return Err("ongeldige GPU-mapafmetingen/data".into()); } Ok(()) } } pub fn detail_map(raw: &Buffers) -> Result, String> { raw.validate()?; let w = raw.width as usize; let h = raw.height as usize; let mut out = vec![0; w * h]; for y in 0..h { for x in 0..w { let i = y * w + x; let mut edge = 0f32; for (nx, ny) in [ (x.saturating_sub(1), y), ((x + 1).min(w - 1), y), (x, y.saturating_sub(1)), (x, (y + 1).min(h - 1)), ] { let j = ny * w + nx; let a = raw.diffuse[i * 4 + 3] as f32 / 255.; let b = raw.diffuse[j * 4 + 3] as f32 / 255.; edge = edge.max((a - b).abs()); if a > 0. && b > 0. { for c in 0..3 { edge = edge.max( (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(), ); edge = edge.max( (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs() / 255., ); } edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.)); } } out[i] = (edge * 255.).round().clamp(0., 255.) as u8; } } Ok(out) } pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf { let mut rgba = Vec::with_capacity(detail.len() * 4); for (i, v) in detail.iter().enumerate() { rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]); } image(raw.width, raw.height, rgba) } pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result { raw.validate()?; if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE { return Err("ongeldige target-afmetingen".into()); } if raw.width != w * SCALE || raw.height != h * SCALE || detail.len() != raw.width as usize * raw.height as usize { return Err("ongeldige 8× resolve-afmetingen".into()); } let count = w as usize * h as usize; let mut diffuse = vec![0; count * 4]; let mut normals = diffuse.clone(); let mut specular = diffuse.clone(); let mut depth = vec![0u16; count * 2]; let mut depth_preview = diffuse.clone(); for y in 0..h { for x in 0..w { let i = (y * w + x) as usize; let mut closest = 1f32; let mut coverage = 0f32; for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; coverage += a; if a > 0. && raw.depth[j].is_finite() { closest = closest.min(raw.depth[j]); } } } let alpha = (coverage / (SCALE * SCALE) as f32 * 255.) .round() .clamp(0., 255.) as u8; if alpha == 0 { continue; } let mut color = [0.; 3]; let mut n = [0.; 3]; let mut shiny = 0.; let mut sum = 0.; // One coherent front-surface cluster; never average foreground with a rear // surface. Edges get extra weight, and any thin covered feature survives. for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; if a == 0. || raw.depth[j] > closest + 0.0025 { continue; } let weight = a * (1. + 4. * detail[j] as f32 / 255.); sum += weight; for c in 0..3 { color[c] += linear(raw.diffuse[j * 4 + c]) * weight; n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight; } shiny += raw.specular[j * 4] as f32 * weight; } } if sum <= 0. { return Err("ongeldige depth/coverage in GPU-resultaat".into()); } n = normalize(n); let d = (closest.clamp(0., 1.) * 65535.).round() as u16; for c in 0..3 { diffuse[i * 4 + c] = srgb(color[c] / sum); normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8; specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8; depth_preview[i * 4 + c] = (d >> 8) as u8; } for map in [ &mut diffuse, &mut normals, &mut specular, &mut depth_preview, ] { map[i * 4 + 3] = alpha; } depth[i * 2] = d; depth[i * 2 + 1] = u16::from(alpha) * 257; } } Ok(Target { diffuse: image(w, h, diffuse), normal: image(w, h, normals), specular: image(w, h, specular), depth, depth_preview: image(w, h, depth_preview), }) } fn safe_parent(root: &Path, relative: &Path) -> Result { let relative = relative .to_str() .ok_or("ongeldig uitvoerpad")? .replace('\\', "/"); let relative = crate::project::portable_relative(&relative)?; let relative = relative.as_str(); let canonical = root.canonicalize().map_err(|e| e.to_string())?; let path = root.join(relative); let parent = path.parent().ok_or("uitvoermap ontbreekt")?; let mut prefix = root.to_path_buf(); for part in Path::new(relative) .parent() .unwrap_or(Path::new("")) .components() { prefix.push(part); if !prefix.exists() { std::fs::create_dir(&prefix).map_err(|e| e.to_string())?; } let real = prefix.canonicalize().map_err(|e| e.to_string())?; if !real.starts_with(&canonical) { return Err("uitvoer verlaat projectmap".into()); } } if !parent .canonicalize() .map_err(|e| e.to_string())? .starts_with(canonical) { return Err("onveilig uitvoerpad".into()); } Ok(path) } fn atomic_file( root: &Path, relative: &Path, write: impl FnOnce(&mut std::fs::File) -> Result<(), String>, ) -> Result<(), String> { let path = safe_parent(root, relative)?; let temp = path.with_extension(format!("part-{}", std::process::id())); let mut owned = false; let result = (|| { let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&temp) .map_err(|e| e.to_string())?; owned = true; write(&mut file)?; file.sync_all().map_err(|e| e.to_string())?; std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?; Ok(()) })(); if owned { let _ = std::fs::remove_file(temp); } result } pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<(), String> { if img.width == 0 || img.height == 0 || img.width > MAX_HIGHRES / SCALE || img.height > MAX_HIGHRES / SCALE || img.rgba.len() != img.width as usize * img.height as usize * 4 { return Err("ongeldige PNG-afmetingen".into()); } atomic_file(root, relative, |file| { let mut encoder = png::Encoder::new(file, img.width, img.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); if relative .file_name() .and_then(|p| p.to_str()) .map(|p| p.ends_with("_diffuse.png")) .unwrap_or(false) { encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); } let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&img.rgba) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string()) }) } pub fn write_depth( root: &Path, relative: &Path, w: u32, h: u32, depth: &[u16], ) -> Result<(), String> { if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE || depth.len() != w as usize * h as usize * 2 { return Err("ongeldige 16-bit depthafmetingen".into()); } let bytes: Vec = depth.iter().flat_map(|x| x.to_be_bytes()).collect(); atomic_file(root, relative, |file| { let mut encoder = png::Encoder::new(file, w, h); encoder.set_color(png::ColorType::GrayscaleAlpha); encoder.set_depth(png::BitDepth::Sixteen); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer.write_image_data(&bytes).map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string()) }) } fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> { use std::io::Write; let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; atomic_file(root, Path::new(name), |f| { f.write_all(&data).map_err(|e| e.to_string()) }) } pub struct GpuRequest { pub scene: Scene, reply: mpsc::SyncSender>, } impl GpuRequest { pub fn fail(self, error: String) { let _ = self.reply.send(Err(error)); } /// # Safety /// `gl` must be current on this UI thread. pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) { let result = renderer.render(gl, &self.scene); let _ = self.reply.send(result); } } #[derive(Default)] struct Pending { pass: Option, progress: Option, terminal: Option, } #[derive(Clone, Default)] pub struct Mailbox(Arc>); impl Mailbox { pub fn publish(&self, event: RenderEvent) { if let Ok(mut state) = self.0.lock() { match event { RenderEvent::Pass { .. } => { state.pass = Some(event); state.progress = None; } RenderEvent::Step { .. } => state.progress = Some(event), _ => state.terminal = Some(event), } } } pub fn drain(&self) -> Vec { let mut events = Vec::new(); if let Ok(mut s) = self.0.lock() { events.extend(s.pass.take()); events.extend(s.progress.take()); events.extend(s.terminal.take()); } events } } pub struct RenderThread { pub mailbox: Mailbox, pub gpu: mpsc::Receiver, pub cancel: Arc, pub handle: Option>, } impl Drop for RenderThread { fn drop(&mut self) { self.cancel.store(true, Ordering::Relaxed); } } pub fn spawn(job: RenderJob, ctx: Option) -> RenderThread { let mailbox = Mailbox::default(); let output = mailbox.clone(); let cancel = Arc::new(AtomicBool::new(false)); let flag = cancel.clone(); let (tx, gpu) = mpsc::channel(); let handle = thread::spawn(move || { let mut emit = |event| { output.publish(event); if let Some(ctx) = &ctx { ctx.request_repaint(); } }; let result = execute(job, &tx, &flag, ctx.as_ref(), &mut emit); if let Err(error) = result { if flag.load(Ordering::Relaxed) { emit(RenderEvent::Cancelled); } else { emit(RenderEvent::Failed(error)); } } }); RenderThread { mailbox, gpu, cancel, handle: Some(handle), } } fn execute( mut job: RenderJob, gpu: &mpsc::Sender, cancel: &AtomicBool, ctx: Option<&eframe::egui::Context>, emit: &mut dyn FnMut(RenderEvent), ) -> Result<(), String> { job.framing.validate()?; if job.target_width == 0 || job.target_height == 0 || job.target_width > MAX_HIGHRES / SCALE || job.target_height > MAX_HIGHRES / SCALE { return Err("8× highres vereist targetafmetingen 1–256".into()); } if !["male", "female"].contains(&job.sex.as_str()) { return Err("ongeldig geslacht".into()); } if let Some(p) = &job.preset { p.validate()?; if !p.applies[usize::from(job.sex == "female")] { return Err("equipment niet geldig voor dit geslacht".into()); } if p.attachments.is_empty() { return Err("equipment heeft geen onderdelen".into()); } } let plan = plan(&job.character, job.fps)?; let output_bytes = u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16; if output_bytes > 16 * 1024 * 1024 * 1024 { return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into()); } std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?; let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| e.to_string())? .as_nanos(); let root = job .output .join(format!("run-{stamp}-{}", std::process::id())); std::fs::create_dir(&root).map_err(|e| e.to_string())?; let set = job .preset .as_ref() .map(|p| p.name.as_str()) .unwrap_or("body") .to_string(); write_json( &root, "render.json", serde_json::json!({"format_version":1,"status":"incomplete","sex":job.sex,"set":set,"target":[job.target_width,job.target_height],"fps":job.fps,"highres_scale":SCALE,"framing":job.framing,"near":NEAR,"far":FAR,"normal_space":"view (+Y up), PNG rows top-down","camera_distance":10,"yaw_zero":"camera on -Z","pitches":[-30,0,30],"yaw_step":30,"sampling":"clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained","alpha":"straight, opaque/cutout, coverage resolve","diffuse":"unlit sRGB albedo × vertex color, linear-light detail resolve","depth":"16-bit gray+alpha; distance=near+(sample/65535)*(far-near)","specular":"linear scalar; body defaults zero","pass_order":PASSES,"saved_maps":["diffuse","depth","normal","specular"],"clips":plan.clips.iter().enumerate().map(|(i,c)|serde_json::json!({"name":c.source_name,"token":c.token,"frames":c.frames,"duration":job.character.clips.get(i).map(|c|c.duration).unwrap_or(0.)})).collect::>(),"images":plan.images,"passes":plan.passes,"png_files":plan.files}), )?; // Internal unique names select every source clip, even duplicate display names. for (i, c) in job.character.clips.iter_mut().enumerate() { c.name = format!("render-clip-{i}"); } let mut completed = 0u32; let mut cache = equipment::TextureCache::default(); let result = (|| { for sample in samples(&plan) { let clip = &plan.clips[sample.clip]; let frame = sample.frame; let pitch = sample.pitch; let yaw = sample.yaw; let key = if job.character.clips.is_empty() { String::new() } else { format!("render-clip-{}", sample.clip) }; if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } let pose = format!( "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}", job.sex, clip.source_name, clip.frames ); emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · highres"), }); let scene = scene( &job, &key, frame as f32 / job.fps as f32, pitch as f32, yaw as f32, &mut cache, )?; let (tx, rx) = mpsc::sync_channel(1); gpu.send(GpuRequest { scene, reply: tx }) .map_err(|_| "GPU-service gesloten")?; if let Some(ctx) = ctx { ctx.request_repaint(); } let raw = loop { match rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(result) => break result?, Err(mpsc::RecvTimeoutError::Timeout) => { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } } Err(_) => return Err("GPU-service gestopt".into()), } }; completed += 1; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: format!("{pose} · highres"), image: Some(image(raw.width, raw.height, raw.diffuse.clone())), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · detail"), }); let detail = detail_map(&raw)?; completed += 1; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: format!("{pose} · detail"), image: Some(detail_image(&raw, &detail)), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · target-res / diffuse"), }); let target = resolve(&raw, &detail, job.target_width, job.target_height)?; for (map, preview) in [ ("diffuse", target.diffuse), ("depth", target.depth_preview), ("normal", target.normal), ("specular", target.specular), ] { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · {map}"), }); let path = output_path(&job.sex, &set, &clip.token, frame, pitch, yaw, map)?; if map == "depth" { write_depth( &root, &path, job.target_width, job.target_height, &target.depth, )?; } else { write_rgba(&root, &path, &preview)?; } completed += 1; emit(RenderEvent::Pass { index: completed - 1, total: plan.passes, label: format!( "{pose} · {}", if map == "diffuse" { "target-res / diffuse" } else { map } ), image: Some(preview), }); } } Ok(()) })(); match result { Ok(()) => { write_json( &root, "complete.json", serde_json::json!({"status":"complete","passes":completed,"png_files":plan.files}), )?; emit(RenderEvent::Finished { saved: Some(root.display().to_string()), }); Ok(()) } Err(error) => { let name = if cancel.load(Ordering::Relaxed) { "cancelled.json" } else { "failed.json" }; let _ = write_json( &root, name, serde_json::json!({"error":error,"completed_passes":completed,"total_passes":plan.passes}), ); Err(error) } } } /// Real readback regression: nearest-surface depth/normal/specular, preserved /// texture × vertex albedo/alpha and independence from opposed light directions. /// # Safety /// Run on a current disposable native context. pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> { let triangle = |z, normal, color, specular| Draw { positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]), normals: Arc::new(vec![normal; 3]), uvs: Arc::new(vec![[0.; 2]; 3]), vertex_colors: Arc::new(Vec::new()), indices: vec![0, 1, 2], color, albedo: None, shiny: None, specular, cutoff: 0.001, flat_normals: false, }; let mut renderer = GpuRenderer::default(); let mut scene = Scene { draws: vec![ triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75), triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.), ], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; let result = (|| { let raw = renderer.render(gl, &scene)?; let i = 16 * 32 + 16; if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255] || (raw.depth[i] - 0.25).abs() > 1e-5 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255] || raw.specular[i * 4].abs_diff(191) > 1 { return Err(format!( "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}", &raw.diffuse[i * 4..i * 4 + 4], raw.depth[i], &raw.normal[i * 4..i * 4 + 4], raw.specular[i * 4] )); } scene.draws.reverse(); let reverse = renderer.render(gl, &scene)?; if reverse.diffuse != raw.diffuse || reverse.normal != raw.normal || reverse.depth != raw.depth { return Err("GPU map winner depends on submission order".into()); } scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)]; scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]); scene.draws[0].albedo = Some(Arc::new(equipment::Texture { content_hash: 99, width: 1, height: 1, rgba: vec![128, 255, 64, 192], })); let expected = [ srgb(linear(128) * 0.25), srgb(0.75), srgb(linear(64) * 0.5), 192, ]; // Production intentionally has no lighting input. This optional conventional // uniform is optimized out now; a future accidental lighting term using it // must fail these actual GPU readbacks under opposed light directions. for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] { let p = renderer.program.ok_or("missing probe program")?; gl.use_program(Some(p)); gl.uniform_3_f32( gl.get_uniform_location(p, "u_light_direction").as_ref(), light[0], light[1], light[2], ); let output = renderer.render(gl, &scene)?; let pixel = &output.diffuse[i * 4..i * 4 + 4]; if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) { return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}")); } } let detail = detail_map(&raw)?; let target = resolve(&raw, &detail, 4, 4)?; if target.diffuse.width != 4 || target.depth.len() != 32 || target .normal .rgba .chunks_exact(4) .filter(|p| p[3] > 0) .any(|p| { let n = p[..3] .iter() .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); (n - 1.).abs() > 0.03 }) { return Err("target map dimensions/normals invalid".into()); } let mut flat = Scene { draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; flat.draws[0].flat_normals = true; let derived = renderer.render(gl, &flat)?; if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] { return Err(format!( "flat normals are not geometry-derived: {:?}", &derived.normal[i * 4..i * 4 + 4] )); } println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives"); Ok(()) })(); renderer.destroy(gl); result } #[cfg(test)] mod tests { use super::*; struct Temp(PathBuf); impl Temp { fn new() -> Self { let p = std::env::temp_dir().join(format!( "paperdoll-production-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir(&p).unwrap(); Self(p) } } impl Drop for Temp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn body() -> PreviewCharacter { preview::load_gltf_file( &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap() } fn raw(w: u32, h: u32) -> Buffers { let n = (w * h) as usize; Buffers { width: w, height: h, diffuse: [255, 0, 0, 255].repeat(n), normal: [128, 128, 255, 255].repeat(n), specular: [191, 191, 191, 255].repeat(n), depth: vec![0.25; n], } } #[test] fn job_counts_every_clip_frame_angle_and_exact_order() { let mut b = body(); let mut c = b.clips[0].clone(); c.name = "../A \u{2603}".into(); c.duration = 0.5; b.clips = vec![c.clone(), c]; let p = plan(&b, 3).unwrap(); assert_eq!((p.images, p.passes, p.files), (144, 864, 576)); let s = samples(&p).collect::>(); assert_eq!(s.len(), 144); assert_eq!( s[0], Sample { clip: 0, frame: 0, pitch: -30, yaw: 0 } ); assert_eq!(s[11].yaw, 330); assert_eq!(s[12].pitch, 0); assert_eq!(s[35].pitch, 30); assert_eq!(s[36].frame, 1); assert_eq!(s[72].clip, 1); assert_ne!(p.clips[0].token, p.clips[1].token); assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token))); assert_eq!( PASSES, [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular" ] ); b.clips.clear(); let p = plan(&b, 12).unwrap(); assert_eq!((p.images, p.files), (36, 144)); } #[test] fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() { let mut b = body(); b.clips.truncate(1); b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1); assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2); b.clips[0].duration = 1.00001; assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3); } #[test] fn planner_rejects_invalid_or_unbounded_inputs() { let mut b = body(); assert!(plan(&b, 0).is_err()); assert!(plan(&b, 241).is_err()); for duration in [f32::NAN, f32::INFINITY, -1., 10000.] { b.clips[0].duration = duration; assert!(plan(&b, 12).is_err()); } } #[test] fn output_tokens_only_four_target_maps() { let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png"); assert_eq!( output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(), expected ); for map in ["diffuse", "normal", "depth", "specular"] { assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok()); } for map in ["highres", "detail", "shiny", "../x"] { assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err()); } for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] { assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err()); } assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err()); assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err()); } #[test] fn resolve_preserves_thin_foreground_and_matching_maps() { let mut r = raw(8, 8); r.diffuse = [0, 255, 0, 255].repeat(64); r.depth.fill(0.75); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); r.depth[0] = 0.25; let d = detail_map(&r).unwrap(); assert!(d.iter().any(|x| *x > 0)); let t = resolve(&r, &d, 1, 1).unwrap(); assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]); assert_eq!(t.specular.rgba, [191, 191, 191, 255]); assert!((t.depth[0] as i32 - 16384).abs() <= 1); let n = &t.normal.rgba; let len = (0..3) .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); assert!((len - 1.).abs() < 0.01); r.diffuse.fill(0); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap(); assert_eq!(t.diffuse.rgba[3], 4); assert_eq!(t.depth[1], 1028); assert_eq!(t.normal.rgba[3], 4); assert_eq!(t.specular.rgba[3], 4); } #[test] fn invalid_map_dimensions_and_depth_fail_without_panics() { let mut r = raw(8, 8); r.normal.pop(); assert!(detail_map(&r).is_err()); assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let mut r = raw(8, 8); r.depth[0] = f32::NAN; assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let r = raw(8, 8); assert!(resolve(&r, &[0; 63], 1, 1).is_err()); assert!(resolve(&r, &[0; 64], 0, 1).is_err()); assert!(resolve(&r, &[0; 64], 257, 1).is_err()); } #[test] fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() { let t = Temp::new(); let p = Path::new("map_diffuse.png"); write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap(); let original = std::fs::read(t.0.join(p)).unwrap(); assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err()); assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original); let mut reader = png::Decoder::new(std::io::Cursor::new(original)) .read_info() .unwrap(); assert!(reader.info().srgb.is_some()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [128, 64, 32, 192]); write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap(); let mut reader = png::Decoder::new(std::io::BufReader::new( std::fs::File::open(t.0.join("depth.png")).unwrap(), )) .read_info() .unwrap(); assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen); assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha); assert!(reader.info().srgb.is_none()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [64, 0, 128, 128]); let tmp = t.0.join("blocked.png") .with_extension(format!("part-{}", std::process::id())); std::fs::write(&tmp, b"keep").unwrap(); assert!(write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).is_err()); assert_eq!(std::fs::read(tmp).unwrap(), b"keep"); assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err()); assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err()); assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err()); } #[cfg(unix)] #[test] fn output_rejects_escaping_symlinks() { let root = Temp::new(); let outside = Temp::new(); std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap(); assert!(write_rgba( &root.0, Path::new("link/sub/x.png"), &image(1, 1, vec![0; 4]) ) .is_err()); assert!(!outside.0.join("sub").exists()); } #[test] fn latest_mailbox_is_bounded_and_retains_terminal() { let m = Mailbox::default(); for i in 0..1000 { m.publish(RenderEvent::Pass { index: i, total: 1000, label: i.to_string(), image: Some(image(1, 1, vec![0; 4])), }); } m.publish(RenderEvent::Step { index: 999, total: 1000, label: "working".into(), }); m.publish(RenderEvent::Cancelled); let events = m.drain(); assert_eq!(events.len(), 3); assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. })); assert!(matches!(&events[2], RenderEvent::Cancelled)); assert!(m.drain().is_empty()); } #[test] fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() { let temp = Temp::new(); let mut job = RenderJob { character: body(), sex: "male".into(), preset: None, target_width: 64, target_height: 96, fps: 12, framing: CameraFraming { height_milli: 3100, center_y_milli: 1200, }, output: temp.0.clone(), }; let mut cache = equipment::TextureCache::default(); let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); let mut preset = equipment::Preset::new("sword".into()); let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj"); equipment::import_obj(&mut preset, &fixture).unwrap(); let joint = job .character .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); preset.bind(0, &job.character, 0, joint).unwrap(); let count = preset .attachments .iter() .map(|a| a.meshes.len()) .sum::(); job.preset = Some(preset); let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(e.draws.len(), count); assert_eq!(b.view, e.view); assert_eq!(b.projection, e.projection); assert_eq!((e.width, e.height), (512, 768)); assert_eq!(e.projection[5], 2. / 3.1); let center = point(&e.view, [0., 1.2, 0.]); assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001); assert!((center[2] + 10.).abs() < 0.0001); let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap(); let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(early.view, late.view); assert_eq!(early.projection, late.projection); assert_ne!(early.draws[0].positions, late.draws[0].positions); let mut invalid = e.clone(); invalid.view[0] = f32::NAN; assert!(invalid.validate().is_err()); } #[test] fn body_import_retains_texture_material_and_linear_vertex_color() { let temp = Temp::new(); let source = std::fs::read( Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let glb = gltf::binary::Glb::from_slice(&source).unwrap(); let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap(); let mut bin = glb.bin.unwrap().into_owned(); while !bin.len().is_multiple_of(4) { bin.push(0); } let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"] .as_u64() .unwrap() as usize; let count = json["accessors"][position]["count"].as_u64().unwrap() as usize; let offset = bin.len(); for _ in 0..count { for value in [0.25f32, 0.75, 0.5, 0.5] { bin.extend(value.to_le_bytes()); } } let view = json["bufferViews"].as_array().unwrap().len(); json["bufferViews"] .as_array_mut() .unwrap() .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16})); let accessor = json["accessors"].as_array().unwrap().len(); json["accessors"].as_array_mut().unwrap().push( serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}), ); json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor); json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()}); json["images"] = serde_json::json!([{"uri":"albedo.png"}]); json["textures"] = serde_json::json!([{"source":0}]); let material = json["meshes"][0]["primitives"][0]["material"] .as_u64() .unwrap() as usize; json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] = serde_json::json!({"index":0}); json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] = serde_json::json!([0.5, 0.75, 1., 1.]); std::fs::write(temp.0.join("geometry.bin"), bin).unwrap(); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); write_rgba( &temp.0, Path::new("albedo.png"), &image(1, 1, vec![128, 255, 64, 192]), ) .unwrap(); let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]); assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]); assert_eq!( body.surfaces[0].albedo.as_ref().unwrap().rgba, [128, 255, 64, 192] ); assert_eq!(body.colors.len(), body.positions.len()); assert_eq!(body.uvs.len(), body.positions.len()); assert!(body.surfaces[0].flat_normals); json["meshes"][0]["primitives"][0]["attributes"] .as_object_mut() .unwrap() .remove("NORMAL"); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert!(!missing.source_normals_complete); let job = RenderJob { character: missing, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: temp.0.clone(), }; assert!( scene( &job, "", 0., 0., 0., &mut equipment::TextureCache::default() ) .unwrap() .draws[0] .flat_normals ); } #[test] fn complete_and_cancelled_batches_have_truthful_atomic_outputs() { for stop in [false, true] { let tmp = Temp::new(); let mut b = body(); b.clips.clear(); let job = RenderJob { character: b, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: tmp.0.clone(), }; let flag = AtomicBool::new(false); let (tx, rx) = mpsc::channel::(); let gpu = std::thread::spawn(move || { for request in rx { let _ = request .reply .send(Ok(raw(request.scene.width, request.scene.height))); } }); let mut labels = Vec::new(); let result = execute(job, &tx, &flag, None, &mut |event| { if let RenderEvent::Pass { label, .. } = event { labels.push(label); if stop && labels.len() == 3 { flag.store(true, Ordering::Relaxed); } } }); drop(tx); gpu.join().unwrap(); let run = std::fs::read_dir(&tmp.0) .unwrap() .next() .unwrap() .unwrap() .path(); assert!(run.join("render.json").exists()); assert_eq!(run.join("complete.json").exists(), !stop); assert_eq!(run.join("cancelled.json").exists(), stop); assert_eq!(result.is_ok(), !stop); let frame = run.join("male/body/clip000-bind/f000000"); assert!(frame.join("p-30_y000_diffuse.png").exists()); assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop); assert!(!frame.join("p-30_y000_highres.png").exists()); assert!(!frame.join("p-30_y000_detail.png").exists()); if !stop { assert_eq!(labels.len(), 216); for labels in labels.chunks_exact(6) { for (label, pass) in labels.iter().zip([ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]) { assert!(label.ends_with(pass)); } } } } } }