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