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