Render a video with Blender on GPUs

This example shows how you can render an animated 3D scene using Blender’s Python interface. We use Modal’s GPU workers for this.

Basic setup

import os
import tempfile

import modal

The S3 locations of the assets we want to render, and the frame ranges.

SCENE_FILENAME = (
    "https://modal-public-assets.s3.amazonaws.com/living_room_cam.blend"
)
MATERIALS_FILENAME = (
    "https://modal-public-assets.s3.amazonaws.com/living_room_final.mtl"
)

START_FRAME = 32
END_FRAME = 34

Defining the image

Blender requires a very custom image in order to run properly. In order to save you some time, we have precompiled the Python packages and stored them in a Dockerhub image.

dockerfile_commands = [
    "RUN export DEBIAN_FRONTEND=noninteractive && "
    "chown root:root /var /etc /usr /var/lib /var/log / && "  # needed for some weird systemd error
    '    echo "deb http://deb.debian.org/debian testing main contrib non-free" > /etc/apt/sources.list.d/testing.list && '
    "    apt update && "
    "    apt install -yq --no-install-recommends libcrypt1 && "
    "    apt install -yq --no-install-recommends"
    "        libgomp1 "
    "        xorg "
    "        openbox "
    "        xvfb "
    "        libxxf86vm1 "
    "        libxfixes3 "
    "        libgl1",
    "COPY --from=akshatb42/bpy:2.93-gpu"
    "     /usr/local/lib/python3.9/dist-packages/"
    "     /usr/local/lib/python3.9/site-packages/",
    "RUN apt install -yq curl",
    f"RUN curl -L -o scene.blend -C - '{SCENE_FILENAME}'",
    f"RUN curl -L -o scene.mtl -C - '{MATERIALS_FILENAME}'",
]
stub = modal.Stub(
    "example-blender-video",
    image=modal.Image.debian_slim(python_version="3.9").dockerfile_commands(
        dockerfile_commands
    ),
)

Setting things up in the containers

We need various global configuration that we want to happen inside the containers (but not locally), such as enabling the GPU device. To do this, we use the stub.is_inside() conditional, which will evaluate to False when the script runs locally, but to True when imported in the cloud.

if stub.is_inside():
    import bpy

    # NOTE: Blender segfaults if you try to do this after the other imports.
    bpy.ops.wm.open_mainfile(filepath="/scene.blend")
    bpy.data.scenes["Scene"].camera = bpy.data.objects.get("Camera.001")

    bpy.data.scenes[0].render.engine = "CYCLES"

    # Set the device_type
    bpy.context.preferences.addons[
        "cycles"
    ].preferences.compute_device_type = "CUDA"

    # Set the device and feature set
    bpy.context.scene.cycles.device = "GPU"

    bpy.context.preferences.addons["cycles"].preferences.get_devices()

    for d in bpy.context.preferences.addons["cycles"].preferences.devices:
        d["use"] = 1  # Using all devices, include GPU and CPU

    print(
        "Has active device:",
        bpy.context.preferences.addons[
            "cycles"
        ].preferences.has_active_device(),
    )

    bpy.data.scenes[0].render.tile_x = 64
    bpy.data.scenes[0].render.tile_y = 64
    bpy.data.scenes[0].cycles.samples = 200

Use a GPU from a Modal function

Now, let’s define the function that renders each frame in parallel. Note the gpu="any" argument which tells Modal to use GPU workers.

@stub.function(gpu="t4")
def render_frame(i):
    print(f"Using frame {i}")

    scn = bpy.context.scene
    scn.render.resolution_x = 400
    scn.render.resolution_y = 400
    scn.render.resolution_percentage = 100
    scn.frame_set(i)

    with tempfile.NamedTemporaryFile(suffix=".png") as tf:
        scn.render.filepath = tf.name
        # Render still frame
        bpy.ops.render.render(write_still=True)
        with open(tf.name, "rb") as image:
            img_bytes = bytearray(image.read())
            return i, img_bytes

Entrypoint

The code that gets run locally. Note that it doesn’t require Blender present to run it. In order to render in parallel, we use the .map method on the render_frame function. This spins up as many workers as are needed—as many as one for each frame, doing everything in parallel.

OUTPUT_DIR = "/tmp/render"


@stub.local_entrypoint()
def main():
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    # Render the frames in parallel using modal, and write them to disk.
    for idx, frame in render_frame.map(range(START_FRAME, END_FRAME + 1)):
        with open(os.path.join(OUTPUT_DIR, f"scene_{idx:03}.png"), "wb") as f:
            f.write(frame)

    # Stitch together frames into a gif.
    import glob

    from PIL import Image

    img, *imgs = [
        Image.open(f)
        for f in sorted(glob.glob(os.path.join(OUTPUT_DIR, "scene*.png")))
    ]
    img.save(
        fp=os.path.join(OUTPUT_DIR, "scene.gif"),
        format="GIF",
        append_images=imgs,
        save_all=True,
        duration=200,
        loop=0,
    )