Omniverse Native Interfaces

Warning

A common misconception is that Omniverse Native Interfaces can be changed over time, but this is not correct. Once created, Omniverse Native Interfaces are immutable. To add functionality, an Interface can be inherited into a new Interface that adds functionality, or a new separate Interface can be created.

Warning

Omniverse Native Interfaces are Beta software and can be difficult to use. Active development by the Carbonite team has paused. If other contributors wish to develop improvements, the Carbonite team is willing to evaluate Merge Requests.

Carbonite Interfaces are actively supported.

Overview

This document provides a deep-dive into the design and philosophy of Omniverse Native Interfaces. For a guide to quickly start using Omniverse Native Interfaces, see ONI Walkthrough. For a guide on extending an existing released Omniverse Native Interface, see Extending an Omniverse Native Interface.

Interfaces at a Glance

The Omniverse SDK has three types of native interfaces: “Carbonite Interfaces”, “Kit Interfaces”, and “Omniverse Native Interfaces”. All three types of interfaces are ABI-safe.

Here, we give a brief introduction into the three different approaches before deep-diving into Omniverse Native Interfaces.

Carbonite Interfaces

Carbonite interfaces follow a C-style approach to interfaces. Interfaces are broken into two parts:

  • A struct of function pointers. This struct essentially represents the “methods” of the interface.

  • A pointer to an opaque “context” object. The context object stores data needed by the interface’s function pointers to operate correctly.

The context pointer is optional. Some interfaces rely on private, globally stored data. On rare occasions, some interfaces require no data to operate correctly.

Another way to think of Carbonite interfaces is a convenient way to import multiple implementations of a C API into your process.

Here’s an example of a simple Carbonite interface:

struct Window; // opaque "context"

struct IWindowing
{
    CARB_PLUGIN_INTERFACE("carb::windowing::IWindowing", 1, 0)

    Window*(CARB_ABI* createWindow)(uint32_t w, uint32_t h);

    void(CARB_ABI* destroyWindow)(Window* window);

    void(CARB_ABI* showWindow)(Window* window);

    void(CARB_ABI* setTitle)(Window* window, const char* title);

    Keyboard*(CARB_ABI* getKeyboard)(Window* window);
};

To instantiate the IWindowing interface, call carb::tryAcquireInterface:

IWindowing* windowing = carb::tryAcquireInterface<IWindowing>();

With the interface instantiated, you can now create and use windows:

Window* window = windowing->createWindow(640, 480);
windowing->setTitle(window, "My Window");

// you must keep track of both the window ("context") and windowing ("interface") pointers
windowing->showWindow(window);

// interface/context memory must be explicitly managed
windowing->destroyWindow(window);

Since the interface function tables and opaque “context” data are separate, you must keep track of both pointers.

Care must also be taken to ensure that the context data passed to interface functions are compatible.

Carbonite interfaces are semantically versioned, meaning that at compile time the expected binary layout of the data pointed to by the IWindowing struct may differ than the actual binary layout of struct at runtime. It is up to the user, at runtime via version checks, to ensure IWindowing has a compatible binary layout. A detailed explanation of the implications of using semantically version interfaces can be found in Semantic Versioning.

Because interfaces are described as a table of function pointers using C, C++ like inheritance is not possible.

Finally, like C, it’s up to the user explicitly manage the interface function table’s and opaque pointer’s memory.

Kit Interfaces

Kit interfaces follow a more C++ approach to interfaces and improve upon Carbonite interfaces:

// a carbonite interface is needed to act as a factory for the kit interface
struct IWindowing
{
    CARB_PLUGIN_INTERFACE("windowing::IWindowing", 1, 0);

    IWindow*(CARB_ABI* createWindowPtr)(uint32_t w, uint32_t h);

    // smart pointer wrapper to createWindowPtr
    carb::ObjectPtr<IWindow> createWindow(uint32_t w, uint32_t h)
    {
        return stealObject(this->createWindowPtr(w, h));
    }
};

// this is the kit interface
struct IWindow : public carb::IObject
{
    CARB_PLUGIN_INTERFACE("windowing::IWindow", 1, 0);

    virtual void showWindow() = 0;

    virtual void setTitle(const char* title) = 0;

    virtual IKeyboard* getKeyboardPtr() = 0;

    // smart pointer wrapper to getKeyboardPtr
    carb::ObjectPtr<IKeyboard> getKeyboard()
    {
        return stealObject(this->getKeyboardPtr());
    }
};

Kit interfaces have the following properties:

  • Abstract base classes: Because Kit interfaces are classes, single inheritance is possible: interfaces can inherit from other interfaces. We’ll see later that interface implementations can even inherit from existing implementations. This greatly reduces duplicated code and increases code reuse. All Kit interfaces inherit from carb::IObject (or an interface that inherits from carb::IObject).

  • Reference counted: Intrusive reference counting makes memory management and binding to other languages easier.

  • Semantically versioned: Like Carbonite interfaces, runtime version checks are still required.

  • Hand-written: Interface authors are expected to ensure the interface is ABI-safe and properly versioned.

To use a Kit interface:

IWindowing* windowing = carb::tryAcquireInterface<IWindowing>();
if (windowing)
{
    auto window = windowing->createWindow(640, 480); // window is reference counted
    window->setTitle("My Window");
    window->showWindow();
}

Kit interfaces do not have separate interface and context pointers. There’s a single pointer (like C++) that stores both the interface and data. As such, the user no longer has to worry about mixing the wrong context pointer with the wrong interface pointer.

Above, we never called a “destroy” method. This is because Kit interfaces are reference counted and in the code above managed by smart pointers. This greatly simplifies memory management at a low cost.

Kit interfaces rely on the same plugin framework designed for Carbonite interfaces. As such, it inherits some of that framework’s legacy:

  • That framework assume interfaces are plain-old-data. Therefore, a Carbonite interface (which is plain-old-data) must be registered to act as a factory for the plugin’s Kit interfaces

  • Interfaces are semantically versioned, meaning runtime version checks are required

  • Multiple implementations of an interface cannot coexist within a DLL

  • Multiple versions of an implementations cannot coexist within a DLL

  • Plugin loading is serial

  • Carbonite interface instantiation via the framework is expensive (though instantiation of the actual Kit interface via the Carbonite interface factory can be relatively cheap).

Omniverse Interfaces

Omniverse interfaces follow a C++ style approach (really a COM-lite approach). Interfaces are abstract base classes that inherit from omni::core::IObject (or another interface that inherits from omni::core::IObject):

OMNI_DECLARE_INTERFACE(IWindow);
OMNI_DECLARE_INTERFACE(IWindowSystem);

class IWindow_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.IWindow")>
{
protected:
    virtual void show_abi(bool shouldShow) noexcept = 0;

    virtual void setTitle_abi(OMNI_ATTR("c_str, not_null") const char* title) noexcept = 0;

    virtual IKeyboard* getKeyboard_abi() noexcept = 0;
};

class IWindowSystem_abi :
    public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.IWindowSystem")>
{
protected:
    virtual IWindow* createWindow_abi(uint32_t w, uint32_t h) noexcept = 0;
};

To instantiate an interface:

auto windowSystem = omni::core::createType<IWindowSystem>();

Not all interfaces can be instantiated by omni::core::createType. For example, to create and use an IWindow:

auto window = windowSystem->createWindow(640, 480);
window->setTitle(640, 480);
window->show(true);

In the case above, IWindowSystem is essentially a factory for IWindow (and any other interfaces related to the window system). More information on object instantiation can be found in Instantiating an Interface.

Omniverse and Kit interfaces have several things in common:

  • Abstract base class: This allows for C++ single inheritance (multiple inheritance is not ABI-safe).

  • Reference counted: This allows for easier memory management and language bindings.

There are several large differences between Kit and Omniverse interfaces:

  • Omniverse interfaces are not semantically versioned. Rather, Omniverse interfaces rely on a customer oriented versioning scheme that does not require runtime checks and encourages many small interfaces rather than large monolithic interfaces. More on versioning Omniverse interfaces can be found in Interface Versioning.

  • Omniverse interfaces rely heavily on code generation. Omniverse interfaces are specified in a subset of C++ which the omni.bind tool is able to consume and generate a user friendly modern C++ wrapper and various language bindings (such as Python). Additionally, omni.bind is used to validate that the interface is ABI-safe. In essence, Omniverse interfaces are much like an interface definition language (IDL) that is also valid C++. More on code generation can be found in API: omni.bind.

  • There are strict rules about how to design an interface. These rules cover thread safety, memory ownership, reference count management, and naming conventions. The omni.bind tool helps to enforce these rules. The end-result is less guessing by the user on how an interface works and less confusion from the interface author on how to design an interface.

  • Omniverse interfaces eschew the Carbonite plugin framework in favor of a new type system. This type system has several improvements over the Carbonite framework:

    • Optimized for concurrent plugin loading and type instantiation

    • DLLs can contain multiple implementations of a given interface

    • DLLs can contain multiple versions of an implementation

    • Interfaces are not assumed to be plain-old-data

    More information on the type system can be found in Instantiating an Interface.

  • Omniverse interface have an ABI-safe dynamic cast-like mechanism.

