Gate LTC LUTs behind a feature and merge them to a texture array (#24065)

# Objective

Alternative to #24004.

https://github.com/bevyengine/bevy/pull/23288 adds ltc luts for rect
light support which implicitly requires `bevy_image/ktx2` and
`bevy_image/zstd` otherwise loading ltc luts will panic.

We either accept to always enable area light supoort (#24004), or add a
feature to opt out it (this PR).

## Solution

Gate ltc luts behind a feature and merge them to a texture array.

## Testing

`rect_light` example works.

---------

Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
This commit is contained in:
Luo Zhihao
2026-05-04 11:44:17 +08:00
committed by Carter Anderson
parent 827358a922
commit c757497b27
18 changed files with 145 additions and 85 deletions
+3 -3
View File
@@ -52,7 +52,7 @@ jobs:
example_name=`basename $example .ron`
echo -n $example_name > last_example_run
echo "running $example_name - "`date`
time TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example cargo run --example $example_name --features "bevy_ci_testing,trace,trace_chrome,bevy_ui_debug"
time TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example cargo run --example $example_name --features "bevy_ci_testing,trace,trace_chrome,bevy_ui_debug,area_light_luts"
sleep 10
if [ `find ./ -maxdepth 1 -name 'screenshot-*.png' -print -quit` ]; then
mkdir screenshots-$example_name
@@ -132,7 +132,7 @@ jobs:
example_name=`basename $example .ron`
echo -n $example_name > last_example_run
echo "running $example_name - "`date`
time TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example xvfb-run cargo run --example $example_name --features "bevy_ci_testing,trace,trace_chrome,bevy_ui_debug"
time TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example xvfb-run cargo run --example $example_name --features "bevy_ci_testing,trace,trace_chrome,bevy_ui_debug,area_light_luts"
sleep 10
if [ `find ./ -maxdepth 1 -name 'screenshot-*.png' -print -quit` ]; then
mkdir screenshots-$example_name
@@ -202,7 +202,7 @@ jobs:
example_name=`basename $example .ron`
echo -n $example_name > last_example_run
echo "running $example_name - "`date`
time WGPU_BACKEND=dx12 TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example cargo run --example $example_name --features "statically-linked-dxc,bevy_ci_testing,trace,trace_chrome,bevy_ui_debug"
time WGPU_BACKEND=dx12 TRACE_CHROME=trace-$example_name.json CI_TESTING_CONFIG=$example cargo run --example $example_name --features "statically-linked-dxc,bevy_ci_testing,trace,trace_chrome,bevy_ui_debug,area_light_luts"
sleep 10
if [ `find ./ -maxdepth 1 -name 'screenshot-*.png' -print -quit` ]; then
mkdir screenshots-$example_name
+5 -1
View File
@@ -574,6 +574,9 @@ bluenoise_texture = ["bevy_internal/bluenoise_texture"]
# Include a preintegrated BRDF Look Up Table for more accurate specular shading.
dfg_lut = ["bevy_internal/dfg_lut"]
# Include Look Up Tables that are required for area lights.
area_light_luts = ["bevy_internal/area_light_luts"]
# NVIDIA Deep Learning Super Sampling
dlss = ["bevy_internal/dlss"]
@@ -1190,7 +1193,7 @@ wasm = true
name = "rect_light"
path = "examples/3d/rect_light.rs"
doc-scrape-examples = true
required-features = ["free_camera"]
required-features = ["free_camera", "area_light_luts"]
[package.metadata.example.rect_light]
name = "Rectangular Area Light"
@@ -5192,6 +5195,7 @@ hidden = true
name = "testbed_3d"
path = "examples/testbed/3d.rs"
doc-scrape-examples = true
required-features = ["area_light_luts"]
[package.metadata.example.testbed_3d]
hidden = true
@@ -12,4 +12,6 @@ The implementation uses [Linearly Transformed Cosines](https://eheitzresearch.wo
Rectangular lights currently don't cast shadows or have support for anisotropic materials.
You need to enable the `area_light_luts` cargo feature to use it.
Check out [the new example](https://github.com/bevyengine/bevy/tree/latest/examples/3d/rect_light.rs) to see them in action.
+3
View File
@@ -85,6 +85,9 @@ bluenoise_texture = ["bevy_pbr?/bluenoise_texture", "ktx2", "bevy_image/zstd"]
# Include a preintegrated BRDF Look Up Table for more accurate specular shading.
dfg_lut = ["bevy_pbr?/dfg_lut", "ktx2", "bevy_image/zstd"]
# Include Look Up Tables that are required for area lights.
area_light_luts = ["bevy_pbr?/area_light_luts", "ktx2", "bevy_image/zstd"]
# NVIDIA Deep Learning Super Sampling
dlss = ["bevy_anti_alias/dlss", "bevy_solari?/dlss"]
+2
View File
@@ -13,6 +13,8 @@ use crate::light_consts;
///
/// Shadow maps are currently unsupported, objects illuminated by a
/// ``RectLight`` will not cast shadows.
///
/// Note: Requires the `area_light_luts` cargo feature.
#[derive(Component, Debug, Clone, Copy, Reflect)]
#[reflect(Component, Default, Debug, Clone)]
#[require(Transform, Visibility, VisibilityClass)]
+1
View File
@@ -22,6 +22,7 @@ pbr_clustered_decals = []
pbr_light_textures = []
bluenoise_texture = ["bevy_image/ktx2", "bevy_image/zstd"]
dfg_lut = ["bevy_image/ktx2", "bevy_image/zstd"]
area_light_luts = ["bevy_image/ktx2", "bevy_image/zstd"]
shader_format_glsl = ["bevy_shader/shader_format_glsl"]
trace = ["bevy_render/trace"]
# Enables the meshlet renderer for dense high-poly scenes (experimental)
+64 -40
View File
@@ -107,14 +107,14 @@ use bevy_asset::{AssetApp, AssetPath, Assets, Handle, RenderAssetUsages};
use bevy_core_pipeline::mip_generation::experimental::depth::early_downsample_depth;
use bevy_core_pipeline::schedule::{Core3d, Core3dSystems};
use bevy_ecs::prelude::*;
use bevy_image::{CompressedImageFormats, Image, ImageSampler, ImageType};
use bevy_image::{Image, ImageSampler};
use bevy_material::AlphaMode;
use bevy_render::{
camera::sort_cameras,
extract_resource::ExtractResourcePlugin,
render_resource::{
Extent3d, TextureDataOrder, TextureDescriptor, TextureDimension, TextureFormat,
TextureUsages,
TextureUsages, TextureViewDescriptor, TextureViewDimension,
},
sync_component::SyncComponentPlugin,
ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderDebugFlags, RenderStartup,
@@ -169,14 +169,14 @@ pub struct Bluenoise {
/// LTC (Linearly Transformed Cosines) LUT textures for area light shading.
///
/// `ltc_1` encodes the 4 non-trivial elements of the inverse GGX LTC matrix.
/// `ltc_2` encodes amplitude and Fresnel-related weights.
/// It is a texture array containing 2 LUT textures:
/// The first entry encodes the 4 non-trivial elements of the inverse GGX LTC matrix.
/// The second entry encodes amplitude and Fresnel-related weights.
///
/// [LUT source and fitting code](https://github.com/selfshadow/ltc_code/blob/master/fit/results)
#[derive(Resource, Clone)]
pub struct LtcLuts {
pub ltc_1: Handle<Image>,
pub ltc_2: Handle<Image>,
pub struct AreaLightLuts {
pub image: Handle<Image>,
}
// See https://github.com/bevyengine/bevy/pull/23737 for information on how the LUT was generated.
@@ -282,15 +282,16 @@ impl Plugin for PbrPlugin {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
#[cfg(feature = "bluenoise_texture")]
let handle = {
let image = Image::from_buffer(
let mut image = Image::from_buffer(
include_bytes!("bluenoise/stbn.ktx2"),
ImageType::Extension("ktx2"),
CompressedImageFormats::NONE,
bevy_image::ImageType::Extension("ktx2"),
bevy_image::CompressedImageFormats::NONE,
false,
ImageSampler::Default,
RenderAssetUsages::RENDER_WORLD,
)
.expect("Failed to decode embedded blue-noise texture");
image.texture_descriptor.label = Some("bluenoise");
images.add(image)
};
@@ -304,39 +305,32 @@ impl Plugin for PbrPlugin {
}
}
let has_ltc_luts = app
let has_area_light_luts = app
.get_sub_app(RenderApp)
.is_some_and(|render_app| render_app.world().is_resource_added::<LtcLuts>());
.is_some_and(|render_app| render_app.world().is_resource_added::<AreaLightLuts>());
if !has_ltc_luts {
if !has_area_light_luts {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let ltc_luts = LtcLuts {
ltc_1: images.add(
Image::from_buffer(
include_bytes!("ltc/ltc1.ktx2"),
ImageType::Extension("ktx2"),
CompressedImageFormats::NONE,
false,
ImageSampler::linear(),
RenderAssetUsages::RENDER_WORLD,
)
.expect("Failed to decode embedded LTC LUT 1"),
),
ltc_2: images.add(
Image::from_buffer(
include_bytes!("ltc/ltc2.ktx2"),
ImageType::Extension("ktx2"),
CompressedImageFormats::NONE,
false,
ImageSampler::linear(),
RenderAssetUsages::RENDER_WORLD,
)
.expect("Failed to decode embedded LTC LUT 2"),
),
#[cfg(feature = "area_light_luts")]
let handle = {
let mut image = Image::from_buffer(
include_bytes!("ltc/ltc.ktx2"),
bevy_image::ImageType::Extension("ktx2"),
bevy_image::CompressedImageFormats::NONE,
false,
ImageSampler::linear(),
RenderAssetUsages::RENDER_WORLD,
)
.expect("Failed to decode embedded LTC LUTs");
image.texture_descriptor.label = Some("area_light_luts");
images.add(image)
};
#[cfg(not(feature = "area_light_luts"))]
let handle = images.add(area_light_luts_placeholder());
let area_light_luts = AreaLightLuts { image: handle };
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.world_mut().insert_resource(ltc_luts);
render_app.world_mut().insert_resource(area_light_luts);
}
}
@@ -349,8 +343,8 @@ impl Plugin for PbrPlugin {
let texture = app.world_mut().resource_mut::<Assets<Image>>().add(
Image::from_buffer(
include_bytes!("environment_map/dfg.ktx2"),
ImageType::Extension("ktx2"),
CompressedImageFormats::NONE,
bevy_image::ImageType::Extension("ktx2"),
bevy_image::CompressedImageFormats::NONE,
false,
ImageSampler::linear(),
RenderAssetUsages::RENDER_WORLD,
@@ -468,7 +462,7 @@ pub fn stbn_placeholder() -> Image {
size: Extent3d::default(),
format,
dimension: TextureDimension::D2,
label: None,
label: Some("bluenoise_placeholder"),
mip_level_count: 1,
sample_count: 1,
usage: TextureUsages::TEXTURE_BINDING,
@@ -481,6 +475,36 @@ pub fn stbn_placeholder() -> Image {
}
}
pub fn area_light_luts_placeholder() -> Image {
let format = TextureFormat::Rgba16Float;
let data = vec![0; 16];
Image {
data: Some(data),
data_order: TextureDataOrder::default(),
texture_descriptor: TextureDescriptor {
size: Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
format,
dimension: TextureDimension::D2,
label: Some("area_light_luts_placeholder"),
mip_level_count: 1,
sample_count: 1,
usage: TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
sampler: ImageSampler::Default,
texture_view_descriptor: Some(TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
}),
asset_usage: RenderAssetUsages::RENDER_WORLD,
copy_on_resize: false,
}
}
impl SyncComponent<PbrPlugin> for DirectionalLight {
type Target = (
Self,
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -420,6 +420,7 @@ pub fn extract_lights(
&mut RenderExtractedShadowMapVisibleEntities,
&mut RenderShadowMapVisibleEntities,
)>,
mut rect_light_missing_luts_warning_emitted: Local<bool>,
) {
let mapper = &visibility_extraction_system_param.mapper;
@@ -824,6 +825,12 @@ pub fn extract_lights(
}
for (main_entity, render_entity, rect_light, transform, view_visibility) in &rect_lights {
if !cfg!(feature = "area_light_luts") && !*rect_light_missing_luts_warning_emitted {
warn!(
"RectLight will not work properly because the `area_light_luts` cargo feature is not enabled."
);
*rect_light_missing_luts_warning_emitted = true;
}
if !view_visibility.get() {
if let Ok(mut entity_commands) = commands.get_entity(render_entity) {
entity_commands.remove::<ExtractedRectLight>();
+3
View File
@@ -3344,6 +3344,9 @@ impl SpecializedMeshPipeline for MeshPipeline {
if cfg!(feature = "dfg_lut") {
shader_defs.push("DFG_LUT".into());
}
if cfg!(feature = "area_light_luts") {
shader_defs.push("AREA_LIGHT_LUTS".into());
}
let bind_group_layout = self.get_view_layout(key.into());
let mut bind_group_layout = vec![
@@ -1,5 +1,5 @@
use crate::{
DfgLut, LtcLuts, ScreenSpaceTransmission, ViewEnvironmentMapUniformOffset,
AreaLightLuts, DfgLut, ScreenSpaceTransmission, ViewEnvironmentMapUniformOffset,
ViewFogUniformOffset, ViewLightProbesUniformOffset, ViewLightsUniformOffset,
ViewScreenSpaceReflectionsUniformOffset,
};
@@ -97,6 +97,7 @@ bitflags::bitflags! {
const SCREEN_SPACE_TRANSMISSION = 1 << 13;
const CONTACT_SHADOWS = 1 << 14;
const DISTANCE_FOG = 1 << 15;
const AREA_LIGHT_LUTS = 1 << 16;
}
}
@@ -150,6 +151,10 @@ impl From<MeshPipelineKey> for MeshPipelineViewLayoutKey {
result |= MeshPipelineViewLayoutKey::STBN;
}
if cfg!(feature = "area_light_luts") {
result |= MeshPipelineViewLayoutKey::AREA_LIGHT_LUTS;
}
if value.contains(MeshPipelineKey::TONEMAP_IN_SHADER) {
result |= MeshPipelineViewLayoutKey::TONEMAP_IN_SHADER;
}
@@ -462,25 +467,23 @@ fn layout_entries(
),));
}
// LTC LUTs for area lights
entries = entries.extend_with_indices((
(
36,
texture_2d(TextureSampleType::Float { filterable: true }),
),
(
37,
texture_2d(TextureSampleType::Float { filterable: true }),
),
(38, sampler(SamplerBindingType::Filtering)),
));
if cfg!(feature = "area_light_luts") {
entries = entries.extend_with_indices((
(
36,
texture_2d_array(TextureSampleType::Float { filterable: true }),
),
(37, sampler(SamplerBindingType::Filtering)),
));
}
// DFG LUT
if cfg!(feature = "dfg_lut") {
entries = entries.extend_with_indices((
(
39,
38,
texture_2d(TextureSampleType::Float { filterable: true }),
),
(40, sampler(SamplerBindingType::Filtering)),
(39, sampler(SamplerBindingType::Filtering)),
));
}
@@ -678,7 +681,7 @@ pub fn prepare_mesh_view_bind_groups(
atmosphere_buffer,
atmosphere_sampler,
blue_noise,
ltc_luts,
area_light_luts,
dfg_lut,
): (
Res<DecalsBuffer>,
@@ -686,7 +689,7 @@ pub fn prepare_mesh_view_bind_groups(
Option<Res<AtmosphereBuffer>>,
Option<Res<AtmosphereSampler>>,
Res<Bluenoise>,
Res<LtcLuts>,
Res<AreaLightLuts>,
Res<DfgLut>,
),
// TODO: Figure out how to reuse the memory. `BindGroupEntry` is non-send on wasm with atomics.
@@ -896,16 +899,16 @@ pub fn prepare_mesh_view_bind_groups(
};
// LTC LUTs for area lights
let (ltc1_view, ltc_sampler) = images
.get(&ltc_luts.ltc_1)
.map(|img| (&img.texture_view, &img.sampler))
.unwrap_or((&fallback_image.d2.texture_view, &fallback_image.d2.sampler));
let ltc2_view = images
.get(&ltc_luts.ltc_2)
.map(|img| &img.texture_view)
.unwrap_or(&fallback_image.d2.texture_view);
entries =
entries.extend_with_indices(((36, ltc1_view), (37, ltc2_view), (38, ltc_sampler)));
if cfg!(feature = "area_light_luts") {
let (ltc_view, ltc_sampler) = images
.get(&area_light_luts.image)
.map(|img| (&img.texture_view, &img.sampler))
.unwrap_or((
&fallback_image.d2_array.texture_view,
&fallback_image.d2_array.sampler,
));
entries = entries.extend_with_indices(((36, ltc_view), (37, ltc_sampler)));
}
// DFG LUT
if cfg!(feature = "dfg_lut") {
@@ -913,7 +916,7 @@ pub fn prepare_mesh_view_bind_groups(
.get(&dfg_lut.texture)
.map(|img| (&img.texture_view, &img.sampler))
.unwrap_or((&fallback_image.d2.texture_view, &fallback_image.d2.sampler));
entries = entries.extend_with_indices(((39, dfg_view), (40, dfg_sampler)));
entries = entries.extend_with_indices(((38, dfg_view), (39, dfg_sampler)));
}
let environment_map_bind_group_entries =
@@ -121,16 +121,19 @@ const VISIBILITY_RANGE_UNIFORM_BUFFER_SIZE: u32 = 64u;
@group(0) @binding(33) var atmosphere_transmittance_sampler: sampler;
@group(0) @binding(34) var<storage> atmosphere: atmosphere_types::Atmosphere;
#endif // ATMOSPHERE
#ifdef BLUE_NOISE_TEXTURE
@group(0) @binding(35) var blue_noise_texture: texture_2d_array<f32>;
#endif // BLUE_NOISE_TEXTURE
@group(0) @binding(36) var ltc_lut1: texture_2d<f32>;
@group(0) @binding(37) var ltc_lut2: texture_2d<f32>;
@group(0) @binding(38) var ltc_lut_sampler: sampler;
#ifdef AREA_LIGHT_LUTS
@group(0) @binding(36) var area_light_luts: texture_2d_array<f32>;
@group(0) @binding(37) var area_light_luts_sampler: sampler;
#endif
#ifdef DFG_LUT
@group(0) @binding(39) var dfg_lut: texture_2d<f32>;
@group(0) @binding(40) var dfg_lut_sampler: sampler;
@group(0) @binding(38) var dfg_lut: texture_2d<f32>;
@group(0) @binding(39) var dfg_lut_sampler: sampler;
#endif // DFG_LUT
#ifdef ENVIRONMENT_MAP
@@ -657,6 +657,7 @@ fn apply_pbr_lighting(
#endif
}
#ifdef AREA_LIGHT_LUTS
// Rect lights
let n_rect_lights = view_bindings::lights.n_rect_lights;
for (var i: u32 = 0u; i < n_rect_lights; i = i + 1u) {
@@ -670,6 +671,7 @@ fn apply_pbr_lighting(
transmitted_light += transmitted_light_contrib;
#endif
}
#endif
#ifdef STANDARD_MATERIAL_DIFFUSE_TRANSMISSION
// NOTE: We use the diffuse transmissive color, the second Lambertian lobe's calculated
+6 -4
View File
@@ -1010,6 +1010,7 @@ fn ltc_integrate_quad(
return sum;
}
#ifdef AREA_LIGHT_LUTS
fn rect_light(
light_id: u32,
input: ptr<function, LightingInput>,
@@ -1046,8 +1047,8 @@ fn rect_light(
let LUT_SCALE = 63.0 / 64.0;
let LUT_BIAS = 0.5 / 64.0;
let uv = vec2<f32>(perceptual_roughness, sqrt(1.0 - NdotV)) * LUT_SCALE + LUT_BIAS;
let t1 = textureSampleLevel(view_bindings::ltc_lut1, view_bindings::ltc_lut_sampler, uv, 0.0);
let t2 = textureSampleLevel(view_bindings::ltc_lut2, view_bindings::ltc_lut_sampler, uv, 0.0);
let t1 = textureSampleLevel(view_bindings::area_light_luts, view_bindings::area_light_luts_sampler, uv, 0, 0.0);
let t2 = textureSampleLevel(view_bindings::area_light_luts, view_bindings::area_light_luts_sampler, uv, 1, 0.0);
// Reconstruct the GGX inverse-LTC matrix
let Minv = mat3x3<f32>(
@@ -1077,8 +1078,8 @@ fn rect_light(
// Sample LUTs for clearcoat layer
let cc_uv = vec2<f32>(clearcoat_perceptual_roughness, sqrt(1.0 - clearcoat_NdotV)) * LUT_SCALE + LUT_BIAS;
let tc1 = textureSampleLevel(view_bindings::ltc_lut1, view_bindings::ltc_lut_sampler, cc_uv, 0.0);
let tc2 = textureSampleLevel(view_bindings::ltc_lut2, view_bindings::ltc_lut_sampler, cc_uv, 0.0);
let tc1 = textureSampleLevel(view_bindings::area_light_luts, view_bindings::area_light_luts_sampler, cc_uv, 0, 0.0);
let tc2 = textureSampleLevel(view_bindings::area_light_luts, view_bindings::area_light_luts_sampler, cc_uv, 1, 0.0);
let Minv_cc = mat3x3<f32>(
vec3<f32>(tc1.x, 0.0, tc1.y),
vec3<f32>(0.0, 1.0, 0.0),
@@ -1097,6 +1098,7 @@ fn rect_light(
return (spec_weight * spec + diffuse_color * diff) * (*light).color.rgb * range_falloff;
#endif
}
#endif
#ifdef ATMOSPHERE
+3
View File
@@ -514,6 +514,9 @@ impl SpecializedRenderPipeline for ScreenSpaceReflectionsPipeline {
if cfg!(feature = "dfg_lut") {
shader_defs.push("DFG_LUT".into());
}
if cfg!(feature = "area_light_luts") {
shader_defs.push("AREA_LIGHT_LUTS".into());
}
#[cfg(not(target_arch = "wasm32"))]
shader_defs.push("USE_DEPTH_SAMPLERS".into());
+1
View File
@@ -62,6 +62,7 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio
|accesskit_unix|Enable AccessKit on Unix backends (currently only works with experimental screen readers and forks.)|
|android-game-activity|Android GameActivity support. Default, choose between this and `android-native-activity`.|
|android-native-activity|Android NativeActivity support. Legacy, should be avoided for most new Android games.|
|area_light_luts|Include Look Up Tables that are required for area lights.|
|asset_processor|Enables the built-in asset processor for processed assets.|
|async-io|Use async-io's implementation of block_on instead of futures-lite's implementation. This is preferred if your application uses async-io.|
|async_executor|Uses `async-executor` as a task execution backend.|