Quick answer: Godot culls against each mesh’s AABB, not its actual geometry. If the AABB is tighter than the rendered pixels — which it always is for skinned, morphed, or procedural meshes — you get pop-out at screen edges. Set custom_aabb on every MeshInstance3D that can deform beyond its bind-pose bounds, debug culling decisions with a VisibleOnScreenNotifier3D, and disable VisibilityRange while you isolate the bug.
Here is how to fix Godot Camera3D frustum culling that incorrectly hides visible meshes. You pan the camera and a tree disappears half a meter before its trunk reaches the screen edge. A character’s outstretched sword vanishes when their torso is still on-camera. A procedural cliff face flickers in and out as the camera rotates. None of this is the engine misbehaving — it is the engine trusting the AABB you handed it, which is too small for what is actually being drawn.
The Symptom
You move or rotate the camera and observe one or more of:
Meshes pop out before reaching the screen edge. A mesh whose silhouette would still be visible disappears entirely. Moving the camera back a fraction of a degree makes it pop back in. The pop happens at a consistent angle, not a random one, which suggests the AABB has a hard boundary the engine is testing against.
Skinned characters lose limbs. An animated character with arms or weapons reaching far from their root disappears when the root bone leaves the frustum, even though the limb is still in view. Common with combat animations, exaggerated runs, and wings/tails.
Procedural meshes flicker. A mesh you generated at runtime via SurfaceTool or ArrayMesh visibly toggles on and off as the camera moves. The AABB Godot computed at build time does not match the geometry you wrote, because you forgot to call regen_normal_maps or assigned to surface_set_material after the AABB was already cached.
Shadows pop in and out. The mesh stays on screen but its shadow flickers. The shadow caster passes use a different cull mode than the visible pass, and the AABB you set may not include the shadow projection direction.
What Causes This
Godot culls against the AABB, not the geometry. The renderer maintains a tree of axis-aligned bounding boxes and tests each one against the frustum planes. If the AABB does not intersect the frustum, the mesh is skipped — even if its actual triangles would. This is the right tradeoff for performance, but it requires the AABB to be a true superset of every pixel the mesh might draw.
Mesh.aabb is computed from bind-pose vertices. When Godot imports a mesh or you build one with ArrayMesh, the AABB reflects the vertex positions in the array. Anything that displaces those vertices at render time — skeletal animation, blendshapes, vertex shaders, particles — can push pixels outside the AABB. The renderer never finds out and culls anyway.
custom_aabb is unset. MeshInstance3D has a custom_aabb property specifically to override the source mesh’s AABB. Most projects leave it at zero (disabled), so the bind-pose AABB is used. For procedural geometry it is usually empty entirely until you write something to surface_set_arrays.
Shadow caster cull mode mismatch. GeometryInstance3D.cast_shadow has modes including On, Off, Double Sided, and Shadows Only. With On (the default), back-faces do not cast shadows. If your camera is positioned such that a wall’s back-face is between the light and the receiver, the shadow disappears. Switching to Double Sided fixes this for thin geometry.
VisibilityRange is fading the mesh. If a MeshInstance3D has a visibility_range_begin or visibility_range_end set, the renderer fades and eventually culls the mesh based on camera distance, not frustum. A fade margin of zero produces a hard pop that looks identical to a frustum bug.
The Fix
Step 1: Set custom_aabb on every MeshInstance3D that can deform. Compute or estimate the worst-case bounds and assign an AABB large enough to contain every possible pose. Yes, this trades a little culling efficiency for correctness — that is fine.
# For a procedural mesh, set custom_aabb after writing the surface
func build_terrain() -> void:
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = _vertices
arrays[Mesh.ARRAY_INDEX] = _indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(
Mesh.PRIMITIVE_TRIANGLES, arrays)
$TerrainChunk.mesh = mesh
# Compute the true bounds and tell Godot
var aabb := AABB(_min_vertex, _max_vertex - _min_vertex)
aabb = aabb.grow(2.0) # slack for vertex shader displacement
$TerrainChunk.custom_aabb = aabb
The grow(2.0) call adds a 2-meter margin on every side. Tune it to your shader — if your vertex shader displaces by a maximum of 0.5 meters, grow(0.5) is enough. Too much margin is a small perf cost; too little is a visual bug.
Step 2: Diagnose with VisibleOnScreenNotifier3D. Drop a notifier as a child of the suspect mesh. Its AABB defaults to a 1m cube but you can resize it to match the mesh. Connect the signals to a print so you see exactly when Godot considers the mesh on or off screen.
func _ready() -> void:
var n: VisibleOnScreenNotifier3D = $Mesh/Notifier
n.aabb = $Mesh.get_aabb() # match the mesh exactly
n.screen_entered.connect(_on_entered)
n.screen_exited.connect(_on_exited)
func _on_entered() -> void:
print("[cull] entered at ", Time.get_ticks_msec())
func _on_exited() -> void:
print("[cull] exited at ", Time.get_ticks_msec())
If the notifier reports “exited” while the mesh is still visibly on screen, your AABB is wrong. If it reports “entered”/“exited” in lockstep with the visible pop, you have proof that culling is the cause and not a different bug.
Skinned Meshes Need Bone-Aware Bounds
For a Skeleton3D-driven mesh, the bind-pose AABB is almost never enough. The right approach is to enable Skeleton → Update In Editor on the mesh import, which causes the AABB to be recomputed from the worst pose in any animation. If your animations are runtime-only, set a generous custom_aabb manually:
func _ready() -> void:
# Skinned mesh, animations can extend 3m beyond bind pose
var base := $CharacterMesh.get_aabb()
$CharacterMesh.custom_aabb = base.grow(3.0)
For weapons and props attached via BoneAttachment3D, do the same on the prop — its local bounds know nothing about the bone’s world position.
Shadow Pop and VisibilityRange
If the visible mesh is fine but the shadow flickers, set cast_shadow to Double Sided on the GeometryInstance3D. This ignores back-face culling during shadow passes, which fixes most shadow pop on thin geometry.
If you have visibility_range_begin or visibility_range_end set, increase the corresponding margin (visibility_range_begin_margin, visibility_range_end_margin) so the fade is gradual rather than a hard cut. While debugging frustum issues, set both ranges to 0 to disable distance-based culling entirely — otherwise you will not know which system is removing the mesh.
“Frustum culling believes whatever box you draw around your mesh. If you draw a small box around a big mesh, the engine will cheerfully hide pixels you can see.”
Related Issues
If meshes render correctly but flicker only at very long distances, see Camera3D Far Clip Z-Fighting. For occlusion culling problems with the new occluder system, check Occluder Instance Not Culling.
Set custom_aabb generously — a slightly oversized box costs nothing, a tight one costs you players noticing the bug.