At this point you probably have questions like, “Why is everything postfixed with _abi?” and “What’s OMNI_TYPE_ID, OMNI_ATTR, and Inherits<>?” The remainder of this document is dedicated to answering these and other questions about Omniverse Native Interfaces.

Note

Note: In order to better fit docs on the page, assume the following code exists:

namespace omni
{
    using namespace omni::core;
}

This means objects like omni::IObject and omni::core::IObject are the same thing.

ABI vs. API

Main Article

Omniverse interfaces have two parts: the ABI and the API. These two parts serve two different audiences:

  • The API is intended for users of the interface. The API consist of easy-to-use inline code designed to hide the gory semantics of the ABI.

  • The ABI is intended for implementation authors. Implementation authors only need to override the ABI methods and can completely ignore the API.

In the following sections, we’ll cover how interface designers should approach designing the ABI and API of an interface.

ABI Interface Design

Omniverse interfaces inherit from omni::core::IObject or another interface that inherits from omni::core::IObject. Interfaces do not support multiple inheritance (see Multiple Inheritance for details on how implementations can use multiple inheritance).

Methods in the ABI are denoted with an _abi postfix. Users should never call methods in the ABI. There are several reason for this:

  • The _abi methods exist to generate ABI-safe code. Said differently, the rules (see below) for _abi methods are designed such that the code generated by the compiler is useable across multiple compilers chains. This is what allows Omniverse interfaces to not have to be recompiled when compilers are upgraded, a new SDK is released, a different compiler (CLang vs. Visual Studio) is used, etc.

  • In order to achieve ABI-safety, _abi methods are greatly limited in the types of data they can produce and consume. Mainly, primitive types like const char*, uint32_t, and structs/unions that meet the requirements of standard layout types. Complex data types, such std::vector, cannot be used because their memory layout may be different across different compilers (they are not ABI-safe). See ABI: Data Types for more information.

  • Omniverse interfaces are reference counted. When using _abi methods, you must manually manage the reference count. This is error-prone.

ABI methods in an interface should be protected to discourage their use by users.

The ordering of methods in the ABI has a direct impact on the binary representation of the interface. Rearranging methods in the ABI will break the ABI

ABI: Data Types

Methods in the ABI are limited to consuming and producing the following data types:

  • Pointers. For example: float*, const char**.

  • Primitive types with known sizes. This include types like uint32_t, float, etc. Types with ABI dependent sizes, such as int are not allowed. An exception to this rule is char which is assumed to be 8-bits. Note, the fixed size requirement is to aid in possible future efforts to serialize ABI data (e.g. RPC calls).

  • Variadic arguments (e.g. ...).

Pointers can point to one of three types of data:

  • Primitive types. For example, float*.

  • Interfaces. For example, IWindow*.

  • struct``s or ``union``s.  For example, ``WindowDesc*.

    • All members in the struct/union must meet the rules above.

    • The struct/union must meet the requirements of a standard layout type.

    • The struct/union must not be opaque.

ABI: C++ Features Not Allowed

The following C++ features are not allowed in the ABI of an interface:

  • Constructor / Destructors

  • Exceptions. All ABI methods must be declared noexcept.

  • Overloaded operators

  • Overloaded methods

  • Data members

  • Non-virtual methods

  • Non-pure virtual methods

  • Static methods

  • Templated methods

  • Default arguments

  • References

  • final

  • = default

  • = delete

Essentially, the ABI is limited to C’s feature set. The only C++ feature allowed is pure virtual methods.

The limits above may seem overly restrictive. One might say, “Every compiler implements references as const pointers, we should allow references.” While that’s a true statement today, it may not be true in the future as new language features are added.

Our goal is to define a set of ABI rules that will minimize the risk of external parties, such as compiler authors, breaking the Omniverse ABI. As a result, we’ve tied ourself to well-known and stable “standards”: the C-ABI and the virtual function table layout defined by Microsoft’s Component Object Model (COM). Both specifications have avoided breaking changes for decades.

While the ABI layer is quite restrictive, we’ll see in API Interface Design that most of these restrictions are lifted in the API layer.

ABI: Handling Interface Pointers

Pointers to Omniverse interfaces are allowed within the ABI portion of an interface. As previously mentioned, interfaces are internally reference counted, and as such rules must be established as to when the internal reference count should be incremented and decremented. Incrementing and decrementing the internal reference count of an interface is handled by IObject::acquire_abi() and IObject::release_abi(). The general rules are:

  • When duplicating (or creating) a pointer to an interface, call acquire_abi().

  • When no longer using a pointer to an interface, call release_abi().

Practically:

  • Return: When returning an interface pointer via a return statement, the callee must call acquire_abi().

  • Out Parameters: When returning an interface pointer via a pointer to an interface pointer (e.g. IFoo**), the callee must call acquire_abi().

  • InOut Parameters: When accepting an interface pointer via a pointer to an interface pointer (e.g. IFoo**), the callee must call release_abi() on the incoming pointer and acquire_abi() on the outgoing pointer. Functions with in-out pointers are rare.

  • In Parameters: The caller does not need to call acquire_abi() when passing a pointer to a method.

One exception to the rules above are getter methods:

  • Getter (optional/discouraged): If a method returns an interface pointer, it may choose to not call acquire_abi, breaking the Return rule. Such a method must be postfixed with WithoutAcquire. For example, getWindowWithoutAcquire_abi().

The Getter rule is designed to avoid excessive increments and decrements of the reference count when chaining calls together. This technique is strongly discouraged, as it is easily possible for the returned pointer to become invalidated (for example, by another thread).

API Interface Design

To ease the ABI restrictions, interface authors implement “API” methods. These methods are designed to alleviate the restrictions of the ABI:

  • API methods can produce and consume complex data types such as std::vector.

  • API methods make heavy use of “smart” pointers (e.g. omni::core::ObjectPtr<>) that manage reference counts for the user.

The end result for users is that using an interface’s API will feel like using any other C++ class.

An interface’s API has few restrictions and is designed to provide a user friendly surface to the underlying ABI. The main restriction is that all API code must be inlined.

Let’s look at a small chunk of the IWindow ABI:

OMNI_DECLARE_INTERFACE(IWindow);

class IWindow_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.IWindow")>
{
protected:
    // abi /////////////////////////////////////////////////////////////////////////////////

    // simple method which accepts a bool
    virtual void show_abi(bool shouldShow) noexcept = 0;

    // returns an interface pointer.  getKeyboard_abi() will have called acquire_abi()
    // on the returned pointer (see *Return* rule)
    virtual IKeyboard* getKeyboard_abi() noexcept = 0;

    // accepts an interface pointer.  the caller does not need to call acquire_abi() on
    // cursor before calling this method (see *In Parameter* rule)
    virtual void setCursor_abi(ICursor* cursor) noexcept = 0;
};

While simple, the ABI above assumes the caller will properly increment/decrement the reference count of the IKeyboard and ICursor interfaces used in the ABI. The goal of the API is to provide a layer above the ABI that makes using the ABI easier. In this case, we’d like the API layer to handle properly incrementing and decrementing the interfaces’ reference counts.

To define the API, we use the OMNI_DEFINE_INTERFACE_API() macro:

OMNI_DEFINE_INTERFACE_API(omni::windowing::IWindow)
{
public:
    void show(bool shouldShow) noexcept
    {
        show_abi(showShow);
    }

    omni::core::ObjectPtr<IKeyboard> getKeyboard() noexcept
    {
        return omni::core::steal(getKeyboard_abi());
    }

    void setCursor(omni::core::ObjectParam<ICursor> cursor) noexcept
    {
        setCursor_abi(cursor.get());
    }
};

Above, notice:

  • The _abi postfix has been stripped.

  • All code in inlined.

  • Smart wrappers (such as omni::core::ObjectPtr and omni::core::ObjectParam) are used to managed interface reference counts.

API: omni.bind

The code above is mainly boiler-plate. To make authoring API code easier, the omni.bind tool can be used to generate the API layer for the interface author:

python omni.bind.py IWindow.h --api IWindow.gen.h

The interface author then only needs to include the generated file into their interface header:

OMNI_DECLARE_INTERFACE(IWindow);

class IWindow_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.IWindow")>
{
protected:
    // abi /////////////////////////////////////////////////////////////////////////////////

    // simple method which accepts a bool
    virtual void show_abi(bool shouldShow) noexcept = 0;

