Skip to content

Scene Depth

App: apps/scene_depth_demo/

Demonstrates scene depth in the mesh ABI (engine PLM-263 / ADR 0089). A mesh shader — typically a water surface — can now read the depth of the opaque scene behind and under it at set 0 binding 5, the enabler for shoreline blend, intersection foam, and depth-tinted colour. With Graphics.opaqueCaptureEnabled(true) the engine copies the opaque-phase depth into a separate image after the opaque phase; a transparent draw (blendMode = "alpha") then samples that copy — so it reads the scene behind it without touching the live depth attachment it is being tested against.

This mirrors Refraction (opaque colour at binding 6, A#3) and Planar Reflection (reflection at binding 7, A#4): the same opaque-capture machinery, one more global feed on the mesh set-0 ABI — but this one carries depth.

Terminal window
% ./plume3d scene_depth_demo

Four opaque colour bands sit at four different depths (near→far, left→right); one transparent quad floats in front of them. The quad reads the opaque depth behind each pixel and paints a shoreline: foam-white where a band sits close behind the surface, grading to deep blue where only the cleared background is behind it. Because the bands are at different depths, the shoreline tracks across the quad — that varying gradient is the proof the shader reads the actual per-pixel scene depth (a constant / default read would make the whole quad uniform). The demo captures a screenshot (scene_depth.png) shortly after startup, then exits.

A mesh shader reads scene depth through the canonical mesh ABI — #include "plume3d.slang" — with two accessors:

// Raw device depth [0,1] at a screen pixel — pass the fragment's SV_Position.xy.
float plume3d_sceneDepthLoad(int2 px);
// Linearize a raw device depth to an eye-space distance. The mesh ABI carries no near/far,
// so the caller passes its own camera planes.
float plume3d_linearizeDepth(float raw, float nearZ, float farZ);

Read it with the fragment’s screen pixel coordinate — the integer SV_Position.xy, not a normalized UV:

#include "plume3d.slang"
[shader("fragment")]
float4 fragment(VSOutput input) : SV_Target {
int2 px = int2(input.PositionCS.xy); // screen pixel
float d = plume3d_sceneDepthLoad(px); // raw opaque-scene depth [0,1] behind this pixel
// "Water depth" = how far the opaque scene is behind the surface → shoreline blend.
float waterZ = 0.3;
float gap = saturate((d - waterZ) * 2.2);
float3 col = lerp(float3(0.90, 0.97, 1.0), // foam (shallow)
float3(0.05, 0.28, 0.55), // deep water
gap);
return float4(col, 1.0);
}

Binding 5 is a Texture2D<float> read via .Load() (a texel fetch), not a combined sampler: a D32 depth image sampled through a SamplerState generates an undeclared split sampler in MSL on MoltenVK, so .Load is the safe form (the same reason the post pass reads depth this way). The binding is always declared in the shared ABI (the PLM-178 superset idiom) — a mesh shader that never reads it is unaffected, and Slang strips the unused resource.

Scene depth rides the same gate as opaque colour (A#3). Each frame in draw(), enable capture, draw the opaque geometry, then the transparent surface, and add a post effect so the scene renders offscreen (opaque capture needs the offscreen path — a passthrough present is fine):

Graphics.opaqueCaptureEnabled(true) // split opaque → copy → transparent (colour AND depth)
for (b in _bands) Graphics.drawMesh(b) // opaque phase (pass 1) → writes the depth buffer
Graphics.drawMesh(_quad) // transparent phase (pass 2) → reads binding 5
Graphics.addPostEffect("shaders/present", []) // passthrough present drives the offscreen path

The transparent quad is classified into the second (post-copy) phase by its blend mode:

_quad = Graphics.newMesh(q, "trianglestrip", _water)
_quad.blendMode = "alpha" // transparent → renders after the opaque depth copy

With capture off (or no post effect) binding 5 is the 1×1 default depth, so the quad reads a single flat value and shows no banding — inert, never a crash.

A water surface draws in the transparent phase of the same scene pass that owns the depth buffer, so it cannot sample the live depth attachment directly — that is depth-attachment feedback (reading the attachment being depth-tested against). The engine solves it exactly as A#3 solved it for colour: after the opaque phase (only when opaque capture is on) it copies the scene depth into a separate opaqueDepth_ image and transitions it to read-only; the transparent phase samples the copy at binding 5. There is no extra cost when opaque capture is off — the binding stays on the 1×1 default and no copy runs.

Reading the opaque scene depth from a mesh shader is the primitive a stylized water surface is built on. It composes with the render foundations already shipped:

  • Waves — the global frame + wind uniform (A#1) animate the surface.
  • Shoreline / foam / depth tint — this (binding 5): fade toward foam where the opaque scene is close behind the surface, tint deeper water by the depth gap.
  • Refraction — opaque colour at binding 6 (A#3): sample the scene behind the surface and distort it.
  • Reflection — planar reflection at binding 7 (A#4): mirror the scene above the plane.
  • No new Wren surface. This is a render-foundation (mesh-ABI) capability, not a scripting change: the gate is the existing Graphics.opaqueCaptureEnabled, the accessors live in the shared plume3d.slang ABI, and existing shaders are unaffected (the binding is additive and inert unless read).
  • plume3d_sceneDepthLoad reads raw device depth ([0,1], Vulkan clip z). For an eye-space distance (metric shoreline width, fog-by-depth), pass your camera’s near/far to plume3d_linearizeDepth.
  • The post ABI (plume3d_post.slang) has exposed sampleable scene depth since A#2 — see the post effects + scene depth guide and the depth-edge outline in the Toon pack. PLM-263 brings the same read to mesh shaders, so a transparent surface can use it mid-scene.

See Graphics.opaqueCaptureEnabled. Screenshot-verified (the shoreline tracks the bands’ varying depth).