Skip to main content

cranpose_ui_graphics/
alpha_mask.rs

1//! Alpha-mask helpers for graphics-layer effects.
2//!
3//! These utilities provide a dev-facing API for common Jetpack Compose style
4//! masking workflows, such as revealing/cutting content with a rounded shape
5//! and a feathered opacity transition.
6
7use crate::{RenderEffect, RuntimeShader};
8
9/// Direction of the gradient cut mask.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
11pub enum CutDirection {
12    /// Keep content from the left edge up to the cut progress.
13    #[default]
14    LeftToRight,
15    /// Keep content from the right edge up to the cut progress.
16    RightToLeft,
17    /// Keep content from the top edge down to the cut progress.
18    TopToBottom,
19    /// Keep content from the bottom edge up to the cut progress.
20    BottomToTop,
21}
22
23impl CutDirection {
24    fn uniform_code(self) -> f32 {
25        match self {
26            CutDirection::LeftToRight => 0.0,
27            CutDirection::RightToLeft => 1.0,
28            CutDirection::TopToBottom => 2.0,
29            CutDirection::BottomToTop => 3.0,
30        }
31    }
32}
33
34/// Configuration for a directional gradient cut mask.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct GradientCutMaskSpec {
37    /// Reveal progress in [0, 1].
38    pub progress: f32,
39    /// Width of the edge feather in dp/px.
40    pub feather: f32,
41    /// Rounded-corner radius of the masked area in dp/px.
42    pub corner_radius: f32,
43    /// Direction from which content is revealed.
44    pub direction: CutDirection,
45}
46
47impl Default for GradientCutMaskSpec {
48    fn default() -> Self {
49        Self {
50            progress: 0.5,
51            feather: 24.0,
52            corner_radius: 16.0,
53            direction: CutDirection::LeftToRight,
54        }
55    }
56}
57
58/// Configuration for a directional gradient fade mask that matches
59/// `drawWithContent { drawRect(..., blendMode = DstOut) }` behavior.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct GradientFadeMaskSpec {
62    /// Axis coordinate where fade starts (fully cut) in dp/px.
63    pub start: f32,
64    /// Axis coordinate where fade ends (fully visible) in dp/px.
65    pub end: f32,
66    /// Direction that defines the gradient axis.
67    pub direction: CutDirection,
68}
69
70impl Default for GradientFadeMaskSpec {
71    fn default() -> Self {
72        Self {
73            start: 0.0,
74            end: 64.0,
75            direction: CutDirection::TopToBottom,
76        }
77    }
78}
79
80/// WGSL shader for directional cut + rounded rect alpha mask.
81///
82/// Uniform layout:
83/// - 0,1: container size in dp
84/// - 2: progress [0,1]
85/// - 3: feather in dp
86/// - 4: corner radius in dp
87/// - 5: direction code (0=L->R, 1=R->L, 2=T->B, 3=B->T)
88pub const GRADIENT_CUT_MASK_WGSL: &str = r#"
89struct VertexOutput {
90    @builtin(position) position: vec4<f32>,
91    @location(0) uv: vec2<f32>,
92}
93
94@vertex
95fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
96    var output: VertexOutput;
97    let x = f32(i32(vertex_index & 1u) * 2 - 1);
98    let y = f32(i32(vertex_index >> 1u) * 2 - 1);
99    output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
100    output.position = vec4<f32>(x, y, 0.0, 1.0);
101    return output;
102}
103
104@group(0) @binding(0) var input_texture: texture_2d<f32>;
105@group(0) @binding(1) var input_sampler: sampler;
106@group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
107
108fn get_float(index: u32) -> f32 {
109    return u[index / 4u][index % 4u];
110}
111
112fn get_vec2(index: u32) -> vec2<f32> {
113    return vec2<f32>(get_float(index), get_float(index + 1u));
114}
115
116fn sd_round_rect(p: vec2<f32>, half_size: vec2<f32>, radius: f32) -> f32 {
117    let q = abs(p) - half_size + vec2<f32>(radius);
118    return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - radius;
119}
120
121fn rounded_rect_alpha(local_px: vec2<f32>, size_px: vec2<f32>, corner_radius_px: f32) -> f32 {
122    let half = size_px * 0.5;
123    let p = local_px - half;
124    let d = sd_round_rect(p, half, corner_radius_px);
125    return 1.0 - smoothstep(-1.0, 1.0, d);
126}
127
128@fragment
129fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
130    let uv = input.uv;
131    let tex_size = vec2<f32>(textureDimensions(input_texture));
132
133    // Effect layer pixel rect injected by renderer in uniform slot 62.
134    let effect_rect = vec4<f32>(get_float(248u), get_float(249u), get_float(250u), get_float(251u));
135    let container_dp = get_vec2(0u);
136
137    // dp -> pixel mapping for local effect coordinates.
138    let dp_scale = effect_rect.zw / max(container_dp, vec2<f32>(1.0));
139    let s = min(dp_scale.x, dp_scale.y);
140
141    let local_px = uv * tex_size - effect_rect.xy;
142    let size_px = container_dp * dp_scale;
143
144    let progress = clamp(get_float(2u), 0.0, 1.0);
145    let feather_px = max(get_float(3u) * s, 0.001);
146    let corner_radius_px = max(get_float(4u) * s, 0.0);
147    let direction = get_float(5u);
148
149    var axis_value = local_px.x;
150    var axis_extent = max(size_px.x, 0.001);
151
152    if (direction >= 0.5 && direction < 1.5) {
153        axis_value = size_px.x - local_px.x;
154        axis_extent = max(size_px.x, 0.001);
155    } else if (direction >= 1.5 && direction < 2.5) {
156        axis_value = local_px.y;
157        axis_extent = max(size_px.y, 0.001);
158    } else if (direction >= 2.5) {
159        axis_value = size_px.y - local_px.y;
160        axis_extent = max(size_px.y, 0.001);
161    }
162
163    var directional_alpha = 1.0;
164    if (progress < 1.0) {
165        let cut_edge = progress * axis_extent;
166        directional_alpha = smoothstep(cut_edge + feather_px * 0.5, cut_edge - feather_px * 0.5, axis_value);
167    }
168    let shape_alpha = rounded_rect_alpha(local_px, size_px, corner_radius_px);
169    let mask = directional_alpha * shape_alpha;
170
171    let sample = textureSample(input_texture, input_sampler, uv);
172    return sample * mask;
173}
174"#;
175
176/// WGSL shader for rounded-rectangle alpha masking with feathered edges.
177///
178/// Uniform layout:
179/// - 0,1: container size in dp
180/// - 2: corner radius in dp
181/// - 3: edge feather in dp
182pub const ROUNDED_ALPHA_MASK_WGSL: &str = r#"
183struct VertexOutput {
184    @builtin(position) position: vec4<f32>,
185    @location(0) uv: vec2<f32>,
186}
187
188@vertex
189fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
190    var output: VertexOutput;
191    let x = f32(i32(vertex_index & 1u) * 2 - 1);
192    let y = f32(i32(vertex_index >> 1u) * 2 - 1);
193    output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
194    output.position = vec4<f32>(x, y, 0.0, 1.0);
195    return output;
196}
197
198@group(0) @binding(0) var input_texture: texture_2d<f32>;
199@group(0) @binding(1) var input_sampler: sampler;
200@group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
201
202fn get_float(index: u32) -> f32 {
203    return u[index / 4u][index % 4u];
204}
205
206fn get_vec2(index: u32) -> vec2<f32> {
207    return vec2<f32>(get_float(index), get_float(index + 1u));
208}
209
210fn sd_round_rect(p: vec2<f32>, half_size: vec2<f32>, radius: f32) -> f32 {
211    let q = abs(p) - half_size + vec2<f32>(radius);
212    return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - radius;
213}
214
215fn rounded_rect_alpha(local_px: vec2<f32>, size_px: vec2<f32>, corner_radius_px: f32, feather_px: f32) -> f32 {
216    let half = size_px * 0.5;
217    let p = local_px - half;
218    let d = sd_round_rect(p, half, corner_radius_px);
219    let half_feather = max(feather_px * 0.5, 0.001);
220    return 1.0 - smoothstep(-half_feather, half_feather, d);
221}
222
223@fragment
224fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
225    let uv = input.uv;
226    let tex_size = vec2<f32>(textureDimensions(input_texture));
227
228    // Effect layer pixel rect injected by renderer in uniform slot 62.
229    let effect_rect = vec4<f32>(get_float(248u), get_float(249u), get_float(250u), get_float(251u));
230    let container_dp = get_vec2(0u);
231
232    // dp -> pixel mapping for local effect coordinates.
233    let dp_scale = effect_rect.zw / max(container_dp, vec2<f32>(1.0));
234    let s = min(dp_scale.x, dp_scale.y);
235
236    let local_px = uv * tex_size - effect_rect.xy;
237    let size_px = container_dp * dp_scale;
238
239    let corner_radius_px = max(get_float(2u) * s, 0.0);
240    let feather_px = max(get_float(3u) * s, 0.0);
241    let mask = rounded_rect_alpha(local_px, size_px, corner_radius_px, feather_px);
242
243    let sample = textureSample(input_texture, input_sampler, uv);
244    return sample * mask;
245}
246"#;
247
248/// WGSL shader for directional fade-out alpha masking (DstOut-style).
249///
250/// Uniform layout:
251/// - 0,1: container size in dp
252/// - 2: fade start in dp
253/// - 3: fade end in dp
254/// - 4: direction code (0=L->R, 1=R->L, 2=T->B, 3=B->T)
255pub const GRADIENT_FADE_DST_OUT_WGSL: &str = r#"
256struct VertexOutput {
257    @builtin(position) position: vec4<f32>,
258    @location(0) uv: vec2<f32>,
259}
260
261@vertex
262fn fullscreen_vs(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
263    var output: VertexOutput;
264    let x = f32(i32(vertex_index & 1u) * 2 - 1);
265    let y = f32(i32(vertex_index >> 1u) * 2 - 1);
266    output.uv = vec2<f32>(x * 0.5 + 0.5, 1.0 - (y * 0.5 + 0.5));
267    output.position = vec4<f32>(x, y, 0.0, 1.0);
268    return output;
269}
270
271@group(0) @binding(0) var input_texture: texture_2d<f32>;
272@group(0) @binding(1) var input_sampler: sampler;
273@group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
274
275fn get_float(index: u32) -> f32 {
276    return u[index / 4u][index % 4u];
277}
278
279fn get_vec2(index: u32) -> vec2<f32> {
280    return vec2<f32>(get_float(index), get_float(index + 1u));
281}
282
283@fragment
284fn effect_fs(input: VertexOutput) -> @location(0) vec4<f32> {
285    let uv = input.uv;
286    let tex_size = vec2<f32>(textureDimensions(input_texture));
287
288    // Effect layer pixel rect injected by renderer in uniform slot 62.
289    let effect_rect = vec4<f32>(get_float(248u), get_float(249u), get_float(250u), get_float(251u));
290    let container_dp = get_vec2(0u);
291
292    // dp -> pixel mapping for local effect coordinates.
293    let dp_scale = effect_rect.zw / max(container_dp, vec2<f32>(1.0));
294
295    let local_px = uv * tex_size - effect_rect.xy;
296    let size_px = container_dp * dp_scale;
297    let direction = get_float(4u);
298
299    var axis_value = local_px.x;
300    var axis_scale = dp_scale.x;
301    if (direction >= 0.5 && direction < 1.5) {
302        axis_value = size_px.x - local_px.x;
303        axis_scale = dp_scale.x;
304    } else if (direction >= 1.5 && direction < 2.5) {
305        axis_value = local_px.y;
306        axis_scale = dp_scale.y;
307    } else if (direction >= 2.5) {
308        axis_value = size_px.y - local_px.y;
309        axis_scale = dp_scale.y;
310    }
311
312    let start_px = get_float(2u) * axis_scale;
313    let end_px = get_float(3u) * axis_scale;
314    let span = max(abs(end_px - start_px), 0.001);
315
316    var keep_alpha = 1.0;
317    if (end_px >= start_px) {
318        keep_alpha = clamp((axis_value - start_px) / span, 0.0, 1.0);
319    } else {
320        keep_alpha = clamp((start_px - axis_value) / span, 0.0, 1.0);
321    }
322
323    let sample = textureSample(input_texture, input_sampler, uv);
324    return sample * keep_alpha;
325}
326"#;
327
328/// Builds a directional cut mask effect.
329///
330/// The resulting `RenderEffect` keeps content from one side up to `progress`
331/// with a feathered transition and rounded-rect outer masking.
332pub fn gradient_cut_mask_effect(
333    spec: &GradientCutMaskSpec,
334    area_width: f32,
335    area_height: f32,
336) -> RenderEffect {
337    let mut shader = RuntimeShader::new(GRADIENT_CUT_MASK_WGSL);
338    shader.set_float2(0, area_width.max(1.0), area_height.max(1.0));
339    shader.set_float(2, spec.progress.clamp(0.0, 1.0));
340    shader.set_float(3, spec.feather.max(0.0));
341    shader.set_float(4, spec.corner_radius.max(0.0));
342    shader.set_float(5, spec.direction.uniform_code());
343    RenderEffect::runtime_shader(shader)
344}
345
346/// Builds a rounded alpha mask effect without directional cutting.
347///
348/// Useful for masking other effects (for example blur) to a rounded rectangle
349/// with an explicit feathered edge width.
350pub fn rounded_alpha_mask_effect(
351    area_width: f32,
352    area_height: f32,
353    corner_radius: f32,
354    edge_feather: f32,
355) -> RenderEffect {
356    let mut shader = RuntimeShader::new(ROUNDED_ALPHA_MASK_WGSL);
357    shader.set_float2(0, area_width.max(1.0), area_height.max(1.0));
358    shader.set_float(2, corner_radius.max(0.0));
359    shader.set_float(3, edge_feather.max(0.0));
360    RenderEffect::runtime_shader(shader)
361}
362
363/// Builds a directional gradient fade mask with DstOut semantics.
364///
365/// Equivalent to drawing an opaque->transparent gradient on top of content
366/// using destination-out blending: the fade start is fully cut, and the end
367/// is fully preserved.
368pub fn gradient_fade_dst_out_effect(
369    spec: &GradientFadeMaskSpec,
370    area_width: f32,
371    area_height: f32,
372) -> RenderEffect {
373    let mut shader = RuntimeShader::new(GRADIENT_FADE_DST_OUT_WGSL);
374    shader.set_float2(0, area_width.max(1.0), area_height.max(1.0));
375    shader.set_float(2, spec.start);
376    shader.set_float(3, spec.end);
377    shader.set_float(4, spec.direction.uniform_code());
378    RenderEffect::runtime_shader(shader)
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn gradient_cut_spec_defaults() {
387        let spec = GradientCutMaskSpec::default();
388        assert_eq!(spec.progress, 0.5);
389        assert_eq!(spec.feather, 24.0);
390        assert_eq!(spec.corner_radius, 16.0);
391        assert_eq!(spec.direction, CutDirection::LeftToRight);
392    }
393
394    #[test]
395    fn gradient_fade_spec_defaults() {
396        let spec = GradientFadeMaskSpec::default();
397        assert_eq!(spec.start, 0.0);
398        assert_eq!(spec.end, 64.0);
399        assert_eq!(spec.direction, CutDirection::TopToBottom);
400    }
401
402    #[test]
403    fn gradient_cut_effect_sets_uniforms() {
404        let spec = GradientCutMaskSpec {
405            progress: 0.33,
406            feather: 18.0,
407            corner_radius: 20.0,
408            direction: CutDirection::BottomToTop,
409        };
410        let effect = gradient_cut_mask_effect(&spec, 320.0, 180.0);
411        let RenderEffect::Shader { shader } = effect else {
412            panic!("expected shader render effect");
413        };
414
415        let u = shader.uniforms();
416        assert_eq!(u[0], 320.0);
417        assert_eq!(u[1], 180.0);
418        assert_eq!(u[2], 0.33);
419        assert_eq!(u[3], 18.0);
420        assert_eq!(u[4], 20.0);
421        assert_eq!(u[5], 3.0);
422    }
423
424    #[test]
425    fn gradient_cut_effect_clamps_values() {
426        let spec = GradientCutMaskSpec {
427            progress: 2.4,
428            feather: -3.0,
429            corner_radius: -8.0,
430            direction: CutDirection::RightToLeft,
431        };
432        let effect = gradient_cut_mask_effect(&spec, 0.0, 0.0);
433        let RenderEffect::Shader { shader } = effect else {
434            panic!("expected shader render effect");
435        };
436
437        let u = shader.uniforms();
438        assert_eq!(u[0], 1.0);
439        assert_eq!(u[1], 1.0);
440        assert_eq!(u[2], 1.0);
441        assert_eq!(u[3], 0.0);
442        assert_eq!(u[4], 0.0);
443        assert_eq!(u[5], 1.0);
444    }
445
446    #[test]
447    fn rounded_alpha_mask_uses_dedicated_shader_uniforms() {
448        let effect = rounded_alpha_mask_effect(240.0, 120.0, 14.0, 6.0);
449        let RenderEffect::Shader { shader } = effect else {
450            panic!("expected shader render effect");
451        };
452
453        assert_eq!(shader.source(), ROUNDED_ALPHA_MASK_WGSL);
454        let u = shader.uniforms();
455        assert_eq!(u[0], 240.0);
456        assert_eq!(u[1], 120.0);
457        assert_eq!(u[2], 14.0);
458        assert_eq!(u[3], 6.0);
459    }
460
461    #[test]
462    fn gradient_fade_dst_out_effect_sets_uniforms() {
463        let spec = GradientFadeMaskSpec {
464            start: 24.0,
465            end: 52.0,
466            direction: CutDirection::BottomToTop,
467        };
468        let effect = gradient_fade_dst_out_effect(&spec, 300.0, 180.0);
469        let RenderEffect::Shader { shader } = effect else {
470            panic!("expected shader render effect");
471        };
472
473        assert_eq!(shader.source(), GRADIENT_FADE_DST_OUT_WGSL);
474        let u = shader.uniforms();
475        assert_eq!(u[0], 300.0);
476        assert_eq!(u[1], 180.0);
477        assert_eq!(u[2], 24.0);
478        assert_eq!(u[3], 52.0);
479        assert_eq!(u[4], 3.0);
480    }
481}