    // returns an interface pointer.  getKeyboard_abi() will have called acquire_abi()
    // on the returned pointer (see *Return* rule)
    virtual IKeyboard* getKeyboard_abi() noexcept = 0;

    // accepts an interface pointer.  the caller does not need to call acquire_abi() on
    // cursor before calling this method (see *In Parameter* rule)
    virtual void setCursor_abi(ICursor* cursor) noexcept = 0;
};

#include "IWindow.gen.h" // new: include API layer generated code

Using omni.bind is not an all or nothing proposition. The interface author can both use omni.bind and also define custom API code via OMNI_DEFINE_INTERFACE_API:

OMNI_DECLARE_INTERFACE(IWindow);

class IWindow_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.IWindow")>
{
protected:
    // abi /////////////////////////////////////////////////////////////////////////////////

    // simple method which accepts a bool
    virtual void show_abi(bool shouldShow) noexcept = 0;

    // returns an interface pointer.  getKeyboard_abi() will have called acquire_abi()
    // on the returned pointer (see *Return* rule)
    virtual IKeyboard* getKeyboard_abi() noexcept = 0;

    // accepts an interface pointer.  the caller does not need to call acquire_abi() on
    // cursor before calling this method (see *In Parameter* rule)
    virtual void setCursor_abi(ICursor* cursor) noexcept = 0;
};

#include "IWindow.gen.h" // include API layer generated code

OMNI_DEFINE_INTERFACE_API(omni::windowing::IWindow)
{
    // new: in addition to having omni.bind generate API boiler-plate code for us, we can add our
    //      own API methods (like pushKey).
    void pushKey(KeyboardKey key)
    {
        getKeyboard()->addKeyPress(key);
    }
};

The user is unaware of the details of the ABI or that the API is a combination of hand-written and auto-generated code:

#include <IWindow.h>

// ...

auto windowSystem = omni::core::createType<IWindowSystem>();

// createWindow returns an ObjectPtr which manages window's reference count
auto window = windowSystem->createWindow(640, 480);

window->pushKey(KeyboardKey::eA); // hand-written api wrapper (that calls auto-generated wrapper)
window->show(true); // auto-generated wrapper

The omni.bind tool is the recommended way to generate API code. In addition to generating efficient and safe API code, the tool is also able to generate bindings for other languages such as Python.

When using omni.bind, interface authors can annotate constructs within interfaces with the OMNI_ATTR macro. This macro describes to omni.bind the intended usage of different elements. For example, if a const pointer member function parameter is marked with the not_null attribute, the API layer generator will generate a method wrapper that accepts a const reference. Advanced annotations, like denoting that a pointer is really a multi-dimensional array or that a method accepts a callback that would like to capture data, are supported. See the omni.bind User Guide for more details.

The following is recommended when using omni.bind:

  • The build system should be used to dynamically update bindings. omni.bind’s -M flag can be used to generate dependency information.

  • Generated code should be checked in. This enables useful debugging features such as Source Linking to work.

API: Method Naming

When wrapping ABI methods in the API by hand, the _abi postfix should be stripped. For example, setCursor_abi becomes setCursor.

API methods do not have to have a 1-to-1 correspondence with ABI methods. An API method may choose to call 0 to many ABI calls.

API: Data Types

There are no restrictions on data types in the API. This can be used to great effect. For example, in example.windowing.cpp, lambdas with captures are passed to IWindow to listen to keyboard events:

omni::ObjectPtr<IWindow> window = /* ... */;
omni::ObjectPtr<ICursor> customCursor = /* ... */;

auto consumer = window->addOnKeyboardEventConsumer(
    [&](IKeyboard* /*keyboard*/, const KeyboardEvent* event)
    {
        if (isKeyRelease(event, KeyboardKey::eKey1))
        {
            // window and customCursor we're captured
            window->setCursor(customCursor.get());
        }
    });

API: C++ Features Not Allowed

  • Constructors / Destructors

  • Exceptions

  • Data members

  • Virtual methods

  • Pure virtual methods

In general, most of C++ is allowed in the API except features that should be used only in the ABI (e.g. virtual methods) or features that would expose implementation details (like constructors).

Exceptions could technically be allowed, but as a group we’ve decided to not use exceptions.

API: Handling Interface Pointers

Raw interface pointers are highly discouraged in the API. Rather, interface pointers should be wrapped in smart pointers like omni::core::ObjectPtr<>. The following rules will help guide API authors:

  • Return: When returning an interface pointer, API methods should return omni::core::ObjectPtr<>. Since most API calls are simply wrappers around ABI calls and the ABI calls will internally call acquire_abi() on the returned pointer, API methods should call omni::core::steal() to create an ObjectPtr that correctly manages the pointers reference count.

  • In Parameters: Interface pointers passed to a method should be wrapped in omni::core::ObjectParam<>. This is a zero-overhead wrapper that allows the passage of both raw interface pointers and omni::core::ObjectPtr<>.

When writing API wrappers by hand, and in doubt as to how to handle interface pointers, see the output of omni.bind.

omni::core::IObject

All interfaces must inherit from omni::core::IObject:

class IObject_abi
{
protected:
    enum : TypeId { kTypeId = OMNI_TYPE_ID("omni.core.IObject") };
    virtual void* cast_abi(TypeId id) noexcept = 0;
    virtual void acquire_abi() noexcept = 0;
    virtual void release_abi() noexcept = 0;
};

IObject defines several types of functionality desirable on every Omniverse interface:

  • Reference counting via acquire_abi() and release_abi().

  • C++’s dynamic_cast<> relies on runtime type information (RTTI) which is not ABI-safe. IObject::cast_abi() provides an ABI-safe way to get dynamic_cast like functionality.

Interfaces should also identify themselves with a unique omni::core::TypeId. This isn’t strictly required by the ABI but in practice is required by other chunks of Omniverse code. omni::core::TypeId is simply a uint64_t and the OMNI_TYPE_ID() function is a compile time expression that hashes the given string to a unique value.

Designing an Interface

Philosophically, an interface’s ABI should be as minimal as possible. Design towards many interfaces with few methods instead of few interfaces with many methods. As we’ll see later, many small interfaces helps with interface “versioning”.

All interfaces must directly inherit from omni::core::IObject or inherit from an interface that inherits from omni::core::IObject. When inheriting from an interface, inherit from the interface’s API, not it’s ABI. For example, inherit from omni::core::IObject instead of omni::core::IObject_abi.

Interfaces do not support multiple inheritance as multiple inheritance is not ABI-safe. Interfaces only support single inheritance, which is ABI-safe. Implementations, however, do support multiple inheritance. See Multiple Inheritance for details.

Interfaces should be classes.

While not strictly required (as it does not affect the ABI), interfaces should utilize the omni::core::Inherits template:

OMNI_DECLARE_INTERFACE(IFoo);
OMNI_DECLARE_INTERFACE(IFooBar);

class IFoo_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("example.IFoo")>
{
protected:
    virtual void doFooStuff_abi() noexcept = 0;
};

class IFooBar_abi : public omni::core::Inherits<IFooBar, OMNI_TYPE_ID("example.IFooBar")>
{
protected:
    virtual void doFooBarStuff_abi() noexcept = 0;
};

Inherits<> is designed to work well with Implements<>, which we’ll cover later. In short, Inherits<>:

  • Defines the interface’s BaseType which is used by Implements<>’s casting code.

  • Defines the interface’s kTypeId, which is used by Implements<>’s casting code and helper functions such as omni::core::createType<>().

ABI methods (pure virtual methods) should be postfixed with _abi. The order in which ABI methods appear in the struct matters, as it affects the binary layout of the class. Since reordering methods in the interface breaks the ABI, make sure you’re happy with their order before releasing.

API methods should not be virtual (or pure virtual). API methods can be reordered since they don’t affect the ABI.

omni.bind can be used to ensure your interface is ABI-safe. When omni.bind encounters an interface (i.e. a class that inherits from omni::core::IObject), it performs a series of checks to see if the interface meets ABI requirements.

Implementing an Interface

Interface implementations can appear as inlined header code or deep within private .cpp file. Implementing an interface is similar to implementing a pure virtual base class in C++.

