
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 <sybren@blender.org>
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
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;
|
|
}
|