Zero-Allocation Interop#
When calling C# methods from JavaScript in a tight game loop, even small allocations add up. The standard CS proxy uses reflection and allocates memory on every call. For per-frame operations like GPU dispatch, physics queries, or input processing, this causes GC pressure and frame hitches.
OneJS provides two zero-allocation interop systems:
- FastPath — Transparently makes regular
CSproxy calls zero-alloc. No JS changes needed. Best foruseFrameSyncand property reads. zaAPI — Explicit zero-alloc function bindings from JavaScript. Best for complex static method calls (GPU compute, physics, etc.).
The Problem#
The standard CS proxy allocates on every call:
function update() {
// Each call allocates ~80-200 bytes:
// - String marshaling for method name
// - Object[] for arguments
// - Boxing for value types
CS.UnityEngine.Physics.Raycast(origin, direction, 100, layerMask)
}At 60fps with multiple calls per frame, this creates megabytes of garbage per second.
FastPath: Zero-Alloc CS Proxy#
The simplest way to eliminate allocations is the FastPath system. It intercepts regular CS proxy calls and handles them with pre-registered typed delegates — no JavaScript changes needed.
How It Works#
When JavaScript reads player.Health, the dispatch layer checks a fast-path registry before allocating any managed strings or doing reflection. If a handler is registered for that type + property, it executes directly with zero allocation. If not, it falls back to the standard reflection path.
Built-in Registrations#
Common Unity types are pre-registered automatically:
- Time:
deltaTime,unscaledDeltaTime,time,frameCount,timeScale, etc. - Transform:
position,localPosition,rotation,localScale,forward,eulerAngles, etc. - GameObject:
activeSelf,activeInHierarchy,name,tag,layer,transform - Input:
mousePosition,GetKey,GetAxis, etc. (legacy input) - Screen:
width,height,dpi - Mathf:
Sin,Cos,Abs,Sqrt,Floor,Ceil,Round
Registering Your Own Properties#
For your own types (game state, player controllers, etc.), register properties in C#:
using UnityEngine;
public class PlayerController : MonoBehaviour
{
public int Health { get; set; } = 100;
public int Gold { get; set; } = 50;
public float Speed { get; set; } = 5f;
public Vector3 Velocity => _rb.linearVelocity;
void Awake()
{
// Register once — all instances of this type benefit
QuickJSNative.FastPath.Property<PlayerController, int>("Health", p => p.Health);
QuickJSNative.FastPath.Property<PlayerController, int>("Gold", p => p.Gold);
QuickJSNative.FastPath.Property<PlayerController, float>(
"Speed", p => p.Speed, (p, v) => p.Speed = v // getter + setter
);
QuickJSNative.FastPath.Property<PlayerController, Vector3>("Velocity", p => p.Velocity);
}
}JavaScript code stays the same — useFrameSync and direct property access both benefit:
// These are now zero-alloc on the C# side
const health = useFrameSync(() => player.Health)
const gold = useFrameSync(() => player.Gold)
const vel = useFrameSync(
() => player.Velocity,
(v) => [v.x, v.y, v.z]
)Available Registration Methods#
// Instance properties
FastPath.Property<TTarget, TValue>(name, getter)
FastPath.Property<TTarget, TValue>(name, getter, setter)
// Static properties
FastPath.StaticProperty<TOwner, TValue>(name, getter)
FastPath.StaticProperty<TOwner, TValue>(name, getter, setter)
// Instance methods (0-2 args)
FastPath.Method<TTarget>(name, action)
FastPath.Method<TTarget, TResult>(name, func)
FastPath.Method<TTarget, TArg0, TResult>(name, func)
FastPath.Method<TTarget, TArg0>(name, action)
// Static methods (0-3 args)
FastPath.StaticMethod<TOwner, TResult>(name, func)
FastPath.StaticMethod<TOwner, TArg0, TResult>(name, func)
FastPath.StaticMethod<TOwner, TArg0, TArg1, TResult>(name, func)Supported Return Types#
FastPath handles these types without boxing:
| Type | How it's returned |
|---|---|
int, float, double, bool, long | Packed directly into InteropValue struct |
Vector2, Vector3, Vector4 | Float components packed into struct |
Quaternion, Color | Float components packed into struct |
string | UTF8 marshaled (allocates on C# side) |
| Reference types | Handle registered, typeHint string allocated |
FastPath vs za API#
| FastPath | za API | |
|---|---|---|
| JS code changes | None | Must use za.fromId() / za.static() |
Works with useFrameSync | Yes, transparently | No (different call path) |
Works with CS proxy | Yes, transparently | No (different call path) |
| Registration | C# only | C# + JS |
| Best for | Property reads, simple methods | Complex static methods, GPU dispatch |
CS proxy code and useFrameSync to be zero-alloc without touching JavaScript. Use the za API when you need explicit zero-alloc functions for complex call patterns (GPU compute, physics batching, etc.).
The za API#
The za (zero-alloc) module provides two binding modes:
- Pre-registered bindings (
za.fromId) - Truly zero-allocation per call - Dynamic bindings (
za.static,za.method) - Reduced allocations, faster thanCSproxy
import { za } from "onejs-unity/interop"
// C# exposes binding IDs (pre-registered with QuickJSNative.Bind<T>())
declare const PhysicsBindings: { raycast: number }
const raycast = za.fromId(PhysicsBindings.raycast, 4)
// Per-frame (zero allocations!)
function update() {
if (raycast(origin, direction, 100, layerMask)) {
// hit something
}
}API Reference#
za.static(typeName, methods)#
Bind multiple static methods on a C# class. Uses reflection internally, so still allocates per call (but less than CS proxy):
import { za } from "onejs-unity/interop"
const Physics = za.static("UnityEngine.Physics", {
Raycast: 4, // shorthand: 4 arguments
SphereCast: { args: 5, returns: "bool" }, // with metadata
OverlapSphereNonAlloc: 4,
})
// Faster than CS proxy, but still allocates (reflection-based)
Physics.Raycast(origin, direction, maxDistance, layerMask)Note: For truly zero-allocation calls, use za.fromId() with pre-registered C# bindings.
za.method(typeName, methodName, argCount)#
Bind a single method:
const getTime = za.method("UnityEngine.Time", "get_time", 0)
const getDeltaTime = za.method("UnityEngine.Time", "get_deltaTime", 0)
function update() {
const t = getTime()
const dt = getDeltaTime()
}za.fromId(bindingId, argCount)#
Use a pre-registered binding ID from C#:
// C# pre-registers bindings and exposes IDs
declare const GPUBindingIds: { dispatch: number, setFloat: number }
const dispatch = za.fromId(GPUBindingIds.dispatch, 5)
dispatch(shaderHandle, kernelIndex, x, y, z)Supported Types#
Arguments and return values support these types:
| Type | JS Value | Notes |
|---|---|---|
int | number | Truncated to integer |
float | number | |
double | number | |
bool | boolean | |
string | string | Valid during call only |
| Object handle | Object with __csHandle | C# object reference |
Vector2 | { x, y } | |
Vector3 | { x, y, z } | |
Vector4 | { x, y, z, w } | |
Color | { r, g, b, a } |
Creating Custom Bindings#
For maximum performance, pre-register bindings in C# and expose the IDs to JavaScript.
Step 1: Create C# Static Methods#
Zero-alloc bindings only work with static methods. For instance methods, create static wrappers:
// MyGameBridge.cs
using OneJS;
public static class MyGameBridge
{
// Direct static method - works directly
public static float GetHealth(int entityHandle)
{
var entity = ObjectRegistry.Get<Entity>(entityHandle);
return entity.Health;
}
// Instance method wrapper - takes handle as first arg
public static void SetHealth(int entityHandle, float value)
{
var entity = ObjectRegistry.Get<Entity>(entityHandle);
entity.Health = value;
}
// Method with Vector3 parameter
public static void MoveEntity(int entityHandle, float x, float y, float z)
{
var entity = ObjectRegistry.Get<Entity>(entityHandle);
entity.transform.position = new Vector3(x, y, z);
}
}Step 2: Register Bindings in C##
Register the methods with typed delegates for truly zero-allocation calls:
// MyGameBridgeInit.cs
using OneJS;
using UnityEngine;
public static class MyGameBridgeInit
{
// Binding IDs exposed to JavaScript
public static int GetHealthId { get; private set; }
public static int SetHealthId { get; private set; }
public static int MoveEntityId { get; private set; }
[RuntimeInitializeOnLoadMethod]
public static void Initialize()
{
// Register with typed delegates - no boxing at call time
GetHealthId = QuickJSNative.Bind<int, float>(
(handle) => MyGameBridge.GetHealth(handle)
);
SetHealthId = QuickJSNative.Bind<int, float>(
(handle, value) => MyGameBridge.SetHealth(handle, value)
);
MoveEntityId = QuickJSNative.Bind<int, float, float, float>(
(handle, x, y, z) => MyGameBridge.MoveEntity(handle, x, y, z)
);
}
// Expose IDs to JavaScript via a method
public static string GetBindingIds()
{
return $"{{\"getHealth\":{GetHealthId},\"setHealth\":{SetHealthId},\"moveEntity\":{MoveEntityId}}}";
}
}Step 3: Use from JavaScript#
import { za } from "onejs-unity/interop"
// Get binding IDs from C# (one-time)
const ids = JSON.parse(CS.MyGameBridgeInit.GetBindingIds())
// Create zero-alloc functions
const getHealth = za.fromId(ids.getHealth, 1)
const setHealth = za.fromId(ids.setHealth, 2)
const moveEntity = za.fromId(ids.moveEntity, 4)
// Per-frame usage - zero allocations!
function update() {
const health = getHealth(playerHandle)
if (health < 50) {
setHealth(playerHandle, health + 10 * deltaTime)
}
moveEntity(playerHandle, pos.x, pos.y, pos.z)
}Dynamic Binding (Convenience Mode)#
If you don't want to pre-register in C#, use dynamic binding. It's faster than the CS proxy but still allocates per call due to reflection:
import { za } from "onejs-unity/interop"
// Dynamic binding - reflection at init time
const MyGame = za.static("MyNamespace.MyGameBridge", {
GetHealth: 1,
SetHealth: 2,
MoveEntity: 4,
})
// Per-frame - faster than CS proxy, but still allocates
function update() {
const health = MyGame.GetHealth(playerHandle)
MyGame.MoveEntity(playerHandle, x, y, z)
}The difference:
| Approach | Init Cost | Per-Call Cost | Best For |
|---|---|---|---|
Pre-registered (za.fromId) | C# setup | Zero-alloc | Maximum performance |
Dynamic (za.static) | Reflection | Allocates (reduced) | Development, non-critical paths |
Standard CS proxy | None | Allocates (most) | Prototyping |
Real-World Example: GPU Compute#
The GPU module uses zero-alloc bindings for per-frame shader dispatch:
import { useComputeShader, useComputeTexture, useAnimationFrame } from "onejs-unity/gpu"
function ParticleSystem({ shaderGlobal }) {
const shader = useComputeShader(shaderGlobal, "Particles")
const texture = useComputeTexture({ autoResize: true })
// Create dispatcher with schema (pre-resolves property IDs)
const dispatch = useMemo(
() => shader?.createDispatcher("CSMain", {
_Time: "float",
_DeltaTime: "float",
_ParticleCount: "int",
_Result: "textureRW",
}) ?? null,
[shader]
)
useAnimationFrame(() => {
if (!dispatch || !texture) return
// All these calls are zero-alloc!
dispatch
.float("_Time", performance.now() / 1000)
.float("_DeltaTime", 0.016)
.int("_ParticleCount", 10000)
.textureRW("_Result", texture)
.dispatchAuto(texture)
})
return <View style={{ backgroundImage: texture }} />
}Property ID Caching#
For shader uniforms, cache property IDs to avoid string allocations:
// Without caching - allocates string on every call
shader.SetFloat("_Time", time) // String "_Time" allocated
// With caching - zero-alloc
const timeId = Shader.PropertyToID("_Time") // Once at init
shader.SetFloatById(timeId, time) // Per-frame, zero-allocThe GPU module's createDispatcher() handles this automatically when you provide a schema.
Limitations#
| Limitation | Reason | Workaround |
|---|---|---|
| Max 8 arguments | Native __zaInvokeN functions defined for N=0-8 | Split into multiple calls |
| Static methods only | Binding system is static-method-based | Create static wrappers in C# |
| No generic methods | Type info lost at runtime | Use concrete overloads |
| Strings valid during call only | QuickJS internal pointer | Copy if needed beyond call |
| No complex return types | InteropValue union limited | Return handles, read properties |
Best Practices#
- Measure first - Use Unity Profiler to identify allocation hot spots before optimizing
- Bind at init time - All
za.static()andza.method()calls should happen once during initialization
- Use pre-registered IDs - For maximum performance, pre-register bindings in C# and use
za.fromId()
- Cache property IDs - For shader uniforms and similar string-keyed APIs
- Batch operations - Fewer large calls beat many small calls
- Profile regularly - Verify zero-alloc behavior with Unity Profiler's "GC Alloc" column
Debugging#
Check registered bindings:
import { za } from "onejs-unity/interop"
console.log("Binding count:", za.getBindingCount())
const info = za.getBindingInfo(bindingId)
if (info) {
console.log(`${info.typeName}.${info.methodName} (${info.argCount} args)`)
}Performance Comparison#
Relative performance (use Unity Profiler to measure your specific use case):
| Method | GC Alloc | JS Changes | Notes |
|---|---|---|---|
Standard CS proxy | High | None | Full reflection + boxing each call |
| FastPath-registered | Zero | None | Typed delegates, intercepts CS proxy transparently |
za.static() dynamic | Medium | Yes | Reflection cached, but object[] allocated per call |
za.fromId() pre-registered | Zero | Yes | Typed delegates, explicit JS binding |
za.fromId() are truly zero-allocation. FastPath is simpler (no JS changes), while za gives more control for complex call patterns. Use Unity Profiler's "GC Alloc" column to verify allocation behavior in your specific use case.