Skip to content

Camera & View Effects

The engine has a general, client-side camera-roll system — a Gmod/Source-style “view punch” that tilts the camera about its forward axis — plus a first consumer that drives it on landing: the fall-impact punch. It also exposes the projection settings next to the field of view in the settings screen: which axis the FOV is expressed on, the optional Panini projection, the optional RCAS sharpening, the optional lens effects (a vignette and a chromatic aberration), and the speed field of view that opens the view as you move. All of it is purely cosmetic and client-side.

That is a deliberate line, not an accident of where the code landed. Settings divide by what they are for: art direction — the exposure and tonemap curve and the bloom look — is world-authored and replicated, because the map author decides the mood. Cost and comfort — everything on this page, plus dither, ambient occlusion, shadow resolution and shadow distance — is client-only, because a map author has no business setting your field of view and performance belongs to whoever owns the GPU. So Panini being client.render.* with no world counterpart is the intended shape: it is a comfort control, and a map cannot force it on you or take it away.

FOV is vertical everywhere in the engine. client.fov stores vertical degrees, the renderer takes a vertical FOV, the shadow cascade fit takes a vertical FOV, and the gizmo projection takes a vertical FOV. That is deliberate: a vertical FOV means the same view on any monitor, while a horizontal one silently means “more view” the wider your window gets.

Some players think in horizontal numbers anyway, so the Video section pairs the field-of-view slider with a two-button axis toggle (client.fovAxis, Vertical by default) that changes only how the number is shown and entered:

  • Flipping the axis converts the value through tan(h/2) = aspect · tan(v/2); it never reinterprets it. Your view does not move. At 16:9, 90 vertical reads as 121.28 horizontal; at 4:3 the same view reads as 106.26.
  • Because a horizontal reading depends on the window shape, it updates as you resize — the slider’s value and both of its ends are converted on every draw. The stored preference stays vertical and never changes with the window.
  • Everything downstream keeps seeing vertical degrees, so nothing else in the engine has to know the toggle exists.

The slider’s travel is 1° to 140° vertical, against an accepted range of 1179. The floor is the whole accepted floor: a narrow FOV is a legitimate setting — for reading detail across a map, for framing a shot — and the slider used to stop at 60, which is about 91 horizontal at 16:9 and therefore wider than most games call normal. The top stops short of 179 on purpose: past roughly 140 the frustum is warped for effect rather than played in, and carrying the travel all the way would spend most of the track on degrees nobody sets. client.fov still accepts anything in 1179 from the console, so the slider’s ceiling bounds the drag, not the value.

Rectilinear perspective stretches the image away from its center without bound: at a wide FOV, edges smear and objects near the corners read as ellipses. The Panini projection (Sharpless, Postle and German, Pannini: A New Projection for Rendering Wide Angle Perspective Images, 2010) trades some of that stretch away while keeping vertical lines straight. Off by default; switch it on in Video → Panini projection, which reveals a Panini strength slider.

PreferenceDefaultRangeWhat it does
client.render.paninifalseon/offMaster switch. The strength keeps its value while off, so toggling back on restores your setting.
client.render.paniniStrength0.501The Panini distance d. 0 is exactly rectilinear (bit-identical to the switch being off); 1 is the canonical Panini.

Why 0.5. The literature’s canonical Panini is d = 1 (what panorama tools ship), but that is tuned for still images. At the engine’s default 90° vertical FOV — a ~121° horizontal field on 16:9 — 0.5 already removes most of the corner stretch and reads clearly, without the cylindrical look a full 1 gives at that width, and it keeps the widened render frustum at ~1.35× tangent instead of ~1.5×. At narrow FOVs Panini is close to a no-op whatever the strength.

How it is implemented, and why enabling it does not change your FOV

Section titled “How it is implemented, and why enabling it does not change your FOV”

The warp is applied in the HDR resolve (the fullscreen pass that already tonemaps the scene onto the swapchain), not as a vertex warp. A vertex warp is cheaper but only correct where geometry is densely tessellated — the rasterizer interpolates linearly between warped vertices, so the long straight edges this engine’s maps are made of (walls, floors, baked box brushes) would bend wrong. Warping the finished image is correct for any tessellation and costs one extra texture-coordinate transform in a pass that already runs.

The catch is that Panini widens the field, so a resolve reading a frame rendered at the requested FOV would sample outside it and show black borders. The engine therefore renders the scene through a deliberately wider frustum, sized so that after the warp:

  • the vertical FOV down the image’s center column is exactly client.fov, and
  • the horizontal FOV across the image’s center row is exactly 2·atan(aspect · tan(fov/2)) — the same horizontal field the rectilinear projection would have shown.

So turning Panini on does not change your field of view; only the distribution of that field across the image changes. (Headless tests measure the actual ray angles at the frame edges to keep it that way.) The frame’s corners map to the source’s corners, so no sample can fall outside the rendered image and no border can appear. The cost is resolution rather than field: the center of the image is magnified out of fewer rendered texels.

Everything that derives from the camera frustum uses the widened pair, which is simply the truth about what the frame renders:

  • Shadow cascades are fitted to the widened frustum, so their coverage still matches what is on screen (a cascade fitted to the narrower requested frustum would leave the outer image unshadowed).
  • Debug gizmos and other world-anchored overlays are drawn on top of the resolved image, so they project through the widened frustum and then through the same forward warp the resolve inverts — they stay glued to their subjects.
  • The crosshair is unaffected: the center of the frame maps to the center of the frame at every strength.

Both settings apply live, from the console or the settings screen — there is nothing to rebuild and no relaunch, ever.

RCAS — Robust Contrast Adaptive Sharpening, the sharpening half of AMD’s FidelityFX Super Resolution — is an optional last step in the same HDR resolve. Off by default; switch it on in Video → Sharpening, which reveals a Sharpening strength slider.