A simple example of how to implement an interface is the concrete Mouse class that implements IMouse (see glfw.cpp. We can see that Mouse is just an unexciting C++ class. It has a constructor, non-virtual helper methods, and several data members, some of which are complex C++ data types like std::vector. The magic of Mouse is that all of these implementation details are essentially hidden. All the Mouse class has to do to be useful to the rest of the world is implement the ABI portion of the IMouse interface. In C++, this boils down to overriding all of the pure virtual methods in IMouse.

Implements<>

Looking at Mouse, we see that all of IMouse’s ABI functions are overridden. However, IMouse inherits from IObject which has ABI functions such as cast_abi(), release_abi(), etc. Where are the implementations for those methods?

The answer is in the omni::core::Implements<> class:

class Mouse : public omni::core::Implements<omni::input::IMouse>
{
    /* ... */
};

Multiple Inheritance

The omni::core::Implements<> template generates efficient implementations of all of the methods in IObject. The template even allows for multiple inheritance. For example, let’s say we have a concrete class that implements both the IKeyboard and IMouse interfaces:

class KeyboardMouse: public omni::core::Implements<input::IKeyboard, input::IMouse>
{
    /* ... */
};

This demonstrates an important concept, while interfaces cannot use multiple inheritance, implementations can. The Implements<> template (in particular Implements<>::cast_abi()) handles all of the mundane details of ensuring the returned interface pointers are ABI-safe.

Implementations are free to use the full gamut of C++ features. The only truly important thing an implementation does is implement the ABI portion of an interface and not let exceptions escape any ABI methods.

Implementations do not need to use Implements<> to implement an interface. Implements<> is the right answer 99% of the time, but there will be cases where Implements<>’s implementation of IObject’s ABI will not make sense.

Instantiating an Interface

We previously saw that interfaces neither have constructors nor destructors. So, how does one go about instantiating an interface? Omniverse interfaces have three methods to instantiate an interface:

  • Via omni::createType<>().

  • Via a method of an already instantiated interface.

  • Via an exported function in the global scope.

In the following sections we’ll cover each method.

omni::createType<>()

omni::core::createType() is an API style wrapper around the ABI function omni::core::ITypeFactory::createType_abi(omni::core::TypeId id).

ITypeFactory is essentially a mapping from a type id to a creation function that returns an instance of the type. Said differently, ITypeFactory will map a type id to a function that knows how to call the constructor for the implementation of a type.

It is up to the application to populate this mapping from type id to creation function. While there are a myriad ways to do this (config files, explicitly calling an implementation specific registration function, plugin discovery, passing a module name to omni::core::createType(), etc), all methods boil down to calling omni::core::ITypeFactory::registerInterfaceImplementations_abi(), which accepts a list of InterfaceImplementation objects.

As an example, IWindowSystem uses the following code to describe its interface (see glfw.cpp):

void getInterfaceImplementations(const omni::InterfaceImplementation** out, uint32_t* outCount)
{
    static const char* interfacesImplemented[] = { "windowing.IWindowSystem" };
    static omni::InterfaceImplementation impls[] = {
        {
            "windowing.IWindowSystem-glfw", // implementation name
            []() { return static_cast<omni::core::IObject*>(new WindowSystem); }, // creation function
            1, // *implementation* version (not the interface version)
            interfacesImplemented, CARB_COUNTOF32(interfacesImplemented)
        }
    };

    *out = impls;
    *outCount = CARB_COUNTOF32(impls);
}

Above, we see that we’re describing the concrete class WindowSystem. We’re assigning WindowSystem a type id of “windowing.IWindowSystem-glfw” and denoting that this is the first version of the implementation. WindowSystem implements a single interface: windowing.IWindowSystem. We can see this from the definition of the WindowSystem class in glfw.cpp:

class WindowSystem : public omni::Implements<omni::IWindowSystem>
{
    /* ... */
}

If the implementation had implemented other interfaces, the interfacesImplemented array would be larger.

Once this mapping is stored in ITypeFactory, it becomes clear how it is used (from omni/core/IObject.h):

template <typename T>
inline ObjectPtr<T> createType(const char* moduleName = nullptr, uint32_t version = 0) noexcept
{
    auto obj = steal(omniGetTypeFactoryWithoutAcquire()->createType(T::kTypeId, moduleName, version));
    return obj.as<T>();
}

// ...

ObjectPtr<IWindowSystem> windowSystem = omni::core::createType<IWindowSystem>();

The createType API asks the given type for its type id, calls ITypeFactory::createType with the given type id, and then casts the returned IObject to the desired interface type. Under the hood, ITypeFactory is simply calling the creation function associated with the given type id. In this case, windowing::IWindowSystem has been mapped to the lambda we provided in getInterfaceImplementations that will create a WindowSystem object.

Above, we instantiated an implementation of an interface. However, interfaces can have many implementations. How does ITypeFactory resolve which implementation to instantiate if multiple implementations of an interface are registered? In short, when calling createType():

  1. If the given type id is a type id for an implementation, instantiate the highest registered version of the implementation.

  2. If the given type id is a type id for an interface:

    1. If the app has set a preferred implementation for the interface, use it.

    2. If no preferred implementation is set, instantiate the first registered implementation.

Fairly simple logic. However, the caller (and even the app) can set creation constraints such as “the implementation must come from a specific DLL” or “the implementation must match a given version”. Specifying these constraints will likely be rare but it is important to note that they can be set and will follow a known set of rules to resolve which implementation should be instantiated. See omni::core::ITypeFactory for more details.

A final note on ITypeFactory: not all implementations should be registered with ITypeFactory. For example, in glfw.cpp, we see that only WindowSystem is registered. Other interface implementations such as Mouse, Keyboard, Gamepad, etc are not registered. For these GLFW implementations of the interfaces, this makes sense. Consider:

ObjectPtr<IMouse> mouse = omni::createType<IMouse>(); // returns nullptr

In the GLFW implementation of IMouse, the mouse must be associated with a GLFW window. The call to createType() simply doesn’t have enough information to create a GLFW implementation of IMouse and associate it with a GLFW window.

That’s not to say there couldn’t be an implementation of IMouse that could be instantiated via omni::core::createType. For example, we could write an implementation of IMouse that directly talked to the OS’s HID system. In this case, it may make sense to register the implementation with ITypeFactory.

Interfaces Instantiating Interfaces

The second way to instantiate an interface is by having another interface return an instantiated interface. For example, IWindow will return an instantiation of IMouse when IWindow::getMouse() is called.

How did IWindow::getMouse() know which constructor to call to create an IMouse? Well, that’s an implementation detail. As a practical example, looking at glfw.cpp reveals that the GLFW wrappers Window and Mouse know about each other and are able to work together to implement both IWindow and IMouse.

Instantiation via Global Functions

The use of ITypeFactory is optional. ITypeFactory is not necessary for ABI-safety. It’s just a centralized mapping of type id’s to creation functions.

The creation functions are necessary for ABI-safety. Interfaces cannot have constructors because constructors must know about the implementation details of the object (memory layout, how to initialize members, etc). If interfaces had constructors, the constructors would need to know about implementation details, which means the interfaces would no longer be ABI-safe.

Creation functions get around this issue. Callers simply have to call a creation function which returns a fully instantiated object. No implementation details are exposed. They’re all hidden away in the implementation of the creation function.

What is the creation function doing? It’s more than likely calling the constructor of a concrete implementation of an interface. The implementation of the creation function is free to know all about the details of an interface implementation. What’s important is that none of the details leak outside the function. What is returned is an ABI-safe interface pointer.

Some implementations of interfaces may choose to expose this creation function directly to callers via a global function. For example, example-glfw.dynamic.dll exports:

OMNI_API IWindowSystem* exampleCreateGlfwWindowSystem();

If an application implicitly links to example-glfw.dynamic.dll, they can simply call exampleCreateGlfwWindowSystem() to instantiate the IWindowSystem implementation example-glfw.dynamic.dll provides. The same is true when statically linking to libraries (e.g. example-glfw.a). example.windowing.no.plugin.app shows how binary interfaces can be both statically and implicitly linked.

To demonstrate, notice that the implicitly linked version of the app grabs its IWindowSystem implementation from example-glfw.dynamic.dll:

> _build\windows-x86_64\debug\example.windowing.implicit.linking.app.exe
IWindowSystem provided by: 'c:/Users/ncournia/dev/carbonite/_build/windows-x86_64/debug/example-glfw.dynamic.dll'

The statically linked version of the app gets its IWindowSystem from within the .exe since the library was statically linked:

> _build\windows-x86_64\debug\example.windowing.static.linking.app.exe
IWindowSystem provided by: 'c:/Users/ncournia/dev/carbonite/_build/windows-x86_64/debug/example.windowing.static.linking.app.exe'

The explicitly loaded version of the app (i.e. the plugin version) grabs IWindowSystem from the plugin DLL:

>_build\windows-x86_64\debug\example.windowing.native.app.exe
IWindowSystem provided by: 'c:/users/ncournia/dev/carbonite/_build/windows-x86_64/debug/example-glfw.plugin.dll'

Even with global creation functions, application are still free to additionally register creation functions with ITypeFactory. Doing so will enable other chunks of code (outside of the core application) to still use functions like omni::core::createType<>(). For example, example-glfw.dynamic.dll exports exampleRegisterGlfwInterfaceImplementations() to allow applications to explicitly register interface implementations in omni-glfw.dynamic.dll.

Destroying an Interface

In the previous sections, we covered how there’s no ABI-safe way to call a constructor and covered the different ways users can safely instantiate objects via creation functions.

Like constructors, destructors are also not ABI-safe. Destructors must know about the memory layout of an object, an ABI-safety sin. Like creation functions, the details of destruction must be hidden away in a function to make destruction ABI-safe.

To achieve this, we could easily add a destroy_abi method to IObject, However, we already have something similar: IObject::release_abi(). release_abi() decrements the reference count and destroys the object if the count hits 0. Easy. The details of destruction are hidden away in release_abi() (behind the ABI).

Implementers will be well served to utilize the Implements<> template, which provides a reasonable implementation of release_abi(). Here’s its implementation:

virtual void release_abi() noexcept override
{
    if (0 == m_refCount.fetch_sub(1, std::memory_order_release) - 1)
    {
        std::atomic_thread_fence(std::memory_order_acquire);
        delete this;
    }
}

Much like it’s OK for creation function to call an implementation’s constructor, because release_abi()’s implementation is an implementation detail, it’s safe for release_abi() to call its own destructor via delete this.

Minimal SDK

ABI-safety is a concept. With it, we can create code that is compiled once and works across multiple tool chains, SDK releases, etc.

Omniverse interfaces define a simple ABI standard via omni::core::IObject and the rules outlined in ABI Interface Design.

Here we introduce the concept of the Minimal SDK, which is a set of interfaces that build upon Omniverse’s ABI-safe philosophy. These interfaces define a minimal feature set useful nearly to all applications.

In short, the Minimal SDK provides:

  • Interface instantiation (via ITypeFactory).

  • Module loading

  • Module discovery

  • Logging

  • Interop to Carbonite and Kit interfaces

Note, the core ABI-safe concepts, like IObject, Inherits<>, Implements<>, etc do not depend on the Minimal SDK. Rather, the Minimal SDK depends upon them.

The Minimal SDK is provided by carb.dll. Why call it carb.dll and not omni.minimal.sdk.dll?

  • carb.dll is already linked to by nearly every Omniverse application.

  • The Minimal SDK depends on most of the Carbonite interface functionality already provided by carb.dll.

Dissecting carb.dll

We can see what carb.dll provides using dumpbin.exe:

> dumpbin.exe /exports _build\windows-x86_64\debug\carb.dll

ordinal hint RVA      name

            1    0 0002BC40 acquireFramework = acquireFramework
            2    1 0002BEC0 carbGetSdkVersion = carbGetSdkVersion
            3    2 000AD5F0 carbReallocate = carbReallocate
            4    3 0002BE70 isFrameworkValid = isFrameworkValid
            5    4 000D0410 omniCoreStart = omniCoreStart
            6    5 000D09D0 omniCoreStop = omniCoreStop
            7    6 000CF080 omniCreateLog = omniCreateLog
            8    7 000E0F60 omniCreateTypeFactory = omniCreateTypeFactory
            9    8 000D01A0 omniGetBuiltInWithoutAcquire = omniGetBuiltInWithoutAcquire
            10    9 000D0240 omniGetLogWithoutAcquire = omniGetLogWithoutAcquire
            11    A 000D0370 omniGetModuleDirectory = omniGetModuleDirectory
            12    B 000D0270 omniGetModuleFilename = omniGetModuleFilename
            13    C 000D0340 omniGetStructuredLogWithoutAcquire = omniGetStructuredLogWithoutAcquire
            14    D 000D11A0 omniGetTelemetryWithoutAcquire = omniGetTelemetryWithoutAcquire
            15    E 000D0310 omniGetTypeFactoryWithoutAcquire = omniGetTypeFactoryWithoutAcquire
            16    F 0002BF90 quickReleaseFrameworkAndTerminate = quickReleaseFrameworkAndTerminate
            17   10 0002BEE0 releaseFramework = releaseFramework

We see carb.dll provides functionality for both Carbonite interfaces and Omniverse interfaces. acquireFramework() and releaseFramework() handle the initialization and shutdown of the Carbonite portion of the library. Internally, acquireFramework() instantiates a pre-determined implementation of IFramework, which it returns. It is the application’s responsibility to set the g_carbFramework variable, which many of Carbonite’s inline functions depend upon.

Likewise, omniCoreStart() and omniCoreStop() do the same for the Omniverse portion, except this time the goal is to create an instance of ITypeFactory. Here the approach differs from tryAcquireInterface(). omniCoreStart() accepts pointers to all of the interfaces needed by the Minimal SDK. If those pointers are NULL, default implementations are instantiated. Applications can call functions like omniCreateTypeFactory() to instantiate the needed inputs to omniCoreStart, though they’re free to provide their own implementations.

Omniverse relies on known function names rather than global variables. Many built-in interfaces such as ITypeFactory are accessed through a common function–omniGetBuiltInWithoutAcquire()–but every built-in interface has a specific accessor function around it. To access the application’s ITypeFactory, call omniGetTypeFactoryWithoutAcquire(). The benefit of using functions rather than a global variables is that the loader can link function symbols when statically or implicitly linking. Explicit linking provides its own challenges and is covered in Explicit Linking of Modules. Furthermore, the global variables are actually instanced per Carbonite module. By using an exported function in a dynamic library, the entire application can easily share the same instance of these built-in interfaces.

Initializing the Minimal SDK

The Minimal SDK needs several global symbols. Each app should call OMNI_APP_GLOBALS() to declare the globals.

App’s should also initialize the Carbonite and Omniverse portions of carb.dll. This is easily done via the OMNI_CORE_INIT() macro:

#include <omni/core/Omni.h>

OMNI_APP_GLOBALS();

int main(int argc, char** argv)
{
    OMNI_CORE_INIT(argc, argv);

    /* ... */
}

The OMNI_CORE_INIT() macro handles several common tasks:

  • Instantiates IFramework and ITypeFactory.

  • Parses command-line arguments and populates global settings.

  • Loads an application specific configuration file.

  • Ensures objects like IFramework and ITypeFactory are properly destroyed upon program exit.

Use of OMNI_APP_GLOBALS and OMNI_CORE_INIT is optional. Applications are free to declare Carbonite’s needed globals and initialize carb.dll in any way they see fit.

Explicit Linking of Modules

An application is able to link to code in three different ways:

  • Static Linking: Code is linked to an application at compile time.

  • Implicit Linking: Shared code (e.g. .dll or .so) is implicitly loaded by the OS when the program starts.

  • Explicit Linking: The application explicitly, while running, decides to call a function like dlopen() or LoadLibrary() to dynamically load code into its address space.

In previous sections, we covered how Omniverse interfaces supports static and implicit linking. In short:

  • Implementations can provide a global function to register themselves with ITypeFactory. For example, exampleGetGlfwInterfaceImplementations(). This enables the application (and any modules it is utilizing) to use functions like omni::core::createType<>().

  • Implementations can provide global “creation” functions that hide the implementation details of instantiating an interface. For example, exampleCreateGlfwWindowSystem().

With these two approaches, applications must, at compile time, utilize one (or both) of these approaches to access code in a static or dynamic library.

Explicit linking of modules (in Carbonite terminology “plugins”) is more involved but also more dynamic. The basic idea is simple: given a module name, ITypeFactory will open the module and call an exported function to get a list of the module’s capabilities and needs. We can see what functions are exported by a module with dumpbin.exe:

> dumpbin.exe /exports _build\windows-x86_64\debug\example-glfw.plugin.dll

ordinal hint RVA      name

        1    0 000034F0 omniModuleGetExports = omniModuleGetExports

Looking at omniModuleGetExports:

using ModuleGetExportsFn = Result(ModuleExports* out);

where ModuleExports is:

struct ModuleExports
{
    //! Magic number.  Used for sanity checking.
    uint16_t magic;

    //! Version of this structure.  Changing this will break most modules.
    uint16_t version;

    //! Size of this structure.  Here the size is sizeof(ModuleExports) + any extra space allocated at the end of this
    //! struct for ModuleExportEntry's.
    uint32_t byteCount;

    //! Pointer to the first byte of the first ModuleExportEntry.
    uint8_t* exportsBegin;

    //! Pointer to the byte after the end of the last ModuleExportEntry.
    uint8_t* exportsEnd;

    /* inline methods omitted... */
};

When a module is loaded, ITypeFactory allocates a buffer and fills the beginning of the buffer with an initialized ModuleExports structure. exportsBegin points to the beginning of a key/value database used by the module to communicate its capabilities and needs. The module populates this database with macros such as MODULE_EXPORTS_SET_EXPORTS(). For example glfw.cpp has the following definition of omniModuleGetExports():

OMNI_MODULE_API omni::Result omniModuleGetExports(omni::ModuleExports* out)
{
    OMNI_MODULE_SET_EXPORTS(out);
    OMNI_MODULE_ON_MODULE_LOAD(out, onLoad);
    return omni::kResultSuccess;
}

Above, OMNI_MODULE_ON_MODULE_LOAD() adds the following entry to the exports database:

struct ModuleExportEntryOnModuleLoad
{
    const char* type;
    ModuleExportEntryFlag flags;
    uint32_t byteCount;
    onModuleLoadFn* onModuleLoad;
};

ITypeFactory will read the database and attempt to handle each key/value pair. For example, when the ModuleExportEntryOnModuleLoad entry is encountered, ITypeFactory will call onModuleLoad to grab a list of public implementations from the module.

If ITypeFactory encounters a key it does not recognize, it will be ignored unless the key’s flags field has the fModuleExportEntryFlagRequired bit set, in which case the module load is aborted. Likewise, if the module does not provide a key ITypeFactory expects, it can abort loading the module.

The ModuleExports structure allows for easy interop with Carbonite and Kit interfaces. In order to support Carbonite facilities such as g_carbFramework in a module, the user should call one of the OMNI_MODULE_REQUIRE_CARB_*() macros. For example, here’s how test::IBar uses the macros:

OMNI_MODULE_GLOBALS();
CARB_GLOBALS("test.bar.plugin"); // declares carbonite globals g_carbFramework

OMNI_MODULE_API omni::Result omniModuleGetExports(omni::ModuleExports* out)
{
    OMNI_MODULE_SET_EXPORTS(out);
    OMNI_MODULE_ON_MODULE_LOAD(out, onLoad);
    OMNI_MODULE_ON_MODULE_STARTED(out, onStarted);
    OMNI_MODULE_ON_MODULE_CAN_UNLOAD(out, onCanUnload);
    OMNI_MODULE_ON_MODULE_UNLOAD(out, onUnload);

    OMNI_MODULE_REQUIRE_CARB_CLIENT_NAME(out); // tells ITypeFactory that g_carbClientName _must_ be defined
    OMNI_MODULE_REQUIRE_CARB_FRAMEWORK(out);

    return omni::kResultSuccess;
}

Above, OMNI_MODULE_REQUIRE_CARB_CLIENT_NAME and OMNI_MODULE_REQUIRE_CARB_FRAMEWORK are used to tell the loading ITypeFactory that if it’s unable to fill the g_carbClientName or g_carbFramework pointers, the module load should fail.

Module Life-Cycle

The life-cycle of a module is as follows:

→ Time →
omniModuleGetExports onLoad onStarted
impl1->createFn
impl2->createFn
impl3->createFn
onCanUnload onUnload

Time flows from left-to-right.

omniModuleGetExports is first called to grab pointers to the other functions. omniModuleGetExports will only be called once.

onLoad is called to get a mapping of type id’s to creation functions. During the onLoad call, ITypeFactory is not accessible by the module. onLoad will only be called once.

Once onLoad returns, the registered creation functions can be called by the application and other modules.

In the diagram, functions within a column can be called in parallel. This leads to an oddity: the onStarted function may be called during, even after, a call to one of the module’s creation functions! It is up to the module to handle this race. A simple approach is to not define onStarted and lazily perform any one time initialization. onStarted, if defined, will only be called once.

If onCanUnload is defined, it will be called by ITypeFactory when the module is requested to be unloaded. If true is returned, onUnload is called, after which the module is unloaded from the application’s process space. onUnload, if defined, is only called once.

onCanUnload may be called multiple times (never in parallel). Once onCanUnload returns true it will never be called again.

onCanUnload and onUnload may not be called under certain circumstances. See Module Unloading for details.

Module Unloading

A module’s onUnload method can be used to clean up any resources before the module is unmapped from the address space of the process.

onUnload is invoked via calls like omni::core:unregisterInterfaceImplementationsFromModule or the owning ITypeFactory’s destructor.

If onUnload is called, it is only ever called once. However, onUnload is not guaranteed to be called during the module’s lifetime. Following are cases where onUnload will not be called:

  • onCanUnload is not exported.

  • onCanUnload returns false.

  • The owning ITypeFactory is destructed after main has returned and its stack frame has been popped (i.e. destructors in main()’s stack frame have run.

  • The process abnormally exits (e.g TerminateProcess).

onUnload has access to the owning ITypeFactory. However, if onUnload is being called during the owning ITypeFactory’s destructor, the module will not be able to load additional modules.

Module Example

In glfw.cpp, we can see the following module code:

OMNI_MODULE_GLOBALS()

namespace
{

omni::Result onLoad(const omni::InterfaceImplementation** out, uint32_t* outCount)
{
    OMNI_LOG_ADD_CHANNEL(OMNI_LOG_CHANNEL);

    static const char* interfacesImplemented[] = { "windowing.IWindowSystem" };
    static omni::InterfaceImplementation impls[] = {
        {
            "windowing.IWindowSystem-glfw",
            []() { return static_cast<omni::IObject*>(new WindowSystem); },
            1, // version
            interfacesImplemented, CARB_COUNTOF32(interfacesImplemented)
        }
    };

    *out = impls;
    *outCount = CARB_COUNTOF32(impls);

    return omni::kResultSuccess;
}

} // anonymous namespace

OMNI_MODULE_API omni::Result omniModuleGetExports(omni::ModuleExports* out)
{
    OMNI_MODULE_SET_EXPORTS(out);
    OMNI_MODULE_ON_MODULE_LOAD(out, onLoad);
    return omni::kResultSuccess;
}

Above we see:

  • OMNI_MODULE_GLOBALS is called in the global scope to define needed globals.

  • omniModuleGetExports is exported and given C-linkage via OMNI_MODULE_API.

  • OMNI_MODULE_ON_MODULE_LOAD is called to tell the loading ITypeFactory about where to find implementation creation functions.

Module Discovery

Module discovery, the process of finding modules to tell ITypeFactory about, is the job of the application. This can be as simple as explicitly registering a module:

omni::registerInterfaceImplementationsFromModule("c:/Users/ncournia/myApp/MyModule.dll");

In practice, many applications use the discovery mechanisms used by existing Carbonite interfaces, such as config files or Kit extensions.

Versioning

Both interfaces and interface implementations can be independently “versioned”. Let’s start with interfaces.

Interface Versioning

The main idea behind Omniverse interface versioning is simple: an interface’s ABI is immutable. Said differently, once an interface like IWindowSystem is publicly released, its ABI will never change. Never. Methods cannot be added, removed, or reordered. Method signatures cannot change. Helper structs used to pass parameters in and out of the ABI are immutable as well.

This is powerful in its simplicity. Throughout time and space, given a IWindowSystem pointer, a user will always know the binary layout of the interface. There’s no guess work. There’s no version checks. There’s no fear of backwards and forwards compatibility. IWindowSystem’s binary layout is a universal constant. We call this versioning approach “customer oriented versioning” because it indexes on making it easier for customers to use Omniverse interfaces.

Adding Methods

If an interface’s ABI is immutable, how does one go about changing the interface? Again the answer is simple: you create a new interface. Let’s add a new method to IWindowSystem that controls the OS’s “night light” mode:

// new: we've added the IWindowSystem2 interface which inherits from IWindowSystem
class IWindowSystem2_abi : public omni::core::Inherits<IWindowSystem, OMNI_TYPE_ID("windowing.IWindowSystem2")>
{
protected:
    virtual void setNightLightModeEnabled_abi(bool enable) noexcept = 0;
    virtual bool isNightLightModeEnabled_abi() noexcept = 0;
};

Here we created a new interface IWindowSystem2 which inherits all of the functionality of IWindowSystem but adds new night light methods.

We can now update our implementation of IWindowSystem in glfw.cpp:

// new: WindowSystem now implements IWindowSystem2 instead of IWindowSystem
class WindowSystem : public omni::Implements<omni::IWindowSystem2>
{
    /* ... */
};

void getInterfaceImplementations(const omni::InterfaceImplementation** out, uint32_t* outCount)
{
    // new: we denote that WindowSystem now implements both
    // "windowing.IWindowSystem" and "windowing.IWindowSystem2"
    static const char* interfacesImplemented[] = {
        "windowing.IWindowSystem",
        "windowing.IWindowSystem2" // new
    };
    static omni::InterfaceImplementation impls[] = {
        {
            "windowing.IWindowSystem-glfw",
            []() { return static_cast<omni::IObject*>(new WindowSystem); },
            2, // new: bump version number
            interfacesImplemented, CARB_COUNTOF32(interfacesImplemented)
        }
    };

    *out = impls;
    *outCount = CARB_COUNTOF32(impls);
}

Our interface registration function, getInterfaceImplementations has now updated the interfaceImplemented field to denote that if the user requests either an windowing.IWindowSystem or an windowing.IWindowSystem2 be created, then instantiating a WindowSystem is appropriate. We also chose to increment the implementation’s version fields. More on that later when we get to implementation versioning.

From a user’s point of view, the new functionality can be accessed in several ways.

// we can directly instantiate an IWindowSystem2
omni::ObjectPtr<IWindowSystem2> windowSystem = omni::createType<IWindowSystem2>();

// if we're given an IWindowSystem, we check to see if it's really an IWindowSystem2 via a call to cast<>
void doWindowSystemStuff(IWindowSystem* windowSystem)
{
    omni::ObjectPtr<IWindowSystem2> windowSystem2 = omni::cast<IWindowSystem2>(windowSystem);
    if (windowSystem2)
    {
        windowSystem2->setNightLightModeEnabled(true);
    }
}

An alternative approach to creating IWindowSystem2 would be to create a new stand-alone interface:

// new: INightLightMode interface
class INightLightMode_abi :
    public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("windowing.INightLightMode")>
{
protected:
    virtual void setNightLightModeEnabled_abi(bool enable) noexcept = 0;
    virtual bool isNightLightModeEnabled_abi() noexcept = 0;
};

This interface does not inherit from IWindowSystem, rather it’s a whole new interface unrelated to IWindowSystem.

Looking at the implementation:

// new: use multiple inheritance to implement both IWindowSystem and INightLightMode
class WindowSystem : public omni::Implements<IWindowSystem, INightLightMode>
{
    /* ... */
};

void getInterfaceImplementations(const omni::InterfaceImplementation** out, uint32_t* outCount)
{
    // new: we denote that WindowSystem now implements both
    // "windowing.IWindowSystem" and "windowing.INightLightMode"
    static const char* interfacesImplemented[] = {
        "windowing.IWindowSystem",
        "windowing.INightLightMode" // new
    };
    static omni::InterfaceImplementation impls[] = {
        {
            "windowing.IWindowSystem-glfw",
            // note the intermediate cast to IWindowSystem to disambiguate conversion to IObject
            []() { return static_cast<omni::IObject*>(static_cast<IWindowSystem*>(new WindowSystem)); },
            2, // new: bump version number
            interfacesImplemented, CARB_COUNTOF32(interfacesImplemented)
        }
    };

    *out = impls;
    *outCount = CARB_COUNTOF32(impls);
}

The concrete IWindowSystem class is now using multiple inheritance to provide night light mode functionality. User’s can use the interface as follows:

// we can directly instantiate an INightLightMode
omni::ObjectPtr<INightLightMode> nightLight = omni::createType<INightLightMode>();

// if we're given an IWindowSystem (or really any interface), we can check to
// see if it implements INightLightMode
void doWindowSystemStuff(IWindowSystem* windowSystem)
{
    omni::ObjectPtr<INightLightMode> nightLight = omni::cast<INightLightMode>(windowSystem);
    if (nightLight)
    {
        nightLight->setNightLightModeEnabled(true);
    }
}

Removing/Changing Methods

When removing/changing the functionality provided by an interface’s ABI, an entirely new interface must be created. You can’t inherit from the old interface. You have to duplicate it and remove/change the ABI functionality you don’t like.

The alternative, removing/changing a method in the ABI and not creating a new interface, causes chaos. You’ve changed a universal constant: the interface’s ABI. As a consequence, users are forced to perform version checks everywhere to make sure an interface like IWindowSystem really is the version of the interface they want.

All of this is a pain. Removing/changing functionality can be incredibly disruptive. We’ve purposely designed the versioning scheme to discourage it.

Rather than removing/changing functionality, consider adding new/alternative functionality and deprecating the old functionality.

Pragmatic Interface Versioning

We’re all human. We change our minds. This is particularly true during the creative process. During the initial design phase of an interface, methods will be added, removed and changed. Creating a new interface for each of these changes would quickly lead to interface names like IWindowSystem26.

To avoid IWindowSystem26, we establish a simple engineering policy:

> Once published, an interface is immutable.

The goal of this versioning policy is reduce customer risk and ultimately make Omniverse a more attractive ecosystem.

API Versioning

Surprisingly, an interface’s API is not versioned. Only the ABI is “versioned”.

We’re able to get away with this because the API is entirely inlined code. Users of an interface are free to fork interface header files and update the API at will. The API does not, and will never, affect the ABI.

Implementation Versioning

Imagine releasing a DLL, myContainers.plugin.dll, which contains two implementations:

  • “my.List” which implements IList.

  • “my.HashTable” which implements IHashTable.

Later, you decide to release a new version of the DLL, myContainers.2.plugin.dll, this time with a bug fix to “my.List” and “my.HashTable” removed.

You notice that users are loading both myContainers.plugin.dll and myContainers.2.plugin.dll within the same application. They load myContainers.2.plugin.dll for the bug fix and myContainers.plugin.dll for the “my.HashTable” implementation.

ITypeFactory has a dilemma. There are two implementations of IList named “my.List”. Which one should be preferred?

As the implementation author, you can set a preference by setting the version number on the implementation:

void getInterfaceImplementations(const omni::InterfaceImplementation** out, uint32_t* outCount)
{
    static const char* interfacesImplemented[] = { "IList" };
    static omni::InterfaceImplementation impls[] = {
        {
            "my.List",
            []() { return static_cast<omni::IObject*>(new List); },
            2, // version is 2
            interfacesImplemented, CARB_COUNTOF32(interfacesImplemented)
        }
    };

    *out = impls;
    *outCount = CARB_COUNTOF32(impls);
}

If an implementation has multiple entries, by default, the highest version is preferred.

Of course, there are multiple ways for users to override this behavior:

// explicitly load version 1 of my.List
auto list = omni::createType<IList>(OMNI_TYPE_ID("my.List"), nullptr, 1));

// explicitly load the highest version of "my.List" from "myContainers.plugin.dll"
auto list = omni::createType<IList>(OMNI_TYPE_ID("my.List"), "myContainers.plugin.dll"));

