Skip to main content

viewport_lib/resources/material/
textures.rs

1use crate::resources::*;
2
3impl DeviceResources {
4    /// Upload an RGBA texture to the GPU and return its texture ID.
5    ///
6    /// The ID can be stored in `Material::texture_id` to apply the texture to objects.
7    /// `rgba_data` must be exactly `width * height * 4` bytes in RGBA8 format.
8    ///
9    /// # Errors
10    ///
11    /// Returns [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData) if the data length is incorrect.
12    pub fn upload_texture(
13        &mut self,
14        device: &wgpu::Device,
15        queue: &wgpu::Queue,
16        width: u32,
17        height: u32,
18        rgba_data: &[u8],
19    ) -> crate::error::ViewportResult<crate::resources::TextureId> {
20        // Sync wrapper around the async path: build the job, drive it to
21        // completion on the calling thread, and take the typed result. The
22        // worker performs the same texture creation, sampler setup, and
23        // bind-group build that the apply closure runs from
24        // `process_uploads`. The data is copied into an owned `Vec` for the
25        // worker thread; small textures absorb this trivially, large
26        // textures pay one extra memcpy.
27        let id = self.begin_upload_texture(device, queue, width, height, rgba_data.to_vec())?;
28        self.drain_until(device, queue, id)?;
29        self.upload_result_texture(id)
30    }
31
32    /// Upload an RGBA texture as a normal map and return its texture ID.
33    ///
34    /// Uses Rgba8Unorm format (not sRGB) so values are linear : required for correct
35    /// normal map decoding. `rgba_data` must be `width * height * 4` bytes.
36    pub fn upload_normal_map(
37        &mut self,
38        device: &wgpu::Device,
39        queue: &wgpu::Queue,
40        width: u32,
41        height: u32,
42        rgba_data: &[u8],
43    ) -> crate::error::ViewportResult<crate::resources::TextureId> {
44        // Sync wrapper; see `upload_texture` for the worker / apply layout.
45        let id = self.begin_upload_normal_map(device, queue, width, height, rgba_data.to_vec())?;
46        self.drain_until(device, queue, id)?;
47        self.upload_result_texture(id)
48    }
49
50    // -----------------------------------------------------------------------
51    // Async texture upload (routed through the upload-job runner)
52    // -----------------------------------------------------------------------
53
54    /// Start an asynchronous albedo texture upload.
55    ///
56    /// Returns a `JobId` immediately. The texture and bind group are built
57    /// on a worker thread; `queue.write_texture` queues the pixel copy and
58    /// the runner gates the job on a fresh submission that flushes those
59    /// writes. Once the status is `Ready`, take the resulting texture id
60    /// with `upload_result_texture` and store it in `Material::texture_id`.
61    ///
62    /// `rgba` transfers into the worker; clone at the call site to retain
63    /// it. Format and binding match the synchronous `upload_texture`.
64    ///
65    /// # Errors
66    ///
67    /// Returns
68    /// [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData)
69    /// when `rgba.len() != width * height * 4`, reported before any job is
70    /// submitted.
71    pub fn begin_upload_texture(
72        &mut self,
73        device: &wgpu::Device,
74        queue: &wgpu::Queue,
75        width: u32,
76        height: u32,
77        rgba: Vec<u8>,
78    ) -> crate::error::ViewportResult<crate::resources::JobId> {
79        let expected = (width * height * 4) as usize;
80        if rgba.len() != expected {
81            return Err(crate::error::ViewportError::InvalidTextureData {
82                expected,
83                actual: rgba.len(),
84            });
85        }
86        Ok(self.spawn_texture_upload(
87            device,
88            queue,
89            TextureUploadSpec {
90                width,
91                height,
92                format: wgpu::TextureFormat::Rgba8UnormSrgb,
93                is_normal_map: false,
94                mip_levels: vec![rgba],
95            },
96        ))
97    }
98
99    /// Start an asynchronous normal-map upload.
100    ///
101    /// Same shape as `begin_upload_texture`, but the texture is created
102    /// with the linear `Rgba8Unorm` format and bound into the normal-map
103    /// slot. Take the result with `upload_result_texture` once `Ready`.
104    ///
105    /// # Errors
106    ///
107    /// Same as `begin_upload_texture`.
108    pub fn begin_upload_normal_map(
109        &mut self,
110        device: &wgpu::Device,
111        queue: &wgpu::Queue,
112        width: u32,
113        height: u32,
114        rgba: Vec<u8>,
115    ) -> crate::error::ViewportResult<crate::resources::JobId> {
116        let expected = (width * height * 4) as usize;
117        if rgba.len() != expected {
118            return Err(crate::error::ViewportError::InvalidTextureData {
119                expected,
120                actual: rgba.len(),
121            });
122        }
123        Ok(self.spawn_texture_upload(
124            device,
125            queue,
126            TextureUploadSpec {
127                width,
128                height,
129                format: wgpu::TextureFormat::Rgba8Unorm,
130                is_normal_map: true,
131                mip_levels: vec![rgba],
132            },
133        ))
134    }
135
136    /// Take the texture id produced by a completed `begin_upload_texture`
137    /// or `begin_upload_normal_map` job.
138    ///
139    /// Returns `JobNotReady` while the upload is still in flight, and
140    /// `JobResultMissing` for ids that have already been taken, were
141    /// issued by a different upload type, or never existed.
142    pub fn upload_result_texture(
143        &mut self,
144        id: crate::resources::JobId,
145    ) -> crate::error::ViewportResult<crate::resources::TextureId> {
146        let mut map = self
147            .job_results
148            .texture
149            .lock()
150            .expect("texture result map poisoned");
151        let slot = match map.get(&id) {
152            Some(s) => s.clone(),
153            None => {
154                return Err(crate::error::ViewportError::JobResultMissing {
155                    reason: "unknown id or wrong upload type",
156                });
157            }
158        };
159        match slot.take() {
160            Some(tex_id) => {
161                map.remove(&id);
162                Ok(tex_id)
163            }
164            None => Err(crate::error::ViewportError::JobNotReady),
165        }
166    }
167
168    /// Upload a pre-compressed, pre-mipped texture and return its texture ID.
169    ///
170    /// Sync wrapper around [`begin_upload_compressed_texture`](Self::begin_upload_compressed_texture):
171    /// see that method for the format and layout requirements. Blocks the
172    /// calling thread until the upload completes.
173    ///
174    /// # Errors
175    ///
176    /// See [`begin_upload_compressed_texture`](Self::begin_upload_compressed_texture).
177    pub fn upload_compressed_texture(
178        &mut self,
179        device: &wgpu::Device,
180        queue: &wgpu::Queue,
181        desc: CompressedTextureDesc<'_>,
182    ) -> crate::error::ViewportResult<crate::resources::TextureId> {
183        let id = self.begin_upload_compressed_texture(device, queue, desc)?;
184        self.drain_until(device, queue, id)?;
185        self.upload_result_texture(id)
186    }
187
188    /// Start an asynchronous upload of pre-compressed, pre-mipped texture data.
189    ///
190    /// Returns a `JobId` immediately; take the resulting texture id with
191    /// [`upload_result_texture`](Self::upload_result_texture) once the status is
192    /// `Ready`, then store it in a `Material` slot. The data is validated and
193    /// copied into the worker before the job is submitted.
194    ///
195    /// # Errors
196    ///
197    /// Returns
198    /// [`ViewportError::UnsupportedTextureFormat`](crate::error::ViewportError::UnsupportedTextureFormat)
199    /// when `desc.format` is not block-compressed or the device lacks its
200    /// required feature (check first with [`supports_texture_format`]), and
201    /// [`ViewportError::InvalidCompressedTextureData`](crate::error::ViewportError::InvalidCompressedTextureData)
202    /// when `mip_levels` is empty or a level's byte length does not match its
203    /// block-packed size.
204    pub fn begin_upload_compressed_texture(
205        &mut self,
206        device: &wgpu::Device,
207        queue: &wgpu::Queue,
208        desc: CompressedTextureDesc<'_>,
209    ) -> crate::error::ViewportResult<crate::resources::JobId> {
210        if !desc.format.is_compressed() {
211            return Err(crate::error::ViewportError::UnsupportedTextureFormat {
212                format: desc.format,
213            });
214        }
215        // wgpu requires block-compressed textures to be created with
216        // block-aligned base dimensions. Reject early with a clear error rather
217        // than letting `create_texture` fail on the worker (which would leave an
218        // invalid texture bound into a bind group). Checked before device
219        // support so the caller gets the specific reason either way.
220        let (block_w, block_h) = desc.format.block_dimensions();
221        if desc.width % block_w != 0 || desc.height % block_h != 0 {
222            return Err(
223                crate::error::ViewportError::CompressedTextureNotBlockAligned {
224                    width: desc.width,
225                    height: desc.height,
226                    block_width: block_w,
227                    block_height: block_h,
228                },
229            );
230        }
231        if !supports_texture_format(device, desc.format) {
232            return Err(crate::error::ViewportError::UnsupportedTextureFormat {
233                format: desc.format,
234            });
235        }
236        if desc.mip_levels.is_empty() {
237            let (_, _, expected) = mip_block_layout(desc.format, desc.width, desc.height);
238            return Err(crate::error::ViewportError::InvalidCompressedTextureData {
239                level: 0,
240                expected,
241                actual: 0,
242            });
243        }
244        // Validate each level against its block-packed size and copy into owned
245        // buffers for the worker thread.
246        let mut mip_levels = Vec::with_capacity(desc.mip_levels.len());
247        for (level, data) in desc.mip_levels.iter().enumerate() {
248            let lw = (desc.width >> level).max(1);
249            let lh = (desc.height >> level).max(1);
250            let (_, _, expected) = mip_block_layout(desc.format, lw, lh);
251            if data.len() != expected {
252                return Err(crate::error::ViewportError::InvalidCompressedTextureData {
253                    level: level as u32,
254                    expected,
255                    actual: data.len(),
256                });
257            }
258            mip_levels.push(data.to_vec());
259        }
260        Ok(self.spawn_texture_upload(
261            device,
262            queue,
263            TextureUploadSpec {
264                width: desc.width,
265                height: desc.height,
266                format: desc.format,
267                is_normal_map: desc.is_normal_map,
268                mip_levels,
269            },
270        ))
271    }
272
273    /// Shared spawn path for the RGBA8 and compressed upload entry points.
274    /// The spec carries the texture format, dimensions, mip chain, and the
275    /// slot (albedo vs normal map) the texture occupies.
276    fn spawn_texture_upload(
277        &mut self,
278        device: &wgpu::Device,
279        queue: &wgpu::Queue,
280        spec: TextureUploadSpec,
281    ) -> crate::resources::JobId {
282        let slot = crate::resources::ResultSlot::<crate::resources::TextureId>::new();
283        let slot_for_apply = slot.clone();
284
285        // Clone the fallback views and the bind-group layout into the
286        // worker so it can build the GpuTexture and bind group without
287        // touching `self` from the worker thread.
288        let bgl = self.texture_bind_group_layout.clone();
289        let fallback_albedo_view = self.fallback_texture.view.clone();
290        let fallback_normal_view = self.fallback_normal_map_view.clone();
291        let fallback_ao_view = self.fallback_ao_map_view.clone();
292        let data_bytes: u64 = spec.mip_levels.iter().map(|l| l.len() as u64).sum();
293
294        let TextureUploadSpec {
295            width,
296            height,
297            format,
298            is_normal_map,
299            mip_levels,
300        } = spec;
301
302        let id = {
303            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
304            runner.submit_with_gpu(device, queue, move |dev, q, progress| {
305                progress.set(0.2);
306                let gpu_texture = build_gpu_texture(
307                    dev,
308                    q,
309                    width,
310                    height,
311                    format,
312                    is_normal_map,
313                    &mip_levels,
314                    &bgl,
315                    &fallback_albedo_view,
316                    &fallback_normal_view,
317                    &fallback_ao_view,
318                );
319                progress.set(0.9);
320
321                // Flush so the runner has a submission to gate on. Implicit
322                // writes queued above are folded into this submit by wgpu.
323                let encoder = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor {
324                    label: Some("async_texture_flush"),
325                });
326                let submission = q.submit(std::iter::once(encoder.finish()));
327                progress.set(1.0);
328
329                Ok(
330                    crate::resources::upload_jobs::JobProduct::with_gpu_and_apply(
331                        submission,
332                        Box::new(move |resources: &mut DeviceResources| {
333                            let tex_id = resources.content.textures.insert(gpu_texture, data_bytes);
334                            slot_for_apply.set(tex_id);
335                        }),
336                    ),
337                )
338            })
339        };
340
341        self.job_results
342            .texture
343            .lock()
344            .expect("texture result map poisoned")
345            .insert(id, slot);
346        id
347    }
348
349    // -----------------------------------------------------------------------
350    // VRAM budget query
351    // -----------------------------------------------------------------------
352
353    /// Current GPU memory usage for user-uploaded textures.
354    ///
355    /// Counts bytes from `upload_texture`, `upload_normal_map`, and the
356    /// async upload entries. Internal resources (shadow maps, colourmaps,
357    /// IBL, post-processing targets) are not included.
358    pub fn texture_memory_stats(&self) -> TextureMemoryStats {
359        TextureMemoryStats {
360            used_bytes: self.content.textures.allocated_bytes(),
361            texture_count: self.content.textures.len() as u32,
362        }
363    }
364
365    /// Release a user-uploaded texture, reclaiming its slot and GPU memory.
366    ///
367    /// Drops the `GpuTexture` (wgpu defers the real free until in-flight
368    /// commands that reference it complete), bumps the slot generation so `id`
369    /// no longer resolves, and evicts the cached bind groups that named the
370    /// texture: the shared `material_bind_groups` / `instance_bind_groups`
371    /// entries whose key contains `id`, and any per-mesh object bind group built
372    /// against it (invalidated so the next `prepare` rebinds the fallback).
373    ///
374    /// Returns `true` if a texture was released, `false` if `id` did not resolve
375    /// to a live texture (already freed, never uploaded, or a stale handle).
376    /// Materials still holding `id` are not rewritten; they fall back to the
377    /// fallback texture until reassigned.
378    pub fn free_texture(&mut self, id: crate::resources::TextureId) -> bool {
379        if !self.content.textures.remove(id) {
380            return false;
381        }
382        self.evict_texture_bind_group_caches(id.raw());
383        true
384    }
385
386    /// Replace the pixels of an already-uploaded texture in place, keeping the
387    /// same `TextureId`.
388    ///
389    /// The handle stays valid: materials and items holding `id` pick up the new
390    /// pixels on the next frame with no reassignment. The generation check is the
391    /// in-flight guard, so a stale handle (its slot freed and reused) returns
392    /// `StaleHandle` instead of overwriting whatever now occupies the slot. Use
393    /// this for content that changes over time (a streamed or animated texture)
394    /// where re-uploading and reassigning a fresh id would be wasteful.
395    ///
396    /// The texture is recreated as an `Rgba8UnormSrgb` albedo texture, matching
397    /// [`upload_texture`](Self::upload_texture); `rgba_data` must be exactly
398    /// `width * height * 4` bytes. Dimensions and format need not match the
399    /// original upload.
400    ///
401    /// # Errors
402    ///
403    /// [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData)
404    /// if the data length is wrong, or
405    /// [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
406    /// if `id` does not resolve to a live texture.
407    pub fn replace_texture(
408        &mut self,
409        device: &wgpu::Device,
410        queue: &wgpu::Queue,
411        id: crate::resources::TextureId,
412        width: u32,
413        height: u32,
414        rgba_data: &[u8],
415    ) -> crate::error::ViewportResult<()> {
416        let expected = (width * height * 4) as usize;
417        if rgba_data.len() != expected {
418            return Err(crate::error::ViewportError::InvalidTextureData {
419                expected,
420                actual: rgba_data.len(),
421            });
422        }
423        let gpu_texture = build_gpu_texture(
424            device,
425            queue,
426            width,
427            height,
428            wgpu::TextureFormat::Rgba8UnormSrgb,
429            false,
430            std::slice::from_ref(&rgba_data.to_vec()),
431            &self.texture_bind_group_layout,
432            &self.fallback_texture.view,
433            &self.fallback_normal_map_view,
434            &self.fallback_ao_map_view,
435        );
436        let bytes = rgba_data.len() as u64;
437        if self
438            .content
439            .textures
440            .replace(id, gpu_texture, bytes)
441            .is_none()
442        {
443            let (index, count) = (id.index(), self.content.textures.len());
444            return Err(crate::error::ViewportError::StaleHandle { index, count });
445        }
446        // The slot's view changed, so bind groups cached against this id now
447        // sample the old texture. Evict them exactly as `free_texture` does; they
448        // rebuild against the new view on the next `prepare`.
449        self.evict_texture_bind_group_caches(id.raw());
450        Ok(())
451    }
452
453    /// Drop cached bind groups that named user texture slot `raw` so they rebuild
454    /// against the current occupant (or the fallback). Shared by `free_texture`
455    /// (slot now empty) and `replace_texture` (slot holds a new view).
456    fn evict_texture_bind_group_caches(&mut self, raw: u64) {
457        self.content
458            .material_bind_groups
459            .retain(|&(a, n, ao), _| a != raw && n != raw && ao != raw);
460        self.instancing
461            .bind_groups
462            .retain(|&(a, n, ao), _| a != raw && n != raw && ao != raw);
463
464        // Invalidate per-mesh object bind groups that sampled the texture so
465        // `update_mesh_texture_bind_group` rebuilds them. `last_tex_key`
466        // positions holding user-texture ids are albedo, normal, ao,
467        // metallic-roughness, and emissive.
468        const INVALID_KEY: (u64, u64, u64, u64, u64, u64, u64, u64, u64, u64, u64) = (
469            u64::MAX,
470            u64::MAX,
471            u64::MAX,
472            u64::MAX,
473            u64::MAX,
474            u64::MAX,
475            u64::MAX,
476            u64::MAX,
477            u64::MAX,
478            u64::MAX,
479            u64::MAX,
480        );
481        for (_, mesh) in self.mesh_store.iter_mut() {
482            let k = mesh.last_tex_key;
483            if k.0 == raw || k.1 == raw || k.2 == raw || k.7 == raw || k.8 == raw {
484                mesh.last_tex_key = INVALID_KEY;
485            }
486        }
487    }
488}
489
490/// Everything `spawn_texture_upload` needs to create and fill a texture.
491/// The RGBA8 entry points build a single-level spec; the compressed entry
492/// point builds one with the caller's format and full mip chain.
493struct TextureUploadSpec {
494    width: u32,
495    height: u32,
496    format: wgpu::TextureFormat,
497    /// Bind into the normal-map slot and label as such.
498    is_normal_map: bool,
499    /// One entry per mip level, level 0 first.
500    mip_levels: Vec<Vec<u8>>,
501}
502
503/// Block-packed layout for a single mip level: `(bytes_per_row, rows_of_blocks,
504/// total_bytes)`. Uses the format's block dimensions and size, so it is correct
505/// for uncompressed formats (1x1 blocks) and block-compressed formats alike.
506fn mip_block_layout(
507    format: wgpu::TextureFormat,
508    level_width: u32,
509    level_height: u32,
510) -> (u32, u32, usize) {
511    let (block_w, block_h) = format.block_dimensions();
512    let block_bytes = format
513        .block_copy_size(None)
514        .expect("texture format has no single block-copy size");
515    let blocks_x = level_width.div_ceil(block_w);
516    let blocks_y = level_height.div_ceil(block_h);
517    let bytes_per_row = blocks_x * block_bytes;
518    let total = (blocks_x * blocks_y * block_bytes) as usize;
519    (bytes_per_row, blocks_y, total)
520}
521
522/// Build a `GpuTexture` (texture, view, sampler, bind group) from one or more
523/// mip levels. Shared by the async upload worker and the synchronous
524/// `replace_texture` path so both create identical resources.
525///
526/// `mip_levels` holds level 0 first; a single level keeps nearest-mip sampling,
527/// more than one enables trilinear. `is_normal_map` selects which bind-group
528/// slot the new view occupies (albedo slot 0 or normal slot 2), with the other
529/// colour slots filled by the fallback views.
530#[allow(clippy::too_many_arguments)]
531fn build_gpu_texture(
532    device: &wgpu::Device,
533    queue: &wgpu::Queue,
534    width: u32,
535    height: u32,
536    format: wgpu::TextureFormat,
537    is_normal_map: bool,
538    mip_levels: &[Vec<u8>],
539    bgl: &wgpu::BindGroupLayout,
540    fallback_albedo_view: &wgpu::TextureView,
541    fallback_normal_view: &wgpu::TextureView,
542    fallback_ao_view: &wgpu::TextureView,
543) -> GpuTexture {
544    let tex_label = if is_normal_map {
545        "user_normal_map_texture"
546    } else {
547        "user_texture"
548    };
549    let mip_level_count = mip_levels.len() as u32;
550    let texture = device.create_texture(&wgpu::TextureDescriptor {
551        label: Some(tex_label),
552        size: wgpu::Extent3d {
553            width,
554            height,
555            depth_or_array_layers: 1,
556        },
557        mip_level_count,
558        sample_count: 1,
559        dimension: wgpu::TextureDimension::D2,
560        format,
561        usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
562        view_formats: &[],
563    });
564    // Upload each mip level. Row/size math is block-based so it is correct for
565    // both uncompressed (1x1 blocks) and block-compressed formats.
566    for (level, data) in mip_levels.iter().enumerate() {
567        let lw = (width >> level).max(1);
568        let lh = (height >> level).max(1);
569        let (bytes_per_row, blocks_high, _) = mip_block_layout(format, lw, lh);
570        queue.write_texture(
571            wgpu::TexelCopyTextureInfo {
572                texture: &texture,
573                mip_level: level as u32,
574                origin: wgpu::Origin3d::ZERO,
575                aspect: wgpu::TextureAspect::All,
576            },
577            data,
578            wgpu::TexelCopyBufferLayout {
579                offset: 0,
580                bytes_per_row: Some(bytes_per_row),
581                rows_per_image: Some(blocks_high),
582            },
583            wgpu::Extent3d {
584                width: lw,
585                height: lh,
586                depth_or_array_layers: 1,
587            },
588        );
589    }
590
591    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
592    let mipmap_filter = if mip_level_count > 1 {
593        wgpu::FilterMode::Linear
594    } else {
595        wgpu::FilterMode::Nearest
596    };
597    let sampler_label = if is_normal_map {
598        "user_normal_map_sampler"
599    } else {
600        "user_texture_sampler"
601    };
602    let sampler =
603        crate::resources::builders::repeat_linear_sampler(device, sampler_label, mipmap_filter);
604    let (slot0_view, slot2_view) = if is_normal_map {
605        (fallback_albedo_view, &view)
606    } else {
607        (&view, fallback_normal_view)
608    };
609    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
610        label: Some(if is_normal_map {
611            "user_normal_map_bg"
612        } else {
613            "user_texture_bg"
614        }),
615        layout: bgl,
616        entries: &[
617            wgpu::BindGroupEntry {
618                binding: 0,
619                resource: wgpu::BindingResource::TextureView(slot0_view),
620            },
621            wgpu::BindGroupEntry {
622                binding: 1,
623                resource: wgpu::BindingResource::Sampler(&sampler),
624            },
625            wgpu::BindGroupEntry {
626                binding: 2,
627                resource: wgpu::BindingResource::TextureView(slot2_view),
628            },
629            wgpu::BindGroupEntry {
630                binding: 3,
631                resource: wgpu::BindingResource::TextureView(fallback_ao_view),
632            },
633        ],
634    });
635    GpuTexture {
636        texture,
637        view,
638        sampler,
639        bind_group,
640    }
641}
642
643/// True when `device` can sample `format`.
644///
645/// Checks the format's required wgpu feature (for example
646/// `TEXTURE_COMPRESSION_BC`, `_ASTC`, or `_ETC2` for block-compressed
647/// formats). Call this before `upload_compressed_texture` to decide whether to
648/// hand the renderer compressed data or fall back to an uncompressed upload.
649pub fn supports_texture_format(device: &wgpu::Device, format: wgpu::TextureFormat) -> bool {
650    device.features().contains(format.required_features())
651}
652
653/// Pre-compressed, pre-mipped texture data, uploaded to the GPU as-is.
654///
655/// The library does no encoding or decoding: compress and build the mip chain
656/// offline in your asset pipeline, then hand the block bytes here. `format`
657/// must be a block-compressed format (BC, ASTC, or ETC2) that the device
658/// supports (check with [`supports_texture_format`]). `mip_levels` holds one
659/// tightly block-packed byte slice per level, level 0 (full size) first;
660/// `mip_levels.len()` becomes the texture's mip count.
661///
662/// Color space is carried by `format` (for example `Bc7RgbaUnormSrgb` for
663/// albedo versus `Bc5RgUnorm` for normals); `is_normal_map` only selects which
664/// internal bind-group slot the texture occupies.
665pub struct CompressedTextureDesc<'a> {
666    /// Width of mip level 0, in texels.
667    pub width: u32,
668    /// Height of mip level 0, in texels.
669    pub height: u32,
670    /// Block-compressed texture format.
671    pub format: wgpu::TextureFormat,
672    /// Bind into the normal-map slot rather than the albedo slot.
673    pub is_normal_map: bool,
674    /// Block bytes per mip level, level 0 first.
675    pub mip_levels: &'a [&'a [u8]],
676}
677
678impl DeviceResources {
679    /// Get or create a cached material bind group for (albedo, normal_map, ao_map) texture combo.
680    ///
681    /// `u64::MAX` sentinel means "use fallback texture for that slot".
682    /// The bind group is cached in `material_bind_groups` keyed by the 3-tuple.
683    #[allow(dead_code)]
684    pub(crate) fn get_material_bind_group(
685        &mut self,
686        device: &wgpu::Device,
687        albedo_id: Option<crate::resources::TextureId>,
688        normal_map_id: Option<crate::resources::TextureId>,
689        ao_map_id: Option<crate::resources::TextureId>,
690    ) -> &wgpu::BindGroup {
691        let key = (
692            albedo_id.map(|t| t.raw()).unwrap_or(u64::MAX),
693            normal_map_id.map(|t| t.raw()).unwrap_or(u64::MAX),
694            ao_map_id.map(|t| t.raw()).unwrap_or(u64::MAX),
695        );
696
697        if !self.content.material_bind_groups.contains_key(&key) {
698            let albedo_view = match albedo_id {
699                Some(id) if self.content.textures.get(id).is_some() => {
700                    &self.content.textures.get(id).unwrap().view
701                }
702                _ => &self.fallback_texture.view,
703            };
704            let normal_view = match normal_map_id {
705                Some(id) if self.content.textures.get(id).is_some() => {
706                    &self.content.textures.get(id).unwrap().view
707                }
708                _ => &self.fallback_normal_map_view,
709            };
710            let ao_view = match ao_map_id {
711                Some(id) if self.content.textures.get(id).is_some() => {
712                    &self.content.textures.get(id).unwrap().view
713                }
714                _ => &self.fallback_ao_map_view,
715            };
716
717            let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
718                label: Some("material_bg"),
719                layout: &self.texture_bind_group_layout,
720                entries: &[
721                    wgpu::BindGroupEntry {
722                        binding: 0,
723                        resource: wgpu::BindingResource::TextureView(albedo_view),
724                    },
725                    wgpu::BindGroupEntry {
726                        binding: 1,
727                        resource: wgpu::BindingResource::Sampler(&self.material_sampler),
728                    },
729                    wgpu::BindGroupEntry {
730                        binding: 2,
731                        resource: wgpu::BindingResource::TextureView(normal_view),
732                    },
733                    wgpu::BindGroupEntry {
734                        binding: 3,
735                        resource: wgpu::BindingResource::TextureView(ao_view),
736                    },
737                ],
738            });
739            self.content.material_bind_groups.insert(key, bg);
740        }
741
742        self.content.material_bind_groups.get(&key).unwrap()
743    }
744
745    /// Rebuild `mesh.object_bind_group` so it includes the texture views, LUT, and scalar
746    /// buffer for the given material + attribute key. Called from `prepare()` when
747    /// `mesh.last_tex_key` differs from the current frame's material/attribute state.
748    ///
749    /// The bind group layout is `object_bgl`:
750    ///   binding 0 -> object uniform buffer
751    ///   binding 1 -> albedo texture view
752    ///   binding 2 -> material sampler (also used for LUT sampling)
753    ///   binding 3 -> normal map view
754    ///   binding 4 -> AO map view
755    ///   binding 5 -> LUT (colourmap) texture view
756    ///   binding 6 -> scalar attribute storage buffer
757    pub(crate) fn update_mesh_texture_bind_group(
758        &mut self,
759        device: &wgpu::Device,
760        mesh_id: crate::resources::mesh::mesh_store::MeshId,
761        albedo_id: Option<crate::resources::TextureId>,
762        normal_map_id: Option<crate::resources::TextureId>,
763        ao_map_id: Option<crate::resources::TextureId>,
764        lut_id: Option<ColourmapId>,
765        active_attr: Option<&str>,
766        matcap_id: Option<crate::resources::MatcapId>,
767        warp_attr: Option<&str>,
768        metallic_roughness_id: Option<crate::resources::TextureId>,
769        emissive_texture_id: Option<crate::resources::TextureId>,
770    ) {
771        let hash_str = |name: &str| -> u64 {
772            use std::hash::{Hash, Hasher};
773            let mut h = std::collections::hash_map::DefaultHasher::new();
774            name.hash(&mut h);
775            h.finish()
776        };
777        let attr_hash = active_attr.map(|n| hash_str(n)).unwrap_or(u64::MAX);
778        let warp_hash = warp_attr.map(|n| hash_str(n)).unwrap_or(u64::MAX);
779
780        // The last two slots track GPU position/normal override (re)bind events.
781        // Bumped by `set_*_override_buffer` / `clear_*_override`, so a fresh
782        // override forces a bind-group rebuild here.
783        let (pos_override_gen, nrm_override_gen) = {
784            let Some(mesh) = self.mesh_store.get(mesh_id) else {
785                return;
786            };
787            (mesh.position_override_gen, mesh.normal_override_gen)
788        };
789
790        let key = (
791            albedo_id.map(|t| t.raw()).unwrap_or(u64::MAX),
792            normal_map_id.map(|t| t.raw()).unwrap_or(u64::MAX),
793            ao_map_id.map(|t| t.raw()).unwrap_or(u64::MAX),
794            lut_id.map(|id| id.0 as u64).unwrap_or(u64::MAX),
795            attr_hash,
796            matcap_id.map(|id| id.index as u64).unwrap_or(u64::MAX),
797            warp_hash,
798            metallic_roughness_id.map(|t| t.raw()).unwrap_or(u64::MAX),
799            emissive_texture_id.map(|t| t.raw()).unwrap_or(u64::MAX),
800            pos_override_gen,
801            nrm_override_gen,
802        );
803
804        {
805            let Some(mesh) = self.mesh_store.get(mesh_id) else {
806                return;
807            };
808            if mesh.last_tex_key == key {
809                return;
810            }
811        }
812
813        let albedo_view = match albedo_id {
814            Some(id) if self.content.textures.get(id).is_some() => {
815                &self.content.textures.get(id).unwrap().view
816            }
817            _ => &self.fallback_texture.view,
818        };
819        let normal_view = match normal_map_id {
820            Some(id) if self.content.textures.get(id).is_some() => {
821                &self.content.textures.get(id).unwrap().view
822            }
823            _ => &self.fallback_normal_map_view,
824        };
825        let ao_view = match ao_map_id {
826            Some(id) if self.content.textures.get(id).is_some() => {
827                &self.content.textures.get(id).unwrap().view
828            }
829            _ => &self.fallback_ao_map_view,
830        };
831        let lut_view = match lut_id {
832            Some(id) if id.0 < self.content.colourmap_views.len() => {
833                &self.content.colourmap_views[id.0]
834            }
835            _ => &self.content.fallback_lut_view,
836        };
837
838        let Some(mesh) = self.mesh_store.get_mut(mesh_id) else {
839            return;
840        };
841
842        let scalar_buf: &wgpu::Buffer = match active_attr {
843            Some(name) => {
844                let found_vertex = mesh.attribute_buffers.get(name);
845                let found_face = mesh.face_attribute_buffers.get(name);
846                found_vertex
847                    .or(found_face)
848                    .unwrap_or(&self.content.fallback_scalar_buf)
849            }
850            None => &self.content.fallback_scalar_buf,
851        };
852
853        let face_colour_buf: &wgpu::Buffer = match active_attr {
854            Some(name) => mesh
855                .face_colour_buffers
856                .get(name)
857                .unwrap_or(&self.content.fallback_face_colour_buf),
858            None => &self.content.fallback_face_colour_buf,
859        };
860
861        // Resolve matcap texture view : fallback to 1x1 white when no matcap active.
862        let matcap_view: &wgpu::TextureView = match matcap_id {
863            Some(id) if id.index < self.content.matcap_views.len() => {
864                &self.content.matcap_views[id.index]
865            }
866            _ => self
867                .content
868                .fallback_matcap_view
869                .as_ref()
870                .unwrap_or(&self.fallback_texture.view),
871        };
872
873        let warp_buf: &wgpu::Buffer = match warp_attr {
874            Some(name) => mesh
875                .vector_attribute_buffers
876                .get(name)
877                .unwrap_or(&self.content.fallback_warp_buf),
878            None => &self.content.fallback_warp_buf,
879        };
880
881        let position_override_buf: &wgpu::Buffer = mesh
882            .position_override_buffer
883            .as_ref()
884            .unwrap_or(&self.content.fallback_position_override_buf);
885        let normal_override_buf: &wgpu::Buffer = mesh
886            .normal_override_buffer
887            .as_ref()
888            .unwrap_or(&self.content.fallback_normal_override_buf);
889
890        let metallic_roughness_view: &wgpu::TextureView = match metallic_roughness_id {
891            Some(id) if self.content.textures.get(id).is_some() => {
892                &self.content.textures.get(id).unwrap().view
893            }
894            _ => &self.fallback_metallic_roughness_texture_view,
895        };
896        let emissive_view: &wgpu::TextureView = match emissive_texture_id {
897            Some(id) if self.content.textures.get(id).is_some() => {
898                &self.content.textures.get(id).unwrap().view
899            }
900            _ => &self.fallback_emissive_texture_view,
901        };
902
903        mesh.object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
904            label: Some("object_bind_group"),
905            layout: &self.object_bind_group_layout,
906            entries: &[
907                wgpu::BindGroupEntry {
908                    binding: 0,
909                    resource: mesh.object_uniform_buf.as_entire_binding(),
910                },
911                wgpu::BindGroupEntry {
912                    binding: 1,
913                    resource: wgpu::BindingResource::TextureView(albedo_view),
914                },
915                wgpu::BindGroupEntry {
916                    binding: 2,
917                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
918                },
919                wgpu::BindGroupEntry {
920                    binding: 3,
921                    resource: wgpu::BindingResource::TextureView(normal_view),
922                },
923                wgpu::BindGroupEntry {
924                    binding: 4,
925                    resource: wgpu::BindingResource::TextureView(ao_view),
926                },
927                wgpu::BindGroupEntry {
928                    binding: 5,
929                    resource: wgpu::BindingResource::TextureView(lut_view),
930                },
931                wgpu::BindGroupEntry {
932                    binding: 6,
933                    resource: scalar_buf.as_entire_binding(),
934                },
935                wgpu::BindGroupEntry {
936                    binding: 7,
937                    resource: wgpu::BindingResource::TextureView(matcap_view),
938                },
939                wgpu::BindGroupEntry {
940                    binding: 8,
941                    resource: face_colour_buf.as_entire_binding(),
942                },
943                wgpu::BindGroupEntry {
944                    binding: 9,
945                    resource: warp_buf.as_entire_binding(),
946                },
947                wgpu::BindGroupEntry {
948                    binding: 10,
949                    resource: wgpu::BindingResource::Sampler(&self.lut_sampler),
950                },
951                wgpu::BindGroupEntry {
952                    binding: 11,
953                    resource: wgpu::BindingResource::TextureView(metallic_roughness_view),
954                },
955                wgpu::BindGroupEntry {
956                    binding: 12,
957                    resource: wgpu::BindingResource::TextureView(emissive_view),
958                },
959                wgpu::BindGroupEntry {
960                    binding: 13,
961                    resource: position_override_buf.as_entire_binding(),
962                },
963                wgpu::BindGroupEntry {
964                    binding: 14,
965                    resource: normal_override_buf.as_entire_binding(),
966                },
967            ],
968        });
969        mesh.last_tex_key = key;
970    }
971
972    /// Build an object bind group that pairs an external per-item uniform buffer with
973    /// the mesh's textures/LUT/matcap/scalar/override resources. Returns the bind group
974    /// and the cache key that was used to construct it.
975    ///
976    /// This mirrors the resource-resolution in `update_mesh_texture_bind_group`, but
977    /// reads from a caller-supplied uniform buffer instead of the mesh's shared
978    /// `object_uniform_buf`. The per-object draw path uses one of these per scene item
979    /// so that items sharing a `MeshId` each get their own transform.
980    pub(crate) fn build_per_item_object_bind_group(
981        &self,
982        device: &wgpu::Device,
983        mesh_id: crate::resources::mesh::mesh_store::MeshId,
984        item_uniform_buf: &wgpu::Buffer,
985        albedo_id: Option<crate::resources::TextureId>,
986        normal_map_id: Option<crate::resources::TextureId>,
987        ao_map_id: Option<crate::resources::TextureId>,
988        lut_id: Option<ColourmapId>,
989        active_attr: Option<&str>,
990        matcap_id: Option<crate::resources::MatcapId>,
991        warp_attr: Option<&str>,
992        metallic_roughness_id: Option<crate::resources::TextureId>,
993        emissive_texture_id: Option<crate::resources::TextureId>,
994        prev_key: Option<u64>,
995    ) -> Option<(wgpu::BindGroup, u64)> {
996        let hash_str = |name: &str| -> u64 {
997            use std::hash::{Hash, Hasher};
998            let mut h = std::collections::hash_map::DefaultHasher::new();
999            name.hash(&mut h);
1000            h.finish()
1001        };
1002        let attr_hash = active_attr.map(|n| hash_str(n)).unwrap_or(u64::MAX);
1003        let warp_hash = warp_attr.map(|n| hash_str(n)).unwrap_or(u64::MAX);
1004
1005        let mesh = self.mesh_store.get(mesh_id)?;
1006        let pos_override_gen = mesh.position_override_gen;
1007        let nrm_override_gen = mesh.normal_override_gen;
1008
1009        let cache_key = {
1010            use std::hash::{Hash, Hasher};
1011            let mut h = std::collections::hash_map::DefaultHasher::new();
1012            mesh_id.index().hash(&mut h);
1013            albedo_id.map(|t| t.raw()).unwrap_or(u64::MAX).hash(&mut h);
1014            normal_map_id
1015                .map(|t| t.raw())
1016                .unwrap_or(u64::MAX)
1017                .hash(&mut h);
1018            ao_map_id.map(|t| t.raw()).unwrap_or(u64::MAX).hash(&mut h);
1019            lut_id
1020                .map(|id| id.0 as u64)
1021                .unwrap_or(u64::MAX)
1022                .hash(&mut h);
1023            attr_hash.hash(&mut h);
1024            matcap_id
1025                .map(|id| id.index as u64)
1026                .unwrap_or(u64::MAX)
1027                .hash(&mut h);
1028            warp_hash.hash(&mut h);
1029            metallic_roughness_id
1030                .map(|t| t.raw())
1031                .unwrap_or(u64::MAX)
1032                .hash(&mut h);
1033            emissive_texture_id
1034                .map(|t| t.raw())
1035                .unwrap_or(u64::MAX)
1036                .hash(&mut h);
1037            pos_override_gen.hash(&mut h);
1038            nrm_override_gen.hash(&mut h);
1039            h.finish()
1040        };
1041
1042        // Cache hit: the previously built bind group is still valid, so skip the
1043        // create_bind_group below. The caller keeps its existing bind group. The
1044        // per-item uniform write happens at the call site regardless, since the
1045        // transform changes each frame.
1046        if prev_key == Some(cache_key) {
1047            return None;
1048        }
1049
1050        let albedo_view = match albedo_id {
1051            Some(id) if self.content.textures.get(id).is_some() => {
1052                &self.content.textures.get(id).unwrap().view
1053            }
1054            _ => &self.fallback_texture.view,
1055        };
1056        let normal_view = match normal_map_id {
1057            Some(id) if self.content.textures.get(id).is_some() => {
1058                &self.content.textures.get(id).unwrap().view
1059            }
1060            _ => &self.fallback_normal_map_view,
1061        };
1062        let ao_view = match ao_map_id {
1063            Some(id) if self.content.textures.get(id).is_some() => {
1064                &self.content.textures.get(id).unwrap().view
1065            }
1066            _ => &self.fallback_ao_map_view,
1067        };
1068        let lut_view = match lut_id {
1069            Some(id) if id.0 < self.content.colourmap_views.len() => {
1070                &self.content.colourmap_views[id.0]
1071            }
1072            _ => &self.content.fallback_lut_view,
1073        };
1074
1075        let scalar_buf: &wgpu::Buffer = match active_attr {
1076            Some(name) => {
1077                let found_vertex = mesh.attribute_buffers.get(name);
1078                let found_face = mesh.face_attribute_buffers.get(name);
1079                found_vertex
1080                    .or(found_face)
1081                    .unwrap_or(&self.content.fallback_scalar_buf)
1082            }
1083            None => &self.content.fallback_scalar_buf,
1084        };
1085
1086        let face_colour_buf: &wgpu::Buffer = match active_attr {
1087            Some(name) => mesh
1088                .face_colour_buffers
1089                .get(name)
1090                .unwrap_or(&self.content.fallback_face_colour_buf),
1091            None => &self.content.fallback_face_colour_buf,
1092        };
1093
1094        let matcap_view: &wgpu::TextureView = match matcap_id {
1095            Some(id) if id.index < self.content.matcap_views.len() => {
1096                &self.content.matcap_views[id.index]
1097            }
1098            _ => self
1099                .content
1100                .fallback_matcap_view
1101                .as_ref()
1102                .unwrap_or(&self.fallback_texture.view),
1103        };
1104
1105        let warp_buf: &wgpu::Buffer = match warp_attr {
1106            Some(name) => mesh
1107                .vector_attribute_buffers
1108                .get(name)
1109                .unwrap_or(&self.content.fallback_warp_buf),
1110            None => &self.content.fallback_warp_buf,
1111        };
1112
1113        let position_override_buf: &wgpu::Buffer = mesh
1114            .position_override_buffer
1115            .as_ref()
1116            .unwrap_or(&self.content.fallback_position_override_buf);
1117        let normal_override_buf: &wgpu::Buffer = mesh
1118            .normal_override_buffer
1119            .as_ref()
1120            .unwrap_or(&self.content.fallback_normal_override_buf);
1121
1122        let metallic_roughness_view: &wgpu::TextureView = match metallic_roughness_id {
1123            Some(id) if self.content.textures.get(id).is_some() => {
1124                &self.content.textures.get(id).unwrap().view
1125            }
1126            _ => &self.fallback_metallic_roughness_texture_view,
1127        };
1128        let emissive_view: &wgpu::TextureView = match emissive_texture_id {
1129            Some(id) if self.content.textures.get(id).is_some() => {
1130                &self.content.textures.get(id).unwrap().view
1131            }
1132            _ => &self.fallback_emissive_texture_view,
1133        };
1134
1135        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1136            label: Some("per_item_object_bind_group"),
1137            layout: &self.object_bind_group_layout,
1138            entries: &[
1139                wgpu::BindGroupEntry {
1140                    binding: 0,
1141                    resource: item_uniform_buf.as_entire_binding(),
1142                },
1143                wgpu::BindGroupEntry {
1144                    binding: 1,
1145                    resource: wgpu::BindingResource::TextureView(albedo_view),
1146                },
1147                wgpu::BindGroupEntry {
1148                    binding: 2,
1149                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
1150                },
1151                wgpu::BindGroupEntry {
1152                    binding: 3,
1153                    resource: wgpu::BindingResource::TextureView(normal_view),
1154                },
1155                wgpu::BindGroupEntry {
1156                    binding: 4,
1157                    resource: wgpu::BindingResource::TextureView(ao_view),
1158                },
1159                wgpu::BindGroupEntry {
1160                    binding: 5,
1161                    resource: wgpu::BindingResource::TextureView(lut_view),
1162                },
1163                wgpu::BindGroupEntry {
1164                    binding: 6,
1165                    resource: scalar_buf.as_entire_binding(),
1166                },
1167                wgpu::BindGroupEntry {
1168                    binding: 7,
1169                    resource: wgpu::BindingResource::TextureView(matcap_view),
1170                },
1171                wgpu::BindGroupEntry {
1172                    binding: 8,
1173                    resource: face_colour_buf.as_entire_binding(),
1174                },
1175                wgpu::BindGroupEntry {
1176                    binding: 9,
1177                    resource: warp_buf.as_entire_binding(),
1178                },
1179                wgpu::BindGroupEntry {
1180                    binding: 10,
1181                    resource: wgpu::BindingResource::Sampler(&self.lut_sampler),
1182                },
1183                wgpu::BindGroupEntry {
1184                    binding: 11,
1185                    resource: wgpu::BindingResource::TextureView(metallic_roughness_view),
1186                },
1187                wgpu::BindGroupEntry {
1188                    binding: 12,
1189                    resource: wgpu::BindingResource::TextureView(emissive_view),
1190                },
1191                wgpu::BindGroupEntry {
1192                    binding: 13,
1193                    resource: position_override_buf.as_entire_binding(),
1194                },
1195                wgpu::BindGroupEntry {
1196                    binding: 14,
1197                    resource: normal_override_buf.as_entire_binding(),
1198                },
1199            ],
1200        });
1201        Some((bg, cache_key))
1202    }
1203
1204    /// Upload a 256-sample RGBA colourmap to the GPU and return its `ColourmapId`.
1205    ///
1206    /// The returned ID can be stored in `SceneRenderItem::colourmap_id`.
1207    /// Use `BuiltinColourmap` variants + [`Self::builtin_colourmap_id`] for the built-in presets.
1208    pub fn upload_colourmap(
1209        &mut self,
1210        device: &wgpu::Device,
1211        queue: &wgpu::Queue,
1212        rgba_data: &[[u8; 4]; 256],
1213    ) -> ColourmapId {
1214        let texture = device.create_texture(&wgpu::TextureDescriptor {
1215            label: Some("lut_texture"),
1216            size: wgpu::Extent3d {
1217                width: 256,
1218                height: 1,
1219                depth_or_array_layers: 1,
1220            },
1221            mip_level_count: 1,
1222            sample_count: 1,
1223            dimension: wgpu::TextureDimension::D2,
1224            format: wgpu::TextureFormat::Rgba8Unorm,
1225            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1226            view_formats: &[],
1227        });
1228        let flat: Vec<u8> = rgba_data.iter().flat_map(|p| p.iter().copied()).collect();
1229        queue.write_texture(
1230            wgpu::TexelCopyTextureInfo {
1231                texture: &texture,
1232                mip_level: 0,
1233                origin: wgpu::Origin3d::ZERO,
1234                aspect: wgpu::TextureAspect::All,
1235            },
1236            &flat,
1237            wgpu::TexelCopyBufferLayout {
1238                offset: 0,
1239                bytes_per_row: Some(256 * 4),
1240                rows_per_image: Some(1),
1241            },
1242            wgpu::Extent3d {
1243                width: 256,
1244                height: 1,
1245                depth_or_array_layers: 1,
1246            },
1247        );
1248        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1249        let id = ColourmapId(self.content.colourmap_textures.len());
1250        self.content.colourmap_textures.push(texture);
1251        self.content.colourmap_views.push(view);
1252        self.content.colourmaps_cpu.push(*rgba_data);
1253        id
1254    }
1255
1256    /// Return the CPU-side colourmap LUT for `id` as 256 RGBA8 entries, or `None` if the id is invalid.
1257    ///
1258    /// Useful for any non-GPU colourmap output: PDF export, table cell colouring, custom legend
1259    /// widgets, or sampling a colour at a specific scalar value. The data is always in memory
1260    /// (kept for GPU upload) so this accessor is free.
1261    pub fn get_colourmap_rgba(&self, id: ColourmapId) -> Option<&[[u8; 4]; 256]> {
1262        self.content.colourmaps_cpu.get(id.0)
1263    }
1264
1265    /// Return the `ColourmapId` for a built-in preset.
1266    ///
1267    /// Call [`Self::ensure_colourmaps_initialized`] first (done automatically by
1268    /// `ViewportRenderer::prepare`).  Panics if colourmaps have not been initialized yet.
1269    pub fn builtin_colourmap_id(&self, preset: BuiltinColourmap) -> ColourmapId {
1270        self.content
1271            .builtin_colourmap_ids
1272            .expect("call ensure_colourmaps_initialized before using built-in colourmaps")
1273            [preset as usize]
1274    }
1275
1276    /// Ensure built-in colourmaps are uploaded to the GPU.
1277    ///
1278    /// Called automatically by `ViewportRenderer::prepare()` on the first frame.
1279    /// Safe to call multiple times : no-op after first invocation.
1280    pub fn ensure_colourmaps_initialized(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
1281        if self.content.colourmaps_initialized {
1282            return;
1283        }
1284        let viridis = self.upload_colourmap(
1285            device,
1286            queue,
1287            &crate::resources::material::colourmap_data::viridis_rgba(),
1288        );
1289        let plasma = self.upload_colourmap(
1290            device,
1291            queue,
1292            &crate::resources::material::colourmap_data::plasma_rgba(),
1293        );
1294        let greyscale = self.upload_colourmap(
1295            device,
1296            queue,
1297            &crate::resources::material::colourmap_data::greyscale_rgba(),
1298        );
1299        let coolwarm = self.upload_colourmap(
1300            device,
1301            queue,
1302            &crate::resources::material::colourmap_data::coolwarm_rgba(),
1303        );
1304        let rainbow = self.upload_colourmap(
1305            device,
1306            queue,
1307            &crate::resources::material::colourmap_data::rainbow_rgba(),
1308        );
1309        let magma = self.upload_colourmap(
1310            device,
1311            queue,
1312            &crate::resources::material::colourmap_data::magma_rgba(),
1313        );
1314        let inferno = self.upload_colourmap(
1315            device,
1316            queue,
1317            &crate::resources::material::colourmap_data::inferno_rgba(),
1318        );
1319        let turbo = self.upload_colourmap(
1320            device,
1321            queue,
1322            &crate::resources::material::colourmap_data::turbo_rgba(),
1323        );
1324        let jet = self.upload_colourmap(
1325            device,
1326            queue,
1327            &crate::resources::material::colourmap_data::jet_rgba(),
1328        );
1329        let rdbu = self.upload_colourmap(
1330            device,
1331            queue,
1332            &crate::resources::material::colourmap_data::rdbu_r_rgba(),
1333        );
1334        self.content.builtin_colourmap_ids = Some([
1335            viridis, plasma, greyscale, coolwarm, rainbow, magma, inferno, turbo, jet, rdbu,
1336        ]);
1337        self.content.colourmaps_initialized = true;
1338    }
1339
1340    // -----------------------------------------------------------------------
1341    // Matcap texture API
1342    // -----------------------------------------------------------------------
1343
1344    /// Upload a 256x256 RGBA matcap texture and return its `MatcapId`.
1345    ///
1346    /// `rgba_data` must be exactly `256 * 256 * 4 = 262_144` bytes.
1347    /// Set `blendable = true` for matcaps whose alpha channel tints the base
1348    /// geometry colour; `false` for static matcaps that fully replace the colour.
1349    ///
1350    /// # Errors
1351    ///
1352    /// Returns [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData)
1353    /// if `rgba_data` has the wrong length.
1354    pub fn upload_matcap(
1355        &mut self,
1356        device: &wgpu::Device,
1357        queue: &wgpu::Queue,
1358        rgba_data: &[u8],
1359        blendable: bool,
1360    ) -> crate::error::ViewportResult<crate::resources::MatcapId> {
1361        let (width, height) = (256u32, 256u32);
1362        let expected = (width * height * 4) as usize;
1363        if rgba_data.len() != expected {
1364            return Err(crate::error::ViewportError::InvalidTextureData {
1365                expected,
1366                actual: rgba_data.len(),
1367            });
1368        }
1369
1370        let texture = device.create_texture(&wgpu::TextureDescriptor {
1371            label: Some("matcap_texture"),
1372            size: wgpu::Extent3d {
1373                width,
1374                height,
1375                depth_or_array_layers: 1,
1376            },
1377            mip_level_count: 1,
1378            sample_count: 1,
1379            dimension: wgpu::TextureDimension::D2,
1380            format: wgpu::TextureFormat::Rgba8Unorm,
1381            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1382            view_formats: &[],
1383        });
1384        queue.write_texture(
1385            wgpu::TexelCopyTextureInfo {
1386                texture: &texture,
1387                mip_level: 0,
1388                origin: wgpu::Origin3d::ZERO,
1389                aspect: wgpu::TextureAspect::All,
1390            },
1391            rgba_data,
1392            wgpu::TexelCopyBufferLayout {
1393                offset: 0,
1394                bytes_per_row: Some(width * 4),
1395                rows_per_image: Some(height),
1396            },
1397            wgpu::Extent3d {
1398                width,
1399                height,
1400                depth_or_array_layers: 1,
1401            },
1402        );
1403
1404        // Ensure the shared clamp sampler is created.
1405        if self.content.matcap_sampler.is_none() {
1406            self.content.matcap_sampler = Some(crate::resources::builders::clamp_linear_sampler(
1407                device,
1408                "matcap_sampler",
1409            ));
1410        }
1411
1412        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1413        let index = self.content.matcap_textures.len();
1414        self.content.matcap_textures.push(texture);
1415        self.content.matcap_views.push(view);
1416
1417        // Lazily initialise the fallback matcap view to binding 7 of the
1418        // first uploaded texture (a plain white 1x1 is fine as fallback).
1419        if self.content.fallback_matcap_view.is_none() {
1420            self.content.fallback_matcap_view = Some(
1421                self.fallback_texture
1422                    .texture
1423                    .create_view(&wgpu::TextureViewDescriptor::default()),
1424            );
1425        }
1426
1427        tracing::debug!(matcap_index = index, blendable, "matcap uploaded");
1428        Ok(crate::resources::MatcapId { index, blendable })
1429    }
1430
1431    /// Return the `MatcapId` for a built-in preset.
1432    ///
1433    /// Panics if called before the renderer has run at least one prepare pass
1434    /// (which calls [`Self::ensure_matcaps_initialized`] automatically).
1435    pub fn builtin_matcap_id(
1436        &self,
1437        preset: crate::resources::BuiltinMatcap,
1438    ) -> crate::resources::MatcapId {
1439        self.content.builtin_matcap_ids
1440            .expect("call ensure_matcaps_initialized (or run one prepare frame) before using built-in matcaps")
1441            [preset as usize]
1442    }
1443
1444    /// Upload the eight built-in matcaps to the GPU if not already done.
1445    ///
1446    /// Called automatically by `ViewportRenderer::prepare()`. Safe to call
1447    /// multiple times : no-op after first invocation.
1448    pub fn ensure_matcaps_initialized(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
1449        if self.content.matcaps_initialized {
1450            return;
1451        }
1452        use crate::resources::material::matcap_data;
1453        let clay = self
1454            .upload_matcap(device, queue, &matcap_data::clay(), true)
1455            .unwrap();
1456        let wax = self
1457            .upload_matcap(device, queue, &matcap_data::wax(), true)
1458            .unwrap();
1459        let candy = self
1460            .upload_matcap(device, queue, &matcap_data::candy(), true)
1461            .unwrap();
1462        let flat = self
1463            .upload_matcap(device, queue, &matcap_data::flat(), true)
1464            .unwrap();
1465        let ceramic = self
1466            .upload_matcap(device, queue, &matcap_data::ceramic(), false)
1467            .unwrap();
1468        let jade = self
1469            .upload_matcap(device, queue, &matcap_data::jade(), false)
1470            .unwrap();
1471        let mud = self
1472            .upload_matcap(device, queue, &matcap_data::mud(), false)
1473            .unwrap();
1474        let normal = self
1475            .upload_matcap(device, queue, &matcap_data::normal(), false)
1476            .unwrap();
1477        self.content.builtin_matcap_ids =
1478            Some([clay, wax, candy, flat, ceramic, jade, mud, normal]);
1479        self.content.matcaps_initialized = true;
1480    }
1481}
1482
1483#[cfg(test)]
1484mod async_texture_tests {
1485    use crate::DeviceResources;
1486    use crate::resources::UploadStatus;
1487
1488    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1489        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1490        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1491            power_preference: wgpu::PowerPreference::LowPower,
1492            compatible_surface: None,
1493            force_fallback_adapter: false,
1494        }))
1495        .ok()?;
1496        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
1497    }
1498
1499    /// Device with `TEXTURE_COMPRESSION_BC` enabled, or `None` when the adapter
1500    /// does not support BC (e.g. a software / mobile adapter) so the caller can
1501    /// skip the test.
1502    fn try_make_bc_device() -> Option<(wgpu::Device, wgpu::Queue)> {
1503        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
1504        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1505            power_preference: wgpu::PowerPreference::LowPower,
1506            compatible_surface: None,
1507            force_fallback_adapter: false,
1508        }))
1509        .ok()?;
1510        if !adapter
1511            .features()
1512            .contains(wgpu::Features::TEXTURE_COMPRESSION_BC)
1513        {
1514            return None;
1515        }
1516        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1517            required_features: wgpu::Features::TEXTURE_COMPRESSION_BC,
1518            ..Default::default()
1519        }))
1520        .ok()
1521    }
1522
1523    fn drive_until_ready(
1524        resources: &mut DeviceResources,
1525        device: &wgpu::Device,
1526        queue: &wgpu::Queue,
1527        id: crate::resources::JobId,
1528    ) {
1529        for _ in 0..200 {
1530            resources.process_uploads(device, queue);
1531            match resources.upload_status(id) {
1532                UploadStatus::Ready => return,
1533                UploadStatus::Failed(e) => panic!("upload failed: {e:?}"),
1534                UploadStatus::Pending { .. } => {
1535                    std::thread::sleep(std::time::Duration::from_millis(5));
1536                }
1537                UploadStatus::Unknown => panic!("job id disappeared"),
1538            }
1539        }
1540        panic!("texture upload did not complete in time");
1541    }
1542
1543    #[test]
1544    fn invalid_size_errors_synchronously() {
1545        let Some((device, queue)) = try_make_device() else {
1546            eprintln!("skipping: no wgpu adapter available");
1547            return;
1548        };
1549        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1550
1551        // 2x2 image requires 16 bytes. Pass 12 and confirm the error fires
1552        // before any job is submitted.
1553        let rgba = vec![0u8; 12];
1554        let err = resources
1555            .begin_upload_texture(&device, &queue, 2, 2, rgba)
1556            .expect_err("invalid size should error");
1557        assert!(matches!(
1558            err,
1559            crate::error::ViewportError::InvalidTextureData {
1560                expected: 16,
1561                actual: 12
1562            }
1563        ));
1564        assert_eq!(resources.uploads_pending(), 0);
1565    }
1566
1567    #[test]
1568    fn begin_upload_texture_completes_and_yields_id() {
1569        let Some((device, queue)) = try_make_device() else {
1570            eprintln!("skipping: no wgpu adapter available");
1571            return;
1572        };
1573        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1574
1575        let rgba = vec![128u8; 4 * 4 * 4];
1576        let id = resources
1577            .begin_upload_texture(&device, &queue, 4, 4, rgba)
1578            .unwrap();
1579        assert_eq!(resources.uploads_pending(), 1);
1580
1581        // Result is not available until the worker finishes.
1582        let err = resources.upload_result_texture(id).unwrap_err();
1583        assert!(matches!(err, crate::error::ViewportError::JobNotReady));
1584
1585        drive_until_ready(&mut resources, &device, &queue, id);
1586
1587        let tex_id = resources.upload_result_texture(id).expect("ready result");
1588        // The first uploaded texture lands at index 0.
1589        assert_eq!(tex_id, crate::resources::TextureId(0));
1590
1591        // Taking the result again reports missing.
1592        let err = resources.upload_result_texture(id).unwrap_err();
1593        assert!(matches!(
1594            err,
1595            crate::error::ViewportError::JobResultMissing { .. }
1596        ));
1597    }
1598
1599    #[test]
1600    fn begin_upload_normal_map_routes_to_same_result_accessor() {
1601        let Some((device, queue)) = try_make_device() else {
1602            eprintln!("skipping: no wgpu adapter available");
1603            return;
1604        };
1605        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1606
1607        let rgba = vec![64u8; 8 * 8 * 4];
1608        let id = resources
1609            .begin_upload_normal_map(&device, &queue, 8, 8, rgba)
1610            .unwrap();
1611        drive_until_ready(&mut resources, &device, &queue, id);
1612        let tex_id = resources.upload_result_texture(id).expect("ready result");
1613        assert_eq!(tex_id, crate::resources::TextureId(0));
1614    }
1615
1616    #[test]
1617    fn sync_upload_still_works() {
1618        let Some((device, queue)) = try_make_device() else {
1619            eprintln!("skipping: no wgpu adapter available");
1620            return;
1621        };
1622        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1623
1624        let rgba = vec![200u8; 4 * 4 * 4];
1625        let tex_id = resources
1626            .upload_texture(&device, &queue, 4, 4, &rgba)
1627            .unwrap();
1628        assert_eq!(tex_id, crate::resources::TextureId(0));
1629    }
1630
1631    #[test]
1632    fn replace_texture_keeps_handle_and_updates_bytes() {
1633        let Some((device, queue)) = try_make_device() else {
1634            eprintln!("skipping: no wgpu adapter available");
1635            return;
1636        };
1637        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1638
1639        let id = resources
1640            .upload_texture(&device, &queue, 4, 4, &vec![200u8; 4 * 4 * 4])
1641            .unwrap();
1642        let bytes_4x4 = resources.resident_bytes().texture_bytes;
1643
1644        // Replace in place with a larger image: same handle, larger byte total.
1645        resources
1646            .replace_texture(&device, &queue, id, 8, 8, &vec![10u8; 8 * 8 * 4])
1647            .expect("replace on a live handle succeeds");
1648        assert!(resources.texture_view(id).is_some(), "handle stays valid");
1649        let bytes_8x8 = resources.resident_bytes().texture_bytes;
1650        assert!(
1651            bytes_8x8 > bytes_4x4,
1652            "replacing with a larger image must grow resident bytes"
1653        );
1654
1655        // Wrong data length is rejected before touching the slot.
1656        let err = resources
1657            .replace_texture(&device, &queue, id, 8, 8, &[0u8; 3])
1658            .unwrap_err();
1659        assert!(matches!(
1660            err,
1661            crate::error::ViewportError::InvalidTextureData { .. }
1662        ));
1663
1664        // A stale handle is rejected.
1665        assert!(resources.free_texture(id));
1666        let err = resources
1667            .replace_texture(&device, &queue, id, 4, 4, &vec![0u8; 4 * 4 * 4])
1668            .unwrap_err();
1669        assert!(matches!(
1670            err,
1671            crate::error::ViewportError::StaleHandle { .. }
1672        ));
1673    }
1674
1675    #[test]
1676    fn block_layout_matches_format() {
1677        use super::mip_block_layout;
1678        use wgpu::TextureFormat as F;
1679
1680        // Uncompressed RGBA8: 1x1 blocks, 4 bytes/texel.
1681        assert_eq!(mip_block_layout(F::Rgba8Unorm, 4, 4), (16, 4, 64));
1682
1683        // BC7: 4x4 blocks, 16 bytes/block.
1684        assert_eq!(mip_block_layout(F::Bc7RgbaUnormSrgb, 4, 4), (16, 1, 16));
1685        assert_eq!(mip_block_layout(F::Bc7RgbaUnormSrgb, 8, 8), (32, 2, 64));
1686        // Non-4-aligned dimensions round up to whole blocks.
1687        assert_eq!(mip_block_layout(F::Bc7RgbaUnormSrgb, 6, 6), (32, 2, 64));
1688
1689        // BC4: 4x4 blocks, 8 bytes/block.
1690        assert_eq!(mip_block_layout(F::Bc4RUnorm, 8, 8), (16, 2, 32));
1691
1692        // ASTC 8x8: 8x8 blocks, 16 bytes/block.
1693        let astc = F::Astc {
1694            block: wgpu::AstcBlock::B8x8,
1695            channel: wgpu::AstcChannel::Unorm,
1696        };
1697        assert_eq!(mip_block_layout(astc, 16, 16), (32, 2, 64));
1698    }
1699
1700    #[test]
1701    fn block_layout_full_mip_pyramid_sum() {
1702        use super::mip_block_layout;
1703        // 64x64 BC7 pyramid down to 1x1: 4096+1024+256+64+16+16+16.
1704        let mut total = 0usize;
1705        let (w, h) = (64u32, 64u32);
1706        for level in 0..=6 {
1707            let lw = (w >> level).max(1);
1708            let lh = (h >> level).max(1);
1709            let (_, _, bytes) = mip_block_layout(wgpu::TextureFormat::Bc7RgbaUnormSrgb, lw, lh);
1710            total += bytes;
1711        }
1712        assert_eq!(total, 5488);
1713    }
1714
1715    #[test]
1716    fn supports_texture_format_false_without_feature() {
1717        let Some((device, _queue)) = try_make_device() else {
1718            eprintln!("skipping: no wgpu adapter available");
1719            return;
1720        };
1721        // The default device requests no features, so BC is never enabled even
1722        // when the adapter could support it.
1723        assert!(!crate::resources::supports_texture_format(
1724            &device,
1725            wgpu::TextureFormat::Bc7RgbaUnormSrgb
1726        ));
1727        // An uncompressed format needs no feature and is always supported.
1728        assert!(crate::resources::supports_texture_format(
1729            &device,
1730            wgpu::TextureFormat::Rgba8Unorm
1731        ));
1732    }
1733
1734    #[test]
1735    fn compressed_upload_rejects_unsupported_and_non_block_formats() {
1736        let Some((device, queue)) = try_make_device() else {
1737            eprintln!("skipping: no wgpu adapter available");
1738            return;
1739        };
1740        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1741
1742        // BC7 without the feature: rejected up front, no job submitted.
1743        let block = vec![0u8; 16];
1744        let err = resources
1745            .begin_upload_compressed_texture(
1746                &device,
1747                &queue,
1748                crate::resources::CompressedTextureDesc {
1749                    width: 4,
1750                    height: 4,
1751                    format: wgpu::TextureFormat::Bc7RgbaUnormSrgb,
1752                    is_normal_map: false,
1753                    mip_levels: &[&block],
1754                },
1755            )
1756            .expect_err("BC7 upload without the feature should error");
1757        assert!(matches!(
1758            err,
1759            crate::error::ViewportError::UnsupportedTextureFormat { .. }
1760        ));
1761
1762        // A non-compressed format is also rejected by this path.
1763        let rgba = vec![0u8; 4 * 4 * 4];
1764        let err = resources
1765            .begin_upload_compressed_texture(
1766                &device,
1767                &queue,
1768                crate::resources::CompressedTextureDesc {
1769                    width: 4,
1770                    height: 4,
1771                    format: wgpu::TextureFormat::Rgba8Unorm,
1772                    is_normal_map: false,
1773                    mip_levels: &[&rgba],
1774                },
1775            )
1776            .expect_err("non-compressed format should error");
1777        assert!(matches!(
1778            err,
1779            crate::error::ViewportError::UnsupportedTextureFormat { .. }
1780        ));
1781        assert_eq!(resources.uploads_pending(), 0);
1782    }
1783
1784    #[test]
1785    fn compressed_upload_validates_level_lengths() {
1786        let Some((device, queue)) = try_make_bc_device() else {
1787            eprintln!("skipping: no adapter with TEXTURE_COMPRESSION_BC");
1788            return;
1789        };
1790        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1791
1792        // 4x4 BC7 needs one 16-byte block; pass 15 and confirm the level check
1793        // fires before any job is submitted.
1794        let bad = vec![0u8; 15];
1795        let err = resources
1796            .begin_upload_compressed_texture(
1797                &device,
1798                &queue,
1799                crate::resources::CompressedTextureDesc {
1800                    width: 4,
1801                    height: 4,
1802                    format: wgpu::TextureFormat::Bc7RgbaUnormSrgb,
1803                    is_normal_map: false,
1804                    mip_levels: &[&bad],
1805                },
1806            )
1807            .expect_err("wrong block length should error");
1808        assert!(matches!(
1809            err,
1810            crate::error::ViewportError::InvalidCompressedTextureData {
1811                level: 0,
1812                expected: 16,
1813                actual: 15,
1814            }
1815        ));
1816
1817        // Empty mip chain is rejected too.
1818        let err = resources
1819            .begin_upload_compressed_texture(
1820                &device,
1821                &queue,
1822                crate::resources::CompressedTextureDesc {
1823                    width: 4,
1824                    height: 4,
1825                    format: wgpu::TextureFormat::Bc7RgbaUnormSrgb,
1826                    is_normal_map: false,
1827                    mip_levels: &[],
1828                },
1829            )
1830            .expect_err("empty mip chain should error");
1831        assert!(matches!(
1832            err,
1833            crate::error::ViewportError::InvalidCompressedTextureData { actual: 0, .. }
1834        ));
1835        assert_eq!(resources.uploads_pending(), 0);
1836    }
1837
1838    #[test]
1839    fn compressed_upload_completes_and_counts_bytes() {
1840        let Some((device, queue)) = try_make_bc_device() else {
1841            eprintln!("skipping: no adapter with TEXTURE_COMPRESSION_BC");
1842            return;
1843        };
1844        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1845
1846        let before = resources.texture_memory_stats().used_bytes;
1847        // 4x4 BC7 = one 16-byte block. Contents need not decode to anything in
1848        // particular; we only exercise the upload path and byte accounting.
1849        let block = vec![0u8; 16];
1850        let id = resources
1851            .begin_upload_compressed_texture(
1852                &device,
1853                &queue,
1854                crate::resources::CompressedTextureDesc {
1855                    width: 4,
1856                    height: 4,
1857                    format: wgpu::TextureFormat::Bc7RgbaUnormSrgb,
1858                    is_normal_map: false,
1859                    mip_levels: &[&block],
1860                },
1861            )
1862            .unwrap();
1863        drive_until_ready(&mut resources, &device, &queue, id);
1864        let tex_id = resources.upload_result_texture(id).expect("ready result");
1865        assert_eq!(tex_id, crate::resources::TextureId(0));
1866
1867        let stats = resources.texture_memory_stats();
1868        assert_eq!(stats.used_bytes - before, 16);
1869        assert_eq!(stats.texture_count, 1);
1870    }
1871
1872    #[test]
1873    fn compressed_upload_rejects_non_block_aligned_dimensions() {
1874        let Some((device, queue)) = try_make_device() else {
1875            eprintln!("skipping: no wgpu adapter available");
1876            return;
1877        };
1878        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
1879
1880        // wgpu cannot create a BC texture whose dimensions are not multiples of
1881        // the 4x4 block. The upload must reject this up front (before any GPU
1882        // work) so a consumer can fall back to an uncompressed upload rather
1883        // than binding an invalid texture. 1419 mirrors a real asset dimension.
1884        for (w, h) in [(6u32, 6u32), (1419, 1024), (1024, 1419)] {
1885            let blocks_x = w.div_ceil(4);
1886            let blocks_y = h.div_ceil(4);
1887            let block = vec![0u8; (blocks_x * blocks_y * 16) as usize];
1888            let err = resources
1889                .begin_upload_compressed_texture(
1890                    &device,
1891                    &queue,
1892                    crate::resources::CompressedTextureDesc {
1893                        width: w,
1894                        height: h,
1895                        format: wgpu::TextureFormat::Bc7RgbaUnormSrgb,
1896                        is_normal_map: false,
1897                        mip_levels: &[&block],
1898                    },
1899                )
1900                .expect_err("non-block-aligned dimensions must be rejected");
1901            assert!(matches!(
1902                err,
1903                crate::error::ViewportError::CompressedTextureNotBlockAligned { .. }
1904            ));
1905        }
1906        assert_eq!(resources.uploads_pending(), 0);
1907    }
1908}
1909
1910// ---------------------------------------------------------------------------
1911// GpuTexture: GPU texture with sampler and bind group
1912// ---------------------------------------------------------------------------
1913
1914/// A GPU texture with its view, sampler, and bind group for shader binding.
1915pub struct GpuTexture {
1916    /// Underlying wgpu texture object.
1917    pub texture: wgpu::Texture,
1918    /// Full-texture view used for sampling.
1919    pub view: wgpu::TextureView,
1920    /// Sampler bound alongside the view.
1921    pub sampler: wgpu::Sampler,
1922    /// Bind group that binds `view` and `sampler` for use in shaders.
1923    pub bind_group: wgpu::BindGroup,
1924}