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}")