3D postprocessing: depth and distance reconstruction
A fullscreen spatial pass with reverse-Z, INV_PROJECTION_MATRIX and controlled distance haze.
Run the example
Download the project. Requires Reckfell 1.0.0 with the C# SDK and Forward+/Vulkan. Open project.reckfell, build C# and run Main.rscene. All resources are included: Poly Haven’s CC0 Pawn Shop interior, with adapted materials and lighting.
Before running, choose root Preview: Original for the room, Distance for diagnostic depth, or Haze for distance haze. At runtime call SetPreview(mode) after _Ready.
Original frame:
The same camera with depth-based haze:
Add it to your own scene
Download the two effect files. This is a component for an existing C# project, not a standalone scene.
- Copy DepthHazePass.cs and DepthHaze.reckfellshader into asset:// root and build C#.
- After the active camera enters the tree call camera.AddChild(new DepthHazePass()). Camera and pass must belong to the same viewport.
- After the pass’s own _Ready use SetDensity(value) for density or SetPreview(0/1/2) for haze, distance and raw depth.
- When replacing the active camera, attach a pass to the new camera. A fullscreen mesh left under the old one does not transfer automatically.
Place the pass in the correct viewport
DepthRoom3D creates the room and adds DepthHazePass as a child of its active Camera3D. The material reads screen_texture and depth_texture from that same viewport. Showing an image through a TextureRect in another viewport does not transfer the source depth buffer there.
using Reckfell;
public partial class DepthRoom3D : Entity3D
{
public enum Mode { Original, Distance, Haze }
[Export] public Mode Preview { get; set; } = Mode.Haze;
public PawnShop3D Room { get; private set; } = null!;
public DepthHazePass Pass { get; private set; } = null!;
public override void _Ready()
{
Room=new PawnShop3D();AddChild(Room);
Pass=new DepthHazePass();Room.ShotCamera.AddChild(Pass);
SetPreview(Preview);
}
public void SetPreview(Mode mode)
{
Pass.Visible=mode!=Mode.Original;
Pass.SetPreview(mode==Mode.Distance?1:0);
}
}QuadMesh has Size=(2,2), placing its vertex X/Y coordinates in −1…1. vertex() writes POSITION directly in clip space. The current reverse-Z pass places the near plane at Z=1, so it uses vec4(VERTEX.xy, 1.0, 1.0), not object world coordinates.
The CPU still tests ordinary mesh bounds before executing vertex(). The QuadMesh therefore sits in front of the camera as its child; ExtraCullMargin adds a margin and IgnoreOcclusionCulling excludes it from ordinary occlusion culling. Assigning POSITION alone does not fix CPU culling for an arbitrary mesh elsewhere in the world.
using Reckfell;
public partial class DepthHazePass : MeshInstance3D
{
public ShaderMaterial Effect { get; private set; } = null!;
public override void _Ready()
{
Mesh=new QuadMesh { Size=new Vector2(2,2) };
Effect=new ShaderMaterial {
Shader=ResourceLoader.Load<Shader>("asset://DepthHaze.reckfellshader"),RenderPriority=127 };
MaterialOverride=Effect;
Position=new Vector3(0,0,-1);
ExtraCullMargin=1;
CastShadow=ShadowCastingSetting.Off;
GIMode=GIModeEnum.Disabled;
IgnoreOcclusionCulling=true;
}
public void SetPreview(int mode)=>Effect.SetShaderParameter("preview",Mathf.Clamp(mode,0,2));
public void SetDensity(float value)=>Effect.SetShaderParameter("haze_density",Mathf.Max(value,0));
}depth_draw_never prevents the pass from writing its own depth; depth_test_disabled lets it cover the frame; cull_disabled removes the quad-facing dependency. unshaded and fog_disabled avoid lighting and built-in fog being applied again to the pass itself. CastShadow=Off and GIMode=Disabled keep screen geometry out of shadows and GI.
Reconstruct distance
Complete DepthHaze.reckfellshader:
shader_type spatial;
render_mode unshaded, fog_disabled, depth_draw_never, depth_test_disabled, cull_disabled;
uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_linear;
uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest;
uniform int preview = 0; // 0: haze, 1: view-space depth, 2: raw depth
uniform float distance_range = 20.0;
uniform float haze_start = 2.0;
uniform float haze_density = 0.08;
uniform vec3 haze_color : source_color = vec3(0.46, 0.57, 0.66);
void vertex() {
POSITION = vec4(VERTEX.xy, 1.0, 1.0);
}
void fragment() {
float raw_depth = textureLod(depth_texture, SCREEN_UV, 0.0).r;
#if CURRENT_RENDERER == RENDERER_COMPATIBILITY
vec3 ndc = vec3(SCREEN_UV, raw_depth) * 2.0 - 1.0;
#else
vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, raw_depth);
#endif
vec4 view_position = INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
view_position.xyz /= view_position.w;
float eye_depth = -view_position.z;
vec3 source = textureLod(screen_texture, SCREEN_UV, 0.0).rgb;
float haze = 1.0 - exp(-max(eye_depth - haze_start, 0.0) * max(haze_density, 0.0));
// A cleared reverse-Z depth value is background, not a finite surface sample.
if (raw_depth <= 0.000001) { haze = 0.0; }
vec3 result = mix(source, haze_color, haze);
if (preview == 1) { result = vec3(clamp(eye_depth / distance_range, 0.0, 1.0)); }
if (preview == 2) { result = vec3(raw_depth); }
ALBEDO = result;
ALPHA = 1.0;
}depth_texture does not contain distance. In current reverse-Z, nearer surfaces produce larger values and farther surfaces smaller ones; cleared background is 0. Perspective-camera depth is nonlinear. Multiplying raw_depth by Camera3D.Far or using 1−raw_depth does not reconstruct distance.
INV_PROJECTION_MATRIX needs NDC coordinates. Forward+/Vulkan keeps depth in 0…1 and converts SCREEN_UV X/Y to −1…1. Compatibility/OpenGL converts all three components to −1…1. CURRENT_RENDERER selects the branch at compile time; it is not a GPU-name check.
The shader then multiplies vec4(ndc,1) by INV_PROJECTION_MATRIX and divides XYZ by W. eye_depth=−view_position.z is positive camera-axis distance. It is not ray length: length(view_position.xyz) differs near screen edges. The same reconstruction is accepted for Perspective and Orthogonal; raw orthogonal depth already has a linear relationship to distance.
Understand the effect and diagnostic view
haze_start sets the starting distance in world units. haze_density controls the mixing rate: 0 preserves the source color, while a positive value gradually moves distant surfaces toward haze_color. The controller does not pass negative density. Cleared-depth background is excluded from haze.
Distance is a diagnostic mask, not a finished game image. Brighter surfaces have larger eye_depth:
The mask divides eye_depth by distance_range=20 and clamps it to 0…1. The color then passes through tonemapping and color encoding. A gray channel in a saved PNG cannot be read directly as numerical distance or original raw_depth. Cleared background appears white in Distance because reconstruction reaches Far; there is no real surface there.
The control fixture checks planes at 2, 6 and 12 units, changing Near/Far and orthogonal Size, and translating/rotating the camera. Checks account for output color conversion. The illustrations show the authored interior rendered in the engine, not these control planes.
Respect the copy stage
Forward+ prepares screen/depth copies for this material before subsequent transparent rendering. Do not treat screen_texture as the final frame containing every transparent object and UI element. RenderPriority=127 does not make the engine recapture these buffers after each transparent material.
Ordinary transparent glass, particles and other materials without the required depth writes may have no separate surface in depth_texture. This example does not solve every such case. The adjacent lesson’s 2D BackBufferCopy is not a command to refresh 3D depth. Use a canvas_item pass for processing an already displayed image without 3D depth.
Diagnose the result
| Symptom | Check |
|---|---|
| effect disappears when moving | quad belongs to the active camera and sits in front of it; CPU and occlusion culling |
| nearly black raw mask | nonlinear depth and selected Near/Far; reconstruct for distance |
| incorrect distance in another renderer | NDC Z conversion branch |
| changing Near/Far breaks the formula | use current INV_PROJECTION_MATRIX instead of a tuned multiplier |
| pass changes lighting or shadows | unshaded, fog_disabled, CastShadow=Off, GIMode=Disabled |
| UI or glass behaves differently | screen/depth copy stage and those objects’ actual depth writes |
Numerical control cases are accepted in Compatibility/OpenGL and Forward+/Vulkan. The full room and illustrations are accepted only in Forward+: 2560×1440, internal scale 1.5, MSAA 8× and TAA. Normal-buffer access, XR, all transparent-surface modes, application export and performance are not accepted.