diff --git a/06_gpu_and_ml/blender/IceModal.blend b/06_gpu_and_ml/blender/IceModal.blend
new file mode 100644
index 000000000..9af6df630
Binary files /dev/null and b/06_gpu_and_ml/blender/IceModal.blend differ
diff --git a/06_gpu_and_ml/blender/blender_video.py b/06_gpu_and_ml/blender/blender_video.py
index c58c4cbf4..cffd9ac67 100644
--- a/06_gpu_and_ml/blender/blender_video.py
+++ b/06_gpu_and_ml/blender/blender_video.py
@@ -8,16 +8,18 @@
#
# You can run it on CPUs to scale out on one hundred containers
# or run it on GPUs to get higher throughput per node.
-# Even with this simple scene, GPUs render 10x faster than CPUs.
+# Even for this simple scene, GPUs render 10x faster than CPUs.
#
# The final render looks something like this:
#
-# ![Spinning Modal logo](https://modal-public-assets.s3.amazonaws.com/modal-blender-render.gif)
+#
+#
+#
#
# ## Defining a Modal app
-import io
-import math
from pathlib import Path
import modal
@@ -25,7 +27,7 @@
# Modal runs your Python functions for you in the cloud.
# You organize your code into apps, collections of functions that work together.
-app = modal.App("examples-blender-logo")
+app = modal.App("examples-blender-video")
# We need to define the environment each function runs in -- its container image.
# The block below defines a container image, starting from a basic Debian Linux image
@@ -51,76 +53,36 @@
# Note that in addition to defining the hardware requirements of the function,
# we also specify the container image that the function runs in (the one we defined above).
-# The details of the rendering function aren't too important for this example,
-# so we abstract them out into functions defined at the end of the file.
-# We draw a simple version of the Modal logo:
-# two neon green rectangular prisms facing different directions.
-# We include a parameter to rotate the prisms around the vertical/Z axis,
-# which we'll use to animate the logo.
+# The details of the scene aren't too important for this example, but we'll load
+# a .blend file that we created earlier. This scene contains a rotating
+# Modal logo made of a transmissive ice-like material, with a generated displacement map. The
+# animation keyframes were defined in Blender.
@app.function(
gpu="A10G" if WITH_GPU else None,
- concurrency_limit=10
- if WITH_GPU
- else 100, # default limits on Modal free tier
+ # default limits on Modal free tier
+ concurrency_limit=10 if WITH_GPU else 100,
image=rendering_image,
)
-def render(angle: int = 0) -> bytes:
- """
- Renders Modal's logo, two neon green rectangular prisms.
-
-
- Args:
- angle: How much to rotate the two prisms around the vertical/Z axis, in degrees.
-
- Returns:
- The rendered frame as a PNG image.
- """
+def render(blend_file: bytes, frame_number: int = 0) -> bytes:
+ """Renders the n-th frame of a Blender file as a PNG."""
import bpy
- # clear existing objects
- bpy.ops.object.select_all(action="DESELECT")
- bpy.ops.object.select_by_type(type="MESH")
- bpy.ops.object.delete()
-
- # ctx: the current Blender state, which we mutate
- ctx = bpy.context
-
- # scene: the 3D environment we are rendering and its camera(s)
- scene = ctx.scene
-
- # configure rendering -- CPU or GPU, resolution, etc.
- # see function definition below for details
- configure_rendering(ctx, WITH_GPU)
-
- scene.render.image_settings.file_format = "PNG"
- scene.render.filepath = "output.png"
+ input_path = "/tmp/input.blend"
+ output_path = f"/tmp/output-{frame_number}.png"
- # set background to black
- black = (0, 0, 0, 1)
- scene.world.node_tree.nodes["Background"].inputs[0].default_value = black
+ # Blender requires input as a file.
+ Path(input_path).write_bytes(blend_file)
- # add the Modal logo: two neon green rectangular prisms
- iridescent_material = create_iridescent_material()
-
- add_prism(ctx, (-2.07, -1, 0), 45, angle, iridescent_material)
- add_prism(ctx, (2.07, 1, 0), -45, angle, iridescent_material)
-
- # add lighting and camera
- add_lighting()
- bpy.ops.object.camera_add(location=(7, -7, 5))
- scene.camera = bpy.context.object
- ctx.object.rotation_euler = (1.1, 0, 0.785)
-
- # render
+ bpy.ops.wm.open_mainfile(filepath=input_path)
+ bpy.context.scene.frame_set(frame_number)
+ bpy.context.scene.render.filepath = output_path
+ configure_rendering(bpy.context, with_gpu=WITH_GPU)
bpy.ops.render.render(write_still=True)
- # return the bytes to the caller
- with open(scene.render.filepath, "rb") as image_file:
- image_bytes = image_file.read()
-
- return image_bytes
+ # Blender renders image outputs to a file as well.
+ return Path(output_path).read_bytes()
# ### Rendering with acceleration
@@ -133,77 +95,68 @@ def render(angle: int = 0) -> bytes:
def configure_rendering(ctx, with_gpu: bool):
# configure the rendering process
ctx.scene.render.engine = "CYCLES"
- ctx.scene.render.resolution_x = 1920
- ctx.scene.render.resolution_y = 1080
- ctx.scene.render.resolution_percentage = 100
+ ctx.scene.render.resolution_x = 3000
+ ctx.scene.render.resolution_y = 2000
+ ctx.scene.render.resolution_percentage = 50
ctx.scene.cycles.samples = 128
- # add GPU acceleration if available
+ cycles = ctx.preferences.addons["cycles"]
+
+ # Use GPU acceleration if available.
if with_gpu:
- ctx.preferences.addons[
- "cycles"
- ].preferences.compute_device_type = "CUDA"
+ cycles.preferences.compute_device_type = "CUDA"
ctx.scene.cycles.device = "GPU"
# reload the devices to update the configuration
- ctx.preferences.addons["cycles"].preferences.get_devices()
- for device in ctx.preferences.addons["cycles"].preferences.devices:
+ cycles.preferences.get_devices()
+ for device in cycles.preferences.devices:
device.use = True
else:
ctx.scene.cycles.device = "CPU"
# report rendering devices -- a nice snippet for debugging and ensuring the accelerators are being used
- for dev in ctx.preferences.addons["cycles"].preferences.devices:
+ for dev in cycles.preferences.devices:
print(
f"ID:{dev['id']} Name:{dev['name']} Type:{dev['type']} Use:{dev['use']}"
)
-# ## Combining frames into a GIF
+# ## Combining frames into a video
#
# Rendering 3D images is fun, and GPUs can make it faster, but rendering 3D videos is better!
# We add another function to our app, running on a different, simpler container image
-# and different hardware, to combine the frames into a GIF.
+# and different hardware, to combine the frames into a video.
-combination_image = modal.Image.debian_slim(python_version="3.11").pip_install(
- "pillow==10.3.0"
+combination_image = modal.Image.debian_slim(python_version="3.11").apt_install(
+ "ffmpeg"
)
# The video has a few parameters, which we set here.
FPS = 60
-FRAME_DURATION_MS = 1000 // FPS
-NUM_FRAMES = 360 # drop this for faster iteration while playing around
+FRAME_COUNT = 250
+FRAME_SKIP = 1 # increase this to skip frames and speed up rendering
-# The function to combine the frames into a GIF takes a sequence of byte sequences, one for each rendered frame,
-# and converts them into a single sequence of bytes, the GIF.
+# The function to combine the frames into a video takes a sequence of byte sequences, one for each rendered frame,
+# and converts them into a single sequence of bytes, the MP4 file.
@app.function(image=combination_image)
-def combine(
- frames_bytes: list[bytes], frame_duration: int = FRAME_DURATION_MS
-) -> bytes:
- print("🎞️ combining frames into a gif")
- from PIL import Image
-
- frames = [
- Image.open(io.BytesIO(frame_bytes)) for frame_bytes in frames_bytes
- ]
-
- gif_image = io.BytesIO()
- frames[0].save(
- gif_image,
- format="GIF",
- save_all=True,
- append_images=frames[1:],
- duration=frame_duration,
- loop=0,
- )
-
- gif_image.seek(0)
-
- return gif_image.getvalue()
+def combine(frames_bytes: list[bytes], fps: int = FPS) -> bytes:
+ import subprocess
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ for i, frame_bytes in enumerate(frames_bytes):
+ frame_path = Path(tmpdir) / f"frame_{i:05}.png"
+ frame_path.write_bytes(frame_bytes)
+ out_path = Path(tmpdir) / "output.mp4"
+ subprocess.run(
+ f"ffmpeg -framerate {fps} -pattern_type glob -i '{tmpdir}/*.png' -c:v libx264 -pix_fmt yuv420p {out_path}",
+ shell=True,
+ )
+ return out_path.read_bytes()
# ## Rendering in parallel in the cloud from the comfort of the command line
@@ -213,14 +166,14 @@ def combine(
# First, we need a function that coordinates our functions to `render` frames and `combine` them.
# We decorate that function with `@app.local_entrypoint` so that we can run it with `modal run blender_video.py`.
#
-# In that function, we use `render.map` to map the `render` function over a `range` of `angle`s,
-# so that the logo will appear to spin in the final video.
+# In that function, we use `render.map` to map the `render` function over the range of frames,
+# so that the logo will spin in the final video.
#
# We collect the bytes from each frame into a `list` locally and then send it to `combine` with `.remote`.
#
# The bytes for the video come back to our local machine, and we write them to a file.
#
-# The whole rendering process (for six seconds of 1080p 60 FPS video) takes about five minutes to run on 10 A10G GPUs,
+# The whole rendering process (for 4 seconds of 1080p 60 FPS video) takes about five minutes to run on 10 A10G GPUs,
# with a per-frame latency of about 10 seconds, and about five minutes to run on 100 CPUs, with a per-frame latency of about one minute.
@@ -228,114 +181,18 @@ def combine(
def main():
output_directory = Path("/tmp") / "render"
output_directory.mkdir(parents=True, exist_ok=True)
- filename = output_directory / "output.gif"
- with open(filename, "wb") as out_file:
- out_file.write(
- combine.remote(list(render.map(range(0, 360, 360 // NUM_FRAMES))))
- )
- print(f"Image saved to {filename}")
-
-
-# ## Addenda
-#
-# The remainder of the code in this example defines the details of the render.
-# It's not particularly interesting, so we put it the end of the file.
-
-
-def add_prism(ctx, location, initial_rotation, angle, material):
- """Add a prism at a given location, rotation, and angle, made of the provided material."""
- import bpy
- import mathutils
-
- bpy.ops.mesh.primitive_cube_add(size=2, location=location)
- obj = ctx.object # the newly created object
- bevel = obj.modifiers.new(name="Bevel", type="BEVEL")
- bevel.width = 0.2
- bevel.segments = 5
- bevel.profile = 1.0
-
- # assign the material to the object
- obj.data.materials.append(material)
-
- obj.scale = (1, 1, 2) # square base, 2x taller than wide
- # Modal logo is rotated 45 degrees
- obj.rotation_euler[1] = math.radians(initial_rotation)
-
- # apply initial transformations
- bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
-
- # to "animate" the rendering, we rotate the prisms around the Z axis
- angle_radians = math.radians(angle)
- rotation_matrix = mathutils.Matrix.Rotation(angle_radians, 4, "Z")
- obj.matrix_world = rotation_matrix @ obj.matrix_world
- bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
-
-
-def create_iridescent_material():
- import bpy
-
- mat = bpy.data.materials.new(name="IridescentGreen")
- mat.use_nodes = True
- nodes = mat.node_tree.nodes
- links = mat.node_tree.links
-
- nodes.clear()
-
- principled_node = nodes.new(type="ShaderNodeBsdfPrincipled")
-
- emission_node = nodes.new(type="ShaderNodeEmission")
- layer_weight = nodes.new(type="ShaderNodeLayerWeight")
- color_ramp = nodes.new(type="ShaderNodeValToRGB")
-
- mix_shader_node = nodes.new(type="ShaderNodeMixShader")
-
- output_node = nodes.new(type="ShaderNodeOutputMaterial")
-
- principled_node.inputs["Base Color"].default_value = (1, 1, 1, 1)
- principled_node.inputs["Metallic"].default_value = 1.0
- principled_node.inputs["Roughness"].default_value = 0.5
-
- color_ramp.color_ramp.elements[0].color = (0, 0, 0, 1)
- color_ramp.color_ramp.elements[1].color = (0, 0.5, 0, 1)
- layer_weight.inputs["Blend"].default_value = 0.4
-
- links.new(layer_weight.outputs["Fresnel"], color_ramp.inputs["Fac"])
- links.new(color_ramp.outputs["Color"], emission_node.inputs["Color"])
-
- emission_node.inputs["Strength"].default_value = 5.0
- emission_node.inputs["Color"].default_value = (0.0, 1.0, 0.0, 1)
-
- links.new(emission_node.outputs["Emission"], mix_shader_node.inputs[1])
- links.new(principled_node.outputs["BSDF"], mix_shader_node.inputs[2])
- links.new(layer_weight.outputs["Fresnel"], mix_shader_node.inputs["Fac"])
-
- links.new(mix_shader_node.outputs["Shader"], output_node.inputs["Surface"])
-
- return mat
-
-
-def add_lighting():
- import bpy
-
- # warm key light
- bpy.ops.object.light_add(type="POINT", location=(5, 5, 5))
- key_light = bpy.context.object
- key_light.data.energy = 100
- key_light.data.color = (1, 0.8, 0.5) # warm
-
- # tight, cool spotlight
- bpy.ops.object.light_add(type="SPOT", radius=1, location=(4, 0, 6))
- spot_light = bpy.context.object
- spot_light.data.energy = 500
- spot_light.data.spot_size = 0.5
- spot_light.data.color = (0.8, 0.8, 1) # cool
- spot_light.rotation_euler = (3.14 / 4, 0, -3.14 / 4)
-
- # soft overall illumination
- bpy.ops.object.light_add(type="AREA", radius=3, location=(-3, 3, 5))
- area_light = bpy.context.object
- area_light.data.energy = 50 # softer
- area_light.data.size = 5 # larger
- area_light.data.color = (1, 1, 1) # neutral
- area_light.rotation_euler = (3.14 / 2, 0, 3.14)
+ blend_bytes = Path("IceModal.blend").read_bytes()
+ args = [
+ (blend_bytes, frame) for frame in range(1, FRAME_COUNT + 1, FRAME_SKIP)
+ ]
+ images = list(render.starmap(args))
+ for i, image in enumerate(images):
+ frame_path = output_directory / f"frame_{i + 1}.png"
+ frame_path.write_bytes(image)
+ print(f"Frame saved to {frame_path}")
+
+ video_path = output_directory / "output.mp4"
+ video_bytes = combine.remote(images)
+ video_path.write_bytes(video_bytes)
+ print(f"Video saved to {video_path}")