Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Background

The Nodal Scene Interface (ɴsɪ) was developed to replace existing APIs in the 3Delight renderer which were showing their age. Particualry the RenderMan Interface and the RenderMan Shading Language.

Having been designed in the 80s and extended several times since, they include features which are no longer relevant and design decisions which do not reflect modern needs.

This makes some features more complex to use than they should be and prevents or greatly increases the complexity of implementing other features.

The design of the ɴsɪ was shaped by multiple goals:

  • Simplicity — The interface itself should be simple to understand and use, even if complex things can be done with it. This simplicity is carried into everything which derives from the interface.
  • Interactive Rendering and Scene Edits — Scene edit operations should not be a special case. There should be no difference between scene description and scene edits. In other words, a scene description is a series of edits and vice versa.
  • Tight Integration with Open Shading Languageᴏsʟ integration is not superficial and affects scene definition. For example, there are no explicit light sources in ɴsɪ: light sources are created by connecting shaders with an emission() closure to a geometry.
  • Scripting — The interface should be accessible from a platform independent, efficient and easily accessible scripting language. Scripts can be used to add render time intelligence to a given scene description.
  • Performance and Multi-Threading — All API design decisions are made with performance in mind and this includes the possibility to run all API calls in a concurrent, multi-threaded environment. Nearly all software today which deals with large data sets needs to use multiple threads at some point. It is important for the interface to support this directly so it does not become a single thread communication bottleneck. This is why commands are self-contained and do not rely on a current state. Everything which is needed to perform an action is passed in on every call.
  • Support for Serialization — The interface calls should be serializable. This implies a mostly unidirectional dataflow from the client application to the renderer and allows greater implementation flexibility.
  • Extensibility — The interface should have as few assumptions as possible built-in about which features the renderer supports. It should also be abstract enough that new features can be added without looking out of place.

The Interface

The Interface Abstraction

The Nodal Scene Interface is built around the concept of nodes. Each node has a unique handle to identify it and a type which describes its intended function in the scene. Nodes are abstract containers for data. The interpretation depends on the node type. Nodes can also be connected to each other to express relationships.

Data is stored on nodes as attributes. Each attribute has a name which is unique on the node and a type which describes the kind of data it holds (strings, integer numbers, floating point numbers, etc).

Relationships and data flow between nodes are represented as connections. Connections have a source and a destination. Both can be either a node or a specific attribute of a node. There are no type restrictions for connections in the interface itself. It is acceptable to connect attributes of different types or even attributes to nodes. The validity of such connections depends on the types of the nodes involved.

What we refer to as the ɴsɪ has two major components:

  • Methods to create nodes, attributes and their connections.
  • Node types understood by the renderer.

Much of the complexity and expressiveness of the interface comes from the supported nodes. The first part was kept deliberately simple to make it easy to support multiple ways of creating nodes. We will list a few of those in the following sections but this list is not meant to be final. New languages and file formats will undoubtedly be supported in the future.

APIs

The C API

This section describes the C implementation of the ɴsɪ, as provided in the nsi.h file. This will also be a reference for the interface in other languages as all concepts are the same.

#define NSI_VERSION 1

The NSI_VERSION macro exists in case there is a need at some point to break source compatibility of the C interface.

#define NSI_SCENE_ROOT ".root"

The NSI_SCENE_ROOT macro defines the handle of the root node.

#define NSI_ALL_NODES ".all"

The NSI_ALL_NODES macro defines a special handle to refer to all nodes in some contexts, such as removing connections.

#define NSI_ALL_ATTRIBUTES ".all"

The NSI_ALL_ATTRIBUTES macro defines a special handle to refer to all attributes in some contexts, such as removing connections.

Context Handling

NSIContext_t NSIBegin(
   int n_params,
   const NSIParam_t *args
)
void NSIEnd(
   NSIContext_t ctx
)

These two functions control creation and destruction of a ɴsɪ context, identified by a handle of type NSIContext_t.

A context must be given explicitly when calling all other functions of the interface. Contexts may be used in multiple threads at once. The NSIContext_t is a convenience typedef and is defined as:

typedef int NSIContext_t;

If NSIBegin fails for some reason, it returns NSI_BAD_CONTEXT which is defined in nsi.h:

#define NSI_BAD_CONTEXT ((NSIContext_t)0)

Optional arguments may be given to NSIBegin() to control the creation of the context:

NameTypeDescription/Values
typestringSets the type of context to create. The possible types are:
render — Execute the calls directly in the renderer. This is the default.
apistream — To write the interface calls to a stream, for later execution. The target for writing the stream must be specified in another argument.
streamfilenamestringThe file to which the stream is to be output, if the context type is apistream. Specify stdout to write to standard output and stderr to write to standard error.
streamformatstring (nsi)The format of the command stream to write. Possible formats are:
nsi — Produces an ɴsɪ stream.
binarynsi — Produces a binary encoded ɴsɪ stream.
autonsi — Behaves like nsi if streamfilename ends with .nsia, and like binarynsi otherwise.
streamcompressionstringThe type of compression to apply to the written command stream.
streampathreplacementint (1)Use 0 to disable replacement of path prefixes by references to environment variables which begin with NSI_PATH_ in an ɴsɪ stream. This should generally be left enabled to ease creation of files which can be moved between systems.
separateprocessint (0)A non-zero value makes rendering occur in a separate process. This can reduce the effect of rendering on the host application.
errorhandlerpointerA function which is to be called by the renderer to report errors. The default handler will print messages to the console.
errorhandlerdatapointerThe userdata argument of the error reporting function.
executeproceduralsstringA list of procedural types that should be executed immediately when a call to NSIEvaluate() or a procedural node is encountered and NSIBegin()’s output type is apistream. This will replace any matching call to NSIEvaluate() with the results of the procedural’s execution.

Arguments vs. Attributes

Arguments are what a user specifies when calling a function of the API. Each function takes extra, optional arguments.

Attributes are properties of nodes and are only set through the aforementioned optional arguments using the NSISetAttribute() and NSISetAttributeAtTime() functions.

Optional Arguments

Any API call can take extra arguments. These are always optional. What this means the call can do work without the user specifying these arguments.

Nodes are special as they have mandatory extra attributes that are set after the node is created inside the API but which must be set before the geometry or concept the node represents can actually be created in the scene.

These attributes are passed as extra arguments to the NSISetAttribute() and NSISetAttributeAtTime() functions.

[!NOTE] Nodes can also take extra arguments when they are created. These optional arguments are only meant to add information needed to create the node that a particular implementation may need.

As of this writing there is no implementation that has any such optional arguments on the NSICreate() function. The possibility to specify them is solely there to make the API future proof.

[!CAUTION] Nodes do not have optional arguments for now. An optional argument on a node is not the same as an attribute on a node.

Attributes — Describe the Node’s Specifics

Attributes are only for nodes. They must be set using the NSISetAttribute() or NSISetAttributeAtTime() functions.

They can not be set on the node when it is created with the NSICreate() function.

[!CAUTION] Only nodes have attributes. They are sent to the API via optional arguments on the API’s attribute functions.

Passing Optional Arguments

struct NSIParam_t
{
    const char *name;
    const void *data;
    int type;
    int arraylength;
    size_t count;
    int flags;
};

This structure is used to pass variable argument lists through the C interface. Most functions accept an array of the structure in a args argument along with its length in a n_params argument.

The meaning of these two arguments will not be documented for every function. Instead, each function will document the arguments which can be given in the array.

name : A C string which gives the argument’s name.

type : Identifies the argument’s type, using one of the following constants:

ConstantDescription
NSITypeFloatSingle 32-bit floating point value.
NSITypeDoubleSingle 64-bit floating point value.
NSITypeIntegerSingle 32-bit integer value.
NSITypeInt64Single 64-bit integer value.
NSITypeStringString value, given as a pointer to a C string.
NSITypeColorColor, given as three 32-bit floating point values.
NSITypePointPoint, given as three 32-bit floating point values.
NSITypeVectorVector, given as three 32-bit floating point values.
NSITypeNormalNormal vector, given as three 32-bit floating point values.
NSITypeMatrixTransformation matrix, in row-major order, given as 16 32-bit floating point values.
NSITypeDoubleMatrixTransformation matrix, in row-major order, given as 16 64-bit floating point values.
NSITypePointerC pointer.
NSITypeHPointHomogeneous point (w·x, w·y, w·z, w), given as four 32-bit floating point values. The stream spells it hpoint. See Geometry in the Type System for the draft name weighted-point.

[!NOTE] The names of these constants spell the storage width in three different ways. See Type Names: API Alternatives for a draft that reworks them.

Tuple types are specified by setting the bit defined by the NSIParamIsArray constant in the flags member and the length of the tuple in the arraylength member.

[!TIP] It helps to view arraylength as a part of the data type. The data type is a tuple with this length when NSIParamIsArray is set.

[!NOTE] If NSIParamIsArray is not set, arraylength is ignored.

The NSIParamIsArray flag is necessary to distinguish between arguments that happen to be of length 1 (set in the count member) and tuples that have a length of 1 (set in the arraylength member) for the resp. argument.

"foo" "int[1]" 1 [42]  # The answer to the ultimate question – in a (single) tuple
"bar" "int" 1 13       # My favorite Friday

The count member gives the number of data items given as the value of the argument.

The data member is a pointer to the data for the argument. This is a pointer to a single value or a number of values. Depending on type, count and arraylength settings.

[!NOTE] When data is an array, the actual number of elements in the array is count × arraylength × n. Where n is specified implicitly through the type member in the table above.

For example, if the type is NSITypeColor (3 values), NSIParamIsArray is set, arraylength is 2 and count is 4, data is expected to contain 24 32-bit floating point values (3×2×4).

The flags member is a bit field with a number of constants used to communicate more information about the argument:

FlagDescription
NSIParamIsArrayTo specify that the argument is an array type, as explained above.
NSIParamPerFaceTo specify that the argument has different values for every face of a geometric primitive, where this might be ambiguous.
NSIParamPerVertexSpecify that the argument has different values for every vertex of a geometric primitive, where this might be ambiguous.
NSIParamInterpolateLinearSpecify that the argument is to be interpolated linearly instead of using some other, default method.

[!NOTE] NSIParamPerFace or NSIParamPerVertex are only strictly needed in rare circumstances when a geometric primitive’s number of vertices matches the number of faces. The most simple case is a tetrahedral mesh which has exactly four vertices and also four faces.

Indirect lookup of arguments is achieved by giving an integer argument of the same name, with the .indices suffix added. This is read to know which values of the other argument to use.

Create "subdiv" "mesh"
SetAttribute "subdiv"
  "nvertices" "int" 4 [ 4 4 4 4 ]
  "P" "point" 9 [
    0 0 0  1 0 0  2 0 0
    0 1 0  1 1 0  2 1 0
    0 2 0  1 2 0  2 2 2 ]
  "P.indices" "int" 16 [
    0 1 4 3  2 3 5 4  3 4 7 6  4 5 8 7 ]
  "subdivision.scheme" "string" 1 "catmull-clark"

Node Creation

void NSICreate(
    NSIContext_t context,
    NSIHandle_t handle,
    const char *type,
    int n_params,
    const NSIParam_t *args
)

This function is used to create a new node. Its arguments are:

context : The context returned by NSIBegin(). See context handling.

handle : A node handle. This string will uniquely identify the node in the scene.

If the supplied handle matches an existing node, the function does nothing if all other arguments match the call which created that node. Otherwise, it emits an error. Note that handles need only be unique within a given interface context. It is acceptable to reuse the same handle inside different contexts. The NSIHandle_t typedef is defined in nsi.h:

typedef const char* NSIHandle_t;

type : The type of node to create.

n_params, args : This pair describes a list of optional arguments. The NSIParam_t type is described in this section.

[!CAUTION] There are no optional arguments defined as of now.


void NSIDelete(
    NSIContext_t ctx,
    NSIHandle_t handle,
    int n_params,
    const NSIParam_t *args
)

This function deletes a node from the scene. All connections to and from the node are also deleted. Note that it is not possible to delete the root or the global node. Its arguments are:

context : The context returned by NSIBegin(). See context handling.

handle : A node handle. It identifies the node to be deleted.

It accepts the following optional arguments:

NameTypeDescription/Values
recursiveintSpecifies whether deletion is recursive. By default, only the specified node is deleted. If a value of 1 is given, then nodes which connect to the specified node are recursively removed unless they also have connections which do not eventually lead to the specified node, or their connection to the deleted node was created with a strength greater than 0. This allows, for example, deletion of an entire shader network in a single call.

Setting Attributes

void NSISetAttribute(
    NSIContext_t ctx,
    NSIHandle_t object,
    int n_params,
    const NSIParam_t *args
)

This function sets attributes on a previously created node. All optional arguments of the function become attributes of the node.

On a shader node, this function is used to set the implicitly defined shader arguments.

Setting an attribute using this function replaces any value previously set by NSISetAttribute() or NSISetAttributeAtTime(). To reset an attribute to its default value, use NSIDeleteAttribute().


void NSISetAttributeAtTime(
    NSIContext_t ctx,
    NSIHandle_t object,
    double time,
    int n_params,
    const NSIParam_t *args
)

This function sets time-varying attributes (i.e. motion blurred). The time argument specifies at which time the attribute is being defined.

It is not required to set time-varying attributes in any particular order. In most uses, attributes that are motion blurred must have the same specification throughout the time range.

A notable exception is the P attribute on particles which can be of different size for each time step because of appearing or disappearing particles. Setting an attribute using this function replaces any value previously set by NSISetAttribute().


void NSIDeleteAttribute(
    NSIContext_t ctx,
    NSIHandle_t object,
    const char *name
)

This function deletes any attribute with a name which matches the name argument on the specified object. There is no way to delete an attribute only for a specific time value.

Deleting an attribute resets it to its default value.

For example, after deleting the transformationmatrix attribute on a transform node, the transform will be an identity. Deleting a previously set attribute on a shader node will default to whatever is declared inside the shader.

Making Connections

void NSIConnect(
    NSIContext_t ctx,
    NSIHandle_t from,
    const char *from_attr,
    NSIHandle_t to,
    const char *to_attr,
    int n_params,
    const NSIParam_t *args
)
void NSIDisconnect(
    NSIContext_t ctx,
    NSIHandle_t from,
    const char *from_attr,
    NSIHandle_t to,
    const char *to_attr
)

These two functions respectively create or remove a connection between two elements. It is not an error to create a connection which already exists or to remove a connection which does not exist but the nodes on which the connection is performed must exist. The arguments are:

from : The handle of the node from which the connection is made.

from_attr : The name of the attribute from which the connection is made. If this is an empty string then the connection is made from the node instead of from a specific attribute of the node.

to : The handle of the node to which the connection is made.

to_attr : The name of the attribute to which the connection is made. If this is an empty string then the connection is made to the node instead of to a specific attribute of the node.

NSIConnect() accepts additional optional arguments.

NameTypeDescription/Values
valueThis can be used to change the value of a node’s attribute in some contexts. Refer to guidelines on inter-object visibility for more information about the utility of this parameter.
priorityint (0)When connecting attribute nodes, indicates in which order the nodes should be considered when evaluating the value of an attribute. At equal priority, the attributes node connected first wins; see the attributes node.
strengthint (0)A connection with a strength greater than 0 will block the progression of a recursive NSIDelete.

Severing Connections

With NSIDisconnect(), the handle for either node, and any or all of the attribute names, may be the special value .all. This will remove all connections which match the other arguments. For example, to disconnect everything from the scene’s root:

NSIDisconnect( NSI_ALL_NODES, "", NSI_SCENE_ROOT, "objects" );

Evaluating Procedurals

void NSIEvaluate(
    NSIContext_t ctx,
    int n_params,
    const NSIParam_t *args
)

This function includes a block of interface calls from an external source into the current scene. It blends together the concepts of a straight file include, commonly known as an archive, with that of procedural include which is traditionally a compiled executable. Both are really the same idea expressed in a different language (note that for delayed procedural evaluation one should use the procedural node).

The ɴsɪ adds a third option which sits in-between — Lua scripts. They are much more powerful than a simple included file yet they are also much easier to generate as they do not require compilation. It is, for example, very realistic to export a whole new script for every frame of an animation. It could also be done for every character in a frame. This gives great flexibility in how components of a scene are put together.

The ability to load ɴsɪ commands straight from memory is also provided.

The optional arguments accepted by this function are:

NameTypeDescription/Values
typestringThe type of file which will generate the interface calls. This can be one of:
apistream — Read in an ɴsɪ stream. This requires either filename, script or buffer/size to be provided as the source of the ɴsɪ commands.
lua — Execute a Lua script, either from file or inline. See also how to evaluate a Lua script.
dynamiclibrary — Execute native compiled code in a loadable library. See dynamic library procedurals for an implementation example.
filenamestringThe file from which to read the interface stream.
replacensidirint (1)When evaluating an apistream, this controls whether ${NSIDIR} references are replaced by the path to the directory that holds filename.
scriptstringA valid Lua script to execute when type is set to lua.
buffer/sizepointer/int64These two arguments define a memory block that contains ɴsɪ commands to execute.
backgroundloadintIf this is nonzero, the object may be loaded in a separate thread, at some later time. This requires that further interface calls not directly reference objects defined in the included file. The only guarantee is that the file will be loaded before rendering begins.

Error Reporting

enum NSIErrorLevel
{
    NSIErrMessage = 0,
    NSIErrInfo = 1,
    NSIErrWarning = 2,
    NSIErrError = 3
}
typedef void (*NSIErrorHandler_t)(
    void *userdata, int level, int code, const char *message
)

This defines the type of the error handler callback given to the NSIBegin() function. When it is called, the level argument is one of the values defined by the NSIErrorLevel enum. The code argument is a numeric identifier for the error message, or 0 when irrelevant. The message argument is the text of the message.

The text of the message will not contain the numeric identifier nor any reference to the error level. It is usually desirable for the error handler to present these values together with the message. The identifier exists to provide easy filtering of messages.

The intended meaning of the error levels is as follows:

LevelDescription
NSIErrMessageFor general messages, such as may be produced by printf() in shaders. The default error handler will print this type of messages without an eol terminator as it’s the duty of the caller to format the message.
NSIErrInfoFor messages which give specific information. These might simply inform about the state of the renderer, files being read, settings being used and so on.
NSIErrWarningFor messages warning about potential problems. These will generally not prevent producing images and may not require any corrective action. They can be seen as suggestions of what to look into if the output is broken but no actual error is produced.
NSIErrErrorFor error messages. These are for problems which will usually break the output and need to be fixed.

Rendering

void NSIRenderControl(
    NSIContext_t ctx,
    int n_params,
    const NSIParam_t *args
)

This function is the only control function of the API. It is responsible for starting, suspending and stopping the render. It also allows for synchronizing the render with interactive calls that might have been issued. The function accepts:

NameTypeDescription/Values
actionstringSpecifies the operation to be performed, which should be one of the following:
start — This starts rendering the scene in the provided context. The render starts in parallel and the control flow is not blocked.
wait — Wait for a render to finish.
synchronize — For an interactive render, apply all the buffered calls to scene’s state.
suspend — Suspends render in the provided context.
resume — Resumes a previously suspended render.
stop — Stops rendering in the provided context without destroying the scene.

NSIRenderControl() accepts the following optional arguments:

NameTypeDescription/Values
progressiveint (0)If set to 1, render the image in a progressive fashion.
interactiveint (0)If set to 1, the renderer will accept commands to edit scene’s state while rendering. The difference with a normal render is that the render task will not exit even if rendering is finished. Interactive renders are by definition progressive. Interactive renders are by definition progressive.
frameSpecifies the frame number of this render.
stoppedcallbackpointerA pointer to a user function that should be called on rendering status changes. The function signature is:
void StoppedCallback(
    void* stoppedcallbackdata,
    NSIContext_t ctx,
    int status
)

The status argument can take the following values:

  • NSIRenderCompleted indicates that rendering has completed normally.
  • NSIRenderAborted indicates that rendering was interrupted before completion.
  • NSIRenderSynchronized indicates that an interactive render has produced an image which reflects all changes to the scene.
  • NSIRenderRestarted indicates that an interactive render has received new changes to the scene and no longer has an up to date image.
NameTypeDescription/Values
stoppedcallbackdatapointerA pointer that will be passed back to the stoppedcallback function.

The C++ API

The nsi.hpp file provides C++ wrappers which are less tedious to use than the low level C interface. All the functionality is inline so no additional libraries are needed and there are no abi issues to consider.

Creating a Context

The core of these wrappers is the NSI::Context class. Its default construction will require linking with the renderer.

#include "nsi.hpp"

NSI::Context nsi;

The nsi_dynamic.hpp file provides an alternate api source which will load the renderer at runtime and thus requires no direct linking.

#include "nsi.hpp"
#include "nsi_dynamic.hpp"

NSI::DynamicAPI nsi_api;
NSI::Context nsi(nsi_api);

In both cases, a new nsi context can then be created with the Begin() method.

nsi.Begin();

This will be bound to the NSI::Context object and released when the object is deleted. It is also possible to bind the object to a handle from the C API, in which case it will not be released unless the End() method is explicitly called.

Argument Passing

The NSI::Context class has methods for all the other ɴsɪ calls. The optional arguments of those can be set by several accessory classes and given in many ways. The most basic is a single argument.

nsi.SetAttribute("handle", NSI::FloatArg("fov", 45.0f));

It is also possible to provide static lists:

nsi.SetAttribute(
    "handle",(
        NSI::FloatArg("fov", 45.0f),
        NSI::DoubleArg("depthoffield.fstop", 4.0)
    )
);

And finally a class supports dynamically building a list.

NSI::ArgumentList args;
args.Add(new NSI::FloatArg("fov", 45.0f));
args.Add(new NSI::DoubleArg("depthoffield.fstop", 4.0));
nsi.SetAttribute("handle", args);

The NSI::ArgumentList object will delete all the objects added to it when it is deleted.

Argument Classes

To be continued …

The Rust API

The nsi crate provides Rust wrappers for the ɴsɪ API. These are based on the low-level wrapper crate nsi-sys that contains autogenerated bindings on top of nsi.h.

Creating a Context

The core of these wrappers is the Context struct. Its construction triggers dynamic linking with the renderer.

#![allow(unused)]
fn main() {
let ctx = nsi::Context::new(None)?
}

The Lua API

The scripted interface is slightly different than its counterpart since it has been adapted to take advantage of the niceties of Lua. The main differences with the C   API are:

  • No need to pass a ɴsɪ context to function calls since it’s already embodied in the ɴsɪ Lua table (which is used as a class).
  • The type argument can be omitted if the argument is an integer, real or string (as with the Kd and filename in the example below).
  • ɴsɪ arguments can either be passed as a variable number of arguments or as a single argument representing an array of arguments (as in the "ggx" shader below)
  • There is no need to call NSIBegin() and NSIEnd() equivalents since the Lua script is run in a valid context.

Below is an example shader creation logic in Lua.

nsi.Create( "lambert", "shader" );
nsi.SetAttribute(
    "lambert",
    { name = "filename", data = "lambert_material.oso" },
    { name = "Kd", data = 0.55 },
    { name = "albedo", data = { 1, 0.5, 0.3 }, type = nsi.TypeColor }
);

nsi.Create( "ggx", "shader" );
nsi.SetAttribute(
    "ggx", {
        {name = "filename", data = "ggx_material.oso" },
        {name = "anisotropy_direction", data = {0.13, 0 ,1}, type = nsi.TypeVector }
    }
);

API Calls

All (in a scripting context) useful ɴsɪ functions are provided and are listed below. There is also a nsi.utilities class which, for now, only contains a method to print errors.

Lua FunctionC equivalent
nsi.SetAttribute()NSISetAttribute()
nsi.SetAttributeAtTime()NSISetAttributeAtTime()
nsi.Create()NSICreate()
nsi.Delete()NSIDelete()
nsi.DeleteAttribute()NSIDeleteAttribute()
nsi.Connect()NSIConnect()
nsi.Disconnect()NSIDisconnect()
Evaluate()NSIEvaluate()

ɴsɪ functions

Optional Function Arguments Format

Each single argument is passed as a Lua table containing the following key values:

  • name – the name of the argument.

  • data – the argument data. Either a value (integer, float or string) or an array.

  • type – the type of the argument. Possible values are:

    Lua TypeC equivalent
    nsi.TypeFloatNSITypeFloat
    nsi.TypeIntegerNSITypeInteger
    nsi.TypeStringNSITypeString
    nsi.TypeColorNSITypeColor
    nsi.TypeNormalNSITypeNormal
    nsi.TypeVectorNSITypeVector
    nsi.TypePointNSITypePoint
    nsi.TypeMatrixNSITypeMatrix
    nsi.TypeDoubleMatrixNSITypeDoubleMatrix
    nsi.TypeHPointNSITypeHPoint

    Lua ɴsɪ argument types

    NSITypeDouble, NSITypeInt64, and NSITypePointer have no Lua equivalent. Use the C or C++ API for those types.

    Warning: In 3Delight 2.9.210, nsi.TypeDouble, nsi.TypeInt64 and nsi.TypePointer are nil. An argument that names one of them has no type, so the renderer infers one, and the value is changed without an error: data=9007199254740993, type=nsi.TypeInt64 arrives as the int 1, and data=0.1, type=nsi.TypeDouble arrives as a float.

  • arraylength – length of the array for each element.

Here are some example of well formed arguments:

--[[ strings, floats and integers do not need a 'type' specifier ]] --
p1 = {
    name = "shaderfilename",
    data = "emitter"
};
p2 = {
    name = "power",
    data = 10.13
};
p3 = {
    name = "toggle",
    data = 1
};

--[[ All other types, including colors and points, need a
     type specified for disambiguation. ]]--
p4 = {
    name = "Cs",
    data = { 1, 0.9, 0.7 },
    type=nsi.TypeColor
};

--[[ An array of 2 colors ]] --
p5 = {
    name = "vertex_color",
    arraylength = 2,
    data= { 1, 1, 1, 0, 0, 0 },
    type= nsi.TypeColor
};

--[[ Create a simple mesh and connect it root ]] --
nsi.Create( "floor", "mesh" )
nsi.SetAttribute(
    "floor", {
        name = "nvertices",
        data = 4
    }, {
        name = "P",
        type = nsi.TypePoint,
        data = { -2, -1, -1, 2, -1, -1, 2, 0, -3, -2, 0, -3 }
    }
)
nsi.Connect( "floor", "", ".root", "objects" )

Evaluating a Lua Script

Script evaluation is done through C, an ɴsɪ stream or even another Lua script. Here is an example using an ɴsɪ stream:

Evaluate
    "filename" "string" 1 ["test.nsi.lua"]
    "type" "string" 1 ["lua"]

It is also possible to evaluate a Lua script inline using the script argument. For example:

Evaluate
    "script" "string" 1 ["nsi.Create(\"light\", \"shader\");"]
    "type" "string" 1 ["lua"]

Both filename and script can be specified to NSIEvaluate() in one go, in which case the inline script will be evaluated before the file and both scripts will share the same ɴsɪ and Lua contexts.

Any error during script parsing or evaluation will be sent to ɴsɪ’s error handler.

Some utilities, such as error reporting, are available through the nsi.utilities class.

[!NOTE] All Lua scripts are run in a sandbox in which all Lua system libraries are disabled.

Passing Arguments to a Lua Script

All arguments passed to NSIEvaluate() will appear in the nsi.scriptparameters table. For example, the following call:

Evaluate
    "filename" "string" 1 ["test.lua"]
    "type" "string" 1 ["lua"]
    "userdata" "color[2]" 1 [1 0 1 2 3 4]

Will register a userdata entry in the nsi.scriptparameters table. So executing the following line in the test.lua script that the above snippete references:

print( nsi.scriptparameters.userdata.data[5] );

Will print:

3.0

Reporting Errors from a Lua Script

Use nsi.utilities.ReportError() to send error messages to the error handler defined in the current nsi context. For example:

nsi.utilities.ReportError( nsi.ErrWarning, "Watch out!" );

The error codes are the same as in the C API. They are shown in the table below.

Lua Error CodesC equivalent
nsi.ErrMessageNSIErrMessage
nsi.ErrWarningNSIErrWarning
nsi.ErrInfoNSIErrInfo
nsi.ErrErrorNSIErrError

Lua ɴsɪ error codes

The Python API

The nsi.py file provides a python wrapper to the C interface. It is compatible with both Python 2.7 and Python 3.

An example of how to us it is provided in python/examples/live_edit/live_edit.py.

Precision of Untyped Arguments

The wrapper infers a type when you do not give one. A Python float becomes NSITypeDouble, and a Python int becomes NSITypeInteger (nsi.py, _GetArgNSIType).

The renderer matches attribute types exactly. It does not convert between widths. An attribute declared float therefore rejects a bare Python float, and warns:

3DL WARNING E6007 wrong type for attribute 'fov' on node 'c' of type
'perspectivecamera' (expected type 'float', got 'double')

The call is dropped, and the attribute keeps its previous value. Pass a FloatArg for any attribute that the node reference documents as float:

nsi.SetAttribute("c", fov=nsi.FloatArg(35))

The shipped live_edit.py example does this for fov, and passes shutterrange bare because that attribute is declared double.

The Interface Stream

It is important for a scene description API to be streamable. This allows saving scene description into files, communicating scene state between processes and provide extra flexibility when sending commands to the renderer ​1.

Instead of re-inventing the wheel, the authors have decided to use exactly the same format as is used by the RenderMan Interface Bytestream (RIB). This has several advantages:

  • Well defined ASCII and binary formats.
  • The ASCII format is human readable and easy to understand.
  • Easy to integrate into existing renderers (writers and readers already available).

Note that since Lua is part of the API, one can use Lua files for API streaming ​2.


Footnotes


  1. The streamable nature of the RenderMan API, through RIB, is an undeniable advantage. RenderMan is a registered trademark of Pixar.

  2. Preliminary tests show that the Lua parser is as fast as an optimized ASCII RIB parser.

Dynamic Library Procedurals

NSIEvaluate and procedural nodes can execute code loaded from a dynamically loaded library that defines a procedural. Executing the procedural is expected to result in a series of ɴsɪ API calls that contribute to the description of the scene. For example, a procedural could read a part of the scene stored in a different file format and translate it directly into ɴsɪ calls.

This section describes how to use the definitions from the nsi_procedural.h header to write such a library in C or C++. However, the process of compiling and linking it is specific to each operating system and out of the scope of this manual.

Entry Point

The renderer expects a dynamic library procedural to contain a NSIProceduralLoad() symbol, which is an entry point for the library’s main function:

struct NSIProcedural_t* NSIProceduralLoad(
    NSIContext_t ctx,
    NSIReport_t report,
    const char* nsi_library_path,
    const char* renderer_version);

It will be called only once per render and has the responsibility of initializing the library and returning a description of the functions implemented by the procedural. However, it is not meant to generate ɴsɪ calls. For that reason, it is passed a unique NSIContext_t that should be used only in that function, usually to call the report function.

It returns a pointer to a descriptor struct of type NSIProcedural_t (see below).

NSIProceduralLoad() receives the following parameters:

NameTypeDescription
ctxNSIContext_tThe ɴsɪ context into which the procedural is being loaded.
reportNSIReport_tA function that can be used to display informational, warning or error messages through the renderer.
nsi_library_pathconst char*The path to the ɴsɪ implementation that is loading the procedural. This allows the procedural to explicitly make its ɴsɪ API calls through the same implementation (for example, by using NSI::DynamicAPI defined in nsi_dynamic.hpp). It’s usually not required if only one implementation of ɴsɪ is installed on the system.
renderer_versionconst char*A character string describing the current version of the renderer.

Procedural Description

typedef void (*NSIProceduralUnload_t)(
    NSIContext_t ctx,
    NSIReport_t report,
    struct NSIProcedural_t* proc);

typedef void (*NSIProceduralExecute_t)(
    NSIContext_t ctx,
    NSIReport_t report,
    struct NSIProcedural_t* proc,
    int nparams,
    const struct NSIParam_t* params);

struct NSIProcedural_t
{
    unsigned nsi_version;
    NSIProceduralUnload_t unload;
    NSIProceduralExecute_t execute;
};

The structure returned by NSIProceduralLoad() contains information needed by the renderer to use the procedural.

