Post Process Effects

PostFX are post-processing effects applied after the scene is rendered. They shape the final image through effects such as ambient occlusion, bloom, color correction, depth of field, motion blur, local exposure, screen-space shadows, screen space reflections, anti-aliasing, and upscaling.

Since version 0.40, PostFX passes can run as compute shaders instead of fullscreen pixel shader draws. Nearly all shipped effects have moved to compute, and custom effects can use the same path on every renderer, including Direct3D 11.

For modders, PostFX are best used to tune presentation, not to replace lighting. Set up the scene with physically meaningful lights, emissives, materials, exposure, and tone mapping first, then use PostFX for final image treatment.

Screen-space shadows use the depth visible from the current camera to add fine contact shadowing and shadows from distant visible geometry. They complement regular shadow maps, but cannot replace shadows from geometry outside the camera view.

The most stable modding paths are custom color correction ramps and Lua extensions that adjust existing PostFX objects. Creating new fullscreen effects with custom shaders is possible, but it is an advanced workflow and can break when the rendering pipeline changes.

What you can mod

Common PostFX modding goals:

  • Add a custom color filter or grade.
  • Change PostFX values when a level, scenario, camera mode, or tool starts.
  • Temporarily enable or disable existing effects.
  • Create a custom shader-based fullscreen effect.

Recommended approaches:

Goal Recommended method
Color filter or LUT Add a 256x1 PNG under art/postfx/.
Level or scenario-specific tuning Use a Lua extension to adjust existing PostFX objects at runtime.
Temporary camera effect Enable, disable, or set values on existing scenetree PostFX objects.
New visual effect Create a custom PostEffect with ShaderData, GFXStateBlockData, and an HLSL compute shader.

Color correction ramps

Color correction ramps are the simplest PostFX mod. They are one-dimensional PNG lookup textures used by the color correction pass.

Place custom ramps in:

art/postfx/

For example, a packed mod can contain:

my_postfx_mod.zip
art/
  postfx/
    my_color_grade.png

Ramp requirements:

  • Use PNG format.
  • Use a 256x1 image.
  • Start from the default color ramp when creating a grade.
  • Keep the grade subtle enough that exposure, sky color, headlights, UI readability, and night driving remain usable.

The game discovers color correction ramps by searching art/postfx/*.png. The same path is used by Photo Mode filters, so a ramp placed there can be used as a custom filter.

See Photomode for a practical color-ramp workflow.

Runtime tuning from Lua

Existing PostFX objects can be found through the scene tree and adjusted from Lua. This is useful for scenarios, extensions, tools, and level-specific presentation.

Typical pattern:

local dof = scenetree.findObject("DOFPostEffect")
if dof then
  dof:setFocusParams(0.0, 0.25, 50, 250, -5, 5)
  dof:setAutoFocus(false)
  dof:setFocalDist(120)
  dof:enable()
end

For simple enable/disable behavior:

local ssao = scenetree.findObject("SSAOPostFx")
if ssao then
  ssao:enable()
end

local motionBlur = scenetree.findObject("PostFxMotionBlur")
if motionBlur then
  motionBlur.strength = 0.5
  motionBlur:enable()
end

Important object names:

Object Main use
SSAOPostFx Screen-space ambient occlusion.
ScreenSpaceShadowsPostFx Screen-space shadow mask.
DOFPostEffect Depth of field chain.
PostFxMotionBlur Current motion blur implementation used by options and Photo Mode.
PostEffectBloomObject Bloom threshold and knee.
PostEffectCombinePassObject Final HDR combine, color correction, and tone mapping related values.
PostEffectLocalExposureObject Local exposure and exposure bias.
GammaPostFX Legacy gamma and color ramp effect.
FogPostFx Atmospheric fog post effect.
UnderwaterFogPostFx Underwater fog pass.
ChromaticLensPostFX Chromatic lens effect.
GlowPostFx Glow pass.
FXAA_PostEffect FXAA antialiasing pass.
SMAA_PostEffect SMAA antialiasing chain.
SMAA_T2X_PostEffect SMAA T2x temporal antialiasing and history resolve. The render view supplies its two-phase camera jitter.
PostFxSSSRObject Screen space reflections (trace, denoise, and history). Only present on Direct3D 12 and Vulkan.

Use the graphics settings API when you want to change the same values exposed by the Options menu. Direct object edits are better for temporary effects, editor tools, and custom camera behavior.

Common graphics setting keys include:

  • PostFXSSAOGeneralQuality (Off, Normal, High)
  • PostFXSpecularOcclusionEnabled
  • PostFXScreenSpaceShadowsEnabled
  • PostFXSSRQuality (Off, Low, Medium, High) and PostFXSSREnabled
  • PostFXDOFGeneralEnabled
  • PostFXMotionBlurEnabled
  • PostFXMotionBlurStrength
  • PostFXMotionBlurPlayerVehicle
  • GraphicAntialiasType and GraphicUpscalingQuality
  • GraphicEVCompensation

Availability keys such as PostFXSSRAvailable and PostFXScreenSpaceShadowsAvailable report whether the effect exists on the current renderer and lighting quality. Check them before exposing options in a mod UI.

Creating custom shader PostEffects

Custom shader-based effects are built from engine PostEffect objects. This is the path to use when a color ramp or runtime tuning cannot express the effect.

A custom effect usually needs:

  • A ShaderData object that points to an HLSL compute shader through DXComputShaderName.
  • A GFXStateBlockData object that defines the sampler states.
  • A PostEffect object that defines shader, textures, target, timing, and priority.
  • An optional callback table for shader constants and enable checks.

The PostEffect fields are the same whether the effect uses a compute or a pixel shader. The engine checks which shader stages the ShaderData provides: if it has a compute shader, the effect is dispatched; otherwise it is drawn as a fullscreen quad.

Compute shader effects

Minimal Lua structure, modelled on lua/ge/client/postFx/fxaa.lua:

local pfxDefaultStateBlock = scenetree.findObject("PFX_DefaultStateBlock")

local shader = scenetree.findObject("MyPostFxShader")
if not shader then
  shader = createObject("ShaderData")
  shader.DXComputShaderName = "shaders/common/postFx/myEffect.cs.hlsl"
  shader:setField("samplerNames", 0, "$colorTex")
  shader.pixVersion = 5.0
  shader:registerObject("MyPostFxShader")
end

local state = scenetree.findObject("MyPostFxStateBlock")
if not state then
  state = createObject("GFXStateBlockData")
  state:inheritParentFields(pfxDefaultStateBlock)
  state.samplersDefined = true
  state:setField("samplerStates", 0, "SamplerClampLinear")
  state:registerObject("MyPostFxStateBlock")
end

local callbacks = {}
callbacks.onEnabled = function()
  return true
end
callbacks.setShaderConsts = function()
  local fx = scenetree.findObject("MyPostFx")
  if fx then
    fx:setShaderConst("$strength", 1.0)
  end
end
rawset(_G, "MyPostFxCallbacks", callbacks)

local fx = scenetree.findObject("MyPostFx")
if not fx then
  fx = createObject("PostEffect")
  fx:setField("shader", 0, "MyPostFxShader")
  fx:setField("stateBlock", 0, "MyPostFxStateBlock")
  fx:setField("texture", 0, "$backBuffer")
  fx:setField("target", 0, "$backBuffer")
  fx:setField("renderTime", 0, "PFXAfterDiffuse")
  fx:setField("renderPriority", 0, "1")
  fx:registerObject("MyPostFx")
end
The field is spelled DXComputShaderName, without the e. It is used on every renderer, not only Direct3D.

The matching shader is split into a header with the uniforms and samplers, and a .cs.hlsl entry point:

// shaders/common/postFx/myEffect.h.hlsl
#ifndef MY_EFFECT_H_HLSL
#define MY_EFFECT_H_HLSL

#include "shaders/common/postFx/postFx.h.hlsl"

uniform_sampler2D(colorTex, 0);

cbuffer perDraw : register(b0)
{
    uniform float strength;

    POSTFX_UNIFORMS;
};

#include "shaders/common/postFx/postFx.hlsl"

#endif
// shaders/common/postFx/myEffect.cs.hlsl
#define PFX_COMPUTE 1

#include "myEffect.h.hlsl"
#include "shaders/common/postFx/postFxCompute.h.hlsl"

PFX_NUMTHREADS
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
    uint2 storePos;
    if (!postFxComputeThread(dispatchThreadID, storePos))
    {
        return;
    }

    PFXVertToPix IN = processPostFxCompute(dispatchThreadID);

    float4 color = tex2D(colorTex, IN.uv0);
    float luma = dot(color.rgb, float3(0.299, 0.587, 0.114));

    pfxOut[storePos] = lerp(color, float4(luma.xxx, color.a), strength);
}

What the pieces do:

Element Purpose
#define PFX_COMPUTE 1 Must come before the first include. It switches POSTFX_UNIFORMS to the compute layout and enables the compute helpers.
postFxCompute.h.hlsl Declares the output texture pfxOut (RWTexture2D<float4> at u0), PFX_NUMTHREADS, and the helper functions. Include it after the effect’s own header so the uniforms are already declared.
PFX_NUMTHREADS Fixed [numthreads(8, 8, 1)]. The engine dispatches enough 8x8 groups to cover the viewport.
postFxComputeThread(id, storePos) Returns the target pixel for this thread and false for threads that overshoot the viewport. Always return early for those.
postFxComputeUv(id) Viewport relative UV, 0 to 1, top left origin.
processPostFxCompute(id) Rebuilds the PFXVertToPix struct (hpos, uv0 to uv3, wsEyeRay) that pixel shader effects received, so an existing effect body can be ported without changes.
pfxOut[storePos] = ... Writes the result. Compute effects have no blend unit and no discard; see the rules below.

Rules that differ from pixel shader effects:

  • Write every pixel you own. There is no discard. If a pixel should stay unchanged, sample the input and write it back (see fog.cs.hlsl, which composites over dst instead of blending).
  • Never sample the texture you write to. When target is $backBuffer, the engine writes to the free half of the scene color ping-pong and the input $backBuffer stays readable. For $outTex or a named target, the output must not also appear in texture.
  • Blending is done in the shader. A pass that used a SrcAlpha / InvSrcAlpha state block now reads the destination and lerps manually. Blend fields on the state block are ignored.
  • Use tex2Dlod or explicit gradients for mip-mapped inputs. Compute shaders cannot compute automatic derivatives.
  • zDefined, zEnable, and blend state block fields are irrelevant. Only the sampler states are used.

Existing compute effects to learn from: fog.lua / fog.cs.hlsl (manual blend, depth reconstruction), fxaa.lua / fxaa/fxaa.cs.hlsl (minimal single pass), smaa.lua (multi-pass chain with $outTex and named targets), dof.lua (many passes registered through a small registerComputeShader helper), and glow.lua.

Compute Shader Example: CMAA2 walks through a complete multi-pass mod with named targets, a temporal history, velocity and depth inputs, runtime pass switching, and a shader body shared between compute and pixel shader variants.

Pixel shader effects

The older fullscreen quad path still works. Set DXVertexShaderFile and DXPixelShaderFile instead of DXComputShaderName, keep zDefined = true, zEnable = false, zWriteEnable = false on the state block, and use processPostFxVert / SV_Target in HLSL. Use it only when porting old content; new effects should use compute so they match the shipped effects and can write intermediate results directly.

Useful PostEffect fields:

Field Description
shader Registered ShaderData object name.
stateBlock Registered GFXStateBlockData object name.
texture Input texture stages, up to six inputs.
target Output target, such as $backBuffer, $outTex, or a named target.
targetScale Relative render target size.
targetSize Absolute render target size.
targetFormat Render target format.
renderTime When the effect runs in the frame.
renderBin Render bin name for bin-timed effects.
renderPriority Order for effects with the same timing. Higher values run earlier.
allowReflectPass Whether the effect also runs during reflection passes.
skip Skips the effect and its children without disabling the parent chain.
skipDraw Skips only this effect’s own pass. Its children still run with the incoming texture, and its target name stays registered. Use it to toggle optional passes in a chain.

Custom effects should use existing game effects as reference examples. Good starting points are lua/ge/client/postFx/fxaa.lua, lua/ge/client/postFx/fog.lua, lua/ge/client/postFx/caustics.lua, lua/ge/client/postFx/smaa.lua, and lua/ge/client/postFx/dof.lua.

Render targets and buffers

PostFX shaders read textures through the texture fields on the PostEffect. A texture can be a special token, a named render target, or a file path.

Special tokens:

Identifier Meaning Notes
$backBuffer Current rendered color buffer. Use for fullscreen color effects and final composite passes.
$inTex Chain input from the previous pass. Use inside multi-pass effects.
$outTex Temporary chain output. Use when a pass should feed the next child pass.
$skyCameraVolume Sky camera volume. Falls back to a small black volume if unavailable.

Named render targets use #name, #name[RT0], #name[RT1], or #name[Depth]. Slots RT0 through RT7 and Depth are supported by the engine, but only slots actually filled by the producer are valid.

Common scene buffers:

Target Available slots Purpose
#prepass RT0, RT1 on Advanced Lighting 1.5, Depth Main opaque prepass / G-buffer. RT0 contains normals and roughness, RT1 contains specular and retroreflective material data until screen space reflections reuse it, and Depth contains device depth. See Reading the prepass .
#glowbuffer RT0 Glow render bin output used by the glow PostFX chain.
#VelocityBuffer RT0, RT1 only with an FSR reactive mask RT0 is screen-space motion, used by motion blur, SMAA T2x, upscalers, and screen space reflections. RT1 is an R8 vehicle silhouette (0 static, 1 vehicle) and is only allocated for FSR 3, FSR 4, and FSR 3 (custom).
#ReactiveMask RT0 Disocclusion mask built from #VelocityBuffer[RT1] during UpscaleDispatch for FSR 3, FSR 4, and FSR 3 (custom). It marks the band a vehicle just uncovered, not the vehicle itself. DLSS and XeSS do not create it.
#AnnotationBuffer RT0, Depth Annotation render target used by annotation/sensor tooling. Not a normal gameplay PostFX input.
#waterDepthGradMap RT0 Water depth gradient map used by underwater fog and water effects.
#skyCameraVolume RT0 Volumetric sky/fog data. Prefer $skyCameraVolume so the engine fallback is used when missing.
#cloudTransmittanceCookie RT0 Cloud shadow/transmittance cookie when available.

Lighting and PostFX masks:

Target Available slots Purpose
#ssaoMask RT0 SSAO/GTAO output. R is ambient occlusion. With specular occlusion enabled the target is RGBA16F: G holds the bent normal accessibility and BA the octahedral bent normal. May be a white fallback when disabled.
#gtaoOutput RT0 The same GTAO texture as #ssaoMask, registered for debug tools while GTAO is running.
#lightinfo RT0, RT1 on Advanced Lighting 1.5 Deferred light accumulation. RT0 is accumulated diffuse light, RT1 is accumulated specular. The lighting passes consume it in the same frame; do not sample it from a later PostFX.
#sssMask RT0 Screen-space shadows mask. May be a white fallback when disabled.
#sunShadowMask RT0 Sun shadow mask used by the advanced lighting path. May be a white fallback.
#adaptedLum RT0 Adapted luminance used by HDR combine and exposure.
#bucketTex RT0 Luminance histogram bucket texture. Internal HDR/exposure data.
#luminanceHistogram RT0 Luminance histogram texture. Internal HDR/exposure data.
#localExposureBlurredLogLum RT0 Local exposure blurred log luminance texture.
#localExposureBilateralGrid RT0 Local exposure bilateral grid texture.

Effect-local targets created by shipped PostFX:

Target Created by Purpose
#dofAutoFocus, #dofAutoFocusHistory DOF auto focus 1x1 focus probe and its history. Only exist while DOF is enabled.
#dofPrefilter, #dofPrefilterTemporal, #dofHistory DOF prefilter Half resolution prefiltered color, its temporal resolve, and the history copy.
#dofPrefilterCoarse, #dofPrefilterCoarse2 DOF prefilter Further downsampled copies of the prefilter.
#dofCocTile, #dofCocTileDilated DOF Circle of confusion tiles used by the bokeh pass.
#dofBokeh DOF bokeh Bokeh image composited over the scene.
#edge EdgeAAPostEffect Edge mask for edge AA/debug.
#screenBlurMask masked screen blur Mask used by the masked blur effect.
#postEffectDebugOutput post effect debug tool Editor/debug output.

Sensor and tool targets such as #LIDAR_Viz_Texture, #RadarGBuffer, and #UltrasonicGBuffer exist in some builds or tools. Do not depend on them for normal graphics mods.

Named targets are only valid when the system that creates them has run and the current graphics path supports them. Always handle missing scenetree objects or disabled graphics features in Lua, and do not assume optional targets exist on every quality preset.

Reading the prepass

Most depth, normal, and roughness effects use the prepass:

fx:setField("texture", 0, "#prepass[RT0]")
fx:setField("texture", 1, "#prepass[Depth]")

The current Advanced Lighting 1.5 prepass has the following layout:

Slot Channels Contents
#prepass[RT0] R, G Octahedrally encoded world-space normal. Decode it with decodeNormalXY(...).
#prepass[RT0] B Per-pixel roughness in the 0 to 1 range. This is the roughness buffer: sample .z or .b. Lighting clamps it to at least 0.02 when decoding.
#prepass[RT0] A Subsurface intensity for opaque materials. Translucent prepass variants can use it for opacity.
#prepass[RT1] R, G, B Specular color, until screen space reflections reuse the target.
#prepass[RT1] A Retroreflectivity coefficient for opaque materials. Translucent prepass draws store coverage here instead.
#prepass[Depth] R Non-linear device/NDC depth. Convert it with the shared depth helpers and projParams; do not treat it as distance in metres.

RT2 through RT7 are not populated by #prepass. The target syntax supports those slot names generically, but that does not mean every named target provides every slot. On the older Advanced Lighting path, the prepass only provides RT0 and Depth.

When screen space reflections are enabled, the SssrSurfaces bin runs immediately after lighting and rewrites #prepass[RT1] in place. From then until the next frame’s prepass, that slot is packed reflection receiver data, not specular color or retroreflectivity. #prepass[RT0] and #prepass[Depth] are not rewritten. With reflections disabled, RT1 keeps the prepass contents for the whole frame. A PostFX that needs specular or retroreflectivity has to run before SssrSurfaces.

In HLSL, include the shared PostFX and G-buffer helpers. The header declares the samplers and uniforms, the .cs.hlsl file does the work:

// myPrepassDebug.h.hlsl
#include "shaders/common/postFx/postFx.h.hlsl"
#include "shaders/common/gbuffer.hlsl"

uniform_sampler2D(prepassTex, 0);
uniform_sampler2D(prepassDepthTex, 1);

cbuffer perDraw : register(b0)
{
    POSTFX_UNIFORMS;
};

#include "shaders/common/postFx/postFx.hlsl"
// myPrepassDebug.cs.hlsl
#define PFX_COMPUTE 1

#include "myPrepassDebug.h.hlsl"
#include "shaders/common/postFx/postFxCompute.h.hlsl"

PFX_NUMTHREADS
void main(uint3 dispatchThreadID : SV_DispatchThreadID)
{
    uint2 storePos;
    if (!postFxComputeThread(dispatchThreadID, storePos))
    {
        return;
    }

    PFXVertToPix IN = processPostFxCompute(dispatchThreadID);

    float4 packedPrepass = tex2Dlod(prepassTex, float4(IN.uv0, 0, 0));
    float4 gbuffer = decodeGBuffer(
        TEX_SAMPLER(prepassDepthTex),
        TEX_SAMPLER(prepassTex),
        IN.uv0,
        projParams
    );

    float3 normalWS = gbuffer.xyz;
    float roughness = packedPrepass.z;
    pfxOut[storePos] = float4(normalWS * 0.5 + 0.5, roughness);
}

The example displays the decoded normal as RGB and roughness as alpha. Eye-linear depth is available in gbuffer.w for effects that need it. Use the helper functions in shaders/common/gbuffer.hlsl for normals and depth instead of manually decoding them. The prepass layout depends on the lighting path and can change.

Useful prepass facts:

  • Roughness is #prepass[RT0].b, not a separate named target.
  • #prepass[RT1] is present on the Advanced Lighting 1.5 path and carries specular color plus retroreflectivity until the SssrSurfaces bin reuses it.
  • #prepass[Depth] is a copied depth texture that can be sampled by PostFX after the prepass.
  • decodeGBuffer(...) returns world-space normal in xyz and eye-linear depth in w for the common PostFX helper overload.
  • projParams, nearFar, and invNearFar are auto-bound when the shader declares them.

Shader constants

PostEffect automatically binds several constants if the shader declares them. Unused constants do not need to be declared.

Target and viewport constants:

Constant Meaning
targetSize Active output target size in pixels.
oneOverTargetSize 1 / targetSize.
texSize0 through texSize5 Size of bound texture stages.
rtParams0 through rtParams5 Viewport-to-render-target transform for texture stages.
targetViewport Active viewport rectangle in normalized target space.
projParams Projection parameters used by depth conversion helpers.

Upscaling and jitter constants (see Jitter and jittered UVs ):

Constant Meaning
sceneJitterUV Current sub-pixel camera jitter in normalized viewport units. Non-zero only while a temporal upscaler or SMAA T2x is active. Depth, normals, and velocity always carry this jitter.
sceneColorJitterUV Same as sceneJitterUV until the upscaler has resolved the scene color, then 0. Use it when combining scene color with a buffer that was rendered with jitter.
projectionOffset Camera projection offset in clip space, including the jitter while it is active.

Compute-only constants (declared by POSTFX_UNIFORMS when PFX_COMPUTE is defined):

Constant Meaning
dispatchRect xy is the viewport origin in target pixels, zw the viewport size. postFxComputeThread uses it to map threads to pixels.
eyeRayTopLeft, eyeRayRight, eyeRayDown World-space eye ray corners. processPostFxCompute interpolates them into wsEyeRay, replacing what the vertex shader used to output.

Camera and transform constants:

Constant Meaning
eyePosWorld Diffuse camera position.
vEye View vector.
nearFar Near and far plane.
invNearFar Reciprocal near and far plane.
worldToScreenScale World-to-screen scale.
projectionOffset Camera projection offset.
matWorldToScreen Matrix used by existing PostFX for world/screen conversion.
matScreenToWorld Matrix used by existing PostFX for screen/world conversion.
matPrevScreenToWorld Previous-frame matrix for temporal/reprojection effects.
invCameraMat Inverse camera matrix.

Lighting, fog, water, and time constants:

Constant Meaning
fogData Fog density, offset, and atmosphere height data.
fogColor Current fog color.
fogScatterParams Fog scattering helper parameters.
ambientSH9 Sky ambient spherical harmonics coefficients.
ambientColor Scene ambient color.
sunDirection Direction toward the sun.
sunRadiance Sun radiance after exposure and sky factor.
sunIrradiance Sun irradiance outside atmosphere.
sunColor Sun light color.
screenSunPos Sun position in normalized screen space.
lightDirection Main light direction.
camForward Camera forward vector.
exposure Last frame exposure.
atmosphericFogEnabled Atmospheric fog enable factor.
waterColor Current water fog color.
waterFogData Water fog density, offset, wet depth, and wet darkening.
waterFogPlane Water fog plane.
waterDepthGradMax Water depth gradient maximum.
turbulenceMultiplier Underwater turbulence amount.
accumTime / deltaTime Simulation time and delta.
accumRealTime / deltaRealTime Real time and delta.

Custom constants can be set from Lua callbacks:

callbacks.setShaderConsts = function()
  local fx = scenetree.findObject("MyPostFx")
  if fx then
    fx:setShaderConst("$strength", 1.0)
    fx:setShaderConst("$tint", "1 0.9 0.8 1")
  end
end

Supported script-set constant shapes are float, float2, float3, float4, and float4 arrays. Prefix shader constant names with $ when calling setShaderConst.

Timing and chaining

PostFX timing decides what buffers are already available and what later systems will see.

renderTime Use
PFXBeforeBin Run before a named render bin. Requires renderBin. Used by fog, caustics, gamma/HDR-related passes, local exposure, and depth of field.
PFXAfterBin Run after a named render bin. Requires renderBin. Used by glow, turbulence, and masked blur.
PFXAfterDiffuse Run after the diffuse scene pass. Good for simple fullscreen effects and antialiasing-style passes.
PFXEndOfFrame Run at the end of the frame. Use only when you specifically need final-frame timing.
PFXTexGenOnDemand Do not schedule every frame. Generate a named texture only when requested. The effect owns its target, so the contents persist between frames; as a child in a chain it still runs in chain order, which makes it the usual way to store a history texture.

Common render bins used by shipped effects:

Render bin Examples
SssrTrace Screen space reflection trace and denoise. Runs after lighting and before the color pass.
ObjTranslucentBin Fog, underwater fog, caustics, edge detection.
GlowBin Glow, turbulence.
UpscaleDispatch Temporal upscaler dispatch (DLSS, FSR, XeSS). DOF runs PFXBeforeBin here so it is applied at render resolution before upscaling. This bin is a no-op for FXAA, SMAA, and SMAA T2x.
AfterPostFX Gamma/color correction, HDR/local exposure, and the SMAA T2x resolve. Runs at display resolution after a temporal upscale and at render resolution for deferred modes.
OverlayRender Motion blur runs before this bin so billboards and icons stay sharp; masked screen blur runs after it.

For a multi-pass effect, make a parent PostEffect and add child effects. Write intermediate passes to $outTex or a named #target, then read $inTex in the next pass. Use a named target when another effect or debug view needs to sample the result by name.

Custom shader constraints

  • A generic PostEffect has six texture inputs and twelve textureCube inputs.
  • ShaderData must reference registered shader files and a supported pixVersion.
  • Missing named targets make the effect invalid until the target exists.
  • renderPriority is sorted descending for effects with the same timing.
  • skip = true skips the effect and its children without disabling siblings. skipDraw = true skips only the effect’s own pass.
  • Targets of normal effects are frame-local. To keep a texture between frames, such as a temporal history, write it from a PFXTexGenOnDemand effect, which owns its target.
  • Reflection passes skip most PostFX unless allowReflectPass = true.
  • Writing directly to $backBuffer is common for final composite passes, but multi-pass effects should usually write to $outTex or named intermediate targets first.
  • Compute effects write through pfxOut at u0 and must not sample their own output. They always run with 8x8 thread groups.
  • Compute effects work on Direct3D 11, Direct3D 12, and Vulkan. Effects that need shader model 6 features, such as the shipped screen space reflections, are only created on Direct3D 12 and Vulkan.
  • Avoid depending on internal HDR/exposure targets unless the mod is tied to the current renderer version.

PostFX and upscaling

When an upscaler or SMAA T2x is active, the scene is rendered at a reduced or jittered resolution and converted to display resolution in the UpscaleDispatch bin. This changes what a custom effect sees:

  • Effects timed before UpscaleDispatch (SssrTrace, ObjTranslucentBin, GlowBin, and so on) run at render resolution. targetSize is smaller than the window and the camera projection contains a sub-pixel jitter.
  • With a temporal upscaler, effects at AfterPostFX or later, and PFXAfterDiffuse effects, run at display resolution on the upscaled image.
  • With FXAA, SMAA, or SMAA T2x, the deferred resolve keeps those effects at render resolution and stretches the finished image afterwards.

Temporal Upscaling describes the full chain, the jitter sequence, the reactive mask, and when the history resets.

  • #VelocityBuffer[RT0] is at render resolution. Temporal effects should reproject with matPrevScreenToWorld and expect the jitter offset. While motion blur is enabled, the bin after AfterPostFX repaints vehicle pixels in that slot with the motion-blur velocity. Effects before that bin, including the upscaler and screen space reflections, still see the true velocity.
  • Screen-space effects that add high-frequency detail (grain, sharpening, dithering) should run after upscaling, or the upscaler will treat the detail as noise.

With FXAA or SMAA and a render resolution below 1.0, the image is stretched bilinearly after tone mapping instead. See Anti-aliasing and upscaling .

Jitter and jittered UVs

Temporal techniques need each frame to sample the scene at a slightly different sub-pixel position so that, over several frames, the history covers the whole pixel. The engine applies this jitter as an offset to the camera projection for the whole frame, not as a texture offset:

Mode Jitter pattern
FSR, XeSS, FSR 3 (custom) Halton (2, 3) sequence in the range -0.5 to +0.5 render pixels. The sequence length grows with the upscale ratio.
DLSS The same Halton pattern with an eight-times longer sequence to avoid a screen-fixed dot pattern during long accumulation.
SMAA T2x Two diagonal phases of ±0.25 pixel, alternating every frame.
FXAA, SMAA, off No jitter.

Every render-resolution bin, including the prepass, velocity buffer, lighting, and all post effects up to UpscaleDispatch, draws with the jittered projection. The upscaler removes the jitter from the color when it resolves the history. The jitter is dropped from the shared camera constants only in the UpscaleOverlay bin, after the last render-resolution effect has run, so editor overlays and the UI are drawn pixel stable.

For a custom effect this means:

  • Depth, normals, and velocity are always jittered. #prepass[RT0], #prepass[Depth], and #VelocityBuffer contain the jittered raster, even for effects that run at display resolution after upscaling.
  • Scene color is jittered until it is resolved. Before UpscaleDispatch the color buffer has the same jitter as depth. After it, the color is stable while depth is not.
  • Use the same UV for everything drawn in the same frame. An effect that only reads jittered buffers and writes back to the jittered color does not need to do anything: the whole frame is offset consistently. This covers most single-frame effects such as fog, color grading, or edge detection.
  • Compensate when mixing jittered and non-jittered data. A fixed screen position (for example an auto focus probe at the screen center, or a mask texture loaded from disk) has to be shifted by sceneJitterUV before sampling a jittered buffer. A buffer that was written before the resolve has to be shifted by -sceneColorJitterUV when it is composited over resolved color.
  • Temporal effects must track the jitter. When accumulating history, store the jitter used for each frame and subtract the delta on reprojection, or the history will smear by up to one pixel per frame. Reset the history when the upscaler mode or resolution changes.
// sample the jittered depth buffer at the stable screen center
float2 probeUV = 0.5 + sceneJitterUV;
float ndc = tex2Dlod(prepassDepthTex, float4(viewportCoordToRenderTarget(probeUV, rtParams1), 0, 0)).r;

// composite a bokeh buffer written before the resolve over resolved scene color
float4 bokeh = tex2D(bokehTex, IN.uv0 - sceneColorJitterUV * rtParams0.zw);

rtParams0.zw converts the viewport-relative jitter into the texture’s own UV space, which matters when the input was rendered with targetScale below 1. The shipped depth of field chain (dof/DOF_AutoFocus.cs.hlsl, dof/DOF_Bokeh.cs.hlsl, dof/DOF_Composite.cs.hlsl, dof/DOF_Temporal.cs.hlsl) uses both constants and is the reference for these cases.

From Lua the current jitter can be read for debugging through RenderView.upscaleJitterPixels on the active render view. It is measured in render-resolution pixels.

Effects that draw crisp screen-space patterns at render resolution (grids, scan lines, dithering, sharpening) will shimmer under jitter, because the pattern is re-sampled through the upscaler every frame. Run such effects after UpscaleDispatch, or blend them in at display resolution.

Performance Considerations

PostFX effects run every frame for every pixel on the screen. Even a seemingly simple shader can significantly impact performance, especially at high resolutions (4K) or on lower-end GPUs.

Optimization tips:

  • Minimize texture fetches: Every tex2D call has a cost. Group your samples and avoid redundant lookups in loops.
  • Avoid complex branching: Heavy use of if/else inside shaders can lead to “branch divergence,” slowing down the GPU. Use step(), lerp(), or clamp() instead.
  • Use group shared memory for blurs: A compute effect can load a tile into groupshared memory once and reuse it for every tap, which is the main advantage over the old pixel shader path.
  • Watch target scale: If an effect does not require full resolution (e.g., a blur pass), use targetScale to render it at a lower resolution and upscale later.
  • Precision: Use half precision for colors and effects where 32-bit floating point accuracy isn’t required.
  • Avoid heavy loops: Avoid long for loops in the pixel shader unless absolutely necessary (e.g., high-quality Gaussian blurs). Consider approximating effects with fewer samples.

Debugging your Effects

Debugging PostFX is challenging because you cannot step through GPU code. The primary method is “visual debugging”—outputting raw data as colors to the screen.

Techniques:

  • NamedTextureTarget Viewer: Use this editor tool to browse and preview all active render targets (NamedTexTargets) in real-time. It supports pan/zoom and channel filtering, making it the fastest way to verify if a buffer like #prepass or #ssaoMask contains the expected data without modifying shaders.
  • Visualizing buffers: Instead of calculating a complex effect, return the value of a specific buffer (e.g., return float4(normalWS * 0.5 + 0.5, 1); to see normals).
  • Color coding values: Map a scalar value (like depth or roughness) to a color ramp to identify precision issues or clamping.
  • Binary search for errors: If a shader fails to compile or causes a crash, comment out sections of the code until you find the problematic line.

Common Pitfalls

  • Linear vs Non-linear Depth: Forgetting that #prepass[Depth] is non-linear (device depth). Always use decodeGBuffer or shared helpers to get eye-linear depth for distance calculations.
  • Resolution Mismatches: Using a downsampled target (via targetScale) but sampling it with UVs meant for full resolution without accounting for the offset/scale.
  • Assuming Target Existence: Assuming #prepass[RT1], #VelocityBuffer[RT1], or #ReactiveMask are available on every graphics preset and upscaler. RT1 of the prepass only exists on Advanced Lighting 1.5, and the reactive mask only exists for FSR 3, FSR 4, and FSR 3 (custom). Always check if the object exists in Lua before enabling an effect that depends on a specific target.
  • Reading #prepass[RT1] after lighting: With screen space reflections enabled, that slot no longer holds specular color once the SssrSurfaces bin has run. Sample it before that bin, or use #prepass[RT0] and #prepass[Depth], which stay intact.
  • Ignoring Alpha Channels: Some targets store critical data (like subsurface intensity or retroreflectivity) in the alpha channel; forgetting to sample .a can lead to missing visual details.
  • Early returns in compute effects: Returning before writing pfxOut leaves stale data in that pixel. Only the postFxComputeThread overshoot check may return without writing.
  • Reading and writing the same target: A compute pass that lists its own output in texture reads undefined data. Ping-pong through $outTex or a second named target instead.
  • Ignoring jitter: Sampling a jittered depth or velocity buffer at a fixed screen position, or compositing a pre-resolve buffer over resolved color without sceneColorJitterUV, produces a one pixel wobble that is only visible with a temporal upscaler or SMAA T2x enabled. Test custom effects with both an upscaler and FXAA. See Jitter and jittered UVs .

Loading and persistence

At startup, the game initializes the Lua PostFX modules and creates the standard PostFX objects. Photo Mode keeps a temporary backup of values it changes so they can be restored when leaving Photo Mode.

This has a few practical consequences for mods:

  • Prefer temporary runtime changes for scenarios, camera tools, and gameplay effects.
  • Restore any temporary changes when your extension unloads or the effect is no longer needed.

Quality settings

PostFX availability depends on graphics settings. Lower presets can disable or reduce SSAO, screen-space shadows, bloom, reflections, and other effects. Screen space reflections and specular occlusion are off in every preset and need High or Ultra Lighting Quality; screen space reflections additionally need Direct3D 12 or Vulkan.

Do not build essential gameplay visibility around a subtle PostFX effect. A level should remain readable when lower graphics presets reduce or disable PostFX.

Recommended checks:

  1. Author and tune on High or Ultra.
  2. Check Normal for typical gameplay.
  3. Check Low and Lowest for readability.
  4. Test day, dusk, night, tunnels, forests, interiors, and weather transitions.
  5. Verify SDR and HDR output if the content depends on bright highlights.

Best practices

  • Use PostFX after lighting, exposure, and tone mapping are already in a good state.
  • Keep color grading subtle enough for gameplay.
  • Avoid using bloom as the only way to communicate that a light is bright.
  • Avoid heavy depth of field or motion blur during normal driving.
  • Keep SSAO and screen-space shadows moderate so they ground objects without dirtying the image.
  • Prefer color ramps for shareable looks.
  • Prefer Lua runtime tuning for temporary camera, scenario, or level behavior.
  • Treat custom shader effects as advanced and version-sensitive.

Related pages

Last modified: October 16, 2026

Any further questions?

Join our discord
Our documentation is currently incomplete and undergoing active development. If you have any questions or feedback, please visit this forum thread.