Skip to content

Commit 72fbcc7

Browse files
aevyriecart
andcommitted
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 <[email protected]>
1 parent c4e791d commit 72fbcc7

File tree

15 files changed

+156
-51
lines changed

15 files changed

+156
-51
lines changed

crates/bevy_core_pipeline/src/core_2d/camera_2d.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ impl Camera2dBundle {
7575
global_transform: Default::default(),
7676
camera: Camera::default(),
7777
camera_2d: Camera2d::default(),
78-
tonemapping: Tonemapping { is_enabled: false },
78+
tonemapping: Tonemapping::Disabled,
7979
}
8080
}
8181
}

crates/bevy_core_pipeline/src/core_3d/camera_3d.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@ impl Default for Camera3dBundle {
7474
fn default() -> Self {
7575
Self {
7676
camera_render_graph: CameraRenderGraph::new(crate::core_3d::graph::NAME),
77-
tonemapping: Tonemapping { is_enabled: true },
77+
tonemapping: Tonemapping::Enabled {
78+
deband_dither: true,
79+
},
7880
camera: Default::default(),
7981
projection: Default::default(),
8082
visible_entities: Default::default(),

crates/bevy_core_pipeline/src/tonemapping/mod.rs

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use bevy_render::camera::Camera;
1212
use bevy_render::extract_component::{ExtractComponent, ExtractComponentPlugin};
1313
use bevy_render::renderer::RenderDevice;
1414
use bevy_render::view::ViewTarget;
15-
use bevy_render::{render_resource::*, RenderApp};
15+
use bevy_render::{render_resource::*, RenderApp, RenderStage};
1616

1717
const TONEMAPPING_SHADER_HANDLE: HandleUntyped =
1818
HandleUntyped::weak_from_u64(Shader::TYPE_UUID, 17015368199668024512);
@@ -42,15 +42,51 @@ impl Plugin for TonemappingPlugin {
4242
app.add_plugin(ExtractComponentPlugin::<Tonemapping>::default());
4343

4444
if let Ok(render_app) = app.get_sub_app_mut(RenderApp) {
45-
render_app.init_resource::<TonemappingPipeline>();
45+
render_app
46+
.init_resource::<TonemappingPipeline>()
47+
.init_resource::<SpecializedRenderPipelines<TonemappingPipeline>>()
48+
.add_system_to_stage(RenderStage::Queue, queue_view_tonemapping_pipelines);
4649
}
4750
}
4851
}
4952

5053
#[derive(Resource)]
5154
pub struct TonemappingPipeline {
5255
texture_bind_group: BindGroupLayout,
53-
tonemapping_pipeline_id: CachedRenderPipelineId,
56+
}
57+
58+
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
59+
pub struct TonemappingPipelineKey {
60+
deband_dither: bool,
61+
}
62+
63+
impl SpecializedRenderPipeline for TonemappingPipeline {
64+
type Key = TonemappingPipelineKey;
65+
66+
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
67+
let mut shader_defs = Vec::new();
68+
if key.deband_dither {
69+
shader_defs.push("DEBAND_DITHER".to_string());
70+
}
71+
RenderPipelineDescriptor {
72+
label: Some("tonemapping pipeline".into()),
73+
layout: Some(vec![self.texture_bind_group.clone()]),
74+
vertex: fullscreen_shader_vertex_state(),
75+
fragment: Some(FragmentState {
76+
shader: TONEMAPPING_SHADER_HANDLE.typed(),
77+
shader_defs,
78+
entry_point: "fragment".into(),
79+
targets: vec![Some(ColorTargetState {
80+
format: ViewTarget::TEXTURE_FORMAT_HDR,
81+
blend: None,
82+
write_mask: ColorWrites::ALL,
83+
})],
84+
}),
85+
primitive: PrimitiveState::default(),
86+
depth_stencil: None,
87+
multisample: MultisampleState::default(),
88+
}
89+
}
5490
}
5591

5692
impl FromWorld for TonemappingPipeline {
@@ -79,37 +115,50 @@ impl FromWorld for TonemappingPipeline {
79115
],
80116
});
81117

