From 60872c89f91937c117d38a3ab9a5f134d120ec41 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 3 Sep 2024 06:47:45 +0200 Subject: [PATCH] Manager: distributed rendering of single images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new job type that can render a single image. It is broken up into separate tiles, each of which can be rendered independently by a worker. Only tested with Cycles. Adaptive sampling is supported. For this, each tile is expanded by 16 pixels in each direction, which is later cropped off before merging the tiles. Denoising is not (yet) supported, as Blender/Cycles does not output all the necessary data into EXR layers. Tile sizes should (for now) be a power of 2, to avoid alignment issues. Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104327 Reviewed-by: Sybren A. Stüvel --- .../scripts/single_image_render.js | 334 ++++++++++++++++++ .../content/usage/job-types/builtin.md | 40 +++ 2 files changed, 374 insertions(+) create mode 100644 internal/manager/job_compilers/scripts/single_image_render.js create mode 100644 web/project-website/content/usage/job-types/builtin.md diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js new file mode 100644 index 00000000..38ea28b6 --- /dev/null +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -0,0 +1,334 @@ +const JOB_TYPE = { + label: "Single Image Render", + description: "Distributed rendering of a single image.", + settings: [ + // Settings for artists to determine: + { + key: "tile_size_x", + type: "int32", + default: 64, + description: "Tile size in pixels for the X axis" + }, + { + key: "tile_size_y", + type: "int32", + default: 64, + description: "Tile size in pixels for the Y axis" + }, + { + key: "frame", type: "int32", required: true, + eval: "C.scene.frame_current", + description: "Frame to render. Examples: '47', '1'" + }, + + // render_output_root + add_path_components determine the value of render_output_path. + { + key: "render_output_root", + type: "string", + subtype: "dir_path", + required: true, + visible: "submission", + description: "Base directory of where render output is stored. Will have some job-specific parts appended to it" + }, + { + key: "add_path_components", + type: "int32", + required: true, + default: 0, + propargs: {min: 0, max: 32}, + visible: "submission", + description: "Number of path components of the current blend file to use in the render output path" + }, + { + key: "render_output_path", type: "string", subtype: "file_path", editable: false, + eval: "str(Path(abspath(settings.render_output_root), last_n_dir_parts(settings.add_path_components), jobname, '{timestamp}', 'tiles'))", + description: "Final file path of where render output will be saved" + }, + + // Automatically evaluated settings: + { + key: "blendfile", + type: "string", + required: true, + description: "Path of the Blend file to render", + visible: "web" + }, + { + key: "format", + type: "string", + required: true, + eval: "C.scene.render.image_settings.file_format", + visible: "web" + }, + { + key: "image_file_extension", + type: "string", + required: true, + eval: "C.scene.render.file_extension", + visible: "hidden", + description: "File extension used when rendering images" + }, + { + key: "resolution_x", + type: "int32", + required: true, + eval: "C.scene.render.resolution_x", + visible: "hidden", + description: "Resolution X" + }, + { + key: "resolution_y", + type: "int32", + required: true, + eval: "C.scene.render.resolution_y", + visible: "hidden", + description: "Resolution Y" + }, + { + key: "resolution_scale", + type: "int32", + required: true, + eval: "C.scene.render.resolution_percentage", + visible: "hidden", + description: "Resolution scale" + } + ] +}; + +function compileJob(job) { + print("Single Image Render job submitted"); + print("job: ", job); + + const settings = job.settings; + const renderOutput = renderOutputPath(job); + + if (settings.resolution_scale !== 100) { + throw "Flamenco currently does not support rendering with a resolution scale other than 100%"; + } + + // Make sure that when the job is investigated later, it shows the + // actually-used render output: + settings.render_output_path = renderOutput; + + const renderDir = path.dirname(renderOutput); + const renderTasks = authorRenderTasks(settings, renderDir, renderOutput); + const mergeTask = authorMergeTask(settings, renderDir); + + for (const rt of renderTasks) { + job.addTask(rt); + } + if (mergeTask) { + // If there is a merge task, all other tasks have to be done first. + for (const rt of renderTasks) { + mergeTask.addDependency(rt); + } + job.addTask(mergeTask); + } +} + +// Do field replacement on the render output path. +function renderOutputPath(job) { + let path = job.settings.render_output_path; + if (!path) { + throw "no render_output_path setting!"; + } + return path.replace(/{([^}]+)}/g, (match, group0) => { + switch (group0) { + case "timestamp": + return formatTimestampLocal(job.created); + default: + return match; + } + }); +} + +// Calculate the borders for the tiles +// Does not take into account the overscan +function calcBorders(tileSizeX, tileSizeY, width, height) { + let borders = []; + for (let y = 0; y < height; y += tileSizeY) { + for (let x = 0; x < width; x += tileSizeX) { + borders.push([x, y, Math.min(x + tileSizeX, width), Math.min(y + tileSizeY, height)]); + } + } + print("borders: ", borders); + return borders; +} + +function authorRenderTasks(settings, renderDir, renderOutput) { + print("authorRenderTasks(", renderDir, renderOutput, ")"); + let renderTasks = []; + let borders = calcBorders(settings.tile_size_x, settings.tile_size_y, settings.resolution_x, settings.resolution_y); + for (let border of borders) { + const task = author.Task(`render-${border[0]}-${border[1]}`, "blender"); + // Overscan is calculated in this manner to avoid rendering outside the image resolution + let pythonExpr = `import bpy + +scene = bpy.context.scene +render = scene.render +render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' +render.use_compositing = False +render.use_stamp = False +overscan = 16 + +render.border_min_x = max(${border[0]} - overscan, 0) / ${settings.resolution_x} +render.border_min_y = max(${border[1]} - overscan, 0) / ${settings.resolution_y} +render.border_max_x = min(${border[2]} + overscan, ${settings.resolution_x}) / ${settings.resolution_x} +render.border_max_y = min(${border[3]} + overscan, ${settings.resolution_x}) / ${settings.resolution_y} +render.use_border = True +render.use_crop_to_border = True +bpy.ops.render.render(write_still=True)` + const command = author.Command("blender-render", { + exe: "{blender}", + exeArgs: "{blenderArgs}", + argsBefore: [], + blendfile: settings.blendfile, + args: [ + "--render-output", path.join(renderDir, path.basename(renderOutput), border[0] + "-" + border[1] + "-" + border[2] + "-" + border[3]), + "--render-format", settings.format, + "--python-expr", pythonExpr + ] + }); + task.addCommand(command); + renderTasks.push(task); + } + return renderTasks; +} + +function authorMergeTask(settings, renderDir, renderOutput) { + print("authorMergeTask(", renderDir, ")"); + const task = author.Task("merge", "blender"); + // Burning metadata into the image is done by the compositor for the entire merged image + // The overall logic of the merge is as follows: + // 1. Find out the Render Layers node and to which socket it is connected + // 2. Load image files from the tiles directory. + // Their correct position is determined by their filename. + // 3. Create a node tree that scales, translates and adds the tiles together. + // A simple version of the node tree is linked here: + // https://devtalk.blender.org/uploads/default/original/3X/f/0/f047f221c70955b32e4b455e53453c5df716079e.jpeg + // 4. The final image is then fed into the socket the Render Layers node was connected to. + // This allows the compositing to work as if the image was rendered in one go. + let pythonExpr = `import bpy + +render = bpy.context.scene.render +render.resolution_x = ${settings.resolution_x} +render.resolution_y = ${settings.resolution_y} +bpy.context.scene.use_nodes = True +render.use_compositing = True +render.use_stamp = True +node_tree = bpy.context.scene.node_tree +overscan = 16 + +render_layers_node = None +for node in node_tree.nodes: + if node.type == 'R_LAYERS': + feed_in_input = node.outputs[0] + render_layers_node = node + break +for link in node_tree.links: + if feed_in_input is not None and link.from_socket == feed_in_input: + feed_in_output = link.to_socket + break + +from pathlib import Path + +root = Path("${path.join(renderDir, path.basename(renderOutput))}/tiles") +image_files = [f for f in root.iterdir() if f.is_file()] + +separate_nodes = [] +first_crop_node = None +translate_nodes = [] +min_width = min([int(f.stem.split('-')[2]) - int(f.stem.split('-')[0]) for f in image_files]) +min_height = min([int(f.stem.split('-')[3]) - int(f.stem.split('-')[1]) for f in image_files]) +for i, image_file in enumerate(image_files): + image_node = node_tree.nodes.new('CompositorNodeImage') + image_node.image = bpy.data.images.load(str(root / image_file.name)) + + crop_node = node_tree.nodes.new('CompositorNodeCrop') + crop_node.use_crop_size = True + left, top, right, bottom = image_file.stem.split('-') + actual_width = int(right) - int(left) + actual_height = int(bottom) - int(top) + if left == '0': + crop_node.min_x = 0 + crop_node.max_x = actual_width + else: + crop_node.min_x = overscan + crop_node.max_x = actual_width + overscan + if top == '0': + crop_node.max_y = 0 + crop_node.min_y = actual_height + else: + crop_node.max_y = overscan + crop_node.min_y = actual_height + overscan + if i == 0: + first_crop_node = crop_node + + translate_node = node_tree.nodes.new('CompositorNodeTranslate') + # translate_node.use_relative = True + translate_node.inputs[1].default_value = float(left) + (actual_width - ${settings.resolution_x}) / 2 + translate_node.inputs[2].default_value = float(top) + (actual_height - ${settings.resolution_y}) / 2 + translate_nodes.append(translate_node) + + separate_node = node_tree.nodes.new('CompositorNodeSeparateColor') + separate_nodes.append(separate_node) + + node_tree.links.new(image_node.outputs[0], crop_node.inputs[0]) + node_tree.links.new(crop_node.outputs[0], translate_node.inputs[0]) + node_tree.links.new(translate_node.outputs[0], separate_node.inputs[0]) + +scale_node = node_tree.nodes.new('CompositorNodeScale') +scale_node.space = 'RELATIVE' +scale_node.inputs[1].default_value = ${settings.resolution_x} / min_width +scale_node.inputs[2].default_value = ${settings.resolution_y} / min_height +node_tree.links.new(first_crop_node.outputs[0], scale_node.inputs[0]) +mix_node = node_tree.nodes.new('CompositorNodeMixRGB') +mix_node.blend_type = 'MIX' +mix_node.inputs[0].default_value = 0.0 +mix_node.inputs[1].default_value = (0, 0, 0, 1) +node_tree.links.new(scale_node.outputs[0], mix_node.inputs[2]) + +mix_adds = [node_tree.nodes.new('CompositorNodeMixRGB') for _ in range(len(separate_nodes))] +math_adds = [node_tree.nodes.new('CompositorNodeMath') for _ in range(len(separate_nodes))] +for i, mix_add in enumerate(mix_adds): + mix_add.blend_type = 'ADD' + if i == 0: + node_tree.links.new(mix_node.outputs[0], mix_add.inputs[1]) + else: + node_tree.links.new(mix_adds[i - 1].outputs[0], mix_add.inputs[1]) + node_tree.links.new(translate_nodes[i].outputs[0], mix_add.inputs[2]) + +for i, math_add in enumerate(math_adds): + math_add.operation = 'ADD' + if i == 0: + node_tree.links.new(mix_node.outputs[0], math_add.inputs[0]) + else: + node_tree.links.new(math_adds[i - 1].outputs[0], math_add.inputs[0]) + node_tree.links.new(separate_nodes[i - 1].outputs[3], math_add.inputs[1]) + +set_alpha_node = node_tree.nodes.new('CompositorNodeSetAlpha') +set_alpha_node.mode = 'REPLACE_ALPHA' +node_tree.links.new(mix_adds[-1].outputs[0], set_alpha_node.inputs[0]) +node_tree.links.new(math_adds[-1].outputs[0], set_alpha_node.inputs[1]) +if feed_in_input is not None: + node_tree.links.new(set_alpha_node.outputs[0], feed_in_output) +else: + raise Exception('No Render Layers Node found. Currently only supported with a Render Layers Node in the Compositor.') + +node_tree.nodes.remove(render_layers_node) +bpy.ops.render.render(write_still=True)` + + const command = author.Command("blender-render", { + exe: "{blender}", + exeArgs: "{blenderArgs}", + argsBefore: [], + blendfile: settings.blendfile, + args: [ + "--render-output", path.join(renderDir, path.basename(renderOutput), "merged"), + "--render-format", settings.format, + "--python-expr", pythonExpr + ] + }); + task.addCommand(command); + return task; +} diff --git a/web/project-website/content/usage/job-types/builtin.md b/web/project-website/content/usage/job-types/builtin.md new file mode 100644 index 00000000..2cf60a6b --- /dev/null +++ b/web/project-website/content/usage/job-types/builtin.md @@ -0,0 +1,40 @@ +--- +title: Built-in Job Types +weight: 10 +--- + +Flamenco comes with built-in job types that are used for most common tasks. Currently, there are two of them: + +- Simple Blender Render +- Single Image Render + +## Simple Blender Render + +This built-in job type is used for rendering a sequence of frames from a single Blender file, and potentially creating a preview video for compatible formats using FFmpeg. This job type is suitable for straightforward rendering tasks where one needs to render a range of frames and potentially compile them into a video. Note that this job type does not render into video formats directly, so the output format should be FFmpeg-compatible image formats. + +The job type defines several settings that can be configured when submitting a job: + +- `Frames` _string, required_: The frame range to render, e.g. '47', '1-30', '3, 5-10, 47-327'. It could also be set to use scene range or automatically determined on submission. +- `Chunk Size` _integer, default: 1_: Number of frames to render in one Blender render task. +- `Render Output Root` _string, required_: Base directory where render output is stored. Job-specific parts will be appended to this path. +- `Add Path Components` _integer, required, default: 0_: Number of path components from the current blend file to use in the render output path. +- `Render Output Path` _non-editable_: Final file path where render output will be saved. This is a computed value based on the `Render Output Root` and `Add Path Components` settings. + +By using this job type, you can easily distribute Blender rendering tasks across multiple workers in your Flamenco setup, potentially saving significant time on large rendering projects. + +## Single Image Render + +This built-in job type is designed for distributed rendering of a single image from a Blender file. It splits the image into tiles, renders each tile separately, and then merges the tiles back into a single image. This approach allows for parallel processing of different parts of the image, potentially speeding up the rendering process. + +Currently, the job type supports composition, as long as there is one single `Render Layers` node. The job type does not support `Denoising` node. + +The job type defines several settings that can be configured when submitting a job: + +- `Tile Size X` _integer, default: 64: Tile size in pixels for the X axis, does not need to be divisible by the image width. +- `Tile Size Y` _integer, default: 64: Tile size in pixels for the Y axis, does not need to be divisible by the image height. +- `Frame` _integer, required_: The frame to render. By default, it uses the current frame in the Blender scene. +- `Render Output Root` _string, required_: Base directory where render output is stored. Job-specific parts will be appended to this path. +- `Add Path Components` _integer, required, default: 0_: Number of path components from the current blend file to use in the render output path. +- `Render Output Path` _non-editable_: Final file path where render output will be saved. This is a computed value based on the `Render Output Root` and `Add Path Components` settings. + +Choosing the right tile size is crucial for performance. Too small tiles might increase overhead, while too large tiles might not distribute the workload effectively.