Tile rendering with Cinema 4D
-
Dear community,
Is there a recommended way to do tile rendering—dividing a large image into smaller pieces that can be rendered quickly and then reassembled into one final image?
Current Approach:
We currently use the "Render Tiles" camera to divide an image into smaller pieces, then assemble them using software like FFmpeg or OpenImageIO.
Here's our detailed step-by-step procedure: https://github.com/aws-deadline/deadline-cloud-for-cinema-4d/blob/mainline/docs/tile_rendering/tile_rendering.md based on this old article.Alternative approach:
But while going over some of the render settings, I stumbled on "RDATA_RENDERREGION_LEFT" (similarly for left, top and bottom).
Could we use this approach instead—rendering specific regions of the image separately and then assembling them into the final image?Has anyone implemented a similar solution or can provide guidance on whether this approach is feasible?
Thank you for your assistance.
-
I was able to get tile rendering done by running a script like this:
import c4d import os doc: c4d.documents.BaseDocument op: c4d.BaseObject | None def main() -> None: # --- Configure these --- tiles_x = 2 # columns tiles_y = 2 # rows output_dir = os.path.join(os.path.expanduser("~"), "Desktop", "tiles") # ------------------------ os.makedirs(output_dir, exist_ok=True) base_rd = doc.GetActiveRenderData() full_w = int(base_rd[c4d.RDATA_XRES]) full_h = int(base_rd[c4d.RDATA_YRES]) tile_w = full_w // tiles_x tile_h = full_h // tiles_y for ty in range(tiles_y): for tx in range(tiles_x): rd = base_rd.GetClone() left = tx * tile_w top = ty * tile_h right = left + tile_w bottom = top + tile_h # Region at full resolution rd[c4d.RDATA_RENDERREGION] = True rd[c4d.RDATA_RENDERREGION_LEFT] = left rd[c4d.RDATA_RENDERREGION_TOP] = top rd[c4d.RDATA_RENDERREGION_RIGHT] = right rd[c4d.RDATA_RENDERREGION_BOTTOM] = bottom # Full-res bitmap — C4D renders region into this bmp = c4d.bitmaps.MultipassBitmap(full_w, full_h, c4d.COLORMODE_RGB) bmp.AddChannel(True, True) result = c4d.documents.RenderDocument( doc, rd.GetData(), bmp, c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS, ) if result != c4d.RENDERRESULT_OK: print(f"Tile ({tx}, {ty}) failed with code: {result}") continue # Crop the tile region out of the full bitmap tile_bmp = c4d.bitmaps.BaseBitmap() tile_bmp.Init(tile_w, tile_h) for y in range(tile_h): for x in range(tile_w): r, g, b = bmp.GetPixel(left + x, top + y) tile_bmp.SetPixel(x, y, r, g, b) path = os.path.join(output_dir, f"tile_{tx}_{ty}.png") tile_bmp.Save(path, c4d.FILTER_PNG) print(f"Saved {path}") c4d.gui.MessageDialog( f"Done! {tiles_x * tiles_y} tiles saved to:\n{output_dir}" ) if __name__ == "__main__": main() -
Hmm, I was able to reassemble the tiles within Cinema 4D as well. Here's the script with reassembly.
Feel free to chime in if we should not be doing this (performance impact, does not work with different renderers etc)
import c4d import os doc: c4d.documents.BaseDocument op: c4d.BaseObject | None def main() -> None: """Renders the scene as a grid of tiles, then reassembles into the final image.""" # --- Configure these --- tiles_x = 2 tiles_y = 2 output_dir = os.path.join(os.path.expanduser("~"), "Desktop", "tiles") final_path = os.path.join(output_dir, "final_assembled.png") # ------------------------ os.makedirs(output_dir, exist_ok=True) base_rd = doc.GetActiveRenderData() full_w = int(base_rd[c4d.RDATA_XRES]) full_h = int(base_rd[c4d.RDATA_YRES]) tile_w = full_w // tiles_x tile_h = full_h // tiles_y tile_bmps = {} for ty in range(tiles_y): for tx in range(tiles_x): rd = base_rd.GetClone() left = tx * tile_w top_ = ty * tile_h right = left + tile_w bottom = top_ + tile_h rd[c4d.RDATA_RENDERREGION] = True rd[c4d.RDATA_RENDERREGION_LEFT] = left rd[c4d.RDATA_RENDERREGION_TOP] = top_ rd[c4d.RDATA_RENDERREGION_RIGHT] = right rd[c4d.RDATA_RENDERREGION_BOTTOM] = bottom bmp = c4d.bitmaps.MultipassBitmap(full_w, full_h, c4d.COLORMODE_RGB) bmp.AddChannel(True, True) print(f"Rendering tile ({tx}, {ty}) region=({left},{top_})-({right},{bottom})") result = c4d.documents.RenderDocument( doc, rd.GetData(), bmp, c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS, ) if result != c4d.RENDERRESULT_OK: print(f"Tile ({tx}, {ty}) failed with code: {result}") return # Crop tile — use same coords as region tile_bmp = c4d.bitmaps.BaseBitmap() tile_bmp.Init(tile_w, tile_h) for y in range(tile_h): for x in range(tile_w): r, g, b = bmp.GetPixel(left + x, top_ + y) tile_bmp.SetPixel(x, y, r, g, b) tile_path = os.path.join(output_dir, f"tile_{tx}_{ty}.png") tile_bmp.Save(tile_path, c4d.FILTER_PNG) print(f"Saved {tile_path}") tile_bmps[(tx, ty)] = tile_bmp # --- Reassemble: place each tile at its matching position, no flip --- print("Assembling final image...") final_bmp = c4d.bitmaps.BaseBitmap() final_bmp.Init(full_w, full_h) for ty in range(tiles_y): for tx in range(tiles_x): tile_bmp = tile_bmps[(tx, ty)] dst_x = tx * tile_w dst_y = ty * tile_h for y in range(tile_h): for x in range(tile_w): r, g, b = tile_bmp.GetPixel(x, y) final_bmp.SetPixel(dst_x + x, dst_y + y, r, g, b) final_bmp.Save(final_path, c4d.FILTER_PNG) print(f"Saved assembled image to {final_path}") c4d.bitmaps.ShowBitmap(final_bmp) c4d.gui.MessageDialog( f"Done! {tiles_x * tiles_y} tiles rendered and assembled.\n{final_path}" ) if __name__ == "__main__": main() -
Hey @karthikbp,
Thank you for reaching out to us. And sorry, I somehow overlooked your topic. Yes, that is how I would have done it too, via
RDATA_RENDERREGION_LEFT, etc. The 2026.2 Python SDK will contain new rendering examples, that could be something I could add, as tile rendering is something that comes up from time to time.As a fair warning: Your code will not respect OCIO, i.e., the images will have the wrong colors. Since you marked this as 2026, where OCIO is the color management standard, this would apply to all documents. See open_color_io_2025_2.py for an example how to do it correctly. The next release will streamline things there quite a bit.
Cheers,
Ferdinand -
Thanks @ferdinand .
I was about to add to this forum post that the rendered images are bit darker than actual.
Thanks for the code link. Here's my updated script with changes for OCIO and I was able to get it rendering correctly at least locally.
Anything to avoid or missed here?import c4d import os doc: c4d.documents.BaseDocument op: c4d.BaseObject | None def EnsureIsOcioDocument(doc: c4d.documents.BaseDocument) -> None: if doc[c4d.DOCUMENT_COLOR_MANAGEMENT] is not c4d.DOCUMENT_COLOR_MANAGEMENT_OCIO: doc[c4d.DOCUMENT_COLOR_MANAGEMENT] = c4d.DOCUMENT_COLOR_MANAGEMENT_OCIO doc.UpdateOcioColorSpaces() if c4d.threading.GeIsMainThreadAndNoDrawThread(): c4d.EventAdd() def main() -> None: """Renders the scene as a grid of tiles, then reassembles into the final image.""" # --- Configure these --- tiles_x = 2 tiles_y = 2 output_dir = os.path.join(os.path.expanduser("~"), "Desktop", "tiles") final_path = os.path.join(output_dir, "final_assembled.png") # ------------------------ os.makedirs(output_dir, exist_ok=True) EnsureIsOcioDocument(doc) if not doc.GetDocumentPath(): c4d.gui.MessageDialog("Please save the document first.") return # Work directly on the document's render data, matching the reference pattern. renderData: c4d.documents.RenderData = doc.GetActiveRenderData() data: c4d.BaseContainer = renderData.GetDataInstance() requiresBaking: bool = data[c4d.RDATA_FORMATDEPTH] is c4d.RDATA_FORMATDEPTH_8 xRes: int = int(data[c4d.RDATA_XRES_VIRTUAL] or data[c4d.RDATA_XRES]) yRes: int = int(data[c4d.RDATA_YRES_VIRTUAL] or data[c4d.RDATA_YRES]) tile_w = xRes // tiles_x tile_h = yRes // tiles_y # Determine save bit flags based on format depth if data[c4d.RDATA_FORMATDEPTH] is c4d.RDATA_FORMATDEPTH_16: save_bits = c4d.SAVEBIT_16BITCHANNELS elif data[c4d.RDATA_FORMATDEPTH] is c4d.RDATA_FORMATDEPTH_32: save_bits = c4d.SAVEBIT_32BITCHANNELS else: save_bits = c4d.SAVEBIT_NONE # Save original render region state to restore later orig_region = data[c4d.RDATA_RENDERREGION] orig_region_left = data[c4d.RDATA_RENDERREGION_LEFT] orig_region_top = data[c4d.RDATA_RENDERREGION_TOP] orig_region_right = data[c4d.RDATA_RENDERREGION_RIGHT] orig_region_bottom = data[c4d.RDATA_RENDERREGION_BOTTOM] orig_bake_flag = data.GetBool(c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER) if requiresBaking: data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER] = False tile_bmps = {} for ty in range(tiles_y): for tx in range(tiles_x): left = tx * tile_w top_ = ty * tile_h right = left + tile_w bottom = top_ + tile_h data[c4d.RDATA_RENDERREGION] = True data[c4d.RDATA_RENDERREGION_LEFT] = left data[c4d.RDATA_RENDERREGION_TOP] = top_ data[c4d.RDATA_RENDERREGION_RIGHT] = right data[c4d.RDATA_RENDERREGION_BOTTOM] = bottom # Always render as 32-bit float bmp = c4d.bitmaps.MultipassBitmap(xRes, yRes, c4d.COLORMODE_RGBf) bmp.AddChannel(True, True) print(f"Rendering tile ({tx}, {ty}) region=({left},{top_})-({right},{bottom})") result = c4d.documents.RenderDocument( doc, data, bmp, c4d.RENDERFLAGS_EXTERNAL, ) if result != c4d.RENDERRESULT_OK: print(f"Tile ({tx}, {ty}) failed with code: {result}") # Restore original settings before returning data[c4d.RDATA_RENDERREGION] = orig_region data[c4d.RDATA_RENDERREGION_LEFT] = orig_region_left data[c4d.RDATA_RENDERREGION_TOP] = orig_region_top data[c4d.RDATA_RENDERREGION_RIGHT] = orig_region_right data[c4d.RDATA_RENDERREGION_BOTTOM] = orig_region_bottom data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER] = orig_bake_flag return # Bake OCIO and null profiles — exactly as reference if requiresBaking: bmp = c4d.documents.BakeOcioViewToBitmap(bmp, data, c4d.SAVEBIT_NONE) or bmp bmp.SetColorProfile(c4d.bitmaps.ColorProfile(), c4d.COLORPROFILE_INDEX_DISPLAYSPACE) bmp.SetColorProfile(c4d.bitmaps.ColorProfile(), c4d.COLORPROFILE_INDEX_VIEW_TRANSFORM) # Crop tile preserving bit depth tile_bmp = bmp.GetClonePart(left, top_, tile_w, tile_h) if tile_bmp is None: print(f"Tile ({tx}, {ty}) crop failed") continue tile_path = os.path.join(output_dir, f"tile_{tx}_{ty}.png") tile_bmp.Save(tile_path, c4d.FILTER_PNG, c4d.BaseContainer(), save_bits) print(f"Saved {tile_path}") tile_bmps[(tx, ty)] = tile_bmp # Restore original render data settings data[c4d.RDATA_RENDERREGION] = orig_region data[c4d.RDATA_RENDERREGION_LEFT] = orig_region_left data[c4d.RDATA_RENDERREGION_TOP] = orig_region_top data[c4d.RDATA_RENDERREGION_RIGHT] = orig_region_right data[c4d.RDATA_RENDERREGION_BOTTOM] = orig_region_bottom data[c4d.RDATA_BAKE_OCIO_VIEW_TRANSFORM_RENDER] = orig_bake_flag # --- Reassemble using GetPixelCnt/SetPixelCnt to preserve full bit depth --- print("Assembling final image...") # Get bit depth from the first tile first_tile = tile_bmps.get((0, 0)) if first_tile is None: print("No tiles to assemble") return tile_bpp = first_tile.GetBt() bpc = tile_bpp // 3 if bpc == 32: color_mode = c4d.COLORMODE_RGBf inc = 12 elif bpc == 16: color_mode = c4d.COLORMODE_RGBw inc = 6 else: color_mode = c4d.COLORMODE_RGB inc = 3 final_bmp = c4d.bitmaps.BaseBitmap() final_bmp.Init(xRes, yRes, depth=tile_bpp) row_buffer = bytearray(tile_w * inc) row_view = memoryview(row_buffer) for ty in range(tiles_y): for tx in range(tiles_x): if (tx, ty) not in tile_bmps: continue tile_bmp = tile_bmps[(tx, ty)] dst_x = tx * tile_w dst_y = ty * tile_h for py in range(tile_h): tile_bmp.GetPixelCnt( 0, py, tile_w, row_view, inc, color_mode, c4d.PIXELCNT_0 ) final_bmp.SetPixelCnt( dst_x, dst_y + py, tile_w, row_view, inc, color_mode, c4d.PIXELCNT_0 ) final_bmp.Save(final_path, c4d.FILTER_PNG, c4d.BaseContainer(), save_bits) print(f"Saved assembled image to {final_path}") c4d.bitmaps.ShowBitmap(final_bmp) c4d.gui.MessageDialog(f"Done! {tiles_x * tiles_y} tiles rendered and assembled.\n{final_path}") if __name__ == "__main__": main()