# Objective
Bevy could benefit from *irradiance volumes*, also known as *voxel
global illumination* or simply as light probes (though this term is not
preferred, as multiple techniques can be called light probes).
Irradiance volumes are a form of baked global illumination; they work by
sampling the light at the centers of each voxel within a cuboid. At
runtime, the voxels surrounding the fragment center are sampled and
interpolated to produce indirect diffuse illumination.
## Solution
This is divided into two sections. The first is copied and pasted from
the irradiance volume module documentation and describes the technique.
The second part consists of notes on the implementation.
### Overview
An *irradiance volume* is a cuboid voxel region consisting of
regularly-spaced precomputed samples of diffuse indirect light. They're
ideal if you have a dynamic object such as a character that can move
about
static non-moving geometry such as a level in a game, and you want that
dynamic object to be affected by the light bouncing off that static
geometry.
To use irradiance volumes, you need to precompute, or *bake*, the
indirect
light in your scene. Bevy doesn't currently come with a way to do this.
Fortunately, [Blender] provides a [baking tool] as part of the Eevee
renderer, and its irradiance volumes are compatible with those used by
Bevy.
The [`bevy-baked-gi`] project provides a tool, `export-blender-gi`, that
can
extract the baked irradiance volumes from the Blender `.blend` file and
package them up into a `.ktx2` texture for use by the engine. See the
documentation in the `bevy-baked-gi` project for more details as to this
workflow.
Like all light probes in Bevy, irradiance volumes are 1×1×1 cubes that
can
be arbitrarily scaled, rotated, and positioned in a scene with the
[`bevy_transform::components::Transform`] component. The 3D voxel grid
will
be stretched to fill the interior of the cube, and the illumination from
the
irradiance volume will apply to all fragments within that bounding
region.
Bevy's irradiance volumes are based on Valve's [*ambient cubes*] as used
in
*Half-Life 2* ([Mitchell 2006], slide 27). These encode a single color
of
light from the six 3D cardinal directions and blend the sides together
according to the surface normal.
The primary reason for choosing ambient cubes is to match Blender, so
that
its Eevee renderer can be used for baking. However, they also have some
advantages over the common second-order spherical harmonics approach:
ambient cubes don't suffer from ringing artifacts, they are smaller (6
colors for ambient cubes as opposed to 9 for spherical harmonics), and
evaluation is faster. A smaller basis allows for a denser grid of voxels
with the same storage requirements.
If you wish to use a tool other than `export-blender-gi` to produce the
irradiance volumes, you'll need to pack the irradiance volumes in the
following format. The irradiance volume of resolution *(Rx, Ry, Rz)* is
expected to be a 3D texture of dimensions *(Rx, 2Ry, 3Rz)*. The
unnormalized
texture coordinate *(s, t, p)* of the voxel at coordinate *(x, y, z)*
with
side *S* ∈ *{-X, +X, -Y, +Y, -Z, +Z}* is as follows:
```text
s = x
t = y + ⎰ 0 if S ∈ {-X, -Y, -Z}
⎱ Ry if S ∈ {+X, +Y, +Z}
⎧ 0 if S ∈ {-X, +X}
p = z + ⎨ Rz if S ∈ {-Y, +Y}
⎩ 2Rz if S ∈ {-Z, +Z}
```
Visually, in a left-handed coordinate system with Y up, viewed from the
right, the 3D texture looks like a stacked series of voxel grids, one
for
each cube side, in this order:
| **+X** | **+Y** | **+Z** |
| ------ | ------ | ------ |
| **-X** | **-Y** | **-Z** |
A terminology note: Other engines may refer to irradiance volumes as
*voxel
global illumination*, *VXGI*, or simply as *light probes*. Sometimes
*light
probe* refers to what Bevy calls a reflection probe. In Bevy, *light
probe*
is a generic term that encompasses all cuboid bounding regions that
capture
indirect illumination, whether based on voxels or not.
Note that, if binding arrays aren't supported (e.g. on WebGPU or WebGL
2),
then only the closest irradiance volume to the view will be taken into
account during rendering.
[*ambient cubes*]:
https://advances.realtimerendering.com/s2006/Mitchell-ShadingInValvesSourceEngine.pdf
[Mitchell 2006]:
https://advances.realtimerendering.com/s2006/Mitchell-ShadingInValvesSourceEngine.pdf
[Blender]: http://blender.org/
[baking tool]:
https://docs.blender.org/manual/en/latest/render/eevee/render_settings/indirect_lighting.html
[`bevy-baked-gi`]: https://github.com/pcwalton/bevy-baked-gi
### Implementation notes
This patch generalizes light probes so as to reuse as much code as
possible between irradiance volumes and the existing reflection probes.
This approach was chosen because both techniques share numerous
similarities:
1. Both irradiance volumes and reflection probes are cuboid bounding
regions.
2. Both are responsible for providing baked indirect light.
3. Both techniques involve presenting a variable number of textures to
the shader from which indirect light is sampled. (In the current
implementation, this uses binding arrays.)
4. Both irradiance volumes and reflection probes require gathering and
sorting probes by distance on CPU.
5. Both techniques require the GPU to search through a list of bounding
regions.
6. Both will eventually want to have falloff so that we can smoothly
blend as objects enter and exit the probes' influence ranges. (This is
not implemented yet to keep this patch relatively small and reviewable.)
To do this, we generalize most of the methods in the reflection probes
patch #11366 to be generic over a trait, `LightProbeComponent`. This
trait is implemented by both `EnvironmentMapLight` (for reflection
probes) and `IrradianceVolume` (for irradiance volumes). Using a trait
will allow us to add more types of light probes in the future. In
particular, I highly suspect we will want real-time reflection planes
for mirrors in the future, which can be easily slotted into this
framework.
## Changelog
> This section is optional. If this was a trivial fix, or has no
externally-visible impact, you can delete this section.
### Added
* A new `IrradianceVolume` asset type is available for baked voxelized
light probes. You can bake the global illumination using Blender or
another tool of your choice and use it in Bevy to apply indirect
illumination to dynamic objects.
347 lines
14 KiB
Rust
347 lines
14 KiB
Rust
//! Environment maps and reflection probes.
|
|
//!
|
|
//! An *environment map* consists of a pair of diffuse and specular cubemaps
|
|
//! that together reflect the static surrounding area of a region in space. When
|
|
//! available, the PBR shader uses these to apply diffuse light and calculate
|
|
//! specular reflections.
|
|
//!
|
|
//! Environment maps come in two flavors, depending on what other components the
|
|
//! entities they're attached to have:
|
|
//!
|
|
//! 1. If attached to a view, they represent the objects located a very far
|
|
//! distance from the view, in a similar manner to a skybox. Essentially, these
|
|
//! *view environment maps* represent a higher-quality replacement for
|
|
//! [`crate::AmbientLight`] for outdoor scenes. The indirect light from such
|
|
//! environment maps are added to every point of the scene, including
|
|
//! interior enclosed areas.
|
|
//!
|
|
//! 2. If attached to a [`LightProbe`], environment maps represent the immediate
|
|
//! surroundings of a specific location in the scene. These types of
|
|
//! environment maps are known as *reflection probes*.
|
|
//! [`ReflectionProbeBundle`] is available as a mechanism to conveniently add
|
|
//! these to a scene.
|
|
//!
|
|
//! Typically, environment maps are static (i.e. "baked", calculated ahead of
|
|
//! time) and so only reflect fixed static geometry. The environment maps must
|
|
//! be pre-filtered into a pair of cubemaps, one for the diffuse component and
|
|
//! one for the specular component, according to the [split-sum approximation].
|
|
//! To pre-filter your environment map, you can use the [glTF IBL Sampler] or
|
|
//! its [artist-friendly UI]. The diffuse map uses the Lambertian distribution,
|
|
//! while the specular map uses the GGX distribution.
|
|
//!
|
|
//! The Khronos Group has [several pre-filtered environment maps] available for
|
|
//! you to use.
|
|
//!
|
|
//! Currently, reflection probes (i.e. environment maps attached to light
|
|
//! probes) use binding arrays (also known as bindless textures) and
|
|
//! consequently aren't supported on WebGL2 or WebGPU. Reflection probes are
|
|
//! also unsupported if GLSL is in use, due to `naga` limitations. Environment
|
|
//! maps attached to views are, however, supported on all platforms.
|
|
//!
|
|
//! [split-sum approximation]: https://cdn2.unrealengine.com/Resources/files/2013SiggraphPresentationsNotes-26915738.pdf
|
|
//!
|
|
//! [glTF IBL Sampler]: https://github.com/KhronosGroup/glTF-IBL-Sampler
|
|
//!
|
|
//! [artist-friendly UI]: https://github.com/pcwalton/gltf-ibl-sampler-egui
|
|
//!
|
|
//! [several pre-filtered environment maps]: https://github.com/KhronosGroup/glTF-Sample-Environments
|
|
|
|
use bevy_asset::{AssetId, Handle};
|
|
use bevy_ecs::{
|
|
bundle::Bundle, component::Component, query::QueryItem, system::lifetimeless::Read,
|
|
};
|
|
use bevy_reflect::Reflect;
|
|
use bevy_render::{
|
|
extract_instances::ExtractInstance,
|
|
prelude::SpatialBundle,
|
|
render_asset::RenderAssets,
|
|
render_resource::{
|
|
binding_types, BindGroupLayoutEntryBuilder, Sampler, SamplerBindingType, Shader,
|
|
TextureSampleType, TextureView,
|
|
},
|
|
renderer::RenderDevice,
|
|
texture::{FallbackImage, Image},
|
|
};
|
|
|
|
use std::num::NonZeroU32;
|
|
use std::ops::Deref;
|
|
|
|
use crate::{
|
|
add_cubemap_texture_view, binding_arrays_are_usable, LightProbe, MAX_VIEW_LIGHT_PROBES,
|
|
};
|
|
|
|
use super::{LightProbeComponent, RenderViewLightProbes};
|
|
|
|
/// A handle to the environment map helper shader.
|
|
pub const ENVIRONMENT_MAP_SHADER_HANDLE: Handle<Shader> =
|
|
Handle::weak_from_u128(154476556247605696);
|
|
|
|
/// A pair of cubemap textures that represent the surroundings of a specific
|
|
/// area in space.
|
|
///
|
|
/// See [`crate::environment_map`] for detailed information.
|
|
#[derive(Clone, Component, Reflect)]
|
|
pub struct EnvironmentMapLight {
|
|
/// The blurry image that represents diffuse radiance surrounding a region.
|
|
pub diffuse_map: Handle<Image>,
|
|
|
|
/// The typically-sharper, mipmapped image that represents specular radiance
|
|
/// surrounding a region.
|
|
pub specular_map: Handle<Image>,
|
|
|
|
/// Scale factor applied to the diffuse and specular light generated by this component.
|
|
///
|
|
/// After applying this multiplier, the resulting values should
|
|
/// be in units of [cd/m^2](https://en.wikipedia.org/wiki/Candela_per_square_metre).
|
|
///
|
|
/// See also <https://google.github.io/filament/Filament.html#lighting/imagebasedlights/iblunit>.
|
|
pub intensity: f32,
|
|
}
|
|
|
|
/// Like [`EnvironmentMapLight`], but contains asset IDs instead of handles.
|
|
///
|
|
/// This is for use in the render app.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct EnvironmentMapIds {
|
|
/// The blurry image that represents diffuse radiance surrounding a region.
|
|
pub(crate) diffuse: AssetId<Image>,
|
|
/// The typically-sharper, mipmapped image that represents specular radiance
|
|
/// surrounding a region.
|
|
pub(crate) specular: AssetId<Image>,
|
|
}
|
|
|
|
/// A bundle that contains everything needed to make an entity a reflection
|
|
/// probe.
|
|
///
|
|
/// A reflection probe is a type of environment map that specifies the light
|
|
/// surrounding a region in space. For more information, see
|
|
/// [`crate::environment_map`].
|
|
#[derive(Bundle)]
|
|
pub struct ReflectionProbeBundle {
|
|
/// Contains a transform that specifies the position of this reflection probe in space.
|
|
pub spatial: SpatialBundle,
|
|
/// Marks this environment map as a light probe.
|
|
pub light_probe: LightProbe,
|
|
/// The cubemaps that make up this environment map.
|
|
pub environment_map: EnvironmentMapLight,
|
|
}
|
|
|
|
/// All the bind group entries necessary for PBR shaders to access the
|
|
/// environment maps exposed to a view.
|
|
pub(crate) enum RenderViewEnvironmentMapBindGroupEntries<'a> {
|
|
/// The version used when binding arrays aren't available on the current
|
|
/// platform.
|
|
Single {
|
|
/// The texture view of the view's diffuse cubemap.
|
|
diffuse_texture_view: &'a TextureView,
|
|
|
|
/// The texture view of the view's specular cubemap.
|
|
specular_texture_view: &'a TextureView,
|
|
|
|
/// The sampler used to sample elements of both `diffuse_texture_views` and
|
|
/// `specular_texture_views`.
|
|
sampler: &'a Sampler,
|
|
},
|
|
|
|
/// The version used when binding arrays are available on the current
|
|
/// platform.
|
|
Multiple {
|
|
/// A texture view of each diffuse cubemap, in the same order that they are
|
|
/// supplied to the view (i.e. in the same order as
|
|
/// `binding_index_to_cubemap` in [`RenderViewLightProbes`]).
|
|
///
|
|
/// This is a vector of `wgpu::TextureView`s. But we don't want to import
|
|
/// `wgpu` in this crate, so we refer to it indirectly like this.
|
|
diffuse_texture_views: Vec<&'a <TextureView as Deref>::Target>,
|
|
|
|
/// As above, but for specular cubemaps.
|
|
specular_texture_views: Vec<&'a <TextureView as Deref>::Target>,
|
|
|
|
/// The sampler used to sample elements of both `diffuse_texture_views` and
|
|
/// `specular_texture_views`.
|
|
sampler: &'a Sampler,
|
|
},
|
|
}
|
|
|
|
/// Information about the environment map attached to the view, if any. This is
|
|
/// a global environment map that lights everything visible in the view, as
|
|
/// opposed to a light probe which affects only a specific area.
|
|
pub struct EnvironmentMapViewLightProbeInfo {
|
|
/// The index of the diffuse and specular cubemaps in the binding arrays.
|
|
pub(crate) cubemap_index: i32,
|
|
/// The smallest mip level of the specular cubemap.
|
|
pub(crate) smallest_specular_mip_level: u32,
|
|
/// The scale factor applied to the diffuse and specular light in the
|
|
/// cubemap. This is in units of cd/m² (candela per square meter).
|
|
pub(crate) intensity: f32,
|
|
}
|
|
|
|
impl ExtractInstance for EnvironmentMapIds {
|
|
type QueryData = Read<EnvironmentMapLight>;
|
|
|
|
type QueryFilter = ();
|
|
|
|
fn extract(item: QueryItem<'_, Self::QueryData>) -> Option<Self> {
|
|
Some(EnvironmentMapIds {
|
|
diffuse: item.diffuse_map.id(),
|
|
specular: item.specular_map.id(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Returns the bind group layout entries for the environment map diffuse and
|
|
/// specular binding arrays respectively, in addition to the sampler.
|
|
pub(crate) fn get_bind_group_layout_entries(
|
|
render_device: &RenderDevice,
|
|
) -> [BindGroupLayoutEntryBuilder; 3] {
|
|
let mut texture_cube_binding =
|
|
binding_types::texture_cube(TextureSampleType::Float { filterable: true });
|
|
if binding_arrays_are_usable(render_device) {
|
|
texture_cube_binding =
|
|
texture_cube_binding.count(NonZeroU32::new(MAX_VIEW_LIGHT_PROBES as _).unwrap());
|
|
}
|
|
|
|
[
|
|
texture_cube_binding,
|
|
texture_cube_binding,
|
|
binding_types::sampler(SamplerBindingType::Filtering),
|
|
]
|
|
}
|
|
|
|
impl<'a> RenderViewEnvironmentMapBindGroupEntries<'a> {
|
|
/// Looks up and returns the bindings for the environment map diffuse and
|
|
/// specular binding arrays respectively, as well as the sampler.
|
|
pub(crate) fn get(
|
|
render_view_environment_maps: Option<&RenderViewLightProbes<EnvironmentMapLight>>,
|
|
images: &'a RenderAssets<Image>,
|
|
fallback_image: &'a FallbackImage,
|
|
render_device: &RenderDevice,
|
|
) -> RenderViewEnvironmentMapBindGroupEntries<'a> {
|
|
if binding_arrays_are_usable(render_device) {
|
|
let mut diffuse_texture_views = vec![];
|
|
let mut specular_texture_views = vec![];
|
|
let mut sampler = None;
|
|
|
|
if let Some(environment_maps) = render_view_environment_maps {
|
|
for &cubemap_id in &environment_maps.binding_index_to_textures {
|
|
add_cubemap_texture_view(
|
|
&mut diffuse_texture_views,
|
|
&mut sampler,
|
|
cubemap_id.diffuse,
|
|
images,
|
|
fallback_image,
|
|
);
|
|
add_cubemap_texture_view(
|
|
&mut specular_texture_views,
|
|
&mut sampler,
|
|
cubemap_id.specular,
|
|
images,
|
|
fallback_image,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Pad out the bindings to the size of the binding array using fallback
|
|
// textures. This is necessary on D3D12 and Metal.
|
|
diffuse_texture_views.resize(MAX_VIEW_LIGHT_PROBES, &*fallback_image.cube.texture_view);
|
|
specular_texture_views
|
|
.resize(MAX_VIEW_LIGHT_PROBES, &*fallback_image.cube.texture_view);
|
|
|
|
return RenderViewEnvironmentMapBindGroupEntries::Multiple {
|
|
diffuse_texture_views,
|
|
specular_texture_views,
|
|
sampler: sampler.unwrap_or(&fallback_image.cube.sampler),
|
|
};
|
|
}
|
|
|
|
if let Some(environment_maps) = render_view_environment_maps {
|
|
if let Some(cubemap) = environment_maps.binding_index_to_textures.first() {
|
|
if let (Some(diffuse_image), Some(specular_image)) =
|
|
(images.get(cubemap.diffuse), images.get(cubemap.specular))
|
|
{
|
|
return RenderViewEnvironmentMapBindGroupEntries::Single {
|
|
diffuse_texture_view: &diffuse_image.texture_view,
|
|
specular_texture_view: &specular_image.texture_view,
|
|
sampler: &diffuse_image.sampler,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
RenderViewEnvironmentMapBindGroupEntries::Single {
|
|
diffuse_texture_view: &fallback_image.cube.texture_view,
|
|
specular_texture_view: &fallback_image.cube.texture_view,
|
|
sampler: &fallback_image.cube.sampler,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LightProbeComponent for EnvironmentMapLight {
|
|
type AssetId = EnvironmentMapIds;
|
|
|
|
// Information needed to render with the environment map attached to the
|
|
// view.
|
|
type ViewLightProbeInfo = EnvironmentMapViewLightProbeInfo;
|
|
|
|
fn id(&self, image_assets: &RenderAssets<Image>) -> Option<Self::AssetId> {
|
|
if image_assets.get(&self.diffuse_map).is_none()
|
|
|| image_assets.get(&self.specular_map).is_none()
|
|
{
|
|
None
|
|
} else {
|
|
Some(EnvironmentMapIds {
|
|
diffuse: self.diffuse_map.id(),
|
|
specular: self.specular_map.id(),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn intensity(&self) -> f32 {
|
|
self.intensity
|
|
}
|
|
|
|
fn create_render_view_light_probes(
|
|
view_component: Option<&EnvironmentMapLight>,
|
|
image_assets: &RenderAssets<Image>,
|
|
) -> RenderViewLightProbes<Self> {
|
|
let mut render_view_light_probes = RenderViewLightProbes::new();
|
|
|
|
// Find the index of the cubemap associated with the view, and determine
|
|
// its smallest mip level.
|
|
if let Some(EnvironmentMapLight {
|
|
diffuse_map: diffuse_map_handle,
|
|
specular_map: specular_map_handle,
|
|
intensity,
|
|
}) = view_component
|
|
{
|
|
if let (Some(_), Some(specular_map)) = (
|
|
image_assets.get(diffuse_map_handle),
|
|
image_assets.get(specular_map_handle),
|
|
) {
|
|
render_view_light_probes.view_light_probe_info = EnvironmentMapViewLightProbeInfo {
|
|
cubemap_index: render_view_light_probes.get_or_insert_cubemap(
|
|
&EnvironmentMapIds {
|
|
diffuse: diffuse_map_handle.id(),
|
|
specular: specular_map_handle.id(),
|
|
},
|
|
) as i32,
|
|
smallest_specular_mip_level: specular_map.mip_level_count - 1,
|
|
intensity: *intensity,
|
|
};
|
|
}
|
|
};
|
|
|
|
render_view_light_probes
|
|
}
|
|
}
|
|
|
|
impl Default for EnvironmentMapViewLightProbeInfo {
|
|
fn default() -> Self {
|
|
Self {
|
|
cubemap_index: -1,
|
|
smallest_specular_mip_level: 0,
|
|
intensity: 1.0,
|
|
}
|
|
}
|
|
}
|