Skip to main content

pebble/graphics/pipeline/
textures.rs

1use crate::{
2    assets::{handle::Handle, storage::Assets, upload::{Asset, AssetSource}},
3    ecs::resources::Read,
4    graphics::{
5        pipeline::{mipmap::{MipLevels, MipmapGenerator}, texture_view::TextureView},
6        render::{Backend, gpu_context::GpuContext},
7        types::TextureFormat,
8    },
9};
10
11/// A 2D texture asset — `from_file`/`from_data`/`empty`, then chained
12/// `with_*` calls, then [`build_asset`](Self::build_asset). An `empty()`
13/// texture can be used as a render target (post-processing, shadow maps).
14pub struct Texture {
15    file: Option<&'static str>,
16    width: u32,
17    height: u32,
18    format: TextureFormat,
19    data: Option<Vec<u8>>,
20    mip_levels: MipLevels,
21}
22
23impl Texture {
24    pub fn from_file(path: &'static str) -> Self {
25        Self { file: Some(path), width: 0, height: 0, format: TextureFormat::Rgba8UnormSrgb, data: None, mip_levels: MipLevels::None }
26    }
27
28    pub fn from_data(width: u32, height: u32, format: TextureFormat, data: Vec<u8>) -> Self {
29        Self { file: None, width, height, format, data: Some(data), mip_levels: MipLevels::None }
30    }
31
32    /// No source data — a render target, or something you'll [`write`](GPUTexture::write) yourself.
33    pub fn empty(width: u32, height: u32, format: TextureFormat) -> Self {
34        Self { file: None, width, height, format, data: None, mip_levels: MipLevels::None }
35    }
36
37    pub fn with_format(mut self, format: TextureFormat) -> Self {
38        self.format = format;
39        self
40    }
41
42    /// Generates a full GPU-side mip chain.
43    pub fn with_mips(mut self) -> Self {
44        self.mip_levels = MipLevels::Full;
45        self
46    }
47
48    /// Generates exactly `count` mip levels, rather than a full chain —
49    /// e.g. for a PBR prefilter pass.
50    pub fn with_mip_count(mut self, count: u32) -> Self {
51        self.mip_levels = MipLevels::Fixed(count);
52        self
53    }
54
55    fn validate(&self) {
56        if self.data.is_some() && (self.width == 0 || self.height == 0) {
57            tracing::warn!(
58                "Texture::from_data(): width/height is 0 ({}x{}) — did you swap the \
59                 argument order, or forget to pass the real dimensions?",
60                self.width,
61                self.height,
62            );
63        }
64    }
65
66    pub fn build_asset(self, name: &str, assets: &mut Assets<Texture>) -> Handle<Texture> {
67        self.validate();
68        assets.insert(name, self)
69    }
70
71    /// CPU-side pixels, e.g. for heightmap sampling. Only ever `Some` for a
72    /// `from_data()` texture — `from_file()` re-decodes from disk on each
73    /// upload rather than keeping a copy around.
74    pub fn data(&self) -> Option<&[u8]> {
75        self.data.as_deref()
76    }
77
78    /// Frees the CPU-side copy of a `from_data()` texture once you're done
79    /// reading it via [`data`](Self::data). Any future re-upload (e.g.
80    /// after GPU backend loss) then produces an empty texture instead of
81    /// the original contents. No-op for `from_file()`/`empty()`.
82    pub fn release_cpu_data(&mut self) {
83        self.data = None;
84    }
85}
86
87/// The GPU-resident texture an uploaded [`Texture`] produces.
88pub struct GPUTexture {
89    texture: wgpu::Texture,
90    view: wgpu::TextureView,
91    width: u32,
92    height: u32,
93    format: TextureFormat,
94    ctx: GpuContext,
95}
96
97impl GPUTexture {
98    /// Overwrites one mip level with new pixel data — e.g. for a render
99    /// target you're writing from the CPU side, or a streamed texture.
100    pub fn write(&self, mip_level: u32, pixels: &[u8]) {
101        write_texture_mip(self.ctx.queue(), &self.texture, 0, mip_level, self.format.into(), self.width, self.height, pixels);
102    }
103
104    pub fn width(&self) -> u32 {
105        self.width
106    }
107
108    pub fn height(&self) -> u32 {
109        self.height
110    }
111
112    /// A view into a single mip level — for binding a specific level (e.g.
113    /// as a render target during mip generation).
114    pub fn get_view(&self, mip_level: u32) -> TextureView {
115        let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
116            dimension: Some(wgpu::TextureViewDimension::D2),
117            base_mip_level: mip_level,
118            mip_level_count: Some(1),
119            ..Default::default()
120        });
121        TextureView::from_raw(view, self.texture.clone())
122    }
123
124    pub(crate) fn view(&self) -> &wgpu::TextureView {
125        &self.view
126    }
127}
128
129pub(crate) fn write_texture_mip(
130    queue: &wgpu::Queue,
131    texture: &wgpu::Texture,
132    origin_z: u32,
133    mip_level: u32,
134    format: wgpu::TextureFormat,
135    width: u32,
136    height: u32,
137    pixels: &[u8],
138) {
139    let width = (width >> mip_level).max(1);
140    let height = (height >> mip_level).max(1);
141    queue.write_texture(
142        wgpu::TexelCopyTextureInfo {
143            texture,
144            mip_level,
145            origin: wgpu::Origin3d { x: 0, y: 0, z: origin_z },
146            aspect: wgpu::TextureAspect::All,
147        },
148        pixels,
149        wgpu::TexelCopyBufferLayout {
150            offset: 0,
151            bytes_per_row: Some(bytes_per_pixel(format) * width),
152            rows_per_image: Some(height),
153        },
154        wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
155    );
156}
157
158pub(crate) fn bytes_per_pixel(format: wgpu::TextureFormat) -> u32 {
159    use wgpu::TextureFormat as F;
160    match format {
161        F::R8Unorm | F::R8Snorm | F::R8Uint | F::R8Sint => 1,
162        F::R16Uint | F::R16Sint | F::R16Unorm | F::R16Snorm | F::R16Float | F::Rg8Unorm | F::Rg8Snorm
163        | F::Rg8Uint | F::Rg8Sint => 2,
164        F::R32Uint | F::R32Sint | F::R32Float | F::Rg16Uint | F::Rg16Sint | F::Rg16Unorm | F::Rg16Snorm
165        | F::Rg16Float | F::Rgba8Unorm | F::Rgba8UnormSrgb | F::Rgba8Snorm | F::Rgba8Uint | F::Rgba8Sint
166        | F::Bgra8Unorm | F::Bgra8UnormSrgb | F::Rgb10a2Uint | F::Rgb10a2Unorm | F::Rg11b10Ufloat
167        | F::Rgb9e5Ufloat => 4,
168        F::R64Uint | F::Rg32Uint | F::Rg32Sint | F::Rg32Float | F::Rgba16Uint | F::Rgba16Sint
169        | F::Rgba16Unorm | F::Rgba16Snorm | F::Rgba16Float => 8,
170        F::Rgba32Uint | F::Rgba32Sint | F::Rgba32Float => 16,
171        other => panic!(
172            "unsupported texture format for GPUTexture: {other:?} — block-compressed, \
173             multi-planar, and depth/stencil formats have no linear CPU-side pixel layout \
174             this helper can compute"
175        ),
176    }
177}
178
179pub(crate) fn check_texture_dimensions(device: &wgpu::Device, what: &str, width: u32, height: u32) {
180    let max = device.limits().max_texture_dimension_2d;
181    if width > max || height > max {
182        panic!("{what}: {width}x{height} exceeds this device's max_texture_dimension_2d ({max})");
183    }
184}
185
186pub(crate) fn check_texture_array_layers(device: &wgpu::Device, what: &str, layer_count: u32) {
187    let max = device.limits().max_texture_array_layers;
188    if layer_count > max {
189        panic!("{what}: {layer_count} layers exceeds this device's max_texture_array_layers ({max})");
190    }
191}
192
193fn take_channels_u8(rgba: &[u8], channels: usize) -> Vec<u8> {
194    rgba.chunks_exact(4).flat_map(|p| p[..channels].to_vec()).collect()
195}
196
197fn bgra_swap(rgba: &[u8]) -> Vec<u8> {
198    rgba.chunks_exact(4).flat_map(|p| [p[2], p[1], p[0], p[3]]).collect()
199}
200
201fn take_channels_f16(rgba32f: &[f32], channels: usize) -> Vec<u8> {
202    rgba32f
203        .chunks_exact(4)
204        .flat_map(|p| p[..channels].iter().flat_map(|c| half::f16::from_f32(*c).to_le_bytes()))
205        .collect()
206}
207
208fn take_channels_f32(rgba32f: &[f32], channels: usize) -> Vec<u8> {
209    rgba32f.chunks_exact(4).flat_map(|p| bytemuck::cast_slice(&p[..channels]).to_vec()).collect()
210}
211
212fn rgba32f_to_unorm16(rgba32f: &[f32]) -> Vec<u8> {
213    rgba32f
214        .iter()
215        .flat_map(|c| ((c.clamp(0.0, 1.0) * 65535.0).round() as u16).to_le_bytes())
216        .collect()
217}
218
219pub(crate) fn decode_file(path: &str, format: wgpu::TextureFormat) -> Option<(u32, u32, Vec<u8>)> {
220    use wgpu::TextureFormat as F;
221
222    let img = match image::open(path) {
223        Ok(img) => img,
224        Err(e) => {
225            tracing::error!("failed to load texture '{path}': {e}");
226            return None;
227        }
228    };
229
230    Some(match format {
231        F::Rgba8Unorm | F::Rgba8UnormSrgb => {
232            let img = img.to_rgba8();
233            let (w, h) = img.dimensions();
234            (w, h, img.into_raw())
235        }
236        F::Bgra8Unorm | F::Bgra8UnormSrgb => {
237            let img = img.to_rgba8();
238            let (w, h) = img.dimensions();
239            (w, h, bgra_swap(&img.into_raw()))
240        }
241        F::R8Unorm => {
242            let img = img.to_rgba8();
243            let (w, h) = img.dimensions();
244            (w, h, take_channels_u8(&img.into_raw(), 1))
245        }
246        F::Rg8Unorm => {
247            let img = img.to_rgba8();
248            let (w, h) = img.dimensions();
249            (w, h, take_channels_u8(&img.into_raw(), 2))
250        }
251        F::Rgba16Unorm => {
252            let img = img.to_rgba32f();
253            let (w, h) = img.dimensions();
254            (w, h, rgba32f_to_unorm16(img.into_raw().as_slice()))
255        }
256        F::Rgba32Float => {
257            let img = img.to_rgba32f();
258            let (w, h) = img.dimensions();
259            let bytes = bytemuck::cast_slice(img.into_raw().as_slice()).to_vec();
260            (w, h, bytes)
261        }
262        F::Rg32Float => {
263            let img = img.to_rgba32f();
264            let (w, h) = img.dimensions();
265            (w, h, take_channels_f32(img.into_raw().as_slice(), 2))
266        }
267        F::R32Float => {
268            let img = img.to_rgba32f();
269            let (w, h) = img.dimensions();
270            (w, h, take_channels_f32(img.into_raw().as_slice(), 1))
271        }
272        F::Rgba16Float => {
273            let img = img.to_rgba32f();
274            let (w, h) = img.dimensions();
275            (w, h, take_channels_f16(img.into_raw().as_slice(), 4))
276        }
277        F::Rg16Float => {
278            let img = img.to_rgba32f();
279            let (w, h) = img.dimensions();
280            (w, h, take_channels_f16(img.into_raw().as_slice(), 2))
281        }
282        F::R16Float => {
283            let img = img.to_rgba32f();
284            let (w, h) = img.dimensions();
285            (w, h, take_channels_f16(img.into_raw().as_slice(), 1))
286        }
287        other => panic!(
288            "unsupported texture format for GPUTexture: {other:?} — file decoding covers the \
289             regular 8/16/32-bit unorm and float formats; block-compressed and multi-planar \
290             formats aren't decodable from an ordinary image file this way"
291        ),
292    })
293}
294
295impl AssetSource for Texture {
296    type Processed = GPUTexture;
297}
298
299impl Asset<Backend> for Texture {
300    type Deps<'a> = Read<'a, MipmapGenerator>;
301
302    fn upload<'a>(&self, backend: &Backend, mipmap_generator: &Read<'a, MipmapGenerator>) -> Option<GPUTexture> {
303        let (width, height, data) = if let Some(path) = self.file {
304            let (w, h, d) = decode_file(path, self.format.into())?;
305            (w, h, Some(d))
306        } else if let Some(data) = &self.data {
307            (self.width, self.height, Some(data.clone()))
308        } else {
309            (self.width, self.height, None)
310        };
311
312        check_texture_dimensions(&backend.device, "GPUTexture", width, height);
313
314        let mip_count = crate::graphics::pipeline::mipmap::mip_count(width.max(height), self.mip_levels);
315        let usage = crate::graphics::pipeline::mipmap::texture_usage_for(mip_count, data.is_some());
316
317        let texture = backend.device.create_texture(&wgpu::TextureDescriptor {
318            label: None,
319            size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
320            mip_level_count: mip_count,
321            sample_count: 1,
322            dimension: wgpu::TextureDimension::D2,
323            format: self.format.into(),
324            usage,
325            view_formats: &[],
326        });
327
328        if let Some(data) = &data {
329            write_texture_mip(&backend.queue, &texture, 0, 0, self.format.into(), width, height, data);
330
331            if mip_count > 1 {
332                mipmap_generator.generate_mips(backend, &texture, self.format.into(), mip_count, 1);
333            }
334        }
335
336        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
337        Some(GPUTexture { texture, view, width, height, format: self.format, ctx: GpuContext::from_backend(backend) })
338    }
339}