Skip to main content

cranpose_ui_graphics/
render_effect.rs

1//! Render effects that can be applied to graphics layers.
2//!
3//! Matches the Jetpack Compose `RenderEffect` API with extensions for custom
4//! WGSL shaders (`RuntimeShader`).
5
6use std::sync::{Arc, Mutex, OnceLock, Weak};
7
8use crate::LayerShape;
9
10const RUNTIME_SHADER_INLINE_UNIFORMS: usize = 16;
11
12/// Edge treatment for blur effects at the boundary of the blurred region.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
14pub enum TileMode {
15    /// Clamp to the edge pixel color.
16    #[default]
17    Clamp,
18    /// Repeat the gradient/effect from start to end.
19    Repeated,
20    /// Mirror the gradient/effect every other repetition.
21    Mirror,
22    /// Treat pixels outside the boundary as transparent.
23    Decal,
24}
25
26/// Controls blur behavior outside source bounds.
27///
28/// This mirrors Compose's `BlurredEdgeTreatment`:
29/// - bounded treatment (`shape != None`) clips blur output and uses `TileMode::Clamp`
30/// - unbounded treatment (`shape == None`) does not clip and uses `TileMode::Decal`
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct BlurredEdgeTreatment {
33    shape: Option<LayerShape>,
34}
35
36impl BlurredEdgeTreatment {
37    /// Bounded treatment that clips to a rectangle.
38    pub const RECTANGLE: Self = Self {
39        shape: Some(LayerShape::Rectangle),
40    };
41
42    /// Unbounded treatment that does not clip blurred output.
43    pub const UNBOUNDED: Self = Self { shape: None };
44
45    /// Bounded treatment with a specific clip shape.
46    pub const fn with_shape(shape: LayerShape) -> Self {
47        Self { shape: Some(shape) }
48    }
49
50    pub fn shape(self) -> Option<LayerShape> {
51        self.shape
52    }
53
54    pub fn clip(self) -> bool {
55        self.shape.is_some()
56    }
57
58    pub fn tile_mode(self) -> TileMode {
59        if self.clip() {
60            TileMode::Clamp
61        } else {
62            TileMode::Decal
63        }
64    }
65}
66
67impl Default for BlurredEdgeTreatment {
68    fn default() -> Self {
69        Self::RECTANGLE
70    }
71}
72
73/// A custom WGSL shader effect, analogous to Android's `RuntimeShader`.
74///
75/// The shader source must be a complete WGSL module that declares:
76/// ```wgsl
77/// @group(0) @binding(0) var input_texture: texture_2d<f32>;
78/// @group(0) @binding(1) var input_sampler: sampler;
79/// @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
80/// ```
81///
82/// Float uniforms are packed linearly into the `u` array. Access them in WGSL
83/// as `u[index / 4][index % 4]` for individual floats, or `u[index / 4].xy`
84/// for vec2, etc. User uniforms may use indices `0..248`; slots `248..256`
85/// are reserved for renderer metadata.
86///
87/// RuntimeShader pipelines operate on premultiplied-alpha textures. Custom
88/// shaders should preserve premultiplied output semantics.
89#[derive(Clone, Debug)]
90pub struct RuntimeShader {
91    source: Arc<str>,
92    source_hash: u64,
93    uniforms: RuntimeShaderUniforms,
94    overrides: Vec<(&'static str, f64)>,
95    input_padding: f32,
96    output_padding: f32,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100struct RuntimeShaderUniforms {
101    len: usize,
102    inline: [f32; RUNTIME_SHADER_INLINE_UNIFORMS],
103    heap: Option<Vec<f32>>,
104}
105
106impl RuntimeShaderUniforms {
107    fn new() -> Self {
108        Self {
109            len: 0,
110            inline: [0.0; RUNTIME_SHADER_INLINE_UNIFORMS],
111            heap: None,
112        }
113    }
114
115    fn as_slice(&self) -> &[f32] {
116        if let Some(heap) = &self.heap {
117            heap.as_slice()
118        } else {
119            &self.inline[..self.len]
120        }
121    }
122
123    fn len(&self) -> usize {
124        self.as_slice().len()
125    }
126
127    fn ensure_len(&mut self, min_len: usize) {
128        if let Some(heap) = &mut self.heap {
129            if heap.len() < min_len {
130                heap.resize(min_len, 0.0);
131            }
132            return;
133        }
134
135        if min_len <= RUNTIME_SHADER_INLINE_UNIFORMS {
136            self.len = self.len.max(min_len);
137            return;
138        }
139
140        let mut heap = Vec::with_capacity(min_len);
141        heap.extend_from_slice(&self.inline[..self.len]);
142        heap.resize(min_len, 0.0);
143        self.heap = Some(heap);
144    }
145
146    fn set(&mut self, index: usize, value: f32) {
147        if let Some(heap) = &mut self.heap {
148            heap[index] = value;
149        } else {
150            self.inline[index] = value;
151        }
152    }
153
154    #[cfg(test)]
155    fn is_inline(&self) -> bool {
156        self.heap.is_none()
157    }
158}
159
160/// Error returned when a shader uniform write targets renderer-owned storage.
161#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
162pub enum RuntimeShaderUniformError {
163    #[error(
164        "uniform range starting at {index} with width {width} exceeds user uniform range 0..{max_user_uniforms}; slots {reserved_start}..{max_uniforms} are reserved for renderer data"
165    )]
166    OutOfUserRange {
167        index: usize,
168        width: usize,
169        max_user_uniforms: usize,
170        reserved_start: usize,
171        max_uniforms: usize,
172    },
173}
174
175impl RuntimeShader {
176    /// Total uniform storage size in floats (64 vec4s = 256 floats).
177    ///
178    /// The final slots are reserved for renderer-managed data.
179    pub const MAX_UNIFORMS: usize = 256;
180    /// First renderer-reserved uniform slot.
181    pub const RESERVED_UNIFORM_START: usize = 248;
182    /// Maximum user-addressable uniform count.
183    pub const MAX_USER_UNIFORMS: usize = Self::RESERVED_UNIFORM_START;
184
185    /// Create a new RuntimeShader from WGSL source code.
186    #[track_caller]
187    pub fn new(wgsl_source: &str) -> Self {
188        let (source, source_hash) =
189            cached_shader_source(std::panic::Location::caller(), wgsl_source);
190        Self {
191            source,
192            source_hash,
193            uniforms: RuntimeShaderUniforms::new(),
194            overrides: Vec::new(),
195            input_padding: 0.0,
196            output_padding: 0.0,
197        }
198    }
199
200    /// Create a RuntimeShader from shared WGSL source code.
201    ///
202    /// This avoids repeatedly copying large shader modules for animated effects
203    /// that rebuild only their uniform payload every frame.
204    pub fn from_shared_source(source: Arc<str>) -> Self {
205        let source_hash = cached_shared_shader_source_hash(&source);
206        Self {
207            source,
208            source_hash,
209            uniforms: RuntimeShaderUniforms::new(),
210            overrides: Vec::new(),
211            input_padding: 0.0,
212            output_padding: 0.0,
213        }
214    }
215
216    /// Fixes a pipeline-overridable constant (`override NAME: T = ...;` in
217    /// the WGSL) for every pipeline compiled from this shader. The value is
218    /// converted to the constant's declared scalar type the way WebGPU does
219    /// (a `bool` is `value != 0`). Each distinct override set compiles its
220    /// own pipeline; renderers use this to fold a material's inactive
221    /// features away without changing the shader text.
222    pub fn set_override(&mut self, name: &'static str, value: f64) {
223        match self
224            .overrides
225            .binary_search_by(|(existing, _)| existing.cmp(&name))
226        {
227            Ok(index) => self.overrides[index].1 = value,
228            Err(index) => self.overrides.insert(index, (name, value)),
229        }
230    }
231
232    /// The pipeline-overridable constants fixed by [`Self::set_override`],
233    /// ordered by name.
234    pub fn overrides(&self) -> &[(&'static str, f64)] {
235        &self.overrides
236    }
237
238    /// Hash of the fixed override set; zero when no override is fixed.
239    pub fn overrides_hash(&self) -> u64 {
240        if self.overrides.is_empty() {
241            return 0;
242        }
243        let mut bytes = Vec::new();
244        for (name, value) in &self.overrides {
245            bytes.extend_from_slice(name.as_bytes());
246            bytes.push(0);
247            bytes.extend_from_slice(&value.to_bits().to_le_bytes());
248        }
249        hash_shader_bytes(&bytes)
250    }
251
252    /// Declares how far the shader may sample outside its effect rect, in
253    /// logical pixels. Backdrop rendering uses this to capture enough input
254    /// around refractive and displacement shaders.
255    pub fn set_input_padding(&mut self, padding: f32) {
256        self.input_padding = if padding.is_finite() {
257            padding.max(0.0)
258        } else {
259            0.0
260        };
261    }
262
263    /// Returns the declared input padding in logical pixels.
264    pub fn input_padding(&self) -> f32 {
265        self.input_padding
266    }
267
268    /// Declares how far the shader WRITES outside its effect rect, in logical
269    /// pixels. Backdrop compositing widens its scissor by this amount so
270    /// SDF-driven coverage (rim glow, wobble, glued neighbor shapes) can
271    /// extend past the node bounds instead of being clipped to them.
272    pub fn set_output_padding(&mut self, padding: f32) {
273        self.output_padding = if padding.is_finite() {
274            padding.max(0.0)
275        } else {
276            0.0
277        };
278    }
279
280    /// Returns the declared output padding in logical pixels.
281    pub fn output_padding(&self) -> f32 {
282        self.output_padding
283    }
284
285    /// Set a single float uniform at the given index.
286    ///
287    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float`]
288    /// when the caller needs to handle invalid uniform writes explicitly.
289    pub fn set_float(&mut self, index: usize, value: f32) {
290        let _ = self.try_set_float(index, value);
291    }
292
293    /// Set a single float uniform at the given index.
294    pub fn try_set_float(
295        &mut self,
296        index: usize,
297        value: f32,
298    ) -> Result<(), RuntimeShaderUniformError> {
299        self.try_ensure_capacity(index, 1)?;
300        self.uniforms.set(index, value);
301        Ok(())
302    }
303
304    /// Set a vec2 uniform at the given index (consumes indices `[index, index+1]`).
305    ///
306    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float2`]
307    /// when the caller needs to handle invalid uniform writes explicitly.
308    pub fn set_float2(&mut self, index: usize, x: f32, y: f32) {
309        let _ = self.try_set_float2(index, x, y);
310    }
311
312    /// Set a vec2 uniform at the given index (consumes indices `[index, index+1]`).
313    pub fn try_set_float2(
314        &mut self,
315        index: usize,
316        x: f32,
317        y: f32,
318    ) -> Result<(), RuntimeShaderUniformError> {
319        self.try_ensure_capacity(index, 2)?;
320        self.uniforms.set(index, x);
321        self.uniforms.set(index + 1, y);
322        Ok(())
323    }
324
325    /// Set a vec4 uniform at the given index (consumes indices `[index..index+4]`).
326    ///
327    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float4`]
328    /// when the caller needs to handle invalid uniform writes explicitly.
329    pub fn set_float4(&mut self, index: usize, x: f32, y: f32, z: f32, w: f32) {
330        let _ = self.try_set_float4(index, x, y, z, w);
331    }
332
333    /// Set a vec4 uniform at the given index (consumes indices `[index..index+4]`).
334    pub fn try_set_float4(
335        &mut self,
336        index: usize,
337        x: f32,
338        y: f32,
339        z: f32,
340        w: f32,
341    ) -> Result<(), RuntimeShaderUniformError> {
342        self.try_ensure_capacity(index, 4)?;
343        self.uniforms.set(index, x);
344        self.uniforms.set(index + 1, y);
345        self.uniforms.set(index + 2, z);
346        self.uniforms.set(index + 3, w);
347        Ok(())
348    }
349
350    /// Get the WGSL source code.
351    pub fn source(&self) -> &str {
352        &self.source
353    }
354
355    /// Get the uniform data as a float slice (for uploading to GPU).
356    pub fn uniforms(&self) -> &[f32] {
357        self.uniforms.as_slice()
358    }
359
360    /// Get the uniform data padded to full 256-float array (for GPU uniform buffer).
361    pub fn uniforms_padded(&self) -> [f32; Self::MAX_UNIFORMS] {
362        let mut padded = [0.0f32; Self::MAX_UNIFORMS];
363        let len = self.uniforms.len().min(Self::MAX_UNIFORMS);
364        padded[..len].copy_from_slice(&self.uniforms.as_slice()[..len]);
365        padded
366    }
367
368    /// Compute a hash of the shader source for pipeline caching.
369    pub fn source_hash(&self) -> u64 {
370        self.source_hash
371    }
372
373    fn try_ensure_capacity(
374        &mut self,
375        index: usize,
376        width: usize,
377    ) -> Result<(), RuntimeShaderUniformError> {
378        let min_len = index
379            .checked_add(width)
380            .ok_or_else(|| Self::uniform_range_error(index, width))?;
381        if min_len > Self::MAX_USER_UNIFORMS {
382            return Err(Self::uniform_range_error(index, width));
383        }
384        self.uniforms.ensure_len(min_len);
385        Ok(())
386    }
387
388    fn uniform_range_error(index: usize, width: usize) -> RuntimeShaderUniformError {
389        RuntimeShaderUniformError::OutOfUserRange {
390            index,
391            width,
392            max_user_uniforms: Self::MAX_USER_UNIFORMS,
393            reserved_start: Self::RESERVED_UNIFORM_START,
394            max_uniforms: Self::MAX_UNIFORMS,
395        }
396    }
397}
398
399impl PartialEq for RuntimeShader {
400    fn eq(&self, other: &Self) -> bool {
401        self.source_hash == other.source_hash
402            && (Arc::ptr_eq(&self.source, &other.source)
403                || self.source.as_ref() == other.source.as_ref())
404            && self.uniforms == other.uniforms
405            && self.overrides.len() == other.overrides.len()
406            && self
407                .overrides
408                .iter()
409                .zip(&other.overrides)
410                .all(|(a, b)| a.0 == b.0 && a.1.to_bits() == b.1.to_bits())
411            && self.input_padding.to_bits() == other.input_padding.to_bits()
412            && self.output_padding.to_bits() == other.output_padding.to_bits()
413    }
414}
415
416fn hash_shader_source(source: &str) -> u64 {
417    hash_shader_bytes(source.as_bytes())
418}
419
420fn hash_shader_bytes(bytes: &[u8]) -> u64 {
421    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
422    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
423
424    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
425        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
426    })
427}
428
429#[derive(Clone, Copy, Debug, PartialEq, Eq)]
430struct ShaderSourceCallsite {
431    file: &'static str,
432    line: u32,
433    column: u32,
434}
435
436struct CachedShaderSource {
437    callsite: ShaderSourceCallsite,
438    source_hash: u64,
439    source: Arc<str>,
440}
441
442struct CachedSharedShaderSourceHash {
443    byte_ptr: usize,
444    len: usize,
445    source_hash: u64,
446    source: Weak<str>,
447}
448
449fn cached_shared_shader_source_hash(source: &Arc<str>) -> u64 {
450    static CACHE: OnceLock<Mutex<Vec<CachedSharedShaderSourceHash>>> = OnceLock::new();
451    let byte_ptr = source.as_ptr() as usize;
452    let len = source.len();
453    let mut cache = CACHE
454        .get_or_init(|| Mutex::new(Vec::new()))
455        .lock()
456        .unwrap_or_else(|poisoned| poisoned.into_inner());
457
458    cache.retain(|entry| entry.source.strong_count() > 0);
459    if let Some(entry) = cache.iter().find(|entry| {
460        entry.byte_ptr == byte_ptr
461            && entry.len == len
462            && entry
463                .source
464                .upgrade()
465                .is_some_and(|cached| Arc::ptr_eq(&cached, source))
466    }) {
467        return entry.source_hash;
468    }
469
470    let source_hash = hash_shader_source(source);
471    cache.push(CachedSharedShaderSourceHash {
472        byte_ptr,
473        len,
474        source_hash,
475        source: Arc::downgrade(source),
476    });
477    source_hash
478}
479
480fn cached_shader_source(
481    location: &'static std::panic::Location<'static>,
482    source: &str,
483) -> (Arc<str>, u64) {
484    static CACHE: OnceLock<Mutex<Vec<CachedShaderSource>>> = OnceLock::new();
485    let callsite = ShaderSourceCallsite {
486        file: location.file(),
487        line: location.line(),
488        column: location.column(),
489    };
490    let mut cache = CACHE
491        .get_or_init(|| Mutex::new(Vec::new()))
492        .lock()
493        .unwrap_or_else(|poisoned| poisoned.into_inner());
494
495    if let Some(entry) = cache.iter_mut().find(|entry| entry.callsite == callsite) {
496        if entry.source.as_ref() == source {
497            return (entry.source.clone(), entry.source_hash);
498        }
499        let source_hash = hash_shader_source(source);
500        entry.source_hash = source_hash;
501        entry.source = Arc::<str>::from(source);
502        return (entry.source.clone(), entry.source_hash);
503    }
504
505    let source_hash = hash_shader_source(source);
506    let shared = Arc::<str>::from(source);
507    cache.push(CachedShaderSource {
508        callsite,
509        source_hash,
510        source: shared.clone(),
511    });
512    (shared, source_hash)
513}
514
515/// A render effect applied to a graphics layer's rendered content.
516///
517/// Matches Jetpack Compose's `RenderEffect` sealed class hierarchy,
518/// extended with `Shader` for custom WGSL effects.
519#[derive(Clone, Debug, PartialEq)]
520pub enum RenderEffect {
521    /// Gaussian blur applied to the layer's rendered content.
522    Blur {
523        radius_x: f32,
524        radius_y: f32,
525        edge_treatment: TileMode,
526    },
527    /// Offset the rendered content by a fixed amount.
528    Offset { offset_x: f32, offset_y: f32 },
529    /// Apply a custom WGSL shader effect.
530    Shader { shader: RuntimeShader },
531    /// Chain two effects: apply `first`, then apply `second` to the result.
532    Chain {
533        first: Box<RenderEffect>,
534        second: Box<RenderEffect>,
535    },
536}
537
538impl RenderEffect {
539    /// Create a blur effect with equal radius in both directions.
540    pub fn blur(radius: f32) -> Self {
541        Self::blur_with_edge_treatment(radius, TileMode::default())
542    }
543
544    /// Create a blur effect with equal radius in both directions and explicit
545    /// edge treatment semantics.
546    pub fn blur_with_edge_treatment(radius: f32, edge_treatment: TileMode) -> Self {
547        Self::Blur {
548            radius_x: radius,
549            radius_y: radius,
550            edge_treatment,
551        }
552    }
553
554    /// Create a blur effect with separate horizontal and vertical radii.
555    pub fn blur_xy(radius_x: f32, radius_y: f32, edge_treatment: TileMode) -> Self {
556        Self::Blur {
557            radius_x,
558            radius_y,
559            edge_treatment,
560        }
561    }
562
563    /// Create an offset effect.
564    pub fn offset(offset_x: f32, offset_y: f32) -> Self {
565        Self::Offset { offset_x, offset_y }
566    }
567
568    /// Create a custom shader effect from a RuntimeShader.
569    pub fn runtime_shader(shader: RuntimeShader) -> Self {
570        Self::Shader { shader }
571    }
572
573    /// Chain this effect with another: `self` is applied first, then `other`.
574    pub fn then(self, other: RenderEffect) -> Self {
575        Self::Chain {
576            first: Box::new(self),
577            second: Box::new(other),
578        }
579    }
580
581    /// Returns `true` if this effect or any chained sub-effect is a
582    /// `RuntimeShader`. Animated shaders produce different output every frame,
583    /// so layer surface caching is counterproductive for them.
584    pub fn contains_runtime_shader(&self) -> bool {
585        match self {
586            RenderEffect::Shader { .. } => true,
587            RenderEffect::Chain { first, second } => {
588                first.contains_runtime_shader() || second.contains_runtime_shader()
589            }
590            _ => false,
591        }
592    }
593
594    /// Maximum logical-pixel input padding required by this effect.
595    pub fn input_padding(&self) -> f32 {
596        match self {
597            RenderEffect::Blur {
598                radius_x, radius_y, ..
599            } => radius_x.abs().max(radius_y.abs()),
600            RenderEffect::Offset { offset_x, offset_y } => offset_x.abs().max(offset_y.abs()),
601            RenderEffect::Shader { shader } => shader.input_padding(),
602            RenderEffect::Chain { first, second } => first.input_padding() + second.input_padding(),
603        }
604    }
605
606    /// Maximum logical-pixel distance this effect WRITES outside its rect.
607    /// Only runtime shaders may declare one (SDF coverage past node bounds);
608    /// blur/offset stay confined to their tight rect.
609    pub fn output_padding(&self) -> f32 {
610        match self {
611            RenderEffect::Blur { .. } | RenderEffect::Offset { .. } => 0.0,
612            RenderEffect::Shader { shader } => shader.output_padding(),
613            RenderEffect::Chain { first, second } => {
614                first.output_padding() + second.output_padding()
615            }
616        }
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    #[test]
623    fn overrides_stay_sorted_and_replace_by_name() {
624        let mut shader = super::RuntimeShader::new("// overrides");
625        shader.set_override("ZETA", 1.0);
626        shader.set_override("ALPHA", 0.0);
627        shader.set_override("ZETA", 2.0);
628        assert_eq!(shader.overrides(), &[("ALPHA", 0.0), ("ZETA", 2.0)]);
629    }
630
631    #[test]
632    fn overrides_distinguish_otherwise_equal_shaders() {
633        let plain = super::RuntimeShader::new("// overrides-eq");
634        let mut raised = plain.clone();
635        raised.set_override("FLAG", 1.0);
636        assert_eq!(plain.overrides_hash(), 0);
637        assert_ne!(plain.overrides_hash(), raised.overrides_hash());
638        assert_ne!(plain, raised);
639        let mut lowered = raised.clone();
640        lowered.set_override("FLAG", 0.0);
641        assert_ne!(raised.overrides_hash(), lowered.overrides_hash());
642        assert_ne!(raised, lowered);
643    }
644
645    use super::*;
646    use crate::RoundedCornerShape;
647
648    #[test]
649    fn runtime_shader_set_uniforms() {
650        let mut shader = RuntimeShader::new("// test");
651        shader.set_float(0, 1.0);
652        shader.set_float2(2, 3.0, 4.0);
653        shader.set_float4(4, 5.0, 6.0, 7.0, 8.0);
654
655        assert_eq!(shader.uniforms()[0], 1.0);
656        assert_eq!(shader.uniforms()[1], 0.0);
657        assert_eq!(shader.uniforms()[2], 3.0);
658        assert_eq!(shader.uniforms()[3], 4.0);
659        assert_eq!(shader.uniforms()[4], 5.0);
660        assert_eq!(shader.uniforms()[5], 6.0);
661        assert_eq!(shader.uniforms()[6], 7.0);
662        assert_eq!(shader.uniforms()[7], 8.0);
663    }
664
665    #[test]
666    fn runtime_shader_padded() {
667        let mut shader = RuntimeShader::new("// test");
668        shader.set_float(0, 42.0);
669        let padded = shader.uniforms_padded();
670        assert_eq!(padded[0], 42.0);
671        assert_eq!(padded[1], 0.0);
672        assert_eq!(padded[255], 0.0);
673    }
674
675    #[test]
676    fn blur_and_offset_declare_input_padding() {
677        assert_eq!(
678            RenderEffect::blur_xy(6.0, 12.0, TileMode::Clamp).input_padding(),
679            12.0
680        );
681        assert_eq!(RenderEffect::offset(-8.0, 3.0).input_padding(), 8.0);
682    }
683
684    #[test]
685    fn chained_effect_padding_accumulates_sampling_ranges() {
686        let mut shader = RuntimeShader::new("// test");
687        shader.set_input_padding(9.0);
688        let effect = RenderEffect::blur_xy(4.0, 6.0, TileMode::Clamp)
689            .then(RenderEffect::runtime_shader(shader))
690            .then(RenderEffect::offset(2.0, -5.0));
691
692        assert_eq!(effect.input_padding(), 20.0);
693    }
694
695    #[test]
696    fn runtime_shader_keeps_common_uniform_payload_inline() {
697        let mut shader = RuntimeShader::new("// test");
698        shader.set_float4(0, 1.0, 2.0, 3.0, 4.0);
699        shader.set_float4(4, 5.0, 6.0, 7.0, 8.0);
700        shader.set_float4(8, 9.0, 10.0, 11.0, 12.0);
701        shader.set_float4(12, 13.0, 14.0, 15.0, 16.0);
702
703        assert!(shader.uniforms.is_inline());
704        assert_eq!(shader.uniforms().len(), 16);
705
706        shader.set_float(16, 17.0);
707        assert!(!shader.uniforms.is_inline());
708        assert_eq!(shader.uniforms()[16], 17.0);
709    }
710
711    #[test]
712    fn runtime_shader_try_set_reports_reserved_uniform_slots() {
713        let mut shader = RuntimeShader::new("// test");
714
715        let err = shader
716            .try_set_float(RuntimeShader::RESERVED_UNIFORM_START, 1.0)
717            .unwrap_err();
718        assert_eq!(
719            err,
720            RuntimeShaderUniformError::OutOfUserRange {
721                index: RuntimeShader::RESERVED_UNIFORM_START,
722                width: 1,
723                max_user_uniforms: RuntimeShader::MAX_USER_UNIFORMS,
724                reserved_start: RuntimeShader::RESERVED_UNIFORM_START,
725                max_uniforms: RuntimeShader::MAX_UNIFORMS,
726            }
727        );
728        assert!(shader.uniforms().is_empty());
729
730        let err = shader
731            .try_set_float4(RuntimeShader::MAX_USER_UNIFORMS - 3, 1.0, 2.0, 3.0, 4.0)
732            .unwrap_err();
733        assert_eq!(
734            err,
735            RuntimeShaderUniformError::OutOfUserRange {
736                index: RuntimeShader::MAX_USER_UNIFORMS - 3,
737                width: 4,
738                max_user_uniforms: RuntimeShader::MAX_USER_UNIFORMS,
739                reserved_start: RuntimeShader::RESERVED_UNIFORM_START,
740                max_uniforms: RuntimeShader::MAX_UNIFORMS,
741            }
742        );
743    }
744
745    #[test]
746    fn runtime_shader_setters_ignore_invalid_uniform_slots_without_panicking() {
747        let mut shader = RuntimeShader::new("// test");
748        shader.set_float(0, 7.0);
749
750        shader.set_float(RuntimeShader::RESERVED_UNIFORM_START, 1.0);
751        shader.set_float4(RuntimeShader::MAX_USER_UNIFORMS - 3, 1.0, 2.0, 3.0, 4.0);
752
753        assert_eq!(shader.uniforms(), &[7.0]);
754    }
755
756    #[test]
757    fn render_effect_chaining() {
758        let blur = RenderEffect::blur(10.0);
759        let offset = RenderEffect::offset(5.0, 5.0);
760        let chained = blur.then(offset);
761        match chained {
762            RenderEffect::Chain { first, second } => {
763                assert!(matches!(*first, RenderEffect::Blur { .. }));
764                assert!(matches!(*second, RenderEffect::Offset { .. }));
765            }
766            _ => panic!("expected Chain"),
767        }
768    }
769
770    #[test]
771    fn blur_convenience() {
772        let effect = RenderEffect::blur(15.0);
773        match effect {
774            RenderEffect::Blur {
775                radius_x,
776                radius_y,
777                edge_treatment,
778            } => {
779                assert_eq!(radius_x, 15.0);
780                assert_eq!(radius_y, 15.0);
781                assert_eq!(edge_treatment, TileMode::Clamp);
782            }
783            _ => panic!("expected Blur"),
784        }
785    }
786
787    #[test]
788    fn blur_with_edge_treatment_uses_explicit_mode() {
789        let effect = RenderEffect::blur_with_edge_treatment(6.0, TileMode::Decal);
790        match effect {
791            RenderEffect::Blur {
792                radius_x,
793                radius_y,
794                edge_treatment,
795            } => {
796                assert_eq!(radius_x, 6.0);
797                assert_eq!(radius_y, 6.0);
798                assert_eq!(edge_treatment, TileMode::Decal);
799            }
800            _ => panic!("expected Blur"),
801        }
802    }
803
804    #[test]
805    fn source_hash_consistent() {
806        let s1 = RuntimeShader::new("fn main() {}");
807        let s2 = RuntimeShader::new("fn main() {}");
808        assert_eq!(s1.source_hash(), s2.source_hash());
809    }
810
811    #[test]
812    fn runtime_shader_from_shared_source_reuses_shared_source() {
813        let source = Arc::<str>::from("fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }");
814        let s1 = RuntimeShader::from_shared_source(source.clone());
815        let s2 = RuntimeShader::from_shared_source(source);
816
817        assert!(Arc::ptr_eq(&s1.source, &s2.source));
818        assert_eq!(s1.source_hash(), s2.source_hash());
819    }
820
821    fn runtime_shader_from_reuse_callsite(source: &str) -> RuntimeShader {
822        RuntimeShader::new(source)
823    }
824
825    fn runtime_shader_from_replacement_callsite(source: &str) -> RuntimeShader {
826        RuntimeShader::new(source)
827    }
828
829    #[test]
830    fn runtime_shader_new_reuses_same_callsite_source() {
831        let source = "fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }";
832        let s1 = runtime_shader_from_reuse_callsite(source);
833        let s2 = runtime_shader_from_reuse_callsite(source);
834
835        assert!(Arc::ptr_eq(&s1.source, &s2.source));
836        assert_eq!(s1.source_hash(), s2.source_hash());
837    }
838
839    #[test]
840    fn runtime_shader_new_replaces_changed_callsite_source() {
841        let s1 = runtime_shader_from_replacement_callsite("fn a() {}");
842        let s2 = runtime_shader_from_replacement_callsite("fn b() {}");
843
844        assert!(!Arc::ptr_eq(&s1.source, &s2.source));
845        assert_ne!(s1.source_hash(), s2.source_hash());
846        assert_eq!(s2.source(), "fn b() {}");
847    }
848
849    #[test]
850    fn runtime_shader_source_storage_has_no_process_global_interner() {
851        let source = include_str!("render_effect.rs");
852        let blocked_static = ["static ", "INTERNER"].concat();
853        let blocked_type = ["ShaderSource", "Interner"].concat();
854
855        assert!(
856            !source.contains(&blocked_static) && !source.contains(&blocked_type),
857            "RuntimeShader source sharing must be explicit via from_shared_source, not a process-global interner"
858        );
859    }
860
861    #[test]
862    fn blur_xy_preserves_tile_mode() {
863        let effect = RenderEffect::blur_xy(3.0, 7.0, TileMode::Clamp);
864        match effect {
865            RenderEffect::Blur {
866                radius_x,
867                radius_y,
868                edge_treatment,
869            } => {
870                assert_eq!(radius_x, 3.0);
871                assert_eq!(radius_y, 7.0);
872                assert_eq!(edge_treatment, TileMode::Clamp);
873            }
874            _ => panic!("expected Blur"),
875        }
876    }
877
878    #[test]
879    fn offset_constructor_sets_components() {
880        let effect = RenderEffect::offset(11.0, -5.0);
881        match effect {
882            RenderEffect::Offset { offset_x, offset_y } => {
883                assert_eq!(offset_x, 11.0);
884                assert_eq!(offset_y, -5.0);
885            }
886            _ => panic!("expected Offset"),
887        }
888    }
889
890    #[test]
891    fn runtime_shader_equality_is_source_value_based() {
892        let mut s1 = RuntimeShader::new("fn main() {}");
893        let mut s2 = RuntimeShader::new("fn main() {}");
894        s1.set_float(0, 1.0);
895        s2.set_float(0, 1.0);
896        assert_eq!(s1, s2);
897    }
898
899    #[test]
900    fn blurred_edge_treatment_defaults_to_bounded_rectangle() {
901        let treatment = BlurredEdgeTreatment::default();
902        assert_eq!(treatment.shape(), Some(LayerShape::Rectangle));
903        assert!(treatment.clip());
904        assert_eq!(treatment.tile_mode(), TileMode::Clamp);
905    }
906
907    #[test]
908    fn blurred_edge_treatment_unbounded_uses_decal_and_no_clip() {
909        let treatment = BlurredEdgeTreatment::UNBOUNDED;
910        assert_eq!(treatment.shape(), None);
911        assert!(!treatment.clip());
912        assert_eq!(treatment.tile_mode(), TileMode::Decal);
913    }
914
915    #[test]
916    fn blurred_edge_treatment_with_shape_uses_bounded_mode() {
917        let rounded = LayerShape::Rounded(RoundedCornerShape::uniform(8.0));
918        let treatment = BlurredEdgeTreatment::with_shape(rounded);
919        assert_eq!(treatment.shape(), Some(rounded));
920        assert!(treatment.clip());
921        assert_eq!(treatment.tile_mode(), TileMode::Clamp);
922    }
923}