Skip to main content

oxiui_render_wgpu/gpu/
stencil.rs

1//! Stencil-buffer clipping for non-rectangular clip paths.
2//!
3//! Rectangular scissors are already handled by the hardware `set_scissor_rect`
4//! API (see `exec.rs` and `geometry.rs`).  This module provides stencil-based
5//! clipping for **non-rectangular** shapes: rounded-rectangle clip masks,
6//! arbitrary path clip masks, and ellipses.
7//!
8//! # Strategy
9//!
10//! 1. Create a **depth + stencil** texture (`Depth24PlusStencil8`) alongside the
11//!    colour target.
12//! 2. A *stencil-fill pass* renders the clip geometry into the stencil buffer
13//!    (colour writes disabled) with `StencilOperation::Replace`.  The reference
14//!    value is 1.
15//! 3. The *content pass* renders normally but with a stencil test: only pixels
16//!    where the stencil value equals 1 pass.
17//! 4. A *stencil-clear pass* resets the stencil to 0 when the clip is popped.
18//!
19//! # Limitations
20//!
21//! - Nested non-rectangular clips are supported by incrementing the stencil
22//!   reference value up to 255 (hardware limit).
23//! - Interaction with MSAA: the depth/stencil texture must use the same
24//!   `sample_count` as the colour target.
25
26use oxiui_core::UiError;
27
28use crate::gpu::device::TARGET_FORMAT;
29
30/// The depth+stencil format used for stencil-based clipping.
31pub const DEPTH_STENCIL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
32
33// ── StencilTarget ─────────────────────────────────────────────────────────────
34
35/// Owns the depth+stencil texture and view for one render target.
36///
37/// Create one `StencilTarget` per `GpuContext` / `RenderTarget`; it must
38/// have the same `width`, `height`, and `sample_count`.
39pub struct StencilTarget {
40    /// The depth+stencil texture.
41    pub texture: wgpu::Texture,
42    /// View over the depth+stencil texture.
43    pub view: wgpu::TextureView,
44    /// Target width (must match the colour texture).
45    pub width: u32,
46    /// Target height (must match the colour texture).
47    pub height: u32,
48    /// MSAA sample count (must match the colour pipeline).
49    pub sample_count: u32,
50}
51
52impl StencilTarget {
53    /// Create a depth+stencil target for a colour target of `width × height`
54    /// pixels with the given MSAA `sample_count`.
55    ///
56    /// # Errors
57    ///
58    /// Returns [`UiError::Unsupported`] if `width` or `height` is zero.
59    pub fn new(
60        device: &wgpu::Device,
61        width: u32,
62        height: u32,
63        sample_count: u32,
64    ) -> Result<Self, UiError> {
65        if width == 0 || height == 0 {
66            return Err(UiError::Unsupported(
67                "StencilTarget dimensions must be non-zero".to_string(),
68            ));
69        }
70        let sc = sample_count.max(1);
71        let texture = device.create_texture(&wgpu::TextureDescriptor {
72            label: Some("oxiui-render-wgpu stencil target"),
73            size: wgpu::Extent3d {
74                width,
75                height,
76                depth_or_array_layers: 1,
77            },
78            mip_level_count: 1,
79            sample_count: sc,
80            dimension: wgpu::TextureDimension::D2,
81            format: DEPTH_STENCIL_FORMAT,
82            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
83            view_formats: &[],
84        });
85        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
86        Ok(Self {
87            texture,
88            view,
89            width,
90            height,
91            sample_count: sc,
92        })
93    }
94
95    /// Resize the stencil target.
96    ///
97    /// Recreates the GPU texture; existing views become invalid.
98    pub fn resize(
99        &mut self,
100        device: &wgpu::Device,
101        new_width: u32,
102        new_height: u32,
103    ) -> Result<(), UiError> {
104        *self = Self::new(device, new_width, new_height, self.sample_count)?;
105        Ok(())
106    }
107}
108
109// ── StencilPipeline ───────────────────────────────────────────────────────────
110
111/// A render pipeline variant that writes to the stencil buffer.
112///
113/// The pipeline outputs *no* colour (colour write mask = NONE) and uses
114/// `StencilOperation::Replace` to write the reference value on every fragment
115/// that passes the stencil test.
116///
117/// Two variants are available:
118/// - `write` — writes reference value on stencil pass (populates clip mask).
119/// - `clear` — writes 0 on every fragment (clears clip mask).
120pub struct StencilWritePipeline {
121    /// Pipeline that writes the reference value into the stencil buffer.
122    pub write: wgpu::RenderPipeline,
123    /// Pipeline that clears (zeroes) the stencil buffer by rendering a fullscreen quad.
124    pub clear: wgpu::RenderPipeline,
125    /// Globals bind group layout (viewport uniform).
126    pub globals_layout: wgpu::BindGroupLayout,
127}
128
129impl StencilWritePipeline {
130    /// Build the stencil-write pipeline.
131    ///
132    /// `sample_count` must match the colour target and `StencilTarget`.
133    pub fn new(device: &wgpu::Device, sample_count: u32) -> Self {
134        // Reuse the solid shader — we only need the vertex stage.
135        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
136            label: Some("oxiui-render-wgpu stencil solid shader"),
137            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/solid.wgsl").into()),
138        });
139
140        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
141            label: Some("oxiui-render-wgpu stencil globals layout"),
142            entries: &[wgpu::BindGroupLayoutEntry {
143                binding: 0,
144                visibility: wgpu::ShaderStages::VERTEX,
145                ty: wgpu::BindingType::Buffer {
146                    ty: wgpu::BufferBindingType::Uniform,
147                    has_dynamic_offset: false,
148                    min_binding_size: None,
149                },
150                count: None,
151            }],
152        });
153
154        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
155            label: Some("oxiui-render-wgpu stencil pipeline layout"),
156            bind_group_layouts: &[Some(&globals_layout)],
157            immediate_size: 0,
158        });
159
160        // Stencil state for the write pipeline: always replace with reference.
161        let write_stencil_face = wgpu::StencilFaceState {
162            compare: wgpu::CompareFunction::Always,
163            fail_op: wgpu::StencilOperation::Keep,
164            depth_fail_op: wgpu::StencilOperation::Keep,
165            pass_op: wgpu::StencilOperation::Replace,
166        };
167
168        // Vertex attribute layout mirrors Vertex (56 bytes).
169        let attrs = vertex_attrs_56();
170        let vertex_layout = wgpu::VertexBufferLayout {
171            array_stride: 56,
172            step_mode: wgpu::VertexStepMode::Vertex,
173            attributes: &attrs,
174        };
175
176        let write = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
177            label: Some("oxiui-render-wgpu stencil write pipeline"),
178            layout: Some(&pipeline_layout),
179            vertex: wgpu::VertexState {
180                module: &shader,
181                entry_point: Some("vs_main"),
182                buffers: std::slice::from_ref(&vertex_layout),
183                compilation_options: wgpu::PipelineCompilationOptions::default(),
184            },
185            fragment: Some(wgpu::FragmentState {
186                module: &shader,
187                entry_point: Some("fs_main"),
188                targets: &[Some(wgpu::ColorTargetState {
189                    format: TARGET_FORMAT,
190                    blend: None,
191                    write_mask: wgpu::ColorWrites::empty(), // no colour output
192                })],
193                compilation_options: wgpu::PipelineCompilationOptions::default(),
194            }),
195            primitive: wgpu::PrimitiveState {
196                topology: wgpu::PrimitiveTopology::TriangleList,
197                strip_index_format: None,
198                front_face: wgpu::FrontFace::Ccw,
199                cull_mode: None,
200                unclipped_depth: false,
201                polygon_mode: wgpu::PolygonMode::Fill,
202                conservative: false,
203            },
204            depth_stencil: Some(wgpu::DepthStencilState {
205                format: DEPTH_STENCIL_FORMAT,
206                depth_write_enabled: Some(false),
207                depth_compare: Some(wgpu::CompareFunction::Always),
208                stencil: wgpu::StencilState {
209                    front: write_stencil_face,
210                    back: write_stencil_face,
211                    read_mask: 0xFF,
212                    write_mask: 0xFF,
213                },
214                bias: wgpu::DepthBiasState::default(),
215            }),
216            multisample: wgpu::MultisampleState {
217                count: sample_count,
218                mask: !0,
219                alpha_to_coverage_enabled: false,
220            },
221            multiview_mask: None,
222            cache: None,
223        });
224
225        // Clear pipeline: zero out stencil.
226        let clear_stencil_face = wgpu::StencilFaceState {
227            compare: wgpu::CompareFunction::Always,
228            fail_op: wgpu::StencilOperation::Zero,
229            depth_fail_op: wgpu::StencilOperation::Zero,
230            pass_op: wgpu::StencilOperation::Zero,
231        };
232
233        let clear = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
234            label: Some("oxiui-render-wgpu stencil clear pipeline"),
235            layout: Some(&pipeline_layout),
236            vertex: wgpu::VertexState {
237                module: &shader,
238                entry_point: Some("vs_main"),
239                buffers: &[vertex_layout],
240                compilation_options: wgpu::PipelineCompilationOptions::default(),
241            },
242            fragment: Some(wgpu::FragmentState {
243                module: &shader,
244                entry_point: Some("fs_main"),
245                targets: &[Some(wgpu::ColorTargetState {
246                    format: TARGET_FORMAT,
247                    blend: None,
248                    write_mask: wgpu::ColorWrites::empty(),
249                })],
250                compilation_options: wgpu::PipelineCompilationOptions::default(),
251            }),
252            primitive: wgpu::PrimitiveState {
253                topology: wgpu::PrimitiveTopology::TriangleList,
254                strip_index_format: None,
255                front_face: wgpu::FrontFace::Ccw,
256                cull_mode: None,
257                unclipped_depth: false,
258                polygon_mode: wgpu::PolygonMode::Fill,
259                conservative: false,
260            },
261            depth_stencil: Some(wgpu::DepthStencilState {
262                format: DEPTH_STENCIL_FORMAT,
263                depth_write_enabled: Some(false),
264                depth_compare: Some(wgpu::CompareFunction::Always),
265                stencil: wgpu::StencilState {
266                    front: clear_stencil_face,
267                    back: clear_stencil_face,
268                    read_mask: 0xFF,
269                    write_mask: 0xFF,
270                },
271                bias: wgpu::DepthBiasState::default(),
272            }),
273            multisample: wgpu::MultisampleState {
274                count: sample_count,
275                mask: !0,
276                alpha_to_coverage_enabled: false,
277            },
278            multiview_mask: None,
279            cache: None,
280        });
281
282        Self {
283            write,
284            clear,
285            globals_layout,
286        }
287    }
288}
289
290/// Build the 56-byte vertex attribute array for the solid shader.
291fn vertex_attrs_56() -> [wgpu::VertexAttribute; 7] {
292    [
293        wgpu::VertexAttribute {
294            format: wgpu::VertexFormat::Float32x2,
295            offset: 0,
296            shader_location: 0,
297        },
298        wgpu::VertexAttribute {
299            format: wgpu::VertexFormat::Float32x4,
300            offset: 8,
301            shader_location: 1,
302        },
303        wgpu::VertexAttribute {
304            format: wgpu::VertexFormat::Float32x2,
305            offset: 24,
306            shader_location: 2,
307        },
308        wgpu::VertexAttribute {
309            format: wgpu::VertexFormat::Float32x2,
310            offset: 32,
311            shader_location: 3,
312        },
313        wgpu::VertexAttribute {
314            format: wgpu::VertexFormat::Float32,
315            offset: 40,
316            shader_location: 4,
317        },
318        wgpu::VertexAttribute {
319            format: wgpu::VertexFormat::Float32,
320            offset: 44,
321            shader_location: 5,
322        },
323        wgpu::VertexAttribute {
324            format: wgpu::VertexFormat::Float32x2,
325            offset: 48,
326            shader_location: 6,
327        },
328    ]
329}
330
331// ── StencilClipState ──────────────────────────────────────────────────────────
332
333/// Tracks the current stencil clip depth.
334///
335/// Each `push_stencil_clip` increments the reference value (max 254);
336/// each `pop_stencil_clip` decrements it.
337#[derive(Clone, Debug, Default)]
338pub struct StencilClipState {
339    depth: u8,
340}
341
342impl StencilClipState {
343    /// Current stencil reference value.  Draw passes should use this as the
344    /// stencil compare reference.
345    pub fn reference(&self) -> u32 {
346        u32::from(self.depth)
347    }
348
349    /// Increment the clip depth (called when pushing a non-rectangular clip).
350    /// Returns the new reference value, or `None` if the depth is already at
351    /// the maximum (254).
352    pub fn push(&mut self) -> Option<u32> {
353        if self.depth >= 254 {
354            return None;
355        }
356        self.depth += 1;
357        Some(u32::from(self.depth))
358    }
359
360    /// Decrement the clip depth (called when popping a non-rectangular clip).
361    pub fn pop(&mut self) {
362        self.depth = self.depth.saturating_sub(1);
363    }
364
365    /// Reset the clip depth to zero.
366    pub fn reset(&mut self) {
367        self.depth = 0;
368    }
369}
370
371// ── Tests ─────────────────────────────────────────────────────────────────────
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use oxiui_core::UiError;
377
378    fn try_device() -> Option<(wgpu::Device, wgpu::Queue)> {
379        let instance = wgpu::Instance::default();
380        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
381            power_preference: wgpu::PowerPreference::default(),
382            force_fallback_adapter: false,
383            compatible_surface: None,
384        }))
385        .ok()?;
386        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
387            label: Some("stencil test device"),
388            required_features: wgpu::Features::empty(),
389            required_limits: wgpu::Limits::downlevel_defaults(),
390            memory_hints: wgpu::MemoryHints::Performance,
391            experimental_features: wgpu::ExperimentalFeatures::disabled(),
392            trace: wgpu::Trace::Off,
393        }))
394        .ok()
395    }
396
397    #[test]
398    fn stencil_clip_state_push_pop() {
399        let mut s = StencilClipState::default();
400        assert_eq!(s.reference(), 0);
401        let r = s.push().expect("first push");
402        assert_eq!(r, 1);
403        assert_eq!(s.reference(), 1);
404        s.pop();
405        assert_eq!(s.reference(), 0);
406    }
407
408    #[test]
409    fn stencil_clip_state_max_depth() {
410        let mut s = StencilClipState::default();
411        for _ in 0..254 {
412            assert!(s.push().is_some());
413        }
414        assert_eq!(s.reference(), 254);
415        assert!(s.push().is_none(), "should not exceed 254");
416    }
417
418    #[test]
419    fn stencil_clip_state_pop_at_zero_is_safe() {
420        let mut s = StencilClipState::default();
421        s.pop(); // should not panic
422        assert_eq!(s.reference(), 0);
423    }
424
425    #[test]
426    fn stencil_target_zero_dimensions_fail() {
427        let Some((device, _)) = try_device() else {
428            return;
429        };
430        assert!(matches!(
431            StencilTarget::new(&device, 0, 64, 1),
432            Err(UiError::Unsupported(_))
433        ));
434    }
435
436    #[test]
437    fn stencil_target_creates_ok() {
438        let Some((device, _)) = try_device() else {
439            return;
440        };
441        let st = StencilTarget::new(&device, 64, 64, 1).expect("create stencil target");
442        assert_eq!(st.width, 64);
443        assert_eq!(st.height, 64);
444        assert_eq!(st.sample_count, 1);
445    }
446
447    #[test]
448    fn stencil_write_pipeline_compiles() {
449        let Some((device, _)) = try_device() else {
450            return;
451        };
452        // Building the pipeline validates the shader; no panic = success.
453        let _pipeline = StencilWritePipeline::new(&device, 1);
454    }
455
456    #[test]
457    fn stencil_format_is_depth24plus_stencil8() {
458        assert_eq!(
459            DEPTH_STENCIL_FORMAT,
460            wgpu::TextureFormat::Depth24PlusStencil8
461        );
462    }
463}