par_term/custom_shader_renderer/
textures.rs

1//! Channel texture management for custom shaders
2//!
3//! Provides loading and management of texture channels (iChannel1-4)
4//! that can be used by custom shaders alongside the terminal content (iChannel0).
5
6use anyhow::{Context, Result};
7use std::path::Path;
8use wgpu::*;
9
10/// A texture channel that can be bound to a custom shader
11pub struct ChannelTexture {
12    /// The GPU texture (kept alive to ensure view/sampler remain valid)
13    #[allow(dead_code)]
14    pub texture: Texture,
15    /// View for binding to shaders
16    pub view: TextureView,
17    /// Sampler for texture filtering
18    pub sampler: Sampler,
19    /// Texture width in pixels
20    pub width: u32,
21    /// Texture height in pixels
22    pub height: u32,
23}
24
25impl ChannelTexture {
26    /// Create a 1x1 transparent black placeholder texture
27    ///
28    /// This is used when no texture is configured for a channel,
29    /// ensuring the shader can still sample from it without errors.
30    pub fn placeholder(device: &Device, queue: &Queue) -> Self {
31        let texture = device.create_texture(&TextureDescriptor {
32            label: Some("Channel Placeholder Texture"),
33            size: Extent3d {
34                width: 1,
35                height: 1,
36                depth_or_array_layers: 1,
37            },
38            mip_level_count: 1,
39            sample_count: 1,
40            dimension: TextureDimension::D2,
41            format: TextureFormat::Rgba8UnormSrgb,
42            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
43            view_formats: &[],
44        });
45
46        // Write transparent black pixel
47        queue.write_texture(
48            TexelCopyTextureInfo {
49                texture: &texture,
50                mip_level: 0,
51                origin: Origin3d::ZERO,
52                aspect: TextureAspect::All,
53            },
54            &[0u8, 0, 0, 0], // RGBA: transparent black
55            TexelCopyBufferLayout {
56                offset: 0,
57                bytes_per_row: Some(4),
58                rows_per_image: Some(1),
59            },
60            Extent3d {
61                width: 1,
62                height: 1,
63                depth_or_array_layers: 1,
64            },
65        );
66
67        let view = texture.create_view(&TextureViewDescriptor::default());
68        let sampler = device.create_sampler(&SamplerDescriptor {
69            label: Some("Channel Placeholder Sampler"),
70            address_mode_u: AddressMode::ClampToEdge,
71            address_mode_v: AddressMode::ClampToEdge,
72            address_mode_w: AddressMode::ClampToEdge,
73            mag_filter: FilterMode::Linear,
74            min_filter: FilterMode::Linear,
75            mipmap_filter: FilterMode::Linear,
76            ..Default::default()
77        });
78
79        Self {
80            texture,
81            view,
82            sampler,
83            width: 1,
84            height: 1,
85        }
86    }
87
88    /// Load a texture from an image file
89    ///
90    /// Supports common image formats (PNG, JPEG, etc.) via the `image` crate.
91    ///
92    /// # Arguments
93    /// * `device` - The wgpu device
94    /// * `queue` - The wgpu queue
95    /// * `path` - Path to the image file
96    ///
97    /// # Returns
98    /// The loaded texture, or an error if loading fails
99    pub fn from_file(device: &Device, queue: &Queue, path: &Path) -> Result<Self> {
100        // Load image and convert to RGBA8
101        let img = image::open(path)
102            .with_context(|| format!("Failed to open image: {}", path.display()))?
103            .to_rgba8();
104
105        let (width, height) = img.dimensions();
106
107        // Create GPU texture
108        let texture = device.create_texture(&TextureDescriptor {
109            label: Some(&format!("Channel Texture: {}", path.display())),
110            size: Extent3d {
111                width,
112                height,
113                depth_or_array_layers: 1,
114            },
115            mip_level_count: 1,
116            sample_count: 1,
117            dimension: TextureDimension::D2,
118            format: TextureFormat::Rgba8UnormSrgb,
119            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
120            view_formats: &[],
121        });
122
123        // Upload image data to GPU
124        queue.write_texture(
125            TexelCopyTextureInfo {
126                texture: &texture,
127                mip_level: 0,
128                origin: Origin3d::ZERO,
129                aspect: TextureAspect::All,
130            },
131            &img,
132            TexelCopyBufferLayout {
133                offset: 0,
134                bytes_per_row: Some(4 * width),
135                rows_per_image: Some(height),
136            },
137            Extent3d {
138                width,
139                height,
140                depth_or_array_layers: 1,
141            },
142        );
143
144        let view = texture.create_view(&TextureViewDescriptor::default());
145
146        // Create sampler with wrapping for tiled textures
147        let sampler = device.create_sampler(&SamplerDescriptor {
148            label: Some(&format!("Channel Sampler: {}", path.display())),
149            address_mode_u: AddressMode::Repeat,
150            address_mode_v: AddressMode::Repeat,
151            address_mode_w: AddressMode::Repeat,
152            mag_filter: FilterMode::Linear,
153            min_filter: FilterMode::Linear,
154            mipmap_filter: FilterMode::Linear,
155            ..Default::default()
156        });
157
158        log::info!(
159            "Loaded channel texture: {} ({}x{})",
160            path.display(),
161            width,
162            height
163        );
164
165        Ok(Self {
166            texture,
167            view,
168            sampler,
169            width,
170            height,
171        })
172    }
173
174    /// Get the resolution as a vec4 [width, height, 1.0, 0.0]
175    ///
176    /// This format matches Shadertoy's iChannelResolution uniform.
177    pub fn resolution(&self) -> [f32; 4] {
178        [self.width as f32, self.height as f32, 1.0, 0.0]
179    }
180}
181
182/// Load channel textures from optional paths
183///
184/// # Arguments
185/// * `device` - The wgpu device
186/// * `queue` - The wgpu queue
187/// * `paths` - Array of 4 optional paths for iChannel1-4
188///
189/// # Returns
190/// Array of 4 ChannelTexture instances (placeholders for None paths)
191pub fn load_channel_textures(
192    device: &Device,
193    queue: &Queue,
194    paths: &[Option<std::path::PathBuf>; 4],
195) -> [ChannelTexture; 4] {
196    let load_or_placeholder = |path: &Option<std::path::PathBuf>, index: usize| -> ChannelTexture {
197        match path {
198            Some(p) => match ChannelTexture::from_file(device, queue, p) {
199                Ok(tex) => tex,
200                Err(e) => {
201                    log::error!(
202                        "Failed to load iChannel{} texture '{}': {}",
203                        index + 1,
204                        p.display(),
205                        e
206                    );
207                    ChannelTexture::placeholder(device, queue)
208                }
209            },
210            None => ChannelTexture::placeholder(device, queue),
211        }
212    };
213
214    [
215        load_or_placeholder(&paths[0], 0),
216        load_or_placeholder(&paths[1], 1),
217        load_or_placeholder(&paths[2], 2),
218        load_or_placeholder(&paths[3], 3),
219    ]
220}