// by default, when instantiating the IList interface, prefer the highest
// version of "my.List" from "myContainers.plugin.dll"
omni::setInterfaceDefaults<IList>(OMNI_TYPE_ID("my.List"), "myContainers.plugin.dll");
// will instantiate highest version of "myList" in "myContainers.plugin.dll"
auto list = omni::createType<IList>();

This gives implementation authors the ability to set a preferred implementation while giving the applications fine-grained control over which implementation should be instantiated.

In Omniverse, implementations have a version number. Interfaces do not have a version number.

Alternative Versioning Approaches Considered

During the design process of Omniverse Native Interfaces, several versioning schemes were considered.

Semantic Versioning

Carbonite’s interface versioning scheme uses Semantic Versioning. In practice, this approach is fraught with peril.

Consider, the following interface:

namespace carb
{
    struct Window; // opaque "context"

    // interface method table
    struct IWindowing
    {
        CARB_PLUGIN_INTERFACE("carb::windowing::IWindowing", 1, 0)

        Window*(CARB_ABI* createWindow)(const WindowDesc& desc);

        void(CARB_ABI* setWindowTitle)(Window* window, const char* title);
    };
}

Happy with this interface, we compile and ship carb.windowing.plugin.dll.

A day later, we decide it would be nice to also get the window title:

