#25576·bevy

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)

Author: ricktaylorCreated Aug 27, 2026Updated Sep 17, 2026
LabelsC-BugA-RenderingC-PerformanceI-RegressionS-Ready-For-ImplementationD-Modest

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_driverCore3d 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.