Skip to main content

bevy_light/
atmosphere.rs

1//! Provides types to specify atmosphere lighting, scattering terms, etc.
2
3use alloc::{borrow::Cow, sync::Arc};
4use bevy_asset::{Asset, AssetEvent, AssetId, Handle};
5use bevy_color::{ColorToComponents, Gray, LinearRgba};
6use bevy_ecs::{
7    component::Component,
8    lifecycle::HookContext,
9    message::MessageReader,
10    system::{Res, ResMut},
11    template::FromTemplate,
12    world::DeferredWorld,
13};
14use bevy_image::Image;
15use bevy_math::curve::{FunctionCurve, Interval, SampleAutoCurve};
16use bevy_math::{ops, Curve, FloatPow, Vec3};
17use bevy_platform::collections::HashSet;
18use bevy_reflect::TypePath;
19use bevy_transform::components::GlobalTransform;
20use core::f32::{self, consts::PI};
21use smallvec::SmallVec;
22use wgpu_types::TextureFormat;
23
24/// Atmosphere for one planet. The entity's [`GlobalTransform`] is the planet center in world space.
25///
26/// Add `AtmosphereSettings` to each 3D camera that should use it, the nearest atmosphere is used for rendering.
27///
28/// If [`GlobalTransform`] is still [`Default`] when this component is first added, it is placed `radius` units directly below the origin on the `Y` axis, so that the planet's normal is roughly `Vec3::Y` around the origin, likely where your camera/scene is located. Unless you're making a game set in space, this is probably what you want. Otherwise, feel free to override this default by setting a transform manually.
29///
30/// The scale on [`GlobalTransform`] rescales the planet in world space. Tune it with the radius offset
31/// when your scene uses other units, like kilometer-sized scenes.
32#[derive(Clone, Component, FromTemplate)]
33#[require(GlobalTransform)]
34#[component(on_add = set_default_transform)]
35pub struct Atmosphere {
36    /// Radius of the planet
37    ///
38    /// units: m
39    pub inner_radius: f32,
40
41    /// Radius at which we consider the atmosphere to 'end' for our
42    /// calculations (from center of planet)
43    ///
44    /// units: m
45    pub outer_radius: f32,
46
47    /// An approximation of the average albedo (or color, roughly) of the
48    /// planet's surface. This is used when calculating multiscattering.
49    ///
50    /// units: N/A
51    pub ground_albedo: Vec3,
52
53    /// A handle to a [`ScatteringMedium`], which describes the substance
54    /// of the atmosphere and how it scatters light.
55    pub medium: Handle<ScatteringMedium>,
56}
57
58fn set_default_transform(mut world: DeferredWorld<'_>, HookContext { entity, .. }: HookContext) {
59    let Some(inner_radius) = world.get::<Atmosphere>(entity).map(|a| a.inner_radius) else {
60        unreachable!("on_add hooks guarantee the component is present");
61    };
62
63    if let Some(mut transform) = world.get_mut::<GlobalTransform>(entity)
64        && *transform == GlobalTransform::default()
65    {
66        *transform = GlobalTransform::from_translation(-Vec3::Y * inner_radius);
67    }
68}
69
70impl Atmosphere {
71    /// An atmosphere like that of earth. Use this with a [`ScatteringMedium::earth`] handle.
72    pub fn earth(medium: Handle<ScatteringMedium>) -> Self {
73        const EARTH_INNER_RADIUS: f32 = 6_360_000.0;
74        const EARTH_OUTER_RADIUS: f32 = 6_460_000.0;
75        const EARTH_ALBEDO: Vec3 = Vec3::splat(0.3);
76        Self {
77            inner_radius: EARTH_INNER_RADIUS,
78            outer_radius: EARTH_OUTER_RADIUS,
79            ground_albedo: EARTH_ALBEDO,
80            medium,
81        }
82    }
83
84    /// Martian atmosphere; use this with a [`ScatteringMedium::mars`] handle.
85    ///
86    /// Mean radius 3389.50 ± 0.2 km [Seidelmann et al. 2007, Table 4].
87    ///
88    /// [Seidelmann et al. 2007, Table 4]: https://doi.org/10.1007/s10569-007-9072-y
89    pub fn mars(medium: Handle<ScatteringMedium>) -> Self {
90        const MARS_INNER_RADIUS: f32 = 3_389_500.0;
91        const MARS_OUTER_RADIUS: f32 = 3_509_500.0;
92        const MARS_ALBEDO: Vec3 = Vec3::splat(0.1);
93        Self {
94            inner_radius: MARS_INNER_RADIUS,
95            outer_radius: MARS_OUTER_RADIUS,
96            ground_albedo: MARS_ALBEDO,
97            medium,
98        }
99    }
100}
101
102/// An asset that defines how a material scatters light.
103///
104/// In order to calculate how light passes through a medium,
105/// you need three pieces of information:
106/// - how much light the medium *absorbs* per unit length
107/// - how much light the medium *scatters* per unit length
108/// - what *directions* the medium is likely to scatter light in.
109///
110/// The first two are fairly simple, and are sometimes referred to together
111/// (accurately enough) as the medium's [optical density].
112///
113/// The last, defined by a [phase function], is the most important in creating
114/// the look of a medium. Our brains are very good at noticing (if unconsciously)
115/// that a dust storm scatters light differently than a rain cloud, for example.
116/// See the docs on [`PhaseFunction`] for more info.
117///
118/// In reality, media are often composed of multiple elements that scatter light
119/// independently, for Earth's atmosphere is composed of the gas itself, but also
120/// suspended dust and particulate. These each scatter light differently, and are
121/// distributed in different amounts at different altitudes. In a [`ScatteringMedium`],
122/// these are each represented by a [`ScatteringTerm`]
123///
124/// ## Technical Details
125///
126/// A [`ScatteringMedium`] is represented on the GPU by a set of two LUTs, which
127/// are re-created every time the asset is modified. See the docs on
128/// `bevy_pbr::GpuScatteringMedium` for more info.
129///
130/// [optical density]: https://en.wikipedia.org/wiki/Optical_Density
131/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
132#[derive(TypePath, Asset, Clone)]
133pub struct ScatteringMedium {
134    /// An optional label for the medium, used when creating the LUTs on the GPU.
135    pub label: Option<Cow<'static, str>>,
136    /// The resolution at which to sample the falloff distribution of each
137    /// scattering term. Custom or more detailed distributions may benefit
138    /// from a higher value, at the cost of more memory use.
139    pub falloff_resolution: u32,
140    /// The resolution at which to sample the phase function of each scattering
141    /// term. Custom or more detailed phase functions may benefit from a higher
142    /// value, at the cost of more memory use.
143    pub phase_resolution: u32,
144    /// The list of [`ScatteringTerm`]s that compose this [`ScatteringMedium`]
145    pub terms: SmallVec<[ScatteringTerm; 1]>,
146}
147
148impl Default for ScatteringMedium {
149    fn default() -> Self {
150        ScatteringMedium::earth(256, 256)
151    }
152}
153
154impl ScatteringMedium {
155    /// Returns a scattering medium with a default label and the
156    /// specified scattering terms.
157    pub fn new(
158        falloff_resolution: u32,
159        phase_resolution: u32,
160        terms: impl IntoIterator<Item = ScatteringTerm>,
161    ) -> Self {
162        Self {
163            label: None,
164            falloff_resolution,
165            phase_resolution,
166            terms: terms.into_iter().collect(),
167        }
168    }
169
170    /// Consumes and returns this scattering medium with a new label.
171    pub fn with_label(self, label: impl Into<Cow<'static, str>>) -> Self {
172        Self {
173            label: Some(label.into()),
174            ..self
175        }
176    }
177
178    /// Consumes and returns this scattering medium with each scattering terms'
179    /// densities multiplied by `multiplier`.
180    pub fn with_density_multiplier(mut self, multiplier: f32) -> Self {
181        self.terms.iter_mut().for_each(|term| {
182            term.absorption *= multiplier;
183            term.scattering *= multiplier;
184        });
185
186        self
187    }
188
189    /// Returns a scattering medium representing an earth atmosphere.
190    ///
191    /// Uses physically-based scale heights from Earth's atmosphere, assuming
192    /// a 60 km atmosphere height:
193    /// - Rayleigh (molecular) scattering: 8 km scale height
194    /// - Mie (aerosol) scattering: 1.2 km scale height
195    pub fn earth(falloff_resolution: u32, phase_resolution: u32) -> Self {
196        Self::new(
197            falloff_resolution,
198            phase_resolution,
199            [
200                // Rayleigh scattering Term
201                ScatteringTerm {
202                    absorption: Vec3::ZERO,
203                    scattering: Vec3::new(5.802e-6, 13.558e-6, 33.100e-6),
204                    falloff: Falloff::Exponential { scale: 8.0 / 60.0 },
205                    phase: PhaseFunction::Rayleigh,
206                },
207                // Mie scattering Term
208                ScatteringTerm {
209                    absorption: Vec3::splat(3.996e-6),
210                    scattering: Vec3::splat(0.444e-6),
211                    falloff: Falloff::Exponential { scale: 1.2 / 60.0 },
212                    phase: PhaseFunction::Mie { asymmetry: 0.8 },
213                },
214                // Ozone scattering Term
215                ScatteringTerm {
216                    absorption: Vec3::new(0.650e-6, 1.881e-6, 0.085e-6),
217                    scattering: Vec3::ZERO,
218                    falloff: Falloff::Tent {
219                        center: 0.75,
220                        width: 0.3,
221                    },
222                    phase: PhaseFunction::Isotropic,
223                },
224            ],
225        )
226        .with_label("earth_atmosphere")
227    }
228
229    /// Returns a scattering medium representing a Martian atmosphere [Schneegans et al. 2024].
230    ///
231    /// Constituents:
232    /// - Rayleigh: carbon dioxide
233    /// - Dust: wavelength-dependent Mie phase, double-exponential density
234    ///
235    /// Requires an Nx1 chromatic phase texture for the dust term.
236    ///
237    /// [Schneegans et al. 2024]: https://doi.org/10.1111/cgf.15010
238    pub fn mars(falloff_resolution: u32, phase_resolution: u32, dust_phase: Handle<Image>) -> Self {
239        const MARS_ATMOSPHERE_HEIGHT: f32 = 120_000.0;
240        const RAYLEIGH_SCALE_HEIGHT: f32 = 8_000.0;
241
242        // Dust density, from Fig. 8.
243        let dust_falloff = Falloff::from_curve(FunctionCurve::new(Interval::UNIT, |p| {
244            let h = (1.0 - p) * MARS_ATMOSPHERE_HEIGHT;
245            0.75 * ops::exp(1.0 - ops::exp(h / 4_000.0))
246                + 0.25 * ops::exp(1.0 - ops::exp(h / 20_000.0))
247        }));
248
249        Self::new(
250            falloff_resolution,
251            phase_resolution,
252            [
253                ScatteringTerm {
254                    // Table 1: Eq. 3 with delta=0.09, refractive index=1.00000337
255                    absorption: Vec3::ZERO,
256                    scattering: Vec3::new(9.91e-8, 2.32e-7, 5.65e-7),
257                    falloff: Falloff::Exponential {
258                        scale: RAYLEIGH_SCALE_HEIGHT / MARS_ATMOSPHERE_HEIGHT,
259                    },
260                    phase: PhaseFunction::Rayleigh,
261                },
262                ScatteringTerm {
263                    // Table 1: number density=5×10^9 m^-3, Mie Theory
264                    absorption: Vec3::new(1.26e-6, 5.25e-6, 9.33e-6), // beta_abs per channel
265                    scattering: Vec3::new(30.67e-6, 25.39e-6, 20.93e-6), // beta_sca per channel
266                    falloff: dust_falloff,
267                    phase: PhaseFunction::from_chromatic_texture(dust_phase),
268                },
269            ],
270        )
271        .with_label("mars_atmosphere")
272    }
273}
274
275/// An individual element of a [`ScatteringMedium`].
276///
277/// A [`ScatteringMedium`] can be built out of a number of simpler [`ScatteringTerm`]s,
278/// which correspond to an individual element of the medium. For example, Earth's
279/// atmosphere would be (roughly) composed of two [`ScatteringTerm`]s: the atmospheric
280/// gases themselves, which extend to the edge of space, and suspended dust particles,
281/// which are denser but lie closer to the ground.
282#[derive(Default, Clone)]
283pub struct ScatteringTerm {
284    /// This term's optical absorption density, or how much light of each wavelength
285    /// it absorbs per meter.
286    ///
287    /// units: m^-1
288    pub absorption: Vec3,
289    /// This term's optical scattering density, or how much light of each wavelength
290    /// it scatters per meter.
291    ///
292    /// units: m^-1
293    pub scattering: Vec3,
294    /// This term's falloff distribution. See the docs on [`Falloff`] for more info.
295    pub falloff: Falloff,
296    /// This term's [phase function], which determines the character of how it
297    /// scatters light. See the docs on [`PhaseFunction`] for more info.
298    ///
299    /// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
300    pub phase: PhaseFunction,
301}
302
303/// Describes how the media in a [`ScatteringTerm`] is distributed.
304///
305/// This is closely related to the optical density values [`ScatteringTerm::absorption`] and
306/// [`ScatteringTerm::scattering`]. Most media aren't the same density everywhere;
307/// near the edge of space Earth's atmosphere is much less dense, and it absorbs
308/// and scatters less light.
309///
310/// [`Falloff`] determines how the density of a medium changes as a function of
311/// an abstract "falloff parameter" `p`. `p = 1` denotes where the medium is the
312/// densest, i.e. at the surface of the Earth, `p = 0` denotes where the medium
313/// fades away completely, i.e. at the edge of space, and values between scale
314/// linearly with distance, so `p = 0.5` would be halfway between the surface
315/// and the edge of space.
316///
317/// When processing a [`ScatteringMedium`], the `absorption` and `scattering` values
318/// for each [`ScatteringTerm`] are multiplied by the value of the falloff function, `f(p)`.
319#[derive(Default, Clone)]
320pub enum Falloff {
321    /// A simple linear falloff function, which essentially
322    /// passes the falloff parameter through unchanged.
323    ///
324    /// f(1) = 1
325    /// f(0) = 0
326    /// f(p) = p
327    #[default]
328    Linear,
329    /// An exponential falloff function parametrized by a proportional scale.
330    /// When paired with an absolute "falloff distance" like the distance from
331    /// Earth's surface to the edge of space, this is analogous to the "height
332    /// scale" value common in atmospheric scattering literature, though it will
333    /// diverge from this for large or negative `scale` values.
334    ///
335    /// f(1) = 1
336    /// f(0) = 0
337    /// f(p) = (e^((1-p)/s) - e^(1/s))/(e - e^(1/s))
338    Exponential {
339        /// The "scale" of the exponential falloff. Values closer to zero will
340        /// produce steeper falloff, and values farther from zero will produce
341        /// gentler falloff, approaching linear falloff as scale goes to `+-∞`.
342        ///
343        /// Negative values change the *concavity* of the falloff function:
344        /// rather than an initial narrow region of steep falloff followed by a
345        /// wide region of gentle falloff, there will be an initial wide region
346        /// of gentle falloff followed by a narrow region of steep falloff.
347        ///
348        /// domain: (-∞, ∞)
349        ///
350        /// NOTE, this function is not defined when `scale == 0`.
351        /// In that case, it will fall back to linear falloff.
352        scale: f32,
353    },
354    /// A tent-shaped falloff function, which produces a triangular
355    /// peak at the center and linearly falls off to either side.
356    ///
357    /// f(`center`) = 1
358    /// f(`center` +- `width` / 2) = 0
359    Tent {
360        /// The center of the tent function peak
361        ///
362        /// domain: [0, 1]
363        center: f32,
364        /// The total width of the tent function peak
365        ///
366        /// domain: [0, 1]
367        width: f32,
368    },
369    /// A falloff function defined by a custom curve.
370    ///
371    /// domain: [0, 1],
372    /// range: [0, 1],
373    Curve(Arc<dyn Curve<f32> + Send + Sync>),
374}
375
376impl Falloff {
377    /// Returns a falloff function corresponding to a custom curve.
378    pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
379        Self::Curve(Arc::new(curve))
380    }
381
382    /// Evaluates the falloff function at the given coordinate.
383    pub fn sample(&self, p: f32) -> f32 {
384        match self {
385            Falloff::Linear => p,
386            Falloff::Exponential { scale } => {
387                // fill discontinuity at scale == 0,
388                // arbitrarily choose linear falloff
389                if *scale == 0.0 {
390                    p
391                } else {
392                    let s = -1.0 / scale;
393                    let exp_p_s = ops::exp((1.0 - p) * s);
394                    let exp_s = ops::exp(s);
395                    (exp_p_s - exp_s) / (1.0 - exp_s)
396                }
397            }
398            Falloff::Tent { center, width } => (1.0 - (p - center).abs() / (0.5 * width)).max(0.0),
399            Falloff::Curve(curve) => curve.sample(p).unwrap_or(0.0),
400        }
401    }
402}
403
404/// Describes how a [`ScatteringTerm`] scatters light in different directions.
405///
406/// A [phase function] is a function `f: [-1, 1] -> [0, ∞)`, symmetric about `x=0`
407/// whose input is the cosine of the angle between an incoming light direction and
408/// and outgoing light direction, and whose output is the proportion of the incoming
409/// light that is actually scattered in that direction.
410///
411/// The phase function has an important effect on the "look" of a medium in a scene.
412/// Media consisting of particles of a different size or shape scatter light differently,
413/// and our brains are very good at telling the difference. A dust cloud, which might
414/// correspond roughly to `PhaseFunction::Mie { asymmetry: 0.8 }`, looks quite different
415/// from the rest of the sky (atmospheric gases), which correspond to `PhaseFunction::Rayleigh`
416///
417/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
418#[derive(Clone)]
419pub enum PhaseFunction {
420    /// A phase function that scatters light evenly in all directions.
421    Isotropic,
422
423    /// A phase function representing [Rayleigh scattering].
424    ///
425    /// Rayleigh scattering occurs naturally for particles much smaller than
426    /// the wavelengths of visible light, such as gas molecules in the atmosphere.
427    /// It's generally wavelength-dependent, where shorter wavelengths are scattered
428    /// more strongly, so [scattering](ScatteringTerm::scattering) should have
429    /// higher values for blue than green and green than red. Particles that
430    /// participate in Rayleigh scattering don't absorb any light, either.
431    ///
432    /// [Rayleigh scattering]: https://en.wikipedia.org/wiki/Rayleigh_scattering
433    Rayleigh,
434
435    /// The [Henyey-Greenstein phase function], which approximates [Mie scattering].
436    ///
437    /// Mie scattering occurs naturally for spherical particles of dust
438    /// and aerosols roughly the same size as the wavelengths of visible light,
439    /// so it's useful for representing dust or sea spray. It's generally
440    /// wavelength-independent, so [absorption](ScatteringTerm::absorption)
441    /// and [scattering](ScatteringTerm::scattering) should be set to a greyscale value.
442    ///
443    /// [Mie scattering]: https://en.wikipedia.org/wiki/Mie_scattering
444    /// [Henyey-Greenstein phase function]: https://www.oceanopticsbook.info/view/scattering/level-2/the-henyey-greenstein-phase-function
445    Mie {
446        /// Whether the Mie scattering function is biased towards scattering
447        /// light forwards (asymmetry > 0) or backwards (asymmetry < 0).
448        ///
449        /// domain: [-1, 1]
450        asymmetry: f32,
451    },
452
453    /// A phase function defined by a custom curve, where the input
454    /// is the cosine of the angle between the incoming light ray
455    /// and the scattered light ray, and the output is the fraction
456    /// of the incoming light scattered in that direction.
457    ///
458    /// Note: it's important for photorealism that the phase function
459    /// be *energy conserving*, meaning that in total no more light can
460    /// be scattered than actually entered the medium. For this to be
461    /// the case, the integral of the phase function over its domain must
462    /// be equal to 1/2π.
463    ///
464    ///   1
465    /// ∫   p(x) dx = 1/2π
466    ///  -1
467    ///
468    /// domain: [-1, 1]
469    /// range: [0, 1]
470    Curve(Arc<dyn Curve<f32> + Send + Sync>),
471
472    /// A wavelength-dependent (chromatic) phase function returning linear RGB
473    /// phase values per channel. Used when the phase varies with wavelength,
474    /// for instance Mie scattering on Martian dust.
475    ///
476    /// Energy conservation applies per channel. Each of the channels must independently
477    /// satisfy the equation above.
478    ///
479    /// domain: [-1, 1]
480    /// range: [0, 1] per channel (R, G, B)
481    ChromaticCurve(Arc<dyn Curve<LinearRgba> + Send + Sync>),
482
483    /// A chromatic phase function sampled from an N×1 texture (R,G,B per column).
484    ///
485    /// Use `Rgba32Float` format. Columns map linearly to cos θ. The LUT spans the
486    /// scattering hemisphere: first column is back-scattering (θ = π), last is
487    /// forward-scattering (θ = 0).
488    /// Resolved to [`PhaseFunction::ChromaticCurve`] when the image loads.
489    ///
490    /// To generate your own, compute the phase function using Mie theory (for example,
491    /// using the `miepython` package) and write it as a 32-bit float texture (`OpenEXR` or `KTX2`).
492    ChromaticTexture(Handle<Image>),
493}
494
495impl PhaseFunction {
496    /// A phase function defined by a custom curve.
497    pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
498        Self::Curve(Arc::new(curve))
499    }
500
501    /// A wavelength-dependent phase function from a curve that returns linear RGBA.
502    pub fn from_chromatic_curve(curve: impl Curve<LinearRgba> + Send + Sync + 'static) -> Self {
503        Self::ChromaticCurve(Arc::new(curve))
504    }
505
506    /// A chromatic phase function from an N×1 texture. Resolved to a curve when loaded.
507    pub fn from_chromatic_texture(image: Handle<Image>) -> Self {
508        Self::ChromaticTexture(image)
509    }
510
511    /// Samples the phase function at the given value in [-1, 1].
512    ///
513    /// Returns `Some(LinearRgba)` with per-channel phase values (scalar phases use R=G=B).
514    /// Returns `None` when the phase is not yet available (e.g. [`PhaseFunction::ChromaticTexture`] before load).
515    pub fn sample(&self, neg_l_dot_v: f32) -> Option<LinearRgba> {
516        const FRAC_4_PI: f32 = 0.25 / PI;
517        const FRAC_3_16_PI: f32 = 0.1875 / PI;
518        match self {
519            PhaseFunction::Isotropic => Some(LinearRgba::gray(FRAC_4_PI)),
520            PhaseFunction::Rayleigh => Some(LinearRgba::gray(
521                FRAC_3_16_PI * (1.0 + neg_l_dot_v * neg_l_dot_v),
522            )),
523            PhaseFunction::Mie { asymmetry } => {
524                let denom = 1.0 + asymmetry.squared() - 2.0 * asymmetry * neg_l_dot_v;
525                Some(LinearRgba::from_vec3(Vec3::splat(
526                    FRAC_4_PI * (1.0 - asymmetry.squared()) / (denom * denom.sqrt()),
527                )))
528            }
529            PhaseFunction::Curve(curve) => curve
530                .sample(neg_l_dot_v)
531                .map(LinearRgba::gray)
532                .or(Some(LinearRgba::gray(0.0))),
533            PhaseFunction::ChromaticCurve(curve) => {
534                curve.sample(neg_l_dot_v).or(Some(LinearRgba::gray(0.0)))
535            }
536            PhaseFunction::ChromaticTexture(_) => None,
537        }
538    }
539}
540
541impl Default for PhaseFunction {
542    fn default() -> Self {
543        Self::Mie { asymmetry: 0.8 }
544    }
545}
546
547/// Resolves [`PhaseFunction::ChromaticTexture`] to [`PhaseFunction::ChromaticCurve`] when the image loads.
548pub fn extract_chromatic_phase_textures(
549    mut reader: MessageReader<AssetEvent<Image>>,
550    images: Res<bevy_asset::Assets<Image>>,
551    mut scattering_media: ResMut<bevy_asset::Assets<ScatteringMedium>>,
552) {
553    let extract_ids: HashSet<AssetId<Image>> = scattering_media
554        .iter()
555        .flat_map(|(_, m)| m.terms.iter())
556        .filter_map(|t| {
557            let PhaseFunction::ChromaticTexture(h) = &t.phase else {
558                return None;
559            };
560            Some(h.id())
561        })
562        .collect();
563
564    for event in reader.read() {
565        let AssetEvent::LoadedWithDependencies { id } = event else {
566            continue;
567        };
568        if !extract_ids.contains(id) {
569            continue;
570        }
571
572        let Some(image) = images.get(*id) else {
573            continue;
574        };
575        if image.texture_descriptor.format != TextureFormat::Rgba32Float {
576            continue;
577        }
578
579        let width = image.texture_descriptor.size.width;
580        if width == 0 {
581            continue;
582        }
583
584        let Some(samples): Option<Vec<LinearRgba>> = (0..width)
585            .map(|x| image.get_color_at_1d(x).ok().map(|c| c.to_linear()))
586            .collect()
587        else {
588            continue;
589        };
590
591        let Ok(curve) = SampleAutoCurve::new(
592            Interval::new(-1.0, 1.0).expect("[-1, 1] valid for cos θ"),
593            samples,
594        ) else {
595            continue;
596        };
597
598        let new_phase = PhaseFunction::from_chromatic_curve(curve);
599
600        for (_id, medium) in scattering_media.iter_mut() {
601            for term in medium.terms.iter_mut() {
602                if let PhaseFunction::ChromaticTexture(handle) = &term.phase
603                    && handle.id() == *id
604                {
605                    term.phase = new_phase.clone();
606                }
607            }
608        }
609    }
610}