Skip to content

Fix occasional flickering by ensuring that each invocation of the indirect parameters building shader only writes to the batch range it's supposed to.#24971

Open
pcwalton wants to merge 11 commits into
bevyengine:mainfrom
pcwalton:flickering-fix

Conversation

@pcwalton

@pcwalton pcwalton commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Since PR #23662, the shadows in the 3d_shapes demo occasionally flicker for a frame or two. This typically happens only when (a) there are many views and (b) objects move from one view to another. 3d_shapes fulfills requirement (a), as it contains a point light, which extracts to 6 shadow views; it also fulfills requirement (b), as the rotation of the objects changes their OBBs, which pushes them into and out of view of the different cubemap sides.

Correct rendering:
batch-slabs-good

Incorrect rendering:
batch-slabs-bad

The cause is quite subtle. For a long time, the indirect parameters building shader (build_indirect_params), which we dispatch right before rendering each render phase of each view, has been preparing the entire shared buffer of indirect draw parameters. This is wasteful, as there's no need to prepare the entire shared buffer of indirect draw parameters for all phases of all views when we're only about to render a portion of that buffer. Now up until #23662 landed, this was harmless to correctness, as the CPU prepared the indirect parameters metadata buffer that the indirect parameters building shader used before any of the GPU shaders ran. So the indirect parameters metadata buffer was always fully up to date, making the indirect parameters building correct, if inefficient. But, after #23662, which moved the logic that populates the indirect parameters metadata buffer from the CPU to the GPU, only the portion of the indirect parameters metadata corresponding to the batch ranges that are about to be drawn has been filled in by the time the indirect parameters building shader runs. Thus, when the indirect parameters building shader runs for mid-frame views and phases, some of the indirect parameters metadata buffer corresponds to data from this frame, and some of the indirect parameters metadata buffer corresponds to data from the previous frame. Because the indirect parameters metadata describes allocations within the indirect parameters buffer that must be nonoverlapping, this violates invariants, causing pieces of indirect parameter data to crash into and corrupt one another.

This commit fixes the issue by restricting the build_indirect_params shader to only work on the range of batches it's supposed to. This requires some extra CPU bookkeeping in order to pack the ranges of batch indices into a dynamic uniform buffer for that shader to read, as well as to ensure the batch range makes its way to the render graph system so that the correct number of workgroups can be dispatched. Fortunately, this fix is not only a correctness fix but also a performance improvement: it means that the GPU writes to each set of indirect draw parameters only once per frame instead of once per phase per view.

indirect parameters building shader only writes to the batch range it's
supposed to.

Since PR bevyengine#23662, the shadows in the `3d_shapes` demo occasionally
flicker for a frame or two. This typically happens only when (a) there
are many views and (b) objects move from one view to another.
`3d_shapes` fulfills requirement (a), as it contains a point light,
which extracts to 6 shadow views; it also fulfills requirement (b), as
the rotation of the objects changes their OBBs, which pushes them into
and out of view of the different cubemap sides.

The cause is quite subtle. For a long time, the indirect parameters
building shader (`build_indirect_params`), which we dispatch right
before rendering each render phase of each view, has been preparing the
*entire* shared buffer of indirect draw parameters. This is wasteful, as
there's no need to prepare the entire shared buffer of indirect draw
parameters for *all* phases of all views when we're only about to render
a portion of that buffer. Now up until bevyengine#23662 landed, this was harmless
to correctness, as the CPU prepared the *indirect parameters metadata*
buffer that the indirect parameters building shader used before any of
the GPU shaders ran. So the indirect parameters metadata buffer was
always fully up to date, making the indirect parameters building
correct, if inefficient. But, after bevyengine#23662, which moved the logic that
populates the indirect parameters metadata buffer from the CPU to the
GPU, only the portion of the indirect parameters metadata *corresponding
to the batch ranges that are about to be drawn* has been filled in by
the time the indirect parameters building shader runs. Thus, when the
indirect parameters building shader runs for mid-frame views and phases,
some of the indirect parameters metadata buffer corresponds to data from
this frame, and some of the indirect parameters metadata buffer
corresponds to data from the *previous* frame. Because the indirect
parameters metadata describes allocations within the indirect parameters
buffer that must be nonoverlapping, this violates invariants, causing
pieces of indirect parameter data to crash into and corrupt one another.

This commit fixes the issue by restricting the `build_indirect_params`
shader to only work on the range of batches it's supposed to. This
requires some extra CPU bookkeeping in order to pack the ranges of batch
indices into a dynamic uniform buffer for that shader to read, as well
as to ensure the batch range makes its way to the render graph system so
that the correct number of workgroups can be dispatched. Fortunately,
this fix is not only a correctness fix but also a performance
improvement: it means that the GPU writes to each set of indirect draw
parameters only once per frame instead of once per phase per view.
@pcwalton pcwalton added S-Needs-Review Needs reviewer attention (from anyone!) to move forward A-Rendering Drawing game state to the screen labels Jul 13, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Jul 13, 2026
@pcwalton

