diff --git a/.gitignore b/.gitignore index ca337b93971e6..dd85b343cf0fa 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ compressed_texture_cache # Generated by "examples/dev_tools/schedule_data.rs" **/app_data.ron + +# AI assistant scratch notes (architecture reverse-engineering, never committed) +.scratch/ diff --git a/crates/bevy_anti_alias/src/fxaa/fxaa.wgsl b/crates/bevy_anti_alias/src/fxaa/fxaa.wgsl index 2ff080de5e8e5..7d2cafe723159 100644 --- a/crates/bevy_anti_alias/src/fxaa/fxaa.wgsl +++ b/crates/bevy_anti_alias/src/fxaa/fxaa.wgsl @@ -7,8 +7,8 @@ // Tweaks by mrDIMAS - https://github.com/FyroxEngine/Fyrox/blob/master/src/renderer/shaders/fxaa_fs.glsl #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +#import bevy_core_pipeline::input_texture::{input_texture, sample_input_level, current_view_index} -@group(0) @binding(0) var screenTexture: texture_2d; @group(0) @binding(1) var samp: sampler; // Trims the algorithm from processing darks. @@ -73,22 +73,40 @@ fn rgb2luma(rgb: vec3) -> f32 { // Performs FXAA post-process anti-aliasing as described in the Nvidia FXAA white paper and the associated shader code. @fragment -fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { - let resolution = vec2(textureDimensions(screenTexture)); +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif + let resolution = vec2(textureDimensions(input_texture)); let inverseScreenSize = 1.0 / resolution.xy; let texCoord = in.position.xy * inverseScreenSize; - let centerSample = textureSampleLevel(screenTexture, samp, texCoord, 0.0); + let centerSample = sample_input_level(samp, texCoord, 0.0); let colorCenter = centerSample.rgb; // Luma at the current fragment let lumaCenter = rgb2luma(colorCenter); // Luma at the four direct neighbors of the current fragment. - let lumaDown = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(0, -1)).rgb); - let lumaUp = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(0, 1)).rgb); - let lumaLeft = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(-1, 0)).rgb); - let lumaRight = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(1, 0)).rgb); + // WGSL requires the const-offset operand of textureSampleLevel to be a + // const-expression, so it can't pass through a helper — branch the + // multiview/non-multiview cases at the callsite. +#ifdef MULTIVIEW + let lumaDown = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(0, -1)).rgb); + let lumaUp = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(0, 1)).rgb); + let lumaLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(-1, 0)).rgb); + let lumaRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(1, 0)).rgb); +#else + let lumaDown = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(0, -1)).rgb); + let lumaUp = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(0, 1)).rgb); + let lumaLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(-1, 0)).rgb); + let lumaRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(1, 0)).rgb); +#endif // Find the maximum and minimum luma around the current fragment. let lumaMin = min(lumaCenter, min(min(lumaDown, lumaUp), min(lumaLeft, lumaRight))); @@ -102,11 +120,19 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { return centerSample; } - // Query the 4 remaining corners lumas. - let lumaDownLeft = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(-1, -1)).rgb); - let lumaUpRight = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(1, 1)).rgb); - let lumaUpLeft = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(-1, 1)).rgb); - let lumaDownRight = rgb2luma(textureSampleLevel(screenTexture, samp, texCoord, 0.0, vec2(1, -1)).rgb); + // Query the 4 remaining corners lumas. Same const-offset constraint as + // above — duplicate the call set under MULTIVIEW. +#ifdef MULTIVIEW + let lumaDownLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(-1, -1)).rgb); + let lumaUpRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(1, 1)).rgb); + let lumaUpLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(-1, 1)).rgb); + let lumaDownRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, current_view_index, 0.0, vec2(1, -1)).rgb); +#else + let lumaDownLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(-1, -1)).rgb); + let lumaUpRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(1, 1)).rgb); + let lumaUpLeft = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(-1, 1)).rgb); + let lumaDownRight = rgb2luma(textureSampleLevel(input_texture, samp, texCoord, 0.0, vec2(1, -1)).rgb); +#endif // Combine the four edges lumas (using intermediary variables for future computations with the same values). let lumaDownUp = lumaDown + lumaUp; @@ -174,8 +200,8 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { var uv2 = currentUv + offset; // * QUALITY(0); // (quality 0 is 1.0) // Read the lumas at both current extremities of the exploration segment, and compute the delta wrt to the local average luma. - var lumaEnd1 = rgb2luma(textureSampleLevel(screenTexture, samp, uv1, 0.0).rgb); - var lumaEnd2 = rgb2luma(textureSampleLevel(screenTexture, samp, uv2, 0.0).rgb); + var lumaEnd1 = rgb2luma(sample_input_level(samp, uv1, 0.0).rgb); + var lumaEnd2 = rgb2luma(sample_input_level(samp, uv2, 0.0).rgb); lumaEnd1 = lumaEnd1 - lumaLocalAverage; lumaEnd2 = lumaEnd2 - lumaLocalAverage; @@ -192,13 +218,13 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { if (!reachedBoth) { for (var i: i32 = 2; i < ITERATIONS; i = i + 1) { // If needed, read luma in 1st direction, compute delta. - if (!reached1) { - lumaEnd1 = rgb2luma(textureSampleLevel(screenTexture, samp, uv1, 0.0).rgb); + if (!reached1) { + lumaEnd1 = rgb2luma(sample_input_level(samp, uv1, 0.0).rgb); lumaEnd1 = lumaEnd1 - lumaLocalAverage; } // If needed, read luma in opposite direction, compute delta. - if (!reached2) { - lumaEnd2 = rgb2luma(textureSampleLevel(screenTexture, samp, uv2, 0.0).rgb); + if (!reached2) { + lumaEnd2 = rgb2luma(sample_input_level(samp, uv2, 0.0).rgb); lumaEnd2 = lumaEnd2 - lumaLocalAverage; } // If the luma deltas at the current extremities is larger than the local gradient, we have reached the side of the edge. @@ -269,6 +295,6 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { } // Read the color at the new UV coordinates, and use it. - var finalColor = textureSampleLevel(screenTexture, samp, finalUv, 0.0).rgb; + var finalColor = sample_input_level(samp, finalUv, 0.0).rgb; return vec4(finalColor, centerSample.a); } diff --git a/crates/bevy_anti_alias/src/fxaa/mod.rs b/crates/bevy_anti_alias/src/fxaa/mod.rs index aa44af488358c..57453d760bc2b 100644 --- a/crates/bevy_anti_alias/src/fxaa/mod.rs +++ b/crates/bevy_anti_alias/src/fxaa/mod.rs @@ -12,15 +12,16 @@ use bevy_render::{ camera::ExtractedCamera, extract_component::{ExtractComponent, ExtractComponentPlugin}, render_resource::{ - binding_types::{sampler, texture_2d}, + binding_types::{sampler, texture_2d, texture_2d_array}, *, }, renderer::RenderDevice, - view::ExtractedView, + view::{ExtractedMultiview, ExtractedView}, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::default; +use core::num::NonZeroU32; mod node; @@ -112,7 +113,10 @@ impl Plugin for FxaaPlugin { #[derive(Resource)] pub struct FxaaPipeline { - texture_bind_group: BindGroupLayoutDescriptor, + pub texture_bind_group: BindGroupLayoutDescriptor, + /// Multiview bind-group layout — the texture binding is a + /// `texture_2d_array` whose layer is picked from `@builtin(view_index)`. + pub texture_bind_group_multiview: BindGroupLayoutDescriptor, sampler: Sampler, fullscreen_shader: FullscreenShader, fragment_shader: Handle, @@ -135,6 +139,17 @@ pub fn init_fxaa_pipeline( ), ); + let texture_bind_group_multiview = BindGroupLayoutDescriptor::new( + "fxaa_texture_bind_group_layout_multiview", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d_array(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + ), + ), + ); + let sampler = render_device.create_sampler(&SamplerDescriptor { mipmap_filter: MipmapFilterMode::Linear, mag_filter: FilterMode::Linear, @@ -144,6 +159,7 @@ pub fn init_fxaa_pipeline( commands.insert_resource(FxaaPipeline { texture_bind_group, + texture_bind_group_multiview, sampler, fullscreen_shader: fullscreen_shader.clone(), fragment_shader: load_embedded_asset!(asset_server.as_ref(), "fxaa.wgsl"), @@ -160,22 +176,50 @@ pub struct FxaaPipelineKey { edge_threshold: Sensitivity, edge_threshold_min: Sensitivity, target_format: TextureFormat, + /// Source texture layer count. `> 1` picks the `texture_2d_array` + /// layout and emits `MULTIVIEW` + `MAX_VIEW_COUNT` shader-defs. + multiview_view_count: u32, } impl SpecializedRenderPipeline for FxaaPipeline { type Key = FxaaPipelineKey; fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let mut shader_defs = vec![ + format!("EDGE_THRESH_{}", key.edge_threshold.get_str()).into(), + format!("EDGE_THRESH_MIN_{}", key.edge_threshold_min.get_str()).into(), + ]; + + let layout = if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + self.texture_bind_group_multiview.clone() + } else { + self.texture_bind_group.clone() + }; + + // Broadcast across every eye layer in a single pass. The matching + // render-pass descriptor in `node.rs` sets the same mask. The mask + // is `(1 << view_count) - 1` (one bit per eye); computed via + // `u32::MAX >> (32 - view_count)` to avoid the shift overflow that + // `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let multiview_mask = if key.multiview_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - key.multiview_view_count)) + } else { + None + }; + RenderPipelineDescriptor { label: Some("fxaa".into()), - layout: vec![self.texture_bind_group.clone()], + multiview_mask, + layout: vec![layout], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { shader: self.fragment_shader.clone(), - shader_defs: vec![ - format!("EDGE_THRESH_{}", key.edge_threshold.get_str()).into(), - format!("EDGE_THRESH_MIN_{}", key.edge_threshold_min.get_str()).into(), - ], + shader_defs, targets: vec![Some(ColorTargetState { format: key.target_format, blend: None, @@ -193,12 +237,16 @@ pub fn prepare_fxaa_pipelines( pipeline_cache: Res, mut pipelines: ResMut>, fxaa_pipeline: Res, - cameras: Query<(Entity, &ExtractedView, &Fxaa), With>, + cameras: Query< + (Entity, &ExtractedView, &Fxaa, Option<&ExtractedMultiview>), + With, + >, ) { - for (entity, view, fxaa) in &cameras { + for (entity, view, fxaa, multiview) in &cameras { if !fxaa.enabled { continue; } + let multiview_view_count = multiview.map_or(1, |m| m.subviews.len() as u32); let pipeline_id = pipelines.specialize( &pipeline_cache, &fxaa_pipeline, @@ -206,6 +254,7 @@ pub fn prepare_fxaa_pipelines( edge_threshold: fxaa.edge_threshold, edge_threshold_min: fxaa.edge_threshold_min, target_format: view.target_format, + multiview_view_count, }, ); diff --git a/crates/bevy_anti_alias/src/fxaa/node.rs b/crates/bevy_anti_alias/src/fxaa/node.rs index e0e126e8239e0..6196711010eba 100644 --- a/crates/bevy_anti_alias/src/fxaa/node.rs +++ b/crates/bevy_anti_alias/src/fxaa/node.rs @@ -9,6 +9,7 @@ use bevy_render::{ renderer::{RenderContext, ViewQuery}, view::ViewTarget, }; +use core::num::NonZeroU32; pub fn fxaa( view: ViewQuery<(&ViewTarget, &CameraFxaaPipeline, &Fxaa)>, @@ -30,12 +31,17 @@ pub fn fxaa( let post_process = target.post_process_write(); let source = post_process.source; let destination = post_process.destination; + let layout = if target.multiview_count().is_some() { + &fxaa_pipeline.texture_bind_group_multiview + } else { + &fxaa_pipeline.texture_bind_group + }; let bind_group = match &mut *cached_bind_group { Some((id, bind_group)) if source.id() == *id => bind_group, cached => { let bind_group = ctx.render_device().create_bind_group( None, - &pipeline_cache.get_bind_group_layout(&fxaa_pipeline.texture_bind_group), + &pipeline_cache.get_bind_group_layout(layout), &BindGroupEntries::sequential((source, &fxaa_pipeline.sampler)), ); @@ -44,6 +50,18 @@ pub fn fxaa( } }; + // Broadcast across every eye layer in a single pass. The matching + // pipeline descriptor in `mod.rs` sets the same mask. The mask is + // `(1 << view_count) - 1` (one bit per eye); computed via + // `u32::MAX >> (32 - view_count)` to avoid the shift overflow that + // `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let view_count = target.multiview_count().map_or(1, |n| n.get()); + let multiview_mask = if view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - view_count)) + } else { + None + }; + let pass_descriptor = RenderPassDescriptor { label: Some("fxaa"), color_attachments: &[Some(RenderPassColorAttachment { @@ -55,7 +73,7 @@ pub fn fxaa( depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, - multiview_mask: None, + multiview_mask, }; let diagnostics = ctx.diagnostic_recorder(); diff --git a/crates/bevy_camera/src/lib.rs b/crates/bevy_camera/src/lib.rs index aadb3532d637f..6620b80579812 100644 --- a/crates/bevy_camera/src/lib.rs +++ b/crates/bevy_camera/src/lib.rs @@ -2,6 +2,7 @@ mod camera; mod clear_color; mod components; +mod multiview; pub mod primitives; mod projection; pub mod visibility; @@ -10,6 +11,7 @@ use bevy_ecs::schedule::SystemSet; pub use camera::*; pub use clear_color::*; pub use components::*; +pub use multiview::*; pub use projection::*; use bevy_app::{App, Plugin}; diff --git a/crates/bevy_camera/src/multiview.rs b/crates/bevy_camera/src/multiview.rs new file mode 100644 index 0000000000000..6f3eb96780fa2 --- /dev/null +++ b/crates/bevy_camera/src/multiview.rs @@ -0,0 +1,132 @@ +use bevy_ecs::prelude::*; +use bevy_math::Mat4; +use bevy_reflect::Reflect; +use bevy_transform::prelude::Transform; +use core::num::NonZeroU32; + +/// Configures a camera to render to multiple view layers in a single render +/// pass. +/// +/// Add this component alongside [`Camera3d`](crate::Camera3d) (or +/// [`Camera2d`](crate::Camera2d)) to enable single-pass multiview rendering. +/// Each [`MultiviewSubview`] in `views` produces one layer of the camera's +/// render target texture array; multiview-aware shaders read per-view data +/// via WGSL's `@builtin(view_index)`. +/// +/// The canonical use case is single-pass stereo rendering for VR / XR, where +/// `views` holds two entries (one per eye). Other uses include cubemap +/// captures and other rare "render this thing from N angles at once" +/// scenarios. +/// +/// The camera's own [`GlobalTransform`](bevy_transform::prelude::GlobalTransform) +/// is the "head" pose; each subview's +/// [`view_from_camera`](MultiviewSubview::view_from_camera) is an offset +/// applied on top of that. Sort distance, frustum culling, and other +/// view-level decisions still use the head pose, so per-eye disagreements +/// (which only matter for objects nearer than the inter-pupillary distance) +/// share the head's ordering. +/// +/// `views` must contain between 1 and [`MAX_VIEW_COUNT`] entries (inclusive). +/// A camera with an empty `views` is treated as if it had no `Multiview` +/// component at all; a camera with more than [`MAX_VIEW_COUNT`] entries is +/// reported as a warning and falls back to non-multiview rendering. +#[derive(Component, Clone, Debug, Reflect)] +#[reflect(Component, Clone, Debug)] +pub struct Multiview { + /// One entry per view layer. See [`Multiview`] for the length contract. + pub views: Vec, +} + +/// Maximum number of layers supported in a single multiview pass. +/// +/// `wgpu`'s `multiview_mask` is a `u32` bitmask with one bit per layer, so +/// the platform ceiling is 32. Hardware limits may be lower (e.g. Vulkan +/// `maxMultiviewViewCount` is at least 6 on conformant devices). +pub const MAX_VIEW_COUNT: usize = 32; + +impl Multiview { + /// Returns the number of view layers as a `NonZeroU32`, or `None` if + /// `views` is empty (in which case the component should be ignored). + pub fn view_count(&self) -> Option { + NonZeroU32::new(self.views.len() as u32) + } + + /// Returns the multiview mask covering all layers (bits `0..view_count`). + /// Suitable for passing as `multiview_mask` on a `RenderPipelineDescriptor` + /// or a `RenderPassDescriptor` for this camera. Returns `None` if `views` + /// is empty or longer than [`MAX_VIEW_COUNT`]. + pub fn view_mask(&self) -> Option { + let count = self.views.len(); + if count == 0 || count > MAX_VIEW_COUNT { + return None; + } + // count == 32 sets every bit; smaller counts mask the low N bits. + let mask = if count == 32 { + u32::MAX + } else { + (1u32 << count) - 1 + }; + NonZeroU32::new(mask) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_math::Mat4; + + fn dummy_subview() -> MultiviewSubview { + MultiviewSubview { + view_from_camera: Transform::IDENTITY, + clip_from_view: Mat4::IDENTITY, + } + } + + #[test] + fn view_mask_empty_is_none() { + let m = Multiview { views: vec![] }; + assert!(m.view_mask().is_none()); + assert!(m.view_count().is_none()); + } + + #[test] + fn view_mask_two_eyes_sets_low_bits() { + let m = Multiview { + views: vec![dummy_subview(), dummy_subview()], + }; + assert_eq!(m.view_mask().unwrap().get(), 0b11); + } + + #[test] + fn view_mask_at_max_sets_all_bits() { + let m = Multiview { + views: vec![dummy_subview(); MAX_VIEW_COUNT], + }; + assert_eq!(m.view_mask().unwrap().get(), u32::MAX); + } + + #[test] + fn view_mask_above_max_is_none() { + let m = Multiview { + views: vec![dummy_subview(); MAX_VIEW_COUNT + 1], + }; + assert!(m.view_mask().is_none()); + } +} + +/// Per-layer data for a [`Multiview`] camera. +#[derive(Clone, Debug, Reflect)] +pub struct MultiviewSubview { + /// Transform of this view relative to the camera's + /// [`GlobalTransform`](bevy_transform::prelude::GlobalTransform). + /// + /// For stereo VR the canonical use is a small translation along the + /// camera's local X axis (`±IPD/2`), optionally with a per-eye + /// rotation if the headset's eye plates are canted. + pub view_from_camera: Transform, + /// Projection matrix for this view (`clip <- view`). + /// + /// Distinct from the camera's [`Projection`](crate::Projection) because + /// VR runtimes typically supply asymmetric per-eye projections. + pub clip_from_view: Mat4, +} diff --git a/crates/bevy_core_pipeline/src/blit/blit.wgsl b/crates/bevy_core_pipeline/src/blit/blit.wgsl index 7a6c25f7959df..ba43fe69dae4f 100644 --- a/crates/bevy_core_pipeline/src/blit/blit.wgsl +++ b/crates/bevy_core_pipeline/src/blit/blit.wgsl @@ -1,4 +1,5 @@ #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +#import bevy_core_pipeline::input_texture::{sample_input, current_view_index} #ifdef SRGB_TO_LINEAR #import bevy_render::color_operations::srgb_to_linear #endif @@ -6,12 +7,19 @@ #import bevy_render::color_operations::oklab_to_linear_rgb #endif -@group(0) @binding(0) var in_texture: texture_2d; @group(0) @binding(1) var in_sampler: sampler; @fragment -fn fs_main(in: FullscreenVertexOutput) -> @location(0) vec4 { - var color = textureSample(in_texture, in_sampler, in.uv); +fn fs_main( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif + var color = sample_input(in_sampler, in.uv); #ifdef SRGB_TO_LINEAR color = vec4(srgb_to_linear(color.rgb), color.a); #endif diff --git a/crates/bevy_core_pipeline/src/blit/mod.rs b/crates/bevy_core_pipeline/src/blit/mod.rs index 9861bd3abb585..255c50814c58c 100644 --- a/crates/bevy_core_pipeline/src/blit/mod.rs +++ b/crates/bevy_core_pipeline/src/blit/mod.rs @@ -5,13 +5,13 @@ use bevy_camera::CompositingSpace; use bevy_ecs::prelude::*; use bevy_render::{ render_resource::{ - binding_types::{sampler, texture_2d}, + binding_types::{sampler, texture_2d, texture_2d_array}, *, }, renderer::RenderDevice, GpuResourceAppExt, RenderApp, RenderStartup, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::default; /// Adds support for specialized "blit pipelines", which can be used to write one texture to another. @@ -34,7 +34,12 @@ impl Plugin for BlitPlugin { #[derive(Resource)] pub struct BlitPipeline { + /// Bind-group layout for blitting a single-layer source texture. pub layout: BindGroupLayoutDescriptor, + /// Bind-group layout for blitting a multi-layer (multiview) source texture. + /// The texture binding is a `texture_2d_array` whose layer is selected by + /// `@builtin(view_index)` in the fragment shader. + pub layout_multiview: BindGroupLayoutDescriptor, pub sampler: Sampler, pub fullscreen_shader: FullscreenShader, pub fragment_shader: Handle, @@ -57,10 +62,22 @@ pub fn init_blit_pipeline( ), ); + let layout_multiview = BindGroupLayoutDescriptor::new( + "blit_bind_group_layout_multiview", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d_array(TextureSampleType::Float { filterable: false }), + sampler(SamplerBindingType::NonFiltering), + ), + ), + ); + let sampler = render_device.create_sampler(&SamplerDescriptor::default()); commands.insert_resource(BlitPipeline { layout, + layout_multiview, sampler, fullscreen_shader: fullscreen_shader.clone(), fragment_shader: load_embedded_asset!(asset_server.as_ref(), "blit.wgsl"), @@ -68,15 +85,24 @@ pub fn init_blit_pipeline( } impl BlitPipeline { + /// Create a bind group for a blit source texture. `multiview_view_count` + /// is the source's layer count (`> 1` selects the `texture_2d_array` + /// layout; `1` selects the single-layer layout). pub fn create_bind_group( &self, render_device: &RenderDevice, src_texture: &TextureView, pipeline_cache: &PipelineCache, + multiview_view_count: u32, ) -> BindGroup { + let layout = if multiview_view_count > 1 { + &self.layout_multiview + } else { + &self.layout + }; render_device.create_bind_group( None, - &pipeline_cache.get_bind_group_layout(&self.layout), + &pipeline_cache.get_bind_group_layout(layout), &BindGroupEntries::sequential((src_texture, &self.sampler)), ) } @@ -90,6 +116,10 @@ pub struct BlitPipelineKey { /// Color space of the source texture. When `Some(Srgb)` or `Some(Oklab)`, the blit converts /// to linear RGB before writing to the output target. pub source_space: Option, + /// Number of layers in the source texture (1 for single-view; `> 1` + /// selects the multiview bind-group layout and emits `MULTIVIEW` + + /// `MAX_VIEW_COUNT` shader-defs into the fragment stage). + pub multiview_view_count: u32, } impl SpecializedRenderPipeline for BlitPipeline { @@ -103,9 +133,20 @@ impl SpecializedRenderPipeline for BlitPipeline { Some(CompositingSpace::Linear) | None => {} } + let layout = if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + self.layout_multiview.clone() + } else { + self.layout.clone() + }; + RenderPipelineDescriptor { label: Some("blit pipeline".into()), - layout: vec![self.layout.clone()], + layout: vec![layout], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { shader: self.fragment_shader.clone(), diff --git a/crates/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs b/crates/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs index 95ed5636fcf3a..1383342ecbedf 100644 --- a/crates/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs +++ b/crates/bevy_core_pipeline/src/core_3d/main_opaque_pass_3d_node.rs @@ -13,8 +13,9 @@ use bevy_render::{ render_phase::ViewBinnedRenderPhases, render_resource::{PipelineCache, RenderPassDescriptor, StoreOp}, renderer::{RenderContext, ViewQuery}, - view::{ExtractedView, ViewDepthTexture, ViewTarget, ViewUniformOffset}, + view::{ExtractedMultiview, ExtractedView, ViewDepthTexture, ViewTarget, ViewUniformOffset}, }; +use core::num::NonZeroU32; use super::AlphaMask3d; @@ -29,6 +30,7 @@ pub fn main_opaque_pass_3d( Option<&SkyboxBindGroup>, &ViewUniformOffset, Option<&MainPassResolutionOverride>, + Option<&ExtractedMultiview>, )>, opaque_phases: Res>, alpha_mask_phases: Res>, @@ -46,6 +48,7 @@ pub fn main_opaque_pass_3d( skybox_bind_group, view_uniform_offset, resolution_override, + multiview, ) = view.into_inner(); let (Some(opaque_phase), Some(alpha_mask_phase)) = ( @@ -61,6 +64,31 @@ pub fn main_opaque_pass_3d( let diagnostics = ctx.diagnostic_recorder(); let diagnostics = diagnostics.as_deref(); + // The main_opaque_pass_3d broadcasts every Opaque3d / AlphaMask3d draw + // across all eyes via `multiview_mask`. PBR `MeshPipeline::specialize` + // sets the matching pipeline-side mask under the same `view_count > 1` + // predicate, so wgpu's required pipeline-vs-pass multiview-mask + // agreement holds for every in-tree Material dispatch through + // `DrawMaterial`. The skybox broadcast pass below reuses the same mask, + // so both passes in this node broadcast under multiview and degrade to + // `None` at view_count == 1. + // + // Custom material authors who ship their own fragment WGSL entry must + // declare `@builtin(view_index)` and assign + // `bevy_pbr::mesh_view_bindings::current_view_index = view_index;` + // under `#ifdef MULTIVIEW` to avoid silent eye-0-broadcast on lighting + // and camera-relative effects — see the `Material` trait docstring. + // + // Mask formula `u32::MAX >> (32 - view_count)` is the shift-safe + // equivalent of `(1u32 << view_count) - 1`; the latter is UB at the + // `MAX_VIEW_COUNT = 32` cap. + let view_count = multiview.map_or(1, |m| m.subviews.len() as u32); + let multiview_mask = if view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - view_count)) + } else { + None + }; + let color_attachments = [Some(target.get_color_attachment())]; let depth_stencil_attachment = Some(depth.get_attachment(StoreOp::Store)); @@ -70,7 +98,7 @@ pub fn main_opaque_pass_3d( depth_stencil_attachment, timestamp_writes: None, occlusion_query_set: None, - multiview_mask: None, + multiview_mask, }); let pass_span = diagnostics.pass_span(&mut render_pass, "main_opaque_pass_3d"); @@ -96,18 +124,49 @@ pub fn main_opaque_pass_3d( } } + pass_span.end(&mut render_pass); + drop(render_pass); + + // Skybox broadcast pass. The cubemap is shared across eyes; per-eye view + // matrices (sampled via `view()` from `@builtin(view_index)`) give each + // eye the correct ray direction, so one broadcast draw fills every layer + // of the multi-layer color + depth attachments. Re-deriving the + // attachments through `target.get_color_attachment()` / + // `depth.get_attachment(...)` hits the second-call `is_first_call` latch + // and returns `LoadOp::Load`, preserving the opaque + alpha-mask output + // from the main_opaque_pass_3d above. Reuses the same `multiview_mask` + // computed at the top of this function. if let (Some(skybox_pipeline), Some(SkyboxBindGroup(skybox_bind_group))) = (skybox_pipeline, skybox_bind_group) && let Some(pipeline) = pipeline_cache.get_render_pipeline(skybox_pipeline.0) { - render_pass.set_render_pipeline(pipeline); - render_pass.set_bind_group( + let skybox_color_attachments = [Some(target.get_color_attachment())]; + let skybox_depth_attachment = Some(depth.get_attachment(StoreOp::Store)); + + let mut skybox_pass = ctx.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("skybox_broadcast"), + color_attachments: &skybox_color_attachments, + depth_stencil_attachment: skybox_depth_attachment, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask, + }); + let skybox_span = diagnostics.pass_span(&mut skybox_pass, "skybox_broadcast"); + + if let Some(viewport) = + Viewport::from_viewport_and_override(camera.viewport.as_ref(), resolution_override) + { + skybox_pass.set_camera_viewport(&viewport); + } + + skybox_pass.set_render_pipeline(pipeline); + skybox_pass.set_bind_group( 0, &skybox_bind_group.0, &[view_uniform_offset.offset, skybox_bind_group.1], ); - render_pass.draw(0..3, 0..1); - } + skybox_pass.draw(0..3, 0..1); - pass_span.end(&mut render_pass); + skybox_span.end(&mut skybox_pass); + } } diff --git a/crates/bevy_core_pipeline/src/core_3d/mod.rs b/crates/bevy_core_pipeline/src/core_3d/mod.rs index c24ae999d69f2..5bfcce4110d64 100644 --- a/crates/bevy_core_pipeline/src/core_3d/mod.rs +++ b/crates/bevy_core_pipeline/src/core_3d/mod.rs @@ -70,7 +70,7 @@ use bevy_render::{ renderer::RenderDevice, sync_world::{MainEntity, RenderEntity}, texture::{ColorAttachment, TextureCache}, - view::{ExtractedView, ViewDepthTexture}, + view::{ExtractedMultiview, ExtractedView, ViewDepthTexture}, Extract, ExtractSchedule, Render, RenderApp, RenderSystems, }; use nonmax::NonMaxU32; @@ -669,10 +669,11 @@ pub fn prepare_core_3d_depth_textures( Option<&DepthPrepass>, &Camera3d, &Msaa, + Option<&ExtractedMultiview>, )>, ) { let mut render_target_usage = >::default(); - for (_, camera, depth_prepass, camera_3d, _msaa) in &views_3d { + for (_, camera, depth_prepass, camera_3d, _msaa, _multiview) in &views_3d { // Default usage required to write to the depth texture let mut usage: TextureUsages = camera_3d.depth_texture_usages.into(); if depth_prepass.is_some() { @@ -686,22 +687,35 @@ pub fn prepare_core_3d_depth_textures( } let mut textures = >::default(); - for (entity, camera, _, camera_3d, msaa) in &views_3d { + for (entity, camera, _, camera_3d, msaa, multiview) in &views_3d { let Some(physical_target_size) = camera.physical_target_size else { continue; }; + // Grow the depth texture to per-eye layer count under multiview so the + // end-of-prepass depth copy and forward-pass depth read have matching + // shape with the prepass depth attachment (see C2 plan). Non-multiview + // cameras get `view_count = 1` → single-layer texture, bit-identical + // to the pre-C2 shape. + let view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let cached_texture = textures - .entry((camera.target.clone(), msaa)) + // Key includes view_count so a multiview and a non-multiview camera + // sharing the same render target don't collide on a cached texture + // of the wrong layer count. + .entry((camera.target.clone(), msaa, view_count)) .or_insert_with(|| { let usage = *render_target_usage .get(&camera.target.clone()) .expect("The depth texture usage should already exist for this target"); + let mut size = physical_target_size.to_extents(); + size.depth_or_array_layers = view_count; + let descriptor = TextureDescriptor { label: Some("view_depth_texture"), // The size of the depth texture - size: physical_target_size.to_extents(), + size, mip_level_count: 1, sample_count: msaa.samples(), dimension: TextureDimension::D2, @@ -779,6 +793,7 @@ pub fn prepare_prepass_textures( Has, Has, Has, + Option<&ExtractedMultiview>, )>, ) { let mut depth_textures1 = >::default(); @@ -799,6 +814,7 @@ pub fn prepare_prepass_textures( deferred_prepass, depth_prepass_double_buffer, deferred_prepass_double_buffer, + multiview, ) in &views_3d { if !opaque_3d_prepass_phases.contains_key(&view.retained_view_entity) @@ -814,11 +830,21 @@ pub fn prepare_prepass_textures( continue; }; - let size = physical_target_size.to_extents(); + // Grow each prepass attachment to `view_count` layers under multiview + // so the per-eye prepass/deferred nodes (session 17) can attach a + // single layer per render pass via + // `ColorAttachment::get_attachment_for_layer`. Non-multiview cameras + // get `view_count = 1` → single-layer textures, bit-identical to the + // pre-C2 shape. Also append `view_count` to each cache key so a + // multiview camera and a non-multiview camera sharing a render target + // don't collide on a cached texture of the wrong layer count. + let view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let mut size = physical_target_size.to_extents(); + size.depth_or_array_layers = view_count; let cached_depth_texture1 = depth_prepass.then(|| { depth_textures1 - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { let descriptor = TextureDescriptor { label: Some("prepass_depth_texture_1"), @@ -839,7 +865,7 @@ pub fn prepare_prepass_textures( let cached_depth_texture2 = depth_prepass_double_buffer.then(|| { depth_textures2 - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { let descriptor = TextureDescriptor { label: Some("prepass_depth_texture_2"), @@ -860,7 +886,7 @@ pub fn prepare_prepass_textures( let cached_normals_texture = normal_prepass.then(|| { normal_textures - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { texture_cache.get( &render_device, @@ -882,7 +908,7 @@ pub fn prepare_prepass_textures( let cached_motion_vectors_texture = motion_vector_prepass.then(|| { motion_vectors_textures - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { texture_cache.get( &render_device, @@ -904,7 +930,7 @@ pub fn prepare_prepass_textures( let cached_deferred_texture1 = deferred_prepass.then(|| { deferred_textures1 - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { texture_cache.get( &render_device, @@ -926,7 +952,7 @@ pub fn prepare_prepass_textures( let cached_deferred_texture2 = deferred_prepass_double_buffer.then(|| { deferred_textures2 - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { texture_cache.get( &render_device, @@ -948,7 +974,7 @@ pub fn prepare_prepass_textures( let cached_deferred_lighting_pass_id_texture = deferred_prepass.then(|| { deferred_lighting_id_textures - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { texture_cache.get( &render_device, diff --git a/crates/bevy_core_pipeline/src/deferred/node.rs b/crates/bevy_core_pipeline/src/deferred/node.rs index 3cbcee0dc6e87..87e35c7d58596 100644 --- a/crates/bevy_core_pipeline/src/deferred/node.rs +++ b/crates/bevy_core_pipeline/src/deferred/node.rs @@ -1,3 +1,5 @@ +use core::num::NonZeroU32; + use bevy_camera::{MainPassResolutionOverride, Viewport}; use bevy_ecs::prelude::*; use bevy_render::occlusion_culling::OcclusionCulling; @@ -5,7 +7,7 @@ use bevy_render::occlusion_culling::OcclusionCulling; use bevy_log::error; #[cfg(feature = "trace")] use bevy_log::info_span; -use bevy_render::view::{ExtractedView, NoIndirectDrawing}; +use bevy_render::view::{ExtractedMultiview, ExtractedView, NoIndirectDrawing}; use bevy_render::{ camera::ExtractedCamera, diagnostic::RecordDiagnostics, @@ -26,6 +28,7 @@ type DeferredPrepassViewQueryData = ( &'static ViewDepthTexture, &'static ViewPrepassTextures, Option<&'static MainPassResolutionOverride>, + Option<&'static ExtractedMultiview>, Has, Has, ); @@ -44,6 +47,7 @@ pub(crate) fn early_deferred_prepass( view_depth_texture, view_prepass_textures, resolution_override, + multiview, _, _, ) = view.into_inner(); @@ -56,6 +60,7 @@ pub(crate) fn early_deferred_prepass( view_depth_texture, view_prepass_textures, resolution_override, + multiview, false, &opaque_deferred_phases, &alpha_mask_deferred_phases, @@ -78,6 +83,7 @@ pub fn late_deferred_prepass( view_depth_texture, view_prepass_textures, resolution_override, + multiview, occlusion_culling, no_indirect_drawing, ) = view.into_inner(); @@ -94,6 +100,7 @@ pub fn late_deferred_prepass( view_depth_texture, view_prepass_textures, resolution_override, + multiview, true, &opaque_deferred_phases, &alpha_mask_deferred_phases, @@ -114,6 +121,7 @@ fn run_deferred_prepass_system( view_depth_texture: &ViewDepthTexture, view_prepass_textures: &ViewPrepassTextures, resolution_override: Option<&MainPassResolutionOverride>, + multiview: Option<&ExtractedMultiview>, is_late: bool, opaque_deferred_phases: &ViewBinnedRenderPhases, alpha_mask_deferred_phases: &ViewBinnedRenderPhases, @@ -133,25 +141,14 @@ fn run_deferred_prepass_system( let diagnostics = ctx.diagnostic_recorder(); let diagnostics = diagnostics.as_deref(); - let mut color_attachments = vec![]; - color_attachments.push( - view_prepass_textures - .normal - .as_ref() - .map(|normals_texture| normals_texture.get_attachment()), - ); - color_attachments.push( - view_prepass_textures - .motion_vectors - .as_ref() - .map(|motion_vectors_texture| motion_vectors_texture.get_attachment()), - ); - // If we clear the deferred texture with LoadOp::Clear(Default::default()) we get these errors: // Chrome: GL_INVALID_OPERATION: No defined conversion between clear value and attachment format. // Firefox: WebGL warning: clearBufferu?[fi]v: This attachment is of type FLOAT, but this function is of type UINT. // Appears to be unsupported: https://registry.khronos.org/webgl/specs/latest/2.0/#3.7.9 - // For webgl2 we fallback to manually clearing + // For webgl2 we fallback to manually clearing. + // Runs before the broadcast pass: `clear_texture` with the default + // subresource range clears every layer of the multi-layer texture in one + // call. #[cfg(all(feature = "webgl", target_arch = "wasm32", not(feature = "webgpu")))] if !is_late { if let Some(deferred_texture) = &view_prepass_textures.deferred { @@ -162,6 +159,49 @@ fn run_deferred_prepass_system( } } + // Dispatch the deferred prepass items as a single broadcast pass under + // multiview: the hardware fans each draw out to every eye layer via + // `@builtin(view_index)`, and the matching `PrepassPipelineSpecializer` + // pipeline-side mask uses the same `view_count > 1` predicate so wgpu's + // required pipeline-vs-pass agreement holds. At view_count = 1 the gate + // collapses to `multiview_mask: None`. `(1 << view_count) - 1` is + // computed as `u32::MAX >> (32 - view_count)` to avoid the shift + // overflow that `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + // + // Attachment lifecycle on entry: when a forward prepass is also present, + // it runs before this node and flips the global `is_first_call` latches + // on `view_depth_texture` (and on the shared `Normal` / `MotionVectors` + // ColorAttachments). Legacy `get_attachment(StoreOp::Store)` here + // therefore returns `LoadOp::Load` and preserves the forward-prepass + // depth + Normal + MotionVectors output. The deferred-only configuration + // leaves those latches untouched, so the first call on this node lands + // `LoadOp::Clear` and writes a fresh depth + Normal + MV pass. The + // deferred-specific `Deferred` and `DeferredLightingPassId` color + // attachments have independent latches (not shared with forward prepass) + // — `early` lands `LoadOp::Clear`, and `late` (when occlusion culling + // is on) lands `LoadOp::Load` against early's output via the second-call + // latch. + let view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let multiview_mask = if view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - view_count)) + } else { + None + }; + + let mut color_attachments = vec![]; + color_attachments.push( + view_prepass_textures + .normal + .as_ref() + .map(|normals_texture| normals_texture.get_attachment()), + ); + color_attachments.push( + view_prepass_textures + .motion_vectors + .as_ref() + .map(|motion_vectors_texture| motion_vectors_texture.get_attachment()), + ); + color_attachments.push( view_prepass_textures .deferred @@ -170,7 +210,11 @@ fn run_deferred_prepass_system( if is_late { deferred_texture.get_attachment() } else { - #[cfg(all(feature = "webgl", target_arch = "wasm32", not(feature = "webgpu")))] + #[cfg(all( + feature = "webgl", + target_arch = "wasm32", + not(feature = "webgpu") + ))] { bevy_render::render_resource::RenderPassColorAttachment { view: &deferred_texture.texture.default_view, @@ -212,7 +256,7 @@ fn run_deferred_prepass_system( depth_stencil_attachment, timestamp_writes: None, occlusion_query_set: None, - multiview_mask: None, + multiview_mask, }); let pass_span = diagnostics.pass_span(&mut render_pass, label); @@ -244,7 +288,9 @@ fn run_deferred_prepass_system( pass_span.end(&mut render_pass); drop(render_pass); - // After rendering to the view depth texture, copy it to the prepass depth texture + // After rendering to the view depth texture, copy it to the prepass depth texture. + // Source + dest extents both carry `depth_or_array_layers = view_count` post-C2 + // sub-A, so this single call copies every layer. if let Some(prepass_depth_texture) = &view_prepass_textures.depth { ctx.command_encoder().copy_texture_to_texture( view_depth_texture.texture.as_image_copy(), diff --git a/crates/bevy_core_pipeline/src/input_texture.wgsl b/crates/bevy_core_pipeline/src/input_texture.wgsl new file mode 100644 index 0000000000000..2b5f161e465ad --- /dev/null +++ b/crates/bevy_core_pipeline/src/input_texture.wgsl @@ -0,0 +1,39 @@ +#define_import_path bevy_core_pipeline::input_texture + +// Shared screen-space input texture for fullscreen post-process pipelines. +// +// Under `#ifdef MULTIVIEW` the binding is a `texture_2d_array` whose layer +// index selects the view (one layer per eye). Otherwise it is a plain +// `texture_2d`. Consumers read it through the helpers below where they +// can (no `offset` argument); pipelines that need the const-offset variants +// of `textureSample`/`textureSampleLevel` (e.g. bloom's 13-tap kernel, FXAA's +// neighborhood lumas) must `#ifdef MULTIVIEW` at the callsite — WGSL requires +// the `offset` operand to be a const-expression, so it can't be threaded +// through a helper function parameter. +// +// Per-fragment view index is threaded via `current_view_index` (defaults to 0, +// overwritten from `@builtin(view_index)` at the top of multiview entry-point +// bodies). This mirrors the convention used by `bevy_pbr::mesh_view_bindings`. +#ifdef MULTIVIEW +@group(0) @binding(0) var input_texture: texture_2d_array; +#else +@group(0) @binding(0) var input_texture: texture_2d; +#endif + +var current_view_index: i32 = 0; + +fn sample_input(s: sampler, uv: vec2) -> vec4 { +#ifdef MULTIVIEW + return textureSample(input_texture, s, uv, current_view_index); +#else + return textureSample(input_texture, s, uv); +#endif +} + +fn sample_input_level(s: sampler, uv: vec2, level: f32) -> vec4 { +#ifdef MULTIVIEW + return textureSampleLevel(input_texture, s, uv, current_view_index, level); +#else + return textureSampleLevel(input_texture, s, uv, level); +#endif +} diff --git a/crates/bevy_core_pipeline/src/lib.rs b/crates/bevy_core_pipeline/src/lib.rs index b0407b208b148..faacbe25bbe5f 100644 --- a/crates/bevy_core_pipeline/src/lib.rs +++ b/crates/bevy_core_pipeline/src/lib.rs @@ -39,6 +39,7 @@ use bevy_app::{App, Plugin}; use bevy_asset::embedded_asset; use bevy_render::renderer::{RenderGraph, RenderGraphSystems}; use bevy_render::RenderApp; +use bevy_shader::load_shader_library; use oit::OrderIndependentTransparencyPlugin; #[derive(Default)] @@ -47,6 +48,7 @@ pub struct CorePipelinePlugin; impl Plugin for CorePipelinePlugin { fn build(&self, app: &mut App) { embedded_asset!(app, "fullscreen_vertex_shader/fullscreen.wgsl"); + load_shader_library!(app, "input_texture.wgsl"); app.add_plugins((Core2dPlugin, Core3dPlugin, CopyDeferredLightingIdPlugin)) .add_plugins(( diff --git a/crates/bevy_core_pipeline/src/oit/oit_draw.wgsl b/crates/bevy_core_pipeline/src/oit/oit_draw.wgsl index 89bacfe40e237..3c531aa5ba59e 100644 --- a/crates/bevy_core_pipeline/src/oit/oit_draw.wgsl +++ b/crates/bevy_core_pipeline/src/oit/oit_draw.wgsl @@ -22,7 +22,7 @@ fn oit_draw(position: vec4f, color: vec4f) { return; } // get the index of the current fragment relative to the screen size - let screen_index = u32(floor(position.x) + floor(position.y) * view.viewport.z); + let screen_index = u32(floor(position.x) + floor(position.y) * view().viewport.z); var new_node_index = atomicAdd(&oit_atomic_counter, 1u); // exit early if we've reached the maximum amount of fragments nodes diff --git a/crates/bevy_core_pipeline/src/oit/resolve/mod.rs b/crates/bevy_core_pipeline/src/oit/resolve/mod.rs index 4847db788b6bf..13f1dea9678f2 100644 --- a/crates/bevy_core_pipeline/src/oit/resolve/mod.rs +++ b/crates/bevy_core_pipeline/src/oit/resolve/mod.rs @@ -14,7 +14,8 @@ use bevy_render::{ camera::ExtractedCamera, render_resource::{ binding_types::{ - storage_buffer_read_only_sized, storage_buffer_sized, texture_depth_2d, uniform_buffer, + storage_buffer_read_only_sized, storage_buffer_sized, texture_depth_2d, + uniform_buffer_sized, }, BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, BlendComponent, BlendState, CachedRenderPipelineId, ColorTargetState, ColorWrites, @@ -22,7 +23,7 @@ use bevy_render::{ TextureFormat, }, renderer::{RenderAdapter, RenderDevice}, - view::{ExtractedView, ViewUniform, ViewUniforms}, + view::{ExtractedMultiview, ExtractedView, ViewUniforms}, Render, RenderApp, RenderSystems, }; use bevy_shader::ShaderDefVal; @@ -114,7 +115,15 @@ impl OitResolvePipeline { &BindGroupLayoutEntries::sequential( ShaderStages::FRAGMENT, ( - uniform_buffer::(true), + // View + // + // Sized `None` (unbounded) so the WGSL is free to declare + // the binding as `array` for + // multiview pipelines and `array` for the + // non-multiview fallback. Backing storage is the packed + // `DynamicArrayUniformBuffer` and the dynamic + // offset selects the per-camera array slot. + uniform_buffer_sized(true, None), // nodes storage_buffer_read_only_sized(false, None), // heads @@ -145,6 +154,10 @@ pub struct OitResolvePipelineKey { target_format: TextureFormat, sorted_fragment_max_count: u32, depth_prepass: bool, + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array` + /// and reads `current_view_index` from `@builtin(view_index)`. + multiview_view_count: u32, } pub fn queue_oit_resolve_pipeline( @@ -157,6 +170,7 @@ pub fn queue_oit_resolve_pipeline( &ExtractedView, &OrderIndependentTransparencySettings, Has, + Option<&ExtractedMultiview>, ), ( With, @@ -170,12 +184,16 @@ pub fn queue_oit_resolve_pipeline( mut cached_pipeline_id: Local>, ) { let mut current_view_entities = EntityHashSet::default(); - for (e, view, oit_settings, depth_prepass) in &cameras { + for (e, view, oit_settings, depth_prepass, multiview) in &cameras { current_view_entities.insert(e); + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); let key = OitResolvePipelineKey { target_format: view.target_format, sorted_fragment_max_count: oit_settings.sorted_fragment_max_count, depth_prepass, + multiview_view_count, }; if let Some((cached_key, id)) = cached_pipeline_id.get(&e) @@ -221,6 +239,13 @@ fn specialize_oit_resolve_pipeline( } else { layout.push(resolve_pipeline.oit_depth_bind_group_layout.clone()); } + if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } RenderPipelineDescriptor { label: Some("oit_resolve_pipeline".into()), diff --git a/crates/bevy_core_pipeline/src/oit/resolve/oit_resolve.wgsl b/crates/bevy_core_pipeline/src/oit/resolve/oit_resolve.wgsl index efaffa7b9300d..96d210090eff7 100644 --- a/crates/bevy_core_pipeline/src/oit/resolve/oit_resolve.wgsl +++ b/crates/bevy_core_pipeline/src/oit/resolve/oit_resolve.wgsl @@ -4,7 +4,24 @@ #import bevy_render::view::View #import bevy_pbr::mesh_view_types::OitFragmentNode -@group(0) @binding(0) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so the OIT resolve pipeline can share the packed +// `DynamicArrayUniformBuffer` behind `ViewUniforms`. The shader reads through +// `view()`, which indexes the array at `current_view_index` (set from +// `@builtin(view_index)` at the top of the fragment under MULTIVIEW). For +// non-multiview pipelines, `MAX_VIEW_COUNT` is undefined and the fallback +// `array` matches the single `ViewUniform` packed per camera. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(0) var view_array: array; +#else +@group(0) @binding(0) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} + @group(0) @binding(1) var nodes: array; @group(0) @binding(2) var heads: array; // No need to be atomic @group(0) @binding(3) var atomic_counter: u32; // No need to be atomic @@ -27,9 +44,17 @@ const LINKED_LIST_END_SENTINEL: u32 = 0xFFFFFFFFu; const SORTED_FRAGMENT_MAX_COUNT: u32 = #{SORTED_FRAGMENT_MAX_COUNT}; @fragment -fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif atomic_counter = 0u; - let screen_index = u32(floor(in.position.x) + floor(in.position.y) * view.viewport.z); + let screen_index = u32(floor(in.position.x) + floor(in.position.y) * view().viewport.z); let head = heads[screen_index] - 1u; if head == LINKED_LIST_END_SENTINEL { diff --git a/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.rs b/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.rs index 06f5566aee39f..8ea3500baf4f2 100644 --- a/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.rs +++ b/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.rs @@ -20,20 +20,22 @@ use bevy_ecs::{ }; use bevy_log::warn; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; +use core::num::NonZeroU32; use bevy_render::{ extract_component::{ExtractComponent, ExtractComponentPlugin}, render_resource::{ - binding_types::uniform_buffer, BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, - BindGroupLayoutEntries, CachedRenderPipelineId, CompareFunction, DepthStencilState, - DownlevelFlags, FragmentState, MultisampleState, PipelineCache, RenderPipelineDescriptor, - ShaderStages, SpecializedRenderPipeline, SpecializedRenderPipelines, + binding_types::{uniform_buffer, uniform_buffer_sized}, + BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, + CachedRenderPipelineId, CompareFunction, DepthStencilState, DownlevelFlags, FragmentState, + MultisampleState, PipelineCache, RenderPipelineDescriptor, ShaderStages, + SpecializedRenderPipeline, SpecializedRenderPipelines, }, renderer::{RenderAdapter, RenderDevice}, sync_component::SyncComponent, - view::{Msaa, ViewUniform, ViewUniforms}, + view::{ExtractedMultiview, Msaa, ViewUniforms}, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::prelude::default; use crate::{ @@ -144,6 +146,10 @@ struct BackgroundMotionVectorsPipeline { struct BackgroundMotionVectorsPipelineKey { samples: u32, normal_prepass: bool, + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array` + /// and reads `current_view_index` from `@builtin(view_index)`. + multiview_view_count: u32, } fn init_background_motion_vectors_pipeline( @@ -157,7 +163,15 @@ fn init_background_motion_vectors_pipeline( &BindGroupLayoutEntries::sequential( ShaderStages::FRAGMENT, ( - uniform_buffer::(true), + // View + // + // Sized `None` (unbounded) so the WGSL is free to declare + // the binding as `array` for + // multiview pipelines and `array` for the + // non-multiview fallback. Backing storage is the packed + // `DynamicArrayUniformBuffer` and the + // dynamic offset selects the per-camera array slot. + uniform_buffer_sized(true, None), uniform_buffer::(true), ), ), @@ -186,8 +200,29 @@ impl SpecializedRenderPipeline for BackgroundMotionVectorsPipeline { target.write_mask = bevy_render::render_resource::ColorWrites::empty(); } + let mut shader_defs = Vec::new(); + if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + + // Broadcast across every eye layer in a single pass. The matching + // render-pass descriptor in `prepass/node.rs` sets the same mask. + // The mask is `(1 << view_count) - 1` (one bit per eye); computed + // via `u32::MAX >> (32 - view_count)` to avoid the shift overflow + // that `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let multiview_mask = if key.multiview_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - key.multiview_view_count)) + } else { + None + }; + RenderPipelineDescriptor { label: Some("background_motion_vectors_pipeline".into()), + multiview_mask, layout: vec![self.bind_group_layout.clone()], vertex: self.fullscreen_shader.to_vertex_state(), depth_stencil: Some(DepthStencilState { @@ -204,6 +239,7 @@ impl SpecializedRenderPipeline for BackgroundMotionVectorsPipeline { }, fragment: Some(FragmentState { shader: self.fragment_shader.clone(), + shader_defs, targets, ..default() }), @@ -218,20 +254,24 @@ fn prepare_background_motion_vectors_pipelines( mut pipelines: ResMut>, pipeline: Res, views: Query< - (Entity, Has, &Msaa), + (Entity, Has, &Msaa, Option<&ExtractedMultiview>), ( With, Without, ), >, ) { - for (entity, normal_prepass, msaa) in &views { + for (entity, normal_prepass, msaa, multiview) in &views { + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); let id = pipelines.specialize( &pipeline_cache, &pipeline, BackgroundMotionVectorsPipelineKey { samples: msaa.samples(), normal_prepass, + multiview_view_count, }, ); commands diff --git a/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.wgsl b/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.wgsl index dbe402e80585a..caccd973e457b 100644 --- a/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.wgsl +++ b/crates/bevy_core_pipeline/src/prepass/background_motion_vectors.wgsl @@ -10,7 +10,24 @@ struct PreviousViewUniforms { view_from_clip: mat4x4, } -@group(0) @binding(0) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so the background motion vectors pipeline can share the packed +// `DynamicArrayUniformBuffer` behind `ViewUniforms`. The shader reads through +// `view()`, which indexes the array at `current_view_index` (set from +// `@builtin(view_index)` at the top of the fragment under MULTIVIEW). For +// non-multiview pipelines, `MAX_VIEW_COUNT` is undefined and the fallback +// `array` matches the single `ViewUniform` packed per camera. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(0) var view_array: array; +#else +@group(0) @binding(0) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} + @group(0) @binding(1) var previous_view: PreviousViewUniforms; /// Writes motion vectors for sky pixels (depth == 0 in reversed-Z) based on camera rotation. @@ -19,12 +36,20 @@ struct PreviousViewUniforms { /// reversed-Z. The GreaterEqual depth test passes only where depth == 0, so this only writes /// to pixels untouched by geometry. @fragment -fn fragment(in: FullscreenVertexOutput) -> @location(1) vec4 { +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(1) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let clip_pos = uv_to_ndc(in.uv); - let world_pos = view.world_from_clip * vec4(clip_pos, 0.0, 1.0); + let world_pos = view().world_from_clip * vec4(clip_pos, 0.0, 1.0); // Use unjittered_clip_from_world for the current frame to strip TAA jitter from the // motion vector. - let curr_clip_pos = (view.unjittered_clip_from_world * world_pos).xy; + let curr_clip_pos = (view().unjittered_clip_from_world * world_pos).xy; let prev_clip_pos = (previous_view.clip_from_world * world_pos).xy; let velocity = (curr_clip_pos - prev_clip_pos) * vec2(0.5, -0.5); return vec4(velocity.x, velocity.y, 0.0, 1.0); diff --git a/crates/bevy_core_pipeline/src/prepass/node.rs b/crates/bevy_core_pipeline/src/prepass/node.rs index efdeff48613bb..736f8a6e64974 100644 --- a/crates/bevy_core_pipeline/src/prepass/node.rs +++ b/crates/bevy_core_pipeline/src/prepass/node.rs @@ -1,3 +1,5 @@ +use core::num::NonZeroU32; + use bevy_camera::{MainPassResolutionOverride, Viewport}; use bevy_ecs::prelude::*; use bevy_log::error; @@ -10,7 +12,9 @@ use bevy_render::{ render_phase::ViewBinnedRenderPhases, render_resource::{PipelineCache, RenderPassDescriptor, StoreOp}, renderer::{RenderContext, ViewQuery}, - view::{ExtractedView, NoIndirectDrawing, ViewDepthTexture, ViewUniformOffset}, + view::{ + ExtractedMultiview, ExtractedView, NoIndirectDrawing, ViewDepthTexture, ViewUniformOffset, + }, }; use crate::prepass::background_motion_vectors::{ @@ -37,6 +41,7 @@ type PrepassViewQueryData = ( Option<&'static BackgroundMotionVectorsBindGroup>, Option<&'static PreviousViewUniformOffset>, Option<&'static MainPassResolutionOverride>, + Option<&'static ExtractedMultiview>, ), (Has, Has), ); @@ -58,6 +63,7 @@ pub fn early_prepass( background_motion_vectors_bind_group, view_prev_uniform_offset, resolution_override, + multiview, ), (_, _), ) = view.into_inner(); @@ -75,6 +81,7 @@ pub fn early_prepass( background_motion_vectors_bind_group, view_prev_uniform_offset, resolution_override, + multiview, &opaque_prepass_phases, &alpha_mask_prepass_phases, &pipeline_cache, @@ -100,6 +107,7 @@ pub fn late_prepass( background_motion_vectors_bind_group, view_prev_uniform_offset, resolution_override, + multiview, ), (occlusion_culling, no_indirect_drawing), ) = view.into_inner(); @@ -121,6 +129,7 @@ pub fn late_prepass( background_motion_vectors_bind_group, view_prev_uniform_offset, resolution_override, + multiview, &opaque_prepass_phases, &alpha_mask_prepass_phases, &pipeline_cache, @@ -147,6 +156,7 @@ fn run_prepass_system( background_motion_vectors_bind_group: Option<&BackgroundMotionVectorsBindGroup>, view_prev_uniform_offset: Option<&PreviousViewUniformOffset>, resolution_override: Option<&MainPassResolutionOverride>, + multiview: Option<&ExtractedMultiview>, opaque_prepass_phases: &ViewBinnedRenderPhases, alpha_mask_prepass_phases: &ViewBinnedRenderPhases, pipeline_cache: &PipelineCache, @@ -166,6 +176,21 @@ fn run_prepass_system( let diagnostics = ctx.diagnostic_recorder(); let diagnostics = diagnostics.as_deref(); + // Dispatch the prepass items as a single broadcast pass under + // multiview: the hardware fans each draw out to every eye layer via + // `@builtin(view_index)`, and the matching `PrepassPipelineSpecializer` + // pipeline-side mask uses the same `view_count > 1` predicate so wgpu's + // required pipeline-vs-pass agreement holds. At view_count = 1 the gate + // collapses to `multiview_mask: None`. `(1 << view_count) - 1` is + // computed as `u32::MAX >> (32 - view_count)` to avoid the shift + // overflow that `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let multiview_mask = if view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - view_count)) + } else { + None + }; + let mut color_attachments = vec![ view_prepass_textures .normal @@ -193,7 +218,7 @@ fn run_prepass_system( depth_stencil_attachment, timestamp_writes: None, occlusion_query_set: None, - multiview_mask: None, + multiview_mask, }); let pass_span = diagnostics.pass_span(&mut render_pass, label); @@ -219,6 +244,16 @@ fn run_prepass_system( } } + pass_span.end(&mut render_pass); + drop(render_pass); + + // Dispatch background motion vectors as a separate broadcast pass after + // the prepass-items broadcast pass. The legacy `get_attachment` / + // `get_attachment(StoreOp::Store)` calls below are the SECOND legacy + // calls in this node — the global latch was already flipped to false by + // the prepass-items pass above, so this pass gets `LoadOp::Load` and + // preserves the prepass-items output rather than re-clearing it. Reuses + // `multiview_mask` computed at function top. if let ( Some(background_motion_vectors_pipeline), Some(background_motion_vectors_bind_group), @@ -230,6 +265,36 @@ fn run_prepass_system( ) && let Some(pipeline) = pipeline_cache.get_render_pipeline(background_motion_vectors_pipeline.0) { + let color_attachments = [ + view_prepass_textures + .normal + .as_ref() + .map(|normals_texture| normals_texture.get_attachment()), + view_prepass_textures + .motion_vectors + .as_ref() + .map(|motion_vectors_texture| motion_vectors_texture.get_attachment()), + None, + None, + ]; + + let depth_stencil_attachment = Some(view_depth_texture.get_attachment(StoreOp::Store)); + + let mut render_pass = ctx.begin_tracked_render_pass(RenderPassDescriptor { + label: Some("background_motion_vectors"), + color_attachments: &color_attachments, + depth_stencil_attachment, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask, + }); + + if let Some(viewport) = + Viewport::from_viewport_and_override(camera.viewport.as_ref(), resolution_override) + { + render_pass.set_camera_viewport(&viewport); + } + render_pass.set_render_pipeline(pipeline); render_pass.set_bind_group( 0, @@ -239,13 +304,12 @@ fn run_prepass_system( render_pass.draw(0..3, 0..1); } - pass_span.end(&mut render_pass); - drop(render_pass); - if deferred_prepass.is_none() && let Some(prepass_depth_texture) = &view_prepass_textures.depth { // TODO: Copy depth texture fails for WebGL2, https://github.com/bevyengine/bevy/issues/9710 + // Source + dest extents both carry `depth_or_array_layers = view_count` + // post-C2 sub-A, so this single call copies every layer. ctx.command_encoder().copy_texture_to_texture( view_depth_texture.texture.as_image_copy(), prepass_depth_texture.texture.texture.as_image_copy(), diff --git a/crates/bevy_core_pipeline/src/skybox/mod.rs b/crates/bevy_core_pipeline/src/skybox/mod.rs index 2b74db367892d..cbf35ddd03b43 100644 --- a/crates/bevy_core_pipeline/src/skybox/mod.rs +++ b/crates/bevy_core_pipeline/src/skybox/mod.rs @@ -15,19 +15,20 @@ use bevy_render::{ extract_component::{ComponentUniforms, DynamicUniformIndex, UniformComponentPlugin}, render_asset::RenderAssets, render_resource::{ - binding_types::{sampler, texture_cube, uniform_buffer}, + binding_types::{sampler, texture_cube, uniform_buffer, uniform_buffer_sized}, *, }, renderer::RenderDevice, sync_component::{SyncComponent, SyncComponentPlugin}, sync_world::RenderEntity, texture::GpuImage, - view::{ExtractedView, Msaa, ViewUniform, ViewUniforms}, + view::{ExtractedMultiview, ExtractedView, Msaa, ViewUniforms}, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_transform::components::Transform; use bevy_utils::default; +use core::num::NonZeroU32; use crate::core_3d::CORE_3D_DEPTH_FORMAT; @@ -120,7 +121,16 @@ impl SkyboxPipeline { ( texture_cube(TextureSampleType::Float { filterable: true }), sampler(SamplerBindingType::Filtering), - uniform_buffer::(true) + // View + // + // Sized `None` (unbounded) so the WGSL is free to + // declare the binding as `array` + // for multiview pipelines and `array` for the + // non-multiview fallback. Backing storage is the + // packed `DynamicArrayUniformBuffer` and + // the dynamic offset selects the per-camera array + // slot. + uniform_buffer_sized(true, None) .visibility(ShaderStages::VERTEX_FRAGMENT), uniform_buffer::(true), ), @@ -141,17 +151,44 @@ struct SkyboxPipelineKey { target_format: TextureFormat, samples: u32, depth_format: TextureFormat, + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array` + /// and reads `current_view_index` from `@builtin(view_index)`. + multiview_view_count: u32, } impl SpecializedRenderPipeline for SkyboxPipeline { type Key = SkyboxPipelineKey; fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { + let mut shader_defs = Vec::new(); + if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + + // Broadcast across every eye layer in a single pass. The matching + // render-pass descriptor in `core_3d/main_opaque_pass_3d_node.rs` + // (the extracted skybox broadcast pass) sets the same mask. The + // mask is `(1 << view_count) - 1` (one bit per eye); computed via + // `u32::MAX >> (32 - view_count)` to avoid the shift overflow that + // `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let multiview_mask = if key.multiview_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - key.multiview_view_count)) + } else { + None + }; + RenderPipelineDescriptor { label: Some("skybox_pipeline".into()), + multiview_mask, layout: vec![self.bind_group_layout.clone()], vertex: VertexState { shader: self.shader.clone(), + shader_defs: shader_defs.clone(), ..default() }, depth_stencil: Some(DepthStencilState { @@ -177,6 +214,7 @@ impl SpecializedRenderPipeline for SkyboxPipeline { }, fragment: Some(FragmentState { shader: self.shader.clone(), + shader_defs, targets: vec![Some(ColorTargetState { format: key.target_format, // BlendState::REPLACE is not needed here, and None will be potentially much faster in some cases. @@ -198,9 +236,15 @@ fn prepare_skybox_pipelines( pipeline_cache: Res, mut pipelines: ResMut>, pipeline: Res, - cameras: Query<(Entity, &ExtractedView, &Msaa), With>, + cameras: Query< + (Entity, &ExtractedView, &Msaa, Option<&ExtractedMultiview>), + With, + >, ) { - for (entity, view, msaa) in &cameras { + for (entity, view, msaa, multiview) in &cameras { + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); let pipeline_id = pipelines.specialize( &pipeline_cache, &pipeline, @@ -208,6 +252,7 @@ fn prepare_skybox_pipelines( target_format: view.target_format, samples: msaa.samples(), depth_format: CORE_3D_DEPTH_FORMAT, + multiview_view_count, }, ); diff --git a/crates/bevy_core_pipeline/src/skybox/skybox.wgsl b/crates/bevy_core_pipeline/src/skybox/skybox.wgsl index 7982370a19794..4d72864bd7d1e 100644 --- a/crates/bevy_core_pipeline/src/skybox/skybox.wgsl +++ b/crates/bevy_core_pipeline/src/skybox/skybox.wgsl @@ -13,7 +13,23 @@ struct SkyboxUniforms { @group(0) @binding(0) var skybox: texture_cube; @group(0) @binding(1) var skybox_sampler: sampler; -@group(0) @binding(2) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so the skybox pipeline can share the packed `DynamicArrayUniformBuffer` +// behind `ViewUniforms`. The shader reads through `view()`, which indexes the +// array at `current_view_index` (set from `@builtin(view_index)` at the top +// of multiview entry points). For non-multiview pipelines, `MAX_VIEW_COUNT` +// is undefined and the fallback `array` matches the single +// `ViewUniform` packed per camera. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(2) var view_array: array; +#else +@group(0) @binding(2) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} @group(0) @binding(3) var uniforms: SkyboxUniforms; fn coords_to_ray_direction(position: vec2, viewport: vec4) -> vec3 { @@ -25,16 +41,16 @@ fn coords_to_ray_direction(position: vec2, viewport: vec4) -> vec3 VertexOutput { return VertexOutput(vec4(clip_position, 0.0, 1.0)); } +struct FragmentInput { + @builtin(position) position: vec4, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +} + @fragment -fn skybox_fragment(in: VertexOutput) -> @location(0) vec4 { - let ray_direction = coords_to_ray_direction(in.position.xy, view.viewport); +fn skybox_fragment(in: FragmentInput) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = in.view_index; +#endif + let ray_direction = coords_to_ray_direction(in.position.xy, view().viewport); // Cube maps are left-handed so we negate the z coordinate. let out = textureSample(skybox, skybox_sampler, ray_direction * vec3(1.0, 1.0, -1.0)); diff --git a/crates/bevy_core_pipeline/src/tonemapping/mod.rs b/crates/bevy_core_pipeline/src/tonemapping/mod.rs index a100635d7227c..2f73d792272bc 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/mod.rs +++ b/crates/bevy_core_pipeline/src/tonemapping/mod.rs @@ -13,16 +13,17 @@ use bevy_render::{ extract_resource::{ExtractResource, ExtractResourcePlugin}, render_asset::RenderAssets, render_resource::{ - binding_types::{sampler, texture_2d, texture_3d, uniform_buffer}, + binding_types::{sampler, texture_2d, texture_3d, uniform_buffer_sized}, *, }, renderer::RenderDevice, texture::{FallbackImage, GpuImage}, - view::{ExtractedView, ViewTarget, ViewUniform}, + view::{ExtractedMultiview, ExtractedView, ViewTarget}, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, }; use bevy_shader::{load_shader_library, Shader, ShaderDefVal}; use bitflags::bitflags; +use core::num::NonZeroU32; mod node; @@ -193,6 +194,10 @@ pub struct TonemappingPipelineKey { deband_dither: DebandDither, tonemapping: Tonemapping, flags: TonemappingPipelineKeyFlags, + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array` + /// and reads `current_view_index` from `@builtin(view_index)`. + multiview_view_count: u32, } impl SpecializedRenderPipeline for TonemappingPipeline { @@ -210,6 +215,14 @@ impl SpecializedRenderPipeline for TonemappingPipeline { 4, )); + if key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + if let DebandDither::Enabled = key.deband_dither { shader_defs.push("DEBAND_DITHER".into()); } @@ -270,8 +283,21 @@ impl SpecializedRenderPipeline for TonemappingPipeline { } Tonemapping::PbrNeutral => shader_defs.push("TONEMAP_METHOD_PBR_NEUTRAL".into()), } + + // Broadcast across every eye layer in a single pass. The matching + // render-pass descriptor in `node.rs` sets the same mask. The mask is + // `(1 << view_count) - 1` (one bit per eye); computed via + // `u32::MAX >> (32 - view_count)` to avoid the shift overflow that + // `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let multiview_mask = if key.multiview_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - key.multiview_view_count)) + } else { + None + }; + RenderPipelineDescriptor { label: Some("tonemapping pipeline".into()), + multiview_mask, layout: vec![self.texture_bind_group.clone()], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { @@ -298,7 +324,14 @@ pub fn init_tonemapping_pipeline( let mut entries = DynamicBindGroupLayoutEntries::new_with_indices( ShaderStages::FRAGMENT, ( - (0, uniform_buffer::(true)), + // View + // + // Sized `None` (unbounded) so the WGSL is free to declare the + // binding as `array` for multiview pipelines + // and `array` for the non-multiview fallback. Backing + // storage is the packed `DynamicArrayUniformBuffer` + // and the dynamic offset selects the per-camera array slot. + (0, uniform_buffer_sized(true, None)), ( 1, texture_2d(TextureSampleType::Float { filterable: false }), @@ -336,11 +369,12 @@ pub fn prepare_view_tonemapping_pipelines( &ExtractedView, Option<&Tonemapping>, Option<&DebandDither>, + Option<&ExtractedMultiview>, ), With, >, ) { - for (entity, view, tonemapping, dither) in view_targets.iter() { + for (entity, view, tonemapping, dither, multiview) in view_targets.iter() { // As an optimization, we omit parts of the shader that are unneeded. let mut flags = TonemappingPipelineKeyFlags::empty(); flags.set( @@ -358,11 +392,16 @@ pub fn prepare_view_tonemapping_pipelines( .any(|section| *section != default()), ); + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); + let key = TonemappingPipelineKey { target_format: view.target_format, deband_dither: *dither.unwrap_or(&DebandDither::Disabled), tonemapping: *tonemapping.unwrap_or(&Tonemapping::None), flags, + multiview_view_count, }; let pipeline = pipelines.specialize(&pipeline_cache, &upscaling_pipeline, key); diff --git a/crates/bevy_core_pipeline/src/tonemapping/node.rs b/crates/bevy_core_pipeline/src/tonemapping/node.rs index b131005b56b50..5dfa9c3607668 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/node.rs +++ b/crates/bevy_core_pipeline/src/tonemapping/node.rs @@ -15,6 +15,7 @@ use bevy_render::{ }; use super::{get_lut_bindings, Tonemapping}; +use core::num::NonZeroU32; /// Cached bind group state for tonemapping. #[derive(Default)] @@ -102,6 +103,18 @@ pub fn tonemapping( } }; + // Broadcast across every eye layer in a single pass. The matching + // pipeline descriptor in `mod.rs` sets the same mask. The mask is + // `(1 << view_count) - 1` (one bit per eye); computed via + // `u32::MAX >> (32 - view_count)` to avoid the shift overflow that + // `1 << 32` would hit at the `MAX_VIEW_COUNT` cap. + let view_count = target.multiview_count().map_or(1, |n| n.get()); + let multiview_mask = if view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - view_count)) + } else { + None + }; + let pass_descriptor = RenderPassDescriptor { label: Some("tonemapping"), color_attachments: &[Some(RenderPassColorAttachment { @@ -116,7 +129,7 @@ pub fn tonemapping( depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, - multiview_mask: None, + multiview_mask, }; let diagnostics = ctx.diagnostic_recorder(); diff --git a/crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl b/crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl index 015cd48c695eb..c9679d106de5a 100644 --- a/crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl +++ b/crates/bevy_core_pipeline/src/tonemapping/tonemapping.wgsl @@ -9,7 +9,23 @@ tonemapping::{tone_mapping, screen_space_dither}, } -@group(0) @binding(0) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so the tonemapping pipeline can share the packed +// `DynamicArrayUniformBuffer` behind `ViewUniforms`. The shader reads through +// `view()`, which indexes the array at `current_view_index` (set from +// `@builtin(view_index)` at the top of the fragment under MULTIVIEW). For +// non-multiview pipelines, `MAX_VIEW_COUNT` is undefined and the fallback +// `array` matches the single `ViewUniform` packed per camera. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(0) var view_array: array; +#else +@group(0) @binding(0) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} @group(0) @binding(1) var hdr_texture: texture_2d; @group(0) @binding(2) var hdr_sampler: sampler; @@ -17,10 +33,18 @@ @group(0) @binding(4) var dt_lut_sampler: sampler; @fragment -fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let hdr_color = textureSample(hdr_texture, hdr_sampler, in.uv); - var output_rgb = tone_mapping(hdr_color, view.color_grading).rgb; + var output_rgb = tone_mapping(hdr_color, view().color_grading).rgb; #ifdef DEBAND_DITHER output_rgb = powsafe(output_rgb.rgb, 1.0 / 2.2); diff --git a/crates/bevy_core_pipeline/src/upscaling/mod.rs b/crates/bevy_core_pipeline/src/upscaling/mod.rs index ba6a95ee79891..5db4383e070a7 100644 --- a/crates/bevy_core_pipeline/src/upscaling/mod.rs +++ b/crates/bevy_core_pipeline/src/upscaling/mod.rs @@ -3,8 +3,10 @@ use bevy_app::prelude::*; use bevy_camera::CameraOutputMode; use bevy_ecs::prelude::*; use bevy_render::{ - camera::ExtractedCamera, render_resource::*, view::ViewTarget, Render, RenderApp, - RenderStartup, RenderSystems, + camera::ExtractedCamera, + render_resource::*, + view::{ExtractedMultiview, ViewTarget}, + Render, RenderApp, RenderStartup, RenderSystems, }; mod node; @@ -54,10 +56,11 @@ fn prepare_view_upscaling_pipelines( Entity, &ViewTarget, Option<&ExtractedCamera>, + Option<&ExtractedMultiview>, Option<&ViewUpscalingPipeline>, )>, ) { - for (entity, view_target, camera, maybe_pipeline) in view_targets.iter() { + for (entity, view_target, camera, multiview, maybe_pipeline) in view_targets.iter() { let blend_state = if let Some(extracted_camera) = camera { match extracted_camera.output_mode { CameraOutputMode::Skip => None, @@ -86,11 +89,14 @@ fn prepare_view_upscaling_pipelines( continue; }; + let multiview_view_count = multiview.map_or(1, |m| m.subviews.len() as u32); + let key = BlitPipelineKey { target_format, blend_state, samples: 1, source_space: view_target.compositing_space, + multiview_view_count, }; if maybe_pipeline.is_none_or(|ViewUpscalingPipeline(_, cached_key)| *cached_key != key) { diff --git a/crates/bevy_core_pipeline/src/upscaling/node.rs b/crates/bevy_core_pipeline/src/upscaling/node.rs index 69e8b8e71fd25..c1c6306db9005 100644 --- a/crates/bevy_core_pipeline/src/upscaling/node.rs +++ b/crates/bevy_core_pipeline/src/upscaling/node.rs @@ -53,6 +53,7 @@ pub fn upscaling( ctx.render_device(), main_texture_view, &pipeline_cache, + target.multiview_count().map_or(1, |n| n.get()), ); let (_, bind_group) = cached.insert((main_texture_view.id(), bind_group)); diff --git a/crates/bevy_dev_tools/src/debug_overlay.wgsl b/crates/bevy_dev_tools/src/debug_overlay.wgsl index 45d99e78c7853..4a8fa267913a6 100644 --- a/crates/bevy_dev_tools/src/debug_overlay.wgsl +++ b/crates/bevy_dev_tools/src/debug_overlay.wgsl @@ -25,14 +25,26 @@ struct DebugBufferConfig { #endif @fragment -fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 { - let uv = frag_coord.xy / view.viewport.zw; +fn fragment( + @builtin(position) frag_coord: vec4, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + let uv = frag_coord.xy / view().viewport.zw; let background = textureSampleLevel(background_texture, background_sampler, uv, 0.0); var output_color: vec4 = vec4(0.0); #ifdef DEBUG_DEPTH #ifdef DEPTH_PREPASS - let depth = textureLoad(depth_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let depth = textureLoad(depth_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let depth = textureLoad(depth_prepass_texture, vec2(frag_coord.xy), 0); + #endif output_color = vec4(vec3(depth), 1.0); #else output_color = vec4(1.0, 0.0, 1.0, 1.0); @@ -41,11 +53,19 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_NORMAL #ifdef NORMAL_PREPASS - let normal_sample = textureLoad(normal_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let normal_sample = textureLoad(normal_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let normal_sample = textureLoad(normal_prepass_texture, vec2(frag_coord.xy), 0); + #endif output_color = vec4(normal_sample.xyz, 1.0); #else #ifdef DEFERRED_PREPASS - let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #endif let normal = octahedral_decode(unpack_24bit_normal(deferred.a)); output_color = vec4(normal * 0.5 + 0.5, 1.0); #else @@ -56,7 +76,11 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_MOTION_VECTORS #ifdef MOTION_VECTOR_PREPASS - let motion_vector = textureLoad(motion_vector_prepass_texture, vec2(frag_coord.xy), 0).rg; + #ifdef MULTIVIEW + let motion_vector = textureLoad(motion_vector_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0).rg; + #else + let motion_vector = textureLoad(motion_vector_prepass_texture, vec2(frag_coord.xy), 0).rg; + #endif // These motion vectors are stored in a format where 1.0 represents full-screen movement. // We use a power curve to amplify small movements while keeping them centered. let mapped_motion = sign(motion_vector) * pow(abs(motion_vector), vec2(0.2)) * 0.5 + 0.5; @@ -68,7 +92,11 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_DEFERRED #ifdef DEFERRED_PREPASS - let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #endif output_color = vec4(vec3(f32(deferred.x) / 255.0, f32(deferred.y) / 255.0, f32(deferred.z) / 255.0), 1.0); #else output_color = vec4(1.0, 0.0, 1.0, 1.0); @@ -77,7 +105,11 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_DEFERRED_BASE_COLOR #ifdef DEFERRED_PREPASS - let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #endif let base_rough = unpack_unorm4x8_(deferred.x); output_color = vec4(pow(base_rough.rgb, vec3(2.2)), 1.0); #else @@ -87,7 +119,11 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_DEFERRED_EMISSIVE #ifdef DEFERRED_PREPASS - let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #endif let emissive = rgb9e5_to_vec3_(deferred.y); output_color = vec4(emissive, 1.0); #else @@ -97,7 +133,11 @@ fn fragment(@builtin(position) frag_coord: vec4) -> @location(0) vec4 #ifdef DEBUG_DEFERRED_METALLIC_ROUGHNESS #ifdef DEFERRED_PREPASS - let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #ifdef MULTIVIEW + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); + #else + let deferred = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); + #endif let base_rough = unpack_unorm4x8_(deferred.x); let props = unpack_unorm4x8_(deferred.z); // R: Reflectance, G: Metallic, B: Occlusion, A: Perceptual Roughness diff --git a/crates/bevy_dev_tools/src/render_debug.rs b/crates/bevy_dev_tools/src/render_debug.rs index 7fbecce018ccb..d341a0c09a8b6 100644 --- a/crates/bevy_dev_tools/src/render_debug.rs +++ b/crates/bevy_dev_tools/src/render_debug.rs @@ -496,6 +496,13 @@ impl SpecializedRenderPipeline for RenderDebugOverlayPipeline { shader_defs.push("MULTISAMPLED".into()); } + if key + .view_layout_key + .contains(MeshPipelineViewLayoutKey::MULTIVIEW) + { + shader_defs.push("MULTIVIEW".into()); + } + let mesh_view_layout_descriptor = self .mesh_view_layouts .get_view_layout(key.view_layout_key) @@ -521,6 +528,7 @@ impl SpecializedRenderPipeline for RenderDebugOverlayPipeline { primitive: bevy_render::render_resource::PrimitiveState::default(), depth_stencil: None, multisample: bevy_render::render_resource::MultisampleState::default(), + multiview_mask: None, immediate_size: 0, zero_initialize_workgroup_memory: false, } diff --git a/crates/bevy_material/src/descriptor.rs b/crates/bevy_material/src/descriptor.rs index 29ffb507b512f..bf879d12b0cbd 100644 --- a/crates/bevy_material/src/descriptor.rs +++ b/crates/bevy_material/src/descriptor.rs @@ -3,7 +3,7 @@ use bevy_asset::Handle; use bevy_derive::Deref; use bevy_mesh::VertexBufferLayout; use bevy_shader::{CachedPipelineId, Shader, ShaderDefVal}; -use core::iter; +use core::{iter, num::NonZeroU32}; use thiserror::Error; use wgpu_types::{ BindGroupLayoutEntry, ColorTargetState, DepthStencilState, MultisampleState, PrimitiveState, @@ -43,6 +43,14 @@ pub struct RenderPipelineDescriptor { pub depth_stencil: Option, /// The multi-sampling properties of the pipeline. pub multisample: MultisampleState, + /// The view mask for multiview rendering, if any. + /// + /// When `Some`, the pipeline renders to multiple view layers in a single + /// pass; each bit of the mask selects a layer of the render target's + /// texture array. Shaders can read the current view via + /// `@builtin(view_index)`. The render pass that uses this pipeline must + /// be configured with a matching multiview mask. + pub multiview_mask: Option, /// The compiled fragment stage, its entry point, and the color targets. pub fragment: Option, /// Whether to zero-initialize workgroup memory by default. If you're not sure, set this to true. diff --git a/crates/bevy_pbr/src/atmosphere/render_sky.wgsl b/crates/bevy_pbr/src/atmosphere/render_sky.wgsl index 16a9aa994a3b1..6d569246c5eda 100644 --- a/crates/bevy_pbr/src/atmosphere/render_sky.wgsl +++ b/crates/bevy_pbr/src/atmosphere/render_sky.wgsl @@ -13,11 +13,22 @@ enable dual_source_blending; #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +// Under MULTIVIEW the view depth texture is grown to a per-eye array (see +// `prepare_core_3d_depth_textures`), and the fragment threads +// `@builtin(view_index)` into the read. WGSL has no +// `texture_depth_multisampled_2d_array`, so the MSAA + multiview combination +// keeps the single-layer shape — the host gates the MULTIVIEW shader def on +// `!MULTISAMPLED` to match. Same shape as the prepass-texture bindings in +// `mesh_view_bindings.wgsl`. #ifdef MULTISAMPLED @group(0) @binding(13) var depth_texture: texture_depth_multisampled_2d; #else +#ifdef MULTIVIEW +@group(0) @binding(13) var depth_texture: texture_depth_2d_array; +#else @group(0) @binding(13) var depth_texture: texture_depth_2d; #endif +#endif struct RenderSkyOutput { #ifdef DUAL_SOURCE_BLENDING @@ -29,8 +40,21 @@ struct RenderSkyOutput { } @fragment -fn main(in: FullscreenVertexOutput) -> RenderSkyOutput { +fn main( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> RenderSkyOutput { +#ifdef MULTIVIEW +#ifndef MULTISAMPLED + let depth = textureLoad(depth_texture, vec2(in.position.xy), view_index, 0); +#else let depth = textureLoad(depth_texture, vec2(in.position.xy), 0); +#endif +#else + let depth = textureLoad(depth_texture, vec2(in.position.xy), 0); +#endif let ray_dir_ws = uv_to_ray_direction(in.uv); let world_pos = get_view_position(); diff --git a/crates/bevy_pbr/src/atmosphere/resources.rs b/crates/bevy_pbr/src/atmosphere/resources.rs index 6f77899ab1f98..e67e3a5da2bb2 100644 --- a/crates/bevy_pbr/src/atmosphere/resources.rs +++ b/crates/bevy_pbr/src/atmosphere/resources.rs @@ -23,9 +23,9 @@ use bevy_render::{ render_resource::{binding_types::*, *}, renderer::{RenderDevice, RenderQueue}, texture::{CachedTexture, TextureCache}, - view::{ExtractedView, Msaa, ViewDepthTexture, ViewUniform, ViewUniforms}, + view::{ExtractedMultiview, ExtractedView, Msaa, ViewDepthTexture, ViewUniform, ViewUniforms}, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::default; use super::GpuAtmosphereSettings; @@ -42,6 +42,12 @@ pub(crate) struct AtmosphereBindGroupLayouts { pub(crate) struct RenderSkyBindGroupLayouts { pub render_sky: BindGroupLayoutDescriptor, pub render_sky_msaa: BindGroupLayoutDescriptor, + /// Variant used on multiview cameras when MSAA is off: binding 13 is a + /// `texture_depth_2d_array` so the fragment can read its own eye's depth + /// via `@builtin(view_index)`. MSAA + multiview keeps the non-multiview + /// `render_sky_msaa` layout — see the carve-out in `render_sky.wgsl` and + /// `mesh_view_bindings.wgsl:99-106`. + pub render_sky_multiview: BindGroupLayoutDescriptor, pub fullscreen_shader: FullscreenShader, pub fragment_shader: Handle, } @@ -219,9 +225,36 @@ impl FromWorld for RenderSkyBindGroupLayouts { ), ); + let render_sky_multiview = BindGroupLayoutDescriptor::new( + "render_sky_multiview_bind_group_layout", + &BindGroupLayoutEntries::with_indices( + ShaderStages::FRAGMENT, + ( + (0, uniform_buffer::(true)), + (1, uniform_buffer::(true)), + (2, uniform_buffer::(true)), + (3, uniform_buffer::(true)), + (4, uniform_buffer::(true)), + // scattering medium luts and sampler + (5, texture_2d(TextureSampleType::default())), + (6, texture_2d(TextureSampleType::default())), + (7, sampler(SamplerBindingType::Filtering)), + // atmosphere luts and sampler + (8, texture_2d(TextureSampleType::default())), // transmittance + (9, texture_2d(TextureSampleType::default())), // multiscattering + (10, texture_2d(TextureSampleType::default())), // sky view + (11, texture_3d(TextureSampleType::default())), // aerial view + (12, sampler(SamplerBindingType::Filtering)), + // per-eye view depth texture array + (13, texture_2d_array(TextureSampleType::Depth)), + ), + ), + ); + Self { render_sky, render_sky_msaa, + render_sky_multiview, fullscreen_shader: world.resource::().clone(), fragment_shader: load_embedded_asset!(world, "render_sky.wgsl"), } @@ -304,6 +337,10 @@ pub(crate) struct RenderSkyPipelineId(pub CachedRenderPipelineId); pub(crate) struct RenderSkyPipelineKey { pub msaa_samples: u32, pub dual_source_blending: bool, + /// Number of subviews on the camera's `Multiview` component, or 1 if the + /// camera is not multiview. Gates the `MULTIVIEW`/`MAX_VIEW_COUNT` shader + /// defs and the per-eye depth-array layout selection in `specialize`. + pub multiview_view_count: u32, } impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { @@ -319,19 +356,35 @@ impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { shader_defs.push("DUAL_SOURCE_BLENDING".into()); } + // WGSL has no `texture_depth_multisampled_2d_array`, so MULTIVIEW only + // kicks in on non-MSAA cameras. See `render_sky.wgsl` and + // `mesh_view_bindings.wgsl:99-106`. + let push_multiview = key.multiview_view_count > 1 && key.msaa_samples == 1; + if push_multiview { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + let dst_factor = if key.dual_source_blending { BlendFactor::Src1 } else { BlendFactor::SrcAlpha }; + let layout = if key.msaa_samples > 1 { + self.render_sky_msaa.clone() + } else if push_multiview { + self.render_sky_multiview.clone() + } else { + self.render_sky.clone() + }; + RenderPipelineDescriptor { label: Some(format!("render_sky_pipeline_{}", key.msaa_samples).into()), - layout: vec![if key.msaa_samples == 1 { - self.render_sky.clone() - } else { - self.render_sky_msaa.clone() - }], + layout: vec![layout], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { shader: self.fragment_shader.clone(), @@ -364,14 +417,18 @@ impl SpecializedRenderPipeline for RenderSkyBindGroupLayouts { } pub(super) fn queue_render_sky_pipelines( - views: Query<(Entity, &Msaa), (With, With)>, + views: Query< + (Entity, &Msaa, Option<&ExtractedMultiview>), + (With, With), + >, pipeline_cache: Res, layouts: Res, mut specializer: ResMut>, render_device: Res, mut commands: Commands, ) { - for (entity, msaa) in &views { + for (entity, msaa, multiview) in &views { + let multiview_view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); let id = specializer.specialize( &pipeline_cache, &layouts, @@ -380,6 +437,7 @@ pub(super) fn queue_render_sky_pipelines( dual_source_blending: render_device .features() .contains(WgpuFeatures::DUAL_SOURCE_BLENDING), + multiview_view_count, }, ); commands.entity(entity).insert(RenderSkyPipelineId(id)); @@ -622,6 +680,7 @@ pub(super) fn prepare_atmosphere_bind_groups( &AtmosphereTextures, &ViewDepthTexture, &Msaa, + Option<&ExtractedMultiview>, ), (With, With), >, @@ -666,7 +725,9 @@ pub(super) fn prepare_atmosphere_bind_groups( .binding() .ok_or(AtmosphereBindGroupError::LightUniforms)?; - for (entity, atmosphere, textures, view_depth_texture, msaa) in &views { + for (entity, atmosphere, textures, view_depth_texture, msaa, multiview) in &views { + let multiview_view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let use_multiview_layout = multiview_view_count > 1 && *msaa == Msaa::Off; let gpu_medium = gpu_media .get(atmosphere.medium) .ok_or(ScatteringMediumMissingError(atmosphere.medium))?; @@ -751,13 +812,16 @@ pub(super) fn prepare_atmosphere_bind_groups( )), ); + let render_sky_layout = if *msaa != Msaa::Off { + &render_sky_layouts.render_sky_msaa + } else if use_multiview_layout { + &render_sky_layouts.render_sky_multiview + } else { + &render_sky_layouts.render_sky + }; let render_sky = render_device.create_bind_group( "render_sky_bind_group", - &pipeline_cache.get_bind_group_layout(if *msaa == Msaa::Off { - &render_sky_layouts.render_sky - } else { - &render_sky_layouts.render_sky_msaa - }), + &pipeline_cache.get_bind_group_layout(render_sky_layout), &BindGroupEntries::with_indices(( // uniforms (0, atmosphere_binding.clone()), diff --git a/crates/bevy_pbr/src/cluster/cluster_raster.wgsl b/crates/bevy_pbr/src/cluster/cluster_raster.wgsl index dbd623a3383d4..b58bd59fd23c0 100644 --- a/crates/bevy_pbr/src/cluster/cluster_raster.wgsl +++ b/crates/bevy_pbr/src/cluster/cluster_raster.wgsl @@ -77,8 +77,24 @@ struct ClusterOffsetsAndCountsElementAtomic { // Information about the clusters as a whole, including the dimensions of the // cluster grid. @group(0) @binding(5) var lights: Lights; -// Information about the view. -@group(0) @binding(6) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so this rasterization pipeline can share the packed +// `DynamicArrayUniformBuffer` behind `ViewUniforms`. Clustering output is a +// single set of storage buffers per camera (shared across all eyes of a +// multiview camera), so this shader reads `view()` at the default +// `current_view_index = 0` — i.e. eye 0's head pose — to keep cluster +// assignment consistent across eyes. Per-eye clustering would require +// splitting the output buffers per eye, which is future work. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(6) var view_array: array; +#else +@group(0) @binding(6) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} #ifndef VERTEX_SHADER #ifdef POPULATE_PASS // The number of objects in each cluster, and the offset of each list. @@ -109,10 +125,10 @@ fn vertex_main(vertex: Vertex) -> Varyings { let position = bounding_sphere.xyz; let radius = bounding_sphere.w; - let view_from_world_scale = compute_view_from_world_scale(view.world_from_view); + let view_from_world_scale = compute_view_from_world_scale(view().world_from_view); let max_view_from_world_scale = max(view_from_world_scale.x, max(view_from_world_scale.y, view_from_world_scale.z)); - let is_orthographic = view.clip_from_view[3].w == 1.0; + let is_orthographic = view().clip_from_view[3].w == 1.0; // Calculate an approximate AABB of the cluster by computing its bounding // sphere and converting that to the AABB. @@ -123,8 +139,8 @@ fn vertex_main(vertex: Vertex) -> Varyings { let cluster_bounds = calculate_sphere_cluster_bounds( position, radius, - view.view_from_world, - view.clip_from_view, + view().view_from_world, + view().clip_from_view, view_from_world_scale, lights.cluster_dimensions.xyz, lights.cluster_factors.zw, @@ -133,7 +149,7 @@ fn vertex_main(vertex: Vertex) -> Varyings { let cluster_bounds_xy = vec4(cluster_bounds.min.xy, cluster_bounds.max.xy + vec2(1u)); // Calculate the bounding sphere's center and radius in view space. - let view_position = (view.view_from_world * vec4(position, 1.0)).xyz; + let view_position = (view().view_from_world * vec4(position, 1.0)).xyz; let view_radius = max_view_from_world_scale * radius; return Varyings( @@ -168,8 +184,8 @@ fn fragment_main(varyings: Varyings) -> @location(0) vec4 { let object_type = z_slices[instance_id].object_type; let z_slice = z_slices[instance_id].z_slice; - let is_orthographic = view.clip_from_view[3].w == 1.0; - let screen_size = view.viewport.zw; + let is_orthographic = view().clip_from_view[3].w == 1.0; + let screen_size = view().viewport.zw; let tile_size = screen_size / vec2(lights.cluster_dimensions.xy); let z_near_far = compute_z_near_and_z_far(is_orthographic); @@ -183,7 +199,7 @@ fn fragment_main(varyings: Varyings) -> @location(0) vec4 { z_far, tile_size, screen_size, - view.view_from_clip, + view().view_from_clip, is_orthographic, lights.cluster_dimensions.xyz, cluster_position @@ -401,7 +417,7 @@ fn cull_spot_light( world_light_direction_rev_xz.y ); let world_light_direction = -world_light_direction_rev; - let view_light_direction = normalize((view.view_from_world * + let view_light_direction = normalize((view().view_from_world * vec4(world_light_direction, 0.0)).xyz); let angle_cos = cos_atan(light_tan_angle); diff --git a/crates/bevy_pbr/src/cluster/cluster_z_slice.wgsl b/crates/bevy_pbr/src/cluster/cluster_z_slice.wgsl index dfc0a9e4998a5..c20af1601a0fa 100644 --- a/crates/bevy_pbr/src/cluster/cluster_z_slice.wgsl +++ b/crates/bevy_pbr/src/cluster/cluster_z_slice.wgsl @@ -33,8 +33,23 @@ // Information about the clusters as a whole, including the dimensions of the // cluster grid. @group(0) @binding(5) var lights: Lights; -// Information about the view. -@group(0) @binding(6) var view: View; +// View uniform, shaped the same as `bevy_pbr::mesh_view_bindings::view_array` +// so this compute pipeline can share the packed `DynamicArrayUniformBuffer` +// behind `ViewUniforms`. Clustering output is a single set of storage buffers +// per camera (shared across all eyes of a multiview camera), so this shader +// reads `view()` at the default `current_view_index = 0` — i.e. eye 0's head +// pose — to keep cluster assignment consistent across eyes. Per-eye clustering +// would require splitting the output buffers per eye, which is future work. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(6) var view_array: array; +#else +@group(0) @binding(6) var view_array: array; +#endif +var current_view_index: i32 = 0; + +fn view() -> View { + return view_array[current_view_index]; +} // A temporary workgroup-local buffer used to accelerate the "farthest depth of // any object" calculation. @@ -105,8 +120,8 @@ fn z_slice_main( radius = clustered_decals.decals[object_index].bounding_sphere_radius; } - let view_from_world_scale = compute_view_from_world_scale(view.world_from_view); - let is_orthographic = view.clip_from_view[3].w == 1.0; + let view_from_world_scale = compute_view_from_world_scale(view().world_from_view); + let is_orthographic = view().clip_from_view[3].w == 1.0; // Gather the farthest Z value among all clusters in this workgroup. // We want to do this *before* bailing out below so that all threads hit the @@ -121,8 +136,8 @@ fn z_slice_main( let cluster_bounds = calculate_sphere_cluster_bounds( position, radius, - view.view_from_world, - view.clip_from_view, + view().view_from_world, + view().clip_from_view, view_from_world_scale, lights.cluster_dimensions.xyz, lights.cluster_factors.zw, @@ -161,7 +176,7 @@ fn accumulate_farthest_z_value( is_orthographic: bool ) { // Compute the maximum Z extent for our clusterable object. - let view_from_world_row_2 = transpose(view.view_from_world)[2]; + let view_from_world_row_2 = transpose(view().view_from_world)[2]; let far_z = dot(-view_from_world_row_2, vec4(position, 1.0)) + radius * view_from_world_scale.z; shared_farthest_z[local_id] = far_z; workgroupBarrier(); diff --git a/crates/bevy_pbr/src/cluster/gpu.rs b/crates/bevy_pbr/src/cluster/gpu.rs index 408e03e82a273..04fc7bb43b88b 100644 --- a/crates/bevy_pbr/src/cluster/gpu.rs +++ b/crates/bevy_pbr/src/cluster/gpu.rs @@ -93,7 +93,7 @@ use bevy_render::{ renderer::{RenderContext, RenderDevice, RenderQueue, ViewQuery}, sync_world::{MainEntity, MainEntityHashMap, MainEntityHashSet, RenderEntity}, texture::{CachedTexture, TextureCache}, - view::{ExtractedView, ViewUniform, ViewUniformOffset, ViewUniforms}, + view::{ExtractedMultiview, ExtractedView, ViewUniformOffset, ViewUniforms}, GpuResourceAppExt, MainWorld, Render, RenderApp, RenderSystems, }; use bevy_shader::{load_shader_library, Shader, ShaderDefVal}; @@ -488,6 +488,18 @@ pub struct ClusteringRasterPipelineKey { /// True if this is the populate (second) pass; false if it's the count /// (first) one. populate_pass: bool, + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array`. + multiview_view_count: u32, +} + +/// The pipeline key that identifies specializations of the +/// `cluster_z_slice.wgsl` shader. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ClusteringZSlicingPipelineKey { + /// Per-camera multiview layer count. `1` is the non-multiview case; + /// `>1` flips the WGSL `view_array` to `array`. + multiview_view_count: u32, } /// The pipeline key that identifies specializations of the @@ -526,8 +538,14 @@ impl FromWorld for ClusteringRasterPipeline { // @group(0) @binding(5) var lights: Lights; binding_types::uniform_buffer::(true) .build(5, ShaderStages::VERTEX_FRAGMENT), - // @group(0) @binding(6) var view: View; - binding_types::uniform_buffer::(true) + // @group(0) @binding(6) var view_array: array; + // + // Sized `None` (unbounded) so the WGSL is free to declare the + // binding as `array` for multiview pipelines + // and `array` for the non-multiview fallback. Backing + // storage is the packed `DynamicArrayUniformBuffer` + // and the dynamic offset selects the per-camera array slot. + binding_types::uniform_buffer_sized(true, None) .build(6, ShaderStages::VERTEX_FRAGMENT), ]; @@ -583,6 +601,12 @@ impl SpecializedRenderPipeline for ClusteringRasterPipeline { } else { fragment_shader_defs.push(ShaderDefVal::from("COUNT_PASS")); } + if key.multiview_view_count > 1 { + fragment_shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } let mut vertex_shader_defs = fragment_shader_defs.clone(); vertex_shader_defs.push(ShaderDefVal::from("VERTEX_SHADER")); @@ -655,8 +679,14 @@ impl FromWorld for ClusteringZSlicingPipeline { binding_types::storage_buffer_read_only::(false), // @group(0) @binding(5) var lights: Lights; binding_types::uniform_buffer::(true), - // @group(0) @binding(6) var view: View; - binding_types::uniform_buffer::(true), + // @group(0) @binding(6) var view_array: array; + // + // Sized `None` (unbounded) so the WGSL declares + // `array` for multiview pipelines and + // `array` for the fallback. Dynamic-offset selects + // the per-camera array slot in the packed view-uniform + // buffer. + binding_types::uniform_buffer_sized(true, None), ), ), ); @@ -671,14 +701,22 @@ impl FromWorld for ClusteringZSlicingPipeline { } impl SpecializedComputePipeline for ClusteringZSlicingPipeline { - type Key = (); + type Key = ClusteringZSlicingPipelineKey; + + fn specialize(&self, key: Self::Key) -> ComputePipelineDescriptor { + let mut shader_defs = vec![]; + if key.multiview_view_count > 1 { + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } - fn specialize(&self, _: Self::Key) -> ComputePipelineDescriptor { ComputePipelineDescriptor { label: Some("clustering Z slicing pipeline".into()), layout: vec![self.bind_group_layout.clone()], shader: self.shader.clone(), - shader_defs: vec![], + shader_defs, entry_point: Some("z_slice_main".into()), zero_initialize_workgroup_memory: true, ..default() @@ -1463,7 +1501,7 @@ fn prepare_cluster_dummy_textures( /// in GPU clustering for each view. fn prepare_clustering_pipelines( mut commands: Commands, - views_query: Query>, + views_query: Query<(Entity, Option<&ExtractedMultiview>), With>, pipeline_cache: Res, mut clustering_z_slicing_pipelines: ResMut< SpecializedComputePipelines, @@ -1476,17 +1514,24 @@ fn prepare_clustering_pipelines( clustering_raster_pipeline: Res, clustering_allocation_pipeline: Res, ) { - for view_entity in &views_query { + for (view_entity, multiview) in &views_query { + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); + let clustering_z_slicing_pipeline_id = clustering_z_slicing_pipelines.specialize( &pipeline_cache, &clustering_z_slicing_pipeline, - (), + ClusteringZSlicingPipelineKey { + multiview_view_count, + }, ); let clustering_count_pipeline_id = clustering_raster_pipelines.specialize( &pipeline_cache, &clustering_raster_pipeline, ClusteringRasterPipelineKey { populate_pass: false, + multiview_view_count, }, ); let clustering_local_allocation_pipeline_id = clustering_allocation_pipelines.specialize( @@ -1504,6 +1549,7 @@ fn prepare_clustering_pipelines( &clustering_raster_pipeline, ClusteringRasterPipelineKey { populate_pass: true, + multiview_view_count, }, ); diff --git a/crates/bevy_pbr/src/decal/clustered.wgsl b/crates/bevy_pbr/src/decal/clustered.wgsl index a2102a80dc384..39a6d11038bdb 100644 --- a/crates/bevy_pbr/src/decal/clustered.wgsl +++ b/crates/bevy_pbr/src/decal/clustered.wgsl @@ -154,17 +154,17 @@ fn clustered_decal_iterator_next(iterator: ptr // Returns the view-space Z coordinate for the given world position. fn get_view_z(world_position: vec3) -> f32 { return dot(vec4( - mesh_view_bindings::view.view_from_world[0].z, - mesh_view_bindings::view.view_from_world[1].z, - mesh_view_bindings::view.view_from_world[2].z, - mesh_view_bindings::view.view_from_world[3].z + mesh_view_bindings::view().view_from_world[0].z, + mesh_view_bindings::view().view_from_world[1].z, + mesh_view_bindings::view().view_from_world[2].z, + mesh_view_bindings::view().view_from_world[3].z ), vec4(world_position, 1.0)); } // Returns true if the current view describes an orthographic projection or // false otherwise. fn view_is_orthographic() -> bool { - return mesh_view_bindings::view.clip_from_view[3].w == 1.0; + return mesh_view_bindings::view().clip_from_view[3].w == 1.0; } fn apply_decals(pbr_input: ptr) { diff --git a/crates/bevy_pbr/src/decal/forward_decal.wgsl b/crates/bevy_pbr/src/decal/forward_decal.wgsl index ffbb5f958b189..fb9d5243e5cef 100644 --- a/crates/bevy_pbr/src/decal/forward_decal.wgsl +++ b/crates/bevy_pbr/src/decal/forward_decal.wgsl @@ -24,7 +24,7 @@ fn get_forward_decal_info(in: VertexOutput) -> ForwardDecalInformation { let scale = (world_from_local * vec4(1.0, 1.0, 1.0, 0.0)).xyz; let scaled_tangent = vec4(in.world_tangent.xyz / scale, in.world_tangent.w); - let V = normalize(view.world_position - in.world_position.xyz); + let V = normalize(view().world_position - in.world_position.xyz); // Transform V from fragment to camera in world space to tangent space. let TBN = calculate_tbn_mikktspace(in.world_normal, scaled_tangent); diff --git a/crates/bevy_pbr/src/deferred/deferred_lighting.wgsl b/crates/bevy_pbr/src/deferred/deferred_lighting.wgsl index 7c14eea4baf99..15d459d47e242 100644 --- a/crates/bevy_pbr/src/deferred/deferred_lighting.wgsl +++ b/crates/bevy_pbr/src/deferred/deferred_lighting.wgsl @@ -43,10 +43,23 @@ fn vertex(@builtin(vertex_index) vertex_index: u32) -> FullscreenVertexOutput { } @fragment -fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + var frag_coord = vec4(in.position.xy, 0.0, 0.0); +#ifdef MULTIVIEW + let deferred_data = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); +#else let deferred_data = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); +#endif #ifdef WEBGL2 frag_coord.z = unpack_unorm3x4_plus_unorm_20_(deferred_data.b).w; @@ -63,7 +76,11 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { if ((pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u) { #ifdef SCREEN_SPACE_AMBIENT_OCCLUSION +#ifdef MULTIVIEW + let ssao = textureLoad(screen_space_ambient_occlusion_texture, vec2(in.position.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0i).r; +#else let ssao = textureLoad(screen_space_ambient_occlusion_texture, vec2(in.position.xy), 0i).r; +#endif let ssao_multibounce = ssao_multibounce(ssao, pbr_input.material.base_color.rgb); pbr_input.diffuse_occlusion = min(pbr_input.diffuse_occlusion, ssao_multibounce); diff --git a/crates/bevy_pbr/src/deferred/mod.rs b/crates/bevy_pbr/src/deferred/mod.rs index e2b9450774bd9..af5d0fa08514c 100644 --- a/crates/bevy_pbr/src/deferred/mod.rs +++ b/crates/bevy_pbr/src/deferred/mod.rs @@ -290,6 +290,9 @@ impl SpecializedRenderPipeline for DeferredLightingLayout { if key.contains(MeshPipelineKey::ATMOSPHERE) { shader_defs.push("ATMOSPHERE".into()); } + if key.max_view_count() > 1 { + shader_defs.push("MULTIVIEW".into()); + } shader_defs.push("STANDARD_MATERIAL_CLEARCOAT".into()); // Always true, since we're in the deferred lighting pipeline diff --git a/crates/bevy_pbr/src/deferred/pbr_deferred_functions.wgsl b/crates/bevy_pbr/src/deferred/pbr_deferred_functions.wgsl index 309df85e11b2b..a1243b974f836 100644 --- a/crates/bevy_pbr/src/deferred/pbr_deferred_functions.wgsl +++ b/crates/bevy_pbr/src/deferred/pbr_deferred_functions.wgsl @@ -74,7 +74,7 @@ fn deferred_gbuffer_from_pbr_input(in: PbrInput) -> vec4 { specular_transmission, diffuse_transmission ); - emissive += in.lightmap_light * diffuse_color * view.exposure; + emissive += in.lightmap_light * diffuse_color * view().exposure; let deferred = vec4( deferred_types::pack_unorm4x8_(vec4(base_color_srgb, in.material.perceptual_roughness)), @@ -121,7 +121,7 @@ fn pbr_input_from_deferred_gbuffer(frag_coord: vec4, gbuffer: vec4) -> let N = octahedral_decode(octahedral_normal); let world_position = vec4(position_ndc_to_world(frag_coord_to_ndc(frag_coord)), 1.0); - let is_orthographic = view.clip_from_view[3].w == 1.0; + let is_orthographic = view().clip_from_view[3].w == 1.0; let V = pbr_functions::calculate_view(world_position, is_orthographic); pbr.frag_coord = frag_coord; diff --git a/crates/bevy_pbr/src/material.rs b/crates/bevy_pbr/src/material.rs index 01549ccdc4e6f..2e823774790fc 100644 --- a/crates/bevy_pbr/src/material.rs +++ b/crates/bevy_pbr/src/material.rs @@ -143,6 +143,33 @@ pub const MATERIAL_BIND_GROUP_INDEX: usize = 3; /// @group(#{MATERIAL_BIND_GROUP}) @binding(1) var color_texture: texture_2d; /// @group(#{MATERIAL_BIND_GROUP}) @binding(2) var color_sampler: sampler; /// ``` +/// +/// # Multiview +/// +/// Under a multiview camera, the main opaque pass broadcasts each +/// `Opaque3d` / `AlphaMask3d` draw across all eye layers in a single +/// dispatch. Any custom WGSL entry (vertex or fragment) must declare +/// `@builtin(view_index)` and assign +/// `bevy_pbr::mesh_view_bindings::current_view_index = view_index;` +/// inside `#ifdef MULTIVIEW`. Otherwise `current_view_index` stays at 0 +/// on every layer, and reads through `view()` / `mesh_view_bindings::*` +/// (and helpers built on them, e.g. `view_transformations::*`) resolve +/// to eye 0's data — silent visual incorrectness. +/// +/// A material that overrides [`fragment_shader`] only and keeps the +/// default vertex entry still renders geometry correctly per layer +/// (the default `mesh.wgsl` vertex entry threads `view_index` itself); +/// the failure mode is restricted to lighting and camera-relative +/// effects in the custom fragment. A material that also overrides +/// [`vertex_shader`] must thread `view_index` in the custom vertex +/// entry the same way, otherwise per-layer geometry breaks too. +/// +/// See `pbr.wgsl` for the canonical fragment-entry pattern, +/// `mesh.wgsl` for the vertex-entry pattern, and +/// `mesh_view_bindings.wgsl` for a paste-ready snippet. +/// +/// [`fragment_shader`]: Material::fragment_shader +/// [`vertex_shader`]: Material::vertex_shader pub trait Material: Asset + AsBindGroup + Clone + Sized { /// Returns this material's vertex shader. If [`ShaderRef::Default`] is returned, the default mesh vertex shader /// will be used. diff --git a/crates/bevy_pbr/src/meshlet/material_pipeline_prepare.rs b/crates/bevy_pbr/src/meshlet/material_pipeline_prepare.rs index 4160cdfd0a6b5..12342615a4991 100644 --- a/crates/bevy_pbr/src/meshlet/material_pipeline_prepare.rs +++ b/crates/bevy_pbr/src/meshlet/material_pipeline_prepare.rs @@ -218,6 +218,7 @@ pub fn prepare_material_meshlet_meshes_main_opaque_pass( bias: DepthBiasState::default(), }), multisample: MultisampleState::default(), + multiview_mask: None, fragment: Some(FragmentState { shader: match material.properties.get_shader(MeshletFragmentShader) { Some(shader) => shader.clone(), diff --git a/crates/bevy_pbr/src/meshlet/visibility_buffer_resolve.wgsl b/crates/bevy_pbr/src/meshlet/visibility_buffer_resolve.wgsl index 8d8a22b943ea6..fa94ad0f66a82 100644 --- a/crates/bevy_pbr/src/meshlet/visibility_buffer_resolve.wgsl +++ b/crates/bevy_pbr/src/meshlet/visibility_buffer_resolve.wgsl @@ -128,14 +128,14 @@ fn resolve_vertex_output(frag_coord: vec4) -> VertexOutput { let partial_derivatives = compute_partial_derivatives( array(world_position_0, world_position_1, world_position_2), frag_coord_ndc, - view.viewport.zw / 2.0, + view().viewport.zw / 2.0, ); let world_position = mat3x4(world_position_0, world_position_1, world_position_2) * partial_derivatives.barycentrics; let world_positions_camera_relative = mat3x3( - world_position_0.xyz - view.world_position, - world_position_1.xyz - view.world_position, - world_position_2.xyz - view.world_position, + world_position_0.xyz - view().world_position, + world_position_1.xyz - view().world_position, + world_position_2.xyz - view().world_position, ); let ddx_world_position = world_positions_camera_relative * partial_derivatives.ddx; let ddy_world_position = world_positions_camera_relative * partial_derivatives.ddy; diff --git a/crates/bevy_pbr/src/prepass/mod.rs b/crates/bevy_pbr/src/prepass/mod.rs index e6b91c6df009d..adbdc218894de 100644 --- a/crates/bevy_pbr/src/prepass/mod.rs +++ b/crates/bevy_pbr/src/prepass/mod.rs @@ -34,19 +34,22 @@ use bevy_render::{ mesh::{allocator::MeshAllocator, RenderMesh}, render_asset::{prepare_assets, RenderAssets}, render_phase::*, - render_resource::{binding_types::uniform_buffer, *}, + render_resource::{ + binding_types::{uniform_buffer, uniform_buffer_sized}, + *, + }, renderer::{RenderAdapter, RenderDevice, RenderQueue}, sync_world::RenderEntity, view::{ - ExtractedView, Msaa, RenderVisibilityRanges, RenderVisibleEntities, RetainedViewEntity, - ViewUniform, ViewUniformOffset, ViewUniforms, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT, + ExtractedMultiview, ExtractedView, Msaa, RenderVisibilityRanges, RenderVisibleEntities, + RetainedViewEntity, ViewUniformOffset, ViewUniforms, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT, }, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderDebugFlags, RenderStartup, RenderSystems, }; use bevy_shader::{load_shader_library, Shader, ShaderDefVal}; use bevy_transform::prelude::GlobalTransform; -use core::any::TypeId; +use core::{any::TypeId, num::NonZeroU32}; pub use prepass_bindings::*; use tracing::{error, warn}; @@ -279,8 +282,10 @@ pub fn init_prepass_pipeline( &BindGroupLayoutEntries::with_indices( ShaderStages::VERTEX_FRAGMENT, ( - // View - (0, uniform_buffer::(true)), + // View. Untyped binding so the WGSL can declare it as + // `array` (multiview) or `array` + // (non-multiview); see `mesh_view_bindings::view`. + (0, uniform_buffer_sized(true, None)), // Globals (1, uniform_buffer::(false)), // PreviousViewUniforms @@ -304,8 +309,10 @@ pub fn init_prepass_pipeline( &BindGroupLayoutEntries::with_indices( ShaderStages::VERTEX_FRAGMENT, ( - // View - (0, uniform_buffer::(true)), + // View. Untyped binding so the WGSL can declare it as + // `array` (multiview) or `array` + // (non-multiview); see `mesh_view_bindings::view`. + (0, uniform_buffer_sized(true, None)), // Globals (1, uniform_buffer::(false)), // VisibilityRanges @@ -406,6 +413,28 @@ impl PrepassPipeline { // since that's the only time it gets called from a prepass pipeline.) shader_defs.push("PREPASS_PIPELINE".into()); + let max_view_count = mesh_key.max_view_count(); + if max_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt("MAX_VIEW_COUNT".into(), max_view_count)); + } + + // Broadcast every prepass + deferred prepass dispatch that flows + // through `DrawPrepass` (`Opaque3dPrepass` / `AlphaMask3dPrepass` / + // `Opaque3dDeferred` / `AlphaMask3dDeferred`) under multiview. The + // pass-side mask in `prepass/node.rs` uses the same + // `view_count > 1` predicate, so wgpu's required pipeline-vs-pass + // multiview-mask agreement holds. `Opaque3d` / `AlphaMask3d` + // main-pass dispatches flow through the separate `MeshPipeline` + // type and set their own pipeline-side mask. Formula is the + // shift-safe equivalent of `(1u32 << max_view_count) - 1`; the + // latter is UB at `MAX_VIEW_COUNT = 32`. + let multiview_mask = if max_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - max_view_count)) + } else { + None + }; + shader_defs.push(ShaderDefVal::UInt( "MATERIAL_BIND_GROUP".into(), crate::MATERIAL_BIND_GROUP_INDEX as u32, @@ -639,6 +668,7 @@ impl PrepassPipeline { alpha_to_coverage_enabled: false, }, label: Some("prepass_pipeline".into()), + multiview_mask, ..default() }; Ok(descriptor) @@ -802,9 +832,12 @@ pub fn check_prepass_views_need_specialization( Option<&DepthPrepass>, Option<&NormalPrepass>, Option<&MotionVectorPrepass>, + Option<&ExtractedMultiview>, )>, ) { - for (view, msaa, depth_prepass, normal_prepass, motion_vector_prepass) in views.iter_mut() { + for (view, msaa, depth_prepass, normal_prepass, motion_vector_prepass, multiview) in + views.iter_mut() + { let mut view_key = MeshPipelineKey::from_msaa_samples(msaa.samples()); if depth_prepass.is_some() { view_key |= MeshPipelineKey::DEPTH_PREPASS; @@ -815,6 +848,9 @@ pub fn check_prepass_views_need_specialization( if motion_vector_prepass.is_some() { view_key |= MeshPipelineKey::MOTION_VECTOR_PREPASS; } + if let Some(multiview) = multiview { + view_key |= MeshPipelineKey::from_max_view_count(multiview.subviews.len() as u32); + } if let Some(current_key) = view_key_cache.get_mut(&view.retained_view_entity) { if *current_key != view_key { diff --git a/crates/bevy_pbr/src/prepass/prepass.wgsl b/crates/bevy_pbr/src/prepass/prepass.wgsl index c94520fb3ffef..e8802d7b7edef 100644 --- a/crates/bevy_pbr/src/prepass/prepass.wgsl +++ b/crates/bevy_pbr/src/prepass/prepass.wgsl @@ -67,7 +67,16 @@ fn morph_prev_vertex(vertex_in: Vertex, instance_index: u32) -> Vertex { #endif // MORPH_TARGETS @vertex -fn vertex(vertex_no_morph: Vertex) -> VertexOutput { +fn vertex( + vertex_no_morph: Vertex, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> VertexOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + var out: VertexOutput; #ifdef MORPH_TARGETS @@ -194,7 +203,16 @@ fn vertex(vertex_no_morph: Vertex) -> VertexOutput { #ifdef PREPASS_FRAGMENT @fragment -fn fragment(in: VertexOutput) -> FragmentOutput { +fn fragment( + in: VertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> FragmentOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + var out: FragmentOutput; #ifdef NORMAL_PREPASS @@ -206,7 +224,7 @@ fn fragment(in: VertexOutput) -> FragmentOutput { #endif // UNCLIPPED_DEPTH_ORTHO_EMULATION #ifdef MOTION_VECTOR_PREPASS - let clip_position_t = view.unjittered_clip_from_world * in.world_position; + let clip_position_t = view().unjittered_clip_from_world * in.world_position; let clip_position = clip_position_t.xy / clip_position_t.w; let previous_clip_position_t = prepass_bindings::previous_view_uniforms.clip_from_world * in.previous_world_position; let previous_clip_position = previous_clip_position_t.xy / previous_clip_position_t.w; diff --git a/crates/bevy_pbr/src/prepass/prepass_bindings.rs b/crates/bevy_pbr/src/prepass/prepass_bindings.rs index 3c66625ed8254..e350f7c7e3947 100644 --- a/crates/bevy_pbr/src/prepass/prepass_bindings.rs +++ b/crates/bevy_pbr/src/prepass/prepass_bindings.rs @@ -1,10 +1,11 @@ use bevy_core_pipeline::prepass::ViewPrepassTextures; use bevy_render::render_resource::{ binding_types::{ - texture_2d, texture_2d_multisampled, texture_depth_2d, texture_depth_2d_multisampled, + texture_2d, texture_2d_array, texture_2d_multisampled, texture_depth_2d, + texture_depth_2d_multisampled, }, BindGroupLayoutEntryBuilder, TextureAspect, TextureSampleType, TextureView, - TextureViewDescriptor, + TextureViewDescriptor, TextureViewDimension, }; use bevy_utils::default; @@ -16,11 +17,19 @@ pub fn get_bind_group_layout_entries( let mut entries: [Option; 4] = [None; 4]; let multisampled = layout_key.contains(MeshPipelineViewLayoutKey::MULTISAMPLED); + // WGSL has no multisampled-array texture type, so the MSAA + multiview + // combination keeps the single-layer multisampled shape. Mirrors the + // shader-side `#ifdef MULTISAMPLED` / `#ifdef MULTIVIEW` interleave in + // `mesh_view_bindings.wgsl`. + let multiview_array = + !multisampled && layout_key.contains(MeshPipelineViewLayoutKey::MULTIVIEW); if layout_key.contains(MeshPipelineViewLayoutKey::DEPTH_PREPASS) { // Depth texture entries[0] = if multisampled { Some(texture_depth_2d_multisampled()) + } else if multiview_array { + Some(texture_2d_array(TextureSampleType::Depth)) } else { Some(texture_depth_2d()) }; @@ -32,6 +41,10 @@ pub fn get_bind_group_layout_entries( Some(texture_2d_multisampled(TextureSampleType::Float { filterable: false, })) + } else if multiview_array { + Some(texture_2d_array(TextureSampleType::Float { + filterable: false, + })) } else { Some(texture_2d(TextureSampleType::Float { filterable: false })) }; @@ -43,33 +56,88 @@ pub fn get_bind_group_layout_entries( Some(texture_2d_multisampled(TextureSampleType::Float { filterable: false, })) + } else if multiview_array { + Some(texture_2d_array(TextureSampleType::Float { + filterable: false, + })) } else { Some(texture_2d(TextureSampleType::Float { filterable: false })) }; } if layout_key.contains(MeshPipelineViewLayoutKey::DEFERRED_PREPASS) { - // Deferred texture - entries[3] = Some(texture_2d(TextureSampleType::Uint)); + // Deferred texture (never multisampled) + entries[3] = if layout_key.contains(MeshPipelineViewLayoutKey::MULTIVIEW) { + Some(texture_2d_array(TextureSampleType::Uint)) + } else { + Some(texture_2d(TextureSampleType::Uint)) + }; } entries } -pub fn get_bindings(prepass_textures: Option<&ViewPrepassTextures>) -> [Option; 4] { +/// Returns texture views for the four prepass texture slots, picking +/// `D2Array` views under `multiview_array` so they line up with the array- +/// typed WGSL bindings. Under multiview each texture carries `view_count` +/// layers; the `D2Array` view wraps the full array and the consumer reads +/// its eye's slice via `current_view_index`. +pub fn get_bindings( + prepass_textures: Option<&ViewPrepassTextures>, + multiview_array: bool, + deferred_multiview: bool, +) -> [Option; 4] { + let view_dimension = if multiview_array { + Some(TextureViewDimension::D2Array) + } else { + None + }; + let depth_desc = TextureViewDescriptor { label: Some("prepass_depth"), aspect: TextureAspect::DepthOnly, + dimension: view_dimension, ..default() }; let depth_view = prepass_textures .and_then(|x| x.depth.as_ref()) .map(|texture| texture.texture.texture.create_view(&depth_desc)); - [ - depth_view, - prepass_textures.and_then(|pt| pt.normal_view().cloned()), - prepass_textures.and_then(|pt| pt.motion_vectors_view().cloned()), - prepass_textures.and_then(|pt| pt.deferred_view().cloned()), - ] + let make_array_view = |label: &'static str, cached: &bevy_render::texture::CachedTexture| { + cached.texture.create_view(&TextureViewDescriptor { + label: Some(label), + dimension: Some(TextureViewDimension::D2Array), + ..default() + }) + }; + + let normal_view = prepass_textures.and_then(|pt| { + pt.normal.as_ref().map(|att| { + if multiview_array { + make_array_view("prepass_normal_array", &att.texture) + } else { + att.texture.default_view.clone() + } + }) + }); + let motion_view = prepass_textures.and_then(|pt| { + pt.motion_vectors.as_ref().map(|att| { + if multiview_array { + make_array_view("prepass_motion_vectors_array", &att.texture) + } else { + att.texture.default_view.clone() + } + }) + }); + let deferred_view = prepass_textures.and_then(|pt| { + pt.deferred.as_ref().map(|att| { + if deferred_multiview { + make_array_view("prepass_deferred_array", &att.texture) + } else { + att.texture.default_view.clone() + } + }) + }); + + [depth_view, normal_view, motion_view, deferred_view] } diff --git a/crates/bevy_pbr/src/prepass/prepass_utils.wgsl b/crates/bevy_pbr/src/prepass/prepass_utils.wgsl index 42f403cd13d3a..79d039b625da7 100644 --- a/crates/bevy_pbr/src/prepass/prepass_utils.wgsl +++ b/crates/bevy_pbr/src/prepass/prepass_utils.wgsl @@ -7,7 +7,11 @@ fn prepass_depth(frag_coord: vec4, sample_index: u32) -> f32 { #ifdef MULTISAMPLED return textureLoad(view_bindings::depth_prepass_texture, vec2(frag_coord.xy), i32(sample_index)); #else // MULTISAMPLED +#ifdef MULTIVIEW + return textureLoad(view_bindings::depth_prepass_texture, vec2(frag_coord.xy), view_bindings::current_view_index, 0); +#else return textureLoad(view_bindings::depth_prepass_texture, vec2(frag_coord.xy), 0); +#endif #endif // MULTISAMPLED } #endif // DEPTH_PREPASS @@ -16,8 +20,12 @@ fn prepass_depth(frag_coord: vec4, sample_index: u32) -> f32 { fn prepass_normal(frag_coord: vec4, sample_index: u32) -> vec3 { #ifdef MULTISAMPLED let normal_sample = textureLoad(view_bindings::normal_prepass_texture, vec2(frag_coord.xy), i32(sample_index)); +#else +#ifdef MULTIVIEW + let normal_sample = textureLoad(view_bindings::normal_prepass_texture, vec2(frag_coord.xy), view_bindings::current_view_index, 0); #else let normal_sample = textureLoad(view_bindings::normal_prepass_texture, vec2(frag_coord.xy), 0); +#endif #endif // MULTISAMPLED return normalize(normal_sample.xyz * 2.0 - vec3(1.0)); } @@ -27,8 +35,12 @@ fn prepass_normal(frag_coord: vec4, sample_index: u32) -> vec3 { fn prepass_motion_vector(frag_coord: vec4, sample_index: u32) -> vec2 { #ifdef MULTISAMPLED let motion_vector_sample = textureLoad(view_bindings::motion_vector_prepass_texture, vec2(frag_coord.xy), i32(sample_index)); +#else +#ifdef MULTIVIEW + let motion_vector_sample = textureLoad(view_bindings::motion_vector_prepass_texture, vec2(frag_coord.xy), view_bindings::current_view_index, 0); #else let motion_vector_sample = textureLoad(view_bindings::motion_vector_prepass_texture, vec2(frag_coord.xy), 0); +#endif #endif return motion_vector_sample.rg; } diff --git a/crates/bevy_pbr/src/render/clustered_forward.wgsl b/crates/bevy_pbr/src/render/clustered_forward.wgsl index 4bdf29494f85a..6432b0769299a 100644 --- a/crates/bevy_pbr/src/render/clustered_forward.wgsl +++ b/crates/bevy_pbr/src/render/clustered_forward.wgsl @@ -54,7 +54,7 @@ fn view_z_to_z_slice( } fn view_fragment_cluster_index(frag_coord: vec2, view_z: f32, is_orthographic: bool) -> u32 { - let xy = vec2(floor((frag_coord - bindings::view.viewport.xy) * bindings::lights.cluster_factors.xy)); + let xy = vec2(floor((frag_coord - bindings::view().viewport.xy) * bindings::lights.cluster_factors.xy)); let z_slice = view_z_to_z_slice( bindings::lights.cluster_factors.zw, bindings::lights.cluster_dimensions.z, diff --git a/crates/bevy_pbr/src/render/mesh.rs b/crates/bevy_pbr/src/render/mesh.rs index 1938a6e3a9f37..b1671bfdd0452 100644 --- a/crates/bevy_pbr/src/render/mesh.rs +++ b/crates/bevy_pbr/src/render/mesh.rs @@ -74,6 +74,7 @@ use bevy_utils::{default, Parallel, TypeIdMap}; use core::any::TypeId; use core::iter; use core::mem::size_of; +use core::num::NonZeroU32; use core::sync::atomic::{AtomicU64, Ordering}; use indexmap::IndexSet; use material_bind_groups::MaterialBindingId; @@ -101,7 +102,7 @@ use bevy_render::camera::{DirtySpecializations, ExtractedCamera, TemporalJitter} use bevy_render::prelude::Msaa; use bevy_render::sync_world::{MainEntity, MainEntityHashMap}; use bevy_render::view::{ - texture_format_from_code, texture_format_to_code, ExtractedView, + texture_format_from_code, texture_format_to_code, ExtractedMultiview, ExtractedView, RenderShadowMapVisibleEntities, RenderVisibleEntities, }; use bevy_render::RenderSystems::PrepareAssets; @@ -386,6 +387,7 @@ pub fn check_views_need_specialization( Has, Has, ), + Option<&ExtractedMultiview>, )>, ) { for ( @@ -402,11 +404,16 @@ pub fn check_views_need_specialization( distance_fog, (has_environment_maps, has_irradiance_volumes), (has_oit, has_atmosphere, has_ssr, has_contact_shadows), + multiview, ) in views.iter_mut() { let mut view_key = MeshPipelineKey::from_msaa_samples(msaa.samples()) | MeshPipelineKey::from_target_format(view.target_format); + if let Some(multiview) = multiview { + view_key |= MeshPipelineKey::from_max_view_count(multiview.subviews.len() as u32); + } + if normal_prepass { view_key |= MeshPipelineKey::NORMAL_PREPASS; } @@ -3065,6 +3072,7 @@ bitflags::bitflags! { const SCREEN_SPACE_SPECULAR_TRANSMISSION_ULTRA = 3 << Self::SCREEN_SPACE_SPECULAR_TRANSMISSION_SHIFT_BITS; const COLOR_TARGET_FORMAT_RESERVED_BITS = Self::COLOR_TARGET_FORMAT_MASK_BITS << Self::COLOR_TARGET_FORMAT_SHIFT_BITS; + const MAX_VIEW_COUNT_RESERVED_BITS = Self::MAX_VIEW_COUNT_MASK_BITS << Self::MAX_VIEW_COUNT_SHIFT_BITS; const ALL_RESERVED_BITS = Self::BLEND_RESERVED_BITS.bits() | Self::MSAA_RESERVED_BITS.bits() | @@ -3072,7 +3080,8 @@ bitflags::bitflags! { Self::SHADOW_FILTER_METHOD_RESERVED_BITS.bits() | Self::VIEW_PROJECTION_RESERVED_BITS.bits() | Self::SCREEN_SPACE_SPECULAR_TRANSMISSION_RESERVED_BITS.bits() | - Self::COLOR_TARGET_FORMAT_RESERVED_BITS.bits(); + Self::COLOR_TARGET_FORMAT_RESERVED_BITS.bits() | + Self::MAX_VIEW_COUNT_RESERVED_BITS.bits(); } } @@ -3105,6 +3114,14 @@ impl MeshPipelineKey { .count_ones() as u64 + Self::SCREEN_SPACE_SPECULAR_TRANSMISSION_SHIFT_BITS; + // Per-camera multiview layer count. Encodes 1..=MAX_VIEW_COUNT directly; + // 0 means "no Multiview component" (treated as non-multiview, i.e. 1). + // `MAX_VIEW_COUNT` is 32 (see `bevy_camera::multiview::MAX_VIEW_COUNT`), + // so 6 bits is enough. + const MAX_VIEW_COUNT_MASK_BITS: u64 = 0b111111; + const MAX_VIEW_COUNT_SHIFT_BITS: u64 = + Self::COLOR_TARGET_FORMAT_MASK_BITS.count_ones() as u64 + Self::COLOR_TARGET_FORMAT_SHIFT_BITS; + pub fn from_msaa_samples(msaa_samples: u32) -> Self { let msaa_bits = (msaa_samples.trailing_zeros() as u64 & Self::MSAA_MASK_BITS) << Self::MSAA_SHIFT_BITS; @@ -3134,6 +3151,24 @@ impl MeshPipelineKey { 1 << ((self.bits() >> Self::MSAA_SHIFT_BITS) & Self::MSAA_MASK_BITS) } + /// Encodes the per-camera multiview layer count into the key. `count == 1` + /// is the non-multiview case; values >1 cause the mesh + prepass pipelines + /// to emit the `MAX_VIEW_COUNT` and `MULTIVIEW` shader defs. + #[inline] + pub fn from_max_view_count(count: u32) -> Self { + Self::from_bits_retain( + ((count as u64) & Self::MAX_VIEW_COUNT_MASK_BITS) << Self::MAX_VIEW_COUNT_SHIFT_BITS, + ) + } + + /// Returns the multiview layer count encoded in this key. Returns 1 when + /// no count has been encoded (i.e. non-multiview). + #[inline] + pub fn max_view_count(&self) -> u32 { + let bits = ((self.bits() >> Self::MAX_VIEW_COUNT_SHIFT_BITS) & Self::MAX_VIEW_COUNT_MASK_BITS) as u32; + bits.max(1) + } + /// Create a [`BaseMeshPipelineKey`] from mesh primitive topology and index format. /// /// For non-strip topologies, [`BaseMeshPipelineKey::STRIP_INDEX_FORMAT_NONE`] is set regardless of the `strip_index_format` argument. @@ -3294,6 +3329,29 @@ impl SpecializedMeshPipeline for MeshPipeline { shader_defs.push("VERTEX_OUTPUT_INSTANCE_INDEX".into()); + let max_view_count = key.max_view_count(); + if max_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt("MAX_VIEW_COUNT".into(), max_view_count)); + } + + // Broadcast every `Opaque3d` / `AlphaMask3d` main-pass dispatch + // that flows through `DrawMaterial` under multiview. The pass-side + // mask in `main_opaque_pass_3d` uses the same `max_view_count > 1` + // predicate, so wgpu's required pipeline-vs-pass multiview-mask + // agreement holds. The prepass + deferred prepass dispatches + // (`Opaque3dPrepass` / `AlphaMask3dPrepass` / `Opaque3dDeferred` / + // `AlphaMask3dDeferred`) flow through `DrawPrepass` and the + // separate `PrepassPipelineSpecializer` type and set their own + // pipeline-side mask. Formula is the shift-safe equivalent of + // `(1u32 << max_view_count) - 1`; the latter is UB at + // `MAX_VIEW_COUNT = 32`. + let multiview_mask = if max_view_count > 1 { + NonZeroU32::new(u32::MAX >> (32 - max_view_count)) + } else { + None + }; + if layout.0.contains(Mesh::ATTRIBUTE_POSITION) { shader_defs.push("VERTEX_POSITIONS".into()); vertex_attributes.push(Mesh::ATTRIBUTE_POSITION.at_shader_location(0)); @@ -3661,6 +3719,7 @@ impl SpecializedMeshPipeline for MeshPipeline { alpha_to_coverage_enabled, }, label: Some(label), + multiview_mask, ..default() }) } diff --git a/crates/bevy_pbr/src/render/mesh.wgsl b/crates/bevy_pbr/src/render/mesh.wgsl index 7c36806e06a5a..e0abb427f220f 100644 --- a/crates/bevy_pbr/src/render/mesh.wgsl +++ b/crates/bevy_pbr/src/render/mesh.wgsl @@ -35,7 +35,16 @@ fn morph_vertex(vertex_in: Vertex, instance_index: u32) -> Vertex { #endif @vertex -fn vertex(vertex_no_morph: Vertex) -> VertexOutput { +fn vertex( + vertex_no_morph: Vertex, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> VertexOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + var out: VertexOutput; #ifdef MORPH_TARGETS diff --git a/crates/bevy_pbr/src/render/mesh_functions.wgsl b/crates/bevy_pbr/src/render/mesh_functions.wgsl index 9488d061f1524..416c82e6daee3 100644 --- a/crates/bevy_pbr/src/render/mesh_functions.wgsl +++ b/crates/bevy_pbr/src/render/mesh_functions.wgsl @@ -146,7 +146,7 @@ fn get_visibility_range_dither_level(instance_index: u32, world_position: vec4) { world_pos = world_from_local[3].xyz; } - let camera_distance = length(world_pos - view.lod_view_world_position); + let camera_distance = length(world_pos - view().lod_view_world_position); // `x` is the minimum range; `w` is the largest range. if (camera_distance < lod_range.x || camera_distance >= lod_range.w) { return; @@ -238,7 +238,7 @@ fn main(@builtin(global_invocation_id) global_invocation_id: vec3) { // `previous_input_index` (in fact, we can't; that index are only valid for // one frame and will be invalid). let timestamp = current_input[input_index].timestamp; - let mesh_changed_this_frame = timestamp == view.frame_count; + let mesh_changed_this_frame = timestamp == view().frame_count; // Look up the previous model matrix, if it could have been. let previous_input_index = current_input[input_index].previous_input_index; @@ -302,7 +302,7 @@ fn main(@builtin(global_invocation_id) global_invocation_id: vec3) { #ifdef EARLY_PHASE max_depth_view = min(-previous_view_uniforms.clip_from_view[3][2], max_depth_view); #else // EARLY_PHASE - max_depth_view = min(-view.clip_from_view[3][2], max_depth_view); + max_depth_view = min(-view().clip_from_view[3][2], max_depth_view); #endif // EARLY_PHASE // Figure out the depth of the occluder, and compare it to our own depth. diff --git a/crates/bevy_pbr/src/render/mesh_view_bindings.rs b/crates/bevy_pbr/src/render/mesh_view_bindings.rs index c3e60b6188ffb..ad501eefc1d40 100644 --- a/crates/bevy_pbr/src/render/mesh_view_bindings.rs +++ b/crates/bevy_pbr/src/render/mesh_view_bindings.rs @@ -31,7 +31,7 @@ use bevy_render::{ renderer::{RenderAdapter, RenderDevice}, texture::{FallbackImage, FallbackImageZero, GpuImage}, view::{ - Msaa, RenderVisibilityRanges, ViewUniform, ViewUniformOffset, ViewUniforms, + Msaa, RenderVisibilityRanges, ViewTarget, ViewUniformOffset, ViewUniforms, VISIBILITY_RANGES_STORAGE_BUFFER_COUNT, }, }; @@ -99,6 +99,7 @@ bitflags::bitflags! { const CONTACT_SHADOWS = 1 << 13; const DISTANCE_FOG = 1 << 14; const AREA_LIGHT_LUTS = 1 << 15; + const MULTIVIEW = 1 << 16; } } @@ -179,6 +180,9 @@ impl From for MeshPipelineViewLayoutKey { if value.contains(MeshPipelineKey::DISTANCE_FOG) { result |= MeshPipelineViewLayoutKey::DISTANCE_FOG; } + if value.max_view_count() > 1 { + result |= MeshPipelineViewLayoutKey::MULTIVIEW; + } result } @@ -252,9 +256,14 @@ fn layout_entries( ShaderStages::FRAGMENT, ( // View + // + // Declared as `uniform_buffer_sized(true, None)` so the WGSL is free to size + // the binding as `array` (multiview) or `array` + // (non-multiview). Backing storage is `DynamicArrayUniformBuffer`; + // the dynamic offset selects the per-camera array slot. ( 0, - uniform_buffer::(true).visibility(ShaderStages::VERTEX_FRAGMENT), + uniform_buffer_sized(true, None).visibility(ShaderStages::VERTEX_FRAGMENT), ), // Lights (1, uniform_buffer::(true)), @@ -372,13 +381,16 @@ fn layout_entries( )); } if layout_key.contains(MeshPipelineViewLayoutKey::SCREEN_SPACE_AMBIENT_OCCLUSION) { - entries = entries.extend_with_indices(( - // Screen space ambient occlusion texture - ( - 17, - texture_2d(TextureSampleType::Float { filterable: false }), - ), - )); + // Screen space ambient occlusion texture + // + // Under multiview the binding is an array texture with one layer per eye; + // non-multiview keeps the original `texture_2d` shape. + let entry = if layout_key.contains(MeshPipelineViewLayoutKey::MULTIVIEW) { + texture_2d_array(TextureSampleType::Float { filterable: false }) + } else { + texture_2d(TextureSampleType::Float { filterable: false }) + }; + entries = entries.extend_with_indices(((17, entry),)); } if layout_key.contains(MeshPipelineViewLayoutKey::TONEMAP_IN_SHADER) { @@ -411,11 +423,16 @@ fn layout_entries( } // View Transmission Texture + // + // Switches to `texture_2d_array` under multiview so per-eye reads can + // index the right layer via `current_view_index`. Sampler is unchanged. + let view_transmission_entry = if layout_key.contains(MeshPipelineViewLayoutKey::MULTIVIEW) { + texture_2d_array(TextureSampleType::Float { filterable: true }) + } else { + texture_2d(TextureSampleType::Float { filterable: true }) + }; entries = entries.extend_with_indices(( - ( - 24, - texture_2d(TextureSampleType::Float { filterable: true }), - ), + (24, view_transmission_entry), (25, sampler(SamplerBindingType::Filtering)), )); @@ -644,6 +661,7 @@ pub fn prepare_mesh_view_bind_groups( &ViewShadowBindings, &ViewClusterBindings, &Msaa, + &ViewTarget, Option<&ScreenSpaceAmbientOcclusionResources>, Option<&ViewPrepassTextures>, Option<&ViewTransmissionTexture>, @@ -717,6 +735,7 @@ pub fn prepare_mesh_view_bind_groups( shadow_bindings, cluster_bindings, msaa, + view_target, ssao_resources, prepass_textures, transmission_texture, @@ -753,8 +772,12 @@ pub fn prepare_mesh_view_bind_groups( } let tonemap_in_shader = camera.is_none_or(|camera| !camera.hdr); + let is_multiview = view_target.multiview_count().is_some(); let mut layout_key = MeshPipelineViewLayoutKey::from(*msaa) | MeshPipelineViewLayoutKey::from(prepass_textures); + if is_multiview { + layout_key |= MeshPipelineViewLayoutKey::MULTIVIEW; + } let mut offsets = ArrayVec::from_iter([ view_uniform_offset.offset, view_lights_offset.offset, @@ -850,17 +873,50 @@ pub fn prepare_mesh_view_bind_groups( )); } + // Under multiview the SSAO output texture carries `view_count` + // layers; bind it via an explicit `D2Array` view so it matches the + // array-typed WGSL binding, and the consumer reads its eye's slice + // via `current_view_index`. Non-multiview keeps the single-layer + // `default_view`. + let ssao_array_view = ssao_resources.filter(|_| is_multiview).map(|res| { + res.screen_space_ambient_occlusion_texture + .texture + .create_view(&TextureViewDescriptor { + label: Some("ssao_texture_array_view"), + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + }) + }); if let Some(ssao_resources) = ssao_resources { layout_key |= MeshPipelineViewLayoutKey::SCREEN_SPACE_AMBIENT_OCCLUSION; - let ssao_view = &ssao_resources - .screen_space_ambient_occlusion_texture - .default_view; + let ssao_view = ssao_array_view.as_ref().unwrap_or( + &ssao_resources.screen_space_ambient_occlusion_texture.default_view, + ); entries = entries.extend_with_indices(((17, ssao_view),)); } - let transmission_view = transmission_texture - .map(|transmission| &transmission.view) - .unwrap_or(&fallback_image_zero.texture_view); + // Under multiview the transmission binding is a `D2Array` view of + // the multi-layer transmission texture (or single-layer fallback, + // which `D2Array` accepts at `array_layer_count = 1`). + let transmission_array_view = if is_multiview { + let source_texture = transmission_texture + .map(|transmission| &transmission.texture) + .unwrap_or(&fallback_image_zero.texture); + Some(source_texture.create_view(&TextureViewDescriptor { + label: Some("view_transmission_array_view"), + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + })) + } else { + None + }; + let transmission_view: &TextureView = transmission_array_view.as_ref().unwrap_or_else( + || { + transmission_texture + .map(|transmission| &transmission.view) + .unwrap_or(&fallback_image_zero.texture_view) + }, + ); let transmission_sampler = transmission_texture .map(|transmission| &transmission.sampler) @@ -873,7 +929,13 @@ pub fn prepare_mesh_view_bind_groups( // See https://github.com/gfx-rs/wgpu/issues/5263 let prepass_bindings; if cfg!(any(feature = "webgpu", not(target_arch = "wasm32"))) || msaa.samples() == 1 { - prepass_bindings = prepass::get_bindings(prepass_textures); + // Bindings 20-22 only switch to D2Array views when multiview is + // active AND MSAA is off (no multisampled-array textures in + // WGSL). Binding 23 (deferred) is never multisampled so the + // multiview switch is unconditional. + let multiview_array = is_multiview && msaa.samples() == 1; + prepass_bindings = + prepass::get_bindings(prepass_textures, multiview_array, is_multiview); for (binding, index) in prepass_bindings .iter() .map(Option::as_ref) diff --git a/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl b/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl index 4d3f16fb0ca13..c7cf913b89cb4 100644 --- a/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl +++ b/crates/bevy_pbr/src/render/mesh_view_bindings.wgsl @@ -7,7 +7,57 @@ globals::Globals, } -@group(0) @binding(0) var view: View; +// View uniform. +// +// Backed by a runtime-sized `array` so multiview cameras can pack +// N per-eye records into a single dynamic-offset slot. `MAX_VIEW_COUNT` is +// the shader-def that names N; when undefined (non-multiview pipelines) the +// fallback is a 1-element array, which matches the single ViewUniform pushed +// by `DynamicArrayUniformBuffer` for non-multiview cameras. +// +// All shader code reads `view()` (not `view_array` directly). The helper +// returns the current view via `view_array[current_view_index]`, where +// `current_view_index` defaults to 0 and is overwritten from +// `@builtin(view_index)` at the top of multiview entry-point bodies. +#ifdef MAX_VIEW_COUNT +@group(0) @binding(0) var view_array: array; +#else +@group(0) @binding(0) var view_array: array; +#endif +var current_view_index: i32 = 0; + +// Custom WGSL entries (vertex or fragment) used by user `Material` +// implementors must thread `@builtin(view_index)` and assign it to +// `current_view_index` under `#ifdef MULTIVIEW`, otherwise `view()` (and any +// helper that reads through it, e.g. `mesh_view_bindings::*`, +// `view_transformations::*`) resolves to eye 0 on every layer under a +// multiview camera. The default `mesh.wgsl` vertex entry and `pbr.wgsl` +// fragment entry already follow this pattern, so a material that overrides +// only one of the two and keeps the default for the other still gets +// correct per-eye behavior from the default side. Paste-ready snippet for a +// custom fragment entry: +// +// struct FragmentInput { +// @builtin(position) frag_coord: vec4, +// #ifdef MULTIVIEW +// @builtin(view_index) view_index: i32, +// #endif +// // ... other fields ... +// } +// +// @fragment +// fn fragment(in: FragmentInput) -> @location(0) vec4 { +// #ifdef MULTIVIEW +// bevy_pbr::mesh_view_bindings::current_view_index = in.view_index; +// #endif +// // ... rest of fragment body ... +// } +// +// The default PBR fragment entry in `pbr.wgsl` follows this pattern. + +fn view() -> View { + return view_array[current_view_index]; +} @group(0) @binding(1) var lights: types::Lights; #ifdef NO_CUBE_ARRAY_TEXTURES_SUPPORT @group(0) @binding(2) var point_shadow_textures: texture_depth_cube; @@ -61,8 +111,12 @@ const VISIBILITY_RANGE_UNIFORM_BUFFER_SIZE: u32 = 64u; #endif #ifdef SCREEN_SPACE_AMBIENT_OCCLUSION +#ifdef MULTIVIEW +@group(0) @binding(17) var screen_space_ambient_occlusion_texture: texture_2d_array; +#else @group(0) @binding(17) var screen_space_ambient_occlusion_texture: texture_2d; #endif +#endif #ifdef TONEMAP_IN_SHADER // NB: If you change these, make sure to update `tonemapping_shared.wgsl` too. @@ -70,6 +124,14 @@ const VISIBILITY_RANGE_UNIFORM_BUFFER_SIZE: u32 = 64u; @group(0) @binding(19) var dt_lut_sampler: sampler; #endif +// Prepass textures. +// +// Under MULTIVIEW we switch to `_array` variants so per-eye reads can index +// the right layer via `current_view_index`. WGSL has no multisampled-array +// texture types, so multisampled prepass bindings keep their single-layer +// shape even under multiview — the MSAA + multiview combination is left +// unsupported at the texture-binding level (rare in practice; VR doesn't +// pair with MSAA). The host-side layout/entry-resolution must mirror this. #ifdef MULTISAMPLED #ifdef DEPTH_PREPASS @group(0) @binding(20) var depth_prepass_texture: texture_depth_multisampled_2d; @@ -84,22 +146,42 @@ const VISIBILITY_RANGE_UNIFORM_BUFFER_SIZE: u32 = 64u; #else // MULTISAMPLED #ifdef DEPTH_PREPASS +#ifdef MULTIVIEW +@group(0) @binding(20) var depth_prepass_texture: texture_depth_2d_array; +#else @group(0) @binding(20) var depth_prepass_texture: texture_depth_2d; +#endif #endif // DEPTH_PREPASS #ifdef NORMAL_PREPASS +#ifdef MULTIVIEW +@group(0) @binding(21) var normal_prepass_texture: texture_2d_array; +#else @group(0) @binding(21) var normal_prepass_texture: texture_2d; +#endif #endif // NORMAL_PREPASS #ifdef MOTION_VECTOR_PREPASS +#ifdef MULTIVIEW +@group(0) @binding(22) var motion_vector_prepass_texture: texture_2d_array; +#else @group(0) @binding(22) var motion_vector_prepass_texture: texture_2d; +#endif #endif // MOTION_VECTOR_PREPASS #endif // MULTISAMPLED #ifdef DEFERRED_PREPASS +#ifdef MULTIVIEW +@group(0) @binding(23) var deferred_prepass_texture: texture_2d_array; +#else @group(0) @binding(23) var deferred_prepass_texture: texture_2d; +#endif #endif // DEFERRED_PREPASS +#ifdef MULTIVIEW +@group(0) @binding(24) var view_transmission_texture: texture_2d_array; +#else @group(0) @binding(24) var view_transmission_texture: texture_2d; +#endif @group(0) @binding(25) var view_transmission_sampler: sampler; #ifdef OIT_ENABLED diff --git a/crates/bevy_pbr/src/render/pbr.wgsl b/crates/bevy_pbr/src/render/pbr.wgsl index 7380b6f2cdccf..a6f9cfb2902b0 100644 --- a/crates/bevy_pbr/src/render/pbr.wgsl +++ b/crates/bevy_pbr/src/render/pbr.wgsl @@ -42,7 +42,14 @@ fn fragment( vertex_output: VertexOutput, @builtin(front_facing) is_front: bool, #endif +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif ) -> FragmentOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + #ifdef MESHLET_MESH_MATERIAL_PASS let vertex_output = resolve_vertex_output(frag_coord); let is_front = true; diff --git a/crates/bevy_pbr/src/render/pbr_fragment.wgsl b/crates/bevy_pbr/src/render/pbr_fragment.wgsl index b09131c7be1eb..f35e49ba02ea9 100644 --- a/crates/bevy_pbr/src/render/pbr_fragment.wgsl +++ b/crates/bevy_pbr/src/render/pbr_fragment.wgsl @@ -46,7 +46,7 @@ fn pbr_input_from_vertex_output( pbr_input.flags = mesh[in.instance_index].flags; #endif - pbr_input.is_orthographic = view.clip_from_view[3].w == 1.0; + pbr_input.is_orthographic = view().clip_from_view[3].w == 1.0; pbr_input.V = pbr_functions::calculate_view(in.world_position, pbr_input.is_orthographic); pbr_input.frag_coord = in.position; pbr_input.world_position = in.world_position; @@ -110,7 +110,7 @@ fn pbr_input_from_standard_material( bias.ddx_uv = in.ddx_uv; bias.ddy_uv = in.ddy_uv; #else // MESHLET_MESH_MATERIAL_PASS - bias.mip_bias = view.mip_bias; + bias.mip_bias = view().mip_bias; #endif // MESHLET_MESH_MATERIAL_PASS // TODO: Transforming UVs mean we need to apply derivative chain rule for meshlet mesh material pass @@ -656,7 +656,11 @@ pbr_input.material.uv_transform = uv_transform; } #endif #ifdef SCREEN_SPACE_AMBIENT_OCCLUSION +#ifdef MULTIVIEW + let ssao = textureLoad(screen_space_ambient_occlusion_texture, vec2(in.position.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0i).r; +#else let ssao = textureLoad(screen_space_ambient_occlusion_texture, vec2(in.position.xy), 0i).r; +#endif let ssao_multibounce = ssao_multibounce(ssao, pbr_input.material.base_color.rgb); diffuse_occlusion = min(diffuse_occlusion, ssao_multibounce); // Use SSAO to estimate the specular occlusion. diff --git a/crates/bevy_pbr/src/render/pbr_functions.wgsl b/crates/bevy_pbr/src/render/pbr_functions.wgsl index ed367181dc33a..a61dddf0bba86 100644 --- a/crates/bevy_pbr/src/render/pbr_functions.wgsl +++ b/crates/bevy_pbr/src/render/pbr_functions.wgsl @@ -263,10 +263,10 @@ fn calculate_view( var V: vec3; if is_orthographic { // Orthographic view vector - V = normalize(vec3(view_bindings::view.clip_from_world[0].z, view_bindings::view.clip_from_world[1].z, view_bindings::view.clip_from_world[2].z)); + V = normalize(vec3(view_bindings::view().clip_from_world[0].z, view_bindings::view().clip_from_world[1].z, view_bindings::view().clip_from_world[2].z)); } else { // Only valid for a perspective projection - V = normalize(view_bindings::view.world_position.xyz - world_position.xyz); + V = normalize(view_bindings::view().world_position.xyz - world_position.xyz); } return V; } @@ -450,10 +450,10 @@ fn apply_pbr_lighting( #endif // STANDARD_MATERIAL_DIFFUSE_TRANSMISSION let view_z = dot(vec4( - view_bindings::view.view_from_world[0].z, - view_bindings::view.view_from_world[1].z, - view_bindings::view.view_from_world[2].z, - view_bindings::view.view_from_world[3].z + view_bindings::view().view_from_world[0].z, + view_bindings::view().view_from_world[1].z, + view_bindings::view().view_from_world[2].z, + view_bindings::view().view_from_world[3].z ), in.world_position); let cluster_index = clustering::view_fragment_cluster_index(in.frag_coord.xy, view_z, in.is_orthographic); var clusterable_object_index_ranges = @@ -837,7 +837,7 @@ fn apply_pbr_lighting( emissive_light = emissive_light * (0.04 + (1.0 - 0.04) * pow(1.0 - clearcoat_NdotV, 5.0)); #endif - emissive_light = emissive_light * mix(1.0, view_bindings::view.exposure, emissive.a); + emissive_light = emissive_light * mix(1.0, view_bindings::view().exposure, emissive.a); #ifdef STANDARD_MATERIAL_SPECULAR_TRANSMISSION transmitted_light += transmission::specular_transmissive_light(in.world_position, in.frag_coord.xyz, view_z, in.N, in.V, F0, ior, thickness, perceptual_roughness, specular_transmissive_color, specular_transmitted_environment_light).rgb; @@ -860,7 +860,7 @@ fn apply_pbr_lighting( // Total light output_color = vec4( - (view_bindings::view.exposure * (transmitted_light + direct_light + indirect_light)) + emissive_light, + (view_bindings::view().exposure * (transmitted_light + direct_light + indirect_light)) + emissive_light, output_color.a ); @@ -912,7 +912,7 @@ fn apply_fog( 0.0 ), fog_params.directional_light_exponent - ) * light.color.rgb * view_bindings::view.exposure; + ) * light.color.rgb * view_bindings::view().exposure; // Sample shadow map to attenuate inscattering in shadowed areas var shadow: f32 = 1.0; @@ -1004,14 +1004,14 @@ fn main_pass_post_lighting_processing( view_bindings::fog, output_color, pbr_input.world_position.xyz, - view_bindings::view.world_position.xyz, + view_bindings::view().world_position.xyz, pbr_input.frag_coord.xy, ); } #endif // DISTANCE_FOG #ifdef TONEMAP_IN_SHADER - output_color = tone_mapping(output_color, view_bindings::view.color_grading); + output_color = tone_mapping(output_color, view_bindings::view().color_grading); #ifdef DEBAND_DITHER var output_rgb = output_color.rgb; output_rgb = powsafe(output_rgb, 1.0 / 2.2); diff --git a/crates/bevy_pbr/src/render/pbr_prepass.wgsl b/crates/bevy_pbr/src/render/pbr_prepass.wgsl index 21e46d2d27d46..12eba8ad5a348 100644 --- a/crates/bevy_pbr/src/render/pbr_prepass.wgsl +++ b/crates/bevy_pbr/src/render/pbr_prepass.wgsl @@ -29,7 +29,14 @@ fn fragment( in: prepass_io::VertexOutput, @builtin(front_facing) is_front: bool, #endif +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif ) -> prepass_io::FragmentOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + #ifdef MESHLET_MESH_MATERIAL_PASS let in = resolve_vertex_output(frag_coord); let is_front = true; @@ -89,7 +96,7 @@ fn fragment( bias.ddx_uv = in.ddx_uv; bias.ddy_uv = in.ddy_uv; #else // MESHLET_MESH_MATERIAL_PASS - bias.mip_bias = view.mip_bias; + bias.mip_bias = view().mip_bias; #endif // MESHLET_MESH_MATERIAL_PASS let Nt = @@ -145,7 +152,16 @@ fn fragment( } #else @fragment -fn fragment(in: prepass_io::VertexOutput) { +fn fragment( + in: prepass_io::VertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + pbr_prepass_functions::prepass_sample_color_and_alpha_discard(in); } #endif // PREPASS_FRAGMENT diff --git a/crates/bevy_pbr/src/render/pbr_prepass_functions.wgsl b/crates/bevy_pbr/src/render/pbr_prepass_functions.wgsl index e6cc870c87a91..9dc85f49fbeb2 100644 --- a/crates/bevy_pbr/src/render/pbr_prepass_functions.wgsl +++ b/crates/bevy_pbr/src/render/pbr_prepass_functions.wgsl @@ -79,7 +79,7 @@ fn prepass_sample_color_and_alpha_discard(in: VertexOutput) { pbr_bindings::base_color_sampler, #endif // BINDLESS uv, - view.mip_bias + view().mip_bias ); } #endif // VERTEX_UVS @@ -90,7 +90,7 @@ fn prepass_sample_color_and_alpha_discard(in: VertexOutput) { #ifdef MOTION_VECTOR_PREPASS fn calculate_motion_vector(world_position: vec4, previous_world_position: vec4) -> vec2 { - let clip_position_t = view.unjittered_clip_from_world * world_position; + let clip_position_t = view().unjittered_clip_from_world * world_position; let clip_position = clip_position_t.xy / clip_position_t.w; let previous_clip_position_t = previous_view_uniforms.clip_from_world * previous_world_position; let previous_clip_position = previous_clip_position_t.xy / previous_clip_position_t.w; diff --git a/crates/bevy_pbr/src/render/view_transformations.wgsl b/crates/bevy_pbr/src/render/view_transformations.wgsl index dfb4d6e96d03c..d63fd4d08c6f0 100644 --- a/crates/bevy_pbr/src/render/view_transformations.wgsl +++ b/crates/bevy_pbr/src/render/view_transformations.wgsl @@ -36,31 +36,31 @@ /// Convert a view space position to world space fn position_view_to_world(view_pos: vec3) -> vec3 { - let world_pos = view_bindings::view.world_from_view * vec4(view_pos, 1.0); + let world_pos = view_bindings::view().world_from_view * vec4(view_pos, 1.0); return world_pos.xyz; } /// Convert a clip space position to world space fn position_clip_to_world(clip_pos: vec4) -> vec3 { - let world_pos = view_bindings::view.world_from_clip * clip_pos; + let world_pos = view_bindings::view().world_from_clip * clip_pos; return world_pos.xyz; } /// Convert a ndc space position to world space fn position_ndc_to_world(ndc_pos: vec3) -> vec3 { - let world_pos = view_bindings::view.world_from_clip * vec4(ndc_pos, 1.0); + let world_pos = view_bindings::view().world_from_clip * vec4(ndc_pos, 1.0); return world_pos.xyz / world_pos.w; } /// Convert a view space direction to world space fn direction_view_to_world(view_dir: vec3) -> vec3 { - let world_dir = view_bindings::view.world_from_view * vec4(view_dir, 0.0); + let world_dir = view_bindings::view().world_from_view * vec4(view_dir, 0.0); return world_dir.xyz; } /// Convert a clip space direction to world space fn direction_clip_to_world(clip_dir: vec4) -> vec3 { - let world_dir = view_bindings::view.world_from_clip * clip_dir; + let world_dir = view_bindings::view().world_from_clip * clip_dir; return world_dir.xyz; } @@ -70,31 +70,31 @@ fn direction_clip_to_world(clip_dir: vec4) -> vec3 { /// Convert a world space position to view space fn position_world_to_view(world_pos: vec3) -> vec3 { - let view_pos = view_bindings::view.view_from_world * vec4(world_pos, 1.0); + let view_pos = view_bindings::view().view_from_world * vec4(world_pos, 1.0); return view_pos.xyz; } /// Convert a clip space position to view space fn position_clip_to_view(clip_pos: vec4) -> vec3 { - let view_pos = view_bindings::view.view_from_clip * clip_pos; + let view_pos = view_bindings::view().view_from_clip * clip_pos; return view_pos.xyz; } /// Convert a ndc space position to view space fn position_ndc_to_view(ndc_pos: vec3) -> vec3 { - let view_pos = view_bindings::view.view_from_clip * vec4(ndc_pos, 1.0); + let view_pos = view_bindings::view().view_from_clip * vec4(ndc_pos, 1.0); return view_pos.xyz / view_pos.w; } /// Convert a world space direction to view space fn direction_world_to_view(world_dir: vec3) -> vec3 { - let view_dir = view_bindings::view.view_from_world * vec4(world_dir, 0.0); + let view_dir = view_bindings::view().view_from_world * vec4(world_dir, 0.0); return view_dir.xyz; } /// Convert a clip space direction to view space fn direction_clip_to_view(clip_dir: vec4) -> vec3 { - let view_dir = view_bindings::view.view_from_clip * clip_dir; + let view_dir = view_bindings::view().view_from_clip * clip_dir; return view_dir.xyz; } @@ -120,25 +120,25 @@ fn position_world_to_prev_ndc(world_pos: vec3) -> vec3 { /// Convert a world space position to clip space fn position_world_to_clip(world_pos: vec3) -> vec4 { - let clip_pos = view_bindings::view.clip_from_world * vec4(world_pos, 1.0); + let clip_pos = view_bindings::view().clip_from_world * vec4(world_pos, 1.0); return clip_pos; } /// Convert a view space position to clip space fn position_view_to_clip(view_pos: vec3) -> vec4 { - let clip_pos = view_bindings::view.clip_from_view * vec4(view_pos, 1.0); + let clip_pos = view_bindings::view().clip_from_view * vec4(view_pos, 1.0); return clip_pos; } /// Convert a world space direction to clip space fn direction_world_to_clip(world_dir: vec3) -> vec4 { - let clip_dir = view_bindings::view.clip_from_world * vec4(world_dir, 0.0); + let clip_dir = view_bindings::view().clip_from_world * vec4(world_dir, 0.0); return clip_dir; } /// Convert a view space direction to clip space fn direction_view_to_clip(view_dir: vec3) -> vec4 { - let clip_dir = view_bindings::view.clip_from_view * vec4(view_dir, 0.0); + let clip_dir = view_bindings::view().clip_from_view * vec4(view_dir, 0.0); return clip_dir; } @@ -148,13 +148,13 @@ fn direction_view_to_clip(view_dir: vec3) -> vec4 { /// Convert a world space position to ndc space fn position_world_to_ndc(world_pos: vec3) -> vec3 { - let ndc_pos = view_bindings::view.clip_from_world * vec4(world_pos, 1.0); + let ndc_pos = view_bindings::view().clip_from_world * vec4(world_pos, 1.0); return ndc_pos.xyz / ndc_pos.w; } /// Convert a view space position to ndc space fn position_view_to_ndc(view_pos: vec3) -> vec3 { - let ndc_pos = view_bindings::view.clip_from_view * vec4(view_pos, 1.0); + let ndc_pos = view_bindings::view().clip_from_view * vec4(view_pos, 1.0); return ndc_pos.xyz / ndc_pos.w; } @@ -164,7 +164,7 @@ fn position_view_to_ndc(view_pos: vec3) -> vec3 { /// Retrieve the perspective camera near clipping plane fn perspective_camera_near() -> f32 { - return view_bindings::view.clip_from_view[3][2]; + return view_bindings::view().clip_from_view[3][2]; } /// Convert ndc depth to linear view z. @@ -173,9 +173,9 @@ fn depth_ndc_to_view_z(ndc_depth: f32) -> f32 { #ifdef VIEW_PROJECTION_PERSPECTIVE return -perspective_camera_near() / ndc_depth; #else ifdef VIEW_PROJECTION_ORTHOGRAPHIC - return -(view_bindings::view.clip_from_view[3][2] - ndc_depth) / view_bindings::view.clip_from_view[2][2]; + return -(view_bindings::view().clip_from_view[3][2] - ndc_depth) / view_bindings::view().clip_from_view[2][2]; #else - let view_pos = view_bindings::view.view_from_clip * vec4(0.0, 0.0, ndc_depth, 1.0); + let view_pos = view_bindings::view().view_from_clip * vec4(0.0, 0.0, ndc_depth, 1.0); return view_pos.z / view_pos.w; #endif } @@ -186,9 +186,9 @@ fn view_z_to_depth_ndc(view_z: f32) -> f32 { #ifdef VIEW_PROJECTION_PERSPECTIVE return -perspective_camera_near() / view_z; #else ifdef VIEW_PROJECTION_ORTHOGRAPHIC - return view_bindings::view.clip_from_view[3][2] + view_z * view_bindings::view.clip_from_view[2][2]; + return view_bindings::view().clip_from_view[3][2] + view_z * view_bindings::view().clip_from_view[2][2]; #else - let ndc_pos = view_bindings::view.clip_from_view * vec4(0.0, 0.0, view_z, 1.0); + let ndc_pos = view_bindings::view().clip_from_view * vec4(0.0, 0.0, view_z, 1.0); return ndc_pos.z / ndc_pos.w; #endif } @@ -223,7 +223,7 @@ fn uv_to_ndc(uv: vec2) -> vec2 { /// returns the (0.0, 0.0) .. (1.0, 1.0) position within the viewport for the current render target /// [0 .. render target viewport size] eg. [(0.0, 0.0) .. (1280.0, 720.0)] to [(0.0, 0.0) .. (1.0, 1.0)] fn frag_coord_to_uv(frag_coord: vec2) -> vec2 { - return (frag_coord - view_bindings::view.viewport.xy) / view_bindings::view.viewport.zw; + return (frag_coord - view_bindings::view().viewport.xy) / view_bindings::view().viewport.zw; } /// Convert frag coord to ndc @@ -234,5 +234,5 @@ fn frag_coord_to_ndc(frag_coord: vec4) -> vec3 { /// Convert ndc space xy coordinate [-1.0 .. 1.0] to [0 .. render target /// viewport size] fn ndc_to_frag_coord(ndc: vec2) -> vec2 { - return ndc_to_uv(ndc) * view_bindings::view.viewport.zw; + return ndc_to_uv(ndc) * view_bindings::view().viewport.zw; } diff --git a/crates/bevy_pbr/src/render/wireframe.wgsl b/crates/bevy_pbr/src/render/wireframe.wgsl index ee388f58ee531..c33972cca9d1e 100644 --- a/crates/bevy_pbr/src/render/wireframe.wgsl +++ b/crates/bevy_pbr/src/render/wireframe.wgsl @@ -61,7 +61,14 @@ fn read_local_position(first_vertex: u32, vertex_index: u32) -> vec3 { fn vertex( @builtin(vertex_index) vertex_index: u32, @builtin(instance_index) instance_index: u32, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif ) -> WireframeVertexOutput { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + var out: WireframeVertexOutput; let first_vertex = mesh[instance_index].first_vertex_index; @@ -82,7 +89,7 @@ fn vertex( let clip1 = position_world_to_clip((world_from_local * vec4(p1, 1.0)).xyz); let clip2 = position_world_to_clip((world_from_local * vec4(p2, 1.0)).xyz); - let viewport_size = view.viewport.zw; + let viewport_size = view().viewport.zw; let screen0 = (clip0.xy / clip0.w) * viewport_size * 0.5; let screen1 = (clip1.xy / clip1.w) * viewport_size * 0.5; let screen2 = (clip2.xy / clip2.w) * viewport_size * 0.5; diff --git a/crates/bevy_pbr/src/ssao/mod.rs b/crates/bevy_pbr/src/ssao/mod.rs index a1767d6c5bec0..4ae5dcd59ac98 100644 --- a/crates/bevy_pbr/src/ssao/mod.rs +++ b/crates/bevy_pbr/src/ssao/mod.rs @@ -31,7 +31,7 @@ use bevy_render::{ sync_component::SyncComponentPlugin, sync_world::RenderEntity, texture::{CachedTexture, TextureCache}, - view::{Msaa, ViewUniform, ViewUniformOffset, ViewUniforms}, + view::{ExtractedMultiview, Msaa, ViewUniform, ViewUniformOffset, ViewUniforms}, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderSystems, }; use bevy_shader::{load_shader_library, Shader, ShaderDefVal}; @@ -215,58 +215,64 @@ fn ssao( let command_encoder = ctx.command_encoder(); command_encoder.push_debug_group("ssao"); - { - let mut preprocess_depth_pass = - command_encoder.begin_compute_pass(&ComputePassDescriptor { - label: Some("ssao_preprocess_depth"), + // Each pipeline runs once per eye against per-layer bind groups. For + // non-multiview cameras `per_view` has one entry and this loops once, + // matching the non-multiview single-pass dispatch shape. + for per_view in &bind_groups.per_view { + { + let mut preprocess_depth_pass = + command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("ssao_preprocess_depth"), + timestamp_writes: None, + }); + preprocess_depth_pass.set_pipeline(preprocess_depth_pipeline); + preprocess_depth_pass.set_bind_group(0, &per_view.preprocess_depth_bind_group, &[]); + preprocess_depth_pass.set_bind_group( + 1, + &bind_groups.common_bind_group, + &[view_uniform_offset.offset], + ); + preprocess_depth_pass.dispatch_workgroups( + camera_size.x.div_ceil(16), + camera_size.y.div_ceil(16), + 1, + ); + } + + { + let mut ssao_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("ssao"), timestamp_writes: None, }); - preprocess_depth_pass.set_pipeline(preprocess_depth_pipeline); - preprocess_depth_pass.set_bind_group(0, &bind_groups.preprocess_depth_bind_group, &[]); - preprocess_depth_pass.set_bind_group( - 1, - &bind_groups.common_bind_group, - &[view_uniform_offset.offset], - ); - preprocess_depth_pass.dispatch_workgroups( - camera_size.x.div_ceil(16), - camera_size.y.div_ceil(16), - 1, - ); - } - - { - let mut ssao_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { - label: Some("ssao"), - timestamp_writes: None, - }); - ssao_pass.set_pipeline(ssao_pipeline); - ssao_pass.set_bind_group(0, &bind_groups.ssao_bind_group, &[]); - ssao_pass.set_bind_group( - 1, - &bind_groups.common_bind_group, - &[view_uniform_offset.offset], - ); - ssao_pass.dispatch_workgroups(camera_size.x.div_ceil(8), camera_size.y.div_ceil(8), 1); - } + ssao_pass.set_pipeline(ssao_pipeline); + ssao_pass.set_bind_group(0, &per_view.ssao_bind_group, &[]); + ssao_pass.set_bind_group( + 1, + &bind_groups.common_bind_group, + &[view_uniform_offset.offset], + ); + ssao_pass.dispatch_workgroups(camera_size.x.div_ceil(8), camera_size.y.div_ceil(8), 1); + } - { - let mut spatial_denoise_pass = command_encoder.begin_compute_pass(&ComputePassDescriptor { - label: Some("ssao_spatial_denoise"), - timestamp_writes: None, - }); - spatial_denoise_pass.set_pipeline(spatial_denoise_pipeline); - spatial_denoise_pass.set_bind_group(0, &bind_groups.spatial_denoise_bind_group, &[]); - spatial_denoise_pass.set_bind_group( - 1, - &bind_groups.common_bind_group, - &[view_uniform_offset.offset], - ); - spatial_denoise_pass.dispatch_workgroups( - camera_size.x.div_ceil(8), - camera_size.y.div_ceil(8), - 1, - ); + { + let mut spatial_denoise_pass = + command_encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("ssao_spatial_denoise"), + timestamp_writes: None, + }); + spatial_denoise_pass.set_pipeline(spatial_denoise_pipeline); + spatial_denoise_pass.set_bind_group(0, &per_view.spatial_denoise_bind_group, &[]); + spatial_denoise_pass.set_bind_group( + 1, + &bind_groups.common_bind_group, + &[view_uniform_offset.offset], + ); + spatial_denoise_pass.dispatch_workgroups( + camera_size.x.div_ceil(8), + camera_size.y.div_ceil(8), + 1, + ); + } } command_encoder.pop_debug_group(); @@ -534,13 +540,20 @@ fn prepare_ssao_textures( mut texture_cache: ResMut, render_device: Res, pipelines: Res, - views: Query<(Entity, &ExtractedCamera, &ScreenSpaceAmbientOcclusion)>, + views: Query<( + Entity, + &ExtractedCamera, + &ScreenSpaceAmbientOcclusion, + Option<&ExtractedMultiview>, + )>, ) { - for (entity, camera, ssao_settings) in &views { + for (entity, camera, ssao_settings, multiview) in &views { let Some(physical_viewport_size) = camera.physical_viewport_size else { continue; }; - let size = physical_viewport_size.to_extents(); + let view_count = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let mut size = physical_viewport_size.to_extents(); + size.depth_or_array_layers = view_count; let preprocessed_depth_texture = texture_cache.get( &render_device, @@ -649,16 +662,24 @@ fn prepare_ssao_pipelines( } } +/// Per-eye bind groups dispatched once per view layer in [`ssao`]. +pub struct SsaoPerViewBindGroups { + pub preprocess_depth_bind_group: BindGroup, + pub ssao_bind_group: BindGroup, + pub spatial_denoise_bind_group: BindGroup, +} + /// A render world component that stores the bind groups necessary to perform /// Screen Space Ambient Occlusion. /// -/// This is stored on each view. +/// This is stored on each view. [`Self::common_bind_group`] is shared across +/// all eyes (it binds the packed view-uniform array); the pipeline-specific +/// bind groups are duplicated per eye so each dispatch reads and writes its +/// own layer of the prepass / SSAO textures. #[derive(Component)] pub struct SsaoBindGroups { pub common_bind_group: BindGroup, - pub preprocess_depth_bind_group: BindGroup, - pub ssao_bind_group: BindGroup, - pub spatial_denoise_bind_group: BindGroup, + pub per_view: Vec, } fn prepare_ssao_bind_groups( @@ -692,64 +713,201 @@ fn prepare_ssao_bind_groups( )), ); - let create_depth_view = |mip_level| { + // Under multiview, each SSAO texture has `view_count` layers and each + // eye is dispatched separately with single-layer `D2` views into its + // own layer. For non-multiview cameras `view_count == 1` and we use + // the textures' default views (bit-identical to the non-multiview + // single-pass path). + let view_count = ssao_resources + .preprocessed_depth_texture + .texture + .depth_or_array_layers(); + let is_multiview = view_count > 1; + + // Prepass attachment textures may be 1-layer at runtime even when + // SSAO is multi-layer (e.g., when the camera has SSAO but the + // prepass component itself is not configured for per-eye storage). + // Building `base_array_layer: layer >= 1` views of a 1-layer texture + // would error wgpu validation, so gate per-layer view creation on + // each texture's actual layer count. When the prepass textures are + // multi-layer, build per-eye views; otherwise fall back to the + // single-layer `prepass_textures.{depth,normal}_view()` helpers and + // all eyes read the shared layer. + let prepass_depth_multilayer = prepass_textures + .depth + .as_ref() + .is_some_and(|d| d.texture.texture.depth_or_array_layers() > 1); + let prepass_normal_multilayer = prepass_textures + .normal + .as_ref() + .is_some_and(|n| n.texture.texture.depth_or_array_layers() > 1); + + // Per-eye, single-layer `D2` view of a specific `mip_level` of the + // 5-mip preprocessed depth texture. + let preprocessed_depth_mip_view = |mip_level: u32, layer: u32| { ssao_resources .preprocessed_depth_texture .texture .create_view(&TextureViewDescriptor { label: Some("ssao_preprocessed_depth_texture_mip_view"), base_mip_level: mip_level, + base_array_layer: layer, + array_layer_count: Some(1), format: Some(pipelines.depth_format), dimension: Some(TextureViewDimension::D2), mip_level_count: Some(1), ..default() }) }; + // Per-eye, single-layer `D2` view of any of the SSAO textures. + // `default` covers `base_mip_level: 0`, `mip_level_count: None` and + // `format: None`. + let single_layer_view = |texture: &Texture, layer: u32, label: &'static str| { + texture.create_view(&TextureViewDescriptor { + label: Some(label), + base_array_layer: layer, + array_layer_count: Some(1), + dimension: Some(TextureViewDimension::D2), + ..default() + }) + }; + // Per-eye `D2` view of one of the prepass attachment textures. + // The host-side prepass `*_view()` helpers return the texture's + // `default_view`, which under multiview is a `D2Array` view of the + // full array. SSAO needs to read its eye's slice, so when multiview + // is active we build a fresh per-layer view here. + let prepass_layer_view = |texture: &Texture, layer: u32, label: &'static str| { + texture.create_view(&TextureViewDescriptor { + label: Some(label), + base_array_layer: layer, + array_layer_count: Some(1), + dimension: Some(TextureViewDimension::D2), + ..default() + }) + }; - let preprocess_depth_bind_group = render_device.create_bind_group( - "ssao_preprocess_depth_bind_group", - &pipeline_cache.get_bind_group_layout(&pipelines.preprocess_depth_bind_group_layout), - &BindGroupEntries::sequential(( - prepass_textures.depth_view().unwrap(), - &create_depth_view(0), - &create_depth_view(1), - &create_depth_view(2), - &create_depth_view(3), - &create_depth_view(4), - )), - ); + let mut per_view = Vec::with_capacity(view_count as usize); + for layer in 0..view_count { + // Pre-compute per-eye views as owned locals so the borrows live + // through `create_bind_group`. For non-multiview cameras the + // existing `default_view` / prepass `*_view()` helpers are still + // used (bit-identical). + let prepass_depth_view = prepass_depth_multilayer.then(|| { + prepass_layer_view( + &prepass_textures.depth.as_ref().unwrap().texture.texture, + layer, + "ssao_prepass_depth_layer_view", + ) + }); + let prepass_normal_view = prepass_normal_multilayer.then(|| { + prepass_layer_view( + &prepass_textures.normal.as_ref().unwrap().texture.texture, + layer, + "ssao_prepass_normal_layer_view", + ) + }); + let preprocessed_depth_layer_view = is_multiview.then(|| { + single_layer_view( + &ssao_resources.preprocessed_depth_texture.texture, + layer, + "ssao_preprocessed_depth_layer_view", + ) + }); + let ssao_noisy_layer_view = is_multiview.then(|| { + single_layer_view( + &ssao_resources.ssao_noisy_texture.texture, + layer, + "ssao_noisy_layer_view", + ) + }); + let depth_differences_layer_view = is_multiview.then(|| { + single_layer_view( + &ssao_resources.depth_differences_texture.texture, + layer, + "ssao_depth_differences_layer_view", + ) + }); + let ssao_output_layer_view = is_multiview.then(|| { + single_layer_view( + &ssao_resources.screen_space_ambient_occlusion_texture.texture, + layer, + "ssao_output_layer_view", + ) + }); - let ssao_bind_group = render_device.create_bind_group( - "ssao_ssao_bind_group", - &pipeline_cache.get_bind_group_layout(&pipelines.ssao_bind_group_layout), - &BindGroupEntries::sequential(( - &ssao_resources.preprocessed_depth_texture.default_view, - prepass_textures.normal_view().unwrap(), - &pipelines.hilbert_index_lut, - &ssao_resources.ssao_noisy_texture.default_view, - &ssao_resources.depth_differences_texture.default_view, - globals_uniforms.clone(), - ssao_resources.settings_buffer.as_entire_binding(), - )), - ); + let mip0 = preprocessed_depth_mip_view(0, layer); + let mip1 = preprocessed_depth_mip_view(1, layer); + let mip2 = preprocessed_depth_mip_view(2, layer); + let mip3 = preprocessed_depth_mip_view(3, layer); + let mip4 = preprocessed_depth_mip_view(4, layer); + + let preprocess_depth_bind_group = render_device.create_bind_group( + "ssao_preprocess_depth_bind_group", + &pipeline_cache + .get_bind_group_layout(&pipelines.preprocess_depth_bind_group_layout), + &BindGroupEntries::sequential(( + prepass_depth_view + .as_ref() + .unwrap_or_else(|| prepass_textures.depth_view().unwrap()), + &mip0, + &mip1, + &mip2, + &mip3, + &mip4, + )), + ); - let spatial_denoise_bind_group = render_device.create_bind_group( - "ssao_spatial_denoise_bind_group", - &pipeline_cache.get_bind_group_layout(&pipelines.spatial_denoise_bind_group_layout), - &BindGroupEntries::sequential(( - &ssao_resources.ssao_noisy_texture.default_view, - &ssao_resources.depth_differences_texture.default_view, - &ssao_resources - .screen_space_ambient_occlusion_texture - .default_view, - )), - ); + let ssao_bind_group = render_device.create_bind_group( + "ssao_ssao_bind_group", + &pipeline_cache.get_bind_group_layout(&pipelines.ssao_bind_group_layout), + &BindGroupEntries::sequential(( + preprocessed_depth_layer_view + .as_ref() + .unwrap_or(&ssao_resources.preprocessed_depth_texture.default_view), + prepass_normal_view + .as_ref() + .unwrap_or_else(|| prepass_textures.normal_view().unwrap()), + &pipelines.hilbert_index_lut, + ssao_noisy_layer_view + .as_ref() + .unwrap_or(&ssao_resources.ssao_noisy_texture.default_view), + depth_differences_layer_view + .as_ref() + .unwrap_or(&ssao_resources.depth_differences_texture.default_view), + globals_uniforms.clone(), + ssao_resources.settings_buffer.as_entire_binding(), + )), + ); + + let spatial_denoise_bind_group = render_device.create_bind_group( + "ssao_spatial_denoise_bind_group", + &pipeline_cache + .get_bind_group_layout(&pipelines.spatial_denoise_bind_group_layout), + &BindGroupEntries::sequential(( + ssao_noisy_layer_view + .as_ref() + .unwrap_or(&ssao_resources.ssao_noisy_texture.default_view), + depth_differences_layer_view + .as_ref() + .unwrap_or(&ssao_resources.depth_differences_texture.default_view), + ssao_output_layer_view.as_ref().unwrap_or( + &ssao_resources + .screen_space_ambient_occlusion_texture + .default_view, + ), + )), + ); + + per_view.push(SsaoPerViewBindGroups { + preprocess_depth_bind_group, + ssao_bind_group, + spatial_denoise_bind_group, + }); + } commands.entity(entity).insert(SsaoBindGroups { common_bind_group, - preprocess_depth_bind_group, - ssao_bind_group, - spatial_denoise_bind_group, + per_view, }); } } diff --git a/crates/bevy_pbr/src/ssr/mod.rs b/crates/bevy_pbr/src/ssr/mod.rs index 1de696857d29d..261e7279b98ab 100644 --- a/crates/bevy_pbr/src/ssr/mod.rs +++ b/crates/bevy_pbr/src/ssr/mod.rs @@ -508,6 +508,13 @@ impl SpecializedRenderPipeline for ScreenSpaceReflectionsPipeline { shader_defs.push("ATMOSPHERE".into()); } + if key + .mesh_pipeline_view_key + .contains(MeshPipelineViewLayoutKey::MULTIVIEW) + { + shader_defs.push("MULTIVIEW".into()); + } + if cfg!(feature = "bluenoise_texture") { shader_defs.push("BLUE_NOISE_TEXTURE".into()); } diff --git a/crates/bevy_pbr/src/ssr/raymarch.wgsl b/crates/bevy_pbr/src/ssr/raymarch.wgsl index 20a76afa03202..e0b36bed4a02d 100644 --- a/crates/bevy_pbr/src/ssr/raymarch.wgsl +++ b/crates/bevy_pbr/src/ssr/raymarch.wgsl @@ -38,7 +38,11 @@ fn depth_texel_clamped(texel: vec2) -> f32 { let dims = textureDimensions(depth_prepass_texture); let max_coord = vec2(i32(dims.x) - 1, i32(dims.y) - 1); let clamped = clamp(texel, vec2(0), max_coord); +#ifdef MULTIVIEW + return textureLoad(depth_prepass_texture, clamped, bevy_pbr::mesh_view_bindings::current_view_index, 0); +#else return textureLoad(depth_prepass_texture, clamped, 0); +#endif } fn depth_sample_nearest_clamped(uv: vec2, tex_size: vec2) -> f32 { @@ -65,7 +69,11 @@ fn depth_sample_bilinear_clamped(uv: vec2, tex_size: vec2) -> f32 { fn depth_sample_linear(uv: vec2, tex_size: vec2) -> f32 { #ifdef USE_DEPTH_SAMPLERS +#ifdef MULTIVIEW + return textureSampleLevel(depth_prepass_texture, depth_linear_sampler, uv, bevy_pbr::mesh_view_bindings::current_view_index, 0u); +#else return textureSampleLevel(depth_prepass_texture, depth_linear_sampler, uv, 0u); +#endif #else return depth_sample_bilinear_clamped(uv, tex_size); #endif @@ -73,7 +81,11 @@ fn depth_sample_linear(uv: vec2, tex_size: vec2) -> f32 { fn depth_sample_nearest(uv: vec2, tex_size: vec2) -> f32 { #ifdef USE_DEPTH_SAMPLERS +#ifdef MULTIVIEW + return textureSampleLevel(depth_prepass_texture, depth_nearest_sampler, uv, bevy_pbr::mesh_view_bindings::current_view_index, 0u); +#else return textureSampleLevel(depth_prepass_texture, depth_nearest_sampler, uv, 0u); +#endif #else return depth_sample_nearest_clamped(uv, tex_size); #endif diff --git a/crates/bevy_pbr/src/ssr/ssr.wgsl b/crates/bevy_pbr/src/ssr/ssr.wgsl index d6e27f8219755..d131eb97ae375 100644 --- a/crates/bevy_pbr/src/ssr/ssr.wgsl +++ b/crates/bevy_pbr/src/ssr/ssr.wgsl @@ -124,14 +124,27 @@ fn evaluate_ssr(R_world: vec3, P_world: vec3, jitter: f32) -> vec4 @location(0) vec4 { +fn fragment( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + bevy_pbr::mesh_view_bindings::current_view_index = view_index; +#endif + // Sample the depth. var frag_coord = in.position; frag_coord.z = prepass_utils::prepass_depth(in.position, 0u); // Load the G-buffer data. let fragment = textureLoad(color_texture, vec2(frag_coord.xy), 0); +#ifdef MULTIVIEW + let gbuffer = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), bevy_pbr::mesh_view_bindings::current_view_index, 0); +#else let gbuffer = textureLoad(deferred_prepass_texture, vec2(frag_coord.xy), 0); +#endif let pbr_input = pbr_input_from_deferred_gbuffer(frag_coord, gbuffer); // Don't do anything if the surface is too rough or too smooth @@ -310,7 +323,7 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { // Accumulate the environment map light. // Note that we multiply by (1.0 - ssr_specular.a * fade) to occlude the // environment map if SSR hits. - indirect_light += (view.exposure * environment_light.specular * specular_occlusion) * (1.0 - (1.0 - ssr_specular.a) * fade); + indirect_light += (view().exposure * environment_light.specular * specular_occlusion) * (1.0 - (1.0 - ssr_specular.a) * fade); #endif // Write the results. diff --git a/crates/bevy_pbr/src/transmission/node.rs b/crates/bevy_pbr/src/transmission/node.rs index e4acfb612df69..3b3d245af2238 100644 --- a/crates/bevy_pbr/src/transmission/node.rs +++ b/crates/bevy_pbr/src/transmission/node.rs @@ -9,7 +9,7 @@ use bevy_render::{ render_phase::ViewSortedRenderPhases, render_resource::{RenderPassDescriptor, StoreOp}, renderer::{RenderContext, ViewQuery}, - view::{ExtractedView, ViewDepthTexture, ViewTarget}, + view::{ExtractedMultiview, ExtractedView, ViewDepthTexture, ViewTarget}, }; use core::ops::Range; use tracing::error; @@ -26,6 +26,7 @@ pub fn main_transmissive_pass_3d( Option<&ViewTransmissionTexture>, &ViewDepthTexture, Option<&MainPassResolutionOverride>, + Option<&'static ExtractedMultiview>, )>, transmissive_phases: Res>, mut ctx: RenderContext, @@ -40,6 +41,7 @@ pub fn main_transmissive_pass_3d( transmission, depth, resolution_override, + multiview, ) = view.into_inner(); let Some(transmissive_phase) = transmissive_phases.get(&extracted_view.retained_view_entity) @@ -57,14 +59,7 @@ pub fn main_transmissive_pass_3d( let diagnostics = ctx.diagnostic_recorder(); let diagnostics = diagnostics.as_deref(); - let render_pass_descriptor = RenderPassDescriptor { - label: Some("main_transmissive_pass_3d"), - color_attachments: &[Some(target.get_color_attachment())], - depth_stencil_attachment: Some(depth.get_attachment(StoreOp::Store)), - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }; + let view_count: u32 = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); if !transmissive_phase.items.is_empty() { let steps = transmission_settings.steps; @@ -75,44 +70,83 @@ pub fn main_transmissive_pass_3d( // `transmissive_phase.items` are depth sorted, so we split them into N = `steps` // ranges, rendering them back-to-front in multiple steps, allowing multiple levels of transparency. for range in split_range(0..transmissive_phase.items.len(), steps) { - // Copy the main texture to the transmission texture + // Copy the main texture to the transmission texture. Both + // textures carry `depth_or_array_layers = view_count`, so a + // single copy spans every eye's layer. + let mut copy_extents = physical_target_size.to_extents(); + copy_extents.depth_or_array_layers = view_count; ctx.command_encoder().copy_texture_to_texture( target.main_texture().as_image_copy(), transmission.texture.as_image_copy(), - physical_target_size.to_extents(), + copy_extents, ); - let mut render_pass = ctx.begin_tracked_render_pass(render_pass_descriptor.clone()); + // Per-eye dispatch: each eye renders its slice of the range + // into its own layer of the (multi-layer) main + depth + // textures. The transmission texture is sampled via the + // `D2Array` view, picking the right layer through the + // PBR pipeline's `@builtin(view_index)` plumbing. + for eye in 0..view_count { + let render_pass_descriptor = RenderPassDescriptor { + label: Some("main_transmissive_pass_3d"), + color_attachments: &[Some(target.get_color_attachment_for_layer(eye))], + depth_stencil_attachment: Some( + depth.get_attachment_for_layer(eye, StoreOp::Store), + ), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }; + let mut render_pass = + ctx.begin_tracked_render_pass(render_pass_descriptor); + let pass_span = + diagnostics.pass_span(&mut render_pass, "main_transmissive_pass_3d"); + + if let Some(viewport) = camera.viewport.as_ref() { + render_pass.set_camera_viewport(viewport); + } + + if let Err(err) = transmissive_phase.render_range( + &mut render_pass, + world, + view_entity, + range.clone(), + ) { + error!("Error encountered while rendering the transmissive phase {err:?}"); + } + + pass_span.end(&mut render_pass); + } + } + } else { + for eye in 0..view_count { + let render_pass_descriptor = RenderPassDescriptor { + label: Some("main_transmissive_pass_3d"), + color_attachments: &[Some(target.get_color_attachment_for_layer(eye))], + depth_stencil_attachment: Some( + depth.get_attachment_for_layer(eye, StoreOp::Store), + ), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }; + let mut render_pass = ctx.begin_tracked_render_pass(render_pass_descriptor); let pass_span = diagnostics.pass_span(&mut render_pass, "main_transmissive_pass_3d"); - if let Some(viewport) = camera.viewport.as_ref() { - render_pass.set_camera_viewport(viewport); + if let Some(viewport) = Viewport::from_viewport_and_override( + camera.viewport.as_ref(), + resolution_override, + ) { + render_pass.set_camera_viewport(&viewport); } - if let Err(err) = - transmissive_phase.render_range(&mut render_pass, world, view_entity, range) - { + if let Err(err) = transmissive_phase.render(&mut render_pass, world, view_entity) { error!("Error encountered while rendering the transmissive phase {err:?}"); } pass_span.end(&mut render_pass); } - } else { - let mut render_pass = ctx.begin_tracked_render_pass(render_pass_descriptor); - let pass_span = diagnostics.pass_span(&mut render_pass, "main_transmissive_pass_3d"); - - if let Some(viewport) = - Viewport::from_viewport_and_override(camera.viewport.as_ref(), resolution_override) - { - render_pass.set_camera_viewport(&viewport); - } - - if let Err(err) = transmissive_phase.render(&mut render_pass, world, view_entity) { - error!("Error encountered while rendering the transmissive phase {err:?}"); - } - - pass_span.end(&mut render_pass); } } } diff --git a/crates/bevy_pbr/src/transmission/texture.rs b/crates/bevy_pbr/src/transmission/texture.rs index 5e06d2d13a809..1e70470100e2a 100644 --- a/crates/bevy_pbr/src/transmission/texture.rs +++ b/crates/bevy_pbr/src/transmission/texture.rs @@ -15,7 +15,7 @@ use bevy_render::{ }, renderer::RenderDevice, texture::TextureCache, - view::ExtractedView, + view::{ExtractedMultiview, ExtractedView}, }; use crate::{ScreenSpaceTransmission, Transmissive3d}; @@ -40,10 +40,11 @@ pub fn prepare_core_3d_transmission_textures( &ExtractedCamera, &ScreenSpaceTransmission, &ExtractedView, + Option<&ExtractedMultiview>, )>, ) { let mut textures = >::default(); - for (entity, camera, transmission, view) in &views_3d { + for (entity, camera, transmission, view, multiview) in &views_3d { if !opaque_3d_phases.contains_key(&view.retained_view_entity) || !alpha_mask_3d_phases.contains_key(&view.retained_view_entity) || !transparent_3d_phases.contains_key(&view.retained_view_entity) @@ -70,15 +71,23 @@ pub fn prepare_core_3d_transmission_textures( continue; } + // Allocate one layer per subview under multiview so the per-eye + // transmissive pass can sample its own eye's pre-step main-texture + // copy via the `D2Array` view at `mesh_view_bindings.rs:897-911`. + // Non-multiview cameras stay 1-layer (byte-identical no-op). + let view_count: u32 = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let mut texture_size = physical_target_size.to_extents(); + texture_size.depth_or_array_layers = view_count; + let cached_texture = textures - .entry(camera.target.clone()) + .entry((camera.target.clone(), view_count)) .or_insert_with(|| { let usage = TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST; let descriptor = TextureDescriptor { label: Some("view_transmission_texture"), // The size of the transmission texture - size: physical_target_size.to_extents(), + size: texture_size, mip_level_count: 1, sample_count: 1, // No need for MSAA, as we'll only copy the main texture here dimension: TextureDimension::D2, diff --git a/crates/bevy_pbr/src/transmission/transmission.wgsl b/crates/bevy_pbr/src/transmission/transmission.wgsl index d6f87cfd5bda4..d51e328cea72f 100644 --- a/crates/bevy_pbr/src/transmission/transmission.wgsl +++ b/crates/bevy_pbr/src/transmission/transmission.wgsl @@ -30,7 +30,7 @@ fn specular_transmissive_light(world_position: vec4, frag_coord: vec3, let exit_position = world_position.xyz + T * thickness; // Transform exit_position into clip space - let clip_exit_position = view_bindings::view.clip_from_world * vec4(exit_position, 1.0); + let clip_exit_position = view_bindings::view().clip_from_world * vec4(exit_position, 1.0); // Scale / offset position so that coordinate is in right space for sampling transmissive background texture let offset_position = (clip_exit_position.xy / clip_exit_position.w) * vec2(0.5, -0.5) + 0.5; @@ -45,7 +45,7 @@ fn specular_transmissive_light(world_position: vec4, frag_coord: vec3, } // Compensate for exposure, since the background color is coming from an already exposure-adjusted texture - background_color = vec4(background_color.rgb / view_bindings::view.exposure, background_color.a); + background_color = vec4(background_color.rgb / view_bindings::view().exposure, background_color.a); // Dot product of the refracted direction with the exit normal (Note: We assume the exit normal is the entry normal but inverted) let MinusNdotT = dot(-N, T); @@ -58,24 +58,34 @@ fn specular_transmissive_light(world_position: vec4, frag_coord: vec3, } fn fetch_transmissive_background_non_rough(offset_position: vec2, frag_coord: vec3) -> vec4 { +#ifdef MULTIVIEW var background_color = textureSampleLevel( view_bindings::view_transmission_texture, view_bindings::view_transmission_sampler, offset_position, + view_bindings::current_view_index, 0.0 ); +#else + var background_color = textureSampleLevel( + view_bindings::view_transmission_texture, + view_bindings::view_transmission_sampler, + offset_position, + 0.0 + ); +#endif #ifdef DEPTH_PREPASS #ifndef WEBGL2 // Use depth prepass data to reject values that are in front of the current fragment - if prepass_utils::prepass_depth(vec4(offset_position * view_bindings::view.viewport.zw, 0.0, 0.0), 0u) > frag_coord.z { + if prepass_utils::prepass_depth(vec4(offset_position * view_bindings::view().viewport.zw, 0.0, 0.0), 0u) > frag_coord.z { background_color.a = 0.0; } #endif #endif #ifdef TONEMAP_IN_SHADER - background_color = approximate_inverse_tone_mapping(background_color, view_bindings::view.color_grading); + background_color = approximate_inverse_tone_mapping(background_color, view_bindings::view().color_grading); #endif return background_color; @@ -83,7 +93,7 @@ fn fetch_transmissive_background_non_rough(offset_position: vec2, frag_coor fn fetch_transmissive_background(offset_position: vec2, frag_coord: vec3, view_z: f32, perceptual_roughness: f32) -> vec4 { // Calculate view aspect ratio, used to scale offset so that it's proportionate - let aspect = view_bindings::view.viewport.z / view_bindings::view.viewport.w; + let aspect = view_bindings::view().viewport.z / view_bindings::view().viewport.w; // Calculate how “blurry” the transmission should be. // Blur is more or less eyeballed to look approximately “right”, since the “correct” @@ -158,17 +168,27 @@ fn fetch_transmissive_background(offset_position: vec2, frag_coord: vec3(modified_offset_position * view_bindings::view.viewport.zw, 0.0, 0.0), 0u) > frag_coord.z { + if prepass_utils::prepass_depth(vec4(modified_offset_position * view_bindings::view().viewport.zw, 0.0, 0.0), 0u) > frag_coord.z { sample = vec4(0.0); } #endif @@ -185,7 +205,7 @@ fn fetch_transmissive_background(offset_position: vec2, frag_coord: vec31` + /// flips the WGSL depth-texture binding to `texture_depth_2d_array` and + /// reads `current_view_index` from `@builtin(view_index)`. Not honored + /// under MSAA — WGSL has no `texture_depth_multisampled_2d_array`, so the + /// MSAA + multiview combination keeps the single-layer binding shape. + multiview_view_count: u32, } /// The same as [`VolumetricFog`] and [`FogVolume`], but formatted for @@ -206,6 +221,8 @@ pub fn init_volumetric_fog_pipeline( 1, if flags.contains(VolumetricFogBindGroupLayoutKey::MULTISAMPLED) { texture_depth_2d_multisampled() + } else if flags.contains(VolumetricFogBindGroupLayoutKey::MULTIVIEW) { + texture_2d_array(TextureSampleType::Depth) } else { texture_depth_2d() }, @@ -287,6 +304,7 @@ pub fn volumetric_fog( &ViewVolumetricFog, &MeshViewBindGroup, &Msaa, + Option<&ExtractedMultiview>, )>, pipeline_cache: Res, volumetric_lighting_pipeline: Res, @@ -304,8 +322,13 @@ pub fn volumetric_fog( view_fog_volumes, view_bind_group, msaa, + multiview, ) = view.into_inner(); + let multiview_view_count = multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1); + // Fetch the uniform buffer and binding. let ( Some(textureless_pipeline), @@ -360,9 +383,11 @@ pub fn volumetric_fog( // TODO: Cache this. let mut bind_group_layout_key = VolumetricFogBindGroupLayoutKey::empty(); + let is_msaa = !matches!(*msaa, Msaa::Off); + bind_group_layout_key.set(VolumetricFogBindGroupLayoutKey::MULTISAMPLED, is_msaa); bind_group_layout_key.set( - VolumetricFogBindGroupLayoutKey::MULTISAMPLED, - !matches!(*msaa, Msaa::Off), + VolumetricFogBindGroupLayoutKey::MULTIVIEW, + multiview_view_count > 1 && !is_msaa, ); // Create the bind group entries. The ones relating to the density @@ -455,17 +480,19 @@ impl SpecializedRenderPipeline for VolumetricFogPipeline { let mut shader_defs = vec!["SHADOW_FILTER_METHOD_HARDWARE_2X2".into()]; // We need a separate layout for MSAA and non-MSAA, as well as one for - // the presence or absence of the density texture. + // the presence or absence of the density texture, and one for the + // multiview / non-multiview depth binding shape. let mut bind_group_layout_key = VolumetricFogBindGroupLayoutKey::empty(); - bind_group_layout_key.set( - VolumetricFogBindGroupLayoutKey::MULTISAMPLED, - key.mesh_pipeline_view_key - .contains(MeshPipelineViewLayoutKey::MULTISAMPLED), - ); + let is_msaa = key + .mesh_pipeline_view_key + .contains(MeshPipelineViewLayoutKey::MULTISAMPLED); + bind_group_layout_key.set(VolumetricFogBindGroupLayoutKey::MULTISAMPLED, is_msaa); bind_group_layout_key.set( VolumetricFogBindGroupLayoutKey::DENSITY_TEXTURE, key.has_density_texture, ); + let push_multiview = key.multiview_view_count > 1 && !is_msaa; + bind_group_layout_key.set(VolumetricFogBindGroupLayoutKey::MULTIVIEW, push_multiview); let volumetric_view_bind_group_layout = self.volumetric_view_bind_group_layouts[bind_group_layout_key.bits() as usize].clone(); @@ -496,6 +523,14 @@ impl SpecializedRenderPipeline for VolumetricFogPipeline { shader_defs.push("DENSITY_TEXTURE".into()); } + if push_multiview { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + let layout = self .mesh_view_layouts .get_view_layout(key.mesh_pipeline_view_key); @@ -553,7 +588,10 @@ pub fn prepare_volumetric_fog_pipelines( mut pipelines: ResMut>, volumetric_lighting_pipeline: Res, fog_assets: Res, - view_targets: Query<(Entity, &ExtractedView), With>, + view_targets: Query< + (Entity, &ExtractedView, Option<&ExtractedMultiview>), + With, + >, meshes: Res>, view_key_cache: Res, ) { @@ -562,7 +600,7 @@ pub fn prepare_volumetric_fog_pipelines( return; }; - for (entity, view) in view_targets.iter() { + for (entity, view, multiview) in view_targets.iter() { let Some(mesh_pipeline_key) = view_key_cache.get(&view.retained_view_entity) else { continue; }; @@ -573,6 +611,9 @@ pub fn prepare_volumetric_fog_pipelines( vertex_buffer_layout: plane_mesh.layout.clone(), target_format: view.target_format, has_density_texture: false, + multiview_view_count: multiview + .map(|m| m.subviews.len() as u32) + .unwrap_or(1), }; let textureless_pipeline_id = pipelines.specialize( &pipeline_cache, @@ -744,6 +785,8 @@ impl VolumetricFogBindGroupLayoutKey { Some("density texture") } else if flag == VolumetricFogBindGroupLayoutKey::MULTISAMPLED { Some("multisampled") + } else if flag == VolumetricFogBindGroupLayoutKey::MULTIVIEW { + Some("multiview") } else { None } diff --git a/crates/bevy_pbr/src/volumetric_fog/volumetric_fog.wgsl b/crates/bevy_pbr/src/volumetric_fog/volumetric_fog.wgsl index a64f85d434682..eb88b1f94b1e3 100644 --- a/crates/bevy_pbr/src/volumetric_fog/volumetric_fog.wgsl +++ b/crates/bevy_pbr/src/volumetric_fog/volumetric_fog.wgsl @@ -71,11 +71,22 @@ struct VolumetricFog { @group(1) @binding(0) var volumetric_fog: VolumetricFog; +// Under MULTIVIEW the view depth texture is grown to a per-eye array (see +// `prepare_core_3d_depth_textures`), and the fragment threads +// `@builtin(view_index)` into the read. WGSL has no +// `texture_depth_multisampled_2d_array`, so the MSAA + multiview combination +// keeps the single-layer shape — the host gates the MULTIVIEW shader def on +// `!MULTISAMPLED` to match. Same shape as the prepass-texture bindings in +// `mesh_view_bindings.wgsl`. #ifdef MULTISAMPLED @group(1) @binding(1) var depth_texture: texture_depth_multisampled_2d; #else +#ifdef MULTIVIEW +@group(1) @binding(1) var depth_texture: texture_depth_2d_array; +#else @group(1) @binding(1) var depth_texture: texture_depth_2d; #endif +#endif #ifdef DENSITY_TEXTURE @group(1) @binding(2) var density_texture: texture_3d; @@ -111,7 +122,16 @@ fn henyey_greenstein(neg_LdotV: f32) -> f32 { } @fragment -fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { +fn fragment( + @builtin(position) position: vec4, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif + // Unpack the `volumetric_fog` settings. let uvw_from_world = volumetric_fog.uvw_from_world; let fog_color = volumetric_fog.fog_color; @@ -128,13 +148,21 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { let jitter_strength = volumetric_fog.jitter_strength; // Unpack the view. - let exposure = view.exposure; + let exposure = view().exposure; // Sample the depth to put an upper bound on the length of the ray (as we // shouldn't trace through solid objects). If this is multisample, just use // sample 0; this is approximate but good enough. let frag_coord = position; +#ifdef MULTIVIEW +#ifndef MULTISAMPLED + let ndc_end_depth_from_buffer = textureLoad(depth_texture, vec2(frag_coord.xy), view_index, 0); +#else let ndc_end_depth_from_buffer = textureLoad(depth_texture, vec2(frag_coord.xy), 0); +#endif +#else + let ndc_end_depth_from_buffer = textureLoad(depth_texture, vec2(frag_coord.xy), 0); +#endif let view_end_depth_from_buffer = -position_ndc_to_view( frag_coord_to_ndc(vec4(position.xy, ndc_end_depth_from_buffer, 1.0))).z; @@ -197,7 +225,7 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { let Rd_ndc = vec3(frag_coord_to_ndc(position).xy, 1.0); let Rd_view = normalize(position_ndc_to_view(Rd_ndc)); var Ro_world = position_view_to_world(view_start_pos.xyz); - let Rd_world = normalize(position_ndc_to_world(Rd_ndc) - view.world_position); + let Rd_world = normalize(position_ndc_to_world(Rd_ndc) - view().world_position); // Offset by jitter. let jitter = interleaved_gradient_noise(position.xy, globals.frame_count) * jitter_strength; @@ -338,7 +366,7 @@ fn fragment(@builtin(position) position: vec4) -> @location(0) vec4 { } // Point lights and Spot lights - let is_orthographic = view.clip_from_view[3].w == 1.0; + let is_orthographic = view().clip_from_view[3].w == 1.0; // Reset `background_alpha` for a new raymarch. background_alpha = 1.0; diff --git a/crates/bevy_post_process/src/bloom/bloom.wgsl b/crates/bevy_post_process/src/bloom/bloom.wgsl index 8a7c3833c820e..9eea77f21e67b 100644 --- a/crates/bevy_post_process/src/bloom/bloom.wgsl +++ b/crates/bevy_post_process/src/bloom/bloom.wgsl @@ -6,6 +6,8 @@ // * [COD] - Next Generation Post Processing in Call of Duty - http://www.iryoku.com/next-generation-post-processing-in-call-of-duty-advanced-warfare // * [PBB] - Physically Based Bloom - https://learnopengl.com/Guest-Articles/2022/Phys.-Based-Bloom +#import bevy_core_pipeline::input_texture::{input_texture, sample_input, current_view_index} + struct BloomUniforms { threshold_precomputations: vec4, viewport: vec4, @@ -13,7 +15,6 @@ struct BloomUniforms { aspect: f32, }; -@group(0) @binding(0) var input_texture: texture_2d; @group(0) @binding(1) var s: sampler; @group(0) @binding(2) var uniforms: BloomUniforms; @@ -55,6 +56,25 @@ fn sample_input_13_tap(uv: vec2) -> vec3 { // is hard to test performance on all platforms, and uniform bloom is the most common case, this // path was retained when adding non-uniform (anamorphic) bloom. This adds a small, but nonzero, // cost to maintainability, but it does help me sleep at night. + // + // Under MULTIVIEW the `array_index` argument must be threaded into every `textureSample` call + // before the const `offset` argument, so the two branches below duplicate the 13 samples. + // WGSL requires `offset` to be a const-expression, so we can't hide this in a helper. +#ifdef MULTIVIEW + let a = textureSample(input_texture, s, uv, current_view_index, vec2(-2, 2)).rgb; + let b = textureSample(input_texture, s, uv, current_view_index, vec2(0, 2)).rgb; + let c = textureSample(input_texture, s, uv, current_view_index, vec2(2, 2)).rgb; + let d = textureSample(input_texture, s, uv, current_view_index, vec2(-2, 0)).rgb; + let e = textureSample(input_texture, s, uv, current_view_index).rgb; + let f = textureSample(input_texture, s, uv, current_view_index, vec2(2, 0)).rgb; + let g = textureSample(input_texture, s, uv, current_view_index, vec2(-2, -2)).rgb; + let h = textureSample(input_texture, s, uv, current_view_index, vec2(0, -2)).rgb; + let i = textureSample(input_texture, s, uv, current_view_index, vec2(2, -2)).rgb; + let j = textureSample(input_texture, s, uv, current_view_index, vec2(-1, 1)).rgb; + let k = textureSample(input_texture, s, uv, current_view_index, vec2(1, 1)).rgb; + let l = textureSample(input_texture, s, uv, current_view_index, vec2(-1, -1)).rgb; + let m = textureSample(input_texture, s, uv, current_view_index, vec2(1, -1)).rgb; +#else let a = textureSample(input_texture, s, uv, vec2(-2, 2)).rgb; let b = textureSample(input_texture, s, uv, vec2(0, 2)).rgb; let c = textureSample(input_texture, s, uv, vec2(2, 2)).rgb; @@ -68,6 +88,7 @@ fn sample_input_13_tap(uv: vec2) -> vec3 { let k = textureSample(input_texture, s, uv, vec2(1, 1)).rgb; let l = textureSample(input_texture, s, uv, vec2(-1, -1)).rgb; let m = textureSample(input_texture, s, uv, vec2(1, -1)).rgb; +#endif #else // This is the flexible, but potentially slower, path for non-uniform sampling. Because the // sample is not a constant, and it can fall outside of the limits imposed on constant sample @@ -83,19 +104,19 @@ fn sample_input_13_tap(uv: vec2) -> vec3 { let pl = 2.0 * ps; let ns = -1.0 * ps; let nl = -2.0 * ps; - let a = textureSample(input_texture, s, uv + vec2(nl.x, pl.y)).rgb; - let b = textureSample(input_texture, s, uv + vec2(0.00, pl.y)).rgb; - let c = textureSample(input_texture, s, uv + vec2(pl.x, pl.y)).rgb; - let d = textureSample(input_texture, s, uv + vec2(nl.x, 0.00)).rgb; - let e = textureSample(input_texture, s, uv).rgb; - let f = textureSample(input_texture, s, uv + vec2(pl.x, 0.00)).rgb; - let g = textureSample(input_texture, s, uv + vec2(nl.x, nl.y)).rgb; - let h = textureSample(input_texture, s, uv + vec2(0.00, nl.y)).rgb; - let i = textureSample(input_texture, s, uv + vec2(pl.x, nl.y)).rgb; - let j = textureSample(input_texture, s, uv + vec2(ns.x, ps.y)).rgb; - let k = textureSample(input_texture, s, uv + vec2(ps.x, ps.y)).rgb; - let l = textureSample(input_texture, s, uv + vec2(ns.x, ns.y)).rgb; - let m = textureSample(input_texture, s, uv + vec2(ps.x, ns.y)).rgb; + let a = sample_input(s, uv + vec2(nl.x, pl.y)).rgb; + let b = sample_input(s, uv + vec2(0.00, pl.y)).rgb; + let c = sample_input(s, uv + vec2(pl.x, pl.y)).rgb; + let d = sample_input(s, uv + vec2(nl.x, 0.00)).rgb; + let e = sample_input(s, uv).rgb; + let f = sample_input(s, uv + vec2(pl.x, 0.00)).rgb; + let g = sample_input(s, uv + vec2(nl.x, nl.y)).rgb; + let h = sample_input(s, uv + vec2(0.00, nl.y)).rgb; + let i = sample_input(s, uv + vec2(pl.x, nl.y)).rgb; + let j = sample_input(s, uv + vec2(ns.x, ps.y)).rgb; + let k = sample_input(s, uv + vec2(ps.x, ps.y)).rgb; + let l = sample_input(s, uv + vec2(ns.x, ns.y)).rgb; + let m = sample_input(s, uv + vec2(ps.x, ns.y)).rgb; #endif #ifdef FIRST_DOWNSAMPLE @@ -134,17 +155,17 @@ fn sample_input_3x3_tent(uv: vec2) -> vec3 { let x = frag_size.x; let y = frag_size.y; - let a = textureSample(input_texture, s, vec2(uv.x - x, uv.y + y)).rgb; - let b = textureSample(input_texture, s, vec2(uv.x, uv.y + y)).rgb; - let c = textureSample(input_texture, s, vec2(uv.x + x, uv.y + y)).rgb; + let a = sample_input(s, vec2(uv.x - x, uv.y + y)).rgb; + let b = sample_input(s, vec2(uv.x, uv.y + y)).rgb; + let c = sample_input(s, vec2(uv.x + x, uv.y + y)).rgb; - let d = textureSample(input_texture, s, vec2(uv.x - x, uv.y)).rgb; - let e = textureSample(input_texture, s, vec2(uv.x, uv.y)).rgb; - let f = textureSample(input_texture, s, vec2(uv.x + x, uv.y)).rgb; + let d = sample_input(s, vec2(uv.x - x, uv.y)).rgb; + let e = sample_input(s, vec2(uv.x, uv.y)).rgb; + let f = sample_input(s, vec2(uv.x + x, uv.y)).rgb; - let g = textureSample(input_texture, s, vec2(uv.x - x, uv.y - y)).rgb; - let h = textureSample(input_texture, s, vec2(uv.x, uv.y - y)).rgb; - let i = textureSample(input_texture, s, vec2(uv.x + x, uv.y - y)).rgb; + let g = sample_input(s, vec2(uv.x - x, uv.y - y)).rgb; + let h = sample_input(s, vec2(uv.x, uv.y - y)).rgb; + let i = sample_input(s, vec2(uv.x + x, uv.y - y)).rgb; var sample = e * 0.25; sample += (b + d + f + h) * 0.125; @@ -155,7 +176,15 @@ fn sample_input_3x3_tent(uv: vec2) -> vec3 { #ifdef FIRST_DOWNSAMPLE @fragment -fn downsample_first(@location(0) output_uv: vec2) -> @location(0) vec4 { +fn downsample_first( + @location(0) output_uv: vec2, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let sample_uv = uniforms.viewport.xy + output_uv * uniforms.viewport.zw; var sample = sample_input_13_tap(sample_uv); // Lower bound of 0.0001 is to avoid propagating multiplying by 0.0 through the diff --git a/crates/bevy_post_process/src/bloom/downsampling_pipeline.rs b/crates/bevy_post_process/src/bloom/downsampling_pipeline.rs index 4e94743d1f01f..18afee3de7f9b 100644 --- a/crates/bevy_post_process/src/bloom/downsampling_pipeline.rs +++ b/crates/bevy_post_process/src/bloom/downsampling_pipeline.rs @@ -10,12 +10,13 @@ use bevy_ecs::{ use bevy_math::{Vec2, Vec4}; use bevy_render::{ render_resource::{ - binding_types::{sampler, texture_2d, uniform_buffer}, + binding_types::{sampler, texture_2d, texture_2d_array, uniform_buffer}, *, }, renderer::RenderDevice, + view::ExtractedMultiview, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::default; #[derive(Component)] @@ -26,8 +27,13 @@ pub struct BloomDownsamplingPipelineIds { #[derive(Resource)] pub struct BloomDownsamplingPipeline { - /// Layout with a texture, a sampler, and uniforms + /// Layout for the regular downsample passes (read bloom's own single-layer + /// mip levels). pub bind_group_layout: BindGroupLayoutDescriptor, + /// Layout for the *first* downsample pass when the camera is multiview — + /// reads the camera's `texture_2d_array` main texture; the layer is picked + /// via `@builtin(view_index)` in the fragment. + pub bind_group_layout_multiview: BindGroupLayoutDescriptor, pub sampler: Sampler, /// The asset handle for the fullscreen vertex shader. pub fullscreen_shader: FullscreenShader, @@ -40,6 +46,11 @@ pub struct BloomDownsamplingPipelineKeys { prefilter: bool, first_downsample: bool, uniform_scale: bool, + /// Number of layers in the source texture, used only for the + /// `first_downsample` specialization (subsequent passes read bloom's own + /// single-layer mips). `> 1` emits the MULTIVIEW shader-defs and picks + /// the array bind-group layout. + multiview_view_count: u32, } /// The uniform struct extracted from [`Bloom`] attached to a Camera. @@ -75,6 +86,18 @@ pub fn init_bloom_downsampling_pipeline( ), ); + let bind_group_layout_multiview = BindGroupLayoutDescriptor::new( + "bloom_downsampling_bind_group_layout_with_settings_multiview", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d_array(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::(true), + ), + ), + ); + // Sampler let sampler = render_device.create_sampler(&SamplerDescriptor { min_filter: FilterMode::Linear, @@ -86,6 +109,7 @@ pub fn init_bloom_downsampling_pipeline( commands.insert_resource(BloomDownsamplingPipeline { bind_group_layout, + bind_group_layout_multiview, sampler, fullscreen_shader: fullscreen_shader.clone(), fragment_shader: load_embedded_asset!(asset_server.as_ref(), "bloom.wgsl"), @@ -96,8 +120,6 @@ impl SpecializedRenderPipeline for BloomDownsamplingPipeline { type Key = BloomDownsamplingPipelineKeys; fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor { - let layout = vec![self.bind_group_layout.clone()]; - let entry_point = if key.first_downsample { "downsample_first".into() } else { @@ -118,6 +140,20 @@ impl SpecializedRenderPipeline for BloomDownsamplingPipeline { shader_defs.push("UNIFORM_SCALE".into()); } + // Multiview only applies to the first downsample pass — subsequent + // passes read bloom's own single-layer mip pyramid. Pick the array + // layout + emit MULTIVIEW defs only when both conditions hold. + let layout = if key.first_downsample && key.multiview_view_count > 1 { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + self.bind_group_layout_multiview.clone() + } else { + self.bind_group_layout.clone() + }; + RenderPipelineDescriptor { label: Some( if key.first_downsample { @@ -127,7 +163,7 @@ impl SpecializedRenderPipeline for BloomDownsamplingPipeline { } .into(), ), - layout, + layout: vec![layout], vertex: self.fullscreen_shader.to_vertex_state(), fragment: Some(FragmentState { shader: self.fragment_shader.clone(), @@ -149,10 +185,11 @@ pub fn prepare_downsampling_pipeline( pipeline_cache: Res, mut pipelines: ResMut>, pipeline: Res, - views: Query<(Entity, &Bloom)>, + views: Query<(Entity, &Bloom, Option<&ExtractedMultiview>)>, ) { - for (entity, bloom) in &views { + for (entity, bloom, multiview) in &views { let prefilter = bloom.prefilter.threshold > 0.0; + let multiview_view_count = multiview.map_or(1, |m| m.subviews.len() as u32); let pipeline_id = pipelines.specialize( &pipeline_cache, @@ -161,6 +198,7 @@ pub fn prepare_downsampling_pipeline( prefilter, first_downsample: false, uniform_scale: bloom.scale == Vec2::ONE, + multiview_view_count: 1, }, ); @@ -171,6 +209,7 @@ pub fn prepare_downsampling_pipeline( prefilter, first_downsample: true, uniform_scale: bloom.scale == Vec2::ONE, + multiview_view_count, }, ); diff --git a/crates/bevy_post_process/src/bloom/mod.rs b/crates/bevy_post_process/src/bloom/mod.rs index f22a612718921..7d1c0b6de319a 100644 --- a/crates/bevy_post_process/src/bloom/mod.rs +++ b/crates/bevy_post_process/src/bloom/mod.rs @@ -136,10 +136,17 @@ pub fn bloom( let view_texture = view_target.main_texture_view(); let view_texture_unsampled = view_target.get_unsampled_color_attachment(); - // Create the first downsampling bind group (reads from main texture) + // Create the first downsampling bind group (reads from main texture). + // Pick the multiview layout when the camera's main texture is an array; + // subsequent downsample passes always read bloom's own single-layer mips. + let first_downsample_layout = if view_target.multiview_count().is_some() { + &downsampling_pipeline_res.bind_group_layout_multiview + } else { + &downsampling_pipeline_res.bind_group_layout + }; let downsampling_first_bind_group = ctx.render_device().create_bind_group( "bloom_downsampling_first_bind_group", - &pipeline_cache.get_bind_group_layout(&downsampling_pipeline_res.bind_group_layout), + &pipeline_cache.get_bind_group_layout(first_downsample_layout), &BindGroupEntries::sequential(( view_texture, &bind_groups.sampler, diff --git a/crates/bevy_post_process/src/dof/dof.wgsl b/crates/bevy_post_process/src/dof/dof.wgsl index 563b0af031644..45207c58c2416 100644 --- a/crates/bevy_post_process/src/dof/dof.wgsl +++ b/crates/bevy_post_process/src/dof/dof.wgsl @@ -75,10 +75,22 @@ struct DualOutput { // @group(0) @binding(0) is `mesh_view_bindings::view`. // The depth texture for the main view. +// +// Under MULTIVIEW the view depth texture is grown to a per-eye array (see +// `prepare_core_3d_depth_textures`), and the fragments thread +// `@builtin(view_index)` through `current_view_index` so the helper read +// indexes the right layer. WGSL has no `texture_depth_multisampled_2d_array`, +// so the MSAA + multiview combination keeps the single-layer shape — the host +// gates the MULTIVIEW shader def on `!MULTISAMPLED` to match. Same shape as +// the prepass-texture bindings in `mesh_view_bindings.wgsl`. #ifdef MULTISAMPLED @group(0) @binding(1) var depth_texture: texture_depth_multisampled_2d; #else // MULTISAMPLED +#ifdef MULTIVIEW +@group(0) @binding(1) var depth_texture: texture_depth_2d_array; +#else // MULTIVIEW @group(0) @binding(1) var depth_texture: texture_depth_2d; +#endif // MULTIVIEW #endif // MULTISAMPLED // The main color texture. @@ -121,9 +133,20 @@ fn calculate_circle_of_confusion(in_frag_coord: vec4) -> f32 { let scale = dof_params.coc_scale_factor; let max_coc_diameter = dof_params.max_circle_of_confusion_diameter; - // Sample the depth. + // Sample the depth. Under MULTIVIEW the binding is a per-eye array and + // we index it with `current_view_index` (set by the fragment entry from + // `@builtin(view_index)`); the multisampled binding remains single-layer + // because WGSL has no `texture_depth_multisampled_2d_array`. let frag_coord = vec2(floor(in_frag_coord.xy)); +#ifdef MULTIVIEW +#ifndef MULTISAMPLED + let raw_depth = textureLoad(depth_texture, frag_coord, current_view_index, 0); +#else // MULTISAMPLED + let raw_depth = textureLoad(depth_texture, frag_coord, 0); +#endif // MULTISAMPLED +#else // MULTIVIEW let raw_depth = textureLoad(depth_texture, frag_coord, 0); +#endif // MULTIVIEW let depth = min(-depth_ndc_to_view_z(raw_depth), dof_params.max_depth); // Calculate the circle of confusion. @@ -191,14 +214,30 @@ fn box_blur_b(frag_coord: vec4, coc: f32, frag_offset: vec2) -> vec4 @location(0) vec4 { +fn gaussian_horizontal( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let coc = calculate_circle_of_confusion(in.position); return gaussian_blur(color_texture_a, color_texture_sampler, in.position, coc, vec2(1.0, 0.0)); } // Calculates the vertical component of the separable Gaussian blur. @fragment -fn gaussian_vertical(in: FullscreenVertexOutput) -> @location(0) vec4 { +fn gaussian_vertical( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let coc = calculate_circle_of_confusion(in.position); return gaussian_blur(color_texture_a, color_texture_sampler, in.position, coc, vec2(0.0, 1.0)); } @@ -212,7 +251,15 @@ fn gaussian_vertical(in: FullscreenVertexOutput) -> @location(0) vec4 { // │ // │ @fragment -fn bokeh_pass_a(in: FullscreenVertexOutput) -> DualOutput { +fn bokeh_pass_a( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> DualOutput { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let coc = calculate_circle_of_confusion(in.position); let vertical = box_blur_a(in.position, coc, vec2(0.0, 1.0)); let diagonal = box_blur_a(in.position, coc, vec2(COS_NEG_FRAC_PI_6, SIN_NEG_FRAC_PI_6)); @@ -232,7 +279,15 @@ fn bokeh_pass_a(in: FullscreenVertexOutput) -> DualOutput { // • #ifdef DUAL_INPUT @fragment -fn bokeh_pass_b(in: FullscreenVertexOutput) -> @location(0) vec4 { +fn bokeh_pass_b( + in: FullscreenVertexOutput, +#ifdef MULTIVIEW + @builtin(view_index) view_index: i32, +#endif +) -> @location(0) vec4 { +#ifdef MULTIVIEW + current_view_index = view_index; +#endif let coc = calculate_circle_of_confusion(in.position); let output_0 = box_blur_a(in.position, coc, vec2(COS_NEG_FRAC_PI_6, SIN_NEG_FRAC_PI_6)); let output_1 = box_blur_b(in.position, coc, vec2(COS_NEG_FRAC_PI_5_6, SIN_NEG_FRAC_PI_5_6)); diff --git a/crates/bevy_post_process/src/dof/mod.rs b/crates/bevy_post_process/src/dof/mod.rs index 0644b7c499188..ec8e57b9a3835 100644 --- a/crates/bevy_post_process/src/dof/mod.rs +++ b/crates/bevy_post_process/src/dof/mod.rs @@ -34,7 +34,8 @@ use bevy_render::{ extract_component::{ComponentUniforms, DynamicUniformIndex, UniformComponentPlugin}, render_resource::{ binding_types::{ - sampler, texture_2d, texture_depth_2d, texture_depth_2d_multisampled, uniform_buffer, + sampler, texture_2d, texture_2d_array, texture_depth_2d, + texture_depth_2d_multisampled, uniform_buffer, }, BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries, CachedRenderPipelineId, ColorTargetState, ColorWrites, FilterMode, FragmentState, LoadOp, @@ -48,12 +49,12 @@ use bevy_render::{ sync_world::RenderEntity, texture::{CachedTexture, TextureCache}, view::{ - prepare_view_targets, ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, - ViewUniformOffset, ViewUniforms, + prepare_view_targets, ExtractedMultiview, ExtractedView, Msaa, ViewDepthTexture, + ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms, }, Extract, ExtractSchedule, GpuResourceAppExt, Render, RenderApp, RenderStartup, RenderSystems, }; -use bevy_shader::Shader; +use bevy_shader::{Shader, ShaderDefVal}; use bevy_utils::{default, once}; use smallvec::SmallVec; use tracing::{info, warn}; @@ -177,6 +178,12 @@ pub struct DepthOfFieldPipelineKey { target_format: TextureFormat, /// Whether the render target is multisampled. multisample: bool, + /// The number of subviews packed into the view uniform buffer. `1` when the + /// camera is not multiview. Gates the `MULTIVIEW`/`MAX_VIEW_COUNT` shader + /// defs (and the per-eye depth-array binding) in `specialize`. MSAA cameras + /// keep the single-layer depth binding regardless because WGSL has no + /// `texture_depth_multisampled_2d_array`. + multiview_view_count: u32, } /// Identifies a specific depth of field render pass. @@ -373,9 +380,25 @@ pub fn init_dof_global_bind_group_layout(mut commands: Commands, render_device: /// specific to each view. pub fn prepare_depth_of_field_view_bind_group_layouts( mut commands: Commands, - view_targets: Query<(Entity, &DepthOfField, &Msaa)>, + view_targets: Query<(Entity, &DepthOfField, &Msaa, Option<&ExtractedMultiview>)>, ) { - for (view, depth_of_field, msaa) in view_targets.iter() { + for (view, depth_of_field, msaa, multiview) in view_targets.iter() { + let is_msaa = *msaa != Msaa::Off; + // Under multiview the view depth texture is grown to a per-eye array + // (see `prepare_core_3d_depth_textures`); the binding has to match. + // MSAA + multiview keeps the single-layer shape because WGSL has no + // `texture_depth_multisampled_2d_array` (same carve-out as the + // prepass-texture bindings in `mesh_view_bindings.wgsl`). + let use_multiview_depth = + multiview.is_some_and(|m| m.subviews.len() > 1) && !is_msaa; + let depth_binding = if is_msaa { + texture_depth_2d_multisampled() + } else if use_multiview_depth { + texture_2d_array(TextureSampleType::Depth) + } else { + texture_depth_2d() + }; + // Create the bind group layout for the passes that take one input. let single_input = BindGroupLayoutDescriptor::new( "depth of field bind group layout (single input)", @@ -383,11 +406,7 @@ pub fn prepare_depth_of_field_view_bind_group_layouts( ShaderStages::FRAGMENT, ( uniform_buffer::(true), - if *msaa != Msaa::Off { - texture_depth_2d_multisampled() - } else { - texture_depth_2d() - }, + depth_binding, texture_2d(TextureSampleType::Float { filterable: true }), ), ), @@ -403,11 +422,7 @@ pub fn prepare_depth_of_field_view_bind_group_layouts( ShaderStages::FRAGMENT, ( uniform_buffer::(true), - if *msaa != Msaa::Off { - texture_depth_2d_multisampled() - } else { - texture_depth_2d() - }, + depth_binding, texture_2d(TextureSampleType::Float { filterable: true }), texture_2d(TextureSampleType::Float { filterable: true }), ), @@ -511,13 +526,16 @@ pub fn prepare_depth_of_field_pipelines( &DepthOfField, &ViewDepthOfFieldBindGroupLayouts, &Msaa, + Option<&ExtractedMultiview>, ), With, >, fullscreen_shader: Res, asset_server: Res, ) { - for (entity, view, depth_of_field, view_bind_group_layouts, msaa) in view_targets.iter() { + for (entity, view, depth_of_field, view_bind_group_layouts, msaa, multiview) in + view_targets.iter() + { let dof_pipeline = DepthOfFieldPipeline { view_bind_group_layouts: view_bind_group_layouts.clone(), global_bind_group_layout: global_bind_group_layout.layout.clone(), @@ -527,6 +545,7 @@ pub fn prepare_depth_of_field_pipelines( // We'll need these two flags to create the `DepthOfFieldPipelineKey`s. let (target_format, multisample) = (view.target_format, *msaa != Msaa::Off); + let multiview_view_count = multiview.map_or(1, |m| m.subviews.len() as u32); // Go ahead and specialize the pipelines. match depth_of_field.mode { @@ -540,6 +559,7 @@ pub fn prepare_depth_of_field_pipelines( DepthOfFieldPipelineKey { target_format, multisample, + multiview_view_count, pass: DofPass::GaussianHorizontal, }, ), @@ -549,6 +569,7 @@ pub fn prepare_depth_of_field_pipelines( DepthOfFieldPipelineKey { target_format, multisample, + multiview_view_count, pass: DofPass::GaussianVertical, }, ), @@ -565,6 +586,7 @@ pub fn prepare_depth_of_field_pipelines( DepthOfFieldPipelineKey { target_format, multisample, + multiview_view_count, pass: DofPass::BokehPass0, }, ), @@ -574,6 +596,7 @@ pub fn prepare_depth_of_field_pipelines( DepthOfFieldPipelineKey { target_format, multisample, + multiview_view_count, pass: DofPass::BokehPass1, }, ), @@ -627,6 +650,20 @@ impl SpecializedRenderPipeline for DepthOfFieldPipeline { shader_defs.push("MULTISAMPLED".into()); } + // Under MULTIVIEW the depth binding becomes a per-eye array and each + // fragment threads `@builtin(view_index)` into `current_view_index`. + // MSAA cameras keep the single-layer depth binding (no + // `texture_depth_multisampled_2d_array` in WGSL), so skip the def + // there to match the `prepare_depth_of_field_view_bind_group_layouts` + // carve-out. + if key.multiview_view_count > 1 && !key.multisample { + shader_defs.push("MULTIVIEW".into()); + shader_defs.push(ShaderDefVal::UInt( + "MAX_VIEW_COUNT".into(), + key.multiview_view_count, + )); + } + RenderPipelineDescriptor { label: Some("depth of field pipeline".into()), layout, diff --git a/crates/bevy_post_process/src/msaa_writeback.rs b/crates/bevy_post_process/src/msaa_writeback.rs index fab9d4204c722..c8cac42464f2a 100644 --- a/crates/bevy_post_process/src/msaa_writeback.rs +++ b/crates/bevy_post_process/src/msaa_writeback.rs @@ -11,7 +11,7 @@ use bevy_render::{ diagnostic::RecordDiagnostics, render_resource::*, renderer::{RenderContext, ViewQuery}, - view::{Msaa, ViewTarget}, + view::{ExtractedMultiview, Msaa, ViewTarget}, Render, RenderApp, RenderSystems, }; @@ -76,8 +76,12 @@ pub(crate) fn msaa_writeback( multiview_mask: None, }; - let bind_group = - blit_pipeline.create_bind_group(ctx.render_device(), post_process.source, &pipeline_cache); + let bind_group = blit_pipeline.create_bind_group( + ctx.render_device(), + post_process.source, + &pipeline_cache, + target.multiview_count().map_or(1, |n| n.get()), + ); let diagnostics = ctx.diagnostic_recorder(); let diagnostics = diagnostics.as_deref(); @@ -102,9 +106,15 @@ fn prepare_msaa_writeback_pipelines( pipeline_cache: Res, mut pipelines: ResMut>, blit_pipeline: Res, - view_targets: Query<(Entity, &ViewTarget, &ExtractedCamera, &Msaa)>, + view_targets: Query<( + Entity, + &ViewTarget, + &ExtractedCamera, + &Msaa, + Option<&ExtractedMultiview>, + )>, ) { - for (entity, view_target, camera, msaa) in view_targets.iter() { + for (entity, view_target, camera, msaa, multiview) in view_targets.iter() { // Determine if we should do MSAA writeback based on the camera's setting let should_writeback = match camera.msaa_writeback { MsaaWriteback::Off => false, @@ -121,11 +131,14 @@ fn prepare_msaa_writeback_pipelines( }; if msaa.samples() > 1 && should_writeback { + let multiview_view_count = multiview.map_or(1, |m| m.subviews.len() as u32); + let key = BlitPipelineKey { target_format: view_target.main_texture_format(), samples: msaa.samples(), blend_state: None, source_space: None, + multiview_view_count, }; let pipeline = pipelines.specialize(&pipeline_cache, &blit_pipeline, key); diff --git a/crates/bevy_render/src/camera.rs b/crates/bevy_render/src/camera.rs index 29d15a00b0b97..f224cc81b8a92 100644 --- a/crates/bevy_render/src/camera.rs +++ b/crates/bevy_render/src/camera.rs @@ -10,9 +10,10 @@ use crate::{ sync_world::{MainEntity, MainEntityHashSet, RenderEntity, SyncToRenderWorld}, texture::{GpuImage, ManualTextureViews}, view::{ - ColorGrading, ExtractedView, ExtractedWindows, Msaa, NoIndirectDrawing, - RenderExtractedVisibleEntities, RenderVisibleEntities, RenderVisibleEntitiesClass, - RetainedViewEntity, ViewUniformOffset, VisibilityExtractionSystemParam, + ColorGrading, ExtractedMultiview, ExtractedSubview, ExtractedView, ExtractedWindows, Msaa, + NoIndirectDrawing, RenderExtractedVisibleEntities, RenderVisibleEntities, + RenderVisibleEntitiesClass, RetainedViewEntity, ViewUniformOffset, + VisibilityExtractionSystemParam, }, Extract, ExtractSchedule, Render, RenderApp, RenderSystems, }; @@ -24,7 +25,8 @@ use bevy_camera::{ visibility::{self, RenderLayers, VisibleEntities}, Camera, Camera2d, Camera3d, CameraMainTextureUsages, CameraOutputMode, CameraUpdateSystems, ClearColor, ClearColorConfig, CompositingSpace, Exposure, Hdr, ManualTextureViewHandle, - MsaaWriteback, NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, Viewport, + MsaaWriteback, Multiview, NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, + Viewport, MAX_VIEW_COUNT, }; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ @@ -493,6 +495,7 @@ pub fn extract_cameras( Option<&RenderLayers>, Option<&Projection>, Has, + Option<&Multiview>, ), )>, >, @@ -512,6 +515,7 @@ pub fn extract_cameras( type ExtractedCameraComponents = ( ExtractedCamera, ExtractedView, + ExtractedMultiview, RenderVisibleEntities, TemporalJitter, MipBias, @@ -540,6 +544,7 @@ pub fn extract_cameras( render_layers, projection, no_indirect_drawing, + multiview, ), ) in query.iter() { @@ -659,6 +664,31 @@ pub fn extract_cameras( *frustum, )); + let multiview = multiview.filter(|m| !m.views.is_empty()); + if let Some(multiview) = multiview { + if multiview.views.len() > MAX_VIEW_COUNT { + warn_once!( + "Camera with {} multiview subviews exceeds MAX_VIEW_COUNT ({}); \ + rendering as non-multiview. This warning fires once per process.", + multiview.views.len(), + MAX_VIEW_COUNT, + ); + commands.remove::(); + } else { + let subviews = multiview + .views + .iter() + .map(|s| ExtractedSubview { + world_from_view: transform.mul_transform(s.view_from_camera), + clip_from_view: s.clip_from_view, + }) + .collect(); + commands.insert(ExtractedMultiview { subviews }); + } + } else { + commands.remove::(); + } + if let Some(temporal_jitter) = temporal_jitter { commands.insert(temporal_jitter.clone()); } else { diff --git a/crates/bevy_render/src/render_resource/batched_uniform_buffer.rs b/crates/bevy_render/src/render_resource/batched_uniform_buffer.rs index f869e6aea9558..e7cbaa794c0b7 100644 --- a/crates/bevy_render/src/render_resource/batched_uniform_buffer.rs +++ b/crates/bevy_render/src/render_resource/batched_uniform_buffer.rs @@ -131,7 +131,7 @@ fn align_to_next(value: u64, alignment: u64) -> u64 { // unclear if it is the correct long-term solution for encase. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] -struct MaxCapacityArray(T, usize); +pub(crate) struct MaxCapacityArray(pub(crate) T, pub(crate) usize); impl ShaderType for MaxCapacityArray where diff --git a/crates/bevy_render/src/render_resource/dynamic_array_buffer.rs b/crates/bevy_render/src/render_resource/dynamic_array_buffer.rs new file mode 100644 index 0000000000000..684ae048a23bd --- /dev/null +++ b/crates/bevy_render/src/render_resource/dynamic_array_buffer.rs @@ -0,0 +1,246 @@ +use core::num::NonZero; + +use encase::ShaderType; +use wgpu::{BindingResource, BufferUsages, Limits}; + +use crate::{ + render_resource::DynamicUniformBuffer, + renderer::{RenderDevice, RenderQueue}, +}; + +use super::{batched_uniform_buffer::MaxCapacityArray, Buffer, GpuArrayBufferable, IntoBinding}; + +/// Similar to [`DynamicUniformBuffer`] but designed for storing multiple +/// runtime-sized arrays of `T` in a single uniform buffer. Each array is +/// accessible via a [`DynamicUniformBuffer`]-style dynamic offset, and is +/// padded out to the length of the largest array so the WGSL side can read +/// them as `array` where `N` is the largest length. +/// +/// This is the host-side companion to multiview-style "view bindings" where +/// each camera contributes a small array of per-view uniforms (one element +/// per eye / cubemap face / shadow cascade) to a single bound uniform. +pub struct DynamicArrayUniformBuffer { + uniforms: DynamicUniformBuffer>>, + temp: Vec>>, + offsets: Vec, + is_queuing_finished: bool, +} + +impl DynamicArrayUniformBuffer { + pub fn new(limits: &Limits) -> Self { + let alignment = limits.min_uniform_buffer_offset_alignment; + + Self { + uniforms: DynamicUniformBuffer::new_with_alignment(alignment as u64), + temp: Vec::new(), + offsets: Vec::new(), + is_queuing_finished: false, + } + } + + pub fn clear(&mut self) { + self.uniforms.clear(); + self.offsets.clear(); + self.is_queuing_finished = false; + self.temp.clear(); + } + + /// Sets a debug label for the underlying GPU buffer. + pub fn set_label(&mut self, label: Option<&str>) { + self.uniforms.set_label(label); + } + + /// Adds extra [`BufferUsages`] beyond the default `COPY_DST | UNIFORM`. + /// Useful when the same buffer should also be bound as a storage buffer. + pub fn add_usages(&mut self, usages: BufferUsages) { + self.uniforms.add_usages(usages); + } + + pub fn is_queuing_finished(&self) -> bool { + self.is_queuing_finished + } + + /// Returns the length of the longest array queued so far. Until + /// [`finish_queuing`](Self::finish_queuing) is called this value can + /// still grow. + pub fn current_max_capacity(&self) -> usize { + self.temp + .iter() + .fold(0usize, |size, array| size.max(array.0.len())) + } + + /// Returns the binding size that the buffer will use for each array + /// slot, given the current max capacity. Equal to `T::stride() * + /// max(current_max_capacity, 1)`. + pub fn current_size(&self) -> NonZero { + Vec::::METADATA + .stride() + .mul(self.current_max_capacity().max(1) as u64) + .0 + } + + /// Reserves a new (initially empty) array and returns an index that can + /// be used with [`push_element`](Self::push_element). + /// + /// Panics if [`finish_queuing`](Self::finish_queuing) has already been + /// called. + pub fn new_array(&mut self) -> DynamicArrayIndex { + assert!( + !self.is_queuing_finished, + "cannot create new arrays after finish_queuing has been called; clear() first" + ); + self.temp.push(MaxCapacityArray(Vec::new(), 0)); + DynamicArrayIndex(self.temp.len() - 1) + } + + /// Pushes a whole array at once and returns its index. + /// + /// Panics if [`finish_queuing`](Self::finish_queuing) has already been + /// called. + pub fn push_array(&mut self, array: Vec) -> DynamicArrayIndex { + assert!( + !self.is_queuing_finished, + "cannot push arrays after finish_queuing has been called; clear() first" + ); + self.temp.push(MaxCapacityArray(array, 0)); + DynamicArrayIndex(self.temp.len() - 1) + } + + /// Appends an element to the array identified by `array`. + /// + /// Panics if [`finish_queuing`](Self::finish_queuing) has already been + /// called. + pub fn push_element(&mut self, array: DynamicArrayIndex, element: T) { + assert!( + !self.is_queuing_finished, + "cannot push elements after finish_queuing has been called; clear() first" + ); + self.temp[array.0].0.push(element); + } + + /// Finalizes the queued arrays. After this call no further arrays or + /// elements may be pushed (until [`clear`](Self::clear) is called) but + /// offsets and bindings become available. + pub fn finish_queuing(&mut self) { + if self.is_queuing_finished { + return; + } + let capacity = self.current_max_capacity(); + for array in &mut self.temp { + array.1 = capacity; + self.offsets.push(self.uniforms.push(&*array)); + } + self.is_queuing_finished = true; + } + + /// Returns the dynamic offset of the array at `index`. Only valid after + /// [`finish_queuing`](Self::finish_queuing). + pub fn get_array_offset(&self, index: DynamicArrayIndex) -> u32 { + self.offsets[index.0] + } + + pub fn write_buffer(&mut self, device: &RenderDevice, queue: &RenderQueue) { + self.uniforms.write_buffer(device, queue); + } + + pub fn buffer(&self) -> Option<&Buffer> { + self.uniforms.buffer() + } + + /// Returns a binding sized to the current max array capacity. Returns + /// `None` if [`finish_queuing`](Self::finish_queuing) has not been called, + /// or if [`write_buffer`](Self::write_buffer) has not yet uploaded the + /// staged data (the latter is when the underlying GPU buffer is + /// allocated). + pub fn binding(&self) -> Option> { + if !self.is_queuing_finished { + return None; + } + let mut binding = self.uniforms.binding(); + if let Some(BindingResource::Buffer(binding)) = &mut binding { + // MaxCapacityArray is runtime-sized so can't use T::min_size(). + binding.size = Some(self.current_size()); + } + binding + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DynamicArrayIndex(usize); + +impl<'a, T: GpuArrayBufferable> IntoBinding<'a> for &'a DynamicArrayUniformBuffer { + #[inline] + fn into_binding(self) -> BindingResource<'a> { + self.binding().unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use encase::ShaderType; + + #[derive(Clone, Copy, ShaderType)] + struct Item { + a: u32, + b: u32, + } + + fn buffer() -> DynamicArrayUniformBuffer { + // Pick an alignment from a stand-in `Limits`. We don't touch the GPU + // in tests, so we only exercise the host-side queueing semantics. + DynamicArrayUniformBuffer::new(&Limits::downlevel_defaults()) + } + + #[test] + fn current_max_capacity_tracks_largest_array() { + let mut buf = buffer(); + let a = buf.push_array(vec![Item { a: 0, b: 0 }; 2]); + buf.push_array(vec![Item { a: 0, b: 0 }; 5]); + buf.push_array(vec![Item { a: 0, b: 0 }; 3]); + assert_eq!(buf.current_max_capacity(), 5); + buf.push_element(a, Item { a: 0, b: 0 }); + // pushing into `a` doesn't change the max because it still has 3 < 5 + assert_eq!(buf.current_max_capacity(), 5); + } + + #[test] + fn finish_queuing_assigns_offsets() { + let mut buf = buffer(); + let a = buf.push_array(vec![Item { a: 1, b: 1 }; 2]); + let b = buf.push_array(vec![Item { a: 2, b: 2 }; 4]); + buf.finish_queuing(); + assert!(buf.is_queuing_finished()); + // Distinct arrays produce distinct offsets. + assert_ne!(buf.get_array_offset(a), buf.get_array_offset(b)); + // `write_buffer` is the GPU-side upload; we don't have a device in + // tests, so the binding stays `None` even though queueing is done. + assert!(buf.binding().is_none()); + } + + #[test] + fn binding_is_none_before_finish_queuing() { + let mut buf = buffer(); + buf.push_array(vec![Item { a: 0, b: 0 }; 2]); + assert!(buf.binding().is_none()); + } + + #[test] + #[should_panic] + fn push_after_finish_panics() { + let mut buf = buffer(); + buf.push_array(vec![Item { a: 0, b: 0 }]); + buf.finish_queuing(); + buf.push_array(vec![Item { a: 0, b: 0 }]); + } + + #[test] + fn clear_resets_state() { + let mut buf = buffer(); + buf.push_array(vec![Item { a: 0, b: 0 }; 3]); + buf.finish_queuing(); + buf.clear(); + assert!(!buf.is_queuing_finished()); + assert_eq!(buf.current_max_capacity(), 0); + } +} diff --git a/crates/bevy_render/src/render_resource/mod.rs b/crates/bevy_render/src/render_resource/mod.rs index 18e745878c70f..559a78a2c5f0b 100644 --- a/crates/bevy_render/src/render_resource/mod.rs +++ b/crates/bevy_render/src/render_resource/mod.rs @@ -6,6 +6,7 @@ mod bind_group_layout; mod bindless; mod buffer; mod buffer_vec; +mod dynamic_array_buffer; mod gpu_array_buffer; mod pipeline; mod pipeline_cache; @@ -23,6 +24,7 @@ pub use bind_group_layout::*; pub use bindless::*; pub use buffer::*; pub use buffer_vec::*; +pub use dynamic_array_buffer::*; pub use gpu_array_buffer::*; pub use pipeline::*; pub use pipeline_cache::*; diff --git a/crates/bevy_render/src/render_resource/pipeline_cache.rs b/crates/bevy_render/src/render_resource/pipeline_cache.rs index 197782336dd79..6b6cc7809bcf0 100644 --- a/crates/bevy_render/src/render_resource/pipeline_cache.rs +++ b/crates/bevy_render/src/render_resource/pipeline_cache.rs @@ -540,7 +540,7 @@ impl PipelineCache { }; let descriptor = RawRenderPipelineDescriptor { - multiview_mask: None, + multiview_mask: descriptor.multiview_mask, depth_stencil: descriptor.depth_stencil.clone(), label: descriptor.label.as_deref(), layout: layout.as_ref().map(|layout| -> &PipelineLayout { layout }), diff --git a/crates/bevy_render/src/texture/texture_attachment.rs b/crates/bevy_render/src/texture/texture_attachment.rs index 4af5a7f9cb5b6..68677616ad6e5 100644 --- a/crates/bevy_render/src/texture/texture_attachment.rs +++ b/crates/bevy_render/src/texture/texture_attachment.rs @@ -1,11 +1,12 @@ use super::CachedTexture; -use crate::render_resource::{TextureFormat, TextureView}; +use crate::render_resource::{Texture, TextureFormat, TextureView}; use alloc::sync::Arc; use bevy_color::LinearRgba; +use bevy_platform::sync::OnceLock; use core::sync::atomic::{AtomicBool, Ordering}; use wgpu::{ Color as WgpuColor, LoadOp, Operations, RenderPassColorAttachment, - RenderPassDepthStencilAttachment, StoreOp, + RenderPassDepthStencilAttachment, StoreOp, TextureViewDescriptor, TextureViewDimension, }; /// A wrapper for a [`CachedTexture`] that is used as a [`RenderPassColorAttachment`]. @@ -16,6 +17,18 @@ pub struct ColorAttachment { pub previous_frame_texture: Option, clear_color: Option, is_first_call: Arc, + /// Lazily-populated per-layer `D2` views of [`Self::texture`], for use by + /// [`Self::get_attachment_for_layer`] / [`Self::get_unsampled_attachment_for_layer`] + /// when the underlying texture is multi-layer (e.g., post-C2 multiview + /// prepass). Populated all-at-once on first per-layer access. + per_layer_views: Arc>>, + /// Lazily-populated per-layer `D2` views of [`Self::resolve_target`]. + per_layer_resolve_views: Arc>>, + /// One first-call latch per layer of [`Self::texture`], populated lazily + /// on first per-layer attachment access. Each per-layer slot is flipped + /// independently so per-eye dispatch (one render pass per layer) clears + /// each layer exactly once per frame instead of only layer 0. + per_layer_first_call: Arc>>, } impl ColorAttachment { @@ -31,6 +44,9 @@ impl ColorAttachment { previous_frame_texture, clear_color, is_first_call: Arc::new(AtomicBool::new(true)), + per_layer_views: Arc::new(OnceLock::new()), + per_layer_resolve_views: Arc::new(OnceLock::new()), + per_layer_first_call: Arc::new(OnceLock::new()), } } @@ -80,25 +96,168 @@ impl ColorAttachment { } } + /// Get this texture view as an attachment targeting a single layer of the + /// underlying texture (and resolve target, if any). For single-layer + /// textures, pass `layer = 0` — the synthesized view is bit-identical to + /// what [`Self::get_attachment`] returns. + /// + /// Used by per-eye dispatch in the prepass / deferred render-graph nodes + /// under multiview, where each eye is rendered to its own layer in a + /// separate render pass. + pub fn get_attachment_for_layer(&self, layer: u32) -> RenderPassColorAttachment<'_> { + if let Some(resolve_target) = self.resolve_target.as_ref() { + let first_call = self.first_call_for_layer(layer); + let resolve_views = self.per_layer_resolve_views.get_or_init(|| { + build_per_layer_d2_views( + &resolve_target.texture, + "color_attachment_resolve_layer_view", + ) + }); + let target_views = self.per_layer_views.get_or_init(|| { + build_per_layer_d2_views(&self.texture.texture, "color_attachment_layer_view") + }); + + RenderPassColorAttachment { + view: &resolve_views[layer as usize], + depth_slice: None, + resolve_target: Some(&target_views[layer as usize]), + ops: Operations { + load: match (self.clear_color, first_call) { + (Some(clear_color), true) => LoadOp::Clear(clear_color), + (None, _) | (Some(_), false) => LoadOp::Load, + }, + store: StoreOp::Store, + }, + } + } else { + self.get_unsampled_attachment_for_layer(layer) + } + } + + /// Per-layer counterpart to [`Self::get_unsampled_attachment`]. See + /// [`Self::get_attachment_for_layer`]. + pub fn get_unsampled_attachment_for_layer(&self, layer: u32) -> RenderPassColorAttachment<'_> { + let first_call = self.first_call_for_layer(layer); + let target_views = self.per_layer_views.get_or_init(|| { + build_per_layer_d2_views(&self.texture.texture, "color_attachment_layer_view") + }); + + RenderPassColorAttachment { + view: &target_views[layer as usize], + depth_slice: None, + resolve_target: None, + ops: Operations { + load: match (self.clear_color, first_call) { + (Some(clear_color), true) => LoadOp::Clear(clear_color), + (None, _) | (Some(_), false) => LoadOp::Load, + }, + store: StoreOp::Store, + }, + } + } + + /// Flip the per-layer first-call latch for `layer`, and mark the global + /// latch as touched so any subsequent legacy `get_attachment` / + /// `get_unsampled_attachment` call loads instead of re-clearing the + /// already-per-layer-cleared texture. + /// + /// The per-layer slots are seeded from the CURRENT value of the global + /// latch on first access — a consumer that runs after an earlier-in-the + /// -frame pass already flipped the global to false (e.g. the transmissive + /// pass running after the main opaque pass) sees `false` on its first + /// per-layer access and loads instead of re-clearing the already-rendered + /// attachment. + fn first_call_for_layer(&self, layer: u32) -> bool { + let initial = self.is_first_call.fetch_and(false, Ordering::SeqCst); + let per_layer_first = self + .per_layer_first_call + .get_or_init(|| init_per_layer_first_call(&self.texture.texture, initial)); + per_layer_first[layer as usize].fetch_and(false, Ordering::SeqCst) + } + pub(crate) fn mark_as_cleared(&self) { self.is_first_call.store(false, Ordering::SeqCst); + if let Some(per_layer) = self.per_layer_first_call.get() { + for slot in per_layer { + slot.store(false, Ordering::SeqCst); + } + } } } +/// Synthesizes a single-layer `D2` `TextureView` of each layer of a (possibly +/// multi-layer) texture. Used to back per-layer attachment access in +/// [`ColorAttachment`] and [`DepthAttachment`]. +fn build_per_layer_d2_views(texture: &Texture, label: &'static str) -> Vec { + let layer_count = texture.depth_or_array_layers(); + (0..layer_count) + .map(|layer| { + texture.create_view(&TextureViewDescriptor { + label: Some(label), + base_array_layer: layer, + array_layer_count: Some(1), + dimension: Some(TextureViewDimension::D2), + ..Default::default() + }) + }) + .collect() +} + +/// Build a vector of first-call latches (one per layer of `texture`) all set +/// to `initial`. Used to back per-layer attachment access in +/// [`ColorAttachment::per_layer_first_call`] and +/// [`DepthAttachment::per_layer_first_call`]. +fn init_per_layer_first_call(texture: &Texture, initial: bool) -> Vec { + (0..texture.depth_or_array_layers()) + .map(|_| AtomicBool::new(initial)) + .collect() +} + /// A wrapper for a [`TextureView`] that is used as a depth-only [`RenderPassDepthStencilAttachment`]. #[derive(Clone)] pub struct DepthAttachment { pub view: TextureView, + /// Underlying multi-layer texture handle, populated only when constructed + /// via [`Self::new_multi_layer`]. Required by + /// [`Self::get_attachment_for_layer`] to synthesize per-layer `D2` views. + multi_layer_texture: Option, clear_value: Option, is_first_call: Arc, + per_layer_views: Arc>>, + /// One first-call latch per layer of [`Self::multi_layer_texture`], + /// populated lazily on first per-layer attachment access. See + /// [`ColorAttachment::per_layer_first_call`] for rationale. + per_layer_first_call: Arc>>, } impl DepthAttachment { pub fn new(view: TextureView, clear_value: Option) -> Self { Self { view, + multi_layer_texture: None, clear_value, is_first_call: Arc::new(AtomicBool::new(clear_value.is_some())), + per_layer_views: Arc::new(OnceLock::new()), + per_layer_first_call: Arc::new(OnceLock::new()), + } + } + + /// Construct a depth attachment backed by a multi-layer texture, enabling + /// per-layer access via [`Self::get_attachment_for_layer`]. `view` is the + /// default (multi-layer) view used by [`Self::get_attachment`] — typically + /// `texture.default_view`. + pub fn new_multi_layer( + texture: Texture, + view: TextureView, + clear_value: Option, + ) -> Self { + Self { + view, + multi_layer_texture: Some(texture), + clear_value, + is_first_call: Arc::new(AtomicBool::new(clear_value.is_some())), + per_layer_views: Arc::new(OnceLock::new()), + per_layer_first_call: Arc::new(OnceLock::new()), } } @@ -125,10 +284,65 @@ impl DepthAttachment { } } + /// Get an attachment targeting a single layer of the underlying multi-layer + /// depth texture. Used by per-eye dispatch in the prepass / deferred + /// render-graph nodes under multiview. + /// + /// Panics if this attachment was not constructed via + /// [`Self::new_multi_layer`]. + pub fn get_attachment_for_layer( + &self, + layer: u32, + store: StoreOp, + ) -> RenderPassDepthStencilAttachment<'_> { + let texture = self.multi_layer_texture.as_ref().expect( + "DepthAttachment::get_attachment_for_layer requires the attachment \ + to be constructed with DepthAttachment::new_multi_layer so the \ + underlying multi-layer texture handle is available", + ); + let per_layer = self + .per_layer_views + .get_or_init(|| build_per_layer_d2_views(texture, "depth_attachment_layer_view")); + + // Mark the global latch as touched so any subsequent legacy + // `get_attachment` call (e.g., main opaque/transparent pass after the + // prepass per-eye loop) loads instead of re-clearing the depth that + // was just written per layer. Seed the per-layer slots from the + // CURRENT global state so a consumer running after an earlier pass + // already flipped the global (e.g. a main-pass consumer after the + // prepass per-eye loop) sees `false` on its first per-layer access. + let initial = self + .is_first_call + .fetch_and(store != StoreOp::Store, Ordering::Relaxed); + let per_layer_first = self.per_layer_first_call.get_or_init(|| { + init_per_layer_first_call(texture, initial && self.clear_value.is_some()) + }); + let first_call = per_layer_first[layer as usize] + .fetch_and(store != StoreOp::Store, Ordering::Relaxed); + + RenderPassDepthStencilAttachment { + view: &per_layer[layer as usize], + depth_ops: Some(Operations { + load: if first_call { + LoadOp::Clear(self.clear_value.unwrap()) + } else { + LoadOp::Load + }, + store, + }), + stencil_ops: None, + } + } + /// Marks this depth attachment as unused this frame so that it'll be /// cleared at first use. pub fn prepare_for_new_frame(&self) { self.is_first_call.store(true, Ordering::Relaxed); + if let Some(per_layer) = self.per_layer_first_call.get() { + for slot in per_layer { + slot.store(true, Ordering::Relaxed); + } + } } } diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index ddabb60e7d408..d9d5f0598a109 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -15,7 +15,9 @@ use crate::{ occlusion_culling::OcclusionCulling, render_asset::RenderAssets, render_phase::ViewRangefinder3d, - render_resource::{DynamicUniformBuffer, ShaderType, Texture, TextureView}, + render_resource::{ + DynamicArrayIndex, DynamicArrayUniformBuffer, ShaderType, Texture, TextureView, + }, renderer::{RenderDevice, RenderQueue}, sync_world::MainEntity, texture::{ @@ -37,6 +39,7 @@ use bevy_render_macros::ExtractComponent; use bevy_shader::load_shader_library; use bevy_transform::components::GlobalTransform; use core::{ + num::NonZeroU32, ops::Range, sync::atomic::{AtomicUsize, Ordering}, }; @@ -390,6 +393,30 @@ impl ExtractedView { } } +/// Per-layer view data for a camera with a [`Multiview`](bevy_camera::Multiview) +/// component, extracted to the render world. +/// +/// Sits alongside [`ExtractedView`] on a multiview camera's render-world +/// entity. The companion [`ExtractedView`] still holds the camera's "head" +/// pose and is used for sort-distance, frustum culling, and other view-level +/// decisions. Per-eye data is read from this component when packing the view +/// uniform array. +#[derive(Component, Clone, Debug)] +pub struct ExtractedMultiview { + /// One entry per layer of the camera's render target texture array. + pub subviews: Vec, +} + +/// Per-layer transform and projection for an [`ExtractedMultiview`]. +#[derive(Clone, Debug)] +pub struct ExtractedSubview { + /// World-space transform of this view (camera's `world_from_view` + /// composed with the subview's per-eye `view_from_camera`). + pub world_from_view: GlobalTransform, + /// Per-eye projection (`clip <- view`). + pub clip_from_view: Mat4, +} + /// Configures filmic color grading parameters to adjust the image appearance. /// /// Color grading is applied just before tonemapping for a given @@ -670,15 +697,15 @@ pub struct ViewUniform { #[derive(Resource)] pub struct ViewUniforms { - pub uniforms: DynamicUniformBuffer, + pub uniforms: DynamicArrayUniformBuffer, } impl FromWorld for ViewUniforms { fn from_world(world: &mut World) -> Self { - let mut uniforms = DynamicUniformBuffer::default(); + let render_device = world.resource::(); + let mut uniforms = DynamicArrayUniformBuffer::new(&render_device.limits()); uniforms.set_label(Some("view_uniforms_buffer")); - let render_device = world.resource::(); if render_device.limits().max_storage_buffers_per_shader_stage > 0 { uniforms.add_usages(BufferUsages::STORAGE); } @@ -699,6 +726,11 @@ pub struct ViewTarget { /// 0 represents `main_textures.a`, 1 represents `main_textures.b` /// This is shared across view targets with the same render target main_texture: Arc, + /// Number of layers in the main texture's underlying array. Always at + /// least 1; greater than 1 only for cameras with a + /// [`Multiview`](bevy_camera::Multiview) component (one layer per + /// subview). Used by [`Self::multiview_count`]. + main_texture_array_layers: u32, /// The final output attachment this view will present to, if available. out_texture: Option, /// Color space of values stored in the main texture (for blit conversion to output) @@ -830,6 +862,18 @@ impl ViewTarget { } } + /// Per-eye color attachment targeting a single layer of the underlying + /// multi-layer main texture. Used by per-eye dispatch under multiview; + /// for single-layer cameras pass `layer = 0` (byte-identical to + /// [`Self::get_color_attachment`]). + pub fn get_color_attachment_for_layer(&self, layer: u32) -> RenderPassColorAttachment<'_> { + if self.main_texture.load(Ordering::SeqCst) == 0 { + self.main_textures.a.get_attachment_for_layer(layer) + } else { + self.main_textures.b.get_attachment_for_layer(layer) + } + } + /// Retrieve this target's "unsampled" main texture's color attachment. pub fn get_unsampled_color_attachment(&self) -> RenderPassColorAttachment<'_> { if self.main_texture.load(Ordering::SeqCst) == 0 { @@ -839,6 +883,19 @@ impl ViewTarget { } } + /// Per-eye counterpart to [`Self::get_unsampled_color_attachment`]. See + /// [`Self::get_color_attachment_for_layer`]. + pub fn get_unsampled_color_attachment_for_layer( + &self, + layer: u32, + ) -> RenderPassColorAttachment<'_> { + if self.main_texture.load(Ordering::SeqCst) == 0 { + self.main_textures.a.get_unsampled_attachment_for_layer(layer) + } else { + self.main_textures.b.get_unsampled_attachment_for_layer(layer) + } + } + /// The "main" unsampled texture. pub fn main_texture(&self) -> &Texture { if self.main_texture.load(Ordering::SeqCst) == 0 { @@ -913,6 +970,19 @@ impl ViewTarget { self.main_texture_format } + /// Number of subviews packed into the main texture's array layers when + /// this view targets a multiview camera, or `None` for a regular + /// single-view camera (1 layer). + /// + /// Useful as a render-side, frame-stable source for the multiview view + /// count when allocating downstream resources (e.g. the + /// `MAX_VIEW_COUNT` shader def) before the view-uniform buffer's + /// capacity has been resolved. + #[inline] + pub fn multiview_count(&self) -> Option { + NonZeroU32::new(self.main_texture_array_layers).filter(|n| n.get() > 1) + } + /// The final texture this view will render to. #[inline] pub fn out_texture(&self) -> Option<&TextureView> { @@ -980,9 +1050,14 @@ pub struct ViewDepthTexture { impl ViewDepthTexture { pub fn new(texture: CachedTexture, clear_value: Option) -> Self { + let attachment = DepthAttachment::new_multi_layer( + texture.texture.clone(), + texture.default_view.clone(), + clear_value, + ); Self { texture: texture.texture, - attachment: DepthAttachment::new(texture.default_view, clear_value), + attachment, } } @@ -990,6 +1065,17 @@ impl ViewDepthTexture { self.attachment.get_attachment(store) } + /// Per-eye depth attachment targeting a single layer of the underlying + /// multi-layer texture. Used by per-eye dispatch in the prepass and + /// deferred render-graph nodes under multiview. + pub fn get_attachment_for_layer( + &self, + layer: u32, + store: StoreOp, + ) -> RenderPassDepthStencilAttachment<'_> { + self.attachment.get_attachment_for_layer(layer, store) + } + pub fn view(&self) -> &TextureView { &self.attachment.view } @@ -1004,6 +1090,7 @@ pub fn prepare_view_uniforms( Entity, Option<&ExtractedCamera>, &ExtractedView, + Option<&ExtractedMultiview>, Option<&Frustum>, Option<&TemporalJitter>, Option<&MipBias>, @@ -1012,19 +1099,20 @@ pub fn prepare_view_uniforms( frame_count: Res, shadow_lod_origin: Option>, ) { - let view_iter = views.iter(); - let view_count = view_iter.len(); - let Some(mut writer) = - view_uniforms - .uniforms - .get_writer(view_count, &render_device, &render_queue) - else { - return; - }; + view_uniforms.uniforms.clear(); + + // Pass 1: build each view's array of subview uniforms and stage them. + // + // `DynamicArrayUniformBuffer`'s offset alignment depends on the longest + // queued array, so we can't resolve `ViewUniformOffset`s until every + // view has been pushed and `finish_queuing` has run. We collect + // `(entity, array_index)` here and fix up offsets in pass 2. + let mut per_entity: Vec<(Entity, DynamicArrayIndex)> = Vec::with_capacity(views.iter().len()); for ( entity, extracted_camera, extracted_view, + extracted_multiview, frustum, temporal_jitter, mip_bias, @@ -1038,60 +1126,45 @@ pub fn prepare_view_uniforms( main_pass_viewport.w = resolution_override.0.y as f32; } - let unjittered_projection = extracted_view.clip_from_view; - let mut clip_from_view = unjittered_projection; - - if let Some(temporal_jitter) = temporal_jitter { - temporal_jitter.jitter_projection(&mut clip_from_view, main_pass_viewport.zw()); - } - - let view_from_clip = clip_from_view.inverse(); - let world_from_view = extracted_view.world_from_view.to_matrix(); - let view_from_world = world_from_view.inverse(); - - let clip_from_world = if temporal_jitter.is_some() { - clip_from_view * view_from_world - } else { - extracted_view - .clip_from_world - .unwrap_or_else(|| clip_from_view * view_from_world) - }; - - // Map Frustum type to shader array, 6> + // Map Frustum type to shader array, 6>. let frustum = frustum .map(|frustum| frustum.half_spaces.map(|h| h.normal_d())) .unwrap_or([Vec4::ZERO; 6]); // Determine the position of the camera used for resolving visibility - // ranges (LODs). + // ranges (LODs). For multiview cameras this is intentionally the + // *head* position, not any individual eye, so LODs don't flicker + // between eyes. let lod_view_world_position = match (&extracted_camera, &shadow_lod_origin) { (Some(_), _) | (None, None) => { // If we're rendering a camera directly (i.e. we're not - // rendering a shadow map), we use this camera's position as the - // LOD view position. + // rendering a shadow map), we use this camera's position as + // the LOD view position. extracted_view.world_from_view.translation() } (None, Some(shadow_lod_origin)) if extracted_view.retained_view_entity.auxiliary_entity == MainEntity::from(Entity::PLACEHOLDER) => { - // If this is a shadow map not associated with a camera (a point - // light or spot light shadow map), use the shadow LOD origin. + // If this is a shadow map not associated with a camera (a + // point light or spot light shadow map), use the shadow LOD + // origin. shadow_lod_origin.0 } (None, Some(shadow_lod_origin)) => { - // Otherwise, if we're rendering a shadow map that is associated - // with a camera (i.e. a directional light shadow map, at - // present), we use the position of that camera as the LOD view - // position. This ensures that each rendered object has a shadow - // and that no invisible objects have shadows. + // Otherwise, if we're rendering a shadow map that is + // associated with a camera (i.e. a directional light shadow + // map, at present), we use the position of that camera as + // the LOD view position. This ensures that each rendered + // object has a shadow and that no invisible objects have + // shadows. match views.get( extracted_view .retained_view_entity .auxiliary_entity .entity(), ) { - Ok((_, _, camera_view, _, _, _, _)) => { + Ok((_, _, camera_view, _, _, _, _, _)) => { camera_view.world_from_view.translation() } Err(_) => shadow_lod_origin.0, @@ -1099,30 +1172,120 @@ pub fn prepare_view_uniforms( } }; - let view_uniforms = ViewUniformOffset { - offset: writer.write(&ViewUniform { - clip_from_world, - unjittered_clip_from_world: unjittered_projection * view_from_world, - world_from_clip: world_from_view * view_from_clip, - world_from_view, - view_from_world, - clip_from_view, - view_from_clip, - world_position: extracted_view.world_from_view.translation(), - exposure: extracted_camera - .map(|c| c.exposure) - .unwrap_or_else(|| Exposure::default().exposure()), + // Per-subview uniforms. Non-multiview cameras produce a single- + // element array; multiview cameras produce one element per layer. + let array_uniforms: Vec = if let Some(multiview) = extracted_multiview { + multiview + .subviews + .iter() + .map(|s| { + build_view_uniform( + s.clip_from_view, + s.world_from_view, + extracted_view, + extracted_camera, + temporal_jitter, + mip_bias, + viewport, + main_pass_viewport, + frustum, + lod_view_world_position, + frame_count.0, + ) + }) + .collect() + } else { + vec![build_view_uniform( + extracted_view.clip_from_view, + extracted_view.world_from_view, + extracted_view, + extracted_camera, + temporal_jitter, + mip_bias, viewport, main_pass_viewport, frustum, lod_view_world_position, - color_grading: extracted_view.color_grading.clone().into(), - mip_bias: mip_bias.unwrap_or(&MipBias(0.0)).0, - frame_count: frame_count.0, - }), + frame_count.0, + )] }; - commands.entity(entity).insert(view_uniforms); + let array_index = view_uniforms.uniforms.push_array(array_uniforms); + per_entity.push((entity, array_index)); + } + + view_uniforms.uniforms.finish_queuing(); + view_uniforms + .uniforms + .write_buffer(&render_device, &render_queue); + + // Pass 2: attach the resolved offset to each entity. + for (entity, array_index) in per_entity { + let offset = view_uniforms.uniforms.get_array_offset(array_index); + commands + .entity(entity) + .insert(ViewUniformOffset { offset }); + } +} + +/// Builds the [`ViewUniform`] for a single view layer. +/// +/// Pulled out of [`prepare_view_uniforms`] so multiview cameras can call it +/// once per subview while sharing the camera-level computations +/// (`viewport`, `frustum`, `lod_view_world_position`, etc.) between layers. +fn build_view_uniform( + clip_from_view: Mat4, + world_from_view: GlobalTransform, + extracted_view: &ExtractedView, + extracted_camera: Option<&ExtractedCamera>, + temporal_jitter: Option<&TemporalJitter>, + mip_bias: Option<&MipBias>, + viewport: Vec4, + main_pass_viewport: Vec4, + frustum: [Vec4; 6], + lod_view_world_position: Vec3, + frame_count: u32, +) -> ViewUniform { + let unjittered_projection = clip_from_view; + let mut clip_from_view = unjittered_projection; + if let Some(temporal_jitter) = temporal_jitter { + temporal_jitter.jitter_projection(&mut clip_from_view, main_pass_viewport.zw()); + } + let view_from_clip = clip_from_view.inverse(); + let world_from_view_mat = world_from_view.to_matrix(); + let view_from_world = world_from_view_mat.inverse(); + + // The `extracted_view.clip_from_world` override is honored when set + // (e.g. for mirror cameras). For multiview subviews it represents the + // head pose, not this eye, so combining the override with multiview is + // undefined; non-multiview is the normal case and works as before. + let clip_from_world = if temporal_jitter.is_some() { + clip_from_view * view_from_world + } else { + extracted_view + .clip_from_world + .unwrap_or_else(|| clip_from_view * view_from_world) + }; + + ViewUniform { + clip_from_world, + unjittered_clip_from_world: unjittered_projection * view_from_world, + world_from_clip: world_from_view_mat * view_from_clip, + world_from_view: world_from_view_mat, + view_from_world, + clip_from_view, + view_from_clip, + world_position: world_from_view.translation(), + exposure: extracted_camera + .map(|c| c.exposure) + .unwrap_or_else(|| Exposure::default().exposure()), + viewport, + main_pass_viewport, + frustum, + lod_view_world_position, + color_grading: extracted_view.color_grading.clone().into(), + mip_bias: mip_bias.unwrap_or(&MipBias(0.0)).0, + frame_count, } } @@ -1194,6 +1357,10 @@ type MainTextureKey = ( TextureUsages, TextureFormat, Msaa, + // Layer count. Multiview cameras need a multi-layer texture array; + // non-multiview cameras get 1. Keyed so cameras with different layer + // counts don't share a cache slot. + u32, ); pub fn prepare_view_targets( @@ -1205,6 +1372,7 @@ pub fn prepare_view_targets( Entity, &ExtractedCamera, &ExtractedView, + Option<&ExtractedMultiview>, &CameraMainTextureUsages, &Msaa, )>, @@ -1214,7 +1382,7 @@ pub fn prepare_view_targets( main_texture_atomics.retain(|_, weak| weak.strong_count() > 0); let mut textures = >::default(); - for (entity, camera, view, texture_usage, msaa) in cameras.iter() { + for (entity, camera, view, multiview, texture_usage, msaa) in cameras.iter() { let Some(target_size) = camera.physical_target_size else { // If we don't have a target size, we can't create the main texture and have to bail commands.entity(entity).try_remove::(); @@ -1250,18 +1418,31 @@ pub fn prepare_view_targets( Some(CompositingSpace::Linear) | None => LinearRgba::from(color).into(), }); + // For multiview cameras, allocate the main texture as a texture array + // with one layer per subview. Non-multiview cameras stay 1-layer. + // Extraction (in `extract_cameras`) clamps subview count to + // `MAX_VIEW_COUNT` (32) and skips inserting `ExtractedMultiview` for + // empty view sets, so casting `len()` to `u32` here is always safe. + let view_count: u32 = multiview.map(|m| m.subviews.len() as u32).unwrap_or(1); + let mut texture_size = target_size.to_extents(); + texture_size.depth_or_array_layers = view_count; + let key: MainTextureKey = ( camera.target.clone(), texture_usage.0, main_texture_format, *msaa, + view_count, ); let (a, b, sampled, main_texture) = textures.entry(key.clone()).or_insert_with(|| { let descriptor = TextureDescriptor { label: None, - size: target_size.to_extents(), + size: texture_size, mip_level_count: 1, sample_count: 1, + // Keep D2 even for multi-layer arrays; `TextureDimension` is + // texture-storage-side. The view-side `D2Array` binding is a + // later concern (shader infrastructure layer). dimension: TextureDimension::D2, format: main_texture_format, usage: texture_usage.0, @@ -1290,7 +1471,7 @@ pub fn prepare_view_targets( &render_device, TextureDescriptor { label: Some("main_texture_sampled"), - size: target_size.to_extents(), + size: texture_size, mip_level_count: 1, sample_count: msaa.samples(), dimension: TextureDimension::D2, @@ -1329,6 +1510,7 @@ pub fn prepare_view_targets( main_texture: main_textures.main_texture.clone(), main_textures, main_texture_format, + main_texture_array_layers: view_count, out_texture: out_attachment.cloned(), compositing_space: camera.compositing_space, });