[!NOTE] The allocation of this structure is managed entirely from within the procedural and it will never be copied or modified by the renderer.

[!TIP] This means that it is possible for a procedural to extend the structure (by over-allocating memory or subclassing, for example) in order to store any extra information that it might need later.

The nsi_version member must be set to NSI_VERSION (defined in nsi.h), so the renderer is able to determine which version of ɴsɪ was used when compiling the procedural.

The function pointer types used in the definition are:

  • NSIProceduralUnload_t is a function that cleans-up after the last execution of the procedural. This is the dual of NSIProceduralLoad(). In addition to arguments ctx and report, also received by NSIProceduralLoad(), it receives the description of the procedural returned by NSIProceduralLoad().
  • NSIProceduralExecute_t is a function that contributes to the description of the scene by generating ɴsɪ API calls. Since NSIProceduralExecute_t might be called multiple times in the same render, it’s important that it uses the context ctx it receives as a parameter to make its ɴsɪ calls, and not the context previously received by NSIProceduralLoad(). It also receives any extra parameters sent to NSIEvaluate, or any extra attributes set on a procedural node. They are stored in the params array (of length nparams). NSIParam_t is described in passing optional arguments.

Error Reporting

All functions of the procedural called by ɴsɪ receive a parameter of type NSIReport_t. This is a pointer to a function which should be used by the procedural to report errors or display any informational message.

typedef void (*NSIReport_t)(
    NSIContext_t ctx, int level, const char* message);

It receives the current context, the error level (as described in error reporting) and the message to be displayed. This information will be forwarded to any error handler attached to the current context, along with other regular renderer messages. Using this, instead of a custom error reporting mechanism, will benefit the user by ensuring that all messages are displayed in a consistent manner.

Preprocessor Macros

Some convenient C preprocessor macros are also defined in nsi_procedural.h:

NSI_PROCEDURAL_UNLOAD(name)

and

NSI_PROCEDURAL_EXECUTE(name)

declare functions of the specified name that match NSIProceduralUnload_t and NSIProceduralExecute_t, respectively.

NSI_PROCEDURAL_LOAD

declares a NSIProceduralLoad function.

NSI_PROCEDURAL_INIT(proc, unload_fct, execute_fct)

initializes a NSIProcedural_t (passed as proc) using the addresses of the procedural’s main functions. It also initializes proc.nsi_version.

So, a skeletal dynamic library procedural (that does nothing) could be implemented as follows.

Please note, however, that the proc static variable in this example contains only constant values, which allows it to be allocated as a static variable. In a more complex implementation, it could have been over-allocated (or subclassed, in C++) to hold additional, variable data1. In that case, it would have been better to allocate the descriptor dynamically — and release it in NSI_PROCEDURAL_UNLOAD — so the procedural could be loaded independently from multiple parallel renders, each using its own instance of the NSIProcedural_t descriptor.

#include "nsi_procedural.h"

NSI_PROCEDURAL_UNLOAD(min_unload)
{
}

NSI_PROCEDURAL_EXECUTE(min_execute)
{
}

NSI_PROCEDURAL_LOAD
{
    static struct NSIProcedural_t proc;
    NSI_PROCEDURAL_INIT(proc, min_unload, min_execute);
    return &proc;
}


  1. A good example of this is available in the 3Delight installation, in file gear.cpp.

Nodes

The following sections describe available nodes in technical terms. Refer to the rendering guidelines for usage details.

NodeFunction
rootScene’s root
globalGlobal settings node
setTo express relationships of groups of nodes
shaderᴏsʟ shader or layer in a shader group
attributesContainer for generic attributes (e.g. visibility)
transformTransformation to place objects in the scene
meshPolygonal mesh or subdivision surface
nurbsNURBS surface patch, optionally trimmed
t-nurccWatertight T-spline surface — rational Catmull-Clark with T-junctions (draft)
planeInfinite plane
facesetAssign attributes to part of a mesh
curvesLinear, B-spline and Catmull-Rom curves
particlesCollection of particles
proceduralGeometry to be loaded in delayed fashion
environmentGeometry type to define environment lighting
vdbparticlesParticles defined by OpenVDB data
volumeVolumetric object defined by OpenVDB data
outputdriverLocation where to output rendered pixels
outputlayerDescribes one render layer to be connected to an outputdriver node
screenDescribes how the view from a camera will be rasterized into an outputlayer node
*cameraSet of nodes to create viewing cameras

Common Attributes

NameTypeDefault
nicenamestring

This is an optional identifier which may be used by the renderer instead of the node handle for various identification purposes.

root

The root node is much like a transform node with the particularity that it is the end connection for all renderable scene elements (see basic scene anatomy). A node can exist in an ɴsɪ context without being connected to the root note but in that case it won’t affect the render in any way. The root node has the reserved handle name .root and doesn’t need to be created using NSICreate. The root node has two defined attributes: objects and geometryattributes. Both are explained in the transform node.

global

This node contains various global settings for a particular ɴsɪ context. Note that these attributes are for the most case implementation specific. This node has the reserved handle name .global and doesn’t need to be created using NSICreate. The following attributes are recognized by 3Delight:

NameTypeDefault
numberofthreadsint0

Specifies the total number of threads to use for a particular render:

  • A value of zero lets the render engine choose an optimal thread value. This is the default behaviour.
  • Any positive value directly sets the total number of render threads.
  • A negative value will start as many threads as optimal plus the specified value. This allows for an easy way to decrease the total number of render threads.
NameTypeDefault
texturememoryint

Specifies the approximate maximum memory size, in megabytes, the renderer will allocate to accelerate texture access.

NameTypeDefault
networkcache.sizeint0

Specifies the maximum network cache size, in gigabytes, the renderer will use to cache textures on a local drive to accelerate data access.

NameTypeDefault
networkcache.directorystring

Specifies the directory in which textures will be cached. A good default value is /var/tmp/3DelightCache on Linux systems.

NameTypeDefault
networkcache.mipmapint1

Enables caching of texture mipmaps separately. This makes more efficient use of available cache space.

NameTypeDefault
networkcache.writestring0

Enables caching for image write operations. This alleviates pressure on networks by first rendering images to a local temporary location and copying them to their final destination at the end of the render. This replaces many small network writes by more efficient larger operations.

NameTypeDefault
license.serverstring

Specifies the name or address of the license server to be used.

NameTypeDefault
license.waitint1

When no license is available for rendering, the behaviour depends on this attribute. Set to 1, the renderer waits until a license becomes available. Set to 0, it stops immediately — useful when managing a renderfarm so other work can be scheduled instead.

NameTypeDefault
license.holdint0

By default, the renderer will get new licenses for every render and release them once it’s done. This can be undesirable if several frames are rendered in sequence from the same process. If this option is set to 1, the licenses obtained for the first frame are held until the last frame is finished.

NameTypeDefault
renderatlowpriorityint0

If set to 1, start the render with a lower process priority. This can be useful if there are other applications that must run during rendering.

NameTypeDefault
bucketorderstringhorizontal

Specifies in what order the buckets are rendered. The available values are:

  • horizontal — row by row, left to right and top to bottom.
  • vertical — column by column, top to bottom and left to right.
  • zigzag — row by row, left to right on even rows and right to left on odd rows.
  • spiral — in a clockwise spiral from the centre of the image.
  • circle — in concentric circles from the centre of the image.
NameTypeDefault
framedouble0

Provides a frame number to be used as a seed for the sampling pattern. See the screen node.

NameTypeDefault
hidemessagesint

This specifies error and warning messages which will not be displayed. The attribute values are the message numbers to ignore.

NameTypeDefault
maximumraydepth.diffuseint1

Specifies the maximum bounce depth a diffuse ray can reach. A depth of 1 specifies one additional bounce compared to purely local illumination.

NameTypeDefault
maximumraydepth.hairint4

Specifies the maximum bounce depth a hair ray can reach. Note that hair are akin to volumetric primitives and might need elevated ray depth to properly capture the illumination.

NameTypeDefault
maximumraydepth.reflectionint1

Specifies the maximum bounce depth a reflection ray can reach. Setting the reflection depth to 0 will only compute local illumination meaning that only emissive surfaces will appear in the reflections.

NameTypeDefault
maximumraydepth.refractionint4

Specifies the maximum bounce depth a refraction ray can reach. A value of 4 allows light to shine through a properly modeled object such as a glass.

NameTypeDefault
maximumraydepth.volumeint0

Specifies the maximum bounce depth a volume ray can reach.

NameTypeDefault
maximumraylength.diffusedouble-1

Limits the distance a ray emitted from a diffuse material can travel. A relatively low value can improve performance with minimal impact on the look, since it restrains the extent of global illumination. A negative value disables the limit.

NameTypeDefault
maximumraylength.hairdouble-1

Limits the distance a ray emitted from a hair closure can travel. Setting it to a negative value disables the limitation.

NameTypeDefault
maximumraylength.reflectiondouble-1

Limits the distance a ray emitted from a reflective material can travel. Setting it to a negative value disables the limitation.

NameTypeDefault
maximumraylength.refractiondouble-1

Limits the distance a ray emitted from a refractive material can travel. Setting it to a negative value disables the limitation.

NameTypeDefault
maximumraylength.speculardouble-1

Limits the distance a ray emitted from a specular (glossy) material can travel. Setting it to a negative value disables the limitation.

NameTypeDefault
maximumraylength.volumedouble-1

Limits the distance a ray emitted from a volume can travel. Setting it to a negative value disables the limitation.

NameTypeDefault
quality.denoiseint1

Enables denoising of output. Currently only supported for interactive renders.

NameTypeDefault
quality.iprglobalupdateint1

Enables a different method of updating the image for interactive renders.

NameTypeDefault
quality.iprinterpolateint1

Enables interpolation of low resolution interactive output, when denoised.

NameTypeDefault
quality.iprspeedmultiplierdouble1

Adjusts targeted render speed when processing multiple scene edits. A higher value will produce faster but lower quality results.

NameTypeDefault
quality.causticsamplesint1

Controls the quality of caustic sampling. Larger values give more accurate results.

NameTypeDefault
quality.shadingsamplesint1

Controls the quality of ʙsᴅꜰ sampling. Larger values give less visible noise.

NameTypeDefault
quality.volumesamplesint1

Controls the quality of volume sampling. Larger values give less visible noise.

NameTypeDefault
referencetimedouble

Specifies a reference time for the frame, where deformation data is most valid. This is the default when the same attribute is not set on a geometry node. It is also the default for the velocityreferencetime attribute of the vdbparticles node and the volume node. If not set, the center of the camera shutter is used.

NameTypeDefault
quality.samplevolumeemissionint1

Enables or disables the higher quality sampling of emission of ᴠᴅʙ volumes. The emission is visible either way, this only affects quality and render time.

NameTypeDefault
show.displacementint1

When set to 1, enables displacement shading. Otherwise, it must be set to 0, which forces the renderer to ignore any displacement shader in the scene.

NameTypeDefault
show.atmosphereint1

When set to 1, enables atmosphere shader(s). Otherwise, it must be set to 0, which forces the renderer to ignore any atmosphere shader in the scene.

NameTypeDefault
show.instancesnodeint1

When set to 1, enables the instances node. Otherwise, it must be set to 0, which forces the renderer to ignore geometry defined by these nodes.

NameTypeDefault
show.multiplescatteringdouble1.0

This is a multiplier on the multiple scattering of ᴠᴅʙ nodes. This parameter is useful to obtain faster draft renders by lowering the value below 1. The range is 0 to 1.

NameTypeDefault
show.osl.subsurfaceint1

When set to 1, enables the subsurface() ᴏsʟ closure. Otherwise, it must be set to 0, which will ignore this closure in ᴏsʟ shaders.

NameTypeDefault
statistics.progressint0

When set to 1, prints rendering progress as a percentage of completed pixels.

NameTypeDefault
statistics.filenamestringnull

Full path of the file where rendering statistics will be written. An empty string will write statistics to standard output. The name null will not output statistics.

NameTypeDefault
texture.missingcolorfloat[4]

If specified, this is used as a default value for the missingcolor parameter of the ᴏsʟ texture() function. The fourth value is used as missingalpha.

NameTypeDefault
texture.missingcolorerrorsint0

If nonzero, errors are reported even when the missingcolor of the ᴏsʟ texture() function is used. This goes against the documented behavior.

NameTypeDefault
exclusiveshading<connection>

When geometry nodes are connected here, all others in the scene will be rendered as black to the camera. This is meant to be used to speed up rendering when adjusting parameters of specific objects during an interactive render. Connected shader nodes will behave in a similar way: objects not using them will be rendered as black. If the connected shader nodes are not the root of their shading network, evaluation of the network ends at them. “Not the root” means they are not connected to an attributes node, and their output is used as another shader node’s input. This allows fine-tune parts of a shading network in isolation.

NameTypeDefault
verboseint0

When set to 1, enables additional informative messages before, during and after rendering.

NameTypeDefault
messages.timestampint0

When set to 1, messages output by the renderer will include the local time.

set

This node can be used to express relationships between objects. An example is to connect many lights to such a node to create a light set and then to connect this node to outputlayer.lightset (outputlayer and light layers). It has the following attributes:

NameTypeDefault
members<connection>

This connection accepts all nodes that are members of the set.

plane

This node represents an infinite plane, centered at the origin and pointing towards Z+. It has no required attributes. The UV coordinates are defined as the X and Y coordinates of the plane.

mesh

This node represents a polygon mesh. It has the following required attributes:

NameTypeDefault
Ppoint

The positions of the object’s vertices. Typically, this attribute will be addressed indirectly through a P.indices attribute.

NameTypeDefault
nverticesint

The number of vertices for each face of the mesh. The number of values for this attribute specifies total face number (unless nholes is defined).

It also has optional attributes:

NameTypeDefault
nholesint

The number of holes in the polygons. When this attribute is defined, the total number of faces in the mesh is defined by the number of values for nholes rather than for nvertices. For each face, there should be (nholes+1) values in nvertices: the respective first value specifies the number of vertices on the outside perimeter of the face, while additional values describe the number of vertices on perimeters of holes in the face. This stream defines a polygon mesh of three square faces, with one triangular hole in the first face and two square holes in the second:

Create "holey" "mesh"
SetAttribute "holey"
  "nholes" "int" 3 [ 1 2 0 ]
  "nvertices" "int" 6 [
    4 3               # Square with 1 triangular hole
    4 4 4             # Square with 2 square holes
    4 ]               # Square with 0 hole
  "P" "point" 23 [
    0 0 0    3 0 0    3 3 0    0 3 0
    1 1 0    2 1 0    1 2 0

    4 0 0    9 0 0    9 3 0    4 3 0
    5 1 0    6 1 0    6 2 0    5 2 0
    7 1 0    8 1 0    8 2 0    7 2 0

    10 0 0   13 0 0   13 3 0   10 3 0 ]
NameTypeDefault
clockwisewindingint0

A value of 1 specifies that polygons with a clockwise winding order are front facing. The default is 0, making counterclockwise polygons front facing.

NameTypeDefault
subdivision.schemestring

A value of "catmull-clark" will cause the mesh to render as a Catmull-Clark subdivision surface.

NameTypeDefault
subdivision.cornerverticesint

This attribute is a list of vertices which are sharp corners. The values are indices into the P attribute, like P.indices.

NameTypeDefault
subdivision.cornersharpnessfloat

This attribute is the sharpness of each specified sharp corner. It must have a value for each value given in subdivision.cornervertices.

NameTypeDefault
subdivision.creaseverticesint

This attribute is a list of crease edges. Each edge is specified as a pair of indices into the P attribute, like P.indices.

NameTypeDefault
subdivision.creasesharpnessfloat

This attribute is the sharpness of each specified crease. It must have a value for each pair of values given in subdivision.creasevertices.

NameTypeDefault
subdivision.smoothcreasecornersint1

This attribute controls whether or not the surface uses enhanced subdivision rules on vertices where more than two creased edges meet. With a value of 0, the vertex becomes a sharp corner. With a value of 1, the vertex is subdivided using an extended crease vertex subdivision rule which yields a smooth crease.

NameTypeDefault
referencetimedouble

Specifies a reference time where deformation data is most valid. This is mainly relevant for velocity blur, in which case it should be set to the time at which position data was originally available. If not set, see the same attribute on the global node.

NameTypeDefault
quadraticmotionint0

A value of 1 will enable curved deformation blur if three equally spaced time samples are provided for the P attribute. Linear deformation is used otherwise.

NameTypeDefault
outlinecreasethresholdfloat10

Controls how sharp a crease must be to be considered for the creation of outlines.

nurbs

This node represents a non-uniform rational B-spline surface: a grid of control points, a knot vector and an order in each parametric direction.

The draft of a future version of this node, with renamed attributes and proposed extensions such as stitching, is in NURBS: Draft Design.

It has the following required attributes:

NameTypeDefault
Ppoint

The control points of the surface. The number of values must be nu × nv. Either this or Pw is required.

NameTypeDefault
Pwhpoint

The control points of the surface, as homogeneous coordinates. The number of values must be nu × nv. Either this or P is required.

Each value is (w·x, w·y, w·z, w): the coordinates are premultiplied by the weight. In 3Delight 2.9.210, a patch whose four control points span ±1 with w = 2 renders at half the size of the same patch given as P.

NameTypeDefault
nuint

The number of control points in the u parametric direction.

NameTypeDefault
nvint

The number of control points in the v parametric direction.

NameTypeDefault
uorderint

The order of the surface in the u parametric direction: the degree plus one, so 2 is linear and 4 is cubic.

NameTypeDefault
vorderint

The order of the surface in the v parametric direction.

NameTypeDefault
uknotfloat

The knot vector in the u parametric direction. The number of values must be nu + uorder.

NameTypeDefault
vknotfloat

The knot vector in the v parametric direction. The number of values must be nv + vorder.

It also has optional attributes:

NameTypeDefault
uminfloat

The lower bound of the u parametric range where the surface is defined. If not specified, the uknot value at index uorder − 1 is used.

NameTypeDefault
umaxfloat

The upper bound of the u parametric range where the surface is defined. If not specified, the uknot value at index nu is used.

NameTypeDefault
vminfloat

The lower bound of the v parametric range where the surface is defined. If not specified, the vknot value at index vorder − 1 is used.

NameTypeDefault
vmaxfloat

The upper bound of the v parametric range where the surface is defined. If not specified, the vknot value at index nv is used.

Trim Curves

Trim curves remove part of the surface. The trimming is done by closed trim loops. Each loop is made of NURBS curves in the parametric space of the surface. The attributes below concatenate the values of all curves of all loops.

NameTypeDefault
trimcurves.ncurvesint

One value per trim loop: the number of curves in that loop. The number of values is the number of loops.

NameTypeDefault
trimcurves.nint

One value per curve: the number of control points of that curve.

Note: The specification prints this attribute as n. The name that 3Delight reads is trimcurves.n.

NameTypeDefault
trimcurves.orderint

One value per curve: the order of that curve.

NameTypeDefault
trimcurves.knotfloat

The knot vectors of all curves, with n + order values for each curve.

NameTypeDefault
trimcurves.minfloat

One value per curve: the lower bound of the valid parametric range of the curve.

NameTypeDefault
trimcurves.maxfloat

One value per curve: the upper bound of the valid parametric range of the curve.

NameTypeDefault
trimcurves.ufloat

The u component of the control points of all curves.

NameTypeDefault
trimcurves.vfloat

The v component of the control points of all curves.

NameTypeDefault
trimcurves.wfloat

The w component of the control points of all curves.

NameTypeDefault
trimcurves.insideint1

Optional. When 1, the part of the surface inside a trim loop is rendered. When 0, the part outside a trim loop is rendered.

t-nurcc

This node represents a T-NURCC, a Non-Uniform Rational Catmull-Clark surface with T-junctions, after Sederberg et al., T-splines and T-NURCCs (SIGGRAPH 2003). It generalizes both smooth-surface primitives of this API. With a regular grid cage it reduces to a bicubic NURBS surface. With uniform knot intervals and no T-junctions it reduces to the Catmull-Clark limit surface of the mesh node. Three features distinguish it:

  • arbitrary-topology control cages
  • local refinement through T-junctions
  • per-edge knot intervals

Together these features express a network of trimmed, stitched patches as a single watertight surface.

This node is a draft: no renderer implements it yet. Like the nurbs draft, its attributes follow the new naming convention.

Control Cage

The control cage is a polygon mesh. It is described exactly like the topology of the mesh node. It has the following required attributes:

NameTypeDefault
vertex-countint

The number of vertices for each face of the cage. The number of values for this attribute specifies the total face count. Four-sided faces are the norm. Faces of other valences are allowed and behave as in Catmull-Clark subdivision.

Supply one of position or position-weighted to provide the control points. position defines a polynomial surface. position-weighted defines a rational one.

NameTypeDefault
positionpoint

The positions of the cage’s control points. Address them indirectly through a position.indices attribute, which holds the concatenated per-face vertex indices, as on the mesh node.

NameTypeDefault
position-weightedweighted-point

Rational alternative to position. Each control point is a weighted (homogeneous) point (wx, wy, wz, w). Address the points indirectly through position-weighted.indices.

T-Junctions

T-junctions need no dedicated attribute. They are a property of the connectivity. A T-junction is a vertex that lies on the interior of a neighbouring face’s edge. Face A lists the edge (a, b), while the faces on the other side list (a, t) and (t, b). The vertex t is a T-junction of face A.

A T-junction must not lie on a face incident to an extraordinary vertex, which is an interior cage vertex of valence other than 4. At least one face must separate a T-junction from an extraordinary vertex.

Knot Intervals

Each edge of the cage carries a non-negative knot interval. The interval is the parametric width the edge spans on the limit surface. Intervals default to 1 everywhere, which is the uniform (Catmull-Clark) case. List only the edges with non-unit intervals. Supply the two attributes below together.

NameTypeDefault
knot-interval.indexint

A list of edges. Specify each edge as a pair of indices into the position attribute, like position.indices. Pair order is irrelevant here. List an edge at most once.

NameTypeDefault
knot-interval.valuefloat

The knot interval of each listed edge. Supply one value per pair in knot-interval.index. Values must be non-negative. A value of 0 is legal and produces a sharp feature. T-NURCCs express creases and corners this way, so this node has no separate sharpness attributes.

Knot intervals are subject to two consistency constraints:

  • Opposing sides of a face must span equal parametric widths. For a plain four-sided face, opposite edges carry equal intervals. Where T-junctions subdivide a side, the sum of its sub-edge intervals must equal the interval of the opposing side.
  • Across a T-junction, the two sub-edges must sum to the undivided edge they subdivide: interval(a, t) + interval(t, b) = interval(a, b).

Stitching

A T-NURCC is watertight by construction, so the interior of the surface needs no stitching. That is the point of the node. Stitching applies only to the open borders of a T-NURCC sheet. Weld those borders to the boundaries of other nodes, either nurbs patches or other t-nurcc sheets. The weld uses the connected weld namespace defined by the shared-boundary draft. Polygon and subdivision meshes can participate in the same namespace. The general use table supports boundary chains. The two attributes below are an alternative shorthand for individual cage edges; supply them together and do not combine them with the general table.

NameTypeDefault
stitch.indexint

A list of boundary cage edges. Specify each edge as a pair of indices into the position attribute. Unlike knot-interval.index, pair order is significant. The pair (a, b) selects traversal from a to b. Its order must align that traversal with the shared reference direction. No separate orientation attribute is needed. The resulting traversal must share its start and direction with the other uses, as specified in the shared-boundary contract.

NameTypeDefault
stitch.edge-idint

The edge identity of each listed boundary edge. Supply one value per pair in stitch.index. The semantics are those of the trim-curves.edge-id/stitch.edge-id attributes of the nurbs node. Boundary uses with the same non-negative value and connected weld node belong together. IDs in different weld nodes are unrelated. The welded counterpart is the arc of the limit-surface boundary that the listed cage edge maps to.

Semantics

The NURCC subdivision rules of the paper cited above define the limit surface. The surface is bicubic. It is C² in regular regions with non-zero knot intervals, and G¹ at extraordinary vertices. Local knot insertion resolves the T-junctions. Two reductions anchor the definition:

  • A cage can have no T-junctions, equal knot intervals throughout, and equal weights. Such a cage renders identically to the same cage as a mesh node with subdivision.scheme "catmull-clark".
  • A cage can be a regular grid: every interior vertex has valence 4, and no T-junctions exist. Such a cage renders identically to the equivalent bicubic nurbs node.

Design Notes

This node is the complement of the nurbs stitching design. That design conserves the weld topology of a CAD shell across a network of trimmed patches. This node lets a pipeline eliminate that topology instead. The pipeline merges the network into one surface, whose continuity is structural rather than declared. T-splines were conceived for exactly this merge. The T-junctions make it lossless, because local refinement absorbs patch boundaries that do not run through the whole network. The pipeline chooses which representation to export. The shared weld namespace lets the two representations coexist in one scene, welded to each other at their open borders.

This node is a separate node type rather than an extension of mesh. Its data model diverges in every direction that matters:

  • rational control points
  • per-edge knot intervals
  • T-junction validity rules
  • an evaluation scheme that is neither pure subdivision nor tensor-product NURBS

Open questions for this draft:

  • Boundary rules for open sheets: interpolating boundaries versus free boundaries.
  • Semi-sharp creases. Zero knot intervals give infinitely sharp features only. A crease.sharpness mechanism like the one of the mesh node may still be wanted.
  • Whether to restrict cages to the analysis-suitable (“standard”) T-spline subset, or to flag that subset.
  • Trim curves on a T-NURCC are deliberately omitted. Local refinement and open borders cover the use cases that trimming covers on nurbs.

faceset

This node is used to provide a way to attach attributes to some faces of another geometric primitive, such as the mesh node. It has the following attributes:

NameTypeDefault
facesint

This attribute is a list of indices of faces. It identifies which faces of the original geometry will be part of this face set.

A face set is connected to the facesets attribute of its geometry. Attributes then attach to the face set as they do to any geometry. This stream defines a face set on a subdivision surface:

Create "subdiv" "mesh"
SetAttribute "subdiv"
  "nvertices" "int" 4 [ 4 4 4 4 ]
  "P" "point" 9 [
    0 0 0   1 0 0   2 0 0
    0 1 0   1 1 0   2 1 0
    0 2 0   1 2 0   2 2 2 ]
  "P.indices" "int" 16 [
    0 1 4 3   1 2 5 4   3 4 7 6   4 5 8 7 ]
  "subdivision.scheme" "string" 1 "catmull-clark"

Create "set1" "faceset"
SetAttribute "set1"
  "faces" "int" 2 [ 0 3 ]
Connect "set1" "" "subdiv" "facesets"

Connect "attributes1" "" "subdiv" "geometryattributes"
Connect "attributes2" "" "set1" "geometryattributes"

curves

This node represents a group of curves. It has the following required attributes:

NameTypeDefault
nverticesint

The number of vertices for each curve. This must be at least 4 for cubic curves and 2 for linear curves. There can be either a single value or one value per curve.

NameTypeDefault
Ppoint

The positions of the curve vertices. The number of values provided, divided by nvertices, gives the number of curves which will be rendered.

NameTypeDefault
widthfloat

The width of the curves.

NameTypeDefault
basisstringcatmull-rom

The basis functions used for curve interpolation. Possible choices are:

  • b-spline — B-spline interpolation.
  • catmull-rom — Catmull-Rom interpolation.
  • linear — Linear interpolation.
NameTypeDefault
extrapolateint0

By default, cubic curves will not be drawn to their end vertices as the basis functions require an extra vertex to define the curve. If this attribute is set to 1, an extra vertex is automatically extrapolated so the curves reach their end vertices, as with linear interpolation.

Attributes may also have a single value, one value per curve, one value per vertex or one value per vertex of a single curve, reused for all curves. Attributes which fall in that last category must always specify NSIParamPerVertex. Note that a single curve is considered a face as far as use of NSIParamPerFace is concerned.

particles

This geometry node represents a collection of tiny particles. Particles are represented by either a disk or a sphere. This primitive is not suitable to render large particles as these should be represented by other means (e.g. instancing).

NameTypeDefault
Ppoint

A mandatory attribute that specifies the center of each particle.

NameTypeDefault
widthfloat

A mandatory attribute that specifies the width of each particle. It can be specified for the entire particles node (only one value provided) or per-particle.

NameTypeDefault
Nnormal

The presence of a normal indicates that each particle is to be rendered as an oriented disk. The orientation of each disk is defined by the provided normal which can be constant or a per-particle attribute. Each particle is assumed to be a sphere if a normal is not provided.

NameTypeDefault
reverseorientationint0

Setting this to 1 will reverse the orientation of spherical particles. Specifically, their u parametric direction is reversed, which also reverses their normal so it points inwards. It has no effect on particles for which N is provided.

NameTypeDefault
idint

This attribute, of the same size as P, assigns a unique identifier to each particle which must be constant throughout the entire shutter range. Its presence is necessary in the case where particles are motion blurred and some of them could appear or disappear during the motion interval. Having such identifiers allows the renderer to properly render such transient particles. This implies that the number of ids might vary for each time step of a motion-blurred particle cloud so the use of NSISetAttributeAtTime is mandatory by definition.

NameTypeDefault
quadraticmotionint0

A value of 1 will enable curved deformation blur if three equally spaced time samples are provided for the P attribute. Linear deformation is used otherwise.

procedural

This node acts as a proxy for geometry that could be defined at a later time than the node’s definition, using a procedural supported by NSIEvaluate. Since the procedural is evaluated in complete isolation from the rest of the scene, it can be done either lazily (depending on its boundingbox attribute) or in parallel with other procedural nodes.

The procedural node supports, as its attributes, all the parameters of the NSIEvaluate API call, meaning that procedural types accepted by that API call (NSI archives, dynamic libraries, LUA scripts) are also supported by this node. Those attributes are used to call a procedural that is expected to define a sub-scene, which has to be independent from the other nodes in the scene. The procedural node will act as the sub-scene’s local root and, as such, also supports all the attributes of a regular transform node. In order to connect the nodes it creates to the sub-scene’s root, the procedural simply has to connect them to the regular root node .root.

In the context of an interactive render, the procedural will be executed again after the node’s attributes have been edited. All nodes previously connected by the procedural to the sub-scene’s root will be deleted automatically before the procedural’s re-execution.

Additionally, this node has the following optional attribute:

NameTypeDefault
boundingboxpoint[2]

Specifies a bounding box for the geometry where boundingbox[0] and boundingbox[1] correspond, respectively, to the “minimum” and the “maximum” corners of the box.

environment

This geometry node defines a sphere of infinite radius. Its only purpose is to render environment lights, solar lights and directional lights; lights which cannot be efficiently modeled using area lights. In practical terms, this node is no different than a geometry node with the exception of shader execution semantics: there is no surface position P, only a direction I (refer to lighting guidelines for more practical details). The following node attribute is recognized:

NameTypeDefault
angledouble360

Specifies the cone angle representing the region of the sphere to be sampled. The angle is measured around the Z+ axis. To orient the environment dome, connect the node to a transform node and apply the rotation there. If the angle is set to 0, the environment describes a directional light. Refer to lighting guidelines for more about how to specify light sources.

shader

This node represents an ᴏsʟ shader, also called layer when part of a shader group. It has the following attributes:

NameTypeDefault
shaderfilenamestring

This is the name of the file which contains the shader’s compiled code.

NameTypeDefault
shaderobjectstring

This contains the complete compiled shader code. It allows providing custom shaders without going through files.

NameTypeDefault
materialxnodedefstring

The name of the MaterialX node definition to use.

NameTypeDefault
materialxversionstring

The MaterialX library version to use to find the node. If unspecified, the most up to date version is used.

Either shaderfilename, shaderobject or materialxnodedef must be provided. All other attributes on this node are considered parameters of the shader. They may either be given values or connected to attributes of other shader nodes to build shader networks. ᴏsʟ shader networks must form acyclic graphs or they will be rejected. Refer to creating ᴏsʟ networks for instructions on ᴏsʟ network creation and usage.

attributes

This node is a generic container for attributes. Its exact purpose depends on where it is connected in the scene. There are currently two uses.

Geometry Attributes

