Export to a .blend file¶
pl.blender.export_blend(path) writes the live PyVista scene to a
Blender file. Open it in Blender's UI to tweak materials, animate
something the bridge doesn't expose, add props, render at higher
quality, or stash a snapshot for later.
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=[800, 600])
pl.add_mesh(
pv.examples.download_bunny(),
color="#dec27c",
pbr=True,
metallic=0.3,
roughness=0.35,
)
pl.add_light(pv.Light(position=(5, -5, 5), light_type="scene light"))
pl.camera_position = [(0.2, 0.18, 0.25), (-0.02, 0.1, 0.0), (0, 1, 0)]
pl.blender.export_blend("bunny.blend")
pl.close()
bunny.blend opens cleanly in Blender 4.5+ / 5.x. All the actors,
materials, lights, world shader, and camera that pl.blender.render
would draw are present in the file.
What's preserved¶
| PyVista state | Survives the export |
|---|---|
Meshes (PolyData, UnstructuredGrid via extract_surface) |
yes — as bpy.types.Mesh |
| Materials (PBR, Phong, unlit, backface) | yes — full shader graphs |
| Light kits (SUN, POINT, SPOT, HEADLIGHT, CAMERA_LIGHT) | yes |
| World shader (solid / gradient / HDRI) | yes |
Glyphs registered via pl.blender.add_glyph(...) |
yes — as Geometry-Nodes-instanced objects |
| Camera (pose + clipping range + ortho/perspective) | yes |
| Identity cache | preserved — subsequent render() calls on this plotter still reuse data blocks |
What's not preserved¶
- HUD overlays (scalar bars / text / axes / bounds) are composited
after the Cycles render, not stored in the
.blend. To get them in Blender, set them up manually using Blender's compositor. - Subplot layouts get flattened —
export_blendwrites a single bpy scene; subplot tile compositing is a render-time operation.
In-session vs on-disk¶
export_blend uses bpy.ops.wm.save_as_mainfile(copy=True), which
means the in-memory bpy session doesn't switch over to the new
file. You can keep calling pl.blender.render(...) afterwards and
it'll continue rendering the same in-memory scene. Open the .blend
in a separate Blender instance to make changes that won't conflict
with the live session.
Animation: export_animation_blend¶
pl.blender.export_animation_blend(path, updater, frames, fps=...)
bakes a camera animation into the saved file. Open it in Blender and
press Space to play; render the timeline at higher quality
through the UI; tweak the f-curves to reshape the motion.
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=[800, 600])
pl.add_mesh(pv.examples.load_random_hills(), cmap="viridis")
pl.camera_position = "iso"
n_frames = 60
updater = pl.blender.orbit_camera(n_frames=n_frames)
pl.blender.export_animation_blend(
"hills_orbit.blend",
updater,
frames=range(n_frames),
fps=30,
)
pl.close()
The saved file has:
scene.cameracarrying keyframes onlocationandrotation_quaternion, one per frame inframes.scene.frame_start = frames[0],scene.frame_end = frames[-1],scene.render.fps = fps.- Quaternion rotation mode, so playback avoids Euler-interpolation surprises (gimbal flips, wrong-side rotations).
Selective channels¶
export_animation_blend gates each animation channel behind its own
kwarg so callers can bake only what they want and finish the rest
by hand in Blender:
pl.blender.export_animation_blend(
"scene.blend", updater, frames=range(60), fps=30,
bake_camera=True, # default; set False to keep camera static
bake_deformation="mdd", # False | True | "mdd" | "shape_keys"
)
Common patterns:
| Goal | kwargs |
|---|---|
| Camera-only orbit (default) | bake_camera=True, bake_deformation=False |
| Deformation-only, animate camera by hand | bake_camera=False, bake_deformation=True |
Both, MDD sidecar (default for True) |
bake_camera=True, bake_deformation=True |
| Self-contained .blend (no sidecar) | bake_camera=True, bake_deformation="shape_keys" |
Baking mesh deformation¶
Two backends, picked by bake_deformation:
"mdd" — Mesh Cache sidecar (default for True)¶
The bridge writes a Lightwave MDD vertex-cache file next to the
.blend (e.g. wave__PolyData.mdd) and adds a Mesh Cache modifier
on the corresponding bpy mesh pointing at it. Blender's built-in
modifier replays the cache on file open — no Python script in the
.blend, no auto-execution prompt.
import numpy as np
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=[800, 600])
plane = pv.Plane(i_resolution=80, j_resolution=80)
rest = plane.points.copy()
pl.add_mesh(plane, color="#dec27c", pbr=True)
pl.camera_position = [(5.0, -5.0, 5.0), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0)]
n_frames = 60
orbit = pl.blender.orbit_camera(n_frames=n_frames)
def update(frame: int) -> None:
orbit(frame)
r = np.linalg.norm(rest[:, :2], axis=1)
plane.points[:, 2] = rest[:, 2] + 0.3 * np.sin(2.0 * r - frame * 0.2)
pl.blender.export_animation_blend(
"wave.blend", update, frames=range(n_frames), fps=30,
bake_deformation=True, # → "mdd"
)
pl.close()
Ship both wave.blend and wave__PolyData.mdd together — the
modifier stores the path relative to the .blend so the pair stays
portable as long as they sit in the same directory.
"shape_keys" — self-contained morph targets¶
When you want everything inside the .blend and don't mind the
size, bake_deformation="shape_keys" uses one Shape Key per frame,
value-keyframed with linear interpolation.
Choosing between the two:
| Trade-off | "mdd" |
"shape_keys" |
|---|---|---|
.blend size on its own |
Small (no per-frame data) | Large (vertex sets inside the file) |
| Sidecar to ship | Yes (.mdd, uncompressed) |
No |
| Total on-disk size | Often larger — MDD is uncompressed; .blend zstd compresses Shape Keys well |
Often smaller for dense per-frame data thanks to zstd |
| Plays without auto-exec | Yes | Yes |
| Outliner clutter | None (one modifier) | One Shape Key per frame |
| Interpolation between frames | Linear (modifier setting) | Linear (linear keyframe points) |
| Constant topology required | Yes | Yes |
| Plays in Blender 4.x and 5.x | Yes | Yes |
Pick "mdd" for cleaner scene structure, easy diff-/edit-ability of the sidecar, and when you'd rather ship one .blend + one cache file than one heavy .blend. Pick "shape_keys" when total on-disk size matters more than Outliner cleanliness or when you need a fully self-contained file.
Cost and constraints (both backends)¶
- Constant topology required. If
mesh.points.shape[0]changes mid-animation, the bridge emits aUserWarningand skips the deformation bake for that mesh. The static state in the file still reflects the last frame. - Light and material animations beyond scalars are not yet keyframed in the saved file. The static state reflects the last frame.
Baking scalar field evolution¶
bake_scalars=True captures per-frame per-vertex scalar values for
every actor that has an active scalar field, applies the actor's
colormap, and builds a single PNG (rows = frames, columns = vertices,
RGBA = colormapped colour). The PNG is packed into the .blend
via Blender's standard image-pack mechanism, so the file stays
self-contained — only the .blend needs to ship. The bridge then
attaches a small Geometry Nodes modifier that samples the packed
image at (U = vertex_index / N_verts, V = scene_frame / N_frames)
on every frame and stores the result into the mesh's existing
"scalars" Color Attribute — which the material's scalar-aware
shader graph already reads. The morph plays back natively on file
open, no Python script needed, no auto-execution prompt.
import numpy as np
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=[800, 600])
plane = pv.Plane(i_resolution=80, j_resolution=80)
plane["heat"] = np.zeros(plane.n_points, dtype=np.float32)
pl.add_mesh(
plane,
scalars="heat",
cmap="viridis",
show_scalar_bar=False,
clim=[-1.0, 1.0],
)
pl.camera_position = [(3, -3, 3), (0, 0, 0), (0, 0, 1)]
n_frames = 60
def update(frame: int) -> None:
# A travelling sine wave on the heat field.
plane["heat"] = np.sin(
plane.points[:, 0] * 2.0 + frame * 0.2
).astype(np.float32)
pl.blender.export_animation_blend(
"heat.blend",
update,
frames=range(n_frames),
fps=30,
bake_scalars=True,
)
pl.close()
Cost and constraints¶
.blendsize grows by roughlyN_frames * N_verts * 4bytes (PNG- compressed). A 60-frame animation of a 6561-vertex plane adds ~50-200 KB depending on how much the field varies. The packed image lives inside the.blend— no external sidecar to ship.- Mixes freely with
bake_deformationandbake_camera— the GN modifier sits in the stack alongside the Mesh Cache modifier, both evaluated per frame. Note thatbake_deformation="mdd"still produces an external.mddsidecar (theMESH_CACHEmodifier can't read from packed data); usebake_deformation="shape_keys"if you need a fully self-contained.blend. - Constant topology required (same
n_pointsevery frame). Drifts trigger aUserWarningand skip the bake. - Both point-data and cell-data scalars are supported.
Point-data scalars index the image by vertex; cell-data scalars
index by polygon (the bridge re-runs
extract_surface().triangulate()per frame so the per-cell array aligns with the bpy mesh's post-translation polygon count). The Store Named Attribute domain switches between POINT and FACE accordingly; the material's existing scalar-aware shader graph picks up either domain. - Colormap must resolve from the actor. The actor must have been
added with
scalars=...+cmap=...soactor.mapper.lookup_table.cmapcarries the matplotlib colormap. Pure RGB-coloured meshes (no scalar field) are skipped silently.
Baking material properties¶
bake_materials=True keyframes each material's Principled BSDF
inputs per frame — Base Color (when no scalar field is driving it),
Metallic, Roughness, Alpha. Only inputs that actually vary across
frames are keyframed; static materials leave the saved action clean.
import numpy as np
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=(800, 600))
actor = pl.add_mesh(
pv.Sphere(),
color="#dec27c",
pbr=True,
metallic=0.0,
roughness=0.5,
)
pl.add_light(pv.Light(position=(5, -5, 5), light_type="scene light"))
pl.camera_position = [(3, 0, 0), (0, 0, 0), (0, 0, 1)]
n_frames = 60
def update(frame: int) -> None:
# Pulse the sphere from dielectric to metal and back.
actor.prop.metallic = 0.5 + 0.5 * np.sin(frame * (2 * np.pi / n_frames))
actor.prop.roughness = 0.1 + 0.4 * abs(np.sin(frame * (2 * np.pi / n_frames)))
pl.blender.export_animation_blend(
"pulse.blend",
update,
frames=range(n_frames),
fps=30,
bake_materials=True,
)
pl.close()
Open pulse.blend and the sphere's surface flickers between matte
dielectric and shiny metal as the timeline plays.
Notes:
- Base Color is skipped when scalars drive it. Materials with a visible scalar field already wire Base Color through a Color Attribute / Image Texture (the scalar-bake path owns that socket); the material bake leaves it alone.
- Phong → GGX conversion. Phong-shaded properties have their
specular_powerconverted to roughness via the Walter et al. (2007) fitα = √(2 / (n + 2)), same as the static path. - Dual-sided materials (front + backface BSDFs) get both nodes keyframed independently.
Baking actor transforms¶
bake_transforms=True keyframes each actor's user_matrix per frame.
The bpy mesh's vertex positions stay in local coords; the per-frame
transform rides on obj.matrix_world (decomposed into location /
rotation_quaternion / scale fcurves). Pairs naturally with the
static-render path that already respects user_matrix for one-frame
output.
import numpy as np
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=(800, 600))
sphere = pv.Sphere(radius=0.5)
actor = pl.add_mesh(sphere, color="#dec27c", pbr=True)
pl.add_mesh(pv.Cube(), color="gray", opacity=0.15)
pl.camera_position = [(6, 0, 3), (0, 0, 0), (0, 0, 1)]
n_frames = 60
def update(frame: int) -> None:
# Orbit the sphere around the origin in the XY plane.
angle = frame * (2.0 * np.pi / n_frames)
m = np.eye(4)
m[0, 3] = 2.0 * np.cos(angle)
m[1, 3] = 2.0 * np.sin(angle)
actor.user_matrix = m
pl.blender.export_animation_blend(
"orbit.blend",
update,
frames=range(n_frames),
fps=30,
bake_transforms=True,
)
pl.close()
Open orbit.blend and the sphere orbits the cube. Only varying
actors are keyframed; the cube (static user_matrix) leaves the
saved action untouched.
Baking light animation¶
bake_lights=True keyframes each pv.Light's world pose, intensity,
and colour per frame. The bridge mirrors the camera path: the
PyVista light is sampled at every frame_index, and the resulting
state lands on the corresponding PVLight_{i} bpy object's location
rotation_quaternionand on its light data-block'senergy/color. Only the channels that actually vary across frames get keyframed, so static lights stay clean.
import numpy as np
import pyvista as pv
pl = pv.Plotter(off_screen=True, window_size=[800, 600])
pl.add_mesh(pv.Sphere(), color="#dec27c", pbr=True)
pl.camera_position = [(3, 0, 0), (0, 0, 0), (0, 0, 1)]
# A light that orbits the sphere and pulses in intensity.
light = pv.Light(position=(5, 0, 5), light_type="scene light", intensity=1.0)
pl.add_light(light)
n_frames = 60
def update(frame: int) -> None:
angle = frame * (2.0 * np.pi / n_frames)
light.position = (5.0 * np.cos(angle), 5.0 * np.sin(angle), 5.0)
light.intensity = 0.5 + 0.5 * np.sin(angle * 2.0)
pl.blender.export_animation_blend(
"lit.blend", update, frames=range(n_frames), fps=30,
bake_lights=True,
)
pl.close()
The vtkLightKit default lights (PVLight_0 through PVLight_4)
stay static; only the explicit pv.Light added above carries
keyframes (it lands at PVLight_5).
Compression¶
bpy.ops.wm.save_as_mainfile accepts a compress kwarg, defaulting
to False (uncompressed). The bridge doesn't surface that knob —
the resulting file may end up zstd-compressed anyway on Blender 5.x
depending on the user-preferences override. Tests that need to be
robust should accept both the uncompressed BLENDER magic and the
zstd \\x28\\xb5\\x2f\\xfd magic.