Skip to main content

arcane_core/renderer/
texture.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6use super::gpu::GpuContext;
7
8/// Opaque handle to a loaded texture.
9pub type TextureId = u32;
10
11/// Entry for a single loaded texture.
12struct TextureEntry {
13    _texture: wgpu::Texture,
14    bind_group: wgpu::BindGroup,
15    width: u32,
16    height: u32,
17}
18
19/// Handle-based texture store. Loads PNGs, uploads to GPU, returns opaque handles.
20pub struct TextureStore {
21    textures: HashMap<TextureId, TextureEntry>,
22    path_to_id: HashMap<String, TextureId>,
23    next_id: TextureId,
24    /// Bind groups for render targets. The render target textures themselves are
25    /// owned by `RenderTargetStore`; we only hold the bind group (which keeps the
26    /// GPU resource alive via wgpu's internal reference counting).
27    render_target_bgs: HashMap<TextureId, (wgpu::BindGroup, u32, u32)>,
28}
29
30impl TextureStore {
31    pub fn new() -> Self {
32        Self {
33            textures: HashMap::new(),
34            path_to_id: HashMap::new(),
35            render_target_bgs: HashMap::new(),
36            next_id: 1, // 0 reserved for "no texture"
37        }
38    }
39
40    /// Load a texture from a PNG file. Returns the texture handle.
41    /// If the same path was already loaded, returns the cached handle.
42    pub fn load(
43        &mut self,
44        gpu: &GpuContext,
45        bind_group_layout: &wgpu::BindGroupLayout,
46        path: &Path,
47    ) -> Result<TextureId> {
48        let path_str = path.to_string_lossy().to_string();
49
50        if let Some(&id) = self.path_to_id.get(&path_str) {
51            return Ok(id);
52        }
53
54        let img_data = std::fs::read(path)
55            .with_context(|| format!("Failed to read texture: {}", path.display()))?;
56
57        let img = image::load_from_memory(&img_data)
58            .with_context(|| format!("Failed to decode image: {}", path.display()))?
59            .to_rgba8();
60
61        let (width, height) = img.dimensions();
62
63        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
64            label: Some(&path_str),
65            size: wgpu::Extent3d {
66                width,
67                height,
68                depth_or_array_layers: 1,
69            },
70            mip_level_count: 1,
71            sample_count: 1,
72            dimension: wgpu::TextureDimension::D2,
73            format: wgpu::TextureFormat::Rgba8UnormSrgb,
74            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
75            view_formats: &[],
76        });
77
78        gpu.queue.write_texture(
79            wgpu::TexelCopyTextureInfo {
80                texture: &texture,
81                mip_level: 0,
82                origin: wgpu::Origin3d::ZERO,
83                aspect: wgpu::TextureAspect::All,
84            },
85            &img,
86            wgpu::TexelCopyBufferLayout {
87                offset: 0,
88                bytes_per_row: Some(4 * width),
89                rows_per_image: Some(height),
90            },
91            wgpu::Extent3d {
92                width,
93                height,
94                depth_or_array_layers: 1,
95            },
96        );
97
98        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
99        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
100            address_mode_u: wgpu::AddressMode::ClampToEdge,
101            address_mode_v: wgpu::AddressMode::ClampToEdge,
102            mag_filter: wgpu::FilterMode::Nearest,
103            min_filter: wgpu::FilterMode::Nearest,
104            ..Default::default()
105        });
106
107        let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
108            label: Some(&format!("texture_bind_group_{}", self.next_id)),
109            layout: bind_group_layout,
110            entries: &[
111                wgpu::BindGroupEntry {
112                    binding: 0,
113                    resource: wgpu::BindingResource::TextureView(&view),
114                },
115                wgpu::BindGroupEntry {
116                    binding: 1,
117                    resource: wgpu::BindingResource::Sampler(&sampler),
118                },
119            ],
120        });
121
122        let id = self.next_id;
123        self.next_id += 1;
124
125        self.textures.insert(
126            id,
127            TextureEntry {
128                _texture: texture,
129                bind_group,
130                width,
131                height,
132            },
133        );
134        self.path_to_id.insert(path_str, id);
135
136        Ok(id)
137    }
138
139    /// Create a solid-color 1x1 texture. Useful for placeholder sprites.
140    pub fn create_solid_color(
141        &mut self,
142        device: &wgpu::Device,
143        queue: &wgpu::Queue,
144        bind_group_layout: &wgpu::BindGroupLayout,
145        name: &str,
146        r: u8,
147        g: u8,
148        b: u8,
149        a: u8,
150    ) -> TextureId {
151        let path_key = format!("__solid__{name}");
152        if let Some(&id) = self.path_to_id.get(&path_key) {
153            return id;
154        }
155
156        let texture = device.create_texture(&wgpu::TextureDescriptor {
157            label: Some(name),
158            size: wgpu::Extent3d {
159                width: 1,
160                height: 1,
161                depth_or_array_layers: 1,
162            },
163            mip_level_count: 1,
164            sample_count: 1,
165            dimension: wgpu::TextureDimension::D2,
166            format: wgpu::TextureFormat::Rgba8UnormSrgb,
167            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
168            view_formats: &[],
169        });
170
171        queue.write_texture(
172            wgpu::TexelCopyTextureInfo {
173                texture: &texture,
174                mip_level: 0,
175                origin: wgpu::Origin3d::ZERO,
176                aspect: wgpu::TextureAspect::All,
177            },
178            &[r, g, b, a],
179            wgpu::TexelCopyBufferLayout {
180                offset: 0,
181                bytes_per_row: Some(4),
182                rows_per_image: Some(1),
183            },
184            wgpu::Extent3d {
185                width: 1,
186                height: 1,
187                depth_or_array_layers: 1,
188            },
189        );
190
191        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
192        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
193            mag_filter: wgpu::FilterMode::Nearest,
194            min_filter: wgpu::FilterMode::Nearest,
195            ..Default::default()
196        });
197
198        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
199            label: Some(&format!("solid_color_bind_group_{name}")),
200            layout: bind_group_layout,
201            entries: &[
202                wgpu::BindGroupEntry {
203                    binding: 0,
204                    resource: wgpu::BindingResource::TextureView(&view),
205                },
206                wgpu::BindGroupEntry {
207                    binding: 1,
208                    resource: wgpu::BindingResource::Sampler(&sampler),
209                },
210            ],
211        });
212
213        let id = self.next_id;
214        self.next_id += 1;
215
216        self.textures.insert(
217            id,
218            TextureEntry {
219                _texture: texture,
220                bind_group,
221                width: 1,
222                height: 1,
223            },
224        );
225        self.path_to_id.insert(path_key, id);
226
227        id
228    }
229
230    /// Upload raw RGBA pixel data as a texture with a pre-assigned ID.
231    /// Used for procedurally generated textures (e.g., built-in font).
232    pub fn upload_raw(
233        &mut self,
234        device: &wgpu::Device,
235        queue: &wgpu::Queue,
236        bind_group_layout: &wgpu::BindGroupLayout,
237        id: TextureId,
238        pixels: &[u8],
239        width: u32,
240        height: u32,
241    ) {
242        let texture = device.create_texture(&wgpu::TextureDescriptor {
243            label: Some(&format!("raw_texture_{id}")),
244            size: wgpu::Extent3d {
245                width,
246                height,
247                depth_or_array_layers: 1,
248            },
249            mip_level_count: 1,
250            sample_count: 1,
251            dimension: wgpu::TextureDimension::D2,
252            format: wgpu::TextureFormat::Rgba8UnormSrgb,
253            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
254            view_formats: &[],
255        });
256
257        queue.write_texture(
258            wgpu::TexelCopyTextureInfo {
259                texture: &texture,
260                mip_level: 0,
261                origin: wgpu::Origin3d::ZERO,
262                aspect: wgpu::TextureAspect::All,
263            },
264            pixels,
265            wgpu::TexelCopyBufferLayout {
266                offset: 0,
267                bytes_per_row: Some(4 * width),
268                rows_per_image: Some(height),
269            },
270            wgpu::Extent3d {
271                width,
272                height,
273                depth_or_array_layers: 1,
274            },
275        );
276
277        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
278        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
279            mag_filter: wgpu::FilterMode::Nearest,
280            min_filter: wgpu::FilterMode::Nearest,
281            ..Default::default()
282        });
283
284        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
285            label: Some(&format!("raw_texture_bind_group_{id}")),
286            layout: bind_group_layout,
287            entries: &[
288                wgpu::BindGroupEntry {
289                    binding: 0,
290                    resource: wgpu::BindingResource::TextureView(&view),
291                },
292                wgpu::BindGroupEntry {
293                    binding: 1,
294                    resource: wgpu::BindingResource::Sampler(&sampler),
295                },
296            ],
297        });
298
299        self.textures.insert(
300            id,
301            TextureEntry {
302                _texture: texture,
303                bind_group,
304                width,
305                height,
306            },
307        );
308    }
309
310    /// Upload raw RGBA pixels as a linear (non-sRGB) texture with bilinear filtering.
311    /// Use this for distance field atlases (MSDF, SDF) where values must be sampled linearly.
312    pub fn upload_raw_linear(
313        &mut self,
314        device: &wgpu::Device,
315        queue: &wgpu::Queue,
316        bind_group_layout: &wgpu::BindGroupLayout,
317        id: TextureId,
318        pixels: &[u8],
319        width: u32,
320        height: u32,
321    ) {
322        let texture = device.create_texture(&wgpu::TextureDescriptor {
323            label: Some(&format!("raw_linear_texture_{id}")),
324            size: wgpu::Extent3d {
325                width,
326                height,
327                depth_or_array_layers: 1,
328            },
329            mip_level_count: 1,
330            sample_count: 1,
331            dimension: wgpu::TextureDimension::D2,
332            format: wgpu::TextureFormat::Rgba8Unorm,
333            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
334            view_formats: &[],
335        });
336
337        queue.write_texture(
338            wgpu::TexelCopyTextureInfo {
339                texture: &texture,
340                mip_level: 0,
341                origin: wgpu::Origin3d::ZERO,
342                aspect: wgpu::TextureAspect::All,
343            },
344            pixels,
345            wgpu::TexelCopyBufferLayout {
346                offset: 0,
347                bytes_per_row: Some(4 * width),
348                rows_per_image: Some(height),
349            },
350            wgpu::Extent3d {
351                width,
352                height,
353                depth_or_array_layers: 1,
354            },
355        );
356
357        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
358        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
359            mag_filter: wgpu::FilterMode::Linear,
360            min_filter: wgpu::FilterMode::Linear,
361            ..Default::default()
362        });
363
364        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
365            label: Some(&format!("raw_linear_texture_bind_group_{id}")),
366            layout: bind_group_layout,
367            entries: &[
368                wgpu::BindGroupEntry {
369                    binding: 0,
370                    resource: wgpu::BindingResource::TextureView(&view),
371                },
372                wgpu::BindGroupEntry {
373                    binding: 1,
374                    resource: wgpu::BindingResource::Sampler(&sampler),
375                },
376            ],
377        });
378
379        self.textures.insert(
380            id,
381            TextureEntry {
382                _texture: texture,
383                bind_group,
384                width,
385                height,
386            },
387        );
388    }
389
390    /// Get the bind group for a texture handle (regular textures and render targets).
391    pub fn get_bind_group(&self, id: TextureId) -> Option<&wgpu::BindGroup> {
392        self.textures
393            .get(&id)
394            .map(|e| &e.bind_group)
395            .or_else(|| self.render_target_bgs.get(&id).map(|(bg, _, _)| bg))
396    }
397
398    /// Get texture dimensions (regular textures and render targets).
399    pub fn get_dimensions(&self, id: TextureId) -> Option<(u32, u32)> {
400        self.textures
401            .get(&id)
402            .map(|e| (e.width, e.height))
403            .or_else(|| self.render_target_bgs.get(&id).map(|&(_, w, h)| (w, h)))
404    }
405
406    /// Register a render target's TextureView as a samplable texture.
407    ///
408    /// The texture itself is owned by `RenderTargetStore`; we only create the
409    /// bind group here. wgpu's internal reference counting keeps the GPU
410    /// resource alive for as long as the bind group exists.
411    pub fn register_render_target(
412        &mut self,
413        device: &wgpu::Device,
414        bind_group_layout: &wgpu::BindGroupLayout,
415        id: TextureId,
416        view: &wgpu::TextureView,
417        width: u32,
418        height: u32,
419    ) {
420        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
421            mag_filter: wgpu::FilterMode::Linear,
422            min_filter: wgpu::FilterMode::Linear,
423            ..Default::default()
424        });
425        let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
426            label: Some(&format!("render_target_bg_{id}")),
427            layout: bind_group_layout,
428            entries: &[
429                wgpu::BindGroupEntry {
430                    binding: 0,
431                    resource: wgpu::BindingResource::TextureView(view),
432                },
433                wgpu::BindGroupEntry {
434                    binding: 1,
435                    resource: wgpu::BindingResource::Sampler(&sampler),
436                },
437            ],
438        });
439        self.render_target_bgs.insert(id, (bg, width, height));
440    }
441
442    /// Remove a render target's bind group. Call after destroying the render target.
443    pub fn unregister_render_target(&mut self, id: TextureId) {
444        self.render_target_bgs.remove(&id);
445    }
446}