965 Errors#
When we started moving out the Unity-specific code, we realised there were hard dependencies. In the beginning we had 965 compile erros on the Godot side now (yey!).
With a lot of Unity-based functions and namespaces, that are not accessable by Godot or plain csharp. So we’re starting with dependency solving.
ServiceRegistry#
An example of a hard dependency: RuntimeConfigurator depending directly on DefaultRuntimeConfiguration. In the old code, RuntimeConfigurator held the assembly-qualified name of DefaultRuntimeConfiguration as a serialized field:
[SerializeField]
private string runtimeConfigurationName = typeof(DefaultRuntimeConfiguration).
AssemblyQualifiedName;
public static BaseRuntimeConfiguration Configuration
{
get
{
if (Instance?.runtimeConfiguration != null)
return Instance.runtimeConfiguration;
Type type = ReflectionUtils.
GetTypeFromAssemblyQualifiedName(Instance?.runtimeConfigurationName);
if (type == null)
{
type = typeof(DefaultRuntimeConfiguration);
}
Configuration = (BaseRuntimeConfiguration)ReflectionUtils.CreateInstanceOfType(type);
return Instance?.runtimeConfiguration;
}
}These would need to be moved out. It is a problem for 2 reasons. Firstly the AssemblyQualifiedName itself is different in Godot. Godot projects always take the assemblyname from the single csproj that is available in Godot they wouldn’t be interoperable. Secondly we don’t have the SerializeField in Godot. We needed to find an abstraction level that fits Godot and Unity both.
At the same time we identified that RuntimeConfigurator.Configuration is a static property. Static members are not only an antipattern, but also make the Godot implementation harder. You can’t override them.
The first naive solution was a Locator Pattern. You have a singleton class in Core and then callbacks in either implementation. This pattern survived in the ForwardingLogger:
public static class ForwardingLogger
{
public static Action<object> LogAction { get; set; }
public static void Log(object message) => LogAction?.Invoke(message);
}Wired up per engine:
// Unity
ForwardingLogger.LogAction = Debug.Log;
// Godot
ForwardingLogger.LogAction = m => GD.Print(m);But we realised that especially the RuntimeConfiguration would become messy if we did it like that. Logging is fire-and-forget. Configuration is not. It has mode handling, step locking, scene objects, user transforms. You can’t just stuff all that into Action delegates.
We identified more classes with similar singleton or static accessor patterns. We had to refactor this. But to what?
We were able to isolate 10 distinct services. Admittedly, there are probably ways to do the separation of concerns cleaner, but we also have to keep in mind that we shouldn’t refactor too much. We’re still working with production-ready code. Every change can cause a regression.
We had three ways forward:
- The Locator Pattern (what the ForwardingLogger uses. Works for simple stuff, doesn’t scale)
- Service Injection via
IService<T>interface (example below) - Build a Service Registry
We decided on the last one. Even though it causes more boilerplate, it gives us the best solutions in the end.
Services we identified#
IProcessRunnerIStepLockServiceIUserServiceIModeServiceIInputControllerIPlatformFileSystemITextToSpeechServiceIRuntimeServiceISceneObjectRegistryILanguageService
These services are scene-independent. They’re split into an interface and an implementation. This lets us keep the interfaces in ProcessEngine (engine-independent part) and have the concrete implementations in each engine.
The ServiceRegistry itself lives in ProcessEngine:
public static class ServiceRegistry
{
private static readonly Dictionary<Type, object> services = new();
public static void Register<T>(T? service) where T : class;
public static void Register<TService, TConfig>(TService? service, TConfig? config)
where TService : class, IService<TConfig>
where TConfig : IServiceConfiguration;
public static T Get<T>() where T : class;
public static bool Has<T>();
}It only has Register, Has, and Get. Registration is called from a ServiceRegistryLoader, implemented on each side. Godot and Unity.
// Unity side
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void OnRuntimeLoad()
{
Instance.Register();
}
private void Register()
{
if (initialized) return;
ServiceRegistry.Register<IProcessRunner, IProcessRunnerConfiguration>(
CreateService<IProcessRunner>(ProcessRunner),
ProcessRunnerConfiguration ?? ProcessRunnerSettings.Instance);
// ... all 10 services get registered here
initialized = true;
}Everything that needs a service can still do so with a one-liner:
// Old singleton pattern
RuntimeConfigurator.Configuration.StepLockHandling.Configure(
RuntimeConfigurator.Configuration.Modes.CurrentMode);
ProcessRunner.Initialize(process);
ProcessRunner.Run();
// New service registry pattern
ServiceRegistry.Get<IStepLockService>().Configure(
ServiceRegistry.Get<IModeService>());
ServiceRegistry.Get<IProcessRunner>().Initialize(process);
ServiceRegistry.Get<IProcessRunner>().Start();Configuration#
We then realised that each service needs a configuration, and you can’t do that with just Register<T>(service). So we utilised the existing SettingsObject in Unity and built a generic IService<T> pattern:
public interface IServiceConfiguration { } // marker
public interface IService<in T> where T : IServiceConfiguration
{
void SetConfiguration(T configuration);
}Now each service has its own configuration. For example:
public interface IProcessRunnerConfiguration : IServiceConfiguration
{
bool ResetEventsOnSceneUnload { get; }
}
public interface ISceneObjectRegistryConfiguration : IServiceConfiguration
{
ISceneObjectFinder SceneObjectFinder { get; }
ISceneObjectIdentity SceneObjectIdentity { get; }
IEditorPrefabHandler EditorPrefabHandler { get; }
}Object structure of Configurations#
So each service now also has a configuration for which, again, an interface exists in ProcessEngine and an implementation in either engine.
For Godot we decided against the counterpart of a ScriptableObject (the Resource) and chose ProjectSettings instead. This felt more natural. The service is a scene-overarching, project-wide thing, so to say.
The SettingsObject
Unity (VRBuilder.Core.Settings): Inherits from ScriptableObject, loaded via Resources.Load by type name, created as .asset files through AssetDatabase in the editor. As it was before.
public class SettingsObject<T> : ScriptableObject where T : ScriptableObject, new()
{
private static T Load()
{
T settings = Resources.Load<T>(typeof(T).Name);
if (settings == null)
{
settings = CreateInstance<T>();
AssetDatabase.CreateAsset(settings, $"Assets/MindPort/VR Builder/Resources/{typeof(T).Name}.asset");
}
return settings;
}
}Godot (TinkerFlow.Core.Settings): Plain abstract class backed by ProjectSettings with Define/Get/Set helpers and a namespace prefix.
public abstract class SettingsObject<T> where T : SettingsObject<T>, new()
{
protected abstract string SettingsPrefix { get; }
protected void Define<TValue>(string key, TValue defaultValue)
{
string path = $"{SettingsPrefix}/{key}";
if (!ProjectSettings.HasSetting(path))
ProjectSettings.SetSetting(path, Variant.From(defaultValue));
ProjectSettings.SetInitialValue(path, Variant.From(defaultValue));
}
}Same name, same role. Different base classes, different backing stores, completely separate files. The tooling is different, but the user-facing API is the same: SettingsObject<T>.Instance.SomeProperty. Or how we used it, without the Instance for the Services, as configuration lookup.
Then for some services we still needed a way to interact with elements in the scene. The process name, for example, lives in the scene because we still keep the system with a process per scene. A change in it needs to be detected, so the ProcessRunner needs to know where it is.
In Unity RuntimeConfigurationSetup (a SceneSetup that runs during editor-time) creates the PROCESS_CONFIGURATION GameObject with RuntimeConfigurator, SceneService, BaseModeHandler, and PlayerInput, then wires them into their corresponding Services throught the ServiceRegistry:
// RuntimeConfigurationSetup.cs (Unity Editor)
public override void Setup(ISceneSetupConfiguration configuration)
{
var go = new GameObject(ProcessConfigurationName);
runtimeConfigurator = go.AddComponent<RuntimeConfigurator>();
ServiceRegistry.Get<RuntimeService>().Configurator = runtimeConfigurator;
sceneService = go.AddComponent<SceneService>();
modeHandler = go.AddComponent<BaseModeHandler>();
ServiceRegistry.Get<IModeService>().ModeHandler = modeHandler;
playerInput = go.AddComponent<PlayerInput>();
}In Godot a TinkerFlow node must be in the scene. It picks up the existing nodes and wires them into the ServiceRegistry in a similar way, then the Unity counterpart:
// TinkerFlow.cs (Godot)
public partial class TinkerFlow : Node
{
public override void _EnterTree()
{
if (ServiceRegistry.Has<RuntimeService>())
{
runtimeConfigurator = this.GetNodeOrNull<RuntimeConfigurator>();
ServiceRegistry.Get<RuntimeService>().Configurator = runtimeConfigurator;
sceneService = this.GetNodeOrNull<SceneService>();
}
}
}Secondly, each service that needs a scene reference now gets its own and doesn’t rely on RuntimeConfigurator anymore.
Summary of the structure#
To round it up: each scene callback that needs it gets its own configuration. A Resource in Godot and a ScriptableObject in Unity. The SceneCallback and SceneConfiguration each have an interface that stays in ProcessEngine.
@startuml
tinkerflow-theme
class "MyServiceConfiguration\n: IServiceConfiguration" as Cfg
class "MyService\n: IService<T>" as Svc {
+ SetConfiguration(T config)
}
class "MySceneConfiguration\n: ISceneConfiguration" as SceneCfg
class "MySceneService\n: ISceneService" as SceneSvc
Cfg -[hidden]right- Svc
Cfg -[hidden]down- SceneCfg
Svc -[hidden]down- SceneSvc
SceneCfg -[hidden]right- SceneSvc
Svc -left-> Cfg : configuration
Svc -down-> SceneSvc : sceneService
SceneSvc -left-> SceneCfg : sceneConfiguration
@endumlLook into the Docs and the API for a full reference. I hope you enjoyed this Blog Post.
~Aron Schaub