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:

  1. FastPath — Transparently makes regular CS proxy calls zero-alloc. No JS changes needed. Best for useFrameSync and property reads.
  2. za API — Explicit zero-alloc function bindings from JavaScript. Best for complex static method calls (GPU compute, physics, etc.).
Related: C# State Sync | 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.

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
These are already zero-alloc — no setup needed.

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:

TypeHow it's returned
int, float, double, bool, longPacked directly into InteropValue struct
Vector2, Vector3, Vector4Float components packed into struct
Quaternion, ColorFloat components packed into struct
stringUTF8 marshaled (allocates on C# side)
Reference typesHandle registered, typeHint string allocated
For primitives and Unity structs, the entire round-trip is allocation-free.

FastPath vs za API#

FastPathza API
JS code changesNoneMust use za.fromId() / za.static()
Works with useFrameSyncYes, transparentlyNo (different call path)
Works with CS proxyYes, transparentlyNo (different call path)
RegistrationC# onlyC# + JS
Best forProperty reads, simple methodsComplex static methods, GPU dispatch
Use FastPath when you want existing 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:

  1. Pre-registered bindings (za.fromId) - Truly zero-allocation per call
  2. Dynamic bindings (za.static, za.method) - Reduced allocations, faster than CS proxy
For truly zero-allocation code, use pre-registered bindings:
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:

TypeJS ValueNotes
intnumberTruncated to integer
floatnumber
doublenumber
boolboolean
stringstringValid during call only
Object handleObject with __csHandleC# 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:

ApproachInit CostPer-Call CostBest For
Pre-registered (za.fromId)C# setupZero-allocMaximum performance
Dynamic (za.static)ReflectionAllocates (reduced)Development, non-critical paths
Standard CS proxyNoneAllocates (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-alloc

The GPU module's createDispatcher() handles this automatically when you provide a schema.

Limitations#

LimitationReasonWorkaround
Max 8 argumentsNative __zaInvokeN functions defined for N=0-8Split into multiple calls
Static methods onlyBinding system is static-method-basedCreate static wrappers in C#
No generic methodsType info lost at runtimeUse concrete overloads
Strings valid during call onlyQuickJS internal pointerCopy if needed beyond call
No complex return typesInteropValue union limitedReturn handles, read properties

Best Practices#

  1. Measure first - Use Unity Profiler to identify allocation hot spots before optimizing
  1. Bind at init time - All za.static() and za.method() calls should happen once during initialization
  1. Use pre-registered IDs - For maximum performance, pre-register bindings in C# and use za.fromId()
  1. Cache property IDs - For shader uniforms and similar string-keyed APIs
  1. Batch operations - Fewer large calls beat many small calls
  1. 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):

MethodGC AllocJS ChangesNotes
Standard CS proxyHighNoneFull reflection + boxing each call
FastPath-registeredZeroNoneTyped delegates, intercepts CS proxy transparently
za.static() dynamicMediumYesReflection cached, but object[] allocated per call
za.fromId() pre-registeredZeroYesTyped delegates, explicit JS binding
Both FastPath and 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.