Every point/spot shadow view executes a full Core3d schedule run, including the per-view GPU-preprocessing chain (13 runs/frame with 12 spot shadow casters)
- Dominant language
- Rust
- Stars
- 48.2k
- Forks
- 4.8k
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 171
Description
## Bevy version and features
0.19.1 (crates.io). Not default features:
`default-features = false`, features = `std`, `async_executor`, `multi_threaded`,
`bevy_asset`, `bevy_image`, `ktx2`, `zstd_rust`, `bevy_window`, `bevy_winit`,
`bevy_render`, `bevy_core_pipeline`, `bevy_pbr`, `bevy_post_process`,
`bevy_picking`, `mesh_picking`, `pbr_light_textures`, `tonemapping_luts`
## [Optional] Relevant system information
- Rust: cargo 1.97.1 (c980f4866 2026-06-30)
- OS: Windows 10
- Adapter:
AdapterInfo { name: "NVIDIA GeForce RTX 3060 Ti", vendor: 4318, device: 9353,
device_type: DiscreteGpu, device_pci_bus_id: "0000:01:00.0", driver: "NVIDIA",
driver_info: "595.95", backend: Vulkan, subgroup_min_size: 32,
subgroup_max_size: 32, transient_saves_memory: false }
## What you did
Rendered a large streamed scene (a Second Life-style world viewer): ~50k standing
mesh instances, one 3D camera, and up to 12 shadow-casting spot lights, with GPU
preprocessing active ("GPU preprocessing is fully supported on this device") so
draws are indirect. Frame times were far worse than GPU load explained (200–450 ms
frames at ~15% GPU utilization), so we instrumented the render schedule directly:
timestamp systems at every `RenderSystems` set boundary, inside the `RenderGraph`
schedule around the `Submit` set, and inside `Core3d` around its four
`Core3dSystems` sets — plus a counter system registered in `Core3d` to count
schedule executions per frame, classified camera-vs-auxiliary via `CurrentView` +
`ExtractedCamera`.
## What went wrong
- What we expected: shadow views add shadow-pass encode cost, roughly proportional
to caster count and shadow-map work.
- What actually happened: every point/spot shadow view executes the **full
`Core3d` schedule**. `bevy_pbr` spawns each spot shadow view (and each
point-light cube face) with `RootNonCameraView(Core3d.intern())`
(`bevy_pbr/src/render/light.rs`, two spawn sites), and `camera_driver`
(`bevy_core_pipeline/src/schedule.rs`) calls `world.run_schedule(Core3d)` once
per root view. With 12 spot shadow casters our in-schedule counter measured
**13–14 full `Core3d` executions per frame**.
The pass systems themselves are nearly free — all four `Core3dSystems` sets
total **8–10 ms/frame summed across all 13 runs** (draws are indirect). The
cost is the un-set systems that run on every schedule execution: `unpack_bins`,
`clear_indirect_parameters_metadata`, `early/late_gpu_preprocess`, the three
`build_indirect_parameters` stages (`bevy_pbr/src/render/gpu_preprocess.rs`,
registered `.before(early_prepass)` etc.), and the shadow-pass systems. This
out-of-set residue measured **110–257 ms/frame**, scaling with BOTH schedule-run
count (58 ms @ 3 runs → 137 ms @ 12 runs, same scene) AND instance count at a
fixed run count (115 → 257 ms as the world streamed in) — i.e., per-view ×
per-instance. Net effect: the render thread spends the frame re-running
instance-scaled indirect-draw bookkeeping once per shadow view, while the GPU
idles at 15%.
## Additional information
Ruled out empirically:
- **Archetype/entity accumulation**: render-world census flat (~319 archetypes,
~1k entities) while the cost doubled.
- **Executor setup as the main cost**: swapping `Core3d` to
`SingleThreadedExecutor` recovered only ~15–20% (~93–104 ms vs ~110–137 ms at
matched windows). Worth having — it cost nothing elsewhere — but not the story.
- **Shadow encode itself**: with all shadows disabled the camera's single run
still shows ~100 ms of the same out-of-set chain at 47k instances.
Workaround in use: `schedule.set_executor(SingleThreadedExecutor::new())` on
`Core3d` for the ~15–20%, and capping shadow-casting spot lights, each of which
costs a full schedule run (~9–12 ms at this scene size). Point lights would be 6×
that (one run per cube face).
Theories/suggestions: share or batch the GPU-preprocessing/indirect-parameters
chain across root views per frame instead of per schedule run, or give shadow
views a minimal schedule rather than full `Core3d`.
Related: #23215 (backtrace documenting the `camera_driver` → `Core3d` nesting),
#24448 (0.19 CPU regression, different mechanism), #11378 and discussion #8304
(executor per-invocation overhead — the ~15–20% component), #4724 / #17564
(archetype floor — ruled out here). Suggested labels: A-Rendering, C-Performance,
P-Regression.
Contributor guide
Research direction
Start by tracing camera_driver in bevy_core_pipeline/src/schedule.rs and the two shadow-view spawn sites in bevy_pbr/src/render/light.rs. Then inspect the GPU preprocessing stages in bevy_pbr/src/render/gpu_preprocess.rs and reproduce the schedule counts with the described instrumentation. Done means an agreed, tested rendering design prevents unnecessary per-shadow-view work while preserving shadow output and reducing CPU frame cost.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- computer-graphics, game-dev, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100