How to preserve tile states when frustum culling is disabled in Cesium?

I’m trying to achieve a 360° city view while keeping the UpdateView cost low when moving. Here’s what’s happening:

Current Behavior: With frustum culling off, Cesium still traverses and updates all tiles—even though Unreal Engine’s camera frustum culling hides off-screen tiles. As a result, UpdateView becomes expensive.

Desired Behavior: When frustum culling is off, Cesium should only update tiles within the camera frustum. Loaded tiles should stay loaded with their last SSE, and unloaded tiles should remain unchanged.

Below is a snippet from my implementation:

if (!cullResult.shouldVisit) {
  const TileSelectionState lastFrameSelectionState =
      tile.getLastSelectionState();

  markTileAndChildrenNonRendered(frameState.lastFrameNumber, tile, result);

  tile.setLastSelectionState(TileSelectionState(
      frameState.currentFrameNumber,
      TileSelectionState::Result::Culled));

  // Add to render list to prevent unloading
  // result.tilesToRenderThisFrame.push_back(&tile);

  ++result.tilesCulled;

  TraversalDetails traversalDetails{};

  if (this->_options.forbidHoles && tile.getRefine() == TileRefine::Replace) {
    // In order to prevent holes, we need to load this tile and also not
    // render any siblings until it is ready. We don't actually need to
    // render it, though.
    addTileToLoadQueue(tile, TileLoadPriorityGroup::Normal, tilePriority);
    traversalDetails = Tileset::createTraversalDetailsForSingleTile(
        frameState,
        tile,
        lastFrameSelectionState);
  } else if (this->_options.preloadSiblings) {
    // Preload this culled sibling as requested.
    addTileToLoadQueue(tile, TileLoadPriorityGroup::Preload, tilePriority);
  }

  return traversalDetails;
}

If I remove the line:

markTileAndChildrenNonRendered(frameState.lastFrameNumber, tile, result);

tiles render correctly, and I also skip unloading culled tiles in the UnloadCachedTiles function. However, sometimes tiles still get unloaded unexpectedly.

Has anyone encountered this behavior? Any recommendations or alternative approaches to ensure that, with frustum culling off, Cesium only updates the state for tiles in the camera frustum while leaving others untouched? Much appreciated @Kevin_Ring

Hi @carlrealvr,

I can’t really understand what you’re going for here. The whole purpose of disabling the frustum culling option is to allow tiles outside the frustum to be selected and rendered. If that’s not what you want… just turn frustum culling back on?

I guess you’re trying to keep tiles from being unloaded when you turn your back on them. In that case, the recommended way to do that is to increase your cache size and allow the least-recently-used cache policy to do its thing.

Thank you for your insights, @Kevin_Ring . Our goal is to maintain a 360° city view to ensure seamless user experience, as dynamically populating tiles during panning significantly detracts from the visual quality we’re aiming for, hence the need for continuous tile retention, while optimizing performance for VR devices like the Quest 2, which have limited CPU and GPU resources. Disabling frustum culling ensures that all tiles are loaded, but this approach leads to continuous updates of tiles outside the camera’s view, resulting in unnecessary CPU usage.

Increasing the cache size helps retain tiles in memory, but we’ve observed that uGltfPrimitiveComponent instances are still being destroyed when tiles are outside the camera’s frustum. Consequently, when the camera rotates back, these tiles reload from scratch, causing noticeable delays.

To address this, we’ve modified the code to prevent refinement of tiles when they’re culled and to avoid removing their static meshes. However, we’ve noticed that over time, some static meshes still unload and reload unexpectedly.

Given the hardware constraints of VR devices, we’re seeking a solution that allows tiles to remain loaded and static meshes to persist, even when they’re outside the camera’s frustum, without incurring continuous CPU overhead from unnecessary updates. We would greatly appreciate any guidance or recommendations you can provide to achieve this behavior.

@Kevin_Ring we made some progress and we understand now why it was unloading the tiles unexpectedly: If the MaxCachedBytes is set to 0, then the unloading mechanism is running in a loop, which is parallel to the TileSelection process. We mark all the tiles which are frustum culled but should not be unloaded inside _visitTileIfNeeded function. All we have to do is make the unloading mechanism run after the tile selection process is done. I’ll keep this open to make sure we get this right, and in case there’s subsequent questions, but wanted to update you asap. Thanks.

