bevy_core_pipeline/auto_exposure/
compensation_curve.rs

1use bevy_asset::prelude::*;
2use bevy_ecs::system::{lifetimeless::SRes, SystemParamItem};
3use bevy_math::{cubic_splines::CubicGenerator, FloatExt, Vec2};
4use bevy_reflect::prelude::*;
5use bevy_render::{
6    render_asset::{RenderAsset, RenderAssetUsages},
7    render_resource::{
8        Extent3d, ShaderType, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
9        TextureView, UniformBuffer,
10    },
11    renderer::{RenderDevice, RenderQueue},
12};
13use thiserror::Error;
14
15const LUT_SIZE: usize = 256;
16
17/// An auto exposure compensation curve.
18/// This curve is used to map the average log luminance of a scene to an
19/// exposure compensation value, to allow for fine control over the final exposure.
20#[derive(Asset, Reflect, Debug, Clone)]
21#[reflect(Default, Clone)]
22pub struct AutoExposureCompensationCurve {
23    /// The minimum log luminance value in the curve. (the x-axis)
24    min_log_lum: f32,
25    /// The maximum log luminance value in the curve. (the x-axis)
26    max_log_lum: f32,
27    /// The minimum exposure compensation value in the curve. (the y-axis)
28    min_compensation: f32,
29    /// The maximum exposure compensation value in the curve. (the y-axis)
30    max_compensation: f32,
31    /// The lookup table for the curve. Uploaded to the GPU as a 1D texture.
32    /// Each value in the LUT is a `u8` representing a normalized exposure compensation value:
33    /// * `0` maps to `min_compensation`
34    /// * `255` maps to `max_compensation`
35    ///
36    /// The position in the LUT corresponds to the normalized log luminance value.
37    /// * `0` maps to `min_log_lum`
38    /// * `LUT_SIZE - 1` maps to `max_log_lum`
39    lut: [u8; LUT_SIZE],
40}
41
42/// Various errors that can occur when constructing an [`AutoExposureCompensationCurve`].
43#[derive(Error, Debug)]
44pub enum AutoExposureCompensationCurveError {
45    /// The curve couldn't be built in the first place.
46    #[error("curve could not be constructed from the given data")]
47    InvalidCurve,
48    /// A discontinuity was found in the curve.
49    #[error("discontinuity found between curve segments")]
50    DiscontinuityFound,
51    /// The curve is not monotonically increasing on the x-axis.
52    #[error("curve is not monotonically increasing on the x-axis")]
53    NotMonotonic,
54}
55
56impl Default for AutoExposureCompensationCurve {
57    fn default() -> Self {
58        Self {
59            min_log_lum: 0.0,
60            max_log_lum: 0.0,
61            min_compensation: 0.0,
62            max_compensation: 0.0,
63            lut: [0; LUT_SIZE],
64        }
65    }
66}
67
68impl AutoExposureCompensationCurve {
69    const SAMPLES_PER_SEGMENT: usize = 64;
70
71    /// Build an [`AutoExposureCompensationCurve`] from a [`CubicGenerator<Vec2>`], where:
72    /// - x represents the average log luminance of the scene in EV-100;
73    /// - y represents the exposure compensation value in F-stops.
74    ///
75    /// # Errors
76    ///
77    /// If the curve is not monotonically increasing on the x-axis,
78    /// returns [`AutoExposureCompensationCurveError::NotMonotonic`].
79    ///
80    /// If a discontinuity is found between curve segments,
81    /// returns [`AutoExposureCompensationCurveError::DiscontinuityFound`].
82    ///
83    /// # Example
84    ///
85    /// ```
86    /// # use bevy_asset::prelude::*;
87    /// # use bevy_math::vec2;
88    /// # use bevy_math::cubic_splines::*;
89    /// # use bevy_core_pipeline::auto_exposure::AutoExposureCompensationCurve;
90    /// # let mut compensation_curves = Assets::<AutoExposureCompensationCurve>::default();
91    /// let curve: Handle<AutoExposureCompensationCurve> = compensation_curves.add(
92    ///     AutoExposureCompensationCurve::from_curve(LinearSpline::new([
93    ///         vec2(-4.0, -2.0),
94    ///         vec2(0.0, 0.0),
95    ///         vec2(2.0, 0.0),
96    ///         vec2(4.0, 2.0),
97    ///     ]))
98    ///     .unwrap()
99    /// );
100    /// ```
101    pub fn from_curve<T>(curve: T) -> Result<Self, AutoExposureCompensationCurveError>
102    where
103        T: CubicGenerator<Vec2>,
104    {
105        let Ok(curve) = curve.to_curve() else {
106            return Err(AutoExposureCompensationCurveError::InvalidCurve);
107        };
108
109        let min_log_lum = curve.position(0.0).x;
110        let max_log_lum = curve.position(curve.segments().len() as f32).x;
111        let log_lum_range = max_log_lum - min_log_lum;
112
113        let mut lut = [0.0; LUT_SIZE];
114
115        let mut previous = curve.position(0.0);
116        let mut min_compensation = previous.y;
117        let mut max_compensation = previous.y;
118
119        for segment in curve {
120            if segment.position(0.0) != previous {
121                return Err(AutoExposureCompensationCurveError::DiscontinuityFound);
122            }
123
124            for i in 1..Self::SAMPLES_PER_SEGMENT {
125                let current = segment.position(i as f32 / (Self::SAMPLES_PER_SEGMENT - 1) as f32);
126
127                if current.x < previous.x {
128                    return Err(AutoExposureCompensationCurveError::NotMonotonic);
129                }
130
131                // Find the range of LUT entries that this line segment covers.
132                let (lut_begin, lut_end) = (
133                    ((previous.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
134                    ((current.x - min_log_lum) / log_lum_range) * (LUT_SIZE - 1) as f32,
135                );
136                let lut_inv_range = 1.0 / (lut_end - lut_begin);
137
138                // Iterate over all LUT entries whose pixel centers fall within the current segment.
139                #[expect(
140                    clippy::needless_range_loop,
141                    reason = "This for-loop also uses `i` to calculate a value `t`."
142                )]
143                for i in lut_begin.ceil() as usize..=lut_end.floor() as usize {
144                    let t = (i as f32 - lut_begin) * lut_inv_range;
145                    lut[i] = previous.y.lerp(current.y, t);
146                    min_compensation = min_compensation.min(lut[i]);
147                    max_compensation = max_compensation.max(lut[i]);
148                }
149
150                previous = current;
151            }
152        }
153
154        let compensation_range = max_compensation - min_compensation;
155
156        Ok(Self {
157            min_log_lum,
158            max_log_lum,
159            min_compensation,
160            max_compensation,
161            lut: if compensation_range > 0.0 {
162                let scale = 255.0 / compensation_range;
163                lut.map(|f: f32| ((f - min_compensation) * scale) as u8)
164            } else {
165                [0; LUT_SIZE]
166            },
167        })
168    }
169}
170
171/// The GPU-representation of an [`AutoExposureCompensationCurve`].
172/// Consists of a [`TextureView`] with the curve's data,
173/// and a [`UniformBuffer`] with the curve's extents.
174pub struct GpuAutoExposureCompensationCurve {
175    pub(super) texture_view: TextureView,
176    pub(super) extents: UniformBuffer<AutoExposureCompensationCurveUniform>,
177}
178
179#[derive(ShaderType, Clone, Copy)]
180pub(super) struct AutoExposureCompensationCurveUniform {
181    min_log_lum: f32,
182    inv_log_lum_range: f32,
183    min_compensation: f32,
184    compensation_range: f32,
185}
186
187impl RenderAsset for GpuAutoExposureCompensationCurve {
188    type SourceAsset = AutoExposureCompensationCurve;
189    type Param = (SRes<RenderDevice>, SRes<RenderQueue>);
190
191    fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages {
192        RenderAssetUsages::RENDER_WORLD
193    }
194
195    fn prepare_asset(
196        source: Self::SourceAsset,
197        _: AssetId<Self::SourceAsset>,
198        (render_device, render_queue): &mut SystemParamItem<Self::Param>,
199    ) -> Result<Self, bevy_render::render_asset::PrepareAssetError<Self::SourceAsset>> {
200        let texture = render_device.create_texture_with_data(
201            render_queue,
202            &TextureDescriptor {
203                label: None,
204                size: Extent3d {
205                    width: LUT_SIZE as u32,
206                    height: 1,
207                    depth_or_array_layers: 1,
208                },
209                mip_level_count: 1,
210                sample_count: 1,
211                dimension: TextureDimension::D1,
212                format: TextureFormat::R8Unorm,
213                usage: TextureUsages::COPY_DST | TextureUsages::TEXTURE_BINDING,
214                view_formats: &[TextureFormat::R8Unorm],
215            },
216            Default::default(),
217            &source.lut,
218        );
219
220        let texture_view = texture.create_view(&Default::default());
221
222        let mut extents = UniformBuffer::from(AutoExposureCompensationCurveUniform {
223            min_log_lum: source.min_log_lum,
224            inv_log_lum_range: 1.0 / (source.max_log_lum - source.min_log_lum),
225            min_compensation: source.min_compensation,
226            compensation_range: source.max_compensation - source.min_compensation,
227        });
228
229        extents.write_buffer(render_device, render_queue);
230
231        Ok(GpuAutoExposureCompensationCurve {
232            texture_view,
233            extents,
234        })
235    }
236}