Skip to main content

Post-processing: full-screen effects and the layers that sit above them

Scope: MapRenderer::setPostProcessEffect, what a fragment shader gets to work with, how the terrain depth reaches it, and how a layer opts out of being stylized. The relief (peak-finder) look is the worked example; the shaded terrain surface it draws over is in 04-terrain.md.

The pipeline

With an effect attached, the frame is redirected (MapRenderer::onDrawFrame):

  1. clearAndBindScreenFBO — sky, background and all layers render into an offscreen colour texture with a real depth buffer, instead of the screen.
  2. applyPostProcessEffect — optionally renders the terrain depth texture first, then draws one full-screen quad with the effect's fragment shader.
  3. With no opted-out layer, that quad goes straight to the screen and the frame is done. With one, it goes to the framebuffer's secondary colour texture (FrameBuffer:: attachSecondaryColorTex) — same FBO, same depth attachment — the overlay layers are drawn on top of it by drawOverlayLayers, and blendAndUnbindScreenFBO blits the result out.

Step 3 is the only reason for the second texture: GL cannot read and write one texture in a pass, and re-rendering the terrain depth into the default framebuffer to get overlays depth-tested would cost a second terrain pass (~20 ms). Swapping the colour attachment keeps the depth buffer the scene was drawn with, so an overlay is still occluded by the ridge in front of it.

Nothing above runs when no effect is set: the split in drawLayers is behind the postProcessing flag, and the secondary texture is allocated on first use.

What a shader gets

PostProcessEffect(name, fragmentShader) takes GLSL ES 1.00 source. Uniforms the renderer sets when the shader declares them (queried with glGetUniformLocation + a >= 0 guard — see 03-vt-renderer.md):

UniformMeaning
sampler2D uColorTexthe rendered frame, premultiplied alpha
sampler2D uTerrainDepthTexpacked terrain depth, only with setTerrainDepthRequired(true)
vec2 uInvScreenSize1/width, 1/height; screen uv is gl_FragCoord.xy * uInvScreenSize
float uNear, uFarfrustum distances, internal units
vec2 uProjInvScaletan(fovy/2)·aspect, tan(fovy/2)
float uTimeseconds since the effect was attached
float / colour parametersevery setFloatParameter / setColorParameter, by name

The depth texture is RGB = 24-bit linear eye depth relative to the far plane (dot(rgb, vec3(1, 1/255, 1/65025))), A = terrain coverage (0 = sky). Eye position of a pixel:

vec3 eyePos = vec3((uv * 2.0 - 1.0) * uProjInvScale, -1.0) * depth * uFar;

It is rendered by TerrainRenderer::renderDepthTexture at half resolution (BUFFER_DOWNSCALE = 2) with nearest filtering, and — for the effect path only — at the terrain's full mesh resolution. The occlusion read-back keeps the cheap 32-cell cap because it samples points; an effect that draws lines from this depth would otherwise draw the depth mesh's own triangulation, which is what the first attempt did (bright facets all over the near field).

The relief outline effect

The SDK carries no effect of its own: PostProcessEffect(name, fragmentShader) takes the shader as a string, the way SkyOptions::setShaderSource and TerrainOptions::setSurfaceShaderSource do, and everything an effect can read is a documented uniform (see the header) or a named parameter the app sets. The relief look — ink lines on paper over the shaded surface — therefore lives in the app: DemoStyles.reliefOutlineShader() in the demo. There was a built-in CreateReliefOutlineEffect() factory; it was removed, because a peak-finder look is not something an SDK should have an opinion about.

Three findings from making that shader match the reference (PeakFinder, and farfromrefug/geo-three) — they are about the depth texture, so they apply to any effect drawing from it:

  • Sample at least one depth texel apart. With a step below BUFFER_DOWNSCALE pixels the four neighbour samples land on the same texel, the tangent vectors come out zero, and normalize(vec3(0)) is undefined — it painted the entire near field flat grey. uDepthTexelSize is the floor.
  • A silhouette belongs to the nearer side. Testing abs(neighbour - depth) draws every ridge twice, once on each side, and at the horizon the pairs merge into a black band. Only a neighbour further away counts.
  • Do not widen terrain-against-terrain lines with distance. The obvious reading of "the horizon is bolder" smears the far ranges solid: up there ridges are a pixel apart, so a 4 px line covers everything. What is bold in a panorama is the sky silhouette, so only that test uses the wide radius (uHorizonBoost); the ridge and crease lines keep one width everywhere.

Ridge/valley lines come from the two tangent directions away from a pixel: opposite on a flat surface (dot = -1), folded together over a crest. Computed on eye positions, not on depth, so a merely oblique slope — which is most of a panorama — does not read as a fold.

Layers above the effect

Layer::setPostProcessed(false) holds a layer back from the stylized pass. It is drawn after the effect, into the same depth buffer, so annotations and sky-anchored objects (13-celestial.md) keep their own appearance while still going behind ridges. Such a layer takes no part in the terrain prelude (depth-write assignment, cover, draping) — it is an overlay, not a layer that paints the ground.

What it costs (measured)

Crosscall HLTE556N (Adreno 610), Grenoble panorama z13.2 tilt 25, 8 pan swipes, -PprofileRender:

ConfigCPU frameGPU totalnotes
ordinary map38.7 ms29.8 msthe CPU number is mostly the swap wait (sky 24.7)
ordinary map + relief effect24.0 ms32.9 msthe effect is ~3 ms of GPU; the CPU drop is the swap wait moving
peak-finder mode (no tile layers)19.3 ms13.0 msprelude 9.5 ms — the depth texture pass
peak-finder, meshResolution 3212.7 ms10.1 msprelude 3.3 ms

The effect's terrain depth texture is drawn at the terrain's own mesh resolution (see above: a coarser depth mesh draws its own triangulation as fold lines), from CPU meshes, every frame — so it scales with TerrainOptions.MeshResolution, and that is the knob to trade line quality for frames. There is no per-camera caching on this path, unlike the occlusion read-back.

Known limits

  • The depth texture is half resolution, so lines are quantised at 2 px and slopes show occasional dotted artefacts. A full-resolution depth pass would fix it and doubles the depth pass cost.
  • The effect resolves once per frame over the whole screen; layer-level effects do not exist.
  • Verified on the emulator (Grenoble panorama, z13.2 tilt 25). Line quality on a device at high DPI has not been measured.