Quick answer: Godot 4’s Vulkan backend flips the Y of the screen texture relative to what GLES3 users are used to, and DEPTH_TEXTURE returns raw non-linear depth in clip space. Sample with FRAGCOORD.xy / VIEWPORT_SIZE, flip Y if needed, and linearize using INV_PROJECTION_MATRIX before you use the value.

Depth-based effects — water, fog, soft particles, SSAO — break in strange ways if the depth sample is wrong. The most common failure modes are an upside-down read (your fog appears at the top of the screen instead of the horizon) and a depth value that seems to saturate to 1.0 everywhere. Both stem from mishandling the depth buffer’s coordinate system and its non-linear encoding.

Pick the right UV

In a Godot 4 spatial or canvas_item shader you have two ways to compute the screen UV for a depth sample:

// Option A: the built-in SCREEN_UV
float depth_raw = texture(DEPTH_TEXTURE, SCREEN_UV).r;

// Option B: compute from FRAGCOORD
vec2 uv = FRAGCOORD.xy / VIEWPORT_SIZE;
float depth_raw = texture(DEPTH_TEXTURE, uv).r;

On Vulkan, SCREEN_UV in a post-process pass is sometimes inverted relative to a user’s expectation coming from GLES. If your effect renders correctly on the top half of the screen and not the bottom, flip the Y: uv.y = 1.0 - uv.y. Test on both Vulkan Forward+ and the Compatibility renderer before shipping.

Linearize the depth value

The value you read is not a linear distance. It is the post-perspective-divide Z in [0, 1] for a standard depth-24 buffer, which is heavily biased toward the near plane. Using it directly produces fog that sits entirely in the first meter of the scene. Convert it to view-space Z:

float linearize_depth(float d, mat4 inv_proj) {
    vec4 ndc = vec4(0.0, 0.0, d * 2.0 - 1.0, 1.0);
    vec4 view = inv_proj * ndc;
    return -view.z / view.w;
}

void fragment() {
    float raw = texture(DEPTH_TEXTURE, SCREEN_UV).r;
    float z = linearize_depth(raw, INV_PROJECTION_MATRIX);
}

In Godot 4, depth in the shader is already in [0, 1] (not [-1, 1]), so whether you multiply by two and subtract one depends on which SDK version you are targeting. If your fog is inverted at mid range, flip the sign of z; if it is always zero, drop the NDC remap step.

Do not forget the perspective divide

If you want world-space position from depth, you need to reconstruct the full clip-space vector and divide by w after multiplying by the inverse view-projection matrix. Forgetting the divide is the most common subtle bug — the result looks “almost right” at the center of the screen and diverges toward the edges:

vec4 clip = vec4(SCREEN_UV * 2.0 - 1.0, depth_raw, 1.0);
vec4 world = INV_VIEW_MATRIX * INV_PROJECTION_MATRIX * clip;
vec3 pos = world.xyz / world.w; // divide is essential

Check renderer differences

Godot supports Forward+, Mobile, and Compatibility renderers, and they handle the depth texture slightly differently. Compatibility (GLES3) inverts Y compared to Vulkan, so the flip that fixes one breaks the other. Keep a small uniform toggle:

uniform bool flip_y = false;
// Set from GDScript based on RenderingServer.get_rendering_method()

Canvas_item shaders and depth

Canvas_item shaders on a 2D viewport cannot read 3D depth — the viewport did not write one. If you are trying to implement a 2D fake-depth effect, attach your post-process shader to a SubViewport with Use 3D enabled, or pipe a separate depth render texture through a uniform sampler.

“Depth sampling has three independent bugs: wrong UV, wrong linearization, missing perspective divide. Fix them in that order.”

Related Issues

If depth is fine but lighting still looks wrong, see Fix Godot 3D models inside out invisible. For another Godot rendering quirk with viewports, Fix Godot viewport stretch mode black bars unexpected walks through related coordinate-system confusion.

Tip: visualize depth as grayscale first — if the gradient is black near the camera and white far away you have the right orientation.