Scripting in the Project Window and Instance Nodes

The Quick Start page used a script on the instance scale node as an example. In this section we'll cover all of the scriptable instance node types.

Objects are held by instances in the database, and these instances in turn can have nodes attached to them that affect the appearance or geometry of an object. The transformations available on an object include the scale, offset, rotation and translation nodes, or the SORT nodes for brevity. These nodes are applied to an instanced object in the order given; scale first and then so on. This is important to know when you're scripting these node values.

Each node type is accessed in the same way via the project window. You expand the object of interest using the triangle to the left of the object name, expand the Object Properties entry, and then click on the scripting icon just to the right of the stopwatch icon for the desired node. This brings up the standard node scripting editor dialog.

Alternatively you can select your object in the modeling window and use the scripting menu Instance Nodes submenu to create and edit scripts on these nodes. The creation option is handy because these nodes aren't usually added to an instance until they're needed, making for a chicken-and-egg situation when it comes to scripting.

The Node Scripting Dialog

Starting an edit session presents the standard node scripting editor dialog:

The round arrows button on the upper right side of the dialog is used to swap the Initialization and Script Source areas. The lower area expands with the dialog window and presents a larger editor. The hash code is a very simple hash of the script in its current form and probably isn't of much use to the script writer.

The Elements popup contains sample scripts. Select an entry to replace any existing dialog content with the sample. This menu can be expanded upon by modifying the s3d.ui.scripting_elements table. The Globals popup provides access to the entire Lua global environment. Selecting an item from this menu will insert the selected item as text.

The Test button can be used to check the script for errors, returning the status result in the bottom Result section. The Remove button will remove the script from the instance node and close the dialog. The Cancel and OK buttons do what you'd expect.

The Initialization section contains Lua code executed whenever the script is re-evaluated. This occurs when the Test button is hit, when this dialog is exited via the OK button, when a file containing the script is loaded from disk, or when the instance our node is attached to is copied. It might happen at other times too, but the basic idea is that the initialization is run once as far as this script is concerned, while the body is run many, many times. It provides a good place to put things that don't need to be re-evaluated every time the node is asked for a value.

The Script Source area has a label above it indicating what the name is of the local variable containing the current value. This will differ depending on the node type being edited. The general idea is that this value comes into the script, is modified by the script, and is then returned by the script. If there is no return statement one is added behind the scenes for you. The type of this local value is one of our vector types defined in the s3d.vec table.

To make node script reuse simple you should consider adding your collection of node scripts for supported node types to the s3d.ui.scripting_elements table. An auto-loaded script could customize this table every time the program starts up, filling it with a library of node scripts ready for selection from the Elements menu.

Custom Node Dialogs

Like the ScriptFX shader, node scripts may supply their own dialog for editing. The mechanisms are very similar with the primary difference being the arguments expected for the function implementing the dialog. An example follows of a custom dialog for the Heartbeat script shipped in the Elements menu of the standard node script editor.

To try out this custom dialog follow the directions in the Quick Start section to create the Heartbeat node script, but then somewhere along the way replace the existing initialization text with the following:

Copy and paste this content into the Initialization section of a scale node script.

-- For the Initialization section.

--

-- Tag a section for parameters we're going to edit in our dialog.

-- There's nothing magic here, just comments in our source code to bound things.

--

--@EDITABLE_PARMS_BEGIN

local parms = { beatPercentage = 25.000000, beatsPerSecond = 3.000000 }

--@EDITABLE_PARMS_END

--

-- Compute precached values.

--

local beatScale = parms.beatPercentage / 100

local beatBase = 1.0 - beatScale

local beatPSScaled = math.pi * parms.beatsPerSecond