namespace carb
{
    struct Window; // opaque "context"

    // interface method table
    struct IWindowing
    {
        // new: we bumped the minor version
        CARB_PLUGIN_INTERFACE("carb::windowing::IWindowing", 1, 1)

        Window*(CARB_ABI* createWindow)(const WindowDesc& desc);

        void(CARB_ABI* setWindowTitle)(Window* window, const char* title);

        // new
        const char*(CARB_ABI* getWindowTitle)(Window* window);
    };
}

Again, we compile and ship carb.windowing.plugin.dll.

The following day we decide we don’t like window titles at all, so we remove all of the window title functions:

namespace carb
{
    struct Window; // opaque "context"

    // interface method table
    struct IWindowing
    {
        // new: we bumped the major version
        CARB_PLUGIN_INTERFACE("carb::windowing::IWindowing", 2, 0)

        Window*(CARB_ABI* createWindow)(const WindowDesc& desc);

        // new: remove window title methods
    };
}

A new day, a new carb.windowing.plugin.dll.

myApp.exe loads one of the three DLLs and instantiates the IWindowing interface:

IWindowing* windowing = carb::tryAcquireInterface<carb::IWindowing>();
Window* window = windowing->createWindow({ 640, 480 });

Which version of IWindowing was instantiated? We don’t know at compile time. A runtime check must be performed. carb::tryAcquireInterface does just that. After loading the DLL, carb::tryAcquireInterface checks to see if the loaded interface has a version compatible with the header used to compile the .exe.

