Skip to content

Commit

Permalink
Update blender_video example to use a .blend file (#738)
Browse files Browse the repository at this point in the history
  • Loading branch information
ekzhang committed May 9, 2024
1 parent 8ee12f1 commit 1b26e07
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 217 deletions.
Binary file added 06_gpu_and_ml/blender/IceModal.blend
Binary file not shown.
292 changes: 75 additions & 217 deletions 06_gpu_and_ml/blender/blender_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,26 @@
#
# 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)
# <center>
# <video controls autoplay loop muted>
# <source src="https://modal-public-assets.s3.amazonaws.com/modal-blender-video.mp4" type="video/mp4">
# </video>
# </center>
#
# ## Defining a Modal app

import io
import math
from pathlib import Path

import modal

# 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -213,129 +166,34 @@ 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.


@app.local_entrypoint()
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)
input_path = Path(__file__).parent / "IceModal.blend"
blend_bytes = input_path.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}")

0 comments on commit 1b26e07

Please sign in to comment.