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.
The interop cost model
Section titled “The interop cost model”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 function3. Host delegate runs, reads args from guest memory4. Host calls WasmAccessManager.CheckAccess (three-axis check)5. Host calls the real UnityEngine/CVR method6. Host writes result back into guest memory7. Guest reads the result
VM -> CVR Links -> Unity -> CVR Links -> VMEvery 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.
Cache what you fetched
Section titled “Cache what you fetched”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),transformof 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.Findstyle lookups every frame.
Use batched APIs
Section titled “Use batched APIs”Prefer one call carrying all the data over many scalar calls.
| Instead of | Use |
|---|---|
transform.position = p; transform.rotation = r; | transform.SetPositionAndRotation(p, r); |
repeated Material.SetFloat / SetColor / SetVector | build a MaterialPropertyBlock once, apply once with Renderer.SetPropertyBlock |
| per-element reads/writes on a large array | single ReadBlock / WriteBlock call with a Span<T> |
a.position.x, a.position.y, a.position.z | read a.position once into a local Vector3 |
Check API Reference for batched variants before reaching for scalar properties.
Push computation into the guest
Section titled “Push computation into the guest”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 readVector3 p2 = new Vector3( // guest math, no host call p.x + Mathf.Sin(Time.time) * 0.1f, p.y, p.z);transform.localPosition = p2; // host writevs.
// 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);Watch per-frame allocations
Section titled “Watch per-frame allocations”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.newof 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);}Don’t subscribe what you don’t need
Section titled “Don’t subscribe what you don’t need”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.
Sample at the right phase
Section titled “Sample at the right phase”PostLateUpdate— best for sampling a player’s final pose (after Avatar IK and Network IK). Don’t do this inUpdateorLateUpdate.FixedUpdate— for physics-driven work. In CVR,Time.fixedDeltaTimescales 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.
Profiling
Section titled “Profiling”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.Logsparingly. 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.
Worked example — official pattern
Section titled “Worked example — official pattern”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.
Related
Section titled “Related”- Limits — the 20 ms epoch deadline and what trips it.
- Runtime Lifecycle — where the dispatch happens.
- Events — post-phase events and execution order.