feat: add gpu driven render graph pipeline

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 20:26:31 +00:00
co-authored by heaust
parent d4e8634f67
commit 05311e564c
51 changed files with 13262 additions and 743 deletions
+1
View File
@@ -46,6 +46,7 @@ thiserror = { workspace = true }
ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
image = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+87 -58
View File
@@ -18,9 +18,10 @@ use web_sys::AddEventListenerOptions;
#[cfg(target_arch = "wasm32")]
pub struct EventListeners {
pub resize_listener: Option<Closure<dyn FnMut()>>,
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub pointer_listener: Option<Closure<dyn FnMut(web_sys::PointerEvent)>>,
pub click_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
pub contextmenu_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
}
@@ -29,9 +30,10 @@ impl EventListeners {
pub fn new() -> Self {
Self {
resize_listener: None,
mousemove_listener: None,
mousedown_listener: None,
pointer_listener: None,
click_listener: None,
wheel_listener: None,
contextmenu_listener: None,
keyboard_listener: None,
}
}
@@ -54,65 +56,82 @@ pub fn setup_event_listeners(
let width = f64::from(resize_canvas.client_width().max(1));
let height = f64::from(resize_canvas.client_height().max(1));
resize_worker_chan
.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}))
.unwrap();
let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}));
});
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
let mousemove_worker_chan = worker_chan.clone();
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
let pointer_worker_chan = worker_chan.clone();
let pointer_canvas = canvas.clone();
let pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)> =
Closure::new(move |event: web_sys::PointerEvent| {
use crate::message::{camera_drag, MouseMessage};
if event.pointer_type() != "mouse" {
return;
}
match event.type_().as_str() {
"pointerdown" if matches!(event.button(), 1 | 2) => {
event.prevent_default();
let _ = pointer_canvas.set_pointer_capture(event.pointer_id());
}
"pointermove"
if pointer_canvas.has_pointer_capture(event.pointer_id())
&& camera_drag(event.buttons()).is_some() =>
{
event.prevent_default();
let message = MouseMessage::from_pointer_evt(
&event,
f64::from(pointer_canvas.client_height().max(1)),
);
let _ = pointer_worker_chan.send(WindowEvent::PointerMove(message));
}
"pointerup" | "pointercancel"
if pointer_canvas.has_pointer_capture(event.pointer_id()) =>
{
let _ = pointer_canvas.release_pointer_capture(event.pointer_id());
}
_ => {}
}
});
for event_name in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
canvas.add_event_listener_with_callback(
event_name,
pointer_listener.as_ref().unchecked_ref(),
)?;
}
let click_worker_chan = worker_chan.clone();
let click_canvas = canvas.clone();
let click_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| {
use crate::message::MouseMessage;
if event.buttons() & 0x04 != 0 {
event.prevent_default();
if event.button() != 0 {
return;
}
let mouse_event_data = MouseMessage::from_evt(event.clone());
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone());
if event.type_() == "click" {
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
}
mousemove_worker_chan.clone().send(event_data).unwrap();
let message =
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
});
window.add_event_listener_with_callback(
"mousemove",
mousemove_listener.as_ref().unchecked_ref(),
)?;
window
.add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?;
let mousedown_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| {
if event.button() == 1 {
event.prevent_default();
}
});
window.add_event_listener_with_callback(
"mousedown",
mousedown_listener.as_ref().unchecked_ref(),
)?;
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
let wheel_worker_chan = worker_chan.clone();
let wheel_canvas = canvas.clone();
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
Closure::new(move |event: web_sys::WheelEvent| {
use crate::message::WheelMessage;
event.prevent_default();
let wheel_event_data = WheelMessage::from_evt(event);
wheel_worker_chan
.send(WindowEvent::PointerWheel(wheel_event_data))
.unwrap();
if let Some(message) =
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
{
let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
}
});
let wheel_options = {
@@ -121,12 +140,19 @@ pub fn setup_event_listeners(
options
};
window.add_event_listener_with_callback_and_add_event_listener_options(
canvas.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
wheel_listener.as_ref().unchecked_ref(),
&wheel_options,
)?;
let contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| event.prevent_default());
canvas.add_event_listener_with_callback(
"contextmenu",
contextmenu_listener.as_ref().unchecked_ref(),
)?;
let keyboard_worker_chan = worker_chan.clone();
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
Closure::new(move |event: web_sys::KeyboardEvent| {
@@ -134,9 +160,7 @@ pub fn setup_event_listeners(
let keyboard_event_data = KeyboardMessage::from_evt(event);
keyboard_worker_chan
.send(WindowEvent::Keyboard(keyboard_event_data))
.unwrap();
let _ = keyboard_worker_chan.send(WindowEvent::Keyboard(keyboard_event_data));
});
window
@@ -144,9 +168,10 @@ pub fn setup_event_listeners(
Ok(EventListeners {
resize_listener: Some(resize_listener),
mousemove_listener: Some(mousemove_listener),
mousedown_listener: Some(mousedown_listener),
pointer_listener: Some(pointer_listener),
click_listener: Some(click_listener),
wheel_listener: Some(wheel_listener),
contextmenu_listener: Some(contextmenu_listener),
keyboard_listener: Some(keyboard_listener),
})
}
@@ -166,6 +191,7 @@ impl WebAppRuntime {
pub fn new<T: crate::renderer::scene::Scene + 'static>(
worker_name: &str,
canvas_selector: &str,
profile: bool,
) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>();
@@ -179,7 +205,7 @@ impl WebAppRuntime {
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
spawn_local(async move {
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
MainWorker::run_render_loop::<T>(receiver, ring).await;
MainWorker::run_render_loop::<T>(receiver, ring, profile).await;
});
})?;
@@ -228,9 +254,12 @@ pub trait WebApp {
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
/// Perform the default WASM initialization routine.
fn setup_runtime() -> Result<WebAppRuntime, JsValue> {
let mut runtime =
WebAppRuntime::new::<Self::Scene>(Self::worker_name(), Self::canvas_selector())?;
fn setup_runtime(profile: bool) -> Result<WebAppRuntime, JsValue> {
let mut runtime = WebAppRuntime::new::<Self::Scene>(
Self::worker_name(),
Self::canvas_selector(),
profile,
)?;
Self::on_runtime_initialized(&mut runtime);
Ok(runtime)
}
+275 -28
View File
@@ -3,7 +3,16 @@ use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt;
use crate::{message::WheelMessage, renderer::scene::UniformResource};
use crate::renderer::scene::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
@@ -81,6 +90,9 @@ pub struct CameraUniform {
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
@@ -115,8 +127,14 @@ impl Camera {
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
if (self.position - self.target).mag_sq() <= f32::EPSILON {
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
}
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
@@ -141,6 +159,9 @@ impl Camera {
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
if !delta_x.is_finite() || !delta_y.is_finite() {
return;
}
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
@@ -174,39 +195,62 @@ impl Camera {
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, msg: &WheelMessage) {
let mut delta = msg.delta_y as f32;
// Match browser delta modes so the wheel delta is always roughly pixels.
match msg.delta_mode {
1 => delta *= 16.0,
2 => delta *= 800.0,
_ => {}
}
// Scrolling up should zoom in.
delta = -delta;
if delta.abs() <= f32::EPSILON {
pub fn zoom(&mut self, delta_y_pixels: f32) {
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
return;
}
// Get forward direction from camera position to target
let mut forward_vec = self.target - self.position;
if forward_vec.mag_sq() <= f32::EPSILON {
forward_vec = Vec3::unit_z();
let mut offset = self.position - self.target;
let mut current_distance = offset.mag();
if !current_distance.is_finite() {
return;
}
if current_distance <= f32::EPSILON {
offset = Vec3::unit_z() * MIN_DISTANCE;
current_distance = MIN_DISTANCE;
}
let direction = offset / current_distance;
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
if !max_distance.is_finite() {
return;
}
let candidate = f64::from(current_distance)
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
let new_position = self.target + direction * new_distance;
if !vec3_is_finite(new_position) {
return;
}
let forward_dir = forward_vec.normalized();
let current_distance = forward_vec.mag();
// Scale dolly movement by distance to target for consistent perceived zoom speed
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
let dolly_translation = forward_dir * dolly_distance;
self.position = new_position;
self.distance = new_distance;
self.dirty = true;
self.compute_view_proj_mat();
}
self.position += dolly_translation;
self.target += dolly_translation;
self.compute_rotor();
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
if !delta_x.is_finite()
|| !delta_y.is_finite()
|| !viewport_height.is_finite()
|| !self.fov.is_finite()
{
return;
}
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
if !distance.is_finite() {
return;
}
let world_units_per_pixel =
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
let basis = OrthonormalBasis::from_camera(self);
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
let position = self.position + translation;
let target = self.target + translation;
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
self.dirty = true;
self.compute_view_proj_mat();
}
@@ -294,3 +338,206 @@ impl Camera {
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
fn vec3_is_finite(value: Vec3) -> bool {
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
assert!(
(actual.x - expected.x).abs() <= epsilon,
"x: {actual:?} != {expected:?}"
);
assert!(
(actual.y - expected.y).abs() <= epsilon,
"y: {actual:?} != {expected:?}"
);
assert!(
(actual.z - expected.z).abs() <= epsilon,
"z: {actual:?} != {expected:?}"
);
}
fn assert_camera_finite(camera: &Camera) {
assert!(vec3_is_finite(camera.position));
assert!(vec3_is_finite(camera.target));
assert!(camera.distance.is_finite());
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
}
#[test]
fn zoom_is_multiplicative_and_preserves_target() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_distance = camera.distance;
camera.zoom(-100.0);
assert!(camera.distance < initial_distance);
assert_eq!(camera.target, initial_target);
camera.zoom(100.0);
assert_vec3_close(camera.position, initial_position, 1e-4);
assert!((camera.distance - initial_distance).abs() <= 1e-4);
camera.zoom(100.0);
assert!(camera.distance > initial_distance);
assert_eq!(camera.target, initial_target);
}
#[test]
fn zoom_clamps_and_remains_finite() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
camera.zoom(f32::NEG_INFINITY);
assert!((camera.distance - 10.0).abs() <= 1e-5);
camera.zoom(-f32::MAX);
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
camera.zoom(f32::MAX);
assert!(camera.distance <= camera.z_far * 0.95);
assert_camera_finite(&camera);
}
#[test]
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_offset = initial_position - initial_target;
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
camera.pan(20.0, 10.0, 1000.0);
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
assert!((camera.distance - 10.0).abs() <= 1e-5);
}
#[test]
fn pan_scale_is_proportional_to_distance() {
let mut near = Camera::new(1.0);
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
let mut far = Camera::new(1.0);
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
near.pan(10.0, 0.0, 1000.0);
far.pan(10.0, 0.0, 1000.0);
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
}
#[test]
fn orbit_preserves_target_and_distance() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
let initial_target = camera.target;
let initial_distance = (camera.position - camera.target).mag();
camera.orbit(40.0, -25.0);
assert_eq!(camera.target, initial_target);
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
assert!((camera.distance - initial_distance).abs() <= 1e-5);
assert!(camera.distance >= MIN_DISTANCE);
assert_camera_finite(&camera);
}
#[test]
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::zero(), Vec3::zero());
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
let position = camera.position;
let target = camera.target;
camera.orbit(f32::NAN, 1.0);
camera.zoom(f32::INFINITY);
camera.pan(f32::NAN, 1.0, 0.0);
assert_eq!(camera.position, position);
assert_eq!(camera.target, target);
camera.pan(1.0, 1.0, 0.0);
camera.orbit(4.0, -3.0);
camera.zoom(2.0);
assert_camera_finite(&camera);
}
}
+516 -11
View File
@@ -4,10 +4,100 @@ use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use crate::render_data::{
InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData,
RenderDataError, RenderFlags,
InstanceHandle, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey,
RenderData, RenderDataError, RenderFlags,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
/// Index of refraction for the dielectric Fresnel response.
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SamplerMetadata {
pub index: usize,
pub mag_filter: Option<String>,
pub min_filter: Option<String>,
pub wrap_s: String,
pub wrap_t: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageSource {
Uri(String),
BufferView(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub index: usize,
pub name: Option<String>,
pub mime_type: Option<String>,
pub source: ImageSource,
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
/// upload images one at a time instead of retaining a decoded image batch.
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct InstalledScene {
pub meshes: Vec<MeshHandle>,
@@ -60,16 +150,31 @@ pub enum ImportError {
GltfParse(#[from] gltf::Error),
#[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String),
#[error("unsupported image source: {0}")]
UnsupportedImage(String),
#[error("invalid KHR_materials_ior value: {0}")]
InvalidIor(f32),
#[error("failed to install imported scene")]
Install(#[from] RenderDataError),
}
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
let ior = value.unwrap_or(1.5);
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
Ok(ior)
} else {
Err(ImportError::InvalidIor(ior))
}
}
#[derive(Clone, Debug)]
pub struct ImportedGeometry {
pub key: (usize, usize),
pub material: MaterialKey,
pub double_sided: bool,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
@@ -82,12 +187,257 @@ pub struct ImportedOccurrence {
pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
TextureReference {
texture: info.texture().index(),
tex_coord: info.tex_coord(),
}
}
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
if length > f32::EPSILON && length.is_finite() {
value.map(|x| x / length)
} else {
fallback
}
}
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [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],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
fn repair_geometry(
positions: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
tangents: Option<Vec<[f32; 4]>>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
) -> Result<
(
Vec<[f32; 3]>,
Vec<[f32; 3]>,
Vec<[f32; 4]>,
Vec<[f32; 2]>,
Vec<u32>,
),
ImportError,
> {
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
return Err(ImportError::InvalidPrimitive(
"triangle indices are malformed".into(),
));
}
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
let tangents_valid = tangents
.as_ref()
.is_some_and(|x| x.len() == positions.len());
if normals_valid && tangents_valid {
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
}
let mut out_p = Vec::with_capacity(indices.len());
let mut out_n = Vec::with_capacity(indices.len());
let mut out_t = Vec::with_capacity(indices.len());
let mut out_uv = Vec::with_capacity(indices.len());
for triangle in indices.chunks_exact(3) {
let ids = [
triangle[0] as usize,
triangle[1] as usize,
triangle[2] as usize,
];
let p = ids.map(|i| positions[i]);
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
let edge1 = sub(p[1], p[0]);
let edge2 = sub(p[2], p[0]);
let (raw_tangent, raw_bitangent) =
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
let r = determinant.recip();
(
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
)
} else {
([0.0; 3], [0.0; 3])
};
for corner in 0..3 {
let n = normals
.as_ref()
.filter(|_| normals_valid)
.map_or(face_normal, |x| x[ids[corner]]);
let n = normalize(n, face_normal);
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
let fallback_axis = if n[0].abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let tangent3 = normalize(
projected,
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
);
let generated = [
tangent3[0],
tangent3[1],
tangent3[2],
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
-1.0
} else {
1.0
},
];
out_p.push(p[corner]);
out_n.push(n);
out_t.push(
tangents
.as_ref()
.filter(|_| tangents_valid)
.map_or(generated, |x| x[ids[corner]]),
);
out_uv.push(uv[corner]);
}
}
let out_i = (0..u32::try_from(out_p.len())
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
.collect();
Ok((out_p, out_n, out_t, out_uv, out_i))
}
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(bytes)?;
let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?;
decode_gltf_model(Gltf::from_slice(bytes)?)
}
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(&bytes)?;
drop(bytes);
decode_gltf_model(model)
}
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
// Reject external images before buffer import can turn them into a generic
// import error (or attempt to interpret a data/external URI).
for image in model.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)));
}
}
let blob = model.blob.take();
let buffers = gltf::import_buffers(&model.document, None, blob)?;
let mut result = ImportedScene::default();
result.materials.push(Material::default());
for material in model.materials() {
let pbr = material.pbr_metallic_roughness();
let normal = material.normal_texture();
let normal_texture = normal.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
let occlusion = material.occlusion_texture();
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
result.materials.push(Material {
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
base_color_factor: pbr.base_color_factor(),
metallic_factor: pbr.metallic_factor(),
roughness_factor: pbr.roughness_factor(),
emissive_factor: material.emissive_factor(),
ior: decode_ior(material.ior())?,
alpha_mode: match material.alpha_mode() {
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
},
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
double_sided: material.double_sided(),
base_color_texture: pbr.base_color_texture().map(texture_reference),
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
normal_texture,
normal_scale: normal.map_or(1.0, |x| x.scale()),
occlusion_texture,
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
emissive_texture: material.emissive_texture().map(texture_reference),
});
}
result.textures = model
.textures()
.map(|x| TextureMetadata {
image: x.source().index(),
sampler: x.sampler().index(),
})
.collect();
result.samplers = model
.samplers()
.map(|x| SamplerMetadata {
index: x.index().unwrap(),
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
min_filter: x.min_filter().map(|v| format!("{v:?}")),
wrap_s: format!("{:?}", x.wrap_s()),
wrap_t: format!("{:?}", x.wrap_t()),
})
.collect();
result.images = model
.images()
.map(|x| -> Result<_, ImportError> {
let (source, mime_type, encoded_data) = match x.source() {
gltf::image::Source::Uri { uri, .. } => {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)))
}
gltf::image::Source::View { view, mime_type } => {
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
ImportError::UnsupportedImage("image buffer is missing".into())
})?;
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView overflows".into())
})?;
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
})?;
(
ImageSource::BufferView(view.index()),
Some(mime_type.to_owned()),
bytes.to_vec(),
)
}
};
Ok(ImageMetadata {
index: x.index(),
name: x.name().map(str::to_owned),
mime_type,
source,
encoded_data,
})
})
.collect::<Result<_, _>>()?;
let mut seen = HashMap::new();
fn visit(
node: gltf::Node<'_>,
@@ -116,12 +466,8 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if count == 0 {
continue;
}
let mut normals: Vec<_> = reader
.read_normals()
.map(|x| x.collect())
.unwrap_or_default();
normals.resize(count, [0., 1., 0.]);
normals.truncate(count);
let normals = reader.read_normals().map(|x| x.collect());
let tangents = reader.read_tangents().map(|x| x.collect());
let mut uvs: Vec<_> = reader
.read_tex_coords(0)
.map(|x| x.into_f32().collect())
@@ -139,11 +485,20 @@ pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
if indices.is_empty() {
continue;
}
let (positions, normals, tangents, uvs, indices) =
repair_geometry(positions, normals, tangents, uvs, indices)?;
let primitive_material = primitive.material();
result.geometries.push(ImportedGeometry {
key,
double_sided: primitive.material().double_sided(),
material: primitive_material
.index()
.map_or(MaterialKey::DEFAULT, |index| {
MaterialKey::new(index as u32 + 1)
}),
double_sided: primitive_material.double_sided(),
positions,
normals,
tangents,
uvs,
indices,
});
@@ -189,9 +544,11 @@ pub fn install_imported(
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)],
material: geometry.material,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: transform,
@@ -250,3 +607,151 @@ pub fn install_imported(
bounds,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_images_are_rejected_explicitly() {
let json = br#"{
"asset":{"version":"2.0"},
"images":[{"uri":"external.png"}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let result = decode_gltf(json);
assert!(
matches!(
result,
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
),
"unexpected result: {result:?}"
);
}
#[test]
fn owned_and_borrowed_decode_paths_remain_compatible() {
let json = br#"{
"asset":{"version":"2.0"},
"scenes":[{"nodes":[]}],"scene":0
}"#;
let borrowed = decode_gltf(json).unwrap();
let owned = decode_gltf_owned(json.to_vec()).unwrap();
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
assert_eq!(borrowed.materials.len(), owned.materials.len());
}
#[test]
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
let repaired = repair_geometry(
positions,
None,
None,
vec![[0.0; 2]; 4],
vec![0, 1, 2, 0, 3, 1],
)
.unwrap();
assert_eq!(repaired.0.len(), 6);
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
assert!(repaired
.2
.iter()
.flatten()
.all(|component| component.is_finite()));
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
}
#[test]
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let generated = repair_geometry(
positions.clone(),
Some(normals.clone()),
None,
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
vec![0, 1, 2, 0, 2, 3],
)
.unwrap();
assert_ne!(generated.2[0][3], generated.2[3][3]);
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
let preserved = repair_geometry(
positions,
Some(normals),
Some(supplied.clone()),
vec![[0.0; 2]; 4],
vec![0, 1, 2],
)
.unwrap();
assert_eq!(preserved.2, supplied);
}
#[test]
fn material_defaults_match_gltf_core_defaults() {
let material = Material::default();
assert_eq!(material.base_color_factor, [1.0; 4]);
assert_eq!(material.metallic_factor, 1.0);
assert_eq!(material.roughness_factor, 1.0);
assert_eq!(material.alpha_cutoff, 0.5);
assert_eq!(material.ior, 1.5);
assert_eq!(material.key, MaterialKey::DEFAULT);
}
#[test]
fn imports_khr_materials_ior() {
let json = br#"{
"asset":{"version":"2.0"},
"extensionsUsed":["KHR_materials_ior"],
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let imported = decode_gltf(json).unwrap();
assert_eq!(imported.materials[1].ior, 1.33);
}
#[test]
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
assert_eq!(decode_ior(None).unwrap(), 1.5);
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
}
#[test]
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
assert!(matches!(
decode_ior(Some(ior)),
Err(ImportError::InvalidIor(_))
));
}
}
#[test]
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
for ior in ["-1", "0.5"] {
let json = format!(
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
);
assert!(matches!(
decode_gltf(json.as_bytes()),
Err(ImportError::InvalidIor(_))
));
}
}
}
+46 -66
View File
@@ -1,72 +1,52 @@
struct UniformData {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
_padding0: f32,
camera_position: vec4<f32>,
}
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
@group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
@group(2) @binding(0) var<uniform> material: MaterialData;
@group(2) @binding(1) var base_tex: texture_2d<f32>;
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
@group(2) @binding(6) var base_sampler: sampler;
@group(2) @binding(7) var mr_sampler: sampler;
@group(2) @binding(8) var normal_sampler: sampler;
@group(2) @binding(9) var occlusion_sampler: sampler;
@group(2) @binding(10) var emissive_sampler: sampler;
struct VertexInput {
@location(0) pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) model_col0: vec4<f32>,
@location(4) model_col1: vec4<f32>,
@location(5) model_col2: vec4<f32>,
@location(6) model_col3: vec4<f32>,
@location(7) normal_col0: vec4<f32>,
@location(8) normal_col1: vec4<f32>,
@location(9) normal_col2: vec4<f32>,
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
fn sample_closure(uv: vec2<f32>) -> Closure {
let bits = material.flags.x; var c: Closure;
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let model = mat4x4<f32>(
in.model_col0,
in.model_col1,
in.model_col2,
in.model_col3,
);
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
out.normal = normalize(normal_matrix * in.normal);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
let light_color = vec3<f32>(1.0, 0.95, 0.85);
let base_color = vec3<f32>(0.55, 0.58, 0.62);
let normal = normalize(in.normal);
let view_dir = normalize(uni.camera_position.xyz - in.world_pos);
let diffuse_strength = max(dot(normal, light_direction), 0.0);
let ambient = 0.45;
var specular = 0.0;
if diffuse_strength > 0.0 {
let halfway_dir = normalize(light_direction + view_dir);
specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0);
}
let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3<f32>(1.0));
let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0);
let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0);
return vec4<f32>(lighting + x - y, 1.0);
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65);
let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up); let env_diff=(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky;
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
}
+74 -19
View File
@@ -1,6 +1,40 @@
use core::fmt;
use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError;
use wasm_bindgen::JsCast;
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraDrag {
Orbit,
Pan,
}
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
if buttons & MIDDLE_BUTTON_MASK != 0 {
Some(CameraDrag::Orbit)
} else if buttons & RIGHT_BUTTON_MASK != 0 {
Some(CameraDrag::Pan)
} else {
None
}
}
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
if !delta_y.is_finite() || !viewport_height.is_finite() {
return None;
}
let delta = match delta_mode {
0 => delta_y,
1 => delta_y * 16.0,
2 => delta_y * viewport_height.max(1.0),
_ => return None,
};
let delta = delta as f32;
delta.is_finite().then_some(delta)
}
#[derive(Debug)]
pub enum WindowEvent {
@@ -42,10 +76,11 @@ pub struct MouseMessage {
pub movement_y: f64,
pub offset_x: f64,
pub offset_y: f64,
pub viewport_height: f64,
}
impl MouseMessage {
pub fn from_evt(event: web_sys::MouseEvent) -> Self {
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
@@ -57,33 +92,24 @@ impl MouseMessage {
movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64,
offset_y: event.offset_y() as f64,
viewport_height,
}
}
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
Self::from_evt(event.unchecked_ref(), viewport_height)
}
}
#[derive(Debug, Clone)]
pub struct WheelMessage {
pub scale_factor: f64,
pub delta_x: f64,
pub delta_y: f64,
pub delta_z: f64,
pub delta_mode: u32,
pub client_x: f64,
pub client_y: f64,
pub delta_y_pixels: f32,
}
impl WheelMessage {
pub fn from_evt(event: web_sys::WheelEvent) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
delta_x: event.delta_x(),
delta_y: event.delta_y(),
delta_z: event.delta_z(),
delta_mode: event.delta_mode(),
client_x: event.client_x() as f64,
client_y: event.client_y() as f64,
}
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
.map(|delta_y_pixels| Self { delta_y_pixels })
}
}
@@ -147,3 +173,32 @@ impl From<BorrowMutError> for DrainEventError {
DrainEventError::BorrowError(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_delta_is_normalized_to_css_pixels() {
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
}
#[test]
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
assert_eq!(camera_drag(0), None);
assert_eq!(camera_drag(1), None);
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
assert_eq!(
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
Some(CameraDrag::Orbit)
);
}
}
+4 -1
View File
@@ -109,12 +109,15 @@ impl MainWorker {
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await));
let renderer = Rc::new(RefCell::new(
Renderer::<T>::new(canvas, events_chan, profile).await,
));
renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer);
}
+48 -1
View File
@@ -40,6 +40,24 @@ impl PipelineKey {
}
}
/// Stable CPU-side identity for a device-independent material.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct MaterialKey(u32);
impl MaterialKey {
/// The glTF/default material.
pub const DEFAULT: Self = Self(0);
pub const fn new(value: u32) -> Self {
Self(value)
}
pub const fn get(self) -> u32 {
self.0
}
}
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderFlags(u32);
@@ -101,9 +119,11 @@ pub struct GeometryRange {
pub struct MeshCreateInfo<'a> {
pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]],
pub indices: &'a [u32],
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags,
pub default_instance_flags: RenderFlags,
pub default_transform: ModelTransform,
@@ -120,6 +140,7 @@ pub struct MeshView {
pub handle: MeshHandle,
pub geometry: GeometryRange,
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags,
pub aabb: Aabb,
pub default_instance: InstanceHandle,
@@ -178,6 +199,7 @@ pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result<Aabb, Ren
pub struct VertexStreams<'a> {
pub positions: &'a [[f32; 3]],
pub normals: &'a [[f32; 3]],
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]],
}
@@ -267,6 +289,7 @@ pub enum RenderDataError {
struct VertexSoa {
positions: Vec<[f32; 3]>,
normals: Vec<[f32; 3]>,
tangents: Vec<[f32; 4]>,
uvs: Vec<[f32; 2]>,
logical_capacity: u32,
max_capacity: Option<u32>,
@@ -287,6 +310,7 @@ struct MeshSoa {
index_starts: Vec<u32>,
index_counts: Vec<u32>,
pipeline_keys: Vec<PipelineKey>,
material_keys: Vec<MaterialKey>,
flags: Vec<RenderFlags>,
aabb_mins: Vec<[f32; 3]>,
aabb_maxs: Vec<[f32; 3]>,
@@ -416,6 +440,7 @@ impl RenderData {
vertices: VertexSoa {
positions: Vec::new(),
normals: Vec::new(),
tangents: Vec::new(),
uvs: Vec::new(),
logical_capacity: 0,
max_capacity: config.max_vertices,
@@ -553,6 +578,8 @@ impl RenderData {
.copy_from_slice(info.positions);
self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.normals);
self.vertices.tangents[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.tangents);
self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)]
.copy_from_slice(info.uvs);
self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)]
@@ -567,6 +594,7 @@ impl RenderData {
index_count,
},
info.pipeline,
info.material,
info.flags,
bounds,
default_instance,
@@ -766,6 +794,7 @@ impl RenderData {
VertexStreams {
positions: &self.vertices.positions,
normals: &self.vertices.normals,
tangents: &self.vertices.tangents,
uvs: &self.vertices.uvs,
}
}
@@ -783,6 +812,7 @@ impl RenderData {
self.instances.slots.clear();
self.vertices.positions.clear();
self.vertices.normals.clear();
self.vertices.tangents.clear();
self.vertices.uvs.clear();
self.indices.values.clear();
self.vertices.allocator.clear();
@@ -800,6 +830,7 @@ impl RenderData {
)?;
reserve_vec(&mut self.vertices.positions, target, "vertices")?;
reserve_vec(&mut self.vertices.normals, target, "vertices")?;
reserve_vec(&mut self.vertices.tangents, target, "vertices")?;
reserve_vec(&mut self.vertices.uvs, target, "vertices")?;
self.vertices.logical_capacity = target;
Ok(())
@@ -821,6 +852,9 @@ impl RenderData {
let vertices = as_usize(self.vertices.allocator.high_water());
self.vertices.positions.resize(vertices, [0.0; 3]);
self.vertices.normals.resize(vertices, [0.0; 3]);
self.vertices
.tangents
.resize(vertices, [0.0, 0.0, 0.0, 1.0]);
self.vertices.uvs.resize(vertices, [0.0; 2]);
self.indices
.values
@@ -834,6 +868,9 @@ impl RenderData {
self.vertices
.normals
.truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices
.tangents
.truncate(as_usize(self.vertices.allocator.high_water()));
self.vertices
.uvs
.truncate(as_usize(self.vertices.allocator.high_water()));
@@ -852,6 +889,7 @@ impl MeshSoa {
index_starts: Vec::new(),
index_counts: Vec::new(),
pipeline_keys: Vec::new(),
material_keys: Vec::new(),
flags: Vec::new(),
aabb_mins: Vec::new(),
aabb_maxs: Vec::new(),
@@ -871,6 +909,7 @@ impl MeshSoa {
reserve_vec(&mut self.index_starts, target, "meshes")?;
reserve_vec(&mut self.index_counts, target, "meshes")?;
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
reserve_vec(&mut self.material_keys, target, "meshes")?;
reserve_vec(&mut self.flags, target, "meshes")?;
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
reserve_vec(&mut self.aabb_maxs, target, "meshes")?;
@@ -885,6 +924,7 @@ impl MeshSoa {
prepared: PreparedSlot,
geometry: GeometryRange,
pipeline: PipelineKey,
material: MaterialKey,
flags: RenderFlags,
bounds: Aabb,
default: InstanceHandle,
@@ -895,6 +935,7 @@ impl MeshSoa {
resize_column(&mut self.index_starts, len, 0);
resize_column(&mut self.index_counts, len, 0);
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT);
resize_column(&mut self.flags, len, RenderFlags::NONE);
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
resize_column(&mut self.aabb_maxs, len, [0.0; 3]);
@@ -906,6 +947,7 @@ impl MeshSoa {
self.index_starts[index] = geometry.index_start;
self.index_counts[index] = geometry.index_count;
self.pipeline_keys[index] = pipeline;
self.material_keys[index] = material;
self.flags[index] = flags;
self.aabb_mins[index] = bounds.min;
self.aabb_maxs[index] = bounds.max;
@@ -925,6 +967,7 @@ impl MeshSoa {
index_count: self.index_counts[index],
},
pipeline: self.pipeline_keys[index],
material: self.material_keys[index],
flags: self.flags[index],
aabb: Aabb {
min: self.aabb_mins[index],
@@ -1054,7 +1097,10 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
if info.positions.is_empty() {
return Err(RenderDataError::EmptyVertices);
}
if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() {
if info.positions.len() != info.normals.len()
|| info.positions.len() != info.tangents.len()
|| info.positions.len() != info.uvs.len()
{
return Err(RenderDataError::MismatchedVertexStreams);
}
let vertex_count =
@@ -1068,6 +1114,7 @@ fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result<u32, RenderDataError>
.iter()
.flatten()
.chain(info.normals.iter().flatten())
.chain(info.tangents.iter().flatten())
.chain(info.uvs.iter().flatten())
.any(|value| !value.is_finite())
{
+20 -1
View File
@@ -3,6 +3,7 @@ use crate::render_data::handle::SlotState;
const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]];
const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3];
const TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
const INDICES: [u32; 3] = [0, 1, 2];
@@ -10,9 +11,11 @@ fn info() -> MeshCreateInfo<'static> {
MeshCreateInfo {
positions: &POSITIONS,
normals: &NORMALS,
tangents: &TANGENTS,
uvs: &UVS,
indices: &INDICES,
pipeline: PipelineKey::new(7),
material: MaterialKey::new(11),
flags: RenderFlags::from_bits_retain(2),
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
@@ -83,6 +86,10 @@ fn default_instance_is_protected_and_flags_are_separate() {
let created = data.create_mesh(info()).unwrap();
assert!(data.instance(created.default_instance).unwrap().is_default);
assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2);
assert_eq!(
data.mesh(created.mesh).unwrap().material,
MaterialKey::new(11)
);
assert_eq!(
data.instance(created.default_instance).unwrap().flags,
RenderFlags::VISIBLE
@@ -239,6 +246,7 @@ fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() {
data.destroy_mesh(second.mesh).unwrap();
assert_eq!(data.streams().positions.len(), 3);
assert_eq!(data.streams().normals.len(), 3);
assert_eq!(data.streams().tangents.len(), 3);
assert_eq!(data.streams().uvs.len(), 3);
assert_eq!(data.indices().len(), 3);
}
@@ -288,6 +296,7 @@ fn aabb_supports_one_point_and_multiple_points() {
let mut one = info();
one.positions = &point;
one.normals = &normal;
one.tangents = &[[1.0, 0.0, 0.0, 1.0]];
one.uvs = &uv;
one.indices = &index;
let mut data = data();
@@ -325,6 +334,13 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let short_tangents = &TANGENTS[..2];
let mut candidate = info();
candidate.tangents = short_tangents;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
RenderDataError::MismatchedVertexStreams
);
let short_uvs = &UVS[..2];
let mut candidate = info();
candidate.uvs = short_uvs;
@@ -339,19 +355,22 @@ fn malformed_geometry_matrix_is_rejected_without_consumption() {
RenderDataError::EmptyIndices
);
for stream in 0..3 {
for stream in 0..4 {
for bad in [f32::NAN, f32::INFINITY] {
let mut positions = POSITIONS;
let mut normals = NORMALS;
let mut tangents = TANGENTS;
let mut uvs = UVS;
match stream {
0 => positions[0][0] = bad,
1 => normals[0][0] = bad,
2 => tangents[0][0] = bad,
_ => uvs[0][0] = bad,
}
let mut candidate = info();
candidate.positions = &positions;
candidate.normals = &normals;
candidate.tangents = &tangents;
candidate.uvs = &uvs;
assert_eq!(
data.create_mesh(candidate).unwrap_err(),
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
use super::MeshFlagV2;
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SemanticTypeV2 {
SurfaceTarget,
TextureSpec,
Texture,
SceneTable,
LocalAabbBuffer,
CameraFrustum,
BooleanFlagBuffer,
DrawStream,
DepthStencilConfig,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionClassV2 {
Source,
CpuPreparation,
Compute,
Render,
Present,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InputCardinalityV2 {
RequiredOne,
OptionalOne,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", content = "types", rename_all = "snake_case")]
pub enum TypeConstraintV2 {
Exact(SemanticTypeV2),
OneOf(&'static [SemanticTypeV2]),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InputRoleV2 {
SemanticRead,
UniformRead,
StorageRead,
IndirectRead,
SampledTexture,
ColorTarget { location: u32 },
DepthTarget,
Present,
Configuration,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OutputMetadataV2 {
None,
BooleanFlag { flag: MeshFlagV2 },
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InputSocketContractV2 {
pub name: &'static str,
pub accepted: TypeConstraintV2,
pub cardinality: InputCardinalityV2,
pub role: InputRoleV2,
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OutputSocketContractV2 {
pub name: &'static str,
pub semantic_type: SemanticTypeV2,
pub metadata: OutputMetadataV2,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContractV2 {
pub key: &'static str,
pub version: u32,
pub execution: ExecutionClassV2,
pub inputs: &'static [InputSocketContractV2],
pub outputs: &'static [OutputSocketContractV2],
pub inherently_observable: bool,
}
use SemanticTypeV2::*;
const fn input(
name: &'static str,
accepted: TypeConstraintV2,
cardinality: InputCardinalityV2,
role: InputRoleV2,
) -> InputSocketContractV2 {
InputSocketContractV2 {
name,
accepted,
cardinality,
role,
}
}
const fn output(
name: &'static str,
semantic_type: SemanticTypeV2,
metadata: OutputMetadataV2,
) -> OutputSocketContractV2 {
OutputSocketContractV2 {
name,
semantic_type,
metadata,
}
}
const REQUIRED: InputCardinalityV2 = InputCardinalityV2::RequiredOne;
const OPTIONAL: InputCardinalityV2 = InputCardinalityV2::OptionalOne;
const NONE_IN: &[InputSocketContractV2] = &[];
const NONE_OUT: &[OutputSocketContractV2] = &[];
const SURFACE_OUT: &[OutputSocketContractV2] =
&[output("surface", SurfaceTarget, OutputMetadataV2::None)];
const SPEC_OUT: &[OutputSocketContractV2] = &[output("spec", TextureSpec, OutputMetadataV2::None)];
const SCENE_OUT: &[OutputSocketContractV2] = &[output("scene", SceneTable, OutputMetadataV2::None)];
const AABB_OUT: &[OutputSocketContractV2] = &[output(
"localAabbs",
LocalAabbBuffer,
OutputMetadataV2::None,
)];
const FRUSTUM_OUT: &[OutputSocketContractV2] =
&[output("frustum", CameraFrustum, OutputMetadataV2::None)];
const VISIBLE_OUT: &[OutputSocketContractV2] = &[output(
"flags",
BooleanFlagBuffer,
OutputMetadataV2::BooleanFlag {
flag: MeshFlagV2::IsVisible,
},
)];
const CULLED_OUT: &[OutputSocketContractV2] = &[output(
"flags",
BooleanFlagBuffer,
OutputMetadataV2::BooleanFlag {
flag: MeshFlagV2::IsFrustumCulled,
},
)];
const DRAW_OUT: &[OutputSocketContractV2] = &[output("draws", DrawStream, OutputMetadataV2::None)];
const CONFIG_OUT: &[OutputSocketContractV2] =
&[output("config", DepthStencilConfig, OutputMetadataV2::None)];
const FORWARD_OUT: &[OutputSocketContractV2] = &[
output("color", Texture, OutputMetadataV2::None),
output("depth", Texture, OutputMetadataV2::None),
];
const FULLSCREEN_COPY_OUT: &[OutputSocketContractV2] =
&[output("color", Texture, OutputMetadataV2::None)];
const LOCAL_IN: &[InputSocketContractV2] = &[input(
"scene",
TypeConstraintV2::Exact(SceneTable),
REQUIRED,
InputRoleV2::SemanticRead,
)];
const VISIBILITY_IN: &[InputSocketContractV2] = LOCAL_IN;
const CULL_IN: &[InputSocketContractV2] = &[
input(
"scene",
TypeConstraintV2::Exact(SceneTable),
REQUIRED,
InputRoleV2::StorageRead,
),
input(
"localAabbs",
TypeConstraintV2::Exact(LocalAabbBuffer),
REQUIRED,
InputRoleV2::StorageRead,
),
input(
"frustum",
TypeConstraintV2::Exact(CameraFrustum),
REQUIRED,
InputRoleV2::UniformRead,
),
];
const QUERY_IN: &[InputSocketContractV2] = &[
input(
"scene",
TypeConstraintV2::Exact(SceneTable),
REQUIRED,
InputRoleV2::StorageRead,
),
input(
"isVisible",
TypeConstraintV2::Exact(BooleanFlagBuffer),
OPTIONAL,
InputRoleV2::StorageRead,
),
input(
"isFrustumCulled",
TypeConstraintV2::Exact(BooleanFlagBuffer),
OPTIONAL,
InputRoleV2::StorageRead,
),
];
const FORWARD_IN: &[InputSocketContractV2] = &[
input(
"scene",
TypeConstraintV2::Exact(SceneTable),
REQUIRED,
InputRoleV2::SemanticRead,
),
input(
"draws",
TypeConstraintV2::Exact(DrawStream),
REQUIRED,
InputRoleV2::IndirectRead,
),
input(
"colorTarget",
TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
REQUIRED,
InputRoleV2::ColorTarget { location: 0 },
),
input(
"depthTarget",
TypeConstraintV2::OneOf(&[TextureSpec, Texture]),
REQUIRED,
InputRoleV2::DepthTarget,
),
input(
"depthStencil",
TypeConstraintV2::Exact(DepthStencilConfig),
REQUIRED,
InputRoleV2::Configuration,
),
];
const FULLSCREEN_COPY_IN: &[InputSocketContractV2] = &[
input(
"source",
TypeConstraintV2::Exact(Texture),
REQUIRED,
InputRoleV2::SampledTexture,
),
input(
"colorTarget",
TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
REQUIRED,
InputRoleV2::ColorTarget { location: 0 },
),
];
const BLOOM_COMPOSITE_IN: &[InputSocketContractV2] = &[
input(
"source",
TypeConstraintV2::Exact(Texture),
REQUIRED,
InputRoleV2::SampledTexture,
),
input(
"bloom",
TypeConstraintV2::Exact(Texture),
REQUIRED,
InputRoleV2::SampledTexture,
),
input(
"colorTarget",
TypeConstraintV2::OneOf(&[SurfaceTarget, TextureSpec, Texture]),
REQUIRED,
InputRoleV2::ColorTarget { location: 0 },
),
];
const PRESENT_IN: &[InputSocketContractV2] = &[input(
"surface",
TypeConstraintV2::Exact(Texture),
REQUIRED,
InputRoleV2::Present,
)];
pub static CONTRACTS_V2: &[ContractV2] = &[
ContractV2 {
key: "surface_target",
version: 1,
execution: ExecutionClassV2::Source,
inputs: NONE_IN,
outputs: SURFACE_OUT,
inherently_observable: false,
},
ContractV2 {
key: "texture_spec",
version: 1,
execution: ExecutionClassV2::Source,
inputs: NONE_IN,
outputs: SPEC_OUT,
inherently_observable: false,
},
ContractV2 {
key: "scene_table",
version: 1,
execution: ExecutionClassV2::Source,
inputs: NONE_IN,
outputs: SCENE_OUT,
inherently_observable: false,
},
ContractV2 {
key: "local_aabb_buffer",
version: 1,
execution: ExecutionClassV2::Source,
inputs: LOCAL_IN,
outputs: AABB_OUT,
inherently_observable: false,
},
ContractV2 {
key: "camera_frustum",
version: 1,
execution: ExecutionClassV2::Source,
inputs: NONE_IN,
outputs: FRUSTUM_OUT,
inherently_observable: false,
},
ContractV2 {
key: "visibility_flags",
version: 1,
execution: ExecutionClassV2::Source,
inputs: VISIBILITY_IN,
outputs: VISIBLE_OUT,
inherently_observable: false,
},
ContractV2 {
key: "frustum_cull",
version: 1,
execution: ExecutionClassV2::Compute,
inputs: CULL_IN,
outputs: CULLED_OUT,
inherently_observable: false,
},
ContractV2 {
key: "mesh_query",
version: 1,
execution: ExecutionClassV2::Compute,
inputs: QUERY_IN,
outputs: DRAW_OUT,
inherently_observable: false,
},
ContractV2 {
key: "depth_stencil_config",
version: 1,
execution: ExecutionClassV2::Source,
inputs: NONE_IN,
outputs: CONFIG_OUT,
inherently_observable: false,
},
ContractV2 {
key: "legacy_forward",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FORWARD_IN,
outputs: FORWARD_OUT,
inherently_observable: false,
},
ContractV2 {
key: "fullscreen_copy",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "tone_map",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "bloom_extract",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "bloom_blur",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "bloom_composite",
version: 1,
execution: ExecutionClassV2::Render,
inputs: BLOOM_COMPOSITE_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "luminance_edge",
version: 1,
execution: ExecutionClassV2::Render,
inputs: FULLSCREEN_COPY_IN,
outputs: FULLSCREEN_COPY_OUT,
inherently_observable: false,
},
ContractV2 {
key: "present",
version: 1,
execution: ExecutionClassV2::Present,
inputs: PRESENT_IN,
outputs: NONE_OUT,
inherently_observable: true,
},
];
pub fn contract(key: &str) -> Option<&'static ContractV2> {
CONTRACTS_V2.iter().find(|contract| contract.key == key)
}
+34 -1
View File
@@ -1,9 +1,14 @@
//! Device-free V1 render graph compiler and compiled graph registry.
mod compiler;
mod compiler_v2;
mod contracts_v2;
mod plan_v2;
mod registry;
mod runtime;
mod runtime_v2;
mod schema;
mod schema_v2;
pub use compiler::{
compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput,
@@ -11,12 +16,38 @@ pub use compiler::{
ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors,
TextureAllocationKey, TextureUsage, TransientAllocation,
};
pub use registry::{CompiledGraphId, Registry};
pub use compiler_v2::{compile_v2, mesh_predicate_matches, parse_and_compile_v2};
pub use contracts_v2::*;
pub use plan_v2::*;
pub use registry::{CompiledGraphId, RegisteredGraph, Registry};
pub use runtime::{
class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent,
RuntimeTextureKey,
};
pub use runtime_v2::*;
pub use schema::*;
pub use schema_v2::*;
pub fn parse_and_compile_any(bytes: &[u8]) -> Result<RegisteredGraph, GraphError> {
if bytes.len() > MAX_JSON_BYTES {
return Err(GraphError::new(
"GRAPH_PAYLOAD_TOO_LARGE",
"graph payload exceeds 1 MiB",
));
}
let text = std::str::from_utf8(bytes)
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
let value: serde_json::Value = serde_json::from_str(text)
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
match value.get("schemaVersion").and_then(|v| v.as_u64()) {
Some(1) => parse_and_compile(bytes).map(RegisteredGraph::V1),
Some(2) => parse_and_compile_v2(bytes).map(RegisteredGraph::V2),
_ => Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be exactly 1 or 2",
)),
}
}
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
pub const MAX_RESOURCES: usize = 1024;
@@ -56,3 +87,5 @@ impl GraphError {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_v2;
+381
View File
@@ -0,0 +1,381 @@
use serde::Serialize;
use super::*;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledGraphV2 {
pub schema_version: u32,
pub graph_id: String,
pub revision: u32,
pub node_count: u32,
pub resources: Vec<CompiledResourceV2>,
pub executions: Vec<CompiledExecutionV2>,
pub texture_families: Vec<TextureFamilyV2>,
pub allocation_classes: Vec<AllocationClassV2>,
pub culled_node_count: u32,
pub culled_resource_count: u32,
pub transient_slot_count: u32,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledResourceV2 {
pub original_node_index: u32,
pub output_ordinal: u16,
pub origin: NodeOutputRef,
pub semantic_type: SemanticTypeV2,
pub producer_execution: Option<u32>,
pub lifetime: Option<LifetimeV2>,
pub plan: ResourcePlanV2,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ResourcePlanV2 {
SurfaceTarget {
family: u32,
},
TextureSpec {
family: u32,
residency: TextureResidencyV2,
descriptor: NormalizedTextureDescriptorV2,
},
Texture {
family: u32,
version: u32,
target: u32,
initialized: bool,
stored: bool,
allocation: Option<AllocationRefV2>,
},
SceneTable,
LocalAabbBuffer {
scene: u32,
},
CameraFrustum,
BooleanFlagBuffer {
scene: u32,
flag: MeshFlagV2,
},
DrawStream {
scene: u32,
},
DepthStencilConfig {
config: NormalizedDepthStencilV2,
},
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledExecutionV2 {
pub id: String,
pub original_node_index: u32,
pub executor: ExecutorRefV2,
pub parameters: NormalizedParametersV2,
pub kind: ExecutionKindV2,
pub inputs: Vec<CompiledSocketInputV2>,
pub outputs: Vec<CompiledSocketOutputV2>,
pub accesses: Vec<CompiledAccessV2>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledSocketInputV2 {
pub socket: String,
pub resource: u32,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledSocketOutputV2 {
pub socket: String,
pub resource: u32,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExecutionKindV2 {
CpuPreparation,
Compute {
work: ComputeWorkV2,
},
Render {
color_attachments: Vec<ColorAttachmentPlanV2>,
depth_stencil: Option<DepthStencilAttachmentPlanV2>,
},
Present {
surface: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputeWorkV2 {
FrustumCull,
MeshQuery,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorAttachmentPlanV2 {
pub resource: u32,
pub location: u32,
pub load: NormalizedColorLoadV2,
pub store: StoreOpV2,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DepthStencilAttachmentPlanV2 {
pub resource: u32,
pub load: NormalizedDepthLoadV2,
pub store: StoreOpV2,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NormalizedColorLoadV2 {
Load,
Clear { value: [f64; 4] },
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NormalizedDepthLoadV2 {
Load,
Clear { value: f32 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StoreOpV2 {
Store,
Discard,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompiledAccessV2 {
pub socket: String,
pub resource: u32,
pub mode: AccessModeV2,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AccessModeV2 {
SemanticRead,
UniformRead,
StorageRead,
StorageWrite {
full_overwrite: bool,
},
IndirectRead,
SampledTexture,
ColorAttachment {
location: u32,
load: NormalizedColorLoadV2,
store: StoreOpV2,
full_overwrite: bool,
},
DepthAttachment {
load: NormalizedDepthLoadV2,
store: StoreOpV2,
full_overwrite: bool,
},
Present,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NormalizedParametersV2 {
SurfaceTarget,
TextureSpec {
residency: TextureResidencyV2,
texture: NormalizedTextureDescriptorV2,
},
SceneTable,
LocalAabbBuffer,
CameraFrustum,
VisibilityFlags,
FrustumCull,
MeshQuery {
filters: [NormalizedMeshFilterV2; 2],
},
DepthStencilConfig {
config: NormalizedDepthStencilV2,
},
LegacyForward {
clear_color: [f64; 4],
},
FullscreenCopy,
ToneMap {
exposure: f32,
},
BloomExtract {
threshold: f32,
knee: f32,
},
BloomBlur {
direction: [f32; 2],
radius: f32,
},
BloomComposite {
intensity: f32,
},
LuminanceEdge {
strength: f32,
},
Present,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedMeshFilterV2 {
pub flag: MeshFlagV2,
pub predicate: TriStatePredicate,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedDepthStencilV2 {
pub depth_compare: CompareFunctionV2,
pub depth_write_enabled: bool,
pub clear_depth: f32,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NormalizedTextureDescriptorV2 {
pub dimension: TextureDimensionV2,
pub format: TextureFormatV2,
pub extent: NormalizedTextureExtentV2,
pub mip_level_count: u32,
pub sample_count: u32,
pub view_formats: Vec<TextureFormatV2>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum NormalizedTextureExtentV2 {
Absolute {
width: u32,
height: u32,
depth_or_array_layers: u32,
},
SurfaceRelative {
width: RatioV2,
height: RatioV2,
depth_or_array_layers: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LifetimeV2 {
pub first_use: u32,
pub last_use: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextureFamilyKeyV2 {
pub source_node: u32,
pub source_socket: u16,
}
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TextureFamilySourceV2 {
ImportedSurface {
resource: u32,
},
AuthoredTexture {
resource: u32,
residency: TextureResidencyV2,
descriptor: NormalizedTextureDescriptorV2,
},
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextureFamilyV2 {
pub id: u32,
pub key: TextureFamilyKeyV2,
pub source: TextureFamilySourceV2,
pub lifetime: LifetimeV2,
pub versions: Vec<TextureVersionV2>,
pub usage: Vec<TextureUsageV2>,
pub allocation: Option<AllocationRefV2>,
pub aliasable: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextureVersionV2 {
pub version: u32,
pub resource: u32,
pub target: u32,
pub initialized: bool,
pub stored: bool,
pub lifetime: LifetimeV2,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TextureCompatibilityKeyV2 {
pub dimension: TextureDimensionV2,
pub format: TextureFormatV2,
pub extent: NormalizedTextureExtentV2,
pub mip_level_count: u32,
pub sample_count: u32,
pub view_formats: Vec<TextureFormatV2>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AllocationClassV2 {
pub key: TextureCompatibilityKeyV2,
pub slots: Vec<AllocationSlotV2>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AllocationKindV2 {
AliasedTransient,
DedicatedTransient,
Persistent,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AllocationSlotV2 {
pub kind: AllocationKindV2,
pub usage: Vec<TextureUsageV2>,
pub occupants: Vec<u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AllocationRefV2 {
pub class: u32,
pub slot: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TextureUsageV2 {
Sampled,
Storage,
CopySrc,
CopyDst,
ColorAttachment,
DepthAttachment,
}
impl CompiledGraphV2 {
pub fn summary(&self, id: [u32; 2]) -> serde_json::Value {
serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count})
}
}
+52 -24
View File
@@ -1,4 +1,24 @@
use super::{parse_and_compile, CompiledGraph, GraphError};
use super::{parse_and_compile_any, CompiledGraph, CompiledGraphV2, GraphError};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum RegisteredGraph {
V1(CompiledGraph),
V2(CompiledGraphV2),
}
impl RegisteredGraph {
fn identity(&self) -> (&str, u32) {
match self {
Self::V1(g) => (g.graph_id.as_str(), g.revision),
Self::V2(g) => (g.graph_id.as_str(), g.revision),
}
}
fn summary(&self, id: [u32; 2]) -> serde_json::Value {
match self {
Self::V1(g) => g.summary(id),
Self::V2(g) => g.summary(id),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompiledGraphId {
pub slot: u32,
@@ -12,13 +32,14 @@ impl From<CompiledGraphId> for [u32; 2] {
#[derive(Debug)]
struct Slot {
generation: u32,
value: Option<CompiledGraph>,
value: Option<RegisteredGraph>,
retired: bool,
}
#[derive(Debug)]
pub struct Registry {
slots: Vec<Slot>,
capacity: u32,
latest_revisions: HashMap<String, u32>,
}
impl Default for Registry {
fn default() -> Self {
@@ -30,33 +51,24 @@ impl Registry {
Self {
slots: vec![],
capacity,
latest_revisions: HashMap::new(),
}
}
pub fn compile(
&mut self,
bytes: &[u8],
) -> Result<(CompiledGraphId, serde_json::Value), GraphError> {
let graph = parse_and_compile(bytes)?;
if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| {
s.value
.as_ref()
.is_some_and(|g| g.graph_id == graph.graph_id)
}) {
if graph.revision <= s.value.as_ref().unwrap().revision {
return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT",
"revision must increase",
));
}
let id = CompiledGraphId {
slot: u32::try_from(i).map_err(|_| {
GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow")
})?,
generation: s.generation,
};
let summary = graph.summary(id.into());
s.value = Some(graph);
return Ok((id, summary));
let graph = parse_and_compile_any(bytes)?;
let (graph_id, revision) = graph.identity();
if self
.latest_revisions
.get(graph_id)
.is_some_and(|latest| revision <= *latest)
{
return Err(GraphError::new(
"GRAPH_REVISION_CONFLICT",
"revision must increase",
));
}
let i = if let Some(i) = self
.slots
@@ -84,10 +96,26 @@ impl Registry {
generation: self.slots[i].generation,
};
let summary = graph.summary(id.into());
self.latest_revisions.insert(graph_id.to_owned(), revision);
self.slots[i].value = Some(graph);
Ok((id, summary))
}
pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> {
match self
.slots
.get(id.slot as usize)
.filter(|s| s.generation == id.generation)
.and_then(|s| s.value.as_ref())
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?
{
RegisteredGraph::V1(g) => Ok(g),
RegisteredGraph::V2(_) => Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
"schemaVersion 2 activation is unavailable until Phase 4",
)),
}
}
pub fn get_registered(&self, id: CompiledGraphId) -> Result<&RegisteredGraph, GraphError> {
self.slots
.get(id.slot as usize)
.filter(|s| s.generation == id.generation)
@@ -95,7 +123,7 @@ impl Registry {
.ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))
}
pub fn contains(&self, id: CompiledGraphId) -> bool {
self.get(id).is_ok()
self.get_registered(id).is_ok()
}
pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> {
let s = self
+605
View File
@@ -0,0 +1,605 @@
use super::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ResolvedExtentV2 {
pub width: u32,
pub height: u32,
pub depth_or_array_layers: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeTextureDescriptorV2 {
pub dimension: wgpu::TextureDimension,
pub format: wgpu::TextureFormat,
pub extent: ResolvedExtentV2,
pub mip_level_count: u32,
pub sample_count: u32,
pub usage: wgpu::TextureUsages,
pub view_formats: Vec<wgpu::TextureFormat>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSurfaceContractV2 {
pub format: wgpu::TextureFormat,
pub width: u32,
pub height: u32,
pub usage: wgpu::TextureUsages,
pub view_formats: Vec<wgpu::TextureFormat>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAllocationSlotV2 {
pub kind: AllocationKindV2,
pub descriptor: RuntimeTextureDescriptorV2,
pub occupants: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAllocationClassV2 {
pub key: TextureCompatibilityKeyV2,
pub slots: Vec<RuntimeAllocationSlotV2>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshQueryRuntimeKeyV2 {
pub visible: TriStatePredicate,
pub frustum_culled: TriStatePredicate,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeExecutionV2 {
pub execution: u32,
pub executor: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeAllocationPlanV2 {
pub classes: Vec<RuntimeAllocationClassV2>,
pub resource_allocations: Vec<Option<AllocationRefV2>>,
pub surface_family: u32,
pub surface_resource: u32,
pub query: MeshQueryRuntimeKeyV2,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimePlanV2 {
pub allocations: RuntimeAllocationPlanV2,
pub executions: Vec<RuntimeExecutionV2>,
pub surface: RuntimeSurfaceContractV2,
}
fn error(code: &'static str, message: impl Into<String>, path: impl Into<String>) -> GraphError {
GraphError::at(code, message, path)
}
pub const fn texture_dimension_v2(value: TextureDimensionV2) -> wgpu::TextureDimension {
match value {
TextureDimensionV2::D1 => wgpu::TextureDimension::D1,
TextureDimensionV2::D2 => wgpu::TextureDimension::D2,
TextureDimensionV2::D3 => wgpu::TextureDimension::D3,
}
}
pub const fn texture_format_v2(value: TextureFormatV2) -> wgpu::TextureFormat {
match value {
TextureFormatV2::Rgba8Unorm => wgpu::TextureFormat::Rgba8Unorm,
TextureFormatV2::Rgba8UnormSrgb => wgpu::TextureFormat::Rgba8UnormSrgb,
TextureFormatV2::Bgra8Unorm => wgpu::TextureFormat::Bgra8Unorm,
TextureFormatV2::Bgra8UnormSrgb => wgpu::TextureFormat::Bgra8UnormSrgb,
TextureFormatV2::Rgba16Float => wgpu::TextureFormat::Rgba16Float,
TextureFormatV2::R32Float => wgpu::TextureFormat::R32Float,
TextureFormatV2::Depth32Float => wgpu::TextureFormat::Depth32Float,
}
}
pub const fn texture_usage_v2(value: TextureUsageV2) -> wgpu::TextureUsages {
match value {
TextureUsageV2::Sampled => wgpu::TextureUsages::TEXTURE_BINDING,
TextureUsageV2::Storage => wgpu::TextureUsages::STORAGE_BINDING,
TextureUsageV2::CopySrc => wgpu::TextureUsages::COPY_SRC,
TextureUsageV2::CopyDst => wgpu::TextureUsages::COPY_DST,
TextureUsageV2::ColorAttachment | TextureUsageV2::DepthAttachment => {
wgpu::TextureUsages::RENDER_ATTACHMENT
}
}
}
pub fn texture_usages_v2(values: &[TextureUsageV2]) -> wgpu::TextureUsages {
values
.iter()
.fold(wgpu::TextureUsages::empty(), |usage, value| {
usage | texture_usage_v2(*value)
})
}
fn scaled(value: u32, ratio: RatioV2, path: &str) -> Result<u32, GraphError> {
if ratio.denominator == 0 {
return Err(error(
"GRAPH_RESOURCE_LIMIT",
"zero extent denominator",
path,
));
}
let product = u64::from(value)
.checked_mul(u64::from(ratio.numerator))
.ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))?;
let result = product
.checked_add(u64::from(ratio.denominator) - 1)
.ok_or_else(|| error("GRAPH_RESOURCE_LIMIT", "extent arithmetic overflow", path))?
/ u64::from(ratio.denominator);
u32::try_from(result.max(1))
.map_err(|_| error("GRAPH_RESOURCE_LIMIT", "extent exceeds u32", path))
}
pub fn resolve_extent_v2(
extent: &NormalizedTextureExtentV2,
surface: [u32; 2],
) -> Result<ResolvedExtentV2, GraphError> {
let resolved = match extent {
NormalizedTextureExtentV2::Absolute {
width,
height,
depth_or_array_layers,
} => ResolvedExtentV2 {
width: *width,
height: *height,
depth_or_array_layers: *depth_or_array_layers,
},
NormalizedTextureExtentV2::SurfaceRelative {
width,
height,
depth_or_array_layers,
} => ResolvedExtentV2 {
width: scaled(surface[0], *width, "extent.width")?,
height: scaled(surface[1], *height, "extent.height")?,
depth_or_array_layers: *depth_or_array_layers,
},
};
if resolved.width == 0 || resolved.height == 0 || resolved.depth_or_array_layers == 0 {
return Err(error(
"GRAPH_RESOURCE_LIMIT",
"texture extent is zero",
"extent",
));
}
Ok(resolved)
}
pub fn resolved_mip_level_count_v2(extent: ResolvedExtentV2) -> u32 {
32 - extent
.width
.max(extent.height)
.max(extent.depth_or_array_layers)
.leading_zeros()
}
fn validate_limits(
dimension: TextureDimensionV2,
extent: ResolvedExtentV2,
mip_count: u32,
limits: Option<&wgpu::Limits>,
path: &str,
) -> Result<(), GraphError> {
let max_mips = resolved_mip_level_count_v2(extent);
if mip_count == 0 || mip_count > max_mips {
return Err(error(
"GRAPH_RESOURCE_LIMIT",
"invalid mip level count",
path,
));
}
if let Some(l) = limits {
let valid = match dimension {
TextureDimensionV2::D1 => extent.width <= l.max_texture_dimension_1d,
TextureDimensionV2::D2 => {
extent.width <= l.max_texture_dimension_2d
&& extent.height <= l.max_texture_dimension_2d
&& extent.depth_or_array_layers <= l.max_texture_array_layers
}
TextureDimensionV2::D3 => {
extent.width <= l.max_texture_dimension_3d
&& extent.height <= l.max_texture_dimension_3d
&& extent.depth_or_array_layers <= l.max_texture_dimension_3d
}
};
if !valid {
return Err(error(
"GRAPH_RESOURCE_LIMIT",
"texture exceeds device limits",
path,
));
}
}
Ok(())
}
pub fn runtime_texture_descriptor_v2(
key: &TextureCompatibilityKeyV2,
usage: &[TextureUsageV2],
surface: [u32; 2],
limits: Option<&wgpu::Limits>,
) -> Result<RuntimeTextureDescriptorV2, GraphError> {
let extent = resolve_extent_v2(&key.extent, surface)?;
validate_limits(
key.dimension,
extent,
key.mip_level_count,
limits,
"allocationClasses.key",
)?;
Ok(RuntimeTextureDescriptorV2 {
dimension: texture_dimension_v2(key.dimension),
format: texture_format_v2(key.format),
extent,
mip_level_count: key.mip_level_count,
sample_count: key.sample_count,
usage: texture_usages_v2(usage),
view_formats: key
.view_formats
.iter()
.copied()
.map(texture_format_v2)
.collect(),
})
}
fn invalid(message: impl Into<String>, path: impl Into<String>) -> GraphError {
error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
}
pub fn prepare_runtime_plan_v2(
graph: &CompiledGraphV2,
surface: RuntimeSurfaceContractV2,
limits: Option<&wgpu::Limits>,
) -> Result<RuntimePlanV2, GraphError> {
if surface.width == 0 || surface.height == 0 {
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"surface extent is zero",
"surface",
));
}
if !surface
.usage
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
{
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"surface lacks render attachment usage",
"surface.usage",
));
}
let mut present_count = 0;
let mut query = None;
let mut executions = Vec::with_capacity(graph.executions.len());
for (i, execution) in graph.executions.iter().enumerate() {
let path = format!("executions[{i}]");
match execution.executor.key.as_str() {
"mesh_query" => {
let NormalizedParametersV2::MeshQuery { filters } = &execution.parameters else {
return Err(invalid("mesh query parameters mismatch", &path));
};
let key = MeshQueryRuntimeKeyV2 {
visible: filters[0].predicate,
frustum_culled: filters[1].predicate,
};
if query.replace(key).is_some() {
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"multiple draw stream queries",
&path,
));
}
}
"legacy_forward" => {}
"fullscreen_copy" | "tone_map" | "bloom_extract" | "bloom_blur" | "bloom_composite"
| "luminance_edge" => {}
"frustum_cull" => {}
"present" => present_count += 1,
_ => {
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"unsupported execution",
&path,
))
}
}
executions.push(RuntimeExecutionV2 {
execution: u32::try_from(i).map_err(|_| invalid("execution index overflow", &path))?,
executor: execution.executor.key.clone(),
});
}
if present_count != 1 {
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"exactly one present is required",
"executions",
));
}
let query = query.ok_or_else(|| {
error(
"GRAPH_EXECUTION_UNSUPPORTED",
"one mesh query is required",
"executions",
)
})?;
let mut surface_pair = None;
let mut resource_allocations = vec![None; graph.resources.len()];
for (fi, family) in graph.texture_families.iter().enumerate() {
if family.id as usize != fi {
return Err(invalid(
"texture family id does not match index",
format!("textureFamilies[{fi}].id"),
));
}
match &family.source {
TextureFamilySourceV2::ImportedSurface { resource } => {
if family.allocation.is_some()
|| surface_pair.replace((family.id, *resource)).is_some()
{
return Err(invalid(
"invalid imported surface allocation",
format!("textureFamilies[{fi}]"),
));
}
}
TextureFamilySourceV2::AuthoredTexture {
residency,
descriptor,
..
} => {
if !matches!(
residency,
TextureResidencyV2::Transient | TextureResidencyV2::Persistent
) || descriptor.dimension != TextureDimensionV2::D2
|| descriptor.mip_level_count != 1
|| descriptor.sample_count != 1
|| !matches!(
descriptor.extent,
NormalizedTextureExtentV2::Absolute {
depth_or_array_layers: 1,
..
} | NormalizedTextureExtentV2::SurfaceRelative {
depth_or_array_layers: 1,
..
}
)
{
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"unsupported runtime texture descriptor",
format!("textureFamilies[{fi}]"),
));
}
if family.allocation.is_none() {
return Err(invalid(
"authored family has no allocation",
format!("textureFamilies[{fi}].allocation"),
));
}
}
}
for (vi, version) in family.versions.iter().enumerate() {
if version.version as usize != vi {
return Err(invalid(
"texture version does not match index",
format!("textureFamilies[{fi}].versions[{vi}]"),
));
}
let resource = graph
.resources
.get(version.resource as usize)
.ok_or_else(|| {
invalid(
"version resource is out of bounds",
format!("textureFamilies[{fi}].versions[{vi}].resource"),
)
})?;
let ResourcePlanV2::Texture {
family: rf,
version: rv,
allocation,
..
} = &resource.plan
else {
return Err(invalid(
"version resource is not a texture",
format!("resources[{}].plan", version.resource),
));
};
if *rf != family.id || *rv != version.version || *allocation != family.allocation {
return Err(invalid(
"texture resource and family disagree",
format!("resources[{}].plan", version.resource),
));
}
resource_allocations[version.resource as usize] = *allocation;
}
}
let (surface_family, surface_resource) = surface_pair
.ok_or_else(|| invalid("missing imported surface family", "textureFamilies"))?;
// Validate the resource-to-family direction as well; compiled plans are public and may be
// cloned and modified by callers.
for (ri, resource) in graph.resources.iter().enumerate() {
if let ResourcePlanV2::Texture {
family,
version,
allocation,
..
} = resource.plan
{
let family_plan = graph.texture_families.get(family as usize).ok_or_else(|| {
invalid(
"texture resource family is out of bounds",
format!("resources[{ri}].plan.family"),
)
})?;
let family_version = family_plan.versions.get(version as usize).ok_or_else(|| {
invalid(
"texture resource version is out of bounds",
format!("resources[{ri}].plan.version"),
)
})?;
if family_version.resource as usize != ri || allocation != family_plan.allocation {
return Err(invalid(
"texture resource is inconsistent with its family",
format!("resources[{ri}].plan"),
));
}
}
}
let mut classes = Vec::with_capacity(graph.allocation_classes.len());
for (ci, class) in graph.allocation_classes.iter().enumerate() {
let mut slots = Vec::with_capacity(class.slots.len());
for (si, slot) in class.slots.iter().enumerate() {
let allocation = AllocationRefV2 {
class: ci as u32,
slot: si as u32,
};
for &family_id in &slot.occupants {
let family = graph
.texture_families
.get(family_id as usize)
.ok_or_else(|| {
invalid(
"slot occupant is out of bounds",
format!("allocationClasses[{ci}].slots[{si}].occupants"),
)
})?;
if family.allocation != Some(allocation) {
return Err(invalid(
"slot occupant allocation disagrees",
format!("allocationClasses[{ci}].slots[{si}].occupants"),
));
}
let TextureFamilySourceV2::AuthoredTexture { descriptor, .. } = &family.source
else {
return Err(invalid(
"imported family occupies a slot",
format!("allocationClasses[{ci}].slots[{si}]"),
));
};
if descriptor.dimension != class.key.dimension
|| descriptor.format != class.key.format
|| descriptor.extent != class.key.extent
|| descriptor.mip_level_count != class.key.mip_level_count
|| descriptor.sample_count != class.key.sample_count
|| descriptor.view_formats != class.key.view_formats
{
return Err(invalid(
"occupant descriptor does not match class key",
format!("allocationClasses[{ci}].key"),
));
}
}
slots.push(RuntimeAllocationSlotV2 {
kind: slot.kind,
descriptor: runtime_texture_descriptor_v2(
&class.key,
&slot.usage,
[surface.width, surface.height],
limits,
)?,
occupants: slot.occupants.clone(),
});
}
classes.push(RuntimeAllocationClassV2 {
key: class.key.clone(),
slots,
});
}
for (fi, family) in graph.texture_families.iter().enumerate() {
if let Some(allocation) = family.allocation {
let slot = graph
.allocation_classes
.get(allocation.class as usize)
.and_then(|c| c.slots.get(allocation.slot as usize))
.ok_or_else(|| {
invalid(
"family allocation is out of bounds",
format!("textureFamilies[{fi}].allocation"),
)
})?;
if slot.occupants.iter().filter(|&&id| id == family.id).count() != 1 {
return Err(invalid(
"family is not exactly once in allocation occupants",
format!("textureFamilies[{fi}].allocation"),
));
}
}
}
let imported = graph
.resources
.get(surface_resource as usize)
.ok_or_else(|| invalid("surface resource is out of bounds", "textureFamilies"))?;
if !matches!(imported.plan, ResourcePlanV2::SurfaceTarget { family } if family == surface_family)
{
return Err(invalid(
"surface source resource mismatch",
format!("resources[{surface_resource}]"),
));
}
// The imported family may only flow through texture versions and the single present.
for (ri, resource) in graph.resources.iter().enumerate() {
if let ResourcePlanV2::Texture {
family, allocation, ..
} = resource.plan
{
if family == surface_family && allocation.is_some() {
return Err(invalid(
"surface texture has an allocation",
format!("resources[{ri}].plan"),
));
}
}
}
let present = graph
.executions
.iter()
.find(|execution| execution.executor.key == "present")
.ok_or_else(|| invalid("present execution disappeared", "executions"))?;
let ExecutionKindV2::Present { surface: presented } = present.kind else {
return Err(invalid("present execution kind mismatch", "executions"));
};
let presented_resource = graph.resources.get(presented as usize).ok_or_else(|| {
invalid(
"present resource is out of bounds",
"executions.present.surface",
)
})?;
if !matches!(presented_resource.plan, ResourcePlanV2::Texture { family, .. } if family == surface_family)
{
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
"present does not resolve to the imported surface",
"executions.present.surface",
));
}
Ok(RuntimePlanV2 {
allocations: RuntimeAllocationPlanV2 {
classes,
resource_allocations,
surface_family,
surface_resource,
query,
},
executions,
surface,
})
}
pub fn validate_activatable_v2(graph: &CompiledGraphV2) -> Result<(), GraphError> {
let surface = RuntimeSurfaceContractV2 {
format: wgpu::TextureFormat::Bgra8Unorm,
width: 1,
height: 1,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: Vec::new(),
};
prepare_runtime_plan_v2(graph, surface, None).map(|_| ())
}
+153
View File
@@ -0,0 +1,153 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct GraphV2 {
pub schema_version: u32,
pub graph_id: String,
pub revision: u32,
pub nodes: Vec<NodeV2>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeV2 {
pub id: String,
pub state: NodeStateV2,
pub executor: ExecutorRefV2,
pub parameters: serde_json::Value,
pub inputs: BTreeMap<String, NodeOutputRef>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeStateV2 {
Enabled,
Muted,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ExecutorRefV2 {
pub key: String,
pub version: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
pub struct NodeOutputRef {
pub node: String,
pub socket: String,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TextureDimensionV2 {
D1,
D2,
D3,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TextureFormatV2 {
Rgba8Unorm,
Rgba8UnormSrgb,
Bgra8Unorm,
Bgra8UnormSrgb,
Rgba16Float,
R32Float,
Depth32Float,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TextureExtentV2 {
Absolute {
width: u32,
height: u32,
#[serde(rename = "depthOrArrayLayers")]
depth_or_array_layers: u32,
},
SurfaceRelative {
width: RatioV2,
height: RatioV2,
#[serde(rename = "depthOrArrayLayers")]
depth_or_array_layers: u32,
},
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
pub struct RatioV2 {
pub numerator: u32,
pub denominator: u32,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TextureResidencyV2 {
Transient,
Persistent,
History,
Readback,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TextureDescriptorV2 {
pub dimension: TextureDimensionV2,
pub format: TextureFormatV2,
pub extent: TextureExtentV2,
pub mip_level_count: u32,
pub sample_count: u32,
#[serde(default)]
pub view_formats: Vec<TextureFormatV2>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TriStatePredicate {
Any,
RequiredTrue,
RequiredFalse,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
pub struct MeshFilterV2 {
pub flag: MeshFlagV2,
pub predicate: TriStatePredicate,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub enum MeshFlagV2 {
IsVisible,
IsFrustumCulled,
}
impl MeshFlagV2 {
pub const ORDERED: [Self; 2] = [Self::IsVisible, Self::IsFrustumCulled];
pub const fn input_socket(self) -> &'static str {
match self {
Self::IsVisible => "isVisible",
Self::IsFrustumCulled => "isFrustumCulled",
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CompareFunctionV2 {
Never,
Less,
Equal,
LessEqual,
Greater,
NotEqual,
GreaterEqual,
Always,
}
+5 -4
View File
@@ -213,12 +213,13 @@ fn registry_capacity() {
);
}
#[test]
fn registry_revision_replaces_in_place() {
let mut r = Registry::new(1);
fn registry_revision_creates_immutable_handle() {
let mut r = Registry::new(2);
let (a, _) = r.compile(&empty("g", 1)).unwrap();
let (b, _) = r.compile(&empty("g", 2)).unwrap();
assert_eq!(a, b);
assert_eq!(r.get(a).unwrap().revision, 2);
assert_ne!(a, b);
assert_eq!(r.get(a).unwrap().revision, 1);
assert_eq!(r.get(b).unwrap().revision, 2);
}
#[test]
fn registry_revision_conflict() {
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
struct Params { planes: array<vec4<f32>, 6>, count: u32, visible_predicate: u32, frustum_predicate: u32, _pad: u32 }
struct Instance { model: mat4x4<f32>, n0: vec4<f32>, n1: vec4<f32>, n2: vec4<f32> }
struct Aabb { min: vec4<f32>, max: vec4<f32> }
struct Meta { index_count: u32, first_index: u32, base_vertex: i32, instance_index: u32 }
struct Command { index_count: u32, instance_count: u32, first_index: u32, base_vertex: i32, first_instance: u32 }
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> instances: array<Instance>;
@group(0) @binding(2) var<storage, read> bounds: array<Aabb>;
@group(0) @binding(3) var<storage, read> authored_visible: array<u32>;
@group(0) @binding(4) var<storage, read> metadata: array<Meta>;
@group(0) @binding(5) var<storage, read_write> frustum_flags: array<u32>;
@group(0) @binding(6) var<storage, read_write> commands: array<Command>;
@compute @workgroup_size(64)
fn frustum_cull(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= params.count) { return; }
let b = bounds[i];
let center = (b.min.xyz + b.max.xyz) * 0.5;
let extent = (b.max.xyz - b.min.xyz) * 0.5;
let m = instances[i].model;
let wc = (m * vec4<f32>(center, 1.0)).xyz;
let ax = m[0].xyz * extent.x;
let ay = m[1].xyz * extent.y;
let az = m[2].xyz * extent.z;
var inside = 1u;
for (var p = 0u; p < 6u; p++) {
let plane = params.planes[p];
let radius = abs(dot(plane.xyz, ax)) + abs(dot(plane.xyz, ay)) + abs(dot(plane.xyz, az));
if (dot(plane.xyz, wc) + plane.w + radius < 0.0) { inside = 0u; }
}
frustum_flags[i] = 1u - inside;
}
fn matches(value: u32, predicate: u32) -> bool {
return predicate == 0u || (predicate == 1u && value != 0u) || (predicate == 2u && value == 0u);
}
@compute @workgroup_size(64)
fn mesh_query(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= params.count) { return; }
let draw_meta = metadata[i];
var selected = true;
if (params.visible_predicate != 0u) {
selected = matches(authored_visible[i], params.visible_predicate);
}
if (params.frustum_predicate != 0u) {
selected = selected && matches(frustum_flags[i], params.frustum_predicate);
}
commands[i] = Command(draw_meta.index_count, select(0u, 1u, selected), draw_meta.first_index, draw_meta.base_vertex, 0u);
}
@@ -0,0 +1,391 @@
use crate::renderer::{
gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledV1, ActiveCompiledV2,
PipelineLibrary, PreparedExecutionV2,
};
use super::super::scene::Scene;
fn encode_scene<'a, T: Scene>(
pass: &mut wgpu::RenderPass<'a>,
scene: &'a T,
gpu: &'a GpuSceneCache,
pipelines: &'a PipelineLibrary,
materials: &'a MaterialResources,
) {
for (i, bind_group) in scene.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, bind_group, &[]);
}
if let (Some(p), Some(n), Some(u), Some(t), Some(i), Some(inst)) = (
&gpu.positions.buffer,
&gpu.normals.buffer,
&gpu.uvs.buffer,
&gpu.tangents.buffer,
&gpu.indices.buffer,
&gpu.instances.buffer,
) {
pass.set_vertex_buffer(0, p.slice(..));
pass.set_vertex_buffer(1, n.slice(..));
pass.set_vertex_buffer(2, u.slice(..));
pass.set_vertex_buffer(3, inst.slice(..));
pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws {
if !draw.effective_visible {
continue;
}
pass.set_pipeline(pipelines.get_pipeline(draw.pipeline));
if pipelines.requires_material(draw.pipeline) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
}
pass.draw_indexed(
draw.indices.clone(),
draw.base_vertex,
draw.instances.clone(),
);
}
}
}
pub(crate) fn encode_compiled_v2<T: Scene>(
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::TextureView,
active: &ActiveCompiledV2,
scene: &T,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> {
use crate::render_graph::{
ExecutionKindV2, NormalizedColorLoadV2, NormalizedDepthLoadV2, ResourcePlanV2, StoreOpV2,
};
let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> {
let is_surface = active
.graph
.resources
.get(resource as usize)
.is_some_and(|resource| {
matches!(
resource.plan,
ResourcePlanV2::SurfaceTarget { family }
| ResourcePlanV2::Texture { family, .. }
if family == active.runtime.allocations.surface_family
)
});
if is_surface {
return Ok(surface);
}
let a = active
.runtime
.allocations
.resource_allocations
.get(resource as usize)
.copied()
.flatten()
.ok_or("V2 resource has no allocation")?;
active
.textures
.get(a.class as usize)
.and_then(|c| c.get(a.slot as usize))
.map(|s| &s.view)
.ok_or("V2 allocation out of bounds")
};
for (execution_index, prepared) in active.executions.iter().enumerate() {
let profile_id = &active.graph.executions[execution_index].id;
match prepared {
PreparedExecutionV2::FrustumCull => {
gpu.encode_frustum_cull(encoder, profile.as_deref_mut(), profile_id);
}
PreparedExecutionV2::MeshQuery => {
gpu.encode_mesh_query(encoder, profile.as_deref_mut(), profile_id);
}
PreparedExecutionV2::Present => {}
PreparedExecutionV2::Fullscreen {
execution,
bind_group,
pipeline,
..
} => {
let execution = active
.graph
.executions
.get(*execution)
.ok_or("V2 execution out of bounds")?;
let ExecutionKindV2::Render {
color_attachments, ..
} = &execution.kind
else {
return Err("fullscreen is not render");
};
let color = color_attachments
.first()
.ok_or("fullscreen target missing")?;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&execution.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: view(color.resource)?,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: match color.load {
NormalizedColorLoadV2::Load => wgpu::LoadOp::Load,
NormalizedColorLoadV2::Clear { value } => {
wgpu::LoadOp::Clear(wgpu::Color {
r: value[0],
g: value[1],
b: value[2],
a: value[3],
})
}
},
store: if color.store == StoreOpV2::Store {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
},
})],
depth_stencil_attachment: None,
occlusion_query_set: None,
timestamp_writes: profile
.as_deref_mut()
.and_then(|p| p.render_writes(&execution.id)),
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, bind_group, &[]);
pass.draw(0..3, 0..1);
}
PreparedExecutionV2::LegacyForward {
execution,
variants,
} => {
let execution = active
.graph
.executions
.get(*execution)
.ok_or("V2 execution out of bounds")?;
let ExecutionKindV2::Render {
color_attachments,
depth_stencil,
} = &execution.kind
else {
return Err("legacy forward is not render");
};
let color = color_attachments.first().ok_or("legacy color missing")?;
let depth = depth_stencil.as_ref().ok_or("legacy depth missing")?;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&execution.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: view(color.resource)?,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: match color.load {
NormalizedColorLoadV2::Load => wgpu::LoadOp::Load,
NormalizedColorLoadV2::Clear { value } => {
wgpu::LoadOp::Clear(wgpu::Color {
r: value[0],
g: value[1],
b: value[2],
a: value[3],
})
}
},
store: if color.store == StoreOpV2::Store {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: view(depth.resource)?,
depth_ops: Some(wgpu::Operations {
load: match depth.load {
NormalizedDepthLoadV2::Load => wgpu::LoadOp::Load,
NormalizedDepthLoadV2::Clear { value } => {
wgpu::LoadOp::Clear(value)
}
},
store: if depth.store == StoreOpV2::Store {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile
.as_deref_mut()
.and_then(|p| p.render_writes(&execution.id)),
});
for (i, group) in scene.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, group, &[]);
}
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
&gpu.positions.buffer,
&gpu.normals.buffer,
&gpu.uvs.buffer,
&gpu.tangents.buffer,
&gpu.indices.buffer,
&gpu.instances.buffer,
) {
pass.set_vertex_buffer(0, p.slice(..));
pass.set_vertex_buffer(1, n.slice(..));
pass.set_vertex_buffer(2, u.slice(..));
pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws {
let slot = draw.instances.start as u64;
let start = slot
* std::mem::size_of::<crate::renderer::gpu_scene::GpuInstance>() as u64;
pass.set_vertex_buffer(3, inst.slice(start..start + 112));
let key = variants
.iter()
.find(|(base, _)| *base == draw.pipeline)
.map(|x| &x.1)
.ok_or("pipeline variant missing")?;
pass.set_pipeline(key);
if pipelines.requires_material(draw.pipeline) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
}
pass.draw_indexed_indirect(
gpu.indirect_commands
.buffer
.as_ref()
.ok_or("indirect command buffer missing")?,
slot * 20,
);
}
}
}
}
}
Ok(())
}
pub(crate) fn encode_immediate<T: Scene>(
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
scene: &T,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.,
g: 0.,
b: 0.,
a: 1.,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile.and_then(|p| p.render_writes("immediate.forward")),
});
encode_scene(&mut pass, scene, gpu, pipelines, materials);
}
pub(crate) fn encode_compiled_v1<T: Scene>(
encoder: &mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
active: &ActiveCompiledV1,
scene: &T,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
for pass in &active.graph.passes {
let depth_resource = pass
.writes
.iter()
.find(|w| w.binding == "depth")
.unwrap()
.resource as usize;
let allocation = active.graph.resources[depth_resource].allocation.unwrap();
let depth_view =
&active.views[active.class_bases[allocation.class as usize] + allocation.slot as usize];
let color = pass.writes.iter().find(|w| w.binding == "color").unwrap();
let depth = pass.writes.iter().find(|w| w.binding == "depth").unwrap();
let (color_load, color_store) = match &color.access {
crate::render_graph::WriteAccess::ColorAttachment { load, store, .. } => (
match load {
crate::render_graph::ColorLoad::Clear { value } => {
wgpu::LoadOp::Clear(wgpu::Color {
r: value[0],
g: value[1],
b: value[2],
a: value[3],
})
}
crate::render_graph::ColorLoad::Load => wgpu::LoadOp::Load,
},
if matches!(store, crate::render_graph::StoreOp::Store) {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
),
_ => unreachable!(),
};
let (depth_load, depth_store) = match &depth.access {
crate::render_graph::WriteAccess::DepthAttachment { load, store } => (
match load {
crate::render_graph::DepthLoad::Clear { value } => wgpu::LoadOp::Clear(*value),
crate::render_graph::DepthLoad::Load => wgpu::LoadOp::Load,
},
if matches!(store, crate::render_graph::StoreOp::Store) {
wgpu::StoreOp::Store
} else {
wgpu::StoreOp::Discard
},
),
_ => unreachable!(),
};
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(&pass.id),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color_view,
resolve_target: None,
ops: wgpu::Operations {
load: color_load,
store: color_store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: depth_load,
store: depth_store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile
.as_deref_mut()
.and_then(|p| p.render_writes(&pass.id)),
});
encode_scene(&mut render_pass, scene, gpu, pipelines, materials);
}
}
+3
View File
@@ -0,0 +1,3 @@
mod legacy_forward;
pub(super) use legacy_forward::{encode_compiled_v1, encode_compiled_v2, encode_immediate};
@@ -0,0 +1,38 @@
@group(0) @binding(0) var source_texture: texture_2d<f32>;
@group(0) @binding(1) var second_texture: texture_2d<f32>;
@group(0) @binding(2) var linear_clamp: sampler;
struct Parameters { a: vec4<f32>, b: vec4<f32> }
@group(0) @binding(3) var<uniform> parameters: Parameters;
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut {
let positions = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
let p = positions[index];
var out: VertexOut; out.position = vec4(p, 0.0, 1.0); out.uv = p * vec2(0.5, -0.5) + vec2(0.5); return out;
}
fn sample_source(uv: vec2<f32>) -> vec4<f32> { return textureSampleLevel(source_texture, linear_clamp, uv, 0.0); }
@fragment fn fs_copy(in: VertexOut) -> @location(0) vec4<f32> { return sample_source(in.uv); }
fn aces(x: vec3<f32>) -> vec3<f32> {
return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0));
}
fn linear_to_srgb(x: vec3<f32>) -> vec3<f32> {
let low = x * 12.92; let high = 1.055 * pow(x, vec3(1.0 / 2.4)) - vec3(0.055);
return select(high, low, x <= vec3(0.0031308));
}
@fragment fn fs_tone_map(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); return vec4(linear_to_srgb(aces(c.rgb * parameters.a.x)), c.a); }
@fragment fn fs_bloom_extract(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.a.y,0.00001); let soft=clamp((brightness-parameters.a.x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.a.x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0);
}
@fragment fn fs_bloom_blur(in: VertexOut) -> @location(0) vec4<f32> {
let size=vec2<f32>(textureDimensions(source_texture)); let step=parameters.a.xy*parameters.a.z/size;
var c=sample_source(in.uv)*0.227027; c+=sample_source(in.uv+step*1.384615)*0.316216; c+=sample_source(in.uv-step*1.384615)*0.316216; c+=sample_source(in.uv+step*3.230769)*0.070270; c+=sample_source(in.uv-step*3.230769)*0.070270; return c;
}
@fragment fn fs_bloom_composite(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); return vec4(c.rgb+textureSampleLevel(second_texture,linear_clamp,in.uv,0.0).rgb*parameters.a.x,c.a); }
fn luminance(c: vec3<f32>) -> f32 { return dot(c,vec3(0.2126,0.7152,0.0722)); }
@fragment fn fs_luminance_edge(in: VertexOut) -> @location(0) vec4<f32> {
let d=1.0/vec2<f32>(textureDimensions(source_texture)); var gx=0.0; var gy=0.0;
gx += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gx += -2.0*luminance(sample_source(in.uv+d*vec2(-1.0,0.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(1.0,0.0)).rgb); gx += -luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb);
gy += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)-2.0*luminance(sample_source(in.uv+d*vec2(0.0,-1.0)).rgb)-luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gy += luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(0.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb);
let edge=clamp(length(vec2(gx,gy))*parameters.a.x,0.0,1.0); return vec4(vec3(edge),1.0);
}
+470 -58
View File
@@ -1,6 +1,9 @@
use std::mem::size_of;
use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags};
use crate::{
render_data::{MaterialKey, MeshHandle, PipelineKey, RenderFlags},
renderer::scene_frame::SceneFramePlan,
};
use bytemuck::{Pod, Zeroable};
#[repr(C)]
@@ -15,10 +18,38 @@ pub struct GpuInstance {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DrawItem {
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub mesh: MeshHandle,
pub indices: std::ops::Range<u32>,
pub base_vertex: i32,
pub instances: std::ops::Range<u32>,
pub effective_visible: bool,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)]
pub struct GpuLocalAabb {
pub min: [f32; 4],
pub max: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
pub struct DrawSlotMetadata {
pub index_count: u32,
pub first_index: u32,
pub base_vertex: i32,
pub instance_index: u32,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
pub struct DrawIndexedIndirect {
pub index_count: u32,
pub instance_count: u32,
pub first_index: u32,
pub base_vertex: i32,
pub first_instance: u32,
}
#[derive(Default)]
@@ -26,30 +57,60 @@ pub struct GpuScenePlan {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub tangents: Vec<[f32; 4]>,
pub indices: Vec<u32>,
pub instances: Vec<GpuInstance>,
pub draws: Vec<DrawItem>,
pub local_aabbs: Vec<GpuLocalAabb>,
pub effective_visibility: Vec<u32>,
pub draw_metadata: Vec<DrawSlotMetadata>,
pub commands: Vec<DrawIndexedIndirect>,
}
fn visibility_matches(
predicate: crate::render_graph::TriStatePredicate,
mesh: RenderFlags,
instance: RenderFlags,
) -> bool {
let effective = mesh.contains(RenderFlags::VISIBLE) && instance.contains(RenderFlags::VISIBLE);
match predicate {
crate::render_graph::TriStatePredicate::Any => true,
crate::render_graph::TriStatePredicate::RequiredTrue => effective,
crate::render_graph::TriStatePredicate::RequiredFalse => !effective,
}
}
impl GpuScenePlan {
pub fn build(data: &RenderData) -> Result<Self, &'static str> {
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
self::GpuScenePlan::build_with_query(
data,
crate::render_graph::MeshQueryRuntimeKeyV2 {
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
},
)
}
pub fn build_with_query(
data: &SceneFramePlan,
query: crate::render_graph::MeshQueryRuntimeKeyV2,
) -> Result<Self, &'static str> {
let _ = query; // Packing is canonical; predicates are evaluated by the GPU.
let mut plan = Self::default();
let mut meshes: Vec<_> = data
.meshes()
.filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE))
.collect();
meshes.sort_by_key(|(handle, mesh)| {
(mesh.pipeline.get(), handle.slot(), handle.generation())
let mut meshes: Vec<_> = data.meshes.iter().collect();
meshes.sort_by_key(|mesh| {
(
mesh.pipeline.get(),
mesh.material.get(),
mesh.handle.slot(),
mesh.handle.generation(),
)
});
let streams = data.streams();
for (handle, mesh) in meshes {
let mut occurrences: Vec<_> = data
.instances()
.filter(|(_, instance)| {
instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE)
})
for mesh in meshes {
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
.iter()
.map(|&index| &data.occurrences[index])
.collect();
occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
if occurrences.is_empty() {
continue;
}
@@ -59,23 +120,25 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.vertex_count as usize)
.ok_or("vertex range overflow")?;
plan.positions.extend_from_slice(
streams
.positions
data.positions
.get(source_start..source_end)
.ok_or("invalid vertex range")?,
);
plan.normals.extend_from_slice(
streams
.normals
data.normals
.get(source_start..source_end)
.ok_or("invalid normal range")?,
);
plan.uvs.extend_from_slice(
streams
.uvs
data.uvs
.get(source_start..source_end)
.ok_or("invalid uv range")?,
);
plan.tangents.extend_from_slice(
data.tangents
.get(source_start..source_end)
.ok_or("invalid tangent range")?,
);
let index_start =
u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?;
let source_index = mesh.geometry.index_start as usize;
@@ -83,20 +146,24 @@ impl GpuScenePlan {
.checked_add(mesh.geometry.index_count as usize)
.ok_or("index range overflow")?;
plan.indices.extend_from_slice(
data.indices()
data.indices
.get(source_index..source_index_end)
.ok_or("invalid index range")?,
);
let instance_start =
u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?;
for (_, instance) in occurrences {
for instance in occurrences {
let instance_start = u32::try_from(plan.instances.len())
.map_err(|_| "instance start exceeds u32")?;
let m = &instance.model;
let determinant = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2])
- m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
plan.instances.push(GpuInstance {
model: instance.model,
normal_0: [
instance.normal[0][0],
instance.normal[0][1],
instance.normal[0][2],
0.0,
if determinant < 0.0 { -1.0 } else { 1.0 },
],
normal_1: [
instance.normal[1][0],
@@ -111,19 +178,41 @@ impl GpuScenePlan {
0.0,
],
});
let base_vertex =
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
let end = index_start
.checked_add(mesh.geometry.index_count)
.ok_or("draw index range overflow")?;
let effective_visible = mesh.flags.contains(RenderFlags::VISIBLE)
&& instance.flags.contains(RenderFlags::VISIBLE);
plan.local_aabbs.push(GpuLocalAabb {
min: [mesh.aabb.min[0], mesh.aabb.min[1], mesh.aabb.min[2], 0.],
max: [mesh.aabb.max[0], mesh.aabb.max[1], mesh.aabb.max[2], 0.],
});
plan.effective_visibility.push(effective_visible as u32);
plan.draw_metadata.push(DrawSlotMetadata {
index_count: mesh.geometry.index_count,
first_index: index_start,
base_vertex,
instance_index: instance_start,
});
plan.commands.push(DrawIndexedIndirect {
index_count: mesh.geometry.index_count,
instance_count: 0,
first_index: index_start,
base_vertex,
first_instance: 0,
});
plan.draws.push(DrawItem {
pipeline: mesh.pipeline,
material: mesh.material,
mesh: mesh.handle,
indices: index_start..end,
base_vertex,
instances: instance_start..instance_start + 1,
effective_visible,
});
}
plan.draws.push(DrawItem {
pipeline: mesh.pipeline,
mesh: handle,
indices: index_start
..index_start
.checked_add(mesh.geometry.index_count)
.ok_or("draw index range overflow")?,
base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?,
instances: instance_start
..u32::try_from(plan.instances.len())
.map_err(|_| "instance end exceeds u32")?,
});
}
Ok(plan)
}
@@ -148,7 +237,7 @@ pub fn required_buffer_capacity(
Ok(grown.min(maximum))
}
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 5] {
const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4];
[
wgpu::VertexBufferLayout {
@@ -171,6 +260,11 @@ pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &INSTANCE_ATTRIBUTES,
},
wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![10 => Float32x4],
},
]
}
@@ -183,12 +277,45 @@ pub struct BufferSlot {
#[derive(Default)]
pub struct GpuSceneCache {
revision: Option<u64>,
query: Option<crate::render_graph::MeshQueryRuntimeKeyV2>,
pub positions: BufferSlot,
pub normals: BufferSlot,
pub uvs: BufferSlot,
pub tangents: BufferSlot,
pub indices: BufferSlot,
pub instances: BufferSlot,
pub local_aabbs: BufferSlot,
pub effective_visibility: BufferSlot,
pub draw_metadata: BufferSlot,
pub frustum_flags: BufferSlot,
pub indirect_commands: BufferSlot,
pub draws: Vec<DrawItem>,
compute: Option<CullingCompute>,
}
struct CullingCompute {
params: wgpu::Buffer,
bind_group: wgpu::BindGroup,
frustum_pipeline: wgpu::ComputePipeline,
query_pipeline: wgpu::ComputePipeline,
}
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
struct CullingParams {
planes: [[f32; 4]; 6],
count: u32,
visible_predicate: u32,
frustum_predicate: u32,
_pad: u32,
}
fn predicate_code(value: crate::render_graph::TriStatePredicate) -> u32 {
match value {
crate::render_graph::TriStatePredicate::Any => 0,
crate::render_graph::TriStatePredicate::RequiredTrue => 1,
crate::render_graph::TriStatePredicate::RequiredFalse => 2,
}
}
impl GpuSceneCache {
@@ -196,15 +323,34 @@ impl GpuSceneCache {
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
data: &RenderData,
data: &SceneFramePlan,
) -> Result<(), String> {
if self.revision == Some(data.revision()) {
self.upload_with_query(
device,
queue,
data,
crate::render_graph::MeshQueryRuntimeKeyV2 {
visible: crate::render_graph::TriStatePredicate::RequiredTrue,
frustum_culled: crate::render_graph::TriStatePredicate::Any,
},
)
}
pub fn upload_with_query(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
data: &SceneFramePlan,
query: crate::render_graph::MeshQueryRuntimeKeyV2,
) -> Result<(), String> {
if self.revision == Some(data.revision) && self.query == Some(query) {
return Ok(());
}
let plan = GpuScenePlan::build(data).map_err(str::to_owned)?;
let plan = GpuScenePlan::build_with_query(data, query).map_err(str::to_owned)?;
if plan.draws.is_empty() {
self.draws.clear();
self.revision = Some(data.revision());
self.revision = Some(data.revision);
self.query = Some(query);
return Ok(());
}
let maximum = device.limits().max_buffer_size;
@@ -218,18 +364,30 @@ impl GpuSceneCache {
bytes(&plan.positions)?,
bytes(&plan.normals)?,
bytes(&plan.uvs)?,
bytes(&plan.tangents)?,
bytes(&plan.indices)?,
bytes(&plan.instances)?,
bytes(&plan.local_aabbs)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.draw_metadata)?,
bytes(&plan.effective_visibility)?,
bytes(&plan.commands)?,
];
let old = [
self.positions.capacity,
self.normals.capacity,
self.uvs.capacity,
self.tangents.capacity,
self.indices.capacity,
self.instances.capacity,
self.local_aabbs.capacity,
self.effective_visibility.capacity,
self.draw_metadata.capacity,
self.frustum_flags.capacity,
self.indirect_commands.capacity,
];
let mut capacities = [0; 5];
for i in 0..5 {
let mut capacities = [0; 11];
for i in 0..11 {
capacities[i] =
required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?;
}
@@ -237,18 +395,30 @@ impl GpuSceneCache {
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
];
let labels = [
"scene positions",
"scene normals",
"scene uvs",
"scene tangents",
"scene indices",
"scene instances",
"scene local aabbs",
"scene effective visibility",
"scene draw metadata",
"scene frustum flags",
"scene indirect commands",
];
let mut replacements: [Option<wgpu::Buffer>; 5] = Default::default();
for i in 0..5 {
let mut replacements: [Option<wgpu::Buffer>; 11] = Default::default();
for i in 0..11 {
if capacities[i] != old[i] {
replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(labels[i]),
@@ -262,8 +432,14 @@ impl GpuSceneCache {
&mut self.positions,
&mut self.normals,
&mut self.uvs,
&mut self.tangents,
&mut self.indices,
&mut self.instances,
&mut self.local_aabbs,
&mut self.effective_visibility,
&mut self.draw_metadata,
&mut self.frustum_flags,
&mut self.indirect_commands,
];
for (i, slot) in slots.into_iter().enumerate() {
if let Some(buffer) = replacements[i].take() {
@@ -275,15 +451,27 @@ impl GpuSceneCache {
bytemuck::cast_slice(&plan.positions),
bytemuck::cast_slice(&plan.normals),
bytemuck::cast_slice(&plan.uvs),
bytemuck::cast_slice(&plan.tangents),
bytemuck::cast_slice(&plan.indices),
bytemuck::cast_slice(&plan.instances),
bytemuck::cast_slice(&plan.local_aabbs),
bytemuck::cast_slice(&plan.effective_visibility),
bytemuck::cast_slice(&plan.draw_metadata),
bytemuck::cast_slice(&plan.effective_visibility),
bytemuck::cast_slice(&plan.commands),
];
let slots = [
&self.positions,
&self.normals,
&self.uvs,
&self.tangents,
&self.indices,
&self.instances,
&self.local_aabbs,
&self.effective_visibility,
&self.draw_metadata,
&self.frustum_flags,
&self.indirect_commands,
];
for (slot, contents) in slots.into_iter().zip(contents) {
if !contents.is_empty() {
@@ -295,15 +483,205 @@ impl GpuSceneCache {
}
}
self.draws = plan.draws;
self.revision = Some(data.revision());
self.rebuild_compute(device)?;
self.revision = Some(data.revision);
self.query = Some(query);
Ok(())
}
fn rebuild_compute(&mut self, device: &wgpu::Device) -> Result<(), String> {
if self.draws.is_empty() {
self.compute = None;
return Ok(());
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("scene culling compute"),
source: wgpu::ShaderSource::Wgsl(include_str!("culling.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("scene culling layout"),
entries: &(0..7)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: if binding == 0 {
wgpu::BufferBindingType::Uniform
} else {
wgpu::BufferBindingType::Storage {
read_only: binding < 5,
}
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect::<Vec<_>>(),
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("scene culling params"),
size: size_of::<CullingParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let buffers = [
&self.instances,
&self.local_aabbs,
&self.effective_visibility,
&self.draw_metadata,
&self.frustum_flags,
&self.indirect_commands,
];
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
}];
for (i, slot) in buffers.iter().enumerate() {
entries.push(wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: slot
.buffer
.as_ref()
.ok_or("culling buffer absent")?
.as_entire_binding(),
});
}
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scene culling group"),
layout: &layout,
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("scene culling pipeline layout"),
bind_group_layouts: &[&layout],
push_constant_ranges: &[],
});
let pipeline = |entry| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
self.compute = Some(CullingCompute {
params,
bind_group,
frustum_pipeline: pipeline("frustum_cull"),
query_pipeline: pipeline("mesh_query"),
});
Ok(())
}
pub fn write_culling_params(
&self,
queue: &wgpu::Queue,
planes: Option<[[f32; 4]; 6]>,
query: crate::render_graph::MeshQueryRuntimeKeyV2,
) {
if let Some(compute) = &self.compute {
if let Some(planes) = planes {
queue.write_buffer(&compute.params, 0, bytemuck::bytes_of(&planes));
}
let tail = CullingParams {
planes: [[0.0; 4]; 6],
count: self.draws.len() as u32,
visible_predicate: predicate_code(query.visible),
frustum_predicate: predicate_code(query.frustum_culled),
_pad: 0,
};
queue.write_buffer(
&compute.params,
std::mem::offset_of!(CullingParams, count) as u64,
&bytemuck::bytes_of(&tail)[std::mem::offset_of!(CullingParams, count)..],
);
}
}
pub(crate) fn encode_frustum_cull(
&self,
encoder: &mut wgpu::CommandEncoder,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
profile_id: &str,
) {
let Some(c) = &self.compute else { return };
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("frustum_cull"),
timestamp_writes: timestamps,
});
pass.set_pipeline(&c.frustum_pipeline);
pass.set_bind_group(0, &c.bind_group, &[]);
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
}
pub(crate) fn encode_mesh_query(
&self,
encoder: &mut wgpu::CommandEncoder,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
profile_id: &str,
) {
let Some(c) = &self.compute else { return };
let timestamps = profile.and_then(|p| p.compute_writes(profile_id));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("mesh_query"),
timestamp_writes: timestamps,
});
pass.set_pipeline(&c.query_pipeline);
pass.set_bind_group(0, &c.bind_group, &[]);
pass.dispatch_workgroups((self.draws.len() as u32 + 63) / 64, 1, 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
#[test]
fn mesh_query_source_guards_optional_flag_buffer_reads() {
let source = include_str!("culling.wgsl");
let visible_guard = source.find("if (params.visible_predicate != 0u)").unwrap();
let visible_load = source.find("matches(authored_visible[i]").unwrap();
let frustum_guard = source.find("if (params.frustum_predicate != 0u)").unwrap();
let frustum_load = source.find("matches(frustum_flags[i]").unwrap();
assert!(visible_guard < visible_load && frustum_guard < frustum_load);
for binding in 0..=6 {
assert!(source.contains(&format!("@binding({binding})")));
}
}
#[test]
fn effective_visibility_handles_every_mesh_instance_combination() {
use crate::render_graph::TriStatePredicate::{Any, RequiredFalse, RequiredTrue};
for (mesh, instance, effective) in [
(false, false, false),
(false, true, false),
(true, false, false),
(true, true, true),
] {
let flags = |visible| {
if visible {
RenderFlags::VISIBLE
} else {
RenderFlags::NONE
}
};
assert!(visibility_matches(Any, flags(mesh), flags(instance)));
assert_eq!(
visibility_matches(RequiredTrue, flags(mesh), flags(instance)),
effective
);
assert_eq!(
visibility_matches(RequiredFalse, flags(mesh), flags(instance)),
!effective
);
}
}
use crate::render_data::{
MeshCreateInfo, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test]
fn instance_is_112_bytes_and_padding_is_zero() {
assert_eq!(size_of::<GpuInstance>(), 112);
@@ -321,6 +699,28 @@ mod tests {
assert!(required_buffer_capacity(0, 33, 32).is_err());
}
#[test]
fn mirrored_model_stores_negative_determinant_sign_in_padding() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let mut mirrored = IDENTITY_MODEL_TRANSFORM;
mirrored[0][0] = -1.0;
data.create_mesh(MeshCreateInfo {
positions: &[[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]],
normals: &[[0., 0., 1.]; 3],
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(0),
material: MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: mirrored,
})
.unwrap();
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
let plan = GpuScenePlan::build(&frame).unwrap();
assert_eq!(plan.instances[0].normal_0[3], -1.0);
}
#[test]
fn capacity_reuses_and_layout_matches_shader_contract() {
assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16));
assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1));
@@ -330,7 +730,7 @@ mod tests {
.iter()
.map(|layout| layout.array_stride)
.collect::<Vec<_>>(),
[12, 12, 8, 112]
[12, 12, 8, 112, 16]
);
assert_eq!(
layouts[3]
@@ -342,7 +742,7 @@ mod tests {
);
}
#[test]
fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() {
fn plan_is_canonical_and_predicate_independent() {
let mut data = RenderData::new(RenderDataConfig {
initial_vertices: 0,
initial_indices: 0,
@@ -359,9 +759,11 @@ mod tests {
data.create_mesh(MeshCreateInfo {
positions: &p,
normals: &n,
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &u,
indices: &i,
pipeline: PipelineKey::new(pipeline),
material: crate::render_data::MaterialKey::DEFAULT,
flags: RenderFlags::VISIBLE,
default_instance_flags: if visible {
RenderFlags::VISIBLE
@@ -377,18 +779,28 @@ mod tests {
let low = add(2, true);
data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
let plan = GpuScenePlan::build(&data).unwrap();
let frame = crate::renderer::scene_frame::SceneFramePlan::build(&data).unwrap();
let plan = GpuScenePlan::build(&frame).unwrap();
assert_eq!(
plan.draws
.iter()
.map(|d| d.pipeline.get())
.collect::<Vec<_>>(),
vec![2, 9]
vec![0, 2, 2, 9]
);
assert_eq!(plan.draws[0].base_vertex, 0);
assert!(!plan.draws[0].effective_visible);
assert_eq!(plan.draws[1].base_vertex, 3);
assert_eq!(plan.draws[0].instances, 0..2);
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]);
assert_eq!(high.mesh, plan.draws[1].mesh);
assert_eq!(plan.draws[1].instances, 1..2);
assert_eq!(plan.draws[2].instances, 2..3);
assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2, 0, 1, 2]);
assert_eq!(high.mesh, plan.draws[3].mesh);
assert_eq!(size_of::<GpuLocalAabb>(), 32);
assert_eq!(size_of::<DrawSlotMetadata>(), 16);
assert_eq!(size_of::<DrawIndexedIndirect>(), 20);
assert!(plan
.commands
.iter()
.all(|command| command.first_instance == 0));
}
}
+597
View File
@@ -0,0 +1,597 @@
use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use image::DynamicImage;
use wgpu::util::DeviceExt;
use crate::{
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference},
render_data::MaterialKey,
};
const BASE: u32 = 1 << 0;
const MR: u32 = 1 << 1;
const NORMAL: u32 = 1 << 2;
const OCCLUSION: u32 = 1 << 3;
const EMISSIVE: u32 = 1 << 4;
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct GpuMaterial {
pub base_color_factor: [f32; 4],
pub emissive_factor: [f32; 4],
pub surface_factors: [f32; 4],
pub alpha_optics: [f32; 4],
pub flags: [u32; 4],
pub uv_sets: [u32; 4],
/// Internal shader diagnostics; zero means the normal shaded view.
pub debug_extras: [u32; 4],
}
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
}
impl From<&Material> for GpuMaterial {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE)
| enabled(value.metallic_roughness_texture, MR)
| enabled(value.normal_texture, NORMAL)
| enabled(value.occlusion_texture, OCCLUSION)
| enabled(value.emissive_texture, EMISSIVE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |x| x.tex_coord),
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
value.normal_texture.map_or(0, |x| x.tex_coord),
value.occlusion_texture.map_or(0, |x| x.tex_coord),
],
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MaterialError {
#[error("external image sources are unsupported")]
ExternalImage,
#[error("unsupported image MIME type: {0}")]
Mime(String),
#[error("image decode failed: {0}")]
Decode(#[from] image::ImageError),
#[error("invalid texture, image, or sampler index")]
InvalidReference,
#[error("invalid decoded RGBA image: {0}")]
InvalidRgba(&'static str),
}
pub(super) struct PreparedMaterials {
groups: HashMap<MaterialKey, wgpu::BindGroup>,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
}
pub struct MaterialResources {
pub layout: wgpu::BindGroupLayout,
groups: HashMap<MaterialKey, wgpu::BindGroup>,
fallback: wgpu::BindGroup,
fallback_views: Vec<wgpu::TextureView>,
fallback_sampler: wgpu::Sampler,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
pub asset_epoch: u64,
}
fn layout_entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty,
count: None,
}
}
fn upload_rgba(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
width: u32,
height: u32,
bytes_per_row: u32,
rgba: &[u8],
) -> (wgpu::Texture, [wgpu::TextureView; 2]) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
});
queue.write_texture(
texture.as_image_copy(),
rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
let linear = texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(wgpu::TextureFormat::Rgba8Unorm),
..Default::default()
});
let srgb = texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(wgpu::TextureFormat::Rgba8UnormSrgb),
..Default::default()
});
(texture, [linear, srgb])
}
fn normalize_rgba(image: DynamicImage) -> (u32, u32, Vec<u8>) {
let rgba = image.into_rgba8();
(rgba.width(), rgba.height(), rgba.into_raw())
}
fn validate_decoded_rgba(
width: u32,
height: u32,
rgba: &[u8],
max_dimension: u32,
) -> Result<u32, MaterialError> {
if width == 0 || height == 0 {
return Err(MaterialError::InvalidRgba("dimensions must be nonzero"));
}
if width > max_dimension || height > max_dimension {
return Err(MaterialError::InvalidRgba("dimensions exceed device limit"));
}
let bytes_per_row = width
.checked_mul(4)
.ok_or(MaterialError::InvalidRgba("row byte count overflows"))?;
let total = bytes_per_row
.checked_mul(height)
.ok_or(MaterialError::InvalidRgba("total byte count overflows"))?;
let total = usize::try_from(total)
.map_err(|_| MaterialError::InvalidRgba("total byte count exceeds usize"))?;
if rgba.len() != total {
return Err(MaterialError::InvalidRgba("pixel byte length is not exact"));
}
Ok(bytes_per_row)
}
fn slot_uses_srgb(slot: usize) -> bool {
matches!(slot, 0 | 4)
}
fn address(value: &str) -> wgpu::AddressMode {
match value {
"ClampToEdge" => wgpu::AddressMode::ClampToEdge,
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat,
"Repeat" => wgpu::AddressMode::Repeat,
_ => unreachable!("gltf crate returned unknown wrap"),
}
}
fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescriptor<'static> {
let (mag, min, mip, s, t) = metadata.map_or(
(
wgpu::FilterMode::Linear,
wgpu::FilterMode::Linear,
wgpu::FilterMode::Linear,
wgpu::AddressMode::Repeat,
wgpu::AddressMode::Repeat,
),
|m| {
let mag = match m.mag_filter.as_deref() {
Some("Nearest") => wgpu::FilterMode::Nearest,
Some("Linear") | None => wgpu::FilterMode::Linear,
_ => unreachable!(),
};
let (min, mip) = match m.min_filter.as_deref() {
Some("Nearest") => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest),
Some("Linear") => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
Some("NearestMipmapNearest") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
}
Some("LinearMipmapNearest") => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
}
Some("NearestMipmapLinear") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
}
Some("LinearMipmapLinear") | None => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
}
_ => unreachable!(),
};
(mag, min, mip, address(&m.wrap_s), address(&m.wrap_t))
},
);
wgpu::SamplerDescriptor {
address_mode_u: s,
address_mode_v: t,
mag_filter: mag,
min_filter: min,
mipmap_filter: mip,
..Default::default()
}
}
impl MaterialResources {
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let mut entries = vec![layout_entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(112),
},
)];
for binding in 1..=5 {
entries.push(layout_entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
));
}
for binding in 6..=10 {
entries.push(layout_entry(
binding,
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
));
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("glTF material group 2"),
entries: &entries,
});
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
let mut fallback_textures = Vec::new();
let mut fallback_views = Vec::new();
for color in colors {
let (t, v) = upload_rgba(device, queue, "neutral material texture", 1, 1, 4, &color);
fallback_views.push(v[0].clone());
fallback_textures.push(t);
}
let fallback_sampler = device.create_sampler(&sampler_descriptor(None));
let fallback = Self::make_group(
device,
&layout,
&Material::default(),
[
&fallback_views[0],
&fallback_views[0],
&fallback_views[1],
&fallback_views[0],
&fallback_views[2],
],
[&fallback_sampler; 5],
);
Self {
layout,
groups: HashMap::new(),
fallback,
fallback_views,
fallback_sampler,
textures: fallback_textures,
views: Vec::new(),
samplers: Vec::new(),
asset_epoch: 0,
}
}
fn make_group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
material: &Material,
views: [&wgpu::TextureView; 5],
samplers: [&wgpu::Sampler; 5],
) -> wgpu::BindGroup {
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("material uniform"),
contents: bytemuck::bytes_of(&GpuMaterial::from(material)),
usage: wgpu::BufferUsages::UNIFORM,
});
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}];
for (i, view) in views.into_iter().enumerate() {
entries.push(wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(view),
});
}
for (i, sampler) in samplers.into_iter().enumerate() {
entries.push(wgpu::BindGroupEntry {
binding: i as u32 + 6,
resource: wgpu::BindingResource::Sampler(sampler),
});
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("material bind group"),
layout,
entries: &entries,
})
}
pub(super) fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene: &ImportedScene,
) -> Result<PreparedMaterials, MaterialError> {
let max_dimension = device.limits().max_texture_dimension_2d;
let mut textures = Vec::with_capacity(scene.images.len());
let mut views = Vec::with_capacity(scene.images.len());
for image in &scene.images {
if !matches!(image.source, ImageSource::BufferView(_)) {
return Err(MaterialError::ExternalImage);
}
let format = match image.mime_type.as_deref() {
Some("image/png") => image::ImageFormat::Png,
Some("image/jpeg") => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
};
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
let (width, height, rgba) = normalize_rgba(decoded);
let bytes_per_row = validate_decoded_rgba(width, height, &rgba, max_dimension)?;
let (texture, image_views) = upload_rgba(
device,
queue,
"glTF embedded image",
width,
height,
bytes_per_row,
&rgba,
);
drop(rgba);
textures.push(texture);
views.push(image_views);
}
let mut samplers = Vec::new();
for sampler in &scene.samplers {
samplers.push(device.create_sampler(&sampler_descriptor(Some(sampler))));
}
let default_sampler = device.create_sampler(&sampler_descriptor(None));
samplers.push(default_sampler);
let default_index = samplers.len() - 1;
let mut groups = HashMap::new();
for material in &scene.materials {
let refs = [
material.base_color_texture,
material.metallic_roughness_texture,
material.normal_texture,
material.occlusion_texture,
material.emissive_texture,
];
let mut selected_views = [
&self.fallback_views[0],
&self.fallback_views[0],
&self.fallback_views[1],
&self.fallback_views[0],
&self.fallback_views[2],
];
let mut selected_samplers = [&self.fallback_sampler; 5];
for (slot, reference) in refs.into_iter().enumerate() {
let Some(reference) = reference.filter(|r| r.tex_coord == 0) else {
continue;
};
let texture = scene
.textures
.get(reference.texture)
.ok_or(MaterialError::InvalidReference)?;
selected_views[slot] = &views
.get(texture.image)
.ok_or(MaterialError::InvalidReference)?[usize::from(slot_uses_srgb(slot))];
let sampler_index = texture.sampler.unwrap_or(default_index);
selected_samplers[slot] = samplers
.get(sampler_index)
.ok_or(MaterialError::InvalidReference)?;
}
groups.insert(
material.key,
Self::make_group(
device,
&self.layout,
material,
selected_views,
selected_samplers,
),
);
}
Ok(PreparedMaterials {
groups,
textures,
views,
samplers,
})
}
pub(super) fn install(&mut self, prepared: PreparedMaterials, asset_epoch: u64) {
self.groups = prepared.groups;
self.textures = prepared.textures;
self.views = prepared.views;
self.samplers = prepared.samplers;
self.asset_epoch = asset_epoch;
}
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
self.groups.get(&key).unwrap_or(&self.fallback)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schlick(f0: f32, cosine: f32) -> f32 {
f0 + (1.0 - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
}
fn ggx_d(n_h: f32, alpha: f32) -> f32 {
let n_h = n_h.clamp(0.0, 1.0);
let alpha2 = alpha * alpha;
let n_h2 = n_h * n_h;
let q = (1.0 - n_h2) + alpha2 * n_h2;
alpha2 / (std::f32::consts::PI * q * q)
}
fn smith_v(n_v: f32, n_l: f32, alpha: f32) -> f32 {
let alpha2 = alpha * alpha;
let gv = n_l * (n_v * n_v * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
let gl = n_v * (n_l * n_l * (1.0 - alpha2) + alpha2).max(0.0).sqrt();
0.5 / (gv + gl).max(1e-6)
}
#[test]
fn pbr_reference_equations_have_known_limits_and_stay_finite() {
assert!((schlick(0.04, 1.0) - 0.04).abs() < 1e-6);
assert!((schlick(0.04, 0.0) - 1.0).abs() < 1e-6);
assert!((ggx_d(0.0, 1.0) - std::f32::consts::FRAC_1_PI).abs() < 1e-6);
let expected_quarter = 16.0 / std::f32::consts::PI;
for actual in [ggx_d(1.0, 0.25), ggx_d(1.0 + f32::EPSILON, 0.25)] {
assert!((actual - expected_quarter).abs() / expected_quarter < 1e-6);
}
for value in [ggx_d(1.0, 0.045 * 0.045), smith_v(0.0, 0.0, 0.002025)] {
assert!(value.is_finite() && value >= 0.0);
}
let roughness_floor_peak = ggx_d(1.0, 0.045 * 0.045);
let expected = 1.0 / (std::f32::consts::PI * 0.045_f32.powi(4));
assert!((roughness_floor_peak - expected).abs() / expected < 1e-6);
assert!((roughness_floor_peak - 77_624.0).abs() / 77_624.0 < 1e-4);
}
#[test]
fn material_uniform_is_112_bytes() {
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112);
}
#[test]
fn texcoord_one_disables_slot() {
let mut m = Material::default();
m.base_color_texture = Some(TextureReference {
texture: 0,
tex_coord: 1,
});
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0);
}
#[test]
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
let mut m = Material::default();
m.ior = 2.0;
m.double_sided = true;
m.normal_texture = Some(TextureReference {
texture: 4,
tex_coord: 0,
});
let gpu = GpuMaterial::from(&m);
assert_eq!(gpu.alpha_optics[2], 2.0);
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
assert_eq!(gpu.flags[0] & NORMAL, NORMAL);
assert_eq!(gpu.flags[1], 1);
assert_eq!(gpu.uv_sets[2], 0);
}
#[test]
fn explicit_ior_sentinel_packs_unit_f0() {
let mut material = Material::default();
material.ior = 0.0;
let gpu = GpuMaterial::from(&material);
assert_eq!(gpu.alpha_optics[2], 0.0);
assert_eq!(gpu.alpha_optics[3], 1.0);
}
#[test]
fn sampler_translation_is_exact() {
let m = SamplerMetadata {
index: 0,
mag_filter: Some("Nearest".into()),
min_filter: Some("LinearMipmapNearest".into()),
wrap_s: "ClampToEdge".into(),
wrap_t: "MirroredRepeat".into(),
};
let d = sampler_descriptor(Some(&m));
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
assert_eq!(d.mipmap_filter, wgpu::FilterMode::Nearest);
assert_eq!(d.address_mode_u, wgpu::AddressMode::ClampToEdge);
assert_eq!(d.address_mode_v, wgpu::AddressMode::MirrorRepeat);
}
#[test]
fn rgb_and_luma_normalize_to_rgba() {
let (_, _, rgb) = normalize_rgba(DynamicImage::ImageRgb8(
image::RgbImage::from_raw(1, 1, vec![1, 2, 3]).unwrap(),
));
assert_eq!(rgb, [1, 2, 3, 255]);
let (_, _, luma) = normalize_rgba(DynamicImage::ImageLuma8(
image::GrayImage::from_raw(1, 1, vec![7]).unwrap(),
));
assert_eq!(luma, [7, 7, 7, 255]);
}
#[test]
fn odd_width_layout_is_tight() {
let width = 3;
assert_eq!(width * 4, 12);
}
#[test]
fn decoded_rgba_validation_rejects_dimensions_overflow_and_wrong_length() {
assert!(validate_decoded_rgba(0, 1, &[], 4096).is_err());
assert!(validate_decoded_rgba(4097, 1, &[], 4096).is_err());
assert!(validate_decoded_rgba(u32::MAX, 2, &[], u32::MAX).is_err());
assert!(validate_decoded_rgba(2, 2, &[0; 15], 4096).is_err());
assert_eq!(validate_decoded_rgba(3, 2, &[0; 24], 4096).unwrap(), 12);
}
#[test]
fn only_color_roles_use_srgb_views() {
assert_eq!(
(0..5).map(slot_uses_srgb).collect::<Vec<_>>(),
[true, false, false, false, true]
);
}
}
+1155 -233
View File
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
use std::{collections::HashMap, num::NonZeroU32};
use crate::render_data::PipelineKey;
use super::DEPTH_FORMAT;
/// Identity of a set of bind-group layouts registered with this library.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub struct PipelineLayoutKey(u64);
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct OwnedVertexBufferLayout {
pub array_stride: u64,
pub step_mode: wgpu::VertexStepMode,
pub attributes: Vec<wgpu::VertexAttribute>,
}
#[derive(Clone, Debug)]
pub struct OwnedProgrammableStage {
pub shader_source: String,
pub entry_point: String,
pub constants: Vec<(String, f64)>,
pub zero_initialize_workgroup_memory: bool,
}
#[derive(Clone, Debug)]
pub struct RenderPipelineSpec {
pub layout: Option<PipelineLayoutKey>,
pub vertex: OwnedProgrammableStage,
pub vertex_layouts: Vec<OwnedVertexBufferLayout>,
pub fragment: Option<OwnedProgrammableStage>,
pub primitive: wgpu::PrimitiveState,
pub depth_stencil: Option<wgpu::DepthStencilState>,
pub multisample: wgpu::MultisampleState,
pub targets: Vec<Option<wgpu::ColorTargetState>>,
pub multiview: Option<NonZeroU32>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct StageKey {
shader_source: String,
entry_point: String,
constants: Vec<(String, u64)>,
zero_initialize_workgroup_memory: bool,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct RenderPipelineKey {
layout: Option<PipelineLayoutKey>,
vertex: StageKey,
vertex_layouts: Vec<OwnedVertexBufferLayout>,
fragment: Option<StageKey>,
primitive: wgpu::PrimitiveState,
depth_stencil: Option<wgpu::DepthStencilState>,
multisample: wgpu::MultisampleState,
targets: Vec<Option<wgpu::ColorTargetState>>,
multiview: Option<NonZeroU32>,
}
impl StageKey {
fn from_stage(stage: &OwnedProgrammableStage) -> Self {
let mut constants: Vec<_> = stage
.constants
.iter()
.map(|(name, value)| (name.clone(), value.to_bits()))
.collect();
constants.sort_by(|a, b| a.0.cmp(&b.0));
Self {
shader_source: stage.shader_source.clone(),
entry_point: stage.entry_point.clone(),
constants,
zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory,
}
}
}
impl RenderPipelineSpec {
fn key(&self) -> RenderPipelineKey {
RenderPipelineKey {
layout: self.layout,
vertex: StageKey::from_stage(&self.vertex),
vertex_layouts: self.vertex_layouts.clone(),
fragment: self.fragment.as_ref().map(StageKey::from_stage),
primitive: self.primitive,
depth_stencil: self.depth_stencil.clone(),
multisample: self.multisample,
targets: self.targets.clone(),
multiview: self.multiview,
}
}
}
fn target_variant_spec(
mut spec: RenderPipelineSpec,
color_format: wgpu::TextureFormat,
depth_format: Option<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> RenderPipelineSpec {
spec.targets = vec![Some(wgpu::ColorTargetState {
format: color_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
spec.depth_stencil = depth_format.map(|format| wgpu::DepthStencilState {
format,
depth_write_enabled: depth_write,
depth_compare,
stencil: Default::default(),
bias: Default::default(),
});
spec
}
pub struct PipelineLibrary {
pipelines: Vec<wgpu::RenderPipeline>,
specs: Vec<RenderPipelineSpec>,
layout_bindings: HashMap<PipelineLayoutKey, Vec<wgpu::BindGroupLayout>>,
pipeline_layouts: HashMap<PipelineLayoutKey, wgpu::PipelineLayout>,
default_layout: Option<PipelineLayoutKey>,
material_layout: Option<PipelineLayoutKey>,
next_layout: u64,
pipeline_registry: HashMap<String, (PipelineKey, RenderPipelineKey)>,
descriptor_cache: HashMap<RenderPipelineKey, PipelineKey>,
}
impl PipelineLibrary {
pub fn new() -> Self {
Self {
pipelines: Vec::new(),
specs: Vec::new(),
layout_bindings: HashMap::new(),
pipeline_layouts: HashMap::new(),
default_layout: None,
material_layout: None,
next_layout: 0,
pipeline_registry: HashMap::new(),
descriptor_cache: HashMap::new(),
}
}
/// Registers a new default layout identity. Existing pipelines retain their layout.
pub fn set_bind_group_layouts(
&mut self,
layouts: &[wgpu::BindGroupLayout; 2],
) -> PipelineLayoutKey {
let key = PipelineLayoutKey(self.next_layout);
self.next_layout = self
.next_layout
.checked_add(1)
.expect("pipeline layout key overflow");
self.layout_bindings.insert(key, layouts.to_vec());
self.default_layout = Some(key);
key
}
/// Registers the glTF-only layout, preserving scene groups at 0 and 1.
pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) {
let base = self
.default_layout
.expect("scene layouts must be registered first");
let mut layouts = self.layout_bindings[&base].clone();
layouts.push(layout.clone());
let key = PipelineLayoutKey(self.next_layout);
self.next_layout += 1;
self.layout_bindings.insert(key, layouts);
self.material_layout = Some(key);
}
fn compatibility_spec(
&self,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
) -> RenderPipelineSpec {
let (vertex_entry, fragment_entry) = if name == "triangle_colored" {
("v_main", "f_main")
} else {
("vs_main", "fs_main")
};
let stage = |entry: &str| OwnedProgrammableStage {
shader_source: shader.to_owned(),
entry_point: entry.to_owned(),
constants: Vec::new(),
zero_initialize_workgroup_memory: true,
};
RenderPipelineSpec {
layout: if name.starts_with("gltf_") {
self.material_layout.or(self.default_layout)
} else {
self.default_layout
},
vertex: stage(vertex_entry),
vertex_layouts: layouts
.iter()
.map(|layout| OwnedVertexBufferLayout {
array_stride: layout.array_stride,
step_mode: layout.step_mode,
attributes: layout.attributes.to_vec(),
})
.collect(),
fragment: Some(stage(fragment_entry)),
primitive: wgpu::PrimitiveState {
cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
targets: vec![Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
multiview: None,
}
}
/// Creates or reuses a pipeline solely by its owned descriptor identity.
pub fn get_or_create_from_spec(
&mut self,
device: &wgpu::Device,
spec: &RenderPipelineSpec,
label: Option<&str>,
) -> PipelineKey {
let key = spec.key();
if let Some(pipeline) = self.descriptor_cache.get(&key) {
return *pipeline;
}
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label,
source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()),
});
let fragment_shader = spec.fragment.as_ref().map(|stage| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label,
source: wgpu::ShaderSource::Wgsl(stage.shader_source.as_str().into()),
})
});
let vertex_constants: Vec<_> = spec
.vertex
.constants
.iter()
.map(|(name, value)| (name.as_str(), *value))
.collect();
let fragment_constants: Option<Vec<_>> = spec.fragment.as_ref().map(|stage| {
stage
.constants
.iter()
.map(|(name, value)| (name.as_str(), *value))
.collect()
});
let vertex_layouts: Vec<_> = spec
.vertex_layouts
.iter()
.map(|layout| wgpu::VertexBufferLayout {
array_stride: layout.array_stride,
step_mode: layout.step_mode,
attributes: &layout.attributes,
})
.collect();
let layout = spec.layout.map(|layout_key| {
if !self.pipeline_layouts.contains_key(&layout_key) {
let bindings = self
.layout_bindings
.get(&layout_key)
.unwrap_or_else(|| panic!("unregistered pipeline layout key {layout_key:?}"));
let refs: Vec<_> = bindings.iter().collect();
self.pipeline_layouts.insert(
layout_key,
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label,
bind_group_layouts: &refs,
push_constant_ranges: &[],
}),
);
}
self.pipeline_layouts.get(&layout_key).unwrap()
});
let fragment = spec.fragment.as_ref().map(|stage| wgpu::FragmentState {
module: fragment_shader.as_ref().unwrap(),
entry_point: Some(&stage.entry_point),
compilation_options: wgpu::PipelineCompilationOptions {
constants: fragment_constants.as_ref().unwrap(),
zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory,
},
targets: &spec.targets,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label,
layout,
vertex: wgpu::VertexState {
module: &vertex_shader,
entry_point: Some(&spec.vertex.entry_point),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &vertex_constants,
zero_initialize_workgroup_memory: spec.vertex.zero_initialize_workgroup_memory,
},
buffers: &vertex_layouts,
},
primitive: spec.primitive,
depth_stencil: spec.depth_stencil.clone(),
multisample: spec.multisample,
fragment,
multiview: spec.multiview,
cache: None,
});
let pipeline_key = PipelineKey::new(self.pipelines.len() as u32);
self.pipelines.push(pipeline);
self.specs.push(spec.clone());
self.descriptor_cache.insert(key, pipeline_key);
pipeline_key
}
pub fn create_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
) -> Result<PipelineKey, String> {
let spec = self.compatibility_spec(name, layouts, shader, format);
let descriptor = spec.key();
if let Some((_, existing)) = self.pipeline_registry.get(name) {
return Err(if existing == &descriptor {
format!("Pipeline '{name}' already exists")
} else {
format!("Pipeline '{name}' already exists with a different descriptor")
});
}
let key = self.get_or_create_from_spec(device, &spec, Some(name));
self.pipeline_registry
.insert(name.to_owned(), (key, descriptor));
Ok(key)
}
pub fn find_pipeline(&self, name: &str) -> Option<PipelineKey> {
self.pipeline_registry.get(name).map(|v| v.0)
}
pub fn get_or_create_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
) -> PipelineKey {
let wanted = self.compatibility_spec(name, layouts, shader, format).key();
if let Some((key, existing)) = self.pipeline_registry.get(name) {
assert_eq!(
existing, &wanted,
"Pipeline '{name}' requested with a different descriptor"
);
return *key;
}
self.create_pipeline(device, name, layouts, shader, format)
.unwrap_or_else(|e| panic!("Failed to create pipeline '{name}': {e}"))
}
pub fn get_pipeline(&self, key: PipelineKey) -> &wgpu::RenderPipeline {
&self.pipelines[key.get() as usize]
}
pub fn requires_material(&self, key: PipelineKey) -> bool {
self.material_layout.is_some()
&& self.specs[key.get() as usize].layout == self.material_layout
}
pub fn pipeline_keys(&self) -> impl Iterator<Item = PipelineKey> + '_ {
let mut keys = self
.pipeline_registry
.values()
.map(|entry| entry.0)
.collect::<Vec<_>>();
keys.sort_by_key(|key| key.get());
keys.dedup();
keys.into_iter()
}
pub fn get_or_create_target_variant(
&mut self,
device: &wgpu::Device,
base: PipelineKey,
color_format: wgpu::TextureFormat,
depth_format: Option<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> Result<PipelineKey, String> {
let spec = self
.specs
.get(base.get() as usize)
.cloned()
.ok_or_else(|| "unknown base pipeline".to_owned())?;
let spec =
target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write);
Ok(self.get_or_create_from_spec(device, &spec, Some("target variant")))
}
pub fn create_target_variant(
&self,
device: &wgpu::Device,
base: PipelineKey,
color_format: wgpu::TextureFormat,
depth_format: Option<wgpu::TextureFormat>,
depth_compare: wgpu::CompareFunction,
depth_write: bool,
) -> Result<wgpu::RenderPipeline, String> {
let spec = self
.specs
.get(base.get() as usize)
.cloned()
.ok_or_else(|| "unknown base pipeline".to_owned())?;
let spec =
target_variant_spec(spec, color_format, depth_format, depth_compare, depth_write);
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("V2 target variant"),
source: wgpu::ShaderSource::Wgsl(spec.vertex.shader_source.as_str().into()),
});
let fragment_shader = spec.fragment.as_ref().map(|stage| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("V2 target variant"),
source: wgpu::ShaderSource::Wgsl(stage.shader_source.as_str().into()),
})
});
let vertex_constants: Vec<_> = spec
.vertex
.constants
.iter()
.map(|(name, value)| (name.as_str(), *value))
.collect();
let fragment_constants: Option<Vec<_>> = spec.fragment.as_ref().map(|stage| {
stage
.constants
.iter()
.map(|(name, value)| (name.as_str(), *value))
.collect()
});
let vertex_layouts: Vec<_> = spec
.vertex_layouts
.iter()
.map(|layout| wgpu::VertexBufferLayout {
array_stride: layout.array_stride,
step_mode: layout.step_mode,
attributes: &layout.attributes,
})
.collect();
let layout = spec
.layout
.map(|key| {
self.pipeline_layouts
.get(&key)
.ok_or("pipeline layout missing")
})
.transpose()?;
let fragment = spec.fragment.as_ref().map(|stage| wgpu::FragmentState {
module: fragment_shader.as_ref().unwrap(),
entry_point: Some(&stage.entry_point),
compilation_options: wgpu::PipelineCompilationOptions {
constants: fragment_constants.as_ref().unwrap(),
zero_initialize_workgroup_memory: stage.zero_initialize_workgroup_memory,
},
targets: &spec.targets,
});
Ok(
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("V2 target variant"),
layout,
vertex: wgpu::VertexState {
module: &vertex_shader,
entry_point: Some(&spec.vertex.entry_point),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &vertex_constants,
zero_initialize_workgroup_memory: spec
.vertex
.zero_initialize_workgroup_memory,
},
buffers: &vertex_layouts,
},
primitive: spec.primitive,
depth_stencil: spec.depth_stencil,
multisample: spec.multisample,
fragment,
multiview: spec.multiview,
cache: None,
}),
)
}
}
impl Default for PipelineLibrary {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> RenderPipelineSpec {
PipelineLibrary::new().compatibility_spec(
"x",
&[],
"shader",
wgpu::TextureFormat::Rgba8Unorm,
)
}
#[test]
fn v2_target_spec_disables_blending_without_mutating_base() {
let mut base = spec();
base.primitive.cull_mode = Some(wgpu::Face::Front);
base.multisample.count = 4;
base.targets[0].as_mut().unwrap().write_mask = wgpu::ColorWrites::RED;
let preserved_vertex = StageKey::from_stage(&base.vertex);
let preserved_fragment = base.fragment.as_ref().map(StageKey::from_stage);
assert!(base.targets[0].as_ref().unwrap().blend.is_some());
let variant = target_variant_spec(
base.clone(),
wgpu::TextureFormat::Rgba16Float,
None,
wgpu::CompareFunction::Always,
false,
);
assert_eq!(variant.targets[0].as_ref().unwrap().blend, None);
assert_eq!(
variant.targets[0].as_ref().unwrap().format,
wgpu::TextureFormat::Rgba16Float
);
assert!(base.targets[0].as_ref().unwrap().blend.is_some());
assert_eq!(variant.primitive, base.primitive);
assert_eq!(variant.multisample, base.multisample);
assert_eq!(StageKey::from_stage(&variant.vertex), preserved_vertex);
assert_eq!(
variant.fragment.as_ref().map(StageKey::from_stage),
preserved_fragment
);
assert_eq!(
variant.targets[0].as_ref().unwrap().write_mask,
wgpu::ColorWrites::ALL
);
}
#[test]
fn descriptor_identity_covers_optional_and_exact_state() {
let base = spec().key();
let mut changed = spec();
changed.layout = Some(PipelineLayoutKey(1));
assert_ne!(base, changed.key());
let first_layout = changed.key();
changed.layout = Some(PipelineLayoutKey(2));
assert_ne!(first_layout, changed.key());
let mut changed = spec();
changed.fragment = None;
assert_ne!(base, changed.key());
let mut changed = spec();
changed.multiview = NonZeroU32::new(2);
assert_ne!(base, changed.key());
let mut changed = spec();
changed.vertex.constants.push(("x".into(), -0.0));
assert_ne!(base, changed.key());
let mut changed2 = changed.clone();
changed2.vertex.constants[0].1 = 0.0;
assert_ne!(changed.key(), changed2.key());
let mut changed = spec();
changed.vertex.zero_initialize_workgroup_memory = false;
assert_ne!(base, changed.key());
}
}
+526
View File
@@ -0,0 +1,526 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
};
use wasm_bindgen::JsValue;
pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_PASSES;
const SLOT_COUNT: usize = 4;
const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32;
const RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlotState {
Free,
Encoding,
Mapping,
}
struct Slot {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
read: wgpu::Buffer,
state: SlotState,
}
struct Completion {
slot: usize,
epoch: u64,
ids: Vec<String>,
values: Option<Vec<u64>>,
}
pub(crate) struct ProfileFrame {
pub query_set: wgpu::QuerySet,
slot: usize,
identity: String,
ids: Vec<String>,
invalid: bool,
}
pub(crate) struct ProfileMap {
slot: usize,
epoch: u64,
ids: Vec<String>,
count: u32,
}
impl ProfileFrame {
fn allocate(&mut self, id: &str) -> Option<u32> {
allocate_id(&mut self.ids, &mut self.invalid, id)
}
pub fn render_writes(&mut self, id: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
pub fn compute_writes(&mut self, id: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::ComputePassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
}
pub(crate) struct Profiler {
enabled: bool,
available: bool,
slots: Vec<Slot>,
completions: Arc<Mutex<Vec<Completion>>>,
epoch: u64,
identity: String,
period_ns: f64,
samples: HashMap<String, VecDeque<(f64, f64)>>,
last_snapshot_ms: f64,
dropped: u64,
}
impl Profiler {
pub fn requested_features(requested: bool, supported: wgpu::Features) -> wgpu::Features {
if requested && supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
wgpu::Features::TIMESTAMP_QUERY
} else {
wgpu::Features::empty()
}
}
pub async fn new(requested: bool, device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let available = requested && device.features().contains(wgpu::Features::TIMESTAMP_QUERY);
let mut slots = Vec::new();
if available {
device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
device.push_error_scope(wgpu::ErrorFilter::Validation);
for _ in 0..SLOT_COUNT {
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("profile timestamps"),
ty: wgpu::QueryType::Timestamp,
count: QUERY_COUNT,
});
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile resolve"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile readback"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
slots.push(Slot {
queries,
resolve,
read,
state: SlotState::Free,
});
}
}
let allocation_failed = if available {
// Pop both scopes before yielding: WebGPU's scope stack must be unwound
// synchronously, even though completion of each pop is asynchronous.
let validation = device.pop_error_scope();
let oom = device.pop_error_scope();
let (validation, oom) = futures::join!(validation, oom);
validation.is_some() || oom.is_some()
} else {
false
};
if allocation_failed {
slots.clear();
}
Self {
enabled: requested,
available: available && !allocation_failed,
slots,
completions: Default::default(),
epoch: 0,
identity: String::new(),
period_ns: queue.get_timestamp_period() as f64,
samples: Default::default(),
last_snapshot_ms: 0.0,
dropped: 0,
}
}
pub fn begin(&mut self, identity: impl FnOnce() -> String) -> Option<ProfileFrame> {
self.drain();
let Some((slot, identity)) = profile_gate(self.enabled, self.available, || {
begin_transition(self.slots.iter_mut().map(|slot| &mut slot.state))
.map(|slot| (slot, identity()))
}) else {
if self.enabled && self.available {
self.dropped += 1;
}
return None;
};
Some(ProfileFrame {
query_set: self.slots[slot].queries.clone(),
slot,
identity,
ids: Vec::new(),
invalid: false,
})
}
pub fn cancel(&mut self, frame: ProfileFrame) {
cancel_state(&mut self.slots[frame.slot].state);
}
pub fn finish(
&mut self,
encoder: &mut wgpu::CommandEncoder,
frame: ProfileFrame,
) -> Option<ProfileMap> {
let count = match finish_transition(
&mut self.slots[frame.slot].state,
frame.invalid,
frame.ids.len(),
) {
FinishAction::Cancel => return None,
FinishAction::Resolve(count) => count,
};
if frame.identity != self.identity {
self.identity = frame.identity;
self.epoch = self.epoch.wrapping_add(1);
self.samples.clear();
}
let slot = frame.slot;
let epoch = self.epoch;
let ids = frame.ids;
encoder.resolve_query_set(
&self.slots[slot].queries,
0..count,
&self.slots[slot].resolve,
0,
);
encoder.copy_buffer_to_buffer(
&self.slots[slot].resolve,
0,
&self.slots[slot].read,
0,
count as u64 * 8,
);
Some(ProfileMap {
slot,
epoch,
ids,
count,
})
}
pub fn map(&mut self, request: ProfileMap) {
let ProfileMap {
slot,
epoch,
ids,
count,
} = request;
self.slots[slot].state = SlotState::Mapping;
let buffer = self.slots[slot].read.clone();
let completions = self.completions.clone();
buffer
.clone()
.slice(..count as u64 * 8)
.map_async(wgpu::MapMode::Read, move |result| {
let values = result.ok().map(|_| {
let bytes = buffer.slice(..count as u64 * 8).get_mapped_range();
let values = bytes
.chunks_exact(8)
.map(|x| u64::from_le_bytes(x.try_into().unwrap()))
.collect();
drop(bytes);
buffer.unmap();
values
});
if let Ok(mut completions) = completions.lock() {
completions.push(Completion {
slot,
epoch,
ids,
values,
});
}
});
}
fn drain(&mut self) {
let completions = if let Ok(mut queue) = self.completions.lock() {
queue.drain(..).collect::<Vec<_>>()
} else {
return;
};
for c in completions {
let Some(v) = completion_transition(
&mut self.slots[c.slot].state,
c.values,
c.epoch,
self.epoch,
&mut self.available,
&mut self.samples,
) else {
continue;
};
for (id, pair) in c.ids.into_iter().zip(v.chunks_exact(2)) {
if let Some(ms) = validate(pair[0], pair[1], self.period_ns) {
let q = self.samples.entry(id).or_default();
q.push_back((js_sys::Date::now(), ms));
}
}
}
}
pub fn snapshot_json(&mut self, now: f64) -> Option<JsValue> {
self.drain();
profile_gate(self.enabled, self.available, || Some(()))?;
(now - self.last_snapshot_ms >= 250.0).then_some(())?;
self.last_snapshot_ms = now;
let cutoff = now - 1000.0;
let mut passes = serde_json::Map::new();
for (id, q) in &mut self.samples {
while q.front().is_some_and(|x| x.0 < cutoff) {
q.pop_front();
}
if !q.is_empty() {
passes.insert(
id.clone(),
serde_json::json!(q.iter().map(|x| x.1).sum::<f64>() / q.len() as f64),
);
}
}
let value = serde_json::json!({"type":"profile-snapshot","requested":self.enabled,"available":self.available,"epoch":self.epoch,"graph":self.identity,"passes":passes,"dropped":self.dropped});
js_sys::JSON::parse(&value.to_string()).ok()
}
}
fn allocate_id(ids: &mut Vec<String>, invalid: &mut bool, id: &str) -> Option<u32> {
if ids.len() >= MAX_PROFILE_PASSES {
*invalid = true;
return None;
}
let first = ids.len() as u32 * 2;
ids.push(id.to_owned());
Some(first)
}
fn profile_gate<T>(enabled: bool, available: bool, f: impl FnOnce() -> Option<T>) -> Option<T> {
(enabled && available).then(f).flatten()
}
fn begin_transition<'a>(states: impl IntoIterator<Item = &'a mut SlotState>) -> Option<usize> {
for (slot, state) in states.into_iter().enumerate() {
if *state == SlotState::Free {
*state = SlotState::Encoding;
return Some(slot);
}
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum FinishAction {
Cancel,
Resolve(u32),
}
fn finish_transition(state: &mut SlotState, invalid: bool, id_count: usize) -> FinishAction {
if *state != SlotState::Encoding || invalid || id_count == 0 {
cancel_state(state);
FinishAction::Cancel
} else {
FinishAction::Resolve(id_count as u32 * 2)
}
}
fn completion_transition(
state: &mut SlotState,
values: Option<Vec<u64>>,
completion_epoch: u64,
current_epoch: u64,
available: &mut bool,
samples: &mut HashMap<String, VecDeque<(f64, f64)>>,
) -> Option<Vec<u64>> {
*state = SlotState::Free;
match values {
None => {
*available = false;
samples.clear();
None
}
Some(_) if !*available || completion_epoch != current_epoch => None,
Some(values) => Some(values),
}
}
fn cancel_state(state: &mut SlotState) {
if *state == SlotState::Encoding {
*state = SlotState::Free;
}
}
fn validate(start: u64, end: u64, period: f64) -> Option<f64> {
if (start == 0 && end == 0) || end < start || !period.is_finite() || period <= 0.0 {
return None;
}
let ms = (end - start) as f64 * period / 1_000_000.0;
if ms.is_finite() && ms >= 0.0 && ms <= 1000.0 {
Some(ms)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_gate() {
assert!(Profiler::requested_features(false, wgpu::Features::TIMESTAMP_QUERY).is_empty());
assert!(Profiler::requested_features(true, wgpu::Features::empty()).is_empty());
assert_eq!(
Profiler::requested_features(true, wgpu::Features::TIMESTAMP_QUERY),
wgpu::Features::TIMESTAMP_QUERY
)
}
#[test]
fn validation() {
assert_eq!(validate(1, 2, 1_000_000.0), Some(1.0));
assert_eq!(validate(2, 2, 1.0), Some(0.0));
assert_eq!(validate(0, 0, 1.0), None);
assert_eq!(validate(2, 1, 1.0), None);
assert_eq!(validate(0, 1, 1_000_000_000.0), Some(1000.0));
assert_eq!(validate(0, 2, 1_000_000_000.0), None);
assert_eq!(validate(1, 2, f64::NAN), None);
assert_eq!(validate(1, 2, f64::INFINITY), None);
assert_eq!(validate(1, 2, 0.0), None);
assert_eq!(validate(1, 2, -1.0), None);
}
#[test]
fn capacity_is_aligned() {
assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0);
assert_eq!(QUERY_COUNT, 2048)
}
#[test]
fn lazy_identity_when_disabled_unavailable_or_full() {
let mut calls = 0;
for (enabled, available) in [(false, true), (true, false)] {
assert_eq!(
profile_gate(enabled, available, || {
calls += 1;
Some(7)
}),
None
);
}
assert_eq!(calls, 0);
let mut full = [SlotState::Mapping; SLOT_COUNT];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut full).map(|slot| {
calls += 1;
slot
})
}),
None
);
assert_eq!(calls, 0);
assert_eq!(full, [SlotState::Mapping; SLOT_COUNT]);
let mut states = [SlotState::Mapping, SlotState::Free];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut states).map(|slot| {
calls += 1;
slot
})
}),
Some(1)
);
assert_eq!(calls, 1);
assert_eq!(states, [SlotState::Mapping, SlotState::Encoding]);
}
#[test]
fn compact_ids_and_query_pairs_resolve_four() {
let mut ids = Vec::new();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "a"), Some(0));
assert_eq!(allocate_id(&mut ids, &mut invalid, "b"), Some(2));
assert_eq!(ids, ["a", "b"]);
let mut state = SlotState::Encoding;
assert_eq!(
finish_transition(&mut state, invalid, ids.len()),
FinishAction::Resolve(4)
);
assert!(!invalid);
}
#[test]
fn capacity_overflow_marks_frame_invalid() {
let mut ids = (0..MAX_PROFILE_PASSES).map(|i| i.to_string()).collect();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "overflow"), None);
assert!(invalid);
assert_eq!(ids.len(), MAX_PROFILE_PASSES);
let mut overflow = SlotState::Encoding;
assert_eq!(
finish_transition(&mut overflow, invalid, ids.len()),
FinishAction::Cancel
);
assert_eq!(overflow, SlotState::Free);
let mut empty = SlotState::Encoding;
assert_eq!(
finish_transition(&mut empty, false, 0),
FinishAction::Cancel
);
assert_eq!(empty, SlotState::Free);
}
#[test]
fn repeated_cancel_and_mapping_guard() {
let mut encoding = SlotState::Encoding;
for _ in 0..=SLOT_COUNT {
cancel_state(&mut encoding);
assert_eq!(encoding, SlotState::Free);
encoding = SlotState::Encoding;
}
let mut mapping = SlotState::Mapping;
cancel_state(&mut mapping);
assert_eq!(mapping, SlotState::Mapping);
let mut free = SlotState::Free;
cancel_state(&mut free);
assert_eq!(free, SlotState::Free);
}
#[test]
fn stale_epoch_map_failure_is_terminal_and_prevents_later_aggregation() {
let mut state = SlotState::Mapping;
let mut available = true;
let mut samples = HashMap::from([
("a".into(), VecDeque::from([(1.0, 2.0)])),
("b".into(), VecDeque::from([(3.0, 4.0)])),
]);
assert_eq!(
completion_transition(&mut state, None, 1, 2, &mut available, &mut samples),
None
);
assert_eq!(state, SlotState::Free);
assert!(!available);
assert!(samples.is_empty());
for epoch in [2, 1] {
state = SlotState::Mapping;
assert_eq!(
completion_transition(
&mut state,
Some(vec![1, 2]),
epoch,
2,
&mut available,
&mut samples
),
None
);
assert_eq!(state, SlotState::Free);
assert!(samples.is_empty());
}
}
#[test]
fn snapshot_gate_is_silent_when_disabled_or_unavailable() {
for enabled in [false, true] {
for available in [false, true] {
assert_eq!(
profile_gate(enabled, available, || Some("snapshot")),
(enabled && available).then_some("snapshot")
);
}
}
}
}
+7 -4
View File
@@ -3,7 +3,7 @@ use wgpu::util::DeviceExt;
use crate::{
camera::Camera,
render_data::RenderData,
renderer::{self, GpuResources},
renderer::{self, PipelineLibrary},
};
pub struct UniformResource {
@@ -76,13 +76,14 @@ impl FrameMetadata {
pub trait Scene: Sized {
fn setup(
context: &renderer::RendererContext,
resources: &mut GpuResources,
resources: &mut PipelineLibrary,
data: &mut RenderData,
) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, dx: f32, dy: f32);
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
fn set_camera_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
@@ -91,6 +92,9 @@ pub trait Scene: Sized {
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
self.camera_mut().map(|camera| camera.frustum_planes())
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None
}
@@ -103,7 +107,7 @@ pub trait Scene: Sized {
}
self.write_uniforms(queue);
}
fn update(&mut self, context: &renderer::RendererContext) {
fn update_cpu(&mut self) {
let position = match self.camera_mut() {
Some(c) => c.position(),
None => return,
@@ -112,7 +116,6 @@ pub trait Scene: Sized {
f.time = js_sys::Date::now() as f32 * 0.001;
f.set_camera_position(position)
}
self.write_uniforms(&context.queue);
}
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
let frame = self.frame_metadata_mut().copied();
+322
View File
@@ -0,0 +1,322 @@
use std::collections::HashMap;
use thiserror::Error;
use crate::render_data::{
affine_world_aabb, Aabb, GeometryRange, InstanceHandle, MaterialKey, MeshHandle,
ModelTransform, NormalMatrix, PipelineKey, RenderData, RenderFlags,
};
#[derive(Clone, Debug)]
pub struct SceneFrameMesh {
pub handle: MeshHandle,
pub geometry: GeometryRange,
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub flags: RenderFlags,
pub aabb: Aabb,
pub default_instance: InstanceHandle,
pub occurrence_range: std::ops::Range<usize>,
}
#[derive(Clone, Debug)]
pub struct SceneFrameOccurrence {
pub handle: InstanceHandle,
pub mesh: MeshHandle,
pub mesh_index: usize,
pub model: ModelTransform,
pub normal: NormalMatrix,
pub flags: RenderFlags,
pub is_default: bool,
pub world_aabb: Aabb,
}
#[derive(Clone, Debug)]
pub struct SceneFramePlan {
pub revision: u64,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub uvs: Vec<[f32; 2]>,
pub tangents: Vec<[f32; 4]>,
pub indices: Vec<u32>,
pub meshes: Vec<SceneFrameMesh>,
pub occurrences: Vec<SceneFrameOccurrence>,
/// Occurrence indices grouped by mesh without changing global occurrence order.
pub mesh_occurrence_indices: Vec<usize>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SceneFrameError {
#[error("an occurrence references a missing mesh")]
MissingMesh,
#[error("world bounds could not be derived")]
InvalidWorldBounds,
#[error("scene frame size overflow")]
SizeOverflow,
}
impl SceneFramePlan {
pub fn build(data: &RenderData) -> Result<Self, SceneFrameError> {
let streams = data.streams();
let mut meshes: Vec<_> = data.meshes().collect();
meshes.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
let mesh_indices: HashMap<_, _> = meshes
.iter()
.enumerate()
.map(|(dense, (handle, _))| (*handle, dense))
.collect();
let mut source_occurrences: Vec<_> = data.instances().collect();
source_occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation()));
let mut counts = vec![0usize; meshes.len()];
let mut occurrences = Vec::with_capacity(source_occurrences.len());
for (handle, occurrence) in source_occurrences {
let mesh_index = *mesh_indices
.get(&occurrence.mesh)
.ok_or(SceneFrameError::MissingMesh)?;
counts[mesh_index] = counts[mesh_index]
.checked_add(1)
.ok_or(SceneFrameError::SizeOverflow)?;
let mesh = meshes[mesh_index].1;
occurrences.push(SceneFrameOccurrence {
handle,
mesh: occurrence.mesh,
mesh_index,
model: occurrence.model,
normal: occurrence.normal,
flags: occurrence.flags,
is_default: handle == mesh.default_instance,
world_aabb: affine_world_aabb(mesh.aabb, occurrence.model)
.map_err(|_| SceneFrameError::InvalidWorldBounds)?,
});
}
let mut offsets = Vec::with_capacity(meshes.len() + 1);
offsets.push(0usize);
for count in counts {
offsets.push(
offsets
.last()
.unwrap()
.checked_add(count)
.ok_or(SceneFrameError::SizeOverflow)?,
);
}
let mut cursors = offsets[..meshes.len()].to_vec();
let mut mesh_occurrence_indices = vec![0; occurrences.len()];
for (occurrence_index, occurrence) in occurrences.iter().enumerate() {
let cursor = &mut cursors[occurrence.mesh_index];
mesh_occurrence_indices[*cursor] = occurrence_index;
*cursor += 1;
}
let meshes = meshes
.into_iter()
.enumerate()
.map(|(dense, (handle, mesh))| SceneFrameMesh {
handle,
geometry: mesh.geometry,
pipeline: mesh.pipeline,
material: mesh.material,
flags: mesh.flags,
aabb: mesh.aabb,
default_instance: mesh.default_instance,
occurrence_range: offsets[dense]..offsets[dense + 1],
})
.collect();
Ok(Self {
revision: data.revision(),
positions: streams.positions.to_vec(),
normals: streams.normals.to_vec(),
uvs: streams.uvs.to_vec(),
tangents: streams.tangents.to_vec(),
indices: data.indices().to_vec(),
meshes,
occurrences,
mesh_occurrence_indices,
})
}
}
#[derive(Default)]
pub struct SceneFrameCache {
plan: Option<Box<SceneFramePlan>>,
}
impl SceneFrameCache {
pub fn get_or_build(&mut self, data: &RenderData) -> Result<&SceneFramePlan, SceneFrameError> {
if self
.plan
.as_ref()
.is_none_or(|plan| plan.revision != data.revision())
{
let replacement = Box::new(SceneFramePlan::build(data)?);
self.plan = Some(replacement);
}
Ok(self.plan.as_deref().unwrap())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM};
fn mesh(data: &mut RenderData, visible: bool) -> crate::render_data::CreatedMesh {
data.create_mesh(MeshCreateInfo {
positions: &[[0., 0., 0.], [2., 0., 0.], [0., 2., 0.]],
normals: &[[0., 0., 1.]; 3],
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(0),
material: crate::render_data::MaterialKey::DEFAULT,
flags: if visible {
RenderFlags::VISIBLE
} else {
RenderFlags::NONE
},
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
})
.unwrap()
}
#[test]
fn cache_reuses_pointer_and_rebuilds_on_revision() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let mut cache = SceneFrameCache::default();
let first = cache.get_or_build(&data).unwrap() as *const _;
assert_eq!(first, cache.get_or_build(&data).unwrap() as *const _);
let created = mesh(&mut data, true);
let second = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(first, second);
data.set_mesh_flags(created.mesh, RenderFlags::NONE)
.unwrap();
let third = cache.get_or_build(&data).unwrap() as *const _;
assert_ne!(second, third);
let mut moved = IDENTITY_MODEL_TRANSFORM;
moved[3][1] = 9.0;
data.set_instance_transform(created.default_instance, moved)
.unwrap();
assert_ne!(third, cache.get_or_build(&data).unwrap() as *const _);
}
#[test]
fn retains_hidden_entries_builds_adjacency_and_world_bounds() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let hidden = mesh(&mut data, false);
let shown = mesh(&mut data, true);
let mut translated = IDENTITY_MODEL_TRANSFORM;
translated[0][0] = 2.;
translated[1][1] = 3.;
translated[2][2] = 4.;
translated[3][0] = 5.;
translated[3][1] = -2.;
let extra = data
.create_instance(hidden.mesh, translated, RenderFlags::NONE)
.unwrap();
let plan = SceneFramePlan::build(&data).unwrap();
assert_eq!((plan.meshes.len(), plan.occurrences.len()), (2, 3));
assert!(plan
.occurrences
.iter()
.any(|o| o.handle == hidden.default_instance && o.is_default));
let occurrence = plan.occurrences.iter().find(|o| o.handle == extra).unwrap();
assert_eq!(occurrence.world_aabb.min, [5., -2., 0.]);
assert_eq!(occurrence.world_aabb.max, [9., 4., 0.]);
for (mesh_index, mesh) in plan.meshes.iter().enumerate() {
assert!(plan.mesh_occurrence_indices[mesh.occurrence_range.clone()]
.iter()
.all(|&i| plan.occurrences[i].mesh_index == mesh_index));
}
assert_eq!(
shown.default_instance.slot(),
plan.occurrences
.iter()
.find(|o| o.mesh == shown.mesh)
.unwrap()
.handle
.slot()
);
}
#[test]
fn slot_reuse_preserves_dense_order_adjacency_and_ownership() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let a = mesh(&mut data, true);
let doomed = mesh(&mut data, true);
let c = mesh(&mut data, true);
let a_extra = data
.create_instance(a.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
let doomed_extra = data
.create_instance(doomed.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
let c_extra = data
.create_instance(c.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE)
.unwrap();
data.destroy_instance(a_extra).unwrap();
data.destroy_mesh(doomed.mesh).unwrap();
let replacement = mesh(&mut data, true);
let replacement_extra = data
.create_instance(
replacement.mesh,
IDENTITY_MODEL_TRANSFORM,
RenderFlags::VISIBLE,
)
.unwrap();
assert_eq!(replacement.mesh.slot(), doomed.mesh.slot());
assert!(replacement.mesh.generation() > doomed.mesh.generation());
let destroyed = [a_extra, doomed.default_instance, doomed_extra];
for reused in [replacement.default_instance, replacement_extra] {
let old = destroyed
.iter()
.find(|old| old.slot() == reused.slot())
.expect("replacement must reuse an interior instance slot");
assert!(reused.generation() > old.generation());
}
assert!(data.instance(doomed_extra).is_none());
let plan = SceneFramePlan::build(&data).unwrap();
assert!(plan
.meshes
.windows(2)
.all(|w| (w[0].handle.slot(), w[0].handle.generation())
< (w[1].handle.slot(), w[1].handle.generation())));
assert!(plan.occurrences.windows(2).all(|w| (
w[0].handle.slot(),
w[0].handle.generation()
) < (
w[1].handle.slot(),
w[1].handle.generation()
)));
let mut seen = vec![0; plan.occurrences.len()];
for (mesh_index, mesh) in plan.meshes.iter().enumerate() {
let adjacency = &plan.mesh_occurrence_indices[mesh.occurrence_range.clone()];
assert!(adjacency.windows(2).all(|w| {
let a = plan.occurrences[w[0]].handle;
let b = plan.occurrences[w[1]].handle;
(a.slot(), a.generation()) < (b.slot(), b.generation())
}));
for &index in adjacency {
seen[index] += 1;
let occurrence = &plan.occurrences[index];
assert_eq!(occurrence.mesh, mesh.handle);
assert_eq!(occurrence.mesh_index, mesh_index);
assert_eq!(
occurrence.is_default,
occurrence.handle == mesh.default_instance
);
}
assert_eq!(
adjacency
.iter()
.filter(|&&i| plan.occurrences[i].is_default)
.count(),
1
);
}
assert!(seen.into_iter().all(|count| count == 1));
assert!(plan.occurrences.iter().any(|o| o.handle == c_extra));
}
}
+193 -22
View File
@@ -1,7 +1,7 @@
//! Triple-buffered, immutable packed scene snapshot shared with JavaScript.
use std::sync::atomic::{AtomicU32, Ordering};
use crate::render_data::{affine_world_aabb, RenderData, RenderFlags};
use crate::{render_data::RenderFlags, renderer::scene_frame::SceneFramePlan};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP");
pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1");
@@ -94,11 +94,11 @@ impl SharedSnapshot {
}
/// Packs and publishes if `data` changed. Returns the newly published data epoch.
pub fn publish(&mut self, data: &RenderData) -> Result<Option<u32>, u32> {
pub fn publish(&mut self, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
if self.control.header[6].load(Ordering::Acquire) == FAILED {
return Err(self.control.header[14].load(Ordering::Relaxed));
}
if self.last_revision == Some(data.revision()) {
if self.last_revision == Some(data.revision) {
return Ok(None);
}
let slot = match self.claim_slot() {
@@ -116,7 +116,7 @@ impl SharedSnapshot {
result
}
fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result<Option<u32>, u32> {
fn publish_claimed(&mut self, slot: usize, data: &SceneFramePlan) -> Result<Option<u32>, u32> {
let epoch = self.next_epoch;
let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?;
let bytes = pack(data, epoch)?;
@@ -136,7 +136,7 @@ impl SharedSnapshot {
let ptr = self.blocks[slot].as_ptr() as usize;
let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?;
let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?;
let revision = data.revision();
let revision = data.revision;
let d = &self.control.slots[slot].0;
let values = [
epoch,
@@ -145,8 +145,8 @@ impl SharedSnapshot {
length,
revision as u32,
(revision >> 32) as u32,
data.mesh_count(),
data.instance_count(),
data.meshes.len() as u32,
data.occurrences.len() as u32,
SCHEMA,
SNAPSHOT_HEADER_BYTES as u32,
0,
@@ -244,9 +244,9 @@ fn wasm_pages(minimum_end: usize) -> Result<u32, u32> {
.map_err(|_| ERROR_OVERFLOW)
}
fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes: Vec<_> = data.meshes().collect();
let instances: Vec<_> = data.instances().collect();
fn pack(data: &SceneFramePlan, epoch: u32) -> Result<Vec<u8>, u32> {
let meshes = &data.meshes;
let instances = &data.occurrences;
let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
@@ -268,7 +268,7 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
let put32 = |out: &mut [u8], at: usize, value: u32| {
out[at..at + 4].copy_from_slice(&value.to_le_bytes())
};
let revision = data.revision();
let revision = data.revision;
for (i, value) in [
BLOB_MAGIC,
SCHEMA,
@@ -310,10 +310,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32(&mut out, at + j * 4, value);
}
}
for (dense, (handle, mesh)) in meshes.iter().enumerate() {
for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()]
.into_iter()
.enumerate()
for (dense, mesh) in meshes.iter().enumerate() {
for (i, value) in [
mesh.handle.slot(),
mesh.handle.generation(),
mesh.flags.bits(),
]
.into_iter()
.enumerate()
{
put32(&mut out, offsets[i] + dense * 4, value);
}
@@ -330,12 +334,14 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
);
}
}
for (dense, (handle, instance)) in instances.iter().enumerate() {
let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?;
let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?;
for (dense, instance) in instances.iter().enumerate() {
let mesh = data
.meshes
.get(instance.mesh_index)
.ok_or(ERROR_INVARIANT)?;
for (i, value) in [
handle.slot(),
handle.generation(),
instance.handle.slot(),
instance.handle.generation(),
instance.mesh.slot(),
instance.mesh.generation(),
instance.flags.bits(),
@@ -356,12 +362,12 @@ fn pack(data: &RenderData, epoch: u32) -> Result<Vec<u8>, u32> {
put32(
&mut out,
offsets[11] + dense * 12 + i * 4,
world.min[i].to_bits(),
instance.world_aabb.min[i].to_bits(),
);
put32(
&mut out,
offsets[12] + dense * 12 + i * 4,
world.max[i].to_bits(),
instance.world_aabb.max[i].to_bits(),
);
}
put32(
@@ -387,6 +393,9 @@ const _: [(); 256] = [(); std::mem::size_of::<SnapshotControl>()];
#[cfg(test)]
mod tests {
use super::*;
use crate::render_data::{
MeshCreateInfo, PipelineKey, RenderData, RenderDataConfig, IDENTITY_MODEL_TRANSFORM,
};
#[test]
fn exact_control_layout_and_initial_values() {
@@ -417,4 +426,166 @@ mod tests {
WRITING
);
}
#[test]
fn producer_blob_abi_matches_schema_exactly() {
let mut data = RenderData::new(RenderDataConfig::default()).unwrap();
let create = |data: &mut RenderData, flags, x: f32| {
data.create_mesh(MeshCreateInfo {
positions: &[[x, 0., 0.], [x + 2., 0., 0.], [x, 3., 0.]],
normals: &[[0., 0., 1.]; 3],
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(7),
material: crate::render_data::MaterialKey::DEFAULT,
flags,
default_instance_flags: RenderFlags::VISIBLE,
default_transform: IDENTITY_MODEL_TRANSFORM,
})
.unwrap()
};
let visible = create(&mut data, RenderFlags::VISIBLE, -1.0);
let hidden = create(&mut data, RenderFlags::NONE, 10.0);
let mut model = IDENTITY_MODEL_TRANSFORM;
model[0][0] = 2.0;
model[1][1] = 0.5;
model[3][0] = 4.0;
model[3][1] = -2.0;
let extra = data
.create_instance(hidden.mesh, model, RenderFlags::NONE)
.unwrap();
let plan = SceneFramePlan::build(&data).unwrap();
let epoch = 0x1234_5678;
let blob = pack(&plan, epoch).unwrap();
let word = |at: usize| u32::from_le_bytes(blob[at..at + 4].try_into().unwrap());
assert_eq!(word(0), BLOB_MAGIC);
assert_eq!(word(4), SCHEMA);
assert_eq!(word(8), SNAPSHOT_HEADER_BYTES as u32);
assert_eq!(word(12), blob.len() as u32);
assert_eq!(word(16), epoch);
assert_eq!(word(20), plan.revision as u32);
assert_eq!(word(24), (plan.revision >> 32) as u32);
assert_eq!(word(28), STREAMS as u32);
assert_eq!(word(32), SNAPSHOT_HEADER_BYTES as u32);
assert_eq!(word(36), DESCRIPTOR_BYTES as u32);
assert_eq!(word(40), 2);
assert_eq!(word(44), 3);
assert_eq!(word(48), 0x0102_0304);
assert_eq!(word(52), SCHEMA_FLAGS);
let strides = [4, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4];
let scalars = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1];
let components = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1];
let counts = [2usize; 5]
.into_iter()
.chain([3usize; 9])
.collect::<Vec<_>>();
let mut offsets = Vec::new();
let mut cursor = DATA_OFFSET;
for i in 0..STREAMS {
let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES;
offsets.push(cursor);
assert_eq!(
[
word(at),
word(at + 4),
word(at + 8),
word(at + 12),
word(at + 16),
word(at + 20),
word(at + 24),
word(at + 28)
],
[
i as u32 + 1,
scalars[i],
cursor as u32,
counts[i] as u32,
components[i],
strides[i] as u32,
4,
0
]
);
assert_eq!(cursor % 16, 0);
cursor = (cursor + strides[i] as usize * counts[i] + 15) & !15;
}
assert_eq!(cursor, blob.len());
for (dense, mesh) in plan.meshes.iter().enumerate() {
assert_eq!(word(offsets[0] + dense * 4), mesh.handle.slot());
assert_eq!(word(offsets[1] + dense * 4), mesh.handle.generation());
assert_eq!(word(offsets[2] + dense * 4), mesh.flags.bits());
for axis in 0..3 {
assert_eq!(
word(offsets[3] + dense * 12 + axis * 4),
mesh.aabb.min[axis].to_bits()
);
assert_eq!(
word(offsets[4] + dense * 12 + axis * 4),
mesh.aabb.max[axis].to_bits()
);
}
}
for (dense, occurrence) in plan.occurrences.iter().enumerate() {
assert_eq!(
[
word(offsets[5] + dense * 4),
word(offsets[6] + dense * 4),
word(offsets[7] + dense * 4),
word(offsets[8] + dense * 4),
word(offsets[9] + dense * 4)
],
[
occurrence.handle.slot(),
occurrence.handle.generation(),
occurrence.mesh.slot(),
occurrence.mesh.generation(),
occurrence.flags.bits()
]
);
for i in 0..16 {
assert_eq!(
word(offsets[10] + dense * 64 + i * 4),
occurrence.model[i / 4][i % 4].to_bits()
);
}
for axis in 0..3 {
assert_eq!(
word(offsets[11] + dense * 12 + axis * 4),
occurrence.world_aabb.min[axis].to_bits()
);
assert_eq!(
word(offsets[12] + dense * 12 + axis * 4),
occurrence.world_aabb.max[axis].to_bits()
);
}
let mesh_visible = plan.meshes[occurrence.mesh_index]
.flags
.contains(RenderFlags::VISIBLE);
assert_eq!(
word(offsets[13] + dense * 4),
(mesh_visible && occurrence.flags.contains(RenderFlags::VISIBLE)) as u32
);
}
assert!(plan
.meshes
.windows(2)
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
assert!(plan
.occurrences
.windows(2)
.all(|w| w[0].handle.slot() < w[1].handle.slot()));
assert_eq!(
plan.occurrences
.iter()
.find(|o| o.handle == extra)
.unwrap()
.model,
model
);
assert!(plan.occurrences.iter().any(|o| o.mesh == visible.mesh));
}
}