Occlusion culling: objects behind an opaque wall
Verify actual render-list culling and avoid hiding visible geometry with an incorrect occluder.
What is culled
Occlusion culling removes fully hidden objects from render submission. It differs from camera-frustum rejection and mesh LOD selection. An opaque wall can already hide sphere pixels while the sphere is still submitted and rejected by depth testing without occlusion culling.
Culling requires OccluderInstance3D geometry. A visible MeshInstance3D wall does not automatically create it. An occluder neither draws a wall nor creates a physics body.
Run the complete example
Use the three files from mesh LOD: GeneratedLodMesh.cs, CountedMesh3D.cs and OptimizationLab3D.cs. Use C# setup, SDK 1.0.0 and net10.0. Run dotnet build and attach OptimizationLab3D.cs to the Entity3D root of a .rscene level. Select Preview=Occlusion before startup.
The complete level file below creates a visible 5×5 wall and a separate 4.9×4.9 QuadOccluder3D in the same plane. The margin keeps the occluder within the opaque wall. UseOcclusionCulling is enabled on the viewport rendering the level.
OptimizationLab3D.cs
using Reckfell;
public partial class OptimizationLab3D : Entity3D
{
public enum PreviewMode { MeshLod, Occlusion }
[Export] public PreviewMode Preview { get; set; } = PreviewMode.MeshLod;
public CountedMesh3D Target { get; private set; } = null!;
public Camera3D Camera { get; private set; } = null!;
public MeshInstance3D Wall { get; private set; } = null!;
public OccluderInstance3D Occluder { get; private set; } = null!;
private Label counter = null!;
public override void _Ready()
{
var material = new StandardMaterial3D
{
AlbedoColor = Colors.White,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded
};
Target = new CountedMesh3D { Mesh = GeneratedLodMesh.Source().GetMesh(), MaterialOverride = material };
AddChild(Target);
var body = new StaticBody3D();AddChild(body);
body.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = 1 } });
Wall = new MeshInstance3D
{
Mesh = new BoxMesh { Size = new Vector3(5, 5, 0.2f) },
Position = new Vector3(0, 0, 2), Visible = Preview == PreviewMode.Occlusion,
MaterialOverride = new StandardMaterial3D
{
AlbedoColor = new Color(0.08f, 0.25f, 1),
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded
}
};
Occluder = new OccluderInstance3D
{
Occluder = new QuadOccluder3D { Size = new Vector2(4.9f, 4.9f) },
Position = new Vector3(0, 0, 2), Visible = Preview == PreviewMode.Occlusion
};
AddChild(Wall);AddChild(Occluder);
Camera = new Camera3D { Current = true, Position = new Vector3(0, 0, 6), Fov = 60, Far = 200 };
AddChild(Camera);
var canvas = new CanvasLayer();AddChild(canvas);
counter = new Label { Position = new Vector2(12, 12) };canvas.AddChild(counter);
GetViewport().UseOcclusionCulling = Preview == PreviewMode.Occlusion;
}
public override void _Process(double delta)
{
counter.Text = "△ " + GetViewport().GetRenderInfo(Viewport.RenderInfoType.Visible, Viewport.RenderInfo.PrimitivesInFrame);
}
}Preview is read in _Ready. Wall, Occluder, Camera and Target are available after creation in _Ready, not before. The exported Preview is not a live mode-switching implementation.
Compare output and render submission
The checked camera sits before the wall; automatic LOD is disabled with threshold 0 to separate the effects. Visible ObjectsInFrame drops from 2 to 1 while scene pixels below the counter remain identical. △ counts primitives, so it changes more than object count.
Actual frames/statistics are verified in current Compatibility/OpenGL and Forward+/Vulkan. Public figures are original 512×384 OpenGL frames, not editor wireframe or FPS measurements. Measure total CPU/GPU time in your level: culling preparation also costs work, particularly when most geometry is visible.
Validate occluder geometry
An occluder must match a real opaque barrier. Do not cover doorways, windows, transparent glass or space outside the wall silhouette. Simplification helps only while visibility remains correct.
Hiding Wall while leaving Occluder active continues hiding the sphere in the test. That is the expected result of invalid occluder data, not proof the sphere is absent. Disabling Occluder.Visible restores it. Update occluders consistently with moving or removed walls.
Checks verify recovery when moving around the wall and through Target.IgnoreOcclusionCulling=true. The latter restores render submission but does not make geometry visible through an opaque wall or bypass every other culling mechanism.
For baked occluders, BakeMask selects gathered visual layers rather than physics CollisionLayer. BakeSimplificationDistance controls simplification, not camera distance. Bake UI and quality for a real imported level are outside this example; it assigns a Quad directly.
What keeps running
The hidden sphere remains Visible=true and IsVisibleInTree=true, continues _Process and retains its separate ray-hittable physics body. Occlusion does not delete entities, stop gameplay timers or unload resources.
It is not a world-streaming implementation. Stopping expensive simulation or releasing chunk resources needs a separate loading/state policy consistent with gameplay, not just camera visibility.
Diagnosis
| Problem | Check |
|---|---|
| Counter stays constant behind a wall | Correct viewport, UseOcclusionCulling, active Occluder and full object coverage |
| Object disappears in open space | Oversized/stale occluder, accidentally covered openings and transforms |
| Part of an object remains visible and is not culled | Partially visible geometry must remain submitted |
| Flag is enabled but FPS is unchanged | Compare render list and CPU/GPU time rather than inferring gains from a flag |
| Hidden object still runs logic | Expected: entity processing is not disabled |