Skip to main content

pebble/wgpu/
textures.rs

1use crate::{
2    assets::upload::Asset,
3    ecs::system::Res,
4    wgpu::{backend::WGPUBackend, mipmap::MipmapGenerator},
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: wgpu::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: wgpu::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: wgpu::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: wgpu::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)).
65pub struct GPUTexture {
66    pub texture: wgpu::Texture,
67    pub view: wgpu::TextureView,
68}
69
70/// Bytes-per-pixel for the pixel formats this loader knows how to produce.
71pub(crate) fn bytes_per_pixel(format: wgpu::TextureFormat) -> u32 {
72    match format {
73        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => 4,
74        wgpu::TextureFormat::Rgba16Float => 8,
75        wgpu::TextureFormat::Rgba32Float => 16,
76        other => panic!("unsupported texture format for GPUTexture: {other:?}"),
77    }
78}
79
80/// Decodes an image file into raw pixel bytes matching `format`.
81///
82/// LDR formats (Rgba8*) decode straight to 8-bit RGBA. HDR/EXR sources (and
83/// any request for a float format) decode through `to_rgba32f()` so that
84/// values outside `[0, 1]` survive, then get packed down to the requested
85/// float width.
86pub(crate) fn decode_file(path: &str, format: wgpu::TextureFormat) -> Option<(u32, u32, Vec<u8>)> {
87    let img = match image::open(path) {
88        Ok(img) => img,
89        Err(e) => {
90            tracing::error!("failed to load texture '{path}': {e}");
91            return None;
92        }
93    };
94
95    Some(match format {
96        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => {
97            let img = img.to_rgba8();
98            let (w, h) = img.dimensions();
99            (w, h, img.into_raw())
100        }
101        wgpu::TextureFormat::Rgba32Float => {
102            let img = img.to_rgba32f();
103            let (w, h) = img.dimensions();
104            let bytes = bytemuck::cast_slice(img.into_raw().as_slice()).to_vec();
105            (w, h, bytes)
106        }
107        wgpu::TextureFormat::Rgba16Float => {
108            let img = img.to_rgba32f();
109            let (w, h) = img.dimensions();
110            let bytes = img
111                .into_raw()
112                .into_iter()
113                .flat_map(|c| half::f16::from_f32(c).to_le_bytes())
114                .collect();
115            (w, h, bytes)
116        }
117        other => panic!("unsupported texture format for GPUTexture: {other:?}"),
118    })
119}
120
121impl Asset<WGPUBackend> for GPUTexture {
122    type Source = TextureDescriptor;
123    type Deps<'a> = Res<'a, MipmapGenerator>;
124
125    fn upload<'a>(
126        source: &TextureDescriptor,
127        backend: &WGPUBackend,
128        mipmap_generator: &Res<'a, MipmapGenerator>,
129    ) -> Option<Self> {
130        // resolve actual pixel data + real dimensions, whether from a file or already-supplied bytes
131        let (width, height, data) = if let Some(path) = source.file {
132            decode_file(path, source.format)?
133        } else if let Some(data) = &source.data {
134            (source.width, source.height, data.clone())
135        } else {
136            tracing::error!("TextureSpec has neither `file` nor `data` set");
137            return None;
138        };
139
140        let mip_count = super::mipmap::mip_count(width.max(height), source.generate_mips);
141
142        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
143            label: None,
144            size: wgpu::Extent3d {
145                width,
146                height,
147                depth_or_array_layers: 1,
148            },
149            mip_level_count: mip_count, // room allocated for all levels now
150            sample_count: 1,
151            dimension: wgpu::TextureDimension::D2,
152            format: source.format,
153            usage: super::mipmap::texture_usage(mip_count),
154            view_formats: &[],
155        });
156
157        // upload level 0 only — fast, synchronous, matches the deferred-mip decision
158        backend.queue.write_texture(
159            wgpu::TexelCopyTextureInfo {
160                texture: &texture,
161                mip_level: 0,
162                origin: wgpu::Origin3d::default(),
163                aspect: wgpu::TextureAspect::All,
164            },
165            &data,
166            wgpu::TexelCopyBufferLayout {
167                offset: 0,
168                bytes_per_row: Some(bytes_per_pixel(source.format) * width),
169                rows_per_image: Some(height),
170            },
171            wgpu::Extent3d {
172                width,
173                height,
174                depth_or_array_layers: 1,
175            },
176        );
177
178        if mip_count > 1 {
179            mipmap_generator.generate_mips(
180                &backend.device,
181                &backend.queue,
182                &texture,
183                source.format,
184                mip_count,
185                1,
186            );
187        }
188
189        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
190        Some(Self { texture, view })
191    }
192}
193
194crate::wgpu::plugin_macros::mipmap_asset_plugin! {
195    /// Registers the [`GPUTexture`] asset pipeline (`Assets<TextureDescriptor>`
196    /// → `ProcessedAssets<GPUTexture>`), plus the [`MipmapGenerator`] it depends
197    /// on for `generate_mips`. Included by
198    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
199    /// assembling the `wgpu` module's plugins by hand.
200    TexturePlugin, GPUTexture
201}