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