Skip to main content

pebble/wgpu/
textures.rs

1use crate::{
2    assets::{handle::Handle, storage::Assets, upload::Asset},
3    ecs::system::Res,
4    wgpu::{backend::WGPUBackend, gpu_context::GpuContext, mipmap::MipmapGenerator, texture_format::TextureFormat},
5};
6
7/// Source data for [`GPUTexture`], loaded from a file or supplied as raw
8/// bytes. Fields are private — build one via the
9/// [`from_file`](Self::from_file)/[`from_data`](Self::from_data)/[`empty`](Self::empty)
10/// constructors rather than as a struct literal.
11pub struct Texture {
12    /// File to decode — `width`/`height` are inferred from the image.
13    /// Takes priority over `data` if both are set.
14    file: Option<&'static str>,
15    /// Width in pixels. Ignored when loading from `file`.
16    width: u32,
17    /// Height in pixels. Ignored when loading from `file`.
18    height: u32,
19    /// GPU pixel format to upload as. Defaults to `Rgba8UnormSrgb`.
20    format: TextureFormat,
21    /// Raw pixel bytes, used when `file` is `None`.
22    data: Option<Vec<u8>>,
23    /// Whether to generate a full mip chain (via [`MipmapGenerator`]).
24    generate_mips: bool,
25}
26
27impl Texture {
28    /// Load pixel data from a file. Width/height are inferred from the
29    /// decoded image.
30    pub fn from_file(path: &'static str) -> Self {
31        Self {
32            file: Some(path),
33            width: 0,
34            height: 0,
35            format: TextureFormat::Rgba8UnormSrgb,
36            data: None,
37            generate_mips: false,
38        }
39    }
40
41    /// Supply raw pixel bytes directly, matching `width`/`height`/`format`.
42    pub fn from_data(width: u32, height: u32, format: TextureFormat, data: Vec<u8>) -> Self {
43        Self {
44            file: None,
45            width,
46            height,
47            format,
48            data: Some(data),
49            generate_mips: false,
50        }
51    }
52
53    /// Allocate a texture on the GPU with no initial pixel data. Content is
54    /// undefined until written via [`GPUTexture::write`].
55    pub fn empty(width: u32, height: u32, format: TextureFormat) -> Self {
56        Self {
57            file: None,
58            width,
59            height,
60            format,
61            data: None,
62            generate_mips: false,
63        }
64    }
65
66    pub fn with_format(mut self, format: TextureFormat) -> Self {
67        self.format = format;
68        self
69    }
70
71    pub fn with_mips(mut self) -> Self {
72        self.generate_mips = true;
73        self
74    }
75
76    /// Logs a WARN if [`from_data`](Self::from_data) was given a zero
77    /// width/height — the resulting texture would have no pixels, almost
78    /// certainly an accidental `0` rather than an intentional one.
79    fn validate(&self) {
80        if self.data.is_some() && (self.width == 0 || self.height == 0) {
81            tracing::warn!(
82                "Texture::from_data(): width/height is 0 ({}x{}) — did you swap the argument \
83                 order, or forget to pass the real dimensions?",
84                self.width,
85                self.height,
86            );
87        }
88    }
89
90    /// Consume the builder and return the finished [`Texture`] value.
91    pub fn build(self) -> Self {
92        self.validate();
93        self
94    }
95
96    /// Consume the builder, insert into `assets` under `name`, and return
97    /// the resulting [`Handle<Texture>`].
98    pub fn build_asset(self, name: &str, assets: &mut Assets<Self>) -> Handle<Self> {
99        self.validate();
100        assets.insert(name, self)
101    }
102}
103
104/// A texture uploaded to the GPU, ready to bind (e.g. via
105/// [`BindingInstanceEntry::Texture`](super::instance::BindingInstanceEntry::Texture)).
106/// Opaque — bind it into a bind group via
107/// [`BindGroupBuilder::texture_2d`](super::buffers::BindGroupBuilder::texture_2d),
108/// there's no way to reach the underlying `wgpu::Texture`/`TextureView` from
109/// outside this crate.
110pub struct GPUTexture {
111    texture: wgpu::Texture,
112    view: wgpu::TextureView,
113    width: u32,
114    height: u32,
115    format: TextureFormat,
116    ctx: GpuContext,
117}
118
119impl GPUTexture {
120    /// Overwrites this texture's level-0 pixel data (`pixels` must match the
121    /// dimensions/format this texture was uploaded with). Mip levels beyond
122    /// 0 are *not* regenerated — if this texture was built `with_mips()`,
123    /// they'll go stale relative to the new level-0 data.
124    pub fn write(&self, pixels: &[u8]) {
125        write_texture_level0(self.ctx.queue(), &self.texture, 0, self.format.into(), self.width, self.height, pixels);
126    }
127
128    pub fn width(&self) -> u32 {
129        self.width
130    }
131
132    pub fn height(&self) -> u32 {
133        self.height
134    }
135
136    pub(crate) fn view(&self) -> &wgpu::TextureView {
137        &self.view
138    }
139}
140
141/// Overwrites one `origin_z`-indexed layer/face's level-0 pixel data (`0` for
142/// a plain [`GPUTexture`], a layer index for [`GPUTextureArray`](super::texture_array::GPUTextureArray),
143/// a face index for [`GPUCubemap`](super::cubemap::GPUCubemap)) — the one
144/// piece of `write_texture` bookkeeping shared by all three, so a future fix
145/// to it (mip handling, row alignment, ...) doesn't need to land in three
146/// places independently.
147pub(crate) fn write_texture_level0(
148    queue: &wgpu::Queue,
149    texture: &wgpu::Texture,
150    origin_z: u32,
151    format: wgpu::TextureFormat,
152    width: u32,
153    height: u32,
154    pixels: &[u8],
155) {
156    queue.write_texture(
157        wgpu::TexelCopyTextureInfo {
158            texture,
159            mip_level: 0,
160            origin: wgpu::Origin3d { x: 0, y: 0, z: origin_z },
161            aspect: wgpu::TextureAspect::All,
162        },
163        pixels,
164        wgpu::TexelCopyBufferLayout {
165            offset: 0,
166            bytes_per_row: Some(bytes_per_pixel(format) * width),
167            rows_per_image: Some(height),
168        },
169        wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
170    );
171}
172
173/// Bytes-per-pixel for every regular (non-block-compressed, non-multi-planar,
174/// non-depth/stencil) texture format — anything with a well-defined linear
175/// CPU-side byte layout, which covers every format [`decode_file`] can
176/// actually decode into plus everything reasonable to upload via
177/// [`Texture::from_data`]. Block-compressed formats (`Bc*`,
178/// `Etc2*`/`Eac*`, `Astc`) need block-aware row/height math this helper
179/// doesn't do, multi-planar formats (`NV12`/`P010`) need per-plane byte
180/// layouts, and depth/stencil formats aren't meaningful to upload arbitrary
181/// pixel bytes into in the first place — all three panic here.
182pub(crate) fn bytes_per_pixel(format: wgpu::TextureFormat) -> u32 {
183    use wgpu::TextureFormat as F;
184    match format {
185        F::R8Unorm | F::R8Snorm | F::R8Uint | F::R8Sint => 1,
186        F::R16Uint | F::R16Sint | F::R16Unorm | F::R16Snorm | F::R16Float | F::Rg8Unorm | F::Rg8Snorm
187        | F::Rg8Uint | F::Rg8Sint => 2,
188        F::R32Uint | F::R32Sint | F::R32Float | F::Rg16Uint | F::Rg16Sint | F::Rg16Unorm | F::Rg16Snorm
189        | F::Rg16Float | F::Rgba8Unorm | F::Rgba8UnormSrgb | F::Rgba8Snorm | F::Rgba8Uint | F::Rgba8Sint
190        | F::Bgra8Unorm | F::Bgra8UnormSrgb | F::Rgb10a2Uint | F::Rgb10a2Unorm | F::Rg11b10Ufloat
191        | F::Rgb9e5Ufloat => 4,
192        F::R64Uint | F::Rg32Uint | F::Rg32Sint | F::Rg32Float | F::Rgba16Uint | F::Rgba16Sint
193        | F::Rgba16Unorm | F::Rgba16Snorm | F::Rgba16Float => 8,
194        F::Rgba32Uint | F::Rgba32Sint | F::Rgba32Float => 16,
195        other => panic!(
196            "unsupported texture format for GPUTexture: {other:?} — block-compressed, \
197             multi-planar, and depth/stencil formats have no linear CPU-side pixel layout \
198             this helper can compute"
199        ),
200    }
201}
202
203/// Panics if `width`/`height` exceed this device's `max_texture_dimension_2d` — the
204/// difference between a clear message here (the actual size and the device's real limit) and
205/// an opaque wgpu validation panic deep inside `create_texture`. `what` identifies which
206/// texture type this is (`"GPUTexture"`, `"GPUCubemap"`, ...), for the panic message — none of
207/// `Texture`/`TextureArray`/`Cubemap`/`TextureBuilder` carry a debug label of their own the way
208/// `Material`/`Compute` do.
209pub(crate) fn check_texture_dimensions(device: &wgpu::Device, what: &str, width: u32, height: u32) {
210    let max = device.limits().max_texture_dimension_2d;
211    if width > max || height > max {
212        panic!("{what}: {width}x{height} exceeds this device's max_texture_dimension_2d ({max})");
213    }
214}
215
216/// Panics if `layer_count` exceeds this device's `max_texture_array_layers` — same rationale
217/// as [`check_texture_dimensions`].
218pub(crate) fn check_texture_array_layers(device: &wgpu::Device, what: &str, layer_count: u32) {
219    let max = device.limits().max_texture_array_layers;
220    if layer_count > max {
221        panic!("{what}: {layer_count} layers exceeds this device's max_texture_array_layers ({max})");
222    }
223}
224
225/// Keeps the first `channels` of every 4-channel (RGBA) pixel, dropping the rest.
226fn take_channels_u8(rgba: &[u8], channels: usize) -> Vec<u8> {
227    rgba.chunks_exact(4).flat_map(|p| p[..channels].to_vec()).collect()
228}
229
230/// Swaps the R and B bytes of every RGBA8 pixel — `image` only decodes to
231/// RGB byte order, so this is how `Bgra8*` gets its channels in the order
232/// wgpu expects.
233fn bgra_swap(rgba: &[u8]) -> Vec<u8> {
234    rgba.chunks_exact(4).flat_map(|p| [p[2], p[1], p[0], p[3]]).collect()
235}
236
237/// Keeps the first `channels` of every 4-channel `f32` pixel, packed down to
238/// half-precision floats.
239fn take_channels_f16(rgba32f: &[f32], channels: usize) -> Vec<u8> {
240    rgba32f
241        .chunks_exact(4)
242        .flat_map(|p| p[..channels].iter().flat_map(|c| half::f16::from_f32(*c).to_le_bytes()))
243        .collect()
244}
245
246/// Keeps the first `channels` of every 4-channel `f32` pixel, as raw `f32` bytes.
247fn take_channels_f32(rgba32f: &[f32], channels: usize) -> Vec<u8> {
248    rgba32f.chunks_exact(4).flat_map(|p| bytemuck::cast_slice(&p[..channels]).to_vec()).collect()
249}
250
251/// Quantizes every 4-channel `f32` pixel (expected in `[0, 1]`) down to
252/// 16-bit unsigned normalized integers.
253fn rgba32f_to_unorm16(rgba32f: &[f32]) -> Vec<u8> {
254    rgba32f
255        .iter()
256        .flat_map(|c| ((c.clamp(0.0, 1.0) * 65535.0).round() as u16).to_le_bytes())
257        .collect()
258}
259
260/// Decodes an image file into raw pixel bytes matching `format`.
261///
262/// LDR 8-bit formats (`Rgba8*`, `Bgra8*`, `R8Unorm`, `Rg8Unorm`) decode
263/// straight through `to_rgba8()`, keeping/reordering channels as needed.
264/// `Rgba16Unorm` decodes through `to_rgba32f()` and quantizes down.
265/// Float formats (`R32Float`/`Rg32Float`/`Rgba32Float`, and the 16-bit float
266/// variants) decode through `to_rgba32f()` so HDR/EXR sources outside
267/// `[0, 1]` survive, then get packed down to the requested channel count and
268/// float width.
269pub(crate) fn decode_file(path: &str, format: wgpu::TextureFormat) -> Option<(u32, u32, Vec<u8>)> {
270    use wgpu::TextureFormat as F;
271
272    let img = match image::open(path) {
273        Ok(img) => img,
274        Err(e) => {
275            tracing::error!("failed to load texture '{path}': {e}");
276            return None;
277        }
278    };
279
280    Some(match format {
281        F::Rgba8Unorm | F::Rgba8UnormSrgb => {
282            let img = img.to_rgba8();
283            let (w, h) = img.dimensions();
284            (w, h, img.into_raw())
285        }
286        F::Bgra8Unorm | F::Bgra8UnormSrgb => {
287            let img = img.to_rgba8();
288            let (w, h) = img.dimensions();
289            (w, h, bgra_swap(&img.into_raw()))
290        }
291        F::R8Unorm => {
292            let img = img.to_rgba8();
293            let (w, h) = img.dimensions();
294            (w, h, take_channels_u8(&img.into_raw(), 1))
295        }
296        F::Rg8Unorm => {
297            let img = img.to_rgba8();
298            let (w, h) = img.dimensions();
299            (w, h, take_channels_u8(&img.into_raw(), 2))
300        }
301        F::Rgba16Unorm => {
302            let img = img.to_rgba32f();
303            let (w, h) = img.dimensions();
304            (w, h, rgba32f_to_unorm16(img.into_raw().as_slice()))
305        }
306        F::Rgba32Float => {
307            let img = img.to_rgba32f();
308            let (w, h) = img.dimensions();
309            let bytes = bytemuck::cast_slice(img.into_raw().as_slice()).to_vec();
310            (w, h, bytes)
311        }
312        F::Rg32Float => {
313            let img = img.to_rgba32f();
314            let (w, h) = img.dimensions();
315            (w, h, take_channels_f32(img.into_raw().as_slice(), 2))
316        }
317        F::R32Float => {
318            let img = img.to_rgba32f();
319            let (w, h) = img.dimensions();
320            (w, h, take_channels_f32(img.into_raw().as_slice(), 1))
321        }
322        F::Rgba16Float => {
323            let img = img.to_rgba32f();
324            let (w, h) = img.dimensions();
325            (w, h, take_channels_f16(img.into_raw().as_slice(), 4))
326        }
327        F::Rg16Float => {
328            let img = img.to_rgba32f();
329            let (w, h) = img.dimensions();
330            (w, h, take_channels_f16(img.into_raw().as_slice(), 2))
331        }
332        F::R16Float => {
333            let img = img.to_rgba32f();
334            let (w, h) = img.dimensions();
335            (w, h, take_channels_f16(img.into_raw().as_slice(), 1))
336        }
337        other => panic!(
338            "unsupported texture format for GPUTexture: {other:?} — file decoding covers the \
339             regular 8/16/32-bit unorm and float formats; block-compressed and multi-planar \
340             formats aren't decodable from an ordinary image file this way"
341        ),
342    })
343}
344
345impl Asset<WGPUBackend> for GPUTexture {
346    type Source = Texture;
347    type Deps<'a> = Res<'a, MipmapGenerator>;
348
349    fn upload<'a>(
350        source: &Texture,
351        backend: &WGPUBackend,
352        mipmap_generator: &Res<'a, MipmapGenerator>,
353    ) -> Option<Self> {
354        // resolve actual pixel data + real dimensions, whether from a file or already-supplied bytes
355        let (width, height, data) = if let Some(path) = source.file {
356            let (w, h, d) = decode_file(path, source.format.into())?;
357            (w, h, Some(d))
358        } else if let Some(data) = &source.data {
359            (source.width, source.height, Some(data.clone()))
360        } else {
361            // empty texture — no initial data, content is undefined until written
362            (source.width, source.height, None)
363        };
364
365        check_texture_dimensions(&backend.device, "GPUTexture", width, height);
366
367        let mip_count = super::mipmap::mip_count(width.max(height), source.generate_mips);
368
369        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
370            label: None,
371            size: wgpu::Extent3d {
372                width,
373                height,
374                depth_or_array_layers: 1,
375            },
376            mip_level_count: mip_count, // room allocated for all levels now
377            sample_count: 1,
378            dimension: wgpu::TextureDimension::D2,
379            format: source.format.into(),
380            usage: super::mipmap::texture_usage(mip_count),
381            view_formats: &[],
382        });
383
384        if let Some(data) = &data {
385            // upload level 0 only — fast, synchronous, matches the deferred-mip decision
386            backend.queue.write_texture(
387                wgpu::TexelCopyTextureInfo {
388                    texture: &texture,
389                    mip_level: 0,
390                    origin: wgpu::Origin3d::default(),
391                    aspect: wgpu::TextureAspect::All,
392                },
393                data,
394                wgpu::TexelCopyBufferLayout {
395                    offset: 0,
396                    bytes_per_row: Some(bytes_per_pixel(source.format.into()) * width),
397                    rows_per_image: Some(height),
398                },
399                wgpu::Extent3d {
400                    width,
401                    height,
402                    depth_or_array_layers: 1,
403                },
404            );
405        }
406
407        if mip_count > 1 {
408            mipmap_generator.generate_mips(
409                &backend.device,
410                &backend.queue,
411                &texture,
412                source.format.into(),
413                mip_count,
414                1,
415            );
416        }
417
418        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
419        Some(Self {
420            texture,
421            view,
422            width,
423            height,
424            format: source.format,
425            ctx: GpuContext::from_backend(backend),
426        })
427    }
428}
429
430crate::wgpu::plugin_macros::mipmap_asset_plugin! {
431    /// Registers the [`GPUTexture`] asset pipeline (`Assets<Texture>`
432    /// → `ProcessedAssets<GPUTexture>`), plus the [`MipmapGenerator`] it depends
433    /// on for `generate_mips`. Included by
434    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
435    /// assembling the `wgpu` module's plugins by hand.
436    TexturePlugin, GPUTexture
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::wgpu::test_util::with_device;
443
444    #[test]
445    fn dimensions_within_the_limit_do_not_panic() {
446        with_device!(device, _queue, {
447            check_texture_dimensions(&device, "GPUTexture", 64, 64);
448        });
449    }
450
451    #[test]
452    fn dimensions_exceeding_the_limit_panic() {
453        with_device!(device, _queue, {
454            let too_big = device.limits().max_texture_dimension_2d + 1;
455            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
456                check_texture_dimensions(&device, "GPUTexture", too_big, 64);
457            }));
458            assert!(result.is_err(), "expected a panic for a width exceeding max_texture_dimension_2d");
459        });
460    }
461
462    #[test]
463    fn layer_count_within_the_limit_does_not_panic() {
464        with_device!(device, _queue, {
465            check_texture_array_layers(&device, "GPUTextureArray", 4);
466        });
467    }
468
469    #[test]
470    fn layer_count_exceeding_the_limit_panics() {
471        with_device!(device, _queue, {
472            let too_many = device.limits().max_texture_array_layers + 1;
473            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
474                check_texture_array_layers(&device, "GPUTextureArray", too_many);
475            }));
476            assert!(result.is_err(), "expected a panic for layer_count exceeding max_texture_array_layers");
477        });
478    }
479}