Reflection and type lookup with caching, interface-typed component queries Unity does not offer, thread-safety primitives with main-thread marshalling, platform capability checks, and Resources loading that names the asset it could not find.
Summary
Between your game and the runtime underneath it sit six classes, each covering a different kind of friction: finding types by name, querying components by interface, moving work back to the main thread, asking what the current platform can do, and loading from Resources without the silent null Unity hands back.
Two of them solve problems Unity leaves open. InterfaceUtil finds components by interface, which GetComponent<T> cannot do usefully across a scene, so a system that wants “every object implementing IDamageable” has somewhere to ask. ResourceUtil wraps Resources.Load with variants that distinguish “missing and that is fine” from “missing and that is a bug”, so the second case names the path in the log rather than passing you a null that looks like the first.
TypeUtil is the reflection layer, and it caches. Type lookup by name, attribute reads, finding every type derived from a base or carrying an attribute: all the operations a plugin system, a save migrator or an editor tool needs, with the results cached so the second call is cheap. Reflection is expensive enough that doing it uncached in a loop is a real cost.
ThreadUtil is the thread-safety toolkit: atomics, volatile reads and writes, spin waits, and the main-thread marshalling that matters because almost all of Unity’s API is main-thread only. PlatformUtil answers what you are running on and what it can do. AssemblyUtil is two properties reporting whether a domain reload is in progress, which is the thing that invalidates every cached reflection result.
Use the system utilities for:
- Plugin and mod discovery: finding every type deriving from a base or carrying an attribute.
- Interface-based systems where the concrete component type is not known: damageable, interactable, saveable.
- Getting results from a background thread or an
asynccontinuation back onto the main thread safely. - Branching on platform capability rather than platform name: touchscreen, gyroscope, compute shaders.
- Loading from
Resourceswhere a missing asset should be reported at the load rather than surfacing as a null three systems later.
Features
- Cached type lookup.
GetTypeandTryGetTyperesolve a type from a string across loaded assemblies, with the result cached. - Attribute reading.
GetAttribute,GetAttributesandHasAttributeon anyMemberInfo, with an index for repeated attributes. - Type discovery.
GetDerivedTypes<T>andGetTypesWithAttribute<T>scan the loaded assemblies once and cache the result, so a plugin registry can build itself at startup with a single scan. - Interface-typed component queries.
GetComponents<T>andFindComponentsOfType<T>whereTis an interface, covering the case Unity’s own API does not. - Main-thread marshalling.
RunOnMainThreadruns immediately when already on the main thread, and otherwise posts to Unity’s synchronization context. - Atomics and volatile access.
Increment,Decrement,Add,ExchangeandCompareExchange, plus typedVolatileReadandVolatileWriteforbool,int,long,float,doubleand reference types. - Spin and yield helpers.
SpinWait,SpinUntilwith a timeout, andYieldPeriodicallyfor a long loop that should not starve other threads. - Platform identity and capability. Family checks like
IsMobileandIsConsole, specific platforms down toIsPS5andIsXboxSeries, and capability checks such asHasTouchscreenandSupportsComputeShaders. - Device information. Processor count, system and graphics memory, maximum texture size, device and OS names, for scaling quality settings to the hardware.
- Resource loading with intent.
Loadwarns when an asset is missing,LoadOptionalstays silent,TryLoadgives you the branch. All three still return null on a miss; what differs is whether it is reported. Plus async andAwaitablevariants and prefab instantiation in one call. - Domain reload detection.
AssemblyUtil.IsReloadingandReloadCount, for editor code that must not touch cached state mid-reload.
The six classes
The six classes split by which part of the runtime each one talks to.
| Class | Covers | Notes |
|---|---|---|
TypeUtil | Reflection, type lookup, attributes | Caches type lookups and derived-type scans, since scanning every loaded assembly is expensive and the answer does not change during a session. ClearCache exists for editor code that survives a recompile. IsSubclassOfRawGeneric handles the open-generic case that IsSubclassOf gets wrong, and IsNullable plus GetUnderlyingTypeOrSelf cover nullable value types when reflecting over serialized fields. |
InterfaceUtil | Component queries by interface | The gap-filler. Unity’s GetComponent<T> does work with interfaces on a single object, but there is no good scene-wide equivalent, and this provides one. Also caches, with its own ClearCache. GetInterfaceTypes reports which interfaces a type implements, for a registry that dispatches by contract. |
ThreadUtil | Threading and main-thread marshalling | IsMainThread and RunOnMainThread are the ones you will use. The atomics wrap Interlocked and the volatile helpers wrap Volatile, both with the typed overloads that avoid casting. SpinUntil takes a timeout so a wait cannot hang forever. |
PlatformUtil | What you are running on, and what it can do | All properties, no methods. Splits into family checks, specific platforms, capability checks and device information. Prefer the capability checks to the platform checks wherever one exists, since a desktop with a touchscreen exists and a phone without a gyroscope does too. |
ResourceUtil | Loading from Resources | The variants encode intent rather than changing the outcome. All of them return null when the asset is absent; Load also logs a warning naming the path, LoadOptional says nothing, and TryLoad reports the result as a bool. Choosing the right one is about whether a miss should show up in the log, not about whether you still need a null check. LoadAsync returns Unity’s request object and LoadAsyncAwaitable returns an Awaitable for async code. The InstantiatePrefab overloads load and instantiate in one call, with parent, position and rotation forms. |
AssemblyUtil | Domain reload state | Two properties. IsReloading is true while the editor is recompiling and swapping the domain, which is exactly when cached reflection results and static state are unreliable. ReloadCount lets a tool notice that a reload happened since it last looked. |
Recipes
Nothing below depends on anything else, so treat this as a lookup: find your problem, then read that one recipe.
Discover every plugin type at startup
Problem. Your game supports content modules that register themselves, and you do not want a hand-maintained list of them. Anything deriving from a base class, or carrying your attribute, should be found automatically.
Solution. GetDerivedTypes and GetTypesWithAttribute scan the loaded assemblies and cache the result, so the startup cost is paid once.
/* Scans every loaded assembly for concrete types deriving from the base. The result
is cached, so a second call during the same session is a dictionary read. */
var abilityTypes = TypeUtil.GetDerivedTypes<AbilityBase>();
foreach (var type in abilityTypes)
{
/* Attribute reads are part of the same class, so metadata and discovery use one
API rather than two. */
if (!TypeUtil.HasAttribute<RegisterAbilityAttribute>(type))
{
continue;
}
var meta = TypeUtil.GetAttribute<RegisterAbilityAttribute>(type);
_abilityRegistry.Register(meta.Id, type);
}
/* The attribute-first form when the attribute is the marker rather than the base. */
var commandTypes = TypeUtil.GetTypesWithAttribute<ConsoleCommandAttribute>();
/* Type from a string, for a save file that stored a type name. TryGetType is the safe
form for anything that came from data. */
if (TypeUtil.TryGetType(save.AbilityTypeName, out var restoredType))
{
Restore(restoredType);
}
Find everything in the scene that can take damage
Problem. Your explosion needs every object implementing IDamageable in a radius. The concrete types are unrelated, so there is no component type to search for.
Solution. InterfaceUtil queries by interface across the scene.
/* Scene-wide query by interface. Unity has no built-in way to ask this question,
so this class exists to answer it. */
var damageables = InterfaceUtil.FindComponentsOfType<IDamageable>();
foreach (var target in damageables)
{
var component = target as Component;
if (component == null)
{
continue;
}
if (blast.Contains(component.transform.position))
{
target.ApplyDamage(damage);
}
}
/* The single-object forms, for when you have the GameObject already. */
if (InterfaceUtil.TryGetComponent<IInteractable>(hitObject, out var interactable))
{
interactable.Interact(player);
}
/* And the reflection-side question: which of my contracts does this type implement?
Useful for a registry that dispatches an object to every system that wants it. */
var contracts = InterfaceUtil.GetInterfaceTypes<ISaveable>();
Get a background result back onto the main thread
Problem. You loaded save data on a worker thread, or awaited a network call, and now you need to update the UI. Almost every Unity API call throws off the main thread.
Solution. RunOnMainThread runs the action immediately if you are already on the main thread, and otherwise posts it to Unity’s synchronization context to run on the next frame.
private async void LoadProfileAsync()
{
/* Work off the main thread: parsing, decompression, a network round trip. */
var profile = await Task.Run(() => ParseProfile(bytes));
/* Back to the main thread to touch Unity objects. If this is already the main
thread the action runs inline, so the call is safe either way. */
ThreadUtil.RunOnMainThread(() =>
{
_nameLabel.text = profile.Name;
_levelLabel.text = profile.Level.ToString();
});
}
/* IsMainThread when you want to branch rather than marshal: assert in debug code, or
take a faster path that skips the marshalling entirely. */
if (!ThreadUtil.IsMainThread)
{
Log.Warning("Texture upload attempted off the main thread.", LogCategory.Renderer);
return;
}
Count work across threads without a lock
Problem. Several worker threads are processing chunks and you need an accurate count of how many finished, plus a flag the main thread can read to know they are done.
Solution. The atomics and volatile helpers, which wrap Interlocked and Volatile with typed overloads.
private int _chunksCompleted;
private bool _generationFinished;
/* Called from worker threads. Increment is atomic, so no lock and no lost updates. */
private void OnChunkDone()
{
var done = ThreadUtil.Increment(ref _chunksCompleted);
if (done == _totalChunks)
{
/* A volatile write makes the flag visible to other threads promptly, rather
than sitting in a register or a store buffer. */
ThreadUtil.VolatileWrite(ref _generationFinished, true);
}
}
/* Read from the main thread. The volatile read is the other half of the pair: without
it the compiler is free to hoist the check out of a loop and spin forever. */
private void Update()
{
if (ThreadUtil.VolatileRead(ref _generationFinished))
{
BuildMeshes();
}
}
/* CompareExchange for claim-once semantics: only the first caller gets the slot. */
if (ThreadUtil.CompareExchange(ref _ownerId, myId, 0) == 0)
{
/* We claimed it. */
}
Wait for a worker without hanging the game
Problem. You need to block briefly until a background job publishes its result, and you want a guarantee that a bug in the worker cannot freeze the game forever.
Solution. SpinUntil takes a timeout and reports whether the condition was met.
/* Returns false on timeout instead of waiting forever, so a stuck worker becomes a
recoverable error rather than a hang. */
if (!ThreadUtil.SpinUntil(() => ThreadUtil.VolatileRead(ref _resultReady), 2000))
{
Log.Error("Chunk worker did not publish a result within 2 seconds.", LogCategory.Core);
return;
}
/* YieldPeriodically inside a long worker loop, so it does not starve other threads.
The counter is passed by ref and the helper decides when to yield. */
var iterations = 0;
for (var i = 0; i < voxelCount; i++)
{
ProcessVoxel(i);
ThreadUtil.YieldPeriodically(ref iterations);
}
Scale quality to the hardware rather than to the platform name
Problem. You want lower settings on weak devices. Branching on “is this mobile” gets a flagship phone the same settings as a five-year-old budget one.
Solution. PlatformUtil reports capability and device information as well as platform identity, and the capability checks are the ones that age well.
/* Capability, not platform. A desktop with a touchscreen exists, and so does a phone
without a gyroscope, so ask about the feature you actually need. */
if (PlatformUtil.HasTouchscreen)
{
EnableTouchControls();
}
if (!PlatformUtil.SupportsComputeShaders)
{
/* Fall back to the fragment shader path rather than failing to render. */
_oceanRenderer.UseComputePath = false;
}
/* Device information for scaling rather than switching. Memory and core count say
more about what a device can handle than its family does. */
var tier = PlatformUtil.SystemMemoryMB >= 6000 && PlatformUtil.ProcessorCount >= 6
? QualityTier.High
: QualityTier.Low;
/* Texture budget from what the GPU will actually accept. */
_streamingBudget = Mathf.Min(PlatformUtil.MaxTextureSize, 2048);
/* Platform identity when the difference really is the platform: store integration,
input glyphs, a certification requirement. */
if (PlatformUtil.IsPS5 || PlatformUtil.IsXboxSeries)
{
EnableConsolePresenceIntegration();
}
/* And the build-type check, for diagnostics that ship disabled. */
if (!PlatformUtil.IsReleaseBuild)
{
EnableDeveloperOverlay();
}
Load a required asset and know immediately when it is missing
Problem. A missing prefab returns null from Resources.Load, and you discover it three systems later as a NullReferenceException with no clue what was actually absent.
Solution. The ResourceUtil variants encode whether the asset is required, so the failure surfaces at the load.
/* Load logs a warning naming the path when the asset is missing, so the failure
shows up where it happened rather than as a null reference three systems later.
It still returns null, so a required asset is worth checking as well. */
var bulletPrefab = ResourceUtil.Load<GameObject>("Prefabs/Bullet");
/* LoadOptional for something genuinely optional: a localisation override, a themed
skin that not every build ships. A null here is expected, not a bug. */
var seasonalSkin = ResourceUtil.LoadOptional<Material>("Skins/Halloween");
/* TryLoad when the branch reads better than a null check. */
if (ResourceUtil.TryLoad<AudioClip>("Audio/Stinger", out var stinger))
{
audioSource.PlayOneShot(stinger);
}
/* Exists to check without loading, for validation passes and editor tooling. */
if (!ResourceUtil.Exists<GameObject>("Prefabs/Boss"))
{
Log.Error("Boss prefab is missing from Resources.", LogCategory.Asset);
}
/* Load and instantiate in one call, with the parenting and placement forms you
would otherwise write around it. */
var muzzleFlash = ResourceUtil.InstantiatePrefab("FX/MuzzleFlash", muzzle.position,
muzzle.rotation, effectsRoot);
/* The typed form returns the component rather than the GameObject. */
var spawnedTurret = ResourceUtil.InstantiatePrefab<Turret>("Prefabs/Turret", baseTransform);
Load without stalling the frame
Problem. Loading a large asset on demand causes a visible hitch.
Solution. Two async shapes, depending on whether your code is coroutine-based or async.
/* Coroutine style: Unity's ResourceRequest, yielded from an IEnumerator. */
private IEnumerator PreloadBoss()
{
var request = ResourceUtil.LoadAsync<GameObject>("Prefabs/Boss");
yield return request;
_bossPrefab = request.asset as GameObject;
}
/* Async style: an Awaitable, for code already using async/await. */
private async Awaitable PreloadBossAsync()
{
_bossPrefab = await ResourceUtil.LoadAsyncAwaitable<GameObject>("Prefabs/Boss");
}
Keep editor tooling honest across a recompile
Problem. Your editor window caches reflection results, and after a script recompile those cached Type objects belong to a domain that no longer exists.
Solution. AssemblyUtil reports reload state, and both caching classes expose a clear.
/* True while the editor is swapping the domain, which is when cached reflection
results and static state are not to be trusted. */
if (AssemblyUtil.IsReloading)
{
return;
}
/* ReloadCount lets a tool notice a reload happened since it last looked, without
subscribing to the editor's own callbacks. */
if (_seenReloadCount != AssemblyUtil.ReloadCount)
{
_seenReloadCount = AssemblyUtil.ReloadCount;
TypeUtil.ClearCache();
InterfaceUtil.ClearCache();
RebuildInspectorModel();
}
API Sketch
All six live in Scylla.Core.Util.
/* ---------------------------------------------------------------- */
/* TypeUtil: reflection, cached */
/* ---------------------------------------------------------------- */
Type GetType(string typeName);
bool TryGetType(string typeName, out Type type);
T GetAttribute<T>(MemberInfo member, int index = 0);
Attribute GetAttribute(MemberInfo member, Type attributeType, int index = 0);
T[] GetAttributes<T>(MemberInfo member);
bool HasAttribute<T>(MemberInfo member);
bool IsSubclassOfRawGeneric(this Type toCheck, Type baseType);
Type[] GetDerivedTypes<T>();
Type[] GetDerivedTypes(Type baseType);
Type[] GetTypesWithAttribute<T>();
bool IsNullable(this Type type);
Type GetUnderlyingTypeOrSelf(this Type type);
void ClearCache();
/* ---------------------------------------------------------------- */
/* InterfaceUtil: component queries by interface */
/* ---------------------------------------------------------------- */
T[] GetComponents<T>();
T[] GetComponents<T>(bool includeInactive);
T[] FindComponentsOfType<T>();
T[] FindComponentsOfType<T>(bool includeInactive);
T[] GetComponents<T>(GameObject gameObject);
T GetComponent<T>(GameObject gameObject);
bool TryGetComponent<T>(GameObject gameObject, out T component);
bool HasComponent<T>(GameObject gameObject);
Type[] GetInterfaceTypes<T>();
Type[] GetInterfaceTypes(Type interfaceType);
void ClearCache();
/* ---------------------------------------------------------------- */
/* ThreadUtil: threading and main-thread marshalling */
/* ---------------------------------------------------------------- */
bool IsMainThread { get; }
void RunOnMainThread(Action action);
void ProcessMainThreadQueue(); // only needed on the fallback path; see the callout
int Increment(ref int location);
int Decrement(ref int location);
int Add(ref int location, int value);
int Exchange(ref int location, int value);
int CompareExchange(ref int location, int value, int comparand);
bool VolatileRead(ref bool location); void VolatileWrite(ref bool location, bool value);
int VolatileRead(ref int location); void VolatileWrite(ref int location, int value);
long VolatileRead(ref long location); void VolatileWrite(ref long location, long value);
float VolatileRead(ref float location); void VolatileWrite(ref float location, float value);
double VolatileRead(ref double location); void VolatileWrite(ref double location, double value);
T VolatileRead<T>(ref T location);
void MemoryBarrier();
void Yield();
void Sleep(int milliseconds);
void SpinWait(int iterations);
bool SpinUntil(Func<bool> condition, int timeoutMilliseconds = -1);
void YieldPeriodically(ref int iterationCount, int yieldFrequency = 256);
/* ---------------------------------------------------------------- */
/* PlatformUtil: all properties */
/* ---------------------------------------------------------------- */
/* Families */ bool IsDesktop, IsMobile, IsConsole, IsWeb, IsWSA;
/* Platforms */ bool IsWindows, IsOSX, IsLinux, IsIOS, IsAndroid, IsWebGL, IsTVOS;
/* */ bool IsPlayStation, IsPS5, IsXbox, IsXboxSeries, IsSwitch;
/* Build */ bool IsReleaseBuild, IsDualInputEnabled;
/* Capabilities */ bool HasTouchscreen, SupportsGyroscope, SupportsAccelerometer;
/* */ bool SupportsVibration, SupportsLocationService, SupportsComputeShaders;
/* Device */ string DeviceName, DeviceModel, OperatingSystem, ProcessorType;
/* */ int ProcessorCount, SystemMemoryMB, GraphicsMemoryMB, MaxTextureSize;
/* ---------------------------------------------------------------- */
/* ResourceUtil: Resources loading with intent */
/* ---------------------------------------------------------------- */
T Load<T>(string path); // warns on a miss, still returns null
T LoadOptional<T>(string path); // silent on a miss, returns null
bool TryLoad<T>(string path, out T resource);
T[] LoadAll<T>(string path);
T GetBuiltin<T>(string path);
bool Exists(string path);
bool Exists<T>(string path);
ResourceRequest LoadAsync<T>(string path);
async Awaitable<T> LoadAsyncAwaitable<T>(string path);
GameObject InstantiatePrefab(string path);
GameObject InstantiatePrefab(string path, Transform parent);
GameObject InstantiatePrefab(string path, string name, Transform parent);
GameObject InstantiatePrefab(string path, Vector3 position, Quaternion rotation, Transform parent = null);
T InstantiatePrefab<T>(string path);
T InstantiatePrefab<T>(string path, Transform parent);
T InstantiatePrefab<T>(string path, Vector3 position, Quaternion rotation, Transform parent = null);
/* ---------------------------------------------------------------- */
/* AssemblyUtil: domain reload state */
/* ---------------------------------------------------------------- */
bool IsReloading { get; }
int ReloadCount { get; }
See the API reference for full signatures, remarks, and exception contracts on every member.
Best Practices
- Do type discovery once, at startup.
GetDerivedTypescaches, but the first call still scans every loaded assembly. Build your registry during initialization rather than lazily on first use in gameplay. - Prefer a self-registering list to a repeated scene-wide interface query.
FindComponentsOfTypewalks the scene; objects adding themselves inOnEnableand removing themselves inOnDisablecosts nothing per query. - Use
IsSubclassOfRawGenericwhenever the base is an open generic.IsSubclassOfreturns false forAbilityBase<>against a closed subclass, and that false looks like a missing type rather than a wrong question. - Wrap Unity API calls from async continuations in
RunOnMainThread. Anawaitcan resume anywhere, and most of Unity’s API throws off the main thread. The call is cheap when you are already on it. - Pair every volatile write with a volatile read. Writing volatile and reading normally gives you no guarantee at all; the compiler can still hoist the read out of a loop.
- Give
SpinUntila timeout. The default is to wait forever, which turns a worker bug into a hung game rather than a logged error. - Ask about capability, not platform, wherever both exist. A desktop with a touchscreen and a phone without a gyroscope both exist, and the capability check is the one that stays correct as hardware changes.
- Scale on device information rather than switching on platform family. Memory and core count separate a flagship phone from a budget one;
IsMobiledoes not. - Pick the
ResourceUtilvariant that matches your intent. All three return null on a miss, so the choice is about whether it is reported:Loadwarns,LoadOptionaldoes not,TryLoadhands you a bool. UsingLoadOptionalfor a required asset is how a missing file becomes a silent null. - Clear the reflection caches on domain reload in editor tooling.
TypeUtilandInterfaceUtilboth cache, and cachedTypeobjects from a previous domain are not valid after a recompile. - Check
AssemblyUtil.IsReloadingbefore touching static state in editor code. A reload in progress is exactly when caches and statics are being torn down. - Prefer
Resourcesfor small, always-needed assets only. Everything in aResourcesfolder is included in the build and loaded into the catalog. For anything large or conditional, Addressables is the better tool.
Pitfalls
Related
- 01 Scylla Core
- 43 Core Utilities – Unity Objects
- 24 Core Utilities – Serialization
- 07 Core Architecture – Module System
- API reference:
Scylla.Core.Util.TypeUtil - API reference:
Scylla.Core.Util.InterfaceUtil - API reference:
Scylla.Core.Util.ThreadUtil - API reference:
Scylla.Core.Util.PlatformUtil - API reference:
Scylla.Core.Util.ResourceUtil - API reference:
Scylla.Core.Util.AssemblyUtil