bevyengine / bevyengine/bevy

Clustered decals vanish at steep camera pitch when GPU clustering is active

Open
#24,918 1 comment 0 reactions 0 assignees View on GitHub
A-Rendering C-Bug D-Modest S-Ready-For-Implementation X-Uncontroversial
Dominant language
Rust
Stars
48.2k
Forks
4.8k
Avg merge
3d 16h
Merged PRs (30d)
171

Description

## Bevy version and features

- 0.19.0 (crates.io)
- default features + `pbr_clustered_decals`
- **also reproduces on `main`** (commit 0b906622a6410d28a83fa7d9ac01ab48563489c0, tested 2026-07-09): the same reproduction compiles unchanged and shows the same behavior

## \[Optional\] Relevant system information

- `cargo 1.96.0 (30a34c682 2026-05-25)`
- macOS 15.5 (Sequoia), Apple M4

```ignore
`AdapterInfo { name: "Apple M4", vendor: 0, device: 0, device_type: IntegratedGpu, device_pci_bus_id: "", driver: "", driver_info: "", backend: Metal, subgroup_min_size: 4, subgroup_max_size: 64, transient_saves_memory: true }`
```

Startup log confirms: "GPU clustering is supported on this device", and
`clustered_decals_are_usable` returns `true`.

## What you did

Spawned a 29×29 grid of `ClusteredDecal`s projecting onto a floor plane and
pitched the camera (at y = 1.6) from the horizon down to -90°.

Single-file reproduction below (no assets; the decal texture is generated).
The camera pitches down automatically over 12 seconds:

- watch the decals vanish as the pitch passes ~-50°;
- press **SPACE** to toggle GPU clustering off → all decals reappear,
including at -90°;
- press **SPACE** again (GPU clustering back on) → they vanish again;
- **R** restarts the sweep. The per-second log prints the pitch, the GPU
clustering state and the count of decals with `ViewVisibility == true`.

main.rs (full reproduction)

```rust
//! Repro: clustered decals disappear at steep camera pitch when GPU
//! clustering is active (bevy 0.19.0, macOS/Metal).
//!
//! A field of floor decals is viewed from y = 1.6. The camera
//! automatically pitches from the horizon down to -90° over 12 seconds.
//!
//! - With GPU clustering ON (default): the decals vanish somewhere between
//! -45° and -60° pitch and the floor is empty when looking straight down,
//! even though their `ViewVisibility` stays true (see the log).
//! - Press SPACE to toggle GPU clustering off (CPU fallback): the decals
//! render correctly at every pitch, including straight down.
//! - Press R to restart the pitch sweep.

use std::f32::consts::FRAC_PI_2;

use bevy::{
asset::RenderAssetUsages,
camera::{primitives::Aabb, visibility::ViewVisibility},
light::{cluster::GlobalClusterSettings, ClusteredDecal},
prelude::*,
render::render_resource::{Extent3d, TextureDimension, TextureFormat},
};

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (pitch_sweep, toggle_gpu_clustering, log_status))
.run();
}

#[derive(Resource, Default)]
struct Sweep {
elapsed: f32,
}

fn setup(
mut commands: Commands,
mut meshes: ResMut>,
mut materials: ResMut>,
mut images: ResMut>,
) {
commands.init_resource::();

commands.spawn((
DirectionalLight::default(),
Transform::from_xyz(10.0, 20.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y),
));

// Floor.
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(40.0, 40.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.6, 0.6, 0.6),
..default()
})),
));

// Decal texture: opaque orange square, generated so the repro needs no
// asset files.
let image = images.add(Image::new_fill(
Extent3d {
width: 64,
height: 64,
..default()
},
TextureDimension::D2,
&[255, 120, 30, 255],
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::default(),
));

// A dense grid of floor decals, each projecting straight down onto the
// plane (unit cube projecting along local -Z; rotated to look down).
// 29×29 = 841 decals, safely under the 1024-per-view cap.
for x in -14..=14 {
for z in -14..=14 {
commands.spawn((
ClusteredDecal {
base_color_texture: Some(image.clone()),
..default()
},
Transform::from_xyz(x as f32, 0.0, z as f32)
.with_scale(Vec3::new(0.35, 0.35, 1.0))
.looking_to(Vec3::NEG_Y, Vec3::Z),
));
}
}

// Camera at y = 1.6.
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 1.6, 0.0),
Msaa::Off,
));
}

/// Pitch the camera from the horizon (0°) down to -90° over 12 seconds.
fn pitch_sweep(
time: Res

/// SPACE toggles GPU clustering; the stashed settings restore it exactly.
fn toggle_gpu_clustering(
keys: Res>,
mut settings: ResMut,
mut stashed: Local>,
) {
if !keys.just_pressed(KeyCode::Space) {
return;
}
match settings.gpu_clustering.take() {
Some(gpu) => {
*stashed = Some(gpu);
info!("GPU clustering: OFF (CPU fallback) — decals render at all pitches");
}
None => {
settings.gpu_clustering = *stashed;
info!("GPU clustering: ON — decals vanish at steep pitch");
}
}
}

fn log_status(
sweep: Res,
settings: Res,
q_decals: Query<(&ViewVisibility, Has), With>,
mut last_log: Local,
) {
if sweep.elapsed - *last_log < 1.0 {
return;
}
*last_log = sweep.elapsed;
let pitch = -(sweep.elapsed / 12.0).min(1.0) * FRAC_PI_2;
let visible = q_decals.iter().filter(|(v, _)| v.get()).count();
info!(
"pitch {:>4.0}° | gpu_clustering {} | decals with ViewVisibility=true: {}",
pitch.to_degrees(),
if settings.gpu_clustering.is_some() { "ON " } else { "OFF" },
visible,
);
}
```

## What went wrong

Expected: decals render regardless of camera pitch (as in 0.18, and as the
CPU clustering path still does in 0.19).

Actually happened, with the (default) GPU clustering path:

- at ~0°…-30° pitch the decals render correctly;
- between roughly -45° and -60° they progressively disappear;
- looking straight down (-90°) the floor is completely empty.

The decals' `ViewVisibility` remains `true` the whole time — they pass
visibility/frustum culling but never reach the screen, which points at cluster
assignment rather than culling.

**Disabling GPU clustering restores correct rendering at every pitch** (set
`GlobalClusterSettings::gpu_clustering = None`; the CPU assignment path renders
the same scene correctly, matching bevy 0.18 behavior).

## Additional information

- Possibly related: #23438 ("Dynamic cluster resizing incorrectly uses UBO
limit when GPU clustering is active") — same code area
(`assign_objects_to_clusters`), different symptom.
- Prior platform breakage from the same feature: #23208 (Android), #23216
(WebGPU).
- Workaround used in our project: disable GPU light clustering at
`PostStartup` via `GlobalClusterSettings`.
- Happy to provide screenshots/video (renders fine at -30°, empty floor at
-90°, fixed with CPU clustering) or test patches on Metal.

## Screenshot examples

### GPU clustering

Image

### CPU clustering

Image

Contributor guide

Open the contributing guide

Research direction

Run the single-file main.rs reproduction and compare GPU clustering with the CPU fallback while pitching the camera to -90°. Start in assign_objects_to_clusters and the GPU clustering path, using GlobalClusterSettings to isolate the behavior. Done means clustered decals remain visible at steep camera pitches with GPU clustering enabled, matching the CPU path.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
computer-graphics
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.