Skip to main content

par_term_render/custom_shader_renderer/
mod.rs

1//! Custom shader renderer for post-processing effects
2//!
3//! Supports Ghostty/Shadertoy-style GLSL shaders with the following uniforms:
4//! - `iTime`: Time in seconds (animated or fixed at 0.0)
5//! - `iResolution`: Viewport resolution (width, height, 1.0)
6//! - `iChannel0-3`: User texture channels (Shadertoy compatible)
7//! - `iChannel4`: Terminal content texture
8//! - `iTimeKeyPress`: Time when last key was pressed (same timebase as iTime)
9//!
10//! Ghostty-compatible cursor uniforms (v1.2.0+):
11//! - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
12//! - `iPreviousCursor`: Previous cursor position and size
13//! - `iCurrentCursorColor`: Current cursor RGBA color (with opacity baked in)
14//! - `iPreviousCursorColor`: Previous cursor RGBA color
15//! - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
16
17use anyhow::{Context, Result};
18use par_term_emu_core_rust::cursor::CursorStyle;
19use std::collections::BTreeMap;
20use std::path::Path;
21use std::time::Instant;
22use wgpu::util::DeviceExt;
23use wgpu::*;
24
25mod builtin_textures;
26mod cubemap;
27mod cursor;
28mod hot_reload;
29pub mod pipeline;
30mod state;
31pub mod textures;
32pub mod transpiler;
33pub mod types;
34mod uniforms;
35
36use cubemap::CubemapTexture;
37use pipeline::{
38    BindGroupInputs, create_bind_group, create_bind_group_layout, create_render_pipeline,
39};
40use textures::{ChannelTexture, load_channel_textures};
41use transpiler::transpile_glsl_to_wgsl;
42
43fn debug_shader_wgsl_filename(shader_name: &str) -> String {
44    format!("/tmp/par_term_{shader_name}_shader.wgsl")
45}
46
47fn write_debug_shader_wgsl(shader_name: &str, wgsl_source: &str) {
48    let debug_filename = debug_shader_wgsl_filename(shader_name);
49    if let Err(e) = std::fs::write(&debug_filename, wgsl_source) {
50        log::warn!("Failed to write debug shader: {}", e);
51    } else {
52        log::info!("Wrote debug shader to {}", debug_filename);
53    }
54}
55
56fn animation_start_after_enabled_update(
57    currently_enabled: bool,
58    enabled: bool,
59    current_start: Instant,
60    now: Instant,
61) -> Instant {
62    if enabled && !currently_enabled {
63        now
64    } else {
65        current_start
66    }
67}
68
69/// Custom shader renderer that applies post-processing effects
70pub struct CustomShaderRenderer {
71    /// The render pipeline for the custom shader
72    pub(crate) pipeline: RenderPipeline,
73    /// Bind group for shader uniforms and textures
74    pub(crate) bind_group: BindGroup,
75    /// Uniform buffer for shader parameters
76    pub(crate) uniform_buffer: Buffer,
77    /// Uniform buffer for custom shader control values
78    pub(crate) custom_uniform_buffer: Buffer,
79    /// Intermediate texture to render terminal content into
80    pub(crate) intermediate_texture: Texture,
81    /// View of the intermediate texture
82    pub(crate) intermediate_texture_view: TextureView,
83    /// Start time for animation
84    pub(crate) start_time: Instant,
85    /// Whether animation is enabled
86    pub(crate) animation_enabled: bool,
87    /// Animation speed multiplier
88    pub(crate) animation_speed: f32,
89    /// Current texture dimensions
90    pub(crate) texture_width: u32,
91    pub(crate) texture_height: u32,
92    /// Surface format for compatibility
93    pub(crate) surface_format: TextureFormat,
94    /// Bind group layout for recreating bind groups on resize
95    pub(crate) bind_group_layout: BindGroupLayout,
96    /// Sampler for the intermediate texture
97    pub(crate) sampler: Sampler,
98    /// Display scale factor for DPI scaling (e.g., 2.0 on Retina)
99    pub(crate) scale_factor: f32,
100    /// Window opacity for transparency
101    pub(crate) window_opacity: f32,
102    /// When true, text is always rendered at full opacity (overrides text_opacity)
103    pub(crate) keep_text_opaque: bool,
104    /// Full content mode - shader receives and can manipulate full terminal content
105    pub(crate) full_content_mode: bool,
106    /// Brightness multiplier for shader output (0.05-1.0)
107    pub(crate) brightness: f32,
108    /// Whether to dim shader output under terminal content.
109    pub(crate) auto_dim_under_text: bool,
110    /// Strength of dimming under terminal content.
111    pub(crate) auto_dim_strength: f32,
112    /// Frame counter for iFrame uniform
113    pub(crate) frame_count: u32,
114    /// Last frame time for calculating time delta
115    pub(crate) last_frame_time: Instant,
116    /// Current mouse position in pixels (xy)
117    pub(crate) mouse_position: [f32; 2],
118    /// Last click position in pixels (zw)
119    pub(crate) mouse_click_position: [f32; 2],
120    /// Whether mouse button is currently pressed (affects sign of zw)
121    pub(crate) mouse_button_down: bool,
122    /// Frame rate tracking: time accumulator for averaging
123    pub(crate) frame_time_accumulator: f32,
124    /// Frame rate tracking: frames in current second
125    pub(crate) frames_in_second: u32,
126    /// Current smoothed frame rate
127    pub(crate) current_frame_rate: f32,
128
129    // ============ Cursor tracking (Ghostty-compatible) ============
130    /// Current cursor position in cell coordinates (col, row)
131    pub(crate) current_cursor_pos: (usize, usize),
132    /// Previous cursor position in cell coordinates
133    pub(crate) previous_cursor_pos: (usize, usize),
134    /// Current cursor RGBA color
135    pub(crate) current_cursor_color: [f32; 4],
136    /// Previous cursor RGBA color
137    pub(crate) previous_cursor_color: [f32; 4],
138    /// Current cursor opacity (0.0 = invisible, 1.0 = fully visible)
139    pub(crate) current_cursor_opacity: f32,
140    /// Previous cursor opacity
141    pub(crate) previous_cursor_opacity: f32,
142    /// Time when cursor position last changed (same timebase as iTime)
143    pub(crate) cursor_change_time: f32,
144    /// Current cursor style (for size calculation)
145    pub(crate) current_cursor_style: CursorStyle,
146    /// Previous cursor style
147    pub(crate) previous_cursor_style: CursorStyle,
148    /// Cell width in pixels (for cursor position calculation)
149    pub(crate) cursor_cell_width: f32,
150    /// Cell height in pixels (for cursor position calculation)
151    pub(crate) cursor_cell_height: f32,
152    /// Window padding in pixels (for cursor position calculation)
153    pub(crate) cursor_window_padding: f32,
154    /// Vertical content offset in pixels (e.g., tab bar height)
155    pub(crate) cursor_content_offset_y: f32,
156    /// Horizontal content offset in pixels (e.g., tab bar on left)
157    pub(crate) cursor_content_offset_x: f32,
158
159    // ============ Cursor shader configuration ============
160    /// User-configured cursor color for shader effects [R, G, B, A]
161    pub(crate) cursor_shader_color: [f32; 4],
162    /// Cursor trail duration in seconds
163    pub(crate) cursor_trail_duration: f32,
164    /// Cursor glow radius in pixels
165    pub(crate) cursor_glow_radius: f32,
166    /// Cursor glow intensity (0.0-1.0)
167    pub(crate) cursor_glow_intensity: f32,
168
169    // ============ Key press tracking ============
170    /// Time when a key was last pressed (same timebase as iTime)
171    pub(crate) key_press_time: f32,
172
173    // ============ Channel textures (iChannel0-3) ============
174    /// Texture channels 0-3 (placeholders or loaded textures, Shadertoy compatible)
175    pub(crate) channel_textures: [ChannelTexture; 4],
176
177    // ============ Cubemap texture (iCubemap) ============
178    /// Cubemap texture for environment mapping (placeholder or loaded)
179    pub(crate) cubemap: CubemapTexture,
180
181    // ============ Background image as iChannel0 ============
182    /// When true, use the background image texture as iChannel0 instead of the configured texture
183    pub(crate) use_background_as_channel0: bool,
184    /// Background texture to use as iChannel0 when use_background_as_channel0 is true
185    /// This is a reference texture (view + sampler + dimensions) from the cell renderer
186    pub(crate) background_channel_texture: Option<ChannelTexture>,
187    /// Blend mode hint exposed to shaders for background-as-iChannel0 composition.
188    pub(crate) background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
189
190    // ============ Solid background color ============
191    /// Solid background color [R, G, B, A] for shader compositing.
192    /// When A > 0, the shader uses this color as background instead of shader output.
193    /// RGB values are NOT premultiplied.
194    pub(crate) background_color: [f32; 4],
195
196    // ============ Progress bar state ============
197    /// Progress bar data [state, percent, isActive, activeCount]
198    pub(crate) progress_data: [f32; 4],
199
200    // ============ Command state ============
201    /// Command state data [state, exitCode, eventTime, running]
202    pub(crate) command_data: [f32; 4],
203
204    // ============ Focused pane bounds ============
205    /// Focused pane bounds [x, y, width, height] in bottom-left-origin pixels.
206    pub(crate) focused_pane: [f32; 4],
207
208    // ============ Scrollback context ============
209    /// Scrollback context [offset, visibleLines, scrollbackLines, normalizedDepth]
210    pub(crate) scroll_data: [f32; 4],
211
212    // ============ Content inset for panels ============
213    /// Right content inset in pixels (e.g., AI Inspector panel).
214    /// The shader renders to a viewport offset by this amount from the left.
215    pub(crate) content_inset_right: f32,
216
217    // ============ Custom shader controls ============
218    /// Custom controls parsed from `// control ...` shader comments.
219    pub(crate) custom_controls: Vec<par_term_config::ShaderControl>,
220    /// Current custom uniform values keyed by control name.
221    pub(crate) custom_uniform_values: BTreeMap<String, par_term_config::ShaderUniformValue>,
222}
223
224/// Parameters for creating a new [`CustomShaderRenderer`].
225pub struct CustomShaderRendererConfig<'a> {
226    pub surface_format: TextureFormat,
227    pub shader_path: &'a Path,
228    pub width: u32,
229    pub height: u32,
230    pub animation_enabled: bool,
231    pub animation_speed: f32,
232    pub window_opacity: f32,
233    pub full_content_mode: bool,
234    pub channel_paths: &'a [Option<std::path::PathBuf>; 4],
235    pub cubemap_path: Option<&'a Path>,
236    pub custom_uniforms: &'a BTreeMap<String, par_term_config::ShaderUniformValue>,
237    pub background_channel0_blend_mode: par_term_config::ShaderBackgroundBlendMode,
238}
239
240impl CustomShaderRenderer {
241    /// Create a new custom shader renderer from a GLSL shader file.
242    pub fn new(
243        device: &Device,
244        queue: &Queue,
245        config: CustomShaderRendererConfig<'_>,
246    ) -> Result<Self> {
247        let CustomShaderRendererConfig {
248            surface_format,
249            shader_path,
250            width,
251            height,
252            animation_enabled,
253            animation_speed,
254            window_opacity,
255            full_content_mode,
256            channel_paths,
257            cubemap_path,
258            custom_uniforms,
259            background_channel0_blend_mode,
260        } = config;
261        // Load the GLSL shader
262        let glsl_source = std::fs::read_to_string(shader_path)
263            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
264
265        let control_parse = par_term_config::parse_shader_controls(&glsl_source);
266        for warning in &control_parse.warnings {
267            log::warn!(
268                "Shader control warning line {}: {}",
269                warning.line,
270                warning.message
271            );
272        }
273        let custom_controls = control_parse.controls;
274        let custom_uniform_values = custom_uniforms.clone();
275
276        // Transpile GLSL to WGSL
277        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
278
279        log::info!(
280            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
281            shader_path.display(),
282            glsl_source.len(),
283            wgsl_source.len()
284        );
285        log::debug!("Generated WGSL:\n{}", wgsl_source);
286
287        // DEBUG: Write generated WGSL to file for inspection
288        let shader_name = shader_path
289            .file_stem()
290            .and_then(|s| s.to_str())
291            .unwrap_or("unknown");
292        write_debug_shader_wgsl(shader_name, &wgsl_source);
293
294        // Pre-validate WGSL
295        let module = naga::front::wgsl::parse_str(&wgsl_source)
296            .context("Custom shader WGSL parse failed")?;
297        let _info = naga::valid::Validator::new(
298            naga::valid::ValidationFlags::all(),
299            naga::valid::Capabilities::empty(),
300        )
301        .validate(&module)
302        .context("Custom shader WGSL validation failed")?;
303
304        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
305            label: Some("Custom Shader Module"),
306            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
307        });
308
309        // Create intermediate texture for terminal content
310        let (intermediate_texture, intermediate_texture_view) =
311            Self::create_intermediate_texture(device, surface_format, width, height);
312
313        // Create sampler for the intermediate texture (terminal content)
314        // Use Nearest filtering to keep text crisp and pixel-perfect
315        let sampler = device.create_sampler(&SamplerDescriptor {
316            label: Some("Custom Shader Sampler"),
317            address_mode_u: AddressMode::ClampToEdge,
318            address_mode_v: AddressMode::ClampToEdge,
319            address_mode_w: AddressMode::ClampToEdge,
320            mag_filter: FilterMode::Nearest,
321            min_filter: FilterMode::Nearest,
322            mipmap_filter: MipmapFilterMode::Nearest,
323            ..Default::default()
324        });
325
326        // Load channel textures (iChannel0-3)
327        let channel_textures = load_channel_textures(device, queue, channel_paths);
328
329        // Load cubemap texture (iCubemap)
330        let cubemap = match cubemap_path {
331            Some(path) => match CubemapTexture::from_prefix(device, queue, path) {
332                Ok(cm) => cm,
333                Err(e) => {
334                    log::error!("Failed to load cubemap '{}': {}", path.display(), e);
335                    CubemapTexture::placeholder(device, queue)
336                }
337            },
338            None => CubemapTexture::placeholder(device, queue),
339        };
340
341        // Create uniform buffers
342        let uniform_buffer = Self::create_uniform_buffer(device);
343        let custom_uniform_data =
344            crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
345                &custom_controls,
346                &custom_uniform_values,
347            );
348        let custom_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
349            label: Some("Custom Shader Control Uniform Buffer"),
350            contents: bytemuck::cast_slice(&[custom_uniform_data]),
351            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
352        });
353
354        // Create bind group layout and bind group
355        let bind_group_layout = create_bind_group_layout(device);
356        let bind_group = create_bind_group(
357            device,
358            BindGroupInputs {
359                layout: &bind_group_layout,
360                uniform_buffer: &uniform_buffer,
361                intermediate_texture_view: &intermediate_texture_view,
362                custom_uniform_buffer: &custom_uniform_buffer,
363                sampler: &sampler,
364                channel_textures: &channel_textures,
365                cubemap: &cubemap,
366            },
367        );
368
369        // Create render pipeline
370        let pipeline = create_render_pipeline(
371            device,
372            &shader_module,
373            &bind_group_layout,
374            surface_format,
375            Some("Custom Shader Pipeline"),
376        );
377
378        let now = Instant::now();
379        Ok(Self {
380            pipeline,
381            bind_group,
382            uniform_buffer,
383            custom_uniform_buffer,
384            intermediate_texture,
385            intermediate_texture_view,
386            start_time: now,
387            animation_enabled,
388            animation_speed,
389            texture_width: width,
390            texture_height: height,
391            surface_format,
392            bind_group_layout,
393            sampler,
394            window_opacity,
395            keep_text_opaque: false,
396            scale_factor: 1.0,
397            full_content_mode,
398            brightness: 1.0,
399            auto_dim_under_text: false,
400            auto_dim_strength: 0.35,
401            frame_count: 0,
402            last_frame_time: now,
403            mouse_position: [0.0, 0.0],
404            mouse_click_position: [0.0, 0.0],
405            mouse_button_down: false,
406            frame_time_accumulator: 0.0,
407            frames_in_second: 0,
408            current_frame_rate: 60.0,
409            current_cursor_pos: (0, 0),
410            previous_cursor_pos: (0, 0),
411            current_cursor_color: [1.0, 1.0, 1.0, 1.0],
412            previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
413            current_cursor_opacity: 1.0,
414            previous_cursor_opacity: 1.0,
415            cursor_change_time: 0.0,
416            current_cursor_style: CursorStyle::SteadyBlock,
417            previous_cursor_style: CursorStyle::SteadyBlock,
418            cursor_cell_width: 10.0,
419            cursor_cell_height: 20.0,
420            cursor_window_padding: 0.0,
421            cursor_content_offset_y: 0.0,
422            cursor_content_offset_x: 0.0,
423            cursor_shader_color: [1.0, 1.0, 1.0, 1.0],
424            cursor_trail_duration: 0.5,
425            cursor_glow_radius: 80.0,
426            cursor_glow_intensity: 0.3,
427            key_press_time: 0.0,
428            channel_textures,
429            cubemap,
430            use_background_as_channel0: false,
431            background_channel_texture: None,
432            background_channel0_blend_mode,
433            background_color: [0.0, 0.0, 0.0, 0.0], // No solid background by default
434            progress_data: [0.0, 0.0, 0.0, 0.0],
435            command_data: [0.0, 0.0, 0.0, 0.0],
436            focused_pane: [0.0, 0.0, width as f32, height as f32],
437            scroll_data: [0.0, 0.0, 0.0, 0.0],
438            content_inset_right: 0.0,
439            custom_controls,
440            custom_uniform_values,
441        })
442    }
443
444    /// Get a view of the intermediate texture for rendering terminal content into
445    pub fn intermediate_texture_view(&self) -> &TextureView {
446        &self.intermediate_texture_view
447    }
448
449    /// Render the custom shader effect to the output texture
450    ///
451    /// # Arguments
452    /// * `device` - The GPU device
453    /// * `queue` - The command queue
454    /// * `output_view` - The texture view to render to
455    /// * `apply_opacity` - Whether to apply window opacity. Set to `false` when rendering
456    ///   to an intermediate texture that will be processed by another shader (to avoid
457    ///   double-applying opacity).
458    pub fn render(
459        &mut self,
460        device: &Device,
461        queue: &Queue,
462        output_view: &TextureView,
463        apply_opacity: bool,
464    ) -> Result<()> {
465        self.render_with_clear_color(
466            device,
467            queue,
468            output_view,
469            apply_opacity,
470            Color::TRANSPARENT,
471        )
472    }
473
474    /// Render the custom shader with a specified clear color.
475    /// Use this for solid background colors where the clear color provides the background.
476    pub fn render_with_clear_color(
477        &mut self,
478        device: &Device,
479        queue: &Queue,
480        output_view: &TextureView,
481        apply_opacity: bool,
482        clear_color: Color,
483    ) -> Result<()> {
484        let now = Instant::now();
485
486        // Calculate time value
487        let time = if self.animation_enabled {
488            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
489        } else {
490            0.0
491        };
492
493        // Calculate time delta
494        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
495        self.last_frame_time = now;
496
497        // Update frame rate calculation
498        self.frame_time_accumulator += time_delta;
499        self.frames_in_second += 1;
500        if self.frame_time_accumulator >= 1.0 {
501            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
502            self.frame_time_accumulator = 0.0;
503            self.frames_in_second = 0;
504        }
505
506        self.frame_count = self.frame_count.wrapping_add(1);
507
508        // Calculate uniforms
509        let uniforms = self.build_uniforms(time, time_delta, apply_opacity);
510        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
511        let custom_uniforms =
512            crate::custom_shader_renderer::types::CustomShaderControlUniforms::from_controls(
513                &self.custom_controls,
514                &self.custom_uniform_values,
515            );
516        queue.write_buffer(
517            &self.custom_uniform_buffer,
518            0,
519            bytemuck::cast_slice(&[custom_uniforms]),
520        );
521
522        // Create command encoder and render
523        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
524            label: Some("Custom Shader Encoder"),
525        });
526
527        {
528            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
529                label: Some("Custom Shader Render Pass"),
530                color_attachments: &[Some(RenderPassColorAttachment {
531                    view: output_view,
532                    resolve_target: None,
533                    ops: Operations {
534                        load: LoadOp::Clear(clear_color),
535                        store: StoreOp::Store,
536                    },
537                    depth_slice: None,
538                })],
539                depth_stencil_attachment: None,
540                timestamp_writes: None,
541                occlusion_query_set: None,
542                multiview_mask: None,
543            });
544
545            // Note: We intentionally do NOT set a viewport here to exclude the panel area.
546            // The viewport approach doesn't work because fragCoord in WGSL is relative to
547            // the render target, not the viewport, causing UV coordinate mismatches.
548            // The opaque panel (PANEL_BG with alpha 255) covers any shader output under it.
549
550            render_pass.set_pipeline(&self.pipeline);
551            render_pass.set_bind_group(0, &self.bind_group, &[]);
552            render_pass.draw(0..4, 0..1);
553        }
554
555        queue.submit(std::iter::once(encoder.finish()));
556        Ok(())
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use std::time::Duration;
564
565    #[test]
566    fn enabling_animation_when_already_enabled_preserves_start_time() {
567        let start_time = Instant::now() - Duration::from_secs(5);
568        let now = Instant::now();
569
570        assert_eq!(
571            animation_start_after_enabled_update(true, true, start_time, now),
572            start_time
573        );
574    }
575
576    #[test]
577    fn enabling_animation_from_disabled_starts_at_now() {
578        let start_time = Instant::now() - Duration::from_secs(5);
579        let now = Instant::now();
580
581        assert_eq!(
582            animation_start_after_enabled_update(false, true, start_time, now),
583            now
584        );
585    }
586
587    #[test]
588    fn debug_shader_wgsl_filename_matches_new_renderer_output_path() {
589        assert_eq!(
590            debug_shader_wgsl_filename("matrix"),
591            "/tmp/par_term_matrix_shader.wgsl"
592        );
593    }
594
595    #[test]
596    fn write_debug_shader_wgsl_refreshes_existing_output() {
597        let shader_name = format!("par_term_test_{}", std::process::id());
598        let path = debug_shader_wgsl_filename(&shader_name);
599        let _ = std::fs::remove_file(&path);
600
601        write_debug_shader_wgsl(&shader_name, "first");
602        write_debug_shader_wgsl(&shader_name, "second");
603
604        assert_eq!(
605            std::fs::read_to_string(&path).expect("read debug wgsl"),
606            "second"
607        );
608        std::fs::remove_file(&path).expect("remove debug wgsl");
609    }
610}