Skip to main content

viewport_lib/resources/scivis/
sprite.rs

1use super::*;
2
3/// Sprite billboard pipelines (emissive + lit, one per blend/depth-write pair),
4/// their bind group layouts, refraction pass, and soft-particle fallbacks. All
5/// lazily built; the uploaded sprite sets live in separate flat stores.
6#[derive(Default)]
7pub(crate) struct SpriteResources {
8    /// Sprite pipeline, alpha-blend, depth_write_enabled: false.
9    pub(crate) pipeline: Option<DualPipeline>,
10    pub(crate) pipeline_depth_write: Option<DualPipeline>,
11    pub(crate) pipeline_additive: Option<DualPipeline>,
12    pub(crate) pipeline_additive_depth_write: Option<DualPipeline>,
13    pub(crate) pipeline_premultiplied: Option<DualPipeline>,
14    pub(crate) pipeline_premultiplied_depth_write: Option<DualPipeline>,
15    /// Refractive sprite pipeline (HDR target only).
16    pub(crate) refraction_pipeline: Option<wgpu::RenderPipeline>,
17    /// Group 2 BGL for the refraction pipeline: scene-colour texture + sampler.
18    pub(crate) refraction_bgl: Option<wgpu::BindGroupLayout>,
19    /// Sampler used by the refraction shader to read the scene-colour resolve.
20    pub(crate) refraction_sampler: Option<wgpu::Sampler>,
21    /// Bind group layout for sprite uniforms + texture + instance buffer (group 1).
22    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
23    /// Bind group layout for the per-pass scene-depth resolve bound at group 2.
24    pub(crate) soft_bgl: Option<wgpu::BindGroupLayout>,
25    /// Fallback bind group for the group-2 soft-particle binding.
26    pub(crate) soft_fallback_bg: Option<wgpu::BindGroup>,
27    /// Sampler used for the group-2 scene-depth binding.
28    pub(crate) soft_sampler: Option<wgpu::Sampler>,
29    /// 1x1 Depth32Float texture backing the soft fallback bind group.
30    pub(crate) soft_fallback_tex: Option<wgpu::Texture>,
31    /// Lit sprite pipelines: one per (blend, depth_write) pair.
32    pub(crate) lit_pipeline: Option<DualPipeline>,
33    pub(crate) lit_pipeline_depth_write: Option<DualPipeline>,
34    pub(crate) lit_pipeline_additive: Option<DualPipeline>,
35    pub(crate) lit_pipeline_additive_depth_write: Option<DualPipeline>,
36    pub(crate) lit_pipeline_premultiplied: Option<DualPipeline>,
37    pub(crate) lit_pipeline_premultiplied_depth_write: Option<DualPipeline>,
38    /// Group 3 BGL for the optional lit normal map (texture + sampler).
39    pub(crate) lit_bgl: Option<wgpu::BindGroupLayout>,
40    /// Fallback bind group for the lit normal map binding.
41    pub(crate) lit_fallback_bg: Option<wgpu::BindGroup>,
42    /// 1x1 RGBA8Unorm texture backing the lit fallback bind group.
43    pub(crate) lit_fallback_tex: Option<wgpu::Texture>,
44    /// Sprite outline mask pipeline (R8Unorm). None until first selected sprite.
45    pub(crate) outline_mask_pipeline: Option<wgpu::RenderPipeline>,
46}
47
48impl DeviceResources {
49    /// Lazily create the sprite billboard pipelines (alpha-blended, instanced quad expansion).
50    ///
51    /// Creates two pipelines that share the same shader and bind group layout but differ
52    /// in `depth_write_enabled`: one for transparent effects (`depth_write: false`) and one
53    /// for opaque-style placed sprites (`depth_write: true`).
54    ///
55    /// No-op if already created. Called from `prepare()` when `frame.scene.sprite_items` is
56    /// non-empty.
57    pub(crate) fn ensure_sprite_pipelines(&mut self, device: &wgpu::Device) {
58        if self.sprite.bgl.is_some() {
59            return;
60        }
61
62        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
63            label: Some("sprite_bgl"),
64            entries: &[
65                // binding 0: SpriteUniform (model, world_space, has_texture)
66                wgpu::BindGroupLayoutEntry {
67                    binding: 0,
68                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
69                    ty: wgpu::BindingType::Buffer {
70                        ty: wgpu::BufferBindingType::Uniform,
71                        has_dynamic_offset: false,
72                        min_binding_size: None,
73                    },
74                    count: None,
75                },
76                // binding 1: sprite texture (or fallback 1x1 when has_texture == 0)
77                wgpu::BindGroupLayoutEntry {
78                    binding: 1,
79                    visibility: wgpu::ShaderStages::FRAGMENT,
80                    ty: wgpu::BindingType::Texture {
81                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
82                        view_dimension: wgpu::TextureViewDimension::D2,
83                        multisampled: false,
84                    },
85                    count: None,
86                },
87                // binding 2: sampler
88                wgpu::BindGroupLayoutEntry {
89                    binding: 2,
90                    visibility: wgpu::ShaderStages::FRAGMENT,
91                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
92                    count: None,
93                },
94                // binding 3: per-sprite instance storage buffer
95                wgpu::BindGroupLayoutEntry {
96                    binding: 3,
97                    visibility: wgpu::ShaderStages::VERTEX,
98                    ty: wgpu::BindingType::Buffer {
99                        ty: wgpu::BufferBindingType::Storage { read_only: true },
100                        has_dynamic_offset: false,
101                        min_binding_size: None,
102                    },
103                    count: None,
104                },
105            ],
106        });
107
108        // Group 2: scene depth + sampler for soft-particle fade. The shader
109        // skips sampling unless soft_particle_distance > 0, so callers may bind
110        // a placeholder when no resolved depth is available.
111        let soft_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
112            label: Some("sprite_soft_bgl"),
113            entries: &[
114                wgpu::BindGroupLayoutEntry {
115                    binding: 0,
116                    visibility: wgpu::ShaderStages::FRAGMENT,
117                    ty: wgpu::BindingType::Texture {
118                        sample_type: wgpu::TextureSampleType::Depth,
119                        view_dimension: wgpu::TextureViewDimension::D2,
120                        multisampled: false,
121                    },
122                    count: None,
123                },
124                wgpu::BindGroupLayoutEntry {
125                    binding: 1,
126                    visibility: wgpu::ShaderStages::FRAGMENT,
127                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
128                    count: None,
129                },
130            ],
131        });
132
133        let soft_sampler =
134            crate::resources::builders::clamp_nearest_sampler(device, "sprite_soft_sampler");
135
136        let fallback_tex = device.create_texture(&wgpu::TextureDescriptor {
137            label: Some("sprite_soft_fallback_tex"),
138            size: wgpu::Extent3d {
139                width: 1,
140                height: 1,
141                depth_or_array_layers: 1,
142            },
143            mip_level_count: 1,
144            sample_count: 1,
145            dimension: wgpu::TextureDimension::D2,
146            format: wgpu::TextureFormat::Depth32Float,
147            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
148            view_formats: &[],
149        });
150        let fallback_view = fallback_tex.create_view(&wgpu::TextureViewDescriptor {
151            aspect: wgpu::TextureAspect::DepthOnly,
152            ..Default::default()
153        });
154        let fallback_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
155            label: Some("sprite_soft_fallback_bg"),
156            layout: &soft_bgl,
157            entries: &[
158                wgpu::BindGroupEntry {
159                    binding: 0,
160                    resource: wgpu::BindingResource::TextureView(&fallback_view),
161                },
162                wgpu::BindGroupEntry {
163                    binding: 1,
164                    resource: wgpu::BindingResource::Sampler(&soft_sampler),
165                },
166            ],
167        });
168
169        let shader = crate::resources::builders::wgsl_module(
170            device,
171            "sprite_shader",
172            crate::resources::builders::wgsl_source!("sprite"),
173        );
174
175        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
176            label: Some("sprite_pipeline_layout"),
177            bind_group_layouts: &[&self.camera_bind_group_layout, &bgl, &soft_bgl],
178            push_constant_ranges: &[],
179        });
180
181        // Position vertex buffer: one vec3 per sprite, Instance stepping.
182        // Stored in an array so both pipeline creations can borrow from it.
183        let vert_attrs = [wgpu::VertexAttribute {
184            offset: 0,
185            shader_location: 0,
186            format: wgpu::VertexFormat::Float32x3,
187        }];
188        let vertex_buffers = [wgpu::VertexBufferLayout {
189            array_stride: 12,
190            step_mode: wgpu::VertexStepMode::Instance,
191            attributes: &vert_attrs,
192        }];
193
194        let sample_count = self.sample_count;
195        let ldr_format = self.target_format;
196        // Sprites are billboards drawn with `Less` depth test, no culling. Each
197        // variant differs only in blend mode and whether it writes depth.
198        let make_sprite = |depth_write: bool, blend: wgpu::BlendState, label: &str| {
199            crate::resources::builders::build_dual_pipeline(
200                device,
201                &crate::resources::builders::DualPipelineDesc {
202                    label,
203                    layout: &layout,
204                    shader: &shader,
205                    vertex_entry: "vs_main",
206                    fragment_entry: "fs_main",
207                    vertex_buffers: &vertex_buffers,
208                    blend: Some(blend),
209                    topology: wgpu::PrimitiveTopology::TriangleList,
210                    cull_mode: None,
211                    depth_write,
212                    depth_compare: wgpu::CompareFunction::Less,
213                    sample_count,
214                    ldr_format,
215                },
216            )
217        };
218
219        // Group 3 BGL for the lit sprite path: optional tangent-space normal
220        // map + filtering sampler. Bound by every lit batch; a 1x1 default
221        // backs the binding when no map is supplied.
222        let lit_bgl = crate::resources::builders::texture_sampler_bgl(
223            device,
224            "sprite_lit_bgl",
225            wgpu::ShaderStages::FRAGMENT,
226        );
227
228        let alpha = wgpu::BlendState::ALPHA_BLENDING;
229        let additive = crate::resources::builders::ADDITIVE_BLEND;
230        let premultiplied = crate::resources::builders::PREMULTIPLIED_BLEND;
231        self.sprite.bgl = Some(bgl);
232        self.sprite.soft_bgl = Some(soft_bgl);
233        self.sprite.soft_sampler = Some(soft_sampler);
234        self.sprite.soft_fallback_tex = Some(fallback_tex);
235        self.sprite.soft_fallback_bg = Some(fallback_bg);
236        self.sprite.pipeline = Some(make_sprite(false, alpha, "sprite_pipeline"));
237        self.sprite.pipeline_depth_write =
238            Some(make_sprite(true, alpha, "sprite_pipeline_depth_write"));
239        self.sprite.pipeline_additive =
240            Some(make_sprite(false, additive, "sprite_pipeline_additive"));
241        self.sprite.pipeline_additive_depth_write = Some(make_sprite(
242            true,
243            additive,
244            "sprite_pipeline_additive_depth_write",
245        ));
246        self.sprite.pipeline_premultiplied = Some(make_sprite(
247            false,
248            premultiplied,
249            "sprite_pipeline_premultiplied",
250        ));
251        self.sprite.pipeline_premultiplied_depth_write = Some(make_sprite(
252            true,
253            premultiplied,
254            "sprite_pipeline_premultiplied_depth_write",
255        ));
256
257        // -----------------------------------------------------------------
258        // Refractive sprite pipeline.
259        //
260        // Group 0: shared camera bindings.
261        // Group 1: shared sprite BGL (uniform / texture / sampler / instance buf).
262        // Group 2: scene-colour resolve texture + sampler.
263        //
264        // Available only on the HDR path. The LDR `paint_to` route has no
265        // resolvable scene-colour texture to sample, mirroring the
266        // soft-particle constraint.
267        let refraction_bgl = crate::resources::builders::texture_sampler_bgl(
268            device,
269            "sprite_refraction_bgl",
270            wgpu::ShaderStages::FRAGMENT,
271        );
272
273        let refraction_sampler =
274            crate::resources::builders::clamp_linear_sampler(device, "sprite_refraction_sampler");
275
276        let refraction_shader = crate::resources::builders::wgsl_module(
277            device,
278            "sprite_refraction_shader",
279            crate::resources::builders::wgsl_source!("sprite_refraction"),
280        );
281
282        let bgl_ref = self.sprite.bgl.as_ref().unwrap();
283        let refraction_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
284            label: Some("sprite_refraction_pipeline_layout"),
285            bind_group_layouts: &[&self.camera_bind_group_layout, bgl_ref, &refraction_bgl],
286            push_constant_ranges: &[],
287        });
288
289        let refraction_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
290            label: Some("sprite_refraction_pipeline"),
291            layout: Some(&refraction_layout),
292            vertex: wgpu::VertexState {
293                module: &refraction_shader,
294                entry_point: Some("vs_main"),
295                buffers: &vertex_buffers,
296                compilation_options: wgpu::PipelineCompilationOptions::default(),
297            },
298            fragment: Some(wgpu::FragmentState {
299                module: &refraction_shader,
300                entry_point: Some("fs_main"),
301                targets: &[Some(wgpu::ColorTargetState {
302                    format: wgpu::TextureFormat::Rgba16Float,
303                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
304                    write_mask: wgpu::ColorWrites::ALL,
305                })],
306                compilation_options: wgpu::PipelineCompilationOptions::default(),
307            }),
308            primitive: wgpu::PrimitiveState {
309                topology: wgpu::PrimitiveTopology::TriangleList,
310                cull_mode: None,
311                ..Default::default()
312            },
313            depth_stencil: Some(wgpu::DepthStencilState {
314                format: wgpu::TextureFormat::Depth24PlusStencil8,
315                depth_write_enabled: false,
316                depth_compare: wgpu::CompareFunction::Less,
317                stencil: wgpu::StencilState::default(),
318                bias: wgpu::DepthBiasState::default(),
319            }),
320            multisample: wgpu::MultisampleState {
321                count: sample_count,
322                ..Default::default()
323            },
324            multiview: None,
325            cache: None,
326        });
327
328        self.sprite.refraction_bgl = Some(refraction_bgl);
329        self.sprite.refraction_sampler = Some(refraction_sampler);
330        self.sprite.refraction_pipeline = Some(refraction_pipeline);
331
332        // -----------------------------------------------------------------
333        // Lit sprite pipelines.
334        //
335        // Group 0: shared camera + clip + lighting bindings (already provides
336        //          the lights uniform at binding 3 and the lights storage at
337        //          binding 13 via `camera_bind_group_layout`).
338        // Group 1: shared sprite BGL (uniform / texture / sampler / instance buf).
339        // Group 2: shared soft-particle BGL (depth + sampler). Lit sprites
340        //          honour the same per-instance soft-fade distance as the
341        //          emissive path.
342        // Group 3: new lit BGL (optional normal map + sampler).
343        let lit_shader = crate::resources::builders::wgsl_module(
344            device,
345            "sprite_lit_shader",
346            crate::resources::builders::wgsl_source!("sprite_lit"),
347        );
348
349        let sprite_bgl_ref = self.sprite.bgl.as_ref().unwrap();
350        let soft_bgl_ref = self.sprite.soft_bgl.as_ref().unwrap();
351        let lit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
352            label: Some("sprite_lit_pipeline_layout"),
353            bind_group_layouts: &[
354                &self.camera_bind_group_layout,
355                sprite_bgl_ref,
356                soft_bgl_ref,
357                &lit_bgl,
358            ],
359            push_constant_ranges: &[],
360        });
361
362        let make_lit = |depth_write: bool, blend: wgpu::BlendState, label: &str| {
363            crate::resources::builders::build_dual_pipeline(
364                device,
365                &crate::resources::builders::DualPipelineDesc {
366                    label,
367                    layout: &lit_layout,
368                    shader: &lit_shader,
369                    vertex_entry: "vs_main",
370                    fragment_entry: "fs_main",
371                    vertex_buffers: &vertex_buffers,
372                    blend: Some(blend),
373                    topology: wgpu::PrimitiveTopology::TriangleList,
374                    cull_mode: None,
375                    depth_write,
376                    depth_compare: wgpu::CompareFunction::Less,
377                    sample_count,
378                    ldr_format,
379                },
380            )
381        };
382
383        self.sprite.lit_pipeline = Some(make_lit(false, alpha, "sprite_lit_pipeline"));
384        self.sprite.lit_pipeline_depth_write =
385            Some(make_lit(true, alpha, "sprite_lit_pipeline_depth_write"));
386        self.sprite.lit_pipeline_additive =
387            Some(make_lit(false, additive, "sprite_lit_pipeline_additive"));
388        self.sprite.lit_pipeline_additive_depth_write = Some(make_lit(
389            true,
390            additive,
391            "sprite_lit_pipeline_additive_depth_write",
392        ));
393        self.sprite.lit_pipeline_premultiplied = Some(make_lit(
394            false,
395            premultiplied,
396            "sprite_lit_pipeline_premultiplied",
397        ));
398        self.sprite.lit_pipeline_premultiplied_depth_write = Some(make_lit(
399            true,
400            premultiplied,
401            "sprite_lit_pipeline_premultiplied_depth_write",
402        ));
403
404        // The fallback bind group reuses the crate-wide `fallback_normal_map`,
405        // already populated with `(128, 128, 255, 255)` for tangent-space `(0, 0, 1)`.
406        let lit_fallback_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
407            label: Some("sprite_lit_fallback_bg"),
408            layout: &lit_bgl,
409            entries: &[
410                wgpu::BindGroupEntry {
411                    binding: 0,
412                    resource: wgpu::BindingResource::TextureView(&self.fallback_normal_map_view),
413                },
414                wgpu::BindGroupEntry {
415                    binding: 1,
416                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
417                },
418            ],
419        });
420
421        self.sprite.lit_bgl = Some(lit_bgl);
422        self.sprite.lit_fallback_bg = Some(lit_fallback_bg);
423    }
424
425    /// Upload one [`SpriteItem`] to the GPU and return draw data.
426    ///
427    /// Called from `prepare()` for each non-empty item in `frame.scene.sprite_items`.
428    pub(crate) fn upload_sprite(
429        &mut self,
430        device: &wgpu::Device,
431        queue: &wgpu::Queue,
432        item: &crate::renderer::SpriteItem,
433    ) -> SpriteGpuData {
434        let count = item.positions.len() as u32;
435
436        // Position vertex buffer (one vec3 per sprite, instance-stepped).
437        let pos_bytes: Vec<u8> = item
438            .positions
439            .iter()
440            .flat_map(|p| bytemuck::bytes_of(p).iter().copied())
441            .collect();
442        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
443            label: Some("sprite_vertex_buf"),
444            size: pos_bytes.len().max(12) as u64,
445            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
446            mapped_at_creation: false,
447        });
448        queue.write_buffer(&vertex_buffer, 0, &pos_bytes);
449
450        // Per-instance storage buffer: build by zipping item vecs with defaults.
451        // Layout matches `SpriteInstance` in `sprite.wgsl`. 64 bytes per instance.
452        #[repr(C)]
453        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
454        struct GpuSpriteInstance {
455            colour: [f32; 4],
456            size: f32,
457            rotation: f32,
458            soft_distance: f32,
459            _pad1: f32,
460            uv_rect: [f32; 4],
461            velocity: [f32; 3],
462            _pad2: f32,
463        }
464
465        let instances: Vec<GpuSpriteInstance> = (0..item.positions.len())
466            .map(|i| GpuSpriteInstance {
467                colour: if i < item.colours.len() {
468                    item.colours[i]
469                } else {
470                    item.default_colour
471                },
472                size: if i < item.sizes.len() {
473                    item.sizes[i]
474                } else {
475                    item.default_size
476                },
477                rotation: if i < item.rotations.len() {
478                    item.rotations[i]
479                } else {
480                    0.0
481                },
482                soft_distance: if i < item.soft_particle_distances.len() {
483                    item.soft_particle_distances[i].max(0.0)
484                } else {
485                    0.0
486                },
487                _pad1: 0.0,
488                uv_rect: if i < item.uv_rects.len() {
489                    item.uv_rects[i]
490                } else {
491                    [0.0, 0.0, 1.0, 1.0]
492                },
493                velocity: if i < item.velocities.len() {
494                    item.velocities[i]
495                } else {
496                    [0.0, 0.0, 0.0]
497                },
498                _pad2: 0.0,
499            })
500            .collect();
501
502        let instance_bytes = bytemuck::cast_slice(&instances);
503        let instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
504            label: Some("sprite_instance_buf"),
505            size: instance_bytes.len().max(48) as u64,
506            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
507            mapped_at_creation: false,
508        });
509        queue.write_buffer(&instance_buf, 0, instance_bytes);
510
511        // Uniform buffer: model matrix + flags + soft-particle distance + orientation
512        // + refraction strength + lit parameters. Layout mirrors `SpriteUniform`
513        // in `sprite_lit.wgsl`; the emissive `sprite.wgsl` reads only the first
514        // half and ignores the trailing lit fields.
515        #[repr(C)]
516        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
517        struct SpriteUniformData {
518            model: [[f32; 4]; 4],
519            world_space: u32,
520            has_texture: u32,
521            soft_particle_distance: f32,
522            orientation: u32,
523            axis: [f32; 3],
524            refraction_strength: f32,
525            lit: u32,
526            normal_mode: u32,
527            has_normal_map: u32,
528            ambient_scale: f32,
529            roughness: f32,
530            receive_shadows: u32,
531            _pad_lit_b: u32,
532            _pad_lit_c: u32,
533        }
534
535        let (texture_view, has_texture): (&wgpu::TextureView, u32) =
536            if let Some(id) = item.texture_id {
537                if let Some(tex) = self.content.textures.get(id) {
538                    (&tex.view, 1)
539                } else {
540                    (&self.content.fallback_lut_view, 0)
541                }
542            } else {
543                (&self.content.fallback_lut_view, 0)
544            };
545
546        let orientation = match item.orientation {
547            crate::renderer::SpriteOrientation::CameraFacing => 0u32,
548            crate::renderer::SpriteOrientation::VelocityStretched => 1u32,
549            crate::renderer::SpriteOrientation::AxisLocked => 2u32,
550        };
551
552        let normal_mode = match item.lit_params.normal_mode {
553            crate::renderer::SpriteNormalMode::Spherical => 0u32,
554            crate::renderer::SpriteNormalMode::Flat => 1u32,
555            crate::renderer::SpriteNormalMode::NormalMap => 2u32,
556        };
557
558        let (normal_view, has_normal_map): (&wgpu::TextureView, u32) =
559            if let Some(id) = item.normal_texture_id {
560                if let Some(tex) = self.content.textures.get(id) {
561                    (&tex.view, 1)
562                } else {
563                    (&self.fallback_normal_map_view, 0)
564                }
565            } else {
566                (&self.fallback_normal_map_view, 0)
567            };
568
569        let uniform_data = SpriteUniformData {
570            model: item.model,
571            world_space: if item.size_mode == crate::renderer::SpriteSizeMode::WorldSpace {
572                1
573            } else {
574                0
575            },
576            has_texture,
577            soft_particle_distance: item
578                .soft_particle_distance
579                .filter(|d| *d > 0.0)
580                .unwrap_or(0.0),
581            orientation,
582            axis: item.axis,
583            refraction_strength: item.refraction_strength.filter(|s| *s > 0.0).unwrap_or(0.0),
584            lit: item.lit as u32,
585            normal_mode,
586            has_normal_map,
587            ambient_scale: item.lit_params.ambient_scale,
588            roughness: item.lit_params.roughness,
589            receive_shadows: item.lit_params.receive_shadows as u32,
590            _pad_lit_b: 0,
591            _pad_lit_c: 0,
592        };
593        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
594            label: Some("sprite_uniform_buf"),
595            size: std::mem::size_of::<SpriteUniformData>() as u64,
596            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
597            mapped_at_creation: false,
598        });
599        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
600
601        let bgl = self
602            .sprite
603            .bgl
604            .as_ref()
605            .expect("ensure_sprite_pipelines not called");
606
607        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
608            label: Some("sprite_bind_group"),
609            layout: bgl,
610            entries: &[
611                wgpu::BindGroupEntry {
612                    binding: 0,
613                    resource: uniform_buf.as_entire_binding(),
614                },
615                wgpu::BindGroupEntry {
616                    binding: 1,
617                    resource: wgpu::BindingResource::TextureView(texture_view),
618                },
619                wgpu::BindGroupEntry {
620                    binding: 2,
621                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
622                },
623                wgpu::BindGroupEntry {
624                    binding: 3,
625                    resource: instance_buf.as_entire_binding(),
626                },
627            ],
628        });
629
630        let lit_normal_bg = if item.lit {
631            self.sprite.lit_bgl.as_ref().map(|lit_bgl| {
632                device.create_bind_group(&wgpu::BindGroupDescriptor {
633                    label: Some("sprite_lit_normal_bg"),
634                    layout: lit_bgl,
635                    entries: &[
636                        wgpu::BindGroupEntry {
637                            binding: 0,
638                            resource: wgpu::BindingResource::TextureView(normal_view),
639                        },
640                        wgpu::BindGroupEntry {
641                            binding: 1,
642                            resource: wgpu::BindingResource::Sampler(&self.material_sampler),
643                        },
644                    ],
645                })
646            })
647        } else {
648            None
649        };
650
651        SpriteGpuData {
652            vertex_buffer,
653            sprite_count: count,
654            bind_group,
655            depth_write: item.depth_write,
656            blend: item.blend,
657            wireframe: false,
658            refraction_strength: item.refraction_strength.filter(|s| *s > 0.0).unwrap_or(0.0),
659            lit: item.lit,
660            lit_normal_bg,
661            _uniform_buf: uniform_buf,
662            _instance_buf: instance_buf,
663        }
664    }
665
666    /// Lazily create the sprite outline mask pipeline (R8Unorm, mask-only).
667    ///
668    /// Same bind group layout and vertex transform as the normal sprite pipeline but
669    /// outputs a flat mask value.  Must be called after `ensure_sprite_pipelines`.
670    pub(crate) fn ensure_sprite_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
671        if self.sprite.outline_mask_pipeline.is_some() {
672            return;
673        }
674        let bgl = self
675            .sprite
676            .bgl
677            .as_ref()
678            .expect("ensure_sprite_pipelines must be called first");
679
680        let shader = crate::resources::builders::wgsl_module(
681            device,
682            "sprite_outline_mask_shader",
683            crate::resources::builders::wgsl_source!("sprite_outline_mask"),
684        );
685
686        let layout = crate::resources::builders::standard_scene_layout(
687            device,
688            "sprite_outline_mask_pipeline_layout",
689            &self.camera_bind_group_layout,
690            bgl,
691        );
692
693        let vert_attrs = [wgpu::VertexAttribute {
694            offset: 0,
695            shader_location: 0,
696            format: wgpu::VertexFormat::Float32x3,
697        }];
698        let vertex_buffers = [wgpu::VertexBufferLayout {
699            array_stride: 12,
700            step_mode: wgpu::VertexStepMode::Instance,
701            attributes: &vert_attrs,
702        }];
703
704        self.sprite.outline_mask_pipeline =
705            Some(crate::resources::builders::build_outline_mask_pipeline(
706                device,
707                "sprite_outline_mask_pipeline",
708                &layout,
709                &shader,
710                wgpu::TextureFormat::R8Unorm,
711                &vertex_buffers,
712                None,
713                false,
714                wgpu::CompareFunction::Less,
715            ));
716    }
717
718    /// Pre-upload a static sprite set and return a typed handle.
719    ///
720    /// Use this for sprites whose positions, sizes, and colours never
721    /// change between frames: foliage, signage, light flares. Submit a
722    /// [`SpriteSetRefItem`](crate::renderer::SpriteSetRefItem) on
723    /// `SceneFrame::sprite_set_refs` each frame to draw the set.
724    pub fn upload_sprite_set(
725        &mut self,
726        device: &wgpu::Device,
727        queue: &wgpu::Queue,
728        item: &crate::renderer::SpriteItem,
729    ) -> crate::resources::SpriteSetId {
730        self.ensure_sprite_pipelines(device);
731        let gpu = self.upload_sprite(device, queue, item);
732        self.content.sprite_set_store.insert(gpu)
733    }
734
735    /// Remove a pre-uploaded sprite set.
736    pub fn drop_sprite_set(&mut self, id: crate::resources::SpriteSetId) -> bool {
737        self.content.sprite_set_store.remove(id)
738    }
739
740    /// Replace the contents of a pre-uploaded sprite set, keeping the same id.
741    pub fn replace_sprite_set(
742        &mut self,
743        device: &wgpu::Device,
744        queue: &wgpu::Queue,
745        id: crate::resources::SpriteSetId,
746        item: &crate::renderer::SpriteItem,
747    ) -> bool {
748        if !self.content.sprite_set_store.contains(id) {
749            return false;
750        }
751        self.ensure_sprite_pipelines(device);
752        let gpu = self.upload_sprite(device, queue, item);
753        self.content.sprite_set_store.replace(id, gpu)
754    }
755
756    /// Start an asynchronous sprite set upload.
757    pub fn begin_upload_sprite_set(
758        &mut self,
759        device: &wgpu::Device,
760        queue: &wgpu::Queue,
761        item: crate::renderer::SpriteItem,
762    ) -> crate::resources::JobId {
763        let slot = crate::resources::ResultSlot::<crate::resources::SpriteSetId>::new();
764        let slot_for_apply = slot.clone();
765        let device_for_apply = device.clone();
766        let queue_for_apply = queue.clone();
767        let id = {
768            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
769            runner.submit_cpu(move |progress| {
770                progress.set(0.9);
771                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
772                    Box::new(move |resources: &mut DeviceResources| {
773                        let sid =
774                            resources.upload_sprite_set(&device_for_apply, &queue_for_apply, &item);
775                        slot_for_apply.set(sid);
776                    }),
777                ))
778            })
779        };
780        self.job_results
781            .sprite_set
782            .lock()
783            .expect("sprite set result map poisoned")
784            .insert(id, slot);
785        id
786    }
787
788    /// Take the [`SpriteSetId`] produced by a completed
789    /// [`begin_upload_sprite_set`](Self::begin_upload_sprite_set) job.
790    pub fn upload_result_sprite_set(
791        &mut self,
792        id: crate::resources::JobId,
793    ) -> crate::error::ViewportResult<crate::resources::SpriteSetId> {
794        let mut map = self
795            .job_results
796            .sprite_set
797            .lock()
798            .expect("sprite set result map poisoned");
799        let slot = match map.get(&id) {
800            Some(s) => s.clone(),
801            None => {
802                return Err(crate::error::ViewportError::JobResultMissing {
803                    reason: "unknown id or wrong upload type",
804                });
805            }
806        };
807        match slot.take() {
808            Some(sid) => {
809                map.remove(&id);
810                Ok(sid)
811            }
812            None => Err(crate::error::ViewportError::JobNotReady),
813        }
814    }
815
816    /// Pre-upload a sprite instance set and return a typed handle.
817    ///
818    /// Use this for sprites whose definition (texture, blend, size mode)
819    /// is stable but whose instance transforms change every frame: NPCs,
820    /// item drops, damage numbers. Submit a
821    /// [`SpriteInstanceSetRefItem`](crate::renderer::SpriteInstanceSetRefItem)
822    /// on `SceneFrame::sprite_instance_set_refs` each frame.
823    ///
824    /// The current implementation pre-bakes both the definition and the
825    /// instance transforms; full per-frame instance transform override
826    /// against a stable definition is a planned follow-up.
827    pub fn upload_sprite_instance_set(
828        &mut self,
829        device: &wgpu::Device,
830        queue: &wgpu::Queue,
831        item: &crate::renderer::SpriteItem,
832    ) -> crate::resources::SpriteInstanceSetId {
833        self.ensure_sprite_pipelines(device);
834        let gpu = self.upload_sprite(device, queue, item);
835        self.content.sprite_instance_set_store.insert(gpu)
836    }
837
838    /// Remove a pre-uploaded sprite instance set.
839    pub fn drop_sprite_instance_set(&mut self, id: crate::resources::SpriteInstanceSetId) -> bool {
840        self.content.sprite_instance_set_store.remove(id)
841    }
842
843    /// Replace the contents of a pre-uploaded sprite instance set, keeping
844    /// the same id.
845    pub fn replace_sprite_instance_set(
846        &mut self,
847        device: &wgpu::Device,
848        queue: &wgpu::Queue,
849        id: crate::resources::SpriteInstanceSetId,
850        item: &crate::renderer::SpriteItem,
851    ) -> bool {
852        if !self.content.sprite_instance_set_store.contains(id) {
853            return false;
854        }
855        self.ensure_sprite_pipelines(device);
856        let gpu = self.upload_sprite(device, queue, item);
857        self.content.sprite_instance_set_store.replace(id, gpu)
858    }
859
860    /// Start an asynchronous sprite instance set upload.
861    pub fn begin_upload_sprite_instance_set(
862        &mut self,
863        device: &wgpu::Device,
864        queue: &wgpu::Queue,
865        item: crate::renderer::SpriteItem,
866    ) -> crate::resources::JobId {
867        let slot = crate::resources::ResultSlot::<crate::resources::SpriteInstanceSetId>::new();
868        let slot_for_apply = slot.clone();
869        let device_for_apply = device.clone();
870        let queue_for_apply = queue.clone();
871        let id = {
872            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
873            runner.submit_cpu(move |progress| {
874                progress.set(0.9);
875                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
876                    Box::new(move |resources: &mut DeviceResources| {
877                        let sid = resources.upload_sprite_instance_set(
878                            &device_for_apply,
879                            &queue_for_apply,
880                            &item,
881                        );
882                        slot_for_apply.set(sid);
883                    }),
884                ))
885            })
886        };
887        self.job_results
888            .sprite_instance_set
889            .lock()
890            .expect("sprite instance set result map poisoned")
891            .insert(id, slot);
892        id
893    }
894
895    /// Take the [`SpriteInstanceSetId`] produced by a completed
896    /// [`begin_upload_sprite_instance_set`](Self::begin_upload_sprite_instance_set) job.
897    pub fn upload_result_sprite_instance_set(
898        &mut self,
899        id: crate::resources::JobId,
900    ) -> crate::error::ViewportResult<crate::resources::SpriteInstanceSetId> {
901        let mut map = self
902            .job_results
903            .sprite_instance_set
904            .lock()
905            .expect("sprite instance set result map poisoned");
906        let slot = match map.get(&id) {
907            Some(s) => s.clone(),
908            None => {
909                return Err(crate::error::ViewportError::JobResultMissing {
910                    reason: "unknown id or wrong upload type",
911                });
912            }
913        };
914        match slot.take() {
915            Some(sid) => {
916                map.remove(&id);
917                Ok(sid)
918            }
919            None => Err(crate::error::ViewportError::JobNotReady),
920        }
921    }
922}
923
924/// Per-frame GPU data for one sprite batch item, created in `prepare()`.
925#[derive(Clone)]
926pub struct SpriteGpuData {
927    /// Position vertex buffer: one `vec3` per sprite, instance-stepped.
928    pub(crate) vertex_buffer: wgpu::Buffer,
929    /// Number of sprites (= draw instance count).
930    pub(crate) sprite_count: u32,
931    /// Bind group (group 1): uniform + texture + sampler + instance storage buffer.
932    pub(crate) bind_group: wgpu::BindGroup,
933    /// Whether this batch was submitted with `depth_write: true`.
934    pub(crate) depth_write: bool,
935    /// Blend mode requested by the host for this batch.
936    pub(crate) blend: crate::renderer::SpriteBlend,
937    /// When true, skip the billboard draw; the wireframe overlay polyline is rendered instead.
938    pub(crate) wireframe: bool,
939    /// Refractive distortion strength in NDC pixels; `0.0` means a regular
940    /// sprite. Routes the draw through the sprite refraction post-pass
941    /// instead of the normal sprite pass.
942    pub(crate) refraction_strength: f32,
943    /// When true, this batch was submitted with `SpriteItem::lit = true` and
944    /// is drawn through the lit sprite pipeline.
945    pub(crate) lit: bool,
946    /// Group 3 bind group for the lit normal-map binding. Always populated for
947    /// lit batches: a fallback texture is bound when no normal map is supplied
948    /// so the same pipeline layout is honoured.
949    pub(crate) lit_normal_bg: Option<wgpu::BindGroup>,
950    // Keep buffers alive for the lifetime of this struct.
951    pub(crate) _uniform_buf: wgpu::Buffer,
952    pub(crate) _instance_buf: wgpu::Buffer,
953}