@Kevin_Ring, a quick follow-up and request for advice:

We continue to experience significant performance costs in the UpdateView function, even when frustum culling is enabled—particularly noticeable on Android devices such as the Quest 2. We’re observing roughly 500 tiles actively processed in each frame, which appears unusually high.

Specifically, UpdateView incurs substantial overhead recalculating the SSE and performing frustum culling checks for all tiles. Could you provide insights into which calculations or processes within UpdateView typically incur the greatest performance costs?

We’ve already attempted several optimizations, including pawn optimization and experiments disabling ancestorMeetsSSE and the kicking mechanism, but these haven’t yielded substantial improvements. Additionally, we encounter occasional empty tile rendering when moving rapidly, seemingly caused by tiles unloading prematurely during refinement.

Would you have suggestions or strategies on optimizing UpdateView further or caching mechanisms that could alleviate the computational load? Any insights to address these unexpected tile unloads during refinement would also be appreciated.

Thank you!

Well, here again it would help to know details of the tileset you’re loading. I can imagine a tileset with a very high branching factor would cause the plugin to spend a lot more time in updateView. But this would also have a severe impact on rendering performance.

I would say that visiting 500 tiles per frame is not particularly unusual in larger tilesets, and shouldn’t have a severe performance impact. But admittedly, Quest 2 is some fairly slow hardware. You might not have much choice but to reduce the Max SSE if that’s your target.

@Kevin_Ring

We’re working with the Google Earth TileSet and targeting improved performance with SSE = 32, culled SSE = 32, and frustum culling disabled. This setup enables a 360° environment where all surrounding tiles remain loaded, eliminating the visual artifacts associated with tile popping during quick camera movements.

To mitigate the performance cost of evaluating a large number of tiles each frame, we’ve implemented a conditional update strategy:

When the camera remains stationary (i.e., no change in Lat/Lon), and all tiles are already loaded, we skip calls to updateView().

This optimization significantly reduces overhead since frustum culling is off and no new visibility decisions need to be made.

The challenge arises during camera movement. With frustum culling disabled, updateView() must process the entire tileset, resulting in substantial performance hits due to the high tile count and broad visibility.

To address this, we’re exploring a hybrid approach:

Continue to render only tiles that are currently in view, even with frustum culling disabled.

However, if a tile’s GltfComponent has already been created, we want to retain it in memory and prevent it from being unloaded, regardless of whether it is currently in view.

The goal is to maintain previously-loaded tiles as persistent in the scene, minimizing repeated instantiation of GltfComponents during minor camera movements. This should reduce frame spikes and improve continuity in user experience.

We’ve implemented this behavior, but it’s not fully reliable — we still observe cases where tiles are being unloaded despite the intended retention logic.

We’re interested in your input on:

Whether this approach aligns with the plugin’s architecture and expected behavior.

Any additional strategies to improve performance in this configuration — e.g., tile-level caching layers or ways to short-circuit redundant computations during updateView() when tiles have not changed.

As a note: Unreal’s internal object culling remains active, so once a StaticMesh is created, it will still be culled naturally when outside the camera frustum.

@Kevin_Ring thanks again

We’re working with the Google Earth TileSet and targeting improved performance with SSE = 32, culled SSE = 32, and frustum culling disabled.

That’s a pretty heavy combo for a low end device, unfortunately. Google Photorealistic 3D Tiles does indeed have a massive number of relatively small tiles, meaning the traversal overhead is higher than with other tilesets. I don’t have any solution to this on the Cesium for Unreal side, though.

However, if a tile’s GltfComponent has already been created, we want to retain it in memory and prevent it from being unloaded, regardless of whether it is currently in view.

This sounds like it could lead to running out of memory pretty quickly, particularly on a limited device like a Quest 2.

But as I’ve said before, the proper way to do this is to increase the Maximum Cache Bytes property. No tiles will be unloaded until the size grows beyond this number, and at that point the least-recently-used tiles will be unloaded first.

Any additional strategies to improve performance in this configuration — e.g., tile-level caching layers or ways to short-circuit redundant computations during updateView() when tiles have not changed.

I’d love to modify updateView to work more incrementally. Rather than always traversing from the root of the tileset hierarchy down to whatever tiles are visible, it could (theoretically) start with the tiles selected last frame and determine if any of them needs to change (i.e., just re-evaluate culling and SSE). This is a big change though, with lots of challenges.