Skip to main content

wisp_storybook/stories/
s_text_mask.rs

1//! Story: text used as an alpha mask (M-TEXT.11 / AUT-85).
2//!
3//! Three compositions, one mask: render "WISP" into a `RenderTexture`,
4//! then call `Renderer::apply_mask_to_texture(foreground, text_mask,
5//! output)` three times with three foreground RTs — a gradient fill,
6//! a blurred backdrop, and a spotlight highlight. The text silhouette
7//! is the only common element; the foreground does the colored work.
8
9use glam::Vec2;
10use wisp::application::Application;
11use wisp::math::Rect;
12use wisp::render::Renderer;
13use wisp::text::{TextTexturePipeline, WispText, WispTextStyle};
14use wisp::texture::render_texture::RenderTexture;
15use wisp::{
16    BlurFilter, Color, DropShadowFilter, Fill, Graphics, Sprite, Stage, Stroke, Texture,
17    WispFontWeight,
18};
19
20use crate::story::Story;
21
22pub fn story() -> Story {
23    Story {
24        id: "text-mask",
25        category: "Text",
26        title: "Text as mask — fill / blur / spotlight",
27        milestone: "M-TEXT.11",
28        writeup: include_str!("writeups/text_mask.md"),
29        build,
30        tick: None,
31    }
32}
33
34const SIZE: u32 = 256;
35
36fn build(app: &Application, stage: &mut Stage) {
37    let format = wgpu::TextureFormat::Rgba8Unorm;
38    let renderer = Renderer::new(app, format).expect("renderer");
39    let pipeline = TextTexturePipeline::new(app, wgpu::TextureFormat::Rgba8UnormSrgb);
40    let root = stage.root();
41
42    // ── Build the text alpha mask in the renderer's format ────
43    let text = WispText::new("WISP").with_style(
44        WispTextStyle::default()
45            .with_size(0.95)
46            .with_weight(WispFontWeight::Bold)
47            .with_color(Color::WHITE),
48    );
49    let text_rt = pipeline.render(app, &text, SIZE, SIZE);
50    let text_bytes = text_rt.read_pixels(app);
51    let text_tex = Texture::from_rgba(app, SIZE, SIZE, &text_bytes);
52
53    let mask_rt = RenderTexture::with_format(app, SIZE, SIZE, format);
54    let mut mask_stage = Stage::new();
55    let mut mask_sprite = Sprite::from_texture(text_tex.clone()).with_anchor(Vec2::new(0.0, 0.0));
56    mask_sprite.container.transform.position = Vec2::new(-1.0, 1.0);
57    mask_sprite.container.transform.scale = Vec2::new(2.0, -2.0);
58    let _ = mask_stage.add_child(mask_stage.root(), mask_sprite);
59    let _ = renderer.render_stage(app, mask_rt.view(), Color::TRANSPARENT, &mask_stage);
60
61    // ── Three foreground RTs ──────────────────────────────────
62    let fill_rt = render_gradient_fill(app, &renderer, format);
63    let blur_rt = render_blur_backdrop(app, &renderer, format);
64    let spot_rt = render_spotlight(app, &renderer, format);
65
66    // ── Three masked outputs ──────────────────────────────────
67    let fill_out = RenderTexture::with_format(app, SIZE, SIZE, format);
68    renderer.apply_mask_to_texture(app, &fill_rt, &mask_rt, &fill_out);
69
70    let blur_out = RenderTexture::with_format(app, SIZE, SIZE, format);
71    renderer.apply_mask_to_texture(app, &blur_rt, &mask_rt, &blur_out);
72
73    let spot_out = RenderTexture::with_format(app, SIZE, SIZE, format);
74    renderer.apply_mask_to_texture(app, &spot_rt, &mask_rt, &spot_out);
75
76    // ── Paper-white backdrop ──────────────────────────────────
77    let backdrop_rt = RenderTexture::with_format(app, SIZE, SIZE, format);
78    let _ = renderer.render_stage(
79        app,
80        backdrop_rt.view(),
81        Color::rgba(0.94, 0.95, 0.97, 1.0),
82        &Stage::new(),
83    );
84    let backdrop_bytes = backdrop_rt.read_pixels(app);
85    let backdrop_tex = Texture::from_rgba(app, SIZE, SIZE, &backdrop_bytes);
86    let mut backdrop = Sprite::from_texture(backdrop_tex).with_anchor(Vec2::splat(0.5));
87    backdrop.container.transform.scale = Vec2::splat(2.0);
88    let _ = stage.add_child(root, backdrop);
89
90    // ── Three rows of masked text (top: fill, mid: blur, bot: spot) ──
91    place_masked(stage, root, &fill_out, app, 0.55);
92    place_masked(stage, root, &blur_out, app, 0.05);
93    place_masked(stage, root, &spot_out, app, -0.45);
94}
95
96fn place_masked(
97    stage: &mut Stage,
98    root: wisp::NodeId,
99    rt: &RenderTexture,
100    app: &Application,
101    y: f32,
102) {
103    let bytes = rt.read_pixels(app);
104    let tex = Texture::from_rgba(app, SIZE, SIZE, &bytes);
105    let mut sprite = Sprite::from_texture(tex).with_anchor(Vec2::splat(0.5));
106    sprite.container.transform.position = Vec2::new(0.0, y);
107    sprite.container.transform.scale = Vec2::new(1.8, -0.4);
108    let _ = stage.add_child(root, sprite);
109}
110
111fn render_gradient_fill(
112    app: &Application,
113    renderer: &Renderer,
114    format: wgpu::TextureFormat,
115) -> RenderTexture {
116    // Horizontal stripes in saturated colors — looks like a poster fill
117    // through the text silhouette.
118    let rt = RenderTexture::with_format(app, SIZE, SIZE, format);
119    let mut s = Stage::new();
120    let bands = [
121        (Color::rgba_u8(255, 100, 80, 255), -1.0),
122        (Color::rgba_u8(255, 200, 60, 255), -0.5),
123        (Color::rgba_u8(80, 220, 120, 255), 0.0),
124        (Color::rgba_u8(60, 170, 240, 255), 0.5),
125    ];
126    let mut g = Graphics::new();
127    for (color, y0) in bands {
128        g.fill(Fill::Solid(color));
129        g.draw_rect(Rect::new(-1.0, y0, 2.0, 0.5));
130    }
131    let _ = s.add_child(s.root(), g);
132    let _ = renderer.render_stage(app, rt.view(), Color::TRANSPARENT, &s);
133    rt
134}
135
136fn render_blur_backdrop(
137    app: &Application,
138    renderer: &Renderer,
139    format: wgpu::TextureFormat,
140) -> RenderTexture {
141    // Render a few overlapping circles then blur them — the masked
142    // text reveals a soft-focus background. Skip on lavapipe (the blur
143    // multi-bind-group filter loses the device); the story still
144    // smoke-tests because we substitute a sharp version.
145    if std::env::var_os("WISP_SKIP_GPU_FILTER_TESTS").is_some() {
146        return render_circles(app, renderer, format);
147    }
148    let sharp = render_circles(app, renderer, format);
149    let blurred = RenderTexture::with_format(app, SIZE, SIZE, format);
150    renderer.apply_filter(app, &BlurFilter { radius: 8.0 }, &sharp, &blurred);
151    blurred
152}
153
154fn render_circles(
155    app: &Application,
156    renderer: &Renderer,
157    format: wgpu::TextureFormat,
158) -> RenderTexture {
159    let rt = RenderTexture::with_format(app, SIZE, SIZE, format);
160    let mut s = Stage::new();
161    let mut g = Graphics::new();
162    g.fill(Fill::Solid(Color::rgba_u8(60, 120, 220, 255)));
163    g.draw_ellipse(Vec2::new(-0.5, 0.3), Vec2::splat(0.45));
164    g.fill(Fill::Solid(Color::rgba_u8(255, 130, 70, 255)));
165    g.draw_ellipse(Vec2::new(0.5, -0.2), Vec2::splat(0.45));
166    g.fill(Fill::Solid(Color::rgba_u8(80, 220, 160, 255)));
167    g.draw_ellipse(Vec2::new(0.0, 0.0), Vec2::splat(0.40));
168    let _ = s.add_child(s.root(), g);
169    let _ = renderer.render_stage(app, rt.view(), Color::rgba(0.05, 0.05, 0.08, 1.0), &s);
170    rt
171}
172
173fn render_spotlight(
174    app: &Application,
175    renderer: &Renderer,
176    format: wgpu::TextureFormat,
177) -> RenderTexture {
178    // Bright warm center, dark falloff at edges. The text-mask reveals
179    // a glowing fragment of the spotlight.
180    let rt = RenderTexture::with_format(app, SIZE, SIZE, format);
181    let mut s = Stage::new();
182    let mut g = Graphics::new();
183    g.fill(Fill::Solid(Color::rgba_u8(255, 235, 200, 255)));
184    g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
185    g.fill(Fill::Solid(Color::rgba_u8(255, 180, 80, 230)));
186    g.stroke(Some(Stroke::new(0.04, Color::rgba_u8(255, 220, 100, 255))));
187    g.draw_ellipse(Vec2::new(0.0, 0.0), Vec2::splat(0.55));
188    g.stroke(None);
189    g.fill(Fill::Solid(Color::rgba_u8(0, 0, 0, 60)));
190    g.draw_rect(Rect::new(-1.0, -1.0, 2.0, 2.0));
191    let _ = s.add_child(s.root(), g);
192    // Light blur to soften — DropShadowFilter with offset zero produces
193    // a softer warm field; for lavapipe-safe path we skip the blur.
194    if std::env::var_os("WISP_SKIP_GPU_FILTER_TESTS").is_some() {
195        let direct = RenderTexture::with_format(app, SIZE, SIZE, format);
196        let _ = renderer.render_stage(app, direct.view(), Color::TRANSPARENT, &s);
197        return direct;
198    }
199    let input = RenderTexture::with_format(app, SIZE, SIZE, format);
200    let _ = renderer.render_stage(app, input.view(), Color::TRANSPARENT, &s);
201    renderer.apply_filter(
202        app,
203        &DropShadowFilter {
204            offset: Vec2::new(0.0, 0.0),
205            blur: 4.0,
206            color: Color::rgba(1.0, 0.8, 0.4, 0.6),
207        },
208        &input,
209        &rt,
210    );
211    rt
212}