---------------------------------------------------------------------------------------------------- -- Turntable animation script -- -- @description Renders a turntable animation -- @author Octane Dev Team -- @version 0.4 -- @script-id OctaneRender Turntable Animation ------------------------------------------------------------------------------ -- 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 local function showError(text) octane.gui.showDialog { type = octane.gui.dialogType.BUTTON_DIALOG, icon = octane.gui.dialogIcon.WARNING, title = "Turntable Animation Error", text = text, } end -- lets create a bunch of labels and sliders local degLbl = createLabel("Degrees") local offsetLbl = createLabel("Start angle") local targetLbl = createLabel("Target distance") local durationLbl = createLabel("Duration") local frameRateLbl = createLabel("Framerate") local frameLbl = createLabel("Frames") local samplesLbl = createLabel("Samples/px") local shutterSpeedLbl = createLabel("Shutter speed (%)") 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) local shutterSpeedSlider = createSlider(100, 0, 100 , .1 , false) -- 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 = 8, -- 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 , shutterSpeedLbl , shutterSpeedSlider, samplesLbl , samplesSlider , }, padding = { 2 }, -- internal padding in each cell inset = { 5 }, -- inset of the group component itself } -- file output local fileGrpChildren = nil local fileChooseButton = nil fileEditor = nil if octane.render.saveImage then -- create a button to show a file chooser 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 fileEditor = octane.gui.create { type = octane.gui.componentType.TEXT_EDITOR, text = "", x = 20, width = 400, height = 20, } local row1 = octane.gui.create { type = octane.gui.componentType.GROUP, border = false, rows = 1, cols = 2, children = { fileChooseButton, fileEditor }, padding = { 2 }, inset = { 0 }, } startnrChk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = "Start file numbering at:", checked = false, width = 150, height = 20 } startnrNum = octane.gui.create { type = octane.gui.componentType.NUMERIC_BOX, minValue = 0, maxValue = 10000, value = 0, step = 1, height = 20 } local row2 = octane.gui.create { type = octane.gui.componentType.GROUP, border = false, rows = 1, cols = 2, children = { startnrChk, startnrNum }, padding = { 2 }, inset = { 0 }, } skipExistingChk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = "Skip existing image files", checked = true, width = 250, height = 20 } fileGrpChildren = { row1, row2, skipExistingChk} else local label = createLabel("Saving images is not available in the demo version") label.width = 500 fileGrpChildren = { label } end -- for layouting the button and the editor we use a group local fileGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "Output", rows = #fileGrpChildren, cols = 1, children = fileGrpChildren, 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.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 RENDERTXT = "Start render" local STOPTXT = "Stop render" local EXITTXT = "Exit" local renderButton = octane.gui.create { type = octane.gui.componentType.BUTTON, text = RENDERTXT, width = 120, height = 20, } local exitButton = octane.gui.create { type = octane.gui.componentType.BUTTON, text = EXITTXT, width = 120, height = 20, } local buttonGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", rows = 1, cols = 2, children = { renderButton, exitButton }, 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.width, height = layoutGrp.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] if not selectedRt or selectedRt.type ~= octane.NT_RENDERTARGET then showError("no render target selected") return nil end -- Create a full copy of the whole project so we don't modify the original project. local copyScene = octane.nodegraph.createRootGraph("Project Copy") local copyRt = copyScene:copyFromGraph(octane.project.getSceneGraph(), { selectedRt })[1] -- check if the copied node is a render target with a thinlens camera connected to it if not copyRt or copyRt.type ~= octane.NT_RENDERTARGET then showError("no render target selected") return nil end -- check if a thin lens camera is connected to the render target local copyCam = copyRt:getInputNode(octane.P_CAMERA) if not copyCam or copyCam .type ~= octane.NT_CAM_THINLENS then showError("no thinlens camera connected to the render target") return nil end return copyScene, copyRt, copyCam end local function setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames, frameRate) -- 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:getInputNode(octane.P_POSITION):setAnimator(octane.A_VALUE, { 0 }, positions, 1 / frameRate) end -- helper functions to get the filename function parseFileInputs() local file = fileEditor and fileEditor.text or "" -- If we have a file name, check where to substitute the sequence number: -- pattern tutorial here: http://lua-users.org/wiki/PatternsTutorial if file ~= "" then -- 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 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" -- display resulting file name fileEditor.text = prefix..sequenceMatch..suffix return {prefix, seqPattern, suffix} end return nil end function getImageFile(fileparts, seq, currentFrame) local fileFrameNo if startnrChk.checked then fileFrameNo = seq - 1 + startnrNum.value else fileFrameNo = currentFrame end return fileparts[1]..fileparts[2]:format(fileFrameNo)..fileparts[3] end -- cancel flag IDLE, RENDERING = 1, 2 status = IDLE -- Render callback function renderCallback() -- check if rendering was cancelled if status == IDLE then octane.render.callbackStop() return end end -- Render main loop local function startRender(sceneGraph, rtNode, camNode) status = RENDERING -- shutter speed local shutter = shutterSpeedSlider.value / (100 * frameRateSlider.value) octane.render.setShutterTime(shutter) -- get the presets from the GUI local rotAngle = degSlider.value local offsetAngle = offsetSlider.value local targetDistance = targetSlider.value local nbFrames = frameSlider.value local frameRate = frameRateSlider.value -- set up the animator for the camera setCamAnimator(camNode, rotAngle, offsetAngle, targetDistance, nbFrames, frameRate) -- file name local fileParts = parseFileInputs() -- set the max samples value of the kernel input node to make sure -- it's not less than the current setting local kernel = rtNode:getInputNode(octane.P_KERNEL) if kernel ~= nil then kernel:setPinValue(octane.P_MAX_SAMPLES, samplesSlider.value, false) end -- start rendering out each frame local currentTime = 0 local nbRendered = 0 local nbSkipped = 0 local nbFailed = 0 for frame=1,nbFrames do -- update the progress bar local text = string.format("rendering frame %d", frame) if nbFailed ~= 0 then text = text.." ("..nbFailed.." failed)" end progressBar.text = text -- current file name local thisFile if fileParts then -- see if a file was given, and replace the sequence number thisFile = getImageFile(fileParts, frame, frame) fileEditor.text = thisFile end -- render frame if the file doesn't exist yet (or no file is given), or always if -- we want to overwrite if not thisFile or not skipExistingChk.checked or not octane.file.exists(thisFile) then -- fire up the render engine, yihaah! -- set the time in the scene sceneGraph:updateTime(currentTime) local finished = pcall(octane.render.start, { renderTargetNode = rtNode, callback = renderCallback, }) if not finished then nbFailed = nbFailed + 1 end -- break out if we're cancelled and set it in the progress bar if status == IDLE then progressBar:updateProperties{ progress = 0, text = "cancelled" } break elseif fileParts then -- save the current frame octane.render.saveImage(thisFile, octane.render.imageType.PNG8) end nbRendered = nbRendered + 1 else nbSkipped = nbSkipped + 1 end -- update the time for the next frame currentTime = frame / frameRate -- update the progress bar progressBar.progress = frame / nbFrames end -- update the progress bar if status ~= IDLE then if nbRendered == 0 and nbSkipped > 0 then progressBar:updateProperties{ text = "all image files already exist", progress = 0 } else local text = "finished" if nbFailed ~= 0 then text = text.." ("..nbFailed.." failed)" end progressBar.text = text end end status = IDLE end local function cancelRender() status = IDLE octane.render.callbackStop() 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.value = tgtLen end ------------------------------------------------------------------------------ -- Main Flow -- Get the render target and camera in global variables SCENE_GRAPH, RT_NODE, CAM_NODE = getSceneCopy() -- if getSceneCopy failed, halt the script if SCENE_GRAPH == nil then return end -- initialize the UI initGui(CAM_NODE) inputwidgets = {degSlider, offsetSlider, targetSlider, samplesSlider, durationSlider, frameRateSlider, frameSlider, shutterSpeedSlider, fileChooseButton, fileEditor, startnrChk, startnrNum, skipExistingChk} -- all the components on the last row are file input widgets and may be -- nil. In Lua this just gives a smaller array. -- 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.value * frameRateSlider.value) frameSlider.value = frames elseif component == frameRateSlider then -- if the frame rate changes, update the #frames local frames = math.ceil(durationSlider.value * frameRateSlider.value) frameSlider.value = frames elseif component == frameSlider then -- if the #frames changes, update the duration of the animation local duration = frameSlider.value / frameRateSlider.value durationSlider.value = duration elseif component == fileChooseButton then -- choose an output file local file = fileEditor.text if octane.file.isAbsolute(file) then file = octane.file.getParentDirectory(file) else file = nil end local ret = octane.gui.showDialog { type = octane.gui.dialogType.FILE_DIALOG, title = "Choose the output file", wildcards = "*.png", path = file, save = true, } -- if a file is chosen if ret.result ~= "" then fileEditor.text = ret.result end elseif component == renderButton then if status == IDLE then renderButton.text = STOPTXT for _, c in pairs(inputwidgets) do c.enable = false end -- start render startRender(SCENE_GRAPH, RT_NODE, CAM_NODE, file) renderButton.text = RENDERTXT for _, c in pairs(inputwidgets) do c.enable = true end elseif status == RENDERING then cancelRender() end elseif component == exitButton then turntableWindow:closeWindow() 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 .callback = guiCallback frameRateSlider.callback = guiCallback frameSlider .callback = guiCallback if fileChooseButton then fileChooseButton.callback = guiCallback end renderButton .callback = guiCallback exitButton .callback = guiCallback turntableWindow.callback = guiCallback -- the script will block here until the window closes turntableWindow:showWindow()