Implementing Software Culling for Cesium Static Mesh Tiles with SnowOcclusionPlugin: Issues with Android and Windows Packaged Builds

We’re working on implementing software culling for Cesium Static Mesh tiles using the SnowOcclusionPlugin, which leverages UE4’s software culling capabilities. While we’ve successfully integrated this plugin for Cesium Static Mesh tiles, we’ve encountered two major issues during development:


  1. Android Issue: Unable to Access 16-Bit Index Buffer
    The function below is responsible for building occlusion data:
TUniquePtr<FSnowMeshOccluderData> FSnowMeshOccluderData::Build(UStaticMesh* Owner) {
    TUniquePtr<FSnowMeshOccluderData> Result;

#if !UE_BUILD_SHIPPING
    if (!IsValid(Owner)) {
        UE_LOG(LogTemp, Error, TEXT("Trying to build Occlusion Data for null mesh. Aborting!"));
        return Result;
    }

    if (Owner->GetRenderData() == nullptr) {
        UE_LOG(LogTemp, Error, TEXT("Trying to build Occlusion Data for null RenderData for Owner: %s. Aborting!"), *GetNameSafe(Owner));
        return Result;
    }

    if (Owner->GetRenderData()->LODResources.IsEmpty()) {
        UE_LOG(LogTemp, Error, TEXT("Trying to build Occlusion Data for empty LODResources for Owner: %s. Aborting!"), *GetNameSafe(Owner));
        return Result;
    }
#endif

    const FStaticMeshLODResources& LODModel = Owner->GetRenderData()->LODResources[Owner->GetRenderData()->CurrentFirstLODIdx];
    UE_LOG(LogTemp, Log, TEXT("DepthOnlyIndexBuffer : %d, LODIndexBuffer : %d, CPUAccess : %d"), LODModel.DepthOnlyIndexBuffer.GetNumIndices(), LODModel.IndexBuffer.GetNumIndices(), (int)Owner->bAllowCPUAccess);

    const FRawStaticIndexBuffer& IndexBuffer = LODModel.DepthOnlyIndexBuffer.GetNumIndices() > 0 ? LODModel.DepthOnlyIndexBuffer : LODModel.IndexBuffer;
    int32 NumVtx = LODModel.VertexBuffers.PositionVertexBuffer.GetNumVertices();
    int32 NumIndices = IndexBuffer.GetNumIndices();

    if (!IndexBuffer.AccessStream16()) {
        UE_LOG(LogTemp, Error, TEXT("Can't access 16-bit IndexBuffer for Occlusion Mesh: %s: 32Bit : %d Aborting!"), *GetNameSafe(Owner), (int)IndexBuffer.Is32Bit());
        return Result;
    }

    if (NumVtx > 0 && NumIndices > 0 && !IndexBuffer.Is32Bit()) {
        Result = MakeUnique<FSnowMeshOccluderData>();
        Result->VerticesSP->SetNumUninitialized(NumVtx);
        Result->IndicesSP->SetNumUninitialized(NumIndices);

        for (int i = 0; i < NumVtx; ++i) {
            FVector Elem = FVector(LODModel.VertexBuffers.PositionVertexBuffer.VertexPosition(i));
            Result->VerticesSP->GetData()[i] = Elem;
        }

        for (int i = 0; i < NumIndices; ++i) {
            uint16 Elem = IndexBuffer.AccessStream16()[i];
            Result->IndicesSP->GetData()[i] = Elem;
        }
    }

    return Result;
}

On Android, we’re unable to access IndexBuffer.AccessStream16() even though bAllowCPUAccess is set to true for the static mesh. This suggests that the buffer may not be correctly copied, despite CPUAccess being enabled. The same code works flawlessly in the Windows editor.


  1. Windows Packaged Build Crash
    We attempted to test the implementation in a Windows packaged build but encountered a crash when loading the tileset. Here’s the stack trace:
msvcp140.dll!00007ffd66392f58() Unknown
RealVrAi.exe!std::_Mutex_base::lock() Line 52 C++
RealVrAi.exe!spdlog::details::registry::default_logger() Line 88 C++
RealVrAi.exe!spdlog::default_logger() Line 77 C++
RealVrAi.exe!getCacheDatabase() Line 116 C++
RealVrAi.exe!getAssetAccessor() Line 128 C++
RealVrAi.exe!ACesium3DTileset::LoadTileset() Line 1117 C++
...
RealVrAi.exe!UWorld::Tick(ELevelTick TickType, float DeltaSeconds) Line 1516 C++
RealVrAi.exe!UGameEngine::Tick(float DeltaSeconds, bool bIdleMode) Line 1786 C++
RealVrAi.exe!FEngineLoop::Tick() Line 5825 C++
RealVrAi.exe!GuardedMain(const wchar_t * CmdLine) Line 188 C++
RealVrAi.exe!GuardedMainWrapper(const wchar_t * CmdLine) Line 118 C++
RealVrAi.exe!LaunchWindowsStartup(HINSTANCE__ * hInInstance, HINSTANCE__ * hPrevInstance, char * __formal, int nCmdShow, const wchar_t * CmdLine) Line 258 C++
RealVrAi.exe!WinMain(HINSTANCE__ * hInInstance, HINSTANCE__ * hPrevInstance, char * pCmdLine, int nCmdShow) Line 299 C++

