diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 00d6b119..bf5619cc 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -152,15 +152,21 @@ jobs: install_lavapipe: true cache: true + # One test at a time, as the GLES step below already does. Each of these + # stands up its own GPU context, and the ray traced ones also build + # acceleration structures and full sized render targets. Ten of those at + # once on a shared runner, where the "GPU" is a software rasterizer + # competing for the same few cores, stretches the heaviest tests by far + # more than the work in them costs. - name: Run GPU integration tests (Linux) if: matrix.name == 'Linux' - run: cargo test --test gpu_examples -- --ignored --nocapture + run: cargo test --test gpu_examples -- --ignored --nocapture --test-threads=1 env: VK_ICD_FILENAMES: /usr/share/vulkan/icd.d/lvp_icd.json - name: Run GPU integration tests if: matrix.name != 'Linux' - run: cargo test --test gpu_examples -- --ignored --nocapture + run: cargo test --test gpu_examples -- --ignored --nocapture --test-threads=1 - name: Install EGL/GLES (Linux) if: matrix.name == 'Linux' diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index 0876c68d..2c0258ea 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -379,6 +379,7 @@ enum Renderer { inner: blade_render::RayTracer, frame_config: blade_render::FrameConfig, ray_config: blade_render::RayConfig, + mode: blade_render::RenderMode, denoiser_enabled: bool, denoiser_config: blade_render::DenoiserConfig, post_proc_config: blade_render::PostProcConfig, @@ -544,7 +545,9 @@ impl Engine { }; let gpu_context = Arc::new(unsafe { gpu::Context::init(context_desc).unwrap() }); - let (surface_size, surface_info, target_surface) = match presentation { + // Note: the color space we ask the surface for is also the one + // the renderers have to produce, see `RenderConfig`. + let (surface_size, surface_info, color_space, target_surface) = match presentation { #[cfg(not(target_os = "android"))] Presentation::Window(window) => { let surface_config = Self::make_surface_config(window.inner_size()); @@ -556,6 +559,7 @@ impl Engine { ( surface_size, surface_info, + surface_config.color_space, TargetSurface::Window(gpu_surface), ) } @@ -565,15 +569,22 @@ impl Engine { panic!("XR presentation is only supported on Android"); #[cfg(target_os = "android")] { + let surface_config = gpu_context + .xr_recommended_surface_config( + openxr::ViewConfigurationType::PRIMARY_STEREO, + ) + .expect("Unable to query the XR surface configuration"); let xr_surface = gpu_context - .create_xr_surface() + .create_xr_surface_configured(surface_config) .expect("Unable to create XR surface from GPU context"); let surface_size = xr_surface.extent(); - let surface_info = gpu::SurfaceInfo { - format: xr_surface.format(), - alpha: gpu::AlphaMode::Ignored, - }; - (surface_size, surface_info, TargetSurface::Xr(xr_surface)) + let surface_info = xr_surface.info(); + ( + surface_size, + surface_info, + surface_config.color_space, + TargetSurface::Xr(xr_surface), + ) } } #[cfg(target_os = "android")] @@ -603,6 +614,7 @@ impl Engine { let render_config = blade_render::RenderConfig { surface_size, surface_info, + color_space, max_debug_lines: 1 << 14, }; let renderer = match config.render_backend { @@ -619,8 +631,10 @@ impl Engine { debug_draw: true, reset_variance: false, reset_reservoirs: true, + reset_accumulation: true, }, ray_config: blade_helpers::default_ray_config(), + mode: blade_render::RenderMode::default(), denoiser_enabled: true, denoiser_config: blade_render::DenoiserConfig { num_passes: 4, @@ -630,6 +644,7 @@ impl Engine { average_luminocity: 0.5, exposure_key_value: 1.0 / 9.6, white_level: 1.0, + tone_map: true, }, }, config::RenderBackend::Rasterizer => Renderer::Rasterizer { @@ -876,6 +891,7 @@ impl Engine { ref mut ray_config, ref mut denoiser_enabled, ref mut denoiser_config, + mode, .. } = self.renderer { @@ -899,12 +915,16 @@ impl Engine { *frame_config, ); frame_config.reset_reservoirs = false; + frame_config.reset_accumulation = false; if !self.render_objects.is_empty() { - inner.ray_trace(command_encoder, self.debug, *ray_config); - if *denoiser_enabled { - inner.denoise(command_encoder, *denoiser_config); - } + inner.render( + command_encoder, + mode, + self.debug, + *ray_config, + denoiser_enabled.then_some(*denoiser_config), + ); } } } @@ -1135,6 +1155,8 @@ impl Engine { command_encoder.init_texture(frame.texture()); match self.renderer { + //Note: the canonical renderer is of no use in a headset, + // so XR always takes the real-time path. Renderer::RayTracer { ref mut inner, ray_config, @@ -1142,7 +1164,9 @@ impl Engine { denoiser_enabled, denoiser_config, post_proc_config, + .. } => { + let mode = blade_render::RenderMode::RealTime; if can_render { inner.build_scene( command_encoder, @@ -1178,11 +1202,15 @@ impl Engine { }; inner.prepare(command_encoder, &render_camera, *frame_config); frame_config.reset_reservoirs = false; + frame_config.reset_accumulation = false; if !self.render_objects.is_empty() { - inner.ray_trace(command_encoder, self.debug, ray_config); - if denoiser_enabled { - inner.denoise(command_encoder, denoiser_config); - } + inner.render( + command_encoder, + mode, + self.debug, + ray_config, + denoiser_enabled.then_some(denoiser_config), + ); } if let mut pass = command_encoder.render( "xr-draw", @@ -1433,6 +1461,7 @@ impl Engine { .default_open(false) .show(ui, |ui| match self.renderer { Renderer::RayTracer { + ref mut mode, ref mut ray_config, ref mut denoiser_enabled, ref mut denoiser_config, @@ -1440,8 +1469,13 @@ impl Engine { ref mut frame_config, .. } => { + if blade_helpers::populate_render_mode(mode, ui) { + frame_config.reset_accumulation = true; + } ray_config.populate_hud(ui); - frame_config.reset_reservoirs |= ui.button("Reset Accumulation").clicked(); + let reset = ui.button("Reset Accumulation").clicked(); + frame_config.reset_reservoirs |= reset; + frame_config.reset_accumulation |= reset; ui.checkbox(denoiser_enabled, "Enable Denoiser"); denoiser_config.populate_hud(ui); post_proc_config.populate_hud(ui); diff --git a/blade-graphics/src/metal/surface.rs b/blade-graphics/src/metal/surface.rs index 67ccd97e..39788a88 100644 --- a/blade-graphics/src/metal/surface.rs +++ b/blade-graphics/src/metal/surface.rs @@ -109,11 +109,12 @@ impl super::Context { pub fn reconfigure_surface(&self, surface: &mut super::Surface, config: crate::SurfaceConfig) { let device = self.device.lock().unwrap(); + let format = match config.color_space { + crate::ColorSpace::Linear => crate::TextureFormat::Bgra8UnormSrgb, + crate::ColorSpace::Srgb => crate::TextureFormat::Bgra8Unorm, + }; surface.info = crate::SurfaceInfo { - format: match config.color_space { - crate::ColorSpace::Linear => crate::TextureFormat::Bgra8UnormSrgb, - crate::ColorSpace::Srgb => crate::TextureFormat::Bgra8Unorm, - }, + format, alpha: if config.transparent { crate::AlphaMode::PostMultiplied } else { diff --git a/blade-graphics/src/vulkan/surface.rs b/blade-graphics/src/vulkan/surface.rs index dbadff32..43d23289 100644 --- a/blade-graphics/src/vulkan/surface.rs +++ b/blade-graphics/src/vulkan/surface.rs @@ -176,6 +176,13 @@ impl super::XrSurface { self.swapchain.format } + pub fn info(&self) -> crate::SurfaceInfo { + crate::SurfaceInfo { + format: self.swapchain.format, + alpha: self.swapchain.alpha, + } + } + pub fn swapchain(&self) -> &xr::Swapchain { &self.raw } @@ -528,7 +535,8 @@ impl super::Context { }; } - fn xr_recommended_surface_config( + /// Surface configuration matching what the XR runtime recommends. + pub fn xr_recommended_surface_config( &self, view_type: xr::ViewConfigurationType, ) -> Option { @@ -547,7 +555,9 @@ impl super::Context { depth: 1, }, usage: crate::TextureUsage::TARGET, - color_space: crate::ColorSpace::Linear, + //Note: a plain swapchain format is the one XR runtimes are + // happiest with, and it means we do the encoding ourselves. + color_space: crate::ColorSpace::Srgb, view_count, }) } @@ -558,7 +568,7 @@ impl super::Context { self.create_xr_surface_configured(config) } - fn create_xr_surface_configured( + pub fn create_xr_surface_configured( &self, config: crate::XrSurfaceConfig, ) -> Option { @@ -783,9 +793,12 @@ fn select_xr_swapchain_format( } } } + // Unlike a window surface, an XR swapchain can't declare a color space: + // the runtime linearizes the sRGB formats and passes the plain ones + // through to the compositor. So the format is what honors the request. match color_space { - crate::ColorSpace::Linear => linear_candidate.or(srgb_candidate), - crate::ColorSpace::Srgb => srgb_candidate.or(linear_candidate), + crate::ColorSpace::Linear => srgb_candidate.or(linear_candidate), + crate::ColorSpace::Srgb => linear_candidate.or(srgb_candidate), } .expect("No compatible XR swapchain format available") } diff --git a/blade-helpers/src/hud.rs b/blade-helpers/src/hud.rs index 55c4a964..9c5ecf9b 100644 --- a/blade-helpers/src/hud.rs +++ b/blade-helpers/src/hud.rs @@ -5,14 +5,25 @@ pub trait ExposeHud { impl ExposeHud for blade_render::RayConfig { fn populate_hud(&mut self, ui: &mut egui::Ui) { ui.add( - egui::Slider::new(&mut self.num_environment_samples, 1..=100u32) + egui::Slider::new(&mut self.num_environment_samples, 0..=100u32) .text("Num env samples") .logarithmic(true), ); + ui.add( + egui::Slider::new(&mut self.num_brdf_samples, 0..=100u32) + .text("Num BRDF samples") + .logarithmic(true), + ); ui.checkbox( &mut self.environment_importance_sampling, "Env importance sampling", ); + ui.add(egui::widgets::Slider::new(&mut self.max_bounces, 0..=16).text("Max bounces")); + ui.add( + egui::Slider::new(&mut self.max_accumulated_samples, 0..=4096u32) + .text("Accumulation limit") + .logarithmic(true), + ); ui.add(egui::widgets::Slider::new(&mut self.tap_count, 0..=10).text("Tap count")); ui.add(egui::widgets::Slider::new(&mut self.tap_radius, 1..=50).text("Tap radius (px)")); ui.add( @@ -43,17 +54,20 @@ impl ExposeHud for blade_render::DenoiserConfig { impl ExposeHud for blade_render::PostProcConfig { fn populate_hud(&mut self, ui: &mut egui::Ui) { - ui.add( - egui::Slider::new(&mut self.average_luminocity, 0.1f32..=1_000f32) - .text("Average luminocity") - .logarithmic(true), - ); - ui.add( - egui::Slider::new(&mut self.exposure_key_value, 0.01f32..=10f32) - .text("Key value") - .logarithmic(true), - ); - ui.add(egui::Slider::new(&mut self.white_level, 0.1f32..=2f32).text("White level")); + ui.checkbox(&mut self.tone_map, "Tone map"); + ui.add_enabled_ui(self.tone_map, |ui| { + ui.add( + egui::Slider::new(&mut self.average_luminocity, 0.1f32..=1_000f32) + .text("Average luminocity") + .logarithmic(true), + ); + ui.add( + egui::Slider::new(&mut self.exposure_key_value, 0.01f32..=10f32) + .text("Key value") + .logarithmic(true), + ); + ui.add(egui::Slider::new(&mut self.white_level, 0.1f32..=2f32).text("White level")); + }); } } @@ -82,9 +96,6 @@ impl ExposeHud for blade_render::RasterConfig { }); }); - ui.add(egui::Slider::new(&mut self.roughness, 0.0..=1.0).text("Roughness")); - ui.add(egui::Slider::new(&mut self.metallic, 0.0..=1.0).text("Metallic")); - ui.label("Light direction"); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut self.light_dir.x).speed(0.05)); @@ -145,6 +156,21 @@ impl ExposeHud for blade_render::DebugConfig { } } +/// Pick the mode of the ray tracer, returning true when it changes. +pub fn populate_render_mode(mode: &mut blade_render::RenderMode, ui: &mut egui::Ui) -> bool { + use strum::IntoEnumIterator as _; + + let old = *mode; + egui::ComboBox::from_label("Mode") + .selected_text(format!("{mode:?}")) + .show_ui(ui, |ui| { + for value in blade_render::RenderMode::iter() { + ui.selectable_value(mode, value, format!("{value:?}")); + } + }); + *mode != old +} + pub fn populate_debug_selection( mouse_pos: &mut Option<[i32; 2]>, selection: &blade_render::SelectionInfo, diff --git a/blade-helpers/src/lib.rs b/blade-helpers/src/lib.rs index 0f5ef165..32923350 100644 --- a/blade-helpers/src/lib.rs +++ b/blade-helpers/src/lib.rs @@ -5,12 +5,15 @@ mod hud; pub use blade_render::Camera; pub use camera::ControlledCamera; -pub use hud::{ExposeHud, populate_debug_selection}; +pub use hud::{ExposeHud, populate_debug_selection, populate_render_mode}; pub fn default_ray_config() -> blade_render::RayConfig { blade_render::RayConfig { num_environment_samples: 1, + num_brdf_samples: 1, environment_importance_sampling: true, + max_bounces: 3, + max_accumulated_samples: 0, tap_count: 2, tap_radius: 20, tap_confidence_near: 15, diff --git a/blade-render/Cargo.toml b/blade-render/Cargo.toml index 2ff51fd3..a2205c7d 100644 --- a/blade-render/Cargo.toml +++ b/blade-render/Cargo.toml @@ -34,7 +34,11 @@ blade-macros = { workspace = true } bytemuck = { workspace = true } choir = { workspace = true } exr = { version = "1.6", optional = true } -gltf = { workspace = true, features = ["names", "utils"], optional = true } +gltf = { workspace = true, features = [ + "names", + "utils", + "KHR_materials_emissive_strength", +], optional = true } glam = { workspace = true } log = { workspace = true } mikktspace = { package = "bevy_mikktspace", version = "0.15.0-rc.3", optional = true } diff --git a/blade-render/code/brdf.inc.wgsl b/blade-render/code/brdf.inc.wgsl new file mode 100644 index 00000000..800b928b --- /dev/null +++ b/blade-render/code/brdf.inc.wgsl @@ -0,0 +1,127 @@ +// Physically based shading in the specular workflow: the diffuse and the +// specular responses of a surface are described independently, which is what +// the BRDF, the light sampling, and the demodulation all want to work with. +// +// Assets author materials in the glTF metallic-roughness form, see +// `material_from_metallic_roughness` for the conversion: +// https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#appendix-b-brdf-implementation + +const PI: f32 = 3.1415926; +// Specular reflectance of a dielectric surface at normal incidence. +const DIELECTRIC_F0: f32 = 0.04; +// A perfectly smooth surface has a singular specular lobe, which +// neither our light sampling nor the denoiser can deal with. +const MIN_ROUGHNESS: f32 = 0.05; + +const LUMINOCITY_WEIGHTS: vec3 = vec3(0.3, 0.4, 0.3); + +fn compute_luminocity(color: vec3) -> f32 { + return dot(color, LUMINOCITY_WEIGHTS); +} + +// Material properties of a shaded point, as stored in the G-buffer. +struct Material { + // Fraction of the light that gets diffused, i.e. the base color + // with the specularly reflected part already taken out. + diffuse_albedo: vec3, + // Specular reflectance at normal incidence. + specular_f0: vec3, + roughness: f32, +} + +// Convert the glTF metallic-roughness parameters into the specular workflow. +// +// Note: this is the only place that knows about the metalness, everything +// downstream of the G-buffer works with the diffuse/specular split. +fn material_from_metallic_roughness(base_color: vec3, metalness: f32, roughness: f32) -> Material { + var mat: Material; + mat.diffuse_albedo = base_color * (1.0 - metalness); + mat.specular_f0 = mix(vec3(DIELECTRIC_F0), base_color, metalness); + mat.roughness = roughness; + return mat; +} + +// GGX width parameter. +fn material_alpha(mat: Material) -> f32 { + let r = clamp(mat.roughness, MIN_ROUGHNESS, 1.0); + return r * r; +} + +// Probability of sampling the specular lobe instead of the diffuse one, +// proportional to how much light each of them is expected to reflect. +fn specular_sampling_ratio(mat: Material) -> f32 { + let diffuse = compute_luminocity(mat.diffuse_albedo); + let specular = compute_luminocity(mat.specular_f0); + return clamp(specular / max(diffuse + specular, 1.0e-5), 0.1, 0.9); +} + +fn fresnel_schlick(cos_theta: f32, f0: vec3) -> vec3 { + return f0 + (vec3(1.0) - f0) * pow(1.0 - cos_theta, 5.0); +} +fn fresnel_schlick_scalar(cos_theta: f32, f0: f32) -> f32 { + return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0); +} + +// Trowbridge-Reitz (GGX) normal distribution. +fn distribution_ggx(n_dot_h: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let denom = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0; + return a2 / max(PI * denom * denom, 1e-7); +} + +// Height-correlated Smith visibility, which already includes +// the "1 / (4 * NdotV * NdotL)" of the microfacet specular term. +fn visibility_smith(n_dot_v: f32, n_dot_l: f32, alpha: f32) -> f32 { + let a2 = alpha * alpha; + let lambda_v = n_dot_l * sqrt(n_dot_v * n_dot_v * (1.0 - a2) + a2); + let lambda_l = n_dot_v * sqrt(n_dot_l * n_dot_l * (1.0 - a2) + a2); + return 0.5 / max(lambda_v + lambda_l, 1e-7); +} + +// Reflected fraction of the light, split into the lobes that are +// estimated and denoised separately. Both of the terms include the +// cosine factor of the light direction. +// +// Note: `diffuse` is demodulated, it needs to be multiplied +// by the diffuse albedo of the material to get the reflected light. +struct BrdfLobes { + diffuse: f32, + specular: vec3, +} + +fn zero_brdf() -> BrdfLobes { + return BrdfLobes(0.0, vec3(0.0)); +} + +fn is_brdf_black(lobes: BrdfLobes) -> bool { + return lobes.diffuse <= 0.0 && all(lobes.specular <= vec3(0.0)); +} + +// Evaluate the BRDF for the light arriving from `light_dir`. +// All the directions are unit length and point away from the surface. +fn evaluate_brdf(mat: Material, normal: vec3, view_dir: vec3, light_dir: vec3) -> BrdfLobes { + let n_dot_l = dot(normal, light_dir); + let n_dot_v = dot(normal, view_dir); + if (n_dot_l <= 0.0 || n_dot_v <= 0.0) { + return zero_brdf(); + } + + let half_dir = normalize(view_dir + light_dir); + let n_dot_h = max(dot(normal, half_dir), 0.0); + let v_dot_h = max(dot(view_dir, half_dir), 0.0); + let alpha = material_alpha(mat); + + let fresnel = fresnel_schlick(v_dot_h, mat.specular_f0); + let specular = distribution_ggx(n_dot_h, alpha) * visibility_smith(n_dot_v, n_dot_l, alpha) * fresnel; + + // Whatever isn't reflected by the specular lobe is available to the diffuse one. + let k_diffuse = 1.0 - fresnel_schlick_scalar(v_dot_h, DIELECTRIC_F0); + + return BrdfLobes(k_diffuse * n_dot_l / PI, specular * n_dot_l); +} + +// Crude approximation of the response to uniform ambient light: +// the diffuse albedo that survives the specular reflection, plus the mirror one. +fn evaluate_ambient(mat: Material) -> vec3 { + return mat.diffuse_albedo * (vec3(1.0) - mat.specular_f0) + mat.specular_f0; +} diff --git a/blade-render/code/camera.inc.wgsl b/blade-render/code/camera.inc.wgsl index c7132373..f471381f 100644 --- a/blade-render/code/camera.inc.wgsl +++ b/blade-render/code/camera.inc.wgsl @@ -8,14 +8,19 @@ struct CameraParams { const VFLIP: vec2 = vec2(1.0, -1.0); -fn get_ray_direction(cp: CameraParams, pixel: vec2) -> vec3 { +// Direction of the ray through a point on the film, in pixel units. +fn get_ray_direction_at(cp: CameraParams, film_pos: vec2) -> vec3 { let half_size = 0.5 * vec2(cp.target_size); - let ndc = (vec2(pixel) + vec2(0.5) - half_size) / half_size; + let ndc = (film_pos - half_size) / half_size; // Right-handed coordinate system with X=right, Y=up, and Z=towards the camera let local_dir = vec3(VFLIP * ndc * tan(0.5 * cp.fov), -1.0); return normalize(qrot(cp.orientation, local_dir)); } +fn get_ray_direction(cp: CameraParams, pixel: vec2) -> vec3 { + return get_ray_direction_at(cp, vec2(pixel) + vec2(0.5)); +} + fn get_projected_pixel_float(cp: CameraParams, point: vec3) -> vec2 { let local_dir = qrot(qinv(cp.orientation), point - cp.position); if local_dir.z >= 0.0 { diff --git a/blade-render/code/color.inc.wgsl b/blade-render/code/color.inc.wgsl new file mode 100644 index 00000000..1ac15f3c --- /dev/null +++ b/blade-render/code/color.inc.wgsl @@ -0,0 +1,13 @@ +// Encode a linear value with the sRGB transfer function. +// +// Only needed when the surface passes our values straight to the display +// instead of converting them, see `SurfaceInfo::color_space`. +fn encode_srgb(linear: vec3) -> vec3 { + let low = 12.92 * linear; + let high = 1.055 * pow(max(linear, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return select(high, low, linear <= vec3(0.0031308)); +} + +fn encode_surface_color(color: vec3, needs_encoding: bool) -> vec3 { + return select(color, encode_srgb(color), needs_encoding); +} diff --git a/blade-render/code/env-light.inc.wgsl b/blade-render/code/env-light.inc.wgsl new file mode 100644 index 00000000..50e6b265 --- /dev/null +++ b/blade-render/code/env-light.inc.wgsl @@ -0,0 +1,85 @@ +// Sampling of the environment map, which is our only light source. +// +// Requires "brdf.inc.wgsl", "random.inc.wgsl", "env-importance.inc.wgsl", +// as well as the `env_map` texture with the `sampler_nearest` +// and `sampler_linear` samplers. + +struct LightSample { + radiance: vec3, + // Solid angle density of drawing this sample. + pdf: f32, + uv: vec2, +} + +fn map_equirect_dir_to_uv(dir: vec3) -> vec2 { + //Note: Y axis is up + let yaw = asin(dir.y); + let pitch = atan2(dir.x, dir.z); + return vec2(pitch + PI, -2.0 * yaw + PI) / (2.0 * PI); +} +fn map_equirect_uv_to_dir(uv: vec2) -> vec3 { + let yaw = PI * (0.5 - uv.y); + let pitch = 2.0 * PI * (uv.x - 0.5); + return vec3(cos(yaw) * sin(pitch), sin(yaw), cos(yaw) * cos(pitch)); +} + +// Radiance arriving from the environment. +// +// Note: sampled without filtering, so that it matches the density +// of `sample_light` exactly, as MIS requires. +fn evaluate_environment(dir: vec3) -> vec3 { + let uv = map_equirect_dir_to_uv(dir); + return textureSampleLevel(env_map, sampler_nearest, uv, 0.0).xyz; +} + +// Same, but filtered, for when the camera looks at the environment directly. +fn evaluate_environment_background(dir: vec3) -> vec3 { + let uv = map_equirect_dir_to_uv(dir); + return textureSampleLevel(env_map, sampler_linear, uv, 0.0).xyz; +} + +fn sample_light_from_sphere(rng: ptr) -> LightSample { + let a = random_gen(rng); + let h = 1.0 - 2.0 * random_gen(rng); // make sure to allow h==1 + let tangential = sqrt(max(0.0, 1.0 - h * h)) * sample_circle_uniform(a); + let dir = vec3(tangential.x, h, tangential.y); + var ls = LightSample(); + ls.uv = map_equirect_dir_to_uv(dir); + ls.pdf = 1.0 / (4.0 * PI); + ls.radiance = textureSampleLevel(env_map, sampler_nearest, ls.uv, 0.0).xyz; + return ls; +} + +fn sample_light_from_environment(rng: ptr) -> LightSample { + let dim = textureDimensions(env_map, 0); + let es = generate_environment_sample(rng, dim); + var ls = LightSample(); + ls.pdf = es.pdf; + // sample the incoming radiance + ls.radiance = textureLoad(env_map, es.pixel, 0).xyz; + // for determining direction - offset randomly within the texel + // this offset has to be uniformly distributed across the surface of the texel + let u = (f32(es.pixel.x) + random_gen(rng)) / f32(dim.x); + let bounds = compute_latitude_area_bounds(es.pixel.y, dim.y); + let v = acos(mix(bounds.x, bounds.y, random_gen(rng))) / PI; + ls.uv = vec2(u, v); + return ls; +} + +fn sample_light(importance: bool, rng: ptr) -> LightSample { + if (importance) { + return sample_light_from_environment(rng); + } else { + return sample_light_from_sphere(rng); + } +} + +// Solid angle density of `sample_light` for a given direction. +fn compute_light_pdf(uv: vec2, importance: bool) -> f32 { + if (!importance) { + return 1.0 / (4.0 * PI); + } + let dim = textureDimensions(env_map, 0); + let pixel = clamp(vec2(uv * vec2(dim)), vec2(0), vec2(dim) - vec2(1)); + return compute_environment_sample_pdf(pixel, dim); +} diff --git a/blade-render/code/fill-gbuf.wgsl b/blade-render/code/fill-gbuf.wgsl index 7d400361..3f9bf36b 100644 --- a/blade-render/code/fill-gbuf.wgsl +++ b/blade-render/code/fill-gbuf.wgsl @@ -1,46 +1,13 @@ enable wgpu_ray_query; +enable wgpu_binding_array; #include "quaternion.inc.wgsl" #include "camera.inc.wgsl" #include "debug.inc.wgsl" #include "debug-param.inc.wgsl" +#include "brdf.inc.wgsl" +#include "hit.inc.wgsl" #include "gbuf.inc.wgsl" -// Has to match the host! -struct Vertex { - pos: vec3, - bitangent_sign: f32, - tex_coords: vec2, - normal: u32, - tangent: u32, -} -struct VertexBuffer { - data: array, -} -struct IndexBuffer { - data: array, -} -var vertex_buffers: binding_array; -var index_buffers: binding_array; -var textures: binding_array>; -var sampler_linear: sampler; -var sampler_nearest: sampler; - -struct HitEntry { - index_buf: u32, - vertex_buf: u32, - winding: f32, - // packed quaternion - geometry_to_world_rotation: u32, - geometry_to_object: mat4x3, - prev_object_to_world: mat4x3, - base_color_texture: u32, - // packed color factor - base_color_factor: u32, - normal_texture: u32, - normal_scale: f32, -} -var hit_entries: array; - var camera: CameraParams; var prev_camera: CameraParams; var debug: DebugParams; @@ -49,14 +16,13 @@ var acc_struct: acceleration_structure; var out_depth: texture_storage_2d; var out_flat_normal: texture_storage_2d; var out_basis: texture_storage_2d; -var out_albedo: texture_storage_2d; +var out_diffuse_albedo: texture_storage_2d; +// RGB is the specular reflectance at normal incidence, alpha is the roughness +var out_specular_f0: texture_storage_2d; +var out_emissive: texture_storage_2d; var out_motion: texture_storage_2d; var out_debug: texture_storage_2d; -fn decode_normal(raw: u32) -> vec3 { - return unpack4x8snorm(raw).xyz; -} - fn debug_raw_normal(pos: vec3, normal_raw: u32, rotation: vec4, debug_len: f32, color: u32) { let nw = normalize(qrot(rotation, decode_normal(normal_raw))); debug_line(pos, pos + debug_len * nw, color); @@ -80,7 +46,10 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { var depth = 0.0; var basis = vec4(0.0); var flat_normal = vec3(0.0); - var albedo = vec3(1.0); + // Note: the sky is fully diffuse and white, so that the environment + // survives the modulation in the post-processing. + var material = Material(vec3(1.0), vec3(0.0), 0.0); + var emissive = vec3(0.0); var motion = vec2(0.0); let enable_debug = all(global_id.xy == debug.mouse_pos); @@ -88,12 +57,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let entry = hit_entries[intersection.instance_custom_data + intersection.geometry_index]; depth = intersection.t; - var indices = intersection.primitive_index * 3u + vec3(0u, 1u, 2u); - if (entry.index_buf != ~0u) { - let iptr = &index_buffers[entry.index_buf].data; - indices = vec3((*iptr)[indices.x], (*iptr)[indices.y], (*iptr)[indices.z]); - } - + let indices = fetch_triangle_indices(entry, intersection.primitive_index); let vptr = &vertex_buffers[entry.vertex_buf].data; let vertices = array( (*vptr)[indices.x], @@ -109,7 +73,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { ); flat_normal = entry.winding * normalize(cross(positions[1].xyz - positions[0].xyz, positions[2].xyz - positions[0].xyz)); - let barycentrics = vec3(1.0 - intersection.barycentrics.x - intersection.barycentrics.y, intersection.barycentrics); + let barycentrics = make_barycentrics(intersection.barycentrics); let position_object = vec4(positions_object * barycentrics, 1.0); let tex_coords = mat3x2(vertices[0].tex_coords, vertices[1].tex_coords, vertices[2].tex_coords) * barycentrics; let normal_geo = normalize(mat3x3(decode_normal(vertices[0].normal), decode_normal(vertices[1].normal), decode_normal(vertices[2].normal)) * barycentrics); @@ -120,14 +84,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let geo_to_world_rot = normalize(unpack4x8snorm(entry.geometry_to_world_rotation)); let tangent_space_geo = mat3x3(tangent_geo, bitangent_geo, normal_geo); - var normal_local: vec3; - if ((debug.texture_flags & DebugTextureFlags_NORMAL) != 0u) { - normal_local = vec3(0.0, 0.0, 1.0); // ignore normal map - } else { - let raw_unorm = textureSampleLevel(textures[entry.normal_texture], sampler_linear, tex_coords, lod).xy; - let n_xy = entry.normal_scale * (2.0 * raw_unorm - 1.0); - normal_local = vec3(n_xy, sqrt(max(0.0, 1.0 - dot(n_xy, n_xy)))); - } + let normal_local = sample_hit_normal_map(entry, tex_coords, lod, debug.texture_flags); var normal = qrot(geo_to_world_rot, tangent_space_geo * normal_local); basis = shortest_arc_quat(vec3(0.0, 0.0, 1.0), normalize(normal)); @@ -166,20 +123,15 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { debug_line(hit_position, hit_position + debug_len * qrot(basis, vec3(0.0, 0.0, 1.0)), 0xFF0000u); } - let base_color_factor = unpack4x8unorm(entry.base_color_factor); - if ((debug.texture_flags & DebugTextureFlags_ALBEDO) != 0u) { - albedo = base_color_factor.xyz; - } else { - let base_color_sample = textureSampleLevel(textures[entry.base_color_texture], sampler_linear, tex_coords, lod); - albedo = (base_color_factor * base_color_sample).xyz; - } + material = sample_hit_material(entry, tex_coords, lod, debug.texture_flags); + emissive = sample_hit_emissive(entry, tex_coords, lod, debug.texture_flags); if (WRITE_DEBUG_IMAGE) { if (debug.view_mode == DebugMode_DiffuseAlbedoTexture) { - textureStore(out_debug, global_id.xy, vec4(albedo, 0.0)); + textureStore(out_debug, global_id.xy, vec4(material.diffuse_albedo, 0.0)); } if (debug.view_mode == DebugMode_DiffuseAlbedoFactor) { - textureStore(out_debug, global_id.xy, base_color_factor); + textureStore(out_debug, global_id.xy, unpack4x8unorm(entry.base_color_factor)); } if (debug.view_mode == DebugMode_NormalTexture) { textureStore(out_debug, global_id.xy, vec4(normal_local, 0.0)); @@ -187,6 +139,15 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { if (debug.view_mode == DebugMode_NormalScale) { textureStore(out_debug, global_id.xy, vec4(entry.normal_scale)); } + if (debug.view_mode == DebugMode_Roughness) { + textureStore(out_debug, global_id.xy, vec4(material.roughness)); + } + if (debug.view_mode == DebugMode_SpecularF0) { + textureStore(out_debug, global_id.xy, vec4(material.specular_f0, 0.0)); + } + if (debug.view_mode == DebugMode_Emissive) { + textureStore(out_debug, global_id.xy, vec4(emissive, 0.0)); + } if (debug.view_mode == DebugMode_GeometryNormal) { textureStore(out_debug, global_id.xy, vec4(normal_geo, 0.0)); } @@ -220,6 +181,8 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { textureStore(out_depth, global_id.xy, vec4(depth, 0.0, 0.0, 0.0)); textureStore(out_basis, global_id.xy, basis); textureStore(out_flat_normal, global_id.xy, vec4(flat_normal, 0.0)); - textureStore(out_albedo, global_id.xy, vec4(albedo, 0.0)); + textureStore(out_diffuse_albedo, global_id.xy, vec4(material.diffuse_albedo, 0.0)); + textureStore(out_specular_f0, global_id.xy, vec4(material.specular_f0, material.roughness)); + textureStore(out_emissive, global_id.xy, vec4(emissive, 0.0)); textureStore(out_motion, global_id.xy, vec4(motion * MOTION_SCALE, 0.0, 0.0)); } diff --git a/blade-render/code/hit.inc.wgsl b/blade-render/code/hit.inc.wgsl new file mode 100644 index 00000000..116b82e7 --- /dev/null +++ b/blade-render/code/hit.inc.wgsl @@ -0,0 +1,103 @@ +// Geometry and material data of the scene, as seen by the ray tracing passes. +// +// Requires `brdf.inc.wgsl` for the material model, +// and `debug-param.inc.wgsl` for the texture flags. + +// Has to match the host! +struct Vertex { + pos: vec3, + bitangent_sign: f32, + tex_coords: vec2, + normal: u32, + tangent: u32, +} +struct VertexBuffer { + data: array, +} +struct IndexBuffer { + data: array, +} + +// Has to match the host! +struct HitEntry { + index_buf: u32, + vertex_buf: u32, + winding: f32, + // packed quaternion + geometry_to_world_rotation: u32, + geometry_to_object: mat4x3, + prev_object_to_world: mat4x3, + base_color_texture: u32, + // packed color factor + base_color_factor: u32, + normal_texture: u32, + normal_scale: f32, + // green channel is roughness, blue channel is metalness + metallic_roughness_texture: u32, + metalness: f32, + roughness: f32, + emissive_texture: u32, + emissive_factor: vec4, +} + +var vertex_buffers: binding_array; +var index_buffers: binding_array; +var hit_entries: array; +var textures: binding_array>; +var sampler_linear: sampler; + +fn decode_normal(raw: u32) -> vec3 { + return unpack4x8snorm(raw).xyz; +} + +fn fetch_triangle_indices(entry: HitEntry, primitive_index: u32) -> vec3 { + var indices = primitive_index * 3u + vec3(0u, 1u, 2u); + if (entry.index_buf != ~0u) { + let iptr = &index_buffers[entry.index_buf].data; + indices = vec3((*iptr)[indices.x], (*iptr)[indices.y], (*iptr)[indices.z]); + } + return indices; +} + +fn make_barycentrics(uv: vec2) -> vec3 { + return vec3(1.0 - uv.x - uv.y, uv); +} + +// Read the material of a hit, converting it into the specular workflow. +// +// `ignore_textures` carries `DebugTextureFlags`, which the debug views +// use to look at the factors in isolation. +fn sample_hit_material(entry: HitEntry, tex_coords: vec2, lod: f32, ignore_textures: u32) -> Material { + var base_color = unpack4x8unorm(entry.base_color_factor).xyz; + if ((ignore_textures & DebugTextureFlags_ALBEDO) == 0u) { + base_color *= textureSampleLevel(textures[entry.base_color_texture], sampler_linear, tex_coords, lod).xyz; + } + + var metalness = entry.metalness; + var roughness = entry.roughness; + if ((ignore_textures & DebugTextureFlags_METALLIC_ROUGHNESS) == 0u) { + let mr = textureSampleLevel(textures[entry.metallic_roughness_texture], sampler_linear, tex_coords, lod); + roughness *= mr.y; + metalness *= mr.z; + } + + return material_from_metallic_roughness(base_color, metalness, roughness); +} + +fn sample_hit_emissive(entry: HitEntry, tex_coords: vec2, lod: f32, ignore_textures: u32) -> vec3 { + var emissive = entry.emissive_factor.xyz; + if ((ignore_textures & DebugTextureFlags_EMISSIVE) == 0u) { + emissive *= textureSampleLevel(textures[entry.emissive_texture], sampler_linear, tex_coords, lod).xyz; + } + return emissive; +} + +// Direction of the normal map, in tangent space. +fn sample_hit_normal_map(entry: HitEntry, tex_coords: vec2, lod: f32, ignore_textures: u32) -> vec3 { + if ((ignore_textures & DebugTextureFlags_NORMAL) != 0u) { + return vec3(0.0, 0.0, 1.0); + } + let raw_unorm = textureSampleLevel(textures[entry.normal_texture], sampler_linear, tex_coords, lod).xy; + let n_xy = entry.normal_scale * (2.0 * raw_unorm - 1.0); + return vec3(n_xy, sqrt(max(0.0, 1.0 - dot(n_xy, n_xy)))); +} diff --git a/blade-render/code/path-trace.wgsl b/blade-render/code/path-trace.wgsl new file mode 100644 index 00000000..6da8a3d4 --- /dev/null +++ b/blade-render/code/path-trace.wgsl @@ -0,0 +1,245 @@ +// The canonical renderer: a brute force path tracer. +// +// There is no reuse and no denoising here, just many paths accumulated over +// the frames, so the result converges to the ground truth. It's meant as a +// reference to compare the real-time path against, not for interactive use. +enable wgpu_ray_query; +enable wgpu_binding_array; +#include "quaternion.inc.wgsl" +#include "random.inc.wgsl" +#include "camera.inc.wgsl" +#include "debug-param.inc.wgsl" +#include "brdf.inc.wgsl" +#include "sampling.inc.wgsl" +#include "env-importance.inc.wgsl" +#include "env-light.inc.wgsl" +#include "hit.inc.wgsl" + +// Paths longer than this may get terminated by Russian roulette. +// Note: short paths are cheap, and rouletting them only adds noise. +const ROULETTE_START: u32 = 4u; +// Only meant to catch the infinities: a reference renderer shouldn't +// clamp the actual radiance, however bright the environment is. +const MAX_RADIANCE: f32 = 1.0e6; + +struct PathTraceParams { + frame_index: u32, + // light samples taken at every vertex of a path + num_environment_samples: u32, + // material samples taken per pixel, i.e. the number of paths + num_brdf_samples: u32, + max_bounces: u32, + // stop accumulating at this many samples, 0 for no limit + max_accumulated_samples: u32, + t_start: f32, + environment_importance_sampling: u32, + // when set, the previous accumulation is discarded + reset_accumulation: u32, +} + +var camera: CameraParams; +var parameters: PathTraceParams; +var acc_struct: acceleration_structure; +var env_map: texture_2d; +var sampler_nearest: sampler; +// RGB is the sum of the radiance, alpha is the number of samples in it +var accumulator: texture_storage_2d; + +struct PathVertex { + position: vec3, + // Normal of the triangle, pointing outwards. + flat_normal: vec3, + // Interpolated normal with the normal map applied. + normal: vec3, + material: Material, + emissive: vec3, +} + +fn trace_ray(position: vec3, direction: vec3, t_min: f32) -> RayIntersection { + var rq: ray_query; + rayQueryInitialize(&rq, acc_struct, + RayDesc(RAY_FLAG_CULL_NO_OPAQUE, 0xFFu, t_min, camera.depth, position, direction) + ); + rayQueryProceed(&rq); + return rayQueryGetCommittedIntersection(&rq); +} + +fn is_occluded(position: vec3, direction: vec3) -> bool { + var rq: ray_query; + let flags = RAY_FLAG_TERMINATE_ON_FIRST_HIT | RAY_FLAG_CULL_NO_OPAQUE; + rayQueryInitialize(&rq, acc_struct, + RayDesc(flags, 0xFFu, parameters.t_start, camera.depth, position, direction) + ); + rayQueryProceed(&rq); + return rayQueryGetCommittedIntersection(&rq).kind != RAY_QUERY_INTERSECTION_NONE; +} + +// Resolve the geometry and the material of a hit. +//Note: this is the leaner sibling of the body of "fill-gbuf.wgsl", +// which additionally needs the tangent space and the motion vectors. +fn resolve_hit(intersection: RayIntersection) -> PathVertex { + let entry = hit_entries[intersection.instance_custom_data + intersection.geometry_index]; + let indices = fetch_triangle_indices(entry, intersection.primitive_index); + let vptr = &vertex_buffers[entry.vertex_buf].data; + let vertices = array( + (*vptr)[indices.x], + (*vptr)[indices.y], + (*vptr)[indices.z], + ); + + let positions_object = entry.geometry_to_object * mat3x4( + vec4(vertices[0].pos, 1.0), vec4(vertices[1].pos, 1.0), vec4(vertices[2].pos, 1.0) + ); + let positions = intersection.object_to_world * mat3x4( + vec4(positions_object[0], 1.0), vec4(positions_object[1], 1.0), vec4(positions_object[2], 1.0) + ); + + let barycentrics = make_barycentrics(intersection.barycentrics); + let tex_coords = mat3x2(vertices[0].tex_coords, vertices[1].tex_coords, vertices[2].tex_coords) * barycentrics; + let normal_geo = normalize(mat3x3(decode_normal(vertices[0].normal), decode_normal(vertices[1].normal), decode_normal(vertices[2].normal)) * barycentrics); + let tangent_geo = normalize(mat3x3(decode_normal(vertices[0].tangent), decode_normal(vertices[1].tangent), decode_normal(vertices[2].tangent)) * barycentrics); + let bitangent_geo = normalize(cross(normal_geo, tangent_geo)) * vertices[0].bitangent_sign; + let tangent_space_geo = mat3x3(tangent_geo, bitangent_geo, normal_geo); + let geo_to_world_rot = normalize(unpack4x8snorm(entry.geometry_to_world_rotation)); + + let lod = 0.0; //TODO: ray differentials + var vertex: PathVertex; + vertex.position = positions * barycentrics; + vertex.flat_normal = entry.winding * normalize(cross(positions[1].xyz - positions[0].xyz, positions[2].xyz - positions[0].xyz)); + let normal_local = sample_hit_normal_map(entry, tex_coords, lod, 0u); + vertex.normal = normalize(qrot(geo_to_world_rot, tangent_space_geo * normal_local)); + vertex.material = sample_hit_material(entry, tex_coords, lod, 0u); + vertex.emissive = sample_hit_emissive(entry, tex_coords, lod, 0u); + return vertex; +} + +// Balance heuristic weight for a strategy taking `count` samples at density +// `pdf`, against another one taking `other_count` samples at `other_pdf`. +fn mis_weight(count: f32, pdf: f32, other_count: f32, other_pdf: f32) -> f32 { + let total = count * pdf + other_count * other_pdf; + return select(0.0, count * pdf / total, total > 0.0); +} + +// Estimate the light arriving at the camera through a single path. +fn trace_path(start_dir: vec3, rng: ptr) -> vec3 { + let importance = parameters.environment_importance_sampling != 0u; + let num_light = f32(parameters.num_environment_samples); + var radiance = vec3(0.0); + var throughput = vec3(1.0); + var position = camera.position; + var direction = start_dir; + // Density of the sample that generated the current ray, which is + // needed to weight the environment it may run into. Negative for + // the camera ray, since there is no other way to generate it. + var bsdf_pdf = -1.0; + var t_min = 0.0; + + for (var bounce = 0u; bounce <= parameters.max_bounces; bounce += 1u) { + let intersection = trace_ray(position, direction, t_min); + if (intersection.kind == RAY_QUERY_INTERSECTION_NONE) { + if (bsdf_pdf < 0.0) { + radiance += throughput * evaluate_environment_background(direction); + } else { + // The light sampling at the previous vertex could have found + // this direction as well, so the two have to be weighted. + let light_pdf = compute_light_pdf(map_equirect_dir_to_uv(direction), importance); + let weight = mis_weight(1.0, bsdf_pdf, num_light, light_pdf); + radiance += throughput * evaluate_environment(direction) * weight; + } + break; + } + + let vertex = resolve_hit(intersection); + let view_dir = -direction; + radiance += throughput * vertex.emissive; + position = vertex.position; + t_min = parameters.t_start; + + // Whether the path will be extended by a BSDF sampled ray. When it + // will not, next event estimation is the only strategy that can find + // the light at this vertex, so it has to carry the whole contribution + // instead of the share the balance heuristic would leave it. + let will_extend = bounce < parameters.max_bounces && parameters.num_brdf_samples != 0u; + let bsdf_count = select(0.0, 1.0, will_extend); + + // Next event estimation: connect to the environment light. + for (var i = 0u; i < parameters.num_environment_samples; i += 1u) { + let ls = sample_light(importance, rng); + if (ls.pdf <= 0.0) { + continue; + } + let light_dir = map_equirect_uv_to_dir(ls.uv); + let bsdf = evaluate_bsdf(vertex.material, vertex.normal, view_dir, light_dir); + if (dot(light_dir, vertex.flat_normal) <= 0.0 || all(bsdf <= vec3(0.0)) + || is_occluded(position, light_dir)) { + continue; + } + let other_pdf = compute_bsdf_pdf(vertex.material, vertex.normal, view_dir, light_dir); + let weight = mis_weight(num_light, ls.pdf, bsdf_count, other_pdf) / (num_light * ls.pdf); + radiance += throughput * bsdf * ls.radiance * weight; + } + + if (!will_extend) { + // The next event estimation above was the last thing to do here. + break; + } + + // Extend the path along a direction drawn from the material. + let bs = sample_bsdf(vertex.material, vertex.normal, view_dir, rng); + if (bs.pdf <= 0.0 || dot(bs.dir, vertex.flat_normal) <= 0.0) { + break; + } + let bsdf = evaluate_bsdf(vertex.material, vertex.normal, view_dir, bs.dir); + throughput *= bsdf / bs.pdf; + bsdf_pdf = bs.pdf; + direction = bs.dir; + + // Russian roulette on the remaining energy. + if (bounce >= ROULETTE_START) { + let probability = clamp(compute_luminocity(throughput), 0.05, 1.0); + if (random_gen(rng) >= probability) { + break; + } + throughput /= probability; + } + if (all(throughput <= vec3(0.0))) { + break; + } + } + + // A single bad path would poison the accumulator forever. + let is_finite = all(radiance == radiance); + return select(vec3(0.0), min(radiance, vec3(MAX_RADIANCE)), is_finite); +} + +@compute @workgroup_size(8, 4) +fn main(@builtin(global_invocation_id) global_id: vec3) { + if (any(global_id.xy >= camera.target_size)) { + return; + } + + var total = vec4(0.0); + if (parameters.reset_accumulation == 0u) { + total = textureLoad(accumulator, global_id.xy); + if (parameters.max_accumulated_samples != 0u + && total.w >= f32(parameters.max_accumulated_samples)) { + // Converged enough, leave the accumulator alone. + return; + } + } + + let global_index = global_id.y * camera.target_size.x + global_id.x; + var rng = random_init(global_index, parameters.frame_index); + + // Each of the material samples at the primary hit starts a path of its own. + let num_paths = max(parameters.num_brdf_samples, 1u); + var sum = vec3(0.0); + for (var i = 0u; i < num_paths; i += 1u) { + // Jitter within the pixel, which anti-aliases for free. + let jitter = vec2(random_gen(&rng), random_gen(&rng)); + let ray_dir = get_ray_direction_at(camera, vec2(global_id.xy) + jitter); + sum += trace_path(ray_dir, &rng); + } + + textureStore(accumulator, global_id.xy, total + vec4(sum, f32(num_paths))); +} diff --git a/blade-render/code/post-proc.wgsl b/blade-render/code/post-proc.wgsl index dc708bb3..8f9be31a 100644 --- a/blade-render/code/post-proc.wgsl +++ b/blade-render/code/post-proc.wgsl @@ -1,18 +1,27 @@ #include "debug.inc.wgsl" +#include "color.inc.wgsl" #include "debug-param.inc.wgsl" -struct ToneMapParams { - enabled: u32, +struct PostProcParams { + tone_map_enabled: u32, average_lum: f32, key_value: f32, // minimum value of the pixels mapped to white brightness white_level: f32, + // when set, the color comes from the path traced accumulator + accumulated: u32, + // when set, the surface needs the values encoded for the display + encode_srgb: u32, } -var t_albedo: texture_2d; +var t_diffuse_albedo: texture_2d; +var t_emissive: texture_2d; var light_diffuse: texture_2d; +var light_specular: texture_2d; +// RGB is the sum of the radiance, alpha is the number of samples in it +var t_accumulation: texture_2d; var t_debug: texture_2d; -var tone_map_params: ToneMapParams; +var post_proc_params: PostProcParams; var debug_params: DebugParams; struct VertexOutput { @@ -31,21 +40,35 @@ fn postfx_vs(@builtin(vertex_index) vi: u32) -> VertexOutput { @fragment fn postfx_fs(vo: VertexOutput) -> @location(0) vec4 { let tc = vec2(i32(vo.clip_pos.x), i32(vo.clip_pos.y)); - let illumunation = textureLoad(light_diffuse, tc, 0); + let illumination = textureLoad(light_diffuse, tc, 0); if (debug_params.view_mode == DebugMode_Final) { - let albedo = textureLoad(t_albedo, tc, 0).xyz; - let color = albedo.xyz * illumunation.xyz; - if (tone_map_params.enabled != 0u) { - // Following https://blog.en.uwa4d.com/2022/07/19/physically-based-renderingg-hdr-tone-mapping/ - let l_adjusted = tone_map_params.key_value / tone_map_params.average_lum * color; - let l_white = tone_map_params.white_level; - let l_ldr = l_adjusted * (1.0 + l_adjusted / (l_white*l_white)) / (1.0 + l_adjusted); - return vec4(l_ldr, 1.0); + var color: vec3; + if (post_proc_params.accumulated != 0u) { + // The canonical renderer produces the final radiance directly. + let total = textureLoad(t_accumulation, tc, 0); + color = total.xyz / max(total.w, 1.0); } else { + // The diffuse light is demodulated by the albedo, while the specular + // one is not, since it's tinted by the Fresnel reflectance. + let diffuse_albedo = textureLoad(t_diffuse_albedo, tc, 0).xyz; + let specular = textureLoad(light_specular, tc, 0).xyz; + let emissive = textureLoad(t_emissive, tc, 0).xyz; + color = diffuse_albedo * illumination.xyz + specular + emissive; + } + if (post_proc_params.tone_map_enabled == 0u) { + // Hand back the composed radiance untouched. A display transfer + // function is only defined over the display range, so a value + // that was never brought into it doesn't get encoded. return vec4(color, 1.0); } + // Following https://blog.en.uwa4d.com/2022/07/19/physically-based-renderingg-hdr-tone-mapping/ + let l_adjusted = post_proc_params.key_value / post_proc_params.average_lum * color; + let l_white = post_proc_params.white_level; + let mapped = l_adjusted * (1.0 + l_adjusted / (l_white*l_white)) / (1.0 + l_adjusted); + let encode = post_proc_params.encode_srgb != 0u; + return vec4(encode_surface_color(mapped, encode), 1.0); } else if (debug_params.view_mode == DebugMode_Variance) { - return vec4(illumunation.w); + return vec4(illumination.w); } else { return textureLoad(t_debug, tc, 0); } diff --git a/blade-render/code/raster.wgsl b/blade-render/code/raster.wgsl index 0637ec91..e1b3d47e 100644 --- a/blade-render/code/raster.wgsl +++ b/blade-render/code/raster.wgsl @@ -1,19 +1,25 @@ +#include "brdf.inc.wgsl" +#include "color.inc.wgsl" + struct RasterFrameParams { view_proj: mat4x4, inv_view_proj: mat4x4, camera_pos: vec4, + // direction towards the light light_dir: vec4, light_color: vec4, + // w component is a flag for the procedural space sky ambient_color: vec4, - material: vec4, + // x: environment map enabled, y: the surface needs sRGB encoding + settings: vec4, } -const PI: f32 = 3.1415926; - struct RasterDrawParams { model: mat4x4, normal_quat: vec4, base_color_factor: vec4, + emissive_factor: vec4, + // x: normal scale, y: metalness, z: roughness material: vec4, } @@ -44,6 +50,9 @@ var vertices: VertexBuffer; var samp: sampler; var base_color_tex: texture_2d; var normal_tex: texture_2d; +// green channel is roughness, blue channel is metalness +var metallic_roughness_tex: texture_2d; +var emissive_tex: texture_2d; fn decode_normal(raw: u32) -> vec3 { return unpack4x8snorm(raw).xyz; @@ -70,41 +79,21 @@ fn raster_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { return out; } -fn fresnel_schlick(cos_theta: f32, f0: vec3) -> vec3 { - return f0 + (vec3(1.0) - f0) * pow(1.0 - cos_theta, 5.0); -} - fn map_equirect_dir_to_uv(dir: vec3) -> vec2 { let yaw = atan2(dir.x, dir.z); let pitch = asin(clamp(dir.y, -1.0, 1.0)); return vec2((yaw / PI + 1.0) * 0.5, pitch / PI + 0.5); } -fn distribution_ggx(n: vec3, h: vec3, roughness: f32) -> f32 { - let a = roughness * roughness; - let a2 = a * a; - let n_dot_h = max(dot(n, h), 0.0); - let denom = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0; - return a2 / (PI * denom * denom); -} - -fn geometry_schlick_ggx(n_dot_v: f32, roughness: f32) -> f32 { - let r = roughness + 1.0; - let k = (r * r) / 8.0; - return n_dot_v / (n_dot_v * (1.0 - k) + k); -} - -fn geometry_smith(n: vec3, v: vec3, l: vec3, roughness: f32) -> f32 { - let n_dot_v = max(dot(n, v), 0.0); - let n_dot_l = max(dot(n, l), 0.0); - let ggx1 = geometry_schlick_ggx(n_dot_v, roughness); - let ggx2 = geometry_schlick_ggx(n_dot_l, roughness); - return ggx1 * ggx2; -} - @fragment fn raster_fs(input: VertexOutput) -> @location(0) vec4 { - let albedo = textureSample(base_color_tex, samp, input.uv).rgb * draw_params.base_color_factor.rgb; + let mr_sample = textureSample(metallic_roughness_tex, samp, input.uv); + let base_color = textureSample(base_color_tex, samp, input.uv).rgb * draw_params.base_color_factor.rgb; + let mat = material_from_metallic_roughness( + base_color, + clamp(draw_params.material.y * mr_sample.z, 0.0, 1.0), + clamp(draw_params.material.z * mr_sample.y, 0.0, 1.0), + ); var n = normalize(input.normal); let normal_scale = draw_params.material.x; @@ -119,33 +108,15 @@ fn raster_fs(input: VertexOutput) -> @location(0) vec4 { let v = normalize(frame_params.camera_pos.xyz - input.world_pos); let l = normalize(frame_params.light_dir.xyz); - let h = normalize(v + l); - - let roughness = clamp(frame_params.material.x, 0.04, 1.0); - let metallic = clamp(frame_params.material.y, 0.0, 1.0); - let f0 = mix(vec3(0.04), albedo, metallic); - - let n_dot_l = max(dot(n, l), 0.0); - let n_dot_v = max(dot(n, v), 0.0); - let d = distribution_ggx(n, h, roughness); - let g = geometry_smith(n, v, l, roughness); - let f = fresnel_schlick(max(dot(h, v), 0.0), f0); - - let numerator = d * g * f; - let denominator = max(4.0 * n_dot_v * n_dot_l, 0.001); - let specular = numerator / denominator; - - let k_s = f; - let k_d = (vec3(1.0) - k_s) * (1.0 - metallic); - let diffuse = k_d * albedo / PI; - let light = (diffuse + specular) * frame_params.light_color.xyz * n_dot_l; - let ambient = albedo * frame_params.ambient_color.xyz; - let color = ambient + light; + let brdf = evaluate_brdf(mat, n, v, l); + let light = (mat.diffuse_albedo * brdf.diffuse + brdf.specular) * frame_params.light_color.xyz; + let ambient = evaluate_ambient(mat) * frame_params.ambient_color.xyz; + let emissive = draw_params.emissive_factor.rgb * textureSample(emissive_tex, samp, input.uv).rgb; + let color = ambient + light + emissive; let mapped = color / (color + vec3(1.0)); - let gamma = pow(mapped, vec3(1.0 / 2.2)); - return vec4(gamma, 1.0); + return vec4(encode_surface_color(mapped, frame_params.settings.y > 0.5), 1.0); } struct SkyOutput { @@ -179,7 +150,7 @@ fn raster_sky_fs(input: SkyOutput) -> @location(0) vec4 { let world = sky_params.inv_view_proj * ndc; let world_pos = world.xyz / world.w; let dir = normalize(world_pos - sky_params.camera_pos.xyz); - let env_enabled = sky_params.material.z > 0.5; + let env_enabled = sky_params.settings.x > 0.5; var color = vec3(0.0); if (env_enabled) { let uv = map_equirect_dir_to_uv(dir); @@ -242,6 +213,5 @@ fn raster_sky_fs(input: SkyOutput) -> @location(0) vec4 { } } let mapped = color / (color + vec3(1.0)); - let gamma = pow(mapped, vec3(1.0 / 2.2)); - return vec4(gamma, 1.0); + return vec4(encode_surface_color(mapped, sky_params.settings.y > 0.5), 1.0); } diff --git a/blade-render/code/ray-trace.wgsl b/blade-render/code/ray-trace.wgsl index 9bd3fbf0..02b11448 100644 --- a/blade-render/code/ray-trace.wgsl +++ b/blade-render/code/ray-trace.wgsl @@ -5,10 +5,12 @@ enable wgpu_ray_query; #include "debug.inc.wgsl" #include "debug-param.inc.wgsl" #include "camera.inc.wgsl" +#include "brdf.inc.wgsl" +#include "sampling.inc.wgsl" +#include "env-light.inc.wgsl" #include "surface.inc.wgsl" #include "gbuf.inc.wgsl" -const PI: f32 = 3.1415926; const MAX_RESERVOIRS: u32 = 4u; // See "DECOUPLING SHADING AND REUSE" in // "Rearchitecting Spatiotemporal Resampling for Production" @@ -20,6 +22,7 @@ const FACTOR_CANDIDATES: u32 = 3u; struct MainParams { frame_index: u32, num_environment_samples: u32, + num_brdf_samples: u32, environment_importance_sampling: u32, tap_count: u32, tap_radius: f32, @@ -51,23 +54,36 @@ struct StoredReservoir { var reservoirs: array; var prev_reservoirs: array; -struct LightSample { - radiance: vec3, - pdf: f32, - uv: vec2, +// Reflected light, separated into the lobes that we estimate +// and denoise independently. +// +// Note: the diffuse part is demodulated, it has to be multiplied +// by the diffuse albedo of the surface. +struct Radiance { + diffuse: vec3, + specular: vec3, +} + +fn zero_radiance() -> Radiance { + return Radiance(vec3(0.0), vec3(0.0)); +} +fn reflect_light(brdf: BrdfLobes, light: vec3) -> Radiance { + return Radiance(brdf.diffuse * light, brdf.specular * light); } struct LiveReservoir { selected_uv: vec2, selected_light_index: u32, selected_target_score: f32, - selected_radiance: vec3, + selected_radiance: Radiance, weight_sum: f32, history: f32, } -fn compute_target_score(radiance: vec3) -> f32 { - return dot(radiance, vec3(0.3, 0.4, 0.3)); +// Note: the target function includes both of the lobes, so the diffuse albedo +// of the surface is needed to bring the diffuse one back into radiance. +fn compute_target_score(radiance: Radiance, diffuse_albedo: vec3) -> f32 { + return compute_luminocity(diffuse_albedo * radiance.diffuse + radiance.specular); } fn get_reservoir_index(pixel: vec2, camera: CameraParams) -> i32 { @@ -87,13 +103,13 @@ fn get_pixel_from_reservoir_index(index: i32, camera: CameraParams) -> vec2 fn bump_reservoir(r: ptr, history: f32) { (*r).history += history; } -fn make_reservoir(ls: LightSample, light_index: u32, brdf: vec3) -> LiveReservoir { +fn make_reservoir(ls: LightSample, light_index: u32, brdf: BrdfLobes, diffuse_albedo: vec3) -> LiveReservoir { var r: LiveReservoir; - r.selected_radiance = ls.radiance * brdf; + r.selected_radiance = reflect_light(brdf, ls.radiance); r.selected_uv = ls.uv; r.selected_light_index = light_index; - r.selected_target_score = compute_target_score(r.selected_radiance); - r.weight_sum = r.selected_target_score / ls.pdf; + r.selected_target_score = compute_target_score(r.selected_radiance, diffuse_albedo); + r.weight_sum = select(0.0, r.selected_target_score / ls.pdf, ls.pdf > 0.0); r.history = 1.0; return r; } @@ -117,7 +133,7 @@ fn normalize_reservoir(r: ptr, history: f32) { (*r).history = history; } } -fn unpack_reservoir(f: StoredReservoir, max_confidence: f32, radiance: vec3) -> LiveReservoir { +fn unpack_reservoir(f: StoredReservoir, max_confidence: f32, radiance: Radiance) -> LiveReservoir { var r: LiveReservoir; r.selected_light_index = f.light_index; r.selected_uv = f.light_uv; @@ -149,69 +165,25 @@ var t_basis: texture_2d; var t_prev_basis: texture_2d; var t_flat_normal: texture_2d; var t_prev_flat_normal: texture_2d; +var t_diffuse_albedo: texture_2d; +var t_prev_diffuse_albedo: texture_2d; +var t_specular_f0: texture_2d; +var t_prev_specular_f0: texture_2d; var t_motion: texture_2d; var out_diffuse: texture_storage_2d; +var out_specular: texture_storage_2d; var out_debug: texture_storage_2d; -fn sample_circle(random: f32) -> vec2 { - let angle = 2.0 * PI * random; - return vec2(cos(angle), sin(angle)); -} - -fn square(v: f32) -> f32 { - return v * v; -} - -fn map_equirect_dir_to_uv(dir: vec3) -> vec2 { - //Note: Y axis is up - let yaw = asin(dir.y); - let pitch = atan2(dir.x, dir.z); - return vec2(pitch + PI, -2.0 * yaw + PI) / (2.0 * PI); -} -fn map_equirect_uv_to_dir(uv: vec2) -> vec3 { - let yaw = PI * (0.5 - uv.y); - let pitch = 2.0 * PI * (uv.x - 0.5); - return vec3(cos(yaw) * sin(pitch), sin(yaw), cos(yaw) * cos(pitch)); -} - -fn evaluate_environment(dir: vec3) -> vec3 { - let uv = map_equirect_dir_to_uv(dir); - return textureSampleLevel(env_map, sampler_linear, uv, 0.0).xyz; -} - -fn sample_light_from_sphere(rng: ptr) -> LightSample { - let a = random_gen(rng); - let h = 1.0 - 2.0 * random_gen(rng); // make sure to allow h==1 - let tangential = sqrt(1.0 - square(h)) * sample_circle(a); - let dir = vec3(tangential.x, h, tangential.y); - var ls = LightSample(); - ls.uv = map_equirect_dir_to_uv(dir); - ls.pdf = 1.0 / (4.0 * PI); - ls.radiance = textureSampleLevel(env_map, sampler_linear, ls.uv, 0.0).xyz; - return ls; -} - -fn sample_light_from_environment(rng: ptr) -> LightSample { - let dim = textureDimensions(env_map, 0); - let es = generate_environment_sample(rng, dim); - var ls = LightSample(); - ls.pdf = es.pdf; - // sample the incoming radiance - ls.radiance = textureLoad(env_map, es.pixel, 0).xyz; - // for determining direction - offset randomly within the texel - // this offset has to be uniformly distributed across the surface of the texel - let u = (f32(es.pixel.x) + random_gen(rng)) / f32(dim.x); - let bounds = compute_latitude_area_bounds(es.pixel.y, dim.y); - let v = acos(mix(bounds.x, bounds.y, random_gen(rng))) / PI; - ls.uv = vec2(u, v); - return ls; -} - fn read_surface(pixel: vec2) -> Surface { var surface: Surface; surface.basis = normalize(textureLoad(t_basis, pixel, 0)); surface.flat_normal = normalize(textureLoad(t_flat_normal, pixel, 0).xyz); surface.depth = textureLoad(t_depth, pixel, 0).x; + surface.view_dir = -get_ray_direction(camera, pixel); + surface.diffuse_albedo = textureLoad(t_diffuse_albedo, pixel, 0).xyz; + let specular = textureLoad(t_specular_f0, pixel, 0); + surface.specular_f0 = specular.xyz; + surface.roughness = specular.w; return surface; } @@ -220,14 +192,55 @@ fn read_prev_surface(pixel: vec2) -> Surface { surface.basis = normalize(textureLoad(t_prev_basis, pixel, 0)); surface.flat_normal = normalize(textureLoad(t_prev_flat_normal, pixel, 0).xyz); surface.depth = textureLoad(t_prev_depth, pixel, 0).x; + surface.view_dir = -get_ray_direction(prev_camera, pixel); + surface.diffuse_albedo = textureLoad(t_prev_diffuse_albedo, pixel, 0).xyz; + let specular = textureLoad(t_prev_specular_f0, pixel, 0); + surface.specular_f0 = specular.xyz; + surface.roughness = specular.w; return surface; } -fn evaluate_brdf(surface: Surface, dir: vec3) -> f32 { - let lambert_brdf = 1.0 / PI; - let lambert_term = qrot(qinv(surface.basis), dir).z; - //Note: albedo not modulated - return lambert_brdf * max(0.0, lambert_term); +fn surface_normal(surface: Surface) -> vec3 { + return qrot(surface.basis, vec3(0.0, 0.0, 1.0)); +} + +fn surface_material(surface: Surface) -> Material { + return Material(surface.diffuse_albedo, surface.specular_f0, surface.roughness); +} + +// Note: the diffuse lobe isn't modulated by the albedo here, +// see `Radiance` for the reasoning. +fn evaluate_surface_brdf(surface: Surface, dir: vec3) -> BrdfLobes { + return evaluate_brdf(surface_material(surface), surface_normal(surface), surface.view_dir, dir); +} + +// Draw a candidate, following either the light or the material distribution. +// +// A narrow specular lobe can't be resolved by sampling the light alone, so +// both of the strategies contribute their share of the candidates. The +// returned density is that of their mixture, weighted by the sample counts, +// which makes the estimator a multi-sample MIS one. +fn sample_incoming_light(surface: Surface, from_light: bool, rng: ptr) -> LightSample { + let importance = parameters.environment_importance_sampling != 0u; + let mat = surface_material(surface); + let normal = surface_normal(surface); + + var ls: LightSample; + if (from_light) { + ls = sample_light(importance, rng); + } else { + let bs = sample_bsdf(mat, normal, surface.view_dir, rng); + ls.uv = map_equirect_dir_to_uv(bs.dir); + ls.radiance = evaluate_environment(bs.dir); + } + + let dir = map_equirect_uv_to_dir(ls.uv); + let num_light = f32(parameters.num_environment_samples); + let num_brdf = f32(parameters.num_brdf_samples); + ls.pdf = (num_light * compute_light_pdf(ls.uv, importance) + + num_brdf * compute_bsdf_pdf(mat, normal, surface.view_dir, dir)) + / max(num_light + num_brdf, 1.0); + return ls; } var debug_len: f32; @@ -249,18 +262,17 @@ fn check_ray_occluded(acs: acceleration_structure, position: vec3, directio return occluded; } -fn evaluate_reflected_light(surface: Surface, light_index: u32, light_uv: vec2) -> vec3 { +fn evaluate_reflected_light(surface: Surface, light_index: u32, light_uv: vec2) -> Radiance { if (light_index != 0u) { - return vec3(0.0); + return zero_radiance(); } let direction = map_equirect_uv_to_dir(light_uv); - let brdf = evaluate_brdf(surface, direction); - if (brdf <= 0.0) { - return vec3(0.0); + let brdf = evaluate_surface_brdf(surface, direction); + if (is_brdf_black(brdf)) { + return zero_radiance(); } - // Note: returns radiance not modulated by albedo let radiance = textureSampleLevel(env_map, sampler_nearest, light_uv, 0.0).xyz; - return radiance * brdf; + return reflect_light(brdf, radiance); } fn get_prev_pixel(pixel: vec2, pos_world: vec3) -> vec2 { @@ -273,12 +285,16 @@ fn get_prev_pixel(pixel: vec2, pos_world: vec3) -> vec2 { } struct TargetScore { - color: vec3, + radiance: Radiance, score: f32, } -fn make_target_score(color: vec3) -> TargetScore { - return TargetScore(color, compute_target_score(color)); +fn zero_target_score() -> TargetScore { + return TargetScore(zero_radiance(), 0.0); +} + +fn make_target_score(radiance: Radiance, diffuse_albedo: vec3) -> TargetScore { + return TargetScore(radiance, compute_target_score(radiance, diffuse_albedo)); } fn estimate_target_score_with_occlusion( @@ -286,44 +302,46 @@ fn estimate_target_score_with_occlusion( debug_len: f32, debug_color: u32, ) -> TargetScore { if (light_index != 0u) { - return TargetScore(); + return zero_target_score(); } let direction = map_equirect_uv_to_dir(light_uv); if (dot(direction, surface.flat_normal) <= 0.0) { - return TargetScore(); + return zero_target_score(); } - let brdf = evaluate_brdf(surface, direction); - if (brdf <= 0.0) { - return TargetScore(); + let brdf = evaluate_surface_brdf(surface, direction); + if (is_brdf_black(brdf)) { + return zero_target_score(); } if (check_ray_occluded(acs, position, direction, debug_len, debug_color)) { - return TargetScore(); + return zero_target_score(); } else { //Note: same as `evaluate_reflected_light` let radiance = textureSampleLevel(env_map, sampler_nearest, light_uv, 0.0).xyz; - return make_target_score(brdf * radiance); + return make_target_score(reflect_light(brdf, radiance), surface.diffuse_albedo); } } -fn evaluate_sample(ls: LightSample, surface: Surface, start_pos: vec3, debug_len: f32, debug_color: u32) -> f32 { +fn evaluate_sample(ls: LightSample, surface: Surface, start_pos: vec3, debug_len: f32, debug_color: u32) -> BrdfLobes { let dir = map_equirect_uv_to_dir(ls.uv); if (dot(dir, surface.flat_normal) <= 0.0) { - return 0.0; + return zero_brdf(); } - let brdf = evaluate_brdf(surface, dir); - if (brdf <= 0.0) { - return 0.0; + let brdf = evaluate_surface_brdf(surface, dir); + if (is_brdf_black(brdf)) { + return zero_brdf(); } - let target_score = compute_target_score(ls.radiance); + // Don't spend a ray on the samples that can't contribute much. + // Note: this is the weight the sample would get in the reservoir. + let target_score = compute_target_score(reflect_light(brdf, ls.radiance), surface.diffuse_albedo); if (target_score < 0.01 * ls.pdf) { - return 0.0; + return zero_brdf(); } if (check_ray_occluded(acc_struct, start_pos, dir, debug_len, debug_color)) { - return 0.0; + return zero_brdf(); } return brdf; @@ -334,7 +352,7 @@ fn ratio(a: f32, b: f32) -> f32 { } struct RestirOutput { - radiance: vec3, + radiance: Radiance, } fn compute_restir(surface: Surface, pixel: vec2, rng: ptr, enable_debug: bool) -> RestirOutput { @@ -342,32 +360,28 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr(0.0))); } if (WRITE_DEBUG_IMAGE && debug.view_mode == DebugMode_Depth) { textureStore(out_debug, pixel, vec4(1.0 / surface.depth)); } let position = camera.position + surface.depth * ray_dir; - let normal = qrot(surface.basis, vec3(0.0, 0.0, 1.0)); let debug_len = select(0.0, surface.depth * 0.2, enable_debug); var canonical = LiveReservoir(); - for (var i = 0u; i < parameters.num_environment_samples; i += 1u) { - var ls: LightSample; - if (parameters.environment_importance_sampling != 0u) { - ls = sample_light_from_environment(rng); - } else { - ls = sample_light_from_sphere(rng); - } - + let num_initial = parameters.num_environment_samples + parameters.num_brdf_samples; + for (var i = 0u; i < num_initial; i += 1u) { + let ls = sample_incoming_light(surface, i < parameters.num_environment_samples, rng); let brdf = evaluate_sample(ls, surface, position, debug_len, 0x00FF00u); - if (brdf > 0.0) { - let other = make_reservoir(ls, 0u, vec3(brdf)); - merge_reservoir(&canonical, other, random_gen(rng)); - } else { + if (is_brdf_black(brdf)) { bump_reservoir(&canonical, 1.0); + } else { + let other = make_reservoir(ls, 0u, brdf, surface.diffuse_albedo); + merge_reservoir(&canonical, other, random_gen(rng)); } } @@ -381,7 +395,7 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr(center_coord + offset); let other_index = get_reservoir_index(other_pixel, prev_camera); @@ -413,7 +427,8 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr(0.0); + var shaded = zero_radiance(); + var shaded_weight = 0.0; let mis_scale = 1.0 / (f32(accepted_count) + parameters.defensive_mis); var mis_canonical = select(mis_scale * parameters.defensive_mis, 1.0, accepted_count == 0u || parameters.use_pairwise_mis == 0u); let inv_count = 1.0 / f32(accepted_count); @@ -448,7 +463,7 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr, rng: ptr(color, 1.0); + let scale = other.weight_sum * neighbor.contribution_weight; + shaded.diffuse += scale * other.selected_radiance.diffuse; + shaded.specular += scale * other.selected_radiance.specular; + shaded_weight += other.weight_sum; } if (other.weight_sum <= 0.0) { bump_reservoir(&reservoir, other.history); @@ -472,7 +489,10 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr(cw * canonical.selected_radiance, 1.0); + let scale = canonical.weight_sum * cw; + shaded.diffuse += scale * canonical.selected_radiance.diffuse; + shaded.specular += scale * canonical.selected_radiance.specular; + shaded_weight += canonical.weight_sum; } merge_reservoir(&reservoir, canonical, random_gen(rng)); @@ -481,9 +501,11 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr) { let enable_restir_debug = (debug.draw_flags & DebugDrawFlags_RESTIR) != 0u && enable_debug; let ro = compute_restir(surface, vec2(global_id.xy), &rng, enable_restir_debug); - let color = ro.radiance; if (enable_debug) { + // Note: the variance is tracked on the fully modulated color + let color = surface.diffuse_albedo * ro.radiance.diffuse + ro.radiance.specular; debug_buf.variance.color_sum += color; debug_buf.variance.color2_sum += color * color; debug_buf.variance.count += 1u; } - textureStore(out_diffuse, global_id.xy, vec4(color, 1.0)); + textureStore(out_diffuse, global_id.xy, vec4(ro.radiance.diffuse, 1.0)); + textureStore(out_specular, global_id.xy, vec4(ro.radiance.specular, 1.0)); } diff --git a/blade-render/code/sampling.inc.wgsl b/blade-render/code/sampling.inc.wgsl new file mode 100644 index 00000000..d12e2ce2 --- /dev/null +++ b/blade-render/code/sampling.inc.wgsl @@ -0,0 +1,82 @@ +// Importance sampling of the material lobes. +// +// Requires "brdf.inc.wgsl" and "random.inc.wgsl". + +struct BsdfSample { + // Direction towards the light, unit length. + dir: vec3, + // Solid angle density of drawing this direction. + pdf: f32, +} + +// Orthonormal frame with Z pointing along the normal. +// Based on "Building an Orthonormal Basis, Revisited" by Duff et al. +fn make_tangent_frame(normal: vec3) -> mat3x3 { + let s = select(-1.0, 1.0, normal.z >= 0.0); + let a = -1.0 / (s + normal.z); + let b = normal.x * normal.y * a; + return mat3x3( + vec3(1.0 + s * normal.x * normal.x * a, s * b, -s * normal.x), + vec3(b, s + normal.y * normal.y * a, -normal.y), + normal, + ); +} + +fn sample_circle_uniform(random: f32) -> vec2 { + let angle = 2.0 * PI * random; + return vec2(cos(angle), sin(angle)); +} + +// Cosine weighted direction in the upper hemisphere of the tangent frame. +fn sample_hemisphere_cosine(rng: ptr) -> vec3 { + let r = random_gen(rng); + let tangential = sqrt(r) * sample_circle_uniform(random_gen(rng)); + return vec3(tangential, sqrt(max(0.0, 1.0 - r))); +} + +// Half-way vector from the GGX distribution, in the tangent frame. +fn sample_ggx_half_dir(alpha: f32, rng: ptr) -> vec3 { + let a2 = alpha * alpha; + let r = random_gen(rng); + let cos_theta = sqrt((1.0 - r) / (1.0 + (a2 - 1.0) * r)); + let sin_theta = sqrt(max(0.0, 1.0 - cos_theta * cos_theta)); + return vec3(sin_theta * sample_circle_uniform(random_gen(rng)), cos_theta); +} + +// Solid angle density of `sample_bsdf` for a given direction. +fn compute_bsdf_pdf(mat: Material, normal: vec3, view_dir: vec3, light_dir: vec3) -> f32 { + let n_dot_l = dot(normal, light_dir); + if (n_dot_l <= 0.0 || dot(normal, view_dir) <= 0.0) { + return 0.0; + } + let half_dir = normalize(view_dir + light_dir); + let n_dot_h = max(dot(normal, half_dir), 0.0); + let v_dot_h = max(dot(view_dir, half_dir), 1.0e-5); + let specular_pdf = distribution_ggx(n_dot_h, material_alpha(mat)) * n_dot_h / (4.0 * v_dot_h); + let diffuse_pdf = n_dot_l / PI; + return mix(diffuse_pdf, specular_pdf, specular_sampling_ratio(mat)); +} + +// Draw a direction from one of the material lobes. +// +// The returned density is that of the mixture of both lobes, so a caller +// doesn't need to know which one the direction came from. +fn sample_bsdf(mat: Material, normal: vec3, view_dir: vec3, rng: ptr) -> BsdfSample { + let frame = make_tangent_frame(normal); + var dir: vec3; + if (random_gen(rng) < specular_sampling_ratio(mat)) { + let half_dir = frame * sample_ggx_half_dir(material_alpha(mat), rng); + dir = 2.0 * dot(view_dir, half_dir) * half_dir - view_dir; + } else { + dir = frame * sample_hemisphere_cosine(rng); + } + dir = normalize(dir); + return BsdfSample(dir, compute_bsdf_pdf(mat, normal, view_dir, dir)); +} + +// Total reflected light for a given direction, ready to be weighted by the +// density of the sample. Both of the lobes are modulated here. +fn evaluate_bsdf(mat: Material, normal: vec3, view_dir: vec3, light_dir: vec3) -> vec3 { + let brdf = evaluate_brdf(mat, normal, view_dir, light_dir); + return mat.diffuse_albedo * brdf.diffuse + brdf.specular; +} diff --git a/blade-render/code/surface.inc.wgsl b/blade-render/code/surface.inc.wgsl index c8327777..4888a563 100644 --- a/blade-render/code/surface.inc.wgsl +++ b/blade-render/code/surface.inc.wgsl @@ -2,6 +2,15 @@ struct Surface { basis: vec4, flat_normal: vec3, depth: f32, + // Direction towards the viewer, unit length. + // Only filled in by the passes that do shading. + view_dir: vec3, + // Material properties, only filled in by the passes that do shading. + // Note: matching the fields of `Material` in "brdf.inc.wgsl", which + // isn't available to all the users of this file. + diffuse_albedo: vec3, + specular_f0: vec3, + roughness: f32, } const SIGMA_N: f32 = 4.0; diff --git a/blade-render/src/lib.rs b/blade-render/src/lib.rs index 26d02640..f4d09f90 100644 --- a/blade-render/src/lib.rs +++ b/blade-render/src/lib.rs @@ -101,7 +101,7 @@ impl From> for Object { #[cfg(not(any(gles, target_arch = "wasm32")))] #[repr(C)] -#[derive(Clone, Copy, Default, bytemuck::Zeroable, bytemuck::Pod)] +#[derive(Clone, Copy, Default, PartialEq, bytemuck::Zeroable, bytemuck::Pod)] struct CameraParams { position: [f32; 3], depth: f32, diff --git a/blade-render/src/model/mod.rs b/blade-render/src/model/mod.rs index 25004a1f..4ba7696e 100644 --- a/blade-render/src/model/mod.rs +++ b/blade-render/src/model/mod.rs @@ -20,6 +20,17 @@ const META_NORMAL: crate::texture::Meta = crate::texture::Meta { generate_mips: false, y_flip: false, }; +//Note: the metallic-roughness values are linear, so no sRGB here +const META_METALLIC_ROUGHNESS: crate::texture::Meta = crate::texture::Meta { + format: blade_graphics::TextureFormat::Bc1Unorm, + generate_mips: true, + y_flip: false, +}; +const META_EMISSIVE: crate::texture::Meta = crate::texture::Meta { + format: blade_graphics::TextureFormat::Bc1UnormSrgb, + generate_mips: true, + y_flip: false, +}; fn pack4x8snorm(v: [f32; 4]) -> u32 { v.iter().rev().fold(0u32, |u, f| { @@ -41,15 +52,43 @@ pub struct Geometry { pub material_index: usize, } +/// Surface appearance, following the glTF 2.0 metallic-roughness model. +/// +/// Each of the textures is modulated by the corresponding factor, +/// so a material without textures is fully described by the factors. //TODO: move out into a separate asset type pub struct Material { pub base_color_texture: Option>, pub base_color_factor: [f32; 4], pub normal_texture: Option>, pub normal_scale: f32, + /// Green channel is roughness, blue channel is metalness. + pub metallic_roughness_texture: Option>, + pub metalness: f32, + pub roughness: f32, + pub emissive_texture: Option>, + /// Emitted radiance, with `KHR_materials_emissive_strength` folded in. + pub emissive_factor: [f32; 3], pub transparent: bool, } +impl Default for Material { + fn default() -> Self { + Self { + base_color_texture: None, + base_color_factor: [1.0; 4], + normal_texture: None, + normal_scale: 0.0, + metallic_roughness_texture: None, + metalness: 0.0, + roughness: 0.5, + emissive_texture: None, + emissive_factor: [0.0; 3], + transparent: false, + } + } +} + pub struct Model { pub name: String, pub winding: f32, @@ -75,6 +114,11 @@ struct CookedMaterial<'a> { base_color_factor: [f32; 4], normal: TextureReference<'a>, normal_scale: f32, + metallic_roughness: TextureReference<'a>, + metalness: f32, + roughness: f32, + emissive: TextureReference<'a>, + emissive_factor: [f32; 3], transparent: bool, } @@ -478,11 +522,32 @@ impl Baker { } /// Description of a procedural model geometry. +/// +/// Each geometry gets a texture-less material of its own, +/// described by the PBR factors here. pub struct ProceduralGeometry { pub name: String, pub vertices: Vec, pub indices: Vec, pub base_color_factor: [f32; 4], + pub metalness: f32, + pub roughness: f32, + pub emissive_factor: [f32; 3], +} + +impl Default for ProceduralGeometry { + fn default() -> Self { + let material = Material::default(); + Self { + name: String::new(), + vertices: Vec::new(), + indices: Vec::new(), + base_color_factor: material.base_color_factor, + metalness: material.metalness, + roughness: material.roughness, + emissive_factor: material.emissive_factor, + } + } } impl Baker { @@ -520,6 +585,8 @@ impl Baker { let mut transform_offset = 0u64; let mut model_geometries = Vec::with_capacity(geometries.len()); let mut materials = Vec::with_capacity(geometries.len()); + let mut meshes = Vec::with_capacity(geometries.len()); + let vertex_stride = mem::size_of::() as u32; for geo in geometries.iter() { index_offset = crate::util::align_to( @@ -561,11 +628,23 @@ impl Baker { let material_index = materials.len(); materials.push(Material { - base_color_texture: None, base_color_factor: geo.base_color_factor, - normal_texture: None, - normal_scale: 0.0, - transparent: false, + metalness: geo.metalness, + roughness: geo.roughness, + emissive_factor: geo.emissive_factor, + ..Material::default() + }); + + meshes.push(blade_graphics::AccelerationStructureMesh { + vertex_data: vertex_buffer.at(start_vertex as u64 * vertex_stride as u64), + vertex_format: blade_graphics::VertexFormat::F32Vec3, + vertex_stride, + vertex_count: geo.vertices.len() as u32, + index_data: index_buffer.at(index_offset), + index_type, + triangle_count, + transform_data: transform_buffer.at(transform_offset), + is_opaque: true, }); model_geometries.push(Geometry { @@ -591,9 +670,49 @@ impl Baker { vertex_buffer, index_buffer, transform_buffer, - acceleration_structure: blade_graphics::AccelerationStructure::default(), + acceleration_structure: self.build_blas(name, meshes), } } + + /// Schedule building of a bottom level acceleration structure for the given meshes. + /// + /// Returns a null acceleration structure if ray tracing isn't supported. + fn build_blas( + &self, + name: &str, + meshes: Vec, + ) -> blade_graphics::AccelerationStructure { + if self.gpu_context.capabilities().ray_query.is_empty() { + return blade_graphics::AccelerationStructure::default(); + } + + let sizes = self + .gpu_context + .get_bottom_level_acceleration_structure_sizes(&meshes); + let acceleration_structure = self.gpu_context.create_acceleration_structure( + blade_graphics::AccelerationStructureDesc { + name, + ty: blade_graphics::AccelerationStructureType::BottomLevel, + size: sizes.data, + }, + ); + let scratch = self.gpu_context.create_buffer(blade_graphics::BufferDesc { + name: "BLAS scratch", + size: sizes.scratch, + memory: blade_graphics::Memory::Device, + }); + + self.pending_operations + .lock() + .unwrap() + .blas_constructs + .push(BlasConstruct { + meshes, + scratch, + dst: acceleration_structure, + }); + acceleration_structure + } } impl blade_asset::Baker for Baker { @@ -652,6 +771,7 @@ impl blade_asset::Baker for Baker { }; for g_material in document.materials() { let pbr = g_material.pbr_metallic_roughness(); + let emissive_strength = g_material.emissive_strength().unwrap_or(1.0); model.materials.push(CookedMaterial { base_color: TextureReference { source_index: match pbr.base_color_texture() { @@ -679,6 +799,35 @@ impl blade_asset::Baker for Baker { ..Default::default() }, normal_scale: g_material.normal_texture().map_or(0.0, |info| info.scale()), + metallic_roughness: TextureReference { + source_index: match pbr.metallic_roughness_texture() { + Some(info) => sources.insert(self.cook_texture( + info.texture(), + META_METALLIC_ROUGHNESS, + &cooker, + &buffers, + )), + None => !0, + }, + ..Default::default() + }, + metalness: pbr.metallic_factor(), + roughness: pbr.roughness_factor(), + emissive: TextureReference { + source_index: match g_material.emissive_texture() { + Some(info) => sources.insert(self.cook_texture( + info.texture(), + META_EMISSIVE, + &cooker, + &buffers, + )), + None => !0, + }, + ..Default::default() + }, + emissive_factor: g_material + .emissive_factor() + .map(|c| c * emissive_strength), transparent: g_material.alpha_mode() != gltf::material::AlphaMode::Opaque, }); } @@ -728,6 +877,8 @@ impl blade_asset::Baker for Baker { for material in model.materials.iter_mut() { material.base_color.complete(&sources); material.normal.complete(&sources); + material.metallic_roughness.complete(&sources); + material.emissive.complete(&sources); } cooker.finish(model); }); @@ -751,6 +902,19 @@ impl blade_asset::Baker for Baker { base_color_factor: material.base_color_factor, normal_texture: self.serve_texture(&material.normal, META_NORMAL, exe_context), normal_scale: material.normal_scale, + metallic_roughness_texture: self.serve_texture( + &material.metallic_roughness, + META_METALLIC_ROUGHNESS, + exe_context, + ), + metalness: material.metalness, + roughness: material.roughness, + emissive_texture: self.serve_texture( + &material.emissive, + META_EMISSIVE, + exe_context, + ), + emissive_factor: material.emissive_factor, transparent: material.transparent, }); } @@ -870,51 +1034,25 @@ impl blade_asset::Baker for Baker { assert!(index_offset <= total_index_size); assert_eq!(transform_offset, total_transform_size); - let ray_tracing_enabled = !self.gpu_context.capabilities().ray_query.is_empty(); - let (acceleration_structure, scratch) = if ray_tracing_enabled { - let sizes = self - .gpu_context - .get_bottom_level_acceleration_structure_sizes(&meshes); - let acceleration_structure = self.gpu_context.create_acceleration_structure( - blade_graphics::AccelerationStructureDesc { - name: str::from_utf8(model.name).unwrap(), - ty: blade_graphics::AccelerationStructureType::BottomLevel, - size: sizes.data, - }, - ); - let scratch = self.gpu_context.create_buffer(blade_graphics::BufferDesc { - name: "BLAS scratch", - size: sizes.scratch, - memory: blade_graphics::Memory::Device, + { + let mut pending_ops = self.pending_operations.lock().unwrap(); + pending_ops.transfers.push(Transfer { + stage: vertex_stage, + dst: vertex_buffer, + size: total_vertex_size, }); - (acceleration_structure, Some(scratch)) - } else { - (blade_graphics::AccelerationStructure::default(), None) - }; - - let mut pending_ops = self.pending_operations.lock().unwrap(); - pending_ops.transfers.push(Transfer { - stage: vertex_stage, - dst: vertex_buffer, - size: total_vertex_size, - }); - pending_ops.transfers.push(Transfer { - stage: index_stage, - dst: index_buffer, - size: total_index_size, - }); - pending_ops.transfers.push(Transfer { - stage: transform_stage, - dst: transform_buffer, - size: total_transform_size, - }); - if let Some(scratch) = scratch { - pending_ops.blas_constructs.push(BlasConstruct { - meshes, - scratch, - dst: acceleration_structure, + pending_ops.transfers.push(Transfer { + stage: index_stage, + dst: index_buffer, + size: total_index_size, + }); + pending_ops.transfers.push(Transfer { + stage: transform_stage, + dst: transform_buffer, + size: total_transform_size, }); } + let acceleration_structure = self.build_blas(str::from_utf8(model.name).unwrap(), meshes); Model { name: String::from_utf8_lossy(model.name).into_owned(), diff --git a/blade-render/src/raster/mod.rs b/blade-render/src/raster/mod.rs index e42e7024..8a452bbd 100644 --- a/blade-render/src/raster/mod.rs +++ b/blade-render/src/raster/mod.rs @@ -2,15 +2,18 @@ use crate::{AssetHub, CameraParams, DummyResources, Object, Shaders, Vertex}; use blade_graphics as gpu; use std::mem; +/// Configuration of the rasterized frame. +/// +/// Note: the surface appearance is described by the materials of the +/// models, this is only about the scene-wide lighting. #[derive(Clone, Copy, Debug)] pub struct RasterConfig { pub clear_color: gpu::TextureColor, + /// Direction *towards* the single directional light. pub light_dir: mint::Vector3, pub light_color: mint::Vector3, pub ambient_color: mint::Vector3, - pub roughness: f32, - pub metallic: f32, - /// When true, the sky fallback renders pure black instead of a blue gradient. + /// When true, the sky fallback renders stars instead of a blue gradient. pub space_sky: bool, } @@ -19,9 +22,9 @@ impl Default for RasterConfig { Self { clear_color: gpu::TextureColor::OpaqueBlack, light_dir: mint::Vector3 { - x: -0.3, - y: -1.0, - z: -0.2, + x: 0.3, + y: 1.0, + z: 0.2, }, light_color: mint::Vector3 { x: 3.0, @@ -33,8 +36,6 @@ impl Default for RasterConfig { y: 0.05, z: 0.05, }, - roughness: 0.4, - metallic: 0.0, space_sky: false, } } @@ -49,7 +50,7 @@ struct RasterFrameParams { light_dir: [f32; 4], light_color: [f32; 4], ambient_color: [f32; 4], - material: [f32; 4], + settings: [f32; 4], } #[repr(C)] @@ -58,6 +59,7 @@ struct RasterDrawParams { model: [f32; 16], normal_quat: [f32; 4], base_color_factor: [f32; 4], + emissive_factor: [f32; 4], material: [f32; 4], } @@ -69,6 +71,8 @@ struct RasterMainData { samp: gpu::Sampler, base_color_tex: gpu::TextureView, normal_tex: gpu::TextureView, + metallic_roughness_tex: gpu::TextureView, + emissive_tex: gpu::TextureView, } #[derive(blade_macros::ShaderData)] @@ -167,6 +171,7 @@ pub struct Rasterizer { depth_view: gpu::TextureView, surface_size: gpu::Extent, surface_info: gpu::SurfaceInfo, + color_space: gpu::ColorSpace, } impl Rasterizer { @@ -215,6 +220,7 @@ impl Rasterizer { depth_view, surface_size: config.surface_size, surface_info: config.surface_info, + color_space: config.color_space, } } @@ -328,10 +334,12 @@ impl Rasterizer { } None => (self.dummy.white_view, 0.0), }; - let base_color_tex = match material.base_color_texture { - Some(handle) => asset_hub.textures[handle].view, - None => self.dummy.white_view, - }; + //Note: the dummies are white, so that the factors are unaffected + let texture_or_white = + |handle: Option>| match handle { + Some(handle) => asset_hub.textures[handle].view, + None => self.dummy.white_view, + }; pc.bind( 0, @@ -346,12 +354,27 @@ impl Rasterizer { material.base_color_factor[2] * object.color_tint[2], material.base_color_factor[3] * object.color_tint[3], ], - material: [normal_scale, 0.0, 0.0, 0.0], + emissive_factor: [ + material.emissive_factor[0], + material.emissive_factor[1], + material.emissive_factor[2], + 0.0, + ], + material: [ + normal_scale, + material.metalness, + material.roughness, + 0.0, + ], }, vertices: model.vertex_buffer.at(0), samp: self.sampler_linear, - base_color_tex, + base_color_tex: texture_or_white(material.base_color_texture), normal_tex, + metallic_roughness_tex: texture_or_white( + material.metallic_roughness_texture, + ), + emissive_tex: texture_or_white(material.emissive_texture), }, ); @@ -524,10 +547,11 @@ impl Rasterizer { let c = config.ambient_color; [c.x, c.y, c.z, config.space_sky as u32 as f32] }, - material: [ - config.roughness, - config.metallic, + settings: [ env_map_enabled as u32 as f32, + // the surface may expect us to encode the values ourselves + (self.color_space == gpu::ColorSpace::Srgb) as u32 as f32, + 0.0, 0.0, ], } diff --git a/blade-render/src/render/mod.rs b/blade-render/src/render/mod.rs index afe88a51..2b250d43 100644 --- a/blade-render/src/render/mod.rs +++ b/blade-render/src/render/mod.rs @@ -33,6 +33,13 @@ fn mat3_transform(t_orig: &blade_graphics::Transform) -> glam::Mat3 { pub struct RenderConfig { pub surface_size: blade_graphics::Extent, pub surface_info: blade_graphics::SurfaceInfo, + /// Color space to produce the image in, matching the one the + /// surface was configured with. + /// + /// `Linear` leaves the encoding to the platform, which is what an sRGB + /// surface format does for us. `Srgb` means the values are passed to the + /// display as they are, so we have to encode them ourselves. + pub color_space: blade_graphics::ColorSpace, pub max_debug_lines: u32, } @@ -58,6 +65,9 @@ pub enum DebugMode { Motion = 8, HitConsistency = 9, SampleReuse = 10, + Roughness = 11, + SpecularF0 = 12, + Emissive = 13, Variance = 15, } @@ -75,6 +85,8 @@ bitflags::bitflags! { pub struct DebugTextureFlags: u32 { const ALBEDO = 1; const NORMAL = 2; + const METALLIC_ROUGHNESS = 4; + const EMISSIVE = 8; } } @@ -88,13 +100,31 @@ pub struct DebugConfig { #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] pub struct RayConfig { + /// Light samples taken at every shading point. pub num_environment_samples: u32, + /// Material samples taken at every shading point. + /// + /// The canonical mode continues the path along each of them, + /// so this is also its number of paths per pixel. + pub num_brdf_samples: u32, + /// Sample the environment map by importance rather than uniformly. pub environment_importance_sampling: bool, + /// Number of surfaces a path is allowed to hit. + /// + /// Note: the real-time mode always stops at the first one. + pub max_bounces: u32, + pub t_start: f32, + /// Number of samples to accumulate before going idle, or 0 for no limit. + /// + /// Note: only used by the canonical mode. + pub max_accumulated_samples: u32, + /// Number of the neighbors to reuse the samples of. + /// + /// Note: reuse is only done by the real-time mode. pub tap_count: u32, pub tap_radius: u32, pub tap_confidence_near: u32, pub tap_confidence_far: u32, - pub t_start: f32, /// Evaluate MIS factor for ReSTIR in a pair-wise fashion. /// Adds 2 extra visibility rays per reused sample. pub pairwise_mis: bool, @@ -103,6 +133,21 @@ pub struct RayConfig { pub defensive_mis: f32, } +/// What the ray tracer does with the scene. +#[derive( + Clone, Copy, Debug, Default, PartialEq, PartialOrd, blade_macros::AsPrimitive, strum::EnumIter, +)] +#[repr(u32)] +pub enum RenderMode { + /// Real time: a single bounce, with the samples reused between + /// the neighbors and the frames, and the result denoised. + #[default] + RealTime = 0, + /// Reference: full paths with no reuse and no denoising, accumulated + /// over the frames until the camera moves. Converges to the ground truth. + Canonical = 1, +} + #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] pub struct DenoiserConfig { pub num_passes: u32, @@ -115,6 +160,15 @@ pub struct PostProcConfig { pub average_luminocity: f32, pub exposure_key_value: f32, pub white_level: f32, + /// Compress the radiance into displayable range. + /// + /// Clearing this leaves the composed linear radiance as it is, which is + /// what a target that can hold it wants: a floating point offscreen + /// texture for capture, analysis, or a neural post-process. The exposure + /// and white level above are then unused. Note that an unbounded value + /// written to a fixed point target still clamps, so the target format has + /// to be a floating point one for this to mean anything. + pub tone_map: bool, } impl Default for PostProcConfig { fn default() -> Self { @@ -122,10 +176,43 @@ impl Default for PostProcConfig { average_luminocity: 1.0, exposure_key_value: 1.0, white_level: 1.0, + tone_map: true, } } } +/// Views of the ray tracer's geometry and material buffer. +/// +/// Handed out by [`RayTracer::view_gbuffer`]. Every one is readable as a +/// sampled texture; the renderer owns them, so nothing here needs destroying. +#[derive(Clone, Copy, Debug)] +pub struct GBufferViews { + /// `R32Float`. Distance from the camera along the ray, not a projected + /// depth, so it is in world units and needs no unprojection. + pub depth: blade_graphics::TextureView, + /// `Rgba8Snorm`. The shading tangent frame as a quaternion, which is where + /// normal mapping ends up. The shading normal is the quaternion applied to + /// `+Z`: `v + 2 * cross(q.xyz, cross(q.xyz, v) + q.w * v)` for + /// `v = (0, 0, 1)`, matching `qrot` in `quaternion.inc.wgsl`. + pub basis: blade_graphics::TextureView, + /// `Rgba8Snorm`. The geometric normal in XYZ, straight from the triangle, + /// with no normal map applied. Cheaper to consume than [`basis`] when the + /// mapped detail is not wanted. + /// + /// [`basis`]: Self::basis + pub flat_normal: blade_graphics::TextureView, + /// `Rgba8Unorm`. Base color with the specularly reflected part taken out. + pub diffuse_albedo: blade_graphics::TextureView, + /// `Rgba8Unorm`. Specular reflectance at normal incidence in RGB, and the + /// roughness in alpha. + pub specular_f0: blade_graphics::TextureView, + /// Emitted radiance, in the renderer's radiance format. + pub emissive: blade_graphics::TextureView, + /// `Rg8Snorm`. Screen space motion since the previous frame, scaled by + /// `MOTION_SCALE` from `gbuf.inc.wgsl`. + pub motion: blade_graphics::TextureView, +} + pub struct SelectionInfo { pub std_deviation: mint::Vector3, pub std_deviation_history: u32, @@ -212,9 +299,17 @@ struct RestirTargets { depth: RenderTarget<2>, basis: RenderTarget<2>, flat_normal: RenderTarget<2>, - albedo: RenderTarget<1>, + /// The base color with the specularly reflected part taken out. + diffuse_albedo: RenderTarget<2>, + /// RGB is the specular reflectance at normal incidence, alpha is the roughness. + specular_f0: RenderTarget<2>, + emissive: RenderTarget<1>, motion: RenderTarget<1>, light_diffuse: RenderTarget<3>, + light_specular: RenderTarget<3>, + /// Sum of the radiance of the canonical renderer, with the + /// number of the accumulated samples in the alpha channel. + accumulation: RenderTarget<1>, camera_params: [CameraParams; 2], } @@ -265,13 +360,21 @@ impl RestirTargets { encoder, gpu, ), - albedo: RenderTarget::new( - "albedo", + diffuse_albedo: RenderTarget::new( + "diffuse-albedo", blade_graphics::TextureFormat::Rgba8Unorm, size, encoder, gpu, ), + specular_f0: RenderTarget::new( + "specular-f0", + blade_graphics::TextureFormat::Rgba8Unorm, + size, + encoder, + gpu, + ), + emissive: RenderTarget::new("emissive", RADIANCE_FORMAT, size, encoder, gpu), motion: RenderTarget::new( "motion", blade_graphics::TextureFormat::Rg8Snorm, @@ -280,6 +383,20 @@ impl RestirTargets { gpu, ), light_diffuse: RenderTarget::new("light-diffuse", RADIANCE_FORMAT, size, encoder, gpu), + light_specular: RenderTarget::new( + "light-specular", + RADIANCE_FORMAT, + size, + encoder, + gpu, + ), + accumulation: RenderTarget::new( + "accumulation", + blade_graphics::TextureFormat::Rgba32Float, + size, + encoder, + gpu, + ), camera_params: [CameraParams::default(); 2], } } @@ -292,9 +409,13 @@ impl RestirTargets { self.depth.destroy(gpu); self.basis.destroy(gpu); self.flat_normal.destroy(gpu); - self.albedo.destroy(gpu); + self.diffuse_albedo.destroy(gpu); + self.specular_f0.destroy(gpu); + self.emissive.destroy(gpu); self.motion.destroy(gpu); self.light_diffuse.destroy(gpu); + self.light_specular.destroy(gpu); + self.accumulation.destroy(gpu); } } @@ -318,6 +439,7 @@ pub struct RayTracer { post_proc_input_index: usize, fill_pipeline: blade_graphics::ComputePipeline, main_pipeline: blade_graphics::ComputePipeline, + path_trace_pipeline: blade_graphics::ComputePipeline, post_proc_pipeline: blade_graphics::RenderPipeline, blur: Blur, acceleration_structure: blade_graphics::AccelerationStructure, @@ -333,9 +455,12 @@ pub struct RayTracer { debug: DebugRender, surface_size: blade_graphics::Extent, surface_info: blade_graphics::SurfaceInfo, + color_space: blade_graphics::ColorSpace, frame_index: usize, frame_scene_built: usize, is_frozen: bool, + reset_accumulation: bool, + show_accumulation: bool, //TODO: refactor `ResourceArray` to not carry the freelist logic // This way we can embed user info into the allocator. texture_resource_lookup: @@ -357,6 +482,7 @@ pub(crate) struct DebugParams { struct MainParams { frame_index: u32, num_environment_samples: u32, + num_brdf_samples: u32, environment_importance_sampling: u32, tap_count: u32, tap_radius: f32, @@ -383,7 +509,9 @@ struct FillData<'a> { out_depth: blade_graphics::TextureView, out_basis: blade_graphics::TextureView, out_flat_normal: blade_graphics::TextureView, - out_albedo: blade_graphics::TextureView, + out_diffuse_albedo: blade_graphics::TextureView, + out_specular_f0: blade_graphics::TextureView, + out_emissive: blade_graphics::TextureView, out_motion: blade_graphics::TextureView, out_debug: blade_graphics::TextureView, } @@ -406,14 +534,48 @@ struct MainData { t_prev_basis: blade_graphics::TextureView, t_flat_normal: blade_graphics::TextureView, t_prev_flat_normal: blade_graphics::TextureView, + t_diffuse_albedo: blade_graphics::TextureView, + t_prev_diffuse_albedo: blade_graphics::TextureView, + t_specular_f0: blade_graphics::TextureView, + t_prev_specular_f0: blade_graphics::TextureView, t_motion: blade_graphics::TextureView, debug_buf: blade_graphics::BufferPiece, reservoirs: blade_graphics::BufferPiece, prev_reservoirs: blade_graphics::BufferPiece, out_diffuse: blade_graphics::TextureView, + out_specular: blade_graphics::TextureView, out_debug: blade_graphics::TextureView, } +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)] +struct PathTraceParams { + frame_index: u32, + num_environment_samples: u32, + num_brdf_samples: u32, + max_bounces: u32, + max_accumulated_samples: u32, + t_start: f32, + environment_importance_sampling: u32, + reset_accumulation: u32, +} + +#[derive(blade_macros::ShaderData)] +struct PathTraceData<'a> { + camera: CameraParams, + parameters: PathTraceParams, + acc_struct: blade_graphics::AccelerationStructure, + hit_entries: blade_graphics::BufferPiece, + index_buffers: &'a blade_graphics::BufferArray, + vertex_buffers: &'a blade_graphics::BufferArray, + textures: &'a blade_graphics::TextureArray, + sampler_linear: blade_graphics::Sampler, + sampler_nearest: blade_graphics::Sampler, + env_map: blade_graphics::TextureView, + env_weights: blade_graphics::TextureView, + accumulator: blade_graphics::TextureView, +} + #[repr(C)] #[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)] struct BlurParams { @@ -449,19 +611,24 @@ struct ATrousData { #[repr(C)] #[derive(Clone, Copy, Default, bytemuck::Zeroable, bytemuck::Pod)] -struct ToneMapParams { - enabled: u32, +struct PostProcParams { + tone_map_enabled: u32, average_lum: f32, key_value: f32, white_level: f32, + accumulated: u32, + encode_srgb: u32, } #[derive(blade_macros::ShaderData)] struct PostProcData { - t_albedo: blade_graphics::TextureView, + t_diffuse_albedo: blade_graphics::TextureView, + t_emissive: blade_graphics::TextureView, light_diffuse: blade_graphics::TextureView, + light_specular: blade_graphics::TextureView, + t_accumulation: blade_graphics::TextureView, t_debug: blade_graphics::TextureView, - tone_map_params: ToneMapParams, + post_proc_params: PostProcParams, debug_params: DebugParams, } @@ -480,6 +647,12 @@ struct HitEntry { base_color_factor: [u8; 4], normal_texture: u32, normal_scale: f32, + metallic_roughness_texture: u32, + metalness: f32, + roughness: f32, + emissive_texture: u32, + //Note: aligned to 16 bytes, matching `vec4` on the WGSL side + emissive_factor: [f32; 4], } #[derive(Clone, PartialEq)] @@ -487,6 +660,7 @@ pub struct Shaders { pub(crate) env_prepare: blade_asset::Handle, pub(crate) fill_gbuf: blade_asset::Handle, pub(crate) ray_trace: blade_asset::Handle, + pub(crate) path_trace: blade_asset::Handle, pub(crate) a_trous: blade_asset::Handle, pub(crate) post_proc: blade_asset::Handle, pub(crate) raster: blade_asset::Handle, @@ -510,6 +684,7 @@ impl Shaders { env_prepare: noop.unwrap_or_else(|| ctx.load_shader("env-prepare.wgsl")), fill_gbuf: noop.unwrap_or_else(|| ctx.load_shader("fill-gbuf.wgsl")), ray_trace: noop.unwrap_or_else(|| ctx.load_shader("ray-trace.wgsl")), + path_trace: noop.unwrap_or_else(|| ctx.load_shader("path-trace.wgsl")), a_trous: noop.unwrap_or_else(|| ctx.load_shader("a-trous.wgsl")), post_proc: noop.unwrap_or_else(|| ctx.load_shader("post-proc.wgsl")), raster: ctx.load_shader("raster.wgsl"), @@ -523,6 +698,7 @@ impl Shaders { struct ShaderPipelines { fill: blade_graphics::ComputePipeline, main: blade_graphics::ComputePipeline, + path_trace: blade_graphics::ComputePipeline, temporal_accum: blade_graphics::ComputePipeline, a_trous: blade_graphics::ComputePipeline, post_proc: blade_graphics::RenderPipeline, @@ -561,6 +737,22 @@ impl ShaderPipelines { }) } + fn create_path_trace( + shader: &blade_graphics::Shader, + gpu: &blade_graphics::Context, + ) -> blade_graphics::ComputePipeline { + shader.check_struct_size::(); + shader.check_struct_size::(); + shader.check_struct_size::(); + shader.check_struct_size::(); + let layout = ::layout(); + gpu.create_compute_pipeline(blade_graphics::ComputePipelineDesc { + name: "path-trace", + data_layouts: &[&layout], + compute: shader.at("main"), + }) + } + fn create_temporal_accum( shader: &blade_graphics::Shader, gpu: &blade_graphics::Context, @@ -618,6 +810,10 @@ impl ShaderPipelines { Ok(Self { fill: Self::create_gbuf_fill(shader_man[shaders.fill_gbuf].raw.as_ref().unwrap(), gpu), main: Self::create_ray_trace(sh_main, gpu), + path_trace: Self::create_path_trace( + shader_man[shaders.path_trace].raw.as_ref().unwrap(), + gpu, + ), temporal_accum: Self::create_temporal_accum(sh_a_trous, gpu), a_trous: Self::create_a_trous(sh_a_trous, gpu), post_proc: Self::create_post_proc( @@ -640,6 +836,10 @@ pub struct FrameConfig { pub debug_draw: bool, pub reset_variance: bool, pub reset_reservoirs: bool, + /// Throw away what the canonical renderer has accumulated so far. + /// + /// Note: this also happens automatically when the camera moves. + pub reset_accumulation: bool, } /// Temporary resources associated with a GPU frame. @@ -711,6 +911,7 @@ impl RayTracer { post_proc_input_index: 0, fill_pipeline: sp.fill, main_pipeline: sp.main, + path_trace_pipeline: sp.path_trace, post_proc_pipeline: sp.post_proc, blur: Blur { temporal_accum_pipeline: sp.temporal_accum, @@ -729,9 +930,12 @@ impl RayTracer { debug, surface_size: config.surface_size, surface_info: config.surface_info, + color_space: config.color_space, frame_index: 0, frame_scene_built: 0, is_frozen: false, + reset_accumulation: true, + show_accumulation: false, texture_resource_lookup: HashMap::default(), } } @@ -759,6 +963,7 @@ impl RayTracer { gpu.destroy_compute_pipeline(&mut self.blur.a_trous_pipeline); gpu.destroy_compute_pipeline(&mut self.fill_pipeline); gpu.destroy_compute_pipeline(&mut self.main_pipeline); + gpu.destroy_compute_pipeline(&mut self.path_trace_pipeline); gpu.destroy_render_pipeline(&mut self.post_proc_pipeline); } @@ -774,6 +979,7 @@ impl RayTracer { tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.fill_gbuf)); tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.ray_trace)); + tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.path_trace)); tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.a_trous)); tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.post_proc)); tasks.extend(asset_hub.shaders.hot_reload(&mut self.shaders.debug_draw)); @@ -803,6 +1009,11 @@ impl RayTracer { ); self.main_pipeline = ShaderPipelines::create_ray_trace(shader, gpu); } + if self.shaders.path_trace != old.path_trace + && let Ok(ref shader) = asset_hub.shaders[self.shaders.path_trace].raw + { + self.path_trace_pipeline = ShaderPipelines::create_path_trace(shader, gpu); + } if self.shaders.a_trous != old.a_trous && let Ok(ref shader) = asset_hub.shaders[self.shaders.a_trous].raw { @@ -843,6 +1054,36 @@ impl RayTracer { self.env_map.weight_view } + /// The geometry and material buffer of the frame that was last prepared. + /// + /// A post process that knows what the renderer knows can do things a post + /// process working from the color alone cannot: exact silhouettes from the + /// depth and the normals, texture detail separated from lighting by the + /// albedo, and the width of a specular highlight from the roughness. This + /// hands those out so a consumer outside the renderer — a neural upscaler, + /// a capture tool, an analysis pass — can read them. + /// + /// The views are only valid until the next [`resize_screen`], and they + /// describe the frame that [`prepare`] last filled: call it after + /// `prepare`, and read before the next one overwrites them. + /// + /// [`resize_screen`]: Self::resize_screen + /// [`prepare`]: Self::prepare + pub fn view_gbuffer(&self) -> GBufferViews { + // The geometry targets are double buffered for temporal reuse, and + // `prepare` advanced the frame index, so the current one is here. + let cur = self.frame_index % 2; + GBufferViews { + depth: self.targets.depth.views[cur], + basis: self.targets.basis.views[cur], + flat_normal: self.targets.flat_normal.views[cur], + diffuse_albedo: self.targets.diffuse_albedo.views[cur], + specular_f0: self.targets.specular_f0.views[cur], + emissive: self.targets.emissive.views[0], + motion: self.targets.motion.views[0], + } + } + #[profiling::function] pub fn resize_screen( &mut self, @@ -918,7 +1159,20 @@ impl RayTracer { let mut geometry_index = 0; let mut instances = Vec::with_capacity(objects.len()); let mut blases = Vec::with_capacity(objects.len()); - let mut texture_indices = HashMap::new(); + let mut texture_indices = + HashMap::, blade_graphics::ResourceIndex>::new(); + // Note: this only borrows `self.textures`, so the buffer arrays stay available. + let mut alloc_texture = + |handle: Option>, + dummy: blade_graphics::ResourceIndex| { + match handle { + Some(handle) => *texture_indices.entry(handle).or_insert_with(|| { + let texture = &asset_hub.textures[handle]; + self.textures.alloc(texture.view) + }), + None => dummy, + } + }; for object in objects { let m3_object = mat3_transform(&object.transform); @@ -962,13 +1216,7 @@ impl RayTracer { w: [0.0, 0.0, 0.0, 1.0].into(), }), prev_object_to_world: mat4_transform(&object.prev_transform).into(), - base_color_texture: match material.base_color_texture { - Some(handle) => *texture_indices.entry(handle).or_insert_with(|| { - let texture = &asset_hub.textures[handle]; - self.textures.alloc(texture.view) - }), - None => dummy_white, - }, + base_color_texture: alloc_texture(material.base_color_texture, dummy_white), base_color_factor: { let c = material.base_color_factor; [ @@ -978,14 +1226,20 @@ impl RayTracer { (c[3] * 255.0) as u8, ] }, - normal_texture: match material.normal_texture { - Some(handle) => *texture_indices.entry(handle).or_insert_with(|| { - let texture = &asset_hub.textures[handle]; - self.textures.alloc(texture.view) - }), - None => dummy_black, - }, + normal_texture: alloc_texture(material.normal_texture, dummy_black), normal_scale: material.normal_scale, + //Note: the dummy is white, so that the factors are unaffected + metallic_roughness_texture: alloc_texture( + material.metallic_roughness_texture, + dummy_white, + ), + metalness: material.metalness, + roughness: material.roughness, + emissive_texture: alloc_texture(material.emissive_texture, dummy_white), + emissive_factor: { + let c = material.emissive_factor; + [c[0], c[1], c[2], 0.0] + }, }; log::debug!("Entry[{geometry_index}] = {hit_entry:?}"); @@ -1111,15 +1365,91 @@ impl RayTracer { self.frame_index += 1; } self.is_frozen = config.frozen; - self.targets.camera_params[self.frame_index % 2] = self.make_camera_params(camera); - self.post_proc_input_index = self.frame_index % 2; + let cur = self.frame_index % 2; + let camera_params = self.make_camera_params(camera); + // A moving camera invalidates the accumulation of the canonical renderer. + self.reset_accumulation = + config.reset_accumulation || camera_params != self.targets.camera_params[cur ^ 1]; + self.show_accumulation = false; + self.targets.camera_params[cur] = camera_params; + self.post_proc_input_index = cur; } - /// Ray trace the scene. + /// Render a frame in the given mode. /// - /// The result is stored internally in an HDR render target. + /// The result is stored internally in an HDR render target, to be + /// brought to the screen by `post_proc`. The denoiser configuration + /// is only used by the real-time mode. + #[profiling::function] + pub fn render( + &mut self, + command_encoder: &mut blade_graphics::CommandEncoder, + mode: RenderMode, + debug_config: DebugConfig, + ray_config: RayConfig, + denoiser_config: Option, + ) { + match mode { + RenderMode::RealTime => { + self.ray_trace(command_encoder, debug_config, ray_config); + if let Some(config) = denoiser_config { + self.denoise(command_encoder, config); + } + } + RenderMode::Canonical => { + self.path_trace(command_encoder, ray_config); + } + } + } + + /// Trace full paths with no reuse and no denoising, accumulating + /// on top of what the previous frames have produced. + #[profiling::function] + fn path_trace( + &mut self, + command_encoder: &mut blade_graphics::CommandEncoder, + config: RayConfig, + ) { + let cur = self.frame_index % 2; + let mut pass = command_encoder.compute("path-trace"); + let mut pc = pass.with(&self.path_trace_pipeline); + let groups = self.path_trace_pipeline.get_dispatch_for(self.surface_size); + pc.bind( + 0, + &PathTraceData { + camera: self.targets.camera_params[cur], + parameters: PathTraceParams { + frame_index: self.frame_index as u32, + num_environment_samples: config.num_environment_samples, + num_brdf_samples: config.num_brdf_samples, + max_bounces: config.max_bounces, + max_accumulated_samples: config.max_accumulated_samples, + t_start: config.t_start, + environment_importance_sampling: config.environment_importance_sampling as u32, + reset_accumulation: self.reset_accumulation as u32, + }, + acc_struct: self.acceleration_structure, + hit_entries: self.hit_buffer.into(), + index_buffers: &self.index_buffers, + vertex_buffers: &self.vertex_buffers, + textures: &self.textures, + sampler_linear: self.samplers.linear, + sampler_nearest: self.samplers.nearest, + env_map: self.env_map.main_view, + env_weights: self.env_map.weight_view, + accumulator: self.targets.accumulation.views[0], + }, + ); + pc.dispatch(groups); + // The following frames add to what this one has produced. + self.reset_accumulation = false; + self.show_accumulation = true; + } + + /// Estimate the lighting with ReSTIR, reusing the samples of + /// the neighbors and of the previous frame. #[profiling::function] - pub fn ray_trace( + fn ray_trace( &self, command_encoder: &mut blade_graphics::CommandEncoder, debug_config: DebugConfig, @@ -1148,7 +1478,9 @@ impl RayTracer { out_depth: self.targets.depth.views[cur], out_basis: self.targets.basis.views[cur], out_flat_normal: self.targets.flat_normal.views[cur], - out_albedo: self.targets.albedo.views[0], + out_diffuse_albedo: self.targets.diffuse_albedo.views[cur], + out_specular_f0: self.targets.specular_f0.views[cur], + out_emissive: self.targets.emissive.views[0], out_motion: self.targets.motion.views[0], out_debug: self.targets.debug.views[0], }, @@ -1168,6 +1500,7 @@ impl RayTracer { parameters: MainParams { frame_index: self.frame_index as u32, num_environment_samples: ray_config.num_environment_samples, + num_brdf_samples: ray_config.num_brdf_samples, environment_importance_sampling: ray_config.environment_importance_sampling as u32, tap_count: ray_config.tap_count, @@ -1198,11 +1531,16 @@ impl RayTracer { t_prev_basis: self.targets.basis.views[prev], t_flat_normal: self.targets.flat_normal.views[cur], t_prev_flat_normal: self.targets.flat_normal.views[prev], + t_diffuse_albedo: self.targets.diffuse_albedo.views[cur], + t_prev_diffuse_albedo: self.targets.diffuse_albedo.views[prev], + t_specular_f0: self.targets.specular_f0.views[cur], + t_prev_specular_f0: self.targets.specular_f0.views[prev], t_motion: self.targets.motion.views[0], debug_buf: self.debug.buffer_resource(), reservoirs: self.targets.reservoir_buf[cur].into(), prev_reservoirs: self.targets.reservoir_buf[prev].into(), out_diffuse: self.targets.light_diffuse.views[cur], + out_specular: self.targets.light_specular.views[cur], out_debug: self.targets.debug.views[0], }, ); @@ -1212,7 +1550,7 @@ impl RayTracer { /// Perform noise reduction using SVGF. #[profiling::function] - pub fn denoise( + fn denoise( &mut self, //TODO: borrow immutably command_encoder: &mut blade_graphics::CommandEncoder, denoiser_config: DenoiserConfig, @@ -1225,6 +1563,11 @@ impl RayTracer { pad: 0, }; let (cur, prev) = self.work_indices(); + // Both of the lighting lobes are filtered the same way. + let radiance_views = [ + self.targets.light_diffuse.views, + self.targets.light_specular.views, + ]; if denoiser_config.temporal_weight < 1.0 { let mut pass = command_encoder.compute("temporal-accum"); @@ -1233,22 +1576,24 @@ impl RayTracer { .blur .a_trous_pipeline .get_dispatch_for(self.surface_size); - pc.bind( - 0, - &TemporalAccumData { - camera: self.targets.camera_params[cur], - prev_camera: self.targets.camera_params[prev], - params, - input: self.targets.light_diffuse.views[prev], - t_depth: self.targets.depth.views[cur], - t_prev_depth: self.targets.depth.views[prev], - t_flat_normal: self.targets.flat_normal.views[cur], - t_prev_flat_normal: self.targets.flat_normal.views[prev], - t_motion: self.targets.motion.views[0], - output: self.targets.light_diffuse.views[cur], - }, - ); - pc.dispatch(groups); + for views in radiance_views.iter() { + pc.bind( + 0, + &TemporalAccumData { + camera: self.targets.camera_params[cur], + prev_camera: self.targets.camera_params[prev], + params, + input: views[prev], + t_depth: self.targets.depth.views[cur], + t_prev_depth: self.targets.depth.views[prev], + t_flat_normal: self.targets.flat_normal.views[cur], + t_prev_flat_normal: self.targets.flat_normal.views[prev], + t_motion: self.targets.motion.views[0], + output: views[cur], + }, + ); + pc.dispatch(groups); + } } assert_eq!(cur, self.post_proc_input_index); @@ -1260,17 +1605,19 @@ impl RayTracer { .blur .a_trous_pipeline .get_dispatch_for(self.surface_size); - pc.bind( - 0, - &ATrousData { - params, - input: self.targets.light_diffuse.views[self.post_proc_input_index], - t_depth: self.targets.depth.views[cur], - t_flat_normal: self.targets.flat_normal.views[cur], - output: self.targets.light_diffuse.views[ping_pong[0]], - }, - ); - pc.dispatch(groups); + for views in radiance_views.iter() { + pc.bind( + 0, + &ATrousData { + params, + input: views[self.post_proc_input_index], + t_depth: self.targets.depth.views[cur], + t_flat_normal: self.targets.flat_normal.views[cur], + output: views[ping_pong[0]], + }, + ); + pc.dispatch(groups); + } self.post_proc_input_index = ping_pong[0]; ping_pong.swap(0, 1); params.iteration += 1; @@ -1293,14 +1640,19 @@ impl RayTracer { pc.bind( 0, &PostProcData { - t_albedo: self.targets.albedo.views[0], + t_diffuse_albedo: self.targets.diffuse_albedo.views[cur], + t_emissive: self.targets.emissive.views[0], light_diffuse: self.targets.light_diffuse.views[self.post_proc_input_index], + light_specular: self.targets.light_specular.views[self.post_proc_input_index], + t_accumulation: self.targets.accumulation.views[0], t_debug: self.targets.debug.views[0], - tone_map_params: ToneMapParams { - enabled: 1, + post_proc_params: PostProcParams { + tone_map_enabled: pp_config.tone_map as u32, average_lum: pp_config.average_luminocity, key_value: pp_config.exposure_key_value, white_level: pp_config.white_level, + accumulated: self.show_accumulation as u32, + encode_srgb: (self.color_space == blade_graphics::ColorSpace::Srgb) as u32, }, debug_params, }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e166a22..a76e96dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,57 @@ Changelog for *Blade* project ## (TBD) +- render: `RayTracer::view_gbuffer` hands out the geometry and material buffer + of the frame that was last prepared, as `GBufferViews`. A post process that + knows what the renderer knows can take silhouettes from the depth and the + normals, separate texture detail from lighting through the albedo, and read + the width of a specular highlight from the roughness, none of which is + recoverable from the color alone. The views belong to the renderer and stay + valid until the next `resize_screen`. +- render: `PostProcConfig::tone_map` can be cleared to leave the composed + radiance alone, so a frame can be captured as high dynamic range data rather + than only as a picture. The exposure controls are unused when it is off, and + the display transfer function is skipped along with the curve, since it is + only defined over the display range. Rendering into a floating point target + is what makes this observable — a fixed point one still clamps. + - breaking: `PostProcConfig` gained a field, so it can no longer be built + without `..Default::default()` +- render: physically based materials, authored as glTF metallic-roughness, shaded in the specular workflow + - `Material` carries metallic-roughness and emissive, both as factors and textures, cooked from glTF (including `KHR_materials_emissive_strength`) + - internally, a material is a diffuse albedo, a specular reflectance at normal incidence, and a roughness; the conversion happens in `material_from_metallic_roughness` when the textures are sampled, and is the only place aware of the metalness + - shared BRDF in `brdf.inc.wgsl`: GGX distribution, height-correlated Smith visibility, Schlick Fresnel, used by the ray tracer as well as the rasterizer + - shared lobe sampling in `sampling.inc.wgsl` and environment sampling in `env-light.inc.wgsl` + - ray tracing: material G-buffer, separate diffuse and specular lighting, light samples drawn from the BRDF as well as the environment with MIS between them + - `ProceduralGeometry` gained the PBR factors, and now builds an acceleration structure, so it can be ray traced + - breaking: `RasterConfig` no longer overrides the roughness and metalness of all the materials + - breaking: the cooked model format has changed, the asset caches need to be cleared + - new debug views for roughness, specular reflectance, and emissive +- render: `RenderMode` selects what the ray tracer does with the scene + - `RenderMode::Canonical` traces full paths with BSDF sampling and next event estimation on the environment, combined by MIS, accumulating the result over the frames with no reuse and no denoising, so it converges to the ground truth + - accumulation is reset by `FrameConfig::reset_accumulation` or by moving the camera, and can be capped by `RayConfig::max_accumulated_samples` + - breaking: `RayTracer::ray_trace` and `denoise` are replaced by `RayTracer::render`, which takes the mode + - breaking: `RayConfig` describes the sampling of both of the modes: `num_environment_samples` and `num_brdf_samples` are the light and material samples taken at a shading point, combined by multi-sample MIS, while `max_bounces` limits the path length + - the light found at the last vertex of a path is no longer partly thrown + away: next event estimation there was weighted against a BSDF sample that + the path never goes on to take, so the balance heuristic held back a share + of the contribution and nothing ever supplied it. The weight is the whole + of it whenever the path ends. Longer paths hide the loss in the throughput + they have left, so the material grid at three bounces moves by SSIM 0.9998, + while `max_bounces` of zero — direct lighting and nothing else — was + missing enough that a white furnace sphere rendered visibly darker than + the environment it has to disappear into, and now matches it exactly. +- both of the render paths now produce the color space that the surface was + configured with, taken as `RenderConfig::color_space`, instead of the + rasterizer always encoding gamma and the ray tracer never doing so +- vk: an XR swapchain honors the requested color space through its format, + since it has no way to declare one: `Linear` picks an sRGB format for the + runtime to convert, `Srgb` picks a plain one that is passed through. The + recommended configuration asks for `Srgb`, which is what the plain format + the runtimes prefer actually needs. +- vk: `xr_recommended_surface_config` and `create_xr_surface_configured` are + public, so that an application knows the configuration of its XR surface +- fix `fill-gbuf.wgsl` missing the `wgpu_binding_array` enable directive +- tests: validate the renderer shaders, snapshot the PBR material grid in both of the render paths - vk: support `VK_EXT_external_memory_host` — enable the extension, query memory-type compatibility via `vkGetMemoryHostPointerPropertiesEXT`, and round allocation size to `minImportedHostPointerAlignment` so `Memory::External(HostAllocation)` imports succeed on drivers that expose the extension ## blade-egui-0.8.2, blade-util-0.4.1 (25 Apr 2026) diff --git a/examples-android/asteroids/asteroids.rs b/examples-android/asteroids/asteroids.rs index 228d26d1..9c323504 100644 --- a/examples-android/asteroids/asteroids.rs +++ b/examples-android/asteroids/asteroids.rs @@ -146,6 +146,8 @@ impl XrInput { vertices: laser_verts, indices: laser_idxs, base_color_factor: [0.2, 1.0, 0.2, 1.0], + roughness: 0.7, + ..Default::default() }], ); let (aim_verts, aim_idxs) = mesh::generate_laser_mesh(3.0, LASER_BEAM_RADIUS * 0.5); @@ -156,6 +158,8 @@ impl XrInput { vertices: aim_verts, indices: aim_idxs, base_color_factor: [0.1, 0.3, 0.6, 1.0], + roughness: 0.7, + ..Default::default() }], ); diff --git a/examples-android/asteroids/game.rs b/examples-android/asteroids/game.rs index 117e288e..190098cc 100644 --- a/examples-android/asteroids/game.rs +++ b/examples-android/asteroids/game.rs @@ -73,6 +73,8 @@ impl AsteroidField { vertices, indices, base_color_factor: color, + roughness: 0.7, + ..Default::default() }], ); variants.push(handle); @@ -503,8 +505,6 @@ pub fn setup_game(engine: &mut blade_engine::Engine) -> GameState { y: 0.02, z: 0.03, }, - roughness: 0.7, - metallic: 0.0, space_sky: true, }); diff --git a/examples-android/asteroids/mesh.rs b/examples-android/asteroids/mesh.rs index d626bdec..0a9a0344 100644 --- a/examples-android/asteroids/mesh.rs +++ b/examples-android/asteroids/mesh.rs @@ -296,6 +296,8 @@ pub fn generate_planet_model( vertices: ocean_verts, indices: ocean_idxs, base_color_factor: ocean_color, + roughness: 0.7, + ..Default::default() }); } if !land_verts.is_empty() { @@ -304,6 +306,8 @@ pub fn generate_planet_model( vertices: land_verts, indices: land_idxs, base_color_factor: land_color, + roughness: 0.7, + ..Default::default() }); } if !ice_verts.is_empty() { @@ -312,6 +316,8 @@ pub fn generate_planet_model( vertices: ice_verts, indices: ice_idxs, base_color_factor: ice_color, + roughness: 0.7, + ..Default::default() }); } @@ -364,6 +370,8 @@ fn generate_ring_band( vertices, indices, base_color_factor: color, + roughness: 0.7, + ..Default::default() } } @@ -433,6 +441,8 @@ pub fn generate_comet_model( vertices: verts, indices: idxs, base_color_factor: [0.9, 0.95, 1.0, 1.0], + roughness: 0.7, + ..Default::default() }], ) } diff --git a/examples/scene/main.rs b/examples/scene/main.rs index a7f78467..c169fa78 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -156,6 +156,7 @@ struct Example { is_point_selected: bool, is_file_hovered: bool, ray_config: blade_render::RayConfig, + mode: blade_render::RenderMode, denoiser_enabled: bool, denoiser_config: blade_render::DenoiserConfig, post_proc_config: blade_render::PostProcConfig, @@ -219,6 +220,8 @@ impl Example { let render_config = blade_render::RenderConfig { surface_size, surface_info, + // matching what `make_surface_config` asks the surface for + color_space: gpu::ColorSpace::Linear, max_debug_lines: 1000, }; let renderer = blade_render::RayTracer::new( @@ -256,6 +259,7 @@ impl Example { is_point_selected: false, is_file_hovered: false, ray_config: blade_helpers::default_ray_config(), + mode: blade_render::RenderMode::default(), denoiser_enabled: true, denoiser_config: blade_render::DenoiserConfig { num_passes: 3, @@ -265,6 +269,7 @@ impl Example { average_luminocity: 1.0, exposure_key_value: 1.0 / 9.6, white_level: 1.0, + tone_map: true, }, debug_blit: None, debug_blit_input: DebugBlitInput::None, @@ -460,6 +465,7 @@ impl Example { debug_draw: self.is_point_selected || self.is_file_hovered, reset_variance: self.debug.mouse_pos.is_none(), reset_reservoirs: self.need_accumulation_reset, + reset_accumulation: self.need_accumulation_reset, }, ); self.need_accumulation_reset = false; @@ -467,11 +473,13 @@ impl Example { //TODO: figure out why the main RT pipeline // causes a GPU crash when there are no objects if !self.objects.is_empty() { - self.renderer - .ray_trace(command_encoder, self.debug, self.ray_config); - if self.denoiser_enabled { - self.renderer.denoise(command_encoder, self.denoiser_config); - } + self.renderer.render( + command_encoder, + self.mode, + self.debug, + self.ray_config, + self.denoiser_enabled.then_some(self.denoiser_config), + ); } } @@ -650,6 +658,8 @@ impl Example { egui::CollapsingHeader::new("Ray Trace") .default_open(false) .show(ui, |ui| { + self.need_accumulation_reset |= + blade_helpers::populate_render_mode(&mut self.mode, ui); self.ray_config.populate_hud(ui); }); self.need_accumulation_reset |= self.ray_config != old_ray_config; diff --git a/tests/gpu_examples.rs b/tests/gpu_examples.rs index fe826dc6..0569c6e9 100644 --- a/tests/gpu_examples.rs +++ b/tests/gpu_examples.rs @@ -14,10 +14,16 @@ use std::slice; #[path = "../examples/bunnymark/example.rs"] mod bunnymark_example; #[cfg(not(gles))] +mod pbr_scene; +#[cfg(not(gles))] #[path = "../examples/ray-query/example.rs"] mod ray_query_example; mod snapshot; +/// Directory with the renderer shaders, needed by the asset hub. +#[cfg(not(gles))] +const SHADER_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/blade-render/code"); + // --- Sky snapshot test structs --- #[repr(C)] @@ -29,7 +35,7 @@ struct SkyFrameParams { light_dir: [f32; 4], light_color: [f32; 4], ambient_color: [f32; 4], - material: [f32; 4], + settings: [f32; 4], } #[derive(blade_macros::ShaderData)] @@ -534,6 +540,7 @@ fn snapshot_space_sky() { height: 300, depth: 1, }; + // A plain format, like an XR swapchain: the shader has to encode let format = gpu::TextureFormat::Rgba8Unorm; // Create offscreen target @@ -574,8 +581,9 @@ fn snapshot_space_sky() { }); // Compile the raster shader and create sky pipeline (no depth attachment) + let source = snapshot::shader_source("raster.wgsl"); let shader = context.create_shader(gpu::ShaderDesc { - source: include_str!("../blade-render/code/raster.wgsl"), + source: &source, naga_module: None, }); let sky_layout = ::layout(); @@ -611,7 +619,8 @@ fn snapshot_space_sky() { light_dir: [0.0, -1.0, 0.0, 0.0], light_color: [1.0, 1.0, 1.0, 0.0], ambient_color: [0.0, 0.0, 0.0, 1.0], // w=1.0 -> space_sky mode - material: [0.4, 0.0, 0.0, 0.0], // material.z=0 -> env_enabled=false + // x=0: no environment map, y=1: encode for a non-sRGB surface + settings: [0.0, 1.0, 0.0, 0.0], }; // Render @@ -671,3 +680,750 @@ fn snapshot_space_sky() { context.destroy_command_encoder(&mut command_encoder); target.destroy(&context); } + +/// Number of accumulated frames for the ray traced snapshot. +/// +/// ReSTIR needs a bit of history to converge, but the cost is paid +/// by the software rasterizers used in CI. +#[cfg(not(gles))] +const RAY_TRACE_FRAMES: usize = 8; + +/// Frames accumulated by the canonical renderer. +/// +/// A uniform environment converges quickly, and the cost is paid +/// by the software rasterizers used in CI. +#[cfg(not(gles))] +const CANONICAL_FRAMES: usize = 32; +/// How far the real-time result may land from the canonical one, +/// as a mean absolute difference of the 8-bit channels. +#[cfg(not(gles))] +const CANONICAL_MAX_DIFFERENCE: f64 = 12.0; + +#[cfg(not(gles))] +struct PbrHarness { + context: std::sync::Arc, + choir: std::sync::Arc, + workers: Vec, + asset_hub: blade_render::AssetHub, + shaders: blade_render::Shaders, +} + +#[cfg(not(gles))] +impl PbrHarness { + /// Bring up the asset hub and cook the renderer shaders. + fn new(context: gpu::Context, cache_name: &str, ray_tracing: bool) -> Self { + let context = std::sync::Arc::new(context); + let choir = choir::Choir::new(); + let workers = (0..2) + .map(|i| choir.add_worker(&format!("{cache_name}-{i}"))) + .collect(); + let cache_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("test-assets") + .join(cache_name); + let asset_hub = blade_render::AssetHub::new(&cache_path, &choir, &context); + let (shaders, shader_task) = + blade_render::Shaders::load(SHADER_DIR.as_ref(), &asset_hub, ray_tracing); + shader_task.join(); + Self { + context, + choir, + workers, + asset_hub, + shaders, + } + } + + fn create_grid_model( + &self, + roughness_range: [f32; 2], + ) -> blade_asset::Handle { + let geometries = pbr_scene::material_grid(roughness_range); + let model = self + .asset_hub + .models + .baker + .create_model("pbr-material-grid", geometries); + self.asset_hub.models.insert(model) + } + + fn destroy(mut self) { + self.asset_hub.destroy(); + // let the workers finish before the choir goes away + self.workers.clear(); + drop(self.choir); + } +} + +/// Rasterize a grid of spheres covering the metallic-roughness space, +/// plus a row of emissive materials. +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context"] +fn snapshot_pbr_raster() { + let context = unsafe { gpu::Context::init(gpu::ContextDesc::default()).unwrap() }; + let size = gpu::Extent { + width: 400, + height: 300, + depth: 1, + }; + // The renderers write linear values, so let the hardware encode them + let format = gpu::TextureFormat::Rgba8UnormSrgb; + + let harness = PbrHarness::new(context, "pbr-raster", false); + let context = std::sync::Arc::clone(&harness.context); + let target = snapshot::OffscreenTarget::new(&context, size, format); + + let mut command_encoder = context.create_command_encoder(gpu::CommandEncoderDesc { + name: "snapshot-pbr-raster", + buffer_count: 1, + manual_barriers: false, + }); + command_encoder.start(); + + let mut rasterizer = blade_render::Rasterizer::new( + &mut command_encoder, + &context, + harness.shaders.clone(), + &harness.asset_hub.shaders, + &blade_render::RenderConfig { + surface_size: size, + surface_info: gpu::SurfaceInfo { + format, + alpha: gpu::AlphaMode::Ignored, + }, + // matching an sRGB surface: the hardware does the encoding + color_space: gpu::ColorSpace::Linear, + max_debug_lines: 16, + }, + ); + + let objects = vec![blade_render::Object::from( + harness.create_grid_model([0.05, 1.0]), + )]; + let mut temp_buffers = Vec::new(); + harness + .asset_hub + .flush(&mut command_encoder, &mut temp_buffers); + + command_encoder.init_texture(target.texture); + command_encoder.init_texture(rasterizer.depth_texture()); + if let mut pass = command_encoder.render( + "raster-pbr", + gpu::RenderTargetSet { + colors: &[gpu::RenderTarget { + view: target.view, + init_op: gpu::InitOp::Clear(gpu::TextureColor::OpaqueBlack), + finish_op: gpu::FinishOp::Store, + }], + depth_stencil: Some(gpu::RenderTarget { + view: rasterizer.depth_view(), + init_op: gpu::InitOp::Clear(gpu::TextureColor::White), + finish_op: gpu::FinishOp::Discard, + }), + }, + ) { + rasterizer.render( + &mut pass, + &pbr_scene::camera(), + &objects, + &harness.asset_hub, + None, + blade_render::RasterConfig { + light_dir: mint::Vector3 { + x: 0.4, + y: 0.5, + z: 1.0, + }, + ..Default::default() + }, + ); + } + + let pixels = target.read_pixels(&context, &mut command_encoder); + snapshot::check("pbr-raster", &pixels, size); + + for buffer in temp_buffers { + context.destroy_buffer(buffer); + } + rasterizer.destroy(&context); + context.destroy_command_encoder(&mut command_encoder); + target.destroy(&context); + harness.destroy(); +} + +/// The material grid, lit by a uniform white environment. +/// +/// This is a white furnace test: an energy conserving BRDF keeps the spheres +/// close to their base color, with the darkening coming from the roughness +/// and from the occlusion between the neighbors. +#[cfg(not(gles))] +const RAY_TRACE_SIZE: gpu::Extent = gpu::Extent { + width: 256, + height: 192, + depth: 1, +}; +/// The post processing writes linear values, so let the hardware encode them. +#[cfg(not(gles))] +const RAY_TRACE_FORMAT: gpu::TextureFormat = gpu::TextureFormat::Rgba8UnormSrgb; + +#[cfg(not(gles))] +enum RayTraceMode { + /// The real-time path: ReSTIR with a denoiser. + Restir, + /// The canonical path: accumulated brute force paths. + Canonical, +} + +/// What the post processing should hand back. +#[cfg(not(gles))] +#[derive(Clone, Copy, PartialEq)] +enum Capture { + /// Tone mapped and encoded for a display, in `Rgba8UnormSrgb`. + Display, + /// The composed linear radiance, untouched, in `Rgba32Float`. + Hdr, +} + +#[cfg(not(gles))] +impl Capture { + fn format(self) -> gpu::TextureFormat { + match self { + Self::Display => RAY_TRACE_FORMAT, + Self::Hdr => gpu::TextureFormat::Rgba32Float, + } + } +} + +/// Render the material grid with the ray tracer, if the GPU can do it. +#[cfg(not(gles))] +fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option> { + render_ray_traced_grid_as(cache_name, mode, Capture::Display, |_, _, _| {}) +} + +/// Render the grid, letting the caller look at the renderer's own state after +/// the frame is rendered and before it is torn down. +/// +/// `inspect` is where a test reaches for something the post processing does not +/// expose, such as the G-buffer. +#[cfg(not(gles))] +fn render_ray_traced_grid_as( + cache_name: &str, + mode: RayTraceMode, + capture: Capture, + inspect: impl FnOnce(&blade_render::RayTracer, &gpu::Context, &mut gpu::CommandEncoder), +) -> Option> { + // Metal acceleration structure APIs can throw uncatchable ObjC exceptions + // in CI environments, even when the device reports ray tracing support. + if cfg!(target_os = "macos") { + println!("Skipping: ray tracing snapshot not supported on macOS CI"); + return None; + } + + let context = unsafe { + match gpu::Context::init(gpu::ContextDesc { + ray_tracing: true, + ..Default::default() + }) { + Ok(c) => c, + Err(e) => { + println!("Skipping: GPU context with ray tracing not available: {e:?}"); + return None; + } + } + }; + if !context + .capabilities() + .ray_query + .contains(gpu::ShaderVisibility::COMPUTE) + { + println!("Skipping: ray_query compute not supported"); + return None; + } + + let size = RAY_TRACE_SIZE; + let harness = PbrHarness::new(context, cache_name, true); + let context = std::sync::Arc::clone(&harness.context); + let target = snapshot::OffscreenTarget::new(&context, size, capture.format()); + + let mut command_encoder = context.create_command_encoder(gpu::CommandEncoderDesc { + name: "snapshot-ray-traced-grid", + buffer_count: 1, + manual_barriers: false, + }); + command_encoder.start(); + + let mut renderer = blade_render::RayTracer::new( + &mut command_encoder, + &context, + harness.shaders.clone(), + &harness.asset_hub.shaders, + &blade_render::RenderConfig { + surface_size: size, + surface_info: gpu::SurfaceInfo { + format: capture.format(), + alpha: gpu::AlphaMode::Ignored, + }, + color_space: gpu::ColorSpace::Linear, + max_debug_lines: 16, + }, + ); + + // A narrow specular lobe is hard on the real-time estimator, + // so the smoothest materials are left out of these. + let objects = vec![blade_render::Object::from( + harness.create_grid_model([0.3, 1.0]), + )]; + let mut temp = blade_render::FrameResources::default(); + harness + .asset_hub + .flush(&mut command_encoder, &mut temp.buffers); + + let camera = pbr_scene::camera(); + let debug_config = blade_render::DebugConfig::default(); + let ray_config = blade_render::RayConfig { + // The canonical mode takes these at every vertex of every path, + // so it needs fewer of them to stay affordable. + num_environment_samples: match mode { + RayTraceMode::Restir => 4, + RayTraceMode::Canonical => 1, + }, + num_brdf_samples: 4, + // the dummy environment map has no importance sampling data + environment_importance_sampling: false, + max_bounces: 3, + max_accumulated_samples: 0, + tap_count: 2, + tap_radius: 16, + tap_confidence_near: 8, + tap_confidence_far: 4, + t_start: 0.01, + pairwise_mis: true, + defensive_mis: 0.1, + }; + let denoiser_config = blade_render::DenoiserConfig { + num_passes: 3, + temporal_weight: 0.1, + }; + let frame_count = match mode { + RayTraceMode::Restir => RAY_TRACE_FRAMES, + RayTraceMode::Canonical => CANONICAL_FRAMES, + }; + for frame_index in 0..frame_count { + renderer.build_scene( + &mut command_encoder, + &objects, + None, + &harness.asset_hub, + &context, + &mut temp, + ); + renderer.prepare( + &mut command_encoder, + &camera, + blade_render::FrameConfig { + frozen: false, + debug_draw: false, + reset_variance: frame_index == 0, + reset_reservoirs: frame_index == 0, + reset_accumulation: frame_index == 0, + }, + ); + renderer.render( + &mut command_encoder, + match mode { + RayTraceMode::Restir => blade_render::RenderMode::RealTime, + RayTraceMode::Canonical => blade_render::RenderMode::Canonical, + }, + debug_config, + ray_config, + Some(denoiser_config), + ); + } + + inspect(&renderer, &context, &mut command_encoder); + + command_encoder.init_texture(target.texture); + if let mut pass = command_encoder.render( + "ray-traced-grid", + gpu::RenderTargetSet { + colors: &[gpu::RenderTarget { + view: target.view, + init_op: gpu::InitOp::Clear(gpu::TextureColor::OpaqueBlack), + finish_op: gpu::FinishOp::Store, + }], + depth_stencil: None, + }, + ) { + renderer.post_proc( + &mut pass, + debug_config, + blade_render::PostProcConfig { + tone_map: capture == Capture::Display, + ..Default::default() + }, + &[], + &[], + ); + } + + let pixels = target.read_pixels(&context, &mut command_encoder); + + for buffer in temp.buffers { + context.destroy_buffer(buffer); + } + for acceleration_structure in temp.acceleration_structures { + context.destroy_acceleration_structure(acceleration_structure); + } + renderer.destroy(&context); + context.destroy_command_encoder(&mut command_encoder); + target.destroy(&context); + harness.destroy(); + Some(pixels) +} + +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context with ray tracing"] +fn snapshot_pbr_ray_trace() { + if let Some(pixels) = render_ray_traced_grid("pbr-ray-trace", RayTraceMode::Restir) { + snapshot::check("pbr-ray-trace", &pixels, RAY_TRACE_SIZE); + } +} + +/// Render the same grid with the canonical renderer, and confirm that the +/// real-time one lands in the same place. +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context with ray tracing"] +fn snapshot_pbr_canonical() { + let Some(pixels) = render_ray_traced_grid("pbr-canonical", RayTraceMode::Canonical) else { + return; + }; + snapshot::check("pbr-canonical", &pixels, RAY_TRACE_SIZE); + + // The real-time estimator is noisy and blurred, but it must not be + // systematically brighter or darker than the ground truth. + let (restir, restir_size) = snapshot::load("pbr-ray-trace"); + assert_eq!(restir_size, RAY_TRACE_SIZE); + let difference = snapshot::mean_abs_diff(&pixels, &restir); + println!("pbr-canonical: mean difference from ReSTIR = {difference:.2}/255"); + assert!( + difference < CANONICAL_MAX_DIFFERENCE, + "the real-time result is {difference:.2}/255 away from the canonical one" + ); +} + +/// Clearing `PostProcConfig::tone_map` has to hand back the composed radiance +/// as it is, with nothing compressed into display range. +/// +/// The grid's emissive row is far brighter than white, so a path that clamps +/// or tone maps cannot produce these values. This is what lets a capture be +/// used as data rather than only as a picture. +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context with ray tracing"] +fn hdr_capture_is_unclipped() { + let Some(bytes) = render_ray_traced_grid_as( + "pbr-hdr", + RayTraceMode::Canonical, + Capture::Hdr, + |_, _, _| {}, + ) else { + return; + }; + let pixels: &[f32] = bytemuck::cast_slice(&bytes); + assert_eq!( + pixels.len(), + (RAY_TRACE_SIZE.width * RAY_TRACE_SIZE.height * 4) as usize + ); + + let mut peak = 0.0f32; + for texel in pixels.chunks_exact(4) { + for &channel in &texel[..3] { + assert!(channel.is_finite(), "non-finite radiance {channel}"); + assert!(channel >= 0.0, "negative radiance {channel}"); + peak = peak.max(channel); + } + } + println!("hdr capture: peak radiance = {peak:.2}"); + assert!( + peak > 1.0, + "peak radiance is {peak:.3}, so the capture was clamped into display range" + ); + + // Stronger than a range check: applying the same curve on the CPU has to + // reproduce the display capture. That pins the HDR path to being the same + // signal one stage earlier, rather than some other buffer that merely + // happens to be bright. + let Some(display) = render_ray_traced_grid_as( + "pbr-hdr-display", + RayTraceMode::Canonical, + Capture::Display, + |_, _, _| {}, + ) else { + return; + }; + + // The same curve the shader applies, at PostProcConfig::default(): unit + // exposure and a white level of 1. Note that a white level of 1 makes the + // curve an identity, so what separates the two captures here is the + // transfer encoding and the 8-bit clamp. + let tone_map = |l: f32| { + let white = 1.0f32; + l * (1.0 + l / (white * white)) / (1.0 + l) + }; + let encode_srgb = |v: f32| { + if v <= 0.0031308 { + 12.92 * v + } else { + 1.055 * v.powf(1.0 / 2.4) - 0.055 + } + }; + + let mut worst = 0.0f32; + for (hdr, shown) in pixels.chunks_exact(4).zip(display.chunks_exact(4)) { + for (&linear, &byte) in hdr[..3].iter().zip(shown[..3].iter()) { + let expected = encode_srgb(tone_map(linear)).clamp(0.0, 1.0) * 255.0; + worst = worst.max((expected - byte as f32).abs()); + } + } + println!("hdr capture: worst channel difference after tone mapping = {worst:.2}/255"); + // Both passes are independent accumulations of a stochastic estimator, so + // they differ by sampling noise rather than being bit-identical. + assert!( + worst < 8.0, + "the tone mapped HDR capture is {worst:.2}/255 away from the display one" + ); +} + +#[cfg(not(gles))] +#[derive(blade_macros::ShaderData)] +struct GBufferProbeData { + t_depth: gpu::TextureView, + t_basis: gpu::TextureView, + t_flat_normal: gpu::TextureView, + t_diffuse_albedo: gpu::TextureView, + t_specular_f0: gpu::TextureView, + output: gpu::TextureView, +} + +/// The G-buffer views have to be bindable from outside the renderer, and they +/// have to describe the frame that was just rendered. +/// +/// The material grid is what makes the second half checkable: its columns +/// sweep the roughness and its rows sweep the metalness, so a buffer that came +/// from the wrong place, or from before the scene was drawn, will not show that +/// structure. +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context with ray tracing"] +fn gbuffer_views_describe_the_rendered_frame() { + let size = RAY_TRACE_SIZE; + let mut probe = Vec::new(); + + let rendered = render_ray_traced_grid_as( + "pbr-gbuffer", + RayTraceMode::Restir, + Capture::Display, + |renderer, context, encoder| { + let format = gpu::TextureFormat::Rgba32Float; + let target = snapshot::OffscreenTarget::new(context, size, format); + // The probe writes through a storage binding rather than as a + // render target. + let storage = context.create_texture(gpu::TextureDesc { + name: "gbuffer-probe", + format, + size, + dimension: gpu::TextureDimension::D2, + array_layer_count: 1, + mip_level_count: 1, + usage: gpu::TextureUsage::STORAGE | gpu::TextureUsage::COPY, + sample_count: 1, + external: None, + }); + let storage_view = context.create_texture_view( + storage, + gpu::TextureViewDesc { + name: "gbuffer-probe", + format, + dimension: gpu::ViewDimension::D2, + subresources: &gpu::TextureSubresources::default(), + }, + ); + encoder.init_texture(storage); + + let shader = context.create_shader(gpu::ShaderDesc { + source: include_str!("shaders/gbuffer_probe.wgsl"), + naga_module: None, + }); + let layout = ::layout(); + let mut pipeline = context.create_compute_pipeline(gpu::ComputePipelineDesc { + name: "gbuffer-probe", + data_layouts: &[&layout], + compute: shader.at("probe"), + }); + + let views = renderer.view_gbuffer(); + { + let mut pass = encoder.compute("gbuffer-probe"); + let mut commands = pass.with(&pipeline); + commands.bind( + 0, + &GBufferProbeData { + t_depth: views.depth, + t_basis: views.basis, + t_flat_normal: views.flat_normal, + t_diffuse_albedo: views.diffuse_albedo, + t_specular_f0: views.specular_f0, + output: storage_view, + }, + ); + commands.dispatch([size.width.div_ceil(8), size.height.div_ceil(8), 1]); + } + + { + let mut transfer = encoder.transfer("gbuffer-probe-readback"); + transfer.copy_texture_to_buffer( + storage.into(), + target.readback.into(), + size.width * 16, + size, + ); + } + let sync_point = context.submit(encoder); + // Same budget as a snapshot readback: this waits on the same + // ray-traced frame, so it faces the same software rasterizer. + assert!( + context + .wait_for(&sync_point, snapshot::READBACK_TIMEOUT_MS) + .unwrap(), + "GPU timed out reading back the G-buffer probe" + ); + + let count = (size.width * size.height * 4) as usize; + probe = vec![0.0f32; count]; + unsafe { + std::ptr::copy_nonoverlapping( + target.readback.data() as *const f32, + probe.as_mut_ptr(), + count, + ); + } + + // The encoder has to be live again for the caller's post processing. + encoder.start(); + context.destroy_compute_pipeline(&mut pipeline); + context.destroy_texture_view(storage_view); + context.destroy_texture(storage); + target.destroy(context); + }, + ); + if rendered.is_none() { + return; + } + assert!(!probe.is_empty(), "the inspect hook did not run"); + + // Depth: positive wherever a ray hit something, and the grid fills enough + // of the frame that plenty of rays did. + let hits = probe.chunks_exact(4).filter(|t| t[0] > 0.0).count(); + let total = (size.width * size.height) as usize; + println!("gbuffer: {hits}/{total} pixels have depth"); + assert!( + hits > total / 10, + "only {hits} of {total} pixels carry depth, the buffer looks empty" + ); + + // Roughness: the grid sweeps it across the columns, so a G-buffer read + // from the right place shows a spread rather than one value. + let mut min_roughness = f32::INFINITY; + let mut max_roughness = f32::NEG_INFINITY; + for texel in probe.chunks_exact(4).filter(|t| t[0] > 0.0) { + min_roughness = min_roughness.min(texel[1]); + max_roughness = max_roughness.max(texel[1]); + } + println!("gbuffer: roughness spans {min_roughness:.2}..{max_roughness:.2}"); + assert!( + max_roughness - min_roughness > 0.3, + "roughness barely varies ({min_roughness}..{max_roughness}), \ + the grid sweeps it so this should be a wide range" + ); + + // Albedo is a unorm target, so it cannot leave [0, 1]. + assert!( + probe.chunks_exact(4).all(|t| (0.0..=1.0).contains(&t[2])), + "albedo left the unit range" + ); + + // The spheres carry no normal map, so the shading normal should agree with + // the geometric one almost everywhere a ray hit. + let aligned = probe + .chunks_exact(4) + .filter(|t| t[0] > 0.0 && t[3] > 0.9) + .count(); + println!("gbuffer: {aligned}/{hits} hits have basis aligned with the flat normal"); + assert!( + aligned * 10 > hits * 9, + "the basis quaternion does not decode to the geometric normal, \ + so it is not the tangent frame it is documented to be" + ); +} + +/// Cook and serve a glTF model, checking that the PBR factors survive the trip. +#[cfg(not(gles))] +#[test] +#[ignore = "requires a working GPU context"] +fn gltf_material_test() { + let context = unsafe { gpu::Context::init(gpu::ContextDesc::default()).unwrap() }; + let harness = PbrHarness::new(context, "gltf-material", false); + let context = std::sync::Arc::clone(&harness.context); + + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("scene") + .join("data") + .join("monkey.gltf"); + let (handle, task) = harness.asset_hub.models.load( + &path, + blade_render::model::Meta { + generate_tangents: true, + front_face: blade_render::model::FrontFace::CounterClockwise, + }, + ); + task.clone().join(); + + // The uploads have to be flushed before the buffers can be destroyed. + let mut command_encoder = context.create_command_encoder(gpu::CommandEncoderDesc { + name: "gltf-material", + buffer_count: 1, + manual_barriers: false, + }); + command_encoder.start(); + let mut temp_buffers = Vec::new(); + harness + .asset_hub + .flush(&mut command_encoder, &mut temp_buffers); + let sync_point = context.submit(&mut command_encoder); + assert!(context.wait_for(&sync_point, 5000).unwrap()); + + let model = &harness.asset_hub.models[handle]; + assert!(!model.geometries.is_empty()); + assert_eq!(model.materials.len(), 2); + for material in model.materials.iter() { + // Matching "pbrMetallicRoughness" of the source + assert_eq!(material.metalness, 0.0); + assert_eq!(material.roughness, 0.5); + assert_eq!(material.base_color_factor[3], 1.0); + assert!((material.base_color_factor[0] - 0.8).abs() < 0.01); + // The model has no textures and doesn't emit light + assert!(material.metallic_roughness_texture.is_none()); + assert!(material.emissive_texture.is_none()); + assert_eq!(material.emissive_factor, [0.0; 3]); + } + + for buffer in temp_buffers { + context.destroy_buffer(buffer); + } + context.destroy_command_encoder(&mut command_encoder); + harness.destroy(); +} diff --git a/tests/parse_shaders.rs b/tests/parse_shaders.rs index 7cee4d68..987ef13e 100644 --- a/tests/parse_shaders.rs +++ b/tests/parse_shaders.rs @@ -1,77 +1,106 @@ use naga::{front::wgsl, valid::Validator}; -use std::{collections::HashMap, fs, path::PathBuf}; +use std::{collections::HashMap, fs, path::Path, path::PathBuf}; -/// Runs through all pass shaders and ensures they are valid WGSL. -#[test] -fn parse_wgsl() { - let mut expansions = HashMap::default(); - expansions.insert( - "DebugMode".to_string(), - blade_render::shader::Expansion::from_enum::(), - ); +fn validate_shader( + path: &Path, + base_path: &Path, + expansions: &HashMap, +) { + println!("Validating {:?}", path); + let shader_raw = fs::read(path).unwrap_or_default(); + let cooker = blade_asset::Cooker::new(base_path, Default::default()); + let mut text_out = blade_render::shader::parse_shader(&shader_raw, &cooker, expansions); - let read_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("examples") - .read_dir() - .unwrap(); + // Substitute cooperative matrix template placeholders with defaults + // so the shader parses as valid WGSL. + text_out = text_out + .replace("ENABLE_F16", "") + .replace("COOP_MAT", "coop_mat8x8") + .replace("INPUT_SCALAR", "f32") + .replace("TILE_SIZE", "8u"); - for sub_entry in read_dir { - let example = match sub_entry { + let module = match wgsl::parse_str(&text_out) { + Ok(module) => module, + Err(e) => panic!("{}", e.emit_to_string(&text_out)), + }; + //TODO: re-use the validator + Validator::new( + naga::valid::ValidationFlags::all() ^ naga::valid::ValidationFlags::BINDINGS, + naga::valid::Capabilities::RAY_QUERY + | naga::valid::Capabilities::ACCELERATION_STRUCTURE_BINDING_ARRAY + | naga::valid::Capabilities::COOPERATIVE_MATRIX + | naga::valid::Capabilities::STORAGE_BUFFER_BINDING_ARRAY + | naga::valid::Capabilities::TEXTURE_AND_SAMPLER_BINDING_ARRAY + | naga::valid::Capabilities::STORAGE_BUFFER_BINDING_ARRAY_NON_UNIFORM_INDEXING + | naga::valid::Capabilities::TEXTURE_AND_SAMPLER_BINDING_ARRAY_NON_UNIFORM_INDEXING, + ) + .validate(&module) + .unwrap_or_else(|e| { + blade_graphics::util::emit_annotated_error(&e, "", &text_out); + blade_graphics::util::print_err(&e); + panic!("Shader validation failed"); + }); +} + +/// Lists the standalone shaders in a directory. +/// +/// The `*.inc.wgsl` includes are only valid in the context of their users. +fn list_shaders(dir: &Path) -> Vec { + let mut list = Vec::new(); + let read_dir = match dir.read_dir() { + Ok(read_dir) => read_dir, + Err(_) => return list, + }; + for file in read_dir { + let path = match file { Ok(entry) => entry.path(), Err(e) => { - println!("Skipping non-example: {:?}", e); + println!("Skipping file: {:?}", e); continue; } }; - let dir = match example.read_dir() { - Ok(dir) => dir, - Err(_) => continue, - }; + let name = path.file_name().unwrap().to_str().unwrap(); + if name.ends_with(".inc.wgsl") || !name.ends_with(".wgsl") { + continue; + } + list.push(path); + } + list +} - for file in dir { - let path = match file { - Ok(entry) => entry.path(), - Err(e) => { - println!("Skipping file: {:?}", e); - continue; - } - }; - let shader_raw = match path.extension() { - Some(ostr) if ostr == "wgsl" => { - println!("Validating {:?}", path); - fs::read(&path).unwrap_or_default() - } - _ => continue, - }; +/// Runs through all pass shaders and ensures they are valid WGSL. +#[test] +fn parse_wgsl() { + use blade_render::shader::Expansion; - let cooker = blade_asset::Cooker::new(&example, Default::default()); - let mut text_out = - blade_render::shader::parse_shader(&shader_raw, &cooker, &expansions); + let mut expansions = HashMap::default(); + expansions.insert( + "DebugMode".to_string(), + Expansion::from_enum::(), + ); + expansions.insert( + "DebugDrawFlags".to_string(), + Expansion::from_bitflags::(), + ); + expansions.insert( + "DebugTextureFlags".to_string(), + Expansion::from_bitflags::(), + ); + expansions.insert("DEBUG_MODE".to_string(), Expansion::Bool(true)); - // Substitute cooperative matrix template placeholders with defaults - // so the shader parses as valid WGSL. - text_out = text_out - .replace("ENABLE_F16", "") - .replace("COOP_MAT", "coop_mat8x8") - .replace("INPUT_SCALAR", "f32") - .replace("TILE_SIZE", "8u"); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut directories = vec![root.join("blade-render").join("code")]; + + for sub_entry in root.join("examples").read_dir().unwrap() { + match sub_entry { + Ok(entry) => directories.push(entry.path()), + Err(e) => println!("Skipping non-example: {:?}", e), + } + } - let module = match wgsl::parse_str(&text_out) { - Ok(module) => module, - Err(e) => panic!("{}", e.emit_to_string(&text_out)), - }; - //TODO: re-use the validator - Validator::new( - naga::valid::ValidationFlags::all() ^ naga::valid::ValidationFlags::BINDINGS, - naga::valid::Capabilities::RAY_QUERY - | naga::valid::Capabilities::COOPERATIVE_MATRIX, - ) - .validate(&module) - .unwrap_or_else(|e| { - blade_graphics::util::emit_annotated_error(&e, "", &text_out); - blade_graphics::util::print_err(&e); - panic!("Shader validation failed"); - }); + for dir in directories { + for path in list_shaders(&dir) { + validate_shader(&path, &dir, &expansions); } } } diff --git a/tests/pbr_scene.rs b/tests/pbr_scene.rs new file mode 100644 index 00000000..7249d169 --- /dev/null +++ b/tests/pbr_scene.rs @@ -0,0 +1,122 @@ +//! A grid of spheres exercising the PBR material model. +//! +//! The columns vary the roughness, the rows vary the metalness, +//! and the last row is emissive. Both the rasterizer and the ray tracer +//! render it, so their results can be compared side by side. +#![cfg(not(gles))] +#![allow(dead_code)] + +use std::f32::consts::PI; + +pub const COLUMNS: usize = 5; +/// 3 rows of metalness values, plus one emissive row. +pub const ROWS: usize = 4; +const SPACING: f32 = 1.5; +const RADIUS: f32 = 0.5; +const SEGMENTS: usize = 32; +const RINGS: usize = 16; +/// Gold-ish, so that the metals are clearly tinted. +const BASE_COLOR: [f32; 4] = [0.95, 0.72, 0.35, 1.0]; +const METALNESS_ROW: [f32; 3] = [0.0, 0.5, 1.0]; +const EMISSIVE_COLORS: [[f32; 3]; COLUMNS] = [ + [1.0, 0.15, 0.1], + [0.15, 1.0, 0.2], + [0.1, 0.3, 1.0], + [1.0, 0.9, 0.5], + [0.3, 0.3, 0.3], +]; + +fn encode_normal(v: [f32; 3]) -> u32 { + let quantize = |f: f32| ((f.clamp(-1.0, 1.0) * 127.0 + 0.5) as i8) as u8 as u32; + quantize(v[0]) | (quantize(v[1]) << 8) | (quantize(v[2]) << 16) +} + +/// Produce a UV sphere with normals and tangents. +fn sphere(center: [f32; 3], radius: f32) -> (Vec, Vec) { + let mut vertices = Vec::with_capacity((SEGMENTS + 1) * (RINGS + 1)); + for ring in 0..=RINGS { + let theta = PI * ring as f32 / RINGS as f32; + let (sin_theta, cos_theta) = theta.sin_cos(); + for segment in 0..=SEGMENTS { + let phi = 2.0 * PI * segment as f32 / SEGMENTS as f32; + let (sin_phi, cos_phi) = phi.sin_cos(); + let normal = [sin_theta * cos_phi, cos_theta, sin_theta * sin_phi]; + vertices.push(blade_render::Vertex { + position: [ + center[0] + radius * normal[0], + center[1] + radius * normal[1], + center[2] + radius * normal[2], + ], + bitangent_sign: 1.0, + tex_coords: [segment as f32 / SEGMENTS as f32, ring as f32 / RINGS as f32], + normal: encode_normal(normal), + tangent: encode_normal([-sin_phi, 0.0, cos_phi]), + }); + } + } + + // Note: counter-clockwise when looked at from the outside, + // so that the flat normals of the ray tracer point outwards. + let mut indices = Vec::with_capacity(SEGMENTS * RINGS * 6); + let stride = (SEGMENTS + 1) as u32; + for ring in 0..RINGS as u32 { + for segment in 0..SEGMENTS as u32 { + let base = ring * stride + segment; + indices.extend_from_slice(&[base, base + 1, base + stride]); + indices.extend_from_slice(&[base + 1, base + stride + 1, base + stride]); + } + } + + (vertices, indices) +} + +/// Build the material grid, with the roughness interpolated +/// between the ends of `roughness_range` across the columns. +pub fn material_grid(roughness_range: [f32; 2]) -> Vec { + let mut geometries = Vec::with_capacity(COLUMNS * ROWS); + for row in 0..ROWS { + // the row past the metals is the emissive one + let metalness = METALNESS_ROW.get(row).copied(); + for (column, &emissive) in EMISSIVE_COLORS.iter().enumerate() { + let center = [ + (column as f32 - 0.5 * (COLUMNS - 1) as f32) * SPACING, + (0.5 * (ROWS - 1) as f32 - row as f32) * SPACING, + 0.0, + ]; + let (vertices, indices) = sphere(center, RADIUS); + let ratio = column as f32 / (COLUMNS - 1) as f32; + geometries.push(blade_render::ProceduralGeometry { + name: format!("sphere[{row}][{column}]"), + vertices, + indices, + base_color_factor: match metalness { + Some(_) => BASE_COLOR, + None => [0.0, 0.0, 0.0, 1.0], + }, + metalness: metalness.unwrap_or_default(), + roughness: roughness_range[0] + ratio * (roughness_range[1] - roughness_range[0]), + emissive_factor: match metalness { + Some(_) => [0.0; 3], + None => emissive, + }, + }); + } + } + geometries +} + +/// A camera that frames the whole grid. +pub fn camera() -> blade_render::Camera { + let fov_y = 0.8f32; + let height = ROWS as f32 * SPACING; + blade_render::Camera { + pos: [0.0, 0.0, 0.5 * height / (0.5 * fov_y).tan()].into(), + rot: mint::Quaternion { + v: [0.0; 3].into(), + s: 1.0, + }, + fov_y, + depth: 100.0, + fov: None, + } +} diff --git a/tests/reference/pbr-canonical.png b/tests/reference/pbr-canonical.png new file mode 100644 index 00000000..003990aa Binary files /dev/null and b/tests/reference/pbr-canonical.png differ diff --git a/tests/reference/pbr-raster.png b/tests/reference/pbr-raster.png new file mode 100644 index 00000000..60c49aed Binary files /dev/null and b/tests/reference/pbr-raster.png differ diff --git a/tests/reference/pbr-ray-trace.png b/tests/reference/pbr-ray-trace.png new file mode 100644 index 00000000..50aa12b2 Binary files /dev/null and b/tests/reference/pbr-ray-trace.png differ diff --git a/tests/reference/space-sky.png b/tests/reference/space-sky.png index 99a70869..01233a08 100644 Binary files a/tests/reference/space-sky.png and b/tests/reference/space-sky.png differ diff --git a/tests/shaders/gbuffer_probe.wgsl b/tests/shaders/gbuffer_probe.wgsl new file mode 100644 index 00000000..57b1b191 --- /dev/null +++ b/tests/shaders/gbuffer_probe.wgsl @@ -0,0 +1,45 @@ +// Sample the ray tracer's G-buffer views and pack what a consumer would want +// into one float target, so a test can read it back in a single copy. +// +// Binding the views is the point: it checks they are usable as sampled +// textures from outside the renderer, not merely that they are non-null. + +var t_depth: texture_2d; +var t_basis: texture_2d; +var t_flat_normal: texture_2d; +var t_diffuse_albedo: texture_2d; +var t_specular_f0: texture_2d; +var output: texture_storage_2d; + +// Matches `qrot` in the renderer's quaternion.inc.wgsl. +fn qrot(q: vec4, v: vec3) -> vec3 { + return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); +} + +@compute @workgroup_size(8, 8, 1) +fn probe(@builtin(global_invocation_id) id: vec3) { + let size = textureDimensions(t_depth, 0); + if id.x >= size.x || id.y >= size.y { + return; + } + let texel = vec2(i32(id.x), i32(id.y)); + + let depth = textureLoad(t_depth, texel, 0).x; + let basis = textureLoad(t_basis, texel, 0); + let flat_normal = textureLoad(t_flat_normal, texel, 0).xyz; + let albedo = textureLoad(t_diffuse_albedo, texel, 0).xyz; + let specular = textureLoad(t_specular_f0, texel, 0); + + // The shading normal is the tangent frame applied to +Z. + let shading_normal = qrot(basis, vec3(0.0, 0.0, 1.0)); + + textureStore(output, texel, vec4( + depth, + specular.w, + albedo.x, + // How far the shading normal and the geometric one agree. Both should + // be unit length, so this lands in [-1, 1] and is near 1 on the flat + // parts of the scene. + dot(shading_normal, flat_normal), + )); +} diff --git a/tests/snapshot.rs b/tests/snapshot.rs index 51721d72..4af1805b 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -4,20 +4,66 @@ use std::path::Path; const SSIM_THRESHOLD: f64 = 0.95; const REFERENCE_DIR: &str = "tests/reference"; +/// How long a snapshot readback may take before it counts as a hang. +/// +/// This is a liveness guard, not a performance budget, so it has to be sized +/// for the slowest thing that legitimately runs: an accumulated path trace on +/// a software rasterizer, several tests deep in a parallel `cargo test` on a +/// shared CI runner. That is roughly an order of magnitude slower than the same +/// work on Lavapipe locally, and two orders slower than a real GPU. +pub const READBACK_TIMEOUT_MS: u32 = 120_000; // SSIM constants for 8-bit images: C1 = (K1*L)^2, C2 = (K2*L)^2 where L=255 const C1: f64 = 6.5025; // (0.01 * 255)^2 const C2: f64 = 58.5225; // (0.03 * 255)^2 const BLOCK: usize = 8; +/// Read a shader from the renderer's code directory, expanding the `#include` directives. +/// +/// Unlike the asset pipeline, this works regardless of the backend +/// the tests are built for, at the cost of not supporting `#use`. +pub fn shader_source(name: &str) -> String { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("blade-render") + .join("code"); + expand_includes(&dir.join(name), &dir) +} + +fn expand_includes(path: &Path, dir: &Path) -> String { + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("Unable to read '{}': {e}", path.display())); + let mut out = String::new(); + for line in text.lines() { + if line.starts_with("#include") { + let include = line + .split('"') + .nth(1) + .unwrap_or_else(|| panic!("Unable to extract the include path from: {line}")); + out += &expand_includes(&dir.join(include), dir); + } else { + assert!( + !line.starts_with("#use"), + "'{}' needs host expansions, load it via the asset hub instead", + path.display() + ); + out += line; + } + out.push('\n'); + } + out +} + pub struct OffscreenTarget { pub texture: gpu::Texture, pub view: gpu::TextureView, pub readback: gpu::Buffer, pub size: gpu::Extent, + /// Bytes per texel, so a float target reads back as well as an 8-bit one. + texel_size: u32, } impl OffscreenTarget { pub fn new(context: &gpu::Context, size: gpu::Extent, format: gpu::TextureFormat) -> Self { + let texel_size = format.block_info().size as u32; let texture = context.create_texture(gpu::TextureDesc { name: "snapshot-target", format, @@ -40,7 +86,7 @@ impl OffscreenTarget { ); let readback = context.create_buffer(gpu::BufferDesc { name: "snapshot-readback", - size: (size.width * size.height) as u64 * 4, + size: (size.width * size.height * texel_size) as u64, memory: gpu::Memory::Shared, }); Self { @@ -48,6 +94,7 @@ impl OffscreenTarget { view, readback, size, + texel_size, } } @@ -61,16 +108,16 @@ impl OffscreenTarget { transfer.copy_texture_to_buffer( self.texture.into(), self.readback.into(), - self.size.width * 4, + self.size.width * self.texel_size, self.size, ); } let sync_point = context.submit(encoder); assert!( - context.wait_for(&sync_point, 5000).unwrap(), + context.wait_for(&sync_point, READBACK_TIMEOUT_MS).unwrap(), "GPU timed out during snapshot readback" ); - let byte_count = (self.size.width * self.size.height * 4) as usize; + let byte_count = (self.size.width * self.size.height * self.texel_size) as usize; let mut pixels = vec![0u8; byte_count]; unsafe { std::ptr::copy_nonoverlapping(self.readback.data(), pixels.as_mut_ptr(), byte_count); @@ -139,6 +186,25 @@ fn compute_ssim(a: &[u8], b: &[u8], width: usize, height: usize) -> f64 { ssim_sum / (blocks_x * blocks_y) as f64 } +/// Read a reference image, for cross-checking two renderers against each other. +pub fn load(name: &str) -> (Vec, gpu::Extent) { + load_reference(&Path::new(REFERENCE_DIR).join(format!("{name}.png"))) +} + +/// Mean absolute difference of the color channels, in 0..255 units. +pub fn mean_abs_diff(a: &[u8], b: &[u8]) -> f64 { + assert_eq!(a.len(), b.len()); + let mut sum = 0.0; + let mut count = 0; + for (texel_a, texel_b) in a.chunks(4).zip(b.chunks(4)) { + for (component_a, component_b) in texel_a[..3].iter().zip(texel_b[..3].iter()) { + sum += (*component_a as f64 - *component_b as f64).abs(); + count += 1; + } + } + sum / count as f64 +} + pub fn check(name: &str, pixels: &[u8], size: gpu::Extent) { let dir = Path::new(REFERENCE_DIR); let reference_path = dir.join(format!("{name}.png"));