PreferenceDefaultRangeWhat it does
client.render.sharpenfalseon/offMaster switch. The strength keeps its value while off, so toggling back on restores your setting.
client.render.sharpenStrength0.5010 is an exact identity (bit-identical to the switch being off); 1 is the algorithm’s maximum.

It is deliberately independent of Panini. The Panini resample is what motivated it — the warp magnifies the middle of the frame and reconstructs it with a Catmull-Rom cubic, which still reads slightly soft — but sharpening is just as useful with the projection off: on any display running below its native resolution, or simply for a player who likes a crisper image. Tying the two together would mean nobody could have one without the other, so they are two switches that happen to sit next to each other in the settings screen and gate nothing but their own strength sliders.

The filter is a 5-tap cross unsharp mask — the center pixel gains what its four neighbors lack — but the weight is chosen per pixel rather than fixed. RCAS measures how much room the local 5-tap range has left before it hits black or white, and permits only the weight that provably cannot push the result out of the display range.

The consequence is the whole point: an edge that already spans the full range gets no sharpening at all, because there is nowhere for an overshoot to live. That is exactly where a fixed-weight unsharp mask puts its worst halo — the bright rim around a dark silhouette against the sky. A flat region is likewise returned exactly unchanged (the filter normalizes by its own weight sum), so clear sky and smooth falloff gain no noise.

The weight is computed per channel and the most restrictive of the three drives all three, so the filter can only move luminance. Per-channel weights would sharpen a saturated edge unevenly across red, green and blue and shift its hue.

It runs after the tonemap curve and before the dither, and both halves of that are load-bearing:

  • After the curve, because RCAS is defined on display-range values — its limiter reasons about the room left before black and white. Sharpening linear HDR radiance instead would let a very bright pixel beside a very dark one produce an enormous overshoot that the curve then compresses into a visible ring.
  • Before the dither, because the dither is deliberately sub-quantization-step noise. Sharpening it afterwards would amplify it into visible grain.

The awkward part is that RCAS needs the four neighboring output pixels, and no pass can read the image it is writing — the resolve’s render target is the swapchain. The two options were an intermediate LDR image plus a second fullscreen pass (a full-resolution attachment, an extra round trip to memory and a pipeline, paid for every frame whether or not sharpening is on), or re-running the resolve chain for the four neighbors inside the one pass. The engine does the latter, behind a uniform branch on a push constant: with the toggle off the extra chains are genuinely not executed, so off is bit-identical to the pre-sharpen renderer rather than merely indistinguishable from it.

On, it is not free. Each neighbor repeats the sample, exposure, bloom composite and curve; with Panini also on, each one is another nine-tap Catmull-Rom fetch, so the resolve’s texture traffic goes up roughly fivefold. That is the trade, and it is why this is opt-in rather than something the Panini switch turns on for you. Like everything else in the resolve it is a push-constant lane, so it applies live with nothing to rebuild.

At the frame’s edges the neighbor taps fall one texel outside the image and read the edge texel (the samplers are clamp-to-edge), which makes the cross degenerate toward flat there — the border sharpens less, rather than wrapping or reading black.

About the strength. The published parameter is a “sharpness stops” value where zero is the strongest setting and each further stop halves the effect. That is an unusable direction for a slider, so the preference is an intuitive 01 strength and the conversion to stops happens at the point of use. The default of 0.5 is one stop down from the maximum: contrast-adaptive sharpening at full strength reads as “processed” on content that is already sharp, and half reads as detail.

Lens effects: vignette and chromatic aberration

Section titled “Lens effects: vignette and chromatic aberration”

Two optional imperfections of a real lens, both in the same HDR resolve and both off by default — the engine’s default look is the scene as it was lit. Graphics → Lens, each switch revealing its own amount sliders.

PreferenceDefaultRangeWhat it does
client.render.vignettefalseon/offMaster switch for the corner darkening.
client.render.vignetteIntensity0.3501How dark the corners go. 0 is an exact identity; 1 takes them to black.
client.render.vignetteSmoothness0.50.051Width of the falloff as a fraction of the frame radius. 1 shades from the center outward; small values confine it to a band at the corners.
client.render.chromaticAberrationfalseon/offMaster switch for the radial color split.
client.render.chromaticAberrationStrength0.3501How far red and blue separate at the corners; 1 is about ten pixels across a 1920-wide window.

Both amounts keep their values while their switch is off, exactly as the sharpen and Panini strengths do, so toggling back on restores the setting you chose. And both are collapsed into a single push-constant number on the way down (intensity or strength, with zero meaning off) because zero is already the shader’s identity — the same trick the sharpen uses, for the same reason.

Both are measured in frame units, not pixels. The radius that drives them is normalized so it is 0 at the center and exactly 1 at every corner, whatever the aspect. That makes each effect a property of the frame — a 4K screen and a 1080p screen see the same vignette and the same fraction-of-the-frame fringe — and it makes both elliptical on a wide viewport, which is the shape that reaches all four corners at equal strength.

A smoothstep falloff multiplying linear radiance, applied after the bloom composite and before the tonemap curve. Both halves of that placement are deliberate:

  • On linear radiance, before the curve, because a vignette is light the lens failed to deliver to the sensor. Scaling the tonemapped image instead would scale values the curve has already compressed, which reads as a flat gray wash over the corners rather than as less light arriving there.
  • After the bloom composite, because glow is scattered light and is dimmed by the same falloff. Bloom added after the vignette would leave bright highlights glowing at full strength inside corners that are otherwise shaded.

It is skipped entirely under the bloom debug view (client.render.bloomDebugLevel), whose job is to show one pyramid level’s raw values.

