Skip to main content

oxiui_render_wgpu/gpu/
shadow.rs

1//! Shadow rendering: mask → 2-pass Gaussian blur → composite onto main target.
2//!
3//! For each [`ShadowDesc`]:
4//! 1. Clear ping texture to transparent.
5//! 2. Render white rect mask of the (offset-shifted) rect into ping using the
6//!    solid pipeline.
7//! 3. Horizontal blur ping → pong.
8//! 4. Vertical blur pong → ping.
9//! 5. Composite ping (tinted by shadow colour) onto the main `target_view`
10//!    using `LoadOp::Load` (alpha blending — shadow renders *under* content
11//!    because it is composited before the solid/gradient/textured passes).
12//!
13//! When `blur_radius <= 0.5` the blur passes are skipped and the white mask is
14//! composited directly, yielding a crisp (hard-edge) shadow.
15//!
16//! Ping-pong textures are allocated once per `render_shadows` call and shared
17//! across all shadows in the draw list.
18
19use crate::gpu::buffer::{push_fullscreen_quad, push_rect_quad, BlurUniforms, CompUniforms};
20use crate::gpu::buffer::{GradientVertex, Vertex};
21use crate::gpu::device::TARGET_FORMAT;
22use crate::gpu::pipeline::{BlurPipeline, CompositePipeline, SolidPipeline};
23use oxiui_core::paint::{DrawCommand, DrawList};
24use oxiui_core::{Color, UiError};
25use wgpu::util::DeviceExt;
26
27/// Maximum Gaussian blur radius in pixels.  Clamped to bound the tap-loop
28/// iteration count (`2 * MAX_BLUR_RADIUS + 1 = 129` iterations worst-case).
29pub const MAX_BLUR_RADIUS: u32 = 64;
30
31// ── ShadowRenderStats ─────────────────────────────────────────────────────────
32
33/// Render pass and draw call counts accumulated across all shadow descriptions
34/// submitted by a single [`render_shadows`] call.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct ShadowRenderStats {
37    /// Total render passes opened across all shadow rendering.
38    pub render_passes: u32,
39    /// Total `pass.draw(...)` calls issued across all shadow rendering.
40    pub draw_calls: u32,
41}
42
43// ── ShadowDesc ────────────────────────────────────────────────────────────────
44
45/// A parsed, ready-to-render shadow.
46pub struct ShadowDesc {
47    /// The shadow rectangle in pixel space (original rect shifted by offset).
48    pub shadow_rect: oxiui_core::geometry::Rect,
49    /// Shadow colour (used as the composite tint).
50    pub color: Color,
51    /// Blur radius in pixels (0 = sharp, >0 = soft halo).
52    pub blur_radius: f32,
53}
54
55// ── ShadowPipelines ───────────────────────────────────────────────────────────
56
57/// The three GPU pipelines needed for shadow rendering.
58pub struct ShadowPipelines<'a> {
59    /// Solid pipeline (used to draw the white rect mask).
60    pub solid: &'a SolidPipeline,
61    /// Blur pipeline (separable Gaussian, horizontal and vertical).
62    pub blur: &'a BlurPipeline,
63    /// Composite pipeline (tint + alpha-blend mask onto target).
64    pub composite: &'a CompositePipeline,
65}
66
67// ── ShadowGpuState ────────────────────────────────────────────────────────────
68
69/// GPU device/queue state needed for shadow rendering.
70pub struct ShadowGpuState<'a> {
71    /// The logical GPU device.
72    pub device: &'a wgpu::Device,
73    /// The command queue.
74    pub queue: &'a wgpu::Queue,
75    /// The main frame colour view (shadow composites here).
76    /// Under MSAA this is the MSAA multisample view (the render target).
77    pub target_view: &'a wgpu::TextureView,
78    /// The MSAA resolve target for the composite pass, or `None` when no MSAA.
79    /// Under MSAA this is the single-sample `color_view`; under no MSAA it is
80    /// `None` and `target_view` is the direct render target.
81    pub resolve_target: Option<&'a wgpu::TextureView>,
82    /// The per-frame viewport globals buffer.
83    pub globals_buffer: &'a wgpu::Buffer,
84    /// The bind group for the viewport globals (group 0, solid pipeline).
85    pub globals_bind_group: &'a wgpu::BindGroup,
86    /// Viewport width in pixels.
87    pub viewport_w: u32,
88    /// Viewport height in pixels.
89    pub viewport_h: u32,
90}
91
92// ── ShadowContext ─────────────────────────────────────────────────────────────
93
94/// Internal shared state threaded through the per-shadow render passes.
95struct ShadowContext<'a> {
96    gpu: &'a ShadowGpuState<'a>,
97    pipelines: &'a ShadowPipelines<'a>,
98    linear_sampler: &'a wgpu::Sampler,
99    fs_vb: &'a wgpu::Buffer,
100    fs_count: u32,
101    texel_w: f32,
102    texel_h: f32,
103}
104
105// ── PingPong ──────────────────────────────────────────────────────────────────
106
107/// Ping-pong texture pair and their views.
108struct PingPong<'a> {
109    ping_tex: &'a wgpu::Texture,
110    ping_view: &'a wgpu::TextureView,
111    pong_tex: &'a wgpu::Texture,
112    pong_view: &'a wgpu::TextureView,
113}
114
115// ── collect_shadows ───────────────────────────────────────────────────────────
116
117/// Collect all [`DrawCommand::BoxShadow`] entries from `list` into
118/// [`ShadowDesc`] values, translating each rect by its offset.
119pub fn collect_shadows(list: &DrawList) -> Vec<ShadowDesc> {
120    list.iter()
121        .filter_map(|cmd| match cmd {
122            DrawCommand::BoxShadow {
123                rect,
124                offset,
125                blur_radius,
126                color,
127            } => {
128                let sr = oxiui_core::geometry::Rect::new(
129                    rect.left() + offset.x,
130                    rect.top() + offset.y,
131                    rect.width(),
132                    rect.height(),
133                );
134                Some(ShadowDesc {
135                    shadow_rect: sr,
136                    color: *color,
137                    blur_radius: *blur_radius,
138                })
139            }
140            _ => None,
141        })
142        .collect()
143}
144
145// ── render_shadows ────────────────────────────────────────────────────────────
146
147/// Render all `descs` shadows, compositing each onto `gpu.target_view`.
148///
149/// Ping-pong textures are allocated once for the entire batch.  Each shadow
150/// issues separate command-encoder submissions so wgpu's internal ordering
151/// guarantees sequential execution.
152///
153/// The caller must ensure the main frame encoder has NOT yet been submitted —
154/// shadows are composited onto the target *before* the solid/gradient/textured
155/// passes so content renders on top.
156///
157/// # Errors
158///
159/// Propagates any [`UiError`] from sub-passes.
160pub fn render_shadows(
161    gpu: &ShadowGpuState<'_>,
162    pipelines: &ShadowPipelines<'_>,
163    descs: &[ShadowDesc],
164) -> Result<ShadowRenderStats, UiError> {
165    if descs.is_empty() {
166        return Ok(ShadowRenderStats::default());
167    }
168
169    let vp_w = gpu.viewport_w;
170    let vp_h = gpu.viewport_h;
171
172    // ── Allocate ping-pong offscreen targets ──────────────────────────────────
173    let ping_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
174        label: Some("oxiui-render-wgpu shadow ping"),
175        size: wgpu::Extent3d {
176            width: vp_w,
177            height: vp_h,
178            depth_or_array_layers: 1,
179        },
180        mip_level_count: 1,
181        sample_count: 1,
182        dimension: wgpu::TextureDimension::D2,
183        format: TARGET_FORMAT,
184        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
185        view_formats: &[],
186    });
187    let pong_tex = gpu.device.create_texture(&wgpu::TextureDescriptor {
188        label: Some("oxiui-render-wgpu shadow pong"),
189        size: wgpu::Extent3d {
190            width: vp_w,
191            height: vp_h,
192            depth_or_array_layers: 1,
193        },
194        mip_level_count: 1,
195        sample_count: 1,
196        dimension: wgpu::TextureDimension::D2,
197        format: TARGET_FORMAT,
198        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
199        view_formats: &[],
200    });
201
202    let ping_view = ping_tex.create_view(&wgpu::TextureViewDescriptor::default());
203    let pong_view = pong_tex.create_view(&wgpu::TextureViewDescriptor::default());
204
205    // Linear sampler shared across all blur and composite passes.
206    let linear_sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
207        label: Some("oxiui-render-wgpu shadow sampler"),
208        address_mode_u: wgpu::AddressMode::ClampToEdge,
209        address_mode_v: wgpu::AddressMode::ClampToEdge,
210        address_mode_w: wgpu::AddressMode::ClampToEdge,
211        mag_filter: wgpu::FilterMode::Linear,
212        min_filter: wgpu::FilterMode::Linear,
213        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
214        lod_min_clamp: 0.0,
215        lod_max_clamp: 32.0,
216        compare: None,
217        anisotropy_clamp: 1,
218        border_color: None,
219    });
220
221    // Fullscreen quad vertex buffer — shared across all passes.
222    let mut fs_verts: Vec<GradientVertex> = Vec::new();
223    push_fullscreen_quad(&mut fs_verts, vp_w as f32, vp_h as f32);
224    let fs_vb = gpu
225        .device
226        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
227            label: Some("oxiui-render-wgpu shadow fullscreen quad"),
228            contents: bytemuck::cast_slice(&fs_verts),
229            usage: wgpu::BufferUsages::VERTEX,
230        });
231
232    let ctx = ShadowContext {
233        gpu,
234        pipelines,
235        linear_sampler: &linear_sampler,
236        fs_vb: &fs_vb,
237        fs_count: fs_verts.len() as u32,
238        texel_w: 1.0 / vp_w as f32,
239        texel_h: 1.0 / vp_h as f32,
240    };
241
242    let pp = PingPong {
243        ping_tex: &ping_tex,
244        ping_view: &ping_view,
245        pong_tex: &pong_tex,
246        pong_view: &pong_view,
247    };
248
249    let mut stats = ShadowRenderStats::default();
250    for shadow in descs {
251        let s = render_shadow_desc(&ctx, &pp, shadow)?;
252        stats.render_passes += s.render_passes;
253        stats.draw_calls += s.draw_calls;
254    }
255
256    Ok(stats)
257}
258
259// ── render_shadow_desc ────────────────────────────────────────────────────────
260
261/// Render a single [`ShadowDesc`]: mask → optional blur → composite.
262///
263/// Returns the number of render passes opened and draw calls issued.
264fn render_shadow_desc(
265    ctx: &ShadowContext<'_>,
266    pp: &PingPong<'_>,
267    shadow: &ShadowDesc,
268) -> Result<ShadowRenderStats, UiError> {
269    let sigma = shadow.blur_radius.max(1.0) / 2.0;
270    let radius = ((3.0 * sigma).ceil() as u32).min(MAX_BLUR_RADIUS) as f32;
271
272    let mut stats = ShadowRenderStats::default();
273
274    // Step 2: white rect mask into ping (clears ping first).
275    // 1 render pass, 1 draw.
276    render_mask(ctx, pp.ping_view, shadow)?;
277    stats.render_passes += 1;
278    stats.draw_calls += 1;
279
280    if shadow.blur_radius > 0.5 {
281        // Step 3: horizontal blur ping → pong. 1 render pass, 1 draw.
282        execute_blur_pass(
283            ctx,
284            pp.ping_tex,
285            pp.pong_view,
286            &BlurPassParams {
287                direction: [1.0, 0.0],
288                texel_size: [ctx.texel_w, ctx.texel_h],
289                radius,
290                sigma,
291            },
292        )?;
293        stats.render_passes += 1;
294        stats.draw_calls += 1;
295        // Step 4: vertical blur pong → ping. 1 render pass, 1 draw.
296        execute_blur_pass(
297            ctx,
298            pp.pong_tex,
299            pp.ping_view,
300            &BlurPassParams {
301                direction: [0.0, 1.0],
302                texel_size: [ctx.texel_w, ctx.texel_h],
303                radius,
304                sigma,
305            },
306        )?;
307        stats.render_passes += 1;
308        stats.draw_calls += 1;
309    }
310
311    // Step 5: composite ping onto main target. 1 render pass, 1 draw.
312    execute_composite_pass(ctx, pp.ping_tex, shadow.color)?;
313    stats.render_passes += 1;
314    stats.draw_calls += 1;
315
316    Ok(stats)
317}
318
319// ── render_mask ───────────────────────────────────────────────────────────────
320
321/// Clear ping to transparent and draw the white rect mask for `shadow`.
322fn render_mask(
323    ctx: &ShadowContext<'_>,
324    ping_view: &wgpu::TextureView,
325    shadow: &ShadowDesc,
326) -> Result<(), UiError> {
327    let sr = &shadow.shadow_rect;
328    let mut rect_verts: Vec<Vertex> = Vec::new();
329    push_rect_quad(
330        &mut rect_verts,
331        sr.left(),
332        sr.top(),
333        sr.width(),
334        sr.height(),
335        Color(255, 255, 255, 255),
336    );
337
338    let rect_vb = ctx
339        .gpu
340        .device
341        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
342            label: Some("oxiui-render-wgpu shadow mask rect"),
343            contents: bytemuck::cast_slice(&rect_verts),
344            usage: wgpu::BufferUsages::VERTEX,
345        });
346
347    let mut encoder = ctx
348        .gpu
349        .device
350        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
351            label: Some("oxiui-render-wgpu shadow mask encoder"),
352        });
353
354    {
355        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
356            label: Some("oxiui-render-wgpu shadow mask pass"),
357            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
358                view: ping_view,
359                depth_slice: None,
360                resolve_target: None,
361                ops: wgpu::Operations {
362                    load: wgpu::LoadOp::Clear(wgpu::Color {
363                        r: 0.0,
364                        g: 0.0,
365                        b: 0.0,
366                        a: 0.0,
367                    }),
368                    store: wgpu::StoreOp::Store,
369                },
370            })],
371            depth_stencil_attachment: None,
372            timestamp_writes: None,
373            occlusion_query_set: None,
374            multiview_mask: None,
375        });
376
377        pass.set_pipeline(&ctx.pipelines.solid.pipeline);
378        pass.set_bind_group(0, ctx.gpu.globals_bind_group, &[]);
379        pass.set_vertex_buffer(0, rect_vb.slice(..));
380        pass.set_scissor_rect(0, 0, ctx.gpu.viewport_w, ctx.gpu.viewport_h);
381        pass.draw(0..rect_verts.len() as u32, 0..1);
382    }
383
384    ctx.gpu.queue.submit(Some(encoder.finish()));
385    Ok(())
386}
387
388// ── BlurPassParams ────────────────────────────────────────────────────────────
389
390/// Parameters for a single separable Gaussian blur pass.
391struct BlurPassParams {
392    direction: [f32; 2],
393    texel_size: [f32; 2],
394    radius: f32,
395    sigma: f32,
396}
397
398// ── execute_blur_pass ─────────────────────────────────────────────────────────
399
400fn execute_blur_pass(
401    ctx: &ShadowContext<'_>,
402    src_tex: &wgpu::Texture,
403    dst_view: &wgpu::TextureView,
404    params: &BlurPassParams,
405) -> Result<(), UiError> {
406    let src_view = src_tex.create_view(&wgpu::TextureViewDescriptor::default());
407
408    let blur_uni = BlurUniforms {
409        direction: params.direction,
410        texel_size: params.texel_size,
411        radius: params.radius,
412        sigma: params.sigma,
413        _pad: [0.0; 2],
414    };
415
416    let blur_ub = ctx
417        .gpu
418        .device
419        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
420            label: Some("oxiui-render-wgpu blur uniform"),
421            contents: bytemuck::bytes_of(&blur_uni),
422            usage: wgpu::BufferUsages::UNIFORM,
423        });
424
425    let globals_bg = ctx
426        .gpu
427        .device
428        .create_bind_group(&wgpu::BindGroupDescriptor {
429            label: Some("oxiui-render-wgpu blur globals bg"),
430            layout: &ctx.pipelines.blur.globals_layout,
431            entries: &[wgpu::BindGroupEntry {
432                binding: 0,
433                resource: ctx.gpu.globals_buffer.as_entire_binding(),
434            }],
435        });
436
437    let src_bg = ctx
438        .gpu
439        .device
440        .create_bind_group(&wgpu::BindGroupDescriptor {
441            label: Some("oxiui-render-wgpu blur source bg"),
442            layout: &ctx.pipelines.blur.source_layout,
443            entries: &[
444                wgpu::BindGroupEntry {
445                    binding: 0,
446                    resource: wgpu::BindingResource::TextureView(&src_view),
447                },
448                wgpu::BindGroupEntry {
449                    binding: 1,
450                    resource: wgpu::BindingResource::Sampler(ctx.linear_sampler),
451                },
452                wgpu::BindGroupEntry {
453                    binding: 2,
454                    resource: blur_ub.as_entire_binding(),
455                },
456            ],
457        });
458
459    let mut encoder = ctx
460        .gpu
461        .device
462        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
463            label: Some("oxiui-render-wgpu blur encoder"),
464        });
465
466    {
467        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
468            label: Some("oxiui-render-wgpu blur pass"),
469            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
470                view: dst_view,
471                depth_slice: None,
472                resolve_target: None,
473                ops: wgpu::Operations {
474                    load: wgpu::LoadOp::Clear(wgpu::Color {
475                        r: 0.0,
476                        g: 0.0,
477                        b: 0.0,
478                        a: 0.0,
479                    }),
480                    store: wgpu::StoreOp::Store,
481                },
482            })],
483            depth_stencil_attachment: None,
484            timestamp_writes: None,
485            occlusion_query_set: None,
486            multiview_mask: None,
487        });
488
489        pass.set_pipeline(&ctx.pipelines.blur.pipeline);
490        pass.set_bind_group(0, &globals_bg, &[]);
491        pass.set_bind_group(1, &src_bg, &[]);
492        pass.set_vertex_buffer(0, ctx.fs_vb.slice(..));
493        pass.set_scissor_rect(0, 0, ctx.gpu.viewport_w, ctx.gpu.viewport_h);
494        pass.draw(0..ctx.fs_count, 0..1);
495    }
496
497    ctx.gpu.queue.submit(Some(encoder.finish()));
498    Ok(())
499}
500
501// ── execute_composite_pass ────────────────────────────────────────────────────
502
503/// Composite `mask_tex` tinted by `shadow_color` onto the main target.
504fn execute_composite_pass(
505    ctx: &ShadowContext<'_>,
506    mask_tex: &wgpu::Texture,
507    shadow_color: Color,
508) -> Result<(), UiError> {
509    let mask_view = mask_tex.create_view(&wgpu::TextureViewDescriptor::default());
510
511    let tint = [
512        shadow_color.0 as f32 / 255.0,
513        shadow_color.1 as f32 / 255.0,
514        shadow_color.2 as f32 / 255.0,
515        shadow_color.3 as f32 / 255.0,
516    ];
517
518    let comp_uni = CompUniforms {
519        tint,
520        texel_size: [ctx.texel_w, ctx.texel_h],
521        _pad: [0.0; 2],
522    };
523
524    let comp_ub = ctx
525        .gpu
526        .device
527        .create_buffer_init(&wgpu::util::BufferInitDescriptor {
528            label: Some("oxiui-render-wgpu composite uniform"),
529            contents: bytemuck::bytes_of(&comp_uni),
530            usage: wgpu::BufferUsages::UNIFORM,
531        });
532
533    let globals_bg = ctx
534        .gpu
535        .device
536        .create_bind_group(&wgpu::BindGroupDescriptor {
537            label: Some("oxiui-render-wgpu composite globals bg"),
538            layout: &ctx.pipelines.composite.globals_layout,
539            entries: &[wgpu::BindGroupEntry {
540                binding: 0,
541                resource: ctx.gpu.globals_buffer.as_entire_binding(),
542            }],
543        });
544
545    let src_bg = ctx
546        .gpu
547        .device
548        .create_bind_group(&wgpu::BindGroupDescriptor {
549            label: Some("oxiui-render-wgpu composite source bg"),
550            layout: &ctx.pipelines.composite.source_layout,
551            entries: &[
552                wgpu::BindGroupEntry {
553                    binding: 0,
554                    resource: wgpu::BindingResource::TextureView(&mask_view),
555                },
556                wgpu::BindGroupEntry {
557                    binding: 1,
558                    resource: wgpu::BindingResource::Sampler(ctx.linear_sampler),
559                },
560                wgpu::BindGroupEntry {
561                    binding: 2,
562                    resource: comp_ub.as_entire_binding(),
563                },
564            ],
565        });
566
567    let mut encoder = ctx
568        .gpu
569        .device
570        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
571            label: Some("oxiui-render-wgpu composite encoder"),
572        });
573
574    {
575        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
576            label: Some("oxiui-render-wgpu composite pass"),
577            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
578                view: ctx.gpu.target_view,
579                depth_slice: None,
580                // When MSAA is active, resolve_target holds the single-sample
581                // colour view so the resolve happens during this pass.
582                resolve_target: ctx.gpu.resolve_target,
583                ops: wgpu::Operations {
584                    // Load existing content — shadow composites on top of whatever
585                    // was already in the target (the prior clear pass).
586                    load: wgpu::LoadOp::Load,
587                    store: wgpu::StoreOp::Store,
588                },
589            })],
590            depth_stencil_attachment: None,
591            timestamp_writes: None,
592            occlusion_query_set: None,
593            multiview_mask: None,
594        });
595
596        pass.set_pipeline(&ctx.pipelines.composite.pipeline);
597        pass.set_bind_group(0, &globals_bg, &[]);
598        pass.set_bind_group(1, &src_bg, &[]);
599        pass.set_vertex_buffer(0, ctx.fs_vb.slice(..));
600        pass.set_scissor_rect(0, 0, ctx.gpu.viewport_w, ctx.gpu.viewport_h);
601        pass.draw(0..ctx.fs_count, 0..1);
602    }
603
604    ctx.gpu.queue.submit(Some(encoder.finish()));
605    Ok(())
606}