Skip to main content

viewport_lib/resources/
plugin_builders.rs

1//! Plugin-facing accessors and pipeline builders on [`ViewportGpuResources`].
2//!
3//! See [`crate::plugin_api`] for the published types these methods return.
4
5use crate::plugin_api::{
6    MaskTargetDesc, OitTargetDesc, OpaqueTargetDesc, PickTargetDesc, ShadowTargetDesc,
7    SharedBindings,
8    target_desc::{OIT_ACCUM_BLEND, OIT_REVEAL_BLEND},
9};
10use crate::resources::ViewportGpuResources;
11
12/// HDR colour format used by the scene buffer. Plugins targeting the HDR
13/// path build pipelines against this format.
14pub const HDR_COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
15
16/// Depth-stencil format shared by every scene render pass.
17pub const SCENE_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
18
19/// Shadow atlas depth format.
20pub const SHADOW_DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
21
22/// Outline-mask colour format.
23pub const MASK_COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
24
25/// Pick-id colour format.
26pub const PICK_COLOR_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R32Uint;
27
28impl ViewportGpuResources {
29    // ------------------------------------------------------------------
30    // Target descriptors and SharedBindings accessor
31    // ------------------------------------------------------------------
32
33    /// Group-0 bind layout shared by every scene pipeline. Use as group 0
34    /// when building a plugin pipeline layout.
35    pub fn shared_bindings(&self) -> SharedBindings<'_> {
36        SharedBindings {
37            group0_layout: &self.camera_bind_group_layout,
38        }
39    }
40
41    /// Render-target descriptor for the HDR opaque scene pass.
42    pub fn opaque_target_desc(&self) -> OpaqueTargetDesc {
43        OpaqueTargetDesc {
44            color_format: HDR_COLOR_FORMAT,
45            depth_format: SCENE_DEPTH_FORMAT,
46            sample_count: self.sample_count,
47        }
48    }
49
50    /// Render-target descriptor for the OIT pass (MRT: accum + reveal).
51    pub fn oit_target_desc(&self) -> OitTargetDesc {
52        OitTargetDesc {
53            accum_format: HDR_COLOR_FORMAT,
54            reveal_format: MASK_COLOR_FORMAT,
55            depth_format: SCENE_DEPTH_FORMAT,
56            accum_blend: OIT_ACCUM_BLEND,
57            reveal_blend: OIT_REVEAL_BLEND,
58            sample_count: self.sample_count,
59        }
60    }
61
62    /// Render-target descriptor for the outline-mask pass.
63    pub fn mask_target_desc(&self) -> MaskTargetDesc {
64        MaskTargetDesc {
65            color_format: MASK_COLOR_FORMAT,
66            depth_format: SCENE_DEPTH_FORMAT,
67            sample_count: 1,
68        }
69    }
70
71    /// Render-target descriptor for the pick-id pass.
72    pub fn pick_target_desc(&self) -> PickTargetDesc {
73        PickTargetDesc {
74            color_format: PICK_COLOR_FORMAT,
75            depth_format: SCENE_DEPTH_FORMAT,
76            sample_count: 1,
77        }
78    }
79
80    /// Render-target descriptor for the shadow-atlas pass.
81    pub fn shadow_target_desc(&self) -> ShadowTargetDesc {
82        ShadowTargetDesc {
83            depth_format: SHADOW_DEPTH_FORMAT,
84            sample_count: 1,
85        }
86    }
87
88    // ------------------------------------------------------------------
89    // Texture-id namespace accessors
90    // ------------------------------------------------------------------
91
92    /// Borrow the `TextureView` for a texture previously uploaded via
93    /// [`upload_texture`](Self::upload_texture) or
94    /// [`upload_normal_map`](Self::upload_normal_map).
95    ///
96    /// Returns `None` if `id` does not refer to a live texture (out of
97    /// range, or the texture was unloaded).
98    ///
99    /// Lifetime contract: the returned view is valid until the texture
100    /// is removed (currently no public removal API exists, so views are
101    /// effectively stable for the lifetime of `self`). Plugins that build a
102    /// bind group from this view must rebuild it after any operation that
103    /// could invalidate the texture (texture-store compaction, device
104    /// recreation). A safer pattern is to fetch the view each frame just
105    /// before building / rebuilding the bind group.
106    pub fn texture_view(&self, id: u64) -> Option<&wgpu::TextureView> {
107        self.textures.get(id as usize).map(|t| &t.view)
108    }
109
110    /// Borrow the sampler the texture was uploaded with.
111    ///
112    /// Most user textures are uploaded with a shared linear-repeat sampler;
113    /// prefer [`material_sampler`](Self::material_sampler) when you need
114    /// the shared lib sampler rather than the per-texture instance.
115    pub fn texture_sampler(&self, id: u64) -> Option<&wgpu::Sampler> {
116        self.textures.get(id as usize).map(|t| &t.sampler)
117    }
118
119    /// Shared linear-repeat sampler used by the lib's material pipelines.
120    ///
121    /// Use this when building a plugin bind group that samples user
122    /// textures the same way `Material` does (linear filter, repeat wrap).
123    pub fn material_sampler(&self) -> &wgpu::Sampler {
124        &self.material_sampler
125    }
126
127    /// Shared linear-clamp sampler used by the lib for colormap LUTs.
128    ///
129    /// Use this when sampling 1D LUT-style data (colourmaps, transfer
130    /// functions) where the texture should not wrap.
131    pub fn lut_sampler(&self) -> &wgpu::Sampler {
132        &self.lut_sampler
133    }
134
135    /// Comparison sampler used for PCF shadow filtering.
136    ///
137    /// Plugins that sample the shadow atlas directly (rather than through
138    /// `viewport_sample_csm`) use this sampler when binding the atlas.
139    pub fn shadow_filter_sampler(&self) -> &wgpu::Sampler {
140        &self.shadow_sampler
141    }
142
143    /// Bind group layout for the per-vertex deformation sidecar.
144    ///
145    /// Plugins building pipelines that draw meshes with registered deformers
146    /// add this layout at group 2 so their vertex stage can read from the
147    /// shared `deform_data` / `deform_instance_data` storage buffers.
148    pub fn deform_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
149        &self.deform.bind_group_layout
150    }
151
152    /// Number of live user-uploaded textures.
153    ///
154    /// `id` values in `0..texture_count()` are addressable via
155    /// [`texture_view`](Self::texture_view), with the caveat that promoted
156    /// IDs from async uploads may sit at the high end.
157    pub fn texture_count(&self) -> usize {
158        self.textures.len()
159    }
160
161    // ------------------------------------------------------------------
162    // Pipeline builders
163    // ------------------------------------------------------------------
164
165    /// Build an opaque scene pipeline that draws into the HDR scene pass.
166    ///
167    /// Standard depth state: `LessEqual` test, depth write on. The pipeline
168    /// layout lists [`shared_bindings`](Self::shared_bindings) as group 0,
169    /// then `extra_bind_group_layouts` as groups 1.., in order. The plugin
170    /// owns all groups past 0.
171    pub fn build_opaque_pipeline(
172        &self,
173        device: &wgpu::Device,
174        opts: &PluginPipelineOpts<'_>,
175    ) -> wgpu::RenderPipeline {
176        let layout = build_layout(device, opts.label, self, opts.extra_bind_group_layouts);
177        let desc = self.opaque_target_desc();
178        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
179            label: opts.label,
180            layout: Some(&layout),
181            vertex: wgpu::VertexState {
182                module: opts.shader,
183                entry_point: Some(opts.vs_entry),
184                buffers: opts.vertex_layouts,
185                compilation_options: Default::default(),
186            },
187            fragment: Some(wgpu::FragmentState {
188                module: opts.shader,
189                entry_point: Some(opts.fs_entry),
190                targets: &[Some(wgpu::ColorTargetState {
191                    format: desc.color_format,
192                    blend: opts.color_blend,
193                    write_mask: wgpu::ColorWrites::ALL,
194                })],
195                compilation_options: Default::default(),
196            }),
197            primitive: opts.primitive,
198            depth_stencil: Some(wgpu::DepthStencilState {
199                format: desc.depth_format,
200                depth_write_enabled: opts.depth_write,
201                depth_compare: opts.depth_compare,
202                stencil: wgpu::StencilState::default(),
203                bias: wgpu::DepthBiasState::default(),
204            }),
205            multisample: wgpu::MultisampleState {
206                count: desc.sample_count,
207                ..Default::default()
208            },
209            multiview: None,
210            cache: None,
211        })
212    }
213
214    /// Build a transparent pipeline that draws into the OIT pass.
215    ///
216    /// The fragment shader must return [`OitOutput`](crate::plugin_api::shared_wgsl::SHARED_OIT_WGSL),
217    /// writing both `@location(0)` (accum) and `@location(1)` (reveal).
218    /// Depth state: `LessEqual` test, depth write off.
219    pub fn build_oit_pipeline(
220        &self,
221        device: &wgpu::Device,
222        opts: &PluginPipelineOpts<'_>,
223    ) -> wgpu::RenderPipeline {
224        let layout = build_layout(device, opts.label, self, opts.extra_bind_group_layouts);
225        let desc = self.oit_target_desc();
226        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
227            label: opts.label,
228            layout: Some(&layout),
229            vertex: wgpu::VertexState {
230                module: opts.shader,
231                entry_point: Some(opts.vs_entry),
232                buffers: opts.vertex_layouts,
233                compilation_options: Default::default(),
234            },
235            fragment: Some(wgpu::FragmentState {
236                module: opts.shader,
237                entry_point: Some(opts.fs_entry),
238                targets: &[
239                    Some(wgpu::ColorTargetState {
240                        format: desc.accum_format,
241                        blend: Some(desc.accum_blend),
242                        write_mask: wgpu::ColorWrites::ALL,
243                    }),
244                    Some(wgpu::ColorTargetState {
245                        format: desc.reveal_format,
246                        blend: Some(desc.reveal_blend),
247                        write_mask: wgpu::ColorWrites::RED,
248                    }),
249                ],
250                compilation_options: Default::default(),
251            }),
252            primitive: opts.primitive,
253            depth_stencil: Some(wgpu::DepthStencilState {
254                format: desc.depth_format,
255                depth_write_enabled: false,
256                depth_compare: wgpu::CompareFunction::LessEqual,
257                stencil: wgpu::StencilState::default(),
258                bias: wgpu::DepthBiasState::default(),
259            }),
260            multisample: wgpu::MultisampleState {
261                count: desc.sample_count,
262                ..Default::default()
263            },
264            multiview: None,
265            cache: None,
266        })
267    }
268
269    /// Build a pipeline for the outline-mask pass (R8 target).
270    ///
271    /// Fragment shader must write `1.0` at `@location(0)` for any covered
272    /// pixel; use [`SHARED_MASK_WGSL`](crate::plugin_api::shared_wgsl::SHARED_MASK_WGSL).
273    /// Depth state: `LessEqual` test, no depth write.
274    pub fn build_mask_pipeline(
275        &self,
276        device: &wgpu::Device,
277        opts: &PluginPipelineOpts<'_>,
278    ) -> wgpu::RenderPipeline {
279        let layout = build_layout(device, opts.label, self, opts.extra_bind_group_layouts);
280        let desc = self.mask_target_desc();
281        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
282            label: opts.label,
283            layout: Some(&layout),
284            vertex: wgpu::VertexState {
285                module: opts.shader,
286                entry_point: Some(opts.vs_entry),
287                buffers: opts.vertex_layouts,
288                compilation_options: Default::default(),
289            },
290            fragment: Some(wgpu::FragmentState {
291                module: opts.shader,
292                entry_point: Some(opts.fs_entry),
293                targets: &[Some(wgpu::ColorTargetState {
294                    format: desc.color_format,
295                    blend: None,
296                    write_mask: wgpu::ColorWrites::RED,
297                })],
298                compilation_options: Default::default(),
299            }),
300            primitive: opts.primitive,
301            depth_stencil: Some(wgpu::DepthStencilState {
302                format: desc.depth_format,
303                depth_write_enabled: false,
304                depth_compare: wgpu::CompareFunction::LessEqual,
305                stencil: wgpu::StencilState::default(),
306                bias: wgpu::DepthBiasState::default(),
307            }),
308            multisample: wgpu::MultisampleState {
309                count: desc.sample_count,
310                ..Default::default()
311            },
312            multiview: None,
313            cache: None,
314        })
315    }
316
317    /// Build a pipeline for the pick-id pass (R32Uint target).
318    ///
319    /// Fragment shader must write the item's `PickId` value at
320    /// `@location(0)`; use [`SHARED_PICK_WGSL`](crate::plugin_api::shared_wgsl::SHARED_PICK_WGSL).
321    pub fn build_pick_pipeline(
322        &self,
323        device: &wgpu::Device,
324        opts: &PluginPipelineOpts<'_>,
325    ) -> wgpu::RenderPipeline {
326        let layout = build_layout(device, opts.label, self, opts.extra_bind_group_layouts);
327        let desc = self.pick_target_desc();
328        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
329            label: opts.label,
330            layout: Some(&layout),
331            vertex: wgpu::VertexState {
332                module: opts.shader,
333                entry_point: Some(opts.vs_entry),
334                buffers: opts.vertex_layouts,
335                compilation_options: Default::default(),
336            },
337            fragment: Some(wgpu::FragmentState {
338                module: opts.shader,
339                entry_point: Some(opts.fs_entry),
340                targets: &[Some(wgpu::ColorTargetState {
341                    format: desc.color_format,
342                    blend: None,
343                    write_mask: wgpu::ColorWrites::RED,
344                })],
345                compilation_options: Default::default(),
346            }),
347            primitive: opts.primitive,
348            depth_stencil: Some(wgpu::DepthStencilState {
349                format: desc.depth_format,
350                depth_write_enabled: true,
351                depth_compare: wgpu::CompareFunction::LessEqual,
352                stencil: wgpu::StencilState::default(),
353                bias: wgpu::DepthBiasState::default(),
354            }),
355            multisample: wgpu::MultisampleState {
356                count: desc.sample_count,
357                ..Default::default()
358            },
359            multiview: None,
360            cache: None,
361        })
362    }
363
364    /// Build a depth-only pipeline for the shadow-atlas pass.
365    ///
366    /// No fragment output. The fragment entry is optional; pass an empty
367    /// string to use a depth-only configuration with no fragment stage.
368    /// Standard depth state: `LessEqual` test, depth write on, with the
369    /// lib's standard depth bias.
370    pub fn build_shadow_pipeline(
371        &self,
372        device: &wgpu::Device,
373        opts: &PluginPipelineOpts<'_>,
374    ) -> wgpu::RenderPipeline {
375        let layout = build_layout(device, opts.label, self, opts.extra_bind_group_layouts);
376        let desc = self.shadow_target_desc();
377        let fragment = if opts.fs_entry.is_empty() {
378            None
379        } else {
380            Some(wgpu::FragmentState {
381                module: opts.shader,
382                entry_point: Some(opts.fs_entry),
383                targets: &[],
384                compilation_options: Default::default(),
385            })
386        };
387        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
388            label: opts.label,
389            layout: Some(&layout),
390            vertex: wgpu::VertexState {
391                module: opts.shader,
392                entry_point: Some(opts.vs_entry),
393                buffers: opts.vertex_layouts,
394                compilation_options: Default::default(),
395            },
396            fragment,
397            primitive: opts.primitive,
398            depth_stencil: Some(wgpu::DepthStencilState {
399                format: desc.depth_format,
400                depth_write_enabled: true,
401                depth_compare: wgpu::CompareFunction::LessEqual,
402                stencil: wgpu::StencilState::default(),
403                bias: wgpu::DepthBiasState {
404                    constant: 2,
405                    slope_scale: 2.0,
406                    clamp: 0.0,
407                },
408            }),
409            multisample: wgpu::MultisampleState {
410                count: desc.sample_count,
411                ..Default::default()
412            },
413            multiview: None,
414            cache: None,
415        })
416    }
417}
418
419/// Inputs to a plugin pipeline builder. All builders take this struct; the
420/// builder picks the target descriptor and blend state.
421pub struct PluginPipelineOpts<'a> {
422    /// Pipeline debug label. Forwarded to wgpu.
423    pub label: Option<&'a str>,
424    /// Shader module containing both the vertex and fragment entry points.
425    pub shader: &'a wgpu::ShaderModule,
426    /// Vertex-stage entry-point name (e.g. `"vs_main"`).
427    pub vs_entry: &'a str,
428    /// Fragment-stage entry-point name (e.g. `"fs_main"`). For
429    /// `build_shadow_pipeline`, pass `""` to skip the fragment stage.
430    pub fs_entry: &'a str,
431    /// Vertex buffer layouts.
432    pub vertex_layouts: &'a [wgpu::VertexBufferLayout<'a>],
433    /// Bind group layouts for groups 1.. (the plugin's per-object data).
434    /// Group 0 is supplied automatically from
435    /// [`ViewportGpuResources::shared_bindings`].
436    pub extra_bind_group_layouts: &'a [&'a wgpu::BindGroupLayout],
437    /// Primitive topology, cull mode, polygon mode.
438    pub primitive: wgpu::PrimitiveState,
439    /// Optional blend state for the opaque builder. `None` = no blending
440    /// (the default for opaque pipelines). Ignored by OIT / mask / pick /
441    /// shadow builders, which use their pass-specific blend state.
442    pub color_blend: Option<wgpu::BlendState>,
443    /// Whether the opaque builder writes depth. Ignored by the other
444    /// builders. Default `true`.
445    pub depth_write: bool,
446    /// Depth-compare function for the opaque builder. Ignored by the other
447    /// builders.
448    pub depth_compare: wgpu::CompareFunction,
449}
450
451impl<'a> PluginPipelineOpts<'a> {
452    /// Construct an opts struct with sensible defaults for the variable
453    /// fields (`TriangleList`, back-face cull, depth write on,
454    /// `LessEqual`, no blend). Callers must supply `shader`, vertex / fragment
455    /// entry points, and the vertex layout.
456    pub fn new(
457        label: Option<&'a str>,
458        shader: &'a wgpu::ShaderModule,
459        vs_entry: &'a str,
460        fs_entry: &'a str,
461        vertex_layouts: &'a [wgpu::VertexBufferLayout<'a>],
462    ) -> Self {
463        Self {
464            label,
465            shader,
466            vs_entry,
467            fs_entry,
468            vertex_layouts,
469            extra_bind_group_layouts: &[],
470            primitive: wgpu::PrimitiveState {
471                topology: wgpu::PrimitiveTopology::TriangleList,
472                cull_mode: Some(wgpu::Face::Back),
473                ..Default::default()
474            },
475            color_blend: None,
476            depth_write: true,
477            depth_compare: wgpu::CompareFunction::LessEqual,
478        }
479    }
480}
481
482fn build_layout(
483    device: &wgpu::Device,
484    label: Option<&str>,
485    res: &ViewportGpuResources,
486    extras: &[&wgpu::BindGroupLayout],
487) -> wgpu::PipelineLayout {
488    let mut bgls: Vec<&wgpu::BindGroupLayout> = Vec::with_capacity(1 + extras.len());
489    bgls.push(&res.camera_bind_group_layout);
490    bgls.extend(extras.iter().copied());
491    device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
492        label,
493        bind_group_layouts: &bgls,
494        push_constant_ranges: &[],
495    })
496}