It is a lens over a scene, so it ends with the session. A session ending unloads the world, and darkening the corners of an empty main-menu frame is shading nothing. The vignette’s resolved intensity is therefore multiplied by the same session level the menu’s scrim rides, which ramps out over client.ui.sessionFade seconds — defaulted to client.render.worldDissolve, so the lens lifts on the same clock the map dithers away on — and snaps straight back on when a session begins. The preference itself is untouched by any of it.

Red is sampled slightly inward and blue slightly outward along the radius, with green left exactly where it was — the same way a real lens’s lateral color splits either side of the middle of the visible spectrum, and the reason the image fringes without appearing to move. The shift is zero at the center and grows linearly outward, so the middle of the frame is never fringed at any strength.

It is part of the sample, so it happens before anything else in the chain: it displaces where each channel’s radiance is read from, which has to precede the exposure that scales it. The offsets are applied in the scene target’s coordinate space (after the Panini inverse mapping rather than before it) — for a displacement of a few texels across a mapping that is smooth at that scale, mapping each channel separately would triple the transform cost for an invisible difference. The bloom tap is not split: the pyramid is a deliberate blur, and fringing a blur achieves nothing.

Switching it on also switches the resolve’s scene sampler from point to bilinear, because the offset is a smooth sub-texel ramp across the frame; point-sampled, it would snap to whole texels and turn that ramp into visible concentric arcs.

Every effect in the HDR resolve, in the order it is applied:

Panini resample → chromatic aberration → exposure → bloom composite
→ vignette → tonemap curve → RCAS sharpen → dither

The rule that produces that order is simply what each step physically is. Everything up to and including the vignette happens to light and therefore runs on linear radiance: the aberration and the resample decide where the radiance is read from, the exposure and the bloom decide how much of it there is, and the vignette decides how much of it reaches the sensor. Everything after the curve happens to a picture: RCAS’s limiter reasons about the room left before black and white, and the dither has to land where the 8-bit quantization does. The curve is the boundary between the two halves, and nothing may cross it.

Each effect is guarded by a uniform branch on a push-constant lane, so anything switched off is not merely cheap — it is not executed, and the frame is bit-identical to an engine that never had the feature. All of it applies live, from the console or the settings screen, with nothing to rebuild.

Screen-space ambient occlusion darkens the creases, contacts and cavities that the ambient term alone cannot know about — the seam where a crate meets a floor, the inside of a doorway, the underside of a stair. On by default; Graphics → Ambient occlusion carries a strength slider, a radius slider, a quality choice and a half-resolution toggle.

PreferenceDefaultRangeWhat it does
client.render.aotrueon/offMaster switch. Off skips the depth prepass entirely — the pass is not merely neutralized, it is not recorded.
client.render.aoIntensity0.8201How far the occlusion is allowed to darken ambient. 0 is an exact identity; the default matches the reference scene’s strength.
client.render.aoRadius0.50.058World-space search radius in meters, so the effect is a fixed physical size rather than a fixed number of pixels.
client.render.aoPower1.50.54Contrast exponent on the visibility. Above 1 deepens the midtones and leaves both ends fixed. Console-only — not in the settings screen.
client.render.aoQuality303Slice and step budget, Low through Ultra. Buys angular and radial resolution, nothing else. Defaults to Ultra because the engine has no temporal accumulation in the default path — a still frame has to be clean on its own.
client.render.aoHalfResolutiontrueon/offRun the pass at half the scene’s width and height. The only knob here that rebuilds a render target.
client.render.aoBias5030Tangent-plane angle bias in degrees. Pushes both horizons away from the surface before the arc integral, so a flat surface seen at a grazing angle stops occluding itself out of depth-reconstruction noise.
client.render.aoFalloff0.600.95Fraction of the radius a sample keeps full weight for. Beyond it the weight ramps linearly to nothing at the radius. Contact shading lives in the first taps, so they are deliberately not attenuated.
client.render.aoThinOccluder0.2500.9How much a subsequent horizon rise is discounted, as a stand-in for occluder thickness. 0 treats every occluder as infinitely deep; higher values let light back in behind thin geometry like a railing.
client.render.aoMaxScreenRadius408128Ceiling on the march’s screen-space radius, in AO texels. Bounds the cache cost of the world-space radius when a surface is very close to the camera.
client.render.aoDenoise203Number of separable bilateral blur passes over the raw occlusion. 0 shows the raw march, which is what you want when diagnosing the pass itself.
client.render.aoBlurRadius214Taps either side of center per blur pass.
client.render.aoBlurSigma1.60.54Spatial falloff of the blur kernel.
client.render.aoSlopeTolerance208How far the bilateral depth tolerance is allowed to widen per texel of the surface’s own depth slope. 0 is a pure depth test, which rejects nearly every neighbor on a steep wall and leaves exactly the noisiest surfaces unfiltered.

The pass is Ground-Truth Ambient Occlusion (Jimenez et al., SIGGRAPH 2016) rather than a hemisphere-sampling SSAO. Both march the depth buffer; the difference is what they do with what they find. Classic SSAO scatters points in a hemisphere and counts how many landed behind geometry — an estimator whose variance you pay for in samples, which is why those implementations need a wide blur and still shimmer. GTAO instead sweeps a small number of slices through the pixel, finds the horizon angle on each side of each slice, and then evaluates the visibility of the arc between them in closed form. Within a slice there is no sampling error at all: the inner integral is exact.

The name’s “ground truth” is the claim that this converges to a reference offline solution, and it comes from one detail — the surface normal is projected into the slice’s own plane before the arc integral, and the integral is cosine-weighted about that projected normal. HBAO skips the projection and integrates about the view direction, which is why it over-darkens surfaces that face away from the camera. That projection is the entire difference, and it is where the correctness lives.

It costs a handful of taps per slice and stays stable in motion, which is what makes a half-resolution pass viable — the technique’s noise floor is low enough that it survives being run at a quarter of the pixels.