Let’s create a new DLL, windowUser.dll that uses the generated Window context object:

void doWindowStuff(Window* window)
{
    // ...

    IWindowing* windowing = carb::tryAcquireInterface<carb::IWindowing>();
    printf("title: %s\n", windowing->getWindowTitle(window)); // crashes!

    // ...
}

The DLL crashes. Why? carb::tryAcquireInterface performs a version check to make sure that the returned interface is compatible with the header used to compile windowUser.dll. Shouldn’t that be enough?

No. There’s no guarantee that the opaque Window context is compatible with the interface. Here ‘’Window’’ was created by another DLL that implements version 2.0 of the interface. The window title simply isn’t in the context.

A common way around this is to always keep the interface and context pointers together:

void doWindowStuff(IWindowing* windowing, Window* window)
{
    // ...

    printf("title: %s\n", windowing->getWindowTitle(window)); // still crashes!

    // ...
}

Still a crash. Here, when getWindowTitle was called, it was assumed by windowUser.dll that the IWindowing pointer was a version of the interface that defined getWindowTitle. In the case above, the version was 2.0, which means the call to getWindowTitle points to garbage. Boom.

There’s a solution to this as well. Perform another runtime version check:

void doWindowStuff(IWindowing* windowing, Window* window)
{
    // ...
    if (carb::verifyInterface(windowing))
    {
        printf("title: %s\n", windowing->getWindowTitle(window)); // safe
    }

    // ...
}

