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 fromcarb::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#
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 likeconst char*
,uint32_t
, and structs/unions that meet the requirements of standard layout types. Complex data types, suchstd::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 asint
are not allowed. An exception to this rule ischar
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 callacquire_abi()
.InOut Parameters: When accepting an interface pointer via a pointer to an interface pointer (e.g.
IFoo**
), the callee must callrelease_abi()
on the incoming pointer andacquire_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 withWithoutAcquire
. 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
andomni::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 callacquire_abi()
on the returned pointer, API methods should callomni::core::steal()
to create anObjectPtr
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 andomni::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()
andrelease_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 getdynamic_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 byImplements<>
’s casting code.Defines the interface’s
kTypeId
, which is used byImplements<>
’s casting code and helper functions such asomni::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()
:
If the given type id is a type id for an implementation, instantiate the highest registered version of the implementation.
If the given type id is a type id for an interface:
If the app has set a preferred implementation for the interface, use it.
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
andITypeFactory
.Parses command-line arguments and populates global settings.
Loads an application specific configuration file.
Ensures objects like
IFramework
andITypeFactory
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()
orLoadLibrary()
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 likeomni::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:
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 inmain()
’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 viaOMNI_MODULE_API
.OMNI_MODULE_ON_MODULE_LOAD
is called to tell the loadingITypeFactory
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.
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.