--[[@CUSTOM_EDITOR

--

-- Bail out and present the normal text editor if the option/alt key is held down.

--

if s3d.ui.IsModifierKeyDown(s3d.ui.modifiers.alt) then return nil end

local scriptfx = require "scriptfx"

--

-- Provide a custom editor for this script.

--

return function (resultClass, resultName, initialization, functionBody)

--

-- Load the parameters from this very initialization text

--

local parms, startParms, endParms = scriptfx.dialog.GetEmbeddedScriptParams( initialization )

--

-- Build the dialog

--

local d = s3d.ui.Dialog("Heartbeat Editor")

local minLabelWidth = 160

local minInputWidth = 48

local c1 = d:BeginCluster("Edit script parameters:")

d:AddStaticText("") -- Just for blank space

local l1 = d:AddStaticText("Beat Percentage:", minLabelWidth)

local l2 = d:AddStaticText("Beats per Second:", minLabelWidth)

d:AddStaticText("") -- Just for blank space

d:SetAlignment(l1, 1)

d:SetAlignment(l2, 1)

d:AddColumn(4)

d:AddStaticText("") -- Just for blank space

local e1 = d:AddNumberText(parms.beatPercentage, 2, minInputWidth)

local e2 = d:AddNumberText(parms.beatsPerSecond, 2, minInputWidth)

d:AddStaticText("") -- Just for blank space

d:SetValueLimits(e1, 0, 100)

d:SetValueLimits(e2, -1000000, 1000000)

d:EndCluster()

d:SetTextStyle(c1, nil, 12)

--

-- Present the dialog.

--

local result = (d:Present() == s3d.ui.Dialog.kOkay)

if result then

--

-- Store off our modified parameters.

--

parms.beatPercentage = d:GetValue(e1)

parms.beatsPerSecond = d:GetValue(e2)

initialization = scriptfx.dialog.SetEmbeddedScriptParams( initialization, startParms, endParms, parms )

end

return result, resultClass, resultName, initialization, functionBody

-- We always need to return the boolean result indicating an OK or cancel selection

-- along with the result class, name, initialization, and function body source text.

-- Returning invalid arguments will cause the standard editor to appear.

end

CUSTOM_EDITOR@]]

You should also replace the Script Source section with the following, although at the time of this writing the following text is identical to the existing text in the Heartbeat script.

Copy and paste this content into the Script Source section of a scale node script.

-- For the Script Source section.

-- Compute our new scale factor, and return the product.

local beatScale = beatBase + beatScale * math.abs (math.cos (time * beatPSScaled))

scale.x = scale.x * beatScale

scale.y = scale.y * beatScale

scale.z = scale.z * beatScale

return scale

Once you've replaced the Heartbeat script sections click the OK button to close the standard editing dialog. The next time you click on the edit button for that scale script you'll see the custom dialog. Try it out, it's cool. To get back to the standard editing dialog just hold down the option key while clicking the scale node script button in the project window.

This mechanism works because the program checks for a multiline comment starting with --[[@CUSTOM_EDITOR and ending with CUSTOM_EDITOR@]] in any node script, evaluates the text between those two tokens as a new Lua script that returns a function, and then executes that function in place of presenting the standard editor. Any errors during the execution of the custom dialog function will result in the standard editor being presented, and returning nil instead of returning a function will have the same effect. The nil result along with a check for the option key is how we support bypassing the custom dialog.

Shared Details

Node scripts execute after normal processing has chosen a value by bracketing the current time with stored key times and interpolating the stored values. Since a script has the last say on what value is produced by a node you are free to do almost anything in your processing, including ignoring a pre-interpolating value in favor of generating or interpolating your own value.

The implementation of a node script actually consists of a Lua block containing some local variables, the initialization source, and a local function declaration holding the actual script source.

These local variables are available to the initialization source and the script source:

    • _local_G -> table
      • An initially empty table set as the global environment for the node script. This ensures that no node script will accidentally pollute the actual global table _G by accident or lazy coding. This table is set to delegate existing global accesses to the actual global table, but any new global declarations will remain local.
    • resultClass -> a Luabind class
      • Contains nil or a reference to the Luabind class of the current value and expected result. If non-nil you can use this to allocate new vector objects of the same type using the constructor syntax.
    • resultName -> string
      • The name used for the node specific current value argument, "scale" for the scale node.
    • hash -> integer
      • A simple hash of the function source.
  • index -> integer
      • An arbitrarily increasing index value, potentially different for every re-parsing of the function.
  • id -> integer
      • The sum of the hash and index, meant to provide a unique identifier for every occurrence of the node script.

