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