Effects
Per-object glow, blur, shadow, echo, mattes, shaders and particles.
Glow blur shadow
Glow, blur and drop shadow: their parameters, the drawables they apply to, and how they animate.

gaanim examples/effects/glow_blur_shadow.pyCode · 254 lines
"""Glow, blur and drop shadow: their parameters, the drawables they apply to, and how they animate.
The first segments set each effect once per variant: glow radius against
intensity, blur on fills, strokes, text and groups, and shadow offset against
softness, color and alpha. The last two animate them: a glow that grows,
pulses and fades, a blur that focuses and defocuses, and a shadow that lifts a
card, grows from under it and fades away. Each label, or row label joined to
its column header, is the code of its variant.
"""
import os
from gaanim import Easing, Scene, Zones
HOLD = 1.0
MOVE = 1.2
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
success = scene.canvas.color("success")
# Shadows are dark, so they show on a light surface: cards cast them on tiles.
shade = scene.canvas.color("background")
surface = scene.canvas.color("chart")
def faded(color, alpha):
"""``color`` at ``alpha`` (0 to 1) opacity; a shadow color's alpha scales the shadow."""
return (color.r, color.g, color.b, round(alpha * 255))
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def table(rows, columns=None, build=None, corner=None, labels="12fr"):
"""Draw one variant per cell, with each row's label to the left of its cells.
``rows`` are (label, value) pairs. Without ``columns`` each row has one
cell and its value is ``build(zone)``. Otherwise ``columns`` are (label,
value) pairs labelled in a header row, after ``corner``, and each cell is
``build(zone, row_value, column_value)``. Builds may return an animation;
they play together, between two holds.
"""
header = columns is not None
cells = columns or [(None, None)]
tracks = [labels] + ["9fr"] * len(cells)
grid = scene.layout.zones(
Zones.grid(rows=len(rows) + header, columns=tracks, column_gap="48px", row_gap="24px", padding="page_padding")
)
if corner:
label(corner).place(grid[0], anchor="right")
for index, (code, _) in enumerate(cells if header else [], start=1):
label(code).place(grid[index])
plays = []
for row_index, (code, row) in enumerate(rows, start=header):
first = row_index * len(tracks)
label(code).place(grid[first], anchor="right")
for index, (_, column) in enumerate(cells, start=1):
zone = grid[first + index]
plays.append(row(zone) if columns is None else build(zone, row, column))
plays = [play for play in plays if hasattr(play, "duration")]
scene.wait(HOLD if not plays else 0.4)
if plays:
scene.play(plays, duration=MOVE)
scene.wait(HOLD)
def gallery(variants, rows=2, columns=2):
"""Lay out (label, build) pairs in a grid, each label under the variant ``build(zone)`` draws."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
for zone, (code, build) in zip(grid, variants):
art, caption = zone.split(Zones.rows(["1fr", 0.45]))
label(code).place(caption)
build(art)
scene.wait(HOLD)
def disc(zone, size=0.28):
"""A disc in the middle of ``zone``, ``size`` of its smaller side in radius, filled with the accent."""
return scene.geometry.circle(min(zone.width, zone.height) * size).move_to(*zone.center)
def pill(zone, width=0.7, height=0.5):
"""A pill ``width`` and ``height`` fractions of ``zone``, in its middle, filled with the accent."""
tall = zone.height * height
return scene.geometry.rounded_rect(zone.width * width, tall, tall / 2).move_to(*zone.center)
def outline(zone):
"""A pill drawn as a stroke with no fill."""
return pill(zone).no_fill().stroke(accent, 0.1)
def word(zone, text):
"""A large word in the middle of ``zone``."""
return scene.text(text, size=min(zone.height * 0.7, zone.width / (0.6 * len(text)))).place(zone)
def coins(zone):
"""Three overlapping discs in one group, as tall as ``zone`` allows."""
(x, y), radius = zone.center, min(zone.height * 0.32, zone.width / 9)
return scene.geometry.group([scene.geometry.circle(radius).move_to(x + dx * radius, y) for dx in (-1.3, 0.0, 1.3)])
def tile(zone):
"""A light tile filling ``zone``, where shadows show."""
return scene.geometry.rounded_rect(zone.width, zone.height, 0.12).fill(surface).move_to(*zone.center)
def card(zone):
"""A light card on a tile filling ``zone``."""
tile(zone)
return pill(zone, 0.6, 0.5).fill(foreground)
# glow(color, radius=0.16, intensity=1.0) adds a halo of ``color`` around the
# fill and stroke: radius is how far it reaches, in scene units, and
# intensity how strong it is.
glow_start = scene.cursor
scene.segment("glow")
table(
[("radius=0.16", 0.16), ("radius=0.5", 0.5), ("radius=1.0", 1.0)],
[("intensity=1.0", 1.0), ("intensity=2.0", 2.0), ("intensity=4.0", 4.0)],
lambda zone, radius, intensity: disc(zone, 0.3).glow(accent, radius=radius, intensity=intensity),
corner="disc.glow(accent, ...)",
labels="16fr",
)
# Any drawable glows: a stroke, a text, a group, in any color.
glow_kinds_start = scene.cursor
scene.segment("glow_drawables")
gallery([
("outline.glow(accent, 0.4, 2.0)", lambda zone: outline(zone).glow(accent, 0.4, 2.0)),
("word.glow(accent, 0.4, 2.0)", lambda zone: word(zone, "Glow").glow(accent, 0.4, 2.0)),
("coins.glow(success, 0.4, 2.0)", lambda zone: coins(zone).glow(success, 0.4, 2.0)),
("word.glow(foreground, 0.3, 3.0)", lambda zone: word(zone, "Neon").fill(accent).glow(foreground, 0.3, 3.0)),
])
# blur(sigma=0.04) is a Gaussian blur of ``sigma`` scene units.
blur_start = scene.cursor
scene.segment("blur")
table(
[("pill", pill), ("outline", outline), ("word", lambda zone: word(zone, "Blur")), ("coins", coins)],
[(".blur()", None), (".blur(0.1)", 0.1), (".blur(0.3)", 0.3)],
lambda zone, make, sigma: make(zone).blur() if sigma is None else make(zone).blur(sigma),
labels="4fr",
)
# shadow(color, x=0.08, y=-0.08, blur=0.06) draws ``color`` offset by (x, y)
# and blurred by ``blur`` scene units, beneath the drawable.
shadow_start = scene.cursor
scene.segment("shadow")
table(
[("x=0.08, y=-0.08", (0.08, -0.08)), ("x=0.3, y=-0.2", (0.3, -0.2)), ("x=0, y=-0.3", (0, -0.3))],
[("blur=0", 0.0), ("blur=0.06", 0.06), ("blur=0.3", 0.3)],
lambda zone, offset, blur: card(zone).shadow(shade, *offset, blur=blur),
corner="card.shadow(shade, ...)",
labels="16fr",
)
# The color's alpha scales the shadow. A text or a group casts one shadow
# from its silhouette, beneath all its members, so overlaps do not darken.
# no_effects() removes glow, blur and shadow, and keeps fill and stroke.
shadow_kinds_start = scene.cursor
scene.segment("shadow_drawables")
table(
[
("card.shadow(faded(shade, 0.4), 0.2, -0.2)", lambda zone: card(zone).shadow(faded(shade, 0.4), 0.2, -0.2)),
("card.shadow(accent, 0.2, -0.2)", lambda zone: card(zone).shadow(accent, 0.2, -0.2)),
("word.shadow(shade, 0.08, -0.08)", lambda zone: (tile(zone), word(zone, "Shadow").shadow(shade, 0.08, -0.08))),
("coins.shadow(shade, 0.15, -0.15)", lambda zone: (tile(zone), coins(zone).shadow(shade, 0.15, -0.15))),
("card.shadow(shade).no_effects()", lambda zone: card(zone).shadow(shade).no_effects()),
],
labels="15fr",
)
# animate.glow and animate.blur go from the current effect to a new one. A
# drawable without glow grows it from zero; glow(None) fades it out.
def glowing(build, start=None):
"""A row whose pill starts with a glow of radius ``start`` (or none) and plays ``build(pill)``."""
def row(zone):
orb = pill(zone, 0.7, 0.45)
if start:
orb.glow(accent, radius=start, intensity=2.0)
return build(orb)
return row
def blurred(sigma, target):
"""A row whose pill starts blurred by ``sigma`` (``0`` is sharp) and animates to ``target``."""
def row(zone):
return pill(zone, 0.7, 0.45).blur(sigma).animate.blur(target)
return row
animate_start = scene.cursor
scene.segment("animate")
table(
[
("orb.animate.glow(accent, 0.5, 2.0)", glowing(lambda orb: orb.animate.glow(accent, 0.5, 2.0))),
(
"orb.animate.glow(accent, 0.5, 2.0)\n .repeat(3, yoyo=True)",
glowing(lambda orb: orb.animate.glow(accent, 0.5, 2.0).duration(MOVE / 6).repeat(3, yoyo=True)),
),
("orb.glow(accent, 0.5, 2.0)\norb.animate.glow(None)", glowing(lambda orb: orb.animate.glow(None), start=0.5)),
("orb.blur(0.3)\norb.animate.blur(0.0)", blurred(0.3, 0.0)),
("orb.animate.blur(0.3)", blurred(0.0, 0.3)),
],
labels="15fr",
)
# animate.shadow grows a new shadow from under the drawable, moves an existing
# one and, with None, fades it out. It combines with other targets in one Anim.
def shadowed(build, start=True):
"""A row whose card starts with the default shadow (or none) and plays ``build(card)``."""
def row(zone):
lifted = card(zone)
if start:
lifted.shadow(shade)
return build(lifted)
return row
shadow_motion_start = scene.cursor
scene.segment("animate_shadow")
table(
[
(
"card.animate.shadow(shade, 0, -0.25, 0.3)\n .scale_to(1.08)",
shadowed(lambda lifted: lifted.animate.shadow(shade, 0, -0.25, 0.3).scale_to(1.08).easing(Easing.SMOOTH)),
),
("card.animate.shadow(shade, 0.3, -0.2)", shadowed(lambda lifted: lifted.animate.shadow(shade, 0.3, -0.2), start=False)),
("card.animate.shadow(None)", shadowed(lambda lifted: lifted.animate.shadow(None))),
],
labels="15fr",
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The static segments, then the animated ones before, during and after their moves.
scene.snapshots(
snapshots,
[start + 0.5 for start in (glow_start, glow_kinds_start, blur_start, shadow_start, shadow_kinds_start)]
+ [start + t for start in (animate_start, shadow_motion_start) for t in (0.2, 0.4 + MOVE / 2, 0.4 + MOVE + 0.5)],
)
else:
scene.render()Echo
Trails of delayed copies with their count, delay and decay, what they replay, held trails and onion skins.

gaanim examples/effects/echo.pyCode · 155 lines
"""Echo: trails of delayed copies, their count, delay and decay, what they replay, and onion skins.
``echo`` draws copies of a drawable as it was a moment earlier, beneath it.
The first segment varies the count, delay and decay on balls crossing the same
track. The second shows that copies replay every animation of the drawable:
turns, scales, color changes, writing and changes of shape. The third stops
and starts a puck to compare copies that catch up, copies that hold their
poses, and a trail limited to a time window. Each label is the code of its
variant.
"""
import math
import os
from gaanim import Easing, Scene, Zones
MOVE = 1.2
HOLD = 0.8
scene = Scene()
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def rows(count, labels="15fr"):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[labels, "9fr"], column_gap="48px", row_gap="24px", padding="page_padding")
)
def track(zone, ticks=()):
"""Draw a track across ``zone`` with ticks at the fractions ``ticks``; return its ends and height."""
(start, y), (end, _) = zone.point("left"), zone.point("right")
scene.geometry.line(start, y, end, y).stroke(rule, 0.02)
for fraction in ticks:
scene.geometry.rect(0.02, 0.4).fill(rule).move_to(start + (end - start) * fraction, y)
return start, end, y
# echo(count=5, delay=0.04, decay=0.6): copy k shows the drawable k * delay
# seconds ago with decay ** k of its opacity. When the drawable stops, the
# copies catch up with it.
options_start = scene.cursor
scene.segment("options")
VARIANTS = [
("ball.echo()", {}),
("ball.echo(12)", {"count": 12}),
("ball.echo(5, delay=0.1)", {"count": 5, "delay": 0.1}),
("ball.echo(8, decay=0.9)", {"count": 8, "decay": 0.9}),
("ball.echo(3, delay=0.15, decay=0.4)", {"count": 3, "delay": 0.15, "decay": 0.4}),
("ball.echo(6, delay=0.08, decay=1.0)", {"count": 6, "delay": 0.08, "decay": 1.0}),
]
grid = rows(len(VARIANTS), labels="12fr")
moves = []
for index, (code, options) in enumerate(VARIANTS):
label(code).place(grid[2 * index], anchor="right")
start, end, y = track(grid[2 * index + 1])
ball = scene.geometry.circle(0.4).move_to(start + 0.4, y).echo(**options)
moves.append(ball.animate.move_to(end - 0.4, y).easing(Easing.SMOOTH))
scene.wait(0.3)
scene.play(moves, duration=MOVE)
scene.wait(HOLD)
# Copies replay the drawable's own animations with their delay: transforms,
# colors, writing and new outlines, at any seek and in every export.
replays_start = scene.cursor
scene.segment("replays")
grid = rows(5)
def stage(index, code):
"""Label row ``index`` with ``code`` and return its stage."""
label(code).place(grid[2 * index], anchor="right")
return grid[2 * index + 1]
zone = stage(0, "bar.echo(6, delay=0.05)\nbar.animate.rotate_by(math.pi)")
bar = scene.geometry.rounded_rect(zone.height * 1.6, 0.2, 0.1).move_to(*zone.center).echo(6, delay=0.05)
zone = stage(1, "ring.echo(5, delay=0.06)\nring.animate.scale_to(2.0)")
ring = scene.geometry.circle(zone.height * 0.25).no_fill().stroke(scene.canvas.color("accent"), 0.06)
ring.move_to(*zone.center).echo(5, delay=0.06)
zone = stage(2, "dot.echo(6, delay=0.05)\ndot.animate.move_to(x, y).fill(success)")
(left, y), (right, _) = zone.point("left"), zone.point("right")
dot = scene.geometry.circle(0.4).move_to(left + 0.4, y).echo(6, delay=0.05)
zone = stage(3, "word.echo(3, delay=0.1)\nword.animate.write()")
word = scene.text("Echo", size=zone.height * 0.8).place(zone).echo(3, delay=0.1)
zone = stage(4, "shape.echo(3, delay=0.3, decay=1.0)\nshape.animate.points(octagon)")
# A square and an octagon with the same eight vertices, so points() can tween them.
size = zone.height * 0.4
(x0, y0), (x1, _) = zone.point("left"), zone.point("right")
ANGLES = [math.pi / 4 * k for k in range(8)]
square = [(size * math.cos(a) / max(abs(math.cos(a)), abs(math.sin(a))),
size * math.sin(a) / max(abs(math.cos(a)), abs(math.sin(a)))) for a in ANGLES]
octagon = [(1.2 * size * math.cos(a), 1.2 * size * math.sin(a)) for a in ANGLES]
shape = scene.geometry.polygon(square).move_to(x0 + size + 0.2, y0).echo(3, delay=0.3, decay=1.0)
scene.wait(0.3)
scene.play(
[
bar.animate.rotate_by(math.pi),
ring.animate.scale_to(2.0),
dot.animate.move_to(right - 0.4, y).fill(success),
word.animate.write(),
shape.animate.points(octagon).move_to(x1 - size - 0.2, y0).fill(success),
],
duration=MOVE,
)
scene.wait(HOLD)
# A puck hops three times with rests in between. Plain copies catch up at each
# rest; with hold=True they freeze where they were, an onion skin of its last
# poses, and move on with it. start and end (scene seconds) limit what the
# copies record, so only the middle hop leaves a trail.
HOP = 0.6
REST = 0.6
hold_start = scene.cursor
scene.segment("hold")
grid = rows(3, labels="12fr")
hops = []
window = (hold_start + 0.3 + HOP + REST, hold_start + 0.3 + 2 * HOP + REST)
CODES = [
("puck.echo(4, delay=0.1)", {}),
("puck.echo(4, delay=0.1, hold=True)", {"hold": True}),
("puck.echo(8, delay=0.04,\n start=t1, end=t2)", {"start": window[0], "end": window[1]}),
]
for index, (code, options) in enumerate(CODES):
label(code).place(grid[2 * index], anchor="right")
start, end, y = track(grid[2 * index + 1].inset((0, 0.5)), ticks=(0, 1 / 3, 2 / 3, 1))
count, delay = (8, 0.04) if "start" in options else (4, 0.1)
puck = scene.geometry.circle(0.5).move_to(start, y).echo(count, delay=delay, **options)
hops.append((puck, start, end, y))
scene.wait(0.3)
for step in (1, 2, 3):
scene.play(
[puck.animate.move_to(start + (end - start) * step / 3, y).easing(Easing.SMOOTH) for puck, start, end, y in hops],
duration=HOP,
)
scene.wait(REST)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Options and replays mid-move and settled, then the hops at each rest and in the middle one.
scene.snapshots(
snapshots,
[options_start + t for t in (0.3 + MOVE * 0.35, 0.3 + MOVE * 0.7, 0.3 + MOVE + 0.5)]
+ [replays_start + t for t in (0.3 + MOVE * 0.4, 0.3 + MOVE * 0.8, 0.3 + MOVE + 0.5)]
+ [hold_start + 0.3 + t for t in (HOP + 0.4, HOP + REST + HOP / 2, 2 * HOP + REST + 0.4, 3 * HOP + 2 * REST + 0.4)],
)
else:
scene.render()Mattes
Track mattes in alpha and luma modes, inverted, animated and removed, and metaballs whose drops melt together.

gaanim examples/effects/mattes.pyCode · 201 lines
"""Track mattes and metaballs: showing a drawable through another one, and drops that melt together.
``matte`` shows a drawable only where a source is opaque, or bright, or the
opposite, and follows the source as it moves, scales, fades or is written.
The first segment shows the four modes, the second a matte that follows its
source's animations, and the third ``scene.geometry.metaballs`` with its
smoothness and threshold. Each label is the code of its variant.
"""
import math
import os
from gaanim import Brush, Scene, Zones
MOVE = 1.6
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
background = scene.canvas.color("background")
rule = scene.canvas.color("rule")
COLORS = [accent, scene.canvas.color("success"), scene.canvas.color("danger"), scene.canvas.color("chart")]
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def gallery(rows=2, columns=2, caption=0.45):
"""The zones of a grid of panels, each split into its art and the caption under it."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
return [zone.split(Zones.rows(["1fr", caption])) for zone in grid]
def stripes(zone):
"""Vertical stripes of theme colors filling ``zone``, as one group."""
count = 16
width = zone.width / count
x, y = zone.center
return scene.geometry.group([
scene.geometry.rect(width, zone.height).fill(COLORS[index % len(COLORS)]).move_to(x + width * (index - (count - 1) / 2), y)
for index in range(count)
])
def frame(zone):
"""A thin outline of ``zone``, to show where the matted stripes could be."""
return scene.geometry.rect(zone.width, zone.height).no_fill().stroke(rule, 0.02).move_to(*zone.center)
def word(zone, text="MATTE"):
"""A large bold word in the middle of ``zone``, used as a matte source."""
return scene.text(text, size=min(zone.height * 0.8, zone.width / (1.25 * len(text))), weight=800).place(zone)
def ramp(zone):
"""A rectangle that fades from light to dark, its dark end covering ``zone``."""
shape = scene.geometry.rect(zone.width * 2.4, zone.height)
shape.fill(Brush.linear([foreground, foreground, background, background], start=(-zone.width * 1.2, 0), end=(zone.width * 1.2, 0)))
x, y = zone.center
return shape.move_to(x - zone.width * 0.7, y)
# "alpha" shows the drawable where the source is opaque and "luma" where it is
# bright; the inverted modes show the opposite. The source is no longer drawn
# on its own, and the matte follows it: here the words slide and the ramps
# sweep across, revealing the stripes softly.
modes_start = scene.cursor
scene.segment("modes")
moves = []
grid = scene.layout.zones(
Zones.grid(rows=4, columns=["15fr", "9fr"], column_gap="48px", row_gap="24px", padding="page_padding")
)
for index, (code, mode) in enumerate([
("stripes.matte(word)", "alpha"),
('stripes.matte(word, "alpha_inverted")', "alpha_inverted"),
('stripes.matte(ramp, "luma")', "luma"),
('stripes.matte(ramp, "luma_inverted")', "luma_inverted"),
]):
label(code).place(grid[2 * index], anchor="right")
art = grid[2 * index + 1]
frame(art)
if mode.startswith("alpha"):
source = word(art).shift_by(-art.width * 0.1, 0)
moves.append(source.animate.shift_by(art.width * 0.2, 0))
else:
source = ramp(art)
moves.append(source.animate.shift_by(art.width * 1.4, 0))
stripes(art).matte(source, mode)
scene.wait(HOLD)
scene.play(moves, duration=MOVE)
scene.wait(HOLD)
# A matte follows everything that happens to its source: writing, turning,
# scaling and fading. A blurred source softens the matte's edge, and
# matte(None) removes it.
def written(art):
"""Stripes matted by a word that is written."""
source = word(art)
stripes(art).matte(source)
return source.animate.write()
def turned(art):
"""Stripes matted by a star that turns."""
source = scene.geometry.star(5, art.height * 0.48, art.height * 0.2).move_to(*art.center)
stripes(art).matte(source)
return source.animate.rotate_by(math.pi / 5)
def iris(art):
"""Stripes matted by a dot that grows twentyfold, an iris reveal."""
source = scene.geometry.circle(art.width * 0.025).move_to(*art.center)
stripes(art).matte(source)
return source.animate.scale_to(20)
def soft(art):
"""Stripes matted by a blurred word."""
stripes(art).matte(word(art).blur(0.12))
def faded(art):
"""Stripes matted by a word that fades to a fifth of its opacity."""
source = word(art)
stripes(art).matte(source)
return source.animate.opacity(0.2)
def removed(art):
"""Stripes whose matte was removed again."""
stripes(art).matte(word(art)).matte(None)
follows_start = scene.cursor
scene.segment("follows")
plays = []
for (art, caption), (code, build) in zip(gallery(rows=3, caption=0.9), [
("stripes.matte(word)\nword.animate.write()", written),
("stripes.matte(star)\nstar.animate.rotate_by(math.pi / 5)", turned),
("stripes.matte(dot)\ndot.animate.scale_to(20)", iris),
("stripes.matte(word.blur(0.12))", soft),
("stripes.matte(word)\nword.animate.opacity(0.2)", faded),
("stripes.matte(word).matte(None)", removed),
]):
label(code).place(caption)
frame(art)
play = build(art)
if play is not None:
plays.append(play)
scene.wait(HOLD)
scene.play(plays, duration=MOVE)
scene.wait(HOLD)
# metaballs melts drawables into one outline, counting each as a circle that
# follows its animations; the drawables themselves stop being drawn. Drops
# closer than smoothness join with a neck, and threshold divides every radius.
# The result is a filled path to style like any other.
def drops(art):
"""Three drops in ``art``: two that approach each other and one that waits above them."""
(x, y), radius = art.center, art.height * 0.28
left = scene.geometry.circle(radius).move_to(x - art.width * 0.32, y - radius * 0.4)
right = scene.geometry.circle(radius * 0.8).move_to(x + art.width * 0.32, y - radius * 0.4)
top = scene.geometry.circle(radius * 0.6).move_to(x, y + radius * 1.2)
moves.extend([left.animate.shift_by(art.width * 0.26, 0), right.animate.shift_by(-art.width * 0.26, 0)])
return [left, right, top]
metaballs_start = scene.cursor
scene.segment("metaballs")
moves = []
for (art, caption), (code, build) in zip(gallery(rows=3, caption=0.9), [
("metaballs(drops)", lambda balls: scene.geometry.metaballs(balls)),
("metaballs(drops, smoothness=0.05)", lambda balls: scene.geometry.metaballs(balls, smoothness=0.05)),
("metaballs(drops, smoothness=1.0)", lambda balls: scene.geometry.metaballs(balls, smoothness=1.0)),
("metaballs(drops, threshold=1.4)", lambda balls: scene.geometry.metaballs(balls, threshold=1.4)),
("metaballs(drops, threshold=0.7)", lambda balls: scene.geometry.metaballs(balls, threshold=0.7)),
(
"metaballs(drops, smoothness=0.8)\n.no_fill().stroke(accent, 0.06)",
lambda balls: scene.geometry.metaballs(balls, smoothness=0.8).no_fill().stroke(accent, 0.06),
),
]):
label(code).place(caption)
build(drops(art))
scene.wait(HOLD)
scene.play(moves, duration=MOVE)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment before, during and after its sources or drops move.
scene.snapshots(
snapshots,
[start + t for start in (modes_start, follows_start, metaballs_start) for t in (0.3, HOLD + MOVE / 2, HOLD + MOVE + 0.3)],
)
else:
scene.render()Glass
Frosted and liquid glass over what is drawn behind, every refraction, bevel, twist and dispersion setting, and backdrop blur.

gaanim examples/effects/glass.pyCode · 217 lines
"""Glass: frosted and liquid glass over what is drawn behind, every parameter, and backdrop blur.
A drawable turned into glass shows what lies behind it blurred, saturated and
bent at its rim, then draws itself on top, so a faint fill tints the glass.
In every panel a glass shape slides over stripes and a word. The first two
segments vary the parameters of ``glass``, frosted and then clear to show
its rim, the third shows ``liquid_glass`` and its options, also on melting
metaballs, and the last plain ``backdrop_blur`` and ``no_glass``. Each label
is the code of its variant.
"""
import os
from gaanim import Scene, Zones
MOVE = 1.6
HOLD = 0.6
scene = Scene()
foreground = scene.canvas.color("foreground")
background = scene.canvas.color("background")
COLORS = [scene.canvas.color(role) for role in ("accent", "success", "danger", "chart")]
def faded(color, alpha):
"""``color`` at ``alpha`` (0 to 1) opacity, for a fill that only tints the glass."""
return (color.r, color.g, color.b, round(alpha * 255))
TINT = faded(foreground, 0.08)
FRAME = faded(foreground, 0.4)
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def gallery(rows, columns, caption=0.45):
"""The zones of a grid of panels, each split into its art and the caption under it."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="60px", padding="page_padding"))
return [zone.split(Zones.rows(["1fr", caption])) for zone in grid]
def backdrop(zone):
"""Thin stripes of theme colors across ``zone`` and a dark word over them."""
count = 18
width = zone.width / count
x, y = zone.center
for index in range(count):
scene.geometry.rect(width, zone.height * 0.8).fill(COLORS[index % len(COLORS)]).move_to(x + width * (index - (count - 1) / 2), y)
scene.text("GLASS", size=zone.height * 0.55, weight=800).fill(background).place(zone)
def card(zone):
"""A rounded card left of the middle of ``zone``, tinted faintly, and its slide to the right."""
width, height = zone.width * 0.42, zone.height * 0.9
(left, y), (right, _) = zone.point("left"), zone.point("right")
shape = scene.geometry.rounded_rect(width, height, height * 0.25).fill(TINT).stroke(FRAME, 0.03)
shape.move_to(left + width * 0.6, y)
return shape, shape.animate.move_to(right - width * 0.6, y)
def panels(variants, rows, columns, caption=0.45):
"""Draw each (label, build) variant on a backdrop, where ``build(zone)`` returns its slide."""
slides = []
for (art, text_zone), (code, build) in zip(gallery(rows, columns, caption), variants):
label(code).place(text_zone)
backdrop(art)
slide = build(art)
slides.extend(slide if isinstance(slide, list) else [slide])
scene.wait(HOLD)
scene.play(slides, duration=MOVE)
scene.wait(HOLD)
def glassy(*args, **options):
"""A card turned into glass with ``glass(*args, **options)``."""
def build(zone):
shape, slide = card(zone)
shape.glass(*args, **options)
return slide
return build
# glass(blur=0.25, saturation=1.4, ..., transparency=1): blur and saturation
# act on what is behind the glass, and transparency below 1 turns it milky.
frosted_start = scene.cursor
scene.segment("frosted")
panels(
[
("glass()", glassy()),
("glass(blur=0.6)", glassy(blur=0.6)),
("glass(saturation=0.0)", glassy(saturation=0.0)),
("glass(transparency=0.4)", glassy(transparency=0.4)),
],
rows=2,
columns=2,
)
# Clear glass (blur 0) shows the rim: bevel scene units wide, it bends what
# is behind by up to refraction; edge is a thin light on the outline.
rim_start = scene.cursor
scene.segment("rim")
panels(
[
("glass(0)", glassy(0)),
("glass(0, refraction=0.5)", glassy(0, refraction=0.5)),
("glass(0, refraction=0.5,\n bevel=0.6)", glassy(0, refraction=0.5, bevel=0.6)),
("glass(0, edge=1.0)", glassy(0, edge=1.0)),
],
rows=2,
columns=2,
caption=0.9,
)
# Across the rim, dispersion splits what is bent into its colors and twist
# slides it along the outline, clockwise when positive.
fringes_start = scene.cursor
scene.segment("fringes")
panels(
[
("glass(0, refraction=0.5,\n bevel=0.4)", glassy(0, refraction=0.5, bevel=0.4)),
("glass(0, refraction=0.5,\n bevel=0.4, dispersion=1.0)", glassy(0, refraction=0.5, bevel=0.4, dispersion=1.0)),
("glass(0, refraction=0.5,\n bevel=0.4, twist=2.0)", glassy(0, refraction=0.5, bevel=0.4, twist=2.0)),
("glass(0, refraction=0.5,\n bevel=0.4, twist=-2.0)", glassy(0, refraction=0.5, bevel=0.4, twist=-2.0)),
],
rows=2,
columns=2,
caption=0.9,
)
# liquid_glass is glass with Liquid Glass defaults: almost no blur, a wide
# rim that bends strongly and slides along the outline, faint color fringes
# and a bright outline. On metaballs the drops keep their lens as they melt.
def liquid(**options):
"""A disc of liquid glass with ``options`` that slides across."""
def build(zone):
radius = zone.height * 0.45
(left, y), (right, _) = zone.point("left"), zone.point("right")
lens = scene.geometry.circle(radius).fill(TINT).move_to(left + radius * 1.5, y).liquid_glass(**options)
return lens.animate.move_to(right - radius * 1.5, y)
return build
def melting(zone):
"""Two drops of liquid glass that meet in the middle of ``zone``."""
radius = zone.height * 0.4
(left, y), (right, _) = zone.point("left"), zone.point("right")
drops = [scene.geometry.circle(radius).move_to(left + radius * 1.5, y), scene.geometry.circle(radius * 0.8).move_to(right - radius * 1.5, y)]
scene.geometry.metaballs(drops, smoothness=0.9).fill(TINT).liquid_glass()
x, _ = zone.center
return [drops[0].animate.move_to(x - radius * 0.6, y), drops[1].animate.move_to(x + radius * 0.6, y)]
liquid_start = scene.cursor
scene.segment("liquid_glass")
panels(
[
("liquid_glass()", liquid()),
("liquid_glass(twist=-0.6)", liquid(twist=-0.6)),
("liquid_glass(dispersion=0.6)", liquid(dispersion=0.6)),
("metaballs(drops).liquid_glass()", melting),
],
rows=2,
columns=2,
)
# backdrop_blur(radius=0.25, saturation=1.0) only blurs what is behind, like
# glass without refraction or edge; no_glass() removes glass or backdrop blur.
def blurring(*args, **options):
"""A card that blurs its backdrop with ``backdrop_blur(*args, **options)``."""
def build(zone):
shape, slide = card(zone)
shape.backdrop_blur(*args, **options)
return slide
return build
def plain(zone):
"""A card whose glass is removed again, so only its faint tint shows."""
shape, slide = card(zone)
shape.glass().no_glass()
return slide
backdrop_start = scene.cursor
scene.segment("backdrop_blur")
panels(
[
("backdrop_blur()", blurring()),
("backdrop_blur(0.6)", blurring(0.6)),
("backdrop_blur(0.3, saturation=0)", blurring(0.3, saturation=0)),
("glass().no_glass()", plain),
],
rows=2,
columns=2,
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment before, during and after its glass slides.
scene.snapshots(
snapshots,
[
start + t
for start in (frosted_start, rim_start, fringes_start, liquid_start, backdrop_start)
for t in (0.3, HOLD + MOVE / 2, HOLD + MOVE + 0.3)
],
)
else:
scene.render()Object shaders
WGSL passes on single drawables with uniforms, every PostProcess preset as an effect, chains and margins.

gaanim examples/effects/object_shaders.pyCode · 200 lines
"""Shader effects on single drawables: WGSL passes with uniforms, every preset, chains and margins.
``shader_effect`` draws a drawable into a texture of its own, runs WGSL passes
over it and draws the result back in its place, while everything around it
stays as it is. The first segment writes passes whose uniforms are a number,
an animated Parameter and a value over time; the next two apply every
``PostProcess`` preset to the same badge; the last chains passes in both
orders, widens the texture with ``margin`` and removes an effect. Each label
is the code of its variant.
"""
import math
import os
from gaanim import Brush, PostProcess, Scene, Zones, computed
MOVE = 1.6
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
# Inside a pass, gaanim_scene(uv) reads the drawable's own texture, uv spans
# its box plus the margin, and time is the scene time in seconds.
RIPPLE = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let wave = gaanim_uniforms.amount * sin(uv.x * 14.0 + time * 5.0);
return gaanim_scene(uv + vec2<f32>(0.0, wave));
}
"""
# Red and blue drift apart from green by up to 4 % of the texture.
SPLIT = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let d = vec2<f32>(gaanim_uniforms.amount * 0.04, 0.0);
let r = gaanim_scene(uv + d);
let g = gaanim_scene(uv);
let b = gaanim_scene(uv - d);
return vec4<f32>(r.r, g.g, b.b, max(max(r.a, g.a), b.a));
}
"""
# A soft white halo around what the texture holds, as strong as glow.
HALO = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let color = gaanim_scene(uv);
var halo = 0.0;
let step = 12.0 / resolution;
for (var i = -3; i <= 3; i++) {
for (var j = -3; j <= 3; j++) {
halo += gaanim_scene(uv + vec2<f32>(f32(i), f32(j)) * step).a;
}
}
halo = min(1.0, halo / 49.0 * gaanim_uniforms.glow * 2.5);
let alpha = max(color.a, halo);
return vec4<f32>(mix(vec3<f32>(1.0), color.rgb, color.a / max(alpha, 1e-4)), alpha);
}
"""
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def gallery(rows, columns, caption=0.45):
"""The zones of a grid of panels, each split into its art and the caption under it."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
return [zone.split(Zones.rows(["1fr", caption])) for zone in grid]
def badge(zone):
"""A gradient card with a star on it, as one group, filling most of ``zone``."""
width, height = min(zone.width * 0.6, zone.height * 2.2), zone.height * 0.8
x, y = zone.center
card = scene.geometry.rounded_rect(width, height, height * 0.2).fill(
Brush.linear([danger, accent, success], start=(-width / 2, 0), end=(width / 2, 0))
)
star = scene.geometry.star(5, height * 0.38, height * 0.16).fill(foreground)
return scene.geometry.group([card.move_to(x, y), star.move_to(x, y)])
def word(zone, text):
"""A large word in the middle of ``zone``."""
return scene.text(text, size=min(zone.height * 0.7, zone.width / (0.65 * len(text))), weight=800).place(zone)
# Uniforms are fields of gaanim_uniforms: a number stays fixed, a Parameter
# can be animated, and a Computed (here of scene.time) is evaluated every frame.
uniforms_start = scene.cursor
scene.segment("uniforms")
shift = scene.viz.parameter(0.0)
pulse = computed(lambda t: 0.5 + 0.5 * math.sin(4 * t), inputs=[scene.time])
grid = scene.layout.zones(
Zones.grid(rows=3, columns=["15fr", "9fr"], column_gap="48px", row_gap="24px", padding="page_padding")
)
label('ripple = PostProcess.shader(RIPPLE,\n uniforms={"amount": 0.06})\nword.shader_effect(ripple, margin=0.4)').place(grid[0], anchor="right")
ripple = PostProcess.shader(RIPPLE, uniforms={"amount": 0.06})
word(grid[1], "Ripple").shader_effect(ripple, margin=0.4)
label('split = PostProcess.shader(SPLIT,\n uniforms={"amount": shift})\nbadge.shader_effect(split, margin=0.3)').place(grid[2], anchor="right")
split = PostProcess.shader(SPLIT, uniforms={"amount": shift})
badge(grid[3]).shader_effect(split, margin=0.3)
label('halo = PostProcess.shader(HALO,\n uniforms={"glow": pulse})\nstar.shader_effect(halo, margin=0.5)').place(grid[4], anchor="right")
halo = PostProcess.shader(HALO, uniforms={"glow": pulse})
art = grid[5]
scene.geometry.star(5, art.height * 0.4, art.height * 0.17).move_to(*art.center).shader_effect(halo, margin=0.5)
scene.wait(HOLD)
# The split grows with the Parameter and closes again.
scene.play([shift.animate.set(1.0)], duration=MOVE / 2)
scene.play([shift.animate.set(0.0)], duration=MOVE / 2)
scene.wait(HOLD)
def presets(variants, caption=0.45):
"""Draw a badge per (label, effect) pair through that effect, in a 2 x 3 grid."""
for (art, text_zone), (code, effect) in zip(gallery(3, 2, caption), variants):
label(code).place(text_zone)
badge(art).shader_effect(effect, margin=0.3)
scene.wait(MOVE)
# Every PostProcess preset works on a single drawable too. Presets that change
# over time (grain, glitch) renew with the scene time.
styles_start = scene.cursor
scene.segment("presets")
presets([
("PostProcess.pixelate(60)", PostProcess.pixelate(60)),
("PostProcess.halftone(40)", PostProcess.halftone(40)),
("PostProcess.dither(2)", PostProcess.dither(2)),
("PostProcess.crt()", PostProcess.crt()),
("PostProcess.grain(0.4, size=3)", PostProcess.grain(0.4, size=3)),
("PostProcess.glitch(0.8, seed=2)", PostProcess.glitch(0.8, seed=2)),
])
# Color presets: grades of exposure, contrast, saturation and temperature,
# bloom, a vignette and the color fringes of a lens.
grades_start = scene.cursor
scene.segment("color_presets")
presets(
[
("PostProcess.color_grade(\n saturation=0, contrast=1.4)", PostProcess.color_grade(saturation=0, contrast=1.4)),
("PostProcess.color_grade(\n exposure=-1, temperature=-1)", PostProcess.color_grade(exposure=-1, temperature=-1)),
("PostProcess.color_grade(\n saturation=2, temperature=1)", PostProcess.color_grade(saturation=2, temperature=1)),
("PostProcess.bloom(\n threshold=0.5, intensity=1.5)", PostProcess.bloom(threshold=0.5, intensity=1.5)),
("PostProcess.vignette(\n strength=0.9, softness=0.3)", PostProcess.vignette(strength=0.9, softness=0.3)),
("PostProcess\n .chromatic_aberration(0.05)", PostProcess.chromatic_aberration(0.05)),
],
caption=0.9,
)
# A list of passes runs in order, each on what the previous one left. margin
# widens the texture for passes that reach past the outline: with 0, the halo
# is cut at the star's box. None removes the effect.
def star(art):
"""A star as tall as most of ``art``, in its middle."""
return scene.geometry.star(5, art.height * 0.46, art.height * 0.2).move_to(*art.center)
aura = PostProcess.shader(HALO, uniforms={"glow": 1.0})
chains_start = scene.cursor
scene.segment("chains")
VARIANTS = [
(
"[PostProcess.pixelate(60),\n PostProcess.halftone(40)]",
lambda art: badge(art).shader_effect([PostProcess.pixelate(60), PostProcess.halftone(40)], margin=0.3),
),
(
"[PostProcess.halftone(40),\n PostProcess.pixelate(60)]",
lambda art: badge(art).shader_effect([PostProcess.halftone(40), PostProcess.pixelate(60)], margin=0.3),
),
("star.shader_effect(aura, margin=0)", lambda art: star(art).shader_effect(aura, margin=0)),
("star.shader_effect(aura,\n margin=0.6)", lambda art: star(art).shader_effect(aura, margin=0.6)),
("word.shader_effect([ripple, split])", lambda art: word(art, "Chain").shader_effect([ripple, split], margin=0.4)),
(
"badge.shader_effect(crt)\n .shader_effect(None)",
lambda art: badge(art).shader_effect(PostProcess.crt()).shader_effect(None),
),
]
for (art, text_zone), (code, build) in zip(gallery(3, 2, 0.9), VARIANTS):
label(code).place(text_zone)
build(art)
scene.wait(HOLD)
scene.play([shift.animate.set(1.0)], duration=MOVE / 2)
scene.play([shift.animate.set(0.0)], duration=MOVE / 2)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The uniforms before, during and after the split, then each gallery twice as time renews it.
scene.snapshots(
snapshots,
[uniforms_start + t for t in (0.3, HOLD + MOVE / 2, HOLD + MOVE + 0.3)]
+ [start + t for start in (styles_start, grades_start) for t in (0.3, 1.2)]
+ [chains_start + t for t in (0.3, HOLD + MOVE / 2)],
)
else:
scene.render()Audio shaders
Per-object shader effects driven by a music track's bands, level, pulses and spectrogram.

gaanim examples/effects/audio_shaders.pyCode · 130 lines
"""Audio-reactive shader effects: per-object WGSL passes driven by a music track.
Audio signals are ordinary uniforms of ``shader_effect``: an equalizer glows
with the bass, a word ripples with the loudness and a badge splits its colors
on every hit. With ``audio=`` a pass reads the whole track instead, here a
panel that draws the last seconds of the spectrum inside its own outline.
Each label is the code of its variant.
"""
import os
from gaanim import Brush, Falloff, PostProcess, Scene, Zones
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
music = scene.media.audio("assets/rhythm.ogg")
RIPPLE = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let wave = 0.12 * gaanim_uniforms.amount * sin(uv.x * 14.0 + time * 5.0);
return gaanim_scene(uv + vec2<f32>(0.0, wave));
}
"""
SPLIT = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let d = vec2<f32>(gaanim_uniforms.amount * 0.05, 0.0);
let r = gaanim_scene(uv + d);
let g = gaanim_scene(uv);
let b = gaanim_scene(uv - d);
return vec4<f32>(r.r, g.g, b.b, max(max(r.a, g.a), b.a));
}
"""
HALO = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let color = gaanim_scene(uv);
var halo = 0.0;
let step = 10.0 / resolution;
for (var i = -3; i <= 3; i++) {
for (var j = -3; j <= 3; j++) {
halo += gaanim_scene(uv + vec2<f32>(f32(i), f32(j)) * step).a;
}
}
halo = min(1.0, halo / 49.0 * gaanim_uniforms.glow * 3.0);
let alpha = max(color.a, halo);
return vec4<f32>(mix(vec3<f32>(1.0), color.rgb, color.a / max(alpha, 1e-4)), alpha);
}
"""
# With audio=, gaanim_audio_spectrum(x, ago) reads the spectrum at x (0 is
# 20 Hz, 1 about 11 kHz) as it sounded ago seconds before: here the left edge
# is two seconds ago and the bottom the bass, lit in the drawable's own color.
SPECTROGRAM = """
fn gaanim_post(uv: vec2<f32>, resolution: vec2<f32>, time: f32) -> vec4<f32> {
let shape = gaanim_scene(uv);
let energy = gaanim_audio_spectrum(1.0 - uv.y, 2.0 * (1.0 - uv.x));
return vec4<f32>(shape.rgb * (0.15 + 0.85 * energy), shape.a);
}
"""
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
grid = scene.layout.zones(
Zones.grid(rows=4, columns=["15fr", "9fr"], column_gap="48px", row_gap="24px", padding="page_padding")
)
stages = [grid[2 * index + 1] for index in range(4)]
label(
'bass = music.band(20, 150, smoothing=0.4)\nhalo = PostProcess.shader(HALO,\n uniforms={"glow": bass})\nbars.shader_effect(halo, margin=0.4)'
).place(grid[0], anchor="right")
bass = music.band(20, 150, smoothing=0.4)
halo = PostProcess.shader(HALO, uniforms={"glow": bass})
stage = stages[0]
bars = scene.viz.equalizer(
music.spectrum(16, smoothing=0.3),
shape="capsule",
center=stage.center,
width=stage.width * 0.8,
height=stage.height * 0.6,
fill=Falloff.index().gradient(accent, danger),
)
bars.shader_effect(halo, margin=0.4)
label(
'loud = music.level(smoothing=0.5)\nripple = PostProcess.shader(RIPPLE,\n uniforms={"amount": loud})\nword.shader_effect(ripple, margin=0.5)'
).place(grid[2], anchor="right")
loud = music.level(smoothing=0.5)
ripple = PostProcess.shader(RIPPLE, uniforms={"amount": loud})
scene.text("Loudness", size=stages[1].height * 0.55, weight=800).place(stages[1]).shader_effect(ripple, margin=0.5)
label(
'hit = music.pulse(0.3)\nsplit = PostProcess.shader(SPLIT,\n uniforms={"amount": hit})\nbadge.shader_effect(split, margin=0.3)'
).place(grid[4], anchor="right")
hit = music.pulse(0.3)
split = PostProcess.shader(SPLIT, uniforms={"amount": hit})
stage = stages[2]
width, height = stage.width * 0.5, stage.height * 0.8
badge = scene.geometry.group([
scene.geometry.rounded_rect(width, height, height * 0.2).fill(
Brush.linear([danger, accent, success], start=(-width / 2, 0), end=(width / 2, 0))
),
scene.geometry.star(5, height * 0.38, height * 0.16).fill(foreground),
]).move_to(*stage.center)
badge.shader_effect(split, margin=0.3)
label(
"scope = PostProcess.shader(SPECTROGRAM,\n audio=music)\npanel.shader_effect(scope)"
).place(grid[6], anchor="right")
scope = PostProcess.shader(SPECTROGRAM, audio=music)
stage = stages[3]
panel = scene.geometry.rounded_rect(stage.width * 0.9, stage.height * 0.9, 0.2).move_to(*stage.center)
panel.shader_effect(scope)
scene.play(music)
scene.wait(music.duration)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Silence before the track, then moments across it with different loudness and hits.
scene.snapshots(snapshots, [0.0, 1.0, 2.3, 3.6, 5.0, 6.4, 7.6])
else:
scene.render()Falloff
Falloffs that give each member of a group its own scale, turn, opacity, position or color: index, distance, noise, sources and their combinations.

