Skip to main content

bevy_post_process/auto_exposure/
compensation_curve.rs

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