knitr::opts_chunk$set( collapse = TRUE, comment = "#>" )
This document provides an overview of the inner workings of unifir, focusing on how scripts, props, and actions work together to produce a Unity scene. The tone and focus here are going to be extremely technical and fiddly; for a friendlier introduction to unifir 101 - A user's guide.
At a high level, unifir operates around the idea of a script object, a
container that stores all the parameters and instructions you want to run
through Unity in a single place. Those parameters and instructions are specified
in the form of props, which let users add certain parameterized pre-written
commands to their script to execute in sequence. The actual execution is then
handled by the action()
function, which both prepares the script for execution
and then executes it. This basic pattern is modeled after the fantastic
recipes package.
With that framework established, let's walk through what exactly scripts and
props are and how they interact with action()
. But first, we should talk about
waiver()
.
As we'll discuss in a moment, unifir does a lot of input checking to make sure that scripts are going to execute successfully in an attempt to give users more useful feedback than Unity's command line interface often offers. That involves making sure that scripts and props have the correct values provided to all of their parameters, and also validating that the user is going to have a working version of Unity to run these commands in at all.
Of course, a short list of places that don't have a working version of Unity includes "CRAN check machines" and "GitHub actions". And so as a result, even the simplest function calls will fail on CRAN -- for instance, running the following would throw an error:
library(unifir) make_script("example")
library(unifir)
In order to work around this, I've borrowed an idea (and the entire function)
from ggplot2: waiver()
. The waiver()
function is a way to indicate to unifir
that yes, you know this value normally needs to be provided, and you know
that it's missing, and that's fine. All waiver()
does is return an object of
class waiver
:
waiver()
On its own, this object doesn't do anything. However, in a few key places,
unifir will understand waiver()
as meaning "don't validate this argument",
which is essential for some odd props and CRAN checks to succeed. I'll be using
it throughout this vignette, starting with:
The "script" is the core object in unifir. To create a script, we use
make_script
:
script <- make_script( project = file.path(tempdir(), "unifir"), unity = waiver() # Don't error if we can't find Unity ) script
This creates an R6 object of the class
unifir_script
. If you haven't worked with R6 before, I recommend checking out
the section of Advanced R on the subject.
At the end of the day, however, a script is effectively just a glorified list.
We can check out its contents using names()
:
names(script)
Some of these contents -- .__encols_env__
, clone
, initialize
-- will be
familiar to anyone used to working with R6. Others, such as the file names for
the scene and script unifir is operating on, are set in make_script()
(and
documented in ?make_script
) and default to NULL
if not set:
all( is.null(script$initialize_project), is.null(script$scene_name), is.null(script$script_name) )
Others, like using
, beats
, and props
, are a little bit more involved.
We'll use these objects in order to track the props we add to our script; to
talk about that, it's time we talk about props.
At a low level, a unifir prop is just another R6 object, created using the
function unifir_prop()
:
prop_file <- tempfile() file.create(prop_file) prop <- unifir_prop( prop_file = prop_file, method_name = "ExampleName", method_type = "ExampleMethod", build = function(script, prop, debug) {}, using = "ExampleDependencies", parameters = list() ) prop
Just as before, we can see our prop's contents using names()
:
names(prop)
These fields are all documented in ?unifir_prop
.
Prop objects are the actual method unifir uses to translate R inputs into C# methods. A typical prop will take some input parameters, provided either by the prop constructor function (more on that in a moment) or set at the script level, interpolate them into a pre-written C# method, and then add that method to a pile of C# code that will be run in sequence to produce a scene. Of course, the details of this process depend on what exactly the C# code is expected to do.
Most of those details are sorted out by prop constructor functions. Rather than
forcing users to use unifir_prop()
directly, unifir provides a number of
wrappers around this function to add specific props to a script. For instance,
if we look at the code that powers our new_scene()
function:
new_scene
This prop takes two parameters -- setup
and mode
-- and the top of the
script makes sure they exist and are passed correctly.
We then get to the internal unifir_prop()
call.
This call passes system.file("NewScene.cs", package = "unifir")
to the
prop_file
argument; if we print that file out we can see that it's a
relatively simple C# method:
readLines(system.file("NewScene.cs", package = "unifir"))
This method calls EditorSceneManager.NewScene
, part of Unity's scripting API,
and uses it to create a new scene. Because this code relies on the
"UnityEngine.SceneManagement", "UnityEditor", and "UnityEditor.SceneManagement"
namespaces, we've included those namespaces in our prop's using
argument.
Our R code is only going to edit three
parts of this function, marked by %
signs -- the method_name
, setup
, and
mode
arguments will all be replaced by their equivalent values in R.
Moving further along the unifir_prop
call, we can then see that method_name
is set to NULL
by default.
method_name
is a unique identifier for this method of this prop,
so should not be hard-coded or provided as a default in your prop constructors.
If you leave it as NULL
, unifir will attempt to generate a name
made of 4 random English words to fill in the space.
The next argument, method_type
, is internally set to NewScene
-- users
cannot control this value. method_type
is meant to be a certain "key"
associated with your specific type of prop, which other props might search for
if they depend on or conflict with your code; as such, it generally
shouldn't be configurable by your users.
We then see that our function parameters are passed as a list to the
parameters
argument. These will be essential for our next argument, the
build()
function, which constructs our C# method.
The build()
function of every prop must take three
(and only three) arguments: script
, the unifir script a prop is stored in,
prop
, the prop being built, and debug
, which is discussed below.
build()
methods with more arguments than this will cause errors.
As a result, all other parameters you need to construct your C# method
must be stored either in the prop or script object, and it is generally
easiest to store them in parameters
(which is not checked by the R6 class).
The actual build function here is relatively simple,
using glue
to replace the snippets between %
symbols with their R
equivalents. If we don't change any of the default arguments to this function,
that means our output C# method will look something like this:
script <- new_scene(script) script$props[[1]]$build(script, script$props[[1]])
The last part of our prop constructor is the add_prop
function, which
registers the prop as part of our script. Its code is incredibly simple,
and mostly deals with creating the script$beats
table:
add_prop
That table is relatively simple, storing four variables: idx
, the order that
methods will be executed in, name
, the method_name
of each method,
type
, the method_type
of each method, and exec
, a boolean representing
whether or not that method should be called in the final C# script:
script$beats
With our props and scripts written, it's showtime! We can use the action
function to transform our R6 objects into an actual C# script, and then
execute that script in Unity.
The action()
does quite a few things. Namely, it:
NULL
. build()
method of each prop, in order of script$beats$idx
, and
stores the constructed C# methods back in the script.script$beats$exec
is TRUE
in sequential order. write = TRUE
, writes a final C# script to file.exec = TRUE
, executes the final C# script in Unity.Most of this process is internal and doesn't matter for any prop constructors
you write. So long as your prop is idempotent and can be constructed using its
own build
argument, action()
shouldn't create any issues.
However, if it does cause trouble, action()
returns a constructed script
object with props replaced by their equivalent C# code. That makes it easy to
see how unifir interpreted your build argument; for instance, we can run our
example script through action()
as so:
script <- make_script( project = file.path(tempdir(), "unifir"), unity = waiver(), # Don't error if we can't find Unity initialize_project = FALSE, # Don't create the project -- so this runs on CRAN script_name = "example_script" ) script <- new_scene(script) exec_script <- action( script, exec = FALSE, write = TRUE )
If we're concerned about how unifir translated our prop code, we can find the
rendered C# inside props
:
exec_script$props
To see the entire produced C# script, we need to read the actual script file itself:
readLines( file.path(tempdir(), "unifir", "Assets", "Editor", "example_script.cs") )
Notice how this code matches our prop, with two additions: first, all the
namespaces we provided to using
are now imported at the top of the script,
and second a function MainFunc
has been created to call our prop.
When executing the C# script, unifir will execute this MainFunc
method,
which will in turn call each prop once in the order it's listed in
script$beats
.
One last thing to know about unifir is that it is also built with a "debug" mode, in which functions will make no changes to your file system. unifir code checks if it's running in debug mode using the following code:
function() { debug <- FALSE if (Sys.getenv("unifir_debugmode") != "" || !is.null(options("unifir_debugmode")$unifir_debugmode)) { debug <- TRUE } debug }
So if unifir_debugmode
is set to any value either as an environment variable
or as an option, unifir will avoid writing anything to file or making any
changes to a user's computer.
When action()
is called, it will provide the current state of debug
to
your prop's build
function. For the majority of props, this can be safely
ignored; if all your prop does is add C# code to the final script, action()
will respect debug on your behalf. However, if your prop makes changes to the
file system before the script is actually executed -- for instance, by moving
prefabs to the project directory or editing configuration files from R --
make sure to wrap those sections of your prop in if (!debug)
!
While I've avoided getting too deep into the underlying mechanics of R6 here, there's one stumbling block that I want to flag for anyone interested in developing with unifir.
The vast majority of objects in R have what's referred to as "copy-on-modify"
semantics. Say for instance you have some object x
:
x <- 2 x
If you assign x
to a new object, y
, we'd expect y
and x
to both have the
same value:
y <- x y == x
As an optimization on R's part, not only are these objects both the same value,
but they actually both point to the same piece of data on your machine. R only
actually creates a new variable, pointing to its own unique data, when you
actually modify the object. As a result, when you change the value of x
,
you don't in turn change the value of y
: R makes a copy when you modify the
original object, so they now point to different data:
x <- 1 y == x
The same is not true for R6 objects, like unifir scripts and props. If you assign a prop to a new object, both of the objects will point to the same data, and changing one object will change them both. This is true whether you change the new object:
other_prop <- prop other_prop$method_name <- "NewName" prop$method_name
Or the original one:
prop$method_name <- "AnotherName" other_prop$method_name
Instead, with R6 objects, we need to make an explicit copy. We can do this using
the clone()
function inside our prop object, like so:
disconnected_prop <- prop$clone()
This creates an actual disconnected object, with the same values as the original it was cloned from. Now we can make changes to our prop (or script) without impacting any of the other copies:
disconnected_prop$method_name <- "OnlyIGetThisName" prop$method_name
Make sure that, if you're trying to have multiple different versions of a prop
or a script, you use clone()
in your own code!
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.