1use anyhow::{Context, Result};
9use std::path::Path;
10use std::time::Instant;
11use wgpu::*;
12
13#[repr(C)]
20#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
21struct CustomShaderUniforms {
22 resolution: [f32; 2],
24 time: f32,
26 time_delta: f32,
28 mouse: [f32; 4],
32 date: [f32; 4],
35 opacity: f32,
37 text_opacity: f32,
39 full_content_mode: f32,
41 frame: f32,
43 frame_rate: f32,
45 resolution_z: f32,
47 _padding: [f32; 2],
49}
50pub struct CustomShaderRenderer {
54 pipeline: RenderPipeline,
56 bind_group: BindGroup,
58 uniform_buffer: Buffer,
60 intermediate_texture: Texture,
62 intermediate_texture_view: TextureView,
64 start_time: Instant,
66 animation_enabled: bool,
68 animation_speed: f32,
70 texture_width: u32,
72 texture_height: u32,
73 surface_format: TextureFormat,
75 bind_group_layout: BindGroupLayout,
77 sampler: Sampler,
79 window_opacity: f32,
81 text_opacity: f32,
83 full_content_mode: bool,
85 frame_count: u32,
87 last_frame_time: Instant,
89 mouse_position: [f32; 2],
91 mouse_click_position: [f32; 2],
93 mouse_button_down: bool,
95 frame_time_accumulator: f32,
97 frames_in_second: u32,
99 current_frame_rate: f32,
101}
102
103impl CustomShaderRenderer {
104 #[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 let glsl_source = std::fs::read_to_string(shader_path)
131 .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
132
133 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 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 let (intermediate_texture, intermediate_texture_view) =
162 Self::create_intermediate_texture(device, surface_format, width, height);
163
164 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 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 let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
186 label: Some("Custom Shader Bind Group Layout"),
187 entries: &[
188 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 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 BindGroupLayoutEntry {
212 binding: 2,
213 visibility: ShaderStages::FRAGMENT,
214 ty: BindingType::Sampler(SamplerBindingType::Filtering),
215 count: None,
216 },
217 ],
218 });
219
220 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 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 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, })
304 }
305
306 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 pub fn intermediate_texture_view(&self) -> &TextureView {
334 &self.intermediate_texture_view
335 }
336
337 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 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 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 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 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 let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
394 self.last_frame_time = now;
395
396 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 self.frame_count = self.frame_count.wrapping_add(1);
407
408 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 [
418 self.mouse_position[0],
419 mouse_y_flipped,
420 self.mouse_click_position[0],
421 click_y_flipped,
422 ]
423 } else {
424 [
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 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 let days_since_epoch = secs / 86400;
444 let secs_today = (secs % 86400) as f32;
445
446 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; [year as f32, month as f32, day as f32, secs_today]
483 };
484
485 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, _padding: [0.0, 0.0],
499 };
500
501 queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
502
503 let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
505 label: Some("Custom Shader Encoder"),
506 });
507
508 {
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 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 #[allow(dead_code)]
539 pub fn animation_enabled(&self) -> bool {
540 self.animation_enabled
541 }
542
543 #[allow(dead_code)]
545 pub fn set_animation_enabled(&mut self, enabled: bool) {
546 self.animation_enabled = enabled;
547 if enabled {
548 self.start_time = Instant::now();
550 }
551 }
552
553 pub fn set_animation_speed(&mut self, speed: f32) {
555 self.animation_speed = speed.max(0.0);
556 }
557
558 pub fn set_opacity(&mut self, opacity: f32) {
560 self.window_opacity = opacity.clamp(0.0, 1.0);
561 }
562
563 pub fn set_full_content_mode(&mut self, enabled: bool) {
565 self.full_content_mode = enabled;
566 }
567
568 #[allow(dead_code)]
570 pub fn full_content_mode(&self) -> bool {
571 self.full_content_mode
572 }
573
574 pub fn set_mouse_position(&mut self, x: f32, y: f32) {
580 self.mouse_position = [x, y];
581 }
582
583 pub fn set_mouse_button(&mut self, pressed: bool, x: f32, y: f32) {
592 self.mouse_button_down = pressed;
593 if pressed {
594 self.mouse_click_position = [x, y];
596 }
597 }
598
599 pub fn reload_from_source(&mut self, device: &Device, source: &str, name: &str) -> Result<()> {
612 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 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 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 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 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 self.pipeline = pipeline;
677
678 self.start_time = Instant::now();
680
681 log::info!("Custom shader reloaded successfully from source");
682 Ok(())
683 }
684}
685
686fn transpile_glsl_to_wgsl(glsl_source: &str, shader_path: &Path) -> Result<String> {
702 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 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 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 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 let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
829
830 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
864fn transpile_glsl_to_wgsl_source(glsl_source: &str, name: &str) -> Result<String> {
868 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 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 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 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 let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
979
980 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}