Behind the scenes the function containing this script source has the following arguments:

  • currentValue -> a vector type
      • This argument is the same as the node dependent argument, scale for the scale node for example.
  • time -> number
      • This argument contains the floating point time in seconds for the current evaluation.
  • dim -> integer
      • This argument is an integer indicating the number of elements in the current value. This is 3 for scale, offset and translation, and 4 for rotation because of the quaternion type used there.
  • context -> an internal type
      • This argument is an internal detail not currently of much interest, but you should leave it alone.

In addition to these function arguments there are some useful accessor functions declared in the local environment of the script body:

    • GetAllKeyTimes () -> table
      • Returns a table stuffed as an array of key times.
    • GetIndexForTime (atTime) -> integer, integer
      • Returns the previous and next indices for the given time. These will be the same value if the given time is actually a key time, or they will otherwise bracket the time with indices for the actual previous and next key times.
    • GetKeyframedValue (atTime) -> a vector value
      • Returns a vector value of the same type as the current value containing the possibly interpolated keyframed value for the given time.
    • GetKeyTimeAt (index) -> number
      • Returns the key time for the given index. Indices in Lua start at 1.
    • GetNumKeyTimes () -> integer
      • Returns the total number of stored key times.
    • GetLastKeyTime () -> number
      • Returns the very last keyframed time.

Specific Node Scripts

Now we get down to the things that make each node script type different. As you'll see they have a lot more in common than they have differences.

Whether the current value actually represents a keyframe or not depends on what values have been stored in the node already and what the time argument is. You can test for a keyframed value by a simple bit of code like this:

local prev, next = GetIndexForTime( time )

local isKeyFrame = (prev == next)

Values between keyframes are interpolated, typically using linear interpolation. The translation node supports other interpolation schemes by means of a path type parameter chosen and controllable from the project window.

Scale Node

For scale nodes the current keyframed value is stored in scale which is an object of the s3d.vec.Point3d class.

The scale node can return any real number values in the result point including zeroes and negative values. NaN's or infinites will almost certainly be bad for the modelers and renderers and should be avoided.

Offset Node

For offset nodes the current keyframed value is stored in offset which is an object of the s3d.vec.Point3d class.

The offset node is very similar to the translation node, except that it is always linearly interpolated and doesn't support user selectable path types. Since it comes before the rotation node it's often used to determine the origin of rotation for an object. Graphically this helps determine the position of the rotation center handle in the modeling views.

Rotation Node

For rotation nodes the current keyframed value is stored in rotation which is an object of the s3d.vec.Quaternion class.

Stored keyframed rotations currently have the limitation that they cannot express multiple revolutions around an axis. In typical usage a user must keyframe a rotation using less than 180 degrees, and then keyframe another rotation less than 180 degrees and so on in order to avoid ambiguity and end up with as many revolutions as desired.

Rotation node scripts have no such limitations. It's easy to script arbitrary rotations about arbitrary axes, and to have those rotations take as much or as little time as desired for a complete revolution or even multiple revolutions.

Translation Node

For translation nodes the current keyframed value is stored in position which is an object of the s3d.vec.Point3d class. Interpolated values are affected by the path type chosen in the project window for the node, with current path types including bezier spline, linear, natural spline, "spline", and TCB (tension, continuity, bias) spline.

An interesting example script included in the shipping Elements popup is the Smooth Path Motion script. For every access of the translation node this script measures the length of the path described by the node using discrete samples along the path both before and after the sampled time in order to adjust the time at which a sample is actually taken. This has the effect of providing smooth motion without acceleration and deceleration artifacts due to misplaced keyframes.

This script could probably be made more efficient. For example it never bothers to constrain its search by calling GetLastKeyTime. Obviously there won't be any keyframes after the last one.

It could be made more useful by adding control parameters to support ease-on and/or ease-out, and maybe even a customizable window of time over which it would operate. It avoids being impossibly slow by caching results for a couple of real world seconds, but it doesn't bother to check the current node time to see if the cache is even applicable or should be invalidated.