C++ Scripting: Part 11 – Collaborators, Structs, and Enums
The series to build a viable system to write Unity scripts in C++ continues! While these 11 articles have covered a lot of ground toward making a usable C++ scripting system, there’s still a lot to do. Writing the code for these articles takes quite a lot of time, so today I’m officially calling for collaborators on the GitHub project. If you’d like to join in, please leave a comment, send an e-mail, or submit a pull request. There’s plenty to do and your help would be greatly appreciated! Aside from that, today’s article is all about adding support for struct and enum types so we can use types like Vector3
and TextureFormat
from our C++ scripts.
Table of Contents
- Part 1: C#/C++ Communication
- Part 2: Update C++ Without Restarting the Editor
- Part 3: Object-Oriented Bindings
- Part 4: Performance Validation
- Part 5: Bindings Code Generator
- Part 6: Building the C++ Plugin
- Part 7: MonoBehaviour Messages
- Part 8: Platform-Dependent Compilation
- Part 9: Out and Ref Parameters
- Part 10: Full Generics Support
- Part 11: Collaborators, Structs, and Enums
- Part 12: Exceptions
- Part 13: Operator Overloading, Indexers, and Type Conversion
- Part 14: Arrays
- Part 15: Delegates
- Part 16: Events
- Part 17: Boxing and Unboxing
- Part 18: Array Index Operator
- Part 19: Implement C# Interfaces with C++ Classes
- Part 20: Performance Improvements
- Part 21: Implement C# Properties and Indexers in C++
- Part 22: Full Base Type Support
- Part 23: Base Type APIs
- Part 24: Default Parameters
- Part 25: Full Type Hierarchy
- Part 26: Hot Reloading
- Part 27: Foreach Loops
- Part 28: Value Types Overhaul
- Part 29: Factory Functions and New MonoBehaviours
- Part 30: Overloaded Types and Decimal
Again, I’m officially calling for collaborators on the GitHub project. If you’d like to join in, please leave a comment, send an e-mail, or submit a pull request. There’s plenty to do and your help would be greatly appreciated!
Even if you’re not interested in contributing, I’d appreciate it if you’d take just a few seconds to answer this poll about the project. There are only three simple questions. Poll closed. Thanks for responding!
Moving on, let’s talk about what’s been added to the project this week. The big new additions this week are support for struct and enum types, including all the bells and whistles that were supported for classes. That means we can use generics to get access to KeyValuePair<TKey, TValue>
and out
parameters so we can call Physics.Raycast
.
As it turn out, there are many kinds of types in C# and we need to support all of them to have a viable scripting system in C++. Here’s a list:
- Classes
- Structs that have class fields
- Structs that don’t have class fields
- Enums
- Primitives
- Pointers
decimal
There may actually be more classifications of types, but that’s a good list for now. So far we’ve talked about exposing classes to C++. We’ve also talked about how to use “structs that don’t have class fields” and primitives as parameters and return values, but never actually exposed them to C++. Today we’ll talk about that and how to expose enums.
First, let’s coin two terms. Structs that have class fields are called “managed structs”. Structs that don’t have class fields are called “full structs”. There might be more appropriate names than these, but they’ll suffice for the purposes of this article. Previously, we treated all primitives and full structs as being trivially copyable between C# and C++. We didn’t need an object handle to reference them. We could just pass the whole thing and make a copy.
That assumption is no longer valid once we want to start supporting managed structs. Consider a struct like RaycastHit
that has a Transform
field. We can’t simply copy this struct between C# and C++ for the same reason we can’t simply copy classes. The presence of a class field means we need a workaround. One option is to make a middle-man struct that replaces the class field with an object handle just like we did with classes:
struct RaycastHitMiddleMan { // Non-class types are as normal public Vector3 point; // Class types are object handles public int transform; }
To pass a managed struct from C# to C++, we’d need C# to make a middle-man struct, copy all non-class fields to it, make an object handle for all class fields, and set those object handles in the middle-man struct. Then on the C++ side, we’d need to make a “real” version of the struct that has nice types like Transform
instead of object handles and essentially “unpack” the middle-man struct into it by doing the reverse of what C# did.
That’s a lot of copying work every time we want to pass one of these structs across the language boundary. This copying would need to be done recursively as the struct may contain another struct that has a class field. So instead, we’ll take another path. Just like how we had an ObjectStore
to store class instances for future access with an object handle, we’ll add a new StructStore<T>
type. This will hold structs of type T
and allow access to them via int
handles. The C++ side will simply hold a handle to the struct, just like with a class.
Full structs, on the other hand, will be fully defined in C++. The code generator can write this code for us because it knows all the fields that the struct has. That means frequently-used struct types like Vector3
and Quaternion
don’t require a StructStore
and can be modified entirely on the C++ side without calling into C#. Of course calling methods, constructors, and properties still requires calling these C# functions.
Similarly to full structs, enums can be defined on the C++ side with all of their enumerators and base type intact. For example, consider the QueryTriggerInteraction
enum that’s passed to Physics.Raycast
. The C++ equivalent is simple to generate:
namespace UnityEngine { // "enum struct" behaves like C#'s struct // Size of the struct is given by its base type: int32_t/int enum struct QueryTriggerInteraction : int32_t { // All enumerators generated with their appropriate values UseGlobal = 0, Ignore = 1, Collide = 2 }; } // Example usage code QueryTriggerInteraction qti = QueryTriggerInteraction::UseGlobal;
Now that we’re supporting five kinds of types (classes, managed structs, full structs, enums, primitives), we need to map out how we’re going to pass these as parameters to C# from C++. Here’s a table showing that:
Type | Is Out? | Is Ref? | C++ OOP Param | C++ Binding Param | C# Binding Param |
---|---|---|---|---|---|
Full Struct | Yes | No | Type* | Type* | out Type |
Full Struct | No | Yes | Type* | Type* | ref Type |
Full Struct | No | No | Type& | Type& | ref Type |
Primitive | Yes | No | Type* | Type* | ref Type |
Primitive | No | Yes | Type* | Type* | ref Type |
Primitive | No | No | Type | Type | Type |
Enum | Yes | No | Type* | Type* | ref Type |
Enum | No | Yes | Type* | Type* | ref Type |
Enum | No | No | Type | Type | Type |
Managed Struct | Yes | No | Type* | int32_t* | ref int |
Managed Struct | No | Yes | Type* | int32_t* | ref int |
Managed Struct | No | No | Type | int32_t | int |
Class | Yes | No | Type* | int32_t* | ref int |
Class | No | Yes | Type* | int32_t* | ref int |
Class | No | No | Type | int32_t | int |
No two kinds of types are quite alike. In the end, we have a C++ API that’s quite “normal” when you’re used to C#. Passing an out
or ref
parameter requires a pointer (&myVariable
) so it’s opt-in like in C# where you have to type out myVariable
. Otherwise, you can simply pass the parameter and the system will handle it regardless of what kind of type it is. Here’s an example using some structs in C++:
// Call a constructor Vector3 vec(1.0f, 2.0f, 3.0f); // Read and write full struct fields without calling C# vec.x = vec.y * 2.0f; // Call a method vec.Set(10.0f, 20.0f, 30.0f); // Use generics with a managed struct type KeyValuePair<String, int32_t> pair("key", 123); // Use a "get" property String key = pair.GetKey();
As designed, this C++ code looks very similar to C# code. Actually, the only difference is calling .GetKey()
instead of accessing the property like a field with .Key
. All of the complexity is taken care of for you behind the scenes.
There are some Type&
parameters in the above table, which means “reference” in C++. This isn’t like a managed reference in C#. There’s no garbage collector or tracking whether you still have a reference to the object. Instead, it’s basically just like a pointer except it can’t be null, you don’t need to type &myVariable
to pass one, and you don’t need to type *myVariable
to get the struct back from one. It’s not suitable for class types since we need them to be null sometimes, but structs can’t be null so we’re free to use them there. The advantage over pointers is that our C++ code can look more like C# by avoiding the need for &
and awkwardness of what to do about a “null struct” that should be an impossibility.
All this use of pointers (*
) and references (&
) behind the scenes means that we’re not making any additional copies of structs. We still have to make one copy to pass the struct as a non-out
, non-ref
parameter, but that’s required by the function’s API. Passing pointers and references in the binding code between C# and C++ means we don’t need to make copies for these internal function calls.
Now that we have support for generating struct types, there’s no need to hand-code a Vector3
type on the C++ side. We can simply generate it if we want to use it. A 2D game might only generate Vector2
instead, which will also work just fine.
And that’s about all there is to structs and enums for this article. We’ve still got to support some remaining types like decimal
and C# pointers, but those are both very infrequently-used in games so they can wait for now.
If you’re interested in collaborating on the project, please leave a comment, send an e-mail, or submit a pull request.