We suspect this crash might be related to a mismatch in our Cesium-native build for Windows. We are building it using the following cmake command:

cmake -B build -S . -A "x64" -G "Visual Studio 17 2022" && cmake --build build --config Release --target install -j8

Build logs indicate no major issues, but we’re curious if anyone has insights into possible mismatches or other causes for the crash.


Key Questions

  1. For Android: Why is IndexBuffer.AccessStream16() inaccessible despite enabling bAllowCPUAccess, and is there a reliable way to ensure the buffer is properly copied for static meshes?
  2. For Windows: Has anyone encountered similar issues with Cesium-native builds or mismatches when targeting packaged builds? Any insights into resolving the crash?

@Kevin_Ring any help or suggestions would be greatly appreciated! Let us know if you need additional details.

Hi @carlrealvr,

On Android, we’re unable to access IndexBuffer.AccessStream16() even though bAllowCPUAccess is set to true for the static mesh.

I don’t think set bAllowCPUAccess anywhere, so I believe it defaults to false. Is that something you’ve changed?

The other thing that comes to mind is that not all of our index buffers will be 16-bit. Is it possible you’re bumping into a 32-bit one on Android?

We attempted to test the implementation in a Windows packaged build but encountered a crash when loading the tileset.

This mutex crash is almost always caused by building with a newer Visual C++ and then attempting to run on a system with the older MSVC redistributable installed. It can be fixed by installing the latest redistributable, which can be found here:

It’s fine to use a newer redistributable with an older compiler, so installing the latest should always be safe.

It’s also possible to build using an older version of the compiler, but it’s a bit tricky to get this to work across cmake, vcpkg, and Unreal.

Thanks, @Kevin_Ring We did set bAllowCPUAccess to true , but there were additional steps needed to access the index buffer on the CPU. We already check if there’s a 16-bit buffer; if not, we fall back to 32-bit. However, UE4’s software culling system doesn’t seem to work well with Cesium Tiles, especially in VR. The VR camera’s projection matrix and each tile’s bounds appear to cause flickering in the culling.

We’re trying to improve Cesium Maps performance on Android VR (Quest), and have a few questions:

  1. Experimental Culling:
    We see there’s an experimental culling feature, but it’s not working. Even tiles behind a mountain are loaded when they shouldn’t be. Is it supposed to hide occluded tiles? Any idea what might be causing this to fail?

  2. Hardware Culling:
    Has hardware culling been implemented for Cesium tiles?

  3. Prevent Unloading:
    When the user moves their head in VR, tiles are unloaded and then reloaded. For a city environment, would it be better to just keep all nearby tiles in memory and rely on frustum/hardware culling? Can we disable the unloading mechanism?

  4. TileSet::UpdateView Performance:
    We notice TileSet::UpdateView takes about 3.5 ms on the CPU, which is significant. Can this be reduced?

  5. Engine Upgrades:
    We’re upgrading from UE 5.3.2 (Oculus branch) to a 5.5 source build to see if it improves performance. Any suggestions or known tips for VR/Android would be welcome.

  6. Occlusion Culling:
    We understand Cesium does frustum culling, but can it also cull objects blocked by terrain (e.g., mountains)? Ideally, tiles that aren’t visible shouldn’t even load.

  7. Other Ideas:
    If you have any other advice on improving Cesium performance in VR on Android, we’d love to hear it.

Thanks again for any insights!

Hi @carlrealvr,

I suggest you see if you can reproduce your occlusion culling results in a stock version of the plugin and with the Cesium for Unreal Samples project. The Experimental Culling feature is indeed hardware-based culling that is meant to avoid loading objects that are define obstacles such as mountains. It’s based on occlusion tests of bounding volumes (by necessity; the point is to avoid loading the triangles, right?) so it may think tiles are visible when you don’t think they are. I believe Unreal’s occlusion culling is also based on axis-aligned bounding volumes, which means that it won’t work as effectively when you’re far from the georeference origin, because the bounding volumes won’t fit as nicely. But you should a reduction in tiles loaded and rendered overall when it is enabled.

It does use Unreal’s hardware occlusion query feature, though. So it’s possible that system simply isn’t implemented on Android. I don’t know offhand if that is the case.

For a city environment, would it be better to just keep all nearby tiles in memory and rely on frustum/hardware culling? Can we disable the unloading mechanism?

I think you would find that the device would run out of memory. In any case, the closest we can get to that with built-in features is to disable frustum culling, enable “enforce culled screen space error”, set “culled maximum screen space error” to the same value as “maximum screen space error”, and set your cache size to be large.

We notice TileSet::UpdateView takes about 3.5 ms on the CPU, which is significant. Can this be reduced?

That does sound unusually long. I don’t know of any magic ways to reduce it, but I’d start by checking to make sure your modifications haven’t inadvertently increased it. It can also take longer with poorly-structured tilesets, such that large parts of the tile tree need to be traversed. Remind me: what tileset are you using?