82-
let tonemap_descriptor = RenderPipelineDescriptor {
83-
label: Some("tonemapping pipeline".into()),
84-
layout: Some(vec![tonemap_texture_bind_group.clone()]),
85-
vertex: fullscreen_shader_vertex_state(),
86-
fragment: Some(FragmentState {
87-
shader: TONEMAPPING_SHADER_HANDLE.typed(),
88-
shader_defs: vec![],
89-
entry_point: "fragment".into(),
90-
targets: vec![Some(ColorTargetState {
91-
format: ViewTarget::TEXTURE_FORMAT_HDR,
92-
blend: None,
93-
write_mask: ColorWrites::ALL,
94-
})],
95-
}),
96-
primitive: PrimitiveState::default(),
97-
depth_stencil: None,
98-
multisample: MultisampleState::default(),
99-
};
100-
101-
let mut cache = render_world.resource_mut::<PipelineCache>();
102118
TonemappingPipeline {
103119
texture_bind_group: tonemap_texture_bind_group,
104-
tonemapping_pipeline_id: cache.queue_render_pipeline(tonemap_descriptor),
120+
}
121+
}
122+
}
123+
124+
#[derive(Component)]
125+
pub struct ViewTonemappingPipeline(CachedRenderPipelineId);
126+
127+
pub fn queue_view_tonemapping_pipelines(
128+
mut commands: Commands,
129+
mut pipeline_cache: ResMut<PipelineCache>,
130+
mut pipelines: ResMut<SpecializedRenderPipelines<TonemappingPipeline>>,
131+
upscaling_pipeline: Res<TonemappingPipeline>,
132+
view_targets: Query<(Entity, &Tonemapping)>,
133+
) {
134+
for (entity, tonemapping) in view_targets.iter() {
135+
if let Tonemapping::Enabled { deband_dither } = tonemapping {
136+
let key = TonemappingPipelineKey {
137+
deband_dither: *deband_dither,
138+
};
139+
let pipeline = pipelines.specialize(&mut pipeline_cache, &upscaling_pipeline, key);
140+
141+
commands
142+
.entity(entity)
143+
.insert(ViewTonemappingPipeline(pipeline));
105144
}
106145
}
107146
}
108147

