Skip to main content

Post-processing Effects

Run a full-screen fragment shader over the rendered frame — with access to the terrain depth — and keep chosen layers above it. This is what a peak-finder / relief look is made of.

Fork feature

Added in PR #56. MapRenderer.setPostProcessEffect() + PostProcessEffect. The SDK ships no effect of its own: the shader is a string the app supplies. Technical notes: docs/internals/rendering/14-post-process.md.

Ink outlines over a shaded relief surface

The demo's relief look: the terrain drawn as a shaded surface (TerrainOptions.setSurfaceShaderSource) with an ink-outline post-process effect on top, no tile layers. Camera z13.2, tilt 25.

Attaching an effect

import com.massifmaps.renderers.PostProcessEffect

val effect = PostProcessEffect("relief-outline", myFragmentShaderSource).apply {
isTerrainDepthRequired = true // render the terrain depth texture for the shader
setFloatParameter("uLineWidth", 1.6f)
setFloatParameter("uHorizonBoost", 4f)
setColorParameter("uInkColor", Color(0xFF202020.toInt()))
}
mapView.mapRenderer.postProcessEffect = effect
Surface API

PostProcessEffect has no spec type and is attached to the MapRenderer rather than to a registry object, so this stays object-API for now — see value types for what is readable through the table. What every layer does expose is postProcessed, so keeping a layer out of the effect is one path:

map.layer("labels").set("postProcessed", false);

With an effect attached, the sky, background and all layers render into an offscreen colour texture with a real depth buffer; the effect then draws one full-screen quad. Nothing of this path runs when no effect is set.

What the shader gets

GLSL ES 1.00. The renderer sets each uniform only if the shader declares it:

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

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

float depth = dot(texture2D(uTerrainDepthTex, uv).rgb, vec3(1.0, 1.0/255.0, 1.0/65025.0));
vec3 eyePos = vec3((uv * 2.0 - 1.0) * uProjInvScale, -1.0) * depth * uFar;

Three rules that any effect drawing lines from that depth will otherwise re-derive the hard way:

  • Sample at least one depth texel apart — the depth texture is rendered at half resolution, so the floor is 2 * uInvScreenSize. A smaller step lands all four neighbours on the same texel, the tangents come out zero and normalize(vec3(0)) paints the near field flat grey. Pass that floor in as a float parameter if the shader needs it as one.
  • A silhouette belongs to the nearer side. Testing abs(neighbour - depth) draws every ridge twice and merges the pairs into a black band at the horizon. Only a neighbour further away counts.
  • Do not widen terrain-against-terrain lines with distance — far ridges are a pixel apart, so a wide line smears the range solid. Only the sky silhouette takes the wide radius.

Layers above the effect

annotationLayer.setPostProcessed(false)

A layer held back from the stylized pass is drawn after the effect, into the same depth buffer — so annotations and sky-anchored objects keep their own appearance and still go 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.

The terrain surface underneath

The relief look is two halves. The effect draws the lines; the shaded paper-white surface comes from the terrain itself:

terrainOptions.surfaceShaderSource = myTerrainSurfaceShader

That shader is what paints the ground where no tile layer does — pair it with --es map false-style configuration (no base map, no hillshade) for a pure panorama.

Cost (measured, emulator)

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

ConfigCPU frameGPU total
ordinary map38.7 ms29.8 ms
ordinary map + relief effect24.0 ms32.9 ms
peak-finder mode (no tile layers)19.3 ms13.0 ms
peak-finder, MeshResolution 3212.7 ms10.1 ms

The effect itself is ~3 ms of GPU. The rest is the terrain depth texture, drawn every frame at the terrain's own mesh resolution (a coarser depth mesh draws its own triangulation as fold lines), so TerrainOptions.MeshResolution is the knob that trades line quality for frames.

Known limits: the depth texture is half resolution, so lines quantise at 2 px; the effect resolves once per frame over the whole screen — there are no layer-level effects.