Skip to main content

pebble/wgpu/
textures.rs

1use crate::{
2    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. Prefer the [`from_file`](Self::from_file)/[`from_data`](Self::from_data)
9/// constructors over setting fields by hand.
10pub struct TextureDescriptor {
11    /// File to decode — `width`/`height` are inferred from the image.
12    /// Takes priority over `data` if both are set.
13    pub file: Option<&'static str>,
14    /// Width in pixels. Ignored when loading from `file`.
15    pub width: u32,
16    /// Height in pixels. Ignored when loading from `file`.
17    pub height: u32,
18    /// GPU pixel format to upload as. Defaults to `Rgba8UnormSrgb`.
19    pub format: TextureFormat,
20    /// Raw pixel bytes, used when `file` is `None`.
21    pub data: Option<Vec<u8>>,
22    /// Whether to generate a full mip chain (via [`MipmapGenerator`]).
23    pub generate_mips: bool,
24}
25
26impl TextureDescriptor {
27    /// Load pixel data from a file. Width/height are inferred from the
28    /// decoded image.
29    pub fn from_file(path: &'static str) -> Self {
30        Self {
31            file: Some(path),
32            width: 0,
33            height: 0,
34            format: TextureFormat::Rgba8UnormSrgb,
35            data: None,
36            generate_mips: false,
37        }
38    }
39
40    /// Supply raw pixel bytes directly, matching `width`/`height`/`format`.
41    pub fn from_data(width: u32, height: u32, format: TextureFormat, data: Vec<u8>) -> Self {
42        Self {
43            file: None,
44            width,
45            height,
46            format,
47            data: Some(data),
48            generate_mips: false,
49        }
50    }
51
52    pub fn with_format(mut self, format: TextureFormat) -> Self {
53        self.format = format;
54        self
55    }
56
57    pub fn with_mips(mut self) -> Self {
58        self.generate_mips = true;
59        self
60    }
61}
62
63/// A texture uploaded to the GPU, ready to bind (e.g. via
64/// [`BindingInstanceEntry::Texture`](super::instance::BindingInstanceEntry::Texture)).
65/// Opaque — bind it into a bind group via
66/// [`BindGroupBuilder::texture_2d`](super::buffers::BindGroupBuilder::texture_2d),
67/// there's no way to reach the underlying `wgpu::Texture`/`TextureView` from
68/// outside this crate.
69pub struct GPUTexture {
70    texture: wgpu::Texture,
71    view: wgpu::TextureView,
72    width: u32,
73    height: u32,
74    format: TextureFormat,
75    ctx: GpuContext,
76}
77
78impl GPUTexture {
79    /// Overwrites this texture's level-0 pixel data (`pixels` must match the
80    /// dimensions/format this texture was uploaded with). Mip levels beyond
81    /// 0 are *not* regenerated — if this texture was built `with_mips()`,
82    /// they'll go stale relative to the new level-0 data.
83    pub fn write(&self, pixels: &[u8]) {
84        write_texture_level0(self.ctx.queue(), &self.texture, 0, self.format.into(), self.width, self.height, pixels);
85    }
86
87    pub fn width(&self) -> u32 {
88        self.width
89    }
90
91    pub fn height(&self) -> u32 {
92        self.height
93    }
94
95    pub(crate) fn view(&self) -> &wgpu::TextureView {
96        &self.view
97    }
98}
99
100/// Overwrites one `origin_z`-indexed layer/face's level-0 pixel data (`0` for
101/// a plain [`GPUTexture`], a layer index for [`GPUTextureArray`](super::texture_array::GPUTextureArray),
102/// a face index for [`GPUCubemap`](super::cubemap::GPUCubemap)) — the one
103/// piece of `write_texture` bookkeeping shared by all three, so a future fix
104/// to it (mip handling, row alignment, ...) doesn't need to land in three
105/// places independently.
106pub(crate) fn write_texture_level0(
107    queue: &wgpu::Queue,
108    texture: &wgpu::Texture,
109    origin_z: u32,
110    format: wgpu::TextureFormat,
111    width: u32,
112    height: u32,
113    pixels: &[u8],
114) {
115    queue.write_texture(
116        wgpu::TexelCopyTextureInfo {
117            texture,
118            mip_level: 0,
119            origin: wgpu::Origin3d { x: 0, y: 0, z: origin_z },
120            aspect: wgpu::TextureAspect::All,
121        },
122        pixels,
123        wgpu::TexelCopyBufferLayout {
124            offset: 0,
125            bytes_per_row: Some(bytes_per_pixel(format) * width),
126            rows_per_image: Some(height),
127        },
128        wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
129    );
130}
131
132/// Bytes-per-pixel for every regular (non-block-compressed, non-multi-planar,
133/// non-depth/stencil) texture format — anything with a well-defined linear
134/// CPU-side byte layout, which covers every format [`decode_file`] can
135/// actually decode into plus everything reasonable to upload via
136/// [`TextureDescriptor::from_data`]. Block-compressed formats (`Bc*`,
137/// `Etc2*`/`Eac*`, `Astc`) need block-aware row/height math this helper
138/// doesn't do, multi-planar formats (`NV12`/`P010`) need per-plane byte
139/// layouts, and depth/stencil formats aren't meaningful to upload arbitrary
140/// pixel bytes into in the first place — all three panic here.
141pub(crate) fn bytes_per_pixel(format: wgpu::TextureFormat) -> u32 {
142    use wgpu::TextureFormat as F;
143    match format {
144        F::R8Unorm | F::R8Snorm | F::R8Uint | F::R8Sint => 1,
145        F::R16Uint | F::R16Sint | F::R16Unorm | F::R16Snorm | F::R16Float | F::Rg8Unorm | F::Rg8Snorm
146        | F::Rg8Uint | F::Rg8Sint => 2,
147        F::R32Uint | F::R32Sint | F::R32Float | F::Rg16Uint | F::Rg16Sint | F::Rg16Unorm | F::Rg16Snorm
148        | F::Rg16Float | F::Rgba8Unorm | F::Rgba8UnormSrgb | F::Rgba8Snorm | F::Rgba8Uint | F::Rgba8Sint
149        | F::Bgra8Unorm | F::Bgra8UnormSrgb | F::Rgb10a2Uint | F::Rgb10a2Unorm | F::Rg11b10Ufloat
150        | F::Rgb9e5Ufloat => 4,
151        F::R64Uint | F::Rg32Uint | F::Rg32Sint | F::Rg32Float | F::Rgba16Uint | F::Rgba16Sint
152        | F::Rgba16Unorm | F::Rgba16Snorm | F::Rgba16Float => 8,
153        F::Rgba32Uint | F::Rgba32Sint | F::Rgba32Float => 16,
154        other => panic!(
155            "unsupported texture format for GPUTexture: {other:?} — block-compressed, \
156             multi-planar, and depth/stencil formats have no linear CPU-side pixel layout \
157             this helper can compute"
158        ),
159    }
160}
161
162/// Keeps the first `channels` of every 4-channel (RGBA) pixel, dropping the rest.
163fn take_channels_u8(rgba: &[u8], channels: usize) -> Vec<u8> {
164    rgba.chunks_exact(4).flat_map(|p| p[..channels].to_vec()).collect()
165}
166
167/// Swaps the R and B bytes of every RGBA8 pixel — `image` only decodes to
168/// RGB byte order, so this is how `Bgra8*` gets its channels in the order
169/// wgpu expects.
170fn bgra_swap(rgba: &[u8]) -> Vec<u8> {
171    rgba.chunks_exact(4).flat_map(|p| [p[2], p[1], p[0], p[3]]).collect()
172}
173
174/// Keeps the first `channels` of every 4-channel `f32` pixel, packed down to
175/// half-precision floats.
176fn take_channels_f16(rgba32f: &[f32], channels: usize) -> Vec<u8> {
177    rgba32f
178        .chunks_exact(4)
179        .flat_map(|p| p[..channels].iter().flat_map(|c| half::f16::from_f32(*c).to_le_bytes()))
180        .collect()
181}
182
183/// Keeps the first `channels` of every 4-channel `f32` pixel, as raw `f32` bytes.
184fn take_channels_f32(rgba32f: &[f32], channels: usize) -> Vec<u8> {
185    rgba32f.chunks_exact(4).flat_map(|p| bytemuck::cast_slice(&p[..channels]).to_vec()).collect()
186}
187
188/// Quantizes every 4-channel `f32` pixel (expected in `[0, 1]`) down to
189/// 16-bit unsigned normalized integers.
190fn rgba32f_to_unorm16(rgba32f: &[f32]) -> Vec<u8> {
191    rgba32f
192        .iter()
193        .flat_map(|c| ((c.clamp(0.0, 1.0) * 65535.0).round() as u16).to_le_bytes())
194        .collect()
195}
196
197/// Decodes an image file into raw pixel bytes matching `format`.
198///
199/// LDR 8-bit formats (`Rgba8*`, `Bgra8*`, `R8Unorm`, `Rg8Unorm`) decode
200/// straight through `to_rgba8()`, keeping/reordering channels as needed.
201/// `Rgba16Unorm` decodes through `to_rgba32f()` and quantizes down.
202/// Float formats (`R32Float`/`Rg32Float`/`Rgba32Float`, and the 16-bit float
203/// variants) decode through `to_rgba32f()` so HDR/EXR sources outside
204/// `[0, 1]` survive, then get packed down to the requested channel count and
205/// float width.
206pub(crate) fn decode_file(path: &str, format: wgpu::TextureFormat) -> Option<(u32, u32, Vec<u8>)> {
207    use wgpu::TextureFormat as F;
208
209    let img = match image::open(path) {
210        Ok(img) => img,
211        Err(e) => {
212            tracing::error!("failed to load texture '{path}': {e}");
213            return None;
214        }
215    };
216
217    Some(match format {
218        F::Rgba8Unorm | F::Rgba8UnormSrgb => {
219            let img = img.to_rgba8();
220            let (w, h) = img.dimensions();
221            (w, h, img.into_raw())
222        }
223        F::Bgra8Unorm | F::Bgra8UnormSrgb => {
224            let img = img.to_rgba8();
225            let (w, h) = img.dimensions();
226            (w, h, bgra_swap(&img.into_raw()))
227        }
228        F::R8Unorm => {
229            let img = img.to_rgba8();
230            let (w, h) = img.dimensions();
231            (w, h, take_channels_u8(&img.into_raw(), 1))
232        }
233        F::Rg8Unorm => {
234            let img = img.to_rgba8();
235            let (w, h) = img.dimensions();
236            (w, h, take_channels_u8(&img.into_raw(), 2))
237        }
238        F::Rgba16Unorm => {
239            let img = img.to_rgba32f();
240            let (w, h) = img.dimensions();
241            (w, h, rgba32f_to_unorm16(img.into_raw().as_slice()))
242        }
243        F::Rgba32Float => {
244            let img = img.to_rgba32f();
245            let (w, h) = img.dimensions();
246            let bytes = bytemuck::cast_slice(img.into_raw().as_slice()).to_vec();
247            (w, h, bytes)
248        }
249        F::Rg32Float => {
250            let img = img.to_rgba32f();
251            let (w, h) = img.dimensions();
252            (w, h, take_channels_f32(img.into_raw().as_slice(), 2))
253        }
254        F::R32Float => {
255            let img = img.to_rgba32f();
256            let (w, h) = img.dimensions();
257            (w, h, take_channels_f32(img.into_raw().as_slice(), 1))
258        }
259        F::Rgba16Float => {
260            let img = img.to_rgba32f();
261            let (w, h) = img.dimensions();
262            (w, h, take_channels_f16(img.into_raw().as_slice(), 4))
263        }
264        F::Rg16Float => {
265            let img = img.to_rgba32f();
266            let (w, h) = img.dimensions();
267            (w, h, take_channels_f16(img.into_raw().as_slice(), 2))
268        }
269        F::R16Float => {
270            let img = img.to_rgba32f();
271            let (w, h) = img.dimensions();
272            (w, h, take_channels_f16(img.into_raw().as_slice(), 1))
273        }
274        other => panic!(
275            "unsupported texture format for GPUTexture: {other:?} — file decoding covers the \
276             regular 8/16/32-bit unorm and float formats; block-compressed and multi-planar \
277             formats aren't decodable from an ordinary image file this way"
278        ),
279    })
280}
281
282impl Asset<WGPUBackend> for GPUTexture {
283    type Source = TextureDescriptor;
284    type Deps<'a> = Res<'a, MipmapGenerator>;
285
286    fn upload<'a>(
287        source: &TextureDescriptor,
288        backend: &WGPUBackend,
289        mipmap_generator: &Res<'a, MipmapGenerator>,
290    ) -> Option<Self> {
291        // resolve actual pixel data + real dimensions, whether from a file or already-supplied bytes
292        let (width, height, data) = if let Some(path) = source.file {
293            decode_file(path, source.format.into())?
294        } else if let Some(data) = &source.data {
295            (source.width, source.height, data.clone())
296        } else {
297            tracing::error!("TextureSpec has neither `file` nor `data` set");
298            return None;
299        };
300
301        let mip_count = super::mipmap::mip_count(width.max(height), source.generate_mips);
302
303        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
304            label: None,
305            size: wgpu::Extent3d {
306                width,
307                height,
308                depth_or_array_layers: 1,
309            },
310            mip_level_count: mip_count, // room allocated for all levels now
311            sample_count: 1,
312            dimension: wgpu::TextureDimension::D2,
313            format: source.format.into(),
314            usage: super::mipmap::texture_usage(mip_count),
315            view_formats: &[],
316        });
317
318        // upload level 0 only — fast, synchronous, matches the deferred-mip decision
319        backend.queue.write_texture(
320            wgpu::TexelCopyTextureInfo {
321                texture: &texture,
322                mip_level: 0,
323                origin: wgpu::Origin3d::default(),
324                aspect: wgpu::TextureAspect::All,
325            },
326            &data,
327            wgpu::TexelCopyBufferLayout {
328                offset: 0,
329                bytes_per_row: Some(bytes_per_pixel(source.format.into()) * width),
330                rows_per_image: Some(height),
331            },
332            wgpu::Extent3d {
333                width,
334                height,
335                depth_or_array_layers: 1,
336            },
337        );
338
339        if mip_count > 1 {
340            mipmap_generator.generate_mips(
341                &backend.device,
342                &backend.queue,
343                &texture,
344                source.format.into(),
345                mip_count,
346                1,
347            );
348        }
349
350        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
351        Some(Self {
352            texture,
353            view,
354            width,
355            height,
356            format: source.format,
357            ctx: GpuContext::from_backend(backend),
358        })
359    }
360}
361
362crate::wgpu::plugin_macros::mipmap_asset_plugin! {
363    /// Registers the [`GPUTexture`] asset pipeline (`Assets<TextureDescriptor>`
364    /// → `ProcessedAssets<GPUTexture>`), plus the [`MipmapGenerator`] it depends
365    /// on for `generate_mips`. Included by
366    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
367    /// assembling the `wgpu` module's plugins by hand.
368    TexturePlugin, GPUTexture
369}