This node can provide various geometry related rendering attributes that are not intrinsic to a particular node (for example, one can’t set the topology of a polygonal mesh using this attributes node). For this use, instances of this node must be connected to the geometryattributes attribute of either geometric primitives or transform nodes (to build attributes hierarchies). Attribute values are gathered along the path starting from the geometric primitive, through all the transform nodes it is connected to, until the scene root is reached.

When an attribute is defined multiple times along this path, the definition with the highest priority is selected. In case of conflicting priorities, the definition that is closest to the geometric primitive (i.e. the furthest from the root) is selected. Connections (for shaders, essentially) can also be assigned priorities, which are used in the same way as for regular attributes. Multiple attributes nodes can be connected to the same geometry or transform nodes (e.g. one attributes node can set object visibility and another can set the surface shader) and will all be considered.

When two attributes nodes connected to the same node supply the same attribute with the same priority, the node connected first wins. The order of the NSIConnect() calls is part of the scene. For example, red_attributes and green_attributes each have a shader connected to their surfaceshader. With this stream, the plane uses the shader of red_attributes:

Connect "red_attributes" "" "plane" "geometryattributes"
Connect "green_attributes" "" "plane" "geometryattributes"

Swap the two Connect statements and the plane uses the shader of green_attributes. To make the result independent of call order, give the connections different priority values.

Note: This rule is observed in 3Delight 2.9.207 with two surfaceshader connections. Earlier versions of this specification did not state it.

In this case, the node has the following attributes:

NameTypeDefault
surfaceshader<connection>

The shader node which will be used to shade the surface is connected to this attribute. A priority (useful for overriding a shader from higher in the scene graph) can be specified by setting the priority attribute of the connection itself.

NameTypeDefault
displacementshader<connection>

The shader node which will be used to displace the surface is connected to this attribute. A priority (useful for overriding a shader from higher in the scene graph) can be specified by setting the priority attribute of the connection itself.

NameTypeDefault
volumeshader<connection>

The shader node which will be used to shade the volume inside the primitive is connected to this attribute.

NameTypeDefault
ATTR.priorityint0

Sets the priority of attribute ATTR when gathering attributes in the scene hierarchy.

NameTypeDefault
visibility.cameraint1
visibility.diffuseint1
visibility.hairint1
visibility.reflectionint1
visibility.refractionint1
visibility.shadowint1
visibility.specularint1
visibility.volumeint1

These attributes set visibility for each ray type specified in ᴏsʟ. The same effect could be achieved using shader code (using the raytype() function) but it is much faster to filter intersections at trace time. A value of 1 makes the object visible to the corresponding ray type, while 0 makes it invisible.

NameTypeDefault
visibilityint1

This attribute sets the default visibility for all ray types. When visibility is set both per ray type and with this default visibility, the attribute with the highest priority is used. If their priority is the same, the more specific attribute (i.e. per ray type) is used.

NameTypeDefault
visibility.set.subsurface<connection>

If a set node is connected to this attribute, subsurface rays will only see objects with a connection to that same set node.

NameTypeDefault
matteint0

If this attribute is set to 1, the object becomes a matte for camera rays. Its transparency is used to control the matte opacity and all other shading components are ignored.

NameTypeDefault
regularemissionint1

If this is set to 1, closures not used with quantize() will use emission from the objects affected by the attribute. If set to 0, they will not.

NameTypeDefault
quantizedemissionint1

If this is set to 1, quantized closures will use emission from the objects affected by the attribute. If set to 0, they will not.

NameTypeDefault
bounds<connection>

When a geometry node (usually a mesh node) is connected to this attribute, it restricts the effect of the attributes node. The node then applies only inside the volume defined by the connected geometry. A transform node may be connected here instead, which is equivalent to connecting every geometry node reachable through that transform.

NameTypeDefault
caustics.castint0

Only objects with this attribute set to a non-zero value are considered to alter the shape of caustics through reflection and refraction.

NameTypeDefault
caustics.emitint0

Only lights with this attribute set to a non-zero value contribute to caustics.

NameTypeDefault
caustics.receiveint0

Only objects with this attribute set to a non-zero value receive caustics.

NameTypeDefault
displacementresolutionfloat1

A multiplier on the level of detail in displacement. Larger values provide more detail; smaller values reduce memory use. It is usually not necessary to set this attribute.

NameTypeDefault
displacementboundfloat

The furthest a displacement shader moves the surface. A renderer can use it to bound displaced geometry before shading it. 3Delight 2.9.210 applies no displacement while it is unset; see 3Delight.

Shader Attributes

This node can be a container for attributes available to shaders. For this purpose, instances of this node must be connected to the shaderattributes attribute of geometric primitives, transform nodes or set nodes. Attribute values are gathered along the path starting from the geometric primitive, through all the transform nodes it is connected to, until the scene root is reached.

Priority is given to nodes attached closest to the geometric primitive, with the highest priority given to attributes set directly on the geometric primitive. Attributes set on this node may only have a single value.

transform

This node represents a geometric transformation. Transform nodes can be chained together to express transform concatenation, hierarchies and instances. Transform nodes also accept attributes to implement hierarchical attribute assignment and overrides. It has the following attributes:

NameTypeDefault
transformationmatrixdoublematrix

This is a 4x4 matrix which describes the node’s transformation. Matrices in ɴsɪ post-multiply column vectors so are of the form:

| w11  w12  w13  0 |
| w21  w22  w23  0 |
| w31  w32  w33  0 |
| Tx   Ty   Tz   1 |
NameTypeDefault
objects<connection>

This is where the transformed objects are connected to. This includes geometry nodes, other transform nodes and camera nodes.

NameTypeDefault
geometryattributes<connection>

This is where attributes nodes may be connected to affect any geometry transformed by this node. Refer to attributes and instancing for explanation on how this connection is used.

NameTypeDefault
shaderattributes<connection>

This is where attributes nodes may be connected to provide shader attributes for any geometry transformed by this node.

instances

This node is an efficient way to specify a large number of instances. It has the following attributes:

NameTypeDefault
sourcemodels<connection>

The instanced models should connect to this attribute. Connections must have an integer index attribute if there are several, so the models effectively form an ordered list.

NameTypeDefault
transformationmatricesdoublematrix

A transformation matrix for each instance.

NameTypeDefault
modelindicesint0

An optional model selector for each instance. The value used is matched to the index attribute of the model connection. A negative value will cause an instance to not be rendered.

NameTypeDefault
disabledinstancesint

An optional list of indices of instances which are not to be rendered.

outputdriver

An output driver defines how an image is transferred to an output destination. The destination could be a file (e.g. “exr” output driver), frame buffer or a memory address. It can be connected to the outputdrivers attribute of an output layer node. It has the following attributes:

NameTypeDefault
drivernamestring

This is the name of the driver to use. The API of the driver is implementation specific and is not covered by this documentation.

NameTypeDefault
imagefilenamestring

Full path to a file for a file-based output driver or some meaningful identifier depending on the output driver.

NameTypeDefault
embedstatisticsint1

A value of 1 specifies that statistics will be embedded into the image file.

Any extra attributes are also forwarded to the output driver which may interpret them however it wishes.

outputlayer

This node describes one specific layer of render output data. It can be connected to the outputlayers attribute of a screen node. It has the following attributes:

NameTypeDefault
variablenamestring

This is the name of a variable to output.

NameTypeDefault
variablesourcestringshader

Indicates where the variable to be output is read from. Possible values are:

  • shader — computed by a shader and output through an ᴏsʟ closure (such as outputvariable() or debug()) or the Ci global variable.
  • attribute — retrieved directly from an attribute with a matching name attached to a geometric primitive.
  • builtin — generated automatically by the renderer (e.g. "z", "alpha", "N.camera", "P.world").
NameTypeDefault
layernamestring

This will be name of the layer as written by the output driver. For example, if the output driver writes to an EXR file then this will be the name of the layer inside that file.

NameTypeDefault
scalarformatstringuint8

Specifies the format in which data will be encoded (quantized) prior to passing it to the output driver. Possible values are:

  • int8 — signed 8-bit integer
  • uint8 — unsigned 8-bit integer
  • int16 — signed 16-bit integer
  • uint16 — unsigned 16-bit integer
  • int32 — signed 32-bit integer
  • uint32 — unsigned 32-bit integer
  • half — IEEE 754 half-precision binary floating point (binary16)
  • float — IEEE 754 single-precision binary floating point (binary32)
NameTypeDefault
layertypestringcolor

Specifies the type of data that will be written to the layer. Possible values are:

  • scalar — A single quantity. Useful for opacity (“alpha”) or depth (“Z”) information.
  • color — A 3-component color.
  • vector — A 3D point or vector. This will help differentiate the data from a color in further processing.
  • quad — A sequence of 4 values, where the fourth value is not an alpha channel.

Each component of those types is stored according to the scalarformat attribute set on the same outputlayer node.

NameTypeDefault
colorprofilestring

The name of an OCIO color profile to apply to rendered image data prior to quantization.

NameTypeDefault
ditheringint0

If set to 1, dithering is applied to integer scalars. Otherwise, it must be set to 0. Turn dithering off when a layer carries values that must not change, such as object IDs.

NameTypeDefault
withalphaint0

If set to 1, an alpha channel is included in the output layer. Otherwise, it must be set to 0.

NameTypeDefault
sortkeyint

This attribute is used as a sorting key when ordering multiple output layer nodes connected to the same output driver node. Layers with the lowest sortkey attribute appear first.

NameTypeDefault
lightset<connection>

This connection accepts either light sources or set nodes to which lights are connected. In this case only listed lights will affect the render of the output layer. If nothing is connected to this attribute then all lights are rendered.

If an environment node is connected here, a component string attribute can be specified on the connection with a value of either sun or background. If this is used, only the corresponding part of the environment will contribute to the output layer.

NameTypeDefault
lightsetnamestring

This can be provided as friendly name for the connected light set. Otherwise, a default name is built from the connected node.

NameTypeDefault
outputdrivers<connection>

This connection accepts output driver nodes to which the layer’s image will be sent.

NameTypeDefault
filterstringblackman-harris

The type of filter to use when reconstructing the final image from sub-pixel samples. Possible values are: "box", "triangle", "catmull-rom", "bessel", "gaussian", "sinc", "mitchell", "blackman-harris", "zmin" and "zmax".

NameTypeDefault
filterwidthdouble3.0

Diameter in pixels of the reconstruction filter. It is not applied when filter is "box" or "zmin".

NameTypeDefault
backgroundvaluefloat0

The value given to pixels where nothing is rendered.

NameTypeDefault
backgroundlayer<connection>

This connection accepts a single output layer node which is meant to be displayed as a background. Not all output drivers support this behavior, so it might be ignored.

NameTypeDefault
lightdepthstringauto

Allows filtering light contributions according to the number of bounces light has made from a light source to the objects in front of the camera. This is only meaningful when the layer’s variablesource is set to shader (otherwise, it’s ignored). Possible values are:

  • direct — Only light coming directly from light sources to visible objects (ie: with no bounce) is included.
  • indirect — Only light coming from light sources to visible objects through at least one bounce is shown.
  • both — All light is included.
  • auto — Selects the appropriate value for lightdepth according to the value of the variablename attribute. If it ends with either .direct or .indirect, the corresponding light depth will be used, and the suffix will be removed from the effective variable name. Otherwise, it will default to both.
NameTypeDefault
cryptomatte.enableint1

With the default value of 1, some output variables – the built-ins id.geometry, id.scenepath, id.surfaceshader and id.asset – are encoded in Cryptomatte format when layertype is color. Extra Cryptomatte data layers, used for antialiased selection, are also added to connected output drivers of type exr. A value of 0 disables both the Cryptomatte encoding of the layer and the extra Cryptomatte data layers.

Any extra attributes are also forwarded to the output driver which may interpret them however it wishes.

screen

This node describes how the view from a camera node will be rasterized into an output layer node. It can be connected to the screens attribute of a camera node.

NameTypeDefault
outputlayers<connection>

This connection accepts output layer nodes which will receive a rendered image of the scene as seen by the camera.

NameTypeDefault
resolutioninteger[2]

Horizontal and vertical resolution of the rendered image, in pixels.

NameTypeDefault
oversamplingint

The total number of samples (i.e. camera rays) to be computed for each pixel in the image.

NameTypeDefault
crop2 × float[2]

The region of the image to be rendered. It’s defined by a list of exactly 2 pairs of floating-point number. Each pair represents a point in ɴᴅᴄ space:

  • Top-left corner of the crop region
  • Bottom-right corner of the crop region
NameTypeDefault
prioritywindow2 × int[2]

For progressive renders, this is the region of the image to be rendered first. It is two pairs of integers. Each represents pixel coordinates:

  • Top-left corner of the high priority region
  • Bottom-right corner of the high priority region
NameTypeDefault
screenwindow2 × double[2]

Specifies the screen space region to be rendered. Each pair represents a 2D point in screen space:

  • Bottom-left corner of the region
  • Top-right corner of the region

Note that the default screen window is set implicitly by the frame aspect ratio: screenwindow = [-f, -1], [f, 1] for f = xres/yres

NameTypeDefault
overscan2 × int[2]

Specifies how many extra pixels to render around the image. The four values represent the amount of overscan on the left, top, right and bottom of the image.

NameTypeDefault
pixelaspectratiofloat

Ratio of the physical width to the height of a single pixel. A value of 1.0 corresponds to square pixels.

NameTypeDefault
staticsamplingpatternint0

This controls whether or not the sampling pattern used to produce the image change for every frame. A nonzero value will cause the same pattern to be used for all frames. A value of zero will cause the pattern to change with the frame attribute of the global node.

NameTypeDefault
importancesamplefilterint0

This enables a rendering mode where the pixel filter is importance sampled. Quality will be reduced and the same filter will be used for all output layers of the screen. Filters with negative lobes (eg. sinc) are unsupported.

vdbparticles

This node represents particles defined by OpenVDB data. It has the following attributes:

NameTypeDefault
vdbfilenamestring

The path to an OpenVDB file with the particle data.

NameTypeDefault
pointsgridstring

The name of the OpenVDB grid to use for particle data. It must be of type PointDataGrid.

NameTypeDefault
velocityreferencetimedouble

The reference time at which the grid data is used directly, without being moved by the velocity attribute. Defaults to the global node’s referencetime attribute.

NameTypeDefault
velocityscaledouble1

A scaling factor applied to the velocity data.

NameTypeDefault
enablepscaleint1

Enables use of the pscale attribute in the grid to specify particle radius.

NameTypeDefault
widthdouble1

The width of particles, if there is no pscale attribute in the file to specify their radius or it is disabled by setting enablepscale to 0.

NameTypeDefault
widthscaledouble1

A scaling factor applied to particle width.

The P, v and pscale grid attributes are used to respectively define particle position, velocity and radius. Other grid attributes may be read by shaders.

volume

This node represents a volumetric object defined by OpenVDB data. It has the following attributes:

NameTypeDefault
vdbfilenamestring

The path to an OpenVDB file with the volumetric data.

NameTypeDefault
densitygridstring

The name of the OpenVDB grid to use as volume density for the volume shader.

NameTypeDefault
colorgridstring

The name of the OpenVDB grid to use as a scattering color multiplier for the volume shader.

NameTypeDefault
emissiongridstring

The name of the OpenVDB grid to use directly as emission for the volume shader.

NameTypeDefault
emissionintensitygridstring

The name of the OpenVDB grid to use as emission intensity for the volume shader.

NameTypeDefault
temperaturegridstring

The name of the OpenVDB grid to use as temperature for the volume shader.

NameTypeDefault
velocitygridstring

The name of the OpenVDB grid to use as motion vectors. This can also name the first of three scalar grids (ie. “velocityX”).

NameTypeDefault
velocityreferencetimedouble

The reference time at which the grid data is used directly, without being moved by the velocity grid. Defaults to the global node’s referencetime attribute.

NameTypeDefault
velocityscaledouble1

A scaling factor applied to the motion vectors.

Camera Nodes

All camera nodes share a set of common attributes. These are listed below.

NameTypeDefault
screens<connection>

This connection accepts screen nodes which will rasterize an image of the scene as seen by the camera. Refer to defining output drivers and layers for more information.

NameTypeDefault
shutterrangedouble

Time interval during which the camera shutter is at least partially open. It’s defined by a list of exactly two values:

  • Time at which the shutter starts opening.
  • Time at which the shutter finishes closing.
NameTypeDefault
shutteropeningdouble

A normalized time interval indicating the time at which the shutter is fully open (a) and the time at which the shutter starts to close (b). These two values define the top part of a trapezoid filter. The end goal of this feature it to simulate a mechanical shutter on which open and close movements are not instantaneous.

An example shutter opening configuration

NameTypeDefault
clippingrangedouble

Distance of the near and far clipping planes from the camera. It’s defined by a list of exactly two values:

  • Distance to the near clipping plane, in front of which scene objects are clipped.
  • Distance to the far clipping plane, behind which scene objects are clipped.

orthographiccamera

This node defines an orthographic camera with a view direction towards the Z- axis. This camera has no specific attributes.

perspectivecamera

This node defines a perspective camera. The canonical camera is viewing in the direction of the Z- axis. The node is usually connected into a transform node for camera placement. It has the following attributes:

NameTypeDefault
fovfloat

The field of view angle, in degrees.

NameTypeDefault
depthoffield.enableint0

Enables depth of field effect for this camera.

NameTypeDefault
depthoffield.fstopdouble

Relative aperture of the camera.

NameTypeDefault
depthoffield.focallengthdouble

Vertical focal length, in scene units, of the camera lens.

NameTypeDefault
depthoffield.focallengthratiodouble1

Ratio of vertical focal length to horizontal focal length. This is the squeeze ratio of an anamorphic lens.

NameTypeDefault
depthoffield.focaldistancedouble

Distance, in scene units, in front of the camera at which objects will be in focus.

NameTypeDefault
depthoffield.aperture.enableint0

By default, the renderer simulates a circular aperture for depth of field. Enable this feature to simulate aperture “blades” as on a real camera. This feature affects the look in out-of-focus regions of the image.

NameTypeDefault
depthoffield.aperture.sidesint5

Number of sides of the camera’s aperture. The minimum number of sides is 3.

NameTypeDefault
depthoffield.aperture.angledouble0

A rotation angle (in degrees) to be applied to the camera’s aperture, in the image plane.

NameTypeDefault
unitlengthmillimetersdouble

Physical length, in millimeters, of one scene unit. Since NSI only uses virtual scene units, this has no effect on the rendered images. However, this value can be useful if the renderer has to communicate with other software or file formats using physical units. For example, the focal length of the camera, expressed in millimeters, would be the product depthoffield.focallength * unitlengthmillimeters.

fisheyecamera

Fish eye cameras are useful for a multitude of applications (e.g. virtual reality). This node accepts these attributes:

NameTypeDefault
fovfloat

Specifies the field of view for this camera node, in degrees.

NameTypeDefault
mappingstringequidistant

Defines one of the supported fisheye mapping functions:

  • equidistant — Maintains angular distances.
  • equisolidangle — Every pixel in the image covers the same solid angle.
  • orthographic — Maintains planar illuminance. This mapping is limited to a 180 field of view.
  • stereographic — Maintains angles throughout the image. Note that stereographic mapping fails to work with field of views close to 360 degrees.

cylindricalcamera

This node specifies a cylindrical projection camera and has the following attributes:

NameTypeDefault
fovfloat90

Specifies the vertical field of view, in degrees.

NameTypeDefault
horizontalfovfloat360

Specifies the horizontal field of view, in degrees.

NameTypeDefault
eyeoffsetfloat

This offset allows to render stereoscopic cylindrical images by specifying an eye offset.

sphericalcamera

This node defines a spherical projection camera. This camera has no specific attributes.

Lens Shaders

A lens shader is an ᴏsʟ network connected to a camera through the lensshader connection. Such shaders receive the position and the direction of each tracer ray and can either change or completely discard the traced ray. This allows to implement distortion maps and cut maps. The following shader variables are provided:

  • P — Contains ray’s origin.
  • I — Contains ray’s direction. Setting this variable to zero instructs the renderer not to trace the corresponding ray sample.
  • time — The time at which the ray is sampled.
  • (u, v) — Coordinates, in screen space, of the ray being traced.

Script Objects

It is a design goal to provide an easy to use and flexible scripting language for ɴsɪ.

The Lua language has been selected for such a task because of its performance, lightness and features1. A flexible scripting interface greatly reduces the need to have API extensions.

For example, what is known as ‘conditional evaluation’ and ‘Ri filters’ in the RenderMan API are superseded by the scripting features of ɴsɪ.

[!NOTE] Although they go hand in hand, scripting objects are not to be confused with the Lua binding.

The binding allows for calling ɴsɪ functions in Lua while scripting objects allow for scene inspection and decision making in Lua. Script objects can make Lua binding calls to make modifications to the scene.


To be continued …

Footnotes


  1. Lua is also portable and streamable.

Rendering Guidelines

Basic Scene Anatomy

The fundamental building blocks of an ɴsɪ scene

A minimal (and useful) ɴsɪ scene graph contains the three following components:

  1. Geometry linked to the .root node, usually through a transform chain.
  2. ᴏsʟ materials linked to scene geometry through an attributes node.
  3. At least one outputdriver ​ → ​ outputlayer ​ → ​ screen ​ → ​ camera ​ → ​ .root chain to describe a view and an output device.

The scene graph above shows a renderable scene with all the necessary elements. Note how the connections always lead to the .root node.

In this view, a node with no output connections is not relevant by definition and will be ignored.

[!CAUTION] For the scene to be visible, at least one of the materials has to be emissive.

A Word – or Two – About Attributes

Those familiar with the RenderMan standard will remember the various ways to attach information to elements of the scene (standard attributes, user attributes, primitive variables, construction parameters). E.g parameters passed to RenderMan Interface calls to build certain objects. For example, knot vectors passed to RiNuPatch().

Attribute inheritance and override

In ɴsɪ things are simpler and all attributes are set through the NSISetAttribute() mechanism. The only distinction is that some attributes are required (intrinsic attributes) and some are optional: a mesh node needs to have P and nvertices defined — otherwise the geometry is invalid.

[!NOTE] In this documentation, all intrinsic attributes are documented at the beginning of each section describing a particular node.

In ᴏsʟ shaders, attributes are accessed using the getattribute() function and this is the only way to access attributes in ɴsɪ. Having one way to set and to access attributes makes things simpler (a design goal) and allows for extra flexibility (another design goal). The figure above shows two features of attribute assignment in ɴsɪ:

Attribute inheritance

: Attributes attached at some parent (in this case, a metal material) affect geometry downstream.

Attribute override

: It is possible to override attributes for a specific geometry by attaching them to a transform node directly upstream (the plastic material overrides metal upstream).

Note that any non-intrinsic attribute can be inherited and overridden, including vertex attributes such as texture coordinates.

Instancing

Instancing in ɴsɪ is naturally performed by connecting a geometry to more than one transform (connecting a geometry node into a transform.objects attribute).

Instancing in ɴsɪ with attribute inheritance and per-instance attribute override

The above figure shows a simple scene with a geometry instanced three times. The scene also demonstrates how to override an attribute for one particular geometry instance, an operation very similar to what we have seen in the attributes section. Note that transforms can also be instanced and this allows for instances of instances using the same semantics.

Creating ᴏsʟ Networks

A simple ᴏsʟ network connected to an attributes node

The semantics used to create ᴏsʟ networks are the same as for scene creation. Each shader node in the network corresponds to a shader node which must be created using NSICreate. Each shader node has implicit attributes corresponding to shader’s parameters and connection between said arguments is done using NSIConnect. Above diagram depicts a simple ᴏsʟ network connected to an attributes node.

Some observations:

  • Both the source and destination attributes (passed to NSIConnect) must be present and map to valid and compatible shader parameters (Lines 21–23).

[!NOTE] There is an exception to this: any non-shader node can be connected to a string attribute of a shader node. This will result in the non-shader node’s handle being used as the string’s value.

This behavior is useful when the shader needs to refer to another node, in a ᴏsʟ call to transform() or getattribute(), for example.

  • There is no symbolic linking between shader arguments and geometry attributes (a.k.a. primvars). One has to explicitly use the getattribute() ᴏsʟ function to read attributes attached to geometry. In the example above, this is done in the read_attribute node (Lines 11–14). Also see the section on attributes.
Create "ggx_metal" "shader"
SetAttribute "ggx"
    "shaderfilename" "string" 1  ["ggx.oso"]

Create "noise" "shader"
SetAttribute "noise"
    "shaderfilename" "string" 1 ["simplenoise.oso"]
    "frequency" "float" 1 [1.0]
    "lacunarity" "float" 1 [2.0]

Create "read_attribute" "shader"
SetAttribute "read_attribute"
    "shaderfilename" "string" 1 ["read_attributes.oso"]
    "attributename" "string" 1 ["st"]

Create "read_texture" "shader"
SetAttribute "read_texture"
    "shaderfilename" "string" 1 ["read_texture.oso"]
    "texturename" "string" 1 ["dirt.exr"]

Connect "read_attribute" "output" "read_texture" "uv"
Connect "read_texture" "output" "ggx_metal" "dirtlayer"
Connect "noise" "output" "ggx_metal" "roughness"

# Connect the OSL network to an attribute node
Connect "ggx_metal" "Ci" "attr" "surfaceshader"

Lighting in the Nodal Scene Interface

Creating lights in nsi

There are no special light source nodes in ɴsɪ (although the environment node, which defines a sphere of infinite radius, could be considered a light in practice).

Any scene geometry can become a light source if its surface shader produces an emission() closure. Some operations on light sources, such as light linking, are done using more general approaches.

Following is a quick summary on how to create different kinds of light in ɴsɪ.

Area Lights

Area lights are created by attaching an emissive surface material to geometry. Below is a simple ᴏsʟ shader for such lights (standard ᴏsʟ emitter).

// Copyright (c) 2009-2010 Sony Pictures Imageworks Inc., et al.  All Rights Reserved.
surface emitter     [[ string help = "Lambertian emitter material" ]]
(
    float power = 1 [[ string help = "Total power of the light" ]],
    color Cs = 1    [[ string help = "Base color" ]])
{
    // Because emission() expects a weight in radiance, we must convert by dividing
    // the power (in Watts) by the surface area and the factor of PI implied by
    // uniform emission over the hemisphere. N.B.: The total power is BEFORE Cs
    // filters the color!
    Ci = (power / (M_PI * surfacearea())) * Cs * emission();
}

Spot and Point Lights

Such lights are created using an epsilon sized geometry (a small disk, a particle, etc.) and optionally using extra arguments to the emission() closure.

surface spotlight(
    color i_color = color(1),
    float intenstity = 1,
    float coneAngle = 40,
    float dropoff = 0,
    float penumbraAngle = 0
) {
    color result = i_color * intenstity * M_PI;

    // Cone and penumbra
    float cosangle = dot(-normalize(I), normalize(N));
    float coneangle = radians(coneAngle);
    float penumbraangle = radians(penumbraAngle);

    float coslimit = cos(coneangle / 2);
    float cospen = cos((coneangle / 2) + penumbraangle);
    float low = min(cospen, coslimit);
    float high = max(cospen, coslimit);

    result *= smoothstep(low, high, cosangle);

    if (dropoff > 0) {
        result *= clamp(pow(cosangle, 1 + dropoff),0,1);
    }
    Ci = result / surfacearea() * emission();
}

Directional and HDR Lights

Directional lights are created by using the environment node and setting the angle attribute to 0. HDR lights are also created using the environment node, albeit with a 2π cone angle, and reading a high dynamic range texture in the attached surface shader. Other directional constructs, such as solar lights, can also be obtained using the environment node.

Since the environment node defines a sphere of infinite radius any connected ᴏsʟ shader must only rely on the I variable and disregard P, as is shown below.

shader hdrlight(
    string texturename = ""
) {
    vector wi = transform("world", I);

    float longitude = atan2(wi[0], wi[2]);
    float latitude = asin(wi[1]);

    float s = (longitude + M_PI) / M_2PI;
    float t = (latitude + M_PI_2) / M_PI;

    Ci = emission() * texture(texturename, s, t);
}

[!NOTE] Environment geometry is visible to camera rays by default so it will appear as a background in renders. To disable this simply switch off camera visibility on the associated attributes node.

Defining Output Drivers and Layers

ɴsɪ graph showing the image output chain

ɴsɪ allows for a very flexible image output model. All the following operations are possible:

  • Defining many outputs in the same render (e.g. many EXR outputs)
  • Defining many output layers per output (e.g. multi-layer EXRs)
  • Rendering different scene views per output layer (e.g. one pass stereo render)
  • Rendering images of different resolutions from the same camera (e.g. two viewports using the same camera, in an animation software)

The figure above depicts an ɴsɪ scene that creates one file with three layers. In this case, all layers are saved to the same file and the render is using one view. A more complex example is shown below: a left and a right camera are used to drive two file outputs, each having two layers (Ci and Diffuse colors).

ɴsɪ graph for a stereo image output

Light Layers

Light layers

The ability to render a certain set of lights per output layer has a formal workflow in ɴsɪ. One can use three methods to define the lights used by a given output layer:

  1. Connect the geometry defining lights directly to the outputlayer.lightset attribute
  2. Create a set of lights using the set node and connect it into outputlayer.lightset
  3. A combination of both 1 and 2

The diagram above shows a scene using method 2 to create an output layer containing only illumination from two lights of the scene. Note that if there are no lights or light sets connected to the lightset attribute then all lights are rendered. The final output pixels contain the illumination from the considered lights on the specific surface variable specified in outputlayer.variablename ().

Inter-Object Visibility

Some common rendering features are difficult to achieve using attributes and hierarchical tree structures. One such example is inter-object visibility in a 3D scene. A special case of this feature is light linking which allows the artist to select which objects a particular light illuminates, or not. Another classical example is a scene in which a ghost character is invisible to camera rays but visible in a mirror.

In ɴsɪ such visibility relationships are implemented using cross-hierarchy connection between one object and another. In the case of the mirror scene, one would first tag the character invisible using the visibility attribute and then connect the attribute node of the receiving object (mirror) to the visibility attribute of the source object (ghost) to override its visibility status. Essentially, this “injects” a new value for the ghost visibility for rays coming from the mirror.

Visibility override, both hierarchically and inter-object

Above figure shows a scenario where both hierarchy attribute overrides and inter-object visibility are applied:

  • The ghost transform has a visibility attribute set to 0 which makes the ghost invisible to all ray types

  • The hat of the ghost has its own attribute with a visibility set to 1 which makes it visible to all ray types

  • The mirror object has its own attributes node that is used to override the visibility of the ghost as seen from the mirror. The nsi stream code to achieve that would look like this:

    Connect "mirror_attribute" "" "ghost_attributes" "visibility"
        "value" "int" 1 [1]
        "priority" "int" 1 [2]
    

    Here, a priority of 2 has been set on the connection for documenting purposes, but it could have been omitted since connections always override regular attributes of equivalent priority.

Footnotes

Cookbook

The Nodal Scene Interface (NSI) is a simple yet expressive API to describe a scene to a renderer. From geometry declaration, to instancing, to attribute inheritance and shader assignments, everything fits in 12 API calls. The following recipes demonstrate how to achieve most common manipulations.

Geometry Creation

Creating geometry nodes is simple. The content of each node is filled using the NSISetAttribute call.

## Polygonal meshes can be created minimally by specifying "P".
## NSI's C++ API provides an easy interface to pass parameters to all NSI
## API calls through the Args class.

Create "simple polygon" "mesh"
SetAttribute "simple polygon"
    "P" "point" 1 [ -1  1  0   1  1  0   1 -1  0   -1 -1  0 ]

Geometry Creation in C++

/*
    Polygonal meshes can be created minimally by specifying "P".
    NSI's C++ API provides an easy interface to pass parameters
    to all NSI API calls through the Args class.
*/
const char *k_poly_handle = "simple polygon"; /* avoids typos */

nsi.Create( k_poly_handle, "mesh" );

NSI::ArgumentList mesh_args;
float points[3*4] = { -1, 1, 0,  1, 1, 0, 1, -1, 0, -1, -1, 0 };
mesh_args.Add(
    NSI::Argument::New( "P" )
        ->SetType( NSITypePoint )
        ->SetCount( 4 )
        ->SetValuePointer( points ) );
nsi.SetAttribute( k_poly_handle, mesh_args );