gaanim examples/effects/falloff.pyCode · 242 lines
"""Falloffs: one value per member of a group, driving its scale, turn, opacity, position or color.
A ``Falloff`` computes a value for every member of a group from its index,
its distance to a target, noise or a value over time, and ``drive`` connects
it to a channel of all members at once. The segments show every channel,
the index ramps, the distance shapes and radii, noise and time sources, the
ways falloffs combine, and ``look_at`` turning a field of arrows toward a
moving cursor. Each label is the code of its variant.
"""
import math
import os
from gaanim import Distribution, Easing, Falloff, Scene, Zones, computed
MOVE = 2.0
HOLD = 0.8
scene = Scene()
accent = scene.canvas.color("accent")
chart = scene.canvas.color("chart")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
foreground = scene.canvas.color("foreground")
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def rows(count, labels="16fr"):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[labels, "9fr"], column_gap="48px", row_gap="12px", padding="page_padding")
)
def gallery(count, rows=3):
"""``count`` panels in two columns, each split into its art and the caption under it."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=2, gap="48px", padding="page_padding"))
return [zone.split(Zones.rows(["1fr", 0.45])) for zone in list(grid)[:count]]
def line_of(shape, zone, count):
"""``count`` copies of ``shape`` spread across ``zone`` in one row."""
spacing = zone.width / count
return scene.geometry.duplicate(shape, Distribution.grid(count, 1, spacing, center=zone.center))
def field(zone, shape, columns=12, rows=3):
"""A grid of copies of ``shape(spacing)`` filling ``zone``."""
spacing = min(zone.width / columns, zone.height / rows)
return scene.geometry.duplicate(shape(spacing), Distribution.grid(columns, rows, spacing, center=zone.center))
def cursor(x, y):
"""A ring that marks where a distance falloff is measured from."""
return scene.geometry.circle(0.16).no_fill().stroke(foreground, 0.05).move_to(x, y).z_index(1)
# drive(channel, falloff) applies a falloff to one channel of every member:
# scale and opacity multiply, rotation adds radians, x and y add scene units,
# and fill takes the colors of falloff.gradient(...).
channels_start = scene.cursor
scene.segment("channels")
grid = rows(7)
label("f = Falloff.index()").place(grid[0], anchor="right")
CHANNELS = [
('row.drive("scale", f.remap(0.3, 1.0))', lambda row, f: row.drive("scale", f.remap(0.3, 1.0))),
('row.drive("rotation", f.remap(0, 1.6))', lambda row, f: row.drive("rotation", f.remap(0, 1.6))),
('row.drive("opacity", f.remap(0.1, 1.0))', lambda row, f: row.drive("opacity", f.remap(0.1, 1.0))),
('row.drive("x", f.remap(0, -1.2))', lambda row, f: row.drive("x", f.remap(0, -1.2))),
('row.drive("y", f.remap(-0.3, 0.3))', lambda row, f: row.drive("y", f.remap(-0.3, 0.3))),
('row.drive("fill", f.gradient(chart, accent))', lambda row, f: row.drive("fill", f.gradient(chart, accent))),
]
for index, (code, drive) in enumerate(CHANNELS, start=1):
label(code).place(grid[2 * index], anchor="right")
stage = grid[2 * index + 1]
drive(line_of(scene.geometry.square(stage.height * 0.45), stage, 7), Falloff.index())
scene.wait(HOLD)
# Falloff.index gives 0 to the first member and 1 to the last, shaped by an
# easing; constant gives every member the same value.
ramps_start = scene.cursor
scene.segment("index")
RAMPS = [
("Falloff.index()", Falloff.index()),
("Falloff.index(easing=Easing.SMOOTH)", Falloff.index(easing=Easing.SMOOTH)),
("Falloff.index(easing=Easing.THERE_AND_BACK)", Falloff.index(easing=Easing.THERE_AND_BACK)),
("Falloff.index(easing=Easing.steps(4))", Falloff.index(easing=Easing.steps(4))),
("Falloff.index(reverse=True)", Falloff.index(reverse=True)),
("Falloff.constant(0.5)", Falloff.constant(0.5)),
]
grid = rows(len(RAMPS) + 1, labels="18fr")
label('dots.drive("scale", f.remap(0.15, 1.0))').place(grid[0], anchor="right")
for index, (code, falloff) in enumerate(RAMPS, start=1):
label(code).place(grid[2 * index], anchor="right")
stage = grid[2 * index + 1]
radius = min(stage.height * 0.45, stage.width / 10 * 0.48)
line_of(scene.geometry.circle(radius), stage, 10).drive("scale", falloff.remap(0.15, 1.0))
scene.wait(HOLD)
# Falloff.distance is 1 at a target and 0 at radius and beyond; the target is
# a point or a drawable it follows. falloff shapes the drop between them.
distance_start = scene.cursor
scene.segment("distance")
SHAPES = [
("Falloff.distance(ring)", {}),
('Falloff.distance(ring, falloff="linear")', {"falloff": "linear"}),
('Falloff.distance(ring, falloff="sharp")', {"falloff": "sharp"}),
('Falloff.distance(ring, falloff="round")', {"falloff": "round"}),
("Falloff.distance(ring, radius=0.8)", {"radius": 0.8}),
("Falloff.distance(ring, radius=3.0)", {"radius": 3.0}),
]
grid = rows(len(SHAPES) + 1, labels="18fr")
label('dots.drive("scale", f.remap(1.0, 2.5))').place(grid[0], anchor="right")
sweeps = []
for index, (code, options) in enumerate(SHAPES, start=1):
label(code).place(grid[2 * index], anchor="right")
stage = grid[2 * index + 1]
(left, y), (right, _) = stage.point("left"), stage.point("right")
ring = cursor(left, y)
dots = line_of(scene.geometry.circle(stage.height * 0.13), stage, 12)
dots.drive("scale", Falloff.distance(ring, **options).remap(1.0, 2.5))
sweeps.append(ring.animate.move_to(right, y))
scene.wait(0.3)
scene.play(sweeps, duration=MOVE)
scene.wait(HOLD)
# Noise gives neighbors similar values and drifts with time: scale is cycles
# per scene unit, frequency how fast it drifts, octaves add detail. source
# brings a value over time, the same for every member; linear ramps from a
# start to an end, here one that moves. Each panel drives rotation and fill.
fields_start = scene.cursor
scene.segment("fields")
wave = computed(lambda t: 0.5 + 0.5 * math.sin(3 * t), inputs=[scene.time])
moving = []
def sweep(art):
"""A ramp from the left of ``art`` to a ring that crosses it."""
(left, y), (right, _) = art.point("left"), art.point("right")
ring = cursor(left, y)
moving.append(ring.animate.move_to(right, y))
return Falloff.linear((left, y), ring)
FIELDS = [
("Falloff.noise()", lambda art: Falloff.noise()),
("Falloff.noise(scale=1.5)", lambda art: Falloff.noise(scale=1.5)),
("Falloff.noise(octaves=4)", lambda art: Falloff.noise(octaves=4)),
("Falloff.noise(frequency=2.0)", lambda art: Falloff.noise(frequency=2.0)),
("Falloff.source(wave)", lambda art: Falloff.source(wave)),
("Falloff.linear(left, ring)", sweep),
]
for (art, caption), (code, make) in zip(gallery(len(FIELDS)), FIELDS):
label(code).place(caption)
squares = field(art, lambda spacing: scene.geometry.square(spacing * 0.62))
falloff = make(art)
squares.drive("rotation", falloff.remap(-0.8, 0.8))
squares.drive("fill", falloff.gradient(chart, accent))
scene.wait(0.3)
scene.play(moving, duration=MOVE)
scene.wait(HOLD)
# Falloffs combine with +, - and * (with numbers or other falloffs), maximum
# and minimum. In each panel a = Falloff.distance(ring_a, radius=1.5) follows
# a ring crossing to the right and b one crossing to the left; the result
# drives scale and fill.
combine_start = scene.cursor
scene.segment("combine")
COMBINED = [
("a.maximum(b)", lambda a, b: a.maximum(b)),
("a.minimum(b)", lambda a, b: a.minimum(b)),
("a + b", lambda a, b: a + b),
("a * Falloff.index()", lambda a, b: a * Falloff.index()),
("a.invert()", lambda a, b: a.invert()),
("a.maximum(0.3) - 0.3 * b", lambda a, b: a.maximum(0.3) - 0.3 * b),
]
crossings = []
for (art, caption), (code, combine) in zip(gallery(len(COMBINED)), COMBINED):
label(code).place(caption)
dots = field(art, lambda spacing: scene.geometry.circle(spacing * 0.2), columns=14)
(left, y), (right, _) = art.point("left"), art.point("right")
ring_a, ring_b = cursor(left, y), cursor(right, y)
falloff = combine(Falloff.distance(ring_a, radius=1.5), Falloff.distance(ring_b, radius=1.5))
dots.drive("scale", falloff.remap(1.0, 2.2))
dots.drive("fill", falloff.gradient(chart, accent))
crossings += [ring_a.animate.move_to(right, y), ring_b.animate.move_to(left, y)]
scene.wait(0.3)
scene.play(crossings, duration=MOVE)
scene.wait(HOLD)
# look_at turns each member to point its x axis at a drawable it follows, or
# at a point; offset adds radians. clear_drive ends every drive and look_at
# at the cursor: the drives let go, and the members keep the turn look_at
# gave them.
aim_start = scene.cursor
scene.segment("look_at")
panels = scene.layout.zones(Zones.grid(rows=1, columns=2, gap="48px", padding="page_padding"))
orbits = []
fields = []
for zone, (code, offset) in zip(panels, [
("arrows.look_at(ring)\narrows.clear_drive()", 0.0),
("arrows.look_at(ring, offset=math.pi)\narrows.clear_drive()", math.pi),
]):
art, caption = zone.split(Zones.rows(["1fr", 0.9]))
label(code).place(caption)
arrows = field(
art.inset(0.3),
lambda spacing: scene.geometry.polygon([(-0.3, -0.2), (0.35, 0.0), (-0.3, 0.2)]).scale_to(spacing * 0.9),
columns=8,
rows=8,
)
radius = min(art.width, art.height) * 0.3
path = scene.geometry.circle(radius).no_fill().stroke(rule, 0.02).move_to(*art.center)
ring = cursor(art.center[0] + radius, art.center[1])
arrows.look_at(ring, offset=offset)
arrows.drive("fill", Falloff.distance(ring, radius=2.0).gradient(chart, accent))
orbits.append(ring.animate.move_along(path).easing(Easing.LINEAR))
fields.append(arrows)
scene.wait(0.3)
scene.play(orbits, duration=2 * MOVE)
for arrows in fields:
arrows.clear_drive()
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The static ramps, then each moving segment early, mid and late; last, the arrows after clear_drive.
scene.snapshots(
snapshots,
[channels_start + 0.4, ramps_start + 0.4]
+ [distance_start + 0.3 + MOVE * t for t in (0.2, 0.5, 0.8)]
+ [fields_start + 0.3 + MOVE * t for t in (0.1, 0.5, 0.9)]
+ [combine_start + 0.3 + MOVE * t for t in (0.2, 0.5, 0.8)]
+ [aim_start + 0.3 + 2 * MOVE * t for t in (0.0, 0.3, 0.7)]
+ [aim_start + 0.3 + 2 * MOVE + 0.5],
)
else:
scene.render()Repeaters
Copies stepped by a transform with repeat, copies placed by a grid, circle, path or random distribution, and animated copy counts.

gaanim examples/effects/repeaters.pyCode · 226 lines
"""Repeaters and duplicators: copies stepped by a transform, copies placed by a distribution, and copy counts.
``scene.geometry.repeat`` makes copies of a shape, each one step further
turned, scaled, shifted and faded. ``scene.geometry.duplicate`` places copies
at the points of a ``Distribution``: a grid, a circle, a path, a random cloud
or a sunflower spiral. Both return a group whose ``count`` shows its first
copies, and ``animate.count`` builds the pattern copy by copy. Each label is
the code of its variant.
"""
import math
import os
from gaanim import Distribution, Easing, Scene, Zones
BUILD = 2.0
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def table(variants):
"""One row per (label, build) pair: the label on the left, ``build(zone)`` on the right.
A build may return an animation; they all play together.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(variants), columns=["15fr", "9fr"], column_gap="48px", row_gap="24px", padding="page_padding")
)
plays = []
for index, (code, build) in enumerate(variants):
label(code).place(grid[2 * index], anchor="right")
play = build(grid[2 * index + 1])
if play is not None:
plays.append(play)
scene.wait(0.3)
if plays:
scene.play(plays, duration=BUILD)
scene.wait(1.0)
# repeat(shape, count, rotate=0, scale=1, offset=(0, 0), opacity=(1, 1),
# about=None): copy i is turned by i * rotate, scaled by scale ** i and shifted
# by i * offset, about the point ``about`` when given or else its own pivot.
def petal(zone):
"""A petal above the middle of ``zone``, and that middle."""
(x, y), size = zone.center, zone.height * 0.5
return scene.geometry.ellipse(size * 0.18, size * 0.5).move_to(x, y + size * 0.5), (x, y)
def flower(zone):
"""Twelve petals turned about the middle of ``zone``."""
shape, center = petal(zone)
scene.geometry.repeat(shape, 12, rotate=math.tau / 12, about=center, opacity=(1.0, 0.4))
def tunnel(zone):
"""Squares that turn and shrink, each inside the last."""
square = scene.geometry.square(zone.height).no_fill().stroke(accent, 0.04).move_to(*zone.center)
scene.geometry.repeat(square, 14, rotate=0.12, scale=0.86)
def spiral(zone):
"""Leaves turned and shrunk about the middle of ``zone``, a spiral."""
(x, y), size = zone.center, zone.height * 0.5
leaf = scene.geometry.ellipse(size * 0.1, size * 0.22).fill(success).move_to(x, y + size * 0.8)
scene.geometry.repeat(leaf, 40, rotate=0.45, scale=0.95, about=(x, y))
def fan(zone):
"""Bars turned about their own middle, a fan."""
bar = scene.geometry.rounded_rect(zone.height, 0.08, 0.04).move_to(*zone.center)
scene.geometry.repeat(bar, 9, rotate=math.pi / 9, opacity=(1.0, 0.2))
repeat_start = scene.cursor
scene.segment("repeat_turns")
table([
("repeat(petal, 12, rotate=math.tau / 12,\n about=center, opacity=(1.0, 0.4))", flower),
("repeat(square, 14, rotate=0.12,\n scale=0.86)", tunnel),
("repeat(leaf, 40, rotate=0.45, scale=0.95,\n about=center)", spiral),
("repeat(bar, 9, rotate=math.pi / 9,\n opacity=(1.0, 0.2))", fan),
])
def row(zone):
"""Dots stepped right across ``zone``."""
x, y = zone.point("left")
dot = scene.geometry.circle(0.25).move_to(x + 0.3, y)
scene.geometry.repeat(dot, 10, offset=(0.54, 0))
def fading(zone):
"""Bars stepped right, fading out."""
x, y = zone.point("left")
bar = scene.geometry.rounded_rect(0.2, zone.height * 0.9, 0.1).move_to(x + 0.2, y)
scene.geometry.repeat(bar, 12, offset=(0.46, 0), opacity=(1.0, 0.1))
def stacked(zone):
"""A word stepped down and right, fading in, like a drop shadow of copies."""
word = scene.text("Repeat", size=zone.height * 0.6, weight=800).place(zone).shift_by(-0.3, 0.25)
scene.geometry.repeat(word, 6, offset=(0.1, -0.1), opacity=(0.15, 1.0))
def stairs(zone):
"""Squares stepped up and right, turning and shrinking."""
x, y = zone.point("bottom_left")
square = scene.geometry.square(zone.height * 0.5).fill(success).move_to(x + zone.height * 0.3, y + zone.height * 0.3)
scene.geometry.repeat(square, 9, offset=(0.55, 0.1), rotate=0.2, scale=0.92)
offset_start = scene.cursor
scene.segment("repeat_offsets")
table([
("repeat(dot, 10, offset=(0.54, 0))", row),
("repeat(bar, 12, offset=(0.46, 0),\n opacity=(1.0, 0.1))", fading),
("repeat(word, 6, offset=(0.1, -0.1),\n opacity=(0.15, 1.0))", stacked),
("repeat(square, 9, offset=(0.55, 0.1),\n rotate=0.2, scale=0.92)", stairs),
])
def arrow():
"""A small arrowhead pointing right."""
return scene.geometry.polygon([(-0.13, -0.1), (0.16, 0.0), (-0.13, 0.1)])
# duplicate(shape, distribution) centers a copy on every point; oriented
# distributions also turn each copy to face outward or along its path.
def grid_of(zone):
"""A grid of dots filling ``zone``."""
spacing = zone.height / 4
scene.geometry.duplicate(scene.geometry.circle(spacing * 0.3), Distribution.grid(12, 4, spacing, center=zone.center))
def ring_of(zone):
"""Arrowheads on a circle, turned to face outward."""
scene.geometry.duplicate(arrow(), Distribution.circle(16, 0.75, center=zone.center, orient=True))
def path_of(zone):
"""Arrowheads along a zigzag, turned to follow it."""
x, y = zone.center
w, h = zone.width * 0.45, zone.height * 0.4
path = [(x - w, y - h), (x - w / 3, y + h), (x + w / 3, y - h), (x + w, y + h)]
scene.geometry.polyline(path).no_fill().stroke(rule, 0.02)
scene.geometry.duplicate(arrow().fill(success), Distribution.along(path, 24, orient=True))
def cloud_of(zone):
"""Stars scattered at random in ``zone``, the same cloud for the same seed."""
bounds = (zone.left + 0.2, zone.bottom + 0.2, zone.right - 0.2, zone.top - 0.2)
scene.geometry.duplicate(scene.geometry.star(5, 0.13, 0.055).fill(foreground), Distribution.random(50, bounds, seed=3))
duplicate_start = scene.cursor
scene.segment("duplicate")
table([
("duplicate(dot,\n Distribution.grid(12, 4, spacing))", grid_of),
("duplicate(arrow, Distribution.circle(\n 16, 0.75, orient=True))", ring_of),
("duplicate(arrow, Distribution.along(\n path, 24, orient=True))", path_of),
("duplicate(star, Distribution.random(\n 50, bounds, seed=3))", cloud_of),
])
# count(n) shows the first n copies (a fraction fades the next one in), and
# animate.count(n) builds the pattern copy by copy, from the first.
def sunflower(zone):
"""Seeds on a sunflower spiral, built from the middle out."""
seeds = scene.geometry.duplicate(
scene.geometry.circle(0.035), Distribution.phyllotaxis(260, 0.055, center=zone.center)
).count(0)
return seeds.animate.count(260).easing(Easing.LINEAR)
def petals(zone):
"""The flower of petals, built petal by petal."""
shape, center = petal(zone)
built = scene.geometry.repeat(shape, 12, rotate=math.tau / 12, about=center).count(0)
return built.animate.count(12).easing(Easing.LINEAR)
def partial(zone):
"""A ring of dots showing two and a half of its copies, then all of them."""
ring = scene.geometry.duplicate(
scene.geometry.circle(0.12).fill(success), Distribution.circle(12, 0.75, center=zone.center)
).count(2.5)
return ring.animate.count(12)
def emptied(zone):
"""A grid of squares that hides its copies again, from the last."""
spacing = zone.height / 4
squares = scene.geometry.duplicate(
scene.geometry.square(spacing * 0.7).fill(foreground), Distribution.grid(12, 4, spacing, center=zone.center)
)
return squares.animate.count(0)
count_start = scene.cursor
scene.segment("count")
table([
("seeds = duplicate(dot, Distribution\n .phyllotaxis(260, 0.055)).count(0)\nseeds.animate.count(260)", sunflower),
("flower = repeat(petal, 12, ...).count(0)\nflower.animate.count(12)", petals),
("ring.count(2.5)\nring.animate.count(12)", partial),
("squares.animate.count(0)", emptied),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The static patterns, then the counts at the start, halfway and built.
scene.snapshots(
snapshots,
[start + 0.5 for start in (repeat_start, offset_start, duplicate_start)]
+ [count_start + t for t in (0.2, 0.3 + BUILD / 2, 0.3 + BUILD + 0.5)],
)
else:
scene.render()Particles
Particle emitter shapes, motion, looks, bursts and confetti, exact at any seek.

gaanim examples/effects/particles.pyCode · 205 lines
"""Particles: emitter shapes, motion, looks, bursts and confetti, all exact at any seek.
``scene.fx.particles`` returns an emitter drawable whose particles are closed
functions of the timeline time, so a seek shows what playback shows. The
segments vary where particles leave from, how they move, how they look, and
when they are emitted: continuously, for a while, or in bursts, ending with
the confetti preset. Each label is the code of its variant.
"""
import math
import os
from gaanim import Brush, Emitter, Scene, Zones
RUN = 2.0
scene = Scene()
accent = scene.canvas.color("accent")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
chart = scene.canvas.color("chart")
rule = scene.canvas.color("rule")
PALETTE = [accent, success, danger, chart]
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def gallery(variants, rows=3, columns=2, caption=0.45):
"""Draw each (label, build) variant in a panel; ``build(zone)`` makes its emitter.
A build may return an animation, which plays while the particles run.
"""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
plays = []
for zone, (code, build) in zip(grid, variants):
art, text_zone = zone.split(Zones.rows(["1fr", caption]))
label(code).place(text_zone)
scene.geometry.rect(art.width, art.height).no_fill().stroke(rule, 0.02).move_to(*art.center)
play = build(art)
if play is not None:
plays.append(play)
if plays:
scene.play(plays, duration=RUN)
else:
scene.wait(RUN)
def drifting(emitter):
"""Slow particles that stay near ``emitter``, built for one panel."""
def build(zone):
scene.fx.particles(emitter.at(zone.center), rate=60, speed=(0.2, 0.5), lifetime=(1.0, 1.6), size=(0.08, 0.14), seed=1)
return build
def trail(zone):
"""A dot that crosses ``zone`` and sheds particles where it was."""
(left, y), (right, _) = zone.point("left"), zone.point("right")
dot = scene.geometry.circle(0.15).move_to(left + 0.4, y)
scene.fx.particles(Emitter.circle(0.15).at(dot), rate=80, speed=(0.1, 0.4), lifetime=(0.6, 1.2), size=(0.08, 0.14), seed=2)
return dot.animate.move_to(right - 0.4, y)
# Emitter.point, circle (inside or on its rim), rect and line set where
# particles leave from; at() centers them on a point or follows a drawable.
emitters_start = scene.cursor
scene.segment("emitters")
gallery([
("Emitter.point()", drifting(Emitter.point())),
("Emitter.circle(0.6)", drifting(Emitter.circle(0.6))),
("Emitter.circle(0.6, edge=True)", drifting(Emitter.circle(0.6, edge=True))),
("Emitter.rect(5, 0.6)", drifting(Emitter.rect(5, 0.6))),
("Emitter.line(5, angle=0.25)", drifting(Emitter.line(5, angle=0.25))),
("Emitter.circle(0.15).at(dot)", trail),
])
def moving(**options):
"""Particles from the left of a panel (its bottom when they rise), moving as ``options`` say."""
def build(zone):
x, y = zone.point("bottom" if "direction" not in options else "left")
start = (x, y + 0.1) if "direction" not in options else (x + 0.3, y)
scene.fx.particles(Emitter.point().at(start), rate=60, size=(0.08, 0.12), seed=3, **options)
return build
# speed heads within spread radians around direction (up by default) for a
# lifetime; gravity accelerates every particle, drag slows it, flutter sways
# it sideways and spin turns it. Each panel emits 60 particles a second.
motion_start = scene.cursor
scene.segment("motion")
gallery(
[
("direction=0, spread=0.2,\nspeed=(3, 5)", moving(direction=0, spread=0.2, speed=(3, 5))),
(
"spread=0.6, speed=(2.5, 3.5),\ngravity=(0, -8), lifetime=0.8",
moving(spread=0.6, speed=(2.5, 3.5), gravity=(0, -8), lifetime=0.8),
),
("direction=0, spread=0.6,\nspeed=(4, 8), drag=3.0", moving(direction=0, spread=0.6, speed=(4, 8), drag=3.0)),
("direction=0, spread=0.2,\nflutter=0.4", moving(direction=0, spread=0.2, flutter=0.4)),
('direction=0, spread=0.6,\nshape="square", spin=8', moving(direction=0, spread=0.6, shape="square", spin=8)),
("direction=0, spread=0.2,\nlifetime=(0.2, 2.0)", moving(direction=0, spread=0.2, lifetime=(0.2, 2.0))),
],
caption=0.9,
)
def looking(**options):
"""Slow particles from the middle of a panel, drawn as ``options`` say."""
def build(zone):
settings = {"rate": 30, "speed": (0.3, 0.6), "lifetime": (1.2, 1.8), "size": (0.16, 0.26), "seed": 4, **options}
scene.fx.particles(Emitter.circle(0.3).at(zone.center), **settings)
return build
def streaks(zone):
"""Fast streaks from the left of a panel, stretched along their velocity."""
x, y = zone.point("left")
scene.fx.particles(
Emitter.point().at((x + 0.3, y)), rate=40, shape="streak", direction=0, spread=0.3, speed=(3, 5), size=(0.06, 0.1), seed=4
)
# shape is circle, square, rect (a 2:1 strip), triangle or streak (stretched
# along the velocity); size scales to size * size_end over each life, the last
# fade of each life fades it, and color is one color, a list or a gradient.
looks_start = scene.cursor
scene.segment("looks")
gallery([
('shape="rect", spin=4', looking(shape="rect", spin=4)),
('shape="triangle", spin=4', looking(shape="triangle", spin=4)),
('shape="streak", speed=(3, 5)', streaks),
("size_end=0.0, fade=0.0", looking(size_end=0.0, fade=0.0)),
("color=[accent, success, danger]", looking(color=[accent, success, danger])),
("color=Brush.linear(...)", looking(color=Brush.linear([danger, accent, success], start=(0, 0), end=(1, 0)))),
])
# rate=0 emits only bursts: burst() at the cursor, or animate.burst(), which
# lasts until its particles settle. duration stops a continuous emitter. The
# emitter is a drawable, so its opacity reaches every particle.
def bursting(zone):
"""An emitter that only bursts, when its animation starts."""
sparks = scene.fx.particles(zone.center, rate=0, speed=(1, 3), drag=1.5, lifetime=(0.8, 1.4), size=(0.08, 0.14), color=PALETTE, seed=5)
return sparks.animate.burst(120)
def burst_now(zone):
"""An emitter that bursts at the cursor, before anything plays."""
scene.fx.particles(zone.center, rate=0, speed=(1, 3), drag=1.5, lifetime=(1.2, 1.8), size=(0.08, 0.14), color=accent, seed=6).burst(80)
def stopping(zone):
"""A stream that stops emitting after half a second."""
x, y = zone.point("left")
scene.fx.particles((x + 0.3, y), rate=80, duration=0.5, direction=0, spread=0.3, speed=(2, 3), lifetime=1.2, size=(0.08, 0.14), seed=7)
def faint(zone):
"""A stream drawn at a third of its opacity."""
scene.fx.particles(Emitter.rect(zone.width * 0.8, 0.2).at(zone.center), rate=60, speed=(0.3, 0.6), size=(0.1, 0.18), seed=8).opacity(0.35)
bursts_start = scene.cursor
scene.segment("bursts")
gallery(
[
("sparks.animate.burst(120)", bursting),
("sparks.burst(80)", burst_now),
("particles(..., duration=0.5)", stopping),
("particles(...).opacity(0.35)", faint),
],
rows=2,
)
# confetti throws paper strips that spin, sway and fall; burst() throws more
# of the same, and colors, direction and spread aim them.
confetti_start = scene.cursor
scene.segment("confetti")
stage = scene.layout.zones(Zones.grid(rows=1, columns=1, padding="page_padding"))[0]
left, bottom = stage.point("bottom_left")
right, _ = stage.point("bottom_right")
label("party = scene.fx.confetti(\n (x, y), count=120, colors=PALETTE)").place(stage, anchor="top_left")
label("later.animate.burst(60)").place(stage, anchor="top_right")
party = scene.fx.confetti((left + stage.width * 0.25, bottom), count=120, colors=PALETTE, seed=2)
later = scene.fx.confetti((right - stage.width * 0.25, bottom), count=0, colors=PALETTE, direction=math.pi / 2 + 0.3, seed=3)
scene.wait(1.0)
scene.play([later.animate.burst(60)])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each gallery once the particles fill it, the bursts early and late, and the confetti flying and falling.
scene.snapshots(
snapshots,
[start + 1.5 for start in (emitters_start, motion_start, looks_start)]
+ [bursts_start + t for t in (0.3, 1.2)]
+ [confetti_start + t for t in (0.6, 1.6, 2.8)],
)
else:
scene.render()Plexus
Live lines between drifting points, by range, nearest neighbors or sequence, with and without distance fading.

gaanim examples/effects/plexus.pyCode · 66 lines
"""Plexus: live lines between drifting points, by range, nearest neighbors or sequence.
``scene.geometry.connect`` joins points with lines that follow them in every
frame. Each panel scatters the same kind of cloud and lets every point drift
its own way, so links appear and break as distances change. The labels are
the code that connects each cloud, after ``dots`` is a group of points.
"""
import os
import random
from gaanim import Distribution, Easing, Scene, Zones
DRIFT = 3.0
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
def label(code):
"""A variant label: the code that builds it, without markup."""
return scene.text(code, role="code")
def cloud(zone, count, seed):
"""``count`` dots scattered in ``zone``, and their drifts, each its own way."""
bounds = (zone.left + 0.4, zone.bottom + 0.3, zone.right - 0.4, zone.top - 0.3)
points = [scene.geometry.dot(0.08).fill(foreground).move_to(x, y) for x, y in Distribution.random(count, bounds, seed=seed).points]
turns = random.Random(seed)
drifts = [
point.animate.shift_by(turns.uniform(-0.8, 0.8), turns.uniform(-0.3, 0.3)).easing(Easing.SMOOTH)
for point in points
]
return scene.geometry.group(points), drifts
# mode="range" joins every pair closer than max_distance, "nearest" each point
# to its nearest neighbors, and "sequential" each point to the next one.
# fade_by_distance makes longer links fainter.
VARIANTS = [
("connect(dots)", lambda dots: scene.geometry.connect(dots)),
("connect(dots, max_distance=2.5)", lambda dots: scene.geometry.connect(dots, max_distance=2.5)),
('connect(dots, 3, mode="nearest")', lambda dots: scene.geometry.connect(dots, 3, mode="nearest")),
('connect(dots, 3, mode="nearest",\n neighbors=4)', lambda dots: scene.geometry.connect(dots, 3, mode="nearest", neighbors=4)),
('connect(dots, 4,\n mode="sequential")', lambda dots: scene.geometry.connect(dots, 4, mode="sequential")),
("connect(dots, 2.5,\n fade_by_distance=False)", lambda dots: scene.geometry.connect(dots, 2.5, fade_by_distance=False)),
]
grid = scene.layout.zones(Zones.grid(rows=3, columns=2, gap="48px", padding="page_padding"))
drifts = []
for index, (zone, (code, build)) in enumerate(zip(grid, VARIANTS)):
art, caption = zone.split(Zones.rows(["1fr", 0.9]))
label(code).place(caption)
dots, moves = cloud(art, 22, seed=index + 1)
build(dots).stroke(accent, 0.025)
drifts += moves
scene.wait(0.3)
scene.play(drifts, duration=DRIFT)
scene.wait(0.5)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The clouds at rest, drifting, and settled where the links have changed.
scene.snapshots(snapshots, [0.2, 0.3 + DRIFT / 3, 0.3 + 2 * DRIFT / 3, 0.3 + DRIFT + 0.3])
else:
scene.render()