Skip to main content

par_term_render/custom_shader_renderer/
textures.rs

1//! Channel texture management and intermediate texture management for custom shaders
2//!
3//! Provides loading and management of texture channels (iChannel0-3) that can be
4//! used by custom shaders alongside the terminal content (iChannel4).
5//!
6//! Also provides the intermediate (ping-pong) texture used to render terminal
7//! content into before the custom shader reads it, as well as the bind group
8//! recreation logic that wires all textures together.
9
10use crate::error::RenderError;
11use std::path::Path;
12use wgpu::*;
13
14use super::CustomShaderRenderer;
15
16/// A texture channel that can be bound to a custom shader
17pub struct ChannelTexture {
18    /// The GPU texture (kept alive to ensure view/sampler remain valid)
19    /// When using an external texture (e.g., background image), this is None
20    pub texture: Option<Texture>,
21    /// View for binding to shaders
22    pub view: TextureView,
23    /// Sampler for texture filtering
24    pub sampler: Sampler,
25    /// Texture width in pixels
26    pub width: u32,
27    /// Texture height in pixels
28    pub height: u32,
29}
30
31impl ChannelTexture {
32    /// Create a 1x1 transparent black placeholder texture
33    ///
34    /// This is used when no texture is configured for a channel,
35    /// ensuring the shader can still sample from it without errors.
36    pub fn placeholder(device: &Device, queue: &Queue) -> Self {
37        let texture = device.create_texture(&TextureDescriptor {
38            label: Some("Channel Placeholder Texture"),
39            size: Extent3d {
40                width: 1,
41                height: 1,
42                depth_or_array_layers: 1,
43            },
44            mip_level_count: 1,
45            sample_count: 1,
46            dimension: TextureDimension::D2,
47            format: TextureFormat::Rgba8UnormSrgb,
48            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
49            view_formats: &[],
50        });
51
52        // Write transparent black pixel
53        queue.write_texture(
54            TexelCopyTextureInfo {
55                texture: &texture,
56                mip_level: 0,
57                origin: Origin3d::ZERO,
58                aspect: TextureAspect::All,
59            },
60            &[0u8, 0, 0, 0], // RGBA: transparent black
61            TexelCopyBufferLayout {
62                offset: 0,
63                bytes_per_row: Some(4),
64                rows_per_image: Some(1),
65            },
66            Extent3d {
67                width: 1,
68                height: 1,
69                depth_or_array_layers: 1,
70            },
71        );
72
73        let view = texture.create_view(&TextureViewDescriptor::default());
74        let sampler = device.create_sampler(&SamplerDescriptor {
75            label: Some("Channel Placeholder Sampler"),
76            address_mode_u: AddressMode::ClampToEdge,
77            address_mode_v: AddressMode::ClampToEdge,
78            address_mode_w: AddressMode::ClampToEdge,
79            mag_filter: FilterMode::Linear,
80            min_filter: FilterMode::Linear,
81            mipmap_filter: FilterMode::Linear,
82            ..Default::default()
83        });
84
85        Self {
86            texture: Some(texture),
87            view,
88            sampler,
89            width: 1,
90            height: 1,
91        }
92    }
93
94    /// Create a ChannelTexture from an existing texture view and sampler.
95    ///
96    /// This is used when sharing a texture from another source (e.g., background image)
97    /// without creating a new copy. The caller is responsible for keeping the source
98    /// texture alive while this ChannelTexture is in use.
99    ///
100    /// # Arguments
101    /// * `view` - The texture view to use
102    /// * `sampler` - The sampler for texture filtering
103    /// * `width` - Texture width in pixels
104    /// * `height` - Texture height in pixels
105    pub fn from_view(view: TextureView, sampler: Sampler, width: u32, height: u32) -> Self {
106        Self {
107            texture: None,
108            view,
109            sampler,
110            width,
111            height,
112        }
113    }
114
115    /// Create a ChannelTexture from a view, sampler, and owned texture.
116    ///
117    /// This is used when creating a new texture that should be kept alive
118    /// by this ChannelTexture instance (e.g., solid color textures).
119    ///
120    /// # Arguments
121    /// * `view` - The texture view to use
122    /// * `sampler` - The sampler for texture filtering
123    /// * `width` - Texture width in pixels
124    /// * `height` - Texture height in pixels
125    /// * `texture` - The owned texture to keep alive
126    pub fn from_view_and_texture(
127        view: TextureView,
128        sampler: Sampler,
129        width: u32,
130        height: u32,
131        texture: Texture,
132    ) -> Self {
133        Self {
134            texture: Some(texture),
135            view,
136            sampler,
137            width,
138            height,
139        }
140    }
141
142    /// Load a texture from an image file
143    ///
144    /// Supports common image formats (PNG, JPEG, etc.) via the `image` crate.
145    ///
146    /// # Arguments
147    /// * `device` - The wgpu device
148    /// * `queue` - The wgpu queue
149    /// * `path` - Path to the image file
150    ///
151    /// # Returns
152    /// The loaded texture, or an error if loading fails
153    pub fn from_file(device: &Device, queue: &Queue, path: &Path) -> Result<Self, RenderError> {
154        // Load image and convert to RGBA8
155        let img = image::open(path)
156            .map_err(|e| RenderError::ImageLoad {
157                path: path.display().to_string(),
158                source: e,
159            })?
160            .to_rgba8();
161
162        let (width, height) = img.dimensions();
163
164        // Create GPU texture
165        let texture = device.create_texture(&TextureDescriptor {
166            label: Some(&format!("Channel Texture: {}", path.display())),
167            size: Extent3d {
168                width,
169                height,
170                depth_or_array_layers: 1,
171            },
172            mip_level_count: 1,
173            sample_count: 1,
174            dimension: TextureDimension::D2,
175            format: TextureFormat::Rgba8UnormSrgb,
176            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
177            view_formats: &[],
178        });
179
180        // Upload image data to GPU
181        queue.write_texture(
182            TexelCopyTextureInfo {
183                texture: &texture,
184                mip_level: 0,
185                origin: Origin3d::ZERO,
186                aspect: TextureAspect::All,
187            },
188            &img,
189            TexelCopyBufferLayout {
190                offset: 0,
191                bytes_per_row: Some(4 * width),
192                rows_per_image: Some(height),
193            },
194            Extent3d {
195                width,
196                height,
197                depth_or_array_layers: 1,
198            },
199        );
200
201        let view = texture.create_view(&TextureViewDescriptor::default());
202
203        // Create sampler with wrapping for tiled textures
204        let sampler = device.create_sampler(&SamplerDescriptor {
205            label: Some(&format!("Channel Sampler: {}", path.display())),
206            address_mode_u: AddressMode::Repeat,
207            address_mode_v: AddressMode::Repeat,
208            address_mode_w: AddressMode::Repeat,
209            mag_filter: FilterMode::Linear,
210            min_filter: FilterMode::Linear,
211            mipmap_filter: FilterMode::Linear,
212            ..Default::default()
213        });
214
215        log::info!(
216            "Loaded channel texture: {} ({}x{})",
217            path.display(),
218            width,
219            height
220        );
221
222        Ok(Self {
223            texture: Some(texture),
224            view,
225            sampler,
226            width,
227            height,
228        })
229    }
230
231    /// Get the resolution as a vec4 [width, height, 1.0, 0.0]
232    ///
233    /// This format matches Shadertoy's iChannelResolution uniform.
234    pub fn resolution(&self) -> [f32; 4] {
235        [self.width as f32, self.height as f32, 1.0, 0.0]
236    }
237}
238
239/// Load channel textures from optional paths
240///
241/// # Arguments
242/// * `device` - The wgpu device
243/// * `queue` - The wgpu queue
244/// * `paths` - Array of 4 optional paths for iChannel0-3
245///
246/// # Returns
247/// Array of 4 ChannelTexture instances (placeholders for None paths)
248pub fn load_channel_textures(
249    device: &Device,
250    queue: &Queue,
251    paths: &[Option<std::path::PathBuf>; 4],
252) -> [ChannelTexture; 4] {
253    let load_or_placeholder = |path: &Option<std::path::PathBuf>, index: usize| -> ChannelTexture {
254        match path {
255            Some(p) => match ChannelTexture::from_file(device, queue, p) {
256                Ok(tex) => tex,
257                Err(e) => {
258                    log::error!(
259                        "Failed to load iChannel{} texture '{}': {}",
260                        index,
261                        p.display(),
262                        e
263                    );
264                    ChannelTexture::placeholder(device, queue)
265                }
266            },
267            None => ChannelTexture::placeholder(device, queue),
268        }
269    };
270
271    [
272        load_or_placeholder(&paths[0], 0),
273        load_or_placeholder(&paths[1], 1),
274        load_or_placeholder(&paths[2], 2),
275        load_or_placeholder(&paths[3], 3),
276    ]
277}
278
279// ============ Intermediate texture and bind-group management ============
280
281impl CustomShaderRenderer {
282    /// Create the intermediate texture for rendering terminal content.
283    ///
284    /// The terminal scene is rendered into this texture first; the custom
285    /// shader then reads it via `iChannel4`.
286    pub(super) fn create_intermediate_texture(
287        device: &Device,
288        format: TextureFormat,
289        width: u32,
290        height: u32,
291    ) -> (Texture, TextureView) {
292        let texture = device.create_texture(&TextureDescriptor {
293            label: Some("Custom Shader Intermediate Texture"),
294            size: Extent3d {
295                width: width.max(1),
296                height: height.max(1),
297                depth_or_array_layers: 1,
298            },
299            mip_level_count: 1,
300            sample_count: 1,
301            dimension: TextureDimension::D2,
302            format,
303            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
304            view_formats: &[],
305        });
306
307        let view = texture.create_view(&TextureViewDescriptor::default());
308        (texture, view)
309    }
310
311    /// Clear the intermediate texture (e.g., when switching to split pane mode).
312    ///
313    /// This prevents old single-pane content from showing through the shader.
314    pub fn clear_intermediate_texture(&self, device: &Device, queue: &Queue) {
315        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
316            label: Some("Clear Intermediate Texture Encoder"),
317        });
318
319        {
320            let _clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
321                label: Some("Clear Intermediate Texture Pass"),
322                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
323                    view: &self.intermediate_texture_view,
324                    resolve_target: None,
325                    ops: wgpu::Operations {
326                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
327                        store: wgpu::StoreOp::Store,
328                    },
329                    depth_slice: None,
330                })],
331                depth_stencil_attachment: None,
332                timestamp_writes: None,
333                occlusion_query_set: None,
334            });
335        }
336
337        queue.submit(std::iter::once(encoder.finish()));
338    }
339
340    /// Resize the intermediate texture when the window size changes.
341    pub fn resize(&mut self, device: &Device, width: u32, height: u32) {
342        if width == self.texture_width && height == self.texture_height {
343            return;
344        }
345
346        self.texture_width = width;
347        self.texture_height = height;
348
349        // Recreate intermediate texture
350        let (texture, view) =
351            Self::create_intermediate_texture(device, self.surface_format, width, height);
352        self.intermediate_texture = texture;
353        self.intermediate_texture_view = view;
354
355        // Recreate bind group with new texture view (handles background as channel0 if enabled)
356        self.recreate_bind_group(device);
357    }
358
359    /// Recreate the bind group, using the background texture for channel0 if enabled.
360    ///
361    /// Priority for iChannel0:
362    /// 1. If `use_background_as_channel0` is enabled and a background texture is set,
363    ///    use the background texture.
364    /// 2. If channel0 has a configured texture (not a 1x1 placeholder), use it.
365    /// 3. Otherwise use the placeholder.
366    ///
367    /// This is called when:
368    /// - The background texture changes (and `use_background_as_channel0` is true)
369    /// - `use_background_as_channel0` flag changes
370    /// - The window resizes (intermediate texture changes)
371    pub(super) fn recreate_bind_group(&mut self, device: &Device) {
372        // Priority: use_background_as_channel0 (explicit override) > configured channel0 > placeholder
373        let channel0_texture = if self.use_background_as_channel0 {
374            // User explicitly wants background image as channel0
375            self.background_channel_texture
376                .as_ref()
377                .unwrap_or(&self.channel_textures[0])
378        } else if self.channel0_has_real_texture() {
379            // Channel0 has a real texture configured
380            &self.channel_textures[0]
381        } else {
382            // Use the placeholder
383            &self.channel_textures[0]
384        };
385
386        // Create a temporary array with the potentially swapped channel0
387        let effective_channels = [
388            channel0_texture,
389            &self.channel_textures[1],
390            &self.channel_textures[2],
391            &self.channel_textures[3],
392        ];
393
394        self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
395            label: Some("Custom Shader Bind Group"),
396            layout: &self.bind_group_layout,
397            entries: &[
398                wgpu::BindGroupEntry {
399                    binding: 0,
400                    resource: self.uniform_buffer.as_entire_binding(),
401                },
402                // iChannel0 (background or configured texture)
403                wgpu::BindGroupEntry {
404                    binding: 1,
405                    resource: wgpu::BindingResource::TextureView(&effective_channels[0].view),
406                },
407                wgpu::BindGroupEntry {
408                    binding: 2,
409                    resource: wgpu::BindingResource::Sampler(&effective_channels[0].sampler),
410                },
411                // iChannel1
412                wgpu::BindGroupEntry {
413                    binding: 3,
414                    resource: wgpu::BindingResource::TextureView(&effective_channels[1].view),
415                },
416                wgpu::BindGroupEntry {
417                    binding: 4,
418                    resource: wgpu::BindingResource::Sampler(&effective_channels[1].sampler),
419                },
420                // iChannel2
421                wgpu::BindGroupEntry {
422                    binding: 5,
423                    resource: wgpu::BindingResource::TextureView(&effective_channels[2].view),
424                },
425                wgpu::BindGroupEntry {
426                    binding: 6,
427                    resource: wgpu::BindingResource::Sampler(&effective_channels[2].sampler),
428                },
429                // iChannel3
430                wgpu::BindGroupEntry {
431                    binding: 7,
432                    resource: wgpu::BindingResource::TextureView(&effective_channels[3].view),
433                },
434                wgpu::BindGroupEntry {
435                    binding: 8,
436                    resource: wgpu::BindingResource::Sampler(&effective_channels[3].sampler),
437                },
438                // iChannel4 (terminal content)
439                wgpu::BindGroupEntry {
440                    binding: 9,
441                    resource: wgpu::BindingResource::TextureView(&self.intermediate_texture_view),
442                },
443                wgpu::BindGroupEntry {
444                    binding: 10,
445                    resource: wgpu::BindingResource::Sampler(&self.sampler),
446                },
447                // iCubemap
448                wgpu::BindGroupEntry {
449                    binding: 11,
450                    resource: wgpu::BindingResource::TextureView(&self.cubemap.view),
451                },
452                wgpu::BindGroupEntry {
453                    binding: 12,
454                    resource: wgpu::BindingResource::Sampler(&self.cubemap.sampler),
455                },
456            ],
457        });
458    }
459
460    /// Check if channel0 has a real configured texture (not just a 1x1 placeholder).
461    pub(super) fn channel0_has_real_texture(&self) -> bool {
462        let ch0 = &self.channel_textures[0];
463        // Placeholder textures are 1x1
464        ch0.width > 1 || ch0.height > 1
465    }
466
467    /// Get the effective channel0 resolution for the `iChannelResolution` uniform.
468    ///
469    /// Priority:
470    /// 1. If `use_background_as_channel0` is enabled and a background texture is set,
471    ///    return its resolution.
472    /// 2. Otherwise return channel0 texture resolution (configured or placeholder).
473    pub(super) fn effective_channel0_resolution(&self) -> [f32; 4] {
474        if self.use_background_as_channel0 {
475            self.background_channel_texture
476                .as_ref()
477                .map(|t| t.resolution())
478                .unwrap_or_else(|| self.channel_textures[0].resolution())
479        } else {
480            self.channel_textures[0].resolution()
481        }
482    }
483}