GPUI Box GitHub

Components / effects

EffectParticles builder

Decorative, batched particles for one policy-resolved EffectPlan.

A builder is RenderOnce. Construct and mount it in one expression.

use gpui_kit::effects::EffectParticles;

Reviewed in visual-effects.

Construct
new(plan: EffectPlan) -> Self
Options

Chain onto the value.

sample_at(elapsed: Duration) -> Self
Example · visual-effects
fn visual_effects(_window: &mut Window, cx: &mut App) -> AnyElement {
    let theme = cx.theme().clone();
    let radial = radial_gradient_stops(
        point(0.32, 0.36),
        point(0.68, 0.72),
        [
            linear_color_stop(theme.colors.accent_strong, 0.0),
            linear_color_stop(theme.colors.info.opacity(0.86), 0.34),
            linear_color_stop(theme.colors.accent.opacity(0.38), 0.7),
            linear_color_stop(theme.colors.canvas.opacity(0.0), 1.0),
        ],
    )
    .color_space(gpui::ColorSpace::Oklab);
    let conic = conic_gradient_stops(
        -24.0,
        point(0.5, 0.5),
        [
            linear_color_stop(theme.colors.accent_strong, 0.0),
            linear_color_stop(theme.colors.info, 0.22),
            linear_color_stop(theme.colors.success, 0.48),
            linear_color_stop(theme.colors.warning, 0.72),
            linear_color_stop(theme.colors.danger, 1.0),
        ],
    )
    .color_space(gpui::ColorSpace::Oklab);
    let path_fill = conic;
    let stroke_fill = radial;
    let stroke_base = theme.colors.hairline_strong.opacity(0.32);
    let sprite_atlas = composited_sprite_atlas(&theme);
    let additive_tint = theme.colors.accent_strong;
    let additive_tint_alt = theme.colors.info;
    let screen_tint = theme.colors.success;
    let screen_tint_alt = theme.colors.warning;
    let mut effect_planner = EffectPlanner::new(EffectPolicy::new(EffectQuality::Cinematic));
    let success_particles = effect_planner.plan(
        EffectEvent::new(
            "scene-success",
            "visual-effects",
            "success-card",
            VisualCue::Success,
        ),
        1,
        false,
    );
    let reward_particles = effect_planner.plan(
        EffectEvent::new(
            "scene-reward",
            "visual-effects",
            "reward-card",
            VisualCue::Reward,
        ),
        1,
        false,
    );
    let mut static_planner = EffectPlanner::new(EffectPolicy::new(EffectQuality::Cinematic));
    let static_particles = static_planner.plan(
        EffectEvent::new(
            "scene-static",
            "visual-effects-static",
            "static-card",
            VisualCue::Reward,
        ),
        1,
        true,
    );
    let label = |text: &'static str| {
        div()
            .absolute()
            .bottom(px(theme.spacing.sm))
            .left(px(theme.spacing.sm))
            .px_token(&theme, Space::Sm)
            .py_token(&theme, Space::Xs)
            .radius(&theme, Radius::Pill)
            // The caption sits on top of artwork it does not control, so it
            // carries its own opaque surface rather than a scrim: a legend
            // that dims with what it labels stops being a legend.
            .frame(&theme, Surface::Panel, Elevation::Raised)
            .child(crate::foundation::text(&theme, TypeScale::Caption, text))
    };

    stack(&theme)
        .child(crate::foundation::text(
            &theme,
            TypeScale::Subtitle,
            "Renderer-backed gradients",
        ))
        .child(
            row(&theme)
                .gap_token(&theme, Space::Md)
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(radial)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.radial", Role::Image)
                                .text("Elliptical radial gradient")
                                .description("Four ordered Oklab color stops"),
                        )
                        .child(label("Radial · 4 stops")),
                )
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(conic)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.conic", Role::Image)
                                .text("Clockwise conic gradient")
                                .description("Five ordered Oklab color stops"),
                        )
                        .child(label("Conic · 5 stops")),
                )
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(theme.colors.sunken)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.path-gradient", Role::Image)
                                .text("Conic gradient path fill")
                                .description("The same renderer primitive clipped by a GPUI path"),
                        )
                        .child(
                            div().absolute().inset_0().child(
                                canvas(
                                    |_, _, _| {},
                                    move |bounds, _, window, _| {
                                        let inset = px(22.0);
                                        let center = bounds.center();
                                        let mut builder = gpui::PathBuilder::fill();
                                        builder.move_to(point(center.x, bounds.top() + inset));
                                        builder.line_to(point(bounds.right() - inset, center.y));
                                        builder.line_to(point(center.x, bounds.bottom() - inset));
                                        builder.line_to(point(bounds.left() + inset, center.y));
                                        builder.close();
                                        if let Ok(path) = builder.build() {
                                            window.paint_path(path, path_fill);
                                        }
                                    },
                                )
                                .size_full(),
                            ),
                        )
                        .child(label("Path fill · conic")),
                ),
        )
        .child(crate::foundation::text(
            &theme,
            TypeScale::Subtitle,
            "Measured path strokes",
        ))
        .child(
            div()
                .relative()
                .w(px(776.0))
                .h(px(180.0))
                .radius(&theme, Radius::Card)
                .overflow_hidden()
                .bg(theme.colors.sunken)
                .semantic_in(
                    cx,
                    NodeSpec::new("scene.effects.path-strokes", Role::Image)
                        .text("Measured path stroke effects")
                        .description("Trimmed, dash-offset, and combined stroke geometry"),
                )
                .child(
                    div().absolute().inset_0().child(
                        canvas(
                            |_, _, _| {},
                            move |bounds, _, window, _| {
                                let build = |mut builder: gpui::PathBuilder, y: f32| {
                                    let start =
                                        point(bounds.left() + px(128.0), bounds.top() + px(y));
                                    let end =
                                        point(bounds.right() - px(24.0), bounds.top() + px(y));
                                    builder.move_to(start);
                                    builder.cubic_bezier_to(
                                        end,
                                        point(start.x + px(132.0), start.y - px(24.0)),
                                        point(end.x - px(132.0), end.y + px(24.0)),
                                    );
                                    builder.build()
                                };

                                if let Ok(path) = build(gpui::PathBuilder::stroke(px(8.0)), 42.0) {
                                    window.paint_path(path, stroke_base);
                                }
                                if let Ok(path) = build(
                                    gpui::PathBuilder::stroke(px(8.0)).stroke_trim(0.0, 0.68),
                                    42.0,
                                ) {
                                    window.paint_path(path, stroke_fill);
                                }
                                if let Ok(path) = build(
                                    gpui::PathBuilder::stroke(px(7.0))
                                        .dash_array(&[px(18.0), px(11.0)])
                                        .dash_offset(px(9.0)),
                                    92.0,
                                ) {
                                    window.paint_path(path, conic);
                                }
                                if let Ok(path) = build(
                                    gpui::PathBuilder::stroke(px(7.0))
                                        .dash_array(&[px(14.0), px(8.0)])
                                        .dash_offset(px(-6.0))
                                        .stroke_trim(0.16, 0.84),
                                    142.0,
                                ) {
                                    window.paint_path(path, stroke_fill);
                                }
                            },
                        )
                        .size_full(),
                    ),
                )
                .child(div().absolute().left(px(16.0)).top(px(31.0)).child(
                    crate::foundation::text(&theme, TypeScale::Caption, "Trim · 68%"),
                ))
                .child(div().absolute().left(px(16.0)).top(px(81.0)).child(
                    crate::foundation::text(&theme, TypeScale::Caption, "Dash phase · 9 px"),
                ))
                .child(div().absolute().left(px(16.0)).top(px(131.0)).child(
                    crate::foundation::text(&theme, TypeScale::Caption, "Trim + phase"),
                )),
        )
        .child(crate::foundation::text(
            &theme,
            TypeScale::Subtitle,
            "Composited sprite batches",
        ))
        .child(
            div()
                .relative()
                .w(px(776.0))
                .h(px(190.0))
                .radius(&theme, Radius::Card)
                .overflow_hidden()
                .bg(theme.colors.sunken)
                .semantic_in(
                    cx,
                    NodeSpec::new("scene.effects.sprite-batch", Role::Image)
                        .text("Composited sprite batch")
                        .description(
                            "One atlas with explicit source rectangles, transforms, masks, and normal, additive, and screen blending",
                        ),
                )
                .child(
                    div().absolute().inset_0().child(
                        canvas(
                            |_, _, _| {},
                            move |frame, _, window, _| {
                                let source = |column| {
                                    bounds(
                                        point(DevicePixels(column * 48), DevicePixels(0)),
                                        size(DevicePixels(48), DevicePixels(48)),
                                    )
                                };
                                let destination = |x, y, width, height| {
                                    bounds(
                                        point(frame.left() + px(x), frame.top() + px(y)),
                                        size(px(width), px(height)),
                                    )
                                };
                                let sprites = [
                                    SpriteInstance::new(
                                        destination(56.0, 26.0, 104.0, 104.0),
                                        source(0),
                                    )
                                    .corner_radii(Corners::all(px(28.0)))
                                    .transform(
                                        SpriteTransform::identity().rotate(radians(-0.16)),
                                    ),
                                    SpriteInstance::new(
                                        destination(270.0, 34.0, 96.0, 96.0),
                                        source(1),
                                    )
                                    .color_mode(SpriteColorMode::AlphaMask, additive_tint)
                                    .blend_mode(SpriteBlendMode::Additive),
                                    SpriteInstance::new(
                                        destination(314.0, 34.0, 96.0, 96.0),
                                        source(1),
                                    )
                                    .transform(
                                        SpriteTransform::identity().rotate(radians(0.22)),
                                    )
                                    .color_mode(SpriteColorMode::AlphaMask, additive_tint_alt)
                                    .blend_mode(SpriteBlendMode::Additive),
                                    SpriteInstance::new(
                                        destination(536.0, 24.0, 112.0, 112.0),
                                        source(2),
                                    )
                                    .color_mode(SpriteColorMode::AlphaMask, screen_tint)
                                    .blend_mode(SpriteBlendMode::Screen),
                                    SpriteInstance::new(
                                        destination(582.0, 30.0, 100.0, 100.0),
                                        source(2),
                                    )
                                    .transform(
                                        SpriteTransform::identity().rotate(radians(0.42)),
                                    )
                                    .color_mode(SpriteColorMode::AlphaMask, screen_tint_alt)
                                    .blend_mode(SpriteBlendMode::Screen),
                                ];
                                window
                                    .paint_sprite_batch(sprite_atlas.clone(), 0, &sprites)
                                    .expect("the canonical sprite batch is valid");
                            },
                        )
                        .size_full(),
                    ),
                )
                .child(
                    div()
                        .absolute()
                        .left(px(64.0))
                        .bottom(px(12.0))
                        .child(crate::foundation::text(
                            &theme,
                            TypeScale::Caption,
                            "Normal · crop + rotate",
                        )),
                )
                .child(
                    div()
                        .absolute()
                        .left(px(282.0))
                        .bottom(px(12.0))
                        .child(crate::foundation::text(
                            &theme,
                            TypeScale::Caption,
                            "Additive · alpha mask",
                        )),
                )
                .child(
                    div()
                        .absolute()
                        .left(px(550.0))
                        .bottom(px(12.0))
                        .child(crate::foundation::text(
                            &theme,
                            TypeScale::Caption,
                            "Screen · atlas reuse",
                        )),
                ),
        )
        .child(crate::foundation::text(
            &theme,
            TypeScale::Subtitle,
            "Policy-owned particle recipes",
        ))
        .child(
            row(&theme)
                .gap_token(&theme, Space::Md)
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(theme.colors.sunken)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.particles-success", Role::Image)
                                .text("Deterministic success burst")
                                .description(
                                    "A policy-owned recipe sampled at a fixed absolute time",
                                ),
                        )
                        .child(
                            EffectParticles::new(success_particles)
                                .sample_at(Duration::from_millis(420)),
                        )
                        .child(label("Success · 36 instances")),
                )
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(theme.colors.sunken)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.particles-reward", Role::Image)
                                .text("Deterministic reward celebration")
                                .description(
                                    "Two emitters share one atlas upload and one sprite batch",
                                ),
                        )
                        .child(
                            EffectParticles::new(reward_particles)
                                .sample_at(Duration::from_millis(620)),
                        )
                        .child(label("Reward · 2 emitters")),
                )
                .child(
                    div()
                        .relative()
                        .w(px(248.0))
                        .h(px(164.0))
                        .radius(&theme, Radius::Card)
                        .overflow_hidden()
                        .bg(theme.colors.sunken)
                        .semantic_in(
                            cx,
                            NodeSpec::new("scene.effects.particles-static", Role::Image)
                                .text("Reduced motion particle fallback")
                                .description(
                                    "A fixed bounded constellation with no animation frames",
                                ),
                        )
                        .child(EffectParticles::new(static_particles))
                        .child(label("Static · no timeline")),
                ),
        )
        .into_any_element()
}

Source: crates/gpui-kit/src/effects.rs