Lessons Learned: Vision Pro, Large Scenes, and Threading

This week I came across an unexpected issue. Loading a large scene on the Vision Pro would result in a run-time error. But loading the same scene on macOS did not. Both macOS and visionOS use essentially the same loading and rendering path. So what could be causing the issue?

To make things worse, the run-time errors were cryptic and pointed to different functions each time the application crashed. Sometimes it looked like a type issue. Other times it looked like a Foundation error. Nothing clearly indicated what the real problem was.

However, during debugging, I started to see a pattern. Most of the time, the error pointed to scene or component data access. That’s when I began wondering:

What if, during loading, the system is trying to access data that is not yet stable?

In other words, what if one part of the engine is writing to components while another part is reading from them?

Then another thought came to mind. What if this issue is also present on macOS, but because macOS does not use a dedicated render thread in the same way as Vision Pro, the race condition is simply not exposed?

After a few more debugging sessions, I realized I may have been onto something.


The Real Issue

On Vision Pro, rendering runs on a dedicated render thread. When we load a large scene, the Untold Engine performs loading work on a separate thread so that we don’t block execution.

That means we had two things happening at the same time:

  • The render thread traversing the scene graph, iterating component storage, performing culling, and building draw calls.
  • The loading thread creating entities, attaching components, recursively tagging entities for static batching, rebuilding batch data, and updating spatial structures such as the octree.

In other words, the render thread was reading from scene/component data while the loading thread was writing to that same data.

This read/write overlap caused race conditions and eventually corrupted state.


Why This Did Not Happen on macOS

The reason this did not happen on macOS is mostly due to timing and threading differences.

On macOS:

  • The renderer and update loop are more tightly coupled.
  • The mutation window during loading is smaller.
  • The render traversal is less likely to intersect with scene mutation at the exact wrong moment.

On Vision Pro:

  • Rendering runs independently on a dedicated thread.
  • Frame submission follows its own cadence.
  • The renderer can traverse the scene while it is still being mutated.

Large scenes amplify this issue because static batching and recursive hierarchy processing take longer, increasing the window where the world is in a partially mutated state.


The Solution

The solution was to add a gating mechanism to prevent any read/write collision while loading was taking place.

The idea is simple:

  • When a major scene mutation phase begins (for example, during large scene loading or static batch generation), increment a shared counter.
  • When the mutation phase finishes, decrement it.
  • The render thread checks this counter before traversing the scene.
  • If a mutation is in progress, the render thread continues to submit frames but avoids traversing scene or component data that may still be unstable.

It’s important to note that I do not block the render thread on visionOS. I let it continue running, but I prevent it from accessing critical scene data while the loading phase is still mutating that data.

After this fix was in place, loading large scenes with the Untold Engine on Vision Pro no longer caused run-time crashes.


Final Thoughts

In the end, the issue was about concurrency.

  • Rendering reads from the world.
  • Loading mutates the world.

Without proper synchronization, those two operations cannot safely overlap.

Vision Pro didn’t introduce a new bug into the Untold Engine. It exposed a hidden assumption in my threading model.

And that’s a good thing. Thanks for reading.

Harold Serrano

Computer Graphics Enthusiast. Currently developing a 3D Game Engine.