diff --git a/code/intermediate/tutorial13-hdr/src/hdr.rs b/code/intermediate/tutorial13-hdr/src/hdr.rs index 6beacac5..36cf5fd3 100644 --- a/code/intermediate/tutorial13-hdr/src/hdr.rs +++ b/code/intermediate/tutorial13-hdr/src/hdr.rs @@ -2,6 +2,7 @@ use wgpu::Operations; use crate::{create_render_pipeline, texture}; +/// Owns the render texture and controls tonemapping pub struct HdrPipeline { pipeline: wgpu::RenderPipeline, bind_group: wgpu::BindGroup, @@ -17,6 +18,8 @@ impl HdrPipeline { let width = config.width; let height = config.height; + // We could use `Rgba32Float`, but that requires some extra + // features to be enabled. let format = wgpu::TextureFormat::Rgba16Float; let texture = texture::Texture::create_2d_texture( @@ -94,6 +97,7 @@ impl HdrPipeline { } } + /// Resize the HDR texture pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { self.texture = texture::Texture::create_2d_texture( device, @@ -122,14 +126,18 @@ impl HdrPipeline { self.height = height; } + /// Exposes the HDR texture pub fn view(&self) -> &wgpu::TextureView { &self.texture.view } + /// The format of the HDR texture pub fn format(&self) -> wgpu::TextureFormat { self.format } + /// This renders the internal HDR texture to the [TextureView] + /// supplied as parameter. pub fn process(&self, encoder: &mut wgpu::CommandEncoder, output: &wgpu::TextureView) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Hdr::process"), diff --git a/code/intermediate/tutorial13-hdr/src/hdr.wgsl b/code/intermediate/tutorial13-hdr/src/hdr.wgsl index c669e372..bf69c078 100644 --- a/code/intermediate/tutorial13-hdr/src/hdr.wgsl +++ b/code/intermediate/tutorial13-hdr/src/hdr.wgsl @@ -27,11 +27,14 @@ fn vs_main( @builtin(vertex_index) vi: u32, ) -> VertexOutput { var out: VertexOutput; + // Generate a triangle that covers the whole screen out.uv = vec2( f32((vi << 1u) & 2u), f32(vi & 2u), ); out.clip_position = vec4(out.uv * 2.0 - 1.0, 0.0, 1.0); + // We need to invert the y coordinate so the image + // is not upside down out.uv.y = 1.0 - out.uv.y; return out; } @@ -49,4 +52,4 @@ fn fs_main(vs: VertexOutput) -> @location(0) vec4 { let hdr = textureSample(hdr_image, hdr_sampler, vs.uv); let sdr = aces_tone_map(hdr.rgb); return vec4(sdr, hdr.a); -} \ No newline at end of file +} diff --git a/code/intermediate/tutorial13-hdr/src/lib.rs b/code/intermediate/tutorial13-hdr/src/lib.rs index a62f6163..848bb22b 100644 --- a/code/intermediate/tutorial13-hdr/src/lib.rs +++ b/code/intermediate/tutorial13-hdr/src/lib.rs @@ -185,7 +185,7 @@ fn create_render_pipeline( color_format: wgpu::TextureFormat, depth_format: Option, vertex_layouts: &[wgpu::VertexBufferLayout], - topology: wgpu::PrimitiveTopology, + topology: wgpu::PrimitiveTopology, // NEW! shader: wgpu::ShaderModuleDescriptor, ) -> wgpu::RenderPipeline { let shader = device.create_shader_module(shader); @@ -208,7 +208,7 @@ fn create_render_pipeline( })], }), primitive: wgpu::PrimitiveState { - topology, + topology, // NEW! strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: Some(wgpu::Face::Back), @@ -653,7 +653,6 @@ impl State { } } - // UPDATED! fn input(&mut self, event: &WindowEvent) -> bool { match event { WindowEvent::KeyboardInput { @@ -682,7 +681,6 @@ impl State { } fn update(&mut self, dt: std::time::Duration) { - // UPDATED! self.camera_controller.update_camera(&mut self.camera, dt); self.camera_uniform .update_view_proj(&self.camera, &self.projection); diff --git a/docs/intermediate/tutorial13-hdr/readme.md b/docs/intermediate/tutorial13-hdr/readme.md index 90413fec..5b4b9a20 100644 --- a/docs/intermediate/tutorial13-hdr/readme.md +++ b/docs/intermediate/tutorial13-hdr/readme.md @@ -1,10 +1,387 @@ # High Dynamic Range Rendering +Up to this point we've been using the sRGB colorspace to render our scene. +While this is fine it limits what we can do with our lighting. We are using +`TextureFormat::Bgra8UnormSrgb` (on most systems) for our surface texture. +This means that we have 8bits for each of the color and alpha channels. While +the channels are stored as integers between 0 and 255 inclusively, they get +converted to and from floating point values between 0.0 and 1.0. The TL:DR of +this is that using 8bit textures we only get 256 possible values in each +channel. + +The kicker with this is most of the precision gets used to represent darker +values of the scene. This means that bright objects like a light bulb have +the same value as exeedingly bright objects such as the sun. This inaccuracy +makes realistic lighting difficult to do right. Because of this, we are going +to switch our rendering system to use high dynamic range in order to give our +scene more flexibility and enable use to leverage more advanced techniques +such as Physically Based Rendering. + ## What is High Dynamic Range? +In laymans terms, a High Dynamic Range texture is a texture with more bits +per pixel. In addition to this, HDR textures are stored as floating point values +instead of integer values. This means that the texture can have brightness values +greater than 1.0 meaning you can have a dynamic range of brighter objects. + ## Switching to HDR +As of writing, wgpu doesn't allow us to use a floating point format such as +`TextureFormat::Rgba16Float` (not all monitors support that anyways), so we +will have to render our scene in an HDR format, then convert the values to a +supported format such as `TextureFormat::Bgra8UnormSrgb` using a technique +called tonemapping. + +Before we do that though we need to switch to using an HDR texture for rendering. + +To start we'll create a file called `hdr.rs` and put the some code in it: + +```rust +use wgpu::Operations; + +use crate::{create_render_pipeline, texture}; + +/// Owns the render texture and controls tonemapping +pub struct HdrPipeline { + pipeline: wgpu::RenderPipeline, + bind_group: wgpu::BindGroup, + texture: texture::Texture, + width: u32, + height: u32, + format: wgpu::TextureFormat, + layout: wgpu::BindGroupLayout, +} + +impl HdrPipeline { + pub fn new(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> Self { + let width = config.width; + let height = config.height; + + // We could use `Rgba32Float`, but that requires some extra + // features to be enabled for rendering. + let format = wgpu::TextureFormat::Rgba16Float; + + let texture = texture::Texture::create_2d_texture( + device, + width, + height, + format, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + wgpu::FilterMode::Nearest, + Some("Hdr::texture"), + ); + + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Hdr::layout"), + entries: &[ + // This is the HDR texture + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Hdr::bind_group"), + layout: &layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture.view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&texture.sampler), + }, + ], + }); + + // We'll cover the shader next + let shader = wgpu::include_wgsl!("hdr.wgsl"); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: None, + bind_group_layouts: &[&layout], + push_constant_ranges: &[], + }); + + let pipeline = create_render_pipeline( + device, + &pipeline_layout, + config.format, + None, + // We'll use some math to generate the vertex data in + // the shader, so we don't need any vertex buffers + &[], + wgpu::PrimitiveTopology::TriangleList, + shader, + ); + + Self { + pipeline, + bind_group, + layout, + texture, + width, + height, + format, + } + } + + /// Resize the HDR texture + pub fn resize(&mut self, device: &wgpu::Device, width: u32, height: u32) { + self.texture = texture::Texture::create_2d_texture( + device, + width, + height, + wgpu::TextureFormat::Rgba16Float, + wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, + wgpu::FilterMode::Nearest, + Some("Hdr::texture"), + ); + self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Hdr::bind_group"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self.texture.view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.texture.sampler), + }, + ], + }); + self.width = width; + self.height = height; + } + + /// Exposes the HDR texture + pub fn view(&self) -> &wgpu::TextureView { + &self.texture.view + } + + /// The format of the HDR texture + pub fn format(&self) -> wgpu::TextureFormat { + self.format + } + + /// This renders the internal HDR texture to the [TextureView] + /// supplied as parameter. + pub fn process(&self, encoder: &mut wgpu::CommandEncoder, output: &wgpu::TextureView) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Hdr::process"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output, + resolve_target: None, + ops: Operations { + load: wgpu::LoadOp::Load, + store: true, + }, + })], + depth_stencil_attachment: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + } +} +``` + +You may have noticed that we added a new parameter to `create_render_pipeline`. Here a the changes to that function: + +```rust +fn create_render_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + color_format: wgpu::TextureFormat, + depth_format: Option, + vertex_layouts: &[wgpu::VertexBufferLayout], + topology: wgpu::PrimitiveTopology, // NEW! + shader: wgpu::ShaderModuleDescriptor, +) -> wgpu::RenderPipeline { + let shader = device.create_shader_module(shader); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + // ... + primitive: wgpu::PrimitiveState { + topology, // NEW! + // ... + }, + // ... + }) +} +``` + +## Tonemapping + +The process of tonemapping is taking an HDR image and converting it to +a Standard Dynamic Range (SDR) which is usually sRGB. The exact +tonemapping curve you uses is ultimately up to your artistic needs, but +for this tutorial we'll use a popular one know as the Academy Color +Encoding System or ACES used throughout the game industry as well as the film industry. + +With that let's jump into the the shader. Create a file called `hdr.wgsl` +and add the following code: + +```wgsl +// Maps HDR values to linear values +// Based on http://www.oscars.org/science-technology/sci-tech-projects/aces +fn aces_tone_map(hdr: vec3) -> vec3 { + let m1 = mat3x3( + 0.59719, 0.07600, 0.02840, + 0.35458, 0.90834, 0.13383, + 0.04823, 0.01566, 0.83777, + ); + let m2 = mat3x3( + 1.60475, -0.10208, -0.00327, + -0.53108, 1.10813, -0.07276, + -0.07367, -0.00605, 1.07602, + ); + let v = m1 * hdr; + let a = v * (v + 0.0245786) - 0.000090537; + let b = v * (0.983729 * v + 0.4329510) + 0.238081; + return clamp(m2 * (a / b), vec3(0.0), vec3(1.0)); +} + +struct VertexOutput { + @location(0) uv: vec2, + @builtin(position) clip_position: vec4, +}; + +@vertex +fn vs_main( + @builtin(vertex_index) vi: u32, +) -> VertexOutput { + var out: VertexOutput; + // Generate a triangle that covers the whole screen + out.uv = vec2( + f32((vi << 1u) & 2u), + f32(vi & 2u), + ); + out.clip_position = vec4(out.uv * 2.0 - 1.0, 0.0, 1.0); + // We need to invert the y coordinate so the image + // is not upside down + out.uv.y = 1.0 - out.uv.y; + return out; +} + +@group(0) +@binding(0) +var hdr_image: texture_2d; + +@group(0) +@binding(1) +var hdr_sampler: sampler; + +@fragment +fn fs_main(vs: VertexOutput) -> @location(0) vec4 { + let hdr = textureSample(hdr_image, hdr_sampler, vs.uv); + let sdr = aces_tone_map(hdr.rgb); + return vec4(sdr, hdr.a); +} +``` + +With those in place we can start using our HDR texture in our core +render pipeline. First we need to add the new `HdrPipeline` to `State`: + +```rust +// lib.rs + +mod hdr; // NEW! + +// ... + +struct State { + // ... + // NEW! + hdr: hdr::HdrPipeline, +} + +impl State { + pub fn new(window: Window) -> anyhow::Result { + // ... + // NEW! + let hdr = hdr::HdrPipeline::new(&device, &config); + + // ... + + Self { + // ... + hdr, // NEW! + } + } +} +``` + +Then when we resize the window, we need to call `resize()` on our +`HdrPipeline`: + +```rust +fn resize(&mut self, new_size: winit::dpi::PhysicalSize) { + // UPDATED! + if new_size.width > 0 && new_size.height > 0 { + // ... + self.hdr + .resize(&self.device, new_size.width, new_size.height); + // ... + } +} +``` + +Next in `render()` we need to switch the `RenderPass` to use our HDR +texture instead of the surface texture: + +```rust +// render() +let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: self.hdr.view(), // UPDATED! + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.1, + g: 0.2, + b: 0.3, + a: 1.0, + }), + store: true, + }, + })], + depth_stencil_attachment: Some( + // ... + ), +}); +``` + +Finally after we draw all the objects in the frame we can run our +tonemapper with the surface texture as the output: + +```rust +// NEW! +// Apply tonemapping +self.hdr.process(&mut encoder, &view); +``` + +It's a pretty easy switch. Here's the image before using HDR: + ![before hdr](./before-hdr.png) + +Here's what it looks like after implementing HDR: + ![after hdr](./after-hdr.png) ## Loading HDR textures