From d3904200939ee6b2c0ee5f9293ad35ba8315cbfc Mon Sep 17 00:00:00 2001 From: Bram Buurlage Date: Fri, 3 May 2024 19:45:17 +0200 Subject: [PATCH] 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 --- Cargo.toml | 11 + assets/textures/basic_metering_mask.png | Bin 0 -> 447 bytes crates/bevy_core_pipeline/Cargo.toml | 1 + .../src/auto_exposure/auto_exposure.wgsl | 191 +++++++++++++++ .../src/auto_exposure/buffers.rs | 87 +++++++ .../src/auto_exposure/compensation_curve.rs | 226 +++++++++++++++++ .../src/auto_exposure/mod.rs | 131 ++++++++++ .../src/auto_exposure/node.rs | 141 +++++++++++ .../src/auto_exposure/pipeline.rs | 94 +++++++ .../src/auto_exposure/settings.rs | 102 ++++++++ crates/bevy_core_pipeline/src/core_3d/mod.rs | 1 + crates/bevy_core_pipeline/src/lib.rs | 1 + .../src/tonemapping/tonemapping_shared.wgsl | 24 +- .../bind_group_layout_entries.rs | 9 + .../src/render_resource/storage_buffer.rs | 19 ++ crates/bevy_render/src/view/mod.rs | 25 +- examples/3d/auto_exposure.rs | 231 ++++++++++++++++++ examples/README.md | 1 + 18 files changed, 1282 insertions(+), 13 deletions(-) create mode 100644 assets/textures/basic_metering_mask.png create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/auto_exposure.wgsl create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/buffers.rs create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/compensation_curve.rs create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/mod.rs create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/node.rs create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/pipeline.rs create mode 100644 crates/bevy_core_pipeline/src/auto_exposure/settings.rs create mode 100644 examples/3d/auto_exposure.rs diff --git a/Cargo.toml b/Cargo.toml index 27bc62b1a7..616a043558 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/assets/textures/basic_metering_mask.png b/assets/textures/basic_metering_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..16c1051c958a95f05ead4591f26a164bed76af8c GIT binary patch literal 447 zcmV;w0YLtVP)00001b5ch_0Itp) z=>Px$c}YY;RA_liS$ADHn;o`x;fK5Fy z?%{Lr`r8>DfsyVf1_$AL@bO%}*T&7*M)NAWN z9q01>L+ik#tY4$}H(C!uvko-d_cML{tK9!eSx8$i|6Hbw6yVI4ylDs_rL=8ZKF!CM zc-cOs)Vx)xvTQSVLDPAFYO(9m_r#Km`jR@|YqwRnIw0ir8(Y{Ly&G(=|MKn+9K^dn pF=ZaQ9?(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 globals: Globals; + +@group(0) @binding(1) var settings: AutoExposure; + +@group(0) @binding(2) var tex_color: texture_2d; + +@group(0) @binding(3) var tex_mask: texture_2d; + +@group(0) @binding(4) var tex_compensation: texture_1d; + +@group(0) @binding(5) var compensation_curve: CompensationCurve; + +@group(0) @binding(6) var histogram: array, 64>; + +@group(0) @binding(7) var exposure: f32; + +@group(0) @binding(8) var view: View; + +var histogram_shared: array, 64>; + +// For a given color, return the histogram bin index +fn color_to_bin(hdr: vec3) -> 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) -> u32 { + let pos = vec2(coords * vec2(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, + @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(textureDimensions(tex_color)); + let uv = vec2(global_invocation_id.xy) / vec2(dim); + + if global_invocation_id.x < dim.x && global_invocation_id.y < dim.y { + let col = textureLoad(tex_color, vec2(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; +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/buffers.rs b/crates/bevy_core_pipeline/src/auto_exposure/buffers.rs new file mode 100644 index 0000000000..5a6d4330f2 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/buffers.rs @@ -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, +} + +pub(super) struct AutoExposureBuffer { + pub(super) state: StorageBuffer, + pub(super) settings: UniformBuffer, +} + +#[derive(Resource)] +pub(super) struct ExtractedStateBuffers { + changed: Vec<(Entity, AutoExposureSettings)>, + removed: Vec, +} + +pub(super) fn extract_buffers( + mut commands: Commands, + changed: Extract>>, + mut removed: Extract>, +) { + 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, + queue: Res, + mut extracted: ResMut, + mut buffers: ResMut, +) { + 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); + } +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/compensation_curve.rs b/crates/bevy_core_pipeline/src/auto_exposure/compensation_curve.rs new file mode 100644 index 0000000000..880d13d917 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/compensation_curve.rs @@ -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`], 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::::default(); + /// let curve: Handle = 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(curve: T) -> Result + where + T: CubicGenerator, + { + 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, +} + +#[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, SRes); + + fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages { + RenderAssetUsages::RENDER_WORLD + } + + fn prepare_asset( + source: Self::SourceAsset, + (render_device, render_queue): &mut SystemParamItem, + ) -> Result> { + 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, + }) + } +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/mod.rs b/crates/bevy_core_pipeline/src/auto_exposure/mod.rs new file mode 100644 index 0000000000..148e74d063 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/mod.rs @@ -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::::default()) + .register_type::() + .init_asset::() + .register_asset_reflect::(); + app.world_mut() + .resource_mut::>() + .insert(&Handle::default(), AutoExposureCompensationCurve::default()); + + app.register_type::(); + app.add_plugins(ExtractComponentPlugin::::default()); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + + render_app + .init_resource::>() + .init_resource::() + .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::(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::(); + render_app.init_resource::(); + } +} + +impl FromWorld for AutoExposureResources { + fn from_world(world: &mut World) -> Self { + Self { + histogram: world + .resource::() + .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, + mut compute_pipelines: ResMut>, + pipeline: Res, + 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(), + }); + } +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/node.rs b/crates/bevy_core_pipeline/src/auto_exposure/node.rs new file mode 100644 index 0000000000..222efe5c62 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/node.rs @@ -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, + Read, + Read, + Read, + )>, +} + +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::(); + let pipeline = world.resource::(); + let resources = world.resource::(); + + let view_uniforms_resource = world.resource::(); + let view_uniforms = &view_uniforms_resource.uniforms; + let view_uniforms_buffer = view_uniforms.buffer().unwrap(); + + let globals_buffer = world.resource::(); + + let auto_exposure_buffers = world.resource::(); + + 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::(); + let mask = world + .resource::>() + .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::>() + .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(()) + } +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/pipeline.rs b/crates/bevy_core_pipeline/src/auto_exposure/pipeline.rs new file mode 100644 index 0000000000..eacff931c7 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/pipeline.rs @@ -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, +} + +#[derive(Component)] +pub struct ViewAutoExposurePipeline { + pub histogram_pipeline: CachedComputePipelineId, + pub mean_luminance_pipeline: CachedComputePipelineId, + pub compensation_curve: Handle, + pub metering_mask: Handle, +} + +#[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 = 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::(); + + Self { + histogram_layout: render_device.create_bind_group_layout( + "compute histogram bind group", + &BindGroupLayoutEntries::sequential( + ShaderStages::COMPUTE, + ( + uniform_buffer::(false), + uniform_buffer::(false), + texture_2d(TextureSampleType::Float { filterable: false }), + texture_2d(TextureSampleType::Float { filterable: false }), + texture_1d(TextureSampleType::Float { filterable: false }), + uniform_buffer::(false), + storage_buffer_sized(false, NonZeroU64::new(HISTOGRAM_BIN_COUNT * 4)), + storage_buffer_sized(false, NonZeroU64::new(4)), + storage_buffer::(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![], + } + } +} diff --git a/crates/bevy_core_pipeline/src/auto_exposure/settings.rs b/crates/bevy_core_pipeline/src/auto_exposure/settings.rs new file mode 100644 index 0000000000..ccef945ef7 --- /dev/null +++ b/crates/bevy_core_pipeline/src/auto_exposure/settings.rs @@ -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, + + /// 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, + + /// 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, + + /// 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, +} + +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(), + } + } +} diff --git a/crates/bevy_core_pipeline/src/core_3d/mod.rs b/crates/bevy_core_pipeline/src/core_3d/mod.rs index e5cd6c0491..c2747b01eb 100644 --- a/crates/bevy_core_pipeline/src/core_3d/mod.rs +++ b/crates/bevy_core_pipeline/src/core_3d/mod.rs @@ -28,6 +28,7 @@ pub mod graph { Taa, MotionBlur, Bloom, + AutoExposure, Tonemapping, Fxaa, Upscaling, diff --git a/crates/bevy_core_pipeline/src/lib.rs b/crates/bevy_core_pipeline/src/lib.rs index 9627addf07..4ca3a03d27 100644 --- a/crates/bevy_core_pipeline/src/lib.rs +++ b/crates/bevy_core_pipeline/src/lib.rs @@ -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; diff --git a/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl b/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl index 42f0d960b6..929edec67c 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl +++ b/crates/bevy_core_pipeline/src/tonemapping/tonemapping_shared.wgsl @@ -30,7 +30,7 @@ fn sample_current_lut(p: vec3) -> vec3 { 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) -> vec3 { fn rgb_to_ycbcr(col: vec3) -> vec3 { let m = mat3x3( - 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) -> vec3 { fn ycbcr_to_rgb(col: vec3) -> vec3 { let m = mat3x3( - 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) -> vec3 { return a / b; } -fn ACESFitted(color: vec3) -> vec3 { +fn ACESFitted(color: vec3) -> vec3 { var fitted_color = color; // sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT let rgb_to_rrt = mat3x3( 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) -> vec3 { 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, in_color_grading: ColorGrading) -> vec4 { // 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, in_color_grading: ColorGrading) -> vec4 { #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); } diff --git a/crates/bevy_render/src/render_resource/bind_group_layout_entries.rs b/crates/bevy_render/src/render_resource/bind_group_layout_entries.rs index 4d4553399a..bc576bcc5a 100644 --- a/crates/bevy_render/src/render_resource/bind_group_layout_entries.rs +++ b/crates/bevy_render/src/render_resource/bind_group_layout_entries.rs @@ -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, diff --git a/crates/bevy_render/src/render_resource/storage_buffer.rs b/crates/bevy_render/src/render_resource/storage_buffer.rs index d30a002c88..1212b02ba7 100644 --- a/crates/bevy_render/src/render_resource/storage_buffer.rs +++ b/crates/bevy_render/src/render_resource/storage_buffer.rs @@ -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 StorageBuffer { } } +impl<'a, T: ShaderType + WriteInto> IntoBinding<'a> for &'a StorageBuffer { + #[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 DynamicStorageBuffer { self.scratch.set_offset(0); } } + +impl<'a, T: ShaderType + WriteInto> IntoBinding<'a> for &'a DynamicStorageBuffer { + #[inline] + fn into_binding(self) -> BindingResource<'a> { + self.binding().unwrap() + } +} diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index 83e934e78d..d211ef71b7 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -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::().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::(); + } + } } /// 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, } +impl FromWorld for ViewUniforms { + fn from_world(world: &mut World) -> Self { + let mut uniforms = DynamicUniformBuffer::default(); + + let render_device = world.resource::(); + 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, diff --git a/examples/3d/auto_exposure.rs b/examples/3d/auto_exposure.rs new file mode 100644 index 0000000000..ff43dd66f6 --- /dev/null +++ b/examples/3d/auto_exposure.rs @@ -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>, + mut materials: ResMut>, + mut compensation_curves: ResMut>, + asset_server: Res, +) { + 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, + basic_metering_mask: Handle, +} + +fn example_control_system( + mut camera: Query<(&mut Transform, &mut AutoExposureSettings), With>, + mut display: Query<&mut Text, With>, + mut mask_image: Query<&mut Style, With>, + time: Res