Animated glyphs¶
pl.blender.export_animation_blend(..., bake_glyphs=True) extends the
GN-instanced glyph path with per-frame animation. The bridge
samples each registered GlyphSpec's source dataset at every frame
of the timeline, packs the per-channel values into float-precision
images embedded in the .blend, and injects a small Geometry Nodes
sub-graph upstream of the existing instancer so the saved file plays
back natively — no Python script, no auto-execution prompt.
A moving vector field¶
import numpy as np
import pyvista as pv
# Static grid of points; the per-frame updater rotates the orient vectors.
xs = np.linspace(-1, 1, 6)
xx, yy, zz = np.meshgrid(xs, xs, [0.0], indexing="ij")
points = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]).astype(np.float32)
cloud = pv.PolyData(points)
cloud["vec"] = np.tile([1.0, 0.0, 0.0], (cloud.n_points, 1)).astype(np.float32)
cloud["mag"] = np.ones(cloud.n_points, dtype=np.float32)
pl = pv.Plotter(off_screen=True, window_size=(960, 720))
pl.blender.add_glyph(
cloud, geom=pv.Arrow(), orient="vec", scale="mag", factor=0.3
)
pl.camera_position = [(3.5, -3.5, 3.5), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0)]
def update(frame: int) -> None:
# Spin the orient vectors around +Z and pulse the scalar mag.
theta = frame * (2.0 * np.pi / 24)
cloud["vec"] = np.tile(
[np.cos(theta), np.sin(theta), 0.0], (cloud.n_points, 1)
).astype(np.float32)
cloud["mag"] = (0.5 + 0.5 * np.sin(theta)) * np.ones(
cloud.n_points, dtype=np.float32
)
pl.blender.export_animation_blend(
"vector_field.blend",
update,
frames=range(24),
fps=24,
bake_camera=False,
bake_glyphs=True,
)
Open vector_field.blend and press Space — the arrows rotate and
pulse without any Python evaluation. The animation rides on
keyframed image samples in the GN modifier.
What gets baked¶
| Channel | Source | Packed format | GN sink |
|---|---|---|---|
positions |
spec.source.points |
RGB × N_frames × N_points (alpha=1) | Set Position node upstream of the instancer |
orient |
spec.source.point_data[orient] |
RGB × N_frames × N_points | Store Named Attribute("pv_orient", FLOAT_VECTOR) |
scale |
spec.source.point_data[scale] |
R × N_frames × N_points | Store Named Attribute("pv_scale", FLOAT) |
Each channel is baked independently. A channel whose per-frame values are constant across the entire timeline is detected and skipped so the saved tree stays clean (no Set Position node, no image, no sampler) and the existing static value continues to drive that channel.
Sampler topology¶
Inside the GN tree, upstream of the existing instancer:
Index ─┐
├─ (i + 0.5) / N_points → u
│
Scene Time ─ (frame - frame_start + 0.5) / N_frames → v
│
Combine XYZ (u, v) → Image Texture (packed)
│
└─ → Set Position / Store Named Attribute
↓
Existing instancer
The Image Texture node uses Closest interpolation so each frame
reads the exact pixel for the current Scene Time, not a temporal
blend across rows.
Constraints and caveats¶
- Constant topology. All frames must have the same
N_points. Topology-changing updaters aren't supported (no warning yet — the bake will just produce a malformed image). - No cell-data scalars.
orientandscalemust live onpoint_data(theadd_glyphAPI requires this for the static path too). - Float-precision images. Each channel is a 32-bit float OpenEXR
packed inside the
.blend. Per-channel size scales as4 bytes × N_frames × N_points × 4 channels. A 24-frame × 10,000- point bake is ~3.7 MB per channel. - Scene Time vs. frame remap. Playback reads
Scene Time.Framedirectly, so users who shiftscene.frame_startafter export will see the animation scroll past the baked range; re-bake or adjust the GNframe_startsubtract constant if you need to remap.