Specifying normals and other texture coordinates follows the same logic. Constant attributes can be declared in a concise form too:

SetAttribute "simple polygon"
    "subdivision.scheme" "string" 1 ["catmull-clark"]

Adding constant attributes in C++

/** Turn our mesh into a subdivision surface */
nsi.SetAttribute( k_poly_handle,
    NSI::CStringPArg("subdivision.scheme", "catmull-clark") );

Transforming Geometry

In NSI, a geometry is rendered only if connected to the scene’s root (which has the special handle “.root”). It is possible to directly connect a geometry node (such as the simple polygon above) to scene’s root but it wouldn’t be very useful. To place/instance a geometry anywhere in the 3D world a transform node is used as in the code snippet below.

Create "my translation" "transform"
Connect "translation"  "" ".root" "objects"
Connect "simple polygon" "" "translation" "objects" );

# Transalte 1 unit in Y
SetAttribute "my translation"
    "transformationmatrix" "doublematrix" 1 [
    1 0 0 0
    0 1 0 0
    0 0 1 0
    0 1 0 1]

Adding constant attributes in C++

const char *k_instance1 = "my translation";

nsi.Create( k_instance1, "transform" );
nsi.Connect( k_instance1, "", NSI_SCENE_ROOT, "objects" );
nsi.Connect( k_poly_handle, "", k_instance1, "objects" );

/*
    Matrices in NSI are in double format to allow for greater
    range and precision.
*/
double trs[16] =
{
    1., 0., 0., 0.,
    0., 1., 0., 0.,
    0., 0., 1., 0.,
    0., 1., 0., 1. /* transalte 1 unit in Y */
};

nsi.SetAttribute( k_instance1,
    NSI::DoubleMatrixArg("transformationmatrix", trs) );

Instancing is as simple as connecting a geometry to different attributes. Instances of instances do work as expected too.

const char *k_instance2 = "another translation";
trs[13] += 1.0; /* translate in Y+ */

nsi.Create( k_instance2, "transform" );
nsi.Connect( k_poly_handle, "", k_instance2, "objects" );
nsi.Connect( k_instance2, "", NSI_SCENE_ROOT, "objects" );

/* We know have two instances of the same polygon in the scene */

Implementer FAQ

Questions an implementer of ɴsɪ runs into when the specification is silent, or when it says one thing and 3Delight does another. Each answer gives the rule, what was observed, and what an implementation should do.

Unless stated otherwise, the observations come from 3Delight 2.9.207. Each was rendered, not read from documentation: a small scene, rendered twice with one change between the renders.

Attributes and Priority

Two attributes nodes on one node set the same attribute at the same priority. Which one applies?

Answer. The node connected first applies. The order of the NSIConnect() calls is part of the scene.

Observed. A plane with two sibling attributes nodes, one with visibility.camera 0 and one with 1. The plane is visible when the visible node is connected first, and hidden when the hidden node is connected first. Handle names and creation order run against the connection order, so they do not decide. The same rule applies to shaders: with a red and a green shader on two sibling attributes nodes, the plane takes the colour of the node connected first. 3Delight prints no warning in either case.

For implementers. Resolve the tie in connection order. Also warn, and list the definitions that were dropped: a scene whose look depends on call order is almost never intentional. The author can remove the ambiguity with ATTR.priority, or with a priority on the shader connection. See the attributes node.

Does a priority on a geometryattributes connection change which attributes node wins?

Answer. No. The priority of a connection has an effect only on shader connections, such as surfaceshader. To rank an attributes node, set ATTR.priority on that node.

Observed. The specification says both things. The NSIConnect() argument table says priority “indicates in which order the nodes should be considered”. The attributes node page says connections can be assigned priorities “(for shaders, essentially)”. 3Delight follows the second sentence:

Connect "near" "" "mesh" "geometryattributes"
Connect "far" "" "xf" "geometryattributes" "priority" "int" 1 [ 10 ]
SetAttribute "near" "visibility" "int" 1 [ 0 ]
SetAttribute "far" "visibility" "int" 1 [ 1 ]

The mesh stays hidden: near wins, and the connection priority has no effect. Replace the connection argument with "visibility.priority" "int" 1 [ 10 ] on far and the mesh is visible. renderdl -cat echoes the connection argument back, so the renderer parses it and then ignores it.

For implementers. Read the connection priority for the three shader slots. Ignore it on geometryattributes.

An attributes node sets ATTR.priority but not ATTR. Does it define anything?

Answer. Yes. It defines ATTR at its default value, and that definition ranks with the given priority like any other.

Observed. An attributes node with only visibility.priority makes the geometry visible over a farther visibility 0, at priority 10 and also at priority 0. The same node two levels up, at priority 10, also wins over a visibility 0 on the node attached to the geometry itself. So it does not win only because it is nearer. A node with no attributes defines nothing.

For implementers. Treat a lone ATTR.priority as a definition of ATTR with its default value.

Which types does ATTR.priority accept?

Answer. Exactly one int. 3Delight ignores the priority when it has any other type or count, and then the definition ranks at priority 0.

Observed. An int64 priority of 10 loses to a nearer definition with no priority. A priority written "int" 2 [ 10 10 ] or "int[2]" 1 [ 10 10 ] is also ignored.

For implementers. Accept a single int. Do not take the first value of a longer array, and do not convert an int64.

Which wins: a far visibility.camera or a nearer visibility?

Answer. At equal priority, the far visibility.camera. The more specific attribute wins before proximity is compared.

For implementers. Rank the candidates by priority first, then by specificity (per-ray before visibility), then by distance from the geometry, then by connection order.

Is an attribute value read strictly by its type?

Answer. No. 3Delight reads a value much more loosely than a priority. On visibility, a float 0.4 is visible and a float 0 is hidden, an int64 0 hides, and a string reads as true. A value of the wrong type is still a definition: it wins its ranking, so the renderer does not look further up the path.

For implementers. Do not treat a value of an unexpected type as “not defined”. That shows the object where the renderer hides it, or the reverse.

Are set nodes a source of attributes?

Answer. Yes, for geometryattributes and for shaderattributes, although the gathering rule names only geometry and transform nodes. Only direct membership counts.

Observed. For each node on the path from the geometry to the root, the order is:

  1. The node’s own attributes nodes.
  2. The attributes nodes on the sets the node is directly a member of, in the order of the memberships.
  3. The next node up the path.

A set inside another set contributes nothing. A set that holds two nodes of the path counts once, at the nearer node. ATTR.priority still ranks above all of this.

For implementers. Gather set containers at each level, with direct membership only.

A geometry has two parents. Which attributes apply?

Answer. Each path to the root applies its own attributes. Connecting a geometry to two transforms draws it twice, and each copy gathers attributes along its own path.

Observed. One parent with visibility 1 and one with visibility 0 draws one copy, not two and not none.

For implementers. Resolve attributes per path, not per geometry.

Types and Motion

What happens to motion samples when one sample has the wrong type?

Answer. The wrong sample unsets the attribute and discards every sample before it. Only the samples set after it remain.

Observed. A good doublematrix at t=0, a float at t=1 and a good doublematrix at t=2 draw a static object at the t=2 matrix. 3Delight warns E6007. Without the float, the object moves across the frame.

For implementers. Do not skip the bad sample and keep the others. That shows motion blur that the renderer does not draw.

Is "double" 16 [...] a matrix?

Answer. No. transformationmatrix must be a doublematrix. With sixteen double values, 3Delight warns E6007 and draws the node at the identity transform. In a stream, the type name for an integer is int: "integer" is rejected with E1000 and the call is skipped.

Can a Lua script pass an int64 or a double?

Answer. Not in 3Delight 2.9.210. nsi.TypeInt64, nsi.TypeDouble and nsi.TypePointer are nil, so an argument that names one of them has no type. The renderer then infers a type and changes the value without an error.

Observed. With renderdl -lua -cat, data=9007199254740993, type=nsi.TypeInt64 is written as "int" 1 1, and data=0.1, type=nsi.TypeDouble is written as a float.

For implementers. Define all type constants in the Lua binding, including the 64-bit ones. A value that cannot be represented should be an error, not a silent conversion. See the Lua API.

The Stream Format

Is the stream line-based?

Answer. No. The stream is a sequence of tokens. A complete scene on one line parses. A parameter list ends at the next bare token that is a statement keyword. Parameter names are always quoted, so this is not ambiguous. Whitespace runs are free, and # starts a comment that ends at the end of the line.

Observed. renderdl -cat reads Create "a" "transform" Create "b" "mesh" SetAttribute "b" "fov" "float" 1 45 as separate statements.

Which escapes does a string accept?

Answer. 3Delight writes \", \\, \t and \n by name, each other byte below 0x20 as three octal digits, such as \001, and each byte at or above 0x7f unchanged. It reads octal escapes of one to three digits. There is no \x.

For implementers. A string is a sequence of bytes, not UTF-8 text. A file name in Latin-1 is valid, so do not reject it.

Does a reader expand ${VAR} in a string?

Answer. No. The value stays as written in the stream. 3Delight expands the reference when it uses the value, for example when it opens a file. Every variable expands, not only variables whose name starts with NSI_PATH_. That prefix only controls which paths streampathreplacement writes as references.

Observed. renderdl -cat echoes ${NSI_PATH_TEST}/out.exr unchanged. A render with that imagefilename writes to the expanded path.

For implementers. Keep the reference in a stream that you read and write again. Expand it where you open the file.

Why does a stream I wrote not start with text?

Answer. With the autonsi format, which is the default, 3Delight writes binarynsi unless the file name ends in .nsia. The binary encoding is not documented. Its files start with the bytes cc 00. To get text, name the file .nsia or convert the file with renderdl -cat.

Threads

Can I make calls on one context from many threads?

Answer. 3Delight accepts calls from several threads. But 3Delight 2.9 runs the calls on one context one at a time, so more threads do not make them faster.

Observed. 20,000 SetAttribute calls on existing meshes take 11.8 ms from one thread and 21.4 ms from sixteen threads. 20,000 Create calls take 11.4 ms and 10.8 ms.

For implementers. To load a scene faster, do the parsing on other threads and send the calls in order. If calls on your implementation can run concurrently, the order still matters: Create before use, and Connect calls into one attribute in stream order (see the first question).

Implementations

ɴsɪ is an interface, and more than one renderer implements it. This chapter lists, for each implementation, where it goes beyond the specification and where it falls short of it.

Each page has the same sections:

  • Extensions: what the implementation accepts or provides beyond the specification.
  • Limitations: parts of the specification it does not implement, or implements differently.
  • Behavior the specification leaves open: how it resolves a case the specification does not decide. The Implementer FAQ states these as questions.

An item is listed only when it was observed – rendered, or read back from the implementation – not when it was only read in documentation. Each page names the version it was observed with.

3Delight

3Delight by Illumination Research is the reference implementation of ɴsɪ. The observations on this page were made with 3Delight 2.9.210 unless a version is named.

Extensions

  • ᴏsʟ extensions. 3Delight resolves intersecting subsurface-scattering volumes with intersection priorities and merge sets. See ᴏsʟ Extensions.
  • A binary stream format. streamformat binarynsi writes an undocumented binary encoding. autonsi, which is the behavior for a streamfilename that does not end in .nsia, selects it. A binary stream starts with the bytes cc 00. renderdl -cat converts it to text.
  • Lua scenes. NSIEvaluate with type lua runs a script with an nsi table. See the Lua API and its limitations below.
  • Display drivers. An output driver’s drivername selects an ndspy display driver. 3Delight looks for <drivername>.dpy in the working directory, then in $DELIGHT/displays and $DELIGHT/lib. No environment variable extends that path. A built-in driver name, such as png, cannot be replaced by a file of the same name.
  • Cryptomatte built-ins. With cryptomatte.enable at its default of 1, the built-in variables id.geometry, id.scenepath, id.surfaceshader and id.asset are written in Cryptomatte format.
  • Tools. renderdl renders a stream or a Lua script. renderdl -cat writes a stream back as text, and renderdl -lua -cat runs a Lua script and writes the calls it makes as a stream.

Limitations

  • Lua cannot pass 64-bit values. nsi.TypeInt64, nsi.TypeDouble and nsi.TypePointer are nil. A value that names one of them is re-typed without an error: an int64 becomes the int 1, a double becomes a float. See the FAQ.
  • A connection priority on geometryattributes has no effect. Only a priority on a shader connection, such as surfaceshader, ranks it. Use ATTR.priority on the attributes node instead. See the FAQ.
  • Calls on one context run one at a time. Several threads may call into one context, but they are serialized: 20,000 SetAttribute calls take 11.8 ms from one thread and 21.4 ms from sixteen.
  • hpoint needs 2.9.210. A nurbs surface’s Pw must have the type hpoint. The same data as flat floats is rejected (E6007), and the surface is then dropped (E6020). 3Delight 2.9.208 has neither the type nor the node.
  • Displacement needs displacementbound. A displacementshader has no effect unless the attributes also set displacementbound, a float: how far, at most, the displacement moves the surface. The manual does not mention it. The library also knows a displacementboundspace, whose behavior is unverified. See the attributes node.
  • Any basis outside the three real values warns and falls back. A curves node whose basis is not "b-spline", "catmull-rom" or "linear" warns E6036 unsupported value and falls back to the default, catmull-rom – confirmed for "hobby", "bezier" and a nonsense string alike, with no special case for any particular name.

Behavior the Specification Leaves Open

  • Equal-priority definitions resolve in connection order. When two attributes nodes on one node define the same attribute at the same priority, the one connected first applies, for plain attributes and for shaders. 3Delight prints no warning about the definitions it drops. See the attributes node.
  • A lone ATTR.priority is a definition of ATTR at its default value. The priority must be exactly one int; any other type or count is ignored.
  • Specificity ranks before proximity. At equal priority, a far visibility.camera wins over a nearer visibility.
  • Values are read loosely. On visibility, a float 0.4 is visible, an int64 0 hides, and a string reads as true. A value of the wrong type is still a definition.
  • set nodes are attribute sources, for geometryattributes and shaderattributes, with direct membership only.
  • A wrong-typed motion sample unsets the attribute and discards every sample before it (E6007).
  • ${VAR} in a string value expands at use, for any variable, when the value is used – for example when a file is opened. A stream reader keeps it as written.
  • The stream reader is lenient about type spellings. "i point" reads as point.

Setup

  • The library is lib3delight in $DELIGHT/lib. The renderdl tool is in $DELIGHT/bin.
  • Without a license, 3Delight renders with a watermark in the image background. The license server (licserver) must be running to use a node-locked license.

OSL Extensions in 3Delight

Subsurface Scattering Inside Intersecting Volumes

Subsurface scattering (SSS) is accessible in 3Delight through the ᴏsʟ subsurface() closure. It simulates the scattering of light inside a volume bounded by a closed surface. A complication that often arises when using this feature is the problem of intersecting SSS volumes. This results in a third volume, also bounded by a composite closed surface.

3Delight handles overlapping SSS volumes that don’t have the same properties by mixing them together, thus creating a hybrid material inside the intersection. For this to work properly, the ᴏsʟ subsurface() closure must be used on both entry and exit of the volume. As a consequence, it shouldn’t depend on the orientation of the bounding surface normal.

Cross-section of overlapping red and blue SSS volumes — a hybrid material is used inside the intersection.

Intersection Priorities

However, this behavior can be changed by assigning priorities to SSS shaders through the optional intersectionpriority parameter of the subsurface() closure. Inside the intersection, the SSS shader with the highest intersection priorities will be used exclusively.

Cross-section of overlapping SSS volumes — highest priority assigned to the red object (left) or the blue object (right).

This tends to be useful when the intersection is not accidental, but rather the result of a decision made when defining the scene geometry. For example, in a model of a mouth a set of teeth can be designed to penetrate the geometry of the gums. This avoids modelling a small “pocket” on the gums around each tooth. In that case, the teeth should be assigned a higher priority than the gums in order for their roots to use only the tooth shader.

The intersectionpriority parameter is an integer between -60 and 60. Its default value is 0.

Merge Sets

Even when overlapping SSS objects use the same shader, 3Delight still treats the intersection as a separate volume with its own surface. This is often not the desired effect. The geometry of the intersecting objects is still intact, so it hinders the propagation of light inside the volumes. The result is darker or brighter areas on the surface along the boundary.

Cross-section of overlapping SSS volumes — internal surfaces are still present on the left, removed on the right using a Merge Set.

This can be fixed by assigning a Merge Set name to each SSS material through the optional mergeset parameter of the subsurface() closure. SSS volumes within the same Merge Set will be considered as a single volume, without internal divisions. This tends to be useful when a complex object is made up of multiple pieces of geometry that overlap in order to appear as a single object.

MoonRay

MoonRay is DreamWorks’ open-source production renderer. It has no ɴsɪ interface of its own. The nsi-moonray backend translates ɴsɪ calls into a MoonRay scene and ships as a library that exports the ɴsɪ C API. The items on this page are recorded in that project’s specifications and were observed with MoonRay commit eef67ae, with 3Delight 2.9.209 as the reference for comparisons.

Extensions

  • ᴏsʟ shader networks run in MoonRay. An ɴsɪ shader network becomes one ᴏsʟ shader group, evaluated by three MoonRay plug-ins: Osl for surfaces, OslDisplacement and OslMap. The backend must be built with ᴏsʟ available; without it, each shader is replaced by a UsdPreviewSurface with parameters read from 3Delight’s shaders, and displacement is dropped with a report.
  • Every emitter runs its actual shader, not a name lookup. ɴsɪ has no light nodes: a light is geometry whose surface shader produces an emission() closure. Every such emitter becomes a MoonRay MeshLight, lit by an OslMap running that same network – colour, intensity, and how both vary across the surface, come from the closure itself, sampled per point. Shader-name recognition (areaLight, pointLight, spotLight, distantLight) is a fallback only, for a build with no ᴏsʟ or a shader with no compiled .oso behind it; then MoonRay supplies the photometry and the light is in the right place with the wrong look.
  • 3Delight’s ᴏsʟ closures are understood. layer_closures, outputvariable and outputconstant are registered, and microfacet’s realeta/complexeta build a conductor. The MaterialX closures (dielectric_bsdf, conductor_bsdf, generalized_schlick_bsdf, sheen_bsdf, subsurface_bssrdf, uniform_edf, layer and others) are mapped too.
  • Per-lobe AOVs. An output variable that names a lobe becomes a MoonRay light-path expression. 3Delight’s variable names are translated, for example reflection to specular and incandescence to emission.
  • Output drivers without ndspy. An output driver named ferris_f32 (or _u32, _i32, _u16, _i16, _u8, _i8) calls Rust closures passed as callback.open, callback.write and callback.finish.
  • Scene export. $NSI_MOONRAY_SCENE writes the translated scene as MoonRay .rdla. The mnry command renders, converts and watches .nsi files.
  • A geometry shared under several transforms keeps every material. ɴsɪ’s lightweight instancing – connecting one node to several transforms, each with its own bound material – is one shared object in the interface, and RdlMeshGeometry cannot do that natively: one object, one transform, one material. The backend expands the shared node into one RdlMeshGeometry per placement at the translator boundary instead of dropping every material but one. Nothing in the ɴsɪ scene is lost, only duplicated internally.

Limitations

  • At most two motion samples per attribute, on one shutter for the whole scene. More samples are reported, not rendered, and each object’s motion is resampled onto the scene’s shutter.
  • Moving instances translate only. Rotation or scale of an instances node across the shutter is reported, not rendered.
  • suspend and resume are not supported. Restarting a MoonRay frame loses the samples taken so far.
  • vdbparticles is not supported. MoonRay has no geometry that reads a point-data grid.
  • A scalar OpenVDB emission grid is refused, and the volume with it. MoonRay’s VdbGeometry reads only an RGB emission grid. (volumeshader itself is used: a bound ᴏsʟ volume closure crosses as an OslVolume and runs.)
  • An environment node’s shader does not run, since MoonRay’s EnvLight is a light class, not a shader. Colour, intensity, exposure and a texture path cross; gradients, mappings and every other per-component contribution the shader computes do not.
  • ᴏsʟ details that are dropped: colored transparency becomes one scalar presence; MaterialX tints become their luminance; occlusion() and the microfacet keywords gamma, thinfilmthickness, thinfilmeta and mediumeta are ignored with a warning; scoped getattribute("scope", "name", ...) is not answered.
  • An emissive mesh cannot also have a non-emissive material, such as glowing metal.
  • One renderer per process. A second concurrent render is refused; use a second process.
  • An orthographic camera renders empty through the in-process progressive path. It renders through the spawned moonray program.
  • Output-driver callbacks need one shared build. Rust closures work only when the application and the backend use the same nsi-ffi-wrap.

Behavior That Differs From 3Delight

  • Pixels are pulled, not pushed. MoonRay renders progressively but does not deliver buckets. The backend polls it and sends each changed rectangle to the output driver.
  • Channels are named after the layer. The beauty output has the channels Ci.R, Ci.G and Ci.B, without alpha, not RGBA.
  • A plain mesh is not subdivided. MoonRay’s mesh subdivides by default; the backend turns that off unless subdivision.scheme is set.
  • ᴏsʟ + adds closures. Only layer() and layer_closures layer them with attenuation.
  • A disconnected object is switched off, not removed, in an interactive session.
  • A scene without a camera gets a default one, and geometry without a shader gets a default material.
  • fov is vertical, matching 3Delight’s framing to within a pixel.

Known MoonRay Bugs

Found while writing the backend; none is filed upstream yet.

  • A scene without a camera crashes MoonRay. The backend adds a default camera.
  • Changing visibility re-tessellates the geometry instead of only rebuilding the acceleration structure, so hiding and showing is not cheap in an interactive session.
  • A mesh light needs a material from the separate moonshine_dwa package; a MoonRay-only build fails. The backend provides a stand-in.
  • A material without a vectorized shading function renders black in MoonRay’s default mode. The backend forces scalar mode.
  • A mesh light with a map shader crashes when its geometry is in no geometry set. The backend adds one.

Setup

  • The library is libnsi_moonray.so (.dylib on macOS, nsi_moonray.dll on Windows). It exports the ɴsɪ C API.
  • MoonRay’s plug-in directory is found without being named: beside the running binary first, then the platform’s own per-user and system-wide install locations. $NSI_MOONRAY_DSO overrides that search and is never second-guessed. If nothing is found anywhere, the in-process path reports every directory it tried and falls back to spawning the moonray program rather than rendering silently empty.
  • The backend links MoonRay when built with the rdl2 feature. Otherwise it writes .rdla and runs the moonray program.
  • MoonRay is licensed under Apache-2.0; nsi-moonray under MIT, Apache-2.0 or Zlib.

NURBS: Draft Design

This page is the draft of a future version of the nurbs node: a NURBS surface patch – a tensor-product spline defined by a grid of control points, two knot vectors, and an order in each parametric direction.

3Delight 2.9.210 implements the nurbs node with the legacy attribute names; the reference page documents it. This draft was written before that release. It uses the new naming convention and proposes changes beyond the renames: consolidated trim-curve control points, per-loop holes, and stitching. Where this draft and the shipped node differ, the reference page is correct for 3Delight today:

  • The shipped Pw has the type hpoint, not weighted-point.
  • The shipped node has no loop count. The number of trimcurves.ncurves values is the number of loops.
  • The shipped trimcurves.inside is one optional value, and 1 keeps the surface inside a loop. The draft’s trim-curves.hole has one value per loop, and 1 removes it.

It has the following required attributes:

NameTypeDefault
u.countint

Control-point count along u. Total control-point count is u.count * v.count. Should be at least u.order; if smaller, the surface is rendered with order equal to u.count.

NameTypeDefault
v.countint

Control-point count along v. Same constraint as u.count relative to v.order.

NameTypeDefault
u.orderint

Order along u: degree + 1, so 2 is linear, 3 quadratic, 4 cubic. Must be at least 2. May differ from v.order.

NameTypeDefault
v.orderint

Order along v. See u.order.

NameTypeDefault
u.knotfloat

Knot vector along u. Length must equal u.count + u.order. Values must be non-decreasing.

NameTypeDefault
v.knotfloat

Knot vector along v. Length must equal v.count + v.order. Values must be non-decreasing.

The surface’s active parameter range can be restricted with the optional u.min/u.max/v.min/v.max attributes. Unlike other geometric primitives, NURBS surfaces do not assume [0, 1] parameter ranges – by default, the active range is the full extent of the corresponding knot vector.

NameTypeDefault
u.minfloat

Lower bound of the active range along u. Must be less than u.max and at least the (u.order - 1)-th value of u.knot.

NameTypeDefault
u.maxfloat

Upper bound of the active range along u. Must be greater than u.min and at most the u.count-th value of u.knot.

NameTypeDefault
v.minfloat

Lower bound of the active range along v. Must be less than v.max and at least the (v.order - 1)-th value of v.knot.

NameTypeDefault
v.maxfloat

Upper bound of the active range along v. Must be greater than v.min and at most the v.count-th value of v.knot.

One of position or position-weighted must be supplied to provide the control points. position defines a polynomial surface; position-weighted defines a rational one.

NameTypeDefault
positionpoint

The u.count * v.count control points (xyz), stored row-major: position[i * u.count + j] is the point at row i, column j.

NameTypeDefault
position-weightedweighted-point

Rational alternative to position: each control point is a weighted (homogeneous) point (wx, wy, wz, w), enabling rational NURBS. Same ordering as position. The weighted-point type is a draft addition – see Geometry in the Type System.

Trim Curves

How trim data is packaged – inline attributes as specified here, or dedicated nodes – is an open design question. See Trim Curves: API Alternatives for the alternatives under discussion; this page describes Option 1.

Trim curves carve a region out of the surface’s parameter domain. They are NURBS curves in the surface’s (u, v) parameter space – rational trim curves use homogeneous (u, v, w) control points, where the actual (u, v) of a control point is (u/w, v/w). Curves are organised into loops: within a loop they connect head-to-tail. Each loop must be explicitly closed – the last point of the last curve must coincide with the first point of the first curve.

The trim-curves.* attributes below are all-or-nothing: supply the full set or omit it entirely, with two exceptions. Supply exactly one of trim-curves.position and trim-curves.position-weighted, never both. And the stitching attributes trim-curves.edge-id/trim-curves.edge-orientation are optional – see Stitching.

NameTypeDefault
trim-curves.loop-countint

The number of trim loops.

NameTypeDefault
trim-curves.curve-countint

The number of curves in each loop. One value per loop.

NameTypeDefault
trim-curves.point-countint

The control-point count of each curve. One value per curve.

NameTypeDefault
trim-curves.orderint

The order of each curve. One value per curve.

NameTypeDefault
trim-curves.knotfloat

The concatenated knot vectors for all curves. The total length is the sum over curves of point-count[i] + order[i].

NameTypeDefault
trim-curves.minfloat

The parametric start of each curve. One value per curve.

NameTypeDefault
trim-curves.maxfloat

The parametric end of each curve. One value per curve.

NameTypeDefault
trim-curves.positionfloat[2]

The concatenated control points of all curves as non-rational (u, v) pairs. The total length is the sum over curves of point-count[i].

NameTypeDefault
trim-curves.position-weightedfloat[3]

Rational alternative to trim-curves.position: the concatenated control points of all curves as homogeneous (u, v, w) triples. Same ordering and total length as trim-curves.position. These are deliberately plain float tuples, not point-typed data – parameter-space coordinates must never transform; see Geometry in the Type System.

NameTypeDefault
trim-curves.holeint

Whether each loop is a hole. One value per loop. A value of 0 keeps the surface inside the loop; a value of 1 marks the loop as a hole – the surface inside it is removed. Loops may nest, alternating: an island inside a hole is again 0.

Stitching

The shared-boundary design owns the common identity model, alternatives, and examples for NURBS, polygon, and subdivision surfaces. Its recommendation is one weld namespace node connected to every participating geometry node. One namespace can contain all joins in a solid.

The attributes below are a compact shorthand for one-segment boundary uses in that namespace. They are proposed additions, not shipped attributes. A surface supplies either these arrays or the general weld.* use table, never both.

A boundary use can also contain several segments. For example, five trim curves can jointly meet one subdivision boundary. That case uses the general table’s trim-loop selector or an explicit ordered chain; the per-curve shorthand cannot express that grouping.

The declaration asks the renderer to preserve the join through tessellation and displacement. It does not prescribe an algorithm. A renderer that ignores the declaration cannot guarantee the requested join.

Trim-Curve Edges

Both attributes below, when supplied, must be supplied together, with one value per curve (aligned with trim-curves.point-count, trim-curves.order, etc.).

NameTypeDefault
trim-curves.edge-idint-1

The edge identity of each curve. One value per curve. Each non-negative entry declares one complete boundary use. Uses with the same ID and connected weld node belong together, including uses on other geometry types. A value of -1 declares no use. Non-negative IDs require a weld connection; IDs are not scene-global.

NameTypeDefault
trim-curves.edge-orientationint0

The traversal direction of each curve relative to its edge’s reference direction. One value per curve. A value of 0 means the curve, traversed from its parametric start to its end, follows the edge’s reference direction; 1 means it opposes it. This value becomes weld.reverse in the general table. After reversal, all uses follow the same reference traversal and share their start within tolerance, including closed boundaries. Native face-boundary orientation is separate. Equal parameter values still need not identify equal positions.

Natural Boundaries

In untrimmed patch networks – and on trimmed faces whose outer boundary is the natural parameter domain, such as a face carrying only hole loops – the welded edge is a whole side of the active domain rectangle [u.min, u.max] x [v.min, v.max]. Forcing such faces through the trimming machinery just to transport edge identities would be wasteful, so these welds are declared directly, per side.

NameTypeDefault
stitch.edge-idint[4]-1

The edge identities of the four sides of the active domain rectangle, in the order u = u.min, u = u.max, v = v.min, v = v.max. Values share the identifier space and semantics of trim-curves.edge-id: boundaries with equal non-negative values are stitched, -1 means no identity.

NameTypeDefault
stitch.edge-orientationint[4]0

The traversal direction of each side relative to the shared reference traversal, in the same side order. A side’s native direction is increasing v for the two u-sides and increasing u for the two v-sides. A value of 1 reverses that native direction; 0 keeps it. The resulting traversal must agree with the other uses, including its start.

This shorthand selects a whole side, so that entire side must bound the retained region. Partly retained sides require explicit ranges in the general table. If a trim curve and a domain side describe the same boundary portion, only one selector declares that use.

A closed surface that is represented as a single patch split at a seam – a cylinder or torus, say – welds to itself by giving the two seam sides the same identity, e.g. equal values for the u = u.min and u = u.max entries of one node.

The per-side shorthand selects whole sides only. Partial sides and T-junctions use local ranges in the general boundary-use table.

Shared Semantics

The shared-boundary design defines identity scope, boundary chains, self-seams, and non-manifold joins. Natural-side and trim-curve shorthand entries follow those same rules. The exporter guarantees that counterpart chains describe the same spatial boundary within the source model’s tolerance. After reversal, starts, ends, and traversal directions agree. Segment counts and parameterizations can differ. Belonging does not ask the renderer to join unrelated geometry.

Each trim curve is a complete use in this shorthand. Five curves with one repeated ID are five uses, not one chain. Selecting the whole loop in the general table expresses a single use made from all five curves.

A trim-boundary use belongs to the retained surface adjacent to that loop. A hole therefore joins its surrounding surface, not the removed interior. Reversal changes correspondence direction, not the retained region. The retained-region rules apply to both shorthand and general declarations.

An optional 3D edge representation is a separate geometry extension. It is not required by these declarations.

Node Types: Naming and Granularity

The naming convention renames attributes consistently, but it doesn’t resolve a deeper inconsistency in how ɴsɪ models primitives. Three different patterns are in play across the existing node types:

PrimitivePatternExamples
MeshesOne node, type attributemesh (polygons and Catmull-Clark via subdivision.scheme)
VolumesOne node, but only one backendvolume (renders OpenVDB exclusively)
ParticlesSplit by backendparticles, vdbparticles
CamerasSplit by projectionperspectivecamera, fisheyecamera, cylindricalcamera, sphericalcamera, orthographiccamera
SurfacesOne node per representationnurbs (shipped in 3Delight 2.9.210), t-nurcc (draft)

