Math
Equations, step-by-step derivations, annotations and matrices.
Equations
Typst math notation, display and inline equations, quantities with units, sizes and aligned multi-line equations.

gaanim examples/math/equations.pyCode · 183 lines
"""Equations: Typst math notation, inline and display math, quantities, sizes and aligned lines.
``scene.text.equation`` typesets Typst math given without its dollars, and
``scene.text`` mixes ``$...$`` math into prose. Each segment covers one part
of the typesetting API, and each label is the code that builds the equation
beside it.
"""
import os
from gaanim import Scene, Zones, quantity
WRITE = 1.2
HOLD = 0.8
SEGMENTS = 10
# Equations are set larger than the theme's math size (0.44) so that they
# read at a glance.
SIZE = 0.8
scene = Scene()
accent = scene.canvas.color("accent")
equation = scene.text.equation
def quoted(source):
"""The Python literal of a Typst source, as the code that passes it."""
quote = "'" if '"' in source else '"'
return quote + source.replace("\\", "\\\\") + quote
def typeset(source):
return equation(source, size=SIZE)
def table(rows, columns=None, build=typeset, tracks=("1fr", "1fr")):
"""Write one equation per cell, with each row's label to the left of its equations.
``rows`` are (label, value) pairs. Without ``columns``, ``build(value)``
makes each row's equation. Otherwise ``columns`` are (label, value) pairs
too, labelled in a header row, and ``build(row_value, column_value)``
makes each cell's equation.
"""
header = [] if columns is None else columns
tracks = [tracks[0]] + [tracks[1]] * max(len(header), 1)
grid = scene.layout.zones(
Zones.grid(rows=len(rows) + bool(header), columns=tracks, column_gap="48px", padding="page_padding")
)
for index, (label, _) in enumerate(header, start=1):
scene.text(label, role="code").place(grid[index], anchor="left")
equations = []
for row_index, (label, row) in enumerate(rows, start=bool(header)):
first = row_index * len(tracks)
scene.text(label, role="code").place(grid[first], anchor="right")
cells = [build(row)] if columns is None else [build(row, column) for _, column in columns]
for index, cell in enumerate(cells, start=1):
equations.append(cell.place(grid[first + index], anchor="left"))
scene.play([shown.animate.write().duration(WRITE) for shown in equations])
scene.wait(HOLD)
def sources(*typst):
"""Rows that typeset each Typst source, labelled with the source."""
return [(quoted(source), source) for source in typst]
# Typst math: scripts, fractions and roots...
scene.segment("notation")
table(sources(
"E = m c^2",
"x_1 + x_2 + dots.c + x_n",
"(a + b) / 2 = 1/2 (a + b)",
"sqrt(x) + root(3, y)",
))
# ...symbols by name, shorthands, accents and delimiters.
scene.segment("symbols")
table(sources(
"alpha + beta != pi",
"x <= y => f(x) <= f(y)",
"hat(x) + arrow(v) + overline(z)",
"abs(x) + norm(v) + floor(y)",
))
# Big operators take their limits above and below...
scene.segment("operators")
table(sources(
"sum_(k=1)^n k = (n(n + 1)) / 2",
"integral_0^1 x^2 dif x = 1/3",
"lim_(x -> 0) (sin x) / x = 1",
))
# ...and arrays line their entries up.
scene.segment("arrays")
table(
sources(
"mat(1, 2; 3, 4) vec(x, y)",
'cases(x "if" x >= 0, -x "if" x < 0)',
"binom(n, k) = (n!) / (k! (n - k)!)",
),
tracks=("4fr", "3fr"),
)
# scene.text sets $...$ inline, beside prose; spaces inside the dollars set a
# display equation, as scene.text.equation does, with its limits above and below.
scene.segment("inline")
table(
[
('scene.text("$sum_(k=1)^n 1/k$", size=0.8)', lambda: scene.text("$sum_(k=1)^n 1/k$", size=0.8)),
('scene.text("$ sum_(k=1)^n 1/k $", size=0.8)', lambda: scene.text("$ sum_(k=1)^n 1/k $", size=0.8)),
('scene.text("$H_n$ grows", size=0.8)', lambda: scene.text("$H_n$ grows", size=0.8)),
],
build=lambda make: make(),
tracks=("2fr", "1fr"),
)
# quantity() writes a number and its unit as math for scene.text.equation:
# a thin space, upright units, exponents and a slash...
scene.segment("quantities")
table(
[
('quantity(9.81, "m/s^2")', quantity(9.81, "m/s^2")),
('quantity(1, "m^3")', quantity(1, "m^3")),
('quantity(1000, "L")', quantity(1000, "L")),
('quantity(6, "kg m/s^2")', quantity(6, "kg m/s^2")),
],
)
# ...with negative exponents, degrees, fixed decimals and a decimal comma.
scene.segment("number_formats")
table(
[
('quantity(50, "s^-1")', quantity(50, "s^-1")),
('quantity(21.5, "°C")', quantity(21.5, "°C")),
("quantity(3.14159, decimals=2)", quantity(3.14159, decimals=2)),
('quantity(2.5, "kg", decimal_separator=",")', quantity(2.5, "kg", decimal_separator=",")),
],
tracks=("2fr", "1fr"),
)
# Size comes from the role, the size keyword or a scale...
scene.segment("sizes")
EULER = "e^(i pi) + 1 = 0"
table(
[
("equation(EULER)", lambda: equation(EULER)),
("equation(EULER, size=0.8)", lambda: equation(EULER, size=0.8)),
("equation(EULER, size=1.2)", lambda: equation(EULER, size=1.2)),
("equation(EULER).scale_to(2)", lambda: equation(EULER).scale_to(2)),
],
build=lambda make: make(),
)
# ...and color from the role, the color keyword or an opacity.
scene.segment("colors")
table(
[
('equation(EULER, size=0.8, role="caption")', lambda: equation(EULER, size=0.8, role="caption")),
("equation(EULER, size=0.8, color=accent)", lambda: equation(EULER, size=0.8, color=accent)),
("equation(EULER, size=0.8, opacity=0.5)", lambda: equation(EULER, size=0.8, opacity=0.5)),
],
build=lambda make: make(),
tracks=("2fr", "1fr"),
)
# A backslash breaks the line and & marks the column the lines align on;
# line_spacing widens the gap between the lines, Typst's own at 1.
scene.segment("aligned")
table(
sources(
"f &= x^2 \\ f' &= 2 x",
"x &= 2 + 3 \\ &= 5",
),
[("line_spacing=1.0", 1.0), ("line_spacing=1.6", 1.6)],
lambda source, spacing: equation(source, size=SIZE, line_spacing=spacing),
tracks=("5fr", "4fr"),
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-write and settled, in each segment.
scene.snapshots(snapshots, [(WRITE + HOLD) * index + t for index in range(SEGMENTS) for t in (0.6, 1.9)])
else:
scene.render()Equation parts
Named and nested equation parts, selections by name or fragment, per-term style and writing an equation part by part.

gaanim examples/math/equation_parts.pyCode · 146 lines
"""Equation parts: named terms, selections, per-term style and writing term by term.
``part`` and ``parts`` name pieces of an equation; indexing the equation
selects a part, a nested part, or every occurrence of a fragment, and the
selection takes a color or an animation without breaking the equation apart.
Each label is the code that differs from row to row.
"""
import os
from gaanim import Scene, Zones, part, parts
DURATION = 1.4
HOLD = 0.8
PARTS_END = DURATION + HOLD
FRAGMENTS_END = PARTS_END + DURATION + HOLD
STYLING_END = FRAGMENTS_END + 2 * DURATION + HOLD
# Equations are set larger than the theme's math size (0.44) so that they
# read at a glance.
SIZE = 0.8
scene = Scene()
accent = scene.canvas.color("accent")
# The pendulum's equation of motion. parts() names the mass and gravity
# terms; "inertia" nests parts given as a mapping, whose names need not be
# Python identifiers.
PENDULUM = (
"-",
parts(mass="m", gravity="g sin(theta)"),
"=",
part("inertia", parts({"mass": "m", "length": "L", "angle''": "theta''"})),
)
# a^2 + b^2 = c^2, one part per square, to write term by term.
PYTHAGORAS = (part("a", "a^2"), "+", part("b", "b^2"), "=", part("c", "c^2"))
def pendulum():
return scene.text.equation(*PENDULUM, size=SIZE)
def energy(mass=None):
"""E = m c^2, with ``mass`` (or a plain part) as the term named "mass"."""
return scene.text.equation("E =", mass or part("mass", "m"), "c^2", size=SIZE)
def table(rows, header=None, tracks=("1fr", "1fr")):
"""Lay out (label, equation) rows, labels on the left, and return the equations.
``header`` labels the equation column.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(rows) + bool(header), columns=list(tracks), column_gap="48px", padding="page_padding")
)
if header:
scene.text(header, role="code").place(grid[1], anchor="left")
equations = []
for index, (label, equation) in enumerate(rows, start=bool(header)):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
equations.append(equation.place(grid[2 * index + 1], anchor="left"))
return equations
def selections(rows):
"""Write one pendulum per (label, select) row, its selection filled with the accent."""
equations = table([(label, pendulum()) for label, _ in rows], header=".fill(accent)")
for (_, select), eq in zip(rows, equations):
select(eq).fill(accent)
scene.play([eq.animate.write().duration(DURATION) for eq in equations])
scene.wait(HOLD)
# A string index selects a top-level part and indexing again goes down a
# level...
scene.segment("parts")
selections([
('eq["gravity"]', lambda eq: eq["gravity"]),
('eq["mass"]', lambda eq: eq["mass"]),
('eq["inertia"]', lambda eq: eq["inertia"]),
('eq["inertia"]["mass"]', lambda eq: eq["inertia"]["mass"]),
('eq["inertia"]["angle\'\'"]', lambda eq: eq["inertia"]["angle''"]),
])
# ...and a string that names no part selects that fragment wherever it
# appears, with Typst symbol names and primes resolved to the glyphs they
# render.
scene.segment("fragments")
selections([
('eq["m"]', lambda eq: eq["m"]),
('eq["theta"]', lambda eq: eq["theta"]),
('eq["sin(theta)"]', lambda eq: eq["sin(theta)"]),
('eq["theta\'\'"]', lambda eq: eq["theta''"]),
])
# A selection restyles its glyphs at once or animates them; a part can also
# carry its own style when the equation is built.
scene.segment("styling")
still, filled, faded, both = energy(), energy(), energy(), energy()
equations = table(
[
('eq["mass"].fill(accent)', still),
('eq["mass"].animate.fill(accent)', filled),
('eq["mass"].animate.opacity(0.3)', faded),
('eq["mass"].animate.fill(accent).opacity(0.6)', both),
('part("mass", "m", color=accent)', energy(part("mass", "m", color=accent))),
('part("mass", "m", size=1.2)', energy(part("mass", "m", size=1.2))),
],
tracks=("5fr", "2fr"),
)
still["mass"].fill(accent)
scene.play([eq.animate.write().duration(DURATION) for eq in equations])
scene.play([
filled["mass"].animate.fill(accent).duration(DURATION),
faded["mass"].animate.opacity(0.3).duration(DURATION),
both["mass"].animate.fill(accent).opacity(0.6).duration(DURATION),
])
scene.wait(HOLD)
# write() traces the glyphs left to right; by="part" writes the terms of
# a^2 + b^2 = c^2 part by part, in the order given. Each label abbreviates
# eq.animate.write(...).
scene.segment("writing")
WRITES = [
("write()", {}),
("write(stagger=0.5)", {"stagger": 0.5}),
('write(by="part")', {"by": "part"}),
('write(by="part", order="reverse")', {"by": "part", "order": "reverse"}),
('write(by="part", order="center")', {"by": "part", "order": "center"}),
('write(by="part", order="random")', {"by": "part", "order": "random"}),
('write(brush="chalk")', {"brush": "chalk"}),
]
equations = table([(label, scene.text.equation(*PYTHAGORAS, size=SIZE)) for label, _ in WRITES], tracks=("8fr", "7fr"))
scene.play([eq.animate.write(**options).duration(DURATION) for (_, options), eq in zip(WRITES, equations)])
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-write and settled for parts and fragments; mid-way and settled
# while styling; early, mid and settled while writing.
scene.snapshots(snapshots, [
0.7, PARTS_END - 0.1, PARTS_END + 0.7, FRAGMENTS_END - 0.1,
FRAGMENTS_END + DURATION + 0.7, STYLING_END - 0.1,
STYLING_END + 0.3, STYLING_END + 0.7, STYLING_END + DURATION + HOLD - 0.1,
])
else:
scene.render()Equation annotations
Emphasis, braces, leader-line notes and hand-drawn marks on single terms of an equation.

gaanim examples/math/equation_annotations.pyCode · 130 lines
"""Equation annotations: emphasis, braces, leader-line notes and hand-drawn marks on single terms.
A selection of an equation, such as the part ``eq["rhs"]``, points at its
glyphs without breaking the equation apart: transient emphasis, braces and
notes that stay, and hand-drawn marks that follow the term. Each label is
the call that marks the term beside it.
"""
import os
from gaanim import Scene, Zones, part
WRITE = 0.8
MARK = 1.2
HOLD = 0.8
SEGMENT = WRITE + MARK + HOLD
SEGMENTS = 6
# Equations are set larger than the theme's math size (0.44) so that each
# mark reads at a glance.
SIZE = 1.0
scene = Scene()
accent = scene.canvas.color("accent")
def energy():
"""E = m c^2, with the parts mass and light inside the part rhs."""
return scene.text.equation(
part("energy", "E"), "=", part("rhs", part("mass", "m"), part("light", "c^2")), size=SIZE
)
def table(rows, tracks=("1fr", "1fr"), anchor="left"):
"""Write E = m c^2 in each row, labels on the left, then play every row's mark.
``rows`` are (label, mark) pairs, where ``mark(eq)`` returns the
animation that marks the row's equation; ``anchor`` places the equation
in its cell.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(rows), columns=list(tracks), column_gap="48px", padding="page_padding")
)
equations = []
for index, (label, _) in enumerate(rows):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
equations.append(energy().place(grid[2 * index + 1], anchor=anchor))
scene.play([eq.animate.write().duration(WRITE) for eq in equations])
scene.play([mark(eq).duration(MARK) for (_, mark), eq in zip(rows, equations)])
scene.wait(HOLD)
# Transient emphasis: the term moves and returns to rest...
scene.segment("emphasis")
table([
('eq["rhs"].animate.indicate()', lambda eq: eq["rhs"].animate.indicate()),
('eq["rhs"].animate.pulse()', lambda eq: eq["rhs"].animate.pulse()),
('eq["rhs"].animate.wiggle()', lambda eq: eq["rhs"].animate.wiggle()),
('eq["rhs"].animate.wave()', lambda eq: eq["rhs"].animate.wave()),
])
# ...or light passes around or under it. highlight tints it and back, focus
# keeps the rest of the equation dimmed, and the marker band stays.
scene.segment("highlights")
table([
('eq["rhs"].animate.flash_around()', lambda eq: eq["rhs"].animate.flash_around()),
('eq["rhs"].animate.flash_under()', lambda eq: eq["rhs"].animate.flash_under()),
('eq["rhs"].animate.highlight()', lambda eq: eq["rhs"].animate.highlight()),
('eq["rhs"].animate.focus()', lambda eq: eq["rhs"].animate.focus()),
('eq["rhs"].animate.marker(accent)', lambda eq: eq["rhs"].animate.marker(accent)),
])
# A brace over the term (above=True) or under it, and its label, stay on
# screen, colored like the term.
scene.segment("braces")
table(
[
('eq["rhs"].animate.brace("rest energy", above=True)', lambda eq: eq["rhs"].animate.brace("rest energy", above=True)),
('eq["rhs"].animate.brace()', lambda eq: eq["rhs"].animate.brace()),
('eq["rhs"].animate.brace("rest energy")', lambda eq: eq["rhs"].animate.brace("rest energy")),
('eq["rhs"]["mass"].animate.brace("mass")', lambda eq: eq["rhs"]["mass"].animate.brace("mass")),
],
tracks=("5fr", "2fr"),
)
# A note sits at offset from the term's center, joined to it by a leader
# line; each label abbreviates eq["energy"].animate.annotate(...).
scene.segment("notes")
table(
[
('annotate("energy")', lambda eq: eq["energy"].animate.annotate("energy")),
('annotate("energy", offset=(0, -0.6))', lambda eq: eq["energy"].animate.annotate("energy", offset=(0, -0.6))),
('annotate("energy", offset=(-2, 0.6))', lambda eq: eq["energy"].animate.annotate("energy", offset=(-2, 0.6))),
('annotate("energy", offset=(-2, -0.6))', lambda eq: eq["energy"].animate.annotate("energy", offset=(-2, -0.6))),
],
tracks=("3fr", "2fr"),
anchor="right",
)
def drawn(mark):
"""The animation that draws ``mark(eq["rhs"].annotate)`` on, for table()."""
return lambda eq: mark(eq["rhs"].annotate).animate.create()
# Hand-drawn marks are drawables that follow the term; mark.animate.create()
# draws them on, one pass after another. Without a color they take the
# theme's stroke. Each label abbreviates eq["rhs"].annotate.<mark>(...).
scene.segment("hand_drawn")
table([
("underline()", drawn(lambda notes: notes.underline())),
("box()", drawn(lambda notes: notes.box())),
("circle(padding=(0.3, 0.15))", drawn(lambda notes: notes.circle(padding=(0.3, 0.15)))),
("strike_through()", drawn(lambda notes: notes.strike_through())),
])
# Strikes and brackets, and the options every mark takes.
scene.segment("hand_drawn_options")
table([
("crossed_off()", drawn(lambda notes: notes.crossed_off())),
('bracket("right")', drawn(lambda notes: notes.bracket("right"))),
("box(roughness=0)", drawn(lambda notes: notes.box(roughness=0))),
("underline(color=accent, passes=1)", drawn(lambda notes: notes.underline(color=accent, passes=1))),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-mark and settled, in each segment.
scene.snapshots(snapshots, [SEGMENT * index + t for index in range(SEGMENTS) for t in (WRITE + MARK / 2, SEGMENT - 0.1)])
else:
scene.render()Derivations
Step-by-step derivations: structural transforms between equations, cancelled, revealed, copied and moved terms, and a worked solution.

gaanim examples/math/derivations.pyCode · 182 lines
"""Derivations: transform one equation into the next, and cancel, reveal, copy or move single terms.
``transform_to`` turns an equation into another: parts with the same name
travel to their new place, matching glyphs morph and the rest shrinks away or
grows in. Selections act on single terms. Each label is the call that
animates the row; the last segment chains the steps of a worked solution.
"""
import os
from gaanim import Scene, Zones, part
WRITE = 1.0
STEP = 1.2
HOLD = 0.8
SEGMENT = WRITE + STEP + HOLD
DERIVATION = 4 * SEGMENT + WRITE
# Equations are set larger than the theme's math size (0.44) so that they
# read at a glance; the worked solution larger still.
SIZE = 0.8
scene = Scene()
def equation(*content):
return scene.text.equation(*content, size=SIZE)
def table(labels, tracks):
"""Lay out one row per label, labels on the left; return the zones beside them."""
grid = scene.layout.zones(
Zones.grid(rows=len(labels), columns=list(tracks), column_gap="48px", padding="page_padding")
)
for index, label in enumerate(labels):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
return [grid[2 * index + 1] for index in range(len(labels))]
def morph(steps, tracks):
"""Write each (label, before, after) row's equation, then morph it into the target.
The label names the call; every row but the replacement_transform_to one
plays transform_to.
"""
zones = table([label for label, _, _ in steps], tracks)
morphs = []
for zone, (label, before, after) in zip(zones, steps):
source = equation(*before).place(zone, anchor="left")
# The target only lends its shape: hidden until the morph ends on it.
target = equation(*after).place(zone, anchor="left").hidden()
play = source.animate.replacement_transform_to if "replacement" in label else source.animate.transform_to
morphs.append((source, play(target)))
scene.play([source.animate.write().duration(WRITE) for source, _ in morphs])
scene.play([anim.duration(STEP) for _, anim in morphs])
scene.wait(HOLD)
# Each row writes an equation, then morphs it into the target named in its
# label: parts with the same name travel to their new place...
scene.segment("transform_to")
morph(
[
(
"eq.animate.transform_to(moved)",
("2", part("x", "x"), part("three", "+ 3"), "=", part("rhs", "11")),
("2", part("x", "x"), "=", part("rhs", "11"), part("three", "- 3")),
),
(
"eq.animate.transform_to(divided)",
(part("five", "5"), part("x", "x"), "=", part("rhs", "25")),
(part("x", "x"), "=", part("rhs", "25"), "/", part("five", "5")),
),
(
"eq.animate.transform_to(expanded)",
("E =", part("mass", "m"), "c^2"),
("E =", part("mass", "(m_1 + m_2)"), "c^2"),
),
(
"eq.animate.transform_to(swapped)",
(part("a", "a"), "+", part("b", "b"), "= c"),
(part("b", "b"), "+", part("a", "a"), "= c"),
),
],
tracks=("8fr", "7fr"),
)
# ...the unnamed row leaves the pairing to the glyphs alone, and
# replacement_transform_to ends with the target in the source's place.
scene.segment("matching")
morph(
[
(
"eq.animate.transform_to(solved)",
(part("x", "x"), "+ 3 =", part("rhs", "7")),
(part("x", "x"), "=", part("rhs", "4")),
),
("eq.animate.transform_to(unnamed)", ("x + 3 = 7",), ("x = 7 - 3",)),
(
"eq.animate.replacement_transform_to(solved)",
(part("x", "x"), "+ 3 =", part("rhs", "7")),
(part("x", "x"), "=", part("rhs", "4")),
),
],
tracks=("2fr", "1fr"),
)
# Selections animate one term inside an equation already on screen: a
# cancelled term is struck out, a revealed one stays hidden until it appears...
scene.segment("terms")
zones = table(
[
'eq["three"].animate.cancel()',
'eq["rhs"].animate.reveal("fade")',
'eq["rhs"].animate.reveal("from_below")',
'eq["rhs"].animate.reveal("wipe")',
],
tracks=("9fr", "7fr"),
)
cancelled = equation("x", part("three", "+ 3"), "= 7").place(zones[0], anchor="left")
pythagoras = [equation("a^2 + b^2 =", part("rhs", "c^2")).place(zone, anchor="left") for zone in zones[1:]]
scene.play([eq.animate.write().duration(WRITE) for eq in [cancelled, *pythagoras]])
scene.play([anim.duration(STEP) for anim in [
cancelled["three"].animate.cancel(),
pythagoras[0]["rhs"].animate.reveal("fade"),
pythagoras[1]["rhs"].animate.reveal("from_below"),
pythagoras[2]["rhs"].animate.reveal("wipe"),
]])
scene.wait(HOLD)
# ...and a term travels to its place in another equation, staying behind with
# copy_to or leaving with morph_to. The target term is hidden until it lands.
scene.segment("transfers")
zones = table(
['e["mass"].animate\n.copy_to(p["mass"])', 'e["mass"].animate\n.morph_to(p["mass"])'],
tracks=("4fr", "9fr"),
)
energies = [equation("E =", part("mass", "m"), "c^2").place(zone, anchor="left") for zone in zones]
momenta = [equation("p =", part("mass", "m"), "v").place(zone, anchor="right") for zone in zones]
scene.play([eq.animate.write().duration(WRITE) for eq in [*energies, *momenta]])
scene.play([anim.duration(STEP) for anim in [
energies[0]["mass"].animate.copy_to(momenta[0]["mass"]),
energies[1]["mass"].animate.morph_to(momenta[1]["mass"]),
]])
scene.wait(HOLD)
# A worked solution: each step morphs the equation in place into the next,
# then a flash goes around the result.
scene.segment("derivation")
SOLUTION = [
("2", part("x", "x"), part("three", "+ 3"), "=", part("rhs", "11")),
("2", part("x", "x"), "=", part("rhs", "11"), part("three", "- 3")),
("2", part("x", "x"), "=", part("rhs", "8")),
(part("x", "x"), "=", part("rhs", "8"), "/", part("two", "2")),
(part("x", "x"), "=", part("result", "4")),
]
center = scene.layout.zones(Zones.grid(rows=1, columns=1, padding="page_padding"))[0]
steps = [scene.text.equation(*content, size=1.4).place(center) for content in SOLUTION]
for later in steps[1:]:
# Each later step only lends its shape until a morph ends on it.
later.hidden()
scene.play([steps[0].animate.write(by="part").duration(WRITE)])
for current, following in zip(steps, steps[1:]):
scene.wait(0.3)
scene.play([current.animate.transform_to(following).duration(STEP)])
scene.play([steps[-1]["result"].animate.flash_around().duration(STEP)])
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Written, mid-morph and settled in transform_to; mid-way and settled in
# matching and transfers; written, mid-way and settled in terms; then the
# first step, mid-way through the third, and the flashed result.
scene.snapshots(snapshots, [
WRITE - 0.05, WRITE + STEP / 2, SEGMENT - 0.1,
SEGMENT + WRITE + STEP / 2, 2 * SEGMENT - 0.1,
2 * SEGMENT + WRITE - 0.05, 2 * SEGMENT + WRITE + STEP / 2, 3 * SEGMENT - 0.1,
3 * SEGMENT + WRITE + STEP / 2, 4 * SEGMENT - 0.1,
DERIVATION + 0.3 + STEP / 2, DERIVATION + 3 * (0.3 + STEP) - STEP / 2,
DERIVATION + 4 * (0.3 + STEP) + STEP / 2, DERIVATION + 4 * (0.3 + STEP) + STEP + HOLD - 0.1,
])
else:
scene.render()Matrices
Matrix delimiters, row and column labels, spacing, cell content and entry and label styles.

gaanim examples/math/matrices.pyCode · 136 lines
"""Matrices: delimiters, row and column labels, spacing, cell content and cell styles.
``scene.viz.matrix`` lays out one drawable per entry between two
delimiters, with optional labels beside the rows and above the columns. Each
cell shows one option, labelled with the code that differs from cell to cell.
"""
import os
from gaanim import MatrixEntry, Scene, TextStyle, Zones
WRITE = 1.0
HOLD = 0.8
SHOW = WRITE + HOLD
SEGMENTS = 5
# Entries are set larger than the theme's math size (0.44) so that each
# matrix reads at a glance; gaps and labels grow to match, and the delimiters
# follow the entries.
SIZE = 0.8
GROWTH = SIZE / 0.44
scene = Scene()
accent = scene.canvas.color("accent")
ACCENT = TextStyle(size=SIZE, color=accent)
DIGITS = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
SQUARE = [[1, 2], [3, 4]]
def matrix(data, **options):
"""scene.viz.matrix with entries of SIZE and its gaps scaled to them."""
sized = {
"entry_style": TextStyle(size=SIZE),
"label_style": TextStyle(size=0.8 * SIZE),
"row_gap": 0.24 * GROWTH,
"column_gap": 0.24 * GROWTH,
"delimiter_gap": 0.12 * GROWTH,
}
return scene.viz.matrix(data, **{**sized, **options})
def show(cells, columns, rows, label_height="48px"):
"""Place (label, matrix) cells in a grid, each label above its matrix, and write them."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="24px", padding="page_padding"))
for zone, (label, shown) in zip(grid, cells):
top, body = zone.split(Zones.rows([label_height, "1fr"]))
scene.text(label, role="code", text_align="center").place(top, anchor="bottom")
shown.place(body)
scene.play([shown.animate.write().duration(WRITE) for _, shown in cells])
scene.wait(HOLD)
# Delimiters around the entries.
scene.segment("delimiters")
show(
[
(f'delimiters="{name}"', matrix(DIGITS, delimiters=name))
for name in ("brackets", "parentheses", "braces", "bars", "double_bars", "none")
],
columns=3,
rows=2,
)
# Labels beside the rows and above the columns, as math or as plain text.
scene.segment("labels")
show(
[
('row_labels=["x", "y"]', matrix(SQUARE, row_labels=["x", "y"])),
('column_labels=["u", "v"]', matrix(SQUARE, column_labels=["u", "v"])),
(
'row_labels=["x", "y"],\ncolumn_labels=["u", "v"]',
matrix(SQUARE, row_labels=["x", "y"], column_labels=["u", "v"]),
),
(
'column_labels=["in", "out"],\nlabel_mode="text"',
matrix([[3, 9], [7, 2]], column_labels=["in", "out"], label_mode="text"),
),
],
columns=2,
rows=2,
label_height="96px",
)
# The gaps between entries and delimiters, and the delimiters' size and weight.
scene.segment("spacing")
show(
[
("row_gap=1.0, column_gap=1.0", matrix(SQUARE, row_gap=1.0, column_gap=1.0)),
("delimiter_gap=0.6", matrix(SQUARE, delimiter_gap=0.6)),
("delimiter_size=3.0", matrix(SQUARE, delimiter_size=3.0)),
("delimiter_weight=700", matrix(SQUARE, delimiter_weight=700)),
],
columns=2,
rows=2,
)
def dot(value, row, column):
"""A cell_factory: a dot whose area follows the entry."""
return scene.geometry.dot(0.1 * value**0.5)
# Entries are Typst math by default; text mode, a number format or a factory
# change what each cell shows. matrix() is scene.viz.matrix with larger entries.
scene.segment("entries")
ROTATION = [["cos theta", "-sin theta"], ["sin theta", "cos theta"]]
show(
[
("matrix(ROTATION)", matrix(ROTATION)),
('cell_mode="text"', matrix([["red", "green"], ["blue", "gray"]], cell_mode="text")),
('numeric_format=".2f"', matrix([[3.14159, 2.71828], [1.41421, 1.73205]], numeric_format=".2f")),
("cell_factory=dot", matrix(DIGITS, cell_factory=dot)),
],
columns=2,
rows=2,
)
# Styles for all the entries, for one entry or for the labels.
scene.segment("styles")
show(
[
("entry_style=ACCENT", matrix(SQUARE, entry_style=ACCENT)),
("MatrixEntry(4, style=ACCENT)", matrix([[1, 2], [3, MatrixEntry(4, style=ACCENT)]])),
('row_labels=["x", "y"],\nlabel_style=ACCENT', matrix(SQUARE, row_labels=["x", "y"], label_style=ACCENT)),
("entry_style=TextStyle(size=0.4)", matrix(SQUARE, entry_style=TextStyle(size=0.4))),
],
columns=2,
rows=2,
label_height="96px",
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-write and settled, in each segment.
scene.snapshots(snapshots, [SHOW * index + t for index in range(SEGMENTS) for t in (WRITE / 2, SHOW - 0.1)])
else:
scene.render()Matrix selections
Matrix row, column, block, diagonal and predicate selections, traversal orders and cell animations.

gaanim examples/math/matrix_selections.pyCode · 179 lines
"""Matrix selections: rows, columns, blocks, diagonals and predicates, traversal orders and cell animations.
Every entry of ``scene.viz.matrix`` is a drawable of its own, so a selection
of entries takes a style or an animation, cell after cell in the order you
pick. Each label is the code that differs from cell to cell.
"""
import os
from gaanim import MatrixSelection, Scene, TextStyle, Zones
WRITE = 1.0
CELL = 0.6
HOLD = 0.8
# A fill sweeps its selection in SPREAD seconds; a write staggers the nine
# entries 0.12 s apart; an animation staggers a row 0.2 s apart.
SPREAD = 0.45
FILL = WRITE + SPREAD + CELL + HOLD
ORDER = 8 * 0.12 + CELL + HOLD
ANIMATE = WRITE + 2 * 0.2 + CELL + HOLD
# Entries are set larger than the theme's math size (0.44) so that each
# matrix reads at a glance (larger still when a segment has one row of
# cells); gaps grow to match, and the delimiters follow the entries.
SIZE = 0.72
scene = Scene()
accent = scene.canvas.color("accent")
DIGITS = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
def matrix(size):
"""A 3 x 3 scene.viz.matrix with entries of ``size`` and its gaps scaled to them."""
growth = size / 0.44
return scene.viz.matrix(
DIGITS,
entry_style=TextStyle(size=size),
row_gap=0.24 * growth,
column_gap=0.24 * growth,
delimiter_gap=0.12 * growth,
)
def gallery(labels, rows=2):
"""Place a 3-column grid of cells, each label above a matrix; return the matrices."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=3, gap="24px", padding="page_padding"))
size = SIZE if rows > 1 else 0.95
matrices = []
for zone, label in zip(grid, labels):
# The label and the matrix (about 4.7 sizes tall) sit together, centered in the cell.
_, top, body, _ = zone.split(Zones.rows(["1fr", "48px", 4.7 * size, "1fr"]))
scene.text(label, role="code", text_align="center").place(top, anchor="bottom")
matrices.append(matrix(size).place(body))
return matrices
def is_even(entry, row, column):
return entry.value % 2 == 0
def fill_in(selected):
"""Animations that fill a selection cell after cell, or a single entry, with the accent."""
if isinstance(selected, MatrixSelection):
stagger = SPREAD / max(len(selected) - 1, 1)
return list(selected.animate.fill(accent, order="row_major", stagger=stagger).duration(CELL))
return [selected.animate.fill(accent).duration(CELL)]
def pick(selections):
"""Write a matrix per (label, select) cell, then fill each selection with the accent."""
matrices = gallery([label for label, _ in selections])
scene.play([shown.animate.write().duration(WRITE) for shown in matrices])
scene.play([anim for (_, select), shown in zip(selections, matrices) for anim in fill_in(select(shown))])
scene.wait(HOLD)
# Selections pick entries by row, column, index, slice, block or predicate;
# here each one fills with the accent, cell after cell. Two indices give the
# entry itself, a drawable.
scene.segment("selections")
pick([
("m.row(1)", lambda m: m.row(1)),
("m.column(2)", lambda m: m.column(2)),
("m[1, 1]", lambda m: m[1, 1]),
("m[:2, 1:]", lambda m: m[:2, 1:]),
("m.block(1, [0, 2])", lambda m: m.block(1, [0, 2])),
("m.where(is_even)", lambda m: m.where(is_even)),
])
# Diagonals, shifted by an offset.
scene.segment("diagonals")
pick([
("m.diagonal()", lambda m: m.diagonal()),
("m.diagonal(1)", lambda m: m.diagonal(1)),
("m.diagonal(-1)", lambda m: m.diagonal(-1)),
("m.anti_diagonal()", lambda m: m.anti_diagonal()),
("m.anti_diagonal(1)", lambda m: m.anti_diagonal(1)),
("m.anti_diagonal(-1)", lambda m: m.anti_diagonal(-1)),
])
def traverse(orders, rows):
"""Write each cell's entries in its order: m.entries.animate.write(order=..., stagger=0.12)."""
matrices = gallery([label for label, _ in orders], rows)
scene.play([
anim
for (_, options), shown in zip(orders, matrices)
for anim in shown.entries.animate.write(**options, stagger=0.12).duration(CELL)
])
scene.wait(HOLD)
# order= sets the sequence in which the entries are written...
scene.segment("orders")
traverse(
[(f'order="{name}"', {"order": name}) for name in (
"row_major", "column_major", "main_diagonal", "anti_diagonal", "spiral_in", "spiral_out",
)],
rows=2,
)
# ...shuffled with a seed, or sorted by a key of (row, column).
scene.segment("custom_orders")
traverse(
[
('order="random"', {"order": "random"}),
("order=lambda r, c: -c", {"order": lambda row, column: -column}),
('order="random", seed=7', {"order": "random", "seed": 7}),
],
rows=1,
)
def act(actions, rows):
"""Write a matrix per (label, action) cell, then play the action on its middle row."""
matrices = gallery([label for label, _ in actions], rows)
scene.play([shown.animate.write().duration(WRITE) for shown in matrices])
scene.play([anim for (_, action), shown in zip(actions, matrices) for anim in action(shown.row(1)).duration(CELL)])
scene.wait(HOLD)
# The same calls animate any selection; each label abbreviates
# m.row(1).animate.<call>. Some return to rest...
scene.segment("animations")
act(
[
("indicate()", lambda row: row.animate.indicate()),
("wiggle()", lambda row: row.animate.wiggle()),
("wiggle(stagger=0.2)", lambda row: row.animate.wiggle(stagger=0.2)),
("scale_by(1.4)", lambda row: row.animate.scale_by(1.4)),
("rotate_by(0.6)", lambda row: row.animate.rotate_by(0.6)),
("opacity(0.25)", lambda row: row.animate.opacity(0.25)),
],
rows=2,
)
# ...and some change the entries for good.
scene.segment("fills_and_fades")
act(
[
("fill(accent)", lambda row: row.animate.fill(accent)),
("fade_out()", lambda row: row.animate.fade_out()),
("fade_out(stagger=0.2)", lambda row: row.animate.fade_out(stagger=0.2)),
],
rows=1,
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-way and settled, in each segment.
scene.snapshots(snapshots, [
WRITE + 0.4, FILL - 0.1,
FILL + WRITE + 0.4, 2 * FILL - 0.1,
2 * FILL + 0.7, 2 * FILL + ORDER - 0.1,
2 * FILL + ORDER + 0.7, 2 * FILL + 2 * ORDER - 0.1,
2 * FILL + 2 * ORDER + WRITE + 0.3, 2 * FILL + 2 * ORDER + ANIMATE - 0.1,
2 * FILL + 2 * ORDER + ANIMATE + WRITE + 0.3, 2 * FILL + 2 * ORDER + 2 * ANIMATE - 0.1,
])
else:
scene.render()Matrix edits
Inserting, removing, swapping and reordering matrix rows and columns, setting entries, and morphing one matrix into another.

gaanim examples/math/matrix_edits.pyCode · 126 lines
"""Matrix edits: insert, remove, swap and reorder rows and columns, set entries, and morph into another matrix.
Structural edits change the matrix in place at the timeline cursor, so each
cell shows its matrix before and after the cut; ``morph_to`` instead
animates the entries into their places in another matrix, which stays. Each
label is the call that edits the matrix below it.
"""
import os
from gaanim import MatrixEntry, Scene, TextStyle, Zones
WRITE = 1.0
HOLD = 1.0
EDIT = WRITE + 2 * HOLD
MORPH = 1.4
scene = Scene()
accent = scene.canvas.color("accent")
DIGITS = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
def matrix(data, size=0.66, **options):
"""scene.viz.matrix with entries of ``size``, larger than the theme's math size (0.44).
The delimiters keep their automatic size, which follows the entries and
the number of rows through every edit.
"""
return scene.viz.matrix(data, entry_style=TextStyle(size=size), **{"column_gap": 0.36, **options})
# Morphs keep their shape, so their matrices are larger.
LARGE = {"size": 1.1, "row_gap": 0.4, "column_gap": 0.6}
def gallery(labels, data=None, **options):
"""Place a 2 x 2 grid of cells, each label above a matrix of ``data`` (DIGITS by default).
``options`` go to matrix(). Returns (matrix, zone) pairs, the zone being
where the matrix sits.
"""
grid = scene.layout.zones(Zones.grid(rows=2, columns=2, gap="24px", padding="page_padding"))
cells = []
for zone, label, values in zip(grid, labels, data or [DIGITS] * len(labels)):
top, body = zone.split(Zones.rows(["48px", "1fr"]))
scene.text(label, role="code").place(top, anchor="bottom")
cells.append((matrix(values, **options).place(body), body))
return cells
def edit(rows):
"""Write each cell's matrix, then apply every (label, change) at the same cut.
``change(m)`` edits the matrix, which stays in its zone.
"""
cells = gallery([label for label, _ in rows])
scene.play([shown.animate.write().duration(WRITE) for shown, _ in cells])
scene.wait(HOLD)
for (shown, _), (_, change) in zip(cells, rows):
change(shown)
scene.wait(HOLD)
scene.segment("rows")
edit([
("m.insert_row(1, [0, 0, 0])", lambda m: m.insert_row(1, [0, 0, 0])),
("m.remove_row(0)", lambda m: m.remove_row(0)),
("m.swap_rows(0, 2)", lambda m: m.swap_rows(0, 2)),
("m.reorder_rows([2, 0, 1])", lambda m: m.reorder_rows([2, 0, 1])),
])
scene.segment("columns")
edit([
("m.insert_column(1, [0, 0, 0])", lambda m: m.insert_column(1, [0, 0, 0])),
("m.remove_column(0)", lambda m: m.remove_column(0)),
("m.swap_columns(0, 2)", lambda m: m.swap_columns(0, 2)),
("m.reorder_columns([2, 0, 1])", lambda m: m.reorder_columns([2, 0, 1])),
])
# set() replaces one entry and returns its new drawable; become() replaces
# all the data, even with another shape.
scene.segment("values")
edit([
("m.set(1, 1, 0)", lambda m: m.set(1, 1, 0)),
("m.set(1, 1, 0).fill(accent)", lambda m: m.set(1, 1, 0).fill(accent)),
("m.become([[1, 0], [0, 1]])", lambda m: m.become([[1, 0], [0, 1]])),
("m.become([[1, 2, 3, 4]])", lambda m: m.become([[1, 2, 3, 4]])),
])
# morph_to pairs the entries of a with those of b (hidden, in the same
# place) by MatrixEntry key, then by value, then by position, unless match
# picks one rule.
scene.segment("morph")
def keyed(rows):
return [[MatrixEntry(name, key=name) for name in row] for row in rows]
MORPHS = [
("a.morph_to(b)", [[1, 2], [3, 4]], [[3, 1], [2, 5]], "auto"),
('a.morph_to(b, match="key")', keyed([["a", "b"], ["c", "d"]]), keyed([["a", "c"], ["b", "d"]]), "key"),
('a.morph_to(b, match="value")', [[1, 2], [3, 4]], [[2, 4], [1, 3]], "value"),
('a.morph_to(b, match="position")', [[1, 2], [3, 4]], [[2, 4], [1, 3]], "position"),
]
cells = gallery([label for label, *_ in MORPHS], [before for _, before, _, _ in MORPHS], **LARGE)
pairs = []
for (source, zone), (_, _, after, match) in zip(cells, MORPHS):
target = matrix(after, **LARGE)
target.place(zone)
target.hidden()
pairs.append((source, target, match))
scene.play([source.animate.write().duration(WRITE) for source, _, _ in pairs])
scene.wait(HOLD / 2)
scene.play([anim for source, target, match in pairs for anim in source.morph_to(target, match=match).duration(MORPH)])
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Before and after the cut in each edit segment; then mid-morph and settled.
scene.snapshots(snapshots, [
*[EDIT * index + t for index in range(3) for t in (WRITE + HOLD / 2, EDIT - 0.1)],
3 * EDIT + WRITE + HOLD / 2 + MORPH / 2, 3 * EDIT + WRITE + HOLD / 2 + MORPH + HOLD - 0.1,
])
else:
scene.render()Matrix operations
Exact matrix sums, products, inverses, determinants and LU, QR and eigen decompositions computed with SymPy.

Code · 161 lines
"""Matrix operations: exact sums, products, inverses, determinants and decompositions with SymPy.
Every operation of a matrix returns a ``MatrixDerivation`` whose ``result``
is a new matrix (or an equation, for a scalar) and whose ``animations()``
fill it in. They need the optional ``gaanim[algebra]`` extra, which installs
SymPy. Each label is the call that computes the row.
"""
import os
from gaanim import Matrix, Scene, TextStyle, Zones
WRITE = 0.8
CELL = 0.6
STAGGER = 0.1
HOLD = 0.8
# A 2 x 2 result fills in four entries, one STAGGER apart; a scalar is
# written at once.
MATRICES = WRITE + CELL + 3 * STAGGER + HOLD
SCALARS = WRITE + CELL + HOLD
# Entries are set larger than the theme's math size (0.44) so that each
# matrix reads at a glance; gaps, symbols and scalar results grow to match,
# and the delimiters follow the entries. The results inherit the operands'
# options: their size, gaps and inline fractions (3/2), which keep the height
# of the other entries.
SIZE = 0.66
GROWTH = SIZE / 0.44
scene = Scene()
accent = scene.canvas.color("accent")
A = [[1, 2], [3, 4]]
B = [[2, 0], [1, 2]]
def matrix(data):
"""A 2 x 2 scene.viz.matrix with entries of SIZE, its gaps scaled to them and inline fractions."""
return scene.viz.matrix(
data,
entry_style=TextStyle(size=SIZE),
fractions="inline",
row_gap=0.24 * GROWTH,
column_gap=0.3 * GROWTH,
delimiter_gap=0.12 * GROWTH,
)
def symbol(math):
return scene.text.equation(math, size=SIZE)
def table(rows, tracks=("1fr", "2fr")):
"""Lay out one row per (label, build), the label on the left.
``build()`` returns the operands, the derivation and the symbol between
them and the result. Each row writes its operands, then plays the
derivation's animations.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(rows), columns=list(tracks), column_gap="48px", padding="page_padding")
)
operands, solutions = [], []
for index, (label, build) in enumerate(rows):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
items, derivation = build()
results = derivation.result if isinstance(derivation.result, tuple) else (derivation.result,)
for result in results:
if not isinstance(result, Matrix):
result.scale_to(GROWTH)
# A matrix is laid out through its drawable, the root of its grid.
shown = [getattr(item, "drawable", item) for item in [*items, *results]]
grid[2 * index + 1].arrange(*shown, gap="24px", justify="start")
operands.extend(getattr(item, "drawable", item) for item in items)
solutions.extend(derivation.animations(stagger=STAGGER))
scene.play([operand.animate.write().duration(WRITE) for operand in operands])
scene.play([anim.duration(CELL) for anim in solutions])
scene.wait(HOLD)
def binary(operation, sign):
"""Operands a, sign, b and "=", with a.<operation>(b)."""
a, b = matrix(A), matrix(B)
return [a, symbol(sign), b, symbol("=")], getattr(a, operation)(b)
def unary(compute, data=A):
"""Operand a and an arrow, with compute(a)."""
a = matrix(data)
return [a, symbol("arrow.r")], compute(a)
# Entry by entry: sum, difference, Hadamard product and a scalar multiple.
scene.segment("elementwise")
table([
("a.add(b)", lambda: binary("add", "+")),
("a.subtract(b)", lambda: binary("subtract", "-")),
("a.hadamard(b)", lambda: binary("hadamard", "dot.o")),
("a.scalar_multiply(2)", lambda: unary(lambda a: a.scalar_multiply(2))),
])
def product():
"""a b, with the row and column that make the first entry in the accent."""
items, derivation = binary("matmul", "dot")
a, _, b, _ = items
a.row(0).fill(accent)
b.column(0).fill(accent)
derivation.result[0, 0].fill(accent)
return items, derivation
# Products, transposes and the reduced row echelon form...
scene.segment("products")
table([
("a.matmul(b)", product),
("a.transpose()", lambda: unary(lambda a: a.transpose())),
("a.rref()", lambda: unary(lambda a: a.rref())),
])
# ...are exact: an inverse keeps its fractions unless exact=False evaluates
# it to a precision.
scene.segment("inverses")
table(
[
("a.inverse()", lambda: unary(lambda a: a.inverse())),
("a.inverse(exact=False,\nprecision=3)", lambda: unary(lambda a: a.inverse(exact=False, precision=3))),
],
tracks=("5fr", "9fr"),
)
# Scalar results are equations.
scene.segment("scalars")
table([
("a.determinant()", lambda: unary(lambda a: a.determinant())),
("a.trace()", lambda: unary(lambda a: a.trace())),
("a.rank()", lambda: unary(lambda a: a.rank())),
])
# Decompositions return two matrices: L and U, Q and R, eigenvalues and
# eigenvectors.
scene.segment("decompositions")
table(
[
("a.lu()", lambda: unary(lambda a: a.lu())),
("a.qr()", lambda: unary(lambda a: a.qr(), [[3, 0], [4, 5]])),
("a.eigen()", lambda: unary(lambda a: a.eigen(), [[2, 1], [1, 2]])),
],
tracks=("1fr", "4fr"),
)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Mid-way and settled, in each segment.
scene.snapshots(snapshots, [
WRITE + 0.4, MATRICES - 0.1,
MATRICES + WRITE + 0.4, 2 * MATRICES - 0.1,
2 * MATRICES + WRITE + 0.4, 3 * MATRICES - 0.1,
3 * MATRICES + WRITE + 0.3, 3 * MATRICES + SCALARS - 0.1,
3 * MATRICES + SCALARS + WRITE + 0.4, 4 * MATRICES + SCALARS - 0.1,
])
else:
scene.render()