Searching through existing code, you’ll find few instances where this pattern is used.

Another gotcha with semantic versioning is locking users into requiring the latest interface. Consider:

void doWindowStuff(IWindowing* windowing, Window* window)
{
    // ...
    if (carb::verifyInterface(windowing))
    {
        windowing->setWindowTitle("My Window"); // safe
    }

    // ...
}

The user originally writes this code during version 1.0 of the interface’s life and everything is great.

Let’s say the interface author releases version 1.1 of the interface (the version that added the getWindowTitle method). The user decides to recompile the code above and finds that his code no longer works with older clients! By recompiling the user code above, version 1.1 became a hard requirement, even though the code doesn’t use any version 1.1 functionality.

There’s too much burden put on interface users when using semantic labelling. Version checks have to be performed everywhere and evidence shows user simply aren’t performing those checks. Likewise, its easy to bake unneeded interface requirements into user code, causing perfectly fine code to stop working with older clients.

Omniverse interfaces take a different approach. Interfaces aren’t really “versioned”. They exist… immutable. IWindow is a known quantity. It’s ABI is guaranteed. Forever. There’s no guess work needed if some of the methods exist or don’t exist. No version checks are required.

But what about the IWindowSystem2 example above? Isn’t that a different version of the IWindowSystem interface? No. It’s a different interface that happens to be compatible with IWindowSystem.

Isn’t omni::cast<>() a version check? No. omni::cast<>() is for checking if an interface pointer implements an interface. It’s not a version check, it’s a more akin to a dynamic_cast.

Generated Smart Pointers

A criticism of Omniverse Native Interfaces is that in order to access desired functionality you need to know the proper pointer type. For example, given an IWindowSystem pointer, if you want to access the aforementioned night light functionality you must cast to the proper pointer type:

omni::ObjectPtr<IWindowSystem> ws = /* ... */
auto ws2 = omni::cast<IWindowSystem2>(ws); // cast to IWindowSystem2 to access setNightLightEnabled()
if (ws2)
{
    ws2->setNightLightEnabled(false);
}

A different approach is to hide the cast in a helper class. Here, we define WindowSystemPtr:

class WindowSystemPtr : public omni::core::ObjectPtr<IWindowSystem>
{
public:
    // constructors omitted...

    WindowPtr createWindow(uint32_t w, uint32_t h)
    {
        // since createWindow is an IWindowSystem method, no interface check is needed
        return m_ptr->createWindow(w, h);
    }

    void setNightLightEnabled(bool en)
    {
        // m_ptr is a IWindowSystem.  see if it implements IWindowSystem2
        auto ws2 = omni::cast<IWindowSystem2>(m_ptr);
        if (ws2)
        {
            ws2->setNightLightEnabled(en);
        }
        else
        {
            throw omni::core::InvalidInterface();
        }
    }

    // other IWindowSystem2 methods omitted...
};

WindowSystemPtr is essentially omni::core::ObjectPtr but with wrappers to call all of the methods in the IWindowSystem class hierarchy. A tool like omni.bind could be updated to generate these interface specific smart pointers.

One thing to note with this approach is that “customer oriented versioning” is still required. ABI methods will still accept raw interface pointers like IWindowSystem2 and the ABI is still defined as outlined in this document. Fundamentally, this approach is additional syntactic sugar in the API layer and compatible with the approach outlined in this document.

Generated smart pointers is promising and merits future investigation.

Sharing Implementations

We’ve covered how Omniverse interfaces can take advantage of inheritance to gain the functionality of another interface. This not only reduces code duplication, it also enables rich interface hierarchies.

Interfaces are just contracts that describe the layout of a binary blob. To be useful things, you not only need the description, you also need the blob. You need an implementation.

At times it’s advantageous to create a novel implementation by inheriting from an existing implementation. Consider:

// IComputeNode.h
class IComputeNode_abi : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("graph.IComputeNode")>
{
protected:
    virtual void setInput_abi(const char* name, IComputeNode* input) noexcept = 0;
    virtual int compute_abi() noexcept = 0;
};

class IIntNode : public omni::core::Inherits<omni::core::IObject, OMNI_TYPE_ID("graph.IIntNode")>
{
protected:
    virtual void setInt_abi(int i) noexcept = 0;
}

// MyComputeNodes.cpp
template <typename T>
class BaseComputeNode : omni::core::public Implements<T>
{
protected:
    virtual void setInput_abi(const char* name, IComputeNode* input) noexcept override
    {
        return m_inputs[name].borrow(input);
    }

public:
    int getInputValue(const char* name)
    {
        auto& input = m_inputs[name];
        if (input)
        {
            return input->compute();
        }
        else
        {
            return 0;
        }
    }

private:
    std::map<std::string, omni::core::ObjectPtr<IComputeNode>> m_inputs;
};

class IntNode : public BaseComputeNode<IIntNode>
{
protected:
    virtual void setInt_abi(int i) noexcept override
    {
        m_int = i;
    }

    virtual int compute_abi() noexcept override
    {
        return m_int;
    }

private:
    int m_int = 0;
};

class AddNode : public BaseComputeNode<IComputeNode>
{
protected:
    virtual int compute_abi() noexcept override
    {
        int a = getInputValue("a");
        int b = getInputValue("b");
        return a + b;
    }
}

class MultiplyAddNode : public BaseComputeNode<IComputeNode>
{
protected:
    virtual int compute_abi() noexcept override
    {
        int a = getInputValue("a");
        int b = getInputValue("b");
        int c = getInputValue("c");
        return a * b + c;
    }
}

// main.cpp
auto a = createNode<IIntNode>();
a->setInt(1);

auto b = createNode<IIntNode>();
b->setInt(2);

auto add = createNode<IComputeNode>("add");
add->setInput("a", a);
add->setInput("b", b);

printf("a+b -> %d\n", add->compute());

In the example above, concrete implementations of IComputeNode were able to share a base implemention of an IComputeNode via the BaseComputeNode template. The author of BaseComptueNode could easily ship the BaseComputeNode implementation as an inline header.

Carbonite / Omniverse Interop

When using the OMNI_CORE_INIT macro and linking to carb.dll, applications are able to use each of Carbonite, Kit, and Omniverse interfaces. Carbonite plugins are able to call Omniverse code and visa-versa. In fact, this feature is a part of the strategy to transition from Carbonite and Kit interfaces to Omniverse interfaces.

As an example, consider source/omni.core/LogImpl.cpp. Here we see that the omni::log::ILog implementation is a wrapper over LogSystem. Looking at source/framework/LoggingPlugin.cpp we see that carb::logging::ILogging _also_ points to LogSystem. In short, we have two different interfaces pointing to the same implementation, meaning messages logged with OMNI_LOG are able to be written to the same log as messages logged with CARB_LOG. Calls to CARB_LOG can be removed over time in favor of OMNI_LOG.

Conclusion

We’ve presented a new way to bring binary safe native interfaces to Omniverse. The approach presented has the following advantages over the current Carbonite interfaces:

  • Safer code via tightly coupled data and methods

  • Code reuse through inheritance

  • Familiar C++-like usage

  • Implicit linking in addition to static and explicit linking

  • DLL’s can contain multiple implementations of an interface

  • DLL’s can contain multiple versions of an implementation

  • No runtime version checks

  • Automatic generation of boiler-plate code and language bindings

  • Inspired by COM, a known industry standard

We’ve also shown that Omniverse binary interfaces can:

  • Wrap Carbonite interfaces.

  • Replace Carbonite interfaces.

  • Live along side Carbonite interfaces.