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//!
7//! # ARC-009 TODO
8//! This file is 669 lines (limit: 800 — approaching threshold). When it exceeds
9//! 800 lines, extract further into custom_shader_renderer/ siblings:
10//!
11//!   compile.rs   — GLSL→WGSL transpilation (naga) and pipeline compilation
12//!   animate.rs   — Time-uniform update and animation-frame bookkeeping
13//!
14//! The directory already has `pipeline.rs`, `transpiler.rs`, `uniforms.rs`,
15//! `hot_reload.rs`, `textures.rs`, `cubemap.rs`, `cursor.rs`, `types.rs` — follow
16//! that existing pattern for any further splits.
17//!
18//! Tracking: Issue ARC-009 in AUDIT.md.
19//! - `iChannel0-3`: User texture channels (Shadertoy compatible)
20//! - `iChannel4`: Terminal content texture
21//! - `iTimeKeyPress`: Time when last key was pressed (same timebase as iTime)
22//!
23//! Ghostty-compatible cursor uniforms (v1.2.0+):
24//! - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
25//! - `iPreviousCursor`: Previous cursor position and size
26//! - `iCurrentCursorColor`: Current cursor RGBA color (with opacity baked in)
27//! - `iPreviousCursorColor`: Previous cursor RGBA color
28//! - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
29
30use anyhow::{Context, Result};
31use par_term_emu_core_rust::cursor::CursorStyle;
32use std::path::Path;
33use std::time::Instant;
34use wgpu::*;
35
36mod cubemap;
37mod cursor;
38mod hot_reload;
39pub mod pipeline;
40pub mod textures;
41pub mod transpiler;
42pub mod types;
43mod uniforms;
44
45use cubemap::CubemapTexture;
46use pipeline::{create_bind_group, create_bind_group_layout, create_render_pipeline};
47use textures::{ChannelTexture, load_channel_textures};
48use transpiler::transpile_glsl_to_wgsl;
49
50/// Custom shader renderer that applies post-processing effects
51pub struct CustomShaderRenderer {
52    /// The render pipeline for the custom shader
53    pub(crate) pipeline: RenderPipeline,
54    /// Bind group for shader uniforms and textures
55    pub(crate) bind_group: BindGroup,
56    /// Uniform buffer for shader parameters
57    pub(crate) uniform_buffer: Buffer,
58    /// Intermediate texture to render terminal content into
59    pub(crate) intermediate_texture: Texture,
60    /// View of the intermediate texture
61    pub(crate) intermediate_texture_view: TextureView,
62    /// Start time for animation
63    pub(crate) start_time: Instant,
64    /// Whether animation is enabled
65    pub(crate) animation_enabled: bool,
66    /// Animation speed multiplier
67    pub(crate) animation_speed: f32,
68    /// Current texture dimensions
69    pub(crate) texture_width: u32,
70    pub(crate) texture_height: u32,
71    /// Surface format for compatibility
72    pub(crate) surface_format: TextureFormat,
73    /// Bind group layout for recreating bind groups on resize
74    pub(crate) bind_group_layout: BindGroupLayout,
75    /// Sampler for the intermediate texture
76    pub(crate) sampler: Sampler,
77    /// Display scale factor for DPI scaling (e.g., 2.0 on Retina)
78    pub(crate) scale_factor: f32,
79    /// Window opacity for transparency
80    pub(crate) window_opacity: f32,
81    /// When true, text is always rendered at full opacity (overrides text_opacity)
82    pub(crate) keep_text_opaque: bool,
83    /// Full content mode - shader receives and can manipulate full terminal content
84    pub(crate) full_content_mode: bool,
85    /// Brightness multiplier for shader output (0.05-1.0)
86    pub(crate) brightness: f32,
87    /// Frame counter for iFrame uniform
88    pub(crate) frame_count: u32,
89    /// Last frame time for calculating time delta
90    pub(crate) last_frame_time: Instant,
91    /// Current mouse position in pixels (xy)
92    pub(crate) mouse_position: [f32; 2],
93    /// Last click position in pixels (zw)
94    pub(crate) mouse_click_position: [f32; 2],
95    /// Whether mouse button is currently pressed (affects sign of zw)
96    pub(crate) mouse_button_down: bool,
97    /// Frame rate tracking: time accumulator for averaging
98    pub(crate) frame_time_accumulator: f32,
99    /// Frame rate tracking: frames in current second
100    pub(crate) frames_in_second: u32,
101    /// Current smoothed frame rate
102    pub(crate) current_frame_rate: f32,
103
104    // ============ Cursor tracking (Ghostty-compatible) ============
105    /// Current cursor position in cell coordinates (col, row)
106    pub(crate) current_cursor_pos: (usize, usize),
107    /// Previous cursor position in cell coordinates
108    pub(crate) previous_cursor_pos: (usize, usize),
109    /// Current cursor RGBA color
110    pub(crate) current_cursor_color: [f32; 4],
111    /// Previous cursor RGBA color
112    pub(crate) previous_cursor_color: [f32; 4],
113    /// Current cursor opacity (0.0 = invisible, 1.0 = fully visible)
114    pub(crate) current_cursor_opacity: f32,
115    /// Previous cursor opacity
116    pub(crate) previous_cursor_opacity: f32,
117    /// Time when cursor position last changed (same timebase as iTime)
118    pub(crate) cursor_change_time: f32,
119    /// Current cursor style (for size calculation)
120    pub(crate) current_cursor_style: CursorStyle,
121    /// Previous cursor style
122    pub(crate) previous_cursor_style: CursorStyle,
123    /// Cell width in pixels (for cursor position calculation)
124    pub(crate) cursor_cell_width: f32,
125    /// Cell height in pixels (for cursor position calculation)
126    pub(crate) cursor_cell_height: f32,
127    /// Window padding in pixels (for cursor position calculation)
128    pub(crate) cursor_window_padding: f32,
129    /// Vertical content offset in pixels (e.g., tab bar height)
130    pub(crate) cursor_content_offset_y: f32,
131    /// Horizontal content offset in pixels (e.g., tab bar on left)
132    pub(crate) cursor_content_offset_x: f32,
133
134    // ============ Cursor shader configuration ============
135    /// User-configured cursor color for shader effects [R, G, B, A]
136    pub(crate) cursor_shader_color: [f32; 4],
137    /// Cursor trail duration in seconds
138    pub(crate) cursor_trail_duration: f32,
139    /// Cursor glow radius in pixels
140    pub(crate) cursor_glow_radius: f32,
141    /// Cursor glow intensity (0.0-1.0)
142    pub(crate) cursor_glow_intensity: f32,
143
144    // ============ Key press tracking ============
145    /// Time when a key was last pressed (same timebase as iTime)
146    pub(crate) key_press_time: f32,
147
148    // ============ Channel textures (iChannel0-3) ============
149    /// Texture channels 0-3 (placeholders or loaded textures, Shadertoy compatible)
150    pub(crate) channel_textures: [ChannelTexture; 4],
151
152    // ============ Cubemap texture (iCubemap) ============
153    /// Cubemap texture for environment mapping (placeholder or loaded)
154    pub(crate) cubemap: CubemapTexture,
155
156    // ============ Background image as iChannel0 ============
157    /// When true, use the background image texture as iChannel0 instead of the configured texture
158    pub(crate) use_background_as_channel0: bool,
159    /// Background texture to use as iChannel0 when use_background_as_channel0 is true
160    /// This is a reference texture (view + sampler + dimensions) from the cell renderer
161    pub(crate) background_channel_texture: Option<ChannelTexture>,
162
163    // ============ Solid background color ============
164    /// Solid background color [R, G, B, A] for shader compositing.
165    /// When A > 0, the shader uses this color as background instead of shader output.
166    /// RGB values are NOT premultiplied.
167    pub(crate) background_color: [f32; 4],
168
169    // ============ Progress bar state ============
170    /// Progress bar data [state, percent, isActive, activeCount]
171    pub(crate) progress_data: [f32; 4],
172
173    // ============ Content inset for panels ============
174    /// Right content inset in pixels (e.g., AI Inspector panel).
175    /// The shader renders to a viewport offset by this amount from the left.
176    pub(crate) content_inset_right: f32,
177}
178
179/// Parameters for creating a new [`CustomShaderRenderer`].
180pub struct CustomShaderRendererConfig<'a> {
181    pub surface_format: TextureFormat,
182    pub shader_path: &'a Path,
183    pub width: u32,
184    pub height: u32,
185    pub animation_enabled: bool,
186    pub animation_speed: f32,
187    pub window_opacity: f32,
188    pub full_content_mode: bool,
189    pub channel_paths: &'a [Option<std::path::PathBuf>; 4],
190    pub cubemap_path: Option<&'a Path>,
191}
192
193impl CustomShaderRenderer {
194    /// Create a new custom shader renderer from a GLSL shader file.
195    pub fn new(
196        device: &Device,
197        queue: &Queue,
198        config: CustomShaderRendererConfig<'_>,
199    ) -> Result<Self> {
200        let CustomShaderRendererConfig {
201            surface_format,
202            shader_path,
203            width,
204            height,
205            animation_enabled,
206            animation_speed,
207            window_opacity,
208            full_content_mode,
209            channel_paths,
210            cubemap_path,
211        } = config;
212        // Load the GLSL shader
213        let glsl_source = std::fs::read_to_string(shader_path)
214            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
215
216        // Transpile GLSL to WGSL
217        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
218
219        log::info!(
220            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
221            shader_path.display(),
222            glsl_source.len(),
223            wgsl_source.len()
224        );
225        log::debug!("Generated WGSL:\n{}", wgsl_source);
226
227        // DEBUG: Write generated WGSL to file for inspection
228        let shader_name = shader_path
229            .file_stem()
230            .and_then(|s| s.to_str())
231            .unwrap_or("unknown");
232        let debug_filename = format!("/tmp/par_term_{}_shader.wgsl", shader_name);
233        if let Err(e) = std::fs::write(&debug_filename, &wgsl_source) {
234            log::warn!("Failed to write debug shader: {}", e);
235        } else {
236            log::info!("Wrote debug shader to {}", debug_filename);
237        }
238
239        // Pre-validate WGSL
240        let module = naga::front::wgsl::parse_str(&wgsl_source)
241            .context("Custom shader WGSL parse failed")?;
242        let _info = naga::valid::Validator::new(
243            naga::valid::ValidationFlags::all(),
244            naga::valid::Capabilities::empty(),
245        )
246        .validate(&module)
247        .context("Custom shader WGSL validation failed")?;
248
249        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
250            label: Some("Custom Shader Module"),
251            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
252        });
253
254        // Create intermediate texture for terminal content
255        let (intermediate_texture, intermediate_texture_view) =
256            Self::create_intermediate_texture(device, surface_format, width, height);
257
258        // Create sampler for the intermediate texture (terminal content)
259        // Use Nearest filtering to keep text crisp and pixel-perfect
260        let sampler = device.create_sampler(&SamplerDescriptor {
261            label: Some("Custom Shader Sampler"),
262            address_mode_u: AddressMode::ClampToEdge,
263            address_mode_v: AddressMode::ClampToEdge,
264            address_mode_w: AddressMode::ClampToEdge,
265            mag_filter: FilterMode::Nearest,
266            min_filter: FilterMode::Nearest,
267            mipmap_filter: FilterMode::Nearest,
268            ..Default::default()
269        });
270
271        // Load channel textures (iChannel0-3)
272        let channel_textures = load_channel_textures(device, queue, channel_paths);
273
274        // Load cubemap texture (iCubemap)
275        let cubemap = match cubemap_path {
276            Some(path) => match CubemapTexture::from_prefix(device, queue, path) {
277                Ok(cm) => cm,
278                Err(e) => {
279                    log::error!("Failed to load cubemap '{}': {}", path.display(), e);
280                    CubemapTexture::placeholder(device, queue)
281                }
282            },
283            None => CubemapTexture::placeholder(device, queue),
284        };
285
286        // Create uniform buffer
287        let uniform_buffer = Self::create_uniform_buffer(device);
288
289        // Create bind group layout and bind group
290        let bind_group_layout = create_bind_group_layout(device);
291        let bind_group = create_bind_group(
292            device,
293            &bind_group_layout,
294            &uniform_buffer,
295            &intermediate_texture_view,
296            &sampler,
297            &channel_textures,
298            &cubemap,
299        );
300
301        // Create render pipeline
302        let pipeline = create_render_pipeline(
303            device,
304            &shader_module,
305            &bind_group_layout,
306            surface_format,
307            Some("Custom Shader Pipeline"),
308        );
309
310        let now = Instant::now();
311        Ok(Self {
312            pipeline,
313            bind_group,
314            uniform_buffer,
315            intermediate_texture,
316            intermediate_texture_view,
317            start_time: now,
318            animation_enabled,
319            animation_speed,
320            texture_width: width,
321            texture_height: height,
322            surface_format,
323            bind_group_layout,
324            sampler,
325            window_opacity,
326            keep_text_opaque: false,
327            scale_factor: 1.0,
328            full_content_mode,
329            brightness: 1.0,
330            frame_count: 0,
331            last_frame_time: now,
332            mouse_position: [0.0, 0.0],
333            mouse_click_position: [0.0, 0.0],
334            mouse_button_down: false,
335            frame_time_accumulator: 0.0,
336            frames_in_second: 0,
337            current_frame_rate: 60.0,
338            current_cursor_pos: (0, 0),
339            previous_cursor_pos: (0, 0),
340            current_cursor_color: [1.0, 1.0, 1.0, 1.0],
341            previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
342            current_cursor_opacity: 1.0,
343            previous_cursor_opacity: 1.0,
344            cursor_change_time: 0.0,
345            current_cursor_style: CursorStyle::SteadyBlock,
346            previous_cursor_style: CursorStyle::SteadyBlock,
347            cursor_cell_width: 10.0,
348            cursor_cell_height: 20.0,
349            cursor_window_padding: 0.0,
350            cursor_content_offset_y: 0.0,
351            cursor_content_offset_x: 0.0,
352            cursor_shader_color: [1.0, 1.0, 1.0, 1.0],
353            cursor_trail_duration: 0.5,
354            cursor_glow_radius: 80.0,
355            cursor_glow_intensity: 0.3,
356            key_press_time: 0.0,
357            channel_textures,
358            cubemap,
359            use_background_as_channel0: false,
360            background_channel_texture: None,
361            background_color: [0.0, 0.0, 0.0, 0.0], // No solid background by default
362            progress_data: [0.0, 0.0, 0.0, 0.0],
363            content_inset_right: 0.0,
364        })
365    }
366
367    /// Get a view of the intermediate texture for rendering terminal content into
368    pub fn intermediate_texture_view(&self) -> &TextureView {
369        &self.intermediate_texture_view
370    }
371
372    /// Render the custom shader effect to the output texture
373    ///
374    /// # Arguments
375    /// * `device` - The GPU device
376    /// * `queue` - The command queue
377    /// * `output_view` - The texture view to render to
378    /// * `apply_opacity` - Whether to apply window opacity. Set to `false` when rendering
379    ///   to an intermediate texture that will be processed by another shader (to avoid
380    ///   double-applying opacity).
381    pub fn render(
382        &mut self,
383        device: &Device,
384        queue: &Queue,
385        output_view: &TextureView,
386        apply_opacity: bool,
387    ) -> Result<()> {
388        self.render_with_clear_color(
389            device,
390            queue,
391            output_view,
392            apply_opacity,
393            Color::TRANSPARENT,
394        )
395    }
396
397    /// Render the custom shader with a specified clear color.
398    /// Use this for solid background colors where the clear color provides the background.
399    pub fn render_with_clear_color(
400        &mut self,
401        device: &Device,
402        queue: &Queue,
403        output_view: &TextureView,
404        apply_opacity: bool,
405        clear_color: Color,
406    ) -> Result<()> {
407        let now = Instant::now();
408
409        // Calculate time value
410        let time = if self.animation_enabled {
411            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
412        } else {
413            0.0
414        };
415
416        // Calculate time delta
417        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
418        self.last_frame_time = now;
419
420        // Update frame rate calculation
421        self.frame_time_accumulator += time_delta;
422        self.frames_in_second += 1;
423        if self.frame_time_accumulator >= 1.0 {
424            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
425            self.frame_time_accumulator = 0.0;
426            self.frames_in_second = 0;
427        }
428
429        self.frame_count = self.frame_count.wrapping_add(1);
430
431        // Calculate uniforms
432        let uniforms = self.build_uniforms(time, time_delta, apply_opacity);
433        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
434
435        // Create command encoder and render
436        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
437            label: Some("Custom Shader Encoder"),
438        });
439
440        {
441            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
442                label: Some("Custom Shader Render Pass"),
443                color_attachments: &[Some(RenderPassColorAttachment {
444                    view: output_view,
445                    resolve_target: None,
446                    ops: Operations {
447                        load: LoadOp::Clear(clear_color),
448                        store: StoreOp::Store,
449                    },
450                    depth_slice: None,
451                })],
452                depth_stencil_attachment: None,
453                timestamp_writes: None,
454                occlusion_query_set: None,
455            });
456
457            // Note: We intentionally do NOT set a viewport here to exclude the panel area.
458            // The viewport approach doesn't work because fragCoord in WGSL is relative to
459            // the render target, not the viewport, causing UV coordinate mismatches.
460            // The opaque panel (PANEL_BG with alpha 255) covers any shader output under it.
461
462            render_pass.set_pipeline(&self.pipeline);
463            render_pass.set_bind_group(0, &self.bind_group, &[]);
464            render_pass.draw(0..4, 0..1);
465        }
466
467        queue.submit(std::iter::once(encoder.finish()));
468        Ok(())
469    }
470
471    /// Check if animation is enabled
472    pub fn animation_enabled(&self) -> bool {
473        self.animation_enabled
474    }
475
476    /// Set animation enabled state
477    pub fn set_animation_enabled(&mut self, enabled: bool) {
478        self.animation_enabled = enabled;
479        if enabled {
480            self.start_time = Instant::now();
481        }
482    }
483
484    /// Update animation speed multiplier
485    pub fn set_animation_speed(&mut self, speed: f32) {
486        self.animation_speed = speed.max(0.0);
487    }
488
489    /// Update window opacity
490    pub fn set_opacity(&mut self, opacity: f32) {
491        self.window_opacity = opacity.clamp(0.0, 1.0);
492    }
493
494    /// Update shader brightness multiplier
495    pub fn set_brightness(&mut self, brightness: f32) {
496        self.brightness = brightness.clamp(0.05, 1.0);
497    }
498
499    /// Update full content mode
500    pub fn set_full_content_mode(&mut self, enabled: bool) {
501        self.full_content_mode = enabled;
502    }
503
504    /// Check if full content mode is enabled
505    pub fn full_content_mode(&self) -> bool {
506        self.full_content_mode
507    }
508
509    /// Set whether text should always be rendered at full opacity
510    /// When true, overrides text_opacity to 1.0
511    pub fn set_keep_text_opaque(&mut self, keep_opaque: bool) {
512        self.keep_text_opaque = keep_opaque;
513    }
514
515    /// Update mouse position in pixel coordinates
516    pub fn set_mouse_position(&mut self, x: f32, y: f32) {
517        self.mouse_position = [x, y];
518    }
519
520    /// Update mouse button state and click position
521    pub fn set_mouse_button(&mut self, pressed: bool, x: f32, y: f32) {
522        self.mouse_button_down = pressed;
523        if pressed {
524            self.mouse_click_position = [x, y];
525        }
526    }
527
528    /// Update key press time for shader effects
529    ///
530    /// Call this when a key is pressed to enable key-press-based shader effects
531    /// like screen pulses or typing animations.
532    pub fn update_key_press(&mut self) {
533        self.key_press_time = if self.animation_enabled {
534            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
535        } else {
536            0.0
537        };
538        log::trace!("Key pressed at shader time={:.3}", self.key_press_time);
539    }
540
541    /// Update a channel texture at runtime
542    pub fn update_channel_texture(
543        &mut self,
544        device: &Device,
545        queue: &Queue,
546        channel: u8,
547        path: Option<&std::path::Path>,
548    ) -> Result<()> {
549        if !(1..=4).contains(&channel) {
550            anyhow::bail!("Invalid channel index: {} (must be 1-4)", channel);
551        }
552
553        let index = (channel - 1) as usize;
554
555        let new_texture = match path {
556            Some(p) => ChannelTexture::from_file(device, queue, p)?,
557            None => ChannelTexture::placeholder(device, queue),
558        };
559
560        self.channel_textures[index] = new_texture;
561
562        // Use recreate_bind_group to properly handle use_background_as_channel0 logic
563        self.recreate_bind_group(device);
564
565        log::info!(
566            "Updated iChannel{} texture: {}",
567            channel,
568            path.map(|p| p.display().to_string())
569                .unwrap_or_else(|| "placeholder".to_string())
570        );
571
572        Ok(())
573    }
574
575    /// Update the cubemap texture at runtime
576    pub fn update_cubemap(
577        &mut self,
578        device: &Device,
579        queue: &Queue,
580        path: Option<&std::path::Path>,
581    ) -> Result<()> {
582        let new_cubemap = match path {
583            Some(p) => CubemapTexture::from_prefix(device, queue, p)?,
584            None => CubemapTexture::placeholder(device, queue),
585        };
586
587        self.cubemap = new_cubemap;
588
589        // Use recreate_bind_group to properly handle use_background_as_channel0 logic
590        self.recreate_bind_group(device);
591
592        log::info!(
593            "Updated cubemap texture: {}",
594            path.map(|p| p.display().to_string())
595                .unwrap_or_else(|| "placeholder".to_string())
596        );
597
598        Ok(())
599    }
600
601    /// Set whether to use the background image as iChannel0.
602    ///
603    /// When enabled and a background texture is set, the background image will be
604    /// used as iChannel0 instead of the configured channel0 texture file.
605    ///
606    /// Note: This only updates the flag. Use `update_use_background_as_channel0`
607    /// if you also need to recreate the bind group.
608    pub fn set_use_background_as_channel0(&mut self, use_background: bool) {
609        if self.use_background_as_channel0 != use_background {
610            self.use_background_as_channel0 = use_background;
611            log::info!("use_background_as_channel0 set to {}", use_background);
612        }
613    }
614
615    /// Check if using background image as iChannel0.
616    pub fn use_background_as_channel0(&self) -> bool {
617        self.use_background_as_channel0
618    }
619
620    /// Set the background texture to use as iChannel0 when enabled.
621    ///
622    /// Call this whenever the background image changes to update the shader's
623    /// channel0 binding. The device parameter is needed to recreate the bind group.
624    ///
625    /// When use_background_as_channel0 is enabled, the background texture takes
626    /// priority over any configured channel0 texture.
627    ///
628    /// # Arguments
629    /// * `device` - The wgpu device
630    /// * `texture` - The background texture (view, sampler, dimensions), or None to clear
631    pub fn set_background_texture(&mut self, device: &Device, texture: Option<ChannelTexture>) {
632        self.background_channel_texture = texture;
633
634        // Recreate bind group if we're using background as channel0
635        // The background texture takes priority over configured channel0 when enabled
636        if self.use_background_as_channel0 {
637            self.recreate_bind_group(device);
638        }
639    }
640
641    /// Set the solid background color for shader compositing.
642    ///
643    /// When set (alpha > 0), the shader uses this color as background instead of shader output.
644    /// This allows solid background colors to show through properly with window transparency.
645    ///
646    /// # Arguments
647    /// * `color` - RGB color values [R, G, B] (0.0-1.0, NOT premultiplied)
648    /// * `active` - Whether solid color mode is active (sets alpha to 1.0 or 0.0)
649    pub fn set_background_color(&mut self, color: [f32; 3], active: bool) {
650        self.background_color = [color[0], color[1], color[2], if active { 1.0 } else { 0.0 }];
651    }
652
653    /// Update progress bar state for shader effects.
654    ///
655    /// # Arguments
656    /// * `state` - Progress state (0=hidden, 1=normal, 2=error, 3=indeterminate, 4=warning)
657    /// * `percent` - Progress percentage as 0.0-1.0
658    /// * `is_active` - 1.0 if any progress bar is active, 0.0 otherwise
659    /// * `active_count` - Total count of active bars (simple + named)
660    pub fn update_progress(&mut self, state: f32, percent: f32, is_active: f32, active_count: f32) {
661        self.progress_data = [state, percent, is_active, active_count];
662    }
663
664    /// Update the use_background_as_channel0 setting and recreate bind group if needed.
665    ///
666    /// Call this when the setting changes in the UI or config.
667    pub fn update_use_background_as_channel0(&mut self, device: &Device, use_background: bool) {
668        if self.use_background_as_channel0 != use_background {
669            self.use_background_as_channel0 = use_background;
670            self.recreate_bind_group(device);
671            log::info!("use_background_as_channel0 toggled to {}", use_background);
672        }
673    }
674
675    /// Set the right content inset (e.g., AI Inspector panel).
676    ///
677    /// When non-zero, the shader will render to a viewport that excludes
678    /// the right inset area, ensuring effects don't appear under the panel.
679    pub fn set_content_inset_right(&mut self, inset: f32) {
680        self.content_inset_right = inset;
681    }
682}