Skip to content

Allow external motion vector injection into prepass textures#22723

Open
kurtkuehnert wants to merge 2 commits into
bevyengine:mainfrom
kurtkuehnert:prepass-motion-vector-copy
Open

Allow external motion vector injection into prepass textures#22723
kurtkuehnert wants to merge 2 commits into
bevyengine:mainfrom
kurtkuehnert:prepass-motion-vector-copy

Conversation

@kurtkuehnert
Copy link
Copy Markdown
Contributor

Objective

Enable external passes to populate prepass motion vectors and normals by allowing copies into prepass textures and exposing a way to suppress the prepass clear when data is written earlier in the frame.

Motivation

Some pipelines need to produce motion vectors (for TAA) and normals outside the prepass. For example, a terrain pass renders once before everything else, and its depth/motion data should feed the prepass outputs without re‑rendering the terrain. Because prepass textures are already bound in the view bind group, they can’t be simultaneously bound as render attachments, so direct rendering into them isn’t possible. The practical workaround is to render into separate textures and copy over before the prepass. This PR makes that viable by allowing COPY_DST usage and by letting callers mark the prepass motion vector/normal attachments as already cleared to avoid the prepass overwriting externally provided data.
This isn’t a perfect solution, but the pattern is rare and the change is minimal and non‑invasive compared to alternatives like removing prepass textures from the view bindings or building a custom view bind group.

@kurtkuehnert kurtkuehnert added the A-Rendering Drawing game state to the screen label Jan 27, 2026
@IceSentry IceSentry added S-Needs-Review Needs reviewer attention (from anyone!) to move forward C-Usability A targeted quality-of-life change that makes Bevy easier to use labels Jan 27, 2026
@jasmine-nominal
Copy link
Copy Markdown
Contributor

I don't love this solution :/. Do you need to use the view bind group? Can you just bind the view yourself? Or I guess you want to bind all the view-space lighting data still?

@kurtkuehnert
Copy link
Copy Markdown
Contributor Author

I don't love this solution :/. Do you need to use the view bind group? Can you just bind the view yourself? Or I guess you want to bind all the view-space lighting data still?

Yeah I totally agree. This is not the optimal solution, but it is minimal in scope and gets the job done (for my use case at least).
Basically I want to render the terrain before the prepass, so that depth and motion vectors are already written correctly and depth testing (and potentially culling) can be more efficient.

Now the problem is, that rendering the terrain twice (prepass and main pass) is too expensive, so I can only run it once. I still want to integrate with lighting, etc.

