Post-Process Pipeline

February 11, 2026 · View on GitHub

The post-process pass composites the game world and RmlUI layers into the final swapchain image, applying barrel warp, chromatic aberration, HUD inertia, and color grading. The game renders clean while the UI gets a warped-glass treatment with per-channel color fringing.

Rendering Architecture

Game render pass ──► color_buffers[0] (game texture)
                                                       ──► Post-process pass ──► Swapchain
RmlUI render pass ──► color_buffers[1] (UI texture)

The UI renders to a separate Vulkan color buffer with a transparent background. Both buffers are bound as sampler2D inputs (descriptor sets 0 and 1) to a fullscreen triangle post-process pass.

Shader Overview

Vertex Shader (Shaders/postprocess.vert)

Draws a single fullscreen triangle (3 vertices, no vertex buffer) covering clip space. UVs are derived directly from vertex position.

Fragment Shader (Shaders/postprocess.frag)

Five stages in order:

  1. Game sampling — Sampled at straight UVs, no distortion applied.

  2. UI distortion — The UI is scaled down slightly when warped (ui_scale = 1.0 + |warp| * 0.5) to create a "curved glass" feel, then barrel-distorted. When chromatic aberration is enabled, R/G/B channels are sampled at different warp strengths:

    • Red: warp + chroma
    • Green: warp (center channel)
    • Blue: warp - chroma

    This splits bright UI elements into visible color fringing at the edges.

  3. Helmet display echo — When echo_strength > 0, the UI texture is sampled a second time at a slightly different scale from screen center (echo_scale), simulating a ghost reflection at a different focal depth inside a helmet visor. The echo is additively blended into the primary UI before compositing:

    echo_uv = (ui_uv - 0.5) * echo_scale + 0.5
    ui.rgb += echo.rgb * echo_strength
    ui.a = max(ui.a, echo.a * echo_strength)
    
  4. Composite — Premultiplied alpha blend with opacity and additive control:

    opacity = clamp(ui_opacity, 0.1, 1.0)
    add = clamp(ui_additive, 0.0, 1.0)
    alpha_weight = ui.a * opacity * (1.0 - add)
    ui_scale_factor = mix(1.0, 0.7, add)
    frag = game * (1.0 - alpha_weight) + ui.rgb * opacity * ui_scale_factor
    
    • Normal mode (add = 0): Standard alpha composite — game darkens under opaque UI, then UI layered on top.
    • Additive mode (add = 1): Game is fully preserved, UI is added as emissive glow (dimmed to 70% to prevent blow-out). Creates a holographic HUD feel.
    • Intermediate values: Smooth blend between modes.
  5. Color grading — Contrast multiply, then gamma power curve.

Push Constants

All effect parameters are passed per-frame via Vulkan push constants from GL_EndRenderingTask() in Quake/gl_vidsdl.c.

OffsetFieldSourceDescription
0gammavid_gamma cvarGamma power curve
4contrastvid_contrast cvarContrast multiplier (clamped 1.0–2.0)
8warp_strengthr_ui_warp cvarBarrel distortion strength (default -0.1)
12chromatic_strengthr_ui_chromatic cvarChromatic aberration (default 0.003, scaled by 1080/height)
16ui_offset_xv_hud_offset_xHorizontal HUD sway (UV-space)
20ui_offset_yv_hud_offset_yVertical HUD bounce (UV-space)
24ui_opacityscr_sbaralpha cvarUI layer opacity input (shader clamps effective range to 0.1–1.0)
28echo_strengthr_ui_echo cvarHelmet display echo intensity (0 = off)
32echo_scaler_ui_echo_scale cvarEcho UV scale from center (1.0 = no offset, >1.0 = larger ghost)
36ui_additiver_ui_additive cvarBlend mode: 0 = normal alpha composite, 1 = additive emissive glow

Console Variables

CvarDefaultFlagsDescription
r_ui_warp-0.1CVAR_NONEBarrel distortion applied to the UI layer. Negative values = pincushion (inward curve).
r_ui_chromatic0.003CVAR_NONEChromatic aberration intensity. Scaled by 1080/height so the effect is resolution-independent. Set to 0 to disable.
r_ui_echo0CVAR_NONEHelmet display echo strength. Adds a faint ghost of the UI at a different focal depth, as if projected inside a curved visor. Set to 0 to disable.
r_ui_echo_scale1CVAR_NONEEcho UV scale from screen center. Values > 1.0 make the ghost slightly larger (simulating a deeper focal plane).
r_ui_additive0CVAR_NONEComposite blend mode. 0 = normal (UI darkens game underneath), 1 = additive (UI glows on top of game). Intermediate values blend between modes. UI is dimmed to 70% in additive mode to prevent color clipping.
vid_gamma(engine default)CVAR_ARCHIVEStandard gamma correction.
vid_contrast1.4CVAR_ARCHIVEContrast multiplier, clamped to [1.0, 2.0].
scr_sbaralpha0.75CVAR_ARCHIVEHUD opacity. Controls both the classic status bar and the RmlUI layer via the post-process pass.

HUD Inertia

The HUD layer shifts slightly in response to player movement, computed per-frame in V_UpdateHudInertia() (Quake/view.c). Two independent axes:

Vertical Bounce

  • Triggered on jump (transition from on-ground to airborne with positive Z velocity)
  • Fires a downward velocity impulse (-0.2)
  • Spring constant omega = 18 — tight enough to match the jump arc

Horizontal Sway

  • Driven by yaw (camera turn) delta each frame
  • Scaled to UV impulse: yaw_delta * -0.002 (HUD lags behind the turn)
  • Spring constant omega = 14 — slightly looser than vertical

Both axes use critically-damped springs (accel = -omega^2 * offset - 2 * omega * velocity) so the HUD returns smoothly to center without oscillation. Tiny residuals below threshold are zeroed to prevent float drift.

Lua Post-Process Controller

The demo and ui_lab mods each include a postprocess_controller.lua script that dynamically adjusts echo (and other effects in ui_lab) based on game state. The base UI has no post-process controller.

Demo Mod (demo/ui/lua/postprocess_controller.lua)

Manages echo intensity per context:

  • Gameplay (HUD visible): r_ui_echo 0.1 — subtle visor ghost
  • Menus: r_ui_echo 0.2 — stronger projection feel

Loaded from hud.rml via <script src>. Uses engine.on_frame("postprocess", fn) with named registration so loading from multiple documents doesn't double-register — the second load overwrites the same callback slot.

Uses engine.hud_visible() to detect menu vs gameplay state. Only writes cvars on state change (dirty tracking via last_echo).

ui_lab (ui_lab/ui/lua/postprocess_controller.lua)

Extends the base pattern with dynamic effects driven by game state:

  • Damage hit: decaying warp + chromatic pulse
  • Weapon fire: decaying chromatic flash
  • Quad damage: continuous chromatic sine pulse
  • Low health (<25): progressive warp intensification
  • Echo toggle: off in menus, restored during gameplay

Source Files

FileRole
Shaders/postprocess.vertFullscreen triangle vertex shader
Shaders/postprocess.fragCompositing + effects fragment shader
Quake/gl_vidsdl.cVulkan resource setup, descriptor sets, push constant dispatch
Quake/gl_rmisc.cPipeline layout creation (push constant range)
Quake/view.cV_UpdateHudInertia() — spring physics for HUD offset
Quake/view.hExports v_hud_offset_x, v_hud_offset_y
demo/ui/lua/postprocess_controller.luaDemo mod echo controller (menu/HUD toggle)
ui_lab/ui/lua/postprocess_controller.luaExtended controller (damage, fire, quad, low health effects)