mesh collapses polygons and subdivision surfaces behind a subdivision.scheme attribute. Cameras do the opposite – five separate node types that differ only in their projection function. Volumes name themselves generically while only one backend is implemented. Particles are split by the data format their control points hold, not by what the renderer sees.

Three coherent resolutions, from least to most invasive:

Option 1 – Honest names, same shape

Keep one node per concern and rename for honesty. The mapping later in this document already adopts these names:

  • volume -> vdb-volume (it only renders OpenVDB).
  • vdbparticles -> vdb-particles (hyphenated).
  • Cameras keep their five hyphenated names (perspective-camera, …).
  • mesh stays as the one merged exception.

The inconsistency with mesh remains. This is a pure rename – no API change.

Option 2 – Collapse to one canonical primitive

Bring volumes, particles, and cameras in line with mesh’s “one node, type attribute” pattern:

  • vdb-volume and vdb-particles collapse into a single vdb node with kind = "volume" | "particles" (or distinguished by which data attribute is supplied).
  • The five camera nodes collapse into one camera node with projection = "perspective" | "fisheye" | "cylindrical" | "spherical" | "orthographic". Projection-specific attributes live behind the projection’s prefix (e.g. fisheye.mapping).

Every scene-graph entity ends up with a single canonical primitive. Migration is “rename node type, add a kind/projection attribute”. The renderer’s dispatch table has to flatten, but no user-side attribute is lost.

Option 3 – Split mesh to match the rest

Adopt the honest renames from Option 1 – volume -> vdb-volume, vdbparticles -> vdb-particles – and additionally replace mesh with polygon-mesh and subdivision-mesh. Each mesh node carries only the attributes meaningful for its surface kind; subdivision.scheme disappears entirely.

This is the most invasive option: every existing scene using subdivision surfaces has to re-target the new node, and the subdivision.* attributes migrate from prefix-grouped on mesh to top-level on subdivision-mesh.

Trade-offs

  • Option 1 is cheapest to deliver; it preserves the inconsistency under prettier names.
  • Option 2 matches the mesh pattern. Requires backend dispatch work but no user-facing data loss.
  • Option 3 is the cleanest in isolation but invalidates the largest existing-asset footprint.

No recommendation in this draft. The decision belongs in the API roadmap, not in a renaming pass.

Trim Curves: API Alternatives

Where should a NURBS surface’s trim data live? This packaging decision is separate from how surfaces declare shared boundaries.

The shipped nurbs node stores trim curves inline under the legacy names. The NURBS draft describes renamed attributes and proposed extensions. Both inline data and separate trim nodes can describe a hole made from several curves.

The shared-boundary design owns the stitching alternatives and their trade-offs. Its recommended weld node supplies one namespace for many joins. A local boundary use can select one curve, a whole loop, or an ordered chain. Trim packaging does not require one weld node per curve.

Packaging Options

CriterionInline attributesSeparate trim nodes
Trim data livesOn the surfaceOn connected nodes
Extra trim nodesNoneOne per loop group
Curve orderingArrays within each loopArrays within each loop; loops stay within one node
Independent loop editsReplace the surface’s trim dataReplace one connected loop group
ReuseThrough geometry instancingUnstitched trim patterns can serve compatible surfaces
Weld identityIDs in a connected weld namespaceSame namespace, obtained through the consuming surface

Connections need no ordering because each trim node carries complete loops. A loop can contain any supported number of curve segments. Loop membership and curve order are data, not connection order.

The inline encoding minimizes exporter bookkeeping. Separate nodes make loop groups independently editable. Neither encoding has an assumed memory or rendering advantage; those costs need measurement.

Shared 3D Edges Are a Separate Extension

The earlier Option 3: edge Nodes also exported a model edge’s 3D curve. It is not a third place to store trim data. A 3D curve could accompany either packaging option and either weld-identity encoding.

A trim curve describes a boundary in one surface’s parameter domain. A weld declaration states which boundaries belong together. A model-edge curve supplies additional geometry. Keeping these roles separate lets an exporter preserve topology without exporting redundant curves.

Decisions to Settle

The trim decision is whether exporters and editors need independent loop-group nodes. The weld decision is how to encode shared identity and boundary uses. Optional model-edge geometry should be considered only when a consumer needs that geometry.

Option 1: Inline Attributes

This option is the current draft, and the shape the shipped nurbs node already has under its legacy names. The draft design specifies it in full. The complete trim description lives on the surface node. The trim-curves.* attribute group holds the loop and curve counts, the orders, the knots, the ranges, the control points, and the hole flags. Optional stitching shorthand uses trim-curves.edge-id and trim-curves.edge-orientation. The per-side stitch.* attributes select natural boundaries. These IDs belong to the connected weld namespace. The general use table is an alternative when a use contains several segments.

Create "face_12" "nurbs"
SetAttribute "face_12"
    ...surface attributes...
    "trim-curves.loop-count" "int" 1 [2]
    "trim-curves.curve-count" "int" 2 [4 1]
    ...

Rationale

A trim curve is data in a surface’s parameter domain. Keeping that data on the surface minimizes exporter bookkeeping. Shared identity belongs to the connected weld namespace, while local arrays identify the participating boundaries. This separates trim packaging from the welding declaration.

Pros

  • Simplest possible exporter: one Create, one attribute block, no handles to invent or track.
  • The order is explicit – array order is loop and curve order.
  • Atomic updates: the all-or-nothing rule keeps a face’s trim state consistent. No window exists where half the loops are connected.
  • No additional trim-node lifetime rules; the optional weld connection follows the shared-boundary design.
  • It matches the one existing ɴsɪ implementation precedent, 3Delight’s trimcurves.*.

Cons

  • Monolithic: an edit to one hole in a live session resends every loop on the face.
  • No reuse: a hole pattern on a hundred identical faces travels a hundred times. Instancing at the object level covers the fully identical case.
  • Individual weld identities live in arrays. The graph shows their shared namespace, but inspecting a specific join also requires reading the IDs.
  • The nurbs attribute namespace absorbs everything: fourteen trim-curves.* names, and more to come.

Option 2: trim Nodes

Trim data moves to a dedicated trim node type, connected to the surface it trims. This node type is the graph-native descendant of RenderMan’s RiTrimCurve. That entity was also separate from the patch: it set graphics state for the RiNuPatch calls that followed, and it was reusable across patches.

A trim node carries one or more complete loops. The payload is the same as in the inline design, but the trim-curves. prefix drops away, because the node type supplies the context. Rule R4 of the naming convention requires this. The trim attributes are loop-count, curve-count, point-count, order, knot, min, max, position/position-weighted, and hole. Optional edge-id and edge-orientation arrays encode one-segment weld uses. A general weld.* table can instead select whole loops or segment chains, as described in the shared-boundary design. The two weld encodings are alternatives.

Create "face_12" "nurbs"
SetAttribute "face_12" ...surface attributes...

Create "face_12_outer" "trim"
SetAttribute "face_12_outer"
    "loop-count" "int" 1 [1]
    ...
Connect "face_12_outer" "" "face_12" "trim-curves"

Create "vent_hole_pattern" "trim"
SetAttribute "vent_hole_pattern"
    "loop-count" "int" 1 [64]
    "hole" "int" 64 [1 1 1 ...]
    ...
Connect "vent_hole_pattern" "" "face_12" "trim-curves"

trim-curves on the nurbs node becomes a multi-connection attribute, plural per rule R7. The surface’s trim state is the union of the loops of all connected trim nodes.

Ordering

A loop orders its curves head-to-tail, but ɴsɪ connections have no order. This mismatch is the usual objection to node-based trims. There are two answers:

  • Whole-loop granularity dissolves the problem. One node describes a loop in full, and array order inside that node is explicit. Between loops no meaningful order exists: the geometry of the loops and the hole flags determine the trimmed region, not the sequence of the loops. The unordered connection set is therefore harmless. This rule is the recommended one: a loop must not span nodes.
  • Sub-loop granularity, if anyone ever wants it, keeps the connection API stateless by supplying the order as data. An index attribute on each node ranks its fragments. This answer is listed for completeness. Whole-loop granularity makes it unnecessary.

Reuse

One trim node may connect to any number of nurbs nodes whose parameter domains it fits. A bolt-hole pattern stamped across identical panel faces is then defined once. Two caveats bound this benefit:

  • Reuse needs identically parameterized faces. CAD faces usually have per-face domains.
  • Weld declarations are local uses. A trim node carrying a weld table or a non-negative edge-id has exactly one consuming surface in this proposal. Its IDs belong to that surface’s connected weld namespace. Reusable trim nodes carry no weld declarations.

The natural-boundary stitch.* attributes stay on the nurbs node in any case.

Pros

  • Independent edits: a swap of one small node replaces or deletes one hole group. The face’s other loops stay put. This granularity is the natural one for live sessions.
  • Reuse of repeated trim patterns across compatible faces.
  • It follows the established precedent, RiTrimCurve, and the general shape of ɴsɪ: shared, composable components are nodes.
  • Attribute names get shorter (R4), and the nurbs namespace stays lean.
  • A loop group can be updated without replacing unrelated trim data.

Cons

  • Exporters must invent and track a handle per trim group, and emit Connect calls. This bookkeeping is modest but real, next to Option 1.
  • Several nodes assemble the face’s trim state. Partially-connected intermediate states exist during editing, and a face is debugged by chasing its connections.
  • Lifetime rules need a specification: recursive delete semantics, for when a surface or a shared trim node goes away.
  • The stitching restriction above. Reuse and welds exclude each other per node, and users must learn that rule.

Option 3: edge Nodes

Historical alternative, now separated into identity and optional geometry. The shared-boundary design owns the unified stitching proposal and its trade-offs.

The original option combined two roles in one edge node: shared identity and an authoritative 3D NURBS curve. Surfaces referred to that node through handle-valued attributes. This required roughly one node per model edge, in addition to the surface nodes.

The revised design separates those roles. A weld node can supply an identity namespace for all joins in a solid. Local use tables identify the participating boundaries, including chains of multiple trim curves or mesh edges. Those declarations do not require a shared 3D curve.

What Additional Geometry Could Provide

An exporter might also preserve the source model’s 3D edge curves for wireframe rendering or other geometry consumers. Such a curve would need an order, knots, a parameter range, and control points. Those are additional geometry, not required weld metadata.

A shared curve does not identify which trim loop, patch side, or mesh boundary uses it. Local selectors remain necessary. Nor does a shared curve by itself specify how a renderer reconciles displacement. That algorithm remains the renderer’s responsibility.

Trade-offs

The benefit is preservation of source geometry that a consumer might otherwise reconstruct. The cost is another curve representation and its relationship to the surface boundaries. An authoritative-curve extension would also need to state what happens when those representations disagree.

The original handle-valued attributes required custom lookup, dependency, and deletion semantics. Its missing-handle fallback treated unresolved references as identity tokens. The current recommendation uses ordinary connections to a real weld node instead. A disconnected declaration does not acquire a scene-global identity.

This extension remains optional and unspecified. It is not necessary to keep joined surfaces joined, and it makes no claim that an existing exporter produces cracks.

Shared Boundaries: Weld Declarations

Proposal, revised after feedback from an experimental implementation. Names follow the naming convention.

The implementation findings below explain the revised rules and their evidence limits. They do not establish that the implementation already supports this revision.

How can an exporter preserve joined boundaries with little bookkeeping, even when the two sides have different representations?

The declaration means: these selected boundaries belong together and must stay joined when rendered. Spatial coincidence alone does not declare a join. The renderer decides how to preserve the join through tessellation and displacement. This proposal specifies no welding algorithm or displacement policy.

Recommendation: One Scope, Many Welds

A weld node supplies an identity namespace. It can describe all joins in a solid through the boundary declarations on its connected geometry. It carries no curves and needs no attributes of its own.

solid_welds --> face_a.weld
            --> face_b.weld
            --> subdivision_mesh.weld

Each geometry node accepts one weld connection. Its local attributes select boundaries and assign integer IDs. Two boundary uses with the same ID and the same connected weld node belong together. ID 12 in another weld node is unrelated.

The effective identity is (weld node, ID). Membership in the node alone does not weld every boundary together. A solid with 10,000 shared boundaries can use one extra node, not 10,000 nodes. The exporter assigns IDs from source topology and writes arrays on the geometry it already exports.

The earlier proposal used one weld per shared edge and one weld-use per occurrence. That remains an alternative below. The recommendation here changes the granularity: one namespace node, with boundary uses stored as data.

A Boundary Use Can Contain Several Segments

A boundary use is one connected, ordered chain on one surface. It can be open or closed. The two uses of a weld need not contain the same number or type of segments.

For example, one use can contain five NURBS trim curves. The other can select one subdivision boundary edge whose limit boundary follows the same locus. These are two uses of one weld, not six competing definitions of an edge.

The exporter declares the relationship already known by the source application. It orders and directs the selected segments without resampling curves or changing the underlying geometry. Different knot vectors, segment counts, or parameter speeds do not change the identity.

The proposed correspondence contract below requires a common start and direction after selection and reversal. This strengthens the earlier draft, which allowed unrelated loop seams and left direction matching to the renderer.

What the Exporter Guarantees

The exporter guarantees that counterpart uses trace the same undisplaced spatial path within the source model’s geometric tolerance. Their lengths must also agree within the applicable tolerance. Equal length alone is insufficient: two unrelated curves can have equal lengths.

This agreement applies to the complete boundaries, not just their endpoints or control vertices. Comparisons concern the evaluated surface boundaries in a common coordinate system. For subdivision, this means the limit boundary, not the cage polyline. Animated declarations must remain valid over the rendered time interval.

No equality of vertex counts, segment counts, knot vectors, or parameter values is required. Five trim curves can meet seventeen mesh edges, or one boundary curve, under the same declaration. The exporter preserves known source topology; it need not generate a sample-to-sample correspondence table.

A numerical tolerance is a validity condition, not a search radius for discovering joins. Coincident boundaries with different IDs remain unrelated. The exact way to convey the source tolerance, if needed by a renderer, remains an open attribute-design decision. This draft does not invent a fixed epsilon or require a renderer to repair mismatched boundaries.

Start, Direction, and Correspondence

For each weld ID, the exporter chooses one reference traversal of the undisplaced boundary. Every use follows that same traversal after its ranges, segment order, and weld.reverse values are applied. No reference-use handle or additional direction attribute is needed: agreement between the resulting traversals is the contract.

For an open boundary, all uses start at the same endpoint and finish at the same endpoint, within tolerance. For a closed boundary, all uses start at the same anchor and travel in the same direction, exactly once around. A use must not backtrack or traverse the boundary multiple times. Degenerate boundaries without a defined traversal are outside this contract.

The exporter expresses an opposite source traversal by reversing its selected segments and their order. For a single segment, only weld.reverse = 1 is needed. Reversing a closed trim-loop reverses both curve order and curve traversal while retaining the loop’s original start as the anchor.

Different closed-loop seams need an explicit selection adjustment. Segment order can rotate when the common anchor is already a segment endpoint. An anchor inside a curve can be expressed by two ranges of that curve: the tail first, then the head. This changes only the selection; it does not split the source geometry. A whole trim-loop selector is suitable when its stored start already matches the anchor.

For example, a periodic curve with the desired anchor at local parameter 0.25 can use ranges [0.25, 1] and [0, 0.25]. Both segments select the same curve index, in that order. Their joint traversal starts and ends at the desired anchor.

Opposite face-boundary traversals in an oriented manifold shell remain valid source topology. The weld traversal is a separate convention used for correspondence. Reversal does not change face orientation or which surface region is retained. Requiring every pair of uses to be anti-parallel would also fail for a weld with more than two uses.

A renderer must not infer a closed use’s direction from its coincident endpoints. A circle’s midpoint also cannot distinguish the two senses. The exporter supplies direction through the reference-traversal contract, rather than through a geometric direction test.

Common starts and directions do not imply common parameter speeds. For example, A(t) = (t, 0, 0) and B(t) = ((t + t*t)/2, 0, 0) traverse the same unit segment. At t = 0.5, their positions are 0.5 and 0.375. Both declarations can be valid, but pairing samples by parameter or array index is invalid.

Consequently, a consumer can pair sample runs directly only after establishing corresponding sample locations. It still resolves correspondence and refinement when segmentation or parameterization differs. Equal normalized distance along a simple boundary is one possible correspondence convention; this draft does not require that algorithm.

A Five-Curve Hole

The existing NURBS trim model already supports multiple curves in one loop:

trim-curves.loop-count = 1
trim-curves.curve-count = [5]
trim-curves.hole = [1]

The five curves connect head-to-tail. The last curve ends at the start of the first curve. Their individual orders, knots, ranges, and control points remain separate. The hole flag applies to the whole loop.

There are two different topological declarations an exporter might need:

  • Each curve meets a different neighboring face: assign five different weld IDs, one to each boundary use.
  • The entire loop meets one boundary on another surface: select the loop as one use and assign one weld ID.

Trim segmentation therefore does not determine weld granularity.

Concrete Encoding

The following is a proposed data layout, not a shipped API. It uses ordinary attributes and connections. No new C API function or connection parameter is needed.

Each geometry node carries a local boundary-use table:

  • weld.id: one non-negative integer per use.
  • weld.segment-count: segment count per use; omitted means one segment per use.
  • weld.kind: one string per segment.
  • weld.index: one int[3] tuple per segment.
  • weld.reverse: one integer per segment; omitted means all zero. 1 reverses the selected segment. The resulting chain must follow the shared reference traversal.
  • weld.range: one float[2] tuple per segment; omitted means the full selected segment.

Segments are concatenated in use order. Counts partition those arrays; their sum equals the segment-array length. Each segment belongs to exactly one use. IDs belong to uses, not to segments.

The index tuple has a fixed width so exporters can build one table without node handles for every segment. Unused components are zero. All indices are zero-based.

weld.kindweld.indexSelected boundary
trim-loop[loop, 0, 0]Complete loop, in its stored curve order
trim-curve[curve, 0, 0]One curve in the flattened trim-curve arrays
nurbs-side[side, 0, 0]Active-domain side: 0 = u-min, 1 = u-max, 2 = v-min, 3 = v-max
mesh-edge[face, loop, edge]Directed local edge occurrence in a polygon or subdivision control mesh

For meshes, loop 0 is the outer perimeter; later loops are polygon holes. The edge runs from a local vertex to the next vertex in that loop, with wraparound. For subdivision, the selection denotes the associated limit-surface boundary, not the straight control-cage segment. Each supported subdivision scheme must define that association. The same declaration applies to the draft t-nurcc control topology.

A natural side follows increasing v for a u-side and increasing u for a v-side. A trim curve follows its stored parameter range. Reversal applies after selection.

A range selects a subinterval before reversal. Its endpoints satisfy 0 <= start < end <= 1, normalized over the curve’s active range or the edge’s local domain. A trim-loop selector accepts only its full range. Partial loops use ordered trim-curve selectors instead.

The resulting use endpoints must coincide with the counterpart’s endpoints in reference-traversal order. At a split between neighboring welds, the adjoining declarations must also select the same geometric junction. Internal segment breaks need no counterpart break when the other use has different granularity.

Local range numbers need not match across surfaces. For example, [0, 0.5] on A(t) above corresponds geometrically to [0, (sqrt(5)-1)/2] on B(t). Both finish at position 0.5. The exporter guarantees that geometric agreement; the renderer cannot assume equal range numbers or equal parameter increments.

The selected segments must form one connected chain on one effective boundary component of the retained surface. A closed-loop selector is a complete use and cannot be mixed with additional segments in that use. Empty uses, invalid indices, and disconnected chains are invalid declarations.

Retained Region and Mixed Selectors

A weld belongs to the retained surface adjacent to the selected boundary. Trimming determines that region before the weld is considered. The declaration does not restore removed surface or infer which material exists from traversal direction.

With the draft’s trim-curves.hole = 1, the use belongs to the surface outside that hole. With hole = 0, it belongs to the retained region inside the outer or island loop. Nested loops and domain clipping still determine the final retained region.

The documented legacy trimcurves.inside = 0 similarly selects the retained region outside the loops; inside = 1 selects the inside. A weld on such a loop therefore has a defined incident surface in either case. These opposite trim settings do not require opposite weld-reference directions. The inside and hole values have opposite meanings and are not interchangeable.

A selected portion must actually separate retained surface from removed surface or from the exterior of the active domain. A curve that does not bound the final retained region cannot declare a boundary use there. If only part survives trimming, the declaration selects only that part.

A nurbs-side selector is valid wherever that domain side bounds retained surface. This includes a patch whose only trim loop is an interior hole: its four outer sides remain boundaries. A trim curve need not duplicate those sides.

Mixed nurbs-side and trim-curve segments are permitted only when they are consecutive portions of one effective boundary component. Their endpoints must be adjacent in surface boundary topology, and the chain must follow that component without a jump or repeated portion. An open use need not close; a closed use must return to its start on the same component. Separate inner and outer loops cannot be concatenated merely because their 3D positions touch.

For example, clipping a trim region against the active domain can produce a boundary composed of trim arcs and domain-side portions. Such consecutive portions may form one use. If a trim curve coincides with a domain side, either selector may describe that portion, but the use must not include both copies.

A renderer that does not support mixed uses must report that limitation. It must not silently interpret them as separate complete uses or claim that the requested join was preserved.

The Five-to-One Join

Suppose patch already has the five-curve hole above. Suppose subdiv has a boundary edge that describes the same closed locus. This illustrative stream fragment contains the entire additional declaration:

Create "solid_welds" "weld"
Connect "solid_welds" "" "patch" "weld"
Connect "solid_welds" "" "subdiv" "weld"

SetAttribute "patch"
    "weld.id" "int" 1 [12]
    "weld.kind" "string" 1 ["trim-loop"]
    "weld.index" "int[3]" 1 [0 0 0]

SetAttribute "subdiv"
    "weld.id" "int" 1 [12]
    "weld.kind" "string" 1 ["mesh-edge"]
    "weld.index" "int[3]" 1 [7 0 2]

This declares that trim loop 0 joins edge 2 of loop 0 on subdivision face 7. The example assumes the same closed locus, start, and traversal direction on both selectors. If one selector runs oppositely, its declaration also supplies weld.reverse = 1. An ordinary open edge cannot join an entire closed hole. If several subdivision edges form the matching ring, they form one use instead.

The five trim curves could also be listed explicitly. The following replaces the patch’s three attributes above and selects the same chain:

SetAttribute "patch"
    "weld.id" "int" 1 [12]
    "weld.segment-count" "int" 1 [5]
    "weld.kind" "string" 5 ["trim-curve" "trim-curve" "trim-curve" "trim-curve" "trim-curve"]
    "weld.index" "int[3]" 5 [0 0 0  1 0 0  2 0 0  3 0 0  4 0 0]

A chain of mesh edges uses the same count and concatenation mechanism. Mixed selectors follow the retained-boundary rules above. The renderer sees one use on each side regardless of segmentation.

Several Independent Joins

The same patch can declare another boundary without another node:

SetAttribute "patch"
    "weld.id" "int" 2 [12 13]
    "weld.kind" "string" 2 ["trim-loop" "nurbs-side"]
    "weld.index" "int[3]" 2 [0 0 0  1 0 0]

Loop 0 belongs to weld 12. The u-max side belongs to weld 13. Another surface declares ID 13 in the same namespace to complete that join. These two IDs remain independent.

Two uses on the same geometry can have the same ID, which expresses a self-seam. More than two uses can share an ID, which expresses a non-manifold join. A use without a counterpart is an open declaration; it joins nothing by itself.

Relation to the Original Stitching Arrays

The NURBS stitching attributes remain a compact shorthand for one-segment uses. They use the same weld connection and ID namespace:

  • Each non-negative trim-curves.edge-id entry selects its corresponding trim curve as one complete use.
  • Each non-negative stitch.edge-id entry selects its corresponding natural side as one complete use.
  • The paired orientation value becomes weld.reverse, aligning that selector with the shared reference traversal.
  • -1 means no use is declared for that entry.

These arrays need no separate matching rules. They lower to the boundary-use table described above. A surface supplies either these shorthand arrays or the general table, never both. An exporter can use the general table everywhere to avoid maintaining two encodings.

Assigning one shorthand ID to five consecutive curves would declare five complete uses of the same boundary. It would not make one five-segment use. A trim-loop selector or explicit segment count expresses that grouping.

With separate trim nodes, a general table can reside on each trim node. Its trim indices refer to that node’s arrays. It obtains the weld namespace through its single consuming surface; natural sides stay on the surface. A trim node carrying weld declarations has one consuming surface, to keep its use unambiguous. Trim packaging does not otherwise change the identity model.

Alternatives and Trade-offs

For E shared boundaries and U uses, these counts exclude existing geometry and trim nodes:

EncodingExtra nodesBenefitCost
Original scene-global IDs0Small arrays; direct mapping from source IDsExporters must coordinate global IDs; no explicit scope connection
One weld namespace plus local use tables1 per namespaceBatches all joins; supports chains; IDs stay local; recommendedExporter maintains arrays; one namespace per geometry node
One weld per boundary plus explicit weld-use nodesE + UIndependent graph edits; each use has an explicit handleMore nodes, handles, connections, and lifetime bookkeeping
One weld per boundary with direct geometry connectionsEFewer nodes than explicit usesStill needs selectors and grouping on connections or attribute slots

For 10,000 boundaries with two uses each, the explicit-use approach adds 30,000 nodes. The recommended encoding adds one namespace node for that solid. Both still describe 20,000 uses; batching removes node overhead, not the topology data. No render-time performance claim follows from these counts.

A shared 3D edge curve is an independent extension. It could accompany either identity encoding. It is not required to preserve belonging, and it does not select the local boundaries by itself.

Scope, Edits, and Remaining Decisions

One geometry node participates in one weld namespace in this recommendation. A mesh containing several solids can use one namespace with distinct IDs. This avoids splitting geometry solely to allocate scopes. Combining independently authored namespaces needs ID remapping, just as combining indexed geometry needs index remapping.

Node handles identify definitions; rendered occurrences need an additional scope rule. Repeating an assembly must repeat its internal joins without welding separate instances together. The exact attachment of that assembly scope remains an implementation discussion. Ambiguous instances must not silently weld to every occurrence of a connected geometry node.

Topology edits that change indices must update the use tables. A table’s arrays form one coherent declaration at a render synchronization point. Removing the weld connection removes its membership; the remaining IDs do not become scene-global. A renderer that ignores these declarations cannot claim to preserve the requested joins.

Implementation Feedback and Coverage

The first implementer reported results from nsi-intermediate and nsi-tessellate in the ɴsɪ repository. The former resolves declarations; the latter welds NURBS nodes. Reported fixtures include io1-ec-214 with 17 faces and 35 edges, and boxy with 80 faces and 124 edges. These measurements have not been independently reproduced for this documentation change.

Reported findingConsequence for this revision
Endpoint-only direction matching folded closed cylinder bands, producing areas 3.2 and 7.8 times the expected values. Quarter-point comparisons distinguished direction in the measured cases.All uses must share a start and traversal direction after reversal. This removes the need to infer direction from endpoints.
Measured manifold uses had opposite source traversals and coincident starts.Exporters align these traversals with weld.reverse. The shared traversal also supports more than two uses; it does not imply equal parameter speeds.
The implementation rejected outside-loop trims because the incident surface was unspecified.A weld belongs to the retained region adjacent to its selected boundary. Hole and outside-loop settings are valid and do not change weld traversal.
Mixed side/trim uses were unsupported and did not occur in the measured exports.Mixed selections must follow consecutive portions of one effective retained boundary component. Separate loops and duplicate portions remain invalid.
Of io1-ec-214’s 70 segments, 48 were natural sides, including partial sides. Ranges worked where split points agreed.Corresponding use endpoints and adjoining weld junctions must agree geometrically. Local range numbers need not match.
All uses across five STEP fixtures had one segment. Circular edges were single rational curves with doubled knots.The default segment count stays one. Ordered chains remain available for differently segmented exports.
Twelve of io1-ec-214’s 17 faces were cylinder bands with self-seams. Open declarations also served as useful diagnostics.Two uses on the same geometry remain distinct uses of one weld. A lone use joins nothing and can help diagnose incomplete export.

The report supplies no validation of mesh-edge selectors, multi-segment correspondence, or differing parameter speeds. Those capabilities remain part of the proposal, with implementation validation outstanding. Open declarations alone do not distinguish an intentional open boundary from missing export data.

The main remaining decisions are occurrence scope, tolerance transport, and the exact attribute encoding. One namespace contains many identities, and one use can contain many ordered segments. Displacement, tessellation, and geometric correspondence remain renderer decisions.

Geometry in the Type System

The nurbs and t-nurcc drafts raise a type-system question. Rational control points are homogeneous (wx, wy, wz, w) tuples. The legacy API passed them as float arrays of length 4, together with a spec warning about how not to declare them. Trim curves add a second case. Their control points are (u, v) or homogeneous (u, v, w) tuples in the surface’s parameter domain. What type should these tuples have?

What ɴsɪ’s types actually encode

point, vector, and normal occupy identical storage – three floats. The type distinguishes behavior: how the value responds to a transform. Points translate, vectors do not, and normals transform by the inverse transpose. Size was never the distinguishing axis, because float plus an array length already expresses any size. This observation decides both questions.

A rational control point is transformable geometry with its own rule. The four components transform as one unit under a 4x4 matrix, which is exactly why rational NURBS survive projective transforms. A float[4] hides that rule from every generic consumer. The legacy warning (“do not declare with array_len(4)”) is a design smell: it admits that the type is missing. Hence the draft type:

ConstantDescription
NSITypeWeightedPointWeighted (homogeneous) point (wx, wy, wz, w), four 32-bit floats.

Under the naming convention it reads as weighted-point. The Type Names draft asks whether that name should also carry a storage width. That name uses the vocabulary the attributes already use, as in position-weighted. RenderMan’s type system grew hpoint for exactly this attribute.

Trim-curve control points are the opposite case. (u, v, w) is a projective 2D point in the surface’s parameter domain. It is not point-typed data that happens to be small. A point type would be wrong twice. First, it misstates the geometry: the point is projective 2D, not Euclidean 3D. Second, it invites type-correct corruption. A transform-baking tool, an instancing optimizer, or a space-converting importer would do the right thing for point data. It would then destroy every trim curve, because parameter-space data must never transform.

The principle

Semantic types are for geometry that transforms; float[N] is the honest type for inert tuples.

point, vector, normal, weighted-point each name a transform rule. Data that no transform ever applies to – parameter-space coordinates, knots, weights on their own – is typed float with an array length, deliberately.

This principle is why trim-curves.position is float[2] and trim-curves.position-weighted is float[3], while the surface’s position-weighted is weighted-point. The first two live in the parameter domain; the third lives in object space. The float[4] smell was never the tuple. It was the inert typing of transformable geometry.

The alternative considered: a dimensional family

A symmetric family was considered and rejected: point-2d, point, and point-4d, with bare point keeping parity with vector and normal.

For it: the family is discoverable and uniform, it gives every tuple in the API a typed home, and one rule covers future needs.

Against it, decisively: the family encodes the wrong axis. Size is already expressible. Behavior is what the type system uniquely encodes, and the dimensional names leave that behavior as fine print. point-4d does not say homogeneous; it could mean a 4D position, or four channels. point-2d does not say parametric, never transforms. Each member would need exactly the per-type behavioral documentation whose absence the family was meant to cure. The family also invites a combinatorial zoo: vector-2d, normal-4d, and more. The projective-2D trim point still fits nowhere in it, as three floats that would masquerade as a 3D point.

Industry precedent supports the semantic choice on both sides. RenderMan named its four-float type hpoint, which is a behavior, not a size. USD faced the 2D question directly and typed texture coordinates as the semantic role texCoord2f, not as the dimensional float2. It did so precisely so that tools know the data is parametric rather than spatial.

Door left open

Generic tooling may one day need parameter-space data to be self-describing, for inspectors that plot trim curves without node-specific knowledge. The consistent move is then a semantic role, a parametric-point in the spirit of USD’s texCoord2f, not a dimensional point-2d. This page reserves the name. It is deliberately not part of the current draft.

Type Names: API Alternatives

NSIType_t grew one type at a time. Width entered the vocabulary three times, and each time it took a different form. This section briefs implementers. It rules on nothing.