Copy link
Copy Markdown
Contributor Author

Please note that this doesn't fix the crash in Bistro that #23662 also surfaced. That will be a separate PR.

@pcwalton pcwalton added C-Bug An unexpected or incorrect behavior P-Regression Functionality that used to work but no longer does. Add a test for this! labels Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-24971

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@tychedelia tychedelia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there's some alignment error on macOS. The pixel eagle failure is real:

Caused by:
  In a CommandEncoder
    In a set_bind_group command
      Dynamic binding index 0 (targeting BindGroup with 'build_indexed_indirect_parameters_bind_group' label 0, binding 4) with value 704, does not respect device's requested `min_uniform_buffer_offset_alignment` limit: 256

2026-07-13T04:09:18.184217Z ERROR bevy_render::error_handler: Caught rendering error: Validation Error

I think you need to check min_uniform_buffer_offset_alignment for the stride if you want to use RawBufferVec.

pad_b: u32,
pad_c: vec4<u32>,
pad_d: vec4<u32>,
pad_e: vec4<u32>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these pads exist due to dynamic offset?

  1. The default limit min_uniform_buffer_offset_alignment is 256 bytes
  2. The struct size doesn't need to be equal to alignment. It just need to be written aligned.

You can use DynamicUniformBuffer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamicUniformBuffer uses encase so it’s slow.

@tychedelia tychedelia added S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Jul 13, 2026

// Record the batch ranges for this phase for the indirect batch set
// building shader to use.
indexed_preparer.flush_batch_range_to(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorted phases don't seem like they'd get build jobs at all, since only batch_and_prepare_binned_render_phase records batch ranges, and even there, unbatchables are allocated outside the recorded range.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m confused, what did you want me to change here?

@stuartparmenter stuartparmenter Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I read this PR, build_indirect_params used to process every batch in the metadata buffer (the old arrayLength bound), and now it only processes the ranges recorded in view_to_indirect_parameters_batch_range. So anything that never gets a range recorded never gets its indirect parameters built.

The only place ranges get recorded is this flush_batch_range_to call, which just covers what the MultidrawableBatchSetPreparer allocated. But a couple of other places allocate metadata in the same buffers and used to rely on the whole-buffer dispatch:

  • Sorted phases — batch_and_prepare_sorted_render_phase writes metadata for its batches (e.g. Transparent3d) but never records a range, so those views/phases never get a build job at all and run_build_indirect_parameters just skips them.
  • Unbatchables (and batchable-but-not-multidraw batches) in binned phases. Those get allocated in the first per-view loop, before the multidrawables loop runs, and the preparer captures its starting index after that. So they land outside every recorded range too.

Unless I'm missing something that fills those in, transparent and unbatchable meshes would end up drawing with stale or garbage indirect parameters. Seems like those allocation sites need to record ranges too, or the per-view range needs to be widened to cover them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, and this was showing up in the testbed as missing transparent meshes! I fixed this by pushing indirect parameter ranges for these types of meshes too.

@pcwalton pcwalton added S-Needs-Review Needs reviewer attention (from anyone!) to move forward and removed S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-24971

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

// We aren't using `DynamicUniformBuffer` because it uses `encase`,
// which is slow.
debug_assert_eq!(
self.buffer_alignment as usize % size_of::<IndirectParametersBuildJob>(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling alignment with RawBufferVec looks very fragile. And it should reserve memory before pushing in loop below.

I prefer to use DynamicUniformBuffer::get_writer for now which is simpler and directly writes to GPU buffer. I don't think it's slow as the struct is very small.

We can consider removing encase in future.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or just pad the struct to 256 bytes? I'm not sure if there is a performance impact.

I saw GpuBinUnpackingMetadata and GpuUniformAllocationMetadata are already padded to 256 bytes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I padded the struct to 256 bytes.

@github-actions

Copy link
Copy Markdown
Contributor

Your PR caused a change in the graphical output of an example or rendering test. This might be intentional, but it could also mean that something broke!
You can review it at https://pixel-eagle.com/project/B04F67C0-C054-4A6F-92EC-F599FEC2FD1D?filter=PR-24971

If it's expected, please add the M-Deliberate-Rendering-Change label.

If this change seems unrelated to your PR, you can consider updating your PR to target the latest main branch, either by rebasing or merging main into it.

@tychedelia

Copy link
Copy Markdown
Member

Just kicked off another example run but can confirm the failure on the testbed still
image

let uniform_offset = indirect_parameters_build_jobs.push(
IndirectParametersBuildJob::new(indirect_parameters_range.clone()),
);
build_indirect_parameters_metadata

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: I think we might leak retained view entities here as I don't see the BuildIndirectParametersMetadata being cleared?

@tychedelia tychedelia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ope, unfortunately the pixel eagle failures are real though:

Image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen C-Bug An unexpected or incorrect behavior P-Regression Functionality that used to work but no longer does. Add a test for this! S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage

Development

Successfully merging this pull request may close these issues.

4 participants