Add PBR Neutral tone mapping (#23761)

# Objective

Add a tonemapping option for for e-commerce, architecture and CAD
applications.

## Solution

Implemented [PBR
Neutral](https://github.com/KhronosGroup/ToneMapping/tree/main/PBR_Neutral)
tone mapping

- Added to 2d and 3d materials, deferred and forward rendering
- Added to bloom_2d example
- Added to tonemapping example

## Testing

Run bloom_2d and tonemapping examples

---------

Co-authored-by: Brian Chirls <brian.chirls@ambr.net>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
Co-authored-by: IceSentry <IceSentry@users.noreply.github.com>
This commit is contained in:
Brian Chirls
2026-04-13 17:56:47 -04:00
committed by GitHub
parent ddc60d26b8
commit f8bdd2b9db
10 changed files with 88 additions and 4 deletions
@@ -160,6 +160,9 @@ pub enum Tonemapping {
/// Somewhat neutral. Suffers from hue shifting. Brights desaturate across the spectrum.
/// NOTE: Requires the `tonemapping_luts` cargo feature.
BlenderFilmic,
/// Designed to faithfully reproduce base color under neutral lighting. Suitable for e-commerce, architecture and CAD applications.
/// See [the KhronosGroup spec](https://github.com/KhronosGroup/ToneMapping/tree/main/PBR_Neutral) for more information.
PbrNeutral,
}
impl Tonemapping {
@@ -265,6 +268,7 @@ impl SpecializedRenderPipeline for TonemappingPipeline {
);
shader_defs.push("TONEMAP_METHOD_BLENDER_FILMIC".into());
}
Tonemapping::PbrNeutral => shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into()),
}
RenderPipelineDescriptor {
label: Some("tonemapping pipeline".into()),
@@ -392,6 +396,7 @@ pub fn get_lut_bindings<'a>(
| Tonemapping::ReinhardLuminance
| Tonemapping::AcesFitted
| Tonemapping::AgX
| Tonemapping::PbrNeutral
| Tonemapping::SomewhatBoringDisplayTransform => &tonemapping_luts.agx,
Tonemapping::TonyMcMapface => &tonemapping_luts.tony_mc_mapface,
Tonemapping::BlenderFilmic => &tonemapping_luts.blender_filmic,
@@ -255,6 +255,54 @@ fn rgb_to_srgb_simple(color: vec3<f32>) -> vec3<f32> {
return pow(color, vec3<f32>(1.0 / 2.2));
}
// PBR Neutral tone mapping
// https://github.com/KhronosGroup/ToneMapping/blob/main/PBR_Neutral/pbrNeutral.glsl
// Adapted under Apache 2.0, per https://github.com/KhronosGroup/ToneMapping/tree/main/LICENSES
fn tonemapping_pbr_neutral(color_in: vec3<f32>) -> vec3<f32> {
// Parameter controlling when highlight compression starts
// (`K_s` in specification)
const start_compression: f32 = 0.8 - 0.04;
// Parameter controlling the speed of desaturation
// (`K-d` in specification)
const desaturation: f32 = 0.15;
// `x` in the specification equation
let min_channel = min(color_in.r, min(color_in.g, color_in.b));
// The amount that the "toe" adjustment reduces the color
// `f` in the specification equation
let offset = select(0.04, min_channel - 6.25 * min_channel * min_channel, min_channel < 0.08);
// The original color, minus the "toe" reduction
// `c_in - f` in the specification equation
let offset_color = color_in - offset;
// Maximum of all offset color channels
// `p` in the specification equation
let max_channel = max(offset_color.r, max(offset_color.g, offset_color.b));
if max_channel < start_compression {
// "toe" at the low-end; or uncompressed, offset color for most of the range
return offset_color;
}
// This doesn't exist in the specification equation. It is part of optimizing
// the math for computation.
let d = 1.0 - start_compression;
// Maximum color channel, scaled to asymptotically approach 1.0
let new_max_channel = 1.0 - d * d / (max_channel + d - start_compression);
// Full color, from offset color, with the same scale applied as `new_max_channel`
// `p_n` in the specification equation
let color = offset_color * (new_max_channel / max_channel);
// Amount to desaturate, used as the blend factor when mixing between
// full color and desaturated.
let g = 1.0 - 1.0 / (desaturation * (max_channel - new_max_channel) + 1.0);
return mix(color, vec3(new_max_channel), g);
}
// 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> {
@@ -372,6 +420,8 @@ fn tone_mapping(in: vec4<f32>, in_color_grading: ColorGrading) -> vec4<f32> {
color = sample_tony_mc_mapface_lut(color);
#else ifdef TONEMAP_METHOD_BLENDER_FILMIC
color = sample_blender_filmic_lut(color.rgb);
#else ifdef TONEMAP_METHOD_PBR_NEUTRAL
color = tonemapping_pbr_neutral(color.rgb);
#endif
// Perceptual post tonemapping grading
+3
View File
@@ -260,6 +260,8 @@ impl SpecializedRenderPipeline for DeferredLightingLayout {
shader_defs.push("TONEMAP_METHOD_BLENDER_FILMIC".into());
} else if method == MeshPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE {
shader_defs.push("TONEMAP_METHOD_TONY_MC_MAPFACE".into());
} else if method == MeshPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL {
shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into());
}
// Debanding is tied to tonemapping in the shader, cannot run without it.
@@ -502,6 +504,7 @@ pub fn prepare_deferred_lighting_pipelines(
}
Tonemapping::TonyMcMapface => MeshPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE,
Tonemapping::BlenderFilmic => MeshPipelineKey::TONEMAP_METHOD_BLENDER_FILMIC,
Tonemapping::PbrNeutral => MeshPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL,
};
}
if let Some(DebandDither::Enabled) = dither {
+1
View File
@@ -630,6 +630,7 @@ pub const fn tonemapping_pipeline_key(tonemapping: Tonemapping) -> MeshPipelineK
}
Tonemapping::TonyMcMapface => MeshPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE,
Tonemapping::BlenderFilmic => MeshPipelineKey::TONEMAP_METHOD_BLENDER_FILMIC,
Tonemapping::PbrNeutral => MeshPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL,
}
}
+4 -1
View File
@@ -3093,6 +3093,7 @@ bitflags::bitflags! {
const TONEMAP_METHOD_SOMEWHAT_BORING_DISPLAY_TRANSFORM = 5 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_TONY_MC_MAPFACE = 6 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_BLENDER_FILMIC = 7 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_PBR_NEUTRAL = 8 << Self::TONEMAP_METHOD_SHIFT_BITS;
const SHADOW_FILTER_METHOD_RESERVED_BITS = Self::SHADOW_FILTER_METHOD_MASK_BITS << Self::SHADOW_FILTER_METHOD_SHIFT_BITS;
const SHADOW_FILTER_METHOD_HARDWARE_2X2 = 0 << Self::SHADOW_FILTER_METHOD_SHIFT_BITS;
const SHADOW_FILTER_METHOD_GAUSSIAN = 1 << Self::SHADOW_FILTER_METHOD_SHIFT_BITS;
@@ -3127,7 +3128,7 @@ impl MeshPipelineKey {
const BLEND_MASK_BITS: u64 = 0b111;
const BLEND_SHIFT_BITS: u64 = Self::MSAA_MASK_BITS.count_ones() as u64 + Self::MSAA_SHIFT_BITS;
const TONEMAP_METHOD_MASK_BITS: u64 = 0b111;
const TONEMAP_METHOD_MASK_BITS: u64 = 0b1111;
const TONEMAP_METHOD_SHIFT_BITS: u64 =
Self::BLEND_MASK_BITS.count_ones() as u64 + Self::BLEND_SHIFT_BITS;
@@ -3546,6 +3547,8 @@ impl SpecializedMeshPipeline for MeshPipeline {
shader_defs.push("TONEMAP_METHOD_BLENDER_FILMIC".into());
} else if method == MeshPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE {
shader_defs.push("TONEMAP_METHOD_TONY_MC_MAPFACE".into());
} else if method == MeshPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL {
shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into());
}
// Debanding is tied to tonemapping in the shader, cannot run without it.
@@ -579,6 +579,7 @@ pub const fn tonemapping_pipeline_key(tonemapping: Tonemapping) -> Mesh2dPipelin
}
Tonemapping::TonyMcMapface => Mesh2dPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE,
Tonemapping::BlenderFilmic => Mesh2dPipelineKey::TONEMAP_METHOD_BLENDER_FILMIC,
Tonemapping::PbrNeutral => Mesh2dPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL,
}
}
+5 -1
View File
@@ -479,6 +479,7 @@ bitflags::bitflags! {
const TONEMAP_METHOD_SOMEWHAT_BORING_DISPLAY_TRANSFORM = 5 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_TONY_MC_MAPFACE = 6 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_BLENDER_FILMIC = 7 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_PBR_NEUTRAL = 8 << Self::TONEMAP_METHOD_SHIFT_BITS;
const STRIP_INDEX_FORMAT_RESERVED_BITS = Self::INDEX_FORMAT_MASK_BITS << Self::INDEX_FORMAT_SHIFT_BITS;
const STRIP_INDEX_FORMAT_NONE = 0 << Self::INDEX_FORMAT_SHIFT_BITS;
const STRIP_INDEX_FORMAT_U32 = 1 << Self::INDEX_FORMAT_SHIFT_BITS;
@@ -493,7 +494,7 @@ impl Mesh2dPipelineKey {
const MSAA_SHIFT_BITS: u32 = 32 - Self::MSAA_MASK_BITS.count_ones();
const PRIMITIVE_TOPOLOGY_MASK_BITS: u32 = 0b111;
const PRIMITIVE_TOPOLOGY_SHIFT_BITS: u32 = Self::MSAA_SHIFT_BITS - 3;
const TONEMAP_METHOD_MASK_BITS: u32 = 0b111;
const TONEMAP_METHOD_MASK_BITS: u32 = 0b1111;
const TONEMAP_METHOD_SHIFT_BITS: u32 =
Self::PRIMITIVE_TOPOLOGY_SHIFT_BITS - Self::TONEMAP_METHOD_MASK_BITS.count_ones();
pub const INDEX_FORMAT_MASK_BITS: u32 = 0b11;
@@ -652,6 +653,9 @@ impl SpecializedMeshPipeline for Mesh2dPipeline {
Mesh2dPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE => {
shader_defs.push("TONEMAP_METHOD_TONY_MC_MAPFACE".into());
}
Mesh2dPipelineKey::TONEMAP_METHOD_PBR_NEUTRAL => {
shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into());
}
_ => {}
}
// Debanding is tied to tonemapping in the shader, cannot run without it.
+6 -1
View File
@@ -112,6 +112,8 @@ bitflags::bitflags! {
const TONEMAP_METHOD_SOMEWHAT_BORING_DISPLAY_TRANSFORM = 5 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_TONY_MC_MAPFACE = 6 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_BLENDER_FILMIC = 7 << Self::TONEMAP_METHOD_SHIFT_BITS;
const TONEMAP_METHOD_PBR_NEUTRAL = 8 << Self::TONEMAP_METHOD_SHIFT_BITS;
}
}
@@ -120,7 +122,7 @@ impl SpritePipelineKey {
const COLOR_TARGET_FORMAT_SHIFT_BITS: u32 = 4;
const MSAA_MASK_BITS: u32 = 0b111;
const MSAA_SHIFT_BITS: u32 = 32 - Self::MSAA_MASK_BITS.count_ones();
const TONEMAP_METHOD_MASK_BITS: u32 = 0b111;
const TONEMAP_METHOD_MASK_BITS: u32 = 0b1111;
const TONEMAP_METHOD_SHIFT_BITS: u32 =
Self::MSAA_SHIFT_BITS - Self::TONEMAP_METHOD_MASK_BITS.count_ones();
@@ -191,6 +193,8 @@ impl SpecializedRenderPipeline for SpritePipeline {
shader_defs.push("TONEMAP_METHOD_BLENDER_FILMIC".into());
} else if method == SpritePipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE {
shader_defs.push("TONEMAP_METHOD_TONY_MC_MAPFACE".into());
} else if method == SpritePipelineKey::TONEMAP_METHOD_PBR_NEUTRAL {
shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into());
}
// Debanding is tied to tonemapping in the shader, cannot run without it.
@@ -549,6 +553,7 @@ pub fn queue_sprites(
}
Tonemapping::TonyMcMapface => SpritePipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE,
Tonemapping::BlenderFilmic => SpritePipelineKey::TONEMAP_METHOD_BLENDER_FILMIC,
Tonemapping::PbrNeutral => SpritePipelineKey::TONEMAP_METHOD_PBR_NEUTRAL,
};
}
if let Some(DebandDither::Enabled) = dither {
+2 -1
View File
@@ -209,6 +209,7 @@ fn next_tonemap(tonemapping: &Tonemapping) -> Tonemapping {
Tonemapping::Reinhard => Tonemapping::ReinhardLuminance,
Tonemapping::ReinhardLuminance => Tonemapping::SomewhatBoringDisplayTransform,
Tonemapping::SomewhatBoringDisplayTransform => Tonemapping::TonyMcMapface,
Tonemapping::TonyMcMapface => Tonemapping::None,
Tonemapping::TonyMcMapface => Tonemapping::PbrNeutral,
Tonemapping::PbrNeutral => Tonemapping::None,
}
}
+11
View File
@@ -310,6 +310,8 @@ fn toggle_tonemapping_method(
**tonemapping = Tonemapping::TonyMcMapface;
} else if keys.just_pressed(KeyCode::Digit8) {
**tonemapping = Tonemapping::BlenderFilmic;
} else if keys.just_pressed(KeyCode::Digit9) {
**tonemapping = Tonemapping::PbrNeutral;
}
**color_grading = (*per_method_settings
@@ -497,6 +499,14 @@ fn update_ui(
""
}
));
text.push_str(&format!(
"(9) {} PBR Neutral\n",
if tonemapping == Tonemapping::PbrNeutral {
">"
} else {
""
}
));
text.push_str("\n\nColor Grading:\n");
text.push_str("(arrow keys)\n");
@@ -588,6 +598,7 @@ impl Default for PerMethodSettings {
Tonemapping::SomewhatBoringDisplayTransform,
Tonemapping::TonyMcMapface,
Tonemapping::BlenderFilmic,
Tonemapping::PbrNeutral,
] {
settings.insert(
method,