109148
#[derive(Component, Clone, Reflect, Default)]
110149
#[reflect(Component)]
111-
pub struct Tonemapping {
112-
pub is_enabled: bool,
150+
pub enum Tonemapping {
151+
#[default]
152+
Disabled,
153+
Enabled {
154+
deband_dither: bool,
155+
},
156+
}
157+
158+
impl Tonemapping {
159+
pub fn is_enabled(&self) -> bool {
160+
matches!(self, Tonemapping::Enabled { .. })
161+
}
113162
}
114163

115164
impl ExtractComponent for Tonemapping {

crates/bevy_core_pipeline/src/tonemapping/node.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::sync::Mutex;
22

3-
use crate::tonemapping::{Tonemapping, TonemappingPipeline};
3+
use crate::tonemapping::{TonemappingPipeline, ViewTonemappingPipeline};
44
use bevy_ecs::prelude::*;
55
use bevy_ecs::query::QueryState;
66
use bevy_render::{
@@ -15,7 +15,7 @@ use bevy_render::{
1515
};
1616

1717
pub struct TonemappingNode {
18-
query: QueryState<(&'static ViewTarget, Option<&'static Tonemapping>), With<ExtractedView>>,
18+
query: QueryState<(&'static ViewTarget, &'static ViewTonemappingPipeline), With<ExtractedView>>,
1919
cached_texture_bind_group: Mutex<Option<(TextureViewId, BindGroup)>>,
2020
}
2121

@@ -54,14 +54,11 @@ impl Node for TonemappingNode {
5454
Err(_) => return Ok(()),
5555
};
5656

57-
let tonemapping_enabled = tonemapping.map_or(false, |t| t.is_enabled);
58-
if !tonemapping_enabled || !target.is_hdr() {
57+
if !target.is_hdr() {
5958
return Ok(());
6059
}
6160

62-
let pipeline = match pipeline_cache
63-
.get_render_pipeline(tonemapping_pipeline.tonemapping_pipeline_id)
64-
{
61+
let pipeline = match pipeline_cache.get_render_pipeline(tonemapping.0) {
6562
Some(pipeline) => pipeline,
6663
None => return Ok(()),
6764
};

crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,15 @@ var hdr_sampler: sampler;
1010
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
1111
let hdr_color = textureSample(hdr_texture, hdr_sampler, in.uv);
1212

13-
return vec4<f32>(reinhard_luminance(hdr_color.rgb), hdr_color.a);
13+
var output_rgb = reinhard_luminance(hdr_color.rgb);
14+
15+
#ifdef DEBAND_DITHER
16+
output_rgb = pow(output_rgb.rgb, vec3<f32>(1.0 / 2.2));
17+
output_rgb = output_rgb + screen_space_dither(in.position.xy);
18+
// This conversion back to linear space is required because our output texture format is
19+
// SRGB; the GPU will assume our output is linear and will apply an SRGB conversion.
20+
output_rgb = pow(output_rgb.rgb, vec3<f32>(2.2));
21+
#endif
22+
23+
return vec4<f32>(output_rgb, hdr_color.a);
1424
}

crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,11 @@ fn reinhard_luminance(color: vec3<f32>) -> vec3<f32> {
2727
let l_new = l_old / (1.0 + l_old);
2828
return tonemapping_change_luminance(color, l_new);
2929
}
30+
31+
// Source: Advanced VR Rendering, GDC 2015, Alex Vlachos, Valve, Slide 49
32+
// https://media.steampowered.com/apps/valve/2015/Alex_Vlachos_Advanced_VR_Rendering_GDC2015.pdf
33+
fn screen_space_dither(frag_coord: vec2<f32>) -> vec3<f32> {
34+
var dither = vec3<f32>(dot(vec2<f32>(171.0, 231.0), frag_coord)).xxx;
35+
dither = fract(dither.rgb / vec3<f32>(103.0, 71.0, 97.0));
36+
return (dither - 0.5) / 255.0;
37+
}

crates/bevy_core_pipeline/src/upscaling/mod.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl Plugin for UpscalingPlugin {
3131
render_app
3232
.init_resource::<UpscalingPipeline>()
3333
.init_resource::<SpecializedRenderPipelines<UpscalingPipeline>>()
34-
.add_system_to_stage(RenderStage::Queue, queue_upscaling_bind_groups);
34+
.add_system_to_stage(RenderStage::Queue, queue_view_upscaling_pipelines);
3535
}
3636
}
3737
}
@@ -110,11 +110,9 @@ impl SpecializedRenderPipeline for UpscalingPipeline {
110110
}
111111

112112
#[derive(Component)]
113-
pub struct UpscalingTarget {
114-
pub pipeline: CachedRenderPipelineId,
115-
}
113+
pub struct ViewUpscalingPipeline(CachedRenderPipelineId);
116114

117-
fn queue_upscaling_bind_groups(
115+
fn queue_view_upscaling_pipelines(
118116
mut commands: Commands,
119117
mut pipeline_cache: ResMut<PipelineCache>,
120118
mut pipelines: ResMut<SpecializedRenderPipelines<UpscalingPipeline>>,
@@ -128,6 +126,8 @@ fn queue_upscaling_bind_groups(
128126
};
129127
let pipeline = pipelines.specialize(&mut pipeline_cache, &upscaling_pipeline, key);
130128

131-
commands.entity(entity).insert(UpscalingTarget { pipeline });
129+
commands
130+
.entity(entity)
131+
.insert(ViewUpscalingPipeline(pipeline));
132132
}
133133
}

crates/bevy_core_pipeline/src/upscaling/node.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ use bevy_render::{
1313
view::{ExtractedView, ViewTarget},
1414
};
1515

16-
use super::{UpscalingPipeline, UpscalingTarget};
16+
use super::{UpscalingPipeline, ViewUpscalingPipeline};
1717

1818
pub struct UpscalingNode {
19-
query: QueryState<(&'static ViewTarget, &'static UpscalingTarget), With<ExtractedView>>,
19+
query: QueryState<(&'static ViewTarget, &'static ViewUpscalingPipeline), With<ExtractedView>>,
2020
cached_texture_bind_group: Mutex<Option<(TextureViewId, BindGroup)>>,
2121
}
2222

@@ -89,7 +89,7 @@ impl Node for UpscalingNode {
8989
}
9090
};
9191

92-
let pipeline = match pipeline_cache.get_render_pipeline(upscaling_target.pipeline) {
92+
let pipeline = match pipeline_cache.get_render_pipeline(upscaling_target.0) {
9393
Some(pipeline) => pipeline,
9494
None => return Ok(()),
9595
};

crates/bevy_pbr/src/material.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,13 @@ pub fn queue_material_meshes<M: Material>(
363363
let mut view_key =
364364
MeshPipelineKey::from_msaa_samples(msaa.samples) | MeshPipelineKey::from_hdr(view.hdr);
365365

366-
if let Some(tonemapping) = tonemapping {
367-
if tonemapping.is_enabled && !view.hdr {
366+
if let Some(Tonemapping::Enabled { deband_dither }) = tonemapping {
367+
if !view.hdr {
368368
view_key |= MeshPipelineKey::TONEMAP_IN_SHADER;
369+
370+
if *deband_dither {
371+
view_key |= MeshPipelineKey::DEBAND_DITHER;
372+
}
369373
}
370374
}
371375
let rangefinder = view.rangefinder3d();

crates/bevy_pbr/src/render/mesh.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,7 @@ bitflags::bitflags! {
518518
const TRANSPARENT_MAIN_PASS = (1 << 0);
519519
const HDR = (1 << 1);
520520
const TONEMAP_IN_SHADER = (1 << 2);
521+
const DEBAND_DITHER = (1 << 3);
521522
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
522523
const PRIMITIVE_TOPOLOGY_RESERVED_BITS = Self::PRIMITIVE_TOPOLOGY_MASK_BITS << Self::PRIMITIVE_TOPOLOGY_SHIFT_BITS;
523524
}
@@ -636,6 +637,11 @@ impl SpecializedMeshPipeline for MeshPipeline {
636637

637638
if key.contains(MeshPipelineKey::TONEMAP_IN_SHADER) {
638639
shader_defs.push("TONEMAP_IN_SHADER".to_string());
640+
641+
// Debanding is tied to tonemapping in the shader, cannot run without it.
642+
if key.contains(MeshPipelineKey::DEBAND_DITHER) {
643+
shader_defs.push("DEBAND_DITHER".to_string());
644+
}
639645
}
640646

641647
let format = match key.contains(MeshPipelineKey::HDR) {

crates/bevy_pbr/src/render/pbr.wgsl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ fn fragment(in: FragmentInput) -> @location(0) vec4<f32> {
9797

9898
#ifdef TONEMAP_IN_SHADER
9999
output_color = tone_mapping(output_color);
100+
#endif
101+
#ifdef DEBAND_DITHER
102+
output_color = dither(output_color, in.frag_coord.xy);
100103
#endif
101104
return output_color;
102105
}

crates/bevy_pbr/src/render/pbr_functions.wgsl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,9 @@ fn tone_mapping(in: vec4<f32>) -> vec4<f32> {
262262
}
263263
#endif
264264

265+
#ifdef DEBAND_DITHER
266+
fn dither(color: vec4<f32>, pos: vec2<f32>) -> vec4<f32> {
267+
return vec4<f32>(color.rgb + screen_space_dither(pos.xy), color.a);
268+
}
269+
#endif
270+

crates/bevy_sprite/src/mesh2d/material.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,13 @@ pub fn queue_material2d_meshes<M: Material2d>(
328328
let mut view_key = Mesh2dPipelineKey::from_msaa_samples(msaa.samples)
329329
| Mesh2dPipelineKey::from_hdr(view.hdr);
330330

331-
if let Some(tonemapping) = tonemapping {
332-
if tonemapping.is_enabled && !view.hdr {
331+
if let Some(Tonemapping::Enabled { deband_dither }) = tonemapping {
332+
if !view.hdr {
333333
view_key |= Mesh2dPipelineKey::TONEMAP_IN_SHADER;
334+
335+
if *deband_dither {
336+
view_key |= Mesh2dPipelineKey::DEBAND_DITHER;
337+
}
334338
}
335339
}
336340

crates/bevy_sprite/src/mesh2d/mesh.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ bitflags::bitflags! {
288288
const NONE = 0;
289289
const HDR = (1 << 0);
290290
const TONEMAP_IN_SHADER = (1 << 1);
291+
const DEBAND_DITHER = (1 << 2);
291292
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
292293
const PRIMITIVE_TOPOLOGY_RESERVED_BITS = Self::PRIMITIVE_TOPOLOGY_MASK_BITS << Self::PRIMITIVE_TOPOLOGY_SHIFT_BITS;
293294
}
@@ -376,6 +377,11 @@ impl SpecializedMeshPipeline for Mesh2dPipeline {
376377

377378
if key.contains(Mesh2dPipelineKey::TONEMAP_IN_SHADER) {
378379
shader_defs.push("TONEMAP_IN_SHADER".to_string());
380+
381+
// Debanding is tied to tonemapping in the shader, cannot run without it.
382+
if key.contains(Mesh2dPipelineKey::DEBAND_DITHER) {
383+
shader_defs.push("DEBAND_DITHER".to_string());
384+
}
379385
}
380386

381387
let vertex_buffer_layout = layout.get_layout(&vertex_attributes)?;

0 commit comments

Comments
 (0)