onedrop_renderer/
warp_pipeline.rs

1//! Warp pass GPU pipeline.
2//!
3//! Renders the warp mesh (cols × rows triangles) into a target texture by
4//! sampling `prev_texture` at the per-vertex warped UV. Decay is applied in
5//! the fragment shader.
6//!
7//! The CPU is responsible for filling the dynamic vertex buffer each frame
8//! via [`WarpPipeline::update_vertices`].
9
10use bytemuck::{Pod, Zeroable};
11use onedrop_codegen::{ShaderUniforms, USER_TEXTURE_SLOTS};
12use wgpu::util::DeviceExt;
13
14use crate::error::Result;
15use crate::user_warp_pipeline::{UserWarpPipeline, WarpAuxViews};
16use crate::warp_mesh::WarpMesh;
17
18/// One vertex consumed by the warp shader.
19///
20/// `pos_clip` is static (laid out by the mesh once); `uv_warp` is rewritten
21/// every frame from per-vertex equation evaluation.
22#[repr(C)]
23#[derive(Clone, Copy, Debug, Pod, Zeroable)]
24pub struct WarpVertex {
25    pub pos_clip: [f32; 2],
26    pub uv_warp: [f32; 2],
27}
28
29#[repr(C)]
30#[derive(Clone, Copy, Debug, Pod, Zeroable)]
31struct WarpUniforms {
32    decay: f32,
33    aspect_x: f32,
34    aspect_y: f32,
35    /// Bitfield of feedback toggles. See `FeedbackParams::to_flags` and the
36    /// `WARP_FLAG_*` constants in `warp.wgsl` for the layout.
37    flags: u32,
38}
39
40pub struct WarpPipeline {
41    pipeline: wgpu::RenderPipeline,
42    bind_group_layout: wgpu::BindGroupLayout,
43    bind_group: wgpu::BindGroup,
44    sampler: wgpu::Sampler,
45    uniforms_buffer: wgpu::Buffer,
46
47    vertex_buffer: wgpu::Buffer,
48    index_buffer: wgpu::Buffer,
49    index_count: u32,
50    vertex_count: u32,
51
52    /// Active when a preset's translated warp shader compiled
53    /// successfully. The default pipeline above stays built so failed
54    /// compiles fall back transparently. See [`UserWarpPipeline`].
55    user_pipeline: Option<UserWarpPipeline>,
56    /// Target format remembered so the user pipeline can be rebuilt
57    /// after a preset swap without re-asking the caller.
58    target_format: wgpu::TextureFormat,
59}
60
61impl WarpPipeline {
62    pub fn new(
63        device: &wgpu::Device,
64        target_format: wgpu::TextureFormat,
65        prev_texture_view: &wgpu::TextureView,
66        mesh: &WarpMesh,
67    ) -> Result<Self> {
68        // Sampler: clamp-to-edge for now. Wrap mode (`b_tex_wrap`) deferred.
69        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
70            label: Some("Warp Sampler"),
71            address_mode_u: wgpu::AddressMode::ClampToEdge,
72            address_mode_v: wgpu::AddressMode::ClampToEdge,
73            address_mode_w: wgpu::AddressMode::ClampToEdge,
74            mag_filter: wgpu::FilterMode::Linear,
75            min_filter: wgpu::FilterMode::Linear,
76            mipmap_filter: wgpu::MipmapFilterMode::Linear,
77            ..Default::default()
78        });
79
80        let uniforms = WarpUniforms {
81            decay: 0.98,
82            aspect_x: 1.0,
83            aspect_y: 1.0,
84            flags: 0,
85        };
86        let uniforms_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
87            label: Some("Warp Uniforms"),
88            contents: bytemuck::bytes_of(&uniforms),
89            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
90        });
91
92        // Initial vertex buffer: identity warp (uv_warp = uv_orig).
93        let initial_vertices: Vec<WarpVertex> = mesh
94            .vertices
95            .iter()
96            .map(|v| WarpVertex {
97                pos_clip: v.pos_clip,
98                uv_warp: v.uv_orig,
99            })
100            .collect();
101        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
102            label: Some("Warp Vertex Buffer"),
103            contents: bytemuck::cast_slice(&initial_vertices),
104            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
105        });
106
107        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
108            label: Some("Warp Index Buffer"),
109            contents: bytemuck::cast_slice(&mesh.indices),
110            usage: wgpu::BufferUsages::INDEX,
111        });
112
113        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
114            label: Some("Warp Bind Group Layout"),
115            entries: &[
116                wgpu::BindGroupLayoutEntry {
117                    binding: 0,
118                    visibility: wgpu::ShaderStages::FRAGMENT,
119                    ty: wgpu::BindingType::Buffer {
120                        ty: wgpu::BufferBindingType::Uniform,
121                        has_dynamic_offset: false,
122                        min_binding_size: None,
123                    },
124                    count: None,
125                },
126                wgpu::BindGroupLayoutEntry {
127                    binding: 1,
128                    visibility: wgpu::ShaderStages::FRAGMENT,
129                    ty: wgpu::BindingType::Texture {
130                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
131                        view_dimension: wgpu::TextureViewDimension::D2,
132                        multisampled: false,
133                    },
134                    count: None,
135                },
136                wgpu::BindGroupLayoutEntry {
137                    binding: 2,
138                    visibility: wgpu::ShaderStages::FRAGMENT,
139                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
140                    count: None,
141                },
142            ],
143        });
144
145        let bind_group = Self::create_bind_group(
146            device,
147            &bind_group_layout,
148            &uniforms_buffer,
149            prev_texture_view,
150            &sampler,
151        );
152
153        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
154            label: Some("Warp Shader"),
155            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/warp.wgsl").into()),
156        });
157
158        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
159            label: Some("Warp Pipeline Layout"),
160            bind_group_layouts: &[Some(&bind_group_layout)],
161            immediate_size: 0,
162        });
163
164        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
165            label: Some("Warp Pipeline"),
166            layout: Some(&pipeline_layout),
167            vertex: wgpu::VertexState {
168                module: &shader,
169                entry_point: Some("vs_main"),
170                buffers: &[wgpu::VertexBufferLayout {
171                    array_stride: std::mem::size_of::<WarpVertex>() as wgpu::BufferAddress,
172                    step_mode: wgpu::VertexStepMode::Vertex,
173                    attributes: &[
174                        wgpu::VertexAttribute {
175                            offset: 0,
176                            shader_location: 0,
177                            format: wgpu::VertexFormat::Float32x2,
178                        },
179                        wgpu::VertexAttribute {
180                            offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress,
181                            shader_location: 1,
182                            format: wgpu::VertexFormat::Float32x2,
183                        },
184                    ],
185                }],
186                compilation_options: Default::default(),
187            },
188            fragment: Some(wgpu::FragmentState {
189                module: &shader,
190                entry_point: Some("fs_main"),
191                targets: &[Some(wgpu::ColorTargetState {
192                    format: target_format,
193                    blend: Some(wgpu::BlendState::REPLACE),
194                    write_mask: wgpu::ColorWrites::ALL,
195                })],
196                compilation_options: Default::default(),
197            }),
198            primitive: wgpu::PrimitiveState {
199                topology: wgpu::PrimitiveTopology::TriangleList,
200                ..Default::default()
201            },
202            depth_stencil: None,
203            multisample: wgpu::MultisampleState::default(),
204            multiview_mask: None,
205            cache: None,
206        });
207
208        Ok(Self {
209            pipeline,
210            bind_group_layout,
211            bind_group,
212            sampler,
213            uniforms_buffer,
214            vertex_buffer,
215            index_buffer,
216            index_count: mesh.indices.len() as u32,
217            vertex_count: mesh.vertices.len() as u32,
218            user_pipeline: None,
219            target_format,
220        })
221    }
222
223    /// Swap in a user-translated warp fragment shader. Mirrors
224    /// [`CompPipeline::set_user_shader_with_plan`]: the WGSL must have
225    /// already been validated by `onedrop_codegen::ShaderCompiler`, the
226    /// renderer threads `prev_view` + `aux` (noise textures, etc.) + a
227    /// per-preset `user_views` array of disk-loaded textures.
228    ///
229    /// On success the warp pass uses the user pipeline; on failure the
230    /// default mesh-warp pipeline keeps running.
231    pub fn set_user_shader_with_plan(
232        &mut self,
233        device: &wgpu::Device,
234        queue: &wgpu::Queue,
235        wrapped_wgsl: &str,
236        prev_view: &wgpu::TextureView,
237        aux: &WarpAuxViews,
238        user_views: [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS],
239    ) -> Result<()> {
240        let user = UserWarpPipeline::new(
241            device,
242            queue,
243            self.target_format,
244            wrapped_wgsl,
245            prev_view,
246            aux,
247            user_views,
248        )?;
249        self.user_pipeline = Some(user);
250        Ok(())
251    }
252
253    /// Drop the user warp shader and route the pass back through the
254    /// engine's default `warp.wgsl`. Idempotent.
255    pub fn reset_to_default(&mut self) {
256        self.user_pipeline = None;
257    }
258
259    /// `true` when the user pipeline is currently driving the warp
260    /// pass.
261    pub fn has_user_shader(&self) -> bool {
262        self.user_pipeline.is_some()
263    }
264
265    /// Upload `ShaderUniforms` to the user pipeline's uniform buffer.
266    /// Caller passes the same `ShaderUniforms` the comp pass receives
267    /// — q-channels, time, audio levels, roam vectors, etc.
268    pub fn update_user_uniforms(&self, queue: &wgpu::Queue, uniforms: &ShaderUniforms) {
269        if let Some(u) = &self.user_pipeline {
270            u.update_uniforms(queue, uniforms);
271        }
272    }
273
274    /// Rebind the user pipeline's texture views after a resize. No-op
275    /// when no user shader is active. The renderer threads the new
276    /// `prev_view` + refreshed `aux` (blur/noise views may have been
277    /// recreated by [`ChainTextures`] on resize).
278    pub fn rebind_user_textures(
279        &mut self,
280        device: &wgpu::Device,
281        prev_view: &wgpu::TextureView,
282        aux: &WarpAuxViews,
283    ) {
284        if let Some(u) = self.user_pipeline.as_mut() {
285            u.rebind_textures(device, prev_view, aux);
286        }
287    }
288
289    fn create_bind_group(
290        device: &wgpu::Device,
291        layout: &wgpu::BindGroupLayout,
292        uniforms_buffer: &wgpu::Buffer,
293        prev_texture_view: &wgpu::TextureView,
294        sampler: &wgpu::Sampler,
295    ) -> wgpu::BindGroup {
296        device.create_bind_group(&wgpu::BindGroupDescriptor {
297            label: Some("Warp Bind Group"),
298            layout,
299            entries: &[
300                wgpu::BindGroupEntry {
301                    binding: 0,
302                    resource: uniforms_buffer.as_entire_binding(),
303                },
304                wgpu::BindGroupEntry {
305                    binding: 1,
306                    resource: wgpu::BindingResource::TextureView(prev_texture_view),
307                },
308                wgpu::BindGroupEntry {
309                    binding: 2,
310                    resource: wgpu::BindingResource::Sampler(sampler),
311                },
312            ],
313        })
314    }
315
316    /// Re-create the bind group after the prev texture view is invalidated
317    /// (e.g., on resize).
318    pub fn rebind_prev_texture(
319        &mut self,
320        device: &wgpu::Device,
321        prev_texture_view: &wgpu::TextureView,
322    ) {
323        self.bind_group = Self::create_bind_group(
324            device,
325            &self.bind_group_layout,
326            &self.uniforms_buffer,
327            prev_texture_view,
328            &self.sampler,
329        );
330    }
331
332    pub fn update_uniforms(&self, queue: &wgpu::Queue, decay: f32, aspect: f32, flags: u32) {
333        let aspect_x = if aspect > 1.0 { 1.0 / aspect } else { 1.0 };
334        let aspect_y = if aspect < 1.0 { aspect } else { 1.0 };
335        let uniforms = WarpUniforms {
336            decay,
337            aspect_x,
338            aspect_y,
339            flags,
340        };
341        queue.write_buffer(&self.uniforms_buffer, 0, bytemuck::bytes_of(&uniforms));
342    }
343
344    /// Reallocate the vertex + index buffers for a different-sized mesh.
345    /// Used by `MilkRenderer::set_mesh_size` when the user picks a new
346    /// mesh quality at runtime. The bind group is unaffected because it
347    /// references the prev-texture view, not the vertex buffer.
348    pub fn rebuild_mesh(&mut self, device: &wgpu::Device, mesh: &WarpMesh) {
349        let initial_vertices: Vec<WarpVertex> = mesh
350            .vertices
351            .iter()
352            .map(|v| WarpVertex {
353                pos_clip: v.pos_clip,
354                uv_warp: v.uv_orig,
355            })
356            .collect();
357        self.vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
358            label: Some("Warp Vertex Buffer"),
359            contents: bytemuck::cast_slice(&initial_vertices),
360            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
361        });
362        self.index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
363            label: Some("Warp Index Buffer"),
364            contents: bytemuck::cast_slice(&mesh.indices),
365            usage: wgpu::BufferUsages::INDEX,
366        });
367        self.vertex_count = mesh.vertices.len() as u32;
368        self.index_count = mesh.indices.len() as u32;
369    }
370
371    /// Upload new per-vertex warped UVs.
372    ///
373    /// `vertices.len()` must equal the original mesh vertex count.
374    pub fn update_vertices(&self, queue: &wgpu::Queue, vertices: &[WarpVertex]) {
375        debug_assert_eq!(
376            vertices.len() as u32,
377            self.vertex_count,
378            "warp vertex count mismatch"
379        );
380        queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(vertices));
381    }
382
383    /// Issue draw commands. Caller owns the encoder.
384    ///
385    /// When the user warp pipeline is active (`set_user_shader_with_plan`
386    /// has succeeded), delegate to it; the vertex/index buffers are
387    /// shared so the per-frame mesh upload still applies. Otherwise the
388    /// engine's hand-written `warp.wgsl` runs as before.
389    pub fn render(&self, encoder: &mut wgpu::CommandEncoder, output_view: &wgpu::TextureView) {
390        if let Some(user) = &self.user_pipeline {
391            user.render(
392                encoder,
393                output_view,
394                &self.vertex_buffer,
395                &self.index_buffer,
396                self.index_count,
397            );
398            return;
399        }
400        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
401            label: Some("Warp Render Pass"),
402            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
403                view: output_view,
404                depth_slice: None,
405                resolve_target: None,
406                ops: wgpu::Operations {
407                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
408                    store: wgpu::StoreOp::Store,
409                },
410            })],
411            depth_stencil_attachment: None,
412            timestamp_writes: None,
413            occlusion_query_set: None,
414            multiview_mask: None,
415        });
416
417        pass.set_pipeline(&self.pipeline);
418        pass.set_bind_group(0, &self.bind_group, &[]);
419        pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
420        pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
421        pass.draw_indexed(0..self.index_count, 0, 0..1);
422    }
423
424    pub fn vertex_count(&self) -> u32 {
425        self.vertex_count
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::chain_textures::ChainTextures;
433    use crate::config::RenderConfig;
434    use crate::gpu_context::GpuContext;
435    use crate::warp_mesh::WarpMesh;
436
437    #[test]
438    fn pipeline_instantiates() {
439        let cfg = RenderConfig::default();
440        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
441        let chain_tex = ChainTextures::new(&gpu.device, &gpu.config);
442        let mesh = WarpMesh::new(8, 6, 16.0 / 9.0);
443        let p = WarpPipeline::new(
444            &gpu.device,
445            gpu.config.texture_format.to_wgpu(),
446            &chain_tex.prev_texture_view,
447            &mesh,
448        );
449        assert!(p.is_ok());
450    }
451
452    #[test]
453    fn warp_vertex_size_is_16() {
454        // 2× vec2<f32> = 16 bytes — must match the vertex layout strides.
455        assert_eq!(std::mem::size_of::<WarpVertex>(), 16);
456    }
457
458    /// `decay` is applied unconditionally inside the warp fragment shader
459    /// (`color *= uniforms.decay`). With an identity-warp mesh and
460    /// `prev_texture` seeded to a uniform grey, the warp output should be
461    /// `input × decay` exactly. Uses a non-sRGB `Bgra8Unorm` target so the
462    /// math is byte-direct.
463    #[test]
464    fn warp_pass_applies_decay_to_prev_sample() {
465        use crate::config::TextureFormat;
466
467        let cfg = RenderConfig {
468            width: 64,
469            height: 64,
470            texture_format: TextureFormat::Bgra8Unorm,
471            ..Default::default()
472        };
473        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
474        let chain_tex = ChainTextures::new(&gpu.device, &gpu.config);
475        let mesh = WarpMesh::new(8, 6, 1.0);
476        let pipeline = WarpPipeline::new(
477            &gpu.device,
478            gpu.config.texture_format.to_wgpu(),
479            &chain_tex.prev_texture_view,
480            &mesh,
481        )
482        .unwrap();
483
484        // Seed prev_texture with uniform grey 200. BGRA layout.
485        let pixels: Vec<u8> = (0..(64 * 64))
486            .flat_map(|_| [200u8, 200, 200, 255])
487            .collect();
488        gpu.queue.write_texture(
489            wgpu::TexelCopyTextureInfo {
490                texture: &chain_tex.prev_texture,
491                mip_level: 0,
492                origin: wgpu::Origin3d::ZERO,
493                aspect: wgpu::TextureAspect::All,
494            },
495            &pixels,
496            wgpu::TexelCopyBufferLayout {
497                offset: 0,
498                bytes_per_row: Some(64 * 4),
499                rows_per_image: Some(64),
500            },
501            wgpu::Extent3d {
502                width: 64,
503                height: 64,
504                depth_or_array_layers: 1,
505            },
506        );
507
508        let read_center = |decay: f32, flags: u32| -> u8 {
509            pipeline.update_uniforms(&gpu.queue, decay, 1.0, flags);
510
511            let mut encoder = gpu.device.create_command_encoder(&Default::default());
512            pipeline.render(&mut encoder, &chain_tex.render_texture_view);
513            gpu.queue.submit(std::iter::once(encoder.finish()));
514
515            let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
516            let unpadded_row: u32 = 64 * 4;
517            let padded_row = unpadded_row.div_ceil(align) * align;
518            let staging = gpu.device.create_buffer(&wgpu::BufferDescriptor {
519                label: Some("Warp Decay Test Staging"),
520                size: (padded_row * 64) as u64,
521                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
522                mapped_at_creation: false,
523            });
524            let mut e2 = gpu.device.create_command_encoder(&Default::default());
525            e2.copy_texture_to_buffer(
526                wgpu::TexelCopyTextureInfo {
527                    texture: &chain_tex.render_texture,
528                    mip_level: 0,
529                    origin: wgpu::Origin3d::ZERO,
530                    aspect: wgpu::TextureAspect::All,
531                },
532                wgpu::TexelCopyBufferInfo {
533                    buffer: &staging,
534                    layout: wgpu::TexelCopyBufferLayout {
535                        offset: 0,
536                        bytes_per_row: Some(padded_row),
537                        rows_per_image: Some(64),
538                    },
539                },
540                wgpu::Extent3d {
541                    width: 64,
542                    height: 64,
543                    depth_or_array_layers: 1,
544                },
545            );
546            gpu.queue.submit(std::iter::once(e2.finish()));
547            let (tx, rx) = std::sync::mpsc::channel();
548            staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
549                let _ = tx.send(r);
550            });
551            gpu.device.poll(wgpu::PollType::wait_indefinitely()).ok();
552            rx.recv().unwrap().unwrap();
553            let view = staging.slice(..).get_mapped_range();
554            let off = (32u32 * padded_row + 32 * 4) as usize;
555            let g = view[off + 1];
556            drop(view);
557            staging.unmap();
558            g
559        };
560
561        // decay = 1.0 → passthrough: input 200 → output 200.
562        let pass = read_center(1.0, 0);
563        assert!(
564            (pass as i32 - 200).abs() <= 4,
565            "decay=1.0 should pass through, got {pass} (expected ~200)"
566        );
567
568        // decay = 0.5 → output = 100.
569        let half = read_center(0.5, 0);
570        assert!(
571            (half as i32 - 100).abs() <= 4,
572            "decay=0.5 should halve, got {half} (expected ~100)"
573        );
574
575        // decay = 0.0 → output black.
576        let zero = read_center(0.0, 0);
577        assert!(zero <= 4, "decay=0.0 should produce black, got {zero}");
578    }
579
580    /// `WARP_FLAG_DARKEN_CENTER` subtracts a small radial bump near the
581    /// screen centre. Seed prev_texture to a uniform mid-grey, then
582    /// compare the centre pixel to a corner pixel: with the flag off they
583    /// match; with the flag on the centre is strictly darker than the
584    /// corner (the subtraction is ~3 grey levels at uv=(0.5, 0.5)).
585    #[test]
586    fn warp_darken_center_attenuates_center_more_than_corner() {
587        use crate::config::TextureFormat;
588
589        let cfg = RenderConfig {
590            width: 64,
591            height: 64,
592            texture_format: TextureFormat::Bgra8Unorm,
593            ..Default::default()
594        };
595        let gpu = pollster::block_on(GpuContext::new(cfg)).unwrap();
596        let chain_tex = ChainTextures::new(&gpu.device, &gpu.config);
597        let mesh = WarpMesh::new(8, 6, 1.0);
598        let pipeline = WarpPipeline::new(
599            &gpu.device,
600            gpu.config.texture_format.to_wgpu(),
601            &chain_tex.prev_texture_view,
602            &mesh,
603        )
604        .unwrap();
605
606        let pixels: Vec<u8> = (0..(64 * 64))
607            .flat_map(|_| [200u8, 200, 200, 255])
608            .collect();
609        gpu.queue.write_texture(
610            wgpu::TexelCopyTextureInfo {
611                texture: &chain_tex.prev_texture,
612                mip_level: 0,
613                origin: wgpu::Origin3d::ZERO,
614                aspect: wgpu::TextureAspect::All,
615            },
616            &pixels,
617            wgpu::TexelCopyBufferLayout {
618                offset: 0,
619                bytes_per_row: Some(64 * 4),
620                rows_per_image: Some(64),
621            },
622            wgpu::Extent3d {
623                width: 64,
624                height: 64,
625                depth_or_array_layers: 1,
626            },
627        );
628
629        let read_pixels = |flags: u32| -> (u8, u8) {
630            pipeline.update_uniforms(&gpu.queue, 1.0, 1.0, flags);
631
632            let mut encoder = gpu.device.create_command_encoder(&Default::default());
633            pipeline.render(&mut encoder, &chain_tex.render_texture_view);
634            gpu.queue.submit(std::iter::once(encoder.finish()));
635
636            let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
637            let unpadded_row: u32 = 64 * 4;
638            let padded_row = unpadded_row.div_ceil(align) * align;
639            let staging = gpu.device.create_buffer(&wgpu::BufferDescriptor {
640                label: Some("DarkenCenter Test Staging"),
641                size: (padded_row * 64) as u64,
642                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
643                mapped_at_creation: false,
644            });
645            let mut e2 = gpu.device.create_command_encoder(&Default::default());
646            e2.copy_texture_to_buffer(
647                wgpu::TexelCopyTextureInfo {
648                    texture: &chain_tex.render_texture,
649                    mip_level: 0,
650                    origin: wgpu::Origin3d::ZERO,
651                    aspect: wgpu::TextureAspect::All,
652                },
653                wgpu::TexelCopyBufferInfo {
654                    buffer: &staging,
655                    layout: wgpu::TexelCopyBufferLayout {
656                        offset: 0,
657                        bytes_per_row: Some(padded_row),
658                        rows_per_image: Some(64),
659                    },
660                },
661                wgpu::Extent3d {
662                    width: 64,
663                    height: 64,
664                    depth_or_array_layers: 1,
665                },
666            );
667            gpu.queue.submit(std::iter::once(e2.finish()));
668            let (tx, rx) = std::sync::mpsc::channel();
669            staging.slice(..).map_async(wgpu::MapMode::Read, move |r| {
670                let _ = tx.send(r);
671            });
672            gpu.device.poll(wgpu::PollType::wait_indefinitely()).ok();
673            rx.recv().unwrap().unwrap();
674            let view = staging.slice(..).get_mapped_range();
675            let center_off = (32u32 * padded_row + 32 * 4) as usize;
676            let corner_off = (2u32 * padded_row + 2 * 4) as usize;
677            let center_g = view[center_off + 1];
678            let corner_g = view[corner_off + 1];
679            drop(view);
680            staging.unmap();
681            (center_g, corner_g)
682        };
683
684        // Flag off — centre and corner should match (uniform 200 in/out).
685        let (off_center, off_corner) = read_pixels(0);
686        assert!(
687            (off_center as i32 - off_corner as i32).abs() <= 2,
688            "flag off: centre {off_center} vs corner {off_corner} should be ~equal"
689        );
690
691        // Flag on (WARP_FLAG_DARKEN_CENTER = 2) — centre is darker; corner
692        // is barely touched (radial exp falloff).
693        let (on_center, on_corner) = read_pixels(2);
694        assert!(
695            (on_corner as i32 - off_corner as i32).abs() <= 2,
696            "flag on: corner {on_corner} should still be ~{off_corner}"
697        );
698        assert!(
699            on_center + 2 < off_center,
700            "flag on: centre {on_center} should be darker than off-state {off_center}"
701        );
702    }
703}