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