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.
| Command | What 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
| Key | Action |
|---|---|
| Space | Play / pause |
| ← / → | Step a frame |
[ / ] | Jump to the previous / next marker |
| Drag along the timeline | Scrub |
| Click a block | Jump to its start and box the shapes it affects |
| Click it again / Esc | Deselect |
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) or4k60.-o, --output: the extension picks the format:mp4,webm,gif,pngorsvg.pngandsvgwrite one file per frame.mp4andwebminclude 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.
- Install the
rhaiextension from the extensions page (zed: extensions). It gives Zed the Rhai language that the server attaches to. - Clone the fastanim repository. Run
zed: install dev extensionand pick itseditors/zeddirectory. - Open a
.rhaifile.dev: open language server logsshows 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
| Call | Effect |
|---|---|
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.time | The 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
| Function | Example |
|---|---|
| Named constants | WHITE, 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.
| Function | Shape |
|---|---|
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.
| Method | Effect |
|---|---|
.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.
| Method | Effect |
|---|---|
.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.
| Method | Returns |
|---|---|
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:
| Field | Type | Value |
|---|---|---|
shape.color | vec4<f32> | The fill color and opacity |
shape.size | vec2<f32> | The box’s width and height in scene units |
shape.time | f32 | Scene 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.
| Function | Typesets |
|---|---|
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:
| Method | Effect |
|---|---|
.fill(color) | Color every glyph |
.rotate(angle) | Rotate about the center |
.glyphs | An 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
| Animation | Effect |
|---|---|
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
| Animation | Effect |
|---|---|
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
| Animation | Effect |
|---|---|
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
| Animation | Effect |
|---|---|
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:
| Rate | Shape |
|---|---|
SMOOTH | Ease in and out (most animations’ default) |
LINEAR | Constant speed |
THERE_AND_BACK | Go 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
| Function | Plays |
|---|---|
[a, b] passed to scene.play | Together |
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 }));
| Option | Type | Default | Effect |
|---|---|---|---|
highlight_changes | bool | false | Flash the tokens that changed |
lag_ratio | float | 0.05 | Stagger between token moves |
move_arc | float | PI / 3 | Arc angle of moved tokens, in radians |
debug | bool | false | Tint 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