Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5358,6 +5358,17 @@ description = "Demonstrates how to create a mirror with a second camera"
category = "3D Rendering"
wasm = true

[[example]]
name = "hdr_calibration"
path = "examples/3d/hdr_calibration.rs"
doc-scrape-examples = true

[package.metadata.example.hdr_calibration]
name = "HDR Calibration"
description = "Demonstrates how to calibrate HDR display output"
category = "3D Rendering"
wasm = true

[[example]]
name = "dynamic_mip_generation"
path = "examples/2d/dynamic_mip_generation.rs"
Expand Down
6 changes: 3 additions & 3 deletions assets/shaders/fullscreen_effect.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
// You don't need to worry about this too much since bevy will compute the correct UVs for you.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput

@group(0) @binding(0) var screen_texture: texture_2d<f32>;
@group(0) @binding(1) var texture_sampler: sampler;
@group(0) @binding(1) var screen_texture: texture_2d<f32>;
@group(0) @binding(2) var texture_sampler: sampler;

struct FullScreenEffect {
intensity: f32,
Expand All @@ -31,7 +31,7 @@ struct FullScreenEffect {
#endif
}

@group(0) @binding(2) var<uniform> settings: FullScreenEffect;
@group(0) @binding(3) var<uniform> settings: FullScreenEffect;

@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
Expand Down
7 changes: 3 additions & 4 deletions assets/shaders/post_processing.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,16 @@
//
// You don't need to worry about this too much since bevy will compute the correct UVs for you.
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput

@group(0) @binding(0) var screen_texture: texture_2d<f32>;
@group(0) @binding(1) var texture_sampler: sampler;
@group(0) @binding(1) var screen_texture: texture_2d<f32>;
@group(0) @binding(2) var texture_sampler: sampler;
struct PostProcessSettings {
intensity: f32,
#ifdef SIXTEEN_BYTE_ALIGNMENT
// WebGL2 structs must be 16 byte aligned.
_webgl2_padding: vec3<f32>
#endif
}
@group(0) @binding(2) var<uniform> settings: PostProcessSettings;
@group(0) @binding(3) var<uniform> settings: PostProcessSettings;

@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
Expand Down
14 changes: 7 additions & 7 deletions crates/bevy_anti_alias/src/contrast_adaptive_sharpening/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ pub fn init_cas_pipeline(

#[derive(PartialEq, Eq, Hash, Clone, Copy, SpecializerKey)]
pub struct CasPipelineKey {
texture_format: TextureFormat,
hdr: bool,
denoise: bool,
}

Expand All @@ -231,7 +231,11 @@ impl Specializer<RenderPipeline> for CasPipelineSpecializer {
fragment.set_target(
0,
ColorTargetState {
format: key.texture_format,
format: if key.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
blend: None,
write_mask: ColorWrites::ALL,
},
Expand Down Expand Up @@ -260,11 +264,7 @@ fn prepare_cas_pipelines(
&pipeline_cache,
CasPipelineKey {
denoise: denoise_cas.0,
texture_format: if view.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
hdr: view.hdr,
},
)?;

Expand Down
14 changes: 7 additions & 7 deletions crates/bevy_anti_alias/src/fxaa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ pub struct CameraFxaaPipeline {
pub struct FxaaPipelineKey {
edge_threshold: Sensitivity,
edge_threshold_min: Sensitivity,
texture_format: TextureFormat,
hdr: bool,
}

impl SpecializedRenderPipeline for FxaaPipeline {
Expand All @@ -188,7 +188,11 @@ impl SpecializedRenderPipeline for FxaaPipeline {
format!("EDGE_THRESH_MIN_{}", key.edge_threshold_min.get_str()).into(),
],
targets: vec![Some(ColorTargetState {
format: key.texture_format,
format: if key.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
blend: None,
write_mask: ColorWrites::ALL,
})],
Expand Down Expand Up @@ -216,11 +220,7 @@ pub fn prepare_fxaa_pipelines(
FxaaPipelineKey {
edge_threshold: fxaa.edge_threshold,
edge_threshold_min: fxaa.edge_threshold_min,
texture_format: if view.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
hdr: view.hdr,
},
);

Expand Down
16 changes: 7 additions & 9 deletions crates/bevy_anti_alias/src/smaa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,7 @@ struct SmaaNeighborhoodBlendingPipeline {
/// A unique identifier for a set of SMAA pipelines.
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct SmaaNeighborhoodBlendingPipelineKey {
/// The format of the framebuffer.
texture_format: TextureFormat,
/// The quality preset.
hdr: bool,
preset: SmaaPreset,
}

Expand Down Expand Up @@ -578,7 +576,11 @@ impl SpecializedRenderPipeline for SmaaNeighborhoodBlendingPipeline {
shader_defs,
entry_point: Some("neighborhood_blending_fragment_main".into()),
targets: vec![Some(ColorTargetState {
format: key.texture_format,
format: if key.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
blend: None,
write_mask: ColorWrites::ALL,
})],
Expand Down Expand Up @@ -618,11 +620,7 @@ fn prepare_smaa_pipelines(
&pipeline_cache,
&smaa_pipelines.neighborhood_blending,
SmaaNeighborhoodBlendingPipelineKey {
texture_format: if view.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
hdr: view.hdr,
preset: smaa.preset,
},
);
Expand Down
24 changes: 20 additions & 4 deletions crates/bevy_core_pipeline/src/fullscreen_material.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use bevy_render::{
TextureSampleType,
},
renderer::{RenderContext, RenderDevice},
view::ViewTarget,
view::{ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
ExtractSchedule, MainWorld, RenderApp, RenderStartup,
};
use bevy_shader::ShaderRef;
Expand Down Expand Up @@ -208,6 +208,8 @@ fn init_pipeline<T: FullscreenMaterial>(
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
// The View uniform
uniform_buffer::<ViewUniform>(true),
// The screen texture
texture_2d(TextureSampleType::Float { filterable: true }),
// The sampler that will be used to sample the screen texture
Expand Down Expand Up @@ -268,13 +270,17 @@ struct FullscreenMaterialNode<T: FullscreenMaterial> {

impl<T: FullscreenMaterial> ViewNode for FullscreenMaterialNode<T> {
// TODO we should expose the depth buffer and the gbuffer if using deferred
type ViewQuery = (&'static ViewTarget, &'static DynamicUniformIndex<T>);
type ViewQuery = (
&'static ViewTarget,
&'static DynamicUniformIndex<T>,
&'static ViewUniformOffset,
);

fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
(view_target, settings_index): QueryItem<Self::ViewQuery>,
(view_target, settings_index, view_uniform_offset): QueryItem<Self::ViewQuery>,
world: &World,
) -> Result<(), NodeRunError> {
let fullscreen_pipeline = world.resource::<FullscreenMaterialPipeline>();
Expand All @@ -290,6 +296,11 @@ impl<T: FullscreenMaterial> ViewNode for FullscreenMaterialNode<T> {
return Ok(());
};

let view_uniforms = world.resource::<ViewUniforms>();
let Some(view_binding) = view_uniforms.uniforms.binding() else {
return Ok(());
};

let data_uniforms = world.resource::<ComponentUniforms<T>>();
let Some(settings_binding) = data_uniforms.uniforms().binding() else {
return Ok(());
Expand All @@ -301,6 +312,7 @@ impl<T: FullscreenMaterial> ViewNode for FullscreenMaterialNode<T> {
"post_process_bind_group",
&pipeline_cache.get_bind_group_layout(&fullscreen_pipeline.layout),
&BindGroupEntries::sequential((
view_binding.clone(),
post_process.source,
&fullscreen_pipeline.sampler,
settings_binding.clone(),
Expand All @@ -321,7 +333,11 @@ impl<T: FullscreenMaterial> ViewNode for FullscreenMaterialNode<T> {
});

render_pass.set_render_pipeline(pipeline);
render_pass.set_bind_group(0, &bind_group, &[settings_index.index()]);
render_pass.set_bind_group(
0,
&bind_group,
&[view_uniform_offset.offset, settings_index.index()],
);
render_pass.draw(0..3, 0..1);

Ok(())
Expand Down
27 changes: 24 additions & 3 deletions crates/bevy_core_pipeline/src/tonemapping/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use bevy_asset::{
};
use bevy_camera::Camera;
use bevy_ecs::prelude::*;
use bevy_image::{CompressedImageFormats, Image, ImageSampler, ImageType};
use bevy_image::{BevyDefault, CompressedImageFormats, Image, ImageSampler, ImageType};
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_render::{
extract_component::{ExtractComponent, ExtractComponentPlugin},
Expand Down Expand Up @@ -160,6 +160,11 @@ pub enum Tonemapping {
/// Somewhat neutral. Suffers from hue shifting. Brights desaturate across the spectrum.
/// NOTE: Requires the `tonemapping_luts` cargo feature.
BlenderFilmic,
/// Tonemapping for HDR displays in the scRGB color space.
///
/// This is typically used for HDR displays that support a wide color gamut and high dynamic range.
/// The output is in linear scRGB (which uses the sRGB primaries but allows values outside [0, 1]).
Pq,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PQ is the display transfer function, which we aren't even using anywhere, so this should probably be named something like HdrNone

}

impl Tonemapping {
Expand Down Expand Up @@ -189,6 +194,8 @@ pub struct TonemappingPipelineKey {
deband_dither: DebandDither,
tonemapping: Tonemapping,
flags: TonemappingPipelineKeyFlags,
hdr: bool,
hdr_output: bool,
}

impl SpecializedRenderPipeline for TonemappingPipeline {
Expand All @@ -210,6 +217,10 @@ impl SpecializedRenderPipeline for TonemappingPipeline {
shader_defs.push("DEBAND_DITHER".into());
}

if key.hdr_output {
shader_defs.push("HDR_OUTPUT".into());
}

// Define shader flags depending on the color grading options in use.
if key.flags.contains(TonemappingPipelineKeyFlags::HUE_ROTATE) {
shader_defs.push("HUE_ROTATE".into());
Expand Down Expand Up @@ -264,6 +275,9 @@ impl SpecializedRenderPipeline for TonemappingPipeline {
);
shader_defs.push("TONEMAP_METHOD_BLENDER_FILMIC".into());
}
Tonemapping::Pq => {
shader_defs.push("TONEMAP_METHOD_PQ".into());
}
}
RenderPipelineDescriptor {
label: Some("tonemapping pipeline".into()),
Expand All @@ -273,7 +287,11 @@ impl SpecializedRenderPipeline for TonemappingPipeline {
shader: self.fragment_shader.clone(),
shader_defs,
targets: vec![Some(ColorTargetState {
format: ViewTarget::TEXTURE_FORMAT_HDR,
format: if key.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
blend: None,
write_mask: ColorWrites::ALL,
})],
Expand Down Expand Up @@ -357,6 +375,8 @@ pub fn prepare_view_tonemapping_pipelines(
deband_dither: *dither.unwrap_or(&DebandDither::Disabled),
tonemapping: *tonemapping.unwrap_or(&Tonemapping::None),
flags,
hdr: view.hdr,
hdr_output: view.hdr_output,
};
let pipeline = pipelines.specialize(&pipeline_cache, &upscaling_pipeline, key);

Expand Down Expand Up @@ -390,7 +410,8 @@ pub fn get_lut_bindings<'a>(
| Tonemapping::ReinhardLuminance
| Tonemapping::AcesFitted
| Tonemapping::AgX
| Tonemapping::SomewhatBoringDisplayTransform => &tonemapping_luts.agx,
| Tonemapping::SomewhatBoringDisplayTransform
| Tonemapping::Pq => &tonemapping_luts.agx,
Tonemapping::TonyMcMapface => &tonemapping_luts.tony_mc_mapface,
Tonemapping::BlenderFilmic => &tonemapping_luts.blender_filmic,
};
Expand Down
6 changes: 6 additions & 0 deletions crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {

var output_rgb = tone_mapping(hdr_color, view.color_grading).rgb;

#ifdef HDR_OUTPUT
// If we're tonemapping for HDR output, we don't want to apply dither or gamma correction in the same way,
// as the output should stay linear scRGB (or the driver will handle the PQ curve).
return vec4<f32>(output_rgb, hdr_color.a);
#endif

#ifdef DEBAND_DITHER
output_rgb = powsafe(output_rgb.rgb, 1.0 / 2.2);
output_rgb = output_rgb + screen_space_dither(in.position.xy);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn rgb_to_ycbcr(col: vec3<f32>) -> vec3<f32> {
fn tonemap_curve(v: f32) -> f32 {
#ifdef 0
// Large linear part in the lows, but compresses highs.
float c = v + v * v + 0.5 * v * v * v;
let c = v + v * v + 0.5 * v * v * v;
return c / (1.0 + c);
#else
return 1.0 - exp(-v);
Expand Down Expand Up @@ -251,6 +251,31 @@ fn tonemapping_reinhard_luminance(color: vec3<f32>) -> vec3<f32> {
return tonemapping_change_luminance(color, l_new);
}

fn tonemapping_pq(color: vec3<f32>, color_grading: ColorGrading) -> vec3<f32> {
Copy link
Member Author

@aevyrie aevyrie Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I played with this a bit, needs love. Linear seems to just work ™️ on macOS.

// PQ (Perceptual Quantizer) / Rec. 2100 HDR.
// We assume the input is linear Rec. 709 (sRGB primaries) and we want to output scRGB.
// scRGB is linear, but it uses sRGB primaries and is scaled such that 1.0 is 80 nits.
// However, modern HDR displays often expect 1.0 to be "paper white" or a specific nit value.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The display doesn't matter, it's what the OS wants (as the OS is going to convert scRGB to PQ when compositing).

Paper white is also not a property of the display, it's just a simple way for the user to set average scene brightness.

// scRGB is often defined as 1.0 = 80 nits, but Bevy's PBR expects 1.0 = 1 lux (or similar physically based unit).
// The `paper_white` parameter allows us to calibrate what 1.0 in the shader means in nits.

// For now, let's implement a simple HDR pass-through that scales the linear color.
// If we want actual PQ encoding, we would need to convert to Rec.2020 and apply the PQ curve.
// But many HDR APIs (like Windows scRGB or macOS HDR) accept linear values.

// We scale the color based on the ratio between the calibrated paper white and the 80 nits
// that scRGB defines as 1.0.
let paper_white_nits = color_grading.paper_white;
let max_luminance_nits = color_grading.max_luminance;

var out_color = color * (paper_white_nits / 80.0);

// Hard clip at max luminance
out_color = min(out_color, vec3(max_luminance_nits / 80.0));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For that matter, scRGB doesn't really care about max luminance either, the OS will just clip. Max luminance only really matters if the tonemapper is doing some sort of 'highlight shaping' to fit the highlights within whatever dynamic range we have over paper white.


return out_color;
}

fn rgb_to_srgb_simple(color: vec3<f32>) -> vec3<f32> {
return pow(color, vec3<f32>(1.0 / 2.2));
}
Expand Down Expand Up @@ -357,6 +382,8 @@ fn tone_mapping(in: vec4<f32>, in_color_grading: ColorGrading) -> vec4<f32> {
// tone_mapping
#ifdef TONEMAP_METHOD_NONE
color = color;
#else ifdef TONEMAP_METHOD_PQ
color = tonemapping_pq(color, color_grading);
#else ifdef TONEMAP_METHOD_REINHARD
color = tonemapping_reinhard(color.rgb);
#else ifdef TONEMAP_METHOD_REINHARD_LUMINANCE
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_pbr/src/deferred/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ pub fn prepare_deferred_lighting_pipelines(
}
Tonemapping::TonyMcMapface => MeshPipelineKey::TONEMAP_METHOD_TONY_MC_MAPFACE,
Tonemapping::BlenderFilmic => MeshPipelineKey::TONEMAP_METHOD_BLENDER_FILMIC,
Tonemapping::Pq => MeshPipelineKey::TONEMAP_METHOD_PQ,
};
}
if let Some(DebandDither::Enabled) = dither {
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_pbr/src/material.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,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::Pq => MeshPipelineKey::TONEMAP_METHOD_PQ,
}
}

Expand Down
Loading
Loading