Implement Auto Exposure plugin (#12792)

# Objective

- Add auto exposure/eye adaptation to the bevy render pipeline.
- Support features that users might expect from other engines:
  - Metering masks
  - Compensation curves
  - Smooth exposure transitions 

This PR is based on an implementation I already built for a personal
project before https://github.com/bevyengine/bevy/pull/8809 was
submitted, so I wasn't able to adopt that PR in the proper way. I've
still drawn inspiration from it, so @fintelia should be credited as
well.

## Solution

An auto exposure compute shader builds a 64 bin histogram of the scene's
luminance, and then adjusts the exposure based on that histogram. Using
a histogram allows the system to ignore outliers like shadows and
specular highlights, and it allows to give more weight to certain areas
based on a mask.

---

## Changelog

- Added: AutoExposure plugin that allows to adjust a camera's exposure
based on it's scene's luminance.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Bram Buurlage 2024-05-03 19:45:17 +02:00 committed by GitHub
parent bdb4899978
commit d390420093
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1282 additions and 13 deletions

View file

@ -677,6 +677,17 @@ description = "A scene showcasing the distance fog effect"
category = "3D Rendering"
wasm = true
[[example]]
name = "auto_exposure"
path = "examples/3d/auto_exposure.rs"
doc-scrape-examples = true
[package.metadata.example.auto_exposure]
name = "Auto Exposure"
description = "A scene showcasing auto exposure"
category = "3D Rendering"
wasm = false
[[example]]
name = "blend_modes"
path = "examples/3d/blend_modes.rs"

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

View file

@ -37,6 +37,7 @@ serde = { version = "1", features = ["derive"] }
bitflags = "2.3"
radsort = "0.1"
nonmax = "0.5"
thiserror = "1.0"
[lints]
workspace = true

View file

@ -0,0 +1,191 @@
// Auto exposure
//
// This shader computes an auto exposure value for the current frame,
// which is then used as an exposure correction in the tone mapping shader.
//
// The auto exposure value is computed in two passes:
// * The compute_histogram pass calculates a histogram of the luminance values in the scene,
// taking into account the metering mask texture. The metering mask is a grayscale texture
// that defines the areas of the screen that should be given more weight when calculating
// the average luminance value. For example, the middle area of the screen might be more important
// than the edges.
// * The compute_average pass calculates the average luminance value of the scene, taking
// into account the low_percent and high_percent settings. These settings define the
// percentage of the histogram that should be excluded when calculating the average. This
// is useful to avoid overexposure when you have a lot of shadows, or underexposure when you
// have a lot of bright specular reflections.
//
// The final target_exposure is finally used to smoothly adjust the exposure value over time.
#import bevy_render::view::View
#import bevy_render::globals::Globals
// Constant to convert RGB to luminance, taken from Real Time Rendering, Vol 4 pg. 278, 4th edition
const RGB_TO_LUM = vec3<f32>(0.2125, 0.7154, 0.0721);
struct AutoExposure {
min_log_lum: f32,
inv_log_lum_range: f32,
log_lum_range: f32,
low_percent: f32,
high_percent: f32,
speed_up: f32,
speed_down: f32,
exponential_transition_distance: f32,
}
struct CompensationCurve {
min_log_lum: f32,
inv_log_lum_range: f32,
min_compensation: f32,
compensation_range: f32,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var<uniform> settings: AutoExposure;
@group(0) @binding(2) var tex_color: texture_2d<f32>;
@group(0) @binding(3) var tex_mask: texture_2d<f32>;
@group(0) @binding(4) var tex_compensation: texture_1d<f32>;
@group(0) @binding(5) var<uniform> compensation_curve: CompensationCurve;
@group(0) @binding(6) var<storage, read_write> histogram: array<atomic<u32>, 64>;
@group(0) @binding(7) var<storage, read_write> exposure: f32;
@group(0) @binding(8) var<storage, read_write> view: View;
var<workgroup> histogram_shared: array<atomic<u32>, 64>;
// For a given color, return the histogram bin index
fn color_to_bin(hdr: vec3<f32>) -> u32 {
// Convert color to luminance
let lum = dot(hdr, RGB_TO_LUM);
if lum < exp2(settings.min_log_lum) {
return 0u;
}
// Calculate the log_2 luminance and express it as a value in [0.0, 1.0]
// where 0.0 represents the minimum luminance, and 1.0 represents the max.
let log_lum = saturate((log2(lum) - settings.min_log_lum) * settings.inv_log_lum_range);
// Map [0, 1] to [1, 63]. The zeroth bin is handled by the epsilon check above.
return u32(log_lum * 62.0 + 1.0);
}
// Read the metering mask at the given UV coordinates, returning a weight for the histogram.
//
// Since the histogram is summed in the compute_average step, there is a limit to the amount of
// distinct values that can be represented. When using the chosen value of 16, the maximum
// amount of pixels that can be weighted and summed is 2^32 / 16 = 16384^2.
fn metering_weight(coords: vec2<f32>) -> u32 {
let pos = vec2<i32>(coords * vec2<f32>(textureDimensions(tex_mask)));
let mask = textureLoad(tex_mask, pos, 0).r;
return u32(mask * 16.0);
}
@compute @workgroup_size(16, 16, 1)
fn compute_histogram(
@builtin(global_invocation_id) global_invocation_id: vec3<u32>,
@builtin(local_invocation_index) local_invocation_index: u32
) {
// Clear the workgroup shared histogram
histogram_shared[local_invocation_index] = 0u;
// Wait for all workgroup threads to clear the shared histogram
storageBarrier();
let dim = vec2<u32>(textureDimensions(tex_color));
let uv = vec2<f32>(global_invocation_id.xy) / vec2<f32>(dim);
if global_invocation_id.x < dim.x && global_invocation_id.y < dim.y {
let col = textureLoad(tex_color, vec2<i32>(global_invocation_id.xy), 0).rgb;
let index = color_to_bin(col);
let weight = metering_weight(uv);
// Increment the shared histogram bin by the weight obtained from the metering mask
atomicAdd(&histogram_shared[index], weight);
}
// Wait for all workgroup threads to finish updating the workgroup histogram
workgroupBarrier();
// Accumulate the workgroup histogram into the global histogram.
// Note that the global histogram was not cleared at the beginning,
// as it will be cleared in compute_average.
atomicAdd(&histogram[local_invocation_index], histogram_shared[local_invocation_index]);
}
@compute @workgroup_size(1, 1, 1)
fn compute_average(@builtin(local_invocation_index) local_index: u32) {
var histogram_sum = 0u;
// Calculate the cumulative histogram and clear the histogram bins.
// Each bin in the cumulative histogram contains the sum of all bins up to that point.
// This way we can quickly exclude the portion of lowest and highest samples as required by
// the low_percent and high_percent settings.
for (var i=0u; i<64u; i+=1u) {
histogram_sum += histogram[i];
histogram_shared[i] = histogram_sum;
// Clear the histogram bin for the next frame
histogram[i] = 0u;
}
let first_index = u32(f32(histogram_sum) * settings.low_percent);
let last_index = u32(f32(histogram_sum) * settings.high_percent);
var count = 0u;
var sum = 0.0;
for (var i=1u; i<64u; i+=1u) {
// The number of pixels in the bin. The histogram values are clamped to
// first_index and last_index to exclude the lowest and highest samples.
let bin_count =
clamp(histogram_shared[i], first_index, last_index) -
clamp(histogram_shared[i - 1u], first_index, last_index);
sum += f32(bin_count) * f32(i);
count += bin_count;
}
var target_exposure = 0.0;
if count > 0u {
// The average luminance of the included histogram samples.
let avg_lum = sum / (f32(count) * 63.0)
* settings.log_lum_range
+ settings.min_log_lum;
// The position in the compensation curve texture to sample for avg_lum.
let u = (avg_lum - compensation_curve.min_log_lum) * compensation_curve.inv_log_lum_range;
// The target exposure is the negative of the average log luminance.
// The compensation value is added to the target exposure to adjust the exposure for
// artistic purposes.
target_exposure = textureLoad(tex_compensation, i32(saturate(u) * 255.0), 0).r
* compensation_curve.compensation_range
+ compensation_curve.min_compensation
- avg_lum;
}
// Smoothly adjust the `exposure` towards the `target_exposure`
let delta = target_exposure - exposure;
if target_exposure > exposure {
let speed_down = settings.speed_down * globals.delta_time;
let exp_down = speed_down / settings.exponential_transition_distance;
exposure = exposure + min(speed_down, delta * exp_down);
} else {
let speed_up = settings.speed_up * globals.delta_time;
let exp_up = speed_up / settings.exponential_transition_distance;
exposure = exposure + max(-speed_up, delta * exp_up);
}
// Apply the exposure to the color grading settings, from where it will be used for the color
// grading pass.
view.color_grading.exposure += exposure;
}

View file

@ -0,0 +1,87 @@
use bevy_ecs::prelude::*;
use bevy_render::{
render_resource::{StorageBuffer, UniformBuffer},
renderer::{RenderDevice, RenderQueue},
Extract,
};
use bevy_utils::{Entry, HashMap};
use super::pipeline::AutoExposureSettingsUniform;
use super::AutoExposureSettings;
#[derive(Resource, Default)]
pub(super) struct AutoExposureBuffers {
pub(super) buffers: HashMap<Entity, AutoExposureBuffer>,
}
pub(super) struct AutoExposureBuffer {
pub(super) state: StorageBuffer<f32>,
pub(super) settings: UniformBuffer<AutoExposureSettingsUniform>,
}
#[derive(Resource)]
pub(super) struct ExtractedStateBuffers {
changed: Vec<(Entity, AutoExposureSettings)>,
removed: Vec<Entity>,
}
pub(super) fn extract_buffers(
mut commands: Commands,
changed: Extract<Query<(Entity, &AutoExposureSettings), Changed<AutoExposureSettings>>>,
mut removed: Extract<RemovedComponents<AutoExposureSettings>>,
) {
commands.insert_resource(ExtractedStateBuffers {
changed: changed
.iter()
.map(|(entity, settings)| (entity, settings.clone()))
.collect(),
removed: removed.read().collect(),
});
}
pub(super) fn prepare_buffers(
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
mut extracted: ResMut<ExtractedStateBuffers>,
mut buffers: ResMut<AutoExposureBuffers>,
) {
for (entity, settings) in extracted.changed.drain(..) {
let (min_log_lum, max_log_lum) = settings.range.into_inner();
let (low_percent, high_percent) = settings.filter.into_inner();
let initial_state = 0.0f32.clamp(min_log_lum, max_log_lum);
let settings = AutoExposureSettingsUniform {
min_log_lum,
inv_log_lum_range: 1.0 / (max_log_lum - min_log_lum),
log_lum_range: max_log_lum - min_log_lum,
low_percent,
high_percent,
speed_up: settings.speed_brighten,
speed_down: settings.speed_darken,
exponential_transition_distance: settings.exponential_transition_distance,
};
match buffers.buffers.entry(entity) {
Entry::Occupied(mut entry) => {
// Update the settings buffer, but skip updating the state buffer.
// The state buffer is skipped so that the animation stays continuous.
let value = entry.get_mut();
value.settings.set(settings);
value.settings.write_buffer(&device, &queue);
}
Entry::Vacant(entry) => {
let value = entry.insert(AutoExposureBuffer {
state: StorageBuffer::from(initial_state),
settings: UniformBuffer::from(settings),
});
value.state.write_buffer(&device, &queue);
value.settings.write_buffer(&device, &queue);
}
}
}
for entity in extracted.removed.drain(..) {
buffers.buffers.remove(&entity);
}
}

View file

@ -0,0 +1,226 @@
use bevy_asset::prelude::*;
use bevy_ecs::system::{lifetimeless::SRes, SystemParamItem};
use bevy_math::{cubic_splines::CubicGenerator, FloatExt, Vec2};
use bevy_reflect::prelude::*;
use bevy_render::{
render_asset::{RenderAsset, RenderAssetUsages},
render_resource::{
Extent3d, ShaderType, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
TextureView, UniformBuffer,
},
renderer::{RenderDevice, RenderQueue},
};
use thiserror::Error;
const LUT_SIZE: usize = 256;
/// An auto exposure compensation curve.
/// This curve is used to map the average log luminance of a scene to an
/// exposure compensation value, to allow for fine control over the final exposure.
#[derive(Asset, Reflect, Debug, Clone)]
#[reflect(Default)]
pub struct AutoExposureCompensationCurve {
/// The minimum log luminance value in the curve. (the x-axis)
min_log_lum: f32,
/// The maximum log luminance value in the curve. (the x-axis)
max_log_lum: f32,
/// The minimum exposure compensation value in the curve. (the y-axis)
min_compensation: f32,
/// The maximum exposure compensation value in the curve. (the y-axis)
max_compensation: f32,
/// The lookup table for the curve. Uploaded to the GPU as a 1D texture.
/// Each value in the LUT is a `u8` representing a normalized exposure compensation value:
/// * `0` maps to `min_compensation`
/// * `255` maps to `max_compensation`
/// The position in the LUT corresponds to the normalized log luminance value.
/// * `0` maps to `min_log_lum`
/// * `LUT_SIZE - 1` maps to `max_log_lum`
lut: [u8; LUT_SIZE],
}
/// Various errors that can occur when constructing an [`AutoExposureCompensationCurve`].
#[derive(Error, Debug)]
pub enum AutoExposureCompensationCurveError {
/// A discontinuity was found in the curve.
#[error("discontinuity found between curve segments")]
DiscontinuityFound,
/// The curve is not monotonically increasing on the x-axis.
#[error("curve is not monotonically increasing on the x-axis")]
NotMonotonic,
}
impl Default for AutoExposureCompensationCurve {
fn default() -> Self {
Self {
min_log_lum: 0.0,
max_log_lum: 0.0,
min_compensation: 0.0,
max_compensation: 0.0,
lut: [0; LUT_SIZE],
}
}
}
impl AutoExposureCompensationCurve {
const SAMPLES_PER_SEGMENT: usize = 64;
/// Build an [`AutoExposureCompensationCurve`] from a [`CubicGenerator<Vec2>`], where:
/// - x represents the average log luminance of the scene in EV-100;
/// - y represents the exposure compensation value in F-stops.
///
/// # Errors
///
/// If the curve is not monotonically increasing on the x-axis,
/// returns [`AutoExposureCompensationCurveError::NotMonotonic`].
///
/// If a discontinuity is found between curve segments,
/// returns [`AutoExposureCompensationCurveError::DiscontinuityFound`].
///
/// # Example
///
/// ```
/// # use bevy_asset::prelude::*;
/// # use bevy_math::vec2;
/// # use bevy_math::cubic_splines::*;
/// # use bevy_core_pipeline::auto_exposure::AutoExposureCompensationCurve;
/// # let mut compensation_curves = Assets::<AutoExposureCompensationCurve>::default();
/// let curve: Handle<AutoExposureCompensationCurve> = compensation_curves.add(
/// AutoExposureCompensationCurve::from_curve(LinearSpline::new([
/// vec2(-4.0, -2.0),
/// vec2(0.0, 0.0),
/// vec2(2.0, 0.0),
/// vec2(4.0, 2.0),
/// ]))
/// .unwrap()
/// );
/// ```
pub fn from_curve<T>(curve: T) -> Result<Self, AutoExposureCompensationCurveError>
where
T: CubicGenerator<Vec2>,
{
let curve = curve.to_curve();
let min_log_lum = curve.position(0.0).x;
let max_log_lum = curve.position(curve.segments().len() as f32).x;
let log_lum_range = max_log_lum - min_log_lum;
let mut lut = [0.0; LUT_SIZE];
let mut previous = curve.position(0.0);
let mut min_compensation = previous.y;
let mut max_compensation = previous.y;
for segment in curve {
if segment.position(0.0) != previous {
return Err(AutoExposureCompensationCurveError::DiscontinuityFound);
}
for i in 1..Self::SAMPLES_PER_SEGMENT {
let current = segment.position(i as f32 / (Self::SAMPLES_PER_SEGMENT - 1) as f32);
if current.x < previous.x {
return Err(AutoExposureCompensationCurveError::NotMonotonic);
}
// Find the range of LUT entries that this line segment covers.
let (lut_begin, lut_end) = (
((previous.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
((current.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
);
let lut_inv_range = 1.0 / (lut_end - lut_begin);
// Iterate over all LUT entries whose pixel centers fall within the current segment.
#[allow(clippy::needless_range_loop)]
for i in lut_begin.ceil() as usize..=lut_end.floor() as usize {
let t = (i as f32 - lut_begin) * lut_inv_range;
lut[i] = previous.y.lerp(current.y, t);
min_compensation = min_compensation.min(lut[i]);
max_compensation = max_compensation.max(lut[i]);
}
previous = current;
}
}
let compensation_range = max_compensation - min_compensation;
Ok(Self {
min_log_lum,
max_log_lum,
min_compensation,
max_compensation,
lut: if compensation_range > 0.0 {
let scale = 255.0 / compensation_range;
lut.map(|f: f32| ((f - min_compensation) * scale) as u8)
} else {
[0; LUT_SIZE]
},
})
}
}
/// The GPU-representation of an [`AutoExposureCompensationCurve`].
/// Consists of a [`TextureView`] with the curve's data,
/// and a [`UniformBuffer`] with the curve's extents.
pub struct GpuAutoExposureCompensationCurve {
pub(super) texture_view: TextureView,
pub(super) extents: UniformBuffer<AutoExposureCompensationCurveUniform>,
}
#[derive(ShaderType, Clone, Copy)]
pub(super) struct AutoExposureCompensationCurveUniform {
min_log_lum: f32,
inv_log_lum_range: f32,
min_compensation: f32,
compensation_range: f32,
}
impl RenderAsset for GpuAutoExposureCompensationCurve {
type SourceAsset = AutoExposureCompensationCurve;
type Param = (SRes<RenderDevice>, SRes<RenderQueue>);
fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages {
RenderAssetUsages::RENDER_WORLD
}
fn prepare_asset(
source: Self::SourceAsset,
(render_device, render_queue): &mut SystemParamItem<Self::Param>,
) -> Result<Self, bevy_render::render_asset::PrepareAssetError<Self::SourceAsset>> {
let texture = render_device.create_texture_with_data(
render_queue,
&TextureDescriptor {
label: None,
size: Extent3d {
width: LUT_SIZE as u32,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D1,
format: TextureFormat::R8Unorm,
usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
view_formats: &[TextureFormat::R8Unorm],
},
Default::default(),
&source.lut,
);
let texture_view = texture.create_view(&Default::default());
let mut extents = UniformBuffer::from(AutoExposureCompensationCurveUniform {
min_log_lum: source.min_log_lum,
inv_log_lum_range: 1.0 / (source.max_log_lum - source.min_log_lum),
min_compensation: source.min_compensation,
compensation_range: source.max_compensation - source.min_compensation,
});
extents.write_buffer(render_device, render_queue);
Ok(GpuAutoExposureCompensationCurve {
texture_view,
extents,
})
}
}

View file

@ -0,0 +1,131 @@
use bevy_app::prelude::*;
use bevy_asset::{load_internal_asset, AssetApp, Assets, Handle};
use bevy_ecs::prelude::*;
use bevy_render::extract_component::ExtractComponentPlugin;
use bevy_render::render_asset::RenderAssetPlugin;
use bevy_render::render_resource::Shader;
use bevy_render::ExtractSchedule;
use bevy_render::{
render_graph::RenderGraphApp,
render_resource::{
Buffer, BufferDescriptor, BufferUsages, PipelineCache, SpecializedComputePipelines,
},
renderer::RenderDevice,
Render, RenderApp, RenderSet,
};
mod buffers;
mod compensation_curve;
mod node;
mod pipeline;
mod settings;
use buffers::{extract_buffers, prepare_buffers, AutoExposureBuffers};
pub use compensation_curve::{AutoExposureCompensationCurve, AutoExposureCompensationCurveError};
use node::AutoExposureNode;
use pipeline::{
AutoExposurePass, AutoExposurePipeline, ViewAutoExposurePipeline, METERING_SHADER_HANDLE,
};
pub use settings::AutoExposureSettings;
use crate::auto_exposure::compensation_curve::GpuAutoExposureCompensationCurve;
use crate::core_3d::graph::{Core3d, Node3d};
/// Plugin for the auto exposure feature.
///
/// See [`AutoExposureSettings`] for more details.
pub struct AutoExposurePlugin;
#[derive(Resource)]
struct AutoExposureResources {
histogram: Buffer,
}
impl Plugin for AutoExposurePlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(
app,
METERING_SHADER_HANDLE,
"auto_exposure.wgsl",
Shader::from_wgsl
);
app.add_plugins(RenderAssetPlugin::<GpuAutoExposureCompensationCurve>::default())
.register_type::<AutoExposureCompensationCurve>()
.init_asset::<AutoExposureCompensationCurve>()
.register_asset_reflect::<AutoExposureCompensationCurve>();
app.world_mut()
.resource_mut::<Assets<AutoExposureCompensationCurve>>()
.insert(&Handle::default(), AutoExposureCompensationCurve::default());
app.register_type::<AutoExposureSettings>();
app.add_plugins(ExtractComponentPlugin::<AutoExposureSettings>::default());
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.init_resource::<SpecializedComputePipelines<AutoExposurePipeline>>()
.init_resource::<AutoExposureBuffers>()
.add_systems(ExtractSchedule, extract_buffers)
.add_systems(
Render,
(
prepare_buffers.in_set(RenderSet::Prepare),
queue_view_auto_exposure_pipelines.in_set(RenderSet::Queue),
),
)
.add_render_graph_node::<AutoExposureNode>(Core3d, node::AutoExposure)
.add_render_graph_edges(
Core3d,
(Node3d::EndMainPass, node::AutoExposure, Node3d::Tonemapping),
);
}
fn finish(&self, app: &mut App) {
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app.init_resource::<AutoExposurePipeline>();
render_app.init_resource::<AutoExposureResources>();
}
}
impl FromWorld for AutoExposureResources {
fn from_world(world: &mut World) -> Self {
Self {
histogram: world
.resource::<RenderDevice>()
.create_buffer(&BufferDescriptor {
label: Some("histogram buffer"),
size: pipeline::HISTOGRAM_BIN_COUNT * 4,
usage: BufferUsages::STORAGE,
mapped_at_creation: false,
}),
}
}
}
fn queue_view_auto_exposure_pipelines(
mut commands: Commands,
pipeline_cache: ResMut<PipelineCache>,
mut compute_pipelines: ResMut<SpecializedComputePipelines<AutoExposurePipeline>>,
pipeline: Res<AutoExposurePipeline>,
view_targets: Query<(Entity, &AutoExposureSettings)>,
) {
for (entity, settings) in view_targets.iter() {
let histogram_pipeline =
compute_pipelines.specialize(&pipeline_cache, &pipeline, AutoExposurePass::Histogram);
let average_pipeline =
compute_pipelines.specialize(&pipeline_cache, &pipeline, AutoExposurePass::Average);
commands.entity(entity).insert(ViewAutoExposurePipeline {
histogram_pipeline,
mean_luminance_pipeline: average_pipeline,
compensation_curve: settings.compensation_curve.clone(),
metering_mask: settings.metering_mask.clone(),
});
}
}

View file

@ -0,0 +1,141 @@
use super::{
buffers::AutoExposureBuffers,
compensation_curve::GpuAutoExposureCompensationCurve,
pipeline::{AutoExposurePipeline, ViewAutoExposurePipeline},
AutoExposureResources,
};
use bevy_ecs::{
query::QueryState,
system::lifetimeless::Read,
world::{FromWorld, World},
};
use bevy_render::{
globals::GlobalsBuffer,
render_asset::RenderAssets,
render_graph::*,
render_resource::*,
renderer::RenderContext,
texture::{FallbackImage, GpuImage},
view::{ExtractedView, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms},
};
#[derive(RenderLabel, Debug, Clone, Hash, PartialEq, Eq)]
pub struct AutoExposure;
pub struct AutoExposureNode {
query: QueryState<(
Read<ViewUniformOffset>,
Read<ViewTarget>,
Read<ViewAutoExposurePipeline>,
Read<ExtractedView>,
)>,
}
impl FromWorld for AutoExposureNode {
fn from_world(world: &mut World) -> Self {
Self {
query: QueryState::new(world),
}
}
}
impl Node for AutoExposureNode {
fn update(&mut self, world: &mut World) {
self.query.update_archetypes(world);
}
fn run(
&self,
graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
world: &World,
) -> Result<(), NodeRunError> {
let view_entity = graph.view_entity();
let pipeline_cache = world.resource::<PipelineCache>();
let pipeline = world.resource::<AutoExposurePipeline>();
let resources = world.resource::<AutoExposureResources>();
let view_uniforms_resource = world.resource::<ViewUniforms>();
let view_uniforms = &view_uniforms_resource.uniforms;
let view_uniforms_buffer = view_uniforms.buffer().unwrap();
let globals_buffer = world.resource::<GlobalsBuffer>();
let auto_exposure_buffers = world.resource::<AutoExposureBuffers>();
let (
Ok((view_uniform_offset, view_target, auto_exposure, view)),
Some(auto_exposure_buffers),
) = (
self.query.get_manual(world, view_entity),
auto_exposure_buffers.buffers.get(&view_entity),
)
else {
return Ok(());
};
let (Some(histogram_pipeline), Some(average_pipeline)) = (
pipeline_cache.get_compute_pipeline(auto_exposure.histogram_pipeline),
pipeline_cache.get_compute_pipeline(auto_exposure.mean_luminance_pipeline),
) else {
return Ok(());
};
let source = view_target.main_texture_view();
let fallback = world.resource::<FallbackImage>();
let mask = world
.resource::<RenderAssets<GpuImage>>()
.get(&auto_exposure.metering_mask);
let mask = mask
.map(|i| &i.texture_view)
.unwrap_or(&fallback.d2.texture_view);
let Some(compensation_curve) = world
.resource::<RenderAssets<GpuAutoExposureCompensationCurve>>()
.get(&auto_exposure.compensation_curve)
else {
return Ok(());
};
let compute_bind_group = render_context.render_device().create_bind_group(
None,
&pipeline.histogram_layout,
&BindGroupEntries::sequential((
&globals_buffer.buffer,
&auto_exposure_buffers.settings,
source,
mask,
&compensation_curve.texture_view,
&compensation_curve.extents,
resources.histogram.as_entire_buffer_binding(),
&auto_exposure_buffers.state,
BufferBinding {
buffer: view_uniforms_buffer,
size: Some(ViewUniform::min_size()),
offset: 0,
},
)),
);
let mut compute_pass =
render_context
.command_encoder()
.begin_compute_pass(&ComputePassDescriptor {
label: Some("auto_exposure_pass"),
timestamp_writes: None,
});
compute_pass.set_bind_group(0, &compute_bind_group, &[view_uniform_offset.offset]);
compute_pass.set_pipeline(histogram_pipeline);
compute_pass.dispatch_workgroups(
view.viewport.z.div_ceil(16),
view.viewport.w.div_ceil(16),
1,
);
compute_pass.set_pipeline(average_pipeline);
compute_pass.dispatch_workgroups(1, 1, 1);
Ok(())
}
}

View file

@ -0,0 +1,94 @@
use super::compensation_curve::{
AutoExposureCompensationCurve, AutoExposureCompensationCurveUniform,
};
use bevy_asset::prelude::*;
use bevy_ecs::prelude::*;
use bevy_render::{
globals::GlobalsUniform,
render_resource::{binding_types::*, *},
renderer::RenderDevice,
texture::Image,
view::ViewUniform,
};
use std::num::NonZeroU64;
#[derive(Resource)]
pub struct AutoExposurePipeline {
pub histogram_layout: BindGroupLayout,
pub histogram_shader: Handle<Shader>,
}
#[derive(Component)]
pub struct ViewAutoExposurePipeline {
pub histogram_pipeline: CachedComputePipelineId,
pub mean_luminance_pipeline: CachedComputePipelineId,
pub compensation_curve: Handle<AutoExposureCompensationCurve>,
pub metering_mask: Handle<Image>,
}
#[derive(ShaderType, Clone, Copy)]
pub struct AutoExposureSettingsUniform {
pub(super) min_log_lum: f32,
pub(super) inv_log_lum_range: f32,
pub(super) log_lum_range: f32,
pub(super) low_percent: f32,
pub(super) high_percent: f32,
pub(super) speed_up: f32,
pub(super) speed_down: f32,
pub(super) exponential_transition_distance: f32,
}
#[derive(PartialEq, Eq, Hash, Clone)]
pub enum AutoExposurePass {
Histogram,
Average,
}
pub const METERING_SHADER_HANDLE: Handle<Shader> = Handle::weak_from_u128(12987620402995522466);
pub const HISTOGRAM_BIN_COUNT: u64 = 64;
impl FromWorld for AutoExposurePipeline {
fn from_world(world: &mut World) -> Self {
let render_device = world.resource::<RenderDevice>();
Self {
histogram_layout: render_device.create_bind_group_layout(
"compute histogram bind group",
&BindGroupLayoutEntries::sequential(
ShaderStages::COMPUTE,
(
uniform_buffer::<GlobalsUniform>(false),
uniform_buffer::<AutoExposureSettingsUniform>(false),
texture_2d(TextureSampleType::Float { filterable: false }),
texture_2d(TextureSampleType::Float { filterable: false }),
texture_1d(TextureSampleType::Float { filterable: false }),
uniform_buffer::<AutoExposureCompensationCurveUniform>(false),
storage_buffer_sized(false, NonZeroU64::new(HISTOGRAM_BIN_COUNT * 4)),
storage_buffer_sized(false, NonZeroU64::new(4)),
storage_buffer::<ViewUniform>(true),
),
),
),
histogram_shader: METERING_SHADER_HANDLE.clone(),
}
}
}
impl SpecializedComputePipeline for AutoExposurePipeline {
type Key = AutoExposurePass;
fn specialize(&self, pass: AutoExposurePass) -> ComputePipelineDescriptor {
ComputePipelineDescriptor {
label: Some("luminance compute pipeline".into()),
layout: vec![self.histogram_layout.clone()],
shader: self.histogram_shader.clone(),
shader_defs: vec![],
entry_point: match pass {
AutoExposurePass::Histogram => "compute_histogram".into(),
AutoExposurePass::Average => "compute_average".into(),
},
push_constant_ranges: vec![],
}
}
}

View file

@ -0,0 +1,102 @@
use std::ops::RangeInclusive;
use super::compensation_curve::AutoExposureCompensationCurve;
use bevy_asset::Handle;
use bevy_ecs::{prelude::Component, reflect::ReflectComponent};
use bevy_reflect::Reflect;
use bevy_render::{extract_component::ExtractComponent, texture::Image};
use bevy_utils::default;
/// Component that enables auto exposure for an HDR-enabled 2d or 3d camera.
///
/// Auto exposure adjusts the exposure of the camera automatically to
/// simulate the human eye's ability to adapt to different lighting conditions.
///
/// Bevy's implementation builds a 64 bin histogram of the scene's luminance,
/// and then adjusts the exposure so that the average brightness of the final
/// render will be middle gray. Because it's using a histogram, some details can
/// be selectively ignored or emphasized. Outliers like shadows and specular
/// highlights can be ignored, and certain areas can be given more (or less)
/// weight based on a mask.
///
/// # Usage Notes
///
/// **Auto Exposure requires compute shaders and is not compatible with WebGL2.**
///
#[derive(Component, Clone, Reflect, ExtractComponent)]
#[reflect(Component)]
pub struct AutoExposureSettings {
/// The range of exposure values for the histogram.
///
/// Pixel values below this range will be ignored, and pixel values above this range will be
/// clamped in the sense that they will count towards the highest bin in the histogram.
/// The default value is `-8.0..=8.0`.
pub range: RangeInclusive<f32>,
/// The portion of the histogram to consider when metering.
///
/// By default, the darkest 10% and the brightest 10% of samples are ignored,
/// so the default value is `0.10..=0.90`.
pub filter: RangeInclusive<f32>,
/// The speed at which the exposure adapts from dark to bright scenes, in F-stops per second.
pub speed_brighten: f32,
/// The speed at which the exposure adapts from bright to dark scenes, in F-stops per second.
pub speed_darken: f32,
/// The distance in F-stops from the target exposure from where to transition from animating
/// in linear fashion to animating exponentially. This helps against jittering when the
/// target exposure keeps on changing slightly from frame to frame, while still maintaining
/// a relatively slow animation for big changes in scene brightness.
///
/// ```text
/// ev
/// ➔●┐
/// | ⬈ ├ exponential section
/// │ ⬈ ┘
/// │ ⬈ ┐
/// │ ⬈ ├ linear section
/// │⬈ ┘
/// ●───────────────────────── time
/// ```
///
/// The default value is 1.5.
pub exponential_transition_distance: f32,
/// The mask to apply when metering. The mask will cover the entire screen, where:
/// * `(0.0, 0.0)` is the top-left corner,
/// * `(1.0, 1.0)` is the bottom-right corner.
/// Only the red channel of the texture is used.
/// The sample at the current screen position will be used to weight the contribution
/// of each pixel to the histogram:
/// * 0.0 means the pixel will not contribute to the histogram,
/// * 1.0 means the pixel will contribute fully to the histogram.
///
/// The default value is a white image, so all pixels contribute equally.
///
/// # Usage Notes
///
/// The mask is quantized to 16 discrete levels because of limitations in the compute shader
/// implementation.
pub metering_mask: Handle<Image>,
/// Exposure compensation curve to apply after metering.
/// The default value is a flat line at 0.0.
/// For more information, see [`AutoExposureCompensationCurve`].
pub compensation_curve: Handle<AutoExposureCompensationCurve>,
}
impl Default for AutoExposureSettings {
fn default() -> Self {
Self {
range: -8.0..=8.0,
filter: 0.10..=0.90,
speed_brighten: 3.0,
speed_darken: 1.0,
exponential_transition_distance: 1.5,
metering_mask: default(),
compensation_curve: default(),
}
}
}

View file

@ -28,6 +28,7 @@ pub mod graph {
Taa,
MotionBlur,
Bloom,
AutoExposure,
Tonemapping,
Fxaa,
Upscaling,

View file

@ -7,6 +7,7 @@
html_favicon_url = "https://bevyengine.org/assets/icon.png"
)]
pub mod auto_exposure;
pub mod blit;
pub mod bloom;
pub mod contrast_adaptive_sharpening;

View file

@ -30,7 +30,7 @@ fn sample_current_lut(p: vec3<f32>) -> vec3<f32> {
return textureSampleLevel(dt_lut_texture, dt_lut_sampler, p, 0.0).rgb;
#else ifdef TONEMAP_METHOD_BLENDER_FILMIC
return textureSampleLevel(dt_lut_texture, dt_lut_sampler, p, 0.0).rgb;
#else
#else
return vec3(1.0, 0.0, 1.0);
#endif
}
@ -42,8 +42,8 @@ fn sample_current_lut(p: vec3<f32>) -> vec3<f32> {
fn rgb_to_ycbcr(col: vec3<f32>) -> vec3<f32> {
let m = mat3x3<f32>(
0.2126, 0.7152, 0.0722,
-0.1146, -0.3854, 0.5,
0.2126, 0.7152, 0.0722,
-0.1146, -0.3854, 0.5,
0.5, -0.4542, -0.0458
);
return col * m;
@ -51,8 +51,8 @@ fn rgb_to_ycbcr(col: vec3<f32>) -> vec3<f32> {
fn ycbcr_to_rgb(col: vec3<f32>) -> vec3<f32> {
let m = mat3x3<f32>(
1.0, 0.0, 1.5748,
1.0, -0.1873, -0.4681,
1.0, 0.0, 1.5748,
1.0, -0.1873, -0.4681,
1.0, 1.8556, 0.0
);
return max(vec3(0.0), col * m);
@ -122,14 +122,14 @@ fn RRTAndODTFit(v: vec3<f32>) -> vec3<f32> {
return a / b;
}
fn ACESFitted(color: vec3<f32>) -> vec3<f32> {
fn ACESFitted(color: vec3<f32>) -> vec3<f32> {
var fitted_color = color;
// sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT
let rgb_to_rrt = mat3x3<f32>(
vec3(0.59719, 0.35458, 0.04823),
vec3(0.07600, 0.90834, 0.01566),
vec3(0.02840, 0.13383, 0.83777)
vec3(0.02840, 0.13383, 0.83777)
);
// ODT_SAT => XYZ => D60_2_D65 => sRGB
@ -224,7 +224,7 @@ fn applyAgXLog(Image: vec3<f32>) -> vec3<f32> {
prepared_image = vec3(r, g, b);
prepared_image = convertOpenDomainToNormalizedLog2_(prepared_image, -10.0, 6.5);
prepared_image = clamp(prepared_image, vec3(0.0), vec3(1.0));
return prepared_image;
}
@ -368,6 +368,10 @@ fn tone_mapping(in: vec4<f32>, in_color_grading: ColorGrading) -> vec4<f32> {
// applies individually to shadows, midtones, and highlights.
#ifdef SECTIONAL_COLOR_GRADING
color = sectional_color_grading(color, &color_grading);
#else
// If we're not doing sectional color grading, the exposure might still need
// to be applied, for example when using auto exposure.
color = color * powsafe(vec3(2.0), color_grading.exposure);
#endif
// tone_mapping
@ -385,14 +389,14 @@ fn tone_mapping(in: vec4<f32>, in_color_grading: ColorGrading) -> vec4<f32> {
#else ifdef TONEMAP_METHOD_SOMEWHAT_BORING_DISPLAY_TRANSFORM
color = somewhat_boring_display_transform(color.rgb);
#else ifdef TONEMAP_METHOD_TONY_MC_MAPFACE
color = sample_tony_mc_mapface_lut(color);
color = sample_tony_mc_mapface_lut(color);
#else ifdef TONEMAP_METHOD_BLENDER_FILMIC
color = sample_blender_filmic_lut(color.rgb);
#endif
// Perceptual post tonemapping grading
color = saturation(color, color_grading.post_saturation);
return vec4(color, in.a);
}

View file

@ -408,6 +408,15 @@ pub mod binding_types {
.into_bind_group_layout_entry_builder()
}
pub fn texture_1d(sample_type: TextureSampleType) -> BindGroupLayoutEntryBuilder {
BindingType::Texture {
sample_type,
view_dimension: TextureViewDimension::D1,
multisampled: false,
}
.into_bind_group_layout_entry_builder()
}
pub fn texture_2d(sample_type: TextureSampleType) -> BindGroupLayoutEntryBuilder {
BindingType::Texture {
sample_type,

View file

@ -8,6 +8,8 @@ use encase::{
};
use wgpu::{util::BufferInitDescriptor, BindingResource, BufferBinding, BufferUsages};
use super::IntoBinding;
/// Stores data to be transferred to the GPU and made accessible to shaders as a storage buffer.
///
/// Storage buffers can be made available to shaders in some combination of read/write mode, and can store large amounts of data.
@ -138,6 +140,16 @@ impl<T: ShaderType + WriteInto> StorageBuffer<T> {
}
}
impl<'a, T: ShaderType + WriteInto> IntoBinding<'a> for &'a StorageBuffer<T> {
#[inline]
fn into_binding(self) -> BindingResource<'a> {
self.buffer()
.expect("Failed to get buffer")
.as_entire_buffer_binding()
.into_binding()
}
}
/// Stores data to be transferred to the GPU and made accessible to shaders as a dynamic storage buffer.
///
/// Dynamic storage buffers can be made available to shaders in some combination of read/write mode, and can store large amounts
@ -256,3 +268,10 @@ impl<T: ShaderType + WriteInto> DynamicStorageBuffer<T> {
self.scratch.set_offset(0);
}
}
impl<'a, T: ShaderType + WriteInto> IntoBinding<'a> for &'a DynamicStorageBuffer<T> {
#[inline]
fn into_binding(self) -> BindingResource<'a> {
self.binding().unwrap()
}
}

View file

@ -36,7 +36,7 @@ use std::{
},
};
use wgpu::{
Extent3d, RenderPassColorAttachment, RenderPassDepthStencilAttachment, StoreOp,
BufferUsages, Extent3d, RenderPassColorAttachment, RenderPassDepthStencilAttachment, StoreOp,
TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
};
@ -114,7 +114,7 @@ impl Plugin for ViewPlugin {
));
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.init_resource::<ViewUniforms>().add_systems(
render_app.add_systems(
Render,
(
prepare_view_targets
@ -127,6 +127,12 @@ impl Plugin for ViewPlugin {
);
}
}
fn finish(&self, app: &mut App) {
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.init_resource::<ViewUniforms>();
}
}
}
/// Configuration resource for [Multi-Sample Anti-Aliasing](https://en.wikipedia.org/wiki/Multisample_anti-aliasing).
@ -415,11 +421,24 @@ pub struct ViewUniform {
render_layers: u32,
}
#[derive(Resource, Default)]
#[derive(Resource)]
pub struct ViewUniforms {
pub uniforms: DynamicUniformBuffer<ViewUniform>,
}
impl FromWorld for ViewUniforms {
fn from_world(world: &mut World) -> Self {
let mut uniforms = DynamicUniformBuffer::default();
let render_device = world.resource::<RenderDevice>();
if render_device.limits().max_storage_buffers_per_shader_stage > 0 {
uniforms.add_usages(BufferUsages::STORAGE);
}
Self { uniforms }
}
}
#[derive(Component)]
pub struct ViewUniformOffset {
pub offset: u32,

View file

@ -0,0 +1,231 @@
//! This example showcases auto exposure,
//! which automatically (but not instantly) adjusts the brightness of the scene in a way that mimics the function of the human eye.
//! Auto exposure requires compute shader capabilities, so it's not available on WebGL.
//!
//! ## Controls
//!
//! | Key Binding | Action |
//! |:-------------------|:---------------------------------------|
//! | `Left` / `Right` | Rotate Camera |
//! | `C` | Toggle Compensation Curve |
//! | `M` | Toggle Metering Mask |
//! | `V` | Visualize Metering Mask |
use bevy::{
core_pipeline::{
auto_exposure::{AutoExposureCompensationCurve, AutoExposurePlugin, AutoExposureSettings},
Skybox,
},
math::{cubic_splines::LinearSpline, primitives::Plane3d, vec2},
prelude::*,
};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_plugins(AutoExposurePlugin)
.add_systems(Startup, setup)
.add_systems(Update, example_control_system)
.run();
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut compensation_curves: ResMut<Assets<AutoExposureCompensationCurve>>,
asset_server: Res<AssetServer>,
) {
let metering_mask = asset_server.load("textures/basic_metering_mask.png");
commands.spawn((
Camera3dBundle {
camera: Camera {
hdr: true,
..default()
},
transform: Transform::from_xyz(1.0, 0.0, 0.0).looking_at(Vec3::ZERO, Vec3::Y),
..default()
},
AutoExposureSettings {
metering_mask: metering_mask.clone(),
..default()
},
Skybox {
image: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"),
brightness: bevy::pbr::light_consts::lux::DIRECT_SUNLIGHT,
},
));
commands.insert_resource(ExampleResources {
basic_compensation_curve: compensation_curves.add(
AutoExposureCompensationCurve::from_curve(LinearSpline::new([
vec2(-4.0, -2.0),
vec2(0.0, 0.0),
vec2(2.0, 0.0),
vec2(4.0, 2.0),
]))
.unwrap(),
),
basic_metering_mask: metering_mask.clone(),
});
let plane = meshes.add(Mesh::from(
Plane3d {
normal: -Dir3::Z,
half_size: Vec2::new(2.0, 0.5),
}
.mesh(),
));
// Build a dimly lit box around the camera, with a slot to see the bright skybox.
for level in -1..=1 {
for side in [-Vec3::X, Vec3::X, -Vec3::Z, Vec3::Z] {
if level == 0 && Vec3::Z == side {
continue;
}
let height = Vec3::Y * level as f32;
commands.spawn(PbrBundle {
mesh: plane.clone(),
material: materials.add(StandardMaterial {
base_color: Color::srgb(
0.5 + side.x * 0.5,
0.75 - level as f32 * 0.25,
0.5 + side.z * 0.5,
),
..default()
}),
transform: Transform::from_translation(side * 2.0 + height)
.looking_at(height, Vec3::Y),
..default()
});
}
}
commands.insert_resource(AmbientLight {
color: Color::WHITE,
brightness: 0.0,
});
commands.spawn(PointLightBundle {
point_light: PointLight {
intensity: 5000.0,
..default()
},
transform: Transform::from_xyz(0.0, 0.0, 0.0),
..default()
});
commands.spawn(ImageBundle {
image: UiImage {
texture: metering_mask,
..default()
},
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
..default()
});
let text_style = TextStyle {
font: asset_server.load("fonts/FiraMono-Medium.ttf"),
font_size: 18.0,
..default()
};
commands.spawn(
TextBundle::from_section(
"Left / Right — Rotate Camera\nC — Toggle Compensation Curve\nM — Toggle Metering Mask\nV — Visualize Metering Mask",
text_style.clone(),
)
.with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
left: Val::Px(10.0),
..default()
}),
);
commands.spawn((
TextBundle::from_section("", text_style).with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(10.0),
right: Val::Px(10.0),
..default()
}),
ExampleDisplay,
));
}
#[derive(Component)]
struct ExampleDisplay;
#[derive(Resource)]
struct ExampleResources {
basic_compensation_curve: Handle<AutoExposureCompensationCurve>,
basic_metering_mask: Handle<Image>,
}
fn example_control_system(
mut camera: Query<(&mut Transform, &mut AutoExposureSettings), With<Camera3d>>,
mut display: Query<&mut Text, With<ExampleDisplay>>,
mut mask_image: Query<&mut Style, With<UiImage>>,
time: Res<Time>,
input: Res<ButtonInput<KeyCode>>,
resources: Res<ExampleResources>,
) {
let (mut camera_transform, mut auto_exposure) = camera.single_mut();
let rotation = if input.pressed(KeyCode::ArrowLeft) {
time.delta_seconds()
} else if input.pressed(KeyCode::ArrowRight) {
-time.delta_seconds()
} else {
0.0
};
camera_transform.rotate_around(Vec3::ZERO, Quat::from_rotation_y(rotation));
if input.just_pressed(KeyCode::KeyC) {
auto_exposure.compensation_curve =
if auto_exposure.compensation_curve == resources.basic_compensation_curve {
Handle::default()
} else {
resources.basic_compensation_curve.clone()
};
}
if input.just_pressed(KeyCode::KeyM) {
auto_exposure.metering_mask =
if auto_exposure.metering_mask == resources.basic_metering_mask {
Handle::default()
} else {
resources.basic_metering_mask.clone()
};
}
mask_image.single_mut().display = if input.pressed(KeyCode::KeyV) {
Display::Flex
} else {
Display::None
};
let mut display = display.single_mut();
display.sections[0].value = format!(
"Compensation Curve: {}\nMetering Mask: {}",
if auto_exposure.compensation_curve == resources.basic_compensation_curve {
"Enabled"
} else {
"Disabled"
},
if auto_exposure.metering_mask == resources.basic_metering_mask {
"Enabled"
} else {
"Disabled"
},
);
}

View file

@ -128,6 +128,7 @@ Example | Description
[Animated Material](../examples/3d/animated_material.rs) | Shows how to animate material properties
[Anti-aliasing](../examples/3d/anti_aliasing.rs) | Compares different anti-aliasing methods
[Atmospheric Fog](../examples/3d/atmospheric_fog.rs) | A scene showcasing the atmospheric fog effect
[Auto Exposure](../examples/3d/auto_exposure.rs) | A scene showcasing auto exposure
[Blend Modes](../examples/3d/blend_modes.rs) | Showcases different blend modes
[Color grading](../examples/3d/color_grading.rs) | Demonstrates color grading
[Deferred Rendering](../examples/3d/deferred_rendering.rs) | Renders meshes with both forward and deferred pipelines