Skip to content

Commit

Permalink
Fix color banding by dithering image before quantization (bevyengine#…
Browse files Browse the repository at this point in the history
…5264)

# Objective

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

## Solution

- Adds dithering to the tonemapping node from bevyengine#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>
  • Loading branch information
2 people authored and ItsDoot committed Feb 1, 2023
1 parent 7c877d3 commit 0a54100
Show file tree
Hide file tree
Showing 15 changed files with 156 additions and 51 deletions.
2 changes: 1 addition & 1 deletion crates/bevy_core_pipeline/src/core_2d/camera_2d.rs
Expand Up @@ -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,
}
}
}
4 changes: 3 additions & 1 deletion crates/bevy_core_pipeline/src/core_3d/camera_3d.rs
Expand Up @@ -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(),
Expand Down
101 changes: 75 additions & 26 deletions crates/bevy_core_pipeline/src/tonemapping/mod.rs
Expand Up @@ -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);
Expand Down Expand Up @@ -42,15 +42,51 @@ 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);
}
}
}

#[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 {
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 4 additions & 7 deletions crates/bevy_core_pipeline/src/tonemapping/node.rs
@@ -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::{
Expand All @@ -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)>>,
}

Expand Down Expand Up @@ -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(()),
};
Expand Down
12 changes: 11 additions & 1 deletion crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl
Expand Up @@ -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);
}
Expand Up @@ -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;
}
12 changes: 6 additions & 6 deletions crates/bevy_core_pipeline/src/upscaling/mod.rs
Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -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>>,
Expand All @@ -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));
}
}
6 changes: 3 additions & 3 deletions crates/bevy_core_pipeline/src/upscaling/node.rs
Expand Up @@ -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)>>,
}

Expand Down Expand Up @@ -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(()),
};
Expand Down
8 changes: 6 additions & 2 deletions crates/bevy_pbr/src/material.rs
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions crates/bevy_pbr/src/render/mesh.rs
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions crates/bevy_pbr/src/render/pbr.wgsl
Expand Up @@ -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;
}
6 changes: 6 additions & 0 deletions crates/bevy_pbr/src/render/pbr_functions.wgsl
Expand Up @@ -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

8 changes: 6 additions & 2 deletions crates/bevy_sprite/src/mesh2d/material.rs
Expand Up @@ -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;
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/bevy_sprite/src/mesh2d/mesh.rs
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)?;
Expand Down

0 comments on commit 0a54100

Please sign in to comment.