onedrop_renderer/
comp_pipeline.rs

1//! Comp pass GPU pipeline.
2//!
3//! Reads `render_texture` (warp output) and writes a display-ready frame to
4//! `final_texture`. Filters here are display-only; they must not feed back
5//! into the next frame's warp. See `shaders/comp.wgsl` for the geometry/UV
6//! conventions.
7//!
8//! ## Uniform layout
9//!
10//! The bind group binds the **standard** [`ShaderUniforms`] layout (defined
11//! in `onedrop-codegen::prelude`) at `@group(0) @binding(0)`. This is the
12//! same layout user comp shaders translated from MD2 HLSL expect, so a
13//! future shader-swap path (sprint B2 follow-up) only needs to recreate
14//! the render pipeline — bind group layout and buffer remain valid.
15//!
16//! [`ShaderUniforms`]: onedrop_codegen::ShaderUniforms
17
18use onedrop_codegen::{ShaderUniforms, USER_TEXTURE_FIRST_BINDING, USER_TEXTURE_SLOTS};
19use wgpu::util::DeviceExt;
20
21use crate::error::Result;
22
23/// WGSL binding index for the previous-frame display texture used by the
24/// echo effect. Sits immediately after the user-texture slots (binding 22
25/// is the last user texture, 23 is `prev_main`).
26pub const PREV_MAIN_BINDING: u32 = USER_TEXTURE_FIRST_BINDING + USER_TEXTURE_SLOTS as u32;
27
28/// Texture views the comp pass binds beyond the main warp output and blur
29/// pyramid. Bundled into a struct so the pipeline constructor + the resize
30/// rebind path don't take a 10-argument list.
31///
32/// All views are GPU-resident and re-bindable; lifetimes match the GpuContext
33/// they came from. The noise views never change after init — only the dynamic
34/// main + blur views are recreated on resize.
35pub struct CompAuxViews<'a> {
36    pub blur1: &'a wgpu::TextureView,
37    pub blur2: &'a wgpu::TextureView,
38    pub blur3: &'a wgpu::TextureView,
39    pub noise_lq: &'a wgpu::TextureView,
40    pub noise_mq: &'a wgpu::TextureView,
41    pub noise_hq: &'a wgpu::TextureView,
42    pub noisevol_lq: &'a wgpu::TextureView,
43    pub noisevol_hq: &'a wgpu::TextureView,
44    /// Previous frame's warp output. The comp pass samples this for the
45    /// echo effect (`echo_zoom`/`echo_alpha`/`echo_orient`). The renderer
46    /// keeps `prev_texture` from frame N-1 alive long enough that this
47    /// binding stays valid for frame N's comp pass — see the re-ordered
48    /// render path in `MilkRenderer::render`.
49    pub prev_main: &'a wgpu::TextureView,
50}
51
52pub struct CompPipeline {
53    /// Built once at startup and never replaced. Used when no preset is
54    /// loaded, when a preset has no `comp_shader`, or when the user
55    /// shader fails to compile / validate.
56    default_pipeline: wgpu::RenderPipeline,
57    /// Active when a preset's translated user shader compiled successfully.
58    /// Always built against the same `pipeline_layout` and bind group as
59    /// the default, so the bind group can stay valid across swaps.
60    user_pipeline: Option<wgpu::RenderPipeline>,
61    pipeline_layout: wgpu::PipelineLayout,
62    target_format: wgpu::TextureFormat,
63    bind_group_layout: wgpu::BindGroupLayout,
64    bind_group: wgpu::BindGroup,
65    /// `sampler_main` — the original linear/clamp-to-edge sampler the comp
66    /// pass has always used. Bound at binding(2). Backwards-compatible with
67    /// every user shader the wrapper has shipped.
68    sampler: wgpu::Sampler,
69    /// Four MD2 sampler variants — name encodes filter × address mode:
70    /// `fw` = filtered+wrap, `fc` = filtered+clamp, `pw` = point+wrap,
71    /// `pc` = point+clamp. Each authored preset chooses one per sample
72    /// site (`tex2D(sampler_fw_main, uv)`, …); the translator maps the
73    /// name to the correct binding at translation time. Order in
74    /// bindings 11..14.
75    sampler_fw: wgpu::Sampler,
76    sampler_fc: wgpu::Sampler,
77    sampler_pw: wgpu::Sampler,
78    sampler_pc: wgpu::Sampler,
79    uniforms_buffer: wgpu::Buffer,
80    /// 1×1 opaque white texture bound into any unfilled user-texture slot.
81    /// Owned by the pipeline so a default `CompPipeline::new` (no texture
82    /// pool wired up) still produces a complete bind group.
83    fallback_user_view: wgpu::TextureView,
84    /// One view per user-texture slot. Initially all set to the fallback;
85    /// `set_user_shader_with_plan` updates them with pool-backed views.
86    /// Kept across `rebind_input_texture` calls so resize doesn't clobber
87    /// the active preset's textures.
88    user_texture_views: [wgpu::TextureView; USER_TEXTURE_SLOTS],
89    /// Latest input view + aux views, retained so we can rebuild the bind
90    /// group when the user texture set changes mid-frame without re-passing
91    /// them. Cloned once per resize / preset load.
92    cached_input_view: wgpu::TextureView,
93    cached_aux: OwnedCompAuxViews,
94}
95
96/// `CompAuxViews` with owned (cloned) views — `wgpu::TextureView` is a
97/// refcounted handle so cloning is cheap. Used inside `CompPipeline` to
98/// remember the latest aux bindings across user-shader swaps.
99struct OwnedCompAuxViews {
100    blur1: wgpu::TextureView,
101    blur2: wgpu::TextureView,
102    blur3: wgpu::TextureView,
103    noise_lq: wgpu::TextureView,
104    noise_mq: wgpu::TextureView,
105    noise_hq: wgpu::TextureView,
106    noisevol_lq: wgpu::TextureView,
107    noisevol_hq: wgpu::TextureView,
108    prev_main: wgpu::TextureView,
109}
110
111impl OwnedCompAuxViews {
112    fn from_borrowed(aux: &CompAuxViews) -> Self {
113        Self {
114            blur1: aux.blur1.clone(),
115            blur2: aux.blur2.clone(),
116            blur3: aux.blur3.clone(),
117            noise_lq: aux.noise_lq.clone(),
118            noise_mq: aux.noise_mq.clone(),
119            noise_hq: aux.noise_hq.clone(),
120            noisevol_lq: aux.noisevol_lq.clone(),
121            noisevol_hq: aux.noisevol_hq.clone(),
122            prev_main: aux.prev_main.clone(),
123        }
124    }
125
126    fn as_borrowed(&self) -> CompAuxViews<'_> {
127        CompAuxViews {
128            blur1: &self.blur1,
129            blur2: &self.blur2,
130            blur3: &self.blur3,
131            noise_lq: &self.noise_lq,
132            noise_mq: &self.noise_mq,
133            noise_hq: &self.noise_hq,
134            noisevol_lq: &self.noisevol_lq,
135            noisevol_hq: &self.noisevol_hq,
136            prev_main: &self.prev_main,
137        }
138    }
139}
140
141impl CompPipeline {
142    pub fn new(
143        device: &wgpu::Device,
144        queue: &wgpu::Queue,
145        target_format: wgpu::TextureFormat,
146        input_texture_view: &wgpu::TextureView,
147        aux: &CompAuxViews,
148    ) -> Result<Self> {
149        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
150            label: Some("Comp Sampler"),
151            address_mode_u: wgpu::AddressMode::ClampToEdge,
152            address_mode_v: wgpu::AddressMode::ClampToEdge,
153            address_mode_w: wgpu::AddressMode::ClampToEdge,
154            mag_filter: wgpu::FilterMode::Linear,
155            min_filter: wgpu::FilterMode::Linear,
156            mipmap_filter: wgpu::MipmapFilterMode::Linear,
157            ..Default::default()
158        });
159        let sampler_fw = make_md2_sampler(device, "Comp Sampler FW", true, true);
160        let sampler_fc = make_md2_sampler(device, "Comp Sampler FC", true, false);
161        let sampler_pw = make_md2_sampler(device, "Comp Sampler PW", false, true);
162        let sampler_pc = make_md2_sampler(device, "Comp Sampler PC", false, false);
163
164        let fallback_user_view = make_fallback_user_texture_view(device, queue);
165        let user_texture_views: [wgpu::TextureView; USER_TEXTURE_SLOTS] =
166            std::array::from_fn(|_| fallback_user_view.clone());
167
168        let initial = ShaderUniforms::default();
169        let uniforms_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
170            label: Some("Comp ShaderUniforms"),
171            contents: bytemuck::bytes_of(&initial),
172            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
173        });
174
175        let bind_group_layout =
176            build_user_shader_bind_group_layout(device, "Comp Bind Group Layout");
177
178        let user_view_refs: [&wgpu::TextureView; USER_TEXTURE_SLOTS] =
179            std::array::from_fn(|i| &user_texture_views[i]);
180        let bind_group = Self::create_bind_group(
181            device,
182            &bind_group_layout,
183            &uniforms_buffer,
184            input_texture_view,
185            &sampler,
186            aux,
187            &sampler_fw,
188            &sampler_fc,
189            &sampler_pw,
190            &sampler_pc,
191            &user_view_refs,
192        );
193
194        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
195            label: Some("Comp Pipeline Layout"),
196            bind_group_layouts: &[Some(&bind_group_layout)],
197            immediate_size: 0,
198        });
199
200        let default_pipeline = build_pipeline_from_wgsl(
201            device,
202            &pipeline_layout,
203            target_format,
204            include_str!("../shaders/comp.wgsl"),
205            "Comp Default",
206        )
207        .expect("default comp shader must compile — bug if not");
208
209        let cached_input_view = input_texture_view.clone();
210        let cached_aux = OwnedCompAuxViews::from_borrowed(aux);
211
212        Ok(Self {
213            default_pipeline,
214            user_pipeline: None,
215            pipeline_layout,
216            target_format,
217            bind_group_layout,
218            bind_group,
219            sampler,
220            sampler_fw,
221            sampler_fc,
222            sampler_pw,
223            sampler_pc,
224            uniforms_buffer,
225            fallback_user_view,
226            user_texture_views,
227            cached_input_view,
228            cached_aux,
229        })
230    }
231
232    /// Swap in a user fragment shader for the comp pass.
233    ///
234    /// `wrapped_wgsl` must be a complete WGSL module exposing `vs_main` and
235    /// `fs_main` and binding the standard `ShaderUniforms` layout — exactly
236    /// what `onedrop-codegen::wrap_user_comp_shader` produces. The bind group
237    /// layout is reused, so the existing bind group stays valid.
238    ///
239    /// Returns `Ok(())` on a successful swap. wgpu's runtime validation may
240    /// surface errors only at draw time (uncaptured-error callback) — the
241    /// caller upstream is expected to have already validated via naga in
242    /// `ShaderCompiler::compile()`, so reaching this method with broken
243    /// WGSL is a programming error.
244    pub fn set_user_shader(&mut self, device: &wgpu::Device, wrapped_wgsl: &str) -> Result<()> {
245        let pipeline = build_pipeline_from_wgsl(
246            device,
247            &self.pipeline_layout,
248            self.target_format,
249            wrapped_wgsl,
250            "Comp User",
251        )?;
252        self.user_pipeline = Some(pipeline);
253        Ok(())
254    }
255
256    /// Drop the user shader and route the comp pass back through the default
257    /// pipeline. Idempotent. User-texture slots are reset to the 1×1 white
258    /// fallback so the bind group stays consistent — the default pipeline
259    /// doesn't sample them, but bind-group validity is layout-driven, not
260    /// shader-driven.
261    pub fn reset_to_default(&mut self, device: &wgpu::Device) {
262        self.user_pipeline = None;
263        for slot in self.user_texture_views.iter_mut() {
264            *slot = self.fallback_user_view.clone();
265        }
266        self.rebuild_bind_group(device);
267    }
268
269    /// `true` when a user shader is currently active. Tests / GUI use this
270    /// to surface whether the visible frame is the user's authored
271    /// composite or the engine's gamma-only fallback.
272    pub fn has_user_shader(&self) -> bool {
273        self.user_pipeline.is_some()
274    }
275
276    /// Borrow the comp pass's cached `CompAuxViews`. The renderer hands
277    /// these to the warp pipeline's user-shader path so the same noise
278    /// textures + `prev_main` view get bound on both passes.
279    pub fn comp_aux_views(&self) -> CompAuxViews<'_> {
280        self.cached_aux.as_borrowed()
281    }
282
283    #[allow(clippy::too_many_arguments)]
284    fn create_bind_group(
285        device: &wgpu::Device,
286        layout: &wgpu::BindGroupLayout,
287        uniforms_buffer: &wgpu::Buffer,
288        input_texture_view: &wgpu::TextureView,
289        sampler: &wgpu::Sampler,
290        aux: &CompAuxViews,
291        sampler_fw: &wgpu::Sampler,
292        sampler_fc: &wgpu::Sampler,
293        sampler_pw: &wgpu::Sampler,
294        sampler_pc: &wgpu::Sampler,
295        user_textures: &[&wgpu::TextureView; USER_TEXTURE_SLOTS],
296    ) -> wgpu::BindGroup {
297        let mut entries: Vec<wgpu::BindGroupEntry> = vec![
298            wgpu::BindGroupEntry {
299                binding: 0,
300                resource: uniforms_buffer.as_entire_binding(),
301            },
302            wgpu::BindGroupEntry {
303                binding: 1,
304                resource: wgpu::BindingResource::TextureView(input_texture_view),
305            },
306            wgpu::BindGroupEntry {
307                binding: 2,
308                resource: wgpu::BindingResource::Sampler(sampler),
309            },
310            wgpu::BindGroupEntry {
311                binding: 3,
312                resource: wgpu::BindingResource::TextureView(aux.blur1),
313            },
314            wgpu::BindGroupEntry {
315                binding: 4,
316                resource: wgpu::BindingResource::TextureView(aux.blur2),
317            },
318            wgpu::BindGroupEntry {
319                binding: 5,
320                resource: wgpu::BindingResource::TextureView(aux.blur3),
321            },
322            wgpu::BindGroupEntry {
323                binding: 6,
324                resource: wgpu::BindingResource::TextureView(aux.noise_lq),
325            },
326            wgpu::BindGroupEntry {
327                binding: 7,
328                resource: wgpu::BindingResource::TextureView(aux.noise_mq),
329            },
330            wgpu::BindGroupEntry {
331                binding: 8,
332                resource: wgpu::BindingResource::TextureView(aux.noise_hq),
333            },
334            wgpu::BindGroupEntry {
335                binding: 9,
336                resource: wgpu::BindingResource::TextureView(aux.noisevol_lq),
337            },
338            wgpu::BindGroupEntry {
339                binding: 10,
340                resource: wgpu::BindingResource::TextureView(aux.noisevol_hq),
341            },
342            wgpu::BindGroupEntry {
343                binding: 11,
344                resource: wgpu::BindingResource::Sampler(sampler_fw),
345            },
346            wgpu::BindGroupEntry {
347                binding: 12,
348                resource: wgpu::BindingResource::Sampler(sampler_fc),
349            },
350            wgpu::BindGroupEntry {
351                binding: 13,
352                resource: wgpu::BindingResource::Sampler(sampler_pw),
353            },
354            wgpu::BindGroupEntry {
355                binding: 14,
356                resource: wgpu::BindingResource::Sampler(sampler_pc),
357            },
358        ];
359        for (i, view) in user_textures.iter().enumerate() {
360            entries.push(wgpu::BindGroupEntry {
361                binding: USER_TEXTURE_FIRST_BINDING + i as u32,
362                resource: wgpu::BindingResource::TextureView(view),
363            });
364        }
365        entries.push(wgpu::BindGroupEntry {
366            binding: PREV_MAIN_BINDING,
367            resource: wgpu::BindingResource::TextureView(aux.prev_main),
368        });
369        device.create_bind_group(&wgpu::BindGroupDescriptor {
370            label: Some("Comp Bind Group"),
371            layout,
372            entries: &entries,
373        })
374    }
375
376    /// Re-create the bind group after any input texture view is invalidated
377    /// (e.g., on resize). The noise texture views never change after init —
378    /// the caller forwards them through `aux` unchanged. User-texture views
379    /// are preserved across resize — the active preset's textures stay bound.
380    pub fn rebind_input_texture(
381        &mut self,
382        device: &wgpu::Device,
383        input_texture_view: &wgpu::TextureView,
384        aux: &CompAuxViews,
385    ) {
386        self.cached_input_view = input_texture_view.clone();
387        self.cached_aux = OwnedCompAuxViews::from_borrowed(aux);
388        self.rebuild_bind_group(device);
389    }
390
391    /// Rebuild the bind group from the currently-cached views + user
392    /// textures. Internal helper called by both `rebind_input_texture`
393    /// (resize) and `set_user_shader_with_plan` (preset load).
394    fn rebuild_bind_group(&mut self, device: &wgpu::Device) {
395        let user_view_refs: [&wgpu::TextureView; USER_TEXTURE_SLOTS] =
396            std::array::from_fn(|i| &self.user_texture_views[i]);
397        let aux = self.cached_aux.as_borrowed();
398        self.bind_group = Self::create_bind_group(
399            device,
400            &self.bind_group_layout,
401            &self.uniforms_buffer,
402            &self.cached_input_view,
403            &self.sampler,
404            &aux,
405            &self.sampler_fw,
406            &self.sampler_fc,
407            &self.sampler_pw,
408            &self.sampler_pc,
409            &user_view_refs,
410        );
411    }
412
413    /// Swap user texture views before a user-shader pipeline swap. Pass
414    /// `None` in any slot to bind the 1×1 white fallback. Length must be
415    /// `USER_TEXTURE_SLOTS`. Doesn't rebuild the bind group — the renderer
416    /// calls `set_user_shader_with_plan` next, which rebuilds in one go.
417    fn install_user_textures(&mut self, views: [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS]) {
418        for (slot, opt) in views.into_iter().enumerate() {
419            self.user_texture_views[slot] = opt.unwrap_or_else(|| self.fallback_user_view.clone());
420        }
421    }
422
423    /// Swap in a user fragment shader and bind disk-loaded user textures
424    /// per the supplied slot list. The renderer prepares `views` by
425    /// resolving the preset's [`onedrop_hlsl::TextureBindingPlan`] against
426    /// its [`crate::TexturePool`] — see `MilkRenderer::set_user_comp_shader`.
427    ///
428    /// `wrapped_wgsl` must be a complete WGSL module exposing `vs_main` and
429    /// `fs_main` and binding the standard `ShaderUniforms` + user-texture
430    /// layout — exactly what `onedrop-codegen::wrap_user_comp_shader_with_plan`
431    /// produces.
432    pub fn set_user_shader_with_plan(
433        &mut self,
434        device: &wgpu::Device,
435        wrapped_wgsl: &str,
436        views: [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS],
437    ) -> Result<()> {
438        let pipeline = build_pipeline_from_wgsl(
439            device,
440            &self.pipeline_layout,
441            self.target_format,
442            wrapped_wgsl,
443            "Comp User",
444        )?;
445        self.install_user_textures(views);
446        self.rebuild_bind_group(device);
447        self.user_pipeline = Some(pipeline);
448        Ok(())
449    }
450
451    /// Upload a fully-populated [`ShaderUniforms`] for this frame.
452    pub fn update_uniforms(&self, queue: &wgpu::Queue, uniforms: &ShaderUniforms) {
453        queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::bytes_of(uniforms));
454    }
455
456    pub fn render(&self, encoder: &mut wgpu::CommandEncoder, output_view: &wgpu::TextureView) {
457        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
458            label: Some("Comp Render Pass"),
459            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
460                view: output_view,
461                depth_slice: None,
462                resolve_target: None,
463                ops: wgpu::Operations {
464                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
465                    store: wgpu::StoreOp::Store,
466                },
467            })],
468            depth_stencil_attachment: None,
469            timestamp_writes: None,
470            occlusion_query_set: None,
471            multiview_mask: None,
472        });
473
474        let pipeline = self
475            .user_pipeline
476            .as_ref()
477            .unwrap_or(&self.default_pipeline);
478        pass.set_pipeline(pipeline);
479        pass.set_bind_group(0, &self.bind_group, &[]);
480        pass.draw(0..3, 0..1);
481    }
482}
483
484/// Build a comp-pass `RenderPipeline` from a fully-formed WGSL source string.
485///
486/// Used both for the default shader (built once at startup) and the user
487/// shader (rebuilt on every preset load). The vertex/fragment entry-point
488/// names are fixed by convention (`vs_main`/`fs_main`) — the user-shader
489/// wrapper in `onedrop-codegen` produces them, and the default
490/// `comp.wgsl` matches.
491fn build_pipeline_from_wgsl(
492    device: &wgpu::Device,
493    pipeline_layout: &wgpu::PipelineLayout,
494    target_format: wgpu::TextureFormat,
495    source: &str,
496    label: &str,
497) -> Result<wgpu::RenderPipeline> {
498    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
499        label: Some(&format!("{label} Shader")),
500        source: wgpu::ShaderSource::Wgsl(source.into()),
501    });
502
503    Ok(
504        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
505            label: Some(&format!("{label} Pipeline")),
506            layout: Some(pipeline_layout),
507            vertex: wgpu::VertexState {
508                module: &shader,
509                entry_point: Some("vs_main"),
510                buffers: &[],
511                compilation_options: Default::default(),
512            },
513            fragment: Some(wgpu::FragmentState {
514                module: &shader,
515                entry_point: Some("fs_main"),
516                targets: &[Some(wgpu::ColorTargetState {
517                    format: target_format,
518                    blend: Some(wgpu::BlendState::REPLACE),
519                    write_mask: wgpu::ColorWrites::ALL,
520                })],
521                compilation_options: Default::default(),
522            }),
523            primitive: wgpu::PrimitiveState {
524                topology: wgpu::PrimitiveTopology::TriangleList,
525                ..Default::default()
526            },
527            depth_stencil: None,
528            multisample: wgpu::MultisampleState::default(),
529            multiview_mask: None,
530            cache: None,
531        }),
532    )
533}
534
535/// 1×1 opaque white texture for unfilled user-texture slots. Returns just
536/// the view — the underlying texture is created on the spot and kept alive
537/// by the view's refcounted handle. Sampling it produces `vec4(1, 1, 1, 1)`
538/// so any `tex2D(sampler_user_N_texture, uv) * X` degrades to `X`.
539pub(crate) fn make_fallback_user_texture_view(
540    device: &wgpu::Device,
541    queue: &wgpu::Queue,
542) -> wgpu::TextureView {
543    let texture = device.create_texture(&wgpu::TextureDescriptor {
544        label: Some("Comp User Texture Fallback"),
545        size: wgpu::Extent3d {
546            width: 1,
547            height: 1,
548            depth_or_array_layers: 1,
549        },
550        mip_level_count: 1,
551        sample_count: 1,
552        dimension: wgpu::TextureDimension::D2,
553        format: wgpu::TextureFormat::Rgba8Unorm,
554        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
555        view_formats: &[],
556    });
557    queue.write_texture(
558        wgpu::TexelCopyTextureInfo {
559            texture: &texture,
560            mip_level: 0,
561            origin: wgpu::Origin3d::ZERO,
562            aspect: wgpu::TextureAspect::All,
563        },
564        &[255u8, 255, 255, 255],
565        wgpu::TexelCopyBufferLayout {
566            offset: 0,
567            bytes_per_row: Some(4),
568            rows_per_image: Some(1),
569        },
570        wgpu::Extent3d {
571            width: 1,
572            height: 1,
573            depth_or_array_layers: 1,
574        },
575    );
576    texture.create_view(&wgpu::TextureViewDescriptor::default())
577}
578
579/// Build one of the four MD2 sampler variants used by `sampler_{fw,fc,pw,pc}`.
580/// MD2 encodes the choice into the sampler name: `f`/`p` = filter mode
581/// (filtered=linear vs point=nearest), `w`/`c` = address mode (wrap vs
582/// clamp-to-edge). Real preset authoring intent is matched per call site
583/// (`sampler_pw_main` = high-frequency point sample wrapped to texture
584/// boundary; `sampler_fc_main` = smooth bilinear sample clamped at edges).
585/// Build the bind-group layout user-translated shaders bind against.
586///
587/// Single source of truth for the 25 entries the codegen wrapper assumes:
588/// `ShaderUniforms` at binding(0), `sampler_main_texture` + sampler at
589/// 1/2, blur1/2/3 at 3/4/5, noise pack at 6..10, MD2 sampler variants at
590/// 11..14, eight user-texture slots at 15..22, and `prev_main` at 23.
591/// Both [`CompPipeline`] (default + user comp shaders) and the warp
592/// pipeline's user-shader path call this so a future binding tweak only
593/// needs to land here.
594pub(crate) fn build_user_shader_bind_group_layout(
595    device: &wgpu::Device,
596    label: &str,
597) -> wgpu::BindGroupLayout {
598    let mut entries: Vec<wgpu::BindGroupLayoutEntry> = vec![
599        // 0 — ShaderUniforms.
600        wgpu::BindGroupLayoutEntry {
601            binding: 0,
602            visibility: wgpu::ShaderStages::FRAGMENT,
603            ty: wgpu::BindingType::Buffer {
604                ty: wgpu::BufferBindingType::Uniform,
605                has_dynamic_offset: false,
606                min_binding_size: None,
607            },
608            count: None,
609        },
610        // 1 — sampler_main_texture.
611        wgpu::BindGroupLayoutEntry {
612            binding: 1,
613            visibility: wgpu::ShaderStages::FRAGMENT,
614            ty: wgpu::BindingType::Texture {
615                sample_type: wgpu::TextureSampleType::Float { filterable: true },
616                view_dimension: wgpu::TextureViewDimension::D2,
617                multisampled: false,
618            },
619            count: None,
620        },
621        // 2 — sampler_main.
622        wgpu::BindGroupLayoutEntry {
623            binding: 2,
624            visibility: wgpu::ShaderStages::FRAGMENT,
625            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
626            count: None,
627        },
628    ];
629    // 3, 4, 5 — blur1/2/3 textures.
630    for binding in 3..=5 {
631        entries.push(wgpu::BindGroupLayoutEntry {
632            binding,
633            visibility: wgpu::ShaderStages::FRAGMENT,
634            ty: wgpu::BindingType::Texture {
635                sample_type: wgpu::TextureSampleType::Float { filterable: true },
636                view_dimension: wgpu::TextureViewDimension::D2,
637                multisampled: false,
638            },
639            count: None,
640        });
641    }
642    // 6, 7, 8 — 2D noise textures.
643    for binding in 6..=8 {
644        entries.push(wgpu::BindGroupLayoutEntry {
645            binding,
646            visibility: wgpu::ShaderStages::FRAGMENT,
647            ty: wgpu::BindingType::Texture {
648                sample_type: wgpu::TextureSampleType::Float { filterable: true },
649                view_dimension: wgpu::TextureViewDimension::D2,
650                multisampled: false,
651            },
652            count: None,
653        });
654    }
655    // 9, 10 — 3D noise volumes.
656    for binding in 9..=10 {
657        entries.push(wgpu::BindGroupLayoutEntry {
658            binding,
659            visibility: wgpu::ShaderStages::FRAGMENT,
660            ty: wgpu::BindingType::Texture {
661                sample_type: wgpu::TextureSampleType::Float { filterable: true },
662                view_dimension: wgpu::TextureViewDimension::D3,
663                multisampled: false,
664            },
665            count: None,
666        });
667    }
668    // 11, 12, 13, 14 — MD2 sampler variants (fw/fc/pw/pc).
669    for binding in 11..=14 {
670        entries.push(wgpu::BindGroupLayoutEntry {
671            binding,
672            visibility: wgpu::ShaderStages::FRAGMENT,
673            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
674            count: None,
675        });
676    }
677    // 15..22 — user texture slots.
678    for n in 0..USER_TEXTURE_SLOTS as u32 {
679        entries.push(wgpu::BindGroupLayoutEntry {
680            binding: USER_TEXTURE_FIRST_BINDING + n,
681            visibility: wgpu::ShaderStages::FRAGMENT,
682            ty: wgpu::BindingType::Texture {
683                sample_type: wgpu::TextureSampleType::Float { filterable: true },
684                view_dimension: wgpu::TextureViewDimension::D2,
685                multisampled: false,
686            },
687            count: None,
688        });
689    }
690    // 23 — prev_main (previous frame's display).
691    entries.push(wgpu::BindGroupLayoutEntry {
692        binding: PREV_MAIN_BINDING,
693        visibility: wgpu::ShaderStages::FRAGMENT,
694        ty: wgpu::BindingType::Texture {
695            sample_type: wgpu::TextureSampleType::Float { filterable: true },
696            view_dimension: wgpu::TextureViewDimension::D2,
697            multisampled: false,
698        },
699        count: None,
700    });
701    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
702        label: Some(label),
703        entries: &entries,
704    })
705}
706
707pub(crate) fn make_md2_sampler(
708    device: &wgpu::Device,
709    label: &str,
710    filtered: bool,
711    wrap: bool,
712) -> wgpu::Sampler {
713    let filter = if filtered {
714        wgpu::FilterMode::Linear
715    } else {
716        wgpu::FilterMode::Nearest
717    };
718    let address = if wrap {
719        wgpu::AddressMode::Repeat
720    } else {
721        wgpu::AddressMode::ClampToEdge
722    };
723    device.create_sampler(&wgpu::SamplerDescriptor {
724        label: Some(label),
725        address_mode_u: address,
726        address_mode_v: address,
727        address_mode_w: address,
728        mag_filter: filter,
729        min_filter: filter,
730        mipmap_filter: wgpu::MipmapFilterMode::Nearest,
731        ..Default::default()
732    })
733}