Implement visibility ranges, also known as hierarchical levels of detail (HLODs). (#12916)

Implement visibility ranges, also known as hierarchical levels of detail
(HLODs).

This commit introduces a new component, `VisibilityRange`, which allows
developers to specify camera distances in which meshes are to be shown
and hidden. Hiding meshes happens early in the rendering pipeline, so
this feature can be used for level of detail optimization. Additionally,
this feature is properly evaluated per-view, so different views can show
different levels of detail.

This feature differs from proper mesh LODs, which can be implemented
later. Engines generally implement true mesh LODs later in the pipeline;
they're typically more efficient than HLODs with GPU-driven rendering.
However, mesh LODs are more limited than HLODs, because they require the
lower levels of detail to be meshes with the same vertex layout and
shader (and perhaps the same material) as the original mesh. Games often
want to use objects other than meshes to replace distant models, such as
*octahedral imposters* or *billboard imposters*.

The reason why the feature is called *hierarchical level of detail* is
that HLODs can replace multiple meshes with a single mesh when the
camera is far away. This can be useful for reducing drawcall count. Note
that `VisibilityRange` doesn't automatically propagate down to children;
it must be placed on every mesh.

Crossfading between different levels of detail is supported, using the
standard 4x4 ordered dithering pattern from [1]. The shader code to
compute the dithering patterns should be well-optimized. The dithering
code is only active when visibility ranges are in use for the mesh in
question, so that we don't lose early Z.

Cascaded shadow maps show the HLOD level of the view they're associated
with. Point light and spot light shadow maps, which have no CSMs,
display all HLOD levels that are visible in any view. To support this
efficiently and avoid doing visibility checks multiple times, we
precalculate all visible HLOD levels for each entity with a
`VisibilityRange` during the `check_visibility_range` system.

A new example, `visibility_range`, has been added to the tree, as well
as a new low-poly version of the flight helmet model to go with it. It
demonstrates use of the visibility range feature to provide levels of
detail.

[1]: https://en.wikipedia.org/wiki/Ordered_dithering#Threshold_map

[^1]: Unreal doesn't have a feature that exactly corresponds to
visibility ranges, but Unreal's HLOD system serves roughly the same
purpose.

## Changelog

### Added

* A new `VisibilityRange` component is available to conditionally enable
entity visibility at camera distances, with optional crossfade support.
This can be used to implement different levels of detail (LODs).

## Screenshots

High-poly model:
![Screenshot 2024-04-09
185541](https://github.com/bevyengine/bevy/assets/157897/7e8be017-7187-4471-8866-974e2d8f2623)

Low-poly model up close:
![Screenshot 2024-04-09
185546](https://github.com/bevyengine/bevy/assets/157897/429603fe-6bb7-4246-8b4e-b4888fd1d3a0)

Crossfading between the two:
![Screenshot 2024-04-09
185604](https://github.com/bevyengine/bevy/assets/157897/86d0d543-f8f3-49ec-8fe5-caa4d0784fd4)

---------

Co-authored-by: Carter Anderson <mcanders1@gmail.com>
This commit is contained in:
Patrick Walton 2024-05-02 19:11:35 -05:00 committed by GitHub
parent fd4589d8df
commit 31835ff76d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2835 additions and 69 deletions

View file

@ -2939,6 +2939,17 @@ description = "Demonstrates FPS overlay"
category = "Dev tools"
wasm = true
[[example]]
name = "visibility_range"
path = "examples/3d/visibility_range.rs"
doc-scrape-examples = true
[package.metadata.example.visibility_range]
name = "Visibility range"
description = "Demonstrates visibility ranges"
category = "3D Rendering"
wasm = true
[[example]]
name = "color_grading"
path = "examples/3d/color_grading.rs"

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@
@group(0) @binding(3) var dt_lut_texture: texture_3d<f32>;
@group(0) @binding(4) var dt_lut_sampler: sampler;
#else
@group(0) @binding(18) var dt_lut_texture: texture_3d<f32>;
@group(0) @binding(19) var dt_lut_sampler: sampler;
@group(0) @binding(19) var dt_lut_texture: texture_3d<f32>;
@group(0) @binding(20) var dt_lut_sampler: sampler;
#endif
// Half the size of the crossfade region between shadows and midtones and

View file

@ -14,7 +14,10 @@ use bevy_render::{
primitives::{Aabb, CascadesFrusta, CubemapFrusta, Frustum, HalfSpace, Sphere},
render_resource::BufferBindingType,
renderer::RenderDevice,
view::{InheritedVisibility, RenderLayers, ViewVisibility, VisibleEntities, WithMesh},
view::{
InheritedVisibility, RenderLayers, ViewVisibility, VisibilityRange, VisibleEntities,
VisibleEntityRanges, WithMesh,
},
};
use bevy_transform::components::{GlobalTransform, Transform};
use bevy_utils::tracing::warn;
@ -1863,6 +1866,7 @@ pub fn check_light_mesh_visibility(
Option<&RenderLayers>,
Option<&Aabb>,
Option<&GlobalTransform>,
Has<VisibilityRange>,
),
(
Without<NotShadowCaster>,
@ -1870,6 +1874,7 @@ pub fn check_light_mesh_visibility(
With<Handle<Mesh>>,
),
>,
visible_entity_ranges: Option<Res<VisibleEntityRanges>>,
) {
fn shrink_entities(visible_entities: &mut VisibleEntities) {
// Check that visible entities capacity() is no more than two times greater than len()
@ -1887,6 +1892,8 @@ pub fn check_light_mesh_visibility(
visible_entities.entities.shrink_to(reserved);
}
let visible_entity_ranges = visible_entity_ranges.as_deref();
// Directional lights
for (directional_light, frusta, mut visible_entities, maybe_view_mask, light_view_visibility) in
&mut directional_lights
@ -1928,6 +1935,7 @@ pub fn check_light_mesh_visibility(
maybe_entity_mask,
maybe_aabb,
maybe_transform,
has_visibility_range,
) in &mut visible_entity_query
{
if !inherited_visibility.get() {
@ -1947,6 +1955,15 @@ pub fn check_light_mesh_visibility(
.get_mut(view)
.expect("Per-view visible entities should have been inserted already");
// Check visibility ranges.
if has_visibility_range
&& visible_entity_ranges.is_some_and(|visible_entity_ranges| {
!visible_entity_ranges.entity_is_in_range_of_view(entity, *view)
})
{
continue;
}
for (frustum, frustum_visible_entities) in
view_frusta.iter().zip(view_visible_entities)
{
@ -2012,6 +2029,7 @@ pub fn check_light_mesh_visibility(
maybe_entity_mask,
maybe_aabb,
maybe_transform,
has_visibility_range,
) in &mut visible_entity_query
{
if !inherited_visibility.get() {
@ -2023,6 +2041,15 @@ pub fn check_light_mesh_visibility(
continue;
}
// Check visibility ranges.
if has_visibility_range
&& visible_entity_ranges.is_some_and(|visible_entity_ranges| {
!visible_entity_ranges.entity_is_in_range_of_any_view(entity)
})
{
continue;
}
// If we have an aabb and transform, do frustum culling
if let (Some(aabb), Some(transform)) = (maybe_aabb, maybe_transform) {
let model_to_world = transform.affine();
@ -2077,6 +2104,7 @@ pub fn check_light_mesh_visibility(
maybe_entity_mask,
maybe_aabb,
maybe_transform,
has_visibility_range,
) in &mut visible_entity_query
{
if !inherited_visibility.get() {
@ -2088,6 +2116,15 @@ pub fn check_light_mesh_visibility(
continue;
}
// Check visibility ranges.
if has_visibility_range
&& visible_entity_ranges.is_some_and(|visible_entity_ranges| {
!visible_entity_ranges.entity_is_in_range_of_any_view(entity)
})
{
continue;
}
// If we have an aabb and transform, do frustum culling
if let (Some(aabb), Some(transform)) = (maybe_aabb, maybe_transform) {
let model_to_world = transform.affine();

View file

@ -31,7 +31,7 @@ use bevy_render::{
render_resource::*,
renderer::RenderDevice,
texture::FallbackImage,
view::{ExtractedView, Msaa, VisibleEntities, WithMesh},
view::{ExtractedView, Msaa, RenderVisibilityRanges, VisibleEntities, WithMesh},
};
use bevy_utils::tracing::error;
use std::marker::PhantomData;
@ -529,6 +529,7 @@ pub fn queue_material_meshes<M: Material>(
render_mesh_instances: Res<RenderMeshInstances>,
render_material_instances: Res<RenderMaterialInstances<M>>,
render_lightmaps: Res<RenderLightmaps>,
render_visibility_ranges: Res<RenderVisibilityRanges>,
mut views: Query<(
&ExtractedView,
&VisibleEntities,
@ -676,6 +677,10 @@ pub fn queue_material_meshes<M: Material>(
mesh_key |= MeshPipelineKey::LIGHTMAPPED;
}
if render_visibility_ranges.entity_has_crossfading_visibility_ranges(*visible_entity) {
mesh_key |= MeshPipelineKey::VISIBILITY_RANGE_DITHER;
}
let pipeline_id = pipelines.specialize(
&pipeline_cache,
&material_pipeline,

View file

@ -50,6 +50,9 @@ struct VertexOutput {
#ifdef VERTEX_OUTPUT_INSTANCE_INDEX
@location(6) @interpolate(flat) instance_index: u32,
#endif
#ifdef VISIBILITY_RANGE_DITHER
@location(7) @interpolate(flat) visibility_range_dither: i32,
#endif
}
struct FragmentOutput {

View file

@ -31,7 +31,10 @@ use bevy_render::{
render_resource::*,
renderer::{RenderDevice, RenderQueue},
texture::{BevyDefault, DefaultImageSampler, ImageSampler, TextureFormatPixelInfo},
view::{prepare_view_targets, GpuCulling, ViewTarget, ViewUniformOffset, ViewVisibility},
view::{
prepare_view_targets, GpuCulling, RenderVisibilityRanges, ViewTarget, ViewUniformOffset,
ViewVisibility, VisibilityRange, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT,
},
Extract,
};
use bevy_transform::components::GlobalTransform;
@ -40,7 +43,7 @@ use bevy_utils::{tracing::error, tracing::warn, Entry, HashMap, Parallel};
#[cfg(debug_assertions)]
use bevy_utils::warn_once;
use bytemuck::{Pod, Zeroable};
use nonmax::NonMaxU32;
use nonmax::{NonMaxU16, NonMaxU32};
use static_assertions::const_assert_eq;
use crate::render::{
@ -347,21 +350,30 @@ impl MeshUniform {
// NOTE: These must match the bit flags in bevy_pbr/src/render/mesh_types.wgsl!
bitflags::bitflags! {
/// Various flags and tightly-packed values on a mesh.
///
/// Flags grow from the top bit down; other values grow from the bottom bit
/// up.
#[repr(transparent)]
pub struct MeshFlags: u32 {
const SHADOW_RECEIVER = 1 << 0;
const TRANSMITTED_SHADOW_RECEIVER = 1 << 1;
/// Bitmask for the 16-bit index into the LOD array.
///
/// This will be `u16::MAX` if this mesh has no LOD.
const LOD_INDEX_MASK = (1 << 16) - 1;
const SHADOW_RECEIVER = 1 << 29;
const TRANSMITTED_SHADOW_RECEIVER = 1 << 30;
// Indicates the sign of the determinant of the 3x3 model matrix. If the sign is positive,
// then the flag should be set, else it should not be set.
const SIGN_DETERMINANT_MODEL_3X3 = 1 << 31;
const NONE = 0;
const UNINITIALIZED = 0xFFFF;
const UNINITIALIZED = 0xFFFFFFFF;
}
}
impl MeshFlags {
fn from_components(
transform: &GlobalTransform,
lod_index: Option<NonMaxU16>,
not_shadow_receiver: bool,
transmitted_receiver: bool,
) -> MeshFlags {
@ -377,8 +389,18 @@ impl MeshFlags {
mesh_flags |= MeshFlags::SIGN_DETERMINANT_MODEL_3X3;
}
let lod_index_bits = match lod_index {
None => u16::MAX,
Some(lod_index) => u16::from(lod_index),
};
mesh_flags |=
MeshFlags::from_bits_retain((lod_index_bits as u32) << MeshFlags::LOD_INDEX_SHIFT);
mesh_flags
}
/// The first bit of the LOD index.
pub const LOD_INDEX_SHIFT: u32 = 0;
}
bitflags::bitflags! {
@ -747,6 +769,7 @@ pub struct ExtractMeshesSet;
/// [`MeshUniform`] building.
pub fn extract_meshes_for_cpu_building(
mut render_mesh_instances: ResMut<RenderMeshInstances>,
render_visibility_ranges: Res<RenderVisibilityRanges>,
mut render_mesh_instance_queues: Local<Parallel<Vec<(Entity, RenderMeshInstanceCpu)>>>,
meshes_query: Extract<
Query<(
@ -759,6 +782,7 @@ pub fn extract_meshes_for_cpu_building(
Has<TransmittedShadowReceiver>,
Has<NotShadowCaster>,
Has<NoAutomaticBatching>,
Has<VisibilityRange>,
)>,
>,
) {
@ -775,13 +799,23 @@ pub fn extract_meshes_for_cpu_building(
transmitted_receiver,
not_shadow_caster,
no_automatic_batching,
visibility_range,
)| {
if !view_visibility.get() {
return;
}
let mesh_flags =
MeshFlags::from_components(transform, not_shadow_receiver, transmitted_receiver);
let mut lod_index = None;
if visibility_range {
lod_index = render_visibility_ranges.lod_index_for_entity(entity);
}
let mesh_flags = MeshFlags::from_components(
transform,
lod_index,
not_shadow_receiver,
transmitted_receiver,
);
let shared = RenderMeshInstanceShared::from_components(
previous_transform,
@ -830,6 +864,7 @@ pub fn extract_meshes_for_cpu_building(
/// [`MeshUniform`] building.
pub fn extract_meshes_for_gpu_building(
mut render_mesh_instances: ResMut<RenderMeshInstances>,
render_visibility_ranges: Res<RenderVisibilityRanges>,
mut batched_instance_buffers: ResMut<
gpu_preprocessing::BatchedInstanceBuffers<MeshUniform, MeshInputUniform>,
>,
@ -848,6 +883,7 @@ pub fn extract_meshes_for_gpu_building(
Has<TransmittedShadowReceiver>,
Has<NotShadowCaster>,
Has<NoAutomaticBatching>,
Has<VisibilityRange>,
)>,
>,
cameras_query: Extract<Query<(), (With<Camera>, With<GpuCulling>)>>,
@ -881,13 +917,23 @@ pub fn extract_meshes_for_gpu_building(
transmitted_receiver,
not_shadow_caster,
no_automatic_batching,
visibility_range,
)| {
if !view_visibility.get() {
return;
}
let mesh_flags =
MeshFlags::from_components(transform, not_shadow_receiver, transmitted_receiver);
let mut lod_index = None;
if visibility_range {
lod_index = render_visibility_ranges.lod_index_for_entity(entity);
}
let mesh_flags = MeshFlags::from_components(
transform,
lod_index,
not_shadow_receiver,
transmitted_receiver,
);
let shared = RenderMeshInstanceShared::from_components(
previous_transform,
@ -1023,9 +1069,14 @@ impl FromWorld for MeshPipeline {
let (render_device, default_sampler, render_queue) = system_state.get_mut(world);
let clustered_forward_buffer_binding_type = render_device
.get_supported_read_only_binding_type(CLUSTERED_FORWARD_STORAGE_BUFFER_COUNT);
let visibility_ranges_buffer_binding_type = render_device
.get_supported_read_only_binding_type(VISIBILITY_RANGES_STORAGE_BUFFER_COUNT);
let view_layouts =
generate_view_layouts(&render_device, clustered_forward_buffer_binding_type);
let view_layouts = generate_view_layouts(
&render_device,
clustered_forward_buffer_binding_type,
visibility_ranges_buffer_binding_type,
);
// A 1x1x1 'all 1.0' texture to use as a dummy texture to use in place of optional StandardMaterial textures
let dummy_white_gpu_image = {
@ -1303,7 +1354,8 @@ bitflags::bitflags! {
const READS_VIEW_TRANSMISSION_TEXTURE = 1 << 12;
const LIGHTMAPPED = 1 << 13;
const IRRADIANCE_VOLUME = 1 << 14;
const LAST_FLAG = Self::IRRADIANCE_VOLUME.bits();
const VISIBILITY_RANGE_DITHER = 1 << 15;
const LAST_FLAG = Self::VISIBILITY_RANGE_DITHER.bits();
// Bitfields
const MSAA_RESERVED_BITS = Self::MSAA_MASK_BITS << Self::MSAA_SHIFT_BITS;
@ -1693,6 +1745,10 @@ impl SpecializedMeshPipeline for MeshPipeline {
},
));
if key.contains(MeshPipelineKey::VISIBILITY_RANGE_DITHER) {
shader_defs.push("VISIBILITY_RANGE_DITHER".into());
}
if self.binding_arrays_are_usable {
shader_defs.push("MULTIPLE_LIGHT_PROBES_IN_ARRAY".into());
}

View file

@ -91,6 +91,11 @@ fn vertex(vertex_no_morph: Vertex) -> VertexOutput {
out.instance_index = vertex_no_morph.instance_index;
#endif
#ifdef VISIBILITY_RANGE_DITHER
out.visibility_range_dither = mesh_functions::get_visibility_range_dither_level(
vertex_no_morph.instance_index, model[3]);
#endif
return out;
}

View file

@ -1,7 +1,7 @@
#define_import_path bevy_pbr::mesh_functions
#import bevy_pbr::{
mesh_view_bindings::view,
mesh_view_bindings::{view, visibility_ranges},
mesh_bindings::mesh,
mesh_types::MESH_FLAGS_SIGN_DETERMINANT_MODEL_3X3_BIT,
view_transformations::position_world_to_clip,
@ -83,3 +83,29 @@ fn mesh_tangent_local_to_world(model: mat4x4<f32>, vertex_tangent: vec4<f32>, in
return vertex_tangent;
}
}
// Returns an appropriate dither level for the current mesh instance.
//
// This looks up the LOD range in the `visibility_ranges` table and compares the
// camera distance to determine the dithering level.
#ifdef VISIBILITY_RANGE_DITHER
fn get_visibility_range_dither_level(instance_index: u32, world_position: vec4<f32>) -> i32 {
let visibility_buffer_index = mesh[instance_index].flags & 0xffffu;
if (visibility_buffer_index > arrayLength(&visibility_ranges)) {
return -16;
}
let lod_range = visibility_ranges[visibility_buffer_index];
let camera_distance = length(view.world_position.xyz - world_position.xyz);
// This encodes the following mapping:
//
// `lod_range.` x y z w camera distance
//
// LOD level -16 -16 0 0 16 16 LOD level
let offset = select(-16, 0, camera_distance >= lod_range.z);
let bounds = select(lod_range.xy, lod_range.zw, camera_distance >= lod_range.z);
let level = i32(round((camera_distance - bounds.x) / (bounds.y - bounds.x) * 16.0));
return offset + clamp(level, 0, 16);
}
#endif

View file

@ -29,7 +29,11 @@ struct MorphWeights {
};
#endif
const MESH_FLAGS_SHADOW_RECEIVER_BIT: u32 = 1u;
const MESH_FLAGS_TRANSMITTED_SHADOW_RECEIVER_BIT: u32 = 2u;
// [2^0, 2^16)
const MESH_FLAGS_VISIBILITY_RANGE_INDEX_BITS: u32 = 65535u;
// 2^29
const MESH_FLAGS_SHADOW_RECEIVER_BIT: u32 = 536870912u;
// 2^30
const MESH_FLAGS_TRANSMITTED_SHADOW_RECEIVER_BIT: u32 = 1073741824u;
// 2^31 - if the flag is set, the sign is positive, else it is negative
const MESH_FLAGS_SIGN_DETERMINANT_MODEL_3X3_BIT: u32 = 2147483648u;

View file

@ -12,13 +12,14 @@ use bevy_ecs::{
entity::Entity,
system::{Commands, Query, Res},
};
use bevy_math::Vec4;
use bevy_render::{
globals::{GlobalsBuffer, GlobalsUniform},
render_asset::RenderAssets,
render_resource::{binding_types::*, *},
renderer::RenderDevice,
texture::{BevyDefault, FallbackImage, FallbackImageMsaa, FallbackImageZero, GpuImage},
view::{Msaa, ViewUniform, ViewUniforms},
view::{Msaa, RenderVisibilityRanges, ViewUniform, ViewUniforms},
};
#[cfg(all(feature = "webgl", target_arch = "wasm32", not(feature = "webgpu")))]
@ -169,6 +170,7 @@ fn buffer_layout(
/// Returns the appropriate bind group layout vec based on the parameters
fn layout_entries(
clustered_forward_buffer_binding_type: BufferBindingType,
visibility_ranges_buffer_binding_type: BufferBindingType,
layout_key: MeshPipelineViewLayoutKey,
render_device: &RenderDevice,
) -> Vec<BindGroupLayoutEntry> {
@ -258,9 +260,19 @@ fn layout_entries(
(10, uniform_buffer::<GpuFog>(true)),
// Light probes
(11, uniform_buffer::<LightProbesUniform>(true)),
// Screen space ambient occlusion texture
// Visibility ranges
(
12,
buffer_layout(
visibility_ranges_buffer_binding_type,
false,
Some(Vec4::min_size()),
)
.visibility(ShaderStages::VERTEX),
),
// Screen space ambient occlusion texture
(
13,
texture_2d(TextureSampleType::Float { filterable: false }),
),
),
@ -269,9 +281,9 @@ fn layout_entries(
// EnvironmentMapLight
let environment_map_entries = environment_map::get_bind_group_layout_entries(render_device);
entries = entries.extend_with_indices((
(13, environment_map_entries[0]),
(14, environment_map_entries[1]),
(15, environment_map_entries[2]),
(14, environment_map_entries[0]),
(15, environment_map_entries[1]),
(16, environment_map_entries[2]),
));
// Irradiance volumes
@ -279,16 +291,16 @@ fn layout_entries(
let irradiance_volume_entries =
irradiance_volume::get_bind_group_layout_entries(render_device);
entries = entries.extend_with_indices((
(16, irradiance_volume_entries[0]),
(17, irradiance_volume_entries[1]),
(17, irradiance_volume_entries[0]),
(18, irradiance_volume_entries[1]),
));
}
// Tonemapping
let tonemapping_lut_entries = get_lut_bind_group_layout_entries();
entries = entries.extend_with_indices((
(18, tonemapping_lut_entries[0]),
(19, tonemapping_lut_entries[1]),
(19, tonemapping_lut_entries[0]),
(20, tonemapping_lut_entries[1]),
));
// Prepass
@ -298,7 +310,7 @@ fn layout_entries(
{
for (entry, binding) in prepass::get_bind_group_layout_entries(layout_key)
.iter()
.zip([20, 21, 22, 23])
.zip([21, 22, 23, 24])
{
if let Some(entry) = entry {
entries = entries.extend_with_indices(((binding as u32, *entry),));
@ -309,10 +321,10 @@ fn layout_entries(
// View Transmission Texture
entries = entries.extend_with_indices((
(
24,
25,
texture_2d(TextureSampleType::Float { filterable: true }),
),
(25, sampler(SamplerBindingType::Filtering)),
(26, sampler(SamplerBindingType::Filtering)),
));
entries.to_vec()
@ -323,10 +335,16 @@ fn layout_entries(
pub fn generate_view_layouts(
render_device: &RenderDevice,
clustered_forward_buffer_binding_type: BufferBindingType,
visibility_ranges_buffer_binding_type: BufferBindingType,
) -> [MeshPipelineViewLayout; MeshPipelineViewLayoutKey::COUNT] {
array::from_fn(|i| {
let key = MeshPipelineViewLayoutKey::from_bits_truncate(i as u32);
let entries = layout_entries(clustered_forward_buffer_binding_type, key, render_device);
let entries = layout_entries(
clustered_forward_buffer_binding_type,
visibility_ranges_buffer_binding_type,
key,
render_device,
);
#[cfg(debug_assertions)]
let texture_count: usize = entries
@ -379,6 +397,7 @@ pub fn prepare_mesh_view_bind_groups(
globals_buffer: Res<GlobalsBuffer>,
tonemapping_luts: Res<TonemappingLuts>,
light_probes_buffer: Res<LightProbesBuffer>,
visibility_ranges: Res<RenderVisibilityRanges>,
) {
if let (
Some(view_binding),
@ -387,6 +406,7 @@ pub fn prepare_mesh_view_bind_groups(
Some(globals),
Some(fog_binding),
Some(light_probes_binding),
Some(visibility_ranges_buffer),
) = (
view_uniforms.uniforms.binding(),
light_meta.view_gpu_lights.binding(),
@ -394,6 +414,7 @@ pub fn prepare_mesh_view_bind_groups(
globals_buffer.buffer.binding(),
fog_meta.gpu_fogs.binding(),
light_probes_buffer.binding(),
visibility_ranges.buffer().buffer(),
) {
for (
entity,
@ -433,7 +454,8 @@ pub fn prepare_mesh_view_bind_groups(
(9, globals.clone()),
(10, fog_binding.clone()),
(11, light_probes_binding.clone()),
(12, ssao_view),
(12, visibility_ranges_buffer.as_entire_binding()),
(13, ssao_view),
));
let environment_map_bind_group_entries = RenderViewEnvironmentMapBindGroupEntries::get(
@ -450,9 +472,9 @@ pub fn prepare_mesh_view_bind_groups(
sampler,
} => {
entries = entries.extend_with_indices((
(13, diffuse_texture_view),
(14, specular_texture_view),
(15, sampler),
(14, diffuse_texture_view),
(15, specular_texture_view),
(16, sampler),
));
}
RenderViewEnvironmentMapBindGroupEntries::Multiple {
@ -461,9 +483,9 @@ pub fn prepare_mesh_view_bind_groups(
sampler,
} => {
entries = entries.extend_with_indices((
(13, diffuse_texture_views.as_slice()),
(14, specular_texture_views.as_slice()),
(15, sampler),
(14, diffuse_texture_views.as_slice()),
(15, specular_texture_views.as_slice()),
(16, sampler),
));
}
}
@ -484,21 +506,21 @@ pub fn prepare_mesh_view_bind_groups(
texture_view,
sampler,
}) => {
entries = entries.extend_with_indices(((16, texture_view), (17, sampler)));
entries = entries.extend_with_indices(((17, texture_view), (18, sampler)));
}
Some(RenderViewIrradianceVolumeBindGroupEntries::Multiple {
ref texture_views,
sampler,
}) => {
entries = entries
.extend_with_indices(((16, texture_views.as_slice()), (17, sampler)));
.extend_with_indices(((17, texture_views.as_slice()), (18, sampler)));
}
None => {}
}
let lut_bindings =
get_lut_bindings(&images, &tonemapping_luts, tonemapping, &fallback_image);
entries = entries.extend_with_indices(((18, lut_bindings.0), (19, lut_bindings.1)));
entries = entries.extend_with_indices(((19, lut_bindings.0), (20, lut_bindings.1)));
// When using WebGL, we can't have a depth texture with multisampling
let prepass_bindings;
@ -508,7 +530,7 @@ pub fn prepare_mesh_view_bind_groups(
for (binding, index) in prepass_bindings
.iter()
.map(Option::as_ref)
.zip([20, 21, 22, 23])
.zip([21, 22, 23, 24])
.flat_map(|(b, i)| b.map(|b| (b, i)))
{
entries = entries.extend_with_indices(((index, binding),));
@ -524,7 +546,7 @@ pub fn prepare_mesh_view_bind_groups(
.unwrap_or(&fallback_image_zero.sampler);
entries =
entries.extend_with_indices(((24, transmission_view), (25, transmission_sampler)));
entries.extend_with_indices(((25, transmission_view), (26, transmission_sampler)));
commands.entity(entity).insert(MeshViewBindGroup {
value: render_device.create_bind_group("mesh_view_bind_group", layout, &entries),

View file

@ -35,58 +35,64 @@
@group(0) @binding(10) var<uniform> fog: types::Fog;
@group(0) @binding(11) var<uniform> light_probes: types::LightProbes;
@group(0) @binding(12) var screen_space_ambient_occlusion_texture: texture_2d<f32>;
#if AVAILABLE_STORAGE_BUFFER_BINDINGS >= 6
@group(0) @binding(12) var<storage> visibility_ranges: array<vec4<f32>>;
#else
@group(0) @binding(12) var<uniform> visibility_ranges: array<vec4<f32>>;
#endif
@group(0) @binding(13) var screen_space_ambient_occlusion_texture: texture_2d<f32>;
#ifdef MULTIPLE_LIGHT_PROBES_IN_ARRAY
@group(0) @binding(13) var diffuse_environment_maps: binding_array<texture_cube<f32>, 8u>;
@group(0) @binding(14) var specular_environment_maps: binding_array<texture_cube<f32>, 8u>;
@group(0) @binding(14) var diffuse_environment_maps: binding_array<texture_cube<f32>, 8u>;
@group(0) @binding(15) var specular_environment_maps: binding_array<texture_cube<f32>, 8u>;
#else
@group(0) @binding(13) var diffuse_environment_map: texture_cube<f32>;
@group(0) @binding(14) var specular_environment_map: texture_cube<f32>;
@group(0) @binding(14) var diffuse_environment_map: texture_cube<f32>;
@group(0) @binding(15) var specular_environment_map: texture_cube<f32>;
#endif
@group(0) @binding(15) var environment_map_sampler: sampler;
@group(0) @binding(16) var environment_map_sampler: sampler;
#ifdef IRRADIANCE_VOLUMES_ARE_USABLE
#ifdef MULTIPLE_LIGHT_PROBES_IN_ARRAY
@group(0) @binding(16) var irradiance_volumes: binding_array<texture_3d<f32>, 8u>;
@group(0) @binding(17) var irradiance_volumes: binding_array<texture_3d<f32>, 8u>;
#else
@group(0) @binding(16) var irradiance_volume: texture_3d<f32>;
@group(0) @binding(17) var irradiance_volume: texture_3d<f32>;
#endif
@group(0) @binding(17) var irradiance_volume_sampler: sampler;
@group(0) @binding(18) var irradiance_volume_sampler: sampler;
#endif
// NB: If you change these, make sure to update `tonemapping_shared.wgsl` too.
@group(0) @binding(18) var dt_lut_texture: texture_3d<f32>;
@group(0) @binding(19) var dt_lut_sampler: sampler;
@group(0) @binding(19) var dt_lut_texture: texture_3d<f32>;
@group(0) @binding(20) var dt_lut_sampler: sampler;
#ifdef MULTISAMPLED
#ifdef DEPTH_PREPASS
@group(0) @binding(20) var depth_prepass_texture: texture_depth_multisampled_2d;
@group(0) @binding(21) var depth_prepass_texture: texture_depth_multisampled_2d;
#endif // DEPTH_PREPASS
#ifdef NORMAL_PREPASS
@group(0) @binding(21) var normal_prepass_texture: texture_multisampled_2d<f32>;
@group(0) @binding(22) var normal_prepass_texture: texture_multisampled_2d<f32>;
#endif // NORMAL_PREPASS
#ifdef MOTION_VECTOR_PREPASS
@group(0) @binding(22) var motion_vector_prepass_texture: texture_multisampled_2d<f32>;
@group(0) @binding(23) var motion_vector_prepass_texture: texture_multisampled_2d<f32>;
#endif // MOTION_VECTOR_PREPASS
#else // MULTISAMPLED
#ifdef DEPTH_PREPASS
@group(0) @binding(20) var depth_prepass_texture: texture_depth_2d;
@group(0) @binding(21) var depth_prepass_texture: texture_depth_2d;
#endif // DEPTH_PREPASS
#ifdef NORMAL_PREPASS
@group(0) @binding(21) var normal_prepass_texture: texture_2d<f32>;
@group(0) @binding(22) var normal_prepass_texture: texture_2d<f32>;
#endif // NORMAL_PREPASS
#ifdef MOTION_VECTOR_PREPASS
@group(0) @binding(22) var motion_vector_prepass_texture: texture_2d<f32>;
@group(0) @binding(23) var motion_vector_prepass_texture: texture_2d<f32>;
#endif // MOTION_VECTOR_PREPASS
#endif // MULTISAMPLED
#ifdef DEFERRED_PREPASS
@group(0) @binding(23) var deferred_prepass_texture: texture_2d<u32>;
@group(0) @binding(24) var deferred_prepass_texture: texture_2d<u32>;
#endif // DEFERRED_PREPASS
@group(0) @binding(24) var view_transmission_texture: texture_2d<f32>;
@group(0) @binding(25) var view_transmission_sampler: sampler;
@group(0) @binding(25) var view_transmission_texture: texture_2d<f32>;
@group(0) @binding(26) var view_transmission_sampler: sampler;

View file

@ -11,6 +11,7 @@
#else
#import bevy_pbr::{
forward_io::{VertexOutput, FragmentOutput},
pbr_functions,
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT,
}
@ -34,6 +35,12 @@ fn fragment(
let is_front = true;
#endif
// If we're in the crossfade section of a visibility range, conditionally
// discard the fragment according to the visibility pattern.
#ifdef VISIBILITY_RANGE_DITHER
pbr_functions::visibility_range_dither(in.position, in.visibility_range_dither);
#endif
// generate a PbrInput struct from the StandardMaterial bindings
var pbr_input = pbr_input_from_standard_material(in, is_front);

View file

@ -21,6 +21,55 @@
#import bevy_core_pipeline::tonemapping::{screen_space_dither, powsafe, tone_mapping}
// This is the standard 4x4 ordered dithering pattern from [1].
//
// We can't use `array<vec4<u32>, 4>` because they can't be indexed dynamically
// due to Naga limitations. So instead we pack into a single `vec4` and extract
// individual bytes.
//
// [1]: https://en.wikipedia.org/wiki/Ordered_dithering#Threshold_map
const DITHER_THRESHOLD_MAP: vec4<u32> = vec4(
0x0a020800,
0x060e040c,
0x09010b03,
0x050d070f
);
// Processes a visibility range dither value and discards the fragment if
// needed.
//
// Visibility ranges, also known as HLODs, are crossfades between different
// levels of detail.
//
// The `dither` value ranges from [-16, 16]. When zooming out, positive values
// are used for meshes that are in the process of disappearing, while negative
// values are used for meshes that are in the process of appearing. In other
// words, when the camera is moving backwards, the `dither` value counts up from
// -16 to 0 when the object is fading in, stays at 0 while the object is
// visible, and then counts up to 16 while the object is fading out.
// Distinguishing between negative and positive values allows the dither
// patterns for different LOD levels of a single mesh to mesh together properly.
#ifdef VISIBILITY_RANGE_DITHER
fn visibility_range_dither(frag_coord: vec4<f32>, dither: i32) {
// If `dither` is 0, the object is visible.
if (dither == 0) {
return;
}
// If `dither` is less than -15 or greater than 15, the object is culled.
if (dither <= -16 || dither >= 16) {
discard;
}
// Otherwise, check the dither pattern.
let coords = vec2<u32>(floor(frag_coord.xy)) % 4u;
let threshold = i32((DITHER_THRESHOLD_MAP[coords.y] >> (coords.x * 8)) & 0xff);
if ((dither >= 0 && dither + threshold >= 16) || (dither < 0 && 1 + dither + threshold <= 0)) {
discard;
}
}
#endif
fn alpha_discard(material: pbr_types::StandardMaterial, output_color: vec4<f32>) -> vec4<f32> {
var color = output_color;
let alpha_mode = material.flags & pbr_types::STANDARD_MATERIAL_FLAGS_ALPHA_MODE_RESERVED_BITS;

View file

@ -107,7 +107,11 @@ impl Plugin for ViewPlugin {
.register_type::<ColorGrading>()
.init_resource::<Msaa>()
// NOTE: windows.is_changed() handles cases where a window was resized
.add_plugins((ExtractResourcePlugin::<Msaa>::default(), VisibilityPlugin));
.add_plugins((
ExtractResourcePlugin::<Msaa>::default(),
VisibilityPlugin,
VisibilityRangePlugin,
));
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.init_resource::<ViewUniforms>().add_systems(

View file

@ -1,14 +1,15 @@
mod range;
mod render_layers;
use std::any::TypeId;
use bevy_derive::Deref;
use bevy_ecs::query::QueryFilter;
pub use range::*;
pub use render_layers::*;
use bevy_app::{Plugin, PostUpdate};
use bevy_asset::{Assets, Handle};
use bevy_ecs::prelude::*;
use bevy_derive::Deref;
use bevy_ecs::{prelude::*, query::QueryFilter};
use bevy_hierarchy::{Children, Parent};
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_transform::{components::GlobalTransform, TransformSystem};
@ -395,6 +396,7 @@ fn reset_view_visibility(mut query: Query<&mut ViewVisibility>) {
pub fn check_visibility<QF>(
mut thread_queues: Local<Parallel<Vec<Entity>>>,
mut view_query: Query<(
Entity,
&mut VisibleEntities,
&Frustum,
Option<&RenderLayers>,
@ -410,13 +412,18 @@ pub fn check_visibility<QF>(
Option<&Aabb>,
&GlobalTransform,
Has<NoFrustumCulling>,
Has<VisibilityRange>,
),
QF,
>,
visible_entity_ranges: Option<Res<VisibleEntityRanges>>,
) where
QF: QueryFilter + 'static,
{
for (mut visible_entities, frustum, maybe_view_mask, camera, no_cpu_culling) in &mut view_query
let visible_entity_ranges = visible_entity_ranges.as_deref();
for (view, mut visible_entities, frustum, maybe_view_mask, camera, no_cpu_culling) in
&mut view_query
{
if !camera.is_active {
continue;
@ -435,6 +442,7 @@ pub fn check_visibility<QF>(
maybe_model_aabb,
transform,
no_frustum_culling,
has_visibility_range,
) = query_item;
// Skip computing visibility for entities that are configured to be hidden.
@ -448,6 +456,15 @@ pub fn check_visibility<QF>(
return;
}
// If outside of the visibility range, cull.
if has_visibility_range
&& visible_entity_ranges.is_some_and(|visible_entity_ranges| {
!visible_entity_ranges.entity_is_in_range_of_view(entity, view)
})
{
return;
}
// If we have an aabb, do frustum culling
if !no_frustum_culling && !no_cpu_culling {
if let Some(model_aabb) = maybe_model_aabb {

View file

@ -0,0 +1,437 @@
//! Specific distances from the camera in which entities are visible, also known
//! as *hierarchical levels of detail* or *HLOD*s.
use std::{
hash::{Hash, Hasher},
ops::Range,
};
use bevy_app::{App, Plugin, PostUpdate};
use bevy_ecs::{
component::Component,
entity::Entity,
query::{Changed, With},
schedule::IntoSystemConfigs as _,
system::{Query, Res, ResMut, Resource},
};
use bevy_math::{vec4, FloatOrd, Vec4};
use bevy_reflect::Reflect;
use bevy_transform::components::GlobalTransform;
use bevy_utils::{prelude::default, EntityHashMap, HashMap};
use nonmax::NonMaxU16;
use wgpu::BufferUsages;
use crate::{
camera::Camera,
render_resource::BufferVec,
renderer::{RenderDevice, RenderQueue},
Extract, ExtractSchedule, Render, RenderApp, RenderSet,
};
use super::{check_visibility, VisibilitySystems, WithMesh};
/// We need at least 4 storage buffer bindings available to enable the
/// visibility range buffer.
///
/// Even though we only use one storage buffer, the first 3 available storage
/// buffers will go to various light-related buffers. We will grab the fourth
/// buffer slot.
pub const VISIBILITY_RANGES_STORAGE_BUFFER_COUNT: u32 = 4;
/// A plugin that enables [`VisibilityRange`]s, which allow entities to be
/// hidden or shown based on distance to the camera.
pub struct VisibilityRangePlugin;
impl Plugin for VisibilityRangePlugin {
fn build(&self, app: &mut App) {
app.register_type::<VisibilityRange>()
.init_resource::<VisibleEntityRanges>()
.add_systems(
PostUpdate,
check_visibility_ranges
.in_set(VisibilitySystems::CheckVisibility)
.before(check_visibility::<WithMesh>),
);
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.init_resource::<RenderVisibilityRanges>()
.add_systems(ExtractSchedule, extract_visibility_ranges)
.add_systems(
Render,
write_render_visibility_ranges.in_set(RenderSet::PrepareResourcesFlush),
);
}
}
/// Specifies the range of distances that this entity must be from the camera in
/// order to be rendered.
///
/// This is also known as *hierarchical level of detail* or *HLOD*.
///
/// Use this component when you want to render a high-polygon mesh when the
/// camera is close and a lower-polygon mesh when the camera is far away. This
/// is a common technique for improving performance, because fine details are
/// hard to see in a mesh at a distance. To avoid an artifact known as *popping*
/// between levels, each level has a *margin*, within which the object
/// transitions gradually from invisible to visible using a dithering effect.
///
/// You can also use this feature to replace multiple meshes with a single mesh
/// when the camera is distant. This is the reason for the term "*hierarchical*
/// level of detail". Reducing the number of meshes can be useful for reducing
/// drawcall count. Note that you must place the [`VisibilityRange`] component
/// on each entity you want to be part of a LOD group, as [`VisibilityRange`]
/// isn't automatically propagated down to children.
///
/// A typical use of this feature might look like this:
///
/// | Entity | `start_margin` | `end_margin` |
/// |-------------------------|----------------|--------------|
/// | Root | N/A | N/A |
/// | ├─ High-poly mesh | [0, 0) | [20, 25) |
/// | ├─ Low-poly mesh | [20, 25) | [70, 75) |
/// | └─ Billboard *imposter* | [70, 75) | [150, 160) |
///
/// With this setup, the user will see a high-poly mesh when the camera is
/// closer than 20 units. As the camera zooms out, between 20 units to 25 units,
/// the high-poly mesh will gradually fade to a low-poly mesh. When the camera
/// is 70 to 75 units away, the low-poly mesh will fade to a single textured
/// quad. And between 150 and 160 units, the object fades away entirely. Note
/// that the `end_margin` of a higher LOD is always identical to the
/// `start_margin` of the next lower LOD; this is important for the crossfade
/// effect to function properly.
#[derive(Component, Clone, PartialEq, Reflect)]
pub struct VisibilityRange {
/// The range of distances, in world units, between which this entity will
/// smoothly fade into view as the camera zooms out.
///
/// If the start and end of this range are identical, the transition will be
/// abrupt, with no crossfading.
///
/// `start_margin.end` must be less than or equal to `end_margin.start`.
pub start_margin: Range<f32>,
/// The range of distances, in world units, between which this entity will
/// smoothly fade out of view as the camera zooms out.
///
/// If the start and end of this range are identical, the transition will be
/// abrupt, with no crossfading.
///
/// `end_margin.start` must be greater than or equal to `start_margin.end`.
pub end_margin: Range<f32>,
}
impl Eq for VisibilityRange {}
impl Hash for VisibilityRange {
fn hash<H>(&self, state: &mut H)
where
H: Hasher,
{
FloatOrd(self.start_margin.start).hash(state);
FloatOrd(self.start_margin.end).hash(state);
FloatOrd(self.end_margin.start).hash(state);
FloatOrd(self.end_margin.end).hash(state);
}
}
impl VisibilityRange {
/// Creates a new *abrupt* visibility range, with no crossfade.
///
/// There will be no crossfade; the object will immediately vanish if the
/// camera is closer than `start` units or farther than `end` units from the
/// model.
///
/// The `start` value must be less than or equal to the `end` value.
#[inline]
pub fn abrupt(start: f32, end: f32) -> Self {
Self {
start_margin: start..start,
end_margin: end..end,
}
}
/// Returns true if both the start and end transitions for this range are
/// abrupt: that is, there is no crossfading.
#[inline]
pub fn is_abrupt(&self) -> bool {
self.start_margin.start == self.start_margin.end
&& self.end_margin.start == self.end_margin.end
}
/// Returns true if the object will be visible at all, given a camera
/// `camera_distance` units away.
///
/// Any amount of visibility, even with the heaviest dithering applied, is
/// considered visible according to this check.
#[inline]
pub fn is_visible_at_all(&self, camera_distance: f32) -> bool {
camera_distance >= self.start_margin.start && camera_distance < self.end_margin.end
}
/// Returns true if the object is completely invisible, given a camera
/// `camera_distance` units away.
///
/// This is equivalent to `!VisibilityRange::is_visible_at_all()`.
#[inline]
pub fn is_culled(&self, camera_distance: f32) -> bool {
!self.is_visible_at_all(camera_distance)
}
}
/// Stores information related to [`VisibilityRange`]s in the render world.
#[derive(Resource)]
pub struct RenderVisibilityRanges {
/// Information corresponding to each entity.
entities: EntityHashMap<Entity, RenderVisibilityEntityInfo>,
/// Maps a [`VisibilityRange`] to its index within the `buffer`.
///
/// This map allows us to deduplicate identical visibility ranges, which
/// saves GPU memory.
range_to_index: HashMap<VisibilityRange, NonMaxU16>,
/// The GPU buffer that stores [`VisibilityRange`]s.
///
/// Each [`Vec4`] contains the start margin start, start margin end, end
/// margin start, and end margin end distances, in that order.
buffer: BufferVec<Vec4>,
/// True if the buffer has been changed since the last frame and needs to be
/// reuploaded to the GPU.
buffer_dirty: bool,
}
/// Per-entity information related to [`VisibilityRange`]s.
struct RenderVisibilityEntityInfo {
/// The index of the range within the GPU buffer.
buffer_index: NonMaxU16,
/// True if the range is abrupt: i.e. has no crossfade.
is_abrupt: bool,
}
impl Default for RenderVisibilityRanges {
fn default() -> Self {
Self {
entities: default(),
range_to_index: default(),
buffer: BufferVec::new(
BufferUsages::STORAGE | BufferUsages::UNIFORM | BufferUsages::VERTEX,
),
buffer_dirty: true,
}
}
}
impl RenderVisibilityRanges {
/// Clears out the [`RenderVisibilityRanges`] in preparation for a new
/// frame.
fn clear(&mut self) {
self.entities.clear();
self.range_to_index.clear();
self.buffer.clear();
self.buffer_dirty = true;
}
/// Inserts a new entity into the [`RenderVisibilityRanges`].
fn insert(&mut self, entity: Entity, visibility_range: &VisibilityRange) {
// Grab a slot in the GPU buffer, or take the existing one if there
// already is one.
let buffer_index = *self
.range_to_index
.entry(visibility_range.clone())
.or_insert_with(|| {
NonMaxU16::try_from(self.buffer.push(vec4(
visibility_range.start_margin.start,
visibility_range.start_margin.end,
visibility_range.end_margin.start,
visibility_range.end_margin.end,
)) as u16)
.unwrap_or_default()
});
self.entities.insert(
entity,
RenderVisibilityEntityInfo {
buffer_index,
is_abrupt: visibility_range.is_abrupt(),
},
);
}
/// Returns the index in the GPU buffer corresponding to the visible range
/// for the given entity.
///
/// If the entity has no visible range, returns `None`.
#[inline]
pub fn lod_index_for_entity(&self, entity: Entity) -> Option<NonMaxU16> {
self.entities.get(&entity).map(|info| info.buffer_index)
}
/// Returns true if the entity has a visibility range and it isn't abrupt:
/// i.e. if it has a crossfade.
#[inline]
pub fn entity_has_crossfading_visibility_ranges(&self, entity: Entity) -> bool {
self.entities
.get(&entity)
.is_some_and(|info| !info.is_abrupt)
}
/// Returns a reference to the GPU buffer that stores visibility ranges.
#[inline]
pub fn buffer(&self) -> &BufferVec<Vec4> {
&self.buffer
}
}
/// Stores which entities are in within the [`VisibilityRange`]s of views.
///
/// This doesn't store the results of frustum or occlusion culling; use
/// [`super::ViewVisibility`] for that. Thus entities in this list may not
/// actually be visible.
///
/// For efficiency, these tables only store entities that have
/// [`VisibilityRange`] components. Entities without such a component won't be
/// in these tables at all.
///
/// The table is indexed by entity and stores a 32-bit bitmask with one bit for
/// each camera, where a 0 bit corresponds to "out of range" and a 1 bit
/// corresponds to "in range". Hence it's limited to storing information for 32
/// views.
#[derive(Resource, Default)]
pub struct VisibleEntityRanges {
/// Stores which bit index each view corresponds to.
views: EntityHashMap<Entity, u8>,
/// Stores a bitmask in which each view has a single bit.
///
/// A 0 bit for a view corresponds to "out of range"; a 1 bit corresponds to
/// "in range".
entities: EntityHashMap<Entity, u32>,
}
impl VisibleEntityRanges {
/// Clears out the [`VisibleEntityRanges`] in preparation for a new frame.
fn clear(&mut self) {
self.views.clear();
self.entities.clear();
}
/// Returns true if the entity is in range of the given camera.
///
/// This only checks [`VisibilityRange`]s and doesn't perform any frustum or
/// occlusion culling. Thus the entity might not *actually* be visible.
///
/// The entity is assumed to have a [`VisibilityRange`] component. If the
/// entity doesn't have that component, this method will return false.
#[inline]
pub fn entity_is_in_range_of_view(&self, entity: Entity, view: Entity) -> bool {
let Some(visibility_bitmask) = self.entities.get(&entity) else {
return false;
};
let Some(view_index) = self.views.get(&view) else {
return false;
};
(visibility_bitmask & (1 << view_index)) != 0
}
/// Returns true if the entity is in range of any view.
///
/// This only checks [`VisibilityRange`]s and doesn't perform any frustum or
/// occlusion culling. Thus the entity might not *actually* be visible.
///
/// The entity is assumed to have a [`VisibilityRange`] component. If the
/// entity doesn't have that component, this method will return false.
#[inline]
pub fn entity_is_in_range_of_any_view(&self, entity: Entity) -> bool {
self.entities.contains_key(&entity)
}
}
/// Checks all entities against all views in order to determine which entities
/// with [`VisibilityRange`]s are potentially visible.
///
/// This only checks distance from the camera and doesn't frustum or occlusion
/// cull.
pub fn check_visibility_ranges(
mut visible_entity_ranges: ResMut<VisibleEntityRanges>,
view_query: Query<(Entity, &GlobalTransform), With<Camera>>,
mut entity_query: Query<(Entity, &GlobalTransform, &VisibilityRange)>,
) {
visible_entity_ranges.clear();
// Early out if the visibility range feature isn't in use.
if entity_query.is_empty() {
return;
}
// Assign an index to each view.
let mut views = vec![];
for (view, view_transform) in view_query.iter().take(32) {
let view_index = views.len() as u8;
visible_entity_ranges.views.insert(view, view_index);
views.push((view, view_transform.translation_vec3a()));
}
// Check each entity/view pair. Only consider entities with
// [`VisibilityRange`] components.
for (entity, entity_transform, visibility_range) in entity_query.iter_mut() {
let mut visibility = 0;
for (view_index, &(_, view_position)) in views.iter().enumerate() {
if visibility_range
.is_visible_at_all((view_position - entity_transform.translation_vec3a()).length())
{
visibility |= 1 << view_index;
}
}
// Invisible entities have no entry at all in the hash map. This speeds
// up checks slightly in this common case.
if visibility != 0 {
visible_entity_ranges.entities.insert(entity, visibility);
}
}
}
/// Extracts all [`VisibilityRange`] components from the main world to the
/// render world and inserts them into [`RenderVisibilityRanges`].
pub fn extract_visibility_ranges(
mut render_visibility_ranges: ResMut<RenderVisibilityRanges>,
visibility_ranges_query: Extract<Query<(Entity, &VisibilityRange)>>,
changed_ranges_query: Extract<Query<Entity, Changed<VisibilityRange>>>,
) {
if changed_ranges_query.is_empty() {
return;
}
render_visibility_ranges.clear();
for (entity, visibility_range) in visibility_ranges_query.iter() {
render_visibility_ranges.insert(entity, visibility_range);
}
}
/// Writes the [`RenderVisibilityRanges`] table to the GPU.
pub fn write_render_visibility_ranges(
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
mut render_visibility_ranges: ResMut<RenderVisibilityRanges>,
) {
// If there haven't been any changes, early out.
if !render_visibility_ranges.buffer_dirty {
return;
}
// If the buffer is empty, push *something* so that we allocate it.
if render_visibility_ranges.buffer.is_empty() {
render_visibility_ranges.buffer.push(default());
}
// Schedule the write.
render_visibility_ranges
.buffer
.write_buffer(&render_device, &render_queue);
render_visibility_ranges.buffer_dirty = false;
}

View file

@ -0,0 +1,337 @@
//! Demonstrates visibility ranges, also known as HLODs.
use std::f32::consts::PI;
use bevy::{
input::mouse::MouseWheel,
math::vec3,
pbr::{light_consts::lux::FULL_DAYLIGHT, CascadeShadowConfigBuilder},
prelude::*,
render::view::VisibilityRange,
};
// Where the camera is focused.
const CAMERA_FOCAL_POINT: Vec3 = vec3(0.0, 0.3, 0.0);
// Speed in units per frame.
const CAMERA_KEYBOARD_ZOOM_SPEED: f32 = 0.05;
// Speed in radians per frame.
const CAMERA_KEYBOARD_PAN_SPEED: f32 = 0.01;
// Speed in units per frame.
const CAMERA_MOUSE_MOVEMENT_SPEED: f32 = 0.25;
// The minimum distance that the camera is allowed to be from the model.
const MIN_ZOOM_DISTANCE: f32 = 0.5;
// The visibility ranges for high-poly and low-poly models respectively, when
// both models are being shown.
static NORMAL_VISIBILITY_RANGE_HIGH_POLY: VisibilityRange = VisibilityRange {
start_margin: 0.0..0.0,
end_margin: 3.0..4.0,
};
static NORMAL_VISIBILITY_RANGE_LOW_POLY: VisibilityRange = VisibilityRange {
start_margin: 3.0..4.0,
end_margin: 8.0..9.0,
};
// A visibility model that we use to always show a model (until the camera is so
// far zoomed out that it's culled entirely).
static SINGLE_MODEL_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
start_margin: 0.0..0.0,
end_margin: 8.0..9.0,
};
// A visibility range that we use to completely hide a model.
static INVISIBLE_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
start_margin: 0.0..0.0,
end_margin: 0.0..0.0,
};
// Allows us to identify the main model.
#[derive(Component, Debug, Clone, Copy, PartialEq)]
enum MainModel {
// The high-poly version.
HighPoly,
// The low-poly version.
LowPoly,
}
// The current mode.
#[derive(Default, Resource)]
struct AppStatus {
// Whether to show only one model.
show_one_model_only: Option<MainModel>,
}
// Sets up the app.
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Visibility Range Example".into(),
..default()
}),
..default()
}))
.init_resource::<AppStatus>()
.add_systems(Startup, setup)
.add_systems(
Update,
(
move_camera,
set_visibility_ranges,
update_help_text,
update_mode,
),
)
.run();
}
// Set up a simple 3D scene. Load the two meshes.
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
asset_server: Res<AssetServer>,
app_status: Res<AppStatus>,
) {
// Spawn a plane.
commands.spawn(PbrBundle {
mesh: meshes.add(Plane3d::default().mesh().size(50.0, 50.0)),
material: materials.add(Color::srgb(0.1, 0.2, 0.1)),
..default()
});
// Spawn the two HLODs.
commands
.spawn(SceneBundle {
scene: asset_server.load("models/FlightHelmet/FlightHelmet.gltf#Scene0"),
..default()
})
.insert(MainModel::HighPoly);
commands
.spawn(SceneBundle {
scene: asset_server.load("models/FlightHelmetLowPoly/FlightHelmetLowPoly.gltf#Scene0"),
..default()
})
.insert(MainModel::LowPoly);
// Spawn a light.
commands.spawn(DirectionalLightBundle {
directional_light: DirectionalLight {
illuminance: FULL_DAYLIGHT,
shadows_enabled: true,
..default()
},
transform: Transform::from_rotation(Quat::from_euler(
EulerRot::ZYX,
0.0,
PI * -0.15,
PI * -0.15,
)),
cascade_shadow_config: CascadeShadowConfigBuilder {
maximum_distance: 30.0,
first_cascade_far_bound: 0.9,
..default()
}
.into(),
..default()
});
// Spawn a camera.
commands
.spawn(Camera3dBundle {
transform: Transform::from_xyz(0.7, 0.7, 1.0).looking_at(CAMERA_FOCAL_POINT, Vec3::Y),
..default()
})
.insert(EnvironmentMapLight {
diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"),
specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"),
intensity: 150.0,
});
// Create the text.
commands.spawn(
TextBundle {
text: app_status.create_text(&asset_server),
..TextBundle::default()
}
.with_style(Style {
position_type: PositionType::Absolute,
bottom: Val::Px(10.0),
left: Val::Px(10.0),
..default()
}),
);
}
// We need to add the `VisibilityRange` components manually, as glTF currently
// has no way to specify visibility ranges. This system watches for new meshes,
// determines which `Scene` they're under, and adds the `VisibilityRange`
// component as appropriate.
fn set_visibility_ranges(
mut commands: Commands,
mut new_meshes: Query<Entity, Added<Handle<Mesh>>>,
parents: Query<(Option<&Parent>, Option<&MainModel>)>,
) {
// Loop over each newly-added mesh.
for new_mesh in new_meshes.iter_mut() {
// Search for the nearest ancestor `MainModel` component.
let (mut current, mut main_model) = (new_mesh, None);
while let Ok((parent, maybe_main_model)) = parents.get(current) {
if let Some(model) = maybe_main_model {
main_model = Some(model);
break;
}
match parent {
Some(parent) => current = **parent,
None => break,
}
}
// Add the `VisibilityRange` component.
match main_model {
Some(MainModel::HighPoly) => {
commands
.entity(new_mesh)
.insert(NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone())
.insert(MainModel::HighPoly);
}
Some(MainModel::LowPoly) => {
commands
.entity(new_mesh)
.insert(NORMAL_VISIBILITY_RANGE_LOW_POLY.clone())
.insert(MainModel::LowPoly);
}
None => {}
}
}
}
// Process the movement controls.
fn move_camera(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut mouse_wheel_events: EventReader<MouseWheel>,
mut cameras: Query<&mut Transform, With<Camera3d>>,
) {
let (mut zoom_delta, mut theta_delta) = (0.0, 0.0);
// Process zoom in and out via the keyboard.
if keyboard_input.pressed(KeyCode::KeyW) || keyboard_input.pressed(KeyCode::ArrowUp) {
zoom_delta -= CAMERA_KEYBOARD_ZOOM_SPEED;
} else if keyboard_input.pressed(KeyCode::KeyS) || keyboard_input.pressed(KeyCode::ArrowDown) {
zoom_delta += CAMERA_KEYBOARD_ZOOM_SPEED;
}
// Process left and right pan via the keyboard.
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
theta_delta -= CAMERA_KEYBOARD_PAN_SPEED;
} else if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
theta_delta += CAMERA_KEYBOARD_PAN_SPEED;
}
// Process zoom in and out via the mouse wheel.
for event in mouse_wheel_events.read() {
zoom_delta -= event.y * CAMERA_MOUSE_MOVEMENT_SPEED;
}
// Update the camera transform.
for transform in cameras.iter_mut() {
let transform = transform.into_inner();
let direction = transform.translation.normalize_or_zero();
let magnitude = transform.translation.length();
let new_direction = Mat3::from_rotation_y(theta_delta) * direction;
let new_magnitude = (magnitude + zoom_delta).max(MIN_ZOOM_DISTANCE);
transform.translation = new_direction * new_magnitude;
transform.look_at(CAMERA_FOCAL_POINT, Vec3::Y);
}
}
// Toggles modes if the user requests.
fn update_mode(
mut meshes: Query<(&mut VisibilityRange, &MainModel)>,
keyboard_input: Res<ButtonInput<KeyCode>>,
mut app_status: ResMut<AppStatus>,
) {
// Toggle the mode as requested.
if keyboard_input.just_pressed(KeyCode::Digit1) || keyboard_input.just_pressed(KeyCode::Numpad1)
{
app_status.show_one_model_only = None;
} else if keyboard_input.just_pressed(KeyCode::Digit2)
|| keyboard_input.just_pressed(KeyCode::Numpad2)
{
app_status.show_one_model_only = Some(MainModel::HighPoly);
} else if keyboard_input.just_pressed(KeyCode::Digit3)
|| keyboard_input.just_pressed(KeyCode::Numpad3)
{
app_status.show_one_model_only = Some(MainModel::LowPoly);
} else {
return;
}
// Update the visibility ranges as appropriate.
for (mut visibility_range, main_model) in meshes.iter_mut() {
*visibility_range = match (main_model, app_status.show_one_model_only) {
(&MainModel::HighPoly, Some(MainModel::LowPoly))
| (&MainModel::LowPoly, Some(MainModel::HighPoly)) => {
INVISIBLE_VISIBILITY_RANGE.clone()
}
(&MainModel::HighPoly, Some(MainModel::HighPoly))
| (&MainModel::LowPoly, Some(MainModel::LowPoly)) => {
SINGLE_MODEL_VISIBILITY_RANGE.clone()
}
(&MainModel::HighPoly, None) => NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone(),
(&MainModel::LowPoly, None) => NORMAL_VISIBILITY_RANGE_LOW_POLY.clone(),
}
}
}
// A system that updates the help text.
fn update_help_text(
mut text_query: Query<&mut Text>,
app_status: Res<AppStatus>,
asset_server: Res<AssetServer>,
) {
for mut text in text_query.iter_mut() {
*text = app_status.create_text(&asset_server);
}
}
impl AppStatus {
// Creates and returns help text reflecting the app status.
fn create_text(&self, asset_server: &AssetServer) -> Text {
Text::from_section(
format!(
"\
{} (1) Switch from high-poly to low-poly based on camera distance
{} (2) Show only the high-poly model
{} (3) Show only the low-poly model
Press 1, 2, or 3 to switch which model is shown
Press WASD or use the mouse wheel to move the camera",
if self.show_one_model_only.is_none() {
'>'
} else {
' '
},
if self.show_one_model_only == Some(MainModel::HighPoly) {
'>'
} else {
' '
},
if self.show_one_model_only == Some(MainModel::LowPoly) {
'>'
} else {
' '
},
),
TextStyle {
font: asset_server.load("fonts/FiraMono-Medium.ttf"),
font_size: 24.0,
..default()
},
)
}
}

View file

@ -160,6 +160,7 @@ Example | Description
[Two Passes](../examples/3d/two_passes.rs) | Renders two 3d passes to the same window from different perspectives
[Update glTF Scene](../examples/3d/update_gltf_scene.rs) | Update a scene from a glTF file, either by spawning the scene as a child of another entity, or by accessing the entities of the scene
[Vertex Colors](../examples/3d/vertex_colors.rs) | Shows the use of vertex colors
[Visibility range](../examples/3d/visibility_range.rs) | Demonstrates visibility ranges
[Wireframe](../examples/3d/wireframe.rs) | Showcases wireframe rendering
## Animation