bevy_pbr/medium.rs
1use alloc::{borrow::Cow, sync::Arc};
2use core::f32::{self, consts::PI};
3
4use bevy_app::{App, Plugin};
5use bevy_asset::{Asset, AssetApp, AssetId};
6use bevy_ecs::{
7 resource::Resource,
8 system::{Commands, Res, SystemParamItem},
9};
10use bevy_math::{ops, Curve, FloatPow, Vec3, Vec4};
11use bevy_reflect::TypePath;
12use bevy_render::{
13 render_asset::{PrepareAssetError, RenderAsset, RenderAssetPlugin},
14 render_resource::{
15 Extent3d, FilterMode, Sampler, SamplerDescriptor, Texture, TextureDataOrder,
16 TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, TextureView,
17 TextureViewDescriptor,
18 },
19 renderer::{RenderDevice, RenderQueue},
20 RenderApp, RenderStartup,
21};
22use smallvec::SmallVec;
23
24#[doc(hidden)]
25pub struct ScatteringMediumPlugin;
26
27impl Plugin for ScatteringMediumPlugin {
28 fn build(&self, app: &mut App) {
29 app.init_asset::<ScatteringMedium>()
30 .add_plugins(RenderAssetPlugin::<GpuScatteringMedium>::default());
31
32 if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
33 render_app.add_systems(RenderStartup, init_scattering_medium_sampler);
34 }
35 }
36}
37
38/// An asset that defines how a material scatters light.
39///
40/// In order to calculate how light passes through a medium,
41/// you need three pieces of information:
42/// - how much light the medium *absorbs* per unit length
43/// - how much light the medium *scatters* per unit length
44/// - what *directions* the medium is likely to scatter light in.
45///
46/// The first two are fairly simple, and are sometimes referred to together
47/// (accurately enough) as the medium's [optical density].
48///
49/// The last, defined by a [phase function], is the most important in creating
50/// the look of a medium. Our brains are very good at noticing (if unconsciously)
51/// that a dust storm scatters light differently than a rain cloud, for example.
52/// See the docs on [`PhaseFunction`] for more info.
53///
54/// In reality, media are often composed of multiple elements that scatter light
55/// independently, for Earth's atmosphere is composed of the gas itself, but also
56/// suspended dust and particulate. These each scatter light differently, and are
57/// distributed in different amounts at different altitudes. In a [`ScatteringMedium`],
58/// these are each represented by a [`ScatteringTerm`]
59///
60/// ## Technical Details
61///
62/// A [`ScatteringMedium`] is represented on the GPU by a set of two LUTs, which
63/// are re-created every time the asset is modified. See the docs on
64/// [`GpuScatteringMedium`] for more info.
65///
66/// [optical density]: https://en.wikipedia.org/wiki/Optical_Density
67/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
68#[derive(TypePath, Asset, Clone)]
69pub struct ScatteringMedium {
70 /// An optional label for the medium, used when creating the LUTs on the GPU.
71 pub label: Option<Cow<'static, str>>,
72 /// The resolution at which to sample the falloff distribution of each
73 /// scattering term. Custom or more detailed distributions may benefit
74 /// from a higher value, at the cost of more memory use.
75 pub falloff_resolution: u32,
76 /// The resolution at which to sample the phase function of each scattering
77 /// term. Custom or more detailed phase functions may benefit from a higher
78 /// value, at the cost of more memory use.
79 pub phase_resolution: u32,
80 /// The list of [`ScatteringTerm`]s that compose this [`ScatteringMedium`]
81 pub terms: SmallVec<[ScatteringTerm; 1]>,
82}
83
84impl ScatteringMedium {
85 // Returns a scattering medium with a default label and the
86 // specified scattering terms.
87 pub fn new(
88 falloff_resolution: u32,
89 phase_resolution: u32,
90 terms: impl IntoIterator<Item = ScatteringTerm>,
91 ) -> Self {
92 Self {
93 label: None,
94 falloff_resolution,
95 phase_resolution,
96 terms: terms.into_iter().collect(),
97 }
98 }
99
100 // Consumes and returns this scattering medium with a new label.
101 pub fn with_label(self, label: impl Into<Cow<'static, str>>) -> Self {
102 Self {
103 label: Some(label.into()),
104 ..self
105 }
106 }
107
108 // Consumes and returns this scattering medium with each scattering terms'
109 // densities multiplied by `multiplier`.
110 pub fn with_density_multiplier(mut self, multiplier: f32) -> Self {
111 self.terms.iter_mut().for_each(|term| {
112 term.absorption *= multiplier;
113 term.scattering *= multiplier;
114 });
115
116 self
117 }
118
119 /// Returns a scattering medium representing an earthlike atmosphere.
120 ///
121 /// Uses physically-based scale heights from Earth's atmosphere, assuming
122 /// a 60 km atmosphere height:
123 /// - Rayleigh (molecular) scattering: 8 km scale height
124 /// - Mie (aerosol) scattering: 1.2 km scale height
125 pub fn earthlike(falloff_resolution: u32, phase_resolution: u32) -> Self {
126 Self::new(
127 falloff_resolution,
128 phase_resolution,
129 [
130 // Rayleigh scattering Term
131 ScatteringTerm {
132 absorption: Vec3::ZERO,
133 scattering: Vec3::new(5.802e-6, 13.558e-6, 33.100e-6),
134 falloff: Falloff::Exponential { scale: 8.0 / 60.0 },
135 phase: PhaseFunction::Rayleigh,
136 },
137 // Mie scattering Term
138 ScatteringTerm {
139 absorption: Vec3::splat(3.996e-6),
140 scattering: Vec3::splat(0.444e-6),
141 falloff: Falloff::Exponential { scale: 1.2 / 60.0 },
142 phase: PhaseFunction::Mie { asymmetry: 0.8 },
143 },
144 // Ozone scattering Term
145 ScatteringTerm {
146 absorption: Vec3::new(0.650e-6, 1.881e-6, 0.085e-6),
147 scattering: Vec3::ZERO,
148 falloff: Falloff::Tent {
149 center: 0.75,
150 width: 0.3,
151 },
152 phase: PhaseFunction::Isotropic,
153 },
154 ],
155 )
156 .with_label("earthlike_atmosphere")
157 }
158}
159
160/// An individual element of a [`ScatteringMedium`].
161///
162/// A [`ScatteringMedium`] can be built out of a number of simpler [`ScatteringTerm`]s,
163/// which correspond to an individual element of the medium. For example, Earth's
164/// atmosphere would be (roughly) composed of two [`ScatteringTerm`]s: the atmospheric
165/// gases themselves, which extend to the edge of space, and suspended dust particles,
166/// which are denser but lie closer to the ground.
167#[derive(Default, Clone)]
168pub struct ScatteringTerm {
169 /// This term's optical absorption density, or how much light of each wavelength
170 /// it absorbs per meter.
171 ///
172 /// units: m^-1
173 pub absorption: Vec3,
174 /// This term's optical scattering density, or how much light of each wavelength
175 /// it scatters per meter.
176 ///
177 /// units: m^-1
178 pub scattering: Vec3,
179 /// This term's falloff distribution. See the docs on [`Falloff`] for more info.
180 pub falloff: Falloff,
181 /// This term's [phase function], which determines the character of how it
182 /// scatters light. See the docs on [`PhaseFunction`] for more info.
183 ///
184 /// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
185 pub phase: PhaseFunction,
186}
187
188/// Describes how the media in a [`ScatteringTerm`] is distributed.
189///
190/// This is closely related to the optical density values [`ScatteringTerm::absorption`] and
191/// [`ScatteringTerm::scattering`]. Most media aren't the same density everywhere;
192/// near the edge of space Earth's atmosphere is much less dense, and it absorbs
193/// and scatters less light.
194///
195/// [`Falloff`] determines how the density of a medium changes as a function of
196/// an abstract "falloff parameter" `p`. `p = 1` denotes where the medium is the
197/// densest, i.e. at the surface of the Earth, `p = 0` denotes where the medium
198/// fades away completely, i.e. at the edge of space, and values between scale
199/// linearly with distance, so `p = 0.5` would be halfway between the surface
200/// and the edge of space.
201///
202/// When processing a [`ScatteringMedium`], the `absorption` and `scattering` values
203/// for each [`ScatteringTerm`] are multiplied by the value of the falloff function, `f(p)`.
204#[derive(Default, Clone)]
205pub enum Falloff {
206 /// A simple linear falloff function, which essentially
207 /// passes the falloff parameter through unchanged.
208 ///
209 /// f(1) = 1
210 /// f(0) = 0
211 /// f(p) = p
212 #[default]
213 Linear,
214 /// An exponential falloff function parametrized by a proportional scale.
215 /// When paired with an absolute "falloff distance" like the distance from
216 /// Earth's surface to the edge of space, this is analogous to the "height
217 /// scale" value common in atmospheric scattering literature, though it will
218 /// diverge from this for large or negative `scale` values.
219 ///
220 /// f(1) = 1
221 /// f(0) = 0
222 /// f(p) = (e^((1-p)/s) - e^(1/s))/(e - e^(1/s))
223 Exponential {
224 /// The "scale" of the exponential falloff. Values closer to zero will
225 /// produce steeper falloff, and values farther from zero will produce
226 /// gentler falloff, approaching linear falloff as scale goes to `+-∞`.
227 ///
228 /// Negative values change the *concavity* of the falloff function:
229 /// rather than an initial narrow region of steep falloff followed by a
230 /// wide region of gentle falloff, there will be an initial wide region
231 /// of gentle falloff followed by a narrow region of steep falloff.
232 ///
233 /// domain: (-∞, ∞)
234 ///
235 /// NOTE, this function is not defined when `scale == 0`.
236 /// In that case, it will fall back to linear falloff.
237 scale: f32,
238 },
239 /// A tent-shaped falloff function, which produces a triangular
240 /// peak at the center and linearly falls off to either side.
241 ///
242 /// f(`center`) = 1
243 /// f(`center` +- `width` / 2) = 0
244 Tent {
245 /// The center of the tent function peak
246 ///
247 /// domain: [0, 1]
248 center: f32,
249 /// The total width of the tent function peak
250 ///
251 /// domain: [0, 1]
252 width: f32,
253 },
254 /// A falloff function defined by a custom curve.
255 ///
256 /// domain: [0, 1],
257 /// range: [0, 1],
258 Curve(Arc<dyn Curve<f32> + Send + Sync>),
259}
260
261impl Falloff {
262 /// Returns a falloff function corresponding to a custom curve.
263 pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
264 Self::Curve(Arc::new(curve))
265 }
266
267 fn sample(&self, p: f32) -> f32 {
268 match self {
269 Falloff::Linear => p,
270 Falloff::Exponential { scale } => {
271 // fill discontinuity at scale == 0,
272 // arbitrarily choose linear falloff
273 if *scale == 0.0 {
274 p
275 } else {
276 let s = -1.0 / scale;
277 let exp_p_s = ops::exp((1.0 - p) * s);
278 let exp_s = ops::exp(s);
279 (exp_p_s - exp_s) / (1.0 - exp_s)
280 }
281 }
282 Falloff::Tent { center, width } => (1.0 - (p - center).abs() / (0.5 * width)).max(0.0),
283 Falloff::Curve(curve) => curve.sample(p).unwrap_or(0.0),
284 }
285 }
286}
287
288/// Describes how a [`ScatteringTerm`] scatters light in different directions.
289///
290/// A [phase function] is a function `f: [-1, 1] -> [0, ∞)`, symmetric about `x=0`
291/// whose input is the cosine of the angle between an incoming light direction and
292/// and outgoing light direction, and whose output is the proportion of the incoming
293/// light that is actually scattered in that direction.
294///
295/// The phase function has an important effect on the "look" of a medium in a scene.
296/// Media consisting of particles of a different size or shape scatter light differently,
297/// and our brains are very good at telling the difference. A dust cloud, which might
298/// correspond roughly to `PhaseFunction::Mie { asymmetry: 0.8 }`, looks quite different
299/// from the rest of the sky (atmospheric gases), which correspond to `PhaseFunction::Rayleigh`
300///
301/// [phase function]: https://www.pbr-book.org/4ed/Volume_Scattering/Phase_Functions
302#[derive(Clone)]
303pub enum PhaseFunction {
304 /// A phase function that scatters light evenly in all directions.
305 Isotropic,
306
307 /// A phase function representing [Rayleigh scattering].
308 ///
309 /// Rayleigh scattering occurs naturally for particles much smaller than
310 /// the wavelengths of visible light, such as gas molecules in the atmosphere.
311 /// It's generally wavelength-dependent, where shorter wavelengths are scattered
312 /// more strongly, so [scattering](ScatteringTerm::scattering) should have
313 /// higher values for blue than green and green than red. Particles that
314 /// participate in Rayleigh scattering don't absorb any light, either.
315 ///
316 /// [Rayleigh scattering]: https://en.wikipedia.org/wiki/Rayleigh_scattering
317 Rayleigh,
318
319 /// The [Henyey-Greenstein phase function], which approximates [Mie scattering].
320 ///
321 /// Mie scattering occurs naturally for spherical particles of dust
322 /// and aerosols roughly the same size as the wavelengths of visible light,
323 /// so it's useful for representing dust or sea spray. It's generally
324 /// wavelength-independent, so [absorption](ScatteringTerm::absorption)
325 /// and [scattering](ScatteringTerm::scattering) should be set to a greyscale value.
326 ///
327 /// [Mie scattering]: https://en.wikipedia.org/wiki/Mie_scattering
328 /// [Henyey-Greenstein phase function]: https://www.oceanopticsbook.info/view/scattering/level-2/the-henyey-greenstein-phase-function
329 Mie {
330 /// Whether the Mie scattering function is biased towards scattering
331 /// light forwards (asymmetry > 0) or backwards (asymmetry < 0).
332 ///
333 /// domain: [-1, 1]
334 asymmetry: f32,
335 },
336
337 /// A phase function defined by a custom curve, where the input
338 /// is the cosine of the angle between the incoming light ray
339 /// and the scattered light ray, and the output is the fraction
340 /// of the incoming light scattered in that direction.
341 ///
342 /// Note: it's important for photorealism that the phase function
343 /// be *energy conserving*, meaning that in total no more light can
344 /// be scattered than actually entered the medium. For this to be
345 /// the case, the integral of the phase function over its domain must
346 /// be equal to 1/2π.
347 ///
348 /// 1
349 /// ∫ p(x) dx = 1/2π
350 /// -1
351 ///
352 /// domain: [-1, 1]
353 /// range: [0, 1]
354 Curve(Arc<dyn Curve<f32> + Send + Sync>),
355}
356
357impl PhaseFunction {
358 /// A phase function defined by a custom curve.
359 pub fn from_curve(curve: impl Curve<f32> + Send + Sync + 'static) -> Self {
360 Self::Curve(Arc::new(curve))
361 }
362
363 fn sample(&self, neg_l_dot_v: f32) -> f32 {
364 const FRAC_4_PI: f32 = 0.25 / PI;
365 const FRAC_3_16_PI: f32 = 0.1875 / PI;
366 match self {
367 PhaseFunction::Isotropic => FRAC_4_PI,
368 PhaseFunction::Rayleigh => FRAC_3_16_PI * (1.0 + neg_l_dot_v * neg_l_dot_v),
369 PhaseFunction::Mie { asymmetry } => {
370 let denom = 1.0 + asymmetry.squared() - 2.0 * asymmetry * neg_l_dot_v;
371 FRAC_4_PI * (1.0 - asymmetry.squared()) / (denom * denom.sqrt())
372 }
373 PhaseFunction::Curve(curve) => curve.sample(neg_l_dot_v).unwrap_or(0.0),
374 }
375 }
376}
377
378impl Default for PhaseFunction {
379 fn default() -> Self {
380 Self::Mie { asymmetry: 0.8 }
381 }
382}
383
384/// The GPU representation of a [`ScatteringMedium`].
385pub struct GpuScatteringMedium {
386 /// The terms of the scattering medium.
387 pub terms: SmallVec<[ScatteringTerm; 1]>,
388 /// The resolution at which to sample the falloff distribution of each
389 /// scattering term.
390 pub falloff_resolution: u32,
391 /// The resolution at which to sample the phase function of each
392 /// scattering term.
393 pub phase_resolution: u32,
394 /// The `density_lut`, a 2D `falloff_resolution x 2` LUT which contains the
395 /// medium's optical density with respect to the atmosphere's "falloff parameter",
396 /// a linear value which is 1.0 at the planet's surface and 0.0 at the edge of
397 /// space. The first and second rows correspond to absorption density and
398 /// scattering density respectively.
399 pub density_lut: Texture,
400 /// The default [`TextureView`] of the `density_lut`
401 pub density_lut_view: TextureView,
402 /// The `scattering_lut`, a 2D `falloff_resolution x phase_resolution` LUT which
403 /// contains the medium's scattering density multiplied by the phase function, with
404 /// the U axis corresponding to the falloff parameter and the V axis corresponding
405 /// to `neg_LdotV * 0.5 + 0.5`, where `neg_LdotV` is the dot product of the light
406 /// direction and the incoming view vector.
407 pub scattering_lut: Texture,
408 /// The default [`TextureView`] of the `scattering_lut`
409 pub scattering_lut_view: TextureView,
410}
411
412impl RenderAsset for GpuScatteringMedium {
413 type SourceAsset = ScatteringMedium;
414
415 type Param = (Res<'static, RenderDevice>, Res<'static, RenderQueue>);
416
417 fn prepare_asset(
418 source_asset: Self::SourceAsset,
419 _asset_id: AssetId<Self::SourceAsset>,
420 (render_device, render_queue): &mut SystemParamItem<Self::Param>,
421 _previous_asset: Option<&Self>,
422 ) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {
423 let mut density: Vec<Vec4> =
424 Vec::with_capacity(2 * source_asset.falloff_resolution as usize);
425
426 density.extend((0..source_asset.falloff_resolution).map(|i| {
427 let falloff = (i as f32 + 0.5) / source_asset.falloff_resolution as f32;
428
429 source_asset
430 .terms
431 .iter()
432 .map(|term| term.absorption.extend(0.0) * term.falloff.sample(falloff))
433 .sum::<Vec4>()
434 }));
435
436 density.extend((0..source_asset.falloff_resolution).map(|i| {
437 let falloff = (i as f32 + 0.5) / source_asset.falloff_resolution as f32;
438
439 source_asset
440 .terms
441 .iter()
442 .map(|term| term.scattering.extend(0.0) * term.falloff.sample(falloff))
443 .sum::<Vec4>()
444 }));
445
446 let mut scattering: Vec<Vec4> = Vec::with_capacity(
447 source_asset.falloff_resolution as usize * source_asset.phase_resolution as usize,
448 );
449
450 scattering.extend(
451 (0..source_asset.falloff_resolution * source_asset.phase_resolution).map(|raw_i| {
452 let i = raw_i % source_asset.phase_resolution;
453 let j = raw_i / source_asset.phase_resolution;
454 let falloff = (i as f32 + 0.5) / source_asset.falloff_resolution as f32;
455 let phase = (j as f32 + 0.5) / source_asset.phase_resolution as f32;
456 let neg_l_dot_v = phase * 2.0 - 1.0;
457
458 source_asset
459 .terms
460 .iter()
461 .map(|term| {
462 term.scattering.extend(0.0)
463 * term.falloff.sample(falloff)
464 * term.phase.sample(neg_l_dot_v)
465 })
466 .sum::<Vec4>()
467 }),
468 );
469
470 let density_lut = render_device.create_texture_with_data(
471 render_queue,
472 &TextureDescriptor {
473 label: source_asset
474 .label
475 .as_deref()
476 .map(|label| format!("{}_density_lut", label))
477 .as_deref()
478 .or(Some("scattering_medium_density_lut")),
479 size: Extent3d {
480 width: source_asset.falloff_resolution,
481 height: 2,
482 depth_or_array_layers: 1,
483 },
484 mip_level_count: 1,
485 sample_count: 1,
486 dimension: TextureDimension::D2,
487 format: TextureFormat::Rgba32Float,
488 usage: TextureUsages::TEXTURE_BINDING,
489 view_formats: &[],
490 },
491 TextureDataOrder::LayerMajor,
492 bytemuck::cast_slice(density.as_slice()),
493 );
494
495 let density_lut_view = density_lut.create_view(&TextureViewDescriptor {
496 label: source_asset
497 .label
498 .as_deref()
499 .map(|label| format!("{}_density_lut_view", label))
500 .as_deref()
501 .or(Some("scattering_medium_density_lut_view")),
502 ..Default::default()
503 });
504
505 let scattering_lut = render_device.create_texture_with_data(
506 render_queue,
507 &TextureDescriptor {
508 label: source_asset
509 .label
510 .as_deref()
511 .map(|label| format!("{}_scattering_lut", label))
512 .as_deref()
513 .or(Some("scattering_medium_scattering_lut")),
514 size: Extent3d {
515 width: source_asset.falloff_resolution,
516 height: source_asset.phase_resolution,
517 depth_or_array_layers: 1,
518 },
519 mip_level_count: 1,
520 sample_count: 1,
521 dimension: TextureDimension::D2,
522 format: TextureFormat::Rgba32Float,
523 usage: TextureUsages::TEXTURE_BINDING,
524 view_formats: &[],
525 },
526 TextureDataOrder::LayerMajor,
527 bytemuck::cast_slice(scattering.as_slice()),
528 );
529
530 let scattering_lut_view = scattering_lut.create_view(&TextureViewDescriptor {
531 label: source_asset
532 .label
533 .as_deref()
534 .map(|label| format!("{}_scattering_lut", label))
535 .as_deref()
536 .or(Some("scattering_medium_scattering_lut_view")),
537 ..Default::default()
538 });
539
540 Ok(Self {
541 terms: source_asset.terms,
542 falloff_resolution: source_asset.falloff_resolution,
543 phase_resolution: source_asset.phase_resolution,
544 density_lut,
545 density_lut_view,
546 scattering_lut,
547 scattering_lut_view,
548 })
549 }
550}
551
552/// The default sampler for all scattering media LUTs.
553///
554/// Just a bilinear clamp-to-edge sampler, nothing fancy.
555#[derive(Resource)]
556pub struct ScatteringMediumSampler(Sampler);
557
558impl ScatteringMediumSampler {
559 pub fn sampler(&self) -> &Sampler {
560 &self.0
561 }
562}
563
564fn init_scattering_medium_sampler(mut commands: Commands, render_device: Res<RenderDevice>) {
565 let sampler = render_device.create_sampler(&SamplerDescriptor {
566 label: Some("scattering_medium_sampler"),
567 mag_filter: FilterMode::Linear,
568 min_filter: FilterMode::Linear,
569 ..Default::default()
570 });
571
572 commands.insert_resource(ScatteringMediumSampler(sampler));
573}