Skip to main content

pebble/wgpu/
textures.rs

1use crate::{
2    assets::{plugin::AssetPlugin, singleton_asset::LazyResourcePlugin, upload::Asset},
3    ecs::{plugin::Plugin, system::Res},
4    wgpu::{backend::WGPUBackend, mipmap::MipmapGenerator},
5};
6
7pub struct TextureSpec {
8    pub file: Option<&'static str>,
9    pub width: u32,
10    pub height: u32,
11    pub format: wgpu::TextureFormat,
12    pub data: Option<Vec<u8>>,
13    pub generate_mips: bool,
14}
15
16impl TextureSpec {
17    pub fn with_format(mut self, format: wgpu::TextureFormat) -> Self {
18        self.format = format;
19        self
20    }
21}
22
23pub struct GPUTexture {
24    pub texture: wgpu::Texture,
25    pub view: wgpu::TextureView,
26}
27
28/// Bytes-per-pixel for the pixel formats this loader knows how to produce.
29pub(crate) fn bytes_per_pixel(format: wgpu::TextureFormat) -> u32 {
30    match format {
31        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => 4,
32        wgpu::TextureFormat::Rgba16Float => 8,
33        wgpu::TextureFormat::Rgba32Float => 16,
34        other => panic!("unsupported texture format for GPUTexture: {other:?}"),
35    }
36}
37
38/// Decodes an image file into raw pixel bytes matching `format`.
39///
40/// LDR formats (Rgba8*) decode straight to 8-bit RGBA. HDR/EXR sources (and
41/// any request for a float format) decode through `to_rgba32f()` so that
42/// values outside `[0, 1]` survive, then get packed down to the requested
43/// float width.
44pub(crate) fn decode_file(path: &str, format: wgpu::TextureFormat) -> (u32, u32, Vec<u8>) {
45    let img = image::open(path).unwrap_or_else(|e| panic!("failed to load texture '{path}': {e}"));
46
47    match format {
48        wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb => {
49            let img = img.to_rgba8();
50            let (w, h) = img.dimensions();
51            (w, h, img.into_raw())
52        }
53        wgpu::TextureFormat::Rgba32Float => {
54            let img = img.to_rgba32f();
55            let (w, h) = img.dimensions();
56            let bytes = bytemuck::cast_slice(img.into_raw().as_slice()).to_vec();
57            (w, h, bytes)
58        }
59        wgpu::TextureFormat::Rgba16Float => {
60            let img = img.to_rgba32f();
61            let (w, h) = img.dimensions();
62            let bytes = img
63                .into_raw()
64                .into_iter()
65                .flat_map(|c| half::f16::from_f32(c).to_le_bytes())
66                .collect();
67            (w, h, bytes)
68        }
69        other => panic!("unsupported texture format for GPUTexture: {other:?}"),
70    }
71}
72
73impl Asset<WGPUBackend> for GPUTexture {
74    type Source = TextureSpec;
75    type Deps<'a> = Res<'a, MipmapGenerator>;
76
77    fn upload<'a>(
78        source: &TextureSpec,
79        backend: &WGPUBackend,
80        mipmap_generator: &Res<'a, MipmapGenerator>,
81    ) -> Option<Self> {
82        // resolve actual pixel data + real dimensions, whether from a file or already-supplied bytes
83        let (width, height, data) = if let Some(path) = source.file {
84            decode_file(path, source.format)
85        } else if let Some(data) = &source.data {
86            (source.width, source.height, data.clone())
87        } else {
88            tracing::error!("TextureSpec has neither `file` nor `data` set");
89            return None;
90        };
91
92        let mip_count = if source.generate_mips {
93            (width.max(height) as f32).log2().floor() as u32 + 1
94        } else {
95            1
96        };
97
98        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
99            label: None,
100            size: wgpu::Extent3d {
101                width,
102                height,
103                depth_or_array_layers: 1,
104            },
105            mip_level_count: mip_count, // room allocated for all levels now
106            sample_count: 1,
107            dimension: wgpu::TextureDimension::D2,
108            format: source.format,
109            usage: if mip_count > 1 {
110                // mips beyond level 0 are rendered into on the GPU, so the
111                // texture needs to double as a render attachment
112                wgpu::TextureUsages::TEXTURE_BINDING
113                    | wgpu::TextureUsages::COPY_DST
114                    | wgpu::TextureUsages::RENDER_ATTACHMENT
115            } else {
116                wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST
117            },
118            view_formats: &[],
119        });
120
121        // upload level 0 only — fast, synchronous, matches the deferred-mip decision
122        backend.queue.write_texture(
123            wgpu::TexelCopyTextureInfo {
124                texture: &texture,
125                mip_level: 0,
126                origin: wgpu::Origin3d::default(),
127                aspect: wgpu::TextureAspect::All,
128            },
129            &data,
130            wgpu::TexelCopyBufferLayout {
131                offset: 0,
132                bytes_per_row: Some(bytes_per_pixel(source.format) * width),
133                rows_per_image: Some(height),
134            },
135            wgpu::Extent3d {
136                width,
137                height,
138                depth_or_array_layers: 1,
139            },
140        );
141
142        if mip_count > 1 {
143            mipmap_generator.generate_mips(
144                &backend.device,
145                &backend.queue,
146                &texture,
147                source.format,
148                mip_count,
149                1,
150            );
151        }
152
153        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
154        Some(Self { texture, view })
155    }
156}
157
158pub struct TexturePlugin;
159impl Plugin for TexturePlugin {
160    fn build(&self, app: &mut crate::prelude::App) {
161        app.add_plugin(LazyResourcePlugin::<WGPUBackend, MipmapGenerator>::new());
162        app.add_plugin(AssetPlugin::<WGPUBackend, GPUTexture>::new());
163    }
164}