The other solution would be to create a separate TerrainViewBindGroup, which just omits the prepass textures (binding 20-24 if I remember correctly. Unfortunately this is not currently possible, since not all parts in prepare_mesh_view_bind_groups are public (most are).

#[derive(Component)]
pub struct MeshViewBindGroup {
pub main: BindGroup,
pub binding_array: BindGroup,
pub empty: BindGroup,
}
pub fn prepare_mesh_view_bind_groups(
mut commands: Commands,
(render_device, pipeline_cache, render_adapter): (
Res<RenderDevice>,
Res<PipelineCache>,
Res<RenderAdapter>,
),
mesh_pipeline: Res<MeshPipeline>,
shadow_samplers: Res<ShadowSamplers>,
(light_meta, global_clusterable_object_meta): (
Res<LightMeta>,
Res<GlobalClusterableObjectMeta>,
),
fog_meta: Res<FogMeta>,
(view_uniforms, environment_map_uniform): (Res<ViewUniforms>, Res<EnvironmentMapUniformBuffer>),
views: Query<(
Entity,
&ViewShadowBindings,
&ViewClusterBindings,
&Msaa,
Option<&ScreenSpaceAmbientOcclusionResources>,
Option<&ViewPrepassTextures>,
Option<&ViewTransmissionTexture>,
&Tonemapping,
Option<&RenderViewLightProbes<EnvironmentMapLight>>,
Option<&RenderViewLightProbes<IrradianceVolume>>,
Has<OrderIndependentTransparencySettings>,
Option<&AtmosphereTextures>,
Has<ExtractedAtmosphere>,
Option<&ViewContactShadowsUniformOffset>,
)>,
(images, mut fallback_images, fallback_image, fallback_image_zero): (
Res<RenderAssets<GpuImage>>,
FallbackImageMsaa,
Res<FallbackImage>,
Res<FallbackImageZero>,
),
globals_buffer: Res<GlobalsBuffer>,
tonemapping_luts: Res<TonemappingLuts>,
light_probes_buffer: Res<LightProbesBuffer>,
visibility_ranges: Res<RenderVisibilityRanges>,
(ssr_buffer, contact_shadows_buffer): (
Res<ScreenSpaceReflectionsBuffer>,
Res<ContactShadowsBuffer>,
),
oit_buffers: Res<OitBuffers>,
(decals_buffer, render_decals, atmosphere_buffer, atmosphere_sampler, blue_noise): (
Res<DecalsBuffer>,
Res<RenderClusteredDecals>,
Option<Res<AtmosphereBuffer>>,
Option<Res<AtmosphereSampler>>,
Res<Bluenoise>,
),
) {
if let (
Some(view_binding),
Some(light_binding),
Some(clusterable_objects_binding),
Some(globals),
Some(fog_binding),
Some(light_probes_binding),
Some(visibility_ranges_buffer),
Some(ssr_binding),
Some(contact_shadows_binding),
Some(environment_map_binding),
) = (
view_uniforms.uniforms.binding(),
light_meta.view_gpu_lights.binding(),
global_clusterable_object_meta
.gpu_clustered_lights
.binding(),
globals_buffer.buffer.binding(),
fog_meta.gpu_fogs.binding(),
light_probes_buffer.binding(),
visibility_ranges.buffer().buffer(),
ssr_buffer.binding(),
contact_shadows_buffer.0.binding(),
environment_map_uniform.binding(),
) {
for (
entity,
shadow_bindings,
cluster_bindings,
msaa,
ssao_resources,
prepass_textures,
transmission_texture,
tonemapping,
render_view_environment_maps,
render_view_irradiance_volumes,
has_oit,
atmosphere_textures,
has_atmosphere,
_contact_shadows_offset,
) in &views
{
let fallback_ssao = fallback_images
.image_for_samplecount(1, TextureFormat::bevy_default())
.texture_view
.clone();
let ssao_view = ssao_resources
.map(|t| &t.screen_space_ambient_occlusion_texture.default_view)
.unwrap_or(&fallback_ssao);
let mut layout_key = MeshPipelineViewLayoutKey::from(*msaa)
| MeshPipelineViewLayoutKey::from(prepass_textures);
if has_oit {
layout_key |= MeshPipelineViewLayoutKey::OIT_ENABLED;
}
if has_atmosphere {
layout_key |= MeshPipelineViewLayoutKey::ATMOSPHERE;
}
if cfg!(feature = "bluenoise_texture") {
layout_key |= MeshPipelineViewLayoutKey::STBN;
}
let layout = mesh_pipeline.get_view_layout(layout_key);
let mut entries = DynamicBindGroupEntries::new_with_indices((
(0, view_binding.clone()),
(1, light_binding.clone()),
(2, &shadow_bindings.point_light_depth_texture_view),
(3, &shadow_samplers.point_light_comparison_sampler),
#[cfg(feature = "experimental_pbr_pcss")]
(4, &shadow_samplers.point_light_linear_sampler),
(5, &shadow_bindings.directional_light_depth_texture_view),
(6, &shadow_samplers.directional_light_comparison_sampler),
#[cfg(feature = "experimental_pbr_pcss")]
(7, &shadow_samplers.directional_light_linear_sampler),
(8, clusterable_objects_binding.clone()),
(
9,
cluster_bindings
.clusterable_object_index_lists_binding()
.unwrap(),
),
(10, cluster_bindings.offsets_and_counts_binding().unwrap()),
(11, globals.clone()),
(12, fog_binding.clone()),
(13, light_probes_binding.clone()),
(14, visibility_ranges_buffer.as_entire_binding()),
(15, ssr_binding.clone()),
(16, contact_shadows_binding.clone()),
(17, ssao_view),
));
entries = entries.extend_with_indices(((18, environment_map_binding.clone()),));
let lut_bindings =
get_lut_bindings(&images, &tonemapping_luts, tonemapping, &fallback_image);
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;
if cfg!(any(not(feature = "webgl"), not(target_arch = "wasm32"))) || msaa.samples() == 1
{
prepass_bindings = prepass::get_bindings(prepass_textures);
for (binding, index) in prepass_bindings
.iter()
.map(Option::as_ref)
.zip([21, 22, 23, 24])
.flat_map(|(b, i)| b.map(|b| (b, i)))
{
entries = entries.extend_with_indices(((index, binding),));
}
};
let transmission_view = transmission_texture
.map(|transmission| &transmission.view)
.unwrap_or(&fallback_image_zero.texture_view);
let transmission_sampler = transmission_texture
.map(|transmission| &transmission.sampler)
.unwrap_or(&fallback_image_zero.sampler);
entries =
entries.extend_with_indices(((25, transmission_view), (26, transmission_sampler)));
if has_oit
&& let (
Some(oit_layers_binding),
Some(oit_layer_ids_binding),
Some(oit_settings_binding),
) = (
oit_buffers.layers.binding(),
oit_buffers.layer_ids.binding(),
oit_buffers.settings.binding(),
)
{
entries = entries.extend_with_indices((
(27, oit_layers_binding.clone()),
(28, oit_layer_ids_binding.clone()),
(29, oit_settings_binding.clone()),
));
}
if has_atmosphere
&& let Some(atmosphere_textures) = atmosphere_textures
&& let Some(atmosphere_buffer) = atmosphere_buffer.as_ref()
&& let Some(atmosphere_sampler) = atmosphere_sampler.as_ref()
&& let Some(atmosphere_buffer_binding) = atmosphere_buffer.buffer.binding()
{
entries = entries.extend_with_indices((
(30, &atmosphere_textures.transmittance_lut.default_view),
(31, &***atmosphere_sampler),
(32, atmosphere_buffer_binding),
));
}
if layout_key.contains(MeshPipelineViewLayoutKey::STBN) {
let stbn_view = &images
.get(&blue_noise.texture)
.expect("STBN texture is added unconditionally with at least a placeholder")
.texture_view;
entries = entries.extend_with_indices(((33, stbn_view),));
}
let mut entries_binding_array = DynamicBindGroupEntries::new();
let environment_map_bind_group_entries = RenderViewEnvironmentMapBindGroupEntries::get(
render_view_environment_maps,
&images,
&fallback_image,
&render_device,
&render_adapter,
);
match environment_map_bind_group_entries {
RenderViewEnvironmentMapBindGroupEntries::Single {
diffuse_texture_view,
specular_texture_view,
sampler,
} => {
entries_binding_array = entries_binding_array.extend_with_indices((
(0, diffuse_texture_view),
(1, specular_texture_view),
(2, sampler),
));
}
RenderViewEnvironmentMapBindGroupEntries::Multiple {
ref diffuse_texture_views,
ref specular_texture_views,
sampler,
} => {
entries_binding_array = entries_binding_array.extend_with_indices((
(0, diffuse_texture_views.as_slice()),
(1, specular_texture_views.as_slice()),
(2, sampler),
));
}
}
let irradiance_volume_bind_group_entries = if IRRADIANCE_VOLUMES_ARE_USABLE {
Some(RenderViewIrradianceVolumeBindGroupEntries::get(
render_view_irradiance_volumes,
&images,
&fallback_image,
&render_device,
&render_adapter,
))
} else {
None
};
match irradiance_volume_bind_group_entries {
Some(RenderViewIrradianceVolumeBindGroupEntries::Single {
texture_view,
sampler,
}) => {
entries_binding_array = entries_binding_array
.extend_with_indices(((3, texture_view), (4, sampler)));
}
Some(RenderViewIrradianceVolumeBindGroupEntries::Multiple {
ref texture_views,
sampler,
}) => {
entries_binding_array = entries_binding_array
.extend_with_indices(((3, texture_views.as_slice()), (4, sampler)));
}
None => {}
}
let decal_bind_group_entries = RenderViewClusteredDecalBindGroupEntries::get(
&render_decals,
&decals_buffer,
&images,
&fallback_image,
&render_device,
&render_adapter,
);
// Add the decal bind group entries.
if let Some(ref render_view_decal_bind_group_entries) = decal_bind_group_entries {
entries_binding_array = entries_binding_array.extend_with_indices((
// `clustered_decals`
(
5,
render_view_decal_bind_group_entries
.decals
.as_entire_binding(),
),
// `clustered_decal_textures`
(
6,
render_view_decal_bind_group_entries
.texture_views
.as_slice(),
),
// `clustered_decal_sampler`
(7, render_view_decal_bind_group_entries.sampler),
));
}
commands.entity(entity).insert(MeshViewBindGroup {
main: render_device.create_bind_group(
"mesh_view_bind_group",
&pipeline_cache.get_bind_group_layout(&layout.main_layout),
&entries,
),
binding_array: render_device.create_bind_group(
"mesh_view_bind_group_binding_array",
&pipeline_cache.get_bind_group_layout(&layout.binding_array_layout),
&entries_binding_array,
),
empty: render_device.create_bind_group(
"mesh_view_bind_group_empty",
&pipeline_cache.get_bind_group_layout(&layout.empty_layout),
&[],
),
});
}
}
}

Keeping them both in sync would be a hassle, but could work. Do you prefer this approach?

Fortunately I can currently hack around this limitation by just using a fullscreen pass to copy the motion vectors, but that is also far from ideal.

@cart cart added this to Rendering Feb 12, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Feb 12, 2026
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-Usability A targeted quality-of-life change that makes Bevy easier to use S-Needs-Review Needs reviewer attention (from anyone!) to move forward

Projects

Status: Needs SME Triage
Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants