Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

fastanim is a mathematical-animation engine in the spirit of manim. Scenes can be written in Rust or as Rhai scripts; this book covers the scripts.

A scene script runs once, top to bottom. It adds shapes and text to a global scene and plays animations on it. What it records is baked into a timeline, so playback, scrubbing and export never run the script again.

// A circle draws itself, then a formula is written under it.
// fastanim preview hello.rhai

let title = scene.add(text("Hello, fastanim").to_edge(UP));
scene.play(write(title));

let ring = scene.add(circle(1.5).stroke(BLUE, 0.06));
scene.play(create(ring).run_time(1.5));

let formula = scene.add(math_tex("y = sin(x)").scale(1.5).fill(GREEN).to_edge(DOWN));
scene.play(write(formula));
scene.wait(1.0);

Save that as hello.rhai and run fastanim preview hello.rhai. The window re-bakes the scene every time you save the file.

Install

Prebuilt binaries are attached to each GitHub release. With cargo-binstall:

cargo binstall --git https://github.com/simonhdickson/fastanim fastanim-cli

Or build from source (Rust 1.92+):

cargo install --locked --git https://github.com/simonhdickson/fastanim fastanim-cli

Video export also needs ffmpeg on your PATH.

Try it in the browser

The playground runs scripts in the browser with no install. It ships with the example scenes from fastanim-script/scenes. Link straight to one with ?scene=<name>, e.g. ?scene=diff, or to the first .rhai file in a public GitHub gist with ?gist=<id>. Share link copies a link that holds the script itself, so edits can be shared with nothing uploaded anywhere.

Running scenes

The fastanim CLI runs scripts in six ways.

CommandWhat it does
fastanim preview <script>Opens a window with a timeline, re-baking on save
fastanim render <script>Exports video or frames
fastanim still <script>Exports one frame
fastanim check <script>...Runs scripts without a window and reports their errors
fastanim bundle <script>Writes the scene’s typeset text to a .bundle for the web player
fastanim profile <script> [--runs N]Bakes the scene cold, then N times warm, and prints time spent typesetting, planning animations, copying keyframes and running the script

Preview

fastanim preview scene.rhai
KeyAction
SpacePlay / pause
← / →Step a frame
[ / ]Jump to the previous / next marker
Drag along the timelineScrub
Click a blockJump to its start and box the shapes it affects
Click it again / EscDeselect

The timeline under the scene has a block per play, labelled with the animation’s kind or its .named title, and rows below for updaters and sounds. The web player has the same timeline.

Typeset text is cached, so a re-bake only typesets text that changed.

Render

fastanim render scene.rhai -q 720p30 -o scene.mp4
fastanim render scene.rhai --section intro -o intro.webm
  • -q, --quality: 480p15, 720p30, 1080p60 (default) or 4k60.
  • -o, --output: the extension picks the format: mp4, webm, gif, png or svg. png and svg write one file per frame. mp4 and webm include the scene’s sounds.
  • --section <marker>: render only from that marker to the next one.

Still

fastanim still scene.rhai --at 2.5s -o frame.png
fastanim still scene.rhai --frame 120 -o frame.svg

--at takes seconds (3.5 or 3.5s); --frame takes a frame index at the chosen quality.

Bundle

The web player typesets text slowly. fastanim bundle scene.rhai runs the script without a window and writes scene.bundle beside it, holding the pre-typeset text.

Check

fastanim check scene.rhai
fastanim check scenes/*.rhai --fps 60

Runs each script without a window, then samples its timeline --fps times a second (30 by default, 0 to skip), running every updater and animation closure along the way. A script only tries an updater when it is added and an animation’s closure at its end, so sampling finds closures that fail part way through. Good scripts print path: ok and their duration; errors go to stderr in the format below, and the exit status is non-zero if any script failed, so it suits CI and an editor’s :make. Errors found while sampling are prefixed with the time, as in scene.rhai: at 0.50s: ....

Editors

fastanim lsp is a language server on stdin and stdout. Point your editor’s LSP client for .rhai files at it. It bakes each open script as you type and marks the error, if any, at its line and column. It also completes function and constant names, and the variables, functions and parameters the script declares. Hover shows a function’s signatures or the line declaring a name. Output from a script’s print and debug goes to stderr.

Both setups below expect fastanim on your PATH (see Install).

Neovim

Neovim 0.11 or later. Add to your init.lua:

vim.filetype.add({ extension = { rhai = "rhai" } })
vim.lsp.config("fastanim", {
  cmd = { "fastanim", "lsp" },
  filetypes = { "rhai" },
  root_markers = { ".git" },
})
vim.lsp.enable("fastanim")

Errors show as diagnostics. Completion comes through <C-x><C-o> or your completion plugin. Hover is K. :checkhealth vim.lsp shows whether the server started.

Zed

Zed starts a language server only when an extension declares it, so fastanim ships one in editors/zed. Zed compiles it to WebAssembly itself, which needs Rust installed through rustup.

  1. Install the rhai extension from the extensions page (zed: extensions). It gives Zed the Rhai language that the server attaches to.
  2. Clone the fastanim repository. Run zed: install dev extension and pick its editors/zed directory.
  3. Open a .rhai file. dev: open language server logs shows the server’s output and any error starting it.

Errors

Errors are reported as path:line:col: message, for example:

scene.rhai:3:14: Function not found: point (i64, i64)

Python scenes

The same API is available from Python, as the fastanim package. A Python scene is a function marked @scene that takes the scene as its argument:

# hello.py
from fastanim import *

@scene
def hello(s: Scene) -> None:
    title = s.add(text("Hello, fastanim").to_edge(UP))
    s.play(write(title))
    ring = s.add(circle(1.5, stroke=BLUE, stroke_width=0.06))
    s.play(create(ring), run_time=1.5)

A plain def construct(scene) works too.

Installing

fastanim records and bakes scenes and exports SVG. Video, PNG and the preview window come with the preview extra, a second native module built with the renderer, which fastanim uses when it is installed:

pip install fastanim            # SVG stills and frames
pip install fastanim[preview]   # + video, PNG and the preview window

From a checkout, build both into a virtualenv with maturin:

maturin develop -m fastanim-py/pyproject.toml
maturin develop -m fastanim-py/preview/pyproject.toml

From the command line

python -m fastanim takes the commands and options of the fastanim CLI:

python -m fastanim preview hello.py
python -m fastanim render hello.py -q 720p30 -o hello.mp4
python -m fastanim render hello.py --section intro -o intro.webm
python -m fastanim still hello.py --at 2.5s -o frame.png

When a file has more than one @scene, name the one to run: hello.py:hello.

Preview runs the file again whenever it is saved and keeps the current time; if the new version fails, the error is printed and the last good scene stays up. Modules the scene imports are not reloaded. The file runs with __name__ set to something other than "__main__", so code under if __name__ == "__main__": is skipped.

From code

hello.preview()                                   # blocks until the window closes
hello.render("hello.mp4", quality="1080p60")      # also section="intro"
hello.still("frame.svg", at=3.5)                  # or frame=210
tl = hello.bake()                                 # the timeline: tl.duration, tl.svg(t), …
tl.render("frames.svg", quality="480p15")         # a directory of SVG frames

Only svg output works without the preview extra; other formats raise a RuntimeError saying to install it. The preview window can be opened once per Python process.

Styles

Shape functions take stroke=, stroke_width=, fill= and fill_opacity= keywords, and the style methods are as in Rhai, with optional arguments as keywords: fill(color, opacity=None), fill_gradient(colors, direction=RIGHT), stroke_gradient(colors, direction=RIGHT), dashed(dashes=15, ratio=0.5), line_cap(cap), line_join(join) and dashed_line(a, b, dash_length=0.05, ratio=0.5). Scene.background(color) sets the background, and Timeline.svg(t) draws over it unless given another background=.

@scene
def styled(s: Scene) -> None:
    s.background("#1c1c28")
    s.add(square(2, fill=BLUE, fill_opacity=0.5).fill_gradient([BLUE, PINK], UP))
    s.add(circle(1.5).dashed(24).line_cap("butt"))

Images, Lottie animations and fonts

image takes a path, or rows of pixels as nested lists or a NumPy array (anything with tolist()), plus height=, width= and pixelated= keywords. lottie(path) takes height= and width= too, and play_lottie(m, start=, end=, loops=) plays it. Text functions take a font= keyword, and load_font(path) loads a font file (relative to the current directory):

import numpy as np

load_font("fonts/Inter-Regular.ttf")

@scene
def gallery(s: Scene) -> None:
    ramp = np.tile(np.arange(256, dtype=np.uint8), (64, 1))
    s.add(image(ramp, height=2, pixelated=True).to_edge(DOWN))
    s.add(text("Hello", font="Inter").to_edge(UP))
    spinner = s.add(lottie("spinner.json", height=3))
    s.play(play_lottie(spinner, loops=3))

The scene

Every script has a global scene. Calls on it append to the timeline in order.

Adding and removing

scene.add(x) puts a shape, text or axes into the scene and returns a mobject handle. The handle is what animations take.

let c = scene.add(circle(1.0));     // shown immediately
scene.play(fade_out(c));
scene.remove(c);                     // gone from the scene

Adding a shape shows it at once. To bring it in with an animation, add it and play the animation straight away: create, write and fade_in start from nothing.

let c = scene.add(circle(1.0));
scene.play(create(c));

Values like circle(1.0) are plain descriptions. Changing one after add does not change the scene; animate the mobject instead.

Background

scene.background(color) sets the color behind every frame; it is black until set. It is one color for the whole scene, so the last call wins wherever it is in the script. The preview, video and still exports, SVG and the browser player all use it.

scene.background(hex(0x1c1c28));

Time

CallEffect
scene.play(anim)Plays an animation, advancing time by its run time
scene.play([a, b, ...])Plays several animations together
scene.wait()Waits 1 second
scene.wait(secs)Waits secs seconds
scene.timeThe current time, in seconds
scene.add_sound(a)Starts a sound without waiting; see Sound
scene.wait_for_sounds()Waits until every sound started so far has finished

Markers

scene.marker("name") names the current time. The preview jumps between markers with [ and ], and fastanim render --section name renders from that marker to the next.

scene.marker("intro");
// ...
scene.marker("proof");

Reading state back

scene.get(m) returns the shape of mobject m as it is now, at the current time. Only shapes can be read back; texts are many glyphs.

let c = scene.add(circle(1.0).shift(LEFT));
scene.play(shift(c, RIGHT * 2.0));
let now = scene.get(c);    // the circle, one unit right of center

Coordinates

Coordinates follow manim. The origin is the center of the frame, x points right and y points up. The frame is FRAME_WIDTH (about 14.2) by FRAME_HEIGHT (8) units, 16:9.

The direction constants are unit points: UP, DOWN, LEFT, RIGHT and ORIGIN.

Shapes, points and colors

Points

point(x, y) makes a point, which also serves as a vector. Points add, subtract, negate, and multiply or divide by a number. Read the parts with .x and .y.

let p = point(1.0, 2.0) + RIGHT * 3.0;   // point(4, 2)
let mid = (p + ORIGIN) / 2.0;
print(mid.y);                             // 1.0

Colors

FunctionExample
Named constantsWHITE, BLACK, BLUE, RED, GREEN, YELLOW, GREY, ORANGE, PINK, PURPLE, TRANSPARENT
rgb(r, g, b)rgb(1.0, 0.5, 0.0), each 0–1
hex(rgb)hex(0xf4d345)
with_alpha(c, a)BLUE.with_alpha(0.5)

Shapes

All shapes are centered on the origin unless they take points.

FunctionShape
circle(radius)Circle
square(side)Square
rectangle(width, height)Rectangle
ellipse(width, height)Ellipse
arc(radius, start, sweep)Arc, sweep radians counter-clockwise from start
line(a, b)Line from point a to point b
dot(p)Small filled white dot at p
polygon([p1, p2, ...])Closed polygon through the points
dashed_line(a, b)Dashed line from a to b, dashes about 0.05 long
dashed_line(a, b, dash_length)The same, dashes about dash_length long
function_graph(f, x0, x1, segments)Graph of y = f(x) for x in x0..x1, in scene units
let wave = function_graph(|x| x.sin(), -PI, PI, 32);

union(a, b), intersection(a, b), difference(a, b) (a without b) and exclusion(a, b) (one or the other, not both) make a new shape from the areas two shapes cover, in the default style.

let lens = intersection(circle(1.0).shift(LEFT * 0.5), circle(1.0).shift(RIGHT * 0.5));

Styling

Style methods return a new shape, so they chain.

MethodEffect
.fill(color)Fill color
.fill(color, opacity)Fill color and opacity, 0–1
.fill_opacity(opacity)Fill opacity, keeping the color or gradient
.stroke(color, width)Outline color and width
.stroke_opacity(opacity)Outline opacity, keeping the color or gradient
.fill_gradient([c1, c2, ...])Fill with a linear gradient from left to right
.fill_gradient([c1, c2, ...], dir)The same, across the shape along dir, e.g. UP
.stroke_gradient([c1, c2, ...])Outline with a linear gradient, left to right; dir as for fills
.line_cap(cap)Ends of open outlines: "round" (default), "butt" or "square"
.line_join(join)Corners: "round" (default), "miter" or "bevel"
.dashed()The outline cut into 15 dashes, each half its period
.dashed(n) / .dashed(n, ratio)n dashes, each ratio (0–1) of its period
.shader(wgsl)Paint the fill with a WGSL shader; see Shader fills
.z_index(n)Draw order; higher draws on top
.rotate(angle)Rotate by angle radians
let s = scene.add(square(2.0).fill(BLUE, 0.5).stroke(WHITE, 0.04).rotate(PI / 4.0));
let warm = scene.add(circle(1.0).fill_gradient([YELLOW, ORANGE, RED], UP));
let ring = scene.add(circle(1.5).stroke(WHITE, 0.1).stroke_gradient([BLUE, PINK]).dashed(24));

.fill and .stroke replace a gradient with a solid color, so set a gradient after them. A gradient spans the shape’s box when it is set and then moves, scales and rotates with the shape; transform blends one gradient into another, or into a solid color. Fading changes its opacity like a solid color’s.

Dashes are geometry: a dashed shape is many short open pieces, so create draws it dash by dash, and a fill of a dashed shape fills each piece on its own. An open outline starts and ends on a dash; a closed one repeats evenly around.

Texts take .fill(color, opacity) and .fill_gradient(...) too, the gradient running across the whole text. To color a group or a text item by item instead, use .set_color_by_gradient([c1, c2, ...]): each shape or glyph, first to last, gets the next color along the stops, keeping its fill and outline opacities.

let title = scene.add(text("fastanim").fill_gradient([BLUE, GREEN]));
let dots = scene.add(group([dot(LEFT), dot(ORIGIN), dot(RIGHT)]).set_color_by_gradient([RED, BLUE]));

Positioning

These methods work on shapes, texts, axes and groups alike.

MethodEffect
.shift(v)Move by vector v
.move_to(p)Move the center to p
.scale(k)Scale about the center
.to_edge(dir)Move against a frame edge, e.g. UP
.next_to(other, dir)Place beside other (a shape, text, group or point, not a mobject handle), DEFAULT_BUFF apart
.next_to(other, dir, buff)The same, buff apart
.align_to(other, dir)Line up the dir edge with other’s
.scale_to_fit_height(h)Scale about the center to be h tall
.scale_to_fit_width(w)Scale about the center to be w wide
.center()The bounding-box center, as a point
.width(), .height()The bounding-box size
let box = square(1.0);
let label = text("box").next_to(box, DOWN);

Images

image(path) loads a PNG or JPEG file (relative to the script’s directory), and image(rows) builds one from pixels: an array of rows, top row first, each pixel a grey value or [r, g, b] or [r, g, b, a], all from 0 to 255. Like manim’s ImageMobject, an image is sized so that one 1080 pixels tall would fill the frame’s height; scale_to_fit_height sets it directly.

An image is a shape: a rectangle with the pixels drawn inside, and no outline. It moves, scales, rotates, fades and groups like any other shape, and .stroke(color, width) gives it a border. create fades it in rather than drawing it. .pixelated() keeps pixels sharp when scaled up, instead of smoothing them.

let photo = scene.add(image("photo.png").scale_to_fit_height(4.0));
let ramp = [];
for i in 0..256 { ramp.push(i); }
let rows = [];
for row in 0..16 { rows.push(ramp); }
let bar = scene.add(image(rows).pixelated().next_to(scene.get(photo), DOWN));
scene.play(rotate(group([photo, bar]), PI / 8.0));

transform between two images moves one onto the other, and switches pixels at the end; use fade_out and fade_in to cross-fade. Image files can’t be loaded in the browser playground yet, but images built from pixels can.

Lottie animations

lottie(path) loads a Lottie file: the JSON that After Effects’ Bodymovin plugin, LottieFiles and many design tools export. It’s sized like an image (a composition 1080 pixels tall fills the frame’s height) and positioned like a group: .scale_to_fit_height, .next_to, .rotate and the rest place its whole frame. .at_frame(f) picks the frame it shows before it plays, the first by default, and .duration is its length in seconds.

Added, it is a group with one shape per fill and stroke in the file, which every group animation (fade_in, shift, scale, indicate, …) works on. play_lottie(m) plays it through once at its own frame rate. Its options are start and end frames (playing backwards if end comes first) and loops, and .run_time speeds it up or slows it down:

let anim = scene.add(lottie("bounce.json").scale_to_fit_height(5.0));
scene.play(fade_in(anim));
scene.play(play_lottie(anim, #{loops: 2}));
scene.play([shift(anim, LEFT * 3.0), play_lottie(anim, #{end: 30.0}).run_time(2.0)]);

play_lottie follows the group, so animations that move, fade or reorder it can play alongside it. They must come before play_lottie in the list, or the Lottie’s own frames overwrite them.

Lottie features drawn: shape, solid, null and precomposition layers, with parenting, time stretch and time remapping; groups, paths, rectangles, ellipses, stars and polygons; fills, strokes and trim paths; eased, held and spatial keyframes. Gradients are drawn in the average of their colours, and rounded star corners are drawn sharp. Masks, mattes, repeaters, dashes, line caps and joins, image layers and text layers are not drawn yet. Like image files, Lottie files can’t be loaded in the browser playground yet.

Groups

group([...]) puts shapes, texts, axes and other groups together, like manim’s VGroup. A group positions as one, and .arrange(dir) (or .arrange(dir, buff)) lays its items out in a row, keeping the group’s center.

let row = group([square(1.0), circle(0.5), text("x")]).arrange(RIGHT).to_edge(UP);
let g = scene.add(row);
scene.play(fade_in(g));
scene.play(rotate(g, PI));      // turns about the group's center
scene.play(indicate(g[1]));     // members by index; g[-1] is the last

scene.add(group) returns a group mobject with one member per item, in order. Every animation that takes a mobject takes a group too, except those that need one shape (transform, update, apply_function, always, scene.get) or one text (transform_diff). g.len() is the number of members.

group([a, b]) of mobjects already added makes a group mobject from them, to play one animation on all of them:

scene.play(fade_out(group([title, g])));

Axes

axes(x_range, y_range) makes manim-style axes, 12 by 6 units. Ranges are [min, max] or [min, max, step]; axes(x_range, y_range, width, height) sets the size.

fit_axes(f, x_range) (or fit_axes(f, x_range, width, height)) picks the y range for you: it samples f across x_range and rounds out to about 8 ticks of 1, 2 or 5 × 10ⁿ. manim has no equivalent.

MethodReturns
ax.c2p(x, y)Graph coordinates to a scene point
ax.p2c(p)Scene point to graph coordinates
ax.plot(f)Shape: graph of f across the whole x range
ax.plot(f, x0, x1)Shape: graph of f in graph coordinates
ax.plot(f, x0, x1, segments)The same in segments cubics instead of 64, for wiggly graphs
ax.vertical_line(p)Shape: line from the x-axis up to p
ax.numbers()Text: tick numbers
ax.labels(x, y)Text: axis labels, as Typst math
ax.shape()Shape: the axes themselves, with arrow tips (filled: recolor with .stroke(c, w).fill(c))

scene.add(ax) adds the axes as one shape.

let ax = axes([-3, 3], [-1.5, 1.5, 0.5]);
scene.play(create(scene.add(ax)));
scene.add(ax.numbers());
let graph = scene.add(ax.plot(|x| x.sin(), -3.0, 3.0).stroke(YELLOW, 0.05));
scene.play(create(graph));

Shader fills

.shader(wgsl) paints a shape’s fill with a WGSL fragment shader, run on the GPU every frame. The desktop preview and fastanim render draw it. SVG output and the browser player can’t run shaders, so they draw the shape’s solid fill instead.

let plasma = `
fn shade(uv: vec2<f32>) -> vec4<f32> {
    let p = (uv - 0.5) * shape.size;
    let t = shape.time;
    let v = sin(p.x * 3.0 + t) + sin(p.y * 3.0 - t) + sin(length(p) * 4.0 - t * 2.0);
    let c = 0.5 + 0.5 * cos(v + vec3<f32>(0.0, 2.0, 4.0));
    return vec4<f32>(c * shape.color.rgb, 1.0);
}`;
let disc = scene.add(circle(1.5).fill(BLUE).shader(plasma));
scene.play(create(disc));

The full scene is fastanim-script/scenes/shader.rhai.

Writing shade

The source defines one function, fn shade(uv: vec2<f32>) -> vec4<f32>, and can add helper functions and constants of its own. shade returns the color at uv as straight (not premultiplied) alpha sRGB, each part 0–1.

uv runs over the shape’s bounding box, from (0, 0) at the top left to (1, 1) at the bottom right. The shader only draws inside the shape, so a circle shows a disc cut from the square it fills.

The uniform shape holds:

FieldTypeValue
shape.colorvec4<f32>The fill color and opacity
shape.sizevec2<f32>The box’s width and height in scene units
shape.timef32Scene time in seconds

shape.time is the scene time, not the time since the shape appeared, so a frame depends only on t and seeking shows the same picture as playing. Use shape.size to keep a pattern the same size in scene units whatever the box’s shape: (uv - 0.5) * shape.size gives the position from the box’s center.

If the WGSL doesn’t compile, the error goes to stderr once and the shape draws its solid fill.

Style and animation

.shader keeps the fill color, using white if the shape had none, and passes it in as shape.color. So .fill before or after .shader tints the shader, and the same color is what SVG and the browser player draw. The fill’s opacity and the shape’s opacity multiply the shader’s alpha, so fade_in, fade_out and .fill(color, opacity) work as on a solid fill. The outline is drawn on top as usual.

The box is fixed when .shader is called and moves, scales and rotates with the shape after that, so the pattern turns with a rotate instead of staying upright. create draws the outline and fill in the usual way, showing the shader as the fill grows.

In a transform between two shader fills, the box blends from one shape’s to the other’s, and the shader source switches to the target’s at the end. A transform between a shader fill and a plain fill switches at the end too.

Each shader is drawn into a texture at the output’s resolution, at most 2048 pixels a side. The preview uses 1080p resolution whatever the window size.

Python

The Python method has the same name and takes the same source string:

disc = s.add(circle(1.5, fill=BLUE).shader(plasma))

Text, math and code

Text is typeset with Typst, using bundled fonts; no LaTeX install is needed. Each text is laid out into per-glyph shapes, split into tokens that diff transforms can match up.

FunctionTypesets
text(s)Plain text
math_tex(s)Math, in Typst math syntax
latex(s)Math, in LaTeX syntax (converted to Typst by MiTeX)
code(src, lang)Syntax-highlighted source code, e.g. code(src, "rust")
list([v1, v2, ...])A row of boxed values, centered on the origin
let a = math_tex("a = sqrt(c^2 - b^2)");
scene.add(a);
scene.add(latex(`\frac{a}{b} + \sqrt{c}`).next_to(a, DOWN));
scene.add(list([3, 1, 2]).to_edge(DOWN));

Rhai backtick strings span lines, which suits code:

const SRC = `fn main() {
    println!("hi");
}`;
let c = scene.add(code(SRC, "rust").scale(0.8));

Fonts

text, math_tex, latex and code take an options map as their last argument. Its one option so far is font, a font family name: one of the bundled fonts (Libertinus Serif, New Computer Modern, New Computer Modern Math, DejaVu Sans Mono), or one loaded from a file with load_font(path). load_font takes a TTF, OTF, TTC or OTC file, relative to the script’s directory, and returns the families it holds; loading the same file again does nothing.

let families = load_font("fonts/Inter-Regular.ttf");   // ["Inter"]
let title = scene.add(text("Hello", #{font: "Inter"}).to_edge(UP));
scene.add(code("let x = 1;", "rust", #{font: "DejaVu Sans Mono"}));

An unknown family is an error rather than a silent fallback. Math in another font falls back to New Computer Modern Math for the symbols that font lacks. Fonts can’t be loaded in the browser playground yet.

Methods

Texts take the positioning methods, plus:

MethodEffect
.fill(color)Color every glyph
.rotate(angle)Rotate about the center
.glyphsAn array of the glyph shapes

Animating text

Most animations apply to every glyph at once. write and unwrite draw glyph by glyph; move_to, scale and rotate move the text as a whole.

A text cannot be read back with scene.get, used with scene.always, or passed to transform or replacement_transform; those need shapes. Use transform_diff to change a text into another.

Animations

An animation takes a mobject handle (from scene.add) and is run with scene.play. Most last 1 second and ease smoothly.

let c = scene.add(circle(1.0));
scene.play(create(c));
scene.play(shift(c, RIGHT * 2.0).run_time(2.0).rate(THERE_AND_BACK));

Appearing and disappearing

AnimationEffect
create(m) / uncreate(m)Draw / undraw the outline
write(m) / unwrite(m)Write / unwrite, glyph by glyph for text
fade_in(m) / fade_out(m)Fade
fade_in(m, v) / fade_out(m, v)Fade while moving by v, e.g. fade_in(m, RIGHT) enters from the left
fade_in(m, k) / fade_out(m, k)Fade while scaling from / to k times the size
fade_in(m, v, k) / fade_out(m, v, k)Both
grow_from_center(m) / shrink_to_center(m)Grow from / shrink to a point
spin_in(m)Spin and grow in
draw_border_then_fill(m)Draw the outline, then fill

Disappearing animations leave the mobject in the scene, invisible. Call scene.remove(m) once it is gone.

Moving

AnimationEffect
shift(m, v)Move by v
move_to(m, p)Move the center to p
scale(m, k)Scale by k about the center
rotate(m, angle)Rotate by angle radians about the center

Changing shape

AnimationEffect
transform(m, shape)Morph shape m into shape
replacement_transform(m, other)Morph shape m into mobject other, which takes its place
apply_function(m, f)Move every point p of m to f(p)
update(m, f)Custom: f(start, alpha) returns m’s shape at progress alpha (0–1)
let s = scene.add(square(2.0));
scene.play(transform(s, circle(1.0).fill(RED)));
scene.play(apply_function(s, |p| point(p.x, p.y + p.x * 0.3)));
scene.play(update(s, |start, a| start.rotate(a * PI)));

For texts, see diff transforms.

Emphasis

AnimationEffect
indicate(m)Briefly enlarge and color
wiggle(m)Wiggle in place
circumscribe(m)Draw a box around m, then remove it
flash(p)Lines bursting out from point p

Timing and easing

.run_time(secs) sets an animation’s length. .rate(r) sets its easing:

RateShape
SMOOTHEase in and out (most animations’ default)
LINEARConstant speed
THERE_AND_BACKGo to the end and return
ease_in(name), ease_out(name), ease_in_out(name)name is "quad", "cubic", "expo" or "back"
spring(stiffness, damping)Overshoot and settle

Names

The preview’s timeline shows a block per animation, labelled with its kind (FadeIn, Write + Create). .named(title) labels it yourself:

scene.play(write(title).named("title"));

Composition

FunctionPlays
[a, b] passed to scene.playTogether
parallel([a, b])Together, as one animation
sequence([a, b])One after another
lagged_start(lag_ratio, [a, b, ...])Staggered: each starts lag_ratio of the way through the one before

These return animations, so they nest and take .run_time and .rate.

let dots = [];
for i in 0..5 {
    dots.push(scene.add(dot(point(i.to_float() - 2.0, 0.0))));
}
scene.play(lagged_start(0.2, dots.map(|d| grow_from_center(d))));
scene.play(sequence([indicate(dots[0]), indicate(dots[4])]));

Diff transforms

transform_diff(m, target) turns text m into text target by diffing their tokens with Myers’ diff. Unchanged tokens stay put or slide, moved tokens travel on an arc, and only real changes fade or morph. It works on any text: text, math_tex, latex and code.

let eq = scene.add(math_tex("a^2 + b^2 = c^2").scale(1.5));
scene.play(write(eq));
scene.play(transform_diff(eq, math_tex("a^2 = c^2 - b^2").scale(1.5)).run_time(1.5));

After the transform, m holds the new text, so it can be diffed again.

Code diffs line by line, then token by token within changed lines:

let src = scene.add(code(BEFORE, "rust"));
scene.play(transform_diff(src, code(AFTER, "rust")).run_time(2.0));

Options

A third argument, a map, tunes the choreography:

scene.play(transform_diff(eq, math_tex("a = sqrt(c^2 - b^2)"), #{ highlight_changes: true }));
OptionTypeDefaultEffect
highlight_changesboolfalseFlash the tokens that changed
lag_ratiofloat0.05Stagger between token moves
move_arcfloatPI / 3Arc angle of moved tokens, in radians
debugboolfalseTint tokens by how they were matched

Lists

transform_list(m, target) diffs two lists cell by cell. Two cells that swap places trade with a pair of arcing moves, which makes sorting algorithms easy to show:

let xs = scene.add(list([3, 1, 2]));
scene.play(transform_list(xs, list([1, 3, 2])));
scene.play(transform_list(xs, list([1, 2, 3])));

Updaters and closures

scene.always

scene.always(m, |state, t| ...) makes shape m follow a function from then on. The closure is called every frame and returns m’s whole shape at time t, in seconds since the always call.

// A dot orbits the origin once every four seconds.
let at = |t| point((TAU * t / 4.0).cos(), (TAU * t / 4.0).sin()) * 2.0;
let d = scene.add(dot(at.call(0.0)).fill(YELLOW));
scene.always(d, |s, t| dot(at.call(t)).fill(YELLOW));
scene.wait(4.0);

Index state with a mobject to read another shape’s current state, so one shape can follow another:

let c = scene.add(circle(0.5));
let label = scene.add(dot(ORIGIN));
scene.always(label, |s, t| dot(ORIGIN).next_to(s[c], UP));
scene.play(shift(c, RIGHT * 3.0));

Pure in t

Every frame must be a function of t and the scene state alone. The timeline can be scrubbed, rendered from a marker or sampled out of order, so a closure cannot carry state from one frame to the next.

Manim updaters often accumulate (dot.shift(dt * v) every frame). Rewrite them in terms of t:

// Instead of moving the dot a little each frame, say where it is at time t.
scene.always(d, |s, t| dot(point(-4.0 + t, 0.0)));

growing_wave.rhai in fastanim-script/scenes plots f(t) = sin(2πt)·t this way: the dot and its trace are both functions of t.

Closures

Closures are values. Call them with .call:

let f = |x| x * 2.0;
print(f.call(1.5));   // 3.0

They capture variables from where they are written, so helpers like at above can be shared between updaters. Functions declared with fn cannot see outer variables; pass scene and anything else in as arguments.

fn fade_all(scene, mobs) {
    scene.play(mobs.map(|m| fade_out(m)));
    for m in mobs {
        scene.remove(m);
    }
}

scene itself cannot be used inside a closure that runs during playback (updaters, apply_function, update, graph functions). Use state instead.

Sound

Scenes can play WAV files. The desktop preview plays them in time with the scene, and fastanim render mixes them into mp4 and webm output. GIFs and frame sequences have no sound, and the browser playground doesn’t play audio yet: audio fails there.

Loading

audio(path) loads a WAV file. A relative path is relative to the script’s directory. .duration is the file’s length in seconds.

let voice = audio("narration.wav");
print(voice.duration);

Playing and waiting

sound(a) is an animation that plays all of a. sound(a, start, end) plays only seconds start to end of it. Like any animation, it lasts as long as the clip, so scene.play waits for the audio to finish before the next call.

scene.play(sound(voice));                 // all of it, then carry on
scene.play(sound(voice, 1.5, 4.0));       // seconds 1.5 to 4 of the file

A sound combines with other animations like any other. Played together, the step lasts until the longest one finishes. In a sequence, each part starts when the previous one ends.

let title = scene.add(text("Hello"));
scene.play([write(title), sound(voice, 0.0, 2.5)]);
scene.play(sequence([sound(voice, 0.0, 1.0), fade_out(title)]));

run_time on a sound changes how long play waits. It doesn’t stretch the audio.

Playing in the background

scene.add_sound(a) and scene.add_sound(a, start, end) start a sound without waiting: time doesn’t advance, so the scene carries on while it plays. scene.wait_for_sounds() waits until every sound started so far has finished.

scene.add_sound(audio("music.wav"));
let c = scene.add(circle(1.0));
scene.play(create(c));
scene.wait_for_sounds();                  // until the music ends

A sound still playing when the scene ends is cut off there.

Previewing

The preview plays sounds while the clock runs at normal speed. Pausing, stepping or scrubbing silences them. When playback resumes, each sound picks up from the current time.

Gotchas

Write floats as floats

API functions take floats, and Rhai does not convert integers. point(1, 0) fails with Function not found: point (i64, i64). Write point(1.0, 0.0), and convert loop indices with .to_float():

for i in 0..5 {
    scene.add(dot(point(i.to_float(), 0.0)));
}

Axes ranges are the exception: axes([-3, 3], [-1, 1]) accepts integers.

Add before animating

Animations take the handle scene.add returns, not the shape. Animating something that was never added, or was removed, fails with not in the scene.

Shapes vs. texts

scene.get, scene.always, transform and replacement_transform need shapes. Texts are many glyphs; change them with transform_diff.

Closures run later

Closures passed to always, update, apply_function, function_graph and plot are called during baking, not where they are written. Errors in them are reported at the next scene.play. They cannot use scene; see updaters.

The standard library

Rhai’s standard library is available: sin, cos, min, max, abs, sqrt, to_float, len, map, filter, print and so on. See the Rhai book.

Limits

A script that runs too long or recurses too deep fails rather than hanging: it gets 10 million operations and 64 call levels.

API reference

Every function and constant a scene script can call, as printed by fastanim script-api. The first argument of a method is the value it is called on: fill(_: Shape, _: Color) is written shape.fill(color). get$x is the property .x, and index$get$ is state[m]. Rhai’s standard library is available too but not listed.

*(_: Point, _: f64) -> Point
*(_: f64, _: Point) -> Point
+(_: Point, _: Point) -> Point
-(_: Point) -> Point
-(_: Point, _: Point) -> Point
/(_: Point, _: f64) -> Point
add(_: &mut Scene, _: Axes) -> Mobject
add(_: &mut Scene, _: Group) -> Mobject
add(_: &mut Scene, _: Lottie) -> Mobject
add(_: &mut Scene, _: Shape) -> Mobject
add(_: &mut Scene, _: Text) -> Mobject
add_sound(_: &mut Scene, _: Audio) -> ()
add_sound(_: &mut Scene, _: Audio, _: f64, _: f64) -> ()
align_to(_: Axes, _: Group, _: Point) -> Axes
align_to(_: Axes, _: Shape, _: Point) -> Axes
align_to(_: Axes, _: Text, _: Point) -> Axes
align_to(_: Group, _: Group, _: Point) -> Group
align_to(_: Group, _: Shape, _: Point) -> Group
align_to(_: Group, _: Text, _: Point) -> Group
align_to(_: Lottie, _: Group, _: Point) -> Lottie
align_to(_: Lottie, _: Shape, _: Point) -> Lottie
align_to(_: Lottie, _: Text, _: Point) -> Lottie
align_to(_: Shape, _: Group, _: Point) -> Shape
align_to(_: Shape, _: Shape, _: Point) -> Shape
align_to(_: Shape, _: Text, _: Point) -> Shape
align_to(_: Text, _: Group, _: Point) -> Text
align_to(_: Text, _: Shape, _: Point) -> Text
align_to(_: Text, _: Text, _: Point) -> Text
always(_: &mut Scene, _: Mobject, _: Fn) -> ()
apply_function(_: Mobject, _: Fn) -> Animation
arc(_: f64, _: f64, _: f64) -> Shape
arrange(_: Group, _: Point) -> Group
arrange(_: Group, _: Point, _: f64) -> Group
at_frame(_: Lottie, _: f64) -> Lottie
audio(_: string) -> Audio
axes(_: array, _: array) -> Axes
axes(_: array, _: array, _: f64, _: f64) -> Axes
background(_: &mut Scene, _: Color) -> ()
c2p(_: &mut Axes, _: f64, _: f64) -> Point
center(_: Axes) -> Point
center(_: Group) -> Point
center(_: Lottie) -> Point
center(_: Shape) -> Point
center(_: Text) -> Point
circle(_: f64) -> Shape
circumscribe(_: Mobject) -> Animation
code(_: string, _: string) -> Text
code(_: string, _: string, _: map) -> Text
create(_: Mobject) -> Animation
dashed(_: Shape) -> Shape
dashed(_: Shape, _: i64) -> Shape
dashed(_: Shape, _: i64, _: f64) -> Shape
dashed_line(_: Point, _: Point) -> Shape
dashed_line(_: Point, _: Point, _: f64) -> Shape
difference(_: Shape, _: Shape) -> Shape
dot(_: Point) -> Shape
draw_border_then_fill(_: Mobject) -> Animation
ease_in(_: string) -> Rate
ease_in_out(_: string) -> Rate
ease_out(_: string) -> Rate
ellipse(_: f64, _: f64) -> Shape
exclusion(_: Shape, _: Shape) -> Shape
fade_in(_: Mobject) -> Animation
fade_in(_: Mobject, _: Point) -> Animation
fade_in(_: Mobject, _: Point, _: f64) -> Animation
fade_in(_: Mobject, _: f64) -> Animation
fade_out(_: Mobject) -> Animation
fade_out(_: Mobject, _: Point) -> Animation
fade_out(_: Mobject, _: Point, _: f64) -> Animation
fade_out(_: Mobject, _: f64) -> Animation
fill(_: Shape, _: Color) -> Shape
fill(_: Shape, _: Color, _: f64) -> Shape
fill(_: Text, _: Color) -> Text
fill(_: Text, _: Color, _: f64) -> Text
fill_gradient(_: Shape, _: array) -> Shape
fill_gradient(_: Shape, _: array, _: Point) -> Shape
fill_gradient(_: Text, _: array) -> Text
fill_gradient(_: Text, _: array, _: Point) -> Text
fill_opacity(_: Shape, _: f64) -> Shape
fit_axes(_: Fn, _: array) -> Axes
fit_axes(_: Fn, _: array, _: f64, _: f64) -> Axes
flash(_: Point) -> Animation
function_graph(_: Fn, _: f64, _: f64, _: i64) -> Shape
get$duration(_: &mut Audio) -> f64
get$duration(_: &mut Lottie) -> f64
get$glyphs(_: &mut Text) -> array
get$time(_: &mut Scene) -> f64
get$x(_: &mut Point) -> f64
get$y(_: &mut Point) -> f64
get(_: &mut Scene, _: Mobject) -> Shape
group(_: array) -> types::dynamic::Dynamic
grow_from_center(_: Mobject) -> Animation
height(_: Axes) -> f64
height(_: Group) -> f64
height(_: Lottie) -> f64
height(_: Shape) -> f64
height(_: Text) -> f64
hex(_: i64) -> Color
image(_: array) -> Shape
image(_: string) -> Shape
index$get$(_: &mut Mobject, _: i64) -> Mobject
index$get$(_: &mut State, _: Mobject) -> Shape
indicate(_: Mobject) -> Animation
intersection(_: Shape, _: Shape) -> Shape
labels(_: &mut Axes, _: string, _: string) -> Text
lagged_start(_: f64, _: array) -> Animation
latex(_: string) -> Text
latex(_: string, _: map) -> Text
len(_: &mut Group) -> i64
len(_: &mut Mobject) -> i64
line(_: Point, _: Point) -> Shape
line_cap(_: Shape, _: string) -> Shape
line_join(_: Shape, _: string) -> Shape
list(_: array) -> Text
load_font(_: string) -> array
lottie(_: string) -> Lottie
marker(_: &mut Scene, _: string) -> ()
math_tex(_: string) -> Text
math_tex(_: string, _: map) -> Text
move_to(_: Axes, _: Point) -> Axes
move_to(_: Group, _: Point) -> Group
move_to(_: Lottie, _: Point) -> Lottie
move_to(_: Mobject, _: Point) -> Animation
move_to(_: Shape, _: Point) -> Shape
move_to(_: Text, _: Point) -> Text
named(_: Animation, _: string) -> Animation
next_to(_: Axes, _: Group, _: Point) -> Axes
next_to(_: Axes, _: Group, _: Point, _: f64) -> Axes
next_to(_: Axes, _: Point, _: Point) -> Axes
next_to(_: Axes, _: Shape, _: Point) -> Axes
next_to(_: Axes, _: Shape, _: Point, _: f64) -> Axes
next_to(_: Axes, _: Text, _: Point) -> Axes
next_to(_: Axes, _: Text, _: Point, _: f64) -> Axes
next_to(_: Group, _: Group, _: Point) -> Group
next_to(_: Group, _: Group, _: Point, _: f64) -> Group
next_to(_: Group, _: Point, _: Point) -> Group
next_to(_: Group, _: Shape, _: Point) -> Group
next_to(_: Group, _: Shape, _: Point, _: f64) -> Group
next_to(_: Group, _: Text, _: Point) -> Group
next_to(_: Group, _: Text, _: Point, _: f64) -> Group
next_to(_: Lottie, _: Group, _: Point) -> Lottie
next_to(_: Lottie, _: Group, _: Point, _: f64) -> Lottie
next_to(_: Lottie, _: Point, _: Point) -> Lottie
next_to(_: Lottie, _: Shape, _: Point) -> Lottie
next_to(_: Lottie, _: Shape, _: Point, _: f64) -> Lottie
next_to(_: Lottie, _: Text, _: Point) -> Lottie
next_to(_: Lottie, _: Text, _: Point, _: f64) -> Lottie
next_to(_: Shape, _: Group, _: Point) -> Shape
next_to(_: Shape, _: Group, _: Point, _: f64) -> Shape
next_to(_: Shape, _: Point, _: Point) -> Shape
next_to(_: Shape, _: Shape, _: Point) -> Shape
next_to(_: Shape, _: Shape, _: Point, _: f64) -> Shape
next_to(_: Shape, _: Text, _: Point) -> Shape
next_to(_: Shape, _: Text, _: Point, _: f64) -> Shape
next_to(_: Text, _: Group, _: Point) -> Text
next_to(_: Text, _: Group, _: Point, _: f64) -> Text
next_to(_: Text, _: Point, _: Point) -> Text
next_to(_: Text, _: Shape, _: Point) -> Text
next_to(_: Text, _: Shape, _: Point, _: f64) -> Text
next_to(_: Text, _: Text, _: Point) -> Text
next_to(_: Text, _: Text, _: Point, _: f64) -> Text
numbers(_: &mut Axes) -> Text
p2c(_: &mut Axes, _: Point) -> Point
parallel(_: array) -> Animation
pixelated(_: Shape) -> Shape
play(_: &mut Scene, _: Animation) -> ()
play(_: &mut Scene, _: array) -> ()
play_lottie(_: Mobject) -> Animation
play_lottie(_: Mobject, _: map) -> Animation
plot(_: &mut Axes, _: Fn) -> Shape
plot(_: &mut Axes, _: Fn, _: f64, _: f64) -> Shape
plot(_: &mut Axes, _: Fn, _: f64, _: f64, _: i64) -> Shape
point(_: f64, _: f64) -> Point
polygon(_: array) -> Shape
rate(_: Animation, _: Rate) -> Animation
rectangle(_: f64, _: f64) -> Shape
remove(_: &mut Scene, _: Mobject) -> ()
replacement_transform(_: Mobject, _: Mobject) -> Animation
rgb(_: f64, _: f64, _: f64) -> Color
rotate(_: Group, _: f64) -> Group
rotate(_: Lottie, _: f64) -> Lottie
rotate(_: Mobject, _: f64) -> Animation
rotate(_: Shape, _: f64) -> Shape
rotate(_: Text, _: f64) -> Text
run_time(_: Animation, _: f64) -> Animation
scale(_: Axes, _: f64) -> Axes
scale(_: Group, _: f64) -> Group
scale(_: Lottie, _: f64) -> Lottie
scale(_: Mobject, _: f64) -> Animation
scale(_: Shape, _: f64) -> Shape
scale(_: Text, _: f64) -> Text
scale_to_fit_height(_: Axes, _: f64) -> Axes
scale_to_fit_height(_: Group, _: f64) -> Group
scale_to_fit_height(_: Lottie, _: f64) -> Lottie
scale_to_fit_height(_: Shape, _: f64) -> Shape
scale_to_fit_height(_: Text, _: f64) -> Text
scale_to_fit_width(_: Axes, _: f64) -> Axes
scale_to_fit_width(_: Group, _: f64) -> Group
scale_to_fit_width(_: Lottie, _: f64) -> Lottie
scale_to_fit_width(_: Shape, _: f64) -> Shape
scale_to_fit_width(_: Text, _: f64) -> Text
sequence(_: array) -> Animation
set_color_by_gradient(_: Group, _: array) -> Group
set_color_by_gradient(_: Text, _: array) -> Text
shader(_: Shape, _: string) -> Shape
shape(_: &mut Axes) -> Shape
shift(_: Axes, _: Point) -> Axes
shift(_: Group, _: Point) -> Group
shift(_: Lottie, _: Point) -> Lottie
shift(_: Mobject, _: Point) -> Animation
shift(_: Shape, _: Point) -> Shape
shift(_: Text, _: Point) -> Text
shrink_to_center(_: Mobject) -> Animation
sound(_: Audio) -> Animation
sound(_: Audio, _: f64, _: f64) -> Animation
spin_in(_: Mobject) -> Animation
spring(_: f64, _: f64) -> Rate
square(_: f64) -> Shape
stroke(_: Shape, _: Color, _: f64) -> Shape
stroke_gradient(_: Shape, _: array) -> Shape
stroke_gradient(_: Shape, _: array, _: Point) -> Shape
stroke_opacity(_: Shape, _: f64) -> Shape
text(_: string) -> Text
text(_: string, _: map) -> Text
to_debug(_: &mut Point) -> string
to_edge(_: Axes, _: Point) -> Axes
to_edge(_: Group, _: Point) -> Group
to_edge(_: Lottie, _: Point) -> Lottie
to_edge(_: Shape, _: Point) -> Shape
to_edge(_: Text, _: Point) -> Text
to_string(_: &mut Point) -> string
transform(_: Mobject, _: Shape) -> Animation
transform_diff(_: Mobject, _: Text) -> Animation
transform_diff(_: Mobject, _: Text, _: map) -> Animation
transform_list(_: Mobject, _: Text) -> Animation
uncreate(_: Mobject) -> Animation
union(_: Shape, _: Shape) -> Shape
unwrite(_: Mobject) -> Animation
update(_: Mobject, _: Fn) -> Animation
vertical_line(_: &mut Axes, _: Point) -> Shape
wait(_: &mut Scene) -> ()
wait(_: &mut Scene, _: f64) -> ()
wait_for_sounds(_: &mut Scene) -> ()
width(_: Axes) -> f64
width(_: Group) -> f64
width(_: Lottie) -> f64
width(_: Shape) -> f64
width(_: Text) -> f64
wiggle(_: Mobject) -> Animation
with_alpha(_: Color, _: f64) -> Color
write(_: Mobject) -> Animation
z_index(_: Shape, _: i64) -> Shape
const UP
const DOWN
const LEFT
const RIGHT
const ORIGIN
const WHITE
const BLACK
const BLUE
const RED
const GREEN
const YELLOW
const GREY
const ORANGE
const PINK
const PURPLE
const TRANSPARENT
const LINEAR
const SMOOTH
const THERE_AND_BACK
const DEFAULT_BUFF
const FRAME_WIDTH
const FRAME_HEIGHT
const PI
const TAU