Skip to content

Full-screen post effects & scene depth

Render the scene to an offscreen target, then run a full-screen post-process shader that samples the rendered scene colour and scene depth (plus the frame clock and your params) and writes the final image. This is how you do colour grading, depth-based fog / underwater, outlines, and any screen-space effect (engine ADR 0074 / PLM-240 + PLM-237).

It is opt-in: when at least one post effect is added, the engine renders the scene offscreen and runs your post pass into the swapchain; with no effects added, the normal path is unchanged.

Companion guides: Frame clock & wind · Custom-material pack shaders.

// apps/<app>/shaders/src/mygrade.slang
#include "plume3d_post.slang" // NOT plume3d.slang — this is the POST ABI
[shader("fragment")]
float4 fragment(PostVSOutput input) : SV_Target {
float4 c = plume3d_sceneColor(input.UV);
float d = plume3d_sceneDepth(input.UV); // 0 (near) .. 1 (far)
float4 tint = plume3d_postParam(0); // params[0] from Graphics.addPostEffect
return float4(c.rgb * tint.rgb, 1.0);
}
// main.wren — draw the scene as usual, then add the post effect each frame in draw()
Graphics.loadShader("shaders/mygrade") // once, in init()
// ... Graphics.drawMesh(...) your scene ...
Graphics.addPostEffect("shaders/mygrade", [1.0, 0.9, 0.8, 1.0]) // params (up to 16 floats)

A post shader #includes plume3d_post.slang and provides only [shader("fragment")] — the full-screen vertex is in the include, and PostVSOutput carries a UV.

accessormeaning
float4 plume3d_sceneColor(uv)the rendered scene colour
float plume3d_sceneDepth(uv)scene depth [0,1] (near → far); read by texel .Load(), so it is 1:1 exact
float plume3d_time()the frame clock (for animated grades — shared with the frame block)
float4 plume3d_postParam(i)your params: i = 0 is params[0..3], i = 1 is params[4..7], …

Do not #include "plume3d.slang" in a post shader — that is the mesh ABI (a different set-0 layout). Full binding contract: Graphics → Post-process effects.

apps/<app>/shaders/src/<name>.slang, #include "plume3d_post.slang", provide [shader("fragment")]. Sample plume3d_sceneColor / plume3d_sceneDepth, and use plume3d_postParam(i) for tunables.

Graphics.loadShader("shaders/<name>") // once, in init()
// in draw() — the post list is per-frame, like draw calls
Graphics.addPostEffect("shaders/<name>", [ /* up to 16 floats */ ])

Graphics.clearPostEffects() stops the effect (drops back to the direct path).

Your meshes render offscreen; the post pass composites them to the screen.

Terminal window
plume3d apps/post_depth_demo # a post effect visualizes the scene depth, then screenshots

The reference (post-depth example): quads drawn at different depths read back as distinct grays (near dark, far light) and the cleared background is white (depth 1.0) — proving the scene rendered offscreen and the post pass read its depth.

  • One post effect is applied in v1 (a ping-pong chain of multiple effects is a follow-up).
  • The offscreen colour is LDR (B8G8R8A8_SRGB), so existing mesh shaders render into it unchanged. True HDR (R16F) + a tonemap / bloom pass is a follow-up.
  • Post effects run for the single-camera path only, and are skipped when fog-of-war, split-screen, or the RTT composite path is active (they own the frame).
  • Read depth with plume3d_sceneDepth (texel .Load), not a .Sample — sampling the depth image through a SamplerState mis-compiles on MoltenVK. The accessor already does the right thing.
  • A post shader loaded via Graphics.loadShader also attempts a (harmless, logged) mesh-pipeline build — ignore the Could not create graphics pipeline line for a post shader; the post pass works.