Fix extended-region scene rendering and delivery
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-22 13:45:31 +02:00
parent c101007362
commit f6f5abe464
14 changed files with 1349 additions and 533 deletions

View File

@@ -0,0 +1,426 @@
//! Cross-platform projection camera and headless `wgpu` triangle renderer.
#![allow(clippy::missing_errors_doc)]
type CameraBasis = ([f32; 3], [f32; 3], [f32; 3]);
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub position: [f32; 3],
pub forward: [f32; 3],
pub up: [f32; 3],
pub vertical_fov_degrees: f32,
}
impl Camera {
pub fn look_at(
position: [f32; 3],
target: [f32; 3],
up: [f32; 3],
vertical_fov_degrees: f32,
) -> Result<Self, RenderError> {
let forward = normalize([
target[0] - position[0],
target[1] - position[1],
target[2] - position[2],
])?;
let camera = Self {
position,
forward,
up: normalize(up)?,
vertical_fov_degrees,
};
camera.basis()?;
Ok(camera)
}
fn basis(self) -> Result<CameraBasis, RenderError> {
if !(10.0..=140.0).contains(&self.vertical_fov_degrees)
|| self
.position
.iter()
.chain(self.forward.iter())
.chain(self.up.iter())
.any(|value| !value.is_finite())
{
return Err(RenderError::InvalidScene);
}
let forward = normalize(self.forward)?;
let right = normalize(cross(forward, self.up))?;
Ok((right, cross(right, forward), forward))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Triangle {
pub vertices: [[f32; 3]; 3],
pub colors_srgb: [[u8; 4]; 3],
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RenderLimits {
pub width: u32,
pub height: u32,
pub max_triangles: usize,
pub far_distance: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RenderError {
AdapterUnavailable,
InvalidScene,
ResourceLimit,
TimedOut,
Readback,
}
impl std::fmt::Display for RenderError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::AdapterUnavailable => "wgpu adapter is unavailable",
Self::InvalidScene => "render scene is invalid",
Self::ResourceLimit => "render resource limit exceeded",
Self::TimedOut => "wgpu render timed out",
Self::Readback => "wgpu readback failed",
})
}
}
impl std::error::Error for RenderError {}
fn normalize(value: [f32; 3]) -> Result<[f32; 3], RenderError> {
let length = dot(value, value).sqrt();
if length < 0.0001 {
return Err(RenderError::InvalidScene);
}
Ok([value[0] / length, value[1] / length, value[2] / length])
}
fn dot(left: [f32; 3], right: [f32; 3]) -> f32 {
left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
}
fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[
left[1] * right[2] - left[2] * right[1],
left[2] * right[0] - left[0] * right[2],
left[0] * right[1] - left[1] * right[0],
]
}
#[cfg(feature = "wgpu")]
pub struct Renderer {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::RenderPipeline,
}
#[cfg(feature = "wgpu")]
const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x4];
#[cfg(feature = "wgpu")]
impl Renderer {
pub async fn new() -> Result<Self, RenderError> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
..Default::default()
})
.await
.map_err(|_| RenderError::AdapterUnavailable)?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("metacrate-rendering-wgpu"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
memory_hints: wgpu::MemoryHints::MemoryUsage,
..Default::default()
})
.await
.map_err(|_| RenderError::AdapterUnavailable)?;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("metacrate-rendering-wgpu-shader"),
source: wgpu::ShaderSource::Wgsl(
r"
struct VertexInput { @location(0) position: vec3<f32>, @location(1) color: vec4<f32>, };
struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) color: vec4<f32>, };
@vertex fn vs_main(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.position = vec4<f32>(input.position, 1.0);
output.color = input.color;
return output;
}
@fragment fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> { return input.color; }
"
.into(),
),
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("metacrate-rendering-wgpu-layout"),
bind_group_layouts: &[],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("metacrate-rendering-wgpu-pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: 7 * std::mem::size_of::<f32>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &VERTEX_ATTRIBUTES,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Less),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8UnormSrgb,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Ok(Self {
device,
queue,
pipeline,
})
}
#[allow(clippy::too_many_lines)]
pub fn render(
&self,
camera: Camera,
triangles: &[Triangle],
background_srgb: [u8; 4],
limits: RenderLimits,
) -> Result<Vec<u8>, RenderError> {
let (vertices, vertex_count) = gpu_vertices(camera, triangles, limits)?;
let vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("metacrate-rendering-wgpu-vertices"),
size: vertices.len().max(4) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if !vertices.is_empty() {
self.queue.write_buffer(&vertex_buffer, 0, &vertices);
}
let extent = wgpu::Extent3d {
width: limits.width,
height: limits.height,
depth_or_array_layers: 1,
};
let color = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("metacrate-rendering-wgpu-color"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let depth = self.device.create_texture(&wgpu::TextureDescriptor {
label: Some("metacrate-rendering-wgpu-depth"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let row_bytes = limits
.width
.checked_mul(4)
.ok_or(RenderError::ResourceLimit)?;
let padded_row_bytes = row_bytes.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
* wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let readback = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("metacrate-rendering-wgpu-readback"),
size: u64::from(padded_row_bytes)
.checked_mul(u64::from(limits.height))
.ok_or(RenderError::ResourceLimit)?,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let color_view = color.create_view(&wgpu::TextureViewDescriptor::default());
let depth_view = depth.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("metacrate-rendering-wgpu-commands"),
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("metacrate-rendering-wgpu-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: f64::from(background_srgb[0]) / 255.0,
g: f64::from(background_srgb[1]) / 255.0,
b: f64::from(background_srgb[2]) / 255.0,
a: f64::from(background_srgb[3]) / 255.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
if vertex_count != 0 {
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
pass.draw(0..vertex_count, 0..1);
}
}
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &color,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_row_bytes),
rows_per_image: Some(limits.height),
},
},
extent,
);
let submission = self.queue.submit([encoder.finish()]);
let slice = readback.slice(..);
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = sender.send(result);
});
self.device
.poll(wgpu::PollType::Wait {
submission_index: Some(submission),
timeout: Some(std::time::Duration::from_secs(5)),
})
.map_err(|_| RenderError::TimedOut)?;
receiver
.recv_timeout(std::time::Duration::from_secs(1))
.map_err(|_| RenderError::TimedOut)?
.map_err(|_| RenderError::Readback)?;
let mapped = slice
.get_mapped_range()
.map_err(|_| RenderError::Readback)?;
let mut rgba = Vec::with_capacity(row_bytes as usize * limits.height as usize);
for row in mapped
.chunks_exact(padded_row_bytes as usize)
.take(limits.height as usize)
{
rgba.extend_from_slice(&row[..row_bytes as usize]);
}
drop(mapped);
readback.unmap();
Ok(rgba)
}
}
#[cfg(feature = "wgpu")]
#[allow(clippy::cast_precision_loss)] // Render dimensions and distance are bounded well below f32's exact integer range.
fn gpu_vertices(
camera: Camera,
triangles: &[Triangle],
limits: RenderLimits,
) -> Result<(Vec<u8>, u32), RenderError> {
if limits.width == 0
|| limits.height == 0
|| limits.far_distance == 0
|| triangles.len() > limits.max_triangles
|| triangles
.iter()
.flat_map(|triangle| triangle.vertices.iter().flatten())
.any(|value| !value.is_finite())
{
return Err(RenderError::InvalidScene);
}
let basis = camera.basis()?;
let aspect = limits.width as f32 / limits.height as f32;
let focal = 1.0 / (camera.vertical_fov_degrees.to_radians() / 2.0).tan();
let mut bytes = Vec::with_capacity(triangles.len() * 3 * 7 * std::mem::size_of::<f32>());
let mut count = 0u32;
for triangle in triangles {
let projected = triangle.vertices.map(|world| {
let delta = [
world[0] - camera.position[0],
world[1] - camera.position[1],
world[2] - camera.position[2],
];
let z = dot(delta, basis.2);
[
dot(delta, basis.0) * focal / aspect / z,
dot(delta, basis.1) * focal / z,
(z / limits.far_distance as f32).clamp(0.0, 1.0),
z,
]
});
if projected.iter().any(|vertex| vertex[3] <= 0.05) {
continue;
}
for (vertex, color) in projected.into_iter().zip(triangle.colors_srgb) {
let color = color.map(|channel| f32::from(channel) / 255.0);
for value in vertex[..3].iter().chain(color.iter()) {
bytes.extend_from_slice(&value.to_ne_bytes());
}
count = count.checked_add(1).ok_or(RenderError::ResourceLimit)?;
}
}
Ok((bytes, count))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn look_at_builds_a_valid_projection_camera() {
let camera =
Camera::look_at([1.0, 2.0, 3.0], [5.0, 2.0, 3.0], [0.0, 0.0, 1.0], 60.0).unwrap();
assert!(
camera
.forward
.into_iter()
.zip([1.0, 0.0, 0.0])
.all(|(actual, expected)| (actual - expected).abs() < f32::EPSILON)
);
assert!(Camera::look_at([0.0; 3], [0.0; 3], [0.0, 0.0, 1.0], 60.0).is_err());
}
}