I spent a long stretch building the layer where a Unity WebGL build meets the JavaScript that drives the interface around it. Unity WebGL means Unity compiled to WebAssembly, which is the fact this entire post turns on. The interesting part was never what I added to that layer. It was what I got to remove.
The version everyone builds first
Unity documents exactly two ways to talk across the boundary.
Going in, unityInstance.SendMessage(gameObject, method, arg). The argument is a string. If you want to send anything structured, you serialize it and the C# side parses it back.
Coming out, you declare [DllImport("__Internal")] on a C# extern and write a matching .jslib that Emscripten links into the WASM glue. The runtime hands your JavaScript a pointer, you call UTF8ToString on it, and you parse whatever you find.
I built it that way first, because it is the path of least resistance and because it is what every sample does. It is entirely fine for commands. It falls apart the moment the traffic becomes continuous.
Do the arithmetic. Forty objects in a scene, each with a position, a rotation, a screen projection and a couple of flags — call it a hundred bytes once you have paid for key names and float formatting. Four kilobytes. Build the object graph in C#, stringify it, marshal it into the heap, UTF8ToString it, parse it back into a different object graph, read six numbers out of it, drop the whole thing on the floor. Sixty times a second. Then do it again in the other direction for anything you want to write back.
The parse cost is the part people notice. The allocation cost is the part that actually hurts: a few thousand short-lived objects per frame on the JS side, matching garbage on the Mono side, and two collectors that will each come to deal with it at a moment of their own choosing. You get frames that miss their budget for reasons that never appear anywhere in your own code.
The realization: Unity's heap is not somewhere else
In a WebAssembly build, Unity's entire memory is a single ArrayBuffer sitting in the same JavaScript context as my code. Emscripten exposes it as Module.HEAPF32.buffer. Mono's managed heap lives inside it, which means a float[] allocated in C# is a run of bytes at some offset within that buffer. A Float32Array constructed over the same ArrayBuffer at the same offset is those bytes.
Not a copy of them. Them.
So: allocate the array in C#, pass the pointer and the length out through the jslib, wrap it once, and then never speak about the crossing again. C# writes buffer[i] = x. JavaScript reads view[i]. There is no encoding step because there is nothing to encode.
One wrinkle that shapes the design: Module is only in scope from jslib code, not from a normal <script>. So the typed array has to be constructed inside the jslib and handed out from there, rather than built by the JS layer from a raw pointer. That is why the init call looks like this and not like something more elegant:
JS_InitBuffers: function(scenePtr, sceneLen, overPtr, overLen) {
var heap = Module.HEAPF32.buffer;
var sb = window.__sceneBridge = window.__sceneBridge || {};
sb.scenePtr = scenePtr; sb.sceneLen = sceneLen;
sb.overPtr = overPtr; sb.overLen = overLen;
sb.sceneView = new Float32Array(heap, scenePtr, sceneLen);
sb.overrideView = new Float32Array(heap, overPtr, overLen);
if (window.__wrapperLayer) {
window.__wrapperLayer._initBuffers(sb.sceneView, sb.overrideView);
}
},
Note that it stores the pointers as well as the views. That turns out to matter enormously, for reasons I get to further down.
The whole layer is a few hundred lines, and almost all of them exist to keep this property true under conditions that want to break it.
Two buffers, one direction each
One buffer would have worked. Two makes ownership unambiguous, and unambiguous ownership is worth more than the handful of bytes it costs. The scene buffer is written by Unity and read by me. The override buffer is written by me and read by Unity. No slot has two authors, so there is nothing to reason about and nothing to lock.
Both are the same shape: a fixed header, then a strided record per object.
Scene buffer — Unity writes, I read:
| Offset | Field |
|---|---|
[0] – [1] | delta time, total time |
[2] – [4] | step number, changed flag, object count |
[5] | stride |
[6] – [9] | surface probe result |
[10] – [21] | camera pose, field of view, orbit target |
+0 – +6 | position, rotation |
+7 – +10 | screen projection, visible, alive |
+11 – +18 | overlay anchor, local bounds |
+19 … | declared type parameters |
Override buffer — I write, Unity reads:
| Offset | Field |
|---|---|
[0] – [1] | step signals |
[2] – [5] | surface probe request |
[6] – [15] | camera command, target pose, duration |
[16] – [17] | playback clock |
+0 – +6 | position, rotation |
+7 | written-fields bitfield |
+8 – +14 | preview pose |
+15 – +19 | preview visibility, opacity, tint |
Two decisions here I would make again without thinking.
The stride lives in the header, rewritten every frame. Both sides could have compiled it in as a constant instead — it is the same number, it changes rarely. But the day I added six floats to the object record, I would have had two constants to change, in two languages, and a build where one shipped and the other did not produces silent garbage rather than a crash. Every object reads a slice of its neighbour and everything looks almost right. Publishing the stride costs one float per frame and deletes that entire category of failure.
The second: slot indices are handed out once and never recycled. When an object is destroyed its slot is marked dead and left alone. That is mildly wasteful in a long session with a lot of churn, and it buys something I use constantly — an index that is stable for the lifetime of the scene is an index I can hold onto without ever asking whether it still means what it meant.
Making raw offsets survivable
Nobody should be writing view[22 + index * 19 + 7], including me at two in the morning. So there is a thin object layer on each side, and it is deliberately, almost aggressively thin.
It holds two maps — instance id to slot index, and id to type name — and everything else is arithmetic done at the point of use. Header fields are prototype getters:
Object.defineProperties(SceneContext.prototype, {
deltaTime: { get() { return this._view[0]; } },
stride: { get() { return this._view[5]; } },
});
scene.deltaTime is one indexed load from a typed array. It reads like a property access because that is all it is. There is no caching layer, no dirty tracking, no change notification — reading the buffer is already about as cheap as reading a field, so any machinery built to avoid reading it would be strictly slower than reading it.
The C# side gets the mirror image: a writer that takes an id and a Vector3 and resolves the offset internally, so no gameplay code ever sees an index either.
The ergonomics were the whole point, not a nicety. If the layer had been unpleasant to use, I would have caught myself bundling things into an object and shipping them across "just this once", and the property I had worked for would have leaked away one convenience at a time.
What still gets to be a string
Not everything belongs in the buffers, and the rule for deciding is frequency, never type.
A step change happens when the scene advances — a few times a minute. It carries a description, a phase name, and a variable set of target poses that may name two objects or nine. That is a poor fit for a fixed-size float record and a perfectly good fit for a JSON string over the jslib. Same for objects spawning and despawning, and for one-shot notifications about async work starting and finishing.
So there is a second channel, and it is the original string-based one. C# calls its DllImport externs, the jslib converts and parses, and the result lands on a single global object as cached state. My frame code never subscribes to any of it. It reads whatever the current state happens to be.
Each hook stays trivial on purpose — it is the one piece of code that lives in two worlds at once, so it gets to do exactly one thing:
JS_OnStepChange: function(jsonPtr) {
window.__wrapperLayer._onStepChange(JSON.parse(UTF8ToString(jsonPtr)));
},
The discipline is one sentence: if it happens every frame it is a float in a buffer; if it happens on a change it may be a string. I have never had to argue that rule with anyone, including myself. It answers on its own.
Hot numbers get their own slots
The hard case is the value that changes every frame but is not part of the base record. A needle angle. A fill level. A temperature reading that only one kind of object has.
Pushing those as JS_OnPropertyChange messages is exactly the pattern I had just spent weeks removing: a JSON payload every frame carrying a single float. So instead, types declare what they need before the buffers are allocated:
bridge.DeclareType("compass_75mm", new[] { "needle_angle" });
bridge.DeclareType("beaker_250ml", new[] { "fill_level", "temperature" });
bridge.InitBuffers(capacity);
The layer builds a name-to-offset map, and the record stride becomes the base fields plus the largest declaration across all types. The map goes to JavaScript once, at initialization. From then on it is:
const angle = scene.getParam("compass1", "needle_angle");
Two dictionary lookups and an indexed read. Types that declare nothing pay nothing except the shared stride, which is the honest cost of a single flat layout and cheaper than the alternative of per-type buffers and the routing logic they would need.
The heap moves under you
Here is the thing about holding a view into someone else's memory: it is valid right up until they reallocate.
That happens two ways, and only one of them is mine.
The obvious one is capacity. Object count outgrows the arrays, C# allocates larger ones and copies. New pointers, new lengths. It calls out through JS_OnBufferResized with both, the views are rebuilt in place, and everything above that line carries on without noticing — which is precisely why nothing above that line is allowed to keep a reference to the raw Float32Array. That rule is the entire reason the object layer exists at all.
The subtle one is not mine. When the WebAssembly heap grows, the old ArrayBuffer is detached, and every typed array over it becomes dead. It does not throw. A detached view simply has zero length, and reads return undefined. If Mono triggers Memory.grow() on its own — a mid-frame allocation somewhere in code I do not own — my perfectly correct layer starts reading nothing at all, and the symptom is objects quietly freezing in place with a completely clean console.
The fix is three lines at the top of the per-frame entry point:
JS_OnFrame: function() {
var heap = Module.HEAPF32.buffer;
var sb = window.__sceneBridge;
if (sb && sb.sceneView.buffer !== heap) {
sb.sceneView = new Float32Array(heap, sb.scenePtr, sb.sceneLen);
sb.overrideView = new Float32Array(heap, sb.overPtr, sb.overLen);
window.__wrapperLayer._onBufferResized(sb.sceneView, sb.overrideView);
}
window.__wrapperLayer._onFrame();
},
An identity comparison on the buffer, once a frame, and an entire class of bug stops existing. This is what those stored pointers from the init call were for.
I did not design that in. I found it, the slow way.
The flag that had to become two bits
My favourite bug from the whole exercise, and another one I found rather than designed.
Each override record had a single flag meaning "this slot is written". Set it, and Unity applied both the position and the rotation from the record. Reasonable — until JS code wrote a position without a rotation, and the untouched rotation fields formed the quaternion (0, 0, 0, 0). Not a rotation. Not even a degenerate one. Assigning it to transform.rotation collapsed the object and it disappeared from the scene.
What made it genuinely nasty to chase is that nothing looked broken from the outside. The GameObject was still there, still registered, still reporting a sensible position every frame. It just had no volume.
The flag became a two-bit field: bit zero means a position was written this frame, bit one means a rotation was.
FrameOutput.prototype.setPosition = function(id, pos) {
var b = OVERRIDE_HEADER + this._map[id] * this._stride;
this._view[b] = pos.x; this._view[b+1] = pos.y; this._view[b+2] = pos.z;
this._view[b + 7] = (this._view[b + 7] | 0) | 1;
};
C# reads the bits independently and keeps whatever the transform already had for the side that was not written. Half a line on each side of the boundary. It turned "write one, leave the other" from a convention people have to remember into a property of the format — which is the only kind of correctness that survives a codebase continuing to grow.
The slow path is allowed to stay slow
Not every direction needed optimizing, and it is worth being clear about that rather than implying I removed serialization everywhere.
When JavaScript tells Unity to do something discrete — load this scene, apply that highlight, begin this phase — it still goes over SendMessage with a JSON string. That is a handful of messages per step. The overhead is real and completely irrelevant at that frequency, and building a second shared-memory command queue to save it would have been a worse layer for no gain anyone could measure.
I did make one structural call there. Rather than scattering receiver GameObjects across the scene, everything routes through a single one, created at boot with [RuntimeInitializeOnLoadMethod] so its name is stable and JavaScript never has to find it. Feature areas attach to that same GameObject as additional MonoBehaviours, each prefixing its methods by domain — OutlineApply, MarkerCreate, SpawnInstances. Unity's SendMessage dispatches by method name across every component on the object, so the prefixes are the only thing stopping two features colliding on a generic verb. One name for the JS side to know, no registry, no lookup table, no lifecycle to manage.
The one place I built a real protocol on top of SendMessage is the handshake for work that spans many frames. JavaScript sends a start message with a payload; C# answers over the jslib channel when it has entered the phase and again when it has finished; the JS side hands the caller a promise. Request and response assembled out of two one-way channels. That is the only place asynchrony belongs, because it is the only place where the answer genuinely is not available this frame.
Four milliseconds, and what they change
The frame budget is the part that changes how you write everything else.
JS_OnFrame is called synchronously from inside Unity's Update(). It is not a callback scheduled for later — it is a subroutine of the frame. Rendering and physics take ten to twelve of the sixteen and a half milliseconds available at sixty frames a second. What is left is mine, and while I am executing, Unity is blocked.
That constraint is clarifying rather than oppressive. The rules fall out of it instead of being imposed:
- Nothing is allocated in the frame path. Objects that need to exist are created once and reused, because the collector runs on the same thread and I would be paying for it inside my own budget.
- DOM elements are created once and repositioned, never created and destroyed per frame. Layout is the expensive operation, not writing a style property.
- Nothing is serialized, which by that point was less a rule than an observation — there was nothing left to serialize.
- Every animation is driven by the delta time in the header rather than a frame count, so a slow frame stretches motion instead of stalling it.
None of these are clever. They are what remains once you cannot hide latency behind an event queue. And the buffer work is what made them affordable in the first place: I could spend the whole budget on how the interface behaves, because none of it was going to parsing.
What it actually bought
Speed, obviously. But the number was never the interesting part, and I would not have kept working on it if that is all it was.
What I actually got was a boundary that stopped being a design constraint. When crossing costs a serialization round trip, you start shaping features around how often they need to cross. You batch updates that did not want to be batched. You cache state on the near side so you can avoid asking for it. You end up maintaining two half-copies of the truth and a synchronization problem you invented yourself, and every new feature has to negotiate with that problem before it can do anything useful.
When crossing costs an array index, you stop thinking about it. The scene state is simply readable, sixty times a second, from either side, at the price of a memory read.
The layer is small. Most of it is the unglamorous half — rebuilding views when the heap moves, publishing the stride so the two sides cannot drift apart, making the write flag honest about what it actually covers. That is the real work. The idea that C# and JavaScript can share an array is a five-minute realization. Making it hold for an hour of uninterrupted use is everything after that.