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 a zero-allocation interop system that eliminates managed heap allocations for hot-path code.
Related: GPU Compute
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.
The Solution: 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 | Speed | Notes |
|---|---|---|---|
Standard CS proxy | High | Slowest | Full reflection + boxing each call |
za.static() dynamic | Medium | Faster | Reflection cached, but object[] allocated per call |
za.fromId() pre-registered | Zero | Fastest | Typed delegates, no boxing |
za.fromId) is the only truly zero-allocation option. Use Unity Profiler's "GC Alloc" column to verify allocation behavior in your specific use case.