An earlier version of this draft compared three schemes in one matrix. That was a mistake: the schemes differed on unrelated axes, so a criteria row could not compare them. The draft now separates three decisions. Each can be settled on its own, and each has its own pages.

One axis, three spellings

The enum in nsi.h already encodes width as an orthogonal bit:

NSITypeFloat   = 1,   NSITypeDouble       = NSITypeFloat   | 0x10,
NSITypeInteger = 2,   NSITypeInt64        = NSITypeInteger | 0x10,
NSITypeMatrix  = 8,   NSITypeDoubleMatrix = NSITypeMatrix  | 0x10,

The 0x10 bit means “64 bits wide”. The names do not agree on how to say that:

NarrowWideHow the name states the width
FloatDoubleThe base word changes. The C spelling of the wider type replaces it.
IntegerInt64A postfix states the width. The base word also shortens, Integer to Int.
MatrixDoubleMatrixA prefix states the width, and it borrows the float word.

What the renderer does today

Any proposal must start from the current behaviour. These results come from renderdl of 3Delight 2.9.

Width is type identity, and the renderer converts nothing. A mismatch in either direction is refused, and the attribute keeps its previous value:

.global.frame           (double)    given float     -> E6007, dropped
.global.numberofthreads (int)       given double    -> E6007, dropped
screen.crop             (float[2])  given double[2] -> E6007, dropped
screen.screenwindow     (double[2]) given float[2]  -> E6007, dropped

Arity is identity too. A float of count 4 does not satisfy a float[2].

"integer" is not a second spelling of "int". It is a syntax error. The parser reports E1000 ... has invalid type 'integer' and skips the whole call.

The encoding already reserves room for wide semantic types. NSITypeSizeOf in nsi.h carries table slots 20 to 23, which are Color|0x10 through Normal|0x10. All four hold zero. The bit pattern exists; only the names and the sizes are missing.

Two consequences follow. ɴsɪ is already schema-fixed in the sense USD is: the node specification fixes the width, and the caller must match it. The width of an attribute is also a hard contract. An arbitrary choice between float and double is therefore a trap for exporter authors, not a cosmetic detail.

Six surfaces, six vocabularies

ConceptC enumC++ classLua constantPython TypeStream keywordRenderer message
32-bit intNSITypeIntegerIntegerArgnsi.TypeIntegerType.Integer"int"'int'
64-bit intNSITypeInt64Int64Argnonenone"int64"'int64'
32-bit floatNSITypeFloatFloatArgnsi.TypeFloatType.Float"float"'float'
64-bit floatNSITypeDoubleDoubleArgnoneType.Double"double"'double'
32-bit matrixNSITypeMatrixnonensi.TypeMatrixType.Matrix"matrix"'matrix'
64-bit matrixNSITypeDoubleMatrixDoubleMatrixArgnoneType.DoubleMatrix"doublematrix"'doublematrix'

The wide types are the ones that surfaces forget. No Lua constant names any of them, and the Python Type class omits Int64 although it defines Double and DoubleMatrix.

An in-spec precedent

The scalarformat attribute of the outputlayer node already names a family of widths:

int8  uint8  int16  uint16  int32  uint32  half  float

That vocabulary abbreviates the integer word and states its width as a number. It keeps the C word for the two float widths. ɴsɪ therefore already contains an answer to part of the vocabulary question, and int32 is house style.

The three decisions

DecidesPages
1. EncodingWhere the width lives in NSIParam_tcomposite constants, a named bit, a flag
2. VocabularyHow a name spells the width, and whether it abbreviatesone page, one matrix
3. Scope and contractWhich types get a second width, and whether a mismatch converts or dropsone page

Decision 2 applies whatever Decision 1 settles, because the stream still needs one token per type. Decision 3 governs how much Decision 2 matters to a user. If the renderer converts, an exporter author stops having to track a width per attribute.

Three axes in one name

A type name carries up to three independent facts. Only one is expressed consistently:

AxisWhere it lives todayConsistent?
Storage widththe name, as Double, as 64, or as a Double prefixno
Aritypartly the name (Color, Matrix), partly arraylengthno
Transform semanticsthe name (Point, Vector, Normal)yes

The Geometry in the Type System page settles the third axis. It is not reopened here.

Questions to Settle

  1. Does anything need a 64-bit geometry type? Precedent says no. USD offers point3d but types the points attribute as point3f[]. Alembic stores P as V3f. glTF has no double at all. The 64-bit point that Decision 1 would enable has no demonstrated customer.
  2. Is width part of a type’s identity, or part of its storage? The renderer answers “identity” today, and refuses every mismatch. A change here is a change of behaviour, not of naming.
  3. Should the renderer convert between widths? This is the highest-value question on this page. It decides whether the arbitrary float against double choices in the current specification stay a trap.
  4. Does a third width ever arrive? scalarformat already lists half. A scheme that can express only two widths forecloses that.
  5. What happens to the old names? Every option can keep the current constants as deprecated aliases, since the enum values do not move. That choice is independent of the three decisions.

Encoding 1: Composite Constants

Each combination of a base type and a width keeps its own constant, as it has since version 1. NSITypeDouble, NSITypeInt64, and NSITypeDoubleMatrix remain distinct enum entries. Only their names come up for discussion, and that discussion is Decision 2.

NSITypeFloat   = 1,   NSITypeDouble       = NSITypeFloat   | 0x10,
NSITypeInteger = 2,   NSITypeInt64        = NSITypeInteger | 0x10,
NSITypeMatrix  = 8,   NSITypeDoubleMatrix = NSITypeMatrix  | 0x10,

Rationale

The encoding works. NSITypeSizeOf maps a constant to a byte count, parameters stay self-describing, and every binding needs one constant per case. A rename under Decision 2 fixes the inconsistency that users actually meet, which is the spelling.

The 0x10 bit stays an implementation detail of the enum values. Nothing outside nsi.h needs to know it exists.

Pros

  • No ABI change, and no source change beyond names.
  • NSITypeSizeOf keeps its signature. A parameter still describes its own layout.
  • One constant names one layout. A switch over the type is complete and a compiler can check it.
  • Every binding stays a flat list of constants, which is what Lua and Python already expose.
  • It is the only encoding that needs no new concept in the specification.

Cons

  • A wide semantic type needs a new constant for each: NSITypeWidePoint, NSITypeWideNormal, NSITypeWideColor, NSITypeWideWeightedPoint. The enum grows as the product of the two axes.
  • The reserved slots stay empty. NSITypeSizeOf has held zeroed entries for Color|0x10 through Normal|0x10 since version 1, and this option leaves them zeroed.
  • A third width multiplies the enum again. scalarformat already names half, so the case is not hypothetical.
  • The orthogonality stays hidden. A reader learns the 0x10 relationship only by reading the enum values.

Encoding 2: A Named Bit

The 0x10 bit stays in the type field and gains a name. Composing it with any base type becomes legal and documented.

NSITypeWide = 0x10,        /* 64-bit variant of the base type */

NSITypeFloat  = 1,
NSITypeInt    = 2,
NSITypeMatrix = 8,

NSITypeFloat | NSITypeWide replaces NSITypeDouble. NSITypePoint | NSITypeWide becomes legal, and it needs no new constant. The old composite names can stay as aliases, because their values do not move.

Rationale

The encoding has always been compositional. Only the documentation and the names were not. This option changes what the API says rather than what it does, and it is the smallest change that makes a wide semantic type expressible.

The four zeroed slots in NSITypeSizeOf, at Color|0x10 through Normal|0x10, are exactly the entries this option fills. The table needs sizes, not a new shape.

Pros

  • A wide variant of any type costs one table entry, not one constant per combination.
  • NSITypeSizeOf keeps its signature. Width is still in the type argument, so a parameter stays self-describing.
  • No second field must travel with the type. A serializer that forwards type forwards the width with it.
  • Existing constants keep their values, so the change is source-compatible if the old names stay as aliases.
  • It documents a relationship that already exists, so no implementation has to change to match.

Cons

  • The type field stops being a plain enumeration. A switch over it must mask the bit, and code that forgets misreads a wide value as an unknown type.
  • Bindings that expose a flat constant list, such as Lua and Python, must expose the bit and the composition rule too.
  • It reserves a bit of the type field permanently, which limits how many base types the field can ever hold.
  • A third width needs a second bit, or a small width field. The bit alone answers half no better than composite constants do.
  • The stream still needs one token per combination, so it gains nothing here. That is Decision 2.

Encoding 3: A Flag in flags

Width leaves the type field entirely. A type names a semantic role and an arity. A flag next to NSIParamIsArray states the storage width.

enum
{
    NSIParamIsArray = 1,
    NSIParamPerFace = 2,
    NSIParamPerVertex = 4,
    NSIParamInterpolateLinear = 8,
    NSIParamIsWide = 16
};

NSITypeDouble, NSITypeInt64, and NSITypeDoubleMatrix cease to exist. Any type can be wide.

Rationale

ɴsɪ already holds one orthogonal axis outside the type name. NSIParamIsArray and arraylength carry arity, and the C API tells readers to view arraylength as a part of the data type. This option treats width the same way.

The result is the smallest type enumeration of the three encodings, and the one where the two axes never multiply.

Pros

  • The enum stops growing with width. Eleven entries cover every combination.
  • Any type gains a wide form, including point, normal, color, and the draft weighted-point.
  • Width sits with the other per-parameter facts, which is where a reader of NSIParam_t already looks for layout modifiers.
  • The composition rule is stated in one place, rather than implied by enum values.

Cons

  • NSITypeSizeOf breaks. The function takes unsigned t and returns a size, so under this option the size stops being a function of its argument. That is a source break for every caller.
  • The type no longer describes the data. Two fields must travel together, and any binding or serializer that passes a type without its flags loses the width silently.
  • The arraylength analogy is weaker than it looks. The renderer treats float[2] as a distinct type when it matches attributes. Arity held outside the name is therefore still part of the contract, not a free modifier.
  • A boolean cannot express a third width. scalarformat already names half, and this encoding forecloses it.
  • Lua and Python gain a second argument beside type, where today one constant suffices.

Decision 2: Vocabulary

Whatever Decision 1 settles, the stream needs one token per type, and every binding needs a name. This page compares the spellings. A spelling is a table, not an architecture, so all four sit here rather than on pages of their own.

Two choices combine. The first is how a name states the width. The second is whether the name abbreviates.

How the name states the width

ConceptA. Numeric, explicitB. Numeric, 32 elidedC. double- prefixD. Short
32-bit intint32intinti32
64-bit intint64int64double-inti64
32-bit floatfloat32floatfloatf32
64-bit floatfloat64float64doublef64
Matrixmatrix32matrixmatrixm32
Wide matrixmatrix64matrix64double-matrixm64
Pointpoint32pointpointp32
Rational pointweighted-point32weighted-pointweighted-pointh32 or w32
Stringstringstringstringstring

A is regular without exception, and it matches the vocabulary of NumPy, Arrow, Alembic, and ONNX. It erases double, so the C inconsistency disappears without anyone having to argue about int. Its cost is that a width postfix reaches the semantic types, where it re-mixes the axis that geometry-types separated. A variant puts the width on scalars only and fixes the compounds by rule.

B changes three names and no more. A bare name means 32 bits. The rule is asymmetric, because two widths exist and only one is ever written. A bare point also becomes “the narrow one” retroactively, the day point64 appears.

C promotes the existing doublematrix pattern to a rule and hyphenates it per R6. It is not uniform as it stands: float to double replaces the word, while int to double-int prefixes it. The uniform form is double-float, which nobody will write. double-int is also etymologically sound and pragmatically confusing, since a reader takes it for twice an integer. Its honest description is “hyphenate doublematrix and stop”, which makes it the minimal-change option.

D is defensible for i32 and f32, which every programmer reads. It is not defensible for c32, p32, n32, or h32. Those are inventions. They contradict the ᴏsʟ words that the same person writes in a shader, and they are jargon in exactly the sense R9 forbids. A .nsi stream is read by humans when they debug an exporter, and these tokens make it opaque. The hesitation between h32 and w32 for the same type shows the scheme has no natural letter for a new semantic type.

A fifth scheme names every type by role, component count and width, as in Color3F32 and RealF64. It has its own page: Type Rename: Role, Components, Width.

Whether the name abbreviates

Under a numeric-width scheme the only abbreviations left are int and float. Both consistent answers satisfy the rule that a vocabulary abbreviates all of its names or none:

ᴏsʟ wordsPlain words
Integerintinteger
Floatfloatreal

int and float are the words ᴏsʟ uses, and a user of this renderer writes them in every shader. The scalarformat attribute already uses int32 and float in this specification. real is Fortran and Pascal vocabulary; the only precedent in ɴsɪ is the prose “integer, real or string” on page 18 of the specification.

The current half-abbreviated state is not a chosen style. "integer" is a syntax error the parser rejects, so there is only one spelling in the stream today, and it is int.

Precedent

APIScalar vocabularySemantic types
USDfloat, double, int, int64point3f, matrix4d – width in the name
AlembicFloat32, Int64POD plus extent plus an interpretation string
glTFcomponentType, an enumerationrole in the attribute name, width fixed per role
ᴏsʟint, floatpoint, vector, normal, color
ɴsɪ scalarformatint32, uint32, half, floatnot applicable

All of them state the width explicitly somewhere. None leaves it to the reader.

Decision 3: Scope and Contract

This decision asks two questions that are about behaviour, not about names. Which types need a second width at all? And what happens when a caller supplies the wrong one?

It governs how much Decision 2 matters. If the renderer converts between widths, an exporter author stops having to track a width per attribute, and the spelling becomes a matter of taste.

Which types need a second width

The specification types about 28 attributes as double and about 14 as float. No principle separates them.

There is a weak habit. Time and world-space lengths tend to be double: frame, referencetime, shutterrange, velocityreferencetime, clippingrange, depthoffield.*. Per-vertex data and values bound to ᴏsʟ tend to be float. The habit has counterexamples in both directions:

WideNarrowBoth are
anglefovan angle in degrees
screenwindowcropa normalised pair on one node
vdbparticles.widthparticles.widtha width on a particle type

screenwindow and crop sit on the same page of the specification, on the same node, and disagree.

One case is genuine. Matrices are consistently wide: transform.transformationmatrix and instances.transformationmatrices are both doublematrix. Precedent agrees, since USD types transforms as matrix4d and Alembic uses M44d.

The narrow matrix is meanwhile a dead type. No attribute in the specification is typed matrix, nsi.hpp defines no MatrixArg, and the only use in this book was an error. NSITypeMatrix can be retired, or matrix can be redefined as the 64-bit type at no cost.

What a mismatch should do

The renderer refuses a mismatch today, in both directions, and keeps the previous value. Three contracts are available:

ContractA caller mustAn arbitrary float against double choice is
Reject, as todaymatch the declared width exactlya trap that costs a dropped attribute
Convertsupply any width of the right kindinvisible, and harmless
Normalisesupply any widthabsent, because the API declares one width per kind

Rejection is the strictest and the most predictable. It also means every arbitrary choice in the table above is a defect that an exporter author has to discover. The Python binding makes this concrete: it types a bare Python float as NSITypeDouble, so every float attribute silently drops one.

Conversion removes the trap, at the cost of hiding a real precision loss when a caller sends 64-bit data to a 32-bit attribute. A warning can cover that case.

Normalisation is the most radical. It says the API declares one width per kind, and the renderer stores whatever it likes internally. It removes the width axis from the vocabulary question entirely, and it would retire double, int64, and doublematrix as names. It cannot remove width from NSIParam_t, because that field describes the layout of caller-owned memory.

Questions

  1. Is there a demonstrated need for a wide point, normal, or color? Precedent says no.
  2. Should NSITypeMatrix be retired, or matrix redefined as 64-bit?
  3. Should the float against double choices in the current specification be audited and normalised, independently of any renaming?
  4. Would conversion with a warning be preferable to the current silent drop?

Type Rename: Role, Components, Width

This draft proposes one naming scheme for every ɴsɪ type. A name has three parts, always in the same order:

  1. The role: what the value is, in mathematical terms. Integer, Real, Color, Point, Vector, Normal, Matrix, String, Pointer.
  2. The component count: how many scalars make one value. A matrix gives its size, 4 for 4×4. A scalar number gives none.
  3. The machine type of one scalar: F32, F64, I32 or I64.

The scheme is the one the Rust binding already uses for its attribute types, such as Color3F32, Point4F32 and Matrix4F64. It is a further option for Decision 2: Vocabulary. It rules on nothing.

The mapping

Constant todayStream name todayProposed
NSITypeFloatfloatRealF32
NSITypeDoubledoubleRealF64
NSITypeIntegerintIntegerI32
NSITypeInt64int64IntegerI64
NSITypeColorcolorColor3F32
NSITypePointpointPoint3F32
NSITypeHPointhpointPoint4F32
NSITypeVectorvectorVector3F32
NSITypeNormalnormalNormal3F32
NSITypeMatrixmatrixMatrix4F32
NSITypeDoubleMatrixdoublematrixMatrix4F64
NSITypeStringstringString
NSITypePointerpointerPointer

An array keeps its length as it does today: IntegerI32[2] for the current int[2].

The role and the machine type look redundant for scalars – a Real is always stored as F32 or F64 – but they answer different questions. The role says what the value means, as Color or Normal does. The suffix says how it is stored. Every name has both parts, so no name is an exception.

What the scheme makes possible

The same three parts name types that ɴsɪ does not have yet, without a new rule for each:

  • Color4F32: a color with alpha.
  • Point2F32: a point in a 2D space. Compare the parametric-point role that Geometry in the Type System reserves for this.
  • Point3F64, Vector3F64, Normal3F64: double-precision geometry, one of the cases Decision 3 weighs.
  • Matrix3F32: a 3×3 matrix.

For

  • Every name states all three facts. Today the width is in the name for double and int64, in a prefix for doublematrix, and absent everywhere else. The component count is never in the name: color is 3 floats because the specification says so.
  • One rule, no exceptions. A reader who knows one name can derive all the others.
  • The Rust binding already uses it. A name read in the specification is the name written in Rust.
  • Width is visible where it matters. A RealF32 sent to an attribute that expects RealF64 is a visible mismatch in the name. Today it is float against double, which many readers take as synonyms. See What the renderer does today.

Against

  • Point4F32 states the size, not the behavior. Geometry in the Type System rejected point-4d for this reason: the name does not say that the four components are homogeneous and transform as one unit. hpoint says it; weighted-point says it. Under this scheme the behavior is in the documentation, not in the name. A way out is a role for it, such as HPoint4F32, at the cost of one exception to “the role is what the data is”.
  • The case does not match the rest of the API. Attribute names are lowercase with hyphens under the naming convention. CamelCase type names in a stream, such as "Color3F32", would be the only mixed-case tokens in it. The lowercase form, "color3f32", is harder to read.
  • Integer and Real are new vocabulary here. Every other API in the precedent table of Decision 2 calls these int and float. They are the mathematical names, as Color, Point and Normal already are.
  • Every existing stream and every binding changes. The stream parser would have to accept both vocabularies for as long as old files exist.

Questions to settle

  1. Does the role name the behavior (HPoint4F32) or only the kind of data (Point4F32)?
  2. Which case in streams and in the C constants: Color3F32 or color3f32, and NSITypeColor3F32?
  3. Is Pointer a role, given that it has no components and no width? The Rust binding calls it Reference.

Acknowledgements

Many thanks to John Haddon, Daniel Dresser, David Minor, Moritz Mœller and Gregory Ducatel for initiating the first discussions and encouraging us to design a new scene description API. Bo Zhou and Paolo Berto helped immensely with plug-in design which ultimately led to improvements in ɴsɪ (e.g. adoption of the screen node). Jordan Thistlewood opened the way for the first integration of ɴsɪ into a commercial plug-in. Stefan Habel did a thorough proofreading of the entire document and gave many suggestions.

The ɴsɪ logo was designed by Paolo Berto.

nsi.h

#ifndef __nsi_h
#define __nsi_h

#include <stddef.h>

#ifdef _WIN32
    #define DL_INTERFACE __declspec(dllimport)
#else
    #define DL_INTERFACE
#endif

