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:

  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 AllocSpeedNotes
Standard CS proxyHighSlowestFull reflection + boxing each call
za.static() dynamicMediumFasterReflection cached, but object[] allocated per call
za.fromId() pre-registeredZeroFastestTyped delegates, no boxing
The pre-registered path (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.