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        gpu: &GpuContext,
143        bind_group_layout: &wgpu::BindGroupLayout,
144        name: &str,
145        r: u8,
146        g: u8,
147        b: u8,
148        a: u8,
149    ) -> TextureId {
150        let path_key = format!("__solid__{name}");
151        if let Some(&id) = self.path_to_id.get(&path_key) {
152            return id;
153        }
154
155        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
156            label: Some(name),
157            size: wgpu::Extent3d {
158                width: 1,
159                height: 1,
160                depth_or_array_layers: 1,
161            },
162            mip_level_count: 1,
163            sample_count: 1,
164            dimension: wgpu::TextureDimension::D2,
165            format: wgpu::TextureFormat::Rgba8UnormSrgb,
166            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
167            view_formats: &[],
168        });
169
170        gpu.queue.write_texture(
171            wgpu::TexelCopyTextureInfo {
172                texture: &texture,
173                mip_level: 0,
174                origin: wgpu::Origin3d::ZERO,
175                aspect: wgpu::TextureAspect::All,
176            },
177            &[r, g, b, a],
178            wgpu::TexelCopyBufferLayout {
179                offset: 0,
180                bytes_per_row: Some(4),
181                rows_per_image: Some(1),
182            },
183            wgpu::Extent3d {
184                width: 1,
185                height: 1,
186                depth_or_array_layers: 1,
187            },
188        );
189
190        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
191        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
192            mag_filter: wgpu::FilterMode::Nearest,
193            min_filter: wgpu::FilterMode::Nearest,
194            ..Default::default()
195        });
196
197        let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
198            label: Some(&format!("solid_color_bind_group_{name}")),
199            layout: bind_group_layout,
200            entries: &[
201                wgpu::BindGroupEntry {
202                    binding: 0,
203                    resource: wgpu::BindingResource::TextureView(&view),
204                },
205                wgpu::BindGroupEntry {
206                    binding: 1,
207                    resource: wgpu::BindingResource::Sampler(&sampler),
208                },
209            ],
210        });
211
212        let id = self.next_id;
213        self.next_id += 1;
214
215        self.textures.insert(
216            id,
217            TextureEntry {
218                _texture: texture,
219                bind_group,
220                width: 1,
221                height: 1,
222            },
223        );
224        self.path_to_id.insert(path_key, id);
225
226        id
227    }
228
229    /// Upload raw RGBA pixel data as a texture with a pre-assigned ID.
230    /// Used for procedurally generated textures (e.g., built-in font).
231    pub fn upload_raw(
232        &mut self,
233        gpu: &GpuContext,
234        bind_group_layout: &wgpu::BindGroupLayout,
235        id: TextureId,
236        pixels: &[u8],
237        width: u32,
238        height: u32,
239    ) {
240        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
241            label: Some(&format!("raw_texture_{id}")),
242            size: wgpu::Extent3d {
243                width,
244                height,
245                depth_or_array_layers: 1,
246            },
247            mip_level_count: 1,
248            sample_count: 1,
249            dimension: wgpu::TextureDimension::D2,
250            format: wgpu::TextureFormat::Rgba8UnormSrgb,
251            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
252            view_formats: &[],
253        });
254
255        gpu.queue.write_texture(
256            wgpu::TexelCopyTextureInfo {
257                texture: &texture,
258                mip_level: 0,
259                origin: wgpu::Origin3d::ZERO,
260                aspect: wgpu::TextureAspect::All,
261            },
262            pixels,
263            wgpu::TexelCopyBufferLayout {
264                offset: 0,
265                bytes_per_row: Some(4 * width),
266                rows_per_image: Some(height),
267            },
268            wgpu::Extent3d {
269                width,
270                height,
271                depth_or_array_layers: 1,
272            },
273        );
274
275        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
276        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
277            mag_filter: wgpu::FilterMode::Nearest,
278            min_filter: wgpu::FilterMode::Nearest,
279            ..Default::default()
280        });
281
282        let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
283            label: Some(&format!("raw_texture_bind_group_{id}")),
284            layout: bind_group_layout,
285            entries: &[
286                wgpu::BindGroupEntry {
287                    binding: 0,
288                    resource: wgpu::BindingResource::TextureView(&view),
289                },
290                wgpu::BindGroupEntry {
291                    binding: 1,
292                    resource: wgpu::BindingResource::Sampler(&sampler),
293                },
294            ],
295        });
296
297        self.textures.insert(
298            id,
299            TextureEntry {
300                _texture: texture,
301                bind_group,
302                width,
303                height,
304            },
305        );
306    }
307
308    /// Upload raw RGBA pixels as a linear (non-sRGB) texture with bilinear filtering.
309    /// Use this for distance field atlases (MSDF, SDF) where values must be sampled linearly.
310    pub fn upload_raw_linear(
311        &mut self,
312        gpu: &GpuContext,
313        bind_group_layout: &wgpu::BindGroupLayout,
314        id: TextureId,
315        pixels: &[u8],
316        width: u32,
317        height: u32,
318    ) {
319        let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
320            label: Some(&format!("raw_linear_texture_{id}")),
321            size: wgpu::Extent3d {
322                width,
323                height,
324                depth_or_array_layers: 1,
325            },
326            mip_level_count: 1,
327            sample_count: 1,
328            dimension: wgpu::TextureDimension::D2,
329            format: wgpu::TextureFormat::Rgba8Unorm,
330            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
331            view_formats: &[],
332        });
333
334        gpu.queue.write_texture(
335            wgpu::TexelCopyTextureInfo {
336                texture: &texture,
337                mip_level: 0,
338                origin: wgpu::Origin3d::ZERO,
339                aspect: wgpu::TextureAspect::All,
340            },
341            pixels,
342            wgpu::TexelCopyBufferLayout {
343                offset: 0,
344                bytes_per_row: Some(4 * width),
345                rows_per_image: Some(height),
346            },
347            wgpu::Extent3d {
348                width,
349                height,
350                depth_or_array_layers: 1,
351            },
352        );
353
354        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
355        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
356            mag_filter: wgpu::FilterMode::Linear,
357            min_filter: wgpu::FilterMode::Linear,
358            ..Default::default()
359        });
360
361        let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
362            label: Some(&format!("raw_linear_texture_bind_group_{id}")),
363            layout: bind_group_layout,
364            entries: &[
365                wgpu::BindGroupEntry {
366                    binding: 0,
367                    resource: wgpu::BindingResource::TextureView(&view),
368                },
369                wgpu::BindGroupEntry {
370                    binding: 1,
371                    resource: wgpu::BindingResource::Sampler(&sampler),
372                },
373            ],
374        });
375
376        self.textures.insert(
377            id,
378            TextureEntry {
379                _texture: texture,
380                bind_group,
381                width,
382                height,
383            },
384        );
385    }
386
387    /// Get the bind group for a texture handle (regular textures and render targets).
388    pub fn get_bind_group(&self, id: TextureId) -> Option<&wgpu::BindGroup> {
389        self.textures
390            .get(&id)
391            .map(|e| &e.bind_group)
392            .or_else(|| self.render_target_bgs.get(&id).map(|(bg, _, _)| bg))
393    }
394
395    /// Get texture dimensions (regular textures and render targets).
396    pub fn get_dimensions(&self, id: TextureId) -> Option<(u32, u32)> {
397        self.textures
398            .get(&id)
399            .map(|e| (e.width, e.height))
400            .or_else(|| self.render_target_bgs.get(&id).map(|&(_, w, h)| (w, h)))
401    }
402
403    /// Register a render target's TextureView as a samplable texture.
404    ///
405    /// The texture itself is owned by `RenderTargetStore`; we only create the
406    /// bind group here. wgpu's internal reference counting keeps the GPU
407    /// resource alive for as long as the bind group exists.
408    pub fn register_render_target(
409        &mut self,
410        gpu: &GpuContext,
411        bind_group_layout: &wgpu::BindGroupLayout,
412        id: TextureId,
413        view: &wgpu::TextureView,
414        width: u32,
415        height: u32,
416    ) {
417        let sampler = gpu.device.create_sampler(&wgpu::SamplerDescriptor {
418            mag_filter: wgpu::FilterMode::Linear,
419            min_filter: wgpu::FilterMode::Linear,
420            ..Default::default()
421        });
422        let bg = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor {
423            label: Some(&format!("render_target_bg_{id}")),
424            layout: bind_group_layout,
425            entries: &[
426                wgpu::BindGroupEntry {
427                    binding: 0,
428                    resource: wgpu::BindingResource::TextureView(view),
429                },
430                wgpu::BindGroupEntry {
431                    binding: 1,
432                    resource: wgpu::BindingResource::Sampler(&sampler),
433                },
434            ],
435        });
436        self.render_target_bgs.insert(id, (bg, width, height));
437    }
438
439    /// Remove a render target's bind group. Call after destroying the render target.
440    pub fn unregister_render_target(&mut self, id: TextureId) {
441        self.render_target_bgs.remove(&id);
442    }
443}