#ifdef  __cplusplus
extern "C" {
#endif

typedef int NSIContext_t;
typedef const char* NSIHandle_t;

#define NSI_BAD_CONTEXT ((NSIContext_t)0)
#define NSI_SCENE_ROOT ".root"
#define NSI_SCENE_GLOBAL ".global"
#define NSI_ALL_NODES ".all"
#define NSI_ALL_ATTRIBUTES ".all"
#define NSI_VERSION 2

/* Type values for NSIParam_t.type */
enum NSIType_t
{
	NSITypeInvalid = 0,
	NSITypeFloat = 1,
	NSITypeDouble = NSITypeFloat | 0x10,
	NSITypeInteger = 2,
	NSITypeInt64 = NSITypeInteger | 0x10,
	NSITypeString = 3,
	NSITypeColor = 4,
	NSITypePoint = 5,
	NSITypeVector = 6,
	NSITypeNormal = 7,
	NSITypeMatrix = 8,
	NSITypeDoubleMatrix = NSITypeMatrix | 0x10,
	NSITypePointer = 9,
	NSITypeHPoint = 10
};

static inline
size_t NSITypeSizeOf(unsigned t)
{
	static const unsigned char sizes[] =
	{
		0, sizeof(float), sizeof(int), sizeof(char*),
		3*sizeof(float), 3*sizeof(float), 3*sizeof(float), 3*sizeof(float),
		16*sizeof(float), sizeof(void*), 4*sizeof(float), 0,
		0, 0, 0, 0,
		0, sizeof(double), 8, 0,
		0, 0, 0, 0,
		16*sizeof(double)
	};
	return t <= 24 ? sizes[t] : 0;
}

/* Flag values for NSIParam_t.flags */
enum
{
	NSIParamIsArray = 1,
	NSIParamPerFace = 2,
	NSIParamPerVertex = 4,
	NSIParamInterpolateLinear = 8
};

/* Structure for optional parameters. */
struct NSIParam_t
{
	const char *name;
	const void *data;
	int type;
	int arraylength;
	size_t count;
	int flags;
};

/* Values for second parameter of NSIRenderStopped_t */
enum NSIStoppingStatus
{
	NSIRenderCompleted = 0,
	NSIRenderAborted = 1,
	NSIRenderSynchronized = 2,
	NSIRenderRestarted = 3
};

/* Error levels for the error callback. */
enum NSIErrorLevel
{
	NSIErrMessage = 0,
	NSIErrInfo = 1,
	NSIErrWarning = 2,
	NSIErrError = 3
};

/* Error handler callback type. */
typedef void (*NSIErrorHandler_t)(
	void *userdata, int level, int code, const char *message );

/* Stopped callback type. */
typedef void (*NSIRenderStopped_t)(
	void *userdata, NSIContext_t ctx, int status );

DL_INTERFACE NSIContext_t NSIBegin(
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSIEnd( NSIContext_t ctx );

DL_INTERFACE void NSICreate(
	NSIContext_t ctx,
	NSIHandle_t handle,
	const char *type,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSIDelete(
	NSIContext_t ctx,
	NSIHandle_t handle,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSISetAttribute(
	NSIContext_t ctx,
	NSIHandle_t object,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSISetAttributeAtTime(
	NSIContext_t ctx,
	NSIHandle_t object,
	double time,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSIDeleteAttribute(
	NSIContext_t ctx,
	NSIHandle_t object,
	const char *name );

DL_INTERFACE void NSIConnect(
	NSIContext_t ctx,
	NSIHandle_t from,
	const char *from_attr,
	NSIHandle_t to,
	const char *to_attr,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSIDisconnect(
	NSIContext_t ctx,
	NSIHandle_t from,
	const char *from_attr,
	NSIHandle_t to,
	const char *to_attr );

DL_INTERFACE void NSIEvaluate(
	NSIContext_t ctx,
	int nparams,
	const struct NSIParam_t *params );

DL_INTERFACE void NSIRenderControl(
	NSIContext_t ctx,
	int nparams,
	const struct NSIParam_t *params );

#ifdef __cplusplus
}
#endif

#endif

nsi.hpp

#ifndef __nsi_hpp
#define __nsi_hpp

#include "nsi.h"

#include <cstdlib>
#include <cstring>
#include <vector>
#include <string>

namespace NSI
{

/* Interface which provides the C API. */
class CAPI
{
public:
	virtual ~CAPI() {}

	virtual NSIContext_t NSIBegin(
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSIEnd(
		NSIContext_t ctx ) const = 0;

	virtual void NSICreate(
		NSIContext_t ctx,
		NSIHandle_t handle,
		const char *type,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSIDelete(
		NSIContext_t ctx,
		NSIHandle_t handle,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSISetAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSISetAttributeAtTime(
		NSIContext_t ctx,
		NSIHandle_t object,
		double time,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSIDeleteAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		const char *name ) const = 0;

	virtual void NSIConnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSIDisconnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr ) const = 0;

	virtual void NSIEvaluate(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const = 0;

	virtual void NSIRenderControl(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const = 0;
};

/* Default API provider, used when linking directly with the renderer. */
class LinkedAPI : public CAPI
{
public:
	static CAPI& Instance() { static LinkedAPI api; return api; }

	virtual NSIContext_t NSIBegin(
		int nparams,
		const NSIParam_t *params ) const
	{
		return ::NSIBegin( nparams, params );
	}

	virtual void NSIEnd(
		NSIContext_t ctx ) const
	{
		::NSIEnd( ctx );
	}

	virtual void NSICreate(
		NSIContext_t ctx,
		NSIHandle_t handle,
		const char *type,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSICreate( ctx, handle, type, nparams, params );
	}

	virtual void NSIDelete(
		NSIContext_t ctx,
		NSIHandle_t handle,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSIDelete( ctx, handle, nparams, params );
	}

	virtual void NSISetAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSISetAttribute( ctx, object, nparams, params );
	}

	virtual void NSISetAttributeAtTime(
		NSIContext_t ctx,
		NSIHandle_t object,
		double time,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSISetAttributeAtTime( ctx, object, time, nparams, params );
	}

	virtual void NSIDeleteAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		const char *name ) const
	{
		::NSIDeleteAttribute( ctx, object, name );
	}

	virtual void NSIConnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSIConnect( ctx, from, from_attr, to, to_attr, nparams, params );
	}

	virtual void NSIDisconnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr ) const
	{
		::NSIDisconnect( ctx, from, from_attr, to, to_attr );
	}

	virtual void NSIEvaluate(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSIEvaluate( ctx, nparams, params );
	}

	virtual void NSIRenderControl(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const
	{
		::NSIRenderControl( ctx, nparams, params );
	}
};

class ArgBase;

/*
	StaticArgumentListProxy is a linked list of ArgBase references which is
	eventually flattened into an array of NSIParam_t.

	What it allows is convenient syntax to build a list of static length
	inline:

	ctx.SetAttribute( "handle",
		(
			NSI::IntegerArg( "arg1", 1 ),
			NSI::StringArg( "arg2", "2" )
		) );
*/
template<unsigned N>
class StaticArgumentListProxy
{
public:
	StaticArgumentListProxy(
		const ArgBase &a,
		const StaticArgumentListProxy<N-1> &prev )
	:
		m_arg( a ), m_prev( prev )
	{
	}

	/* Write the list to a contiguous NSIParam_t array, from the end. */
	inline void FlattenList( NSIParam_t *p ) const;

	/* This appends an argument to an existing list. */
	StaticArgumentListProxy<N+1> operator,( const ArgBase &a ) const
	{
		return StaticArgumentListProxy<N+1>( a, *this );
	}

private:
	const ArgBase &m_arg;
	const StaticArgumentListProxy<N-1> &m_prev;
};


/*
	This template specialization exists so the object can be built directly
	from ArgBase's operator, without using any temporaries.

	It also ends the recursive list. The the reason above is why it does not
	end with a <0> specialization which would have been much simpler.
*/
template<>
class StaticArgumentListProxy<2>
{
public:
	StaticArgumentListProxy(
		const ArgBase &a0,
		const ArgBase &a1 )
	:
		m_arg0( a0 ), m_arg1( a1 )
	{
	}

	/* Write the list to a contiguous NSIParam_t array, from the end. */
	inline void FlattenList( NSIParam_t *p ) const;

	/* This appends an argument to an existing list. */
	StaticArgumentListProxy<3> operator,( const ArgBase &a ) const
	{
		return StaticArgumentListProxy<3>( a, *this );
	}

private:
	const ArgBase &m_arg0;
	const ArgBase &m_arg1;
};


class ArgBase
{
	/* Arguments are not meant to be copied around. */
	ArgBase( const ArgBase& );
	void operator=( const ArgBase& );

public:
	/*
		It is a convention that argument names provided as C strings are not
		copied (caller must keep the string valid) while the names provided as
		C++ string are copied. This should fit with the general behavior of C
		vs C++ strings.
	*/
	ArgBase( const char *name )
	:
		m_name( name ), m_name_buf( 0 )
	{
	}

	ArgBase( const std::string &name )
	:
		m_name_buf( new char[name.size() + 1u] )
	{
		m_name = m_name_buf;
		m_name_buf[ name.copy( m_name_buf, std::string::npos ) ] = 0;
	}

	virtual ~ArgBase()
	{
		if( m_name_buf )
			delete[] m_name_buf;
	}

	/* This starts building a list from (arg, arg). */
	StaticArgumentListProxy<2> operator,( const ArgBase &arg )
	{
		return StaticArgumentListProxy<2>( *this, arg );
	}

	virtual void FillNSIParam( NSIParam_t &p ) const = 0;

protected:
	const char *m_name;
	/* When we own the name string, this points to it. Otherwise 0. */
	char *m_name_buf;
};


template<unsigned N>
void StaticArgumentListProxy<N>::FlattenList( NSIParam_t *p ) const
{
	m_arg.FillNSIParam( *p );
	m_prev.FlattenList( p - 1 );
}

void StaticArgumentListProxy<2>::FlattenList( NSIParam_t *p ) const
{
	m_arg1.FillNSIParam( p[0] );
	m_arg0.FillNSIParam( p[-1] );
}


/*
	Generic argument class to handle what is not easily done with the more
	specific classes.
*/
class Argument : public ArgBase
{
public:
	Argument( const char *name )
	:
		ArgBase( name ),
		m_data_buffer( 0 )
	{
		m_param.name = m_name;
		m_param.data = 0;
		m_param.type = NSITypeInvalid;
		m_param.arraylength = 0;
		m_param.count = 1;
		m_param.flags = 0;
	}

	Argument( const std::string &name )
	:
		ArgBase( name ),
		m_data_buffer( 0 )
	{
		m_param.name = m_name;
		m_param.data = 0;
		m_param.type = NSITypeInvalid;
		m_param.arraylength = 0;
		m_param.count = 1;
		m_param.flags = 0;
	}

	virtual ~Argument()
	{
		if( m_data_buffer )
			std::free( m_data_buffer );
	}

	/*
		Those two, along with the SetXX returning this, are to support adding
		arguments to an ArgumentList without using a variable to hold the
		pointer. eg.

		argument_list.Add(
			Argument::New( "attributename" )
			->SetType( NSITypeInteger )
			->SetCount( 4 )
			->CopyValue( att_value, 4*sizeof(int) ) );
	*/
	static Argument* New( const char *name )
		{ return new Argument( name ); }
	static Argument* New( const std::string &name )
		{ return new Argument( name ); }

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p = m_param;
	}

	Argument* SetType( NSIType_t type )
	{
		m_param.type = type;
		m_param.flags &= ~int(NSIParamIsArray);
		return this;
	}

	Argument* SetArrayType( NSIType_t type, size_t arraylength )
	{
		m_param.type = type;
		m_param.arraylength = int(arraylength);
		m_param.flags |= NSIParamIsArray;
		return this;
	}

	Argument* SetCount( size_t count ) { m_param.count = count; return this; }

	void* AllocValue( size_t value_size )
	{
		if( m_data_buffer )
			std::free( m_data_buffer );
		m_data_buffer = std::malloc( value_size );
		m_param.data = m_data_buffer;
		return m_data_buffer;
	}

	Argument* CopyValue( const void *value, size_t value_size )
	{
		std::memcpy( AllocValue( value_size ), value, value_size );
		return this;
	}

	Argument* SetValuePointer( const void *value )
	{
		m_param.data = value;
		return this;
	}

	Argument* SetFlags( int flags )
	{
		m_param.flags |= flags;
		return this;
	}

	Argument* ResetFlags( int flags )
	{
		m_param.flags &= ~flags;
		return this;
	}

private:
	NSIParam_t m_param;
	void *m_data_buffer;
};

class IntegerArg : public ArgBase
{
public:
	IntegerArg( const char *name, int v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	IntegerArg( const std::string &name, int v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypeInteger;
		p.count = 1;
		p.flags = 0;
	}

private:
	int m_v;
};

class FloatArg : public ArgBase
{
public:
	FloatArg( const char *name, float v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	FloatArg( const std::string &name, float v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypeFloat;
		p.count = 1;
		p.flags = 0;
	}

private:
	float m_v;
};

class DoubleArg : public ArgBase
{
public:
	DoubleArg( const char *name, double v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	DoubleArg( const std::string &name, double v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypeDouble;
		p.count = 1;
		p.flags = 0;
	}

private:
	double m_v;
};

template<int TYPE>
class F3Arg : public ArgBase
{
public:
	F3Arg( const char *name, const float *v )
	:
		ArgBase( name )
	{
		m_v[0] = v[0];
		m_v[1] = v[1];
		m_v[2] = v[2];
	}

	F3Arg( const std::string &name, const float *v )
	:
		ArgBase( name )
	{
		m_v[0] = v[0];
		m_v[1] = v[1];
		m_v[2] = v[2];
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v[0];
		p.type = TYPE;
		p.count = 1;
		p.flags = 0;
	}

private:
	float m_v[3];
};

typedef F3Arg<NSITypeColor> ColorArg;
typedef F3Arg<NSITypePoint> PointArg;
typedef F3Arg<NSITypeVector> VectorArg;
typedef F3Arg<NSITypeNormal> NormalArg;

class DoubleMatrixArg : public ArgBase
{
public:
	DoubleMatrixArg( const char *name, const double *v )
	:
		ArgBase( name )
	{
		for( int i=0; i<16; i++ )
			m_v[i] = v[i];
	}

	DoubleMatrixArg( const std::string &name, const double *v )
	:
		ArgBase( name )
	{
		for( int i=0; i<16; i++ )
			m_v[i] = v[i];
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v[0];
		p.type = NSITypeDoubleMatrix;
		p.count = 1;
		p.flags = 0;
	}

private:
	double m_v[16];
};

/*
	This does not make a copy of the given string. Use StringArg if that string
	is shorter lived than the argument list.
*/
class CStringPArg : public ArgBase
{
public:
	CStringPArg( const char *name, const char *v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	CStringPArg( const std::string &name, const char *v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypeString;
		p.count = 1;
		p.flags = 0;
	}

private:
	const char *m_v;
};

class StringArg : public ArgBase
{
public:
	StringArg( const char *name, const char *v )
	:
		ArgBase( name ), m_s( v )
	{
	}

	StringArg( const std::string &name, const char *v )
	:
		ArgBase( name ), m_s( v )
	{
	}

	StringArg( const char *name, const std::string &v )
	:
		ArgBase( name ), m_s( v )
	{
	}

	StringArg( const std::string &name, const std::string &v )
	:
		ArgBase( name ), m_s( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		m_v = m_s.c_str();
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypeString;
		p.count = 1;
		p.flags = 0;
	}

private:
	std::string m_s;
	mutable const char *m_v;
};


class PointerArg : public ArgBase
{
public:
	PointerArg( const char *name, const void *v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	PointerArg( const std::string &name, const void *v )
	:
		ArgBase( name ), m_v( v )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = &m_v;
		p.type = NSITypePointer;
		p.count = 1;
		p.flags = 0;
	}

private:
	const void *m_v;
};


/* This does not make a copy of the given integers array */
class IntegersArg : public ArgBase
{
public:
	IntegersArg( const char *name, const int *v, size_t count )
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	IntegersArg( const std::string &name, const int *v, size_t count)
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = m_v;
		p.type = NSITypeInteger;
		p.count = m_count;
		p.flags = 0;
	}

private:
	const int *m_v;
	size_t m_count;
};


/* This does not make a copy of the given points array */
class PointsArg : public ArgBase
{
public:
	PointsArg( const char *name, const float *v, size_t count )
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	PointsArg( const std::string &name, const float *v, size_t count)
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = m_v;
		p.type = NSITypePoint;
		p.count = m_count;
		p.flags = 0;
	}

private:
	const float *m_v;
	size_t m_count;
};


/* This does not make a copy of the given normals array */
class NormalsArg : public ArgBase
{
public:
	NormalsArg( const char *name, const float *v, size_t count )
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	NormalsArg( const std::string &name, const float *v, size_t count)
	:
		ArgBase( name ), m_v( v ), m_count( count )
	{
	}

	virtual void FillNSIParam( NSIParam_t &p ) const
	{
		p.name = m_name;
		p.data = m_v;
		p.type = NSITypeNormal;
		p.count = m_count;
		p.flags = 0;
	}

private:
	const float *m_v;
	size_t m_count;
};


class ArgumentList
{
	ArgumentList( const ArgumentList& );
	void operator=( const ArgumentList& );
public:
	ArgumentList() {}

	~ArgumentList() { clear(); }

	void clear()
	{
		while( !m_args.empty() )
		{
			delete m_args.back();
			m_args.pop_back();
		}
	}

	bool empty() const { return m_args.empty(); }

	size_t size() const { return m_args.size(); }
	const ArgBase* operator[]( size_t i ) const { return m_args[i]; }

	void Add( ArgBase *arg )
	{
		m_args.push_back( arg );
	}

	void push( ArgBase *arg )
	{
		m_args.push_back( arg );
	}

	void push_back( ArgBase *arg )
	{
		m_args.push_back( arg );
	}


private:
	std::vector<ArgBase*> m_args;
};
typedef ArgumentList DynamicArgumentList; /* backward compatibility */




class Context
{
	/*
		Don't allow copying because ownership semantics get really messy. If
		you really know what you're doing, use the Handle() method and build
		another context from it.
	*/
	Context( const Context& );
	void operator=( const Context& );

private:
	class FlatArgumentList
	{
		void operator=( const FlatArgumentList& );
#if __cplusplus < 201103L
	/* Stupid rule with old C++ requires this to be visible. */
	public:
#endif
		FlatArgumentList( const FlatArgumentList& );

	public:
		/* Empty list. */
		FlatArgumentList()
		:
			m_nsi_params( 0 ),
			m_size_nsi_params( 0 )
		{
		}

		/* From dynamically built argument list. */
		FlatArgumentList( const ArgumentList &arglist )
		{
			m_size_nsi_params = arglist.size();
			m_nsi_params = new NSIParam_t[ m_size_nsi_params ];
			for( unsigned i = 0; i < m_size_nsi_params; ++i )
			{
				arglist[i]->FillNSIParam( m_nsi_params[i] );
			}
		}

		/* From a static argument list. */
		template<unsigned N>
		FlatArgumentList( const StaticArgumentListProxy<N> &arglist )
		{
			m_size_nsi_params = N;
			m_nsi_params = new NSIParam_t[ m_size_nsi_params ];
			arglist.FlattenList( m_nsi_params + N - 1u );
		}

		/* From a single argument. */
		FlatArgumentList( const ArgBase &arg )
		{
			m_size_nsi_params = 1;
			m_nsi_params = new NSIParam_t[ m_size_nsi_params ];
			arg.FillNSIParam( m_nsi_params[0] );
		}

		~FlatArgumentList()
		{
			delete[] m_nsi_params;
		}

		int size() const { return int(m_size_nsi_params); }
		const NSIParam_t* list() const { return m_nsi_params; }

	private:
		mutable NSIParam_t *m_nsi_params;
		mutable size_t m_size_nsi_params;
	};

public:
	/* Deprecated. */
	explicit Context( NSIContext_t ctx )
	:
		m_ctx( ctx ),
		m_owns_context( false ),
		m_api( LinkedAPI::Instance() )
	{
	}

	Context( const CAPI &api = LinkedAPI::Instance() )
	:
		m_ctx( NSI_BAD_CONTEXT ),
		m_owns_context( false ),
		m_api( api )
	{
	}

	/* Destroys the context, if owned by this object. */
	~Context()
	{
		if( m_owns_context )
			End();
	}

	/*
		Use an existing C API handle. The context will not be destroyed with
		this object but End() may be called explicitly to destroy it.
	*/
	void SetHandle( NSIContext_t ctx )
	{
		if( m_owns_context && m_ctx != NSI_BAD_CONTEXT )
			End();

		m_ctx = ctx;
		m_owns_context = false;
	}

	/* Retrieve the C API handle. */
	NSIContext_t Handle() const { return m_ctx; }

	/*
		Make this object no longer own the C API handle. Meaning the destructor
		will not End() it.
	*/
	void Detach() { m_owns_context = false; }

	/* Create a new context. */
	void Begin( const FlatArgumentList &params = FlatArgumentList() )
	{
		if( m_owns_context && m_ctx != NSI_BAD_CONTEXT )
			End();

		m_ctx = m_api.NSIBegin( params.size(), params.list() );
		m_owns_context = true;
	}

	/* Destroy the context. */
	void End()
	{
		m_api.NSIEnd( m_ctx );
		m_ctx = NSI_BAD_CONTEXT;
		m_owns_context = false;
	}

	void Create(
		const std::string &handle,
		const std::string &type,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSICreate(
			m_ctx,
			handle.c_str(),
			type.c_str(),
			params.size(), params.list() );
	}

	void Delete(
		const std::string &handle,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSIDelete(
			m_ctx,
			handle.c_str(),
			params.size(), params.list() );
	}

	void SetAttribute(
		const std::string &object,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSISetAttribute(
			m_ctx,
			object.c_str(),
			params.size(), params.list() );
	}

	void SetAttributeAtTime(
		const std::string &object,
		double time,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSISetAttributeAtTime(
			m_ctx,
			object.c_str(),
			time,
			params.size(), params.list() );
	}

	void DeleteAttribute(
		const std::string &object,
		const std::string &name )
	{
		m_api.NSIDeleteAttribute(
			m_ctx,
			object.c_str(),
			name.c_str() );
	}

	void Connect(
		const std::string &from,
		const std::string &from_attr,
		const std::string &to,
		const std::string &to_attr,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSIConnect(
			m_ctx,
			from.c_str(),
			from_attr.c_str(),
			to.c_str(),
			to_attr.c_str(),
			params.size(), params.list() );
	}

	void Disconnect(
		const std::string &from,
		const std::string &from_attr,
		const std::string &to,
		const std::string &to_attr,
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSIDisconnect(
			m_ctx,
			from.c_str(),
			from_attr.c_str(),
			to.c_str(),
			to_attr.c_str() );
	}

	void Evaluate(
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSIEvaluate(
			m_ctx,
			params.size(), params.list() );
	}

	void RenderControl(
		const FlatArgumentList &params = FlatArgumentList() )
	{
		m_api.NSIRenderControl(
			m_ctx,
			params.size(), params.list() );
	}

private:
	NSIContext_t m_ctx;
	bool m_owns_context;
	const CAPI &m_api;
};

};

#endif

nsi_dynamic.hpp

/*
	This file contains the code required to load NSI at runtime instead of
	linking with it at build time. To use, simply give an instance to the
	context constructor:

	NSI::DynamicAPI api;
	NSI::Context ctx( api );

	ctx.Begin();
	ctx.Create( "myhandle", "mesh" );
	...

	The DynamicAPI class loads and unloads the library so at least one instance
	must be kept active while the renderer is in use.
*/

#ifndef __nsi_dynamic_hpp
#define __nsi_dynamic_hpp

#include "nsi.hpp"

#if defined(__linux__) || defined(__APPLE__)
#	include <dlfcn.h>
#elif defined(_WIN32)
#	include <windows.h>
#endif

#include <algorithm>
#include <cstdlib>
#include <string>

namespace NSI
{

/* API provider which dynamically loads the renderer. */
class DynamicAPI : public CAPI
{
#if defined(__linux__) || defined(__APPLE__)
	void *m_lib;

public:
	template<typename T>
	void LoadFunction( T &function, const char *name )
	{
		if( m_lib )
			function = (T) dlsym( m_lib, name );
		else
			function = (T)0;
	}
#elif defined(_WIN32)
	HMODULE m_lib;

public:
	template<typename T>
	void LoadFunction( T &function, const char *name )
	{
		if( m_lib )
			function = (T) GetProcAddress( m_lib, name );
		else
			function = (T)0;
	}
#else
public:
	template<typename T>
	void LoadFunction( T &function, const char *name )
	{
		function = (T)0;
	}
#endif

public:
	DynamicAPI(const char* path = 0)
	{
#if defined(__linux__)
		m_lib = dlopen( path ? path : "lib3delight.so", RTLD_NOW );
#elif defined(__APPLE__)
		if( path )
		{
			m_lib = dlopen( path, RTLD_NOW );
		}
		else
		{
			m_lib = dlopen( "/Applications/3Delight/lib/lib3delight.dylib", RTLD_NOW );
			if( !m_lib )
			{
				m_lib = dlopen( "lib3delight.dylib", RTLD_NOW );
			}
			if( !m_lib )
			{
				/* Last resort, try DELIGHT. */
				const char *delight = getenv("DELIGHT");
				if( delight && delight[0] )
				{
					std::string dl = delight;
					if( dl.back() != '/' )
						dl.push_back('/');
					dl.append("lib/lib3delight.dylib");
					m_lib = dlopen(dl.c_str(), RTLD_NOW);
				}
			}
		}
#elif defined(_WIN32)
		m_lib = LoadLibraryA( path ? path : "3Delight.dll" );
		if( m_lib == NULL && !path )
		{
			/* PATH search might have been disabled by
			   SetDefaultDllDirectories(). Try with DELIGHT. */
			const char *delight = getenv("DELIGHT");
			if( delight && delight[0] )
			{
				std::string dl = delight;
				std::replace(dl.begin(), dl.end(), '/', '\\');
				if( dl.back() != '\\' )
					dl.push_back('\\');
				dl.append("bin\\3Delight.dll");
				m_lib = LoadLibraryA(dl.c_str());
			}
		}
#endif
		LoadFunction( Begin, "NSIBegin" );
		LoadFunction( End, "NSIEnd" );
		LoadFunction( Create, "NSICreate" );
		LoadFunction( Delete, "NSIDelete" );
		LoadFunction( SetAttribute, "NSISetAttribute" );
		LoadFunction( SetAttributeAtTime, "NSISetAttributeAtTime" );
		LoadFunction( DeleteAttribute, "NSIDeleteAttribute" );
		LoadFunction( Connect, "NSIConnect" );
		LoadFunction( Disconnect, "NSIDisconnect" );
		LoadFunction( Evaluate, "NSIEvaluate" );
		LoadFunction( RenderControl, "NSIRenderControl" );
	}

	virtual ~DynamicAPI()
	{
#if defined(__linux__) || defined(__APPLE__)
		if( m_lib != 0 )
			dlclose( m_lib );
#elif defined(_WIN32)
		if( m_lib != 0 )
			FreeLibrary( m_lib );
#endif
	}

	virtual NSIContext_t NSIBegin(
		int nparams,
		const NSIParam_t *params ) const
	{
		if( Begin )
			return Begin( nparams, params );
		else
			return NSI_BAD_CONTEXT;
	}

	virtual void NSIEnd(
		NSIContext_t ctx ) const
	{
		if( End )
			End( ctx );
	}

	virtual void NSICreate(
		NSIContext_t ctx,
		NSIHandle_t handle,
		const char *type,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( Create )
			Create( ctx, handle, type, nparams, params );
	}

	virtual void NSIDelete(
		NSIContext_t ctx,
		NSIHandle_t handle,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( Delete )
			Delete( ctx, handle, nparams, params );
	}

	virtual void NSISetAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( SetAttribute )
			SetAttribute( ctx, object, nparams, params );
	}

	virtual void NSISetAttributeAtTime(
		NSIContext_t ctx,
		NSIHandle_t object,
		double time,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( SetAttributeAtTime )
			SetAttributeAtTime( ctx, object, time, nparams, params );
	}

	virtual void NSIDeleteAttribute(
		NSIContext_t ctx,
		NSIHandle_t object,
		const char *name ) const
	{
		if( DeleteAttribute )
			DeleteAttribute( ctx, object, name );
	}

	virtual void NSIConnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( Connect )
			Connect( ctx, from, from_attr, to, to_attr, nparams, params );
	}

	virtual void NSIDisconnect(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr ) const
	{
		if( Disconnect )
			Disconnect( ctx, from, from_attr, to, to_attr );
	}

	virtual void NSIEvaluate(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( Evaluate )
			Evaluate( ctx, nparams, params );
	}

	virtual void NSIRenderControl(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params ) const
	{
		if( RenderControl )
			RenderControl( ctx, nparams, params );
	}

private:
	/* API function pointers. */
	NSIContext_t (*Begin)(
		int nparams,
		const NSIParam_t *params );

	void (*End)(
		NSIContext_t ctx );

	void (*Create)(
		NSIContext_t ctx,
		NSIHandle_t handle,
		const char *type,
		int nparams,
		const NSIParam_t *params );

	void (*Delete)(
		NSIContext_t ctx,
		NSIHandle_t handle,
		int nparams,
		const NSIParam_t *params );

	void (*SetAttribute)(
		NSIContext_t ctx,
		NSIHandle_t object,
		int nparams,
		const NSIParam_t *params );

	void (*SetAttributeAtTime)(
		NSIContext_t ctx,
		NSIHandle_t object,
		double time,
		int nparams,
		const NSIParam_t *params );

	void (*DeleteAttribute)(
		NSIContext_t ctx,
		NSIHandle_t object,
		const char *name );

	void (*Connect)(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr,
		int nparams,
		const NSIParam_t *params );

	void (*Disconnect)(
		NSIContext_t ctx,
		NSIHandle_t from,
		const char *from_attr,
		NSIHandle_t to,
		const char *to_attr );

	void (*Evaluate)(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params );

	void (*RenderControl)(
		NSIContext_t ctx,
		int nparams,
		const NSIParam_t *params );
};

}

#endif

nsi.py

"""
Python binding to 3Delight's Nodal Scene Interface
"""

BAD_CONTEXT = 0

SCENE_ROOT = '.root'
SCENE_GLOBAL = '.global'
ALL_NODES = '.all'

import ctypes
import os
import platform

# Load 3Delight
if platform.system() == "Windows":
    _lib3delight = ctypes.cdll.LoadLibrary('3Delight')
elif platform.system() == "Darwin":
    __delight = os.getenv('DELIGHT')
    if __delight is None:
        __delight = '/Applications/3Delight'
    _lib3delight = ctypes.cdll.LoadLibrary(__delight + '/lib/lib3delight.dylib')
else:
    _lib3delight = ctypes.cdll.LoadLibrary('lib3delight.so')


class _NSIParam_t(ctypes.Structure):
    """
    Python version of the NSIParam_t struct to interface with the C API.
    """
    _fields_ = [
        ("name", ctypes.c_char_p),
        ("data", ctypes.c_void_p),
        ("type", ctypes.c_int),
        ("arraylength", ctypes.c_int),
        ("count", ctypes.c_size_t),
        ("flags", ctypes.c_int)
        ]

class Type:
    """
    Python version of the C NSIType_t enum
    """
    Invalid = 0
    Float = 1
    Double = Float | 0x10
    Integer = 2
    String = 3
    Color = 4
    Point = 5
    Vector = 6
    Normal = 7
    Matrix = 8
    DoubleMatrix = Matrix | 0x10
    Pointer = 9

_nsi_type_num_elements = {
    Type.Float : 1,
    Type.Double : 1,
    Type.Integer : 1,
    Type.String : 1,
    Type.Color : 3,
    Type.Point : 3,
    Type.Vector : 3,
    Type.Normal : 3,
    Type.Matrix : 16,
    Type.DoubleMatrix : 16,
    Type.Pointer : 1 }

class Flags:
    """
    Python version of the NSIParam_t flags values
    """
    IsArray = 1
    PerFace = 2
    PerVertex = 4
    InterpolateLinear = 8

def _GetArgNSIType(value):
    if isinstance(value, Arg):
        if value.type is not None:
            return value.type
        else:
            return _GetArgNSIType(value.value)
    if isinstance(value, (tuple, list)):
        return _GetArgNSIType(value[0])
    if isinstance(value, (int, bool)):
        return Type.Integer
    if isinstance(value, float):
        return Type.Double
    if isinstance(value, str):
        return Type.String
    return Type.Invalid

def _GetArgCType(value):
    nsitype = _GetArgNSIType(value)
    typemap = {
        Type.Float : ctypes.c_float,
        Type.Double : ctypes.c_double,
        Type.Integer : ctypes.c_int,
        Type.String : ctypes.c_char_p,
        Type.Color : ctypes.c_float,
        Type.Point : ctypes.c_float,
        Type.Vector : ctypes.c_float,
        Type.Normal : ctypes.c_float,
        Type.Matrix : ctypes.c_float,
        Type.DoubleMatrix : ctypes.c_double,
        Type.Pointer : ctypes.c_void_p
    }
    return typemap.get(nsitype)

def _BuildOneCArg(nsiparam, value):
    """
    Fill one _NSIParam_t object from an argument value.
    """
    # TODO: Support numpy.matrix as DoubleMatrix argument.
    datatype = _GetArgCType(value)
    arraylength = None
    countoverride = None
    flags = 0
    v = value
    if isinstance(value, Arg):
        v = value.value
        arraylength = value.arraylength
        countoverride = value.count
        flags = value.flags

    if v is None:
        nsiparam.type = Type.Invalid
        return

    if isinstance(v, ctypes.c_void_p):
        # Raw data given with ctypes. Must use nsi.Arg. No safety here.
        datacount = 0 # Will be overriden by countoverride.
        nsiparam.data = v
    elif isinstance(v, (tuple, list)):
        # Data is multiple values (eg. a list of floats).
        datacount = len(v)
        arraytype = datatype * datacount;
        if v and isinstance(v[0], str):
            # Encode all the strings to utf-8
            fixedv = [x.encode('utf-8') for x in v]
            nsiparam.data = ctypes.cast(ctypes.pointer(
                arraytype(*fixedv)), ctypes.c_void_p)
        else:
            nsiparam.data = ctypes.cast(ctypes.pointer(
                arraytype(*v)), ctypes.c_void_p)
    else:
        # Data is a single object (string, float, int).
        datacount = 1
        if isinstance(v, str):
            nsiparam.data = ctypes.cast(ctypes.pointer(
                datatype(v.encode('utf-8'))), ctypes.c_void_p)
        else:
            nsiparam.data = ctypes.cast(ctypes.pointer(
                datatype(v)), ctypes.c_void_p)

    valuecount = datacount

    nsitype = _GetArgNSIType(value)
    numelements = _nsi_type_num_elements.get(nsitype, 0)
    if numelements == 0:
        valuecount = 0
    else:
        valuecount = int(valuecount / numelements)

    if arraylength is not None:
        nsiparam.arraylength = arraylength
        flags |= Flags.IsArray
        if arraylength == 0:
            valuecount = 0
        else:
            valuecount = int(valuecount / arraylength)

    if countoverride is not None:
        if countoverride < valuecount or isinstance(v, ctypes.c_void_p):
            valuecount = countoverride

    nsiparam.type = nsitype
    nsiparam.count = valuecount
    nsiparam.flags = flags

def _BuildCArgumentList(args):
    cargs_type = _NSIParam_t * len(args)
    cargs = cargs_type()
    for i, arg in enumerate(args.items()):
        cargs[i].name = arg[0].encode('utf-8')
        _BuildOneCArg(cargs[i], arg[1])
    return cargs

class Context:
    """
    A NSI context.

    All NSI operations are done in a specific context. Multiple contexts may
    cohexist.

    Most methods of the Context accept a named argument list. The argument
    values can be native python integer, string or float (which is given to NSI
    as a double). More complex types should use one of the Arg classes in this
    module.
    """

    def __init__(self, handle=None):
        """
        If an integer handle argument is provided, this object will be bound to
        an existing NSI context with that handle.

        It is not required that the context have been created by the python
        binding.
        """
        self._handle = BAD_CONTEXT

    def Begin(self, **arglist):
        """
        Create a new NSI context and bind this object to it.
        """
        a = _BuildCArgumentList(arglist)
        self._handle = _lib3delight.NSIBegin(len(a), a)

    def End(self):
        """
        Release the context.

        If this object was bound to an external handle, that handle will on
        longer be valid after this call.
        """
        # TODO: Support with statement
        if self._handle != BAD_CONTEXT:
            _lib3delight.NSIEnd( self._handle )

    def Create(self, handle, type, **arglist):
        """
        Create a new node.

        Parameters
        handle : The handle of the node to create.
        type : The type of node to create.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSICreate(
            self._handle,
            handle.encode('utf-8'),
            type.encode('utf-8'),
            len(a), a)

    def Delete(self, handle, **arglist):
        """
        Delete a node.

        Parameters
        handle : The handle of the node to delete.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSIDelete(
            self._handle,
            handle.encode('utf-8'),
            len(a), a)

    def SetAttribute(self, handle, **arglist):
        """
        Set attributes of a node.

        Parameters
        handle : The handle of the node on which to set attributes.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSISetAttribute(
            self._handle,
            handle.encode('utf-8'),
            len(a), a)

    def SetAttributeAtTime(self, handle, time, **arglist):
        """
        Set attributes of a node for a specific time.

        Parameters
        handle : The handle of the node on which to set attributes.
        time : The time for which the attributes are set.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSISetAttributeAtTime(
            self._handle,
            handle.encode('utf-8'),
            ctypes.c_double(time),
            len(a), a)

    def DeleteAttribute(self, handle, name):
        """
        Delete an attribute of a node.

        Parameters
        handle : The handle of the node on which to delete an attribute.
        name : The name of the attribute to delete.
        """
        _lib3delight.NSIDeleteAttribute(
            self._handle,
            handle.encode('utf-8'),
            name.encode('utf-8'))

    def Connect(self, from_handle, from_attr, to_handle, to_attr, **arglist):
        """
        Connect nodes or specific attributes of nodes.

        Parameters
        from_handle : The handle of the node to connect from.
        from_attr : Optional attribute to connect from.
        to_handle : The handle of the node to connect to.
        to_attr : Optional attribute to connect to.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSIConnect(
            self._handle,
            from_handle.encode('utf-8'),
            (from_attr if from_attr else '').encode('utf-8'),
            to_handle.encode('utf-8'),
            (to_attr if to_attr else '').encode('utf-8'),
            len(a), a)

    def Disconnect(self, from_handle, from_attr, to_handle, to_attr, **arglist):
        """
        Disconnect nodes or specific attributes of nodes.

        Parameters
        from_handle : The handle of the node to disconnect from.
        from_attr : Optional attribute to disconnect from.
        to_handle : The handle of the node to disconnect to.
        to_attr : Optional attribute to disconnect to.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSIDisconnect(
            self._handle,
            from_handle.encode('utf-8'),
            (from_attr if from_attr else '').encode('utf-8'),
            to_handle.encode('utf-8'),
            (to_attr if to_attr else '').encode('utf-8'))

    def Evaluate(self, **arglist):
        """
        Evaluate NSI commands from some other source.

        This can read other files, run scripts, etc.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSIEvaluate(
            self._handle,
            len(a), a)

    def RenderControl(self, **arglist):
        """
        Control rendering.

        This is used to start and stop renders, wait for them to complete, etc.
        """
        a = _BuildCArgumentList(arglist)
        _lib3delight.NSIRenderControl(
            self._handle,
            len(a), a)

class Arg:
    """
    Wrapper for NSI parameter list values.

    NSI functions which accept a parameter list may be given values wrapped in
    an Arg object to specify details about the argument. For example, 3 float
    values are normally output as 3 NSITypeDouble values. To set a color
    instead, give nsi.Arg((0.4, 0.2, 0.5), type=nsi.Type.Color)

    The most common types have specific wrappers which are easier to use. The
    above example could instead be nsi.ColorArg(0.4, 0.2, 0.5)
    """
    def __init__(self, v, type=None, arraylength=None, flags=None, count=None):
        """
        Parameters
        v : The value.
        type : An optional value from nsi.Type
        arraylength : An optional integer to specify the array length of the
        base type. For example, 2 for texture coordinates.
        flags : Optional flags from nsi.Flags
        count : Number of values of the base type.
        """
        self.type = None
        self.arraylength = None
        self.flags = 0
        self.count = None

        if isinstance(v, Arg):
            # Fold its attributes into this object. This allows chaining Arg
            # objects.
            self.value = v.value
            self.type = v.type
            self.flags = v.flags
            self.count = v.count
        else:
            self.value = v

        if arraylength is not None:
            self.arraylength = arraylength
        if type is not None:
            self.type = type
        if flags is not None:
            self.flags |= flags
        if count is not None:
            self.count = count

class IntegerArg(Arg):
    """
    Wrapper for NSI parameter list integer value.

    Use as nsi.IntegerArg(2). This is generally not needed as it is the default
    behavior when an int is given. Using this class will enforce the type.
    """
    def __init__(self, v):
        Arg.__init__(self, int(v), type=Type.Integer)

class FloatArg(Arg):
    """
    Wrapper for NSI parameter list float value.

    Use as nsi.FloatArg(0.5).
    """
    def __init__(self, v):
        Arg.__init__(self, v, type=Type.Float)

class DoubleArg(Arg):
    """
    Wrapper for NSI parameter list double value.

    Use as nsi.DoubleArg(0.5).
    """
    def __init__(self, v):
        Arg.__init__(self, v, type=Type.Double)

class ColorArg(Arg):
    """
    Wrapper for NSI parameter list color value.

    Use as nsi.ColorArg(0.2, 0.3, 0.4) or nsi.ColorArg(0.5)
    """
    def __init__(self, r, g=None, b=None):
        if b is None:
            Arg.__init__(self, (r,r,r), type=Type.Color)
        else:
            Arg.__init__(self, (r,g,b), type=Type.Color)

# vim: set softtabstop=4 expandtab shiftwidth=4:

nsi_procedural.h

#ifndef __nsi_procedural_h
#define __nsi_procedural_h

#include "nsi.h"

#ifdef  __cplusplus
extern "C" {
#endif

struct NSIProcedural_t;

/* A function that reports messages through the renderer */
typedef void (*NSIReport_t)(NSIContext_t ctx, int level, const char* message);

/* A function that cleans-up after the last execution of the procedural */
#define NSI_PROCEDURAL_UNLOAD(name) \
	void name( \
		NSIContext_t ctx, \
		NSIReport_t report, \
		struct NSIProcedural_t* proc)
typedef NSI_PROCEDURAL_UNLOAD((*NSIProceduralUnload_t));

/* A function that translates the procedural into NSI calls */
#define NSI_PROCEDURAL_EXECUTE(name) \
	void name( \
		NSIContext_t ctx, \
		NSIReport_t report, \
		struct NSIProcedural_t* proc, \
		int nparams, \
		const struct NSIParam_t* params)
typedef NSI_PROCEDURAL_EXECUTE((*NSIProceduralExecute_t));

/* Descriptor of procedural */
struct NSIProcedural_t
{
	/* Expected version of NSI */
	unsigned nsi_version;
	/* Pointers to procedural's functions */
	NSIProceduralUnload_t unload;
	NSIProceduralExecute_t execute;
};

/* Convenient macro for procedural descriptor initialization */
#define NSI_PROCEDURAL_INIT(proc, unload_fct, execute_fct) \
	{ \
		(proc).nsi_version = NSI_VERSION; \
		(proc).unload = unload_fct; \
		(proc).execute = execute_fct; \
	}

/* The entry-point of the procedural. Returns a descriptor. */
#define NSI_PROCEDURAL_LOAD_SYMBOL NSIProceduralLoad
#define NSI_PROCEDURAL_LOAD_PARAMS \
	NSIContext_t ctx, \
	NSIReport_t report, \
	const char* nsi_library_path, \
	const char* renderer_version
typedef struct NSIProcedural_t* (*NSIProceduralLoad_t)(
	NSI_PROCEDURAL_LOAD_PARAMS);

/* Convenient macro for declaration of NSIProceduralLoad */
#define NSI_PROCEDURAL_LOAD \
	_3DL_EXTERN_C _3DL_EXPORT \
		struct NSIProcedural_t* NSI_PROCEDURAL_LOAD_SYMBOL( \
			NSI_PROCEDURAL_LOAD_PARAMS)

#ifdef __cplusplus
}
#endif

#endif

gear.cpp

#include "nsi_procedural.h"

#include "nsi_dynamic.hpp"
#include "nsi_util.h"

#include <math.h>


// Extends NSIProcedural_t to store private data
struct GearProcedural : public NSIProcedural_t
{
	explicit GearProcedural(const char* nsi_library_path)
		:	api(nsi_library_path)
	{
	}

	/*
		This loads symbols from the calling NSI library directly.
		It avoids having to link the procedural against an NSI library and
		letting the operating system locate it later.
		If linking the procedural against NSI is not a problem, then this can
		be omitted and the regular NSI API can be used directly.
	*/
	NSI::DynamicAPI api;
};


static NSI_PROCEDURAL_UNLOAD(gear_unload)
{
	/*
		The "proc" parameter is actually a pointer to the GearProcedural object
		allocated in NSIProceduralLoad.
	*/
	GearProcedural* gproc = (GearProcedural*)proc;
	delete gproc;
}


static NSI_PROCEDURAL_EXECUTE(gear_execute)
{
	/*
		The "proc" parameter is actually a pointer to the GearProcedural object
		allocated in NSIProceduralLoad.
	*/
	GearProcedural* gproc = (GearProcedural*)proc;

	// Retrieve parameters using utility functions from nsi_util.h
	const char* parent_node =
		NSI::FindStringParameter("parentnode", nparams, params);
	const char* node =
		NSI::FindStringParameter("node", nparams, params);
	const int* nb_teeth =
		NSI::FindIntegerParameter("nb_teeth", nparams, params);
	const float* inner_radius =
		NSI::FindFloatParameter("inner_radius", nparams, params);
	const float* outer_radius =
		NSI::FindFloatParameter("outer_radius", nparams, params);
	const float* teeth_slope =
		NSI::FindFloatParameter("teeth_slope", nparams, params);

	// Validate parameters and report errors

	if(!parent_node)
	{
		/*
			This is normal when the procedural is executed through a procedural
			node instead of a call to NSIEvaluate.
		*/
		parent_node = NSI_SCENE_ROOT;
	}

	if(!node)
	{
		/*
			This is normal when the procedural is executed through a procedural
			node instead of a call to NSIEvaluate.
			In that case, since the procedural is evaluated inside its own NSI
			context, in isolation from the main scene, there is no need to
			specify the new node's handle from outside the procedural.
		*/
		node = "gear";
	}

	if(!nb_teeth || *nb_teeth < 6)
	{
		report(ctx, NSIErrError, "gear : invalid number of teeth");
		return;
	}

	if(!inner_radius || *inner_radius <= 0.0f)
	{
		report(ctx, NSIErrError, "gear : invalid inner radius");
		return;
	}

	if(!outer_radius || *outer_radius <= *inner_radius)
	{
		report(ctx, NSIErrError, "gear : invalid outer radius");
		return;
	}

	float slope = teeth_slope ? *teeth_slope : 0.75f;
	if(slope <= 0.0f || slope > 1.0f)
	{
		report(ctx, NSIErrError, "gear : invalid teeth slope");
		return;
	}

	// Build the positions vector
	std::vector<float> P;
	float pitch = 2.0f * float(M_PI) / float(*nb_teeth);
	float inner_offset = pitch * (1.0f / (1.0f+slope)) / 2.0f;
	float outer_offset =
		pitch * (slope / (1.0f+slope)) / 2.0f * *inner_radius / *outer_radius;
	for(int v = 0; v < *nb_teeth; v++)
	{
		float tooth_axis = v*pitch;

		P.push_back(-sinf(tooth_axis-inner_offset) * *inner_radius);
		P.push_back(cosf(tooth_axis-inner_offset) * *inner_radius);
		P.push_back(0.0f);

		P.push_back(-sinf(tooth_axis-outer_offset) * *outer_radius);
		P.push_back(cosf(tooth_axis-outer_offset) * *outer_radius);
		P.push_back(0.0f);

		P.push_back(-sinf(tooth_axis+outer_offset) * *outer_radius);
		P.push_back(cosf(tooth_axis+outer_offset) * *outer_radius);
		P.push_back(0.0f);

		P.push_back(-sinf(tooth_axis+inner_offset) * *inner_radius);
		P.push_back(cosf(tooth_axis+inner_offset) * *inner_radius);
		P.push_back(0.0f);
	}

	/*
		Initialize a NSI::Context wrapper object from the DynamicAPI and the
		actual context handle (see comment in NSIProceduralLoad).
		If the procedural was to be linked directly against an NSI
		implementation, we could have omitted NSI::Context's constructor
		parameter.
	*/
	NSI::Context nsi(gproc->api);
	nsi.SetHandle(ctx);

	// Create the mesh and set its attributes
	nsi.Create(node, "mesh");
	nsi.SetAttribute(node,
		(
			NSI::IntegerArg("nvertices", P.size()/3),
			NSI::PointsArg("P", &P[0], P.size()/3)
		) );

	// Connect it to its parent transform
	nsi.Connect(node, "", parent_node, "objects");
}


// Main procedural entry point
NSI_PROCEDURAL_LOAD
{
	GearProcedural* proc = new GearProcedural(nsi_library_path);
	NSI_PROCEDURAL_INIT(*proc, gear_unload, gear_execute);

	return proc;
}

Naming Convention Redesign

Status: Draft

Rationale

The current ɴsɪ attribute names grew organically and suffer from several inconsistencies that make the API harder to learn and use than it needs to be.

No word separation. Most multi-word names are run together, forcing users to mentally parse where words begin and end – and the conventions are unpredictable:

NameIntended meaning
numberofthreadsnumber of threads
texturememorytexture memory
renderatlowpriorityrender at low priority
clockwisewindingclockwise winding
importancesamplefilterimportance sample filter
unitlengthmillimetersunit length millimeters

Inconsistent grouping. Some attributes use dot-separated groups, others don’t – even for closely related settings:

GroupedNot grouped
subdivision.cornerverticesclockwisewinding
subdivision.creasesharpnessoutlinecreasethreshold
visibility.camerasurfaceshader
quality.shadingsamplestexturememory
show.displacementrenderatlowpriority

Cryptic abbreviations alongside verbose names. Single-letter names coexist with long compound words:

TerseVerbose
Ptransformationmatrices
Nemissionintensitygrid
fovquality.samplevolumeemission
idstoppedcallbackdata

Node type names baked into attributes. Some attributes redundantly include the node type, others don’t:

RedundantClean
shaderfilename (on shader node)filter (on outputlayer node)
shaderobject (on shader node)angle (on environment node)
vdbfilename (on volume node)width (on particles node)
imagefilename (on outputdriver node)basis (on curves node)

This document proposes a systematic naming convention that resolves these inconsistencies. The complete mapping from current to proposed names is in the attribute mapping below.

Why Separate Words at All?

Concatenated names like maximumraylength or importancesamplefilter are hard to read – especially for non-native English speakers, who may not immediately see where one word ends and the next begins. Word separators make attribute names accessible to a wider audience without any downside: attribute name strings are interned by the renderer, so separators have zero runtime cost. They add a few bytes to the source but nothing to render time.

A common objection is that separators mean more typing. In practice this matters less than it used to: code is increasingly written with AI assistance and autocompletion, so keystroke count is a non-issue. What matters is how easily a human can read and review the code – and depth-of-field.focal-length is unambiguously clearer than depthoffield.focallength.

Redesign Proposal

Hyphenate Multi-Word Attributes

Attribute names use hyphens (-) as word separators, not underscores (_). This is a deliberate choice: almost no programming language allows hyphens in identifiers (variable-name is invalid in C, C++, Python, Rust, Lua, etc.), so attribute name strings are instantly distinguishable from code identifiers in any language. When you see reflection.ray-depth-max in source code, it can only be an attribute name – never a variable, function, or type.

Core Convention

  • . (dots) separate hierarchy levels – these correspond to what would be groups/rollouts/sections in UI/attribute editor.
  • - (hyphens) separate words within a single label.
  • Singular nouns when used as modifiers in compound names (English compound noun rule).
    • Example: point-grid not points-grid – “point” modifies “grid”.
  • Plain English over jargon (governing principle – R9).
    • Example field-of-view not fov.
  • Compound node type names also use hyphens: vdb-particles, output-driver, output-layer, face-set, perspective-camera, fisheye-camera, etc.
  • Example: subdivision.corner.sharpness – group “Subdivision”, sub-group “Corner”, label “Sharpness”.

Rulings

R1: Type-first grouping for ray settings

Ray depth/length settings on the global node are grouped by ray type, since each type has multiple related attributes (depth + length):

reflection.ray-depth-max
reflection.ray-length-max
diffuse.ray-depth-max
diffuse.ray-length-max

Only use dot-separated hierarchy when there are 2 or more related attributes that form a logical group. Single standalone attributes use flat hyphenated naming:

Groups (dot-separated):

subdivision.scheme
subdivision.corner.index
subdivision.corner.sharpness
subdivision.corner.automatic
visibility.camera
visibility.diffuse
visibility.reflection

Flat (hyphen-separated):

vertex-count
hole-count
clockwise
reference-time
quadratic-motion

R3: The grouping level is context-dependent, not concept-fixed

The same concept (e.g., “reflection”) can be a group in one context and a leaf in another. The rule is: whichever level has 2+ siblings becomes the group.

On the global node – each ray type has 2+ settings (depth + length), so the ray type is the group:

reflection.ray-depth-max
reflection.ray-length-max

On the attributes node – each ray type has only ONE visibility flag, but “visibility” has 8+ flags, so visibility is the group:

visibility.reflection
visibility.diffuse
visibility.camera

The alternative (type-first everywhere: reflection.visibility, diffuse.visibility, etc.) would create 8 singleton groups, violating R2. The 2+ rule takes precedence over concept consistency.

R4: Node type is an implicit top-level group

The node type itself provides context, so attribute names should not redundantly include the node type. On a curves node, the attribute is basis, not curve-basis. On a particles node, the attribute is id, not particle-id.

R5: Rename everything, including legacy single-letter names

No grandfather clause for industry-standard abbreviations:

  • P -> position
  • N -> normal
  • nvertices -> vertex-count
  • nholes -> hole-count

Single-word names that are already clear stay as-is: width, basis, id, matte, clockwise.

R6: Hyphen-separate compound domain terms

No concatenated words. All multi-word terms get hyphens:

  • depthoffield -> depth-of-field
  • fstop -> focal-stop
  • focallength -> focal-length
  • focaldistance -> focal-distance

This applies within both group names and leaf labels:

depth-of-field.enable
depth-of-field.focal-stop
depth-of-field.focal-length
depth-of-field.aperture.enable    <- sub-group (3 attrs: enable, sides, angle)
depth-of-field.aperture.sides
depth-of-field.aperture.angle

R7: Connection attribute plurality matches cardinality

If a connection attribute accepts multiple connections, use plural. If it accepts only one, use singular.

Plural (multi-connection):

objects              (root, transform -- multiple geometry nodes)
attributes           (root, transform -- multiple attribute nodes)
members              (set -- multiple objects)
screens              (camera -- multiple screen nodes)
output-layers        (screen -- multiple layer nodes)
output-drivers       (output-layer -- multiple driver nodes)

Singular (single-connection):

shader.surface       (attributes -- one surface shader)
shader.displacement  (attributes -- one displacement shader)
shader.volume        (attributes -- one volume shader)
background-layer     (output-layer -- one background layer)

R8: Group by concern, not by concept

Attributes are grouped by what kind of setting they are, not by what rendering concept they relate to. This keeps groups semantically coherent:

  • quality.* = how much effort the renderer spends (sampling counts, performance)
  • shading.* = which shading features are enabled/disabled (feature toggles)
  • {type}.* = per-ray-type limits (depth, length)
quality.shading-samples           <- sampling effort
quality.volume-samples            <- sampling effort
quality.denoise                   <- quality toggle
quality.volume-emission-sampling  <- quality toggle
quality.preview.global-update     <- preview/IPR quality
quality.preview.interpolate       <- preview/IPR quality
quality.preview.speed-multiplier  <- preview/IPR quality

shading.displacement              <- feature toggle
shading.atmosphere                <- feature toggle
shading.multiple-scattering       <- feature toggle
shading.osl-subsurface            <- feature toggle

volume.ray-depth-max              <- ray limit
volume.ray-length-max             <- ray limit

This avoids scattering quality-related attrs across concept groups (which would make it hard to find “all the knobs that affect render speed”).

The quality.* group exists on every node that has quality settings, not only on global. On screen, the number of camera rays per pixel and the importance-sampled pixel filter are both quality settings (R3: the group forms wherever it has 2+ members):

quality.pixel-samples             <- sampling effort, per pixel (screen)
quality.importance-sample-filter  <- quality toggle (screen)

Within quality.*, the suffix tells a count from a switch:

  • -samples is a count of samples: pixel-samples, shading-samples, volume-samples.
  • -sampling switches a sampling technique on or off: volume-emission-sampling.

R9: Plain English over jargon (governing principle)

This is a governing principle that applies across all naming decisions. When choosing between a technical abbreviation/jargon term and a plain English equivalent, always prefer plain English. Names should be understandable without domain-specific knowledge.

  • ipr -> preview (Interactive Progressive Rendering -> just “preview”)
  • fov -> field-of-view
  • fstop -> focal-stop (kept because it’s the actual name of the unit, not jargon)

The group quality.ipr.* becomes quality.preview.*:

quality.preview.global-update
quality.preview.interpolate
quality.preview.speed-multiplier

R10: Use .enable suffix for booleans in mixed groups

When a boolean on/off attribute belongs to a group that also has non-boolean attrs, append .enable to distinguish the toggle from the group:

depth-of-field.enable         <- mixed group (has focal-stop, focal-length, etc.)
depth-of-field.focal-stop
depth-of-field.focal-length

When the group is all-toggles or the boolean is standalone, no .enable needed:

shading.displacement          <- all-toggle group, obviously a toggle
shading.atmosphere

quality.denoise               <- obviously a toggle from context

R11: Unify callbacks under callback.* group

All callback/handler function pointers across the API follow a consistent pattern: callback.{purpose} for the function pointer and callback.{purpose}.data for the associated userdata.

This applies across different API calls – the callback group is a cross-cutting convention:

# NSIBegin
callback.error               <- error handler function (was: errorhandler)
callback.error.data          <- error handler userdata (was: errorhandlerdata)

# NSIRenderControl
callback.stop                <- stopped callback function (was: stoppedcallback)
callback.stop.data           <- stopped callback userdata (was: stoppedcallbackdata)

R12: Singular nouns as modifiers in compound names

When a noun serves as a modifier (adjective) in a compound name, use its singular form. This follows standard English compound noun formation: “dog house” not “dogs house”, “vertex count” not “vertices count”.

vertex-count         ✓  (not vertices-count)
hole-count           ✓  (not holes-count)
face-index           ✓  (not faces-index)
point-grid           ✓  (not points-grid)
object-index         ✓  (not objects-index)

Plurals are reserved for R7 (connection cardinality), where the attribute name IS the thing, not a modifier:

objects              ✓  (the attribute is multiple objects, not a modifier)
members              ✓
output-layers        ✓

Open Question: Node Types

The convention renames attributes. How node types are named and split – one mesh for polygons and subdivision surfaces but five camera nodes, volume for one backend – is a separate question. It has its own page: Node Types: Naming and Granularity.

Open Question: ᴏsʟ Built-In Variable Alignment

ɴsɪ is designed to feed ᴏsʟ shaders. When the renderer puts the control-point position into an attribute named P on a mesh node, an ᴏsʟ shader reads it as the built-in global variable P without any plumbing in between. That 1:1 mapping is part of what makes ᴏsʟ shaders portable across renderers, and it directly conflicts with R5.

R5 currently renames the legacy single-letter attribute names:

CurrentR5 proposalᴏsʟ built-in
Ppositionpoint P
Nnormalnormal N

Adopting R5 as written means the attribute name and the ᴏsʟ global diverge. The renderer either has to translate position -> P at the ᴏsʟ binding step – hidden machinery that surprises anyone debugging by attribute name – or the cross-renderer ᴏsʟ contract has to break for ɴsɪ specifically.

The ᴏsʟ globals that overlap with current ɴsɪ attribute names are: P, N, Ng, u, v, dPdu, dPdv, I.

Option A – Carve out an exception in R5

Legacy single-letter names that match an ᴏsʟ global stay as-is, even where the surrounding convention would rename them. This preserves the ɴsɪ <-> ᴏsʟ alignment.

Affected rows revert in the rename mapping:

  • mesh: P stays.
  • nurbs: P stays. Pw stays (semantics flow into the same ᴏsʟ binding).
  • curves: P stays.
  • particles: P stays, N stays.

nvertices, nholes, clockwisewinding etc. still rename – they aren’t ᴏsʟ globals.

Option B – Rename and translate

Adopt R5 as written. The renderer translates position -> P (and normal -> N, …) when binding attributes to ᴏsʟ shader globals.

This keeps ɴsɪ-level naming uniform but introduces hidden translation. Shader authors writing portable code now read about P in the ᴏsʟ docs and position in the ɴsɪ docs and have to internalise the mapping. Debugging tools that show attribute names won’t match what the shader sees.

Trade-off

The decision turns on which contract matters more:

  • ᴏsʟ portability (Option A) – the convention that “the attribute named P is what the shader reads as P” is sacred.
  • ɴsɪ-internal consistency (Option B) – single-letter names are jargon and R5’s reasoning applies uniformly; the ᴏsʟ binding layer can absorb the cost.

Resolution: Option A

The ᴏsʟ globals keep their names. P, N, Pw, Ng, u, v, dPdu, dPdv and I are not renamed and not deprecated; every other row of the mapping stands. A shader reads the attribute named P as P, with nothing in between, and no debugging tool has to translate.

The rows that name them in the complete mappingP to position on mesh, nurbs, curves and particles, N to normal, Pw to position-weighted – are therefore not adopted. The rest of each of those sections is.

Complete Attribute Mapping

Every attribute across all node types, with current -> new name and the ruling(s) that apply. Attributes where current = new are omitted.

Common (All Nodes)

CurrentNewRules
nicenamenice-nameR6

global Node

CurrentNewRules
numberofthreadsthread-countR2 (1 attr, flat), R5, R6
texturememorytexture-memoryR2 (1 attr, flat), R6
networkcache.sizenetwork-cache.sizeR6
networkcache.directorynetwork-cache.directoryR6
networkcache.mipmapnetwork-cache.mipmapR6
networkcache.writenetwork-cache.writeR6
renderatlowprioritylow-priorityR6, R9
bucketorderbucket-orderR6
hidemessagesmessages.hideR2 (2 message attrs: hide + timestamp)
maximumraydepth.diffusediffuse.ray-depth-maxR1
maximumraydepth.hairhair.ray-depth-maxR1
maximumraydepth.reflectionreflection.ray-depth-maxR1
maximumraydepth.refractionrefraction.ray-depth-maxR1
maximumraydepth.volumevolume.ray-depth-maxR1
maximumraylength.diffusediffuse.ray-length-maxR1
maximumraylength.hairhair.ray-length-maxR1
maximumraylength.reflectionreflection.ray-length-maxR1
maximumraylength.refractionrefraction.ray-length-maxR1
maximumraylength.specularspecular.ray-length-maxR1 (pattern consistency)
maximumraylength.volumevolume.ray-length-maxR1
quality.denoisequality.denoiseR8
quality.iprglobalupdatequality.preview.global-updateR8, R9
quality.iprinterpolatequality.preview.interpolateR8, R9
quality.iprspeedmultiplierquality.preview.speed-multiplierR8, R9
quality.shadingsamplesquality.shading-samplesR8, R6
quality.volumesamplesquality.volume-samplesR8, R6
quality.causticsamplesquality.caustic-samplesR8, R6
quality.samplevolumeemissionquality.volume-emission-samplingR8, R6
referencetimereference-timeR6
show.displacementshading.displacementR8
show.atmosphereshading.atmosphereR8
show.multiplescatteringshading.multiple-scatteringR8, R6
show.osl.subsurfaceshading.osl-subsurfaceR8, R6
show.instancesnodeshading.instances-nodeR8, R6 (feature toggle, as the other show.*)
texture.missingcolortexture.missing-colorR2 (2 texture attrs -> group), R6
texture.missingcolorerrorstexture.missing-color-errorsR2, R6
exclusiveshadingexclusive-shadingR6
messages.timestampmessages.timestamp

Unchanged: license.server, license.wait, license.hold, frame, statistics.progress, statistics.filename, verbose

root Node

CurrentNewRules
geometryattributesattributesR6, R7 (multi-conn -> plural)

Unchanged: objects

set Node

Unchanged: members

mesh Node

CurrentNewRules
PpositionR5
nverticesvertex-countR5, R6
nholeshole-countR5, R6
clockwisewindingclockwiseR6 (simplification)
subdivision.cornerverticessubdivision.corner.indexR2 (3 corner attrs -> sub-group)
subdivision.cornersharpnesssubdivision.corner.sharpnessR2
subdivision.smoothcreasecornerssubdivision.corner.automaticR2
subdivision.creaseverticessubdivision.crease.indexR2 (2 crease attrs -> sub-group)
subdivision.creasesharpnesssubdivision.crease.sharpnessR2
referencetimereference-timeR6
quadraticmotionquadratic-motionR6
outlinecreasethresholdoutline-crease-thresholdR6

Unchanged: subdivision.scheme

nurbs Node

The u and v axes each have five related attributes (count, order, knot, min, max), so per R3 each axis becomes a group.

CurrentNewRules
nuu.countR3 (axis group), R5, R6
nvv.countR3 (axis group), R5, R6
uorderu.orderR3, R6
vorderv.orderR3, R6
uknotu.knotR3, R6
vknotv.knotR3, R6
uminu.minR3, R6
umaxu.maxR3, R6
vminv.minR3, R6
vmaxv.maxR3, R6
PpositionR5
Pwposition-weightedR5, R6 (alternative to position); type hpoint -> weighted-point
trim-curves.loop-countNew: the shipped node derives it from the number of trimcurves.ncurves values
trimcurves.ncurvestrim-curves.curve-countR5, R6
trimcurves.ntrim-curves.point-countR5 (n and cv are jargon), R9
trimcurves.ordertrim-curves.orderR6 (group prefix only)
trimcurves.knottrim-curves.knotR6
trimcurves.mintrim-curves.minR6
trimcurves.maxtrim-curves.maxR6
trimcurves.u, .vtrim-curves.positionAPI change (consolidation); mirrors surface
trimcurves.u, .v, .wtrim-curves.position-weightedAPI change (consolidation); mirrors surface
trimcurves.insidetrim-curves.holeAPI change: one value per loop, and inverted (inside 1 keeps the inside; hole 1 removes it)
trim-curves.edge-idNew (stitching); id is fine per R5
trim-curves.edge-orientationNew (stitching)
stitch.edge-idNew (stitching), R2 (group of 2)
stitch.edge-orientationNew (stitching)

Consolidation note (API change, not pure rename). The current API stores trim-curve control points as three parallel arrays (trimcurves.u, trimcurves.v, trimcurves.w) – a structure-of-arrays layout. The surface stores its control points as one interleaved array (P or Pw) – an array-of-structures layout. The redesign aligns the two:

  • trim-curves.positionfloat[2], non-rational (u, v) pairs. Replaces trimcurves.u and trimcurves.v.
  • trim-curves.position-weightedfloat[3], rational (u, v, w) triples. Replaces trimcurves.u, trimcurves.v, and trimcurves.w.

Supply one of the two; never both. This is the same supply-one-of pattern the surface uses for position/position-weighted.

The types differ on purpose: the surface’s position-weighted uses the draft weighted-point type – four floats that transform as one homogeneous unit – while the trim-curve tuples stay plain float[2]/float[3], because parameter-space data must never transform. See Geometry in the Type System.

Status note. 3Delight 2.9.210 implements the nurbs node with the names in the “Current” column; its reference page documents them. The new names are those of the draft design, which was written before that release. The stitching attributes (trim-curves.edge-id, trim-curves.edge-orientation, stitch.edge-id, stitch.edge-orientation) were introduced directly under the new convention and have no legacy counterparts.

The shared-boundary proposal scopes these IDs through a weld connection. Its general weld.* table also supports whole loops and multi-segment uses. The per-curve and per-side stitching arrays remain an alternative shorthand, not a separate identity system.

t-nurcc Node

New node type (draft, no implementation), introduced directly under the new convention – there are no legacy names to map. Its attributes reuse spellings established elsewhere in this document: vertex-count, position/position-weighted (mirroring nurbs), the sparse group.index + group.value edge-list idiom (mirroring subdivision.crease.*) for knot-interval.*, and the stitch.edge-id identifier space shared with nurbs.

face-set Node

CurrentNewRules
facesface-indexR9 (descriptive)

curves Node

CurrentNewRules
nverticesvertex-countR5, R6
PpositionR5

Unchanged: width, basis, extrapolate

particles Node

CurrentNewRules
PpositionR5
NnormalR5
reverseorientationreverse-orientationR6
quadraticmotionquadratic-motionR6

Unchanged: width, id

procedural Node

CurrentNewRules
boundingboxbounding-boxR6

environment Node

Unchanged: angle

shader Node

CurrentNewRules
shaderfilenamefilenameR4 (node type provides context)
shaderobjectobjectR4
materialxnodedefmaterialx.node-definitionR2 (2 MaterialX attrs -> group), R9
materialxversionmaterialx.versionR2

attributes (geometry) Node

CurrentNewRules
surfaceshadershader.surfaceR2, R7 (single-conn -> singular)
displacementshadershader.displacementR2, R7
volumeshadershader.volumeR2, R7
visibility.set.subsurfacevisibility.subsurface-setR6
regularemissionemission.regularR2 (2 emission attrs -> group)
quantizedemissionemission.quantizedR2
displacementresolutiondisplacement-resolutionR6

Unchanged: caustics.cast, caustics.emit, caustics.receive, ATTR.priority, visibility.camera, visibility.diffuse, visibility.hair, visibility.reflection, visibility.refraction, visibility.shadow, visibility.specular, visibility.volume, visibility, matte, bounds

transform Node

CurrentNewRules
transformationmatrixmatrixR4
geometryattributesattributesR6, R7
shaderattributesshader-attributesR6

Unchanged: objects

instances Node

CurrentNewRules
sourcemodelsobjectsR7 (multi-conn), R9
transformationmatricesmatricesR4, R6
modelindicesobject-indexR6, R9
disabledinstancesdisabled-indexR6

output-driver Node

CurrentNewRules
drivernamedriver-nameR6
imagefilenamefilenameR4
embedstatisticsembed-statisticsR6

output-layer Node

CurrentNewRules
variablenamevariable-nameR6
variablesourcevariable-sourceR6
layernamelayer-nameR6
scalarformatscalar-formatR6
layertypelayer-typeR6
colorprofilecolor-profileR6
withalphawith-alphaR6
sortkeysort-keyR6
lightsetlight-setR6
lightsetnamelight-set-nameR6
outputdriversoutput-driversR6, R7
filterwidthfilter.widthR2 (2 filter attrs -> group)
filterfilter.nameR2
backgroundvaluebackground.valueR2 (2 background attrs -> group)
backgroundlayerbackground.layerR2, R7 (single-conn)
lightdepthlight-depthR6
cryptomatte.enablecryptomatteR2 (single attr, flat), R10 (standalone toggle)

Unchanged: dithering

screen Node

CurrentNewRules
outputlayersoutput-layersR6, R7
prioritywindowpriority-windowR6
screenwindowscreen-windowR6
pixelaspectratiopixel-aspect-ratioR6
oversamplingquality.pixel-samplesR8, R9
staticsamplingpatternstatic-sampling-patternR6
importancesamplefilterquality.importance-sample-filterR6, R8

Unchanged: resolution, crop, overscan

oversampling is jargon for a count: the number of camera rays per pixel. quality.pixel-samples names it the way quality.shading-samples names the count on global. static-sampling-pattern stays flat: it selects whether the pattern changes per frame, which is not render effort.

vdb-particles Node

CurrentNewRules
vdbfilenamefilenameR4
pointsgridpoint-gridR6, R12
velocityreferencetimevelocity.reference-timeR2 (2 velocity attrs -> group)
velocityscalevelocity.scaleR2
enablepscaleuse-point-scaleR9 (plain English)
widthscalewidth-scaleR6

Unchanged: width

volume Node

CurrentNewRules
vdbfilenamefilenameR4
densitygridgrid.densityR2 (6 grid attrs -> group)
colorgridgrid.colorR2
emissiongridgrid.emissionR2
emissionintensitygridgrid.emission-intensityR2, R6
temperaturegridgrid.temperatureR2
velocitygridgrid.velocityR2
velocityreferencetimevelocity.reference-timeR2
velocityscalevelocity.scaleR2

Camera Nodes (perspective-camera, fisheye-camera, cylindrical-camera)

Common (all cameras):

CurrentNewRules
screensscreensR7
shutterrangeshutter.rangeR2 (2 shutter attrs -> group)
shutteropeningshutter.openingR2
clippingrangeclipping-rangeR6

perspective-camera:

CurrentNewRules
fovfield-of-viewR9
depthoffield.enabledepth-of-field.enableR6, R10
depthoffield.fstopdepth-of-field.focal-stopR6
depthoffield.focallengthdepth-of-field.focal-lengthR6
depthoffield.focallengthratiodepth-of-field.focal-length-ratioR6
depthoffield.focaldistancedepth-of-field.focal-distanceR6
depthoffield.aperture.enabledepth-of-field.aperture.enableR6, R10
depthoffield.aperture.sidesdepth-of-field.aperture.sidesR6
depthoffield.aperture.angledepth-of-field.aperture.angleR6
unitlengthmillimetersunit-length-millimetersR6

fisheye-camera:

CurrentNewRules
fovfield-of-viewR9

Unchanged: mapping

cylindrical-camera:

CurrentNewRules
fovfield-of-view.verticalR2 (2 fov attrs -> group), R9
horizontalfovfield-of-view.horizontalR2, R6, R9
eyeoffseteye-offsetR6

API Parameter Mapping

NSIBegin

CurrentNewRules
streamfilenamestream.filenameR2 (4 stream attrs -> group)
streamformatstream.formatR2
streamcompressionstream.compressionR2
streampathreplacementstream.path-replacementR2, R6
separateprocessseparate-processR6
errorhandlercallback.errorR11 (unified callback group)
errorhandlerdatacallback.error.dataR11
executeproceduralsevaluate-replaceR9

Unchanged: type

NSIDelete

Unchanged: recursive

NSIConnect

Unchanged: value, priority, strength

NSIEvaluate

CurrentNewRules
backgroundloadbackground-loadR6
replacensidirreplace-nsidirR6 (NSIDIR is a variable name, kept whole)

Unchanged: type, filename, script, buffer, size

NSIRenderControl

CurrentNewRules
stoppedcallbackcallback.stopR11 (unified callback group)
stoppedcallbackdatacallback.stop.dataR11

Unchanged: action, progressive, interactive, frame

Open question – action as a positional argument: Every meaningful NSIRenderControl call needs an action value – one of start, wait, synchronize, suspend, resume, stop. The call is a no-op without it. Yet the current signature carries action inside the optional-parameter bag, peer to the genuinely-optional progressive/interactive/callback parameters.

Promoting action to a required positional argument would make intent visible at the call site:

typedef enum {
    NSIRenderStart,
    NSIRenderWait,
    NSIRenderSynchronize,
    NSIRenderSuspend,
    NSIRenderResume,
    NSIRenderStop,
} NSIRenderAction;

void NSIRenderControl(
    NSIContext_t        ctx,
    NSIRenderAction     action,
    int                 nparams,
    const NSIParam_t   *params);

A string overload can be kept for the Lua and Python bindings, where "start"/"wait"/"stop" read idiomatically. The enum form catches typos at compile time and surfaces the closed set of allowed values to IDE completion.

If adopted, action leaves this rename table entirely – there is nothing left to rename.