From 254e684623de6006384b4c030049250fefa28f2e Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sat, 25 Jul 2026 18:40:07 +0000 Subject: [PATCH 1/6] Physically based materials and a canonical renderer Materials used to be just a base color and a normal map, with the roughness and metalness coming from a global config, so the shading couldn't be physically based at all. Material model: - `Material` carries metallic-roughness and emissive, 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 metallic-roughness inputs are converted in `material_from_metallic_roughness` where the textures are sampled: folding it into the factors instead would only be exact for the materials without a metallic-roughness texture, and doing it fully at cook time would mean merging the base color and metalness images into per-material diffuse and specular maps, giving up the sharing of the texture assets. - `brdf.inc.wgsl` has the shared BRDF: GGX distribution, height correlated Smith visibility, Schlick Fresnel. Lobe sampling is shared in `sampling.inc.wgsl`, environment sampling in `env-light.inc.wgsl`, and the geometry with the material fetch in `hit.inc.wgsl`. - ray tracing: material G-buffer, diffuse and specular lighting kept apart, candidates drawn from the BRDF as well as from the environment with MIS between them, since a narrow specular lobe can't be resolved by sampling the light alone - `ProceduralGeometry` gained the PBR factors, and builds an acceleration structure, so it can be ray traced at all - `RasterConfig` no longer overrides the material properties, and the cooked model format changed, so asset caches need to be cleared Canonical renderer: - `RayTracer::path_trace` traces full paths with BSDF sampling and next event estimation on the environment, combined by MIS, accumulated over the frames with no reuse and no denoising. Configured by `PathTraceConfig`, reset by `FrameConfig::reset_accumulation` or by moving the camera. Available in the scene example and in the engine. Color space: - the rasterizer was encoding gamma in the shader, which double corrected on a surface configured with the default `ColorSpace::Linear`. Both render paths now produce linear values, leaving the encoding to the surface, and an XR swapchain picks its format accordingly. Tests: - `parse_shaders` validates the renderer shaders too, which caught `fill-gbuf.wgsl` missing the `wgpu_binding_array` enable directive - new snapshots of a metallic-roughness sphere grid through the rasterizer, through ReSTIR, and through the canonical renderer, with the real-time result cross-checked against the ground truth - `gltf_material_test` covers the cooking of the PBR factors Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMzwvzypi5eqZLxgjUQX1 --- blade-engine/src/lib.rs | 38 +- blade-graphics/src/vulkan/surface.rs | 7 +- blade-helpers/src/hud.rs | 23 +- blade-render/Cargo.toml | 6 +- blade-render/code/brdf.inc.wgsl | 127 +++++++ blade-render/code/camera.inc.wgsl | 9 +- blade-render/code/env-light.inc.wgsl | 85 +++++ blade-render/code/fill-gbuf.wgsl | 97 ++--- blade-render/code/hit.inc.wgsl | 103 ++++++ blade-render/code/path-trace.wgsl | 218 +++++++++++ blade-render/code/post-proc.wgsl | 39 +- blade-render/code/raster.wgsl | 87 ++--- blade-render/code/ray-trace.wgsl | 275 +++++++------- blade-render/code/sampling.inc.wgsl | 82 +++++ blade-render/code/surface.inc.wgsl | 9 + blade-render/src/lib.rs | 2 +- blade-render/src/model/mod.rs | 232 +++++++++--- blade-render/src/raster/mod.rs | 58 +-- blade-render/src/render/mod.rs | 345 +++++++++++++++--- docs/CHANGELOG.md | 21 ++ examples-android/asteroids/asteroids.rs | 4 + examples-android/asteroids/game.rs | 4 +- examples-android/asteroids/mesh.rs | 10 + examples/scene/main.rs | 27 +- tests/gpu_examples.rs | 465 +++++++++++++++++++++++- tests/parse_shaders.rs | 151 ++++---- tests/pbr_scene.rs | 123 +++++++ tests/reference/pbr-canonical.png | Bin 0 -> 39458 bytes tests/reference/pbr-raster.png | Bin 0 -> 37900 bytes tests/reference/pbr-ray-trace.png | Bin 0 -> 21720 bytes tests/reference/space-sky.png | Bin 3330 -> 3262 bytes tests/snapshot.rs | 54 +++ 32 files changed, 2235 insertions(+), 466 deletions(-) create mode 100644 blade-render/code/brdf.inc.wgsl create mode 100644 blade-render/code/env-light.inc.wgsl create mode 100644 blade-render/code/hit.inc.wgsl create mode 100644 blade-render/code/path-trace.wgsl create mode 100644 blade-render/code/sampling.inc.wgsl create mode 100644 tests/pbr_scene.rs create mode 100644 tests/reference/pbr-canonical.png create mode 100644 tests/reference/pbr-raster.png create mode 100644 tests/reference/pbr-ray-trace.png diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index 0876c68d..d841d16a 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -381,6 +381,9 @@ enum Renderer { ray_config: blade_render::RayConfig, denoiser_enabled: bool, denoiser_config: blade_render::DenoiserConfig, + /// When set, the canonical renderer replaces the real-time one. + canonical_enabled: bool, + canonical_config: blade_render::PathTraceConfig, post_proc_config: blade_render::PostProcConfig, }, Rasterizer { @@ -619,6 +622,7 @@ impl Engine { debug_draw: true, reset_variance: false, reset_reservoirs: true, + reset_accumulation: true, }, ray_config: blade_helpers::default_ray_config(), denoiser_enabled: true, @@ -626,6 +630,8 @@ impl Engine { num_passes: 4, temporal_weight: 0.1, }, + canonical_enabled: false, + canonical_config: blade_render::PathTraceConfig::default(), post_proc_config: blade_render::PostProcConfig { average_luminocity: 0.5, exposure_key_value: 1.0 / 9.6, @@ -876,6 +882,8 @@ impl Engine { ref mut ray_config, ref mut denoiser_enabled, ref mut denoiser_config, + canonical_enabled, + canonical_config, .. } = self.renderer { @@ -899,11 +907,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); + if canonical_enabled { + inner.path_trace(command_encoder, canonical_config); + } else { + inner.ray_trace(command_encoder, self.debug, *ray_config); + if *denoiser_enabled { + inner.denoise(command_encoder, *denoiser_config); + } } } } @@ -1135,6 +1148,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,6 +1157,7 @@ impl Engine { denoiser_enabled, denoiser_config, post_proc_config, + .. } => { if can_render { inner.build_scene( @@ -1178,6 +1194,7 @@ 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 { @@ -1436,14 +1453,27 @@ impl Engine { ref mut ray_config, ref mut denoiser_enabled, ref mut denoiser_config, + ref mut canonical_enabled, + ref mut canonical_config, ref mut post_proc_config, ref mut frame_config, .. } => { 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); + if ui + .checkbox(canonical_enabled, "Canonical renderer") + .changed() + { + frame_config.reset_accumulation = true; + } + if *canonical_enabled { + canonical_config.populate_hud(ui); + } post_proc_config.populate_hud(ui); } Renderer::Rasterizer { diff --git a/blade-graphics/src/vulkan/surface.rs b/blade-graphics/src/vulkan/surface.rs index dbadff32..bca4adb7 100644 --- a/blade-graphics/src/vulkan/surface.rs +++ b/blade-graphics/src/vulkan/surface.rs @@ -783,9 +783,12 @@ fn select_xr_swapchain_format( } } } + // Unlike a window surface, an XR swapchain has no way to declare the color + // space of its contents: the runtime linearizes the sRGB formats and passes + // the plain ones through. So the format has to match what the app produces. 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..ed802e25 100644 --- a/blade-helpers/src/hud.rs +++ b/blade-helpers/src/hud.rs @@ -34,6 +34,26 @@ impl ExposeHud for blade_render::RayConfig { } } +impl ExposeHud for blade_render::PathTraceConfig { + fn populate_hud(&mut self, ui: &mut egui::Ui) { + ui.add( + egui::Slider::new(&mut self.samples_per_frame, 1..=64u32) + .text("Samples per frame") + .logarithmic(true), + ); + ui.add(egui::widgets::Slider::new(&mut self.max_bounces, 0..=16).text("Max bounces")); + ui.add( + egui::widgets::Slider::new(&mut self.t_start, 0.001..=0.5) + .text("T min") + .logarithmic(true), + ); + ui.checkbox( + &mut self.environment_importance_sampling, + "Env importance sampling", + ); + } +} + impl ExposeHud for blade_render::DenoiserConfig { fn populate_hud(&mut self, ui: &mut egui::Ui) { ui.add(egui::Slider::new(&mut self.temporal_weight, 0.0..=1.0f32).text("Temporal weight")); @@ -82,9 +102,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)); 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..10811b17 --- /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 "metallic", everything +// downstream of the G-buffer works with the diffuse/specular split. +fn material_from_metallic_roughness(base_color: vec3, metallic: f32, roughness: f32) -> Material { + var mat: Material; + mat.diffuse_albedo = base_color * (1.0 - metallic); + mat.specular_f0 = mix(vec3(DIELECTRIC_F0), base_color, metallic); + 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/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..e9b135c2 --- /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 metallic + metallic_roughness_texture: u32, + metallic_factor: f32, + roughness_factor: 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 metallic = entry.metallic_factor; + var roughness = entry.roughness_factor; + if ((ignore_textures & DebugTextureFlags_METALLIC_ROUGHNESS) == 0u) { + let mr = textureSampleLevel(textures[entry.metallic_roughness_texture], sampler_linear, tex_coords, lod); + roughness *= mr.y; + metallic *= mr.z; + } + + return material_from_metallic_roughness(base_color, metallic, 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..08cffe10 --- /dev/null +++ b/blade-render/code/path-trace.wgsl @@ -0,0 +1,218 @@ +// 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, + num_samples: u32, + max_bounces: 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 of a strategy with density `pdf`, +// against another one that would have produced `other_pdf`. +fn mis_weight(pdf: f32, other_pdf: f32) -> f32 { + return select(0.0, pdf / (pdf + other_pdf), pdf > 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; + 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); + radiance += throughput * evaluate_environment(direction) * mis_weight(bsdf_pdf, light_pdf); + } + break; + } + + let vertex = resolve_hit(intersection); + let view_dir = -direction; + radiance += throughput * vertex.emissive; + position = vertex.position; + t_min = parameters.t_start; + + // Next event estimation: connect to the environment light. + let ls = sample_light(importance, rng); + if (ls.pdf > 0.0) { + 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 && any(bsdf > vec3(0.0)) + && !is_occluded(position, light_dir)) { + let other_pdf = compute_bsdf_pdf(vertex.material, vertex.normal, view_dir, light_dir); + let weight = mis_weight(ls.pdf, other_pdf) / ls.pdf; + radiance += throughput * bsdf * ls.radiance * weight; + } + } + + if (bounce == parameters.max_bounces) { + // 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; + } + + let global_index = global_id.y * camera.target_size.x + global_id.x; + var rng = random_init(global_index, parameters.frame_index); + + var sum = vec3(0.0); + for (var i = 0u; i < parameters.num_samples; 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); + } + + var total = vec4(sum, f32(parameters.num_samples)); + if (parameters.reset_accumulation == 0u) { + total += textureLoad(accumulator, global_id.xy); + } + textureStore(accumulator, global_id.xy, total); +} diff --git a/blade-render/code/post-proc.wgsl b/blade-render/code/post-proc.wgsl index dc708bb3..3fdc5b72 100644 --- a/blade-render/code/post-proc.wgsl +++ b/blade-render/code/post-proc.wgsl @@ -1,18 +1,24 @@ #include "debug.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, } -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 +37,32 @@ 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) { + 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) { // 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_adjusted = post_proc_params.key_value / post_proc_params.average_lum * color; + let l_white = post_proc_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); } else { return vec4(color, 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..9acfa5d9 100644 --- a/blade-render/code/raster.wgsl +++ b/blade-render/code/raster.wgsl @@ -1,19 +1,24 @@ +#include "brdf.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 + 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: metallic factor, z: roughness factor material: vec4, } @@ -44,6 +49,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 metallic +var metallic_roughness_tex: texture_2d; +var emissive_tex: texture_2d; fn decode_normal(raw: u32) -> vec3 { return unpack4x8snorm(raw).xyz; @@ -70,41 +78,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 +107,17 @@ 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; + // Note: the result stays linear, like the one of the ray tracer. + // Encoding it for the display is up to the surface, see `ColorSpace`. let mapped = color / (color + vec3(1.0)); - let gamma = pow(mapped, vec3(1.0 / 2.2)); - return vec4(gamma, 1.0); + return vec4(mapped, 1.0); } struct SkyOutput { @@ -179,7 +151,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); @@ -241,7 +213,8 @@ fn raster_sky_fs(input: SkyOutput) -> @location(0) vec4 { color = mix(horizon, zenith, t); } } + // Note: the result stays linear, like the one of the ray tracer. + // Encoding it for the display is up to the surface, see `ColorSpace`. let mapped = color / (color + vec3(1.0)); - let gamma = pow(mapped, vec3(1.0 / 2.2)); - return vec4(gamma, 1.0); + return vec4(mapped, 1.0); } diff --git a/blade-render/code/ray-trace.wgsl b/blade-render/code/ray-trace.wgsl index 9bd3fbf0..fa426ff1 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" @@ -51,23 +53,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 +102,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 +132,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 +164,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 +191,64 @@ 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); +} + +// Portion of the candidates that follow the BRDF instead of the light. +// +// Rough diffuse surfaces are served well by sampling the light, while +// a narrow or dominant specular lobe needs to be sampled directly. +fn compute_brdf_sampling_ratio(surface: Surface) -> f32 { + let mat = surface_material(surface); + let smoothness = 1.0 - clamp(mat.roughness, 0.0, 1.0); + return clamp(max(specular_sampling_ratio(mat), smoothness * smoothness), 0.1, 0.9); +} + +// Draw a candidate following either the light distribution or the BRDF. +// +// The returned density is the one of the mixture of both strategies, +// which is the balance heuristic MIS weight for a single sample. +fn sample_incoming_light(surface: Surface, rng: ptr) -> LightSample { + let importance = parameters.environment_importance_sampling != 0u; + let mat = surface_material(surface); + let normal = surface_normal(surface); + let brdf_ratio = compute_brdf_sampling_ratio(surface); + + var ls: LightSample; + if (random_gen(rng) < brdf_ratio) { + 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); + } else { + ls = sample_light(importance, rng); + } + + let dir = map_equirect_uv_to_dir(ls.uv); + ls.pdf = mix( + compute_light_pdf(ls.uv, importance), + compute_bsdf_pdf(mat, normal, surface.view_dir, dir), + brdf_ratio, + ); + return ls; } var debug_len: f32; @@ -249,18 +270,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 +293,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 +310,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 +360,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 +368,27 @@ 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 ls = sample_incoming_light(surface, 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 +402,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 +434,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 +470,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 +496,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 +508,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..e8d1984f 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 metallic. + pub metallic_roughness_texture: Option>, + pub metallic_factor: f32, + pub roughness_factor: 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, + metallic_factor: 0.0, + roughness_factor: 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>, + metallic_factor: f32, + roughness_factor: 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 metallic_factor: f32, + pub roughness_factor: 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, + metallic_factor: material.metallic_factor, + roughness_factor: material.roughness_factor, + 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, + metallic_factor: geo.metallic_factor, + roughness_factor: geo.roughness_factor, + 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() + }, + metallic_factor: pbr.metallic_factor(), + roughness_factor: 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, + ), + metallic_factor: material.metallic_factor, + roughness_factor: material.roughness_factor, + 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..2e8a9bea 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)] @@ -328,10 +332,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 +352,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.metallic_factor, + material.roughness_factor, + 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,12 +545,7 @@ 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, - env_map_enabled as u32 as f32, - 0.0, - ], + settings: [env_map_enabled as u32 as f32, 0.0, 0.0, 0.0], } } } diff --git a/blade-render/src/render/mod.rs b/blade-render/src/render/mod.rs index afe88a51..eb24aa3e 100644 --- a/blade-render/src/render/mod.rs +++ b/blade-render/src/render/mod.rs @@ -58,6 +58,9 @@ pub enum DebugMode { Motion = 8, HitConsistency = 9, SampleReuse = 10, + Roughness = 11, + SpecularF0 = 12, + Emissive = 13, Variance = 15, } @@ -75,6 +78,8 @@ bitflags::bitflags! { pub struct DebugTextureFlags: u32 { const ALBEDO = 1; const NORMAL = 2; + const METALLIC_ROUGHNESS = 4; + const EMISSIVE = 8; } } @@ -103,6 +108,31 @@ pub struct RayConfig { pub defensive_mis: f32, } +/// Configuration of the canonical renderer. +/// +/// It traces full paths without any reuse or denoising, accumulating +/// the result over the frames, so it converges to the ground truth. +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] +pub struct PathTraceConfig { + /// Number of paths traced per pixel in a single frame. + pub samples_per_frame: u32, + /// Maximum number of surfaces a path is allowed to hit. + pub max_bounces: u32, + pub t_start: f32, + pub environment_importance_sampling: bool, +} + +impl Default for PathTraceConfig { + fn default() -> Self { + Self { + samples_per_frame: 1, + max_bounces: 3, + t_start: 0.01, + environment_importance_sampling: true, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] pub struct DenoiserConfig { pub num_passes: u32, @@ -212,9 +242,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 +303,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 +326,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 +352,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 +382,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, @@ -336,6 +401,8 @@ pub struct RayTracer { 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: @@ -383,7 +450,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 +475,46 @@ 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_samples: u32, + max_bounces: 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 +550,23 @@ 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, } #[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 +585,12 @@ struct HitEntry { base_color_factor: [u8; 4], normal_texture: u32, normal_scale: f32, + metallic_roughness_texture: u32, + metallic_factor: f32, + roughness_factor: f32, + emissive_texture: u32, + //Note: aligned to 16 bytes, matching `vec4` on the WGSL side + emissive_factor: [f32; 4], } #[derive(Clone, PartialEq)] @@ -487,6 +598,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 +622,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 +636,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 +675,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 +748,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 +774,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 +849,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, @@ -732,6 +871,8 @@ impl RayTracer { frame_index: 0, frame_scene_built: 0, is_frozen: false, + reset_accumulation: true, + show_accumulation: false, texture_resource_lookup: HashMap::default(), } } @@ -759,6 +900,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 +916,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 +946,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 { @@ -918,7 +1066,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 +1123,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 +1133,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, + ), + metallic_factor: material.metallic_factor, + roughness_factor: material.roughness_factor, + 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,8 +1272,58 @@ 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; + } + + /// Render the scene with the canonical renderer: full paths, no reuse, + /// and no denoising, accumulated on top of the previous frames. + /// + /// The result replaces the real-time one in the post-processing. + #[profiling::function] + pub fn path_trace( + &mut self, + command_encoder: &mut blade_graphics::CommandEncoder, + config: PathTraceConfig, + ) { + 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_samples: config.samples_per_frame.max(1), + max_bounces: config.max_bounces, + 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; } /// Ray trace the scene. @@ -1148,7 +1359,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], }, @@ -1198,11 +1411,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], }, ); @@ -1225,6 +1443,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 +1456,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 +1485,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 +1520,18 @@ 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: 1, 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, }, debug_params, }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e166a22..6064f05c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,27 @@ Changelog for *Blade* project ## (TBD) +- 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 + - 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 metallic 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: canonical rendering mode + - `RayTracer::path_trace` 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 + - configured by `PathTraceConfig`, reset by `FrameConfig::reset_accumulation` or by moving the camera + - available in the scene example and in `blade-engine` as "Canonical" +- fix the rasterizer encoding gamma in the shader, which double corrected the + colors on a surface configured with the default `ColorSpace::Linear`; both of + the render paths now produce linear values and leave the encoding to the surface +- vk: pick an sRGB XR swapchain format for `ColorSpace::Linear` and a plain one + for `ColorSpace::Srgb`, since an XR swapchain can't declare its color space +- 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..a9684c4e 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_factor: 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_factor: 0.7, + ..Default::default() }], ); diff --git a/examples-android/asteroids/game.rs b/examples-android/asteroids/game.rs index 117e288e..be2aaee0 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_factor: 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..a8aed821 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_factor: 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_factor: 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_factor: 0.7, + ..Default::default() }); } @@ -364,6 +370,8 @@ fn generate_ring_band( vertices, indices, base_color_factor: color, + roughness_factor: 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_factor: 0.7, + ..Default::default() }], ) } diff --git a/examples/scene/main.rs b/examples/scene/main.rs index a7f78467..f8a1626e 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -156,6 +156,8 @@ struct Example { is_point_selected: bool, is_file_hovered: bool, ray_config: blade_render::RayConfig, + canonical_enabled: bool, + canonical_config: blade_render::PathTraceConfig, denoiser_enabled: bool, denoiser_config: blade_render::DenoiserConfig, post_proc_config: blade_render::PostProcConfig, @@ -256,6 +258,8 @@ impl Example { is_point_selected: false, is_file_hovered: false, ray_config: blade_helpers::default_ray_config(), + canonical_enabled: false, + canonical_config: blade_render::PathTraceConfig::default(), denoiser_enabled: true, denoiser_config: blade_render::DenoiserConfig { num_passes: 3, @@ -460,6 +464,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,10 +472,15 @@ 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); + if self.canonical_enabled { + self.renderer + .path_trace(command_encoder, self.canonical_config); + } else { + self.renderer + .ray_trace(command_encoder, self.debug, self.ray_config); + if self.denoiser_enabled { + self.renderer.denoise(command_encoder, self.denoiser_config); + } } } } @@ -661,6 +671,15 @@ impl Example { self.denoiser_config.populate_hud(ui); }); + let old_canonical_config = self.canonical_config; + egui::CollapsingHeader::new("Canonical") + .default_open(false) + .show(ui, |ui| { + ui.checkbox(&mut self.canonical_enabled, "Enable"); + self.canonical_config.populate_hud(ui); + }); + self.need_accumulation_reset |= self.canonical_config != old_canonical_config; + egui::CollapsingHeader::new("Tone Map").show(ui, |ui| { self.post_proc_config.populate_hud(ui); }); diff --git a/tests/gpu_examples.rs b/tests/gpu_examples.rs index fe826dc6..0f5a6c40 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,7 +540,8 @@ fn snapshot_space_sky() { height: 300, depth: 1, }; - let format = gpu::TextureFormat::Rgba8Unorm; + // The sky is rendered in linear space, so let the hardware encode it + let format = gpu::TextureFormat::Rgba8UnormSrgb; // Create offscreen target let target = snapshot::OffscreenTarget::new(&context, size, format); @@ -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,7 @@ 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 + settings: [0.0, 0.0, 0.0, 0.0], // settings.x=0 -> env_enabled=false }; // Render @@ -671,3 +679,452 @@ 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 and samples per frame of 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; +#[cfg(not(gles))] +const CANONICAL_SAMPLES: u32 = 4; +/// 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, + }, + 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, +} + +/// 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> { + // 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, RAY_TRACE_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: RAY_TRACE_FORMAT, + alpha: gpu::AlphaMode::Ignored, + }, + 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 { + num_environment_samples: 4, + // the dummy environment map has no importance sampling data + environment_importance_sampling: false, + 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 canonical_config = blade_render::PathTraceConfig { + samples_per_frame: CANONICAL_SAMPLES, + max_bounces: 3, + t_start: 0.01, + environment_importance_sampling: false, + }; + + 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, + }, + ); + match mode { + RayTraceMode::Restir => { + renderer.ray_trace(&mut command_encoder, debug_config, ray_config); + renderer.denoise(&mut command_encoder, denoiser_config); + } + RayTraceMode::Canonical => { + renderer.path_trace(&mut command_encoder, canonical_config); + } + } + } + + 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::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" + ); +} + +/// 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.metallic_factor, 0.0); + assert_eq!(material.roughness_factor, 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..95f83acb --- /dev/null +++ b/tests/pbr_scene.rs @@ -0,0 +1,123 @@ +//! A grid of spheres exercising the PBR material model. +//! +//! The columns vary the roughness, the rows vary the metallic factor, +//! 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 metallic 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 METALLIC_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 metallic ones is the emissive one + let metallic = METALLIC_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 metallic { + Some(_) => BASE_COLOR, + None => [0.0, 0.0, 0.0, 1.0], + }, + metallic_factor: metallic.unwrap_or_default(), + roughness_factor: roughness_range[0] + + ratio * (roughness_range[1] - roughness_range[0]), + emissive_factor: match metallic { + 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 0000000000000000000000000000000000000000..003990aa89934ecd675a0a5c3eb20c286f5b8155 GIT binary patch literal 39458 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%0DjbW}CI8T8>!{w-ev-b>f_phjy{}`R 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 czsZlsta|8zGs~M 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%0A^ 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 literal 3330 zcmZWsc|26>8$Yv{nPRL{GPWr#Dod8hk~q4GZZ4x#(xSpuV@;T=$utvkZ5bh5i9w4? zAySbg+nAwji4ejlBiU&Xe&=xe-Oumy`{ViJymQ|3zR&Y~muJE$HkSCc#I*nb;160^ z90mXc8ww2^23q;{Vz3ebB=`p{%f(=9kxBo z6iLgN-+f}Z{?_gDp-bT+@rS=#olj>zQr0;X3*hcULZC7GCxDx zxTWUd%0c|Uol5$$n4`npuW0dh3)6GvNs3>d??1NsF0fgK^fThyw4W4hWp+Nbz>>nD z8sBQc@cwKbK-su5*gkyek5 zRlC7Gq+L#IZF~Lsv!T!USplD?<2ljNbK1_9k9QfU+>Q`Lj%UV{gx2cJNX9tdLb)s) z*@NP!G$@*qsGjf1k-<4Sl}J+f7GsLu)Yd}u;DePs5TtNmqX9Q{j~26k7yLWMcWu>; zXbGzGM5r^0mm2-`I88W1#DNvvzF*oz!C}uWO4mqpd>9Pj+lj($OS^_n9B=^p?8zq2 zJ<5zGEl|)0VIU!pVA$`ChI8HR(NI$9EWqVL-+RT39s1JD2&mvXJ$rmNAO4A(`#uQW zr>+#BX=<%Jsd0?1SLekY8MH&kX(h{p{vFUot0mFQ%}8|SV`0HR1j7+7Nn-ZbEwZNaPD1dN7mV(SW*7lOhz3| z_j1(#X>qsyRDXSX#Q!%OZalbMf~aqp8GZE7=hW$ehDzj>Hyu@$weGoBwbi#mwSfo) z6O@^JC$Ppc!)hg=K$NgI*BhWPVBC&?L-}@9jgkPI1-yX9vH!O93Np#Wz*_}5tYp1s zMu3fL_ctl@9ESxEs(<-@O?R~9%Zp9g|B`4GPc07sh28mN;L{x~G2T+*EE;r+_S&nh zuI+a2s6#H7E9#3?L$E6)?^i}BskAp5n=w>amxN|W<9wmokqnOmg%U9)I+fR> zq7=xRW*N(aLs<%mZ=&7GCZrJi)j(M#y2n^r)fgH~fSan+%R2Q`@DN}&)VZP*cKP^} z_E>N z<@tX&f^A9I2=9Cy7=!tv7t{V>(K!v|b1JvGM`SG)|g9Z3iJw08|4NHyK zAxi>JlgTrYFM3Ut2&rE|rnTmYqN1us@t8>%(djG@A0$$eYECa%#TlWKC!-eJnWRJC zFW}#JWhO5O{uk;nL!b~49@?ZxgBD#bQpW}au$ZQ^?5k@AZis=mSHEJ2+*>x11&fo&>=|*bKS!lilHWuL_0+^GQN@jt z=*;km0WOyoP`)8P5rA}Ni!pG}SDw_Kw zuT$zfWg<@CaQVoxp=dUZX)-xRt%+9oXvWj-Gf%k);jI}Yq1%#Kp|QX4hYTT9=;VHdR*GuD^HPfOX+xYBv-Ezxz^Ri{bb2Ax8qGy z`fTHu)VH-L-i>jd6h7pLW0BuElcnk-2p?S}`U+2=6FksG@5QO_fF%I-+7*vx6T9-f zf++aQ+OIUShJVDOGP0Adq{Gb70(@XoaP&Q;}=%#52?1 ztxy5-cc!44i3mn=Rs<6=6=>zZA$V<>sChG8hUEWj>EYLO9AmDHjA(6lx;n^LPm)Br zbk(j$cg$6)E(a1S#`aFu@G_!PMOVS~4Me|VO+hvTV#d#|49-jp4aA7e`OaU&PxLI$ zjHG(6cIwwhw!NP-v)qrJQnassU7?oosP!4^O)|soM}7G>+?5`7n&!lO_5zZ$lk7>V zR5Kf1r&1oUoPiL$%eXe_l73jbo6ZVF{bRj~;8OR~=}J-ewlako5@_f(n~*&q%q>n_*JOl5lk67m zPfUzl?hVg^^?Cc^6YHDz6vV(;VunCc$#4VU(gzR@}S+E+wd7tdCbkxrKW`Vst7X8axETh)v{_RQ+BRIt2LgdUTAUJLa?W) zBsE7aw|?P_1gC6%MpZgYs&pND8=Mb3L>=r#xm0}YKW&A5ja4{@7)Qt#+Wx8BSk~0% zzC)3eXnj|Seui!^JJzKP_}zm=_K@GtaeT$~Z@CY*Z=+VoD#Ovc(A)pW%#|YJ# zxBHquwwk5T`^?m&R`2_BA{@RjohkF^$Hz|_t04R@sk(WzIeiHEM2G&{00;NmSmf+G Gd;LFY;JWYt diff --git a/tests/snapshot.rs b/tests/snapshot.rs index 51721d72..18eeb411 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -9,6 +9,41 @@ 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, @@ -139,6 +174,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")); From 207f4d3377cdeed4341cb91bb0106aab6aedc67d Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sat, 25 Jul 2026 19:16:28 +0000 Subject: [PATCH 2/6] Honor the surface color space, rename to metalness, add a render mode Three follow-ups from the review. The rasterizer encoding gamma in the shader wasn't wrong, it was the only thing correct for where it runs. `xr_recommended_surface_config` asks for `ColorSpace::Linear`, but `select_xr_swapchain_format` answers it with a plain format, and an XR runtime passes those straight to the compositor instead of linearizing them. So the XR swapchain needs encoded values, which is exactly what the raster path produced, while a window surface under the same request gets an sRGB format and needs linear ones. Nothing told the renderers which of the two they were writing to. So `SurfaceInfo` now reports the color space of the surface contents, meaning what we have to produce: `Linear` when an sRGB format or a linear display space does the encoding for us, `Srgb` when the values are passed through. Both render paths encode accordingly, with the standard sRGB transfer function rather than a 2.2 power, so the two kinds of surface look the same. The XR format choice is left alone, and the raster output on a plain surface matches the previous reference at SSIM 0.9988. "Metallic" is what the glTF spec calls the field, but it reads as an adjective where we want a quantity, so the value is `metalness` now. The name "metallic-roughness" stays where it refers to the glTF texture or the model itself. Canonical is no longer a separate call with a config of its own: - `RenderMode` picks what the ray tracer does, and `RayTracer::render` performs the frame, so a user configures a mode instead of calling a different method. `ray_trace` and `denoise` became internal steps. - `RayConfig` describes the sampling for both of the modes: `num_environment_samples` and `num_brdf_samples` are the light and material samples taken at a shading point, `max_bounces` limits the path length, and `max_accumulated_samples` caps the accumulation. - ReSTIR draws those two counts of candidates and weights them by the mixture of their densities, which is multi-sample MIS with counts, and replaces the heuristic that used to pick a strategy at random. The canonical mode continues a path along each of its material samples and takes the light samples at every vertex. The explicit counts also brought the real-time result closer to the ground truth: the cross-check against the canonical snapshot went from 4.33 to 3.94 out of 255. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMzwvzypi5eqZLxgjUQX1 --- blade-engine/src/lib.rs | 57 +++++------ blade-graphics/src/gles/egl.rs | 12 ++- blade-graphics/src/gles/web.rs | 2 + blade-graphics/src/lib.rs | 28 ++++++ blade-graphics/src/metal/surface.rs | 11 ++- blade-graphics/src/util.rs | 14 +++ blade-graphics/src/vulkan/mod.rs | 2 + blade-graphics/src/vulkan/surface.rs | 20 +++- blade-helpers/src/hud.rs | 48 ++++++---- blade-helpers/src/lib.rs | 5 +- blade-render/code/brdf.inc.wgsl | 8 +- blade-render/code/color.inc.wgsl | 13 +++ blade-render/code/hit.inc.wgsl | 14 +-- blade-render/code/path-trace.wgsl | 60 ++++++++---- blade-render/code/post-proc.wgsl | 11 ++- blade-render/code/raster.wgsl | 15 ++- blade-render/code/ray-trace.wgsl | 43 ++++----- blade-render/src/model/mod.rs | 34 +++---- blade-render/src/raster/mod.rs | 12 ++- blade-render/src/render/mod.rs | 122 ++++++++++++++++-------- docs/CHANGELOG.md | 24 ++--- examples-android/asteroids/asteroids.rs | 4 +- examples-android/asteroids/game.rs | 2 +- examples-android/asteroids/mesh.rs | 10 +- examples/scene/main.rs | 34 +++---- tests/gpu_examples.rs | 54 ++++++----- tests/pbr_scene.rs | 19 ++-- tests/reference/pbr-raster.png | Bin 37900 -> 37899 bytes tests/reference/pbr-ray-trace.png | Bin 21720 -> 20737 bytes tests/reference/space-sky.png | Bin 3262 -> 3262 bytes 30 files changed, 405 insertions(+), 273 deletions(-) create mode 100644 blade-render/code/color.inc.wgsl diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index d841d16a..41cb2594 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -379,11 +379,9 @@ 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, - /// When set, the canonical renderer replaces the real-time one. - canonical_enabled: bool, - canonical_config: blade_render::PathTraceConfig, post_proc_config: blade_render::PostProcConfig, }, Rasterizer { @@ -572,10 +570,7 @@ impl Engine { .create_xr_surface() .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, - }; + let surface_info = xr_surface.info(); (surface_size, surface_info, TargetSurface::Xr(xr_surface)) } } @@ -625,13 +620,12 @@ impl Engine { 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, temporal_weight: 0.1, }, - canonical_enabled: false, - canonical_config: blade_render::PathTraceConfig::default(), post_proc_config: blade_render::PostProcConfig { average_luminocity: 0.5, exposure_key_value: 1.0 / 9.6, @@ -882,8 +876,7 @@ impl Engine { ref mut ray_config, ref mut denoiser_enabled, ref mut denoiser_config, - canonical_enabled, - canonical_config, + mode, .. } = self.renderer { @@ -910,14 +903,13 @@ impl Engine { frame_config.reset_accumulation = false; if !self.render_objects.is_empty() { - if canonical_enabled { - inner.path_trace(command_encoder, canonical_config); - } else { - 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), + ); } } } @@ -1159,6 +1151,7 @@ impl Engine { post_proc_config, .. } => { + let mode = blade_render::RenderMode::RealTime; if can_render { inner.build_scene( command_encoder, @@ -1196,10 +1189,13 @@ impl Engine { 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", @@ -1450,30 +1446,23 @@ 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, - ref mut canonical_enabled, - ref mut canonical_config, ref mut post_proc_config, ref mut frame_config, .. } => { + if blade_helpers::populate_render_mode(mode, ui) { + frame_config.reset_accumulation = true; + } ray_config.populate_hud(ui); 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); - if ui - .checkbox(canonical_enabled, "Canonical renderer") - .changed() - { - frame_config.reset_accumulation = true; - } - if *canonical_enabled { - canonical_config.populate_hud(ui); - } post_proc_config.populate_hud(ui); } Renderer::Rasterizer { diff --git a/blade-graphics/src/gles/egl.rs b/blade-graphics/src/gles/egl.rs index 5d5d9a3c..889352b0 100644 --- a/blade-graphics/src/gles/egl.rs +++ b/blade-graphics/src/gles/egl.rs @@ -790,7 +790,11 @@ impl super::Context { surface: surface_window, wl_window: new_wl_window, extent: size, - info: crate::SurfaceInfo { format, alpha }, + info: crate::SurfaceInfo { + format, + alpha, + color_space: crate::SurfaceInfo::passthrough_color_space(format), + }, swap_interval, }); } @@ -918,7 +922,11 @@ impl super::Context { surface: window_surface, wl_window: new_wl_window, extent: size, - info: crate::SurfaceInfo { format, alpha }, + info: crate::SurfaceInfo { + format, + alpha, + color_space: crate::SurfaceInfo::passthrough_color_space(format), + }, swap_interval, }; diff --git a/blade-graphics/src/gles/web.rs b/blade-graphics/src/gles/web.rs index b602d620..a43c93dc 100644 --- a/blade-graphics/src/gles/web.rs +++ b/blade-graphics/src/gles/web.rs @@ -107,6 +107,8 @@ impl super::Context { info: crate::SurfaceInfo { format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::PreMultiplied, + // the canvas expects the values to be encoded + color_space: crate::ColorSpace::Srgb, }, extent: crate::Extent::default(), }; diff --git a/blade-graphics/src/lib.rs b/blade-graphics/src/lib.rs index df0149ae..10e79ba6 100644 --- a/blade-graphics/src/lib.rs +++ b/blade-graphics/src/lib.rs @@ -1399,6 +1399,34 @@ pub enum AlphaMode { pub struct SurfaceInfo { pub format: TextureFormat, pub alpha: AlphaMode, + /// Color space that the contents of the surface are interpreted in, + /// which is what the renderers have to produce. + /// + /// It's `Linear` when the platform does the encoding for us, either + /// via an sRGB format or via a linear display color space. It's `Srgb` + /// when the values are passed through and have to be encoded by us, + /// which is notably the case for a plain XR swapchain format. + pub color_space: ColorSpace, +} + +impl SurfaceInfo { + /// Color space of the contents of a surface that declares one, given the + /// format we ended up with and the space the user asked to work in. + pub(crate) fn derive_color_space(format: TextureFormat, requested: ColorSpace) -> ColorSpace { + if format.is_srgb() { + // the format does the encoding for us + ColorSpace::Linear + } else { + requested + } + } + + /// Color space of the contents of a surface that has no way to declare one, + /// such as an XR swapchain: an sRGB format converts for us, while anything + /// else is passed through to the display and has to be encoded already. + pub(crate) fn passthrough_color_space(format: TextureFormat) -> ColorSpace { + Self::derive_color_space(format, ColorSpace::Srgb) + } } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/blade-graphics/src/metal/surface.rs b/blade-graphics/src/metal/surface.rs index 67ccd97e..6934c702 100644 --- a/blade-graphics/src/metal/surface.rs +++ b/blade-graphics/src/metal/surface.rs @@ -6,6 +6,7 @@ use objc2_quartz_core::CAMetalLayer; const SURFACE_INFO: crate::SurfaceInfo = crate::SurfaceInfo { format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::Ignored, + color_space: crate::ColorSpace::Srgb, }; impl super::Surface { @@ -109,11 +110,13 @@ 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, + color_space: crate::SurfaceInfo::derive_color_space(format, config.color_space), alpha: if config.transparent { crate::AlphaMode::PostMultiplied } else { diff --git a/blade-graphics/src/util.rs b/blade-graphics/src/util.rs index 264d3f37..3646f43b 100644 --- a/blade-graphics/src/util.rs +++ b/blade-graphics/src/util.rs @@ -43,6 +43,20 @@ pub fn emit_annotated_error(ann_err: &naga::WithSpan, filename: &st } impl super::TextureFormat { + /// Returns true if accessing the texels converts them + /// between the sRGB and the linear space. + pub const fn is_srgb(&self) -> bool { + matches!( + *self, + Self::Rgba8UnormSrgb + | Self::Bgra8UnormSrgb + | Self::Bc1UnormSrgb + | Self::Bc2UnormSrgb + | Self::Bc3UnormSrgb + | Self::Bc7UnormSrgb + ) + } + pub const fn block_info(&self) -> super::TexelBlockInfo { const fn uncompressed(size: u8) -> super::TexelBlockInfo { super::TexelBlockInfo { diff --git a/blade-graphics/src/vulkan/mod.rs b/blade-graphics/src/vulkan/mod.rs index 627b691d..4d709f2c 100644 --- a/blade-graphics/src/vulkan/mod.rs +++ b/blade-graphics/src/vulkan/mod.rs @@ -109,6 +109,8 @@ struct Swapchain { raw: vk::SwapchainKHR, format: crate::TextureFormat, alpha: crate::AlphaMode, + /// Color space the contents are interpreted in, see `SurfaceInfo`. + color_space: crate::ColorSpace, target_size: [u16; 2], } diff --git a/blade-graphics/src/vulkan/surface.rs b/blade-graphics/src/vulkan/surface.rs index bca4adb7..a9adef84 100644 --- a/blade-graphics/src/vulkan/surface.rs +++ b/blade-graphics/src/vulkan/surface.rs @@ -7,6 +7,7 @@ impl super::Surface { crate::SurfaceInfo { format: self.swapchain.format, alpha: self.swapchain.alpha, + color_space: self.swapchain.color_space, } } @@ -176,6 +177,14 @@ impl super::XrSurface { self.swapchain.format } + pub fn info(&self) -> crate::SurfaceInfo { + crate::SurfaceInfo { + format: self.swapchain.format, + alpha: self.swapchain.alpha, + color_space: self.swapchain.color_space, + } + } + pub fn swapchain(&self) -> &xr::Swapchain { &self.raw } @@ -262,6 +271,7 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::Ignored, + color_space: crate::ColorSpace::Srgb, target_size: [0; 2], }, full_screen_exclusive: fullscreen_exclusive_ext.full_screen_exclusive_supported != 0, @@ -524,6 +534,7 @@ impl super::Context { raw: raw_swapchain, format, alpha, + color_space: crate::SurfaceInfo::derive_color_space(format, config.color_space), target_size, }; } @@ -587,6 +598,7 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format, alpha: crate::AlphaMode::Ignored, + color_space: crate::SurfaceInfo::passthrough_color_space(format), target_size: [config.size.width as u16, config.size.height as u16], }, view_count: config.view_count.max(1), @@ -725,6 +737,7 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format, alpha: crate::AlphaMode::Ignored, + color_space: crate::SurfaceInfo::passthrough_color_space(format), target_size, }; surface.view_count = config.view_count.max(1); @@ -783,12 +796,9 @@ fn select_xr_swapchain_format( } } } - // Unlike a window surface, an XR swapchain has no way to declare the color - // space of its contents: the runtime linearizes the sRGB formats and passes - // the plain ones through. So the format has to match what the app produces. match color_space { - crate::ColorSpace::Linear => srgb_candidate.or(linear_candidate), - crate::ColorSpace::Srgb => linear_candidate.or(srgb_candidate), + crate::ColorSpace::Linear => linear_candidate.or(srgb_candidate), + crate::ColorSpace::Srgb => srgb_candidate.or(linear_candidate), } .expect("No compatible XR swapchain format available") } diff --git a/blade-helpers/src/hud.rs b/blade-helpers/src/hud.rs index ed802e25..2c54af70 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( @@ -34,26 +45,6 @@ impl ExposeHud for blade_render::RayConfig { } } -impl ExposeHud for blade_render::PathTraceConfig { - fn populate_hud(&mut self, ui: &mut egui::Ui) { - ui.add( - egui::Slider::new(&mut self.samples_per_frame, 1..=64u32) - .text("Samples per frame") - .logarithmic(true), - ); - ui.add(egui::widgets::Slider::new(&mut self.max_bounces, 0..=16).text("Max bounces")); - ui.add( - egui::widgets::Slider::new(&mut self.t_start, 0.001..=0.5) - .text("T min") - .logarithmic(true), - ); - ui.checkbox( - &mut self.environment_importance_sampling, - "Env importance sampling", - ); - } -} - impl ExposeHud for blade_render::DenoiserConfig { fn populate_hud(&mut self, ui: &mut egui::Ui) { ui.add(egui::Slider::new(&mut self.temporal_weight, 0.0..=1.0f32).text("Temporal weight")); @@ -162,6 +153,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/code/brdf.inc.wgsl b/blade-render/code/brdf.inc.wgsl index 10811b17..800b928b 100644 --- a/blade-render/code/brdf.inc.wgsl +++ b/blade-render/code/brdf.inc.wgsl @@ -31,12 +31,12 @@ struct Material { // Convert the glTF metallic-roughness parameters into the specular workflow. // -// Note: this is the only place that knows about "metallic", everything +// 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, metallic: f32, roughness: f32) -> Material { +fn material_from_metallic_roughness(base_color: vec3, metalness: f32, roughness: f32) -> Material { var mat: Material; - mat.diffuse_albedo = base_color * (1.0 - metallic); - mat.specular_f0 = mix(vec3(DIELECTRIC_F0), base_color, metallic); + mat.diffuse_albedo = base_color * (1.0 - metalness); + mat.specular_f0 = mix(vec3(DIELECTRIC_F0), base_color, metalness); mat.roughness = roughness; return mat; } 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/hit.inc.wgsl b/blade-render/code/hit.inc.wgsl index e9b135c2..116b82e7 100644 --- a/blade-render/code/hit.inc.wgsl +++ b/blade-render/code/hit.inc.wgsl @@ -32,10 +32,10 @@ struct HitEntry { base_color_factor: u32, normal_texture: u32, normal_scale: f32, - // green channel is roughness, blue channel is metallic + // green channel is roughness, blue channel is metalness metallic_roughness_texture: u32, - metallic_factor: f32, - roughness_factor: f32, + metalness: f32, + roughness: f32, emissive_texture: u32, emissive_factor: vec4, } @@ -73,15 +73,15 @@ fn sample_hit_material(entry: HitEntry, tex_coords: vec2, lod: f32, ignore_ base_color *= textureSampleLevel(textures[entry.base_color_texture], sampler_linear, tex_coords, lod).xyz; } - var metallic = entry.metallic_factor; - var roughness = entry.roughness_factor; + 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; - metallic *= mr.z; + metalness *= mr.z; } - return material_from_metallic_roughness(base_color, metallic, roughness); + return material_from_metallic_roughness(base_color, metalness, roughness); } fn sample_hit_emissive(entry: HitEntry, tex_coords: vec2, lod: f32, ignore_textures: u32) -> vec3 { diff --git a/blade-render/code/path-trace.wgsl b/blade-render/code/path-trace.wgsl index 08cffe10..440755ef 100644 --- a/blade-render/code/path-trace.wgsl +++ b/blade-render/code/path-trace.wgsl @@ -24,8 +24,13 @@ const MAX_RADIANCE: f32 = 1.0e6; struct PathTraceParams { frame_index: u32, - num_samples: 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 @@ -108,15 +113,17 @@ fn resolve_hit(intersection: RayIntersection) -> PathVertex { return vertex; } -// Balance heuristic weight of a strategy with density `pdf`, -// against another one that would have produced `other_pdf`. -fn mis_weight(pdf: f32, other_pdf: f32) -> f32 { - return select(0.0, pdf / (pdf + other_pdf), pdf > 0.0); +// 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; @@ -136,7 +143,8 @@ fn trace_path(start_dir: vec3, rng: ptr) -> vec3, rng: ptr) -> vec3 0.0) { + 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 && any(bsdf > vec3(0.0)) - && !is_occluded(position, light_dir)) { - let other_pdf = compute_bsdf_pdf(vertex.material, vertex.normal, view_dir, light_dir); - let weight = mis_weight(ls.pdf, other_pdf) / ls.pdf; - radiance += throughput * bsdf * ls.radiance * weight; + 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, 1.0, other_pdf) / (num_light * ls.pdf); + radiance += throughput * bsdf * ls.radiance * weight; } - if (bounce == parameters.max_bounces) { + if (bounce == parameters.max_bounces || parameters.num_brdf_samples == 0u) { // The next event estimation above was the last thing to do here. break; } @@ -199,20 +211,28 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { 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 < parameters.num_samples; i += 1u) { + 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); } - var total = vec4(sum, f32(parameters.num_samples)); - if (parameters.reset_accumulation == 0u) { - total += textureLoad(accumulator, global_id.xy); - } - textureStore(accumulator, global_id.xy, total); + 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 3fdc5b72..f8ad3a46 100644 --- a/blade-render/code/post-proc.wgsl +++ b/blade-render/code/post-proc.wgsl @@ -1,4 +1,5 @@ #include "debug.inc.wgsl" +#include "color.inc.wgsl" #include "debug-param.inc.wgsl" struct PostProcParams { @@ -9,6 +10,8 @@ struct PostProcParams { 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_diffuse_albedo: texture_2d; @@ -52,15 +55,15 @@ fn postfx_fs(vo: VertexOutput) -> @location(0) vec4 { let emissive = textureLoad(t_emissive, tc, 0).xyz; color = diffuse_albedo * illumination.xyz + specular + emissive; } + var mapped = color; if (post_proc_params.tone_map_enabled != 0u) { // 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 l_ldr = l_adjusted * (1.0 + l_adjusted / (l_white*l_white)) / (1.0 + l_adjusted); - return vec4(l_ldr, 1.0); - } else { - return vec4(color, 1.0); + 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(illumination.w); } else { diff --git a/blade-render/code/raster.wgsl b/blade-render/code/raster.wgsl index 9acfa5d9..e1b3d47e 100644 --- a/blade-render/code/raster.wgsl +++ b/blade-render/code/raster.wgsl @@ -1,4 +1,5 @@ #include "brdf.inc.wgsl" +#include "color.inc.wgsl" struct RasterFrameParams { view_proj: mat4x4, @@ -9,7 +10,7 @@ struct RasterFrameParams { light_color: vec4, // w component is a flag for the procedural space sky ambient_color: vec4, - // x: environment map enabled + // x: environment map enabled, y: the surface needs sRGB encoding settings: vec4, } @@ -18,7 +19,7 @@ struct RasterDrawParams { normal_quat: vec4, base_color_factor: vec4, emissive_factor: vec4, - // x: normal scale, y: metallic factor, z: roughness factor + // x: normal scale, y: metalness, z: roughness material: vec4, } @@ -49,7 +50,7 @@ var vertices: VertexBuffer; var samp: sampler; var base_color_tex: texture_2d; var normal_tex: texture_2d; -// green channel is roughness, blue channel is metallic +// green channel is roughness, blue channel is metalness var metallic_roughness_tex: texture_2d; var emissive_tex: texture_2d; @@ -114,10 +115,8 @@ fn raster_fs(input: VertexOutput) -> @location(0) vec4 { let emissive = draw_params.emissive_factor.rgb * textureSample(emissive_tex, samp, input.uv).rgb; let color = ambient + light + emissive; - // Note: the result stays linear, like the one of the ray tracer. - // Encoding it for the display is up to the surface, see `ColorSpace`. let mapped = color / (color + vec3(1.0)); - return vec4(mapped, 1.0); + return vec4(encode_surface_color(mapped, frame_params.settings.y > 0.5), 1.0); } struct SkyOutput { @@ -213,8 +212,6 @@ fn raster_sky_fs(input: SkyOutput) -> @location(0) vec4 { color = mix(horizon, zenith, t); } } - // Note: the result stays linear, like the one of the ray tracer. - // Encoding it for the display is up to the surface, see `ColorSpace`. let mapped = color / (color + vec3(1.0)); - return vec4(mapped, 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 fa426ff1..02b11448 100644 --- a/blade-render/code/ray-trace.wgsl +++ b/blade-render/code/ray-trace.wgsl @@ -22,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, @@ -213,41 +214,32 @@ fn evaluate_surface_brdf(surface: Surface, dir: vec3) -> BrdfLobes { return evaluate_brdf(surface_material(surface), surface_normal(surface), surface.view_dir, dir); } -// Portion of the candidates that follow the BRDF instead of the light. +// Draw a candidate, following either the light or the material distribution. // -// Rough diffuse surfaces are served well by sampling the light, while -// a narrow or dominant specular lobe needs to be sampled directly. -fn compute_brdf_sampling_ratio(surface: Surface) -> f32 { - let mat = surface_material(surface); - let smoothness = 1.0 - clamp(mat.roughness, 0.0, 1.0); - return clamp(max(specular_sampling_ratio(mat), smoothness * smoothness), 0.1, 0.9); -} - -// Draw a candidate following either the light distribution or the BRDF. -// -// The returned density is the one of the mixture of both strategies, -// which is the balance heuristic MIS weight for a single sample. -fn sample_incoming_light(surface: Surface, rng: ptr) -> LightSample { +// 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); - let brdf_ratio = compute_brdf_sampling_ratio(surface); var ls: LightSample; - if (random_gen(rng) < brdf_ratio) { + 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); - } else { - ls = sample_light(importance, rng); } let dir = map_equirect_uv_to_dir(ls.uv); - ls.pdf = mix( - compute_light_pdf(ls.uv, importance), - compute_bsdf_pdf(mat, normal, surface.view_dir, dir), - brdf_ratio, - ); + 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; } @@ -381,8 +373,9 @@ fn compute_restir(surface: Surface, pixel: vec2, rng: ptr>, pub normal_scale: f32, - /// Green channel is roughness, blue channel is metallic. + /// Green channel is roughness, blue channel is metalness. pub metallic_roughness_texture: Option>, - pub metallic_factor: f32, - pub roughness_factor: f32, + pub metalness: f32, + pub roughness: f32, pub emissive_texture: Option>, /// Emitted radiance, with `KHR_materials_emissive_strength` folded in. pub emissive_factor: [f32; 3], @@ -80,8 +80,8 @@ impl Default for Material { normal_texture: None, normal_scale: 0.0, metallic_roughness_texture: None, - metallic_factor: 0.0, - roughness_factor: 0.5, + metalness: 0.0, + roughness: 0.5, emissive_texture: None, emissive_factor: [0.0; 3], transparent: false, @@ -115,8 +115,8 @@ struct CookedMaterial<'a> { normal: TextureReference<'a>, normal_scale: f32, metallic_roughness: TextureReference<'a>, - metallic_factor: f32, - roughness_factor: f32, + metalness: f32, + roughness: f32, emissive: TextureReference<'a>, emissive_factor: [f32; 3], transparent: bool, @@ -530,8 +530,8 @@ pub struct ProceduralGeometry { pub vertices: Vec, pub indices: Vec, pub base_color_factor: [f32; 4], - pub metallic_factor: f32, - pub roughness_factor: f32, + pub metalness: f32, + pub roughness: f32, pub emissive_factor: [f32; 3], } @@ -543,8 +543,8 @@ impl Default for ProceduralGeometry { vertices: Vec::new(), indices: Vec::new(), base_color_factor: material.base_color_factor, - metallic_factor: material.metallic_factor, - roughness_factor: material.roughness_factor, + metalness: material.metalness, + roughness: material.roughness, emissive_factor: material.emissive_factor, } } @@ -629,8 +629,8 @@ impl Baker { let material_index = materials.len(); materials.push(Material { base_color_factor: geo.base_color_factor, - metallic_factor: geo.metallic_factor, - roughness_factor: geo.roughness_factor, + metalness: geo.metalness, + roughness: geo.roughness, emissive_factor: geo.emissive_factor, ..Material::default() }); @@ -811,8 +811,8 @@ impl blade_asset::Baker for Baker { }, ..Default::default() }, - metallic_factor: pbr.metallic_factor(), - roughness_factor: pbr.roughness_factor(), + metalness: pbr.metallic_factor(), + roughness: pbr.roughness_factor(), emissive: TextureReference { source_index: match g_material.emissive_texture() { Some(info) => sources.insert(self.cook_texture( @@ -907,8 +907,8 @@ impl blade_asset::Baker for Baker { META_METALLIC_ROUGHNESS, exe_context, ), - metallic_factor: material.metallic_factor, - roughness_factor: material.roughness_factor, + metalness: material.metalness, + roughness: material.roughness, emissive_texture: self.serve_texture( &material.emissive, META_EMISSIVE, diff --git a/blade-render/src/raster/mod.rs b/blade-render/src/raster/mod.rs index 2e8a9bea..909bbe47 100644 --- a/blade-render/src/raster/mod.rs +++ b/blade-render/src/raster/mod.rs @@ -360,8 +360,8 @@ impl Rasterizer { ], material: [ normal_scale, - material.metallic_factor, - material.roughness_factor, + material.metalness, + material.roughness, 0.0, ], }, @@ -545,7 +545,13 @@ impl Rasterizer { let c = config.ambient_color; [c.x, c.y, c.z, config.space_sky as u32 as f32] }, - settings: [env_map_enabled as u32 as f32, 0.0, 0.0, 0.0], + settings: [ + env_map_enabled as u32 as f32, + // the surface may expect us to encode the values ourselves + (self.surface_info.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 eb24aa3e..dfab75ac 100644 --- a/blade-render/src/render/mod.rs +++ b/blade-render/src/render/mod.rs @@ -93,13 +93,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, @@ -108,29 +126,19 @@ pub struct RayConfig { pub defensive_mis: f32, } -/// Configuration of the canonical renderer. -/// -/// It traces full paths without any reuse or denoising, accumulating -/// the result over the frames, so it converges to the ground truth. -#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] -pub struct PathTraceConfig { - /// Number of paths traced per pixel in a single frame. - pub samples_per_frame: u32, - /// Maximum number of surfaces a path is allowed to hit. - pub max_bounces: u32, - pub t_start: f32, - pub environment_importance_sampling: bool, -} - -impl Default for PathTraceConfig { - fn default() -> Self { - Self { - samples_per_frame: 1, - max_bounces: 3, - t_start: 0.01, - environment_importance_sampling: true, - } - } +/// 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)] @@ -424,6 +432,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, @@ -492,8 +501,10 @@ struct MainData { #[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)] struct PathTraceParams { frame_index: u32, - num_samples: 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, @@ -556,6 +567,7 @@ struct PostProcParams { key_value: f32, white_level: f32, accumulated: u32, + encode_srgb: u32, } #[derive(blade_macros::ShaderData)] @@ -586,8 +598,8 @@ struct HitEntry { normal_texture: u32, normal_scale: f32, metallic_roughness_texture: u32, - metallic_factor: f32, - roughness_factor: f32, + metalness: f32, + roughness: f32, emissive_texture: u32, //Note: aligned to 16 bytes, matching `vec4` on the WGSL side emissive_factor: [f32; 4], @@ -1140,8 +1152,8 @@ impl RayTracer { material.metallic_roughness_texture, dummy_white, ), - metallic_factor: material.metallic_factor, - roughness_factor: material.roughness_factor, + metalness: material.metalness, + roughness: material.roughness, emissive_texture: alloc_texture(material.emissive_texture, dummy_white), emissive_factor: { let c = material.emissive_factor; @@ -1282,15 +1294,40 @@ impl RayTracer { self.post_proc_input_index = cur; } - /// Render the scene with the canonical renderer: full paths, no reuse, - /// and no denoising, accumulated on top of the previous frames. + /// Render a frame in the given mode. /// - /// The result replaces the real-time one in the post-processing. + /// 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 path_trace( + pub fn render( &mut self, command_encoder: &mut blade_graphics::CommandEncoder, - config: PathTraceConfig, + 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"); @@ -1302,8 +1339,10 @@ impl RayTracer { camera: self.targets.camera_params[cur], parameters: PathTraceParams { frame_index: self.frame_index as u32, - num_samples: config.samples_per_frame.max(1), + 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, @@ -1326,11 +1365,10 @@ impl RayTracer { self.show_accumulation = true; } - /// Ray trace the scene. - /// - /// The result is stored internally in an HDR render target. + /// 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, @@ -1381,6 +1419,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, @@ -1430,7 +1469,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, @@ -1532,6 +1571,9 @@ impl RayTracer { key_value: pp_config.exposure_key_value, white_level: pp_config.white_level, accumulated: self.show_accumulation as u32, + encode_srgb: (self.surface_info.color_space + == blade_graphics::ColorSpace::Srgb) + as u32, }, debug_params, }, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6064f05c..56b7179a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,23 +4,25 @@ Changelog for *Blade* project - 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 + - 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 metallic of all the materials + - 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: canonical rendering mode - - `RayTracer::path_trace` 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 - - configured by `PathTraceConfig`, reset by `FrameConfig::reset_accumulation` or by moving the camera - - available in the scene example and in `blade-engine` as "Canonical" -- fix the rasterizer encoding gamma in the shader, which double corrected the - colors on a surface configured with the default `ColorSpace::Linear`; both of - the render paths now produce linear values and leave the encoding to the surface -- vk: pick an sRGB XR swapchain format for `ColorSpace::Linear` and a plain one - for `ColorSpace::Srgb`, since an XR swapchain can't declare its color space +- 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 +- `SurfaceInfo` now reports the `color_space` of the surface contents, which is + what the renderers have to produce. An sRGB format or a linear display space + means linear values, while a plain format that the platform passes straight + through - notably an XR swapchain - means we encode them ourselves. Both of + the render paths honor it, instead of the rasterizer always encoding and the + ray tracer never doing so. - 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 diff --git a/examples-android/asteroids/asteroids.rs b/examples-android/asteroids/asteroids.rs index a9684c4e..9c323504 100644 --- a/examples-android/asteroids/asteroids.rs +++ b/examples-android/asteroids/asteroids.rs @@ -146,7 +146,7 @@ impl XrInput { vertices: laser_verts, indices: laser_idxs, base_color_factor: [0.2, 1.0, 0.2, 1.0], - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }], ); @@ -158,7 +158,7 @@ impl XrInput { vertices: aim_verts, indices: aim_idxs, base_color_factor: [0.1, 0.3, 0.6, 1.0], - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }], ); diff --git a/examples-android/asteroids/game.rs b/examples-android/asteroids/game.rs index be2aaee0..190098cc 100644 --- a/examples-android/asteroids/game.rs +++ b/examples-android/asteroids/game.rs @@ -73,7 +73,7 @@ impl AsteroidField { vertices, indices, base_color_factor: color, - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }], ); diff --git a/examples-android/asteroids/mesh.rs b/examples-android/asteroids/mesh.rs index a8aed821..0a9a0344 100644 --- a/examples-android/asteroids/mesh.rs +++ b/examples-android/asteroids/mesh.rs @@ -296,7 +296,7 @@ pub fn generate_planet_model( vertices: ocean_verts, indices: ocean_idxs, base_color_factor: ocean_color, - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }); } @@ -306,7 +306,7 @@ pub fn generate_planet_model( vertices: land_verts, indices: land_idxs, base_color_factor: land_color, - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }); } @@ -316,7 +316,7 @@ pub fn generate_planet_model( vertices: ice_verts, indices: ice_idxs, base_color_factor: ice_color, - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }); } @@ -370,7 +370,7 @@ fn generate_ring_band( vertices, indices, base_color_factor: color, - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() } } @@ -441,7 +441,7 @@ pub fn generate_comet_model( vertices: verts, indices: idxs, base_color_factor: [0.9, 0.95, 1.0, 1.0], - roughness_factor: 0.7, + roughness: 0.7, ..Default::default() }], ) diff --git a/examples/scene/main.rs b/examples/scene/main.rs index f8a1626e..bd29410d 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -156,8 +156,7 @@ struct Example { is_point_selected: bool, is_file_hovered: bool, ray_config: blade_render::RayConfig, - canonical_enabled: bool, - canonical_config: blade_render::PathTraceConfig, + mode: blade_render::RenderMode, denoiser_enabled: bool, denoiser_config: blade_render::DenoiserConfig, post_proc_config: blade_render::PostProcConfig, @@ -258,8 +257,7 @@ impl Example { is_point_selected: false, is_file_hovered: false, ray_config: blade_helpers::default_ray_config(), - canonical_enabled: false, - canonical_config: blade_render::PathTraceConfig::default(), + mode: blade_render::RenderMode::default(), denoiser_enabled: true, denoiser_config: blade_render::DenoiserConfig { num_passes: 3, @@ -472,16 +470,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() { - if self.canonical_enabled { - self.renderer - .path_trace(command_encoder, self.canonical_config); - } else { - 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), + ); } } @@ -660,6 +655,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; @@ -671,15 +668,6 @@ impl Example { self.denoiser_config.populate_hud(ui); }); - let old_canonical_config = self.canonical_config; - egui::CollapsingHeader::new("Canonical") - .default_open(false) - .show(ui, |ui| { - ui.checkbox(&mut self.canonical_enabled, "Enable"); - self.canonical_config.populate_hud(ui); - }); - self.need_accumulation_reset |= self.canonical_config != old_canonical_config; - egui::CollapsingHeader::new("Tone Map").show(ui, |ui| { self.post_proc_config.populate_hud(ui); }); diff --git a/tests/gpu_examples.rs b/tests/gpu_examples.rs index 0f5a6c40..d232089b 100644 --- a/tests/gpu_examples.rs +++ b/tests/gpu_examples.rs @@ -540,8 +540,8 @@ fn snapshot_space_sky() { height: 300, depth: 1, }; - // The sky is rendered in linear space, so let the hardware encode it - let format = gpu::TextureFormat::Rgba8UnormSrgb; + // A plain format, like an XR swapchain: the shader has to encode + let format = gpu::TextureFormat::Rgba8Unorm; // Create offscreen target let target = snapshot::OffscreenTarget::new(&context, size, format); @@ -619,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 - settings: [0.0, 0.0, 0.0, 0.0], // settings.x=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 @@ -687,14 +688,12 @@ fn snapshot_space_sky() { #[cfg(not(gles))] const RAY_TRACE_FRAMES: usize = 8; -/// Frames and samples per frame of the canonical renderer. +/// 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; -#[cfg(not(gles))] -const CANONICAL_SAMPLES: u32 = 4; /// 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))] @@ -792,6 +791,8 @@ fn snapshot_pbr_raster() { 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, }, @@ -927,6 +928,7 @@ fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option Option 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, @@ -960,13 +970,6 @@ fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option RAY_TRACE_FRAMES, RayTraceMode::Canonical => CANONICAL_FRAMES, @@ -991,15 +994,16 @@ fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option { - renderer.ray_trace(&mut command_encoder, debug_config, ray_config); - renderer.denoise(&mut command_encoder, denoiser_config); - } - RayTraceMode::Canonical => { - renderer.path_trace(&mut command_encoder, canonical_config); - } - } + 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), + ); } command_encoder.init_texture(target.texture); @@ -1112,8 +1116,8 @@ fn gltf_material_test() { assert_eq!(model.materials.len(), 2); for material in model.materials.iter() { // Matching "pbrMetallicRoughness" of the source - assert_eq!(material.metallic_factor, 0.0); - assert_eq!(material.roughness_factor, 0.5); + 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 diff --git a/tests/pbr_scene.rs b/tests/pbr_scene.rs index 95f83acb..7249d169 100644 --- a/tests/pbr_scene.rs +++ b/tests/pbr_scene.rs @@ -1,6 +1,6 @@ //! A grid of spheres exercising the PBR material model. //! -//! The columns vary the roughness, the rows vary the metallic factor, +//! 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))] @@ -9,7 +9,7 @@ use std::f32::consts::PI; pub const COLUMNS: usize = 5; -/// 3 rows of metallic values, plus one emissive row. +/// 3 rows of metalness values, plus one emissive row. pub const ROWS: usize = 4; const SPACING: f32 = 1.5; const RADIUS: f32 = 0.5; @@ -17,7 +17,7 @@ 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 METALLIC_ROW: [f32; 3] = [0.0, 0.5, 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], @@ -75,8 +75,8 @@ fn sphere(center: [f32; 3], radius: f32) -> (Vec, Vec 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 metallic ones is the emissive one - let metallic = METALLIC_ROW.get(row).copied(); + // 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, @@ -89,14 +89,13 @@ pub fn material_grid(roughness_range: [f32; 2]) -> Vec BASE_COLOR, None => [0.0, 0.0, 0.0, 1.0], }, - metallic_factor: metallic.unwrap_or_default(), - roughness_factor: roughness_range[0] - + ratio * (roughness_range[1] - roughness_range[0]), - emissive_factor: match metallic { + 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, }, diff --git a/tests/reference/pbr-raster.png b/tests/reference/pbr-raster.png index 4f44cf2459f629a48413f0cbbab5577e41483820..60c49aed02d9cc6ee7500d4d831eb7fefa9c66ff 100644 GIT binary patch delta 12658 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�dS10ZfQ&b9@ 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 delta 12659 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 diff --git a/tests/reference/pbr-ray-trace.png b/tests/reference/pbr-ray-trace.png index 1c3ad6ca9636debc497802c763e9cfe0bc45253a..50aa12b2282834353261a99b6e8b40b57c1d615f 100644 GIT binary patch literal 20737 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%0A^ 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 From 32ecce494613ff56fd7bf4dd050fb5550c717af8 Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sat, 25 Jul 2026 21:20:53 +0000 Subject: [PATCH 3/6] Take the color space from the render configuration Reverts the `SurfaceInfo::color_space` I added: an application states the color space it works in when configuring the surface, so reporting it back is redundant. `blade-render` takes it as `RenderConfig::color_space`, next to the surface info the application already passes in. That leaves the XR path, which was the reason the information seemed to be missing: `xr_recommended_surface_config` asked for `ColorSpace::Linear`, while `select_xr_swapchain_format` answered it with a plain format that the runtime passes to the compositor as-is. The request wasn't honored, so no one could rely on it. An XR swapchain has no way to declare a color space, so its format is what honors the request: an sRGB one for `Linear`, which the runtime converts, and a plain one for `Srgb`, which it passes through. The recommended configuration now asks for `Srgb`, matching the plain format that it ends up with, which is the same format as before this change - the swapchain the asteroids example gets is unchanged, and so is the encoding it writes. `xr_recommended_surface_config` and `create_xr_surface_configured` became public, so that an application can hold the configuration of its XR surface rather than having it hidden inside `create_xr_surface`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AwMzwvzypi5eqZLxgjUQX1 --- blade-engine/src/lib.rs | 20 +++++++++++++++++--- blade-graphics/src/gles/egl.rs | 12 ++---------- blade-graphics/src/gles/web.rs | 2 -- blade-graphics/src/lib.rs | 28 ---------------------------- blade-graphics/src/metal/surface.rs | 2 -- blade-graphics/src/util.rs | 14 -------------- blade-graphics/src/vulkan/mod.rs | 2 -- blade-graphics/src/vulkan/surface.rs | 22 +++++++++++----------- blade-render/src/raster/mod.rs | 4 +++- blade-render/src/render/mod.rs | 13 ++++++++++--- docs/CHANGELOG.md | 16 ++++++++++------ examples/scene/main.rs | 2 ++ tests/gpu_examples.rs | 6 +++--- 13 files changed, 58 insertions(+), 85 deletions(-) diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index 41cb2594..73bfb02c 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -545,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()); @@ -557,6 +559,7 @@ impl Engine { ( surface_size, surface_info, + surface_config.color_space, TargetSurface::Window(gpu_surface), ) } @@ -566,12 +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 = xr_surface.info(); - (surface_size, surface_info, TargetSurface::Xr(xr_surface)) + ( + surface_size, + surface_info, + surface_config.color_space, + TargetSurface::Xr(xr_surface), + ) } } #[cfg(target_os = "android")] @@ -601,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 { diff --git a/blade-graphics/src/gles/egl.rs b/blade-graphics/src/gles/egl.rs index 889352b0..5d5d9a3c 100644 --- a/blade-graphics/src/gles/egl.rs +++ b/blade-graphics/src/gles/egl.rs @@ -790,11 +790,7 @@ impl super::Context { surface: surface_window, wl_window: new_wl_window, extent: size, - info: crate::SurfaceInfo { - format, - alpha, - color_space: crate::SurfaceInfo::passthrough_color_space(format), - }, + info: crate::SurfaceInfo { format, alpha }, swap_interval, }); } @@ -922,11 +918,7 @@ impl super::Context { surface: window_surface, wl_window: new_wl_window, extent: size, - info: crate::SurfaceInfo { - format, - alpha, - color_space: crate::SurfaceInfo::passthrough_color_space(format), - }, + info: crate::SurfaceInfo { format, alpha }, swap_interval, }; diff --git a/blade-graphics/src/gles/web.rs b/blade-graphics/src/gles/web.rs index a43c93dc..b602d620 100644 --- a/blade-graphics/src/gles/web.rs +++ b/blade-graphics/src/gles/web.rs @@ -107,8 +107,6 @@ impl super::Context { info: crate::SurfaceInfo { format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::PreMultiplied, - // the canvas expects the values to be encoded - color_space: crate::ColorSpace::Srgb, }, extent: crate::Extent::default(), }; diff --git a/blade-graphics/src/lib.rs b/blade-graphics/src/lib.rs index 10e79ba6..df0149ae 100644 --- a/blade-graphics/src/lib.rs +++ b/blade-graphics/src/lib.rs @@ -1399,34 +1399,6 @@ pub enum AlphaMode { pub struct SurfaceInfo { pub format: TextureFormat, pub alpha: AlphaMode, - /// Color space that the contents of the surface are interpreted in, - /// which is what the renderers have to produce. - /// - /// It's `Linear` when the platform does the encoding for us, either - /// via an sRGB format or via a linear display color space. It's `Srgb` - /// when the values are passed through and have to be encoded by us, - /// which is notably the case for a plain XR swapchain format. - pub color_space: ColorSpace, -} - -impl SurfaceInfo { - /// Color space of the contents of a surface that declares one, given the - /// format we ended up with and the space the user asked to work in. - pub(crate) fn derive_color_space(format: TextureFormat, requested: ColorSpace) -> ColorSpace { - if format.is_srgb() { - // the format does the encoding for us - ColorSpace::Linear - } else { - requested - } - } - - /// Color space of the contents of a surface that has no way to declare one, - /// such as an XR swapchain: an sRGB format converts for us, while anything - /// else is passed through to the display and has to be encoded already. - pub(crate) fn passthrough_color_space(format: TextureFormat) -> ColorSpace { - Self::derive_color_space(format, ColorSpace::Srgb) - } } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/blade-graphics/src/metal/surface.rs b/blade-graphics/src/metal/surface.rs index 6934c702..39788a88 100644 --- a/blade-graphics/src/metal/surface.rs +++ b/blade-graphics/src/metal/surface.rs @@ -6,7 +6,6 @@ use objc2_quartz_core::CAMetalLayer; const SURFACE_INFO: crate::SurfaceInfo = crate::SurfaceInfo { format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::Ignored, - color_space: crate::ColorSpace::Srgb, }; impl super::Surface { @@ -116,7 +115,6 @@ impl super::Context { }; surface.info = crate::SurfaceInfo { format, - color_space: crate::SurfaceInfo::derive_color_space(format, config.color_space), alpha: if config.transparent { crate::AlphaMode::PostMultiplied } else { diff --git a/blade-graphics/src/util.rs b/blade-graphics/src/util.rs index 3646f43b..264d3f37 100644 --- a/blade-graphics/src/util.rs +++ b/blade-graphics/src/util.rs @@ -43,20 +43,6 @@ pub fn emit_annotated_error(ann_err: &naga::WithSpan, filename: &st } impl super::TextureFormat { - /// Returns true if accessing the texels converts them - /// between the sRGB and the linear space. - pub const fn is_srgb(&self) -> bool { - matches!( - *self, - Self::Rgba8UnormSrgb - | Self::Bgra8UnormSrgb - | Self::Bc1UnormSrgb - | Self::Bc2UnormSrgb - | Self::Bc3UnormSrgb - | Self::Bc7UnormSrgb - ) - } - pub const fn block_info(&self) -> super::TexelBlockInfo { const fn uncompressed(size: u8) -> super::TexelBlockInfo { super::TexelBlockInfo { diff --git a/blade-graphics/src/vulkan/mod.rs b/blade-graphics/src/vulkan/mod.rs index 4d709f2c..627b691d 100644 --- a/blade-graphics/src/vulkan/mod.rs +++ b/blade-graphics/src/vulkan/mod.rs @@ -109,8 +109,6 @@ struct Swapchain { raw: vk::SwapchainKHR, format: crate::TextureFormat, alpha: crate::AlphaMode, - /// Color space the contents are interpreted in, see `SurfaceInfo`. - color_space: crate::ColorSpace, target_size: [u16; 2], } diff --git a/blade-graphics/src/vulkan/surface.rs b/blade-graphics/src/vulkan/surface.rs index a9adef84..43d23289 100644 --- a/blade-graphics/src/vulkan/surface.rs +++ b/blade-graphics/src/vulkan/surface.rs @@ -7,7 +7,6 @@ impl super::Surface { crate::SurfaceInfo { format: self.swapchain.format, alpha: self.swapchain.alpha, - color_space: self.swapchain.color_space, } } @@ -181,7 +180,6 @@ impl super::XrSurface { crate::SurfaceInfo { format: self.swapchain.format, alpha: self.swapchain.alpha, - color_space: self.swapchain.color_space, } } @@ -271,7 +269,6 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format: crate::TextureFormat::Rgba8Unorm, alpha: crate::AlphaMode::Ignored, - color_space: crate::ColorSpace::Srgb, target_size: [0; 2], }, full_screen_exclusive: fullscreen_exclusive_ext.full_screen_exclusive_supported != 0, @@ -534,12 +531,12 @@ impl super::Context { raw: raw_swapchain, format, alpha, - color_space: crate::SurfaceInfo::derive_color_space(format, config.color_space), target_size, }; } - 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 { @@ -558,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, }) } @@ -569,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 { @@ -598,7 +597,6 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format, alpha: crate::AlphaMode::Ignored, - color_space: crate::SurfaceInfo::passthrough_color_space(format), target_size: [config.size.width as u16, config.size.height as u16], }, view_count: config.view_count.max(1), @@ -737,7 +735,6 @@ impl super::Context { raw: vk::SwapchainKHR::null(), format, alpha: crate::AlphaMode::Ignored, - color_space: crate::SurfaceInfo::passthrough_color_space(format), target_size, }; surface.view_count = config.view_count.max(1); @@ -796,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-render/src/raster/mod.rs b/blade-render/src/raster/mod.rs index 909bbe47..8a452bbd 100644 --- a/blade-render/src/raster/mod.rs +++ b/blade-render/src/raster/mod.rs @@ -171,6 +171,7 @@ pub struct Rasterizer { depth_view: gpu::TextureView, surface_size: gpu::Extent, surface_info: gpu::SurfaceInfo, + color_space: gpu::ColorSpace, } impl Rasterizer { @@ -219,6 +220,7 @@ impl Rasterizer { depth_view, surface_size: config.surface_size, surface_info: config.surface_info, + color_space: config.color_space, } } @@ -548,7 +550,7 @@ impl Rasterizer { settings: [ env_map_enabled as u32 as f32, // the surface may expect us to encode the values ourselves - (self.surface_info.color_space == gpu::ColorSpace::Srgb) as u32 as f32, + (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 dfab75ac..4239cd00 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, } @@ -406,6 +413,7 @@ 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, @@ -880,6 +888,7 @@ 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, @@ -1571,9 +1580,7 @@ impl RayTracer { key_value: pp_config.exposure_key_value, white_level: pp_config.white_level, accumulated: self.show_accumulation as u32, - encode_srgb: (self.surface_info.color_space - == blade_graphics::ColorSpace::Srgb) - 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 56b7179a..1815968e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,12 +17,16 @@ Changelog for *Blade* project - 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 -- `SurfaceInfo` now reports the `color_space` of the surface contents, which is - what the renderers have to produce. An sRGB format or a linear display space - means linear values, while a plain format that the platform passes straight - through - notably an XR swapchain - means we encode them ourselves. Both of - the render paths honor it, instead of the rasterizer always encoding and the - ray tracer never doing so. +- 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 diff --git a/examples/scene/main.rs b/examples/scene/main.rs index bd29410d..c948caad 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -220,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( diff --git a/tests/gpu_examples.rs b/tests/gpu_examples.rs index d232089b..26130363 100644 --- a/tests/gpu_examples.rs +++ b/tests/gpu_examples.rs @@ -791,9 +791,9 @@ fn snapshot_pbr_raster() { surface_info: gpu::SurfaceInfo { format, alpha: gpu::AlphaMode::Ignored, - // matching an sRGB surface: the hardware does the encoding - color_space: gpu::ColorSpace::Linear, }, + // matching an sRGB surface: the hardware does the encoding + color_space: gpu::ColorSpace::Linear, max_debug_lines: 16, }, ); @@ -928,8 +928,8 @@ fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option Date: Sun, 26 Jul 2026 04:21:56 +0000 Subject: [PATCH 4/6] Stop the GPU tests from timing out on a software rasterizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPU test job failed on both Lavapipe hosts while passing on macOS, in the two heaviest tests: the accumulated ReSTIR grid and the canonical path trace. Neither was wrong, both were killed at the five second readback timeout, with no validation error and no device loss. Two things were against them. Software rasterizing dozens of accumulated ray traced frames is slow to begin with — the binary takes 18s on CI against 1.6s on Lavapipe locally. On top of that the suite runs ten tests at once, each standing up its own GPU context, and the ray traced ones also building acceleration structures and full sized render targets, so on a shared runner they compete for the same few cores and the heaviest ones stretch by much more than their own cost. So: run one test at a time, as the GLES step already did, and size the timeout for the slowest thing that legitimately runs. The timeout is there to stop a wedged GPU from hanging the suite, not to assert a frame time, so it should not be set from what a real device manages. Serializing costs nothing — the work is CPU bound either way, and the whole suite still finishes in 5s on Lavapipe locally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019vTxtSJSvMtFaZJNeWZVAG --- .github/workflows/check.yaml | 10 ++++++++-- tests/snapshot.rs | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) 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/tests/snapshot.rs b/tests/snapshot.rs index 18eeb411..87b6f944 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -4,6 +4,14 @@ 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 @@ -102,7 +110,7 @@ impl OffscreenTarget { } 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; From 418abe34637141607ec63f338a1998b0468e5c41 Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sun, 26 Jul 2026 04:22:31 +0000 Subject: [PATCH 5/6] Hand out the radiance and the G-buffer for a post process to consume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer outside the renderer — a neural upscaler, a capture tool, an analysis pass — can currently only see the tone mapped result, which has already thrown away both the dynamic range and everything the renderer knew about the geometry behind each pixel. - `PostProcConfig::tone_map` can be cleared to leave the composed radiance alone. 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: the struct gained a field. - `RayTracer::view_gbuffer` returns the depth, tangent basis, flat normal, diffuse albedo, specular reflectance, emissive, and motion of the frame that was last prepared, with each format and encoding documented so a consumer does not have to read the shaders. The views belong to the renderer and stay valid until the next `resize_screen`. Tests: - `hdr_capture_is_unclipped` checks the capture exceeds the display range, and that tone mapping it on the CPU reproduces the display capture, which pins it to the same signal one stage earlier rather than to some other buffer that happens to be bright - `gbuffer_views_describe_the_rendered_frame` binds the views from a compute shader and checks the roughness spans the range the material grid was built with, so a buffer read from the wrong place or before the scene was drawn would not pass, and that the basis quaternion decodes to the geometric normal as documented - `render_ray_traced_grid_as` gained an `inspect` hook, so a test can reach the renderer's own state without duplicating the setup - `OffscreenTarget` reads back any texel size, not only 8-bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019vTxtSJSvMtFaZJNeWZVAG --- blade-engine/src/lib.rs | 1 + blade-helpers/src/hud.rs | 25 +-- blade-render/code/post-proc.wgsl | 15 +- blade-render/src/render/mod.rs | 74 +++++++- docs/CHANGELOG.md | 15 ++ examples/scene/main.rs | 1 + tests/gpu_examples.rs | 301 ++++++++++++++++++++++++++++++- tests/shaders/gbuffer_probe.wgsl | 45 +++++ tests/snapshot.rs | 10 +- 9 files changed, 463 insertions(+), 24 deletions(-) create mode 100644 tests/shaders/gbuffer_probe.wgsl diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index 73bfb02c..2c0258ea 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -644,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 { diff --git a/blade-helpers/src/hud.rs b/blade-helpers/src/hud.rs index 2c54af70..9c5ecf9b 100644 --- a/blade-helpers/src/hud.rs +++ b/blade-helpers/src/hud.rs @@ -54,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")); + }); } } diff --git a/blade-render/code/post-proc.wgsl b/blade-render/code/post-proc.wgsl index f8ad3a46..8f9be31a 100644 --- a/blade-render/code/post-proc.wgsl +++ b/blade-render/code/post-proc.wgsl @@ -55,13 +55,16 @@ fn postfx_fs(vo: VertexOutput) -> @location(0) vec4 { let emissive = textureLoad(t_emissive, tc, 0).xyz; color = diffuse_albedo * illumination.xyz + specular + emissive; } - var mapped = color; - if (post_proc_params.tone_map_enabled != 0u) { - // 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; - mapped = l_adjusted * (1.0 + l_adjusted / (l_white*l_white)) / (1.0 + l_adjusted); + 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) { diff --git a/blade-render/src/render/mod.rs b/blade-render/src/render/mod.rs index 4239cd00..2b250d43 100644 --- a/blade-render/src/render/mod.rs +++ b/blade-render/src/render/mod.rs @@ -160,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 { @@ -167,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, @@ -1012,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, @@ -1575,7 +1647,7 @@ impl RayTracer { t_accumulation: self.targets.accumulation.views[0], t_debug: self.targets.debug.views[0], post_proc_params: PostProcParams { - tone_map_enabled: 1, + 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, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1815968e..e8f5bb31 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,21 @@ 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 diff --git a/examples/scene/main.rs b/examples/scene/main.rs index c948caad..c169fa78 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -269,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, diff --git a/tests/gpu_examples.rs b/tests/gpu_examples.rs index 26130363..0569c6e9 100644 --- a/tests/gpu_examples.rs +++ b/tests/gpu_examples.rs @@ -875,9 +875,44 @@ enum RayTraceMode { 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") { @@ -909,7 +944,7 @@ fn render_ray_traced_grid(cache_name: &str, mode: RayTraceMode) -> Option Option Option Option= 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] 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 87b6f944..4af1805b 100644 --- a/tests/snapshot.rs +++ b/tests/snapshot.rs @@ -57,10 +57,13 @@ pub struct OffscreenTarget { 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, @@ -83,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 { @@ -91,6 +94,7 @@ impl OffscreenTarget { view, readback, size, + texel_size, } } @@ -104,7 +108,7 @@ 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, ); } @@ -113,7 +117,7 @@ impl OffscreenTarget { 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); From fbbcaef065f5072643455c360bb718017f29cd63 Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sun, 26 Jul 2026 05:14:59 +0000 Subject: [PATCH 6/6] Give next event estimation the whole weight when a path ends The canonical renderer combines two ways of finding the light at a vertex: next event estimation, which samples the environment directly, and a BSDF sample that may run into it on the way out. The balance heuristic splits the contribution between them, so that neither is counted twice. At the last vertex of a path there is no second strategy. The loop breaks after the next event estimation, and the BSDF sample it was weighted against is never taken, so the share held back for it was simply lost. The weight is now the whole contribution whenever the path is about to end, which is what `will_extend` decides, and it also says what the loop was already testing for in its break condition. How much this cost depends on how much throughput the path had left at that last vertex, so deep paths hid it well: the material grid at three bounces moves by SSIM 0.9998, and the cross-check against ReSTIR is unchanged at 3.96 out of 255. At `max_bounces` of zero, though, the only vertex there is was losing a share of everything, and zero bounces is exactly the configuration that means direct lighting and nothing else. A white furnace shows it without a reference to compare against: a lone convex sphere cannot bounce light onto itself, so a direct-only render of one has to match a converged one, and an energy conserving BRDF has to leave it indistinguishable from the environment behind it. It was rendering visibly darker instead. It now matches the converged render exactly, to the mean code value over the sphere. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MaDekFuFvymYai37WQ7SDr --- blade-render/code/path-trace.wgsl | 11 +++++++++-- docs/CHANGELOG.md | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/blade-render/code/path-trace.wgsl b/blade-render/code/path-trace.wgsl index 440755ef..6da8a3d4 100644 --- a/blade-render/code/path-trace.wgsl +++ b/blade-render/code/path-trace.wgsl @@ -155,6 +155,13 @@ fn trace_path(start_dir: vec3, rng: ptr) -> vec3, rng: ptr) -> vec3