pub mod camera;
pub mod gpu;
pub mod hdri;
pub use camera::CameraConfig;
pub use camera::Projection;
pub use gpu::GpuRenderer;
pub use hdri::HdriData;
use crate::meshing::MeshOutput;
#[derive(Clone, Copy, Debug)]
pub struct GridConfig {
pub half_extent: i32,
pub fit_to_bounds: bool,
pub margin: i32,
pub spacing: i32,
pub plane_y: f32,
pub show_axes: bool,
pub line_rgba: [f32; 4],
}
impl Default for GridConfig {
fn default() -> Self {
Self {
half_extent: 16,
fit_to_bounds: false,
margin: 1,
spacing: 1,
plane_y: 0.0,
show_axes: true,
line_rgba: [0.5, 0.5, 0.55, 0.5],
}
}
}
#[derive(Clone)]
pub struct RenderConfig {
pub width: u32,
pub height: u32,
pub yaw: f32,
pub pitch: f32,
pub zoom: f32,
pub fov: f32,
pub target: Option<[f32; 3]>,
pub background: Option<[f32; 4]>,
pub projection: Projection,
pub sphere_fit: bool,
pub grid: Option<GridConfig>,
}
impl Default for RenderConfig {
fn default() -> Self {
Self {
width: 1024,
height: 1024,
yaw: 45.0,
pitch: 30.0,
zoom: 1.0,
fov: 45.0,
target: None,
background: None,
projection: Projection::Perspective,
sphere_fit: false,
grid: None,
}
}
}
impl RenderConfig {
fn to_camera(&self) -> CameraConfig {
CameraConfig {
yaw_deg: self.yaw,
pitch_deg: self.pitch,
zoom: self.zoom,
fov_deg: self.fov,
target: self.target,
projection: self.projection,
background: self.background,
sphere_fit: self.sphere_fit,
}
}
pub fn isometric() -> Self {
Self {
yaw: 45.0,
pitch: 35.264,
projection: Projection::Orthographic,
..Self::default()
}
}
}
#[derive(Debug)]
pub enum RenderError {
NoGpuAdapter,
DeviceCreation(String),
RenderFailed(String),
PngEncode(String),
Io(std::io::Error),
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoGpuAdapter => write!(
f,
"No GPU adapter found. Neither hardware nor software rendering is available."
),
Self::DeviceCreation(e) => write!(f, "Failed to create GPU device: {}", e),
Self::RenderFailed(e) => write!(f, "Render failed: {}", e),
Self::PngEncode(e) => write!(f, "PNG encoding failed: {}", e),
Self::Io(e) => write!(f, "I/O error: {}", e),
}
}
}
impl std::error::Error for RenderError {}
pub async fn render_meshes_async(
meshes: &[MeshOutput],
config: &RenderConfig,
hdri: Option<&HdriData>,
) -> Result<Vec<u8>, RenderError> {
let renderer = GpuRenderer::new(meshes, config.width, config.height, hdri).await?;
renderer.set_grid(config.grid);
let camera = config.to_camera();
#[cfg(not(target_arch = "wasm32"))]
{
renderer.render_frame(&camera)
}
#[cfg(target_arch = "wasm32")]
{
Err(RenderError::RenderFailed(
"Use render_meshes_async with wasm_bindgen_futures on WASM".into(),
))
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_animation(
meshes: &[MeshOutput],
frames: &[crate::animation::Frame],
config: &RenderConfig,
hdri: Option<&HdriData>,
) -> Result<Vec<Vec<u8>>, RenderError> {
pollster::block_on(async {
let renderer = GpuRenderer::new(meshes, config.width, config.height, hdri).await?;
renderer.set_grid(config.grid);
let base = config.to_camera();
let mut out = Vec::with_capacity(frames.len());
let mut poses = vec![crate::animation::Pose::IDENTITY; meshes.len()];
for frame in frames {
poses
.iter_mut()
.for_each(|p| *p = crate::animation::Pose::IDENTITY);
for (id, pose) in &frame.poses {
if let Some(slot) = poses.get_mut(*id as usize) {
*slot = *pose;
}
}
renderer.set_poses(&poses);
renderer.set_gizmos(&frame.gizmos);
let camera = match &frame.camera {
Some(c) => {
let mut cfg = config.clone();
cfg.yaw += c.yaw;
cfg.pitch += c.pitch;
cfg.zoom *= c.zoom;
cfg.to_camera()
}
None => base.clone(),
};
out.push(renderer.render_frame(&camera)?);
}
Ok(out)
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_animation_to_files(
meshes: &[MeshOutput],
frames: &[crate::animation::Frame],
config: &RenderConfig,
hdri: Option<&HdriData>,
prefix: &str,
) -> Result<Vec<String>, RenderError> {
let pixels = render_animation(meshes, frames, config, hdri)?;
let mut paths = Vec::with_capacity(pixels.len());
for (i, px) in pixels.iter().enumerate() {
let path = format!("{prefix}{i:04}.png");
let png = encode_png(px, config.width, config.height)?;
std::fs::write(&path, &png).map_err(RenderError::Io)?;
paths.push(path);
}
Ok(paths)
}
pub fn animation_view_projs(
meshes: &[MeshOutput],
frames: &[crate::animation::Frame],
config: &RenderConfig,
) -> Vec<[[f32; 4]; 4]> {
let (bmin, bmax) = camera::merged_bounds(meshes);
let aspect = config.width as f32 / config.height.max(1) as f32;
frames
.iter()
.map(|frame| {
let mut cam = config.to_camera();
if let Some(c) = &frame.camera {
cam.yaw_deg += c.yaw;
cam.pitch_deg += c.pitch;
cam.zoom *= c.zoom;
}
camera::compute_view_proj(bmin, bmax, aspect, &cam).0
})
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_meshes(
meshes: &[MeshOutput],
config: &RenderConfig,
hdri: Option<&HdriData>,
) -> Result<Vec<u8>, RenderError> {
pollster::block_on(render_meshes_async(meshes, config, hdri))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn render_meshes_png(
meshes: &[MeshOutput],
config: &RenderConfig,
hdri: Option<&HdriData>,
) -> Result<Vec<u8>, RenderError> {
let pixels = render_meshes(meshes, config, hdri)?;
encode_png(&pixels, config.width, config.height)
}
pub fn encode_png(pixels: &[u8], width: u32, height: u32) -> Result<Vec<u8>, RenderError> {
let img = image::RgbaImage::from_raw(width, height, pixels.to_vec())
.ok_or_else(|| RenderError::PngEncode("Failed to create image from pixels".into()))?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| RenderError::PngEncode(e.to_string()))?;
Ok(buf.into_inner())
}
pub fn encode_animation_gif(
frames: &[Vec<u8>],
width: u32,
height: u32,
fps: f64,
) -> Result<Vec<u8>, RenderError> {
use image::codecs::gif::{GifEncoder, Repeat as GifRepeat};
use image::{Delay, Frame};
let mut bytes = Vec::new();
{
let mut encoder = GifEncoder::new(&mut bytes);
encoder
.set_repeat(GifRepeat::Infinite)
.map_err(|e| RenderError::PngEncode(e.to_string()))?;
let fps = fps.max(1.0).round() as u32;
for pixels in frames {
let image =
image::RgbaImage::from_raw(width, height, pixels.clone()).ok_or_else(|| {
RenderError::PngEncode("Failed to create GIF frame from pixels".into())
})?;
encoder
.encode_frame(Frame::from_parts(
image,
0,
0,
Delay::from_numer_denom_ms(1000, fps),
))
.map_err(|e| RenderError::PngEncode(e.to_string()))?;
}
}
Ok(bytes)
}
pub fn write_animation_gif(
frames: &[Vec<u8>],
width: u32,
height: u32,
fps: f64,
path: &str,
) -> Result<(), RenderError> {
let gif = encode_animation_gif(frames, width, height, fps)?;
std::fs::write(path, gif).map_err(RenderError::Io)
}
#[cfg(not(target_arch = "wasm32"))]
impl crate::UniversalSchematic {
pub fn render(
&self,
pack: &crate::meshing::ResourcePackSource,
config: &RenderConfig,
) -> Result<Vec<u8>, RenderError> {
let mesh_config = crate::meshing::MeshConfig::default();
let meshes = self
.mesh_chunks_parallel(pack, &mesh_config, 64, num_cpus())
.map_err(|e| RenderError::RenderFailed(e.to_string()))?;
render_meshes(&meshes, config, None)
}
pub fn render_png(
&self,
pack: &crate::meshing::ResourcePackSource,
config: &RenderConfig,
) -> Result<Vec<u8>, RenderError> {
let pixels = self.render(pack, config)?;
encode_png(&pixels, config.width, config.height)
}
pub fn render_to_file(
&self,
pack: &crate::meshing::ResourcePackSource,
path: &str,
config: &RenderConfig,
) -> Result<(), RenderError> {
let png = self.render_png(pack, config)?;
std::fs::write(path, &png).map_err(RenderError::Io)
}
}
#[cfg(not(target_arch = "wasm32"))]
fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
#[cfg(test)]
mod config_tests {
use super::*;
use crate::rendering::camera::Projection;
#[test]
fn default_config_is_perspective_no_background() {
let c = RenderConfig::default();
assert_eq!(c.projection, Projection::Perspective);
assert!(c.background.is_none());
}
#[test]
fn isometric_sets_ortho_and_angles() {
let c = RenderConfig::isometric();
assert_eq!(c.projection, Projection::Orthographic);
assert!((c.yaw - 45.0).abs() < 1e-4);
assert!((c.pitch - 35.264).abs() < 1e-3);
}
#[test]
fn animation_gif_encoder_produces_a_looping_gif() {
let black = vec![0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255];
let white = vec![255; 16];
let gif = encode_animation_gif(&[black, white], 2, 2, 20.0).unwrap();
assert!(gif.starts_with(b"GIF"));
assert!(gif.windows(11).any(|w| w == b"NETSCAPE2.0"));
}
#[test]
fn to_camera_propagates_projection_and_background() {
let mut c = RenderConfig::default();
c.projection = Projection::Orthographic;
c.background = Some([1.0, 0.0, 0.0, 0.5]);
let cam = c.to_camera();
assert_eq!(cam.projection, Projection::Orthographic);
assert_eq!(cam.background, Some([1.0, 0.0, 0.0, 0.5]));
}
}