par_term/
custom_shader_renderer.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`: Terminal content texture
7
8use anyhow::{Context, Result};
9use std::path::Path;
10use std::time::Instant;
11use wgpu::*;
12
13/// Uniform data passed to custom shaders
14/// Layout must match GLSL std140 rules:
15/// - vec2 aligned to 8 bytes
16/// - vec4 aligned to 16 bytes
17/// - float aligned to 4 bytes
18/// - struct size rounded to 16 bytes (largest alignment)
19#[repr(C)]
20#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
21struct CustomShaderUniforms {
22    /// Viewport resolution (iResolution.xy) - offset 0, size 8
23    resolution: [f32; 2],
24    /// Time in seconds since shader started (iTime) - offset 8, size 4
25    time: f32,
26    /// Time since last frame in seconds (iTimeDelta) - offset 12, size 4
27    time_delta: f32,
28    /// Mouse state (iMouse) - offset 16, size 16
29    /// xy = current position (if dragging) or last drag position
30    /// zw = click position (positive when held, negative when released)
31    mouse: [f32; 4],
32    /// Date/time (iDate) - offset 32, size 16
33    /// x = year, y = month (0-11), z = day (1-31), w = seconds since midnight
34    date: [f32; 4],
35    /// Window opacity for transparency support - offset 48, size 4
36    opacity: f32,
37    /// Text opacity (separate from window opacity) - offset 52, size 4
38    text_opacity: f32,
39    /// Full content mode: 1.0 = shader receives and outputs full content, 0.0 = background only
40    full_content_mode: f32,
41    /// Frame counter (iFrame) - offset 60, size 4
42    frame: f32,
43    /// Current frame rate in FPS (iFrameRate) - offset 64, size 4
44    frame_rate: f32,
45    /// Pixel aspect ratio (iResolution.z) - offset 68, size 4, usually 1.0
46    resolution_z: f32,
47    /// Padding to reach 80 bytes (multiple of 16) - offset 72, size 8
48    _padding: [f32; 2],
49}
50// Total size: 80 bytes
51
52/// Custom shader renderer that applies post-processing effects
53pub struct CustomShaderRenderer {
54    /// The render pipeline for the custom shader
55    pipeline: RenderPipeline,
56    /// Bind group for shader uniforms and textures
57    bind_group: BindGroup,
58    /// Uniform buffer for shader parameters
59    uniform_buffer: Buffer,
60    /// Intermediate texture to render terminal content into
61    intermediate_texture: Texture,
62    /// View of the intermediate texture
63    intermediate_texture_view: TextureView,
64    /// Start time for animation
65    start_time: Instant,
66    /// Whether animation is enabled
67    animation_enabled: bool,
68    /// Animation speed multiplier
69    animation_speed: f32,
70    /// Current texture dimensions
71    texture_width: u32,
72    texture_height: u32,
73    /// Surface format for compatibility
74    surface_format: TextureFormat,
75    /// Bind group layout for recreating bind groups on resize
76    bind_group_layout: BindGroupLayout,
77    /// Sampler for the intermediate texture
78    sampler: Sampler,
79    /// Window opacity for transparency
80    window_opacity: f32,
81    /// Text opacity (separate from window opacity)
82    text_opacity: f32,
83    /// Full content mode - shader receives and can manipulate full terminal content
84    full_content_mode: bool,
85    /// Frame counter for iFrame uniform
86    frame_count: u32,
87    /// Last frame time for calculating time delta
88    last_frame_time: Instant,
89    /// Current mouse position in pixels (xy)
90    mouse_position: [f32; 2],
91    /// Last click position in pixels (zw)
92    mouse_click_position: [f32; 2],
93    /// Whether mouse button is currently pressed (affects sign of zw)
94    mouse_button_down: bool,
95    /// Frame rate tracking: time accumulator for averaging
96    frame_time_accumulator: f32,
97    /// Frame rate tracking: frames in current second
98    frames_in_second: u32,
99    /// Current smoothed frame rate
100    current_frame_rate: f32,
101}
102
103impl CustomShaderRenderer {
104    /// Create a new custom shader renderer from a GLSL shader file
105    ///
106    /// # Arguments
107    /// * `device` - The wgpu device
108    /// * `queue` - The wgpu queue
109    /// * `surface_format` - The surface texture format
110    /// * `shader_path` - Path to the GLSL shader file
111    /// * `width` - Initial viewport width
112    /// * `height` - Initial viewport height
113    /// * `animation_enabled` - Whether to animate iTime
114    /// * `animation_speed` - Animation speed multiplier
115    #[allow(clippy::too_many_arguments)]
116    pub fn new(
117        device: &Device,
118        _queue: &Queue,
119        surface_format: TextureFormat,
120        shader_path: &Path,
121        width: u32,
122        height: u32,
123        animation_enabled: bool,
124        animation_speed: f32,
125        window_opacity: f32,
126        text_opacity: f32,
127        full_content_mode: bool,
128    ) -> Result<Self> {
129        // Load the GLSL shader
130        let glsl_source = std::fs::read_to_string(shader_path)
131            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
132
133        // Transpile GLSL to WGSL
134        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
135
136        log::info!(
137            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
138            shader_path.display(),
139            glsl_source.len(),
140            wgsl_source.len()
141        );
142        log::debug!("Generated WGSL:\n{}", wgsl_source);
143
144        // Create the shader module
145        // Pre-validate WGSL to surface errors gracefully
146        let module = naga::front::wgsl::parse_str(&wgsl_source)
147            .context("Custom shader WGSL parse failed")?;
148        let _info = naga::valid::Validator::new(
149            naga::valid::ValidationFlags::all(),
150            naga::valid::Capabilities::empty(),
151        )
152        .validate(&module)
153        .context("Custom shader WGSL validation failed")?;
154
155        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
156            label: Some("Custom Shader Module"),
157            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
158        });
159
160        // Create intermediate texture for terminal content
161        let (intermediate_texture, intermediate_texture_view) =
162            Self::create_intermediate_texture(device, surface_format, width, height);
163
164        // Create sampler for the intermediate texture
165        let sampler = device.create_sampler(&SamplerDescriptor {
166            label: Some("Custom Shader Sampler"),
167            address_mode_u: AddressMode::ClampToEdge,
168            address_mode_v: AddressMode::ClampToEdge,
169            address_mode_w: AddressMode::ClampToEdge,
170            mag_filter: FilterMode::Linear,
171            min_filter: FilterMode::Linear,
172            mipmap_filter: FilterMode::Linear,
173            ..Default::default()
174        });
175
176        // Create uniform buffer
177        let uniform_buffer = device.create_buffer(&BufferDescriptor {
178            label: Some("Custom Shader Uniforms"),
179            size: std::mem::size_of::<CustomShaderUniforms>() as u64,
180            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
181            mapped_at_creation: false,
182        });
183
184        // Create bind group layout
185        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
186            label: Some("Custom Shader Bind Group Layout"),
187            entries: &[
188                // Uniform buffer (binding 0)
189                BindGroupLayoutEntry {
190                    binding: 0,
191                    visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
192                    ty: BindingType::Buffer {
193                        ty: BufferBindingType::Uniform,
194                        has_dynamic_offset: false,
195                        min_binding_size: None,
196                    },
197                    count: None,
198                },
199                // iChannel0 texture (binding 1)
200                BindGroupLayoutEntry {
201                    binding: 1,
202                    visibility: ShaderStages::FRAGMENT,
203                    ty: BindingType::Texture {
204                        sample_type: TextureSampleType::Float { filterable: true },
205                        view_dimension: TextureViewDimension::D2,
206                        multisampled: false,
207                    },
208                    count: None,
209                },
210                // Sampler (binding 2)
211                BindGroupLayoutEntry {
212                    binding: 2,
213                    visibility: ShaderStages::FRAGMENT,
214                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
215                    count: None,
216                },
217            ],
218        });
219
220        // Create bind group
221        let bind_group = device.create_bind_group(&BindGroupDescriptor {
222            label: Some("Custom Shader Bind Group"),
223            layout: &bind_group_layout,
224            entries: &[
225                BindGroupEntry {
226                    binding: 0,
227                    resource: uniform_buffer.as_entire_binding(),
228                },
229                BindGroupEntry {
230                    binding: 1,
231                    resource: BindingResource::TextureView(&intermediate_texture_view),
232                },
233                BindGroupEntry {
234                    binding: 2,
235                    resource: BindingResource::Sampler(&sampler),
236                },
237            ],
238        });
239
240        // Create pipeline layout
241        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
242            label: Some("Custom Shader Pipeline Layout"),
243            bind_group_layouts: &[&bind_group_layout],
244            push_constant_ranges: &[],
245        });
246
247        // Create render pipeline
248        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
249            label: Some("Custom Shader Pipeline"),
250            layout: Some(&pipeline_layout),
251            vertex: VertexState {
252                module: &shader_module,
253                entry_point: Some("vs_main"),
254                buffers: &[],
255                compilation_options: Default::default(),
256            },
257            fragment: Some(FragmentState {
258                module: &shader_module,
259                entry_point: Some("fs_main"),
260                targets: &[Some(ColorTargetState {
261                    format: surface_format,
262                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
263                    write_mask: ColorWrites::ALL,
264                })],
265                compilation_options: Default::default(),
266            }),
267            primitive: PrimitiveState {
268                topology: PrimitiveTopology::TriangleStrip,
269                ..Default::default()
270            },
271            depth_stencil: None,
272            multisample: MultisampleState::default(),
273            multiview: None,
274            cache: None,
275        });
276
277        let now = Instant::now();
278        Ok(Self {
279            pipeline,
280            bind_group,
281            uniform_buffer,
282            intermediate_texture,
283            intermediate_texture_view,
284            start_time: now,
285            animation_enabled,
286            animation_speed,
287            texture_width: width,
288            texture_height: height,
289            surface_format,
290            bind_group_layout,
291            sampler,
292            window_opacity,
293            text_opacity,
294            full_content_mode,
295            frame_count: 0,
296            last_frame_time: now,
297            mouse_position: [0.0, 0.0],
298            mouse_click_position: [0.0, 0.0],
299            mouse_button_down: false,
300            frame_time_accumulator: 0.0,
301            frames_in_second: 0,
302            current_frame_rate: 60.0, // Start with reasonable default
303        })
304    }
305
306    /// Create the intermediate texture for rendering terminal content
307    fn create_intermediate_texture(
308        device: &Device,
309        format: TextureFormat,
310        width: u32,
311        height: u32,
312    ) -> (Texture, TextureView) {
313        let texture = device.create_texture(&TextureDescriptor {
314            label: Some("Custom Shader Intermediate Texture"),
315            size: Extent3d {
316                width: width.max(1),
317                height: height.max(1),
318                depth_or_array_layers: 1,
319            },
320            mip_level_count: 1,
321            sample_count: 1,
322            dimension: TextureDimension::D2,
323            format,
324            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
325            view_formats: &[],
326        });
327
328        let view = texture.create_view(&TextureViewDescriptor::default());
329        (texture, view)
330    }
331
332    /// Get a view of the intermediate texture for rendering terminal content into
333    pub fn intermediate_texture_view(&self) -> &TextureView {
334        &self.intermediate_texture_view
335    }
336
337    /// Resize the intermediate texture when window size changes
338    pub fn resize(&mut self, device: &Device, width: u32, height: u32) {
339        if width == self.texture_width && height == self.texture_height {
340            return;
341        }
342
343        self.texture_width = width;
344        self.texture_height = height;
345
346        // Recreate intermediate texture
347        let (texture, view) =
348            Self::create_intermediate_texture(device, self.surface_format, width, height);
349        self.intermediate_texture = texture;
350        self.intermediate_texture_view = view;
351
352        // Recreate bind group with new texture view
353        self.bind_group = device.create_bind_group(&BindGroupDescriptor {
354            label: Some("Custom Shader Bind Group"),
355            layout: &self.bind_group_layout,
356            entries: &[
357                BindGroupEntry {
358                    binding: 0,
359                    resource: self.uniform_buffer.as_entire_binding(),
360                },
361                BindGroupEntry {
362                    binding: 1,
363                    resource: BindingResource::TextureView(&self.intermediate_texture_view),
364                },
365                BindGroupEntry {
366                    binding: 2,
367                    resource: BindingResource::Sampler(&self.sampler),
368                },
369            ],
370        });
371    }
372
373    /// Render the custom shader effect to the output texture
374    ///
375    /// This should be called after the terminal content has been rendered to the
376    /// intermediate texture obtained via `intermediate_texture_view()`.
377    pub fn render(
378        &mut self,
379        device: &Device,
380        queue: &Queue,
381        output_view: &TextureView,
382    ) -> Result<()> {
383        let now = Instant::now();
384
385        // Calculate time value
386        let time = if self.animation_enabled {
387            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
388        } else {
389            0.0
390        };
391
392        // Calculate time delta
393        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
394        self.last_frame_time = now;
395
396        // Update frame rate calculation (smoothed over ~1 second)
397        self.frame_time_accumulator += time_delta;
398        self.frames_in_second += 1;
399        if self.frame_time_accumulator >= 1.0 {
400            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
401            self.frame_time_accumulator = 0.0;
402            self.frames_in_second = 0;
403        }
404
405        // Increment frame counter
406        self.frame_count = self.frame_count.wrapping_add(1);
407
408        // Calculate iMouse uniform
409        // xy = current position (Shadertoy uses bottom-left origin, so flip Y)
410        // zw = click position (positive when button down, negative when up)
411        let height = self.texture_height as f32;
412        let mouse_y_flipped = height - self.mouse_position[1];
413        let click_y_flipped = height - self.mouse_click_position[1];
414
415        let mouse = if self.mouse_button_down {
416            // When dragging, xy = current position, zw = positive click position
417            [
418                self.mouse_position[0],
419                mouse_y_flipped,
420                self.mouse_click_position[0],
421                click_y_flipped,
422            ]
423        } else {
424            // When not dragging, xy = last drag position (or 0), zw = negative click position
425            [
426                self.mouse_position[0],
427                mouse_y_flipped,
428                -self.mouse_click_position[0].abs(),
429                -click_y_flipped.abs(),
430            ]
431        };
432
433        // Calculate iDate uniform
434        // x = year, y = month (0-11), z = day (1-31), w = seconds since midnight
435        let date = {
436            use std::time::{SystemTime, UNIX_EPOCH};
437            let now_sys = SystemTime::now();
438            let since_epoch = now_sys.duration_since(UNIX_EPOCH).unwrap_or_default();
439            let secs = since_epoch.as_secs();
440
441            // Calculate date components (simplified UTC calculation)
442            // This is a basic implementation - for more accuracy, consider using chrono
443            let days_since_epoch = secs / 86400;
444            let secs_today = (secs % 86400) as f32;
445
446            // Approximate year/month/day calculation
447            // Starting from 1970-01-01
448            let mut year = 1970i32;
449            let mut remaining_days = days_since_epoch as i32;
450
451            loop {
452                let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
453                    366
454                } else {
455                    365
456                };
457                if remaining_days < days_in_year {
458                    break;
459                }
460                remaining_days -= days_in_year;
461                year += 1;
462            }
463
464            let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
465            let days_in_months: [i32; 12] = if is_leap {
466                [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
467            } else {
468                [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
469            };
470
471            let mut month = 0i32;
472            for (i, &days) in days_in_months.iter().enumerate() {
473                if remaining_days < days {
474                    month = i as i32;
475                    break;
476                }
477                remaining_days -= days;
478            }
479
480            let day = remaining_days + 1; // Days are 1-indexed
481
482            [year as f32, month as f32, day as f32, secs_today]
483        };
484
485        // Update uniforms
486        let uniforms = CustomShaderUniforms {
487            resolution: [self.texture_width as f32, self.texture_height as f32],
488            time,
489            time_delta,
490            mouse,
491            date,
492            opacity: self.window_opacity,
493            text_opacity: self.text_opacity,
494            full_content_mode: if self.full_content_mode { 1.0 } else { 0.0 },
495            frame: self.frame_count as f32,
496            frame_rate: self.current_frame_rate,
497            resolution_z: 1.0, // Pixel aspect ratio, usually 1.0
498            _padding: [0.0, 0.0],
499        };
500
501        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
502
503        // Create command encoder
504        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
505            label: Some("Custom Shader Encoder"),
506        });
507
508        // Render pass
509        {
510            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
511                label: Some("Custom Shader Render Pass"),
512                color_attachments: &[Some(RenderPassColorAttachment {
513                    view: output_view,
514                    resolve_target: None,
515                    ops: Operations {
516                        // Clear to transparent to support window transparency
517                        load: LoadOp::Clear(Color::TRANSPARENT),
518                        store: StoreOp::Store,
519                    },
520                    depth_slice: None,
521                })],
522                depth_stencil_attachment: None,
523                timestamp_writes: None,
524                occlusion_query_set: None,
525            });
526
527            render_pass.set_pipeline(&self.pipeline);
528            render_pass.set_bind_group(0, &self.bind_group, &[]);
529            render_pass.draw(0..4, 0..1);
530        }
531
532        queue.submit(std::iter::once(encoder.finish()));
533
534        Ok(())
535    }
536
537    /// Check if animation is enabled
538    #[allow(dead_code)]
539    pub fn animation_enabled(&self) -> bool {
540        self.animation_enabled
541    }
542
543    /// Set animation enabled state
544    #[allow(dead_code)]
545    pub fn set_animation_enabled(&mut self, enabled: bool) {
546        self.animation_enabled = enabled;
547        if enabled {
548            // Reset start time when enabling animation
549            self.start_time = Instant::now();
550        }
551    }
552
553    /// Update animation speed multiplier
554    pub fn set_animation_speed(&mut self, speed: f32) {
555        self.animation_speed = speed.max(0.0);
556    }
557
558    /// Update window opacity (content alpha passed to shader)
559    pub fn set_opacity(&mut self, opacity: f32) {
560        self.window_opacity = opacity.clamp(0.0, 1.0);
561    }
562
563    /// Update full content mode
564    pub fn set_full_content_mode(&mut self, enabled: bool) {
565        self.full_content_mode = enabled;
566    }
567
568    /// Check if full content mode is enabled
569    #[allow(dead_code)]
570    pub fn full_content_mode(&self) -> bool {
571        self.full_content_mode
572    }
573
574    /// Update mouse position in pixel coordinates
575    ///
576    /// # Arguments
577    /// * `x` - Mouse X position in pixels (0 = left edge)
578    /// * `y` - Mouse Y position in pixels (0 = top edge, will be flipped for shader)
579    pub fn set_mouse_position(&mut self, x: f32, y: f32) {
580        self.mouse_position = [x, y];
581    }
582
583    /// Update mouse button state and click position
584    ///
585    /// Call this when the left mouse button is pressed or released.
586    ///
587    /// # Arguments
588    /// * `pressed` - True if mouse button is now pressed, false if released
589    /// * `x` - Mouse X position in pixels at time of click/release
590    /// * `y` - Mouse Y position in pixels at time of click/release (will be flipped for shader)
591    pub fn set_mouse_button(&mut self, pressed: bool, x: f32, y: f32) {
592        self.mouse_button_down = pressed;
593        if pressed {
594            // Record click position when button is pressed
595            self.mouse_click_position = [x, y];
596        }
597    }
598
599    /// Reload the shader from a source string
600    ///
601    /// This method compiles the new shader source and replaces the current pipeline.
602    /// If compilation fails, returns an error and the old shader remains active.
603    ///
604    /// # Arguments
605    /// * `device` - The wgpu device
606    /// * `source` - The GLSL shader source code
607    /// * `name` - A name for error messages (e.g., "editor")
608    ///
609    /// # Returns
610    /// Ok(()) if successful, Err with error message if compilation fails
611    pub fn reload_from_source(&mut self, device: &Device, source: &str, name: &str) -> Result<()> {
612        // Transpile GLSL to WGSL
613        let wgsl_source = transpile_glsl_to_wgsl_source(source, name)?;
614
615        log::info!(
616            "Reloading custom shader from source ({} bytes GLSL -> {} bytes WGSL)",
617            source.len(),
618            wgsl_source.len()
619        );
620        log::debug!("Generated WGSL:\n{}", wgsl_source);
621
622        // Pre-validate WGSL to surface errors gracefully
623        let module = naga::front::wgsl::parse_str(&wgsl_source)
624            .context("Custom shader WGSL parse failed")?;
625        let _info = naga::valid::Validator::new(
626            naga::valid::ValidationFlags::all(),
627            naga::valid::Capabilities::empty(),
628        )
629        .validate(&module)
630        .context("Custom shader WGSL validation failed")?;
631
632        // Create the shader module
633        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
634            label: Some("Custom Shader Module (reloaded)"),
635            source: ShaderSource::Wgsl(wgsl_source.into()),
636        });
637
638        // Create pipeline layout
639        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
640            label: Some("Custom Shader Pipeline Layout (reloaded)"),
641            bind_group_layouts: &[&self.bind_group_layout],
642            push_constant_ranges: &[],
643        });
644
645        // Create render pipeline
646        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
647            label: Some("Custom Shader Pipeline (reloaded)"),
648            layout: Some(&pipeline_layout),
649            vertex: VertexState {
650                module: &shader_module,
651                entry_point: Some("vs_main"),
652                buffers: &[],
653                compilation_options: Default::default(),
654            },
655            fragment: Some(FragmentState {
656                module: &shader_module,
657                entry_point: Some("fs_main"),
658                targets: &[Some(ColorTargetState {
659                    format: self.surface_format,
660                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
661                    write_mask: ColorWrites::ALL,
662                })],
663                compilation_options: Default::default(),
664            }),
665            primitive: PrimitiveState {
666                topology: PrimitiveTopology::TriangleStrip,
667                ..Default::default()
668            },
669            depth_stencil: None,
670            multisample: MultisampleState::default(),
671            multiview: None,
672            cache: None,
673        });
674
675        // Success! Replace the old pipeline
676        self.pipeline = pipeline;
677
678        // Reset animation timer
679        self.start_time = Instant::now();
680
681        log::info!("Custom shader reloaded successfully from source");
682        Ok(())
683    }
684}
685
686/// Transpile a Ghostty/Shadertoy-style GLSL shader to WGSL
687///
688/// The input shader should have a `mainImage(out vec4 fragColor, in vec2 fragCoord)` function
689/// and can use the following uniforms:
690/// - `iTime`: Time in seconds
691/// - `iTimeDelta`: Time since last frame in seconds
692/// - `iFrame`: Frame counter
693/// - `iFrameRate`: Current frame rate in FPS
694/// - `iResolution`: Viewport resolution (vec2, z available as iResolutionZ)
695/// - `iMouse`: Mouse state (xy=current pos, zw=click pos)
696/// - `iDate`: Date/time (year, month, day, seconds since midnight)
697/// - `iChannel0`: Terminal content texture (sampler2D)
698/// - `iOpacity`: Window opacity (par-term specific)
699/// - `iTextOpacity`: Text opacity (par-term specific)
700/// - `iFullContent`: Full content mode flag (par-term specific)
701fn transpile_glsl_to_wgsl(glsl_source: &str, shader_path: &Path) -> Result<String> {
702    // Wrap the Shadertoy-style shader in a proper GLSL fragment shader
703    // We need to:
704    // 1. Add version and precision qualifiers
705    // 2. Declare uniforms and samplers
706    // 3. Add input/output declarations
707    // 4. Add a main() that calls mainImage()
708
709    let wrapped_glsl = format!(
710        r#"#version 450
711
712// Uniforms - must match Rust struct layout (std140)
713// Total size: 80 bytes
714layout(set = 0, binding = 0) uniform Uniforms {{
715    vec2 iResolution;      // offset 0, size 8 - Viewport resolution
716    float iTime;           // offset 8, size 4 - Time in seconds
717    float iTimeDelta;      // offset 12, size 4 - Time since last frame
718    vec4 iMouse;           // offset 16, size 16 - Mouse state (xy=current, zw=click)
719    vec4 iDate;            // offset 32, size 16 - Date (year, month, day, seconds)
720    float iOpacity;        // offset 48, size 4 - Window opacity
721    float iTextOpacity;    // offset 52, size 4 - Text opacity
722    float iFullContent;    // offset 56, size 4 - Full content mode (1.0 = enabled)
723    float iFrame;          // offset 60, size 4 - Frame counter
724    float iFrameRate;      // offset 64, size 4 - Current FPS
725    float iResolutionZ;    // offset 68, size 4 - Pixel aspect ratio (usually 1.0)
726    vec2 _pad;             // offset 72, size 8 - Padding
727}};                        // total: 80 bytes
728
729// Terminal content texture (iChannel0)
730layout(set = 0, binding = 1) uniform texture2D _iChannel0Tex;
731layout(set = 0, binding = 2) uniform sampler _iChannel0Sampler;
732
733// Combined sampler for texture() calls
734#define iChannel0 sampler2D(_iChannel0Tex, _iChannel0Sampler)
735
736// Input from vertex shader
737layout(location = 0) in vec2 v_uv;
738
739// Output color
740layout(location = 0) out vec4 outColor;
741
742// ============ User shader code begins ============
743
744{glsl_source}
745
746// ============ User shader code ends ============
747
748void main() {{
749    vec2 fragCoord = v_uv * iResolution;
750    vec4 shaderColor;
751    mainImage(shaderColor, fragCoord);
752
753    if (iFullContent > 0.5) {{
754        // Full content mode: shader output is used directly
755        // The shader has full control over the terminal content via iChannel0
756        // Apply window opacity to the shader's alpha output
757        outColor = vec4(shaderColor.rgb * iOpacity, shaderColor.a * iOpacity);
758    }} else {{
759        // Background-only mode: text is composited cleanly on top
760        // Sample terminal to detect text pixels
761        vec4 terminalColor = texture(iChannel0, v_uv);
762        float hasText = step(0.01, terminalColor.a);
763
764        // Text pixels: use terminal color with text opacity
765        // Background pixels: use shader output with window opacity
766        vec3 textCol = terminalColor.rgb;
767        vec3 bgCol = shaderColor.rgb;
768
769        // Composite: text over shader background
770        float textA = hasText * iTextOpacity;
771        float bgA = (1.0 - hasText) * iOpacity;
772
773        vec3 finalRgb = textCol * textA + bgCol * bgA;
774        float finalA = textA + bgA;
775
776        outColor = vec4(finalRgb, finalA);
777    }}
778}}
779"#
780    );
781
782    // Parse GLSL using naga
783    let mut parser = naga::front::glsl::Frontend::default();
784    let options = naga::front::glsl::Options::from(naga::ShaderStage::Fragment);
785
786    let module = parser.parse(&options, &wrapped_glsl).map_err(|errors| {
787        let error_messages: Vec<String> = errors
788            .errors
789            .iter()
790            .map(|e| format!("  {:?}", e.kind))
791            .collect();
792        anyhow::anyhow!(
793            "GLSL parse error in '{}'. Errors:\n{}",
794            shader_path.display(),
795            error_messages.join("\n")
796        )
797    })?;
798
799    // Validate the module
800    let info = naga::valid::Validator::new(
801        naga::valid::ValidationFlags::all(),
802        naga::valid::Capabilities::all(),
803    )
804    .validate(&module)
805    .map_err(|e| {
806        anyhow::anyhow!(
807            "Shader validation failed for '{}': {:?}",
808            shader_path.display(),
809            e
810        )
811    })?;
812
813    // Generate WGSL output for fragment shader
814    let mut fragment_wgsl = String::new();
815    let mut writer =
816        naga::back::wgsl::Writer::new(&mut fragment_wgsl, naga::back::wgsl::WriterFlags::empty());
817
818    writer.write(&module, &info).map_err(|e| {
819        anyhow::anyhow!(
820            "WGSL generation failed for '{}': {:?}",
821            shader_path.display(),
822            e
823        )
824    })?;
825
826    // The generated WGSL will have a main() function but we need to rename it to fs_main
827    // and add a vertex shader
828    let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
829
830    // Build the complete shader with vertex shader
831    let full_wgsl = format!(
832        r#"// Auto-generated WGSL from GLSL shader: {}
833
834struct VertexOutput {{
835    @builtin(position) position: vec4<f32>,
836    @location(0) uv: vec2<f32>,
837}}
838
839@vertex
840fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {{
841    var out: VertexOutput;
842
843    // Generate full-screen quad vertices (triangle strip)
844    let x = f32(vertex_index & 1u);
845    let y = f32((vertex_index >> 1u) & 1u);
846
847    // Full screen in NDC
848    out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
849    out.uv = vec2<f32>(x, y);
850
851    return out;
852}}
853
854// ============ Fragment shader (transpiled from GLSL) ============
855
856{fragment_wgsl}
857"#,
858        shader_path.display()
859    );
860
861    Ok(full_wgsl)
862}
863
864/// Transpile a Ghostty/Shadertoy-style GLSL shader to WGSL from source string
865///
866/// Same as `transpile_glsl_to_wgsl` but takes a source string and name instead of a file path.
867fn transpile_glsl_to_wgsl_source(glsl_source: &str, name: &str) -> Result<String> {
868    // Wrap the Shadertoy-style shader in a proper GLSL fragment shader
869    let wrapped_glsl = format!(
870        r#"#version 450
871
872// Uniforms - must match Rust struct layout (std140)
873// Total size: 80 bytes
874layout(set = 0, binding = 0) uniform Uniforms {{
875    vec2 iResolution;      // offset 0, size 8 - Viewport resolution
876    float iTime;           // offset 8, size 4 - Time in seconds
877    float iTimeDelta;      // offset 12, size 4 - Time since last frame
878    vec4 iMouse;           // offset 16, size 16 - Mouse state (xy=current, zw=click)
879    vec4 iDate;            // offset 32, size 16 - Date (year, month, day, seconds)
880    float iOpacity;        // offset 48, size 4 - Window opacity
881    float iTextOpacity;    // offset 52, size 4 - Text opacity
882    float iFullContent;    // offset 56, size 4 - Full content mode (1.0 = enabled)
883    float iFrame;          // offset 60, size 4 - Frame counter
884    float iFrameRate;      // offset 64, size 4 - Current FPS
885    float iResolutionZ;    // offset 68, size 4 - Pixel aspect ratio (usually 1.0)
886    vec2 _pad;             // offset 72, size 8 - Padding
887}};                        // total: 80 bytes
888
889// Terminal content texture (iChannel0)
890layout(set = 0, binding = 1) uniform texture2D _iChannel0Tex;
891layout(set = 0, binding = 2) uniform sampler _iChannel0Sampler;
892
893// Combined sampler for texture() calls
894#define iChannel0 sampler2D(_iChannel0Tex, _iChannel0Sampler)
895
896// Input from vertex shader
897layout(location = 0) in vec2 v_uv;
898
899// Output color
900layout(location = 0) out vec4 outColor;
901
902// ============ User shader code begins ============
903
904{glsl_source}
905
906// ============ User shader code ends ============
907
908void main() {{
909    vec2 fragCoord = v_uv * iResolution;
910    vec4 shaderColor;
911    mainImage(shaderColor, fragCoord);
912
913    if (iFullContent > 0.5) {{
914        // Full content mode: shader output is used directly
915        // The shader has full control over the terminal content via iChannel0
916        // Apply window opacity to the shader's alpha output
917        outColor = vec4(shaderColor.rgb * iOpacity, shaderColor.a * iOpacity);
918    }} else {{
919        // Background-only mode: text is composited cleanly on top
920        // Sample terminal to detect text pixels
921        vec4 terminalColor = texture(iChannel0, v_uv);
922        float hasText = step(0.01, terminalColor.a);
923
924        // Text pixels: use terminal color with text opacity
925        // Background pixels: use shader output with window opacity
926        vec3 textCol = terminalColor.rgb;
927        vec3 bgCol = shaderColor.rgb;
928
929        // Composite: text over shader background
930        float textA = hasText * iTextOpacity;
931        float bgA = (1.0 - hasText) * iOpacity;
932
933        vec3 finalRgb = textCol * textA + bgCol * bgA;
934        float finalA = textA + bgA;
935
936        outColor = vec4(finalRgb, finalA);
937    }}
938}}
939"#
940    );
941
942    // Parse GLSL using naga
943    let mut parser = naga::front::glsl::Frontend::default();
944    let options = naga::front::glsl::Options::from(naga::ShaderStage::Fragment);
945
946    let module = parser.parse(&options, &wrapped_glsl).map_err(|errors| {
947        let error_messages: Vec<String> = errors
948            .errors
949            .iter()
950            .map(|e| format!("  {:?}", e.kind))
951            .collect();
952        anyhow::anyhow!(
953            "GLSL parse error in '{}'. Errors:\n{}",
954            name,
955            error_messages.join("\n")
956        )
957    })?;
958
959    // Validate the module
960    let info = naga::valid::Validator::new(
961        naga::valid::ValidationFlags::all(),
962        naga::valid::Capabilities::all(),
963    )
964    .validate(&module)
965    .map_err(|e| anyhow::anyhow!("Shader validation failed for '{}': {:?}", name, e))?;
966
967    // Generate WGSL output for fragment shader
968    let mut fragment_wgsl = String::new();
969    let mut writer =
970        naga::back::wgsl::Writer::new(&mut fragment_wgsl, naga::back::wgsl::WriterFlags::empty());
971
972    writer
973        .write(&module, &info)
974        .map_err(|e| anyhow::anyhow!("WGSL generation failed for '{}': {:?}", name, e))?;
975
976    // The generated WGSL will have a main() function but we need to rename it to fs_main
977    // and add a vertex shader
978    let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
979
980    // Build the complete shader with vertex shader
981    let full_wgsl = format!(
982        r#"// Auto-generated WGSL from GLSL shader: {}
983
984struct VertexOutput {{
985    @builtin(position) position: vec4<f32>,
986    @location(0) uv: vec2<f32>,
987}}
988
989@vertex
990fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {{
991    var out: VertexOutput;
992
993    // Generate full-screen quad vertices (triangle strip)
994    let x = f32(vertex_index & 1u);
995    let y = f32((vertex_index >> 1u) & 1u);
996
997    // Full screen in NDC
998    out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
999    out.uv = vec2<f32>(x, y);
1000
1001    return out;
1002}}
1003
1004// ============ Fragment shader (transpiled from GLSL) ============
1005
1006{fragment_wgsl}
1007"#,
1008        name
1009    );
1010
1011    Ok(full_wgsl)
1012}