Skip to content

Performance

TL;DR: Performance inside CVR’s WASM scripting is dominated by the cost of crossing the host-guest boundary. Every Unity or CVR call marshals through the binder layer. Cache what you fetched, call batched APIs where available, and avoid per-frame allocations. Guest-side math (Mathf, Vector3, LINQ over in-guest collections) is cheap; host calls are not.

For every Unity or CVR API your script touches, there are roughly these steps:

1. Guest C# calls a method on a shimmed type (e.g. Transform.position)
2. The shim marshals args into guest linear memory and invokes an imported host function
3. Host delegate runs, reads args from guest memory
4. Host calls WasmAccessManager.CheckAccess (three-axis check)
5. Host calls the real UnityEngine/CVR method
6. Host writes result back into guest memory
7. Guest reads the result
VM -> CVR Links -> Unity -> CVR Links -> VM

Every round-trip pays this cost. You can hit the 20 ms-per-call epoch deadline if you make too many in one tick. More realistically: you won’t trap, but you’ll be measurably slow.

Don’t re-read a property from the host every time you use it — store it once and reuse.

void Update()
{
// Four host calls for what is the same transform.
target.position = Camera.current.transform.position;
target.rotation = Camera.current.transform.rotation;
}
void Update()
{
Transform cam = Camera.current.transform; // 1 host call to resolve Camera.current
target.SetPositionAndRotation(cam.position, cam.rotation); // 1 batched write (see below)
}

Cache at the longest reasonable scope:

  • Once per Start — component references the script owns (Rigidbody, Animator, TextMeshProUGUI), transform of its own root, the initial position.
  • Once per event — transient collaborators for that single event handler (the collider’s gameObject, a player’s avatar handle).
  • Never in Update — resolving GameObject.Find style lookups every frame.

Prefer one call carrying all the data over many scalar calls.

Instead ofUse
transform.position = p; transform.rotation = r;transform.SetPositionAndRotation(p, r);
repeated Material.SetFloat / SetColor / SetVectorbuild a MaterialPropertyBlock once, apply once with Renderer.SetPropertyBlock
per-element reads/writes on a large arraysingle ReadBlock / WriteBlock call with a Span<T>
a.position.x, a.position.y, a.position.zread a.position once into a local Vector3

Check API Reference for batched variants before reaching for scalar properties.

Pure C# work — math, string manipulation, LINQ over in-guest collections, sorting, parsing — runs entirely in the WASM module without any host call. It’s as fast as AOT-compiled .NET.

Keep that work guest-side:

// Fast: one host read, one host write per frame.
Vector3 p = transform.localPosition; // host read
Vector3 p2 = new Vector3( // guest math, no host call
p.x + Mathf.Sin(Time.time) * 0.1f,
p.y,
p.z);
transform.localPosition = p2; // host write

vs.

// Slow: three host reads and three host writes via separate property accesses.
transform.localPosition = new Vector3(
transform.localPosition.x + Mathf.Sin(Time.time) * 0.1f,
transform.localPosition.y,
transform.localPosition.z);

Every new T[], string interpolation, or LINQ .ToArray() allocates. WASM’s GC runs on the single guest thread; a big collection pass can spike the epoch deadline.

Allocation offenders to avoid in Update / FixedUpdate

Section titled “Allocation offenders to avoid in Update / FixedUpdate”
  • $"{a} / {b}" — string interpolation allocates. Throttle HUD updates to ~10 Hz (see 04 — Racing System).
  • list.ToArray() / list.ToList() — allocates.
  • new of a reference type — allocates. Prefer value-type structs.
  • Closures over locals (() => localVar) — allocates a closure instance.

Cache heap-allocated workspaces:

[WasmSerialized] private readonly Vector3[] buffer = new Vector3[128];
void Update()
{
int n = ReadIntoBuffer(buffer);
ProcessBuffer(buffer, n);
}

The build scanner sets a bitflag for every matching method name. Each enabled bit means the host dispatches the event to your VM. If you define OnPlayerTriggerEnter(Player) on a behaviour that has nothing to do with triggers, you still pay the dispatch cost for every player crossing every trigger in your content root.

Remove unused event methods; they’re cheap to write but not free at runtime.

  • PostLateUpdate — best for sampling a player’s final pose (after Avatar IK and Network IK). Don’t do this in Update or LateUpdate.
  • FixedUpdate — for physics-driven work. In CVR, Time.fixedDeltaTime scales with display refresh rate (30 Hz → 144 Hz), so don’t assume 50 Hz.
  • Update — visual / input / non-physics reactive work.

Running 20 simple Update behaviours at 60 Hz with two host calls each = 2400 host calls/sec. Easily fine. Running 200 Update behaviours with 20 host calls each = 240 000 host calls/sec. Measurable.

There is no guest-side profiler yet. Proxies:

  • Time.realtimeSinceStartup — read at both ends of a block to measure a chunk. One host call per read; the read itself is essentially free compared to what it measures.
  • Debug.Log sparingly. Logging is also a host call.
  • CVR_Utils_GetEpochTime() — the host epoch counter (100 kHz ticks). Useful for microbench of host bindings since it advances in real time while you’re inside a host call.

From the CVR team’s own guidance:

private void OnWillRenderObject()
{
// 1 host call to resolve once
Transform camTransform = Camera.current.transform;
// 2 host calls to read
Vector3 pos = camTransform.position;
Quaternion rot = camTransform.rotation;
// 1 host call to write both
transform.SetPositionAndRotation(pos, rot);
}

Four host calls instead of six-or-more from naive code, and the logic reads more clearly.

  • Limits — the 20 ms epoch deadline and what trips it.
  • Runtime Lifecycle — where the dispatch happens.
  • Events — post-phase events and execution order.