From b283b8a56032cea0f243cd6a1ddd4f9d5daec2dc Mon Sep 17 00:00:00 2001 From: Jonas Wagner Date: Mon, 8 Jul 2024 09:57:11 +0200 Subject: [PATCH] Introduce dithering to reduce banding (#4497) This PR introduces dithering in the egui_glow and egui_wgpu backends to reduce banding artifacts. It's based on the approach mentioned in #4493 with the small difference that the amount of noise is scaled down slightly to avoid dithering colors that can be represented exactly. This keeps flat surfaces clean. Exaggerated dithering to show what is happening: ![Screenshot from 2024-05-14 19-09-48](https://github.com/emilk/egui/assets/293536/75782b83-9023-4cb2-99f7-a24e15fdefcc) Subtle dithering as commited. ![Screenshot from 2024-05-14 19-13-40](https://github.com/emilk/egui/assets/293536/eb904698-a6ec-494a-952b-447e9a49bfda) Closes #4493 --- crates/eframe/src/epi.rs | 22 ++++++++++ crates/eframe/src/native/glow_integration.rs | 7 ++- crates/eframe/src/native/wgpu_integration.rs | 1 + crates/eframe/src/web/web_painter_glow.rs | 2 +- crates/eframe/src/web/web_painter_wgpu.rs | 14 ++++-- crates/egui-wgpu/src/egui.wgsl | 46 ++++++++++++++++++-- crates/egui-wgpu/src/lib.rs | 9 +++- crates/egui-wgpu/src/renderer.rs | 15 +++++-- crates/egui-wgpu/src/winit.rs | 4 ++ crates/egui_glow/examples/pure_glow.rs | 2 +- crates/egui_glow/src/painter.rs | 4 +- crates/egui_glow/src/shader/fragment.glsl | 30 ++++++++++++- crates/egui_glow/src/winit.rs | 3 +- 13 files changed, 141 insertions(+), 18 deletions(-) diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 4a05c97a..c8c9069a 100644 --- a/crates/eframe/src/epi.rs +++ b/crates/eframe/src/epi.rs @@ -369,6 +369,15 @@ pub struct NativeOptions { /// The folder where `eframe` will store the app state. If not set, eframe will get the paths /// from [directories]. pub persistence_path: Option, + + /// Controls whether to apply dithering to minimize banding artifacts. + /// + /// Dithering assumes an sRGB output and thus will apply noise to any input value that lies between + /// two 8bit values after applying the sRGB OETF function, i.e. if it's not a whole 8bit value in "gamma space". + /// This means that only inputs from texture interpolation and vertex colors should be affected in practice. + /// + /// Defaults to true. + pub dithering: bool, } #[cfg(not(target_arch = "wasm32"))] @@ -429,6 +438,8 @@ impl Default for NativeOptions { persist_window: true, persistence_path: None, + + dithering: true, } } } @@ -466,6 +477,15 @@ pub struct WebOptions { /// Configures wgpu instance/device/adapter/surface creation and renderloop. #[cfg(feature = "wgpu")] pub wgpu_options: egui_wgpu::WgpuConfiguration, + + /// Controls whether to apply dithering to minimize banding artifacts. + /// + /// Dithering assumes an sRGB output and thus will apply noise to any input value that lies between + /// two 8bit values after applying the sRGB OETF function, i.e. if it's not a whole 8bit value in "gamma space". + /// This means that only inputs from texture interpolation and vertex colors should be affected in practice. + /// + /// Defaults to true. + pub dithering: bool, } #[cfg(target_arch = "wasm32")] @@ -481,6 +501,8 @@ impl Default for WebOptions { #[cfg(feature = "wgpu")] wgpu_options: egui_wgpu::WgpuConfiguration::default(), + + dithering: true, } } } diff --git a/crates/eframe/src/native/glow_integration.rs b/crates/eframe/src/native/glow_integration.rs index 13576bdb..7af9973b 100644 --- a/crates/eframe/src/native/glow_integration.rs +++ b/crates/eframe/src/native/glow_integration.rs @@ -184,7 +184,12 @@ impl GlowWinitApp { })) }; - let painter = egui_glow::Painter::new(gl, "", native_options.shader_version)?; + let painter = egui_glow::Painter::new( + gl, + "", + native_options.shader_version, + native_options.dithering, + )?; Ok((glutin_window_context, painter)) } diff --git a/crates/eframe/src/native/wgpu_integration.rs b/crates/eframe/src/native/wgpu_integration.rs index 5b9785fc..fc3f6e9e 100644 --- a/crates/eframe/src/native/wgpu_integration.rs +++ b/crates/eframe/src/native/wgpu_integration.rs @@ -194,6 +194,7 @@ impl WgpuWinitApp { self.native_options.stencil_buffer, ), self.native_options.viewport.transparent.unwrap_or(false), + self.native_options.dithering, ); let window = Arc::new(window); diff --git a/crates/eframe/src/web/web_painter_glow.rs b/crates/eframe/src/web/web_painter_glow.rs index b54f6f64..29e23fa2 100644 --- a/crates/eframe/src/web/web_painter_glow.rs +++ b/crates/eframe/src/web/web_painter_glow.rs @@ -26,7 +26,7 @@ impl WebPainterGlow { #[allow(clippy::arc_with_non_send_sync)] let gl = std::sync::Arc::new(gl); - let painter = egui_glow::Painter::new(gl, shader_prefix, None) + let painter = egui_glow::Painter::new(gl, shader_prefix, None, options.dithering) .map_err(|err| format!("Error starting glow painter: {err}"))?; Ok(Self { canvas, painter }) diff --git a/crates/eframe/src/web/web_painter_wgpu.rs b/crates/eframe/src/web/web_painter_wgpu.rs index de5ba601..1da12b0a 100644 --- a/crates/eframe/src/web/web_painter_wgpu.rs +++ b/crates/eframe/src/web/web_painter_wgpu.rs @@ -169,10 +169,16 @@ impl WebPainterWgpu { let depth_format = egui_wgpu::depth_format_from_bits(options.depth_buffer, 0); - let render_state = - RenderState::create(&options.wgpu_options, &instance, &surface, depth_format, 1) - .await - .map_err(|err| err.to_string())?; + let render_state = RenderState::create( + &options.wgpu_options, + &instance, + &surface, + depth_format, + 1, + options.dithering, + ) + .await + .map_err(|err| err.to_string())?; let surface_configuration = wgpu::SurfaceConfiguration { format: render_state.target_format, diff --git a/crates/egui-wgpu/src/egui.wgsl b/crates/egui-wgpu/src/egui.wgsl index 552bcbbf..b60d9de9 100644 --- a/crates/egui-wgpu/src/egui.wgsl +++ b/crates/egui-wgpu/src/egui.wgsl @@ -8,12 +8,35 @@ struct VertexOutput { struct Locals { screen_size: vec2, + dithering: u32, // 1 if dithering is enabled, 0 otherwise // Uniform buffers need to be at least 16 bytes in WebGL. // See https://github.com/gfx-rs/wgpu/issues/2072 - _padding: vec2, + _padding: u32, }; @group(0) @binding(0) var r_locals: Locals; + +// ----------------------------------------------- +// Adapted from +// https://www.shadertoy.com/view/llVGzG +// Originally presented in: +// Jimenez 2014, "Next Generation Post-Processing in Call of Duty" +// +// A good overview can be found in +// https://blog.demofox.org/2022/01/01/interleaved-gradient-noise-a-different-kind-of-low-discrepancy-sequence/ +// via https://github.com/rerun-io/rerun/ +fn interleaved_gradient_noise(n: vec2) -> f32 { + let f = 0.06711056 * n.x + 0.00583715 * n.y; + return fract(52.9829189 * fract(f)); +} + +fn dither_interleaved(rgb: vec3, levels: f32, frag_coord: vec4) -> vec3 { + var noise = interleaved_gradient_noise(frag_coord.xy); + // scale down the noise slightly to ensure flat colors aren't getting dithered + noise = (noise - 0.5) * 0.95; + return rgb + noise / (levels - 1.0); +} + // 0-1 linear from 0-1 sRGB gamma fn linear_from_gamma_rgb(srgb: vec3) -> vec3 { let cutoff = srgb < vec3(0.04045); @@ -77,8 +100,17 @@ fn fs_main_linear_framebuffer(in: VertexOutput) -> @location(0) vec4 { // We always have an sRGB aware texture at the moment. let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); let tex_gamma = gamma_from_linear_rgba(tex_linear); - let out_color_gamma = in.color * tex_gamma; - return vec4(linear_from_gamma_rgb(out_color_gamma.rgb), out_color_gamma.a); + var out_color_gamma = in.color * tex_gamma; + // Dither the float color down to eight bits to reduce banding. + // This step is optional for egui backends. + // Note that dithering is performed on the gamma encoded values, + // because this function is used together with a srgb converting target. + if r_locals.dithering == 1 { + let out_color_gamma_rgb = dither_interleaved(out_color_gamma.rgb, 256.0, in.position); + out_color_gamma = vec4(out_color_gamma_rgb, out_color_gamma.a); + } + let out_color_linear = linear_from_gamma_rgb(out_color_gamma.rgb); + return vec4(out_color_linear, out_color_gamma.a); } @fragment @@ -86,6 +118,12 @@ fn fs_main_gamma_framebuffer(in: VertexOutput) -> @location(0) vec4 { // We always have an sRGB aware texture at the moment. let tex_linear = textureSample(r_tex_color, r_tex_sampler, in.tex_coord); let tex_gamma = gamma_from_linear_rgba(tex_linear); - let out_color_gamma = in.color * tex_gamma; + var out_color_gamma = in.color * tex_gamma; + // Dither the float color down to eight bits to reduce banding. + // This step is optional for egui backends. + if r_locals.dithering == 1 { + let out_color_gamma_rgb = dither_interleaved(out_color_gamma.rgb, 256.0, in.position); + out_color_gamma = vec4(out_color_gamma_rgb, out_color_gamma.a); + } return out_color_gamma; } diff --git a/crates/egui-wgpu/src/lib.rs b/crates/egui-wgpu/src/lib.rs index 118f2465..d03e4e04 100644 --- a/crates/egui-wgpu/src/lib.rs +++ b/crates/egui-wgpu/src/lib.rs @@ -90,6 +90,7 @@ impl RenderState { surface: &wgpu::Surface<'static>, depth_format: Option, msaa_samples: u32, + dithering: bool, ) -> Result { crate::profile_scope!("RenderState::create"); // async yield give bad names using `profile_function` @@ -164,7 +165,13 @@ impl RenderState { .await? }; - let renderer = Renderer::new(&device, target_format, depth_format, msaa_samples); + let renderer = Renderer::new( + &device, + target_format, + depth_format, + msaa_samples, + dithering, + ); Ok(Self { adapter: Arc::new(adapter), diff --git a/crates/egui-wgpu/src/renderer.rs b/crates/egui-wgpu/src/renderer.rs index 18e13a89..016af3f4 100644 --- a/crates/egui-wgpu/src/renderer.rs +++ b/crates/egui-wgpu/src/renderer.rs @@ -133,14 +133,16 @@ impl ScreenDescriptor { #[repr(C)] struct UniformBuffer { screen_size_in_points: [f32; 2], + dithering: u32, // Uniform buffers need to be at least 16 bytes in WebGL. // See https://github.com/gfx-rs/wgpu/issues/2072 - _padding: [u32; 2], + _padding: u32, } impl PartialEq for UniformBuffer { fn eq(&self, other: &Self) -> bool { self.screen_size_in_points == other.screen_size_in_points + && self.dithering == other.dithering } } @@ -169,6 +171,8 @@ pub struct Renderer { next_user_texture_id: u64, samplers: HashMap, + dithering: bool, + /// Storage for resources shared with all invocations of [`CallbackTrait`]'s methods. /// /// See also [`CallbackTrait`]. @@ -185,6 +189,7 @@ impl Renderer { output_color_format: wgpu::TextureFormat, output_depth_format: Option, msaa_samples: u32, + dithering: bool, ) -> Self { crate::profile_function!(); @@ -201,6 +206,7 @@ impl Renderer { label: Some("egui_uniform_buffer"), contents: bytemuck::cast_slice(&[UniformBuffer { screen_size_in_points: [0.0, 0.0], + dithering: u32::from(dithering), _padding: Default::default(), }]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, @@ -212,7 +218,7 @@ impl Renderer { label: Some("egui_uniform_bind_group_layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, - visibility: wgpu::ShaderStages::VERTEX, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { has_dynamic_offset: false, min_binding_size: NonZeroU64::new(std::mem::size_of::() as _), @@ -364,13 +370,15 @@ impl Renderer { // Buffers on wgpu are zero initialized, so this is indeed its current state! previous_uniform_buffer_content: UniformBuffer { screen_size_in_points: [0.0, 0.0], - _padding: [0, 0], + dithering: 0, + _padding: 0, }, uniform_bind_group, texture_bind_group_layout, textures: HashMap::default(), next_user_texture_id: 0, samplers: HashMap::default(), + dithering, callback_resources: CallbackResources::default(), } } @@ -781,6 +789,7 @@ impl Renderer { let uniform_buffer_content = UniformBuffer { screen_size_in_points, + dithering: u32::from(self.dithering), _padding: Default::default(), }; if uniform_buffer_content != self.previous_uniform_buffer_content { diff --git a/crates/egui-wgpu/src/winit.rs b/crates/egui-wgpu/src/winit.rs index 4a909bfc..46db8821 100644 --- a/crates/egui-wgpu/src/winit.rs +++ b/crates/egui-wgpu/src/winit.rs @@ -83,6 +83,7 @@ pub struct Painter { configuration: WgpuConfiguration, msaa_samples: u32, support_transparent_backbuffer: bool, + dithering: bool, depth_format: Option, screen_capture_state: Option, @@ -113,6 +114,7 @@ impl Painter { msaa_samples: u32, depth_format: Option, support_transparent_backbuffer: bool, + dithering: bool, ) -> Self { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: configuration.supported_backends, @@ -123,6 +125,7 @@ impl Painter { configuration, msaa_samples, support_transparent_backbuffer, + dithering, depth_format, screen_capture_state: None, @@ -264,6 +267,7 @@ impl Painter { &surface, self.depth_format, self.msaa_samples, + self.dithering, ) .await?; self.render_state.get_or_insert(render_state) diff --git a/crates/egui_glow/examples/pure_glow.rs b/crates/egui_glow/examples/pure_glow.rs index 70f07421..0066b2ea 100644 --- a/crates/egui_glow/examples/pure_glow.rs +++ b/crates/egui_glow/examples/pure_glow.rs @@ -161,7 +161,7 @@ fn main() { let (gl_window, gl) = create_display(&event_loop); let gl = std::sync::Arc::new(gl); - let mut egui_glow = egui_glow::EguiGlow::new(&event_loop, gl.clone(), None, None); + let mut egui_glow = egui_glow::EguiGlow::new(&event_loop, gl.clone(), None, None, true); let event_loop_proxy = egui::mutex::Mutex::new(event_loop.create_proxy()); egui_glow diff --git a/crates/egui_glow/src/painter.rs b/crates/egui_glow/src/painter.rs index 5116c95d..db2ff76d 100644 --- a/crates/egui_glow/src/painter.rs +++ b/crates/egui_glow/src/painter.rs @@ -138,6 +138,7 @@ impl Painter { gl: Arc, shader_prefix: &str, shader_version: Option, + dithering: bool, ) -> Result { crate::profile_function!(); crate::check_for_gl_error_even_in_release!(&gl, "before Painter::new"); @@ -197,9 +198,10 @@ impl Painter { &gl, glow::FRAGMENT_SHADER, &format!( - "{}\n#define NEW_SHADER_INTERFACE {}\n#define SRGB_TEXTURES {}\n{}\n{}", + "{}\n#define NEW_SHADER_INTERFACE {}\n#define DITHERING {}\n#define SRGB_TEXTURES {}\n{}\n{}", shader_version_declaration, shader_version.is_new_shader_interface() as i32, + dithering as i32, srgb_textures as i32, shader_prefix, FRAG_SRC diff --git a/crates/egui_glow/src/shader/fragment.glsl b/crates/egui_glow/src/shader/fragment.glsl index c1fc1740..30da2809 100644 --- a/crates/egui_glow/src/shader/fragment.glsl +++ b/crates/egui_glow/src/shader/fragment.glsl @@ -16,6 +16,27 @@ uniform sampler2D u_sampler; varying vec2 v_tc; #endif +// ----------------------------------------------- +// Adapted from +// https://www.shadertoy.com/view/llVGzG +// Originally presented in: +// Jimenez 2014, "Next Generation Post-Processing in Call of Duty" +// +// A good overview can be found in +// https://blog.demofox.org/2022/01/01/interleaved-gradient-noise-a-different-kind-of-low-discrepancy-sequence/ +// via https://github.com/rerun-io/rerun/ +float interleaved_gradient_noise(vec2 n) { + float f = 0.06711056 * n.x + 0.00583715 * n.y; + return fract(52.9829189 * fract(f)); +} + +vec3 dither_interleaved(vec3 rgb, float levels) { + float noise = interleaved_gradient_noise(gl_FragCoord.xy); + // scale down the noise slightly to ensure flat colors aren't getting dithered + noise = (noise - 0.5) * 0.95; + return rgb + noise / (levels - 1.0); +} + // 0-1 sRGB gamma from 0-1 linear vec3 srgb_gamma_from_linear(vec3 rgb) { bvec3 cutoff = lessThan(rgb, vec3(0.0031308)); @@ -37,5 +58,12 @@ void main() { #endif // We multiply the colors in gamma space, because that's the only way to get text to look right. - gl_FragColor = v_rgba_in_gamma * texture_in_gamma; + vec4 frag_color_gamma = v_rgba_in_gamma * texture_in_gamma; + + // Dither the float color down to eight bits to reduce banding. + // This step is optional for egui backends. +#if DITHERING + frag_color_gamma.rgb = dither_interleaved(frag_color_gamma.rgb, 256.); +#endif + gl_FragColor = frag_color_gamma; } diff --git a/crates/egui_glow/src/winit.rs b/crates/egui_glow/src/winit.rs index a9bec5fd..c3bcfe38 100644 --- a/crates/egui_glow/src/winit.rs +++ b/crates/egui_glow/src/winit.rs @@ -27,8 +27,9 @@ impl EguiGlow { gl: std::sync::Arc, shader_version: Option, native_pixels_per_point: Option, + dithering: bool, ) -> Self { - let painter = crate::Painter::new(gl, "", shader_version) + let painter = crate::Painter::new(gl, "", shader_version, dithering) .map_err(|err| { log::error!("error occurred in initializing painter:\n{err}"); })