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