Most shipping GTAO leans on a temporal resolve: a cheap noisy march, then history to average the error away. The engine’s default path is MSAA, not TAA, so there is no history to lean on and every frame has to stand on its own. Four things follow from that, and they are the difference between the pass reading as shading and reading as an artifact.

Two decorrelated noise sources. The slice rotation and the march offset are driven by different 4×4 spatial patterns rather than one value used twice. Sharing a value correlates the two axes of the estimator, and the residual stops being noise and becomes a rigid cross-hatch — a structured error that no symmetric blur can remove, because it is the same in every neighborhood the blur averages over.

A tangent-plane bias. Both horizons on a grazing surface sit against the tangent clamp, and the horizon estimator is a max(), which rectifies depth-reconstruction noise upward — so a flat, entirely unoccluded wall reads as occluded. aoBias pushes the clamp off the tangent plane, which is the HBAO answer to the same problem and costs nothing.

A slope-aware bilateral. The blur and the upsample both reject neighbors on a depth difference. A pure depth test makes that rejection width a function of the camera only, so a steep wall — where depth changes fast across a texel for entirely legitimate reasons — rejects every tap and comes out completely unfiltered. aoSlopeTolerance widens the tolerance by the surface’s own measured slope, so the surfaces that need filtering most actually get it.

Even step spacing. The march spreads its taps evenly across the screen radius with a one-texel floor between them, instead of the quadratic spacing that puts the first taps inside the center texel and spends half the budget re-reading the pixel it started from.

Together these are why aoQuality defaults to Ultra rather than the middle setting: the budget goes to making a still frame correct, not to feeding an accumulator that is not there.

A forward renderer has an awkward ordering problem here: ambient occlusion needs a depth buffer, and the only pass that produces one is the same opaque pass that needs the occlusion. The engine resolves it the way Unity’s URP does — a depth prepass followed by an AO texture the forward shader samples — with one twist. Because the scene renders multisampled, the honest options were to resolve the scene’s MSAA depth (which needs VK_KHR_depth_stencil_resolve, negotiation, and a full-resolution resolve every frame) or to rasterize a single-sample depth prepass at the AO pass’s own extent. The engine does the latter: at the default half resolution that prepass is a quarter of the fragments of a full-res resolve, it needs no extension, and the MSAA scene depth is never touched. The prepass reuses the shadow pass’s depth-only vertex shader verbatim.

The AO target is R16G16_SFLOAT: red is visibility, green is linear view depth. One texture, one binding, and the depth channel rides along to serve as the edge-stopping signal for both the bilateral blur and the upsample — the blur refuses to average across a depth discontinuity, and the forward shader’s bilinear upsample rejects the same edges on the same terms. Those two rejection widths and the C# mirror are pinned to each other by test, because an upsample looser than the blur would drag occlusion straight back across the silhouettes the blur just declined to cross.

Ambient only. The opaque shader computes

color = direct + ambient * bakedOcclusion * ssao + emission

so the sun, every analytic light and every emissive surface are untouched. This is the same line the sun’s cascaded shadows draw from the other side: shadow attenuates direct and leaves ambient alone; ambient occlusion attenuates ambient and leaves direct alone. Together they cover the two terms without ever doubling up on one.

ambient here is whichever fill won for that fragment — the hemisphere colors, the sky’s harmonics, or a baked irradiance volume if the surface sits inside one. Occlusion multiplies all three identically; a map that gains a GI bake does not change this line.

The reasoning is that a screen-space guess has no business attenuating light that already carries its own real shadowing — a shadow map knows what actually occludes the sun, and multiplying it by a depth-buffer estimate only darkens the same geometry twice. Applying occlusion to the final color instead, which is the shortcut a tonemap-time composite would force, would dim emissive surfaces: a lamp housing would occlude its own light. Because ambient is only separable inside the forward shader, the AO texture is sampled there rather than composited later, and that is why it needs a FrameUbo lane and a set-0 binding rather than another fullscreen pass.

At the default settings the pass is a half-resolution depth prepass, a half-resolution horizon march, and aoDenoise × 2 half-resolution separable blur passes, plus four taps in the opaque shader for the bilateral upsample. Quality moves the march’s slice and step counts only; the shader’s loops are bounded by compile-time ceilings and break out early, so a lower quality genuinely gathers fewer samples rather than masking them. aoDenoise and aoBlurRadius are the same story on the blur side — a smaller number records fewer passes and takes fewer taps.

Everything except aoHalfResolution is a live push-constant or uniform lane with nothing to rebuild. Toggling half resolution reallocates the AO target, so it waits for the device to idle first — the one knob on this page that costs more than a frame.

MSAA antialiases geometric edges — it takes extra coverage samples at silhouettes and nowhere else. It does nothing at all for the shimmer that comes out of shading: a specular highlight crawling along a rail, a normal map sparkling at a grazing angle, a thin bright feature that lands in a different pixel every frame. Those are subpixel signals in the shading, not in the coverage, and no amount of multisampling touches them.

Temporal antialiasing attacks exactly that. The scene is rendered with a sub-pixel offset that moves every frame, so across a short run of frames the rasterizer has sampled a whole pattern of positions inside every pixel; blending each frame into an accumulated history converges on the average of those positions. It is supersampling spread across time instead of across one frame’s samples, and it costs one fullscreen pass rather than N× the fragments.

Off by default — this landed as infrastructure first, and it is also the groundwork an FSR2-class upscaler needs.

PreferenceDefaultRangeWhat it does
client.render.taafalseon/offMaster switch. Off allocates nothing, jitters nothing, and records no pass — the frame is bit-identical to an engine that never had the feature. On forces MSAA off.
client.render.taaBlend0.900.98The history’s share of each resolved pixel before the luma weighting. Higher is smoother and ghosts more readily; 0 discards the history entirely. Deliberately capped short of 1.0, which would be an accumulator that never admits the current frame.

