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