Zoetrope - A custom Renderer in Maya

Zoetrope - A custom Renderer in Maya

In this post, I will talk about how to develop a renderer that fits specific production challenges.

Preface

Xwift is a shelf that contains specialized scripts when I was producing my animated film. I want to write some blogs to document them, in case there’s anyone out there that needs some inspiration.

Everything is developed and tested using Maya 2022.

Swift is not (well, not yet) open-source and therefore I will not share the whole script in my post. However, after reading my posts I believe you can implement your own, given some time and effort.

Introduction

Zoetrope, one of my most-used script in my production, is arguably one of the most controversial script I ever wrote.

It is a custom render script I wrote as an alternative for Maya Batch and Maya Render Sequence.

An experienced technical artist might look at Zoetrope and say: This is utterly stupid, I don’t see why it even exists. (and it’s bulky too, 483 lines of code)

And I totally agree. However, it was developed to solve some of critical problems we faced during remote production, and in some sense, it saved my film.

The Story

It was January of the year 2020, and we started to work on a new animated short film using Maya 2017. Little did I knew what was ahead of the future.

It was working fine at first. Two months into production, a global pandemic swept over the entire United States. My school, University of Washington, was the first school in the U.S. that shut down the campus and the entire production had to went remote.

Soon as I started to light some of my scenes, Maya 2017’s Arnold RenderView stopped working with remote desktop. The only thing I got with Arnold RenderView when doing remote work was a white screen, like so:

Broken Arnold RenderView A picture with a broken Arnold RenderView

Any lighter can confirm this is like chopping off the legs of a marathon athlete (ok this is terrible metaphor but you get my point).

But the production had to continue, so the naive initial workaround was: Put some lights in the scene, render it, then save it to desktop and open the image.

It’s a blind guess, considering the amount to scenes we needed to light (8 sequences, 81 shots), is is just simply too much.

Render One Frame

It is natural, then, to think about writing something that renders a frame out and save it by clicking of a button. And that was what I did in the beginning.

This is the guts of rendering out a frame using command line:

# Render a frame into scene_folder/renders/render_layer/ folder.
def naive_render_one_frame(width, height, file_format="tif", render_layer="defaultRenderLayer"):
    frame = cmds.currentTime(query=True)  # current frame on timeline
    file_dir = (sceneName().parent + "\\renders\\" + render_layer).replace('\\', '/')
    arnoldRender(width, height, True, True, 'render_cam', ' -layer ' + render_layer)


It is essentially doing the same thing as Maya Render Sequence, but only rendering one frame.

Say if I have a scene called scene.ma and I am currently at frame 24, then it renders out an image under:

|scene.ma
|renders
    |defaultRenderLayer
        |scene_1_0024.tif (I will talk about the _1_ later)


Now that I have the power to render out one frame, I can extend that to render out an entire sequence.

Changing range using Timeline vs. Render Settings

In practice, I found it much more useful if I am going to render on multiple machines, to control the range of render on each machine using the Timeline slider.

Say I have 6 machines, and I want to render out a scene with frames 1-120. Using elementary school algebra, each machine will render 19 frames and the last machine will render 20 frames.

A traditional workflow would be:

1. Open the scene on each computer 
2. go to render settings
3. calculate start and end frame
4. Open Render Settings
5. type in start frame 
6. type in end frame 
7. close render settings
8. change workspace to render
9. render - render sequence or batch render.


My proposed workflow that controls the range of render using the inner padding of the timeline is:

Save the scene with inner padding of timeline 1-20 and outter timeline 1-120.

On each machine:
1. Slide the inner padding of the timeline to 1-20, 21-30, ...
2. Click a button that renders out the sequence 


TimeLine Padding TimeLine Padding mentioned above

Much easier!

So all there is left to do is develop a function that renders out the sequence using timeline.

Preparation

Getting Timeline

First, let’s get the range of the timeline. Here’s a handy class:

class TimelineProperties:
    @property
    def START(self):
        return playbackOptions(q=1, animationStartTime=1)
    @property
    def END(self):
        return playbackOptions(q=1, animationEndTime=1)
    @property
    def INNER_START(self):
        return playbackOptions(q=1, minTime=1)
    @property
    def INNER_END(self):
        return playbackOptions(q=1, maxTime=1)


Using this class is also very easy. Take an example that gets the start of the inner padding:

inner_padding = int(TimelineProperties().INNER_START)


Piece of cake!

Getting Other Scene Information

We also need some other information about the scene. These values come directly from the render settings. I included three functions to get thr resolution, padding, and frame rate.

# Getter Methods
def get_resolution_settings(attr):
    """
    attr = "width" or "height".
    Returns an int of the current width or height of the current render settings.
    """
    return cmds.getAttr("%s.%s" % ("defaultResolution", attr))
def get_padding():
    """
    Returns an int of the current frame padding in the render settings.
    """
    return cmds.getAttr("defaultRenderGlobals.extensionPadding")
def get_frame_rate():
    """
    Returns an int of the framerate of the current scene.
    """
    # Framerate Info
    FRAMERATE_INFO = {"game": 15, "film": 24, "pal": 25, "ntsc": 30, "show": 48, "palf": 50, "ntscf": 60}
    current_time = cmds.currentUnit(query=True, time=True)
    return FRAMERATE_INFO.get(current_time)


Cleanup: Caching defaultArnoldDriver_pre

Here’s a bug I found after using the render one frame function. We needed to specify a render_layer in order for the function to work. In order to render the master layer, I set it to defaultRenderLayer. But then, after that, no matter if you use batch or sequence render, running the render one frame code seem to override the layer settings in the render settings. If you are rendering out a scene in several layers later, this becomes a pain since you can’t anymore.

Turns out, we need to toggle an attribute called defaultArnoldDriver_pre. What we then need to do, then, is get this attribute before we render a frame, cache it, render a frame, then set defaultArnoldDriver_pre back to the cached value.

In order to do that, let’s make a global value called DEFAULTARNOLDDRIVER_PRE so both the cache function and set function can access and modify it.

global DEFAULTARNOLDDRIVER_PRE
DEFAULTARNOLDDRIVER_PRE = ""  # Initialize

def cache_defaultArnoldDriver_pre():
    # Returns an string of the defaultArnoldDriver.pre
    global DEFAULTARNOLDDRIVER_PRE
    DEFAULTARNOLDDRIVER_PRE = cmds.getAttr("defaultArnoldDriver.pre")
    print("[Zoetrope] Successfully get and cached Driver settings: " + DEFAULTARNOLDDRIVER_PRE)
def set_defaultArnoldDriver_pre():
    # Sets the defaultArnoldDriver.pre to its original state.
    cmds.setAttr("defaultArnoldDriver.pre", DEFAULTARNOLDDRIVER_PRE, type="string")
    print("[Zoetrope] Successfully reset Driver settings to original: " + DEFAULTARNOLDDRIVER_PRE)
    
cache_defaultArnoldDriver_pre()
# TODO: Do frame render
set_defaultArnoldDriver_pre()


Toggle Render Settings

Not necessary, but here are a couple settings that helps when rendering. You should at least have a function that toggles the scene to your specific rendering requirements. Here’s mine that sets the rendered image name to name_#.ext:

def toggle_render_settings():
    # This controls the "_" in the padding, 0 for nothing, 1 for ".", and 2 for "_".
    cmds.setAttr("defaultRenderGlobals.periodInExt", 2)
    # This controls the "ext". Setting this to 0 makes name_#.ext while 1 makes name.#
    cmds.setAttr("defaultRenderGlobals.outFormatControl", 0)
    # This controls whether you want to render a single frame. 
    # 0 for single frame, and 1 for the animation.
    cmds.setAttr("defaultRenderGlobals.animation", 1)
    # This controls whether you want the extension or the frame number to go first in the file name.
    cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1)
    # This controls whether you want the extension ".ext" to be in your image name at all.
    cmds.setAttr("defaultRenderGlobals.outFormatControl", 0)
    print("[Zoetrope] Set Padding - Render settings correctly set.")


Implement Sequence Rendering

GUI

In a former post, I talked about how Maya does not support multi-thread processing. For our purposes, just know that once Maya starts rendering, that’s the only thing it can do, so the Maya window will freeze. There’s noway to kill it other than go to task manager and kill the Maya process. This is one of the limitation of Zoetrope.

One of the most common mistake I made was setting the render range wrong. Therefore, a little GUI that ask the user to confirm the range they want to render is handy. A simple dialog box will do.

msg = "Will render from frame " + str(renderStart) + " to frame " + str(renderEnd) + ". Are you sure?"
prompt_start = cmds.confirmDialog(title='Confirm Render', message=msg, button=['Yes', 'No'], 
                                    defaultButton='Yes', cancelButton='No', dismissString='No')
                                      
if prompt_start != "No":
    # TODO: Start rendering
    
    # Exit message.
    cmds.confirmDialog(title='Xwift Zoetrope: Task Finished.',
                       message='Successfully rendered all requested layers into target directory.',
                       button=['I got it!'], defaultButton='I got it!', dismissString='I got it!')


Render the Selected Range

Implementing the render selected range is nothing but a loop that executes a fancier version of render_one_frame over and over again.

# Set the start of the render range.
current_frame = renderStart

# Do render
while current_frame <= renderEnd:
    # Set current time to the target render frame.
    cmds.currentTime(current_frame)
    # Proceed with render the frame
    render_file_status = render_one_frame(width, height, current_frame, target_format, layer)
    # render_one_frame() will catch any error occurs.
    # So render_file_status returns True if rendered successfully, and False if failed.
    if not render_file_status:
        print("[Zoetrope] ERROR checking last render image, attempting to restart render again.")
    else:
        current_frame += 1


That’s all the guts for implementing the algorithm. However, there are a couple more things that can make it better.

Improvements

Cleanup: Check render size

I had this problem every once and a while that somehow Maya just skips a frame when rendering the sequence. Like this:

|scene.ma
|renders
    |defaultRenderLayer
        | scene_0001.tif - 2048KB
        | scene_0002.tif - 1KB  (skipped)
        | scene_0003.tif - 2048KB  


Then, I will have to come in and re-render that frame. But that is a task that can simply done by computer using a simple file size check:

def render_one_frame(width, height, file_format="tif", render_layer="defaultRenderLayer"):
    # TODO: Do preparation
    # TODO: Do Render
    # TODO: Do rename _1_ (Will talk about this below)
    
    # Not a robust solution since this cannot detect partially rendered images. 
    # But should keep unrendered images off the pipeline.
    if os.stat(postfix_image_dir).st_size > 2048:
        return True
    else:
        return False 


Way to Interrupt

As mentioned above, Maya will freeze while doing work, and there’s no easy way to stop it. We need to implement a way to at least be able to interrupt the loop in between each frame’s render.

I am using a stupid method: In each iteration of the above loop, if Maya detects a stop file named simply stop_render_YES.txt, it will break the loop.

Why, as some of you ask, can’t we just make a dialog box with a stop button? Well, because as mentioned, Maya cannot do multi-thread processing. Rendering and maintaining a dialog box that constantly checks if the user pressed a button is two threads in a same time. I know what you want to ask, the answer is Maya will literally crash if try to implement it with QThreads. Dirty programming, but working programming.

We need two functions, one that makes a file called stop_render_NO.txt, and one that checks the stop file.

def make_stop_txt_file(render_layer):
    """
    Makes a text file called "stop_render_NO.txt"
    """
    render_path = (sceneName().parent + "\\renders\\" + render_layer).replace('\\', '/')
    f = open(os.path.join(render_path, "stop_render_NO.txt"), "w+")
    f.write("To stop Zoetrope from rendering next image, rename this file to stop_render_YES.txt. ")
    f.close()


def check_stop_txt_file(render_layer):
    '''
    Checks if a directory contains a "stop_render_NO.txt".
    '''
    render_path = (sceneName().parent + "\\renders\\" + render_layer).replace('\\', '/')
    if not os.path.exists(os.path.join(render_path, "stop_render_NO.txt")):
        if os.path.exists(os.path.join(render_path, "stop_render_YES.txt")):
            print("Stop file exists. User requested Zoetrope to pause after this iteration.")
            return True
        else:
            make_stop_txt_file(render_layer)
            check_stop_txt_file(render_layer) 
    else:
        print("No user stop render request found. Proceed with next render.")
        return False


With these, we can integrate them into the beginning of the while loop mentioned above.

while current_frame <= renderEnd:
    # Check the stop file to see if the user wants to abort.
    layer = "defaultRenderLayer"
    if check_stop_txt_file(layer):
        print("User attempt to break the loop during the start of a render cycle. Aborting.")
        break
    ...


The _1_ bug

Here’s another thing Maya does that is annoying:

  • If you render out an image with Maya Batch Renderer, the image will come out as image_0000.tif.
  • However, if you render out an image (or sequence) with Maya Render Sequence, every image comes out as image_1_0000.tif.

I don’t know what is causing it, but it is annoying from a perfectionist’s perspective. Zoetrope, who uses the same method as Maya Render Sequence, also plagued with this problem.

Then, what we need to do, is add some extra lines in render_one_frame that renames the render once it is finished and created. Easier said than done, here’s how:

def render_one_frame(width, height, file_format="tif", render_layer="defaultRenderLayer"):
    # TODO: Do preparation
    
    file_dir = (sceneName().parent + "\\renders\\" + render_layer).replace('\\', '/')
    bug = "_1_"  # idk whyyyy does this happen 
    prefix_image_dir = file_dir + "\\" + prepend + bug + "{:0>4d}".format(frame) + "." + file_format
    postfix_image_dir = file_dir + "\\" + prepend + "_" + "{:0>4d}".format(frame) + "." + file_format
    
    # TODO: Do Render
    
    if os.path.exists(postfix_image_dir):
    os.remove(postfix_image_dir)
    if not os.path.exists(postfix_image_dir):
        print("Frame Renderer - Image exists at: " + postfix_image_dir + " and got deleted successfully.")
    os.rename(prefix_image_dir, postfix_image_dir)
    
    # TODO: Do cleanup


Conclusion

Working code is better than broken code. Go dirty if it can make things happen.

I hope this is helpful.

If you want to quickly say hi just shoot me a message using the contact portal.

Zoetrope - A custom Renderer in Maya
Older post

Reload Custom Scripts in Maya

Newer post

Integrate FFMPEG and Video Encoding with Maya

Zoetrope - A custom Renderer in Maya