Skip to main content

bevy_react/layer/render/
mips.rs

1//! Mip-chain generation for 3D-transformed layers' capture textures.
2//!
3//! A transformed composite quad minifies its capture (tilt compresses many
4//! texels per pixel); bilinear-only sampling shimmers. Layers with the
5//! `TRANSFORM3D` promotion reason therefore allocate their sampled texture
6//! with a full mip chain ([`super::alloc_layer_slot`] /
7//! [`super::alloc_filter_slot`], keyed on the *reason* so identity↔non-identity
8//! value changes never realloc), and this module rebuilds the chain exactly
9//! when level 0 is rewritten — a cached capture keeps its mips for free.
10//!
11//! The lifecycle mirrors the filter machinery: [`prepare_layer_mips`] stages
12//! one [`MipRun`] per dirty chain (pipeline + per-level bind groups/targets —
13//! the capture pass can create no GPU resources), `ui_layer_capture_pass`
14//! replays it right after the layer's capture/filter passes, and the
15//! composite samples through the trilinear/anisotropic sampler only while the
16//! slot's `mips_valid` holds (the fallback is plain bilinear over level 0 —
17//! never a gate, never a stale mip). For filtered layers the chain builds on
18//! the **filter output ping-pong** (what the quad actually samples), not the
19//! raw capture.
20//!
21//! Each downsample level is one fullscreen-triangle pass (`mip_blit.wgsl`)
22//! sampling level `i` into level `i + 1`: linear filtering into a half-size
23//! target is a standard 2×2 box downsample, correct on premultiplied content.
24
25use bevy::asset::{AssetServer, Handle};
26use bevy::prelude::*;
27use bevy::render::render_resource::binding_types::{sampler, texture_2d};
28use bevy::render::render_resource::{
29    BindGroup, BindGroupEntries, BindGroupLayoutDescriptor, BindGroupLayoutEntries,
30    CachedRenderPipelineId, ColorTargetState, ColorWrites, FilterMode, FragmentState,
31    PipelineCache, RenderPipelineDescriptor, Sampler, SamplerBindingType, SamplerDescriptor,
32    ShaderStages, SpecializedRenderPipeline, SpecializedRenderPipelines, Texture, TextureFormat,
33    TextureSampleType, TextureView, TextureViewDescriptor, VertexState,
34};
35use bevy::render::renderer::RenderDevice;
36use bevy::shader::Shader;
37
38use super::{ExtractedUiLayers, FilterSlot, LayerTextureStore};
39
40/// The per-texture mip-chain views a mipped slot carries. Built at alloc time
41/// (the capture pass creates no views); the blit source bind groups are
42/// filled lazily at first staging (they need the pipeline's layout) and die
43/// with the slot on realloc.
44pub struct MipChain {
45    /// All-mips view — the trilinear composite sample source.
46    pub full_view: TextureView,
47    /// One single-mip view per level (`[0]` = base). Level `i + 1` is the
48    /// render target of the pass that samples level `i`.
49    pub level_views: Vec<TextureView>,
50    /// Blit source bind groups: entry `i` samples `level_views[i]`.
51    pub bind_groups: Vec<BindGroup>,
52}
53
54/// Mip levels for a texture of `size` — the full chain down to 1×1.
55pub fn mip_level_count(size: UVec2) -> u32 {
56    size.max(UVec2::ONE).max_element().ilog2() + 1
57}
58
59/// Build the per-level + full views for a texture allocated with
60/// [`mip_level_count`] levels.
61pub fn build_mip_chain(texture: &Texture, levels: u32) -> MipChain {
62    let full_view = texture.create_view(&TextureViewDescriptor {
63        label: Some("ui_layer_mips_full"),
64        ..Default::default()
65    });
66    let level_views = (0..levels)
67        .map(|level| {
68            texture.create_view(&TextureViewDescriptor {
69                label: Some("ui_layer_mip_level"),
70                base_mip_level: level,
71                mip_level_count: Some(1),
72                ..Default::default()
73            })
74        })
75        .collect();
76    MipChain {
77        full_view,
78        level_views,
79        bind_groups: Vec::new(),
80    }
81}
82
83/// The downsample-blit pipeline: one texture + sampler bind group, no
84/// uniforms (deliberately not the filter layout — that mandates the 160-byte
85/// `FilterUniforms` via `min_binding_size`).
86#[derive(Resource)]
87pub struct LayerBlitPipeline {
88    pub layout: BindGroupLayoutDescriptor,
89    /// Linear clamp-to-edge, non-mipping — each pass samples exactly one
90    /// level through a single-mip view.
91    pub sampler: Sampler,
92    pub shader: Handle<Shader>,
93}
94
95pub fn init_layer_blit_pipeline(
96    mut commands: Commands,
97    render_device: Res<RenderDevice>,
98    asset_server: Res<AssetServer>,
99) {
100    let layout = BindGroupLayoutDescriptor::new(
101        "ui_layer_blit_layout",
102        &BindGroupLayoutEntries::sequential(
103            ShaderStages::FRAGMENT,
104            (
105                texture_2d(TextureSampleType::Float { filterable: true }),
106                sampler(SamplerBindingType::Filtering),
107            ),
108        ),
109    );
110    commands.insert_resource(LayerBlitPipeline {
111        layout,
112        sampler: render_device.create_sampler(&SamplerDescriptor {
113            label: Some("ui_layer_blit_sampler"),
114            mag_filter: FilterMode::Linear,
115            min_filter: FilterMode::Linear,
116            ..Default::default()
117        }),
118        shader: bevy::asset::load_embedded_asset!(asset_server.as_ref(), "mip_blit.wgsl"),
119    });
120}
121
122#[derive(Clone, Copy, Hash, PartialEq, Eq)]
123pub struct LayerBlitPipelineKey {
124    pub target_format: TextureFormat,
125}
126
127impl SpecializedRenderPipeline for LayerBlitPipeline {
128    type Key = LayerBlitPipelineKey;
129
130    fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
131        RenderPipelineDescriptor {
132            vertex: VertexState {
133                shader: self.shader.clone(),
134                entry_point: Some("vertex".into()),
135                ..Default::default()
136            },
137            fragment: Some(FragmentState {
138                shader: self.shader.clone(),
139                entry_point: Some("fragment".into()),
140                targets: vec![Some(ColorTargetState {
141                    format: key.target_format,
142                    // Replace-write: each level is fully overwritten.
143                    blend: None,
144                    write_mask: ColorWrites::ALL,
145                })],
146                ..Default::default()
147            }),
148            layout: vec![self.layout.clone()],
149            label: Some("ui_layer_blit_pipeline".into()),
150            ..Default::default()
151        }
152    }
153}
154
155/// One staged downsample pass: bind `bind_group` (source level), render 3
156/// vertices into `target` (the next level).
157pub struct MipLevel {
158    pub bind_group: BindGroup,
159    pub target: TextureView,
160}
161
162/// A layer's staged downsample chain this frame.
163pub struct MipRun {
164    pub pipeline: CachedRenderPipelineId,
165    pub levels: Vec<MipLevel>,
166}
167
168/// Per-frame mip staging, index-aligned with [`ExtractedUiLayers::layers`].
169/// `runs[idx] = None` = no downsample work (unmipped layer, cache hit, source
170/// not ready, or pipeline still compiling — the composite then falls back to
171/// bilinear over level 0 via the slot's `mips_valid`).
172#[derive(Resource, Default)]
173pub struct LayerMipMeta {
174    pub runs: Vec<Option<MipRun>>,
175}
176
177/// Stage the downsample chain for every `wants_mips` layer whose sampled
178/// texture's mips are stale. Predictive like the filter staging: `mips_valid`
179/// flips true only when the staged run is certain to execute this frame
180/// (source complete + blit pipeline compiled); until then the composite's
181/// bilinear fallback covers.
182///
183/// Runs after `prepare_layer_textures` (slots/views exist) and
184/// `prepare_layer_filters` (`output_valid`/`output_index` decided), before
185/// `prepare_layer_composites` (its bind-group choice reads `mips_valid`).
186pub fn prepare_layer_mips(
187    extracted: Res<ExtractedUiLayers>,
188    mut store: ResMut<LayerTextureStore>,
189    pipeline: Option<Res<LayerBlitPipeline>>,
190    mut specialized: ResMut<SpecializedRenderPipelines<LayerBlitPipeline>>,
191    pipeline_cache: Res<PipelineCache>,
192    render_device: Res<RenderDevice>,
193    mut meta: ResMut<LayerMipMeta>,
194) {
195    meta.runs.clear();
196    meta.runs.resize_with(extracted.layers.len(), || None);
197    let Some(pipeline) = pipeline else {
198        return;
199    };
200    for (idx, layer) in extracted.layers.iter().enumerate() {
201        if !layer.wants_mips {
202            continue;
203        }
204        let Some(slot) = store.slots.get_mut(&layer.main_entity) else {
205            continue;
206        };
207        let pipeline_id = specialized.specialize(
208            &pipeline_cache,
209            &pipeline,
210            LayerBlitPipelineKey {
211                target_format: layer.target_format,
212            },
213        );
214        // Pick the sampled texture: the filter output ping-pong when a chain
215        // is present (that is what the composite quad samples), else the raw
216        // capture. Each carries its own chain + validity flag.
217        let (chain, mips_valid, source_valid) = if layer.chain.is_some() {
218            let Some(filter) = slot.filter.as_mut() else {
219                continue;
220            };
221            let FilterSlot {
222                mips,
223                mips_valid,
224                output_index,
225                output_valid,
226                ..
227            } = filter;
228            let Some(chain) = mips[*output_index].as_mut() else {
229                continue;
230            };
231            (chain, mips_valid, *output_valid)
232        } else {
233            let Some(chain) = slot.mips.as_mut() else {
234                continue;
235            };
236            (chain, &mut slot.mips_valid, slot.content_valid)
237        };
238        if *mips_valid {
239            continue; // Cache hit: last generation still matches level 0.
240        }
241        // Never downsample a blank/partial level 0, and never mark valid with
242        // an uncompiled pipeline (the replay would silently skip the run).
243        if !source_valid || pipeline_cache.get_render_pipeline(pipeline_id).is_none() {
244            continue;
245        }
246        if chain.bind_groups.is_empty() {
247            let layout = pipeline_cache.get_bind_group_layout(&pipeline.layout);
248            chain.bind_groups = chain
249                .level_views
250                .iter()
251                .map(|view| {
252                    render_device.create_bind_group(
253                        "ui_layer_mip_source",
254                        &layout,
255                        &BindGroupEntries::sequential((view, &pipeline.sampler)),
256                    )
257                })
258                .collect();
259        }
260        let levels = (0..chain.level_views.len().saturating_sub(1))
261            .map(|level| MipLevel {
262                bind_group: chain.bind_groups[level].clone(),
263                target: chain.level_views[level + 1].clone(),
264            })
265            .collect();
266        meta.runs[idx] = Some(MipRun {
267            pipeline: pipeline_id,
268            levels,
269        });
270        *mips_valid = true;
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn mip_level_count_covers_full_chain() {
280        assert_eq!(mip_level_count(UVec2::new(1, 1)), 1);
281        assert_eq!(mip_level_count(UVec2::new(2, 2)), 2);
282        assert_eq!(mip_level_count(UVec2::new(256, 64)), 9);
283        // Non-power-of-two rounds down (300 → 8 halvings to reach 1: 300,
284        // 150, 75, 37, 18, 9, 4, 2, 1 = 9 levels via ilog2(300)=8).
285        assert_eq!(mip_level_count(UVec2::new(300, 20)), 9);
286        // Degenerate zero clamps to one level.
287        assert_eq!(mip_level_count(UVec2::ZERO), 1);
288    }
289}