-- -- 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()