Not because the combination is philosophically wrong — MSAA keeping geometric edges while TAA kills shading shimmer is a perfectly reasonable pairing, and plenty of engines ship it. It is excluded here for a concrete structural reason: this engine’s scene depth is multisampled and never resolved, and the temporal resolve needs a single-sample full-resolution depth to reproject through.

The three ways out were all worse than the exclusion. Resolving the MSAA depth needs VK_KHR_depth_stencil_resolve, negotiation, and a full-resolution resolve every frame — and an averaged depth is a surface that is not there, which is precisely the input a reprojection must not be given. A full-resolution single-sample depth prepass would be a second rasterization of the whole scene, which defeats the point of a cheap resolve (the AO pass gets away with its own prepass only because it runs at half resolution). And a per-sample resolve is a different, much larger pass.

So enabling TAA drops the effective sample count to 1. The requested count is remembered, and switching TAA back off restores it — the settings screen’s MSAA choice is not clobbered, just overridden while the resolve is running. This is also the shape FSR2 has, which wants a single-sample input for the same reasons.

scene (jittered) → TAA resolve → chromatic aberration → exposure → bloom
→ vignette → tonemap curve → RCAS sharpen → dither

TAA runs on linear radiance before the tonemap, immediately after the scene pass, which puts RCAS after it — the FSR-shaped order, and the right one: temporal accumulation is inherently slightly soft, and the sharpen’s job is to answer that on the finished picture. No new preference was needed for the interplay; the existing client.render.sharpen already lands last.

The resolve writes two attachments: attachment 0 is the HDR image the rest of the chain consumes (so the bloom and tonemap descriptors never change and nothing downstream knows the pass exists), and attachment 1 is the history the next frame reads, whose alpha carries this frame’s reversed-Z depth for the disocclusion test. Two history images ping-pong.

The offsets come from the Halton (2, 3) low-discrepancy sequence, eight phases, each landing inside one pixel and centered on the pixel center. Halton fills the pixel far more evenly at small counts than random offsets and never clumps the way a rotated grid can.

The offset is baked into the projection as a clip-space shift — the engine’s matrices are row-vector, so it is a subtraction on M31/M32, which adds offset · w to clip x/y and survives the perspective divide as an exact constant NDC shift at any depth. Nothing about w, the depth buffer or the near plane moves.

Only the frame uniform block’s Projection and ViewProjection are jittered. Everything that must be stable reads the unjittered matrix and is unaffected: the shadow cascade fits (a jittered fit would wobble the cascade bounds and shimmer the shadows — the opposite of the goal), the sky’s ray directions, the Panini frustum widening and resample constants, the debug gizmo projections, and the AO depth prepass. The AO prepass therefore misregisters against the scene by at most half a texel, which is well inside the tolerance of a half-resolution bilateral upsample and is not worth a second matrix to fix.

Headless captures stay deterministic. Jitter is gated on the pass being active and the pass is off by default, so --render output is byte-identical to before. Asked for explicitly, a capture pins the jitter phase to zero and drops the history first, so a render stays a pure function of its inputs.

Motion vectors: what exists, and the limitation stated plainly

Section titled “Motion vectors: what exists, and the limitation stated plainly”

There is no velocity attachment. The reprojection is derived from depth and the previous view-projection: a pixel’s depth is unprojected to a world point and reprojected through the previous camera. That is exact for static geometry, which is what the engine’s maps are made of.

It is wrong for anything that moved in world space between the two frames. A moving prop reprojects to where the background behind it was, so its history is rejected by the neighborhood clip and it falls back to roughly the un-accumulated image rather than smearing. That is the correct failure mode — no ghost trails — but it does mean moving objects get no temporal supersampling. Fixing it needs a velocity attachment written by the opaque pass from the previous frame’s model matrix, which is also exactly what FSR2 would require.

The two matrices in the reprojection are deliberately not symmetric: the current one is the jittered matrix, because the depth being inverted was rasterized through it, and the previous one is the unjittered matrix, because the history is an accumulation over many offsets and is indexed by the plain pixel grid.

Three mechanisms, in order of how much they catch:

  1. Outright rejection. History that reprojected behind the camera or off the edge of the previous frame never saw this pixel. History whose recorded depth differs from the reprojected depth by more than 5% is a disocclusion. Both depths are reversed-Z NDC values whose relative difference equals that of the linear depths they encode, so the test is scale-free and needs neither the near plane nor a linearization.
  2. Neighborhood clip. Whatever survives is confined to the color range the current frame’s 3×3 neighborhood actually spans, in YCoCg — the eye’s sensitivity is overwhelmingly in luma, so an axis-aligned box in RGB is a poor fit to the colors a neighborhood really contains. The history is moved along the line toward the box center until it lands on the surface, not clamped per-axis, because a per-axis clamp lands on a corner: a hue the neighborhood may not contain at all.
  3. Luma-reciprocal weighting. Each sample is weighted by the reciprocal of its own luma, so one very bright sample cannot dominate the average and flicker (Karis, High Quality Temporal Supersampling, SIGGRAPH 2014).

On top of that the renderer invalidates the history outright for events reprojection cannot detect: a map switch or reload, a resize, a target rebuild, and the pass being enabled. A history that should have been dropped does not crash — it produces one frame of garbage that then feeds itself, which is why the invalidation rules live in a plain testable object rather than being scattered through the record path.

