render_tiles

Demonstrates how to render a document split into tiles which can be composited back together to a full image.

render_tiles.py

"""Demonstrates how to render a document split into tiles which can be composited back together to 
a full image. 

Create a scene, save it, and then run the script. The script will render the document in 2x2 tiles 
and save the tiles and a composite of all tiles into a folder named <DocumentName>_tiles next to 
the document.

Topics:
    * rendering
"""
__author__ = "Ferdinand Hoppe"
__copyright__ = "Copyright (C) 2026 MAXON Computer GmbH"
__date__ = "08/04/2026"
__license__ = "Apache-2.0 License"
__version__ = "2026.2.0"

import itertools
import os
import shutil

import c4d
import mxutils


doc: c4d.documents.BaseDocument  # The currently active document.
op: c4d.BaseObject | None  # The primary selected object in `doc`. Can be `None`.

TILE_COUNT: tuple[int, int] = (2, 2) # Number of tiles in x and y direction.
KERNEL_BORDER: int = 16 # Number of pixels to render additionally around each tile to avoid seams 
                        # when the render engine is not render region aware (and uses some kind of 
                        # filter kernel in rendering, which is usually the case). We have to guess
                        # here as it is obvious from the out side what the maximum kernel size of a
                        # renderer is for a given rendering.

# List of tile coordinates, e. g., (0, 0), (0, 1), (1, 0), (1, 1) for TILE_COUNT = (2, 2).
TILE_INDICES: list[tuple[int, int]] = list(itertools.product(range(TILE_COUNT[0]), 
                                                             range(TILE_COUNT[1])))

def SaveShowBitmap(bitmap: c4d.bitmaps.BaseBitmap, name: str, directory: str) -> None:
    """Saves the given bitmap as a PSD with the given name in the given directory and also
    displays it in the picture viewer.
    """
    c4d.bitmaps.ShowBitmap(bitmap, name)
    filePath: str = os.path.join(directory, f"{name}.psd")
    if bitmap.Save(filePath, c4d.FILTER_PSD) != c4d.IMAGERESULT_OK:
        raise RuntimeError(f"Failed to save {name} to disk.")

@mxutils.SET_STATUS("Rendering document ...", doSpin=True)
def main() -> None:
    """Called by Cinema 4D when the script is being executed.
    """
    if not doc.GetDocumentPath() or not doc.GetDocumentName():
        return c4d.gui.MessageDialog("Please save the document before running the script.")

    c4d.ClearPythonConsole()

    # The folder where we will save results to. Delete the folder when it already exists, so that 
    # we have a clean slate for the output.
    outPath: str = os.path.join(doc.GetDocumentPath(),
                                     f"{os.path.splitext(doc.GetDocumentName())[0]}_tiles")

    if os.path.exists(outPath):
        os.chmod(outPath, 0o755)
        shutil.rmtree(outPath)
    os.makedirs(outPath, exist_ok=True)

    # Draw a copy of the render data container, so that we can modify it without changing the document.
    rData: c4d.documents.RenderData = doc.GetActiveRenderData()
    renderSettings: c4d.BaseContainer = mxutils.CheckType(
        rData.GetDataInstance().GetClone(c4d.COPYFLAGS_NONE))

    # Get the absolute resolution of the render and compute the x and y size of the tiles based on 
    # the tile count. Also setup the final composite bitmap where we will stitch the tiles together.
    xRes: int = int(renderSettings[c4d.RDATA_XRES_VIRTUAL] or renderSettings[c4d.RDATA_XRES])
    yRes: int = int(renderSettings[c4d.RDATA_YRES_VIRTUAL] or renderSettings[c4d.RDATA_YRES])
    tileXSize: int = xRes // TILE_COUNT[0]
    tileYSize: int = yRes // TILE_COUNT[1]
    composite: c4d.bitmaps.BaseBitmap = mxutils.CheckType(
        c4d.bitmaps.AllocateRenderBitmap(renderSettings))

    # Now we iterate over all tiles and render the document for each tile.
    for x, y in TILE_INDICES:

        # Draw a copy of the render settings for the current tile and then compute the coordinates of
        # the tile within the overall image based on the tile size and the tile index.
        tileSettings: c4d.BaseContainer = renderSettings.GetClone(c4d.COPYFLAGS_NONE)
        xMin, yMin = x * tileXSize, y * tileYSize
        xMax, yMax = (x + 1) * tileXSize, (y + 1) * tileYSize

        # Here we now run into a problem when the render engine is not render region aware in its 
        # rendering. Let us we assume we have an image with the pixel P in it as shown below. And 
        # the rendering applies some kind of filter kernel to the image, so that the value of P is 
        # influenced by its neighboring pixels, here shown as the numbers 1 to 8. This could be
        # anything from a simple box filter, over anti-aliasing filters, to more complex filters 
        # such as motion blur or rendering effects.
        #
        #                               ┌─────────────────────┐
        #                               │       1 2 3         │
        #                               │       8 P 4         │
        #                               │       7 6 5         │
        #                               └─────────────────────┘
        #
        # When we now split up an image into two tiles Ta and Tb via RDATA_RENDERREGION, we end up
        # with this:
        #
        #                               ┌──────────┬──────────┐
        #                               │       1 2│3         │
        #                               │       8 P│4         │
        #                               │ Ta    7 6│5      Tb │
        #                               └──────────┴──────────┘
        #
        # I.e., in the original rendering, P would be influenced by the pixels 1 to 8, but when we
        # render the tiles separately, P is only influenced by the pixels 1, 2, 6, 7, and 8 because
        # 3, 4, and 5 are not part of the tile Ta anymore and instead are part of the tile Tb. This 
        # means that the pixel P will have a different value within the tile Ta than it would have 
        # in the original rendering.
        #
        # Some render engines such as Redshift are render region aware and already render such
        # renderings with a pixel border around the tile that matches the maximum kernel size, and
        # then return the correctly cropped tile. But other render engines such as the standard 
        # renderer do not do this. Here we have to add a border ourselves and guess a maximum kernel 
        # size. This is what the KERNEL_BORDER constant is for. After the rendering we then have to 
        # crop the tile bitmaps to the original tile size before we stitch them together.

        # You must adopt this to the render engines you are using and ask the vendors if their engine
        # is render region aware or not. For the builtin render engines in Cinema 4D, only RS does it.
        isKernelAware: bool = tileSettings[c4d.RDATA_RENDERENGINE] == c4d.VPrsrenderer

        xMin_: int = max(0, xMin - KERNEL_BORDER) if not isKernelAware else xMin
        yMin_: int = max(0, yMin - KERNEL_BORDER) if not isKernelAware else yMin
        xMax_: int = min(xRes, xMax + KERNEL_BORDER) if not isKernelAware else xMax
        yMax_: int = min(yRes, yMax + KERNEL_BORDER) if not isKernelAware else yMax

        # Now we set the render region for the current tile. Render regions are expressed in relation
        # to their border. So RDATA_RENDERREGION_BOTTOM expresses for example how many pixels the 
        # bottom border the render region is away from the bottom border of the overall image.
        tileSettings[c4d.RDATA_RENDERREGION] = True
        tileSettings[c4d.RDATA_RENDERREGION_LEFT] = xMin_
        tileSettings[c4d.RDATA_RENDERREGION_TOP] = yMin_
        tileSettings[c4d.RDATA_RENDERREGION_RIGHT] = xRes - xMax_
        tileSettings[c4d.RDATA_RENDERREGION_BOTTOM] = yRes - yMax_

        # Now we render the document for the current tile using our tile settings. The bitmap we
        # render into has to be of the full size of the overall image, because RenderRegion works
        # in Cinema 4D in a way that you get a full size image as a result, but only the pixels in
        # the render region are actually rendered. So, for clarity I used #settings here, but you 
        # could also pass #tileSettings, #AllocateRenderBitmap will ignore render region settings.
        bmp: c4d.bitmaps.MultipassBitmap = mxutils.CheckType(
            c4d.bitmaps.AllocateRenderBitmap(renderSettings))
        flags = c4d.RENDERFLAGS_AUTO_SETUP | c4d.RENDERFLAGS_OCIO_BAKE_RENDERING
        if c4d.documents.RenderDocument(doc, tileSettings, bmp, flags) != c4d.RENDERRESULT_OK:
            raise RuntimeError("Rendering failed.")

        # Now we save out the (cropped back) tile and also copy it to the composite. I am not 
        # handling multi pass layers here which would be an extra layer of complexity, but is
        # absolutely doable. I.e., we just composite the final RGBA result here.

        xMinOut: int = xMin_ if isKernelAware else xMin
        yMinOut: int = yMin_ if isKernelAware else yMin
        xMaxOut: int = xMax_ if isKernelAware else xMax
        yMaxOut: int = yMax_ if isKernelAware else yMax
        if not isKernelAware:
            cropped: c4d.bitmaps.BaseBitmap = bmp.GetClonePart(
                xMinOut, yMinOut, xMaxOut - xMinOut, yMaxOut - yMinOut)
            SaveShowBitmap(cropped, f"Tile ({x}, {y})", outPath)
        else:
            SaveShowBitmap(bmp, f"Tile ({x}, {y})", outPath)

        print(f"Rendered tile ({x}, {y}) with render region ({xMin_}, {yMin_}, {xMax_}, {yMax_})"
              f" and output region ({xMinOut}, {yMinOut}, {xMaxOut}, {yMaxOut}), ")

        # We are going to copy data row by row into the composite. #bytesPerPixel is the number of
        # bytes each pixel occupies in the bitmap. #tileWidth is the width in pixels of the tile we 
        # are copying, and #buffer is the line buffer we use for copying. We then copy from the min
        # x coordinate up to the width of the tile (i.e., max x coordinate) for each y-coordinate of 
        # the tile. It is important to use the color mode of the tile, and not the color mode of the
        # composite for both the getting and setting of pixels.
        bytesPerPixel: int = bmp.GetBt() // 8
        tileWidth: int = xMaxOut - xMinOut
        buffer: bytearray = bytearray(tileWidth * bytesPerPixel)
        for y_ in range(yMinOut, yMaxOut):
            bmp.GetPixelCnt(xMinOut, y_, tileWidth, buffer, bytesPerPixel, bmp.GetColorMode(), 
                            c4d.PIXELCNT_NONE)
            composite.SetPixelCnt(xMinOut, y_, tileWidth, buffer, bytesPerPixel, bmp.GetColorMode(), 
                                  c4d.PIXELCNT_NONE)

    # Finally, we copy over the color profiles from the last rendered tile to the composite. We have to
    # do this because the renderer will set the final color profiles of an image, #AllocateRenderBitmap
    # will only return an image with the default profiles as it cannot know them in advance.
    for space in (c4d.COLORPROFILE_INDEX_IMAGE, c4d.COLORPROFILE_INDEX_RENDERSPACE, 
                  c4d.COLORPROFILE_INDEX_VIEW_TRANSFORM, c4d.COLORPROFILE_INDEX_DISPLAYSPACE):
        composite.SetColorProfile(bmp.GetColorProfile(space), space)
    
    SaveShowBitmap(composite, "Composite Result", outPath)
    print(f"Saved {len(TILE_INDICES)} rendered tiles and compositing to: {outPath}")


if __name__ == '__main__':
    main()