Fix color banding by dithering image before quantization (#5264)

# Objective

- Closes #5262 
- Fix color banding caused by quantization.

## Solution

- Adds dithering to the tonemapping node from #3425.
- This is inspired by Godot's default "debanding" shader: https://gist.github.com/belzecue/
- Unlike Godot:
  - debanding happens after tonemapping. My understanding is that this is preferred, because we are running the debanding at the last moment before quantization (`[f32, f32, f32, f32]` -> `f32`). This ensures we aren't biasing the dithering strength by applying it in a different (linear) color space.
  - This code instead uses and reference the origin source, Valve at GDC 2015

![Screenshot from 2022-11-10 13-44-46](https://user-images.githubusercontent.com/2632925/201218880-70f4cdab-a1ed-44de-a88c-8759e77197f1.png)
![Screenshot from 2022-11-10 13-41-11](https://user-images.githubusercontent.com/2632925/201218883-72393352-b162-41da-88bb-6e54a1e26853.png)


## Additional Notes 

Real time rendering to standard dynamic range outputs is limited to 8 bits of depth per color channel. Internally we keep everything in full 32-bit precision (`vec4<f32>`) inside passes and 16-bit between passes until the image is ready to be displayed, at which point the GPU implicitly converts our `vec4<f32>` into a single 32bit value per pixel, with each channel (rgba) getting 8 of those 32 bits.

### The Problem

8 bits of color depth is simply not enough precision to make each step invisible - we only have 256 values per channel! Human vision can perceive steps in luma to about 14 bits of precision. When drawing a very slight gradient, the transition between steps become visible because with a gradient, neighboring pixels will all jump to the next "step" of precision at the same time.

### The Solution

One solution is to simply output in HDR - more bits of color data means the transition between bands will become smaller. However, not everyone has hardware that supports 10+ bit color depth. Additionally, 10 bit color doesn't even fully solve the issue, banding will result in coherent bands on shallow gradients, but the steps will be harder to perceive.

The solution in this PR adds noise to the signal before it is "quantized" or resampled from 32 to 8 bits. Done naively, it's easy to add unneeded noise to the image. To ensure dithering is correct and absolutely minimal, noise is adding *within* one step of the output color depth. When converting from the 32bit to 8bit signal, the value is rounded to the nearest 8 bit value (0 - 255). Banding occurs around the transition from one value to the next, let's say from 50-51. Dithering will never add more than +/-0.5 bits of noise, so the pixels near this transition might round to 50 instead of 51 but will never round more than one step. This means that the output image won't have excess variance:
  - in a gradient from 49 to 51, there will be a step between each band at 49, 50, and 51.
  - Done correctly, the modified image of this gradient will never have a adjacent pixels more than one step (0-255) from each other.
  - I.e. when scanning across the gradient you should expect to see:
```
                  |-band-| |-band-| |-band-|
Baseline:         49 49 49 50 50 50 51 51 51
Dithered:         49 50 49 50 50 51 50 51 51
Dithered (wrong): 49 50 51 49 50 51 49 51 50
```

![Screenshot from 2022-11-10 14-12-36](https://user-images.githubusercontent.com/2632925/201219075-ab3f46be-d4e9-4869-b66b-a92e1706f49e.png)
![Screenshot from 2022-11-10 14-11-48](https://user-images.githubusercontent.com/2632925/201219079-ec5d2add-817d-487a-8fc1-84569c9cda73.png)




You can see from above how correct dithering "fuzzes" the transition between bands to reduce distinct steps in color, without adding excess noise.

### HDR

The previous section (and this PR) assumes the final output is to an 8-bit texture, however this is not always the case. When Bevy adds HDR support, the dithering code will need to take the per-channel depth into account instead of assuming it to be 0-255. Edit: I talked with Rob about this and it seems like the current solution is okay. We may need to revisit once we have actual HDR final image output.

---

## Changelog

### Added

- All pipelines now support deband dithering. This is enabled by default in 3D, and can be toggled in the `Tonemapping` component in camera bundles. Banding is a graphical artifact created when the rendered image is crunched from high precision (f32 per color channel) down to the final output (u8 per channel in SDR). This results in subtle gradients becoming blocky due to the reduced color precision. Deband dithering applies a small amount of noise to the signal before it is "crunched", which breaks up the hard edges of blocks (bands) of color. Note that this does not add excess noise to the image, as the amount of noise is less than a single step of a color channel - just enough to break up the transition between color blocks in a gradient.


Co-authored-by: Carter Anderson <mcanders1@gmail.com>
This commit is contained in:
Aevyrie 2022-11-11 19:43:45 +00:00
parent c4e791d628
commit 72fbcc7633
15 changed files with 156 additions and 51 deletions

View File

@ -75,7 +75,7 @@ impl Camera2dBundle {
global_transform: Default::default(),
camera: Camera::default(),
camera_2d: Camera2d::default(),
tonemapping: Tonemapping { is_enabled: false },
tonemapping: Tonemapping::Disabled,
}
}
}

View File

@ -74,7 +74,9 @@ impl Default for Camera3dBundle {
fn default() -> Self {
Self {
camera_render_graph: CameraRenderGraph::new(crate::core_3d::graph::NAME),
tonemapping: Tonemapping { is_enabled: true },
tonemapping: Tonemapping::Enabled {
deband_dither: true,
},
camera: Default::default(),
projection: Default::default(),
visible_entities: Default::default(),

View File

@ -12,7 +12,7 @@ use bevy_render::camera::Camera;
use bevy_render::extract_component::{ExtractComponent, ExtractComponentPlugin};
use bevy_render::renderer::RenderDevice;
use bevy_render::view::ViewTarget;
use bevy_render::{render_resource::*, RenderApp};
use bevy_render::{render_resource::*, RenderApp, RenderStage};
const TONEMAPPING_SHADER_HANDLE: HandleUntyped =
HandleUntyped::weak_from_u64(Shader::TYPE_UUID, 17015368199668024512);
@ -42,7 +42,10 @@ impl Plugin for TonemappingPlugin {
app.add_plugin(ExtractComponentPlugin::<Tonemapping>::default());
if let Ok(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.init_resource::<TonemappingPipeline>();
render_app
.init_resource::<TonemappingPipeline>()
.init_resource::<SpecializedRenderPipelines<TonemappingPipeline>>()
.add_system_to_stage(RenderStage::Queue, queue_view_tonemapping_pipelines);
}
}
}
@ -50,7 +53,40 @@ impl Plugin for TonemappingPlugin {
#[derive(Resource)]
pub struct TonemappingPipeline {
texture_bind_group: BindGroupLayout,
tonemapping_pipeline_id: CachedRenderPipelineId,
}
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct TonemappingPipelineKey {
deband_dither: bool,
}
impl SpecializedRenderPipeline for TonemappingPipeline {
type Key = TonemappingPipelineKey;
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
let mut shader_defs = Vec::new();
if key.deband_dither {
shader_defs.push("DEBAND_DITHER".to_string());
}
RenderPipelineDescriptor {
label: Some("tonemapping pipeline".into()),
layout: Some(vec![self.texture_bind_group.clone()]),
vertex: fullscreen_shader_vertex_state(),
fragment: Some(FragmentState {
shader: TONEMAPPING_SHADER_HANDLE.typed(),
shader_defs,
entry_point: "fragment".into(),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
primitive: PrimitiveState::default(),
depth_stencil: None,
multisample: MultisampleState::default(),
}
}
}
impl FromWorld for TonemappingPipeline {
@ -79,37 +115,50 @@ impl FromWorld for TonemappingPipeline {
],
});
let tonemap_descriptor = RenderPipelineDescriptor {
label: Some("tonemapping pipeline".into()),
layout: Some(vec![tonemap_texture_bind_group.clone()]),
vertex: fullscreen_shader_vertex_state(),
fragment: Some(FragmentState {
shader: TONEMAPPING_SHADER_HANDLE.typed(),
shader_defs: vec![],
entry_point: "fragment".into(),
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
primitive: PrimitiveState::default(),
depth_stencil: None,
multisample: MultisampleState::default(),
};
let mut cache = render_world.resource_mut::<PipelineCache>();
TonemappingPipeline {
texture_bind_group: tonemap_texture_bind_group,
tonemapping_pipeline_id: cache.queue_render_pipeline(tonemap_descriptor),
}
}
}
#[derive(Component)]
pub struct ViewTonemappingPipeline(CachedRenderPipelineId);
pub fn queue_view_tonemapping_pipelines(
mut commands: Commands,
mut pipeline_cache: ResMut<PipelineCache>,
mut pipelines: ResMut<SpecializedRenderPipelines<TonemappingPipeline>>,
upscaling_pipeline: Res<TonemappingPipeline>,
view_targets: Query<(Entity, &Tonemapping)>,
) {
for (entity, tonemapping) in view_targets.iter() {
if let Tonemapping::Enabled { deband_dither } = tonemapping {
let key = TonemappingPipelineKey {
deband_dither: *deband_dither,
};
let pipeline = pipelines.specialize(&mut pipeline_cache, &upscaling_pipeline, key);
commands
.entity(entity)
.insert(ViewTonemappingPipeline(pipeline));
}
}
}
#[derive(Component, Clone, Reflect, Default)]
#[reflect(Component)]
pub struct Tonemapping {
pub is_enabled: bool,
pub enum Tonemapping {
#[default]
Disabled,
Enabled {
deband_dither: bool,
},
}
impl Tonemapping {
pub fn is_enabled(&self) -> bool {
matches!(self, Tonemapping::Enabled { .. })
}
}
impl ExtractComponent for Tonemapping {

View File

@ -1,6 +1,6 @@
use std::sync::Mutex;
use crate::tonemapping::{Tonemapping, TonemappingPipeline};
use crate::tonemapping::{TonemappingPipeline, ViewTonemappingPipeline};
use bevy_ecs::prelude::*;
use bevy_ecs::query::QueryState;
use bevy_render::{
@ -15,7 +15,7 @@ use bevy_render::{
};
pub struct TonemappingNode {
query: QueryState<(&'static ViewTarget, Option<&'static Tonemapping>), With<ExtractedView>>,
query: QueryState<(&'static ViewTarget, &'static ViewTonemappingPipeline), With<ExtractedView>>,
cached_texture_bind_group: Mutex<Option<(TextureViewId, BindGroup)>>,
}
@ -54,14 +54,11 @@ impl Node for TonemappingNode {
Err(_) => return Ok(()),
};
let tonemapping_enabled = tonemapping.map_or(false, |t| t.is_enabled);
if !tonemapping_enabled || !target.is_hdr() {
if !target.is_hdr() {
return Ok(());
}
let pipeline = match pipeline_cache
.get_render_pipeline(tonemapping_pipeline.tonemapping_pipeline_id)
{
let pipeline = match pipeline_cache.get_render_pipeline(tonemapping.0) {
Some(pipeline) => pipeline,
None => return Ok(()),
};

View File

@ -10,5 +10,15 @@ var hdr_sampler: sampler;
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
let hdr_color = textureSample(hdr_texture, hdr_sampler, in.uv);
return vec4<f32>(reinhard_luminance(hdr_color.rgb), hdr_color.a);
var output_rgb = reinhard_luminance(hdr_color.rgb);
#ifdef DEBAND_DITHER
output_rgb = pow(output_rgb.rgb, vec3<f32>(1.0 / 2.2));
output_rgb = output_rgb + screen_space_dither(in.position.xy);
// This conversion back to linear space is required because our output texture format is
// SRGB; the GPU will assume our output is linear and will apply an SRGB conversion.
output_rgb = pow(output_rgb.rgb, vec3<f32>(2.2));
#endif
return vec4<f32>(output_rgb, hdr_color.a);
}

View File

@ -27,3 +27,11 @@ fn reinhard_luminance(color: vec3<f32>) -> vec3<f32> {
let l_new = l_old / (1.0 + l_old);
return tonemapping_change_luminance(color, l_new);
}
// Source: Advanced VR Rendering, GDC 2015, Alex Vlachos, Valve, Slide 49
// https://media.steampowered.com/apps/valve/2015/Alex_Vlachos_Advanced_VR_Rendering_GDC2015.pdf
fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
var dither = vec3<f32>(dot(vec2<f32>(171.0, 231.0), frag_coord)).xxx;
dither = fract(dither.rgb / vec3<f32>(103.0, 71.0, 97.0));
return (dither - 0.5) / 255.0;
}

View File

@ -31,7 +31,7 @@ impl Plugin for UpscalingPlugin {
render_app
.init_resource::<UpscalingPipeline>()
.init_resource::<SpecializedRenderPipelines<UpscalingPipeline>>()
.add_system_to_stage(RenderStage::Queue, queue_upscaling_bind_groups);
.add_system_to_stage(RenderStage::Queue, queue_view_upscaling_pipelines);
}
}
}
@ -110,11 +110,9 @@ impl SpecializedRenderPipeline for UpscalingPipeline {
}
#[derive(Component)]
pub struct UpscalingTarget {
pub pipeline: CachedRenderPipelineId,
}
pub struct ViewUpscalingPipeline(CachedRenderPipelineId);
fn queue_upscaling_bind_groups(
fn queue_view_upscaling_pipelines(
mut commands: Commands,
mut pipeline_cache: ResMut<PipelineCache>,
mut pipelines: ResMut<SpecializedRenderPipelines<UpscalingPipeline>>,
@ -128,6 +126,8 @@ fn queue_upscaling_bind_groups(
};
let pipeline = pipelines.specialize(&mut pipeline_cache, &upscaling_pipeline, key);
commands.entity(entity).insert(UpscalingTarget { pipeline });
commands
.entity(entity)
.insert(ViewUpscalingPipeline(pipeline));
}
}

View File

@ -13,10 +13,10 @@ use bevy_render::{
view::{ExtractedView, ViewTarget},
};
use super::{UpscalingPipeline, UpscalingTarget};
use super::{UpscalingPipeline, ViewUpscalingPipeline};
pub struct UpscalingNode {
query: QueryState<(&'static ViewTarget, &'static UpscalingTarget), With<ExtractedView>>,
query: QueryState<(&'static ViewTarget, &'static ViewUpscalingPipeline), With<ExtractedView>>,
cached_texture_bind_group: Mutex<Option<(TextureViewId, BindGroup)>>,
}
@ -89,7 +89,7 @@ impl Node for UpscalingNode {
}
};
let pipeline = match pipeline_cache.get_render_pipeline(upscaling_target.pipeline) {
let pipeline = match pipeline_cache.get_render_pipeline(upscaling_target.0) {
Some(pipeline) => pipeline,
None => return Ok(()),
};

View File

@ -363,9 +363,13 @@ pub fn queue_material_meshes<M: Material>(
let mut view_key =
MeshPipelineKey::from_msaa_samples(msaa.samples) | MeshPipelineKey::from_hdr(view.hdr);
if let Some(tonemapping) = tonemapping {
if tonemapping.is_enabled && !view.hdr {
if let Some(Tonemapping::Enabled { deband_dither }) = tonemapping {
if !view.hdr {
view_key |= MeshPipelineKey::TONEMAP_IN_SHADER;
if *deband_dither {
view_key |= MeshPipelineKey::DEBAND_DITHER;
}
}
}
let rangefinder = view.rangefinder3d();

View File

@ -518,6 +518,7 @@ bitflags::bitflags! {
const TRANSPARENT_MAIN_PASS = (1 << 0);
const HDR = (1 << 1);
const TONEMAP_IN_SHADER = (1 << 2);
const DEBAND_DITHER = (1 << 3);
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
const PRIMITIVE_TOPOLOGY_RESERVED_BITS = Self::PRIMITIVE_TOPOLOGY_MASK_BITS << Self::PRIMITIVE_TOPOLOGY_SHIFT_BITS;
}
@ -636,6 +637,11 @@ impl SpecializedMeshPipeline for MeshPipeline {
if key.contains(MeshPipelineKey::TONEMAP_IN_SHADER) {
shader_defs.push("TONEMAP_IN_SHADER".to_string());
// Debanding is tied to tonemapping in the shader, cannot run without it.
if key.contains(MeshPipelineKey::DEBAND_DITHER) {
shader_defs.push("DEBAND_DITHER".to_string());
}
}
let format = match key.contains(MeshPipelineKey::HDR) {

View File

@ -97,6 +97,9 @@ fn fragment(in: FragmentInput) -> @location(0) vec4<f32> {
#ifdef TONEMAP_IN_SHADER
output_color = tone_mapping(output_color);
#endif
#ifdef DEBAND_DITHER
output_color = dither(output_color, in.frag_coord.xy);
#endif
return output_color;
}

View File

@ -262,3 +262,9 @@ fn tone_mapping(in: vec4<f32>) -> vec4<f32> {
}
#endif
#ifdef DEBAND_DITHER
fn dither(color: vec4<f32>, pos: vec2<f32>) -> vec4<f32> {
return vec4<f32>(color.rgb + screen_space_dither(pos.xy), color.a);
}
#endif

View File

@ -328,9 +328,13 @@ pub fn queue_material2d_meshes<M: Material2d>(
let mut view_key = Mesh2dPipelineKey::from_msaa_samples(msaa.samples)
| Mesh2dPipelineKey::from_hdr(view.hdr);
if let Some(tonemapping) = tonemapping {
if tonemapping.is_enabled && !view.hdr {
if let Some(Tonemapping::Enabled { deband_dither }) = tonemapping {
if !view.hdr {
view_key |= Mesh2dPipelineKey::TONEMAP_IN_SHADER;
if *deband_dither {
view_key |= Mesh2dPipelineKey::DEBAND_DITHER;
}
}
}

View File

@ -288,6 +288,7 @@ bitflags::bitflags! {
const NONE = 0;
const HDR = (1 << 0);
const TONEMAP_IN_SHADER = (1 << 1);
const DEBAND_DITHER = (1 << 2);
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
const PRIMITIVE_TOPOLOGY_RESERVED_BITS = Self::PRIMITIVE_TOPOLOGY_MASK_BITS << Self::PRIMITIVE_TOPOLOGY_SHIFT_BITS;
}
@ -376,6 +377,11 @@ impl SpecializedMeshPipeline for Mesh2dPipeline {
if key.contains(Mesh2dPipelineKey::TONEMAP_IN_SHADER) {
shader_defs.push("TONEMAP_IN_SHADER".to_string());
// Debanding is tied to tonemapping in the shader, cannot run without it.
if key.contains(Mesh2dPipelineKey::DEBAND_DITHER) {
shader_defs.push("DEBAND_DITHER".to_string());
}
}
let vertex_buffer_layout = layout.get_layout(&vertex_attributes)?;

View File

@ -151,6 +151,7 @@ bitflags::bitflags! {
const COLORED = (1 << 0);
const HDR = (1 << 1);
const TONEMAP_IN_SHADER = (1 << 2);
const DEBAND_DITHER = (1 << 3);
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
}
}
@ -212,6 +213,11 @@ impl SpecializedRenderPipeline for SpritePipeline {
if key.contains(SpritePipelineKey::TONEMAP_IN_SHADER) {
shader_defs.push("TONEMAP_IN_SHADER".to_string());
// Debanding is tied to tonemapping in the shader, cannot run without it.
if key.contains(SpritePipelineKey::DEBAND_DITHER) {
shader_defs.push("DEBAND_DITHER".to_string());
}
}
let format = match key.contains(SpritePipelineKey::HDR) {
@ -508,9 +514,13 @@ pub fn queue_sprites(
for (mut transparent_phase, visible_entities, view, tonemapping) in &mut views {
let mut view_key = SpritePipelineKey::from_hdr(view.hdr) | msaa_key;
if let Some(tonemapping) = tonemapping {
if tonemapping.is_enabled && !view.hdr {
if let Some(Tonemapping::Enabled { deband_dither }) = tonemapping {
if !view.hdr {
view_key |= SpritePipelineKey::TONEMAP_IN_SHADER;
if *deband_dither {
view_key |= SpritePipelineKey::DEBAND_DITHER;
}
}
}
let pipeline = pipelines.specialize(