This pass is the groundwork, not the destination. An FSR2/3-class upscaler reuses the jitter, the history, the reprojection and the rejection wholesale, and would still need:

  • Upscale-ratio handling. Today the resolve is 1:1 — the scene target and the output are the same extent. Upscaling means a scene target smaller than the output, a jitter scaled in the render target’s texels while the history accumulates at output resolution, and a reconstruction filter (Lanczos-shaped) rather than the current point fetch. The render-scale plumbing already exists for supersampling; this is the same lever pulled the other way.
  • Per-object motion vectors. The camera reprojection above is exact for static geometry and gives up on movers. An upscaler cannot give up on them: it needs a velocity attachment written by the opaque pass from the previous frame’s model matrix.
  • A reactive mask. Transparency, particles and animated shader effects have no meaningful depth or velocity, so they must be flagged to reduce their history weight rather than be reprojected. This is an extra attachment, or a channel of an existing one.
  • A transparency-and-composition mask for anything drawn after the resolve, and a lock/luminance history — FSR2’s per-pixel lock on thin features it has resolved, which is what lets it hold detail at aggressive ratios that a plain neighborhood clip would throw away.
  • An exposure signal. FSR2 accumulates in a pre-exposed space and wants the frame’s exposure value; the engine’s exposure currently applies downstream of where the resolve runs.

Moving fast opens the view: sprinting widens it a little, a long fall widens it a lot, and noclip flight widens it in whatever direction it is going. On by default (Video → Speed field of view, with a Speed FOV amount slider).

One signal drives it and the wind. The rush you see and the rush you hear are the same number. SpeedRush.Speed reduces one frame of predicted motion to a single scalar, and the host computes it once per tick and hands it to both the fall/flight wind loop (FallWind) and the view widening (SpeedFov):

StateThe speed it readsWhy
Flying (noclip)full velocity magnitudeDirection-agnostic — climbing, diving and strafing at the same speed rush identically.
Airbornedescent speed onlyRising is not a rush, and the run speed carried into a jump must not make the air feel faster than the ground did.
Groundedhorizontal ground speedWhat separates a sprint from a walk; a stair-step’s vertical component is not travel.

That scalar goes through one shared deadzone → normalize → clamp ramp (SpeedRush.Ramp): exactly zero at or below a start speed, linear above it, pinned at the ceiling from a full speed onward. Every consumer scales that 0..1 by its own units — a gain for the wind, degrees for the view — so the two can differ in thresholds but never in the shape of the curve.

Sprint and fall share one curve. With the shipped defaults the ramp runs from 6 to 24 m/s — the fall wind’s own thresholds — so a default walk (5.08 m/s) is dead still, a default sprint (10.16 m/s) sits at ~23% of the effect (a ~2.8° nudge), noclip cruise (12 m/s) at ~33%, and a sustained fall or full-throttle flight saturates it. Sprinting needing “its own” curve turned out to be a tuning question, not a structural one.

An offset, not a multiplier. The widening is added to client.fov in vertical degrees. A multiplier would scale the effect with your base FOV, so a wide-FOV player would get both a wider base and a bigger swing while a narrow-FOV player would barely notice it; an offset means “a sprint opens the view by this many degrees” whatever base you chose. Because the stored FOV is always vertical, the offset is unambiguous on either setting of the axis toggle above. The sum is clamped into the accepted FOV range, so no tuning can produce a degenerate frustum.

It composes with Panini because the offset is applied where the base preference is read, before anything derives from it. Panini’s widened render frustum, the shadow cascade fit and the debug-gizmo projection are all pure functions of the requested FOV, so they see the boosted value and stay exactly self-consistent — the warp can never desync from the field actually rendered.

Smoothing is a critically-damped spring on the offset (the same model as the camera roll below), advanced on the frame clock while the target is set on the tick clock. At the default damping ratio of 1 it settles without overshooting into a visible wobble, and it is sub-stepped at a fixed maximum step so the same elapsed time produces the same view at 30 fps as at 240 fps.

It is client-side and cosmetic only — it touches nothing predicted, nothing replicated, and no protocol version. Switching it off (or losing prediction) eases the view back to your chosen FOV rather than cutting.

Moving fast smears the frame along the way you are going. On by default (Graphics → Motion blur, with Motion blur strength, Shutter and Blur starts at sliders) — unlike TAA, MSAA and ambient occlusion, because it is speed-gated rather than always-on: at rest it is not a cheap blur, it is no pass at all.

No blur at rest, ever. The effect is driven by a gain from the same SpeedRush.Speed signal that opens the view and raises the wind, so standing still is not “almost sharp” — it is bit-identical to the effect being switched off. So is a zero strength, and so is a zero shutter: all three mean the filter is not allocated and not recorded at all.

Rotation alone does not blur. Standing still and flicking the mouse produces a perfectly sharp frame however enormous the screen velocity is, because the gain is zero. This is deliberate — rotation blur is the main cause of motion-blur nausea. Once you are already moving, rotation contributes a capped 15% of its share, applied per pixel rather than as a screen-wide approximation.

It rides exactly the speed FOV’s ramp. Onset 9 m/s and saturation 24 m/s — the same two numbers the speed field of view uses, so the two are one curve rather than two that happen to end together. The frame opens and smears in the same instant, at the same fraction, at every speed. The audio pair still waits for its own, higher number (client.audio.fallWind.startSpeed, 12): what you see and what you hear are allowed to arrive apart, but two things you see are not. All of that is asserted by a test against the shared constants rather than restated as literals.

This is worth stating in plain numbers, because it is the one thing about the effect that surprises people.

Shutter is measured in frames of exposure, and 100% is exactly one frame. A real 360° shutter cannot expose for longer than the frame it is exposing — that is the physical ceiling, and it is inherently short: at 60 fps and 24 m/s the camera travels 40 cm in a frame, which is a handful of pixels for anything more than a few meters away. A physically honest camera blur therefore looks mild no matter how far the strength slider is pushed, because strength scales the streak and the shutter is what sets it.

Cinematic motion blur has always exaggerated past that, so the shutter slider does too: it runs to 400%, four whole frames of exposure, and the number is literally the frame count. The defaults are not there — the shipped exposure is 15% of a frame, which is what Valve shipped in Source, and is meant to read as a texture on fast movement rather than as a stylization. Everything from 100% up is a deliberate choice to leave physical truth behind.

Two things follow from that, and both are automatic:

  • The radial screen cap opens with the exposure. It is 4% of screen width at a physical exposure (Valve’s figure) and scales in proportion above one frame. Left flat it would quietly become the ceiling: a four-frame exposure would compute four times the streak and have three quarters of it clamped away, so the slider would move and the picture would not. It is still a hard bound at every setting.
  • A longer streak buys more taps. A fixed tap count over a streak four times as long is four times the gap between taps, and past a point the gather resolves as a row of discrete ghosts rather than a smear — which reads as weaker and dirtier, not stronger. client.render.motionBlurSamples is therefore a floor, and the count rises to keep taps within client.render.motionBlurTapSpacing pixels of each other. At the shipped settings nothing is added; at a four-frame exposure on a 1600-wide frame it is 31 taps rather than 8, which is the cost of asking for the long streak.

Screen velocity is recovered from each pixel’s own depth, by reprojecting through the previous frame’s camera — the same derivation the temporal resolve already performs. There is no velocity attachment and the scene pass pays nothing; the effect is one HDR-resolution pass plus two tile-sized ones.

That makes it camera-only, and the limitation is worth stating plainly: a prop moving under a still camera gets no blur and stays sharp, and a camera tracking a moving prop over-blurs it. It degrades by omission, never by artifact — velocity always comes from the pixel’s own depth, so nothing is dragged outside its silhouette and no wrong-direction streak is manufactured. Per-object velocity is future work and would overwrite this field rather than replace the pass.

The filter runs on linear radiance, before bloom and long before the tonemap curve, which is what conserves energy as a highlight spreads: 1000 units across two pixels gives two 500s, so a bright spot dims as it grows rather than doubling in area at full brightness. The price of that is fireflies, which client.render.motionBlurClamp caps.

Two things reliably make motion blur unpleasant, and both are handled the way Source handles them.

Long frames. The reconstruction assumes the camera moved in a straight line across the frame, which a hitch does not guarantee, so the effect fades out linearly from 50 down to 30 fps and a frame longer than 1/15 s is not blurred at all. Note this is not the same as the streak getting longer on a slow frame — it cannot, because the exposure normalization cancels the frame duration out, so a streak is the same length at 144 fps as at 40.

Camera jumps. A snapshot correction from the server moves your pawn, which moves the camera, which makes the reprojection span a discontinuity — and camera-only blur derives its whole velocity field from exactly that pair. Left alone, a correction landing mid-sprint is a full-screen smear. The engine watches the camera itself rather than subscribing to events, which catches a correction, a respawn, a teleport, a map switch and a spectator change with one mechanism: moving more than 2.5 m in a single frame cancels the blur for that frame, and more than 4 m additionally holds rotational blur down for a second and ramps it back.

Four are on the settings screen; the rest are console-only tuning, and every one of them applies live.

PreferenceDefaultRangeWhat it does
client.render.motionBlurtrueon/offMaster switch. On by default; speed-gated, so at rest it costs nothing.
client.render.motionBlurAmount1.001Strength. 0 is exactly off, not a very small blur.
client.render.motionBlurShutter0.1504Frames of exposure the virtual shutter is open for. 1 is a physical 360° shutter; above that is deliberate exaggeration. 0 is exactly off.
client.render.motionBlurStartSpeed9.00200Speed (m/s) below which nothing blurs. Deliberately equal to client.camera.speedFov.startSpeed.
client.render.motionBlurFullSpeed24.00400Speed at which the blur saturates — the speed FOV’s ceiling, and the wind’s.
client.render.motionBlurRotation0.1501Share of the camera’s rotation that blurs while you are moving.
client.render.motionBlurMaxScreen0.0400.25Longest streak as a fraction of screen width at a one-frame exposure, clamped radially so it never bends a streak. Scales with the shutter above one frame.
client.render.motionBlurReferenceFps6015480The frame rate the shutter fraction names an exposure against.
client.render.motionBlurClamp64165504Radiance ceiling on each tap. This is the firefly control.
client.render.motionBlurTileSize20440Velocity tile edge in pixels. Rebuilds two small images.
client.render.motionBlurSamples8232Fewest reconstruction taps; a longer streak is given more.
client.render.motionBlurTapSpacing161256Widest gap allowed between consecutive taps, in pixels. Lower is smoother and costs taps.
client.render.motionBlurHitchSeconds0.066701Frames longer than this are not blurred at all.
client.render.motionBlurJumpDistance2.50.11000Single-frame camera movement (m) that counts as a teleport.
client.render.motionBlurRecoveryDistance4.00.11000Jump above which rotational blur is held down afterward.
client.render.motionBlurRecoverySeconds1.0010How long that hold lasts.

The full derivation — the reversed-Z depth comparison, why the reconstruction is never divided by w, which published resolves were deliberately not ported, and what per-object velocity needs from the netcode before it can ship — is in Engine/design-notes/motion-blur.md.

Photograph it with dh render --renderMotion; a still cannot show it.

Roll is a barrel-tilt about the camera’s forward axis: it rotates the view’s up/right basis while leaving forward untouched. That makes it purely visual — it is client-side only, never networked, and does not affect movement, collision, or where the crosshair and aim point. Only the tilt of the horizon changes.

CameraRoll (in DigitalHeaven.Engine.Client) is a critically-damped spring that always returns the roll angle to zero. Its state is the current angle and angular velocity, integrated each frame:

accel = -Stiffness*angle - Damping*vel
vel += accel*dt
angle += vel*dt
  • Damping = 2*DampingRatio*sqrt(Stiffness) — with DampingRatio = 1.0 this is critical damping: an impulse rises to a single peak and eases back to level with no oscillation or overshoot. A ratio below 1 lets the punch bounce back past level (a springier wobble).
  • Stiffness = 100 is tuned so a single impulse peaks in ~100 ms (peak time ≈ 1/sqrt(Stiffness)) then snaps back — a quick punch rather than a lingering dwell.
  • The integration sub-steps a large frame dt for stability, and the angle is clamped to a sanity maximum (MaxAngleDegrees, 20°) — sized so a huge fall reads as a distinctly bigger tilt than a medium one rather than both pinning the same low cap.

The public entry point is AddImpulse(float angularVelocity), which adds to the angular velocity. Because impulses add rather than replace, multiple sources compound: two kicks in quick succession reach a higher peak than one. The system is deliberately general — any future effect (explosions, weapon hits, recoil) can roll the camera by calling AddImpulse; no effect-specific logic lives inside it.

The roll spring lives on ClientCamera and is integrated each frame on the frame clock (ClientRuntime calls it after the host’s per-frame hook, before the view is built). ClientCamera.GetView bakes the current angle into the frame’s RenderView, and CameraMath.View applies it as a rotation of the up vector only handed to CreateLookAt — the look target (Position + Forward) is unchanged, so aim is provably untouched (a headless test asserts the forward direction maps to view-space −Z at any roll).

Sign convention: a positive angle is a counterclockwise view roll (the horizon’s right side rises); negative is clockwise.

ClientRuntime.AddCameraRollImpulse exposes the impulse entry to the host, and the roll is reset to level on respawn/reconnect so a punch never persists across a prediction reset.

Fall-impact view punch (the first consumer)

Section titled “Fall-impact view punch (the first consumer)”

FallImpactRoll (in DigitalHeaven.Engine.Host) is a distinct piece — not part of the spring — that converts a landing into a roll impulse. It fires on the same per-tick landing edge that drives the landing sound, so its severity matches the impact you hear (it reads MovementAudio.LastLandingDescent, the fall’s peak descent speed for that edge).

Magnitude is mapped from descent speed and shares its thresholds with the movement audio:

  • Zero at or below the roll’s own descent floor (FallImpactRoll.MinDescent = 6.0 m/s, set with headroom above a plain jump’s 5.08 m/s landing) — a hop or small step-down doesn’t tilt the view.
  • Climbs linearly above that at a fixed slope per m/s of descent (ImpulsePerDescentSpeed). The ramp is unbounded in descent — it is not normalized to a fixed top speed — so a tall multi-second fall keeps punching harder than a medium one instead of saturating; only the impulse clamp and the spring’s angle cap bound the result.
  • An extra kick (HardBonus) is added once the fall is hard enough to hurt (MovementAudio.FallPainSpeed = 11.0 m/s — the same threshold that layers the fall-pain grunt), so a painful fall snaps distinctly harder than a merely firm one.
  • Scaled by an overall strength multiplier (default 1.0), then clamped to a maximum impulse.

Direction comes from the lateral motion relative to the view: the horizontal impact velocity is projected onto the camera right axis.

  • Falling to the left of view → counterclockwise (positive).
  • Falling to the right of view → clockwise (negative).
  • Straight down (negligible lateral motion) → a random direction.

A strong lateral bias also modestly scales the magnitude, but its main product is the direction. The resulting signed impulse is handed to ClientRuntime.AddCameraRollImpulse; a too-soft fall yields a zero impulse and is a no-op.

The feel knobs above ship as their named constants but are exposed as live client.* preferences, so the punch and pain-grunt onset can be dialed in from the console (or a loaded profile) without a rebuild — pair them with the landing readout of the sound-cue overlay (client.debugSoundCues) to tune by eye. All are client-local and never networked; the host reads them and pushes them into the roll spring / fall-impact impulse / movement audio each frame, so an edit takes effect at once. The defaults reproduce the shipped feel exactly.

PreferenceDefaultRangeWhat it does
client.camera.roll.stiffness10010600Roll-spring stiffness — higher is snappier (faster peak and return).
client.camera.roll.dampingRatio1.00.22.0Damping ratio — 1.0 is critical; below 1 the punch bounces back past level.
client.camera.roll.maxDegrees20045Hard cap on the roll magnitude, in degrees.
client.camera.fallImpact.minDescent6.0020Descent speed (m/s) below which a landing fires no view punch.
client.camera.fallImpact.strength1.003Overall multiplier on the computed roll impulse (1.0 = shipped feel).
client.audio.fallPainSpeed11.0425Descent speed (m/s) at/above which a fall hurts — layers the pain grunt and adds the view-punch hard bonus.
client.camera.speedFov.enabledtrueon/offMaster switch for the speed field of view. Off eases the view back to client.fov.
client.camera.speedFov.startSpeed9.00200Deadzone: below this speed the view does not widen at all. Deliberately below the audio pair’s 12 (client.audio.fallWind.startSpeed / client.audio.surfaceScrape.startSpeed) — a sprint (10.16 m/s) is meant to be seen and not heard.
client.camera.speedFov.fullSpeed24.01500Speed at which the widening reaches its ceiling; faster clamps there.
client.camera.speedFov.maxDegrees12.0060Vertical degrees added to client.fov at full speed.
client.camera.speedFov.stiffness64.01600Speed-FOV spring stiffness — higher answers the sprint key sooner.
client.camera.speedFov.dampingRatio1.00.22.0Damping ratio — 1.0 is critical (no overshoot); below 1 the view springs past.

The three client.camera.roll.* values retune the spring in place (damping is recomputed as 2*ratio*sqrt(stiffness) so a ratio of 1.0 stays critical). The minDescent, strength and fallPainSpeed values are threaded into FallImpactRoll.Impulse as parameters on the landing edge, and fallPainSpeed also drives the movement audio’s pain-grunt onset — keeping the pure spring and impulse functions device-free and unit-testable while the host owns all preference reads.