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: FilterMode::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: FilterMode::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            });
383        }
384
385        queue.submit(std::iter::once(encoder.finish()));
386    }
387
388    /// Resize the intermediate texture when the window size changes.
389    pub fn resize(&mut self, device: &Device, width: u32, height: u32) {
390        if width == self.texture_width && height == self.texture_height {
391            return;
392        }
393
394        self.texture_width = width;
395        self.texture_height = height;
396
397        // Recreate intermediate texture
398        let (texture, view) =
399            Self::create_intermediate_texture(device, self.surface_format, width, height);
400        self.intermediate_texture = texture;
401        self.intermediate_texture_view = view;
402
403        // Recreate bind group with new texture view (handles background as channel0 if enabled)
404        self.recreate_bind_group(device);
405    }
406
407    /// Recreate the bind group, using the background texture for channel0 if enabled.
408    ///
409    /// Priority for iChannel0:
410    /// 1. If `use_background_as_channel0` is enabled and a background texture is set,
411    ///    use the background texture.
412    /// 2. If channel0 has a configured texture (not a 1x1 placeholder), use it.
413    /// 3. Otherwise use the placeholder.
414    ///
415    /// This is called when:
416    /// - The background texture changes (and `use_background_as_channel0` is true)
417    /// - `use_background_as_channel0` flag changes
418    /// - The window resizes (intermediate texture changes)
419    pub(super) fn recreate_bind_group(&mut self, device: &Device) {
420        // Priority: use_background_as_channel0 (explicit override) > configured channel0 > placeholder
421        let channel0_texture = if self.use_background_as_channel0 {
422            // User explicitly wants background image as channel0
423            self.background_channel_texture
424                .as_ref()
425                .unwrap_or(&self.channel_textures[0])
426        } else if self.channel0_has_real_texture() {
427            // Channel0 has a real texture configured
428            &self.channel_textures[0]
429        } else {
430            // Use the placeholder
431            &self.channel_textures[0]
432        };
433
434        // Create a temporary array with the potentially swapped channel0
435        let effective_channels = [
436            channel0_texture,
437            &self.channel_textures[1],
438            &self.channel_textures[2],
439            &self.channel_textures[3],
440        ];
441
442        self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
443            label: Some("Custom Shader Bind Group"),
444            layout: &self.bind_group_layout,
445            entries: &[
446                wgpu::BindGroupEntry {
447                    binding: 0,
448                    resource: self.uniform_buffer.as_entire_binding(),
449                },
450                // iChannel0 (background or configured texture)
451                wgpu::BindGroupEntry {
452                    binding: 1,
453                    resource: wgpu::BindingResource::TextureView(&effective_channels[0].view),
454                },
455                wgpu::BindGroupEntry {
456                    binding: 2,
457                    resource: wgpu::BindingResource::Sampler(&effective_channels[0].sampler),
458                },
459                // iChannel1
460                wgpu::BindGroupEntry {
461                    binding: 3,
462                    resource: wgpu::BindingResource::TextureView(&effective_channels[1].view),
463                },
464                wgpu::BindGroupEntry {
465                    binding: 4,
466                    resource: wgpu::BindingResource::Sampler(&effective_channels[1].sampler),
467                },
468                // iChannel2
469                wgpu::BindGroupEntry {
470                    binding: 5,
471                    resource: wgpu::BindingResource::TextureView(&effective_channels[2].view),
472                },
473                wgpu::BindGroupEntry {
474                    binding: 6,
475                    resource: wgpu::BindingResource::Sampler(&effective_channels[2].sampler),
476                },
477                // iChannel3
478                wgpu::BindGroupEntry {
479                    binding: 7,
480                    resource: wgpu::BindingResource::TextureView(&effective_channels[3].view),
481                },
482                wgpu::BindGroupEntry {
483                    binding: 8,
484                    resource: wgpu::BindingResource::Sampler(&effective_channels[3].sampler),
485                },
486                // iChannel4 (terminal content)
487                wgpu::BindGroupEntry {
488                    binding: 9,
489                    resource: wgpu::BindingResource::TextureView(&self.intermediate_texture_view),
490                },
491                wgpu::BindGroupEntry {
492                    binding: 10,
493                    resource: wgpu::BindingResource::Sampler(&self.sampler),
494                },
495                // iCubemap
496                wgpu::BindGroupEntry {
497                    binding: 11,
498                    resource: wgpu::BindingResource::TextureView(&self.cubemap.view),
499                },
500                wgpu::BindGroupEntry {
501                    binding: 12,
502                    resource: wgpu::BindingResource::Sampler(&self.cubemap.sampler),
503                },
504                // Custom shader controls
505                wgpu::BindGroupEntry {
506                    binding: 13,
507                    resource: self.custom_uniform_buffer.as_entire_binding(),
508                },
509            ],
510        });
511    }
512
513    /// Check if channel0 has a real configured texture (not just a 1x1 placeholder).
514    pub(super) fn channel0_has_real_texture(&self) -> bool {
515        let ch0 = &self.channel_textures[0];
516        // Placeholder textures are 1x1
517        ch0.width > 1 || ch0.height > 1
518    }
519
520    /// Get the effective channel0 resolution for the `iChannelResolution` uniform.
521    ///
522    /// Priority:
523    /// 1. If `use_background_as_channel0` is enabled and a background texture is set,
524    ///    return its resolution.
525    /// 2. Otherwise return channel0 texture resolution (configured or placeholder).
526    pub(super) fn effective_channel0_resolution(&self) -> [f32; 4] {
527        if self.use_background_as_channel0 {
528            self.background_channel_texture
529                .as_ref()
530                .map(|t| t.resolution())
531                .unwrap_or_else(|| self.channel_textures[0].resolution())
532        } else {
533            self.channel_textures[0].resolution()
534        }
535    }
536}