In this second part we'll finish the turntable animation script we started (see this post for part 1 http://render.otoy.com/forum/viewtopic.php?f=73&t=37313. It uses some lua API functions introduces in Octane version 1.23 so don't be discouraged if it doesn't work in your version of Octane. I won't go over everything in the script, the UI code was covered in part 1. I won't cover everything only the most important parts but if there are any questions please ask. To use it, just select the desired render target and run the script.
Because we don't want to change our original scene while creating an animation, we take a full copy of the whole scene in a separate root graph. Remember, root graphs created in lua aren't added to the project and are destroyed when the script ends. They come in very handy as temporary storage. We do some extra checks to make sure the user has selected a render target and that that render target has a thinlens camera connected to it:
- Code: Select all
-- Returns copies of the original scene graph, the camera node and the rendertarget.
-- This prevents us from modifying the original scene.
local function getSceneCopy()
-- get the selected render target
local selectedRt = octane.project.getSelection()[1]
-- find the index of the selected render target node
local idx = -1
for i, item in ipairs(octane.nodegraph.getRootGraph():getOwnedItems()) do
if item == selectedRt then
idx = i
break
end
end
-- see if we could find the node
if idx == -1 then showError("no render target selected", true) end
-- create a full copy of the project so that we don't modify the original project
local copyScene = octane.nodegraph.createRootGraph("Project Copy")
copies = copyScene:copyFrom(octane.nodegraph.getRootGraph():getOwnedItems())
copyRt = copies[idx]
-- check if the copied node is a render target with a thinlens camera connected to it
if not copyRt or copyRt:getProperties().type ~= octane.NT_RENDERTARGET then
showError("no render target selected", true)
end
-- check if a thin lens camera is connected to the render target
local copyCam = copyRt:getConnectedNode(octane.P_CAMERA)
if not copyCam or copyCam :getProperties().type ~= octane.NT_CAM_THINLENS then
showError("no thinlens camera connected to the render target", true)
end
return copyScene, copyRt, copyCam
end
Another thing we need is animate the camera's position. This was already discussed in another post (see this post http://render.otoy.com/forum/viewtopic.php?f=73&t=37455) so I'm not going to repeat myself:
- Code: Select all
local function setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames)
-- get the original camera settings
local origCamTarget = camNode:getPinValue(octane.P_TARGET)
local origCamPosition = camNode:getPinValue(octane.P_POSITION)
local origCamUp = camNode:getPinValue(octane.P_UP)
local origViewDir = octane.vec.normalized(octane.vec.sub(origCamTarget, origCamPosition))
-- calculate the new camera position for each frame
local positions = {}
for i=0,nbFrames-1 do
-- calculate the angle of rotation for this frame
local angle = math.rad( (i / nbFrames) * rotAngle + offsetAngle )
-- rotate the viewing direction around the up vector
local newViewDir = octane.vec.rotate(origViewDir, origCamUp, angle)
-- scale the new view dir with the target distance
newViewDir = octane.vec.scale(newViewDir, targetDistance)
-- calculate the new camera position
local newCamPosition = octane.vec.sub(origCamTarget, newViewDir)
-- store the new camera position
table.insert(positions, newCamPosition)
end
-- animate the camera position
camNode:getConnectedNode(octane.P_POSITION):setAnimator(octane.A_VALUE, { 0 }, positions, 1 / nbFrames)
end
For creating the filename for each frame we created a helper function
createSavePath
. It takes the original path the user selected and tries to squeeze in the number of the frame. I stole this code from Roeland, he discusses it in this post http://render.otoy.com/forum/viewtopic.php?f=73&t=37387:- Code: Select all
-- creates a save path for the current frame
local function createSavePath(path, frame)
local file = octane.file.getFileName(path)
-- strip png extension
file = file:gsub("%.png$", "")
-- split file into prefix, sequence number and suffix
-- make sure the sequence number is in the final file name part
local prefix, sequenceMatch, suffix = file:match("(.-)(%d+)([^\\/]*)$")
-- if the file name doesn't contain a sequence number, the match fails
-- so just assume it is all prefix.
if sequenceMatch == nil then
prefix = file
sequenceMatch = "0000"
suffix = ""
end
-- pattern for string.format.
seqPattern = "%0"..sequenceMatch:len().."d"
-- add png extension
suffix = suffix..".png"
-- return the path to the output file
return octane.file.getParentDirectory(path).."/"..prefix..seqPattern:format(frame)..suffix
end
The interesting code is in the
startRender
function. First we need to make sure we set the IS_CANCELLED
flag to false. Then we set the shutter time for camera motion blur. Actually adding a slider to control motion blur is to be done. Next we disable most of the UI except the cancel button. We don't want people playing with this while rendering, we do want to allow them to cancel. Then we hook up the animator for the camera. And then (finally) we're ready to start rendering. What we do is run a loop, update the time for the next frame and render that frame. We also update the progress bar and check if we're not canceled. We register a render callback as well but all it does is check if we weren't canceled.- Code: Select all
-- flag indicating cancellation
IS_CANCELLED = false
function renderCallback()
-- check if rendering was cancelled
if (IS_CANCELLED) then
octane.render.stop()
return
end
end
local function startRender(sceneGraph, rtNode, camNode, path)
-- clear the cancel flag
IS_CANCELLED = false
-- TODO: add a motion blur slider
octane.render.setShutterTime(0.005)
-- disable part of the ui except for the cancel button
degSlider :updateProperties{ enable = false }
offsetSlider :updateProperties{ enable = false }
targetSlider :updateProperties{ enable = false }
samplesSlider :updateProperties{ enable = false }
durationSlider :updateProperties{ enable = false }
frameRateSlider :updateProperties{ enable = false }
frameSlider :updateProperties{ enable = false }
fileChooseButton:updateProperties{ enable = false }
cancelButton :updateProperties{ enable = true }
-- get the presets from the GUI
local rotAngle = degSlider:getProperties().value
local offsetAngle = offsetSlider:getProperties().value
local targetDistance = targetSlider:getProperties().value
local nbFrames = frameSlider:getProperties().value
local nbSamples = samplesSlider:getProperties().value
local outPath = fileEditor:getProperties().text
-- set up the animator for the camera
setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames)
-- start rendering out each frame
local currentTime = 0
for frame=1,nbFrames do
-- set the time in the scene
sceneGraph:updateTime(currentTime)
-- update the progress bar
progressBar:updateProperties{ text = string.format("rendering frame %d", frame) }
-- fire up the render engine, yihaah!
octane.render.start
{
renderTargetNode = rtNode,
maxSamples = nbSamples,
callback = renderCallback,
}
-- break out if we're cancelled and set it in the progress bar
if IS_CANCELLED then
progressBar:updateProperties{ progress = 0, text = "cancelled" }
break
end
-- save the current frame
local out = createSavePath(path, frame)
octane.render.saveImage(out, octane.render.imageType.PNG8)
-- update the time for the next frame
currentTime = frame * (1 / nbFrames)
-- update the progress bar
progressBar:updateProperties{ progress = frame / nbFrames }
end
-- enable part of the ui except for the cancel button
degSlider :updateProperties{ enable = true }
offsetSlider :updateProperties{ enable = true }
targetSlider :updateProperties{ enable = true }
samplesSlider :updateProperties{ enable = true }
durationSlider :updateProperties{ enable = true }
frameRateSlider :updateProperties{ enable = true }
frameSlider :updateProperties{ enable = true }
fileChooseButton:updateProperties{ enable = true}
cancelButton :updateProperties{ enable = false }
-- update the progress bar
progressBar:updateProperties{ progress = 0, text = "finished" }
end
We need to modify the GUI callback to make everything work. The "logic" is here. The duration, framerate and frames sliders are coupled, when we update one we want to update the others accordingly. When the user clicks the button to choose a file, well we want to show a file chooser. When a file is chosen we can enable the render button. If the user clicks the render button, we simply start rendering. And if he clicks the cancel button we cancel the rendering:
- Code: Select all
-- callback handling the GUI elements
local function guiCallback(component, event)
if component == durationSlider then
-- if the duration of the animation changes, update the #frames
local frames = math.ceil(durationSlider:getProperties().value * frameRateSlider:getProperties().value)
frameSlider:updateProperties{ value = frames }
elseif component == frameRateSlider then
-- if the frame rate changes, update the #frames
local frames = math.ceil(durationSlider:getProperties().value * frameRateSlider:getProperties().value)
frameSlider:updateProperties{ value = frames }
elseif component == frameSlider then
-- if the #frames changes, update the duration of the animation
local duration = frameSlider:getProperties().value / frameRateSlider:getProperties().value
durationSlider:updateProperties{ value = duration }
elseif component == fileChooseButton then
-- choose an output file
local ret = octane.gui.showDialog
{
type = octane.gui.dialogType.FILE_DIALOG,
title = "Choose the output file",
wildcards = "*.png",
save = true,
}
-- if a file is chosen
if ret.result ~= "" then
renderButton:updateProperties{ enable = true }
fileEditor:updateProperties{ text = ret.result }
OUT_PATH = ret.result
else
renderButton:updateProperties{ enable = false }
fileEditor:updateProperties{ text = "" }
OUT_PATH = nil
end
elseif component == renderButton then
startRender(SCENE_GRAPH, RT_NODE, CAM_NODE, OUT_PATH)
elseif component == cancelButton then
cancelRender()
elseif component == turntableWindow then
-- when the window closes, cancel rendering
if event == octane.gui.eventType.WINDOW_CLOSE then
cancelRender()
end
end
end
In the end, the script looks like this:
- Code: Select all
--
-- Turntable animation script
--
------------------------------------------------------------------------------
-- GUI code
-- creates a text label and returns it
local function createLabel(text)
return octane.gui.create
{
type = octane.gui.componentType.LABEL, -- type of component
text = text, -- text that appears on the label
width = 100, -- width of the label in pixels
height = 24, -- height of the label in pixels
}
end
-- creates a slider and returns it
local function createSlider(value, min, max, step, log)
return octane.gui.create
{
type = octane.gui.componentType.SLIDER, -- type of the component
width = 400, -- width of the slider in pixels
height = 20, -- height of the slider in pixels
value = value, -- value of the slider
minValue = min, -- minimum value of the slider
maxValue = max, -- maximum value of the slider
step = step, -- interval between 2 discrete slider values
logarithmic = log -- set the slider logarithmic
}
end
-- helper to pop-up an error dialog and optionally halts the script
local function showError(text, halt)
octane.gui.showDialog
{
type = octane.gui.dialogType.ERROR_DIALOG,
title = "Turntable Animation Error",
text = text,
}
if halt then error("ERROR: "..text) end
end
-- lets create a bunch of labels and sliders
local degLbl = createLabel("Degrees")
local offsetLbl = createLabel("Start Angle")
local targetLbl = createLabel("Target Offset")
local durationLbl = createLabel("Duration")
local frameRateLbl = createLabel("Framerate")
local frameLbl = createLabel("Frames")
local samplesLbl = createLabel("Samples/px")
local degSlider = createSlider(360, -360 , 360, 1, false)
local offsetSlider = createSlider(0 , -180 , 180, 1, false)
local targetSlider = createSlider(10 , 0.001, 10000, 0.001, true)
local samplesSlider = createSlider(400, 1 , 16000, 1, true)
-- these sliders are couples (25 frames @ 25 fps is 1 second of animation)
local durationSlider = createSlider(1 , 1 , 3600 , 0.001, true)
local frameRateSlider = createSlider(25, 10, 120 , 1 , false)
local frameSlider = createSlider(25, 10, 432000, 1 , true)
-- manual layouting is tedious so let's add all our stuff in a group.
local settingsGrp = octane.gui.create
{
type = octane.gui.componentType.GROUP, -- type of component
text = "Settings", -- title for the group
rows = 7, -- number of rows in the grid
cols = 2, -- number of colums in the grid
-- the children is a list of child component that go in each cell. The cells
-- are filled left to right, top to bottom. I just formatted the list to show
-- where each component goes in the grid.
children =
{
degLbl , degSlider ,
offsetLbl , offsetSlider ,
targetLbl , targetSlider ,
durationLbl , durationSlider ,
frameRateLbl , frameRateSlider ,
frameLbl , frameSlider ,
samplesLbl , samplesSlider ,
},
padding = { 2 }, -- internal padding in each cell
inset = { 5 }, -- inset of the group component itself
}
-- file output
-- create a button to show a file chooser
local fileChooseButton = octane.gui.create
{
type = octane.gui.componentType.BUTTON,
text = "Output...",
width = 80,
height = 20,
}
-- create an editor that will show the chosen file path
local fileEditor = octane.gui.create
{
type = octane.gui.componentType.TEXT_EDITOR,
text = "",
x = 20,
width = 400,
height = 20,
enable = false,
}
-- for layouting the button and the editor we use a group
local fileGrp = octane.gui.create
{
type = octane.gui.componentType.GROUP,
text = "Output",
rows = 1,
cols = 2,
children =
{
fileChooseButton, fileEditor,
},
padding = { 2 },
inset = { 5 },
}
-- progress bar
-- eye candy, a progress bar
local progressBar = octane.gui.create
{
type = octane.gui.componentType.PROGRESS_BAR,
text = "render progress",
width = fileGrp:getProperties().width * 0.8, -- as wide as the group above
height = 20,
}
-- for layouting the progress bar
local progressGrp = octane.gui.create
{
type = octane.gui.componentType.GROUP,
text = "",
rows = 1,
cols = 1,
children = { progressBar },
padding = { 10 },
centre = true, -- centre the progress bar in it's cell
border = false,
}
-- render & cancel buttons
local renderButton = octane.gui.create
{
type = octane.gui.componentType.BUTTON,
text = "Render",
width = 80,
height = 20,
}
local cancelButton = octane.gui.create
{
type = octane.gui.componentType.BUTTON,
text = "Cancel",
width = 80,
height = 20,
}
local buttonGrp = octane.gui.create
{
type = octane.gui.componentType.GROUP,
text = "",
rows = 1,
cols = 2,
children = { renderButton, cancelButton },
padding = { 5 },
border = false,
}
-- group that layouts the other groups
local layoutGrp = octane.gui.create
{
type = octane.gui.componentType.GROUP,
text = "",
rows = 4,
cols = 1,
children =
{
settingsGrp,
fileGrp,
progressGrp,
buttonGrp
},
centre = true,
padding = { 2 },
border = false,
debug = false, -- true to show the outlines of the group, handy
}
-- window that holds all components
local turntableWindow = octane.gui.create
{
type = octane.gui.componentType.WINDOW,
text = "Turntable Animation",
children = { layoutGrp },
width = layoutGrp:getProperties().width,
height = layoutGrp:getProperties().height,
}
------------------------------------------------------------------------------
-- Animation Rendering Code (helpers to get us going)
-- Returns copies of the original scene graph, the camera node and the rendertarget.
-- This prevents us from modifying the original scene.
local function getSceneCopy()
-- get the selected render target
local selectedRt = octane.project.getSelection()[1]
-- find the index of the selected render target node
local idx = -1
for i, item in ipairs(octane.nodegraph.getRootGraph():getOwnedItems()) do
if item == selectedRt then
idx = i
break
end
end
-- see if we could find the node
if idx == -1 then showError("no render target selected", true) end
-- create a full copy of the project so that we don't modify the original project
local copyScene = octane.nodegraph.createRootGraph("Project Copy")
copies = copyScene:copyFrom(octane.nodegraph.getRootGraph():getOwnedItems())
copyRt = copies[idx]
-- check if the copied node is a render target with a thinlens camera connected to it
if not copyRt or copyRt:getProperties().type ~= octane.NT_RENDERTARGET then
showError("no render target selected", true)
end
-- check if a thin lens camera is connected to the render target
local copyCam = copyRt:getConnectedNode(octane.P_CAMERA)
if not copyCam or copyCam :getProperties().type ~= octane.NT_CAM_THINLENS then
showError("no thinlens camera connected to the render target", true)
end
return copyScene, copyRt, copyCam
end
local function setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames)
-- get the original camera settings
local origCamTarget = camNode:getPinValue(octane.P_TARGET)
local origCamPosition = camNode:getPinValue(octane.P_POSITION)
local origCamUp = camNode:getPinValue(octane.P_UP)
local origViewDir = octane.vec.normalized(octane.vec.sub(origCamTarget, origCamPosition))
-- calculate the new camera position for each frame
local positions = {}
for i=0,nbFrames-1 do
-- calculate the angle of rotation for this frame
local angle = math.rad( (i / nbFrames) * rotAngle + offsetAngle )
-- rotate the viewing direction around the up vector
local newViewDir = octane.vec.rotate(origViewDir, origCamUp, angle)
-- scale the new view dir with the target distance
newViewDir = octane.vec.scale(newViewDir, targetDistance)
-- calculate the new camera position
local newCamPosition = octane.vec.sub(origCamTarget, newViewDir)
-- store the new camera position
table.insert(positions, newCamPosition)
end
-- animate the camera position
camNode:getConnectedNode(octane.P_POSITION):setAnimator(octane.A_VALUE, { 0 }, positions, 1 / nbFrames)
end
-- creates a save path for the current frame
local function createSavePath(path, frame)
local file = octane.file.getFileName(path)
-- strip png extension
file = file:gsub("%.png$", "")
-- split file into prefix, sequence number and suffix
-- make sure the sequence number is in the final file name part
local prefix, sequenceMatch, suffix = file:match("(.-)(%d+)([^\\/]*)$")
-- if the file name doesn't contain a sequence number, the match fails
-- so just assume it is all prefix.
if sequenceMatch == nil then
prefix = file
sequenceMatch = "0000"
suffix = ""
end
-- pattern for string.format.
seqPattern = "%0"..sequenceMatch:len().."d"
-- add png extension
suffix = suffix..".png"
-- return the path to the output file
return octane.file.getParentDirectory(path).."/"..prefix..seqPattern:format(frame)..suffix
end
-- flag indicating cancellation
IS_CANCELLED = false
function renderCallback()
-- check if rendering was cancelled
if (IS_CANCELLED) then
octane.render.stop()
return
end
end
local function startRender(sceneGraph, rtNode, camNode, path)
-- clear the cancel flag
IS_CANCELLED = false
-- TODO: add a motion blur slider
octane.render.setShutterTime(0.005)
-- disable part of the ui except for the cancel button
degSlider :updateProperties{ enable = false }
offsetSlider :updateProperties{ enable = false }
targetSlider :updateProperties{ enable = false }
samplesSlider :updateProperties{ enable = false }
durationSlider :updateProperties{ enable = false }
frameRateSlider :updateProperties{ enable = false }
frameSlider :updateProperties{ enable = false }
fileChooseButton:updateProperties{ enable = false }
cancelButton :updateProperties{ enable = true }
-- get the presets from the GUI
local rotAngle = degSlider:getProperties().value
local offsetAngle = offsetSlider:getProperties().value
local targetDistance = targetSlider:getProperties().value
local nbFrames = frameSlider:getProperties().value
local nbSamples = samplesSlider:getProperties().value
local outPath = fileEditor:getProperties().text
-- set up the animator for the camera
setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames)
-- start rendering out each frame
local currentTime = 0
for frame=1,nbFrames do
-- set the time in the scene
sceneGraph:updateTime(currentTime)
-- update the progress bar
progressBar:updateProperties{ text = string.format("rendering frame %d", frame) }
-- fire up the render engine, yihaah!
octane.render.start
{
renderTargetNode = rtNode,
maxSamples = nbSamples,
callback = renderCallback,
}
-- break out if we're cancelled and set it in the progress bar
if IS_CANCELLED then
progressBar:updateProperties{ progress = 0, text = "cancelled" }
break
end
-- save the current frame
local out = createSavePath(path, frame)
octane.render.saveImage(out, octane.render.imageType.PNG8)
-- update the time for the next frame
currentTime = frame * (1 / nbFrames)
-- update the progress bar
progressBar:updateProperties{ progress = frame / nbFrames }
end
-- enable part of the ui except for the cancel button
degSlider :updateProperties{ enable = true }
offsetSlider :updateProperties{ enable = true }
targetSlider :updateProperties{ enable = true }
samplesSlider :updateProperties{ enable = true }
durationSlider :updateProperties{ enable = true }
frameRateSlider :updateProperties{ enable = true }
frameSlider :updateProperties{ enable = true }
fileChooseButton:updateProperties{ enable = true}
cancelButton :updateProperties{ enable = false }
-- update the progress bar
progressBar:updateProperties{ progress = 0, text = "finished" }
end
local function cancelRender()
IS_CANCELLED = true
octane.render.stop()
end
local function initGui(camNode)
-- set the initial target distance in the distance slider
local target = camNode:getPinValue(octane.P_TARGET)
local position = camNode:getPinValue(octane.P_POSITION)
local viewDir = octane.vec.sub(target, position)
local tgtLen = octane.vec.length(viewDir)
targetSlider:updateProperties{ value = tgtLen }
-- render and cancel button are disable
renderButton:updateProperties{ enable = false }
cancelButton:updateProperties{ enable = false }
end
------------------------------------------------------------------------------
-- Main Flow
-- global variable that holds the output path
OUT_PATH = nil
-- Get the render target and camera in global variables
SCENE_GRAPH, RT_NODE, CAM_NODE = getSceneCopy()
-- initialize the UI
initGui(CAM_NODE)
-- callback handling the GUI elements
local function guiCallback(component, event)
if component == durationSlider then
-- if the duration of the animation changes, update the #frames
local frames = math.ceil(durationSlider:getProperties().value * frameRateSlider:getProperties().value)
frameSlider:updateProperties{ value = frames }
elseif component == frameRateSlider then
-- if the frame rate changes, update the #frames
local frames = math.ceil(durationSlider:getProperties().value * frameRateSlider:getProperties().value)
frameSlider:updateProperties{ value = frames }
elseif component == frameSlider then
-- if the #frames changes, update the duration of the animation
local duration = frameSlider:getProperties().value / frameRateSlider:getProperties().value
durationSlider:updateProperties{ value = duration }
elseif component == fileChooseButton then
-- choose an output file
local ret = octane.gui.showDialog
{
type = octane.gui.dialogType.FILE_DIALOG,
title = "Choose the output file",
wildcards = "*.png",
save = true,
}
-- if a file is chosen
if ret.result ~= "" then
renderButton:updateProperties{ enable = true }
fileEditor:updateProperties{ text = ret.result }
OUT_PATH = ret.result
else
renderButton:updateProperties{ enable = false }
fileEditor:updateProperties{ text = "" }
OUT_PATH = nil
end
elseif component == renderButton then
startRender(SCENE_GRAPH, RT_NODE, CAM_NODE, OUT_PATH)
elseif component == cancelButton then
cancelRender()
elseif component == turntableWindow then
-- when the window closes, cancel rendering
if event == octane.gui.eventType.WINDOW_CLOSE then
cancelRender()
end
end
end
-- hookup the callback with all the GUI elements
durationSlider:updateProperties { callback = guiCallback }
frameRateSlider:updateProperties { callback = guiCallback }
frameSlider:updateProperties { callback = guiCallback }
fileChooseButton:updateProperties { callback = guiCallback }
renderButton:updateProperties { callback = guiCallback }
cancelButton:updateProperties { callback = guiCallback }
turntableWindow:updateProperties { callback = guiCallback }
-- the script will block here until the window closes
turntableWindow:showWindow()
We realize that this isn't a trivial script. So if there are any questions we're more than happy to help. What's still missing so far is controlling motion blur.
Here's a turntable animation of "Mr Beep" rendered with the script so it works (at least on my machine .
Here's the script for download:
cheers,
Thomas