diff --git a/crates/eframe/src/epi.rs b/crates/eframe/src/epi.rs index 4a05c97aaf2..c8c9069a0bf 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 13576bdbd2d..7af9973be14 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 5b9785fc9f5..fc3f6e9e134 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 b54f6f64423..29e23fa2fa8 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 de5ba601111..1da12b0a083 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 552bcbbf391..b60d9de9e83 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 118f246540a..d03e4e046f6 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 18e13a89caf..016af3f4477 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 4a909bfc75f..46db8821e02 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 70f07421475..0066b2ea815 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 5116c95d585..db2ff76d733 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 c1fc1740148..30da2809ee4 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 a9bec5fd039..c3bcfe386b5 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}"); })