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 1–179. The floor is the whole accepted floor: a narrow FOV is a legitimate setting — for reading detail across a map, for framing a shot. 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 1–179 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.50–1The 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.50–10 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 0–1 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.350–1How dark the corners go. 0 is an exact identity; 1 takes them to black.
client.render.vignetteSmoothness0.50.05–1Width 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.350–1How 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. Off by default (client.render.ao); Graphics → Ambient occlusion carries a strength slider, a radius slider, a quality choice and a half-resolution toggle.

PreferenceDefaultRangeWhat it does
client.render.aofalseon/offMaster switch. Off skips the depth prepass entirely — the pass is not merely neutralized, it is not recorded.
client.render.aoIntensity10–1How far the occlusion is allowed to darken ambient. 0 is an exact identity; the default applies all of it, since the multi-bounce fit already keeps bright creases from crushing.
client.render.aoRadius0.50.05–8World-space search radius in meters, so the effect is a fixed physical size rather than a fixed number of pixels.
client.render.aoPower20.5–4Contrast exponent on the visibility, near XeGTAO’s reference 2.2. Above 1 deepens the midtones and leaves both ends fixed. Console-only — not in the settings screen.
client.render.aoQuality30–3Slice 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.aoBias20–30Tangent-plane angle bias in degrees. Pushes both horizons away from the surface before the arc integral, so a floor seen at a grazing angle far from the eye stops occluding itself out of depth-reconstruction noise (a few percent at 0).
client.render.aoFalloff0.60–0.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.250–0.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.aoMaxScreenRadius408–128Ceiling 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.aoDenoise20–3Number 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.aoBlurRadius21–4Taps either side of center per blur pass.
client.render.aoBlurSigma1.60.5–4Spatial falloff of the blur kernel.
client.render.aoSlopeTolerance20–8How 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.

Two conventions decide whether it measures anything at all. Each slice is a direction in view space, and its taps are fetched along the same direction on screen — which means flipping the vertical component, because texture v runs down while view-space y runs up (XeGTAO does the same). The pass used to march the unflipped direction, which paired every slice’s projected normal with the opposite side’s horizons: every floor, ceiling and off-center wall read as heavily occluded, a flat floor went gray to black with distance, and the old bias and strength defaults had been tuned against that. Second, the visibility is divided by what the same slices integrate to with nothing in the way, not by the slice count: slices evenly spaced around the screen axis are not evenly spaced around an off-center view vector, so a plain average read a flat wall near the frame’s edge at about 0.9. The ratio is exactly one for any unoccluded surface anywhere on screen.

Ao.PixelVisibility runs the whole march on the CPU, and the tests put it on an oblique floor, off-center walls and a real crease — the closed-form tests alone only ever saw a surface facing the eye, where the flip changes nothing.

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 distant floor seen almost edge-on reads a few percent 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.

It draws what the scene pass shows, not what the scene submits. Most draws take a fast path with no fragment stage, the shadow pass’s depth-only vertex shader. A draw whose shape is decided per fragment takes a masked path instead — the scene pass’s own vertex shader and pipeline layout, with ao_prepass.frag discarding exactly what the opaque shader discards:

DrawIn the occlusion depth
OpaqueWhole, fast path
Alpha cutout (alphaMode: "mask")Cut by baseColor alpha at alphaCutoff (UV0), so a foliage card occludes as leaves, not as a quad
Camera-proximity ditherThe same screen door the scene pass draws
First-person bodyThe head cut and the body’s own near plane, so the head the camera never sees is not an occluder in front of the lens
Anything, while the world dissolvesThe same dissolve
Blended (alphaMode: "blend")Absent — and it receives no screen-space occlusion either, since every tap it could take belongs to whatever stands behind it

The dissolve, the proximity fade and the head cut are one shared function (draw_dissolve.glsl) that both shaders call, so the two passes cannot disagree about which fragments exist. Both paths rasterize through the scene’s jittered camera matrix — the one the frame block carries — so under TAA the map lines up exactly with the jittered pixels that sample it and the temporal resolve absorbs the offset.

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. When all four upsample taps are rejected — a sliver of background just past a foreground silhouette — the upsample searches the surrounding 4×4 for the tap whose depth matches best, which is this surface’s own occlusion whenever it shows anywhere nearby. It used to take the best of the four, which stamped the foreground’s occlusion onto the background along every silhouette.

Ambient only. The opaque shader computes

ao = min(materialOcclusion, ssao)
color = direct + diffuseAmbient * multiBounce(ao, albedo)
+ reflections * specularOcclusion(n·v, ao, roughness) + 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.

  • min, not a product. The material’s occlusion map and the screen-space pass measure the same thing, how much of the sky a point sees; where both know about one crease a product would count it twice. HDRP combines them the same way.
  • Multi-bounce (Jimenez et al. 2016). A crease on a bright surface is lit by the bright walls around it, so a raw visibility darkens it far more than it ever looks. The paper’s cubic fit lifts the visibility per channel against the surface’s albedo; it never darkens and leaves 0 and 1 where they are.
  • Reflections take specular occlusion, not the diffuse visibility (Lagarde and de Rousiers 2014). A reflection probe is one narrow lobe: the approximation opens up as the view grazes and as the lobe tightens, so a mirror in a corner is not dimmed like a wall. It used to be multiplied by the raw screen-space value, which dirtied every glossy floor with a diffuse-shaped shadow.
  • Blended surfaces receive none — see the table above.

diffuseAmbient 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. A lightmap that folded the ambient in (bakeAmbient) already carries its own ray-traced occlusion, so there the diffuse fill is the bake’s and only the reflections take the screen-space term.

The occlusion debug view (--debugView occlusion, client.render.debugView) shows ao — the combined visibility before the multi-bounce lift — so a render with client.render.ao true photographs the screen-space pass directly, and one with client.render.aoDenoise 0 photographs the raw march.

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 (sixteen more only where all four are rejected). The prepass’s masked path costs a fragment stage and the full vertex streams, and only the cutout, dithered and head-cut draws take it. 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.90–0.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, and the debug gizmo projections. The AO depth prepass deliberately reads the jittered pair: its masked draws take the camera from the frame block, and a map built from the jittered depth registers exactly with the jittered pixels that sample it, while the resolve absorbs the offset like any other shading.

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.

Look down and your own body is there — legs, chest, whatever your avatar wears, seen from the front the way you see your own. On by default, and it is one switch: Player → First-person body, next to the avatar and scale rows, with nothing to tune beside it.

The treatment is the one the Minecraft mod shipped first, and the engine and the mod’s avatar service now share the code for it (FirstPersonBody): the body stands a hand behind the lens, its head is folded away by bone, and the camera does not swing on a neck. Every shell draws it through the same ClientPresentation.SubmitLocalBody, so a phone looking down finds the same body a desktop does.

Where the eyes are, how long the neck is and where the head ends are read off the worn avatar rather than asked of the player. Nobody can answer “how far below your eye does your chin start” for a fox, a robot and a human at once, and any single slider setting is wrong for two of the three. The whole derivation is AvatarViewGeometry.Derive, run once when you put an avatar on — and again when its pallet hot reloads — never per frame, and it reports where each number came from so the wear-time log line reads back as authored / bone / fallback.

NumberFirst answerThenLast resort
Eyethe rig’s viewPosition, carried through the build as eyeHeight + eyeForwardthe eye bones’ midpoint in the bind pose, for the forward reachon the body’s own axis, at the measured eye height
Neck pivotthe Head bone’s bind position, measured to the eyethe Neck boneclient.render.firstPersonPivotDown / Forward
Headthe Head bone and everything under it, folded awaya plane at the Neck bone, a fraction up toward the eyea plane client.render.firstPersonHeadChop below the eye

The eye leaves the axis. A compiled avatar’s eye carries a forward component as well as a height — a height alone puts the camera in the middle of the head, fine for a human face, visibly wrong for a muzzle. The eye turns with yaw only: an eye is fixed in the skull, and the head’s own swing on pitch is the neck’s to model. It is the aim eye, so a trace leaves from the face. What it deliberately does not move is the body: the pawn’s capsule, its position readout and where its avatar stands all still ride the vertical axis.

Everything scales with you. Every derived number is an offset in the avatar’s own model units, multiplied through the same AvatarFit the hull is fitted by and then by the pawn’s players.*.scale — so a mouse-sized player gets a mouse-sized neck. And because they are offsets from the eye rather than absolute heights, one derivation survives a crouch.

A neck outside 0.03–0.15 m is pulled to length and logged by name, keeping the direction the rig measured. A skull swings about the top of the neck, which is what the Head bone marks and the Neck bone sits a whole vertebra below. The ceiling is short for the reason Quake III cut its own 0.26 m neck: a long one carries the chest out of frame on the way down.

A camera at the eye of a body standing under it looks down into the neck: the shoulders, the chest and the arms are all at or behind the lens, so looking down showed the inside of the torso, the arms splayed round the view and the ground through the middle. Three things put the lens in front of a body instead.

The body stands a setback behind the camera. client.render.firstPersonSetback (0.25 m, the Minecraft mod’s standing number) along your facing, measured from the aim eye, so it is the same distance from the lens on a muzzled avatar as on a flat face. Looking down then finds the front of the chest and the belly, with the knees and feet beyond them; at a level glance the body is below the frame, as it is in Minecraft.

The head stays where the lens is. The locomotion leans the trunk into a run, into a flight (35° at noclip speed) and into a crouch, and every lean carries the head away from the camera, which does not move: before this, flying at 13 m/s put the animated head 0.15 m in front of the lens instead of 0.15 m behind it, and the flight’s ring of body triangles fanning out in front of the camera was the torso cut where it crossed the eye. Between the animation and the springs, the body is now shifted horizontally by exactly the animated head joint’s displacement from rest, so the neck sits where the setback put it whatever the pose; the feet stay on the floor because only the horizontal part is cancelled. The springs are stepped with the shifted transform, so a tail swings from where the body is actually drawn.

The head is folded away by bone. A rig that maps a Head bone has that bone and everything parented under it (hair, ears, glasses, anything armatureLinked to it, and an accessory armature whose head bone carries the same name) collapsed onto the head joint before the draw, so exactly the head disappears however far the body bends. A horizontal plane would cut shoulders and raised arms and expose the inside of the torso, so the plane is kept only for a rig with no Head bone and for the fallback box: there the fragment stage cuts at a horizontal plane below your smoothed eye, fully drawn below, gone above, with a short dithered band across the seam.

A crouch is a crouch. The locomotion reads the crouch off the eye height, so the hips sink and sit back, the knees bend over the planted feet and the trunk leans over them, as fast as the camera sinks. Before this the body stood at full height while the eye dropped to 0.71 m, and looking down from a crouch looked out of the thighs.

The body is drawn from the camera’s own blend. The camera is drawn from the sub-tick blend of the two newest predicted ticks; the frame stage hands every host that same blend (ClientFrameEyeStage.RenderEye, through ClientCamera.BlendEye) and the body stands on it. The iOS shell used to stand the body on the newest tick, so at 13.6 m/s the body was drawn up to a full tick (0.23 m) ahead of the lens.

One predicate decides whether the body is drawn at all. PawnBody.DrawsLocalPawn(editorCameraDetached, hasRenderEye, firstPersonBody) is the single answer, so the detached editor camera is unchanged whatever this setting says — it keeps the whole body, head included, standing where you are, and keeps its own proximity fade. Turning the setting off means nothing of you is drawn in first person.

The shadow is the body’s shadow. The plane cut is a fragment-stage discard and the shadow pass is depth-only, so a plane-cut body casts its whole shadow. A folded head is folded in the pose every pass draws, so the sun shadow of a rig with a Head bone is headless: there is no shadow-only draw to give it back, and that is the price of trimming exactly the head.

Your spring bones run on your own body. A tail, a mane or a bell swings in first person exactly as it does on another player’s pawn — the same solver, one instance kept per pawn, stepped with the same per-frame root motion, so what jiggles is driven by the transform you are actually drawn at.

It is client presentation only. No protocol version, nothing replicated, nothing predicted; other players see your avatar exactly as they always did.

Where you aim and where you look are two transforms

Section titled “Where you aim and where you look are two transforms”

The frame now carries a PlayerViewpoint: an aim transform and a view transform. The aim is the authority — traces, interactions, the entity pick, the audio listener and the head cut’s plane are all authored against it, and it is exactly the fixed eye that was there before. The view is the camera pose the projection is built from, and it is derived from the aim, never the other way around. A change that only moves the camera therefore cannot move a shot, a grab or a listener by construction rather than by care, and that is asserted directly: a test drives the view somewhere else entirely and demands the aim not follow.

The one deliberate exception is the editor’s picking ray and the world gizmos, which read the view. Both invert the projection that was actually rendered, so they must use the transform that produced the pixels the mouse is pointing at — a pick against the aim would select whatever is under a cursor position that was never drawn.

Off by default (client.render.firstPersonPivot). With the body standing behind the lens, swinging the lens forward on a neck carries it further from the body, and at the 30° to 60° a player actually glances down at, a neck shows less of the body, not more. The Minecraft mod has none. What follows is what turning it on does.

A camera bolted to a point in your skull rotates in place, so looking down at your own chest pushes the lens into it. Real heads pivot around the neck, some distance below and behind the eyes, so looking down draws the eyes back and away from the body. That is the classic neck model — Quake III’s CG_OffsetFirstPersonView, Google Cardboard’s head model, and every VR runtime since.

The view origin is the aim eye rotated about the pivot. Take the offset from the pivot to the eye — measured off the avatar’s own Head bone, as above — rotate it by the eased pitch in the pawn’s yaw frame, and the difference is where the camera moves. Three properties fall out and each has a test:

  • At level pitch it is the aim eye, exactly. Zero rotation moves nothing, so a level frame with the pivot on and a level frame with it off are the same bytes.
  • Yaw does not move it. The offset is expressed in the yaw frame, so turning on the spot rotates the frame and the offset together and the origin stays put — no swing, no lurch.
  • It never rises above the aim eye. Looking up would otherwise lift the camera out of your own head. Cardboard’s detail is to subtract the model’s own rise back off, so looking up is a pure draw-back over the shoulders and the lens stays at eye height or below. The price is a slope kink in the vertical at level pitch, which is invisible and is the cheaper of the two.

The pitch is eased before the rotation: below firstPersonPivotEase degrees the pitch is used as-is, and past it the remaining travel is compressed by a quadratic that arrives at the look limit with zero rate. The join is continuous in value and slope (C1), so the camera has no visible kick at the seam and no snap at the limit. At the shipped 60° ease, an 89° look down swings the neck as if it were 74.5°.

First person only. The pivot is folded in exactly where the first-person body is, so the detached editor camera and every other view are untouched, and switching the body off switches the neck off with it. It is the desktop host’s: the iOS shell’s camera has no neck at all, which the default now agrees with.

The body has a near plane of its own. At steep down-pitches your own chest is closer to the lens than client.render.nearPlane (0.05 m), and until it had one it was sliced open — black voids and triangle fans through the chest and the mane, worse with the pivot on, because the pivot brings more of the body into frame. The world’s near plane is deliberately not lowered for it: that is depth precision every other surface in the map pays for. Instead the body alone is drawn against client.render.firstPersonBodyNear (0.02 m), the same idea Source ships at the same ratio — a world near of 7 units and a viewmodel near of 1.

It costs no second pass and no second projection. The scene’s projection is reversed-Z with an infinite far plane, which puts the near plane in clip z and the view distance in clip w: the depth written is z / w, and Vulkan’s clip test z <= w is the near test. So the body’s vertices are drawn with z = min(worldNear, max(bodyNear, w)). Past the world near that is the world near unchanged, so the body writes exactly the depth the world projection would have written for the same distance and the two occlude each other with no bias and no z-fighting — an arm through a wall is still cut by the wall. Between the two planes the body survives instead of being clipped, at the nearest depth the buffer has, so it covers the world rather than sinking behind it. Closer than its own plane it is still cut, which is what makes the preference a real plane. The one thing this cannot order is the few centimeters between the planes: everything there is flat at the front of the depth buffer and resolves by draw order, which is why the number is centimeters and not meters.

Nothing outside the body is touched. The draw that asked for the head cut is the draw that gets the near plane — one flag answers both — so every other surface in the frame renders byte for byte as before.

The neck’s excursion is at most 0.25 m — the clamp above — against a 0.40 m player hull; at every sane field of view the camera stays well inside the hull, so there is no sweep or boxcast and the view can never be pushed through a wall by looking down.

DigitalHeaven.Engine.Host --render <dir> --firstPerson --map <map> --avatar <barcode>

Stands one pawn at the world origin and photographs it from its own eye: a fixed list of pitches with the neck on and off (<map>_fp_<pitch>_pivotOn.png / pivotOff), a walked stride for the springs (_fp_stride<n>), and the scenarios mltn reported, each animated, sprung and moved for 1.2 s through the live pawn visual before the shutter: scenario_standDown (−90°), scenario_stand45, scenario_run45 and scenario_runDown (6.5 m/s), scenario_crouchDown and scenario_fly36 (13 m/s along a −36° view). The body is placed through the same FirstPersonBody setback and frame cut as a live client, and each scenario logs where the drawn head joint and feet landed against the camera, so a regression is a number before it is a picture. Nothing reads a preference or a clock, so two runs are the same bytes.

One is on the settings screen. Everything else here is console-only. The plane’s numbers apply only to a rig with no Head bone, and the three geometry numbers are fallbacks on top of that: they apply only where the worn avatar gives no answer at all.

PreferenceDefaultRangeWhat it does
client.render.firstPersonBodytrueon/offDraw your own avatar in first person. Off draws no body at all. The detached editor camera ignores this and always draws you.
client.render.firstPersonSetback0.250–1Meters behind the camera the body stands, at the reference pawn scale. 0 stands it under the eye, where looking down looks into it.
client.render.firstPersonPivotfalseon/offSwing the camera on the worn avatar’s neck as you pitch. Needs firstPersonBody too.
client.render.firstPersonHeadChopBand0.040–0.25Plane only. Height of the dithered blend band, centered on the cut — so the cut height names where the body is half gone. 0 is a hard edge. Not derived: this is softness, not anatomy.
client.render.firstPersonBodyNear0.020.005–0.05Meters from the eye the body’s own near plane sits at. The ceiling is the world near plane, where the body has no near plane of its own. Lower it if a mane or a paw still opens up at a steep pitch.
client.render.firstPersonPivotEase600–89Degrees of pitch the neck follows one-for-one. Past it the swing is compressed onto the look limit, so the extremes stay smooth. 0 compresses the whole range.
client.render.firstPersonChopMargin0.250–1Plane only. How far up from the Neck bone toward the eye the cut goes, as a fraction of the eye-to-head span — a fraction rather than a length because the span is what differs between a mouse and a giant.
client.render.firstPersonPivotRadiusMin0.030–0.25Shortest neck accepted from a rig’s measurement, in meters.
client.render.firstPersonPivotRadiusMax0.150–0.25Longest neck accepted from a rig’s measurement, in meters.
client.render.firstPersonHeadChop0.120–0.5Fallback only. Meters below the eye the cut sits when the rig has neither a Head nor a Neck.
client.render.firstPersonPivotDown0.0750–0.25Fallback only. Meters the pivot sits below the eye when the rig has no neck to measure.
client.render.firstPersonPivotForward0.080–0.25Fallback only. Meters the pivot sits behind the eye when the rig has no neck to measure.

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.00–1Strength. 0 is exactly off, not a very small blur.
client.render.motionBlurShutter0.150–4Frames 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.00–200Speed (m/s) below which nothing blurs. Deliberately equal to client.camera.speedFov.startSpeed.
client.render.motionBlurFullSpeed24.00–400Speed at which the blur saturates — the speed FOV’s ceiling, and the wind’s.
client.render.motionBlurRotation0.150–1Share of the camera’s rotation that blurs while you are moving.
client.render.motionBlurMaxScreen0.040–0.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.motionBlurReferenceFps6015–480The frame rate the shutter fraction names an exposure against.
client.render.motionBlurClamp641–65504Radiance ceiling on each tap. This is the firefly control.
client.render.motionBlurTileSize204–40Velocity tile edge in pixels. Rebuilds two small images.
client.render.motionBlurSamples82–32Fewest reconstruction taps; a longer streak is given more.
client.render.motionBlurTapSpacing161–256Widest gap allowed between consecutive taps, in pixels. Lower is smoother and costs taps.
client.render.motionBlurHitchSeconds0.06670–1Frames longer than this are not blurred at all.
client.render.motionBlurJumpDistance2.50.1–1000Single-frame camera movement (m) that counts as a teleport.
client.render.motionBlurRecoveryDistance4.00.1–1000Jump above which rotational blur is held down afterward.
client.render.motionBlurRecoverySeconds1.00–10How 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.

A dh.material with a water block is drawn by its own pass, recorded after the opaque scene and the sky and before the temporal resolve and the motion filter, so both of those see a frame the water is already in. The mesh the material is applied to is the surface: cull is off, the depth test is on against the scene, depth is written to a copy of it so water occludes water, and the fragment stage composes the whole pixel itself rather than blending.

Three things the pass needs that no other pass did, and how each is met:

  • A second draw list. A sub-mesh whose material authored water is routed to the scene’s water list at build and rebuild and never reaches the opaque pass.
  • A copy of the finished scene color. A shader may never sample the attachment it is writing, and the water writes the very image the scene pass produced — so the pass first copies that image (one vkCmdCopyImage at scene extent) and refracts through the copy.
  • A copy of the scene depth, attached and written. A second vkCmdCopyImage fills it from the single-sample depth, the block attaches the copy, and the draws write it — so two water fragments at one pixel are resolved by distance instead of by draw order, while the shader goes on sampling the untouched scene depth for the receiver and the refraction. Starting from the opaque depth means an opaque occluder still rejects both. No prepass. Under MSAA that is the resolved depth, which now exists whenever water is on (motion blur wanted it first), and the water is rasterized at one sample into the resolved color.

The optics, in the order they apply per pixel: the water behind the texel is measured along the view ray (the forward-depth difference times the ray’s obliquity — beam attenuation, not the vertical falloff a downwelling coefficient describes); the transmitted sample is offset by the surface slope, ramped by that depth so a shallow edge cannot smear the shore against it, faded with eye distance, and scaled by the projection so one slope displaces the same fraction of the frame at every FOV; a sample that lands on something in front of the surface is pulled back continuously through the same ramp, with a hard reject only as the last clamp, so nothing flips per pixel per frame; the sample is attenuated per channel with Beer-Lambert over that path; and the result is blended toward the sky in the mirror direction by Fresnel at water’s F0 of 0.02. When the sky is an image, that reflection is sampled at the mip the mirror direction’s screen-space footprint asks for, so a small bright source is a glitter path rather than a stack of hard discs on the crests it happens to land on.

Every water surface evaluates one shared wave function from one shared parameter block, on the GPU and on the CPU, from tick-derived time: a Gerstner spectrum of four spread lanes that displaces the mesh, a normal-only chop under it, an optional current that advects both, and foam from the shoreline depth and the spectrum’s Jacobian.

Crest foam persists. Each water body owns a plan-view history — a texel is a place in the world, not a pixel on the screen — accumulated by a pass of its own (waterFoam in --gpuTimings, recorded between the sky and the water) and faded so that a texel is down to a faint residual after the preset’s foamPersistenceSeconds. A wake therefore stays where the wave left it and drifts out over the authored seconds. The shoreline band is measured against the depth behind the surface, which a plan view cannot see, so it stays instantaneous.

The underside of a surface is the same interface read from the dense side, so it is shaded by its own branch rather than by the front face’s math with a flipped sign. The wave normal is flipped — the slope field itself is untouched — and the refraction runs water to air: inside Snell’s window, a cone about 48.6 degrees wide, the sky arrives refracted through the surface, and every direction past the critical angle is total internal reflection, a mirror of the body itself. Both halves are drawn from the same reflection source the front face uses, image sky or procedural, never a second one. Foam from below is only the crest and its history, lit through the layer’s own up and composited as a thin skin on the interface; the shoreline band is deliberately not asked, because it measures what lies behind the surface and from underneath that is a kilometer of sky, which saturates the band everywhere and turns the whole underside into one flat sheet of noise. Finally the body’s own Beer-Lambert absorption is applied over the distance from the eye to the fragment, so the surface and the floor beneath it agree about how much water is in between.

The window’s refraction offset is band-limited: it may move a texel by only a few pixels between neighboring fragments (WaterWindowMaxGradient in water.frag). A rippled surface at distance swings the tap across a hundred pixels from one fragment to the next, which turns the window into a sheet of speckle where the quay and the sky interleave; limiting the offset’s screen-space gradient keeps the shimmer and drops the noise. The limit is a sampling bound, not a look, so it is not a preference.

When the camera itself is inside a body, the opaque stage lays that body’s absorption over everything it draws as distance fog, fading toward the sky’s ambient rather than toward black so a lit body reads as a glow instead of a void. The body under the eye is found by the one camera-in-water query on the render scene, which tests the eye against each water sub-mesh’s transformed bounds — the same rows the surface shades from, so there is no second notion of “in water” to drift.

That query also hands the shader the body’s world-space box, and the fog extincts only over the part of the eye-to-fragment ray that is actually inside it. The eye stands in the box, so the ray leaves through exactly one face: the fogged length is the distance to that exit, and everything past it is air. A fragment still inside the body keeps its whole distance. Without the clip, a swimmer in a small pool looking out over the rim saw the far hillside dimmed as if a kilometer of lake stood in front of it — the fog was measuring how far away a thing was rather than how much water was between it and the eye. It is a closed-form slab exit, not a ray march, so it costs the same at any distance. The surface seen from below needs no clip of its own and gets none: the eye and the surface fragment are both points of one convex box, so the exit is the fragment, and both halves read the same law out of water_medium.glsl and agree at the seam. Rendering/WaterMedium.FoggedMeters is the CPU mirror of that GLSL, which is what lets the clip be tested without a device.

client.render.underwaterFog scales the density; the default of 1 is the body’s authored absorption unchanged.

The same law fogs the sky: a procedural sky is infinitely far and has no fragment to measure a segment to, so it measures its own ray to the body’s exit face instead. Without it, a swimmer looking out through a body’s wall saw an unfogged horizon standing in the middle of the water.

A water body is a volume, so it has sides and a bottom as well as a top. Those faces are not interfaces and are never shaded as one: no waves, no foam, no displacement — only the up-facing surface is displaced at all. A side face is the boundary of the medium. Seen from outside it shows the Fresnel of the water’s index over the body’s own medium looked through for the meters of water standing behind that face, which is the same eye-path absorption the fog uses, so a free-standing body reads as a wall of water rather than as a hole. Where no water stands behind it — a face lying flush against the terrain that contains the body, which is the ordinary pond and the ordinary sea — the face is the scene behind it and therefore invisible; client.render.waterBoundaryFade is the width of that fade, and it is what keeps a coplanar face from flashing against the bank. Seen from inside, the face is not drawn: the under-water fog already ends its ray at exactly that boundary, and drawing it would darken the same water twice.

Fog is an eye path — how much water stands between the camera and a fragment. It says nothing about a crate on the bottom of a pond seen from the bank, which the eye reaches through almost no water and which was therefore drawn as dry as the grass beside it. Light reaching that crate crossed the column standing over it first, so the opaque stage attenuates the direct and ambient terms by the depth below the resting surface, per channel, through the same Beer-Lambert law the fog reads out of water_medium.glsl — waterDaylight in the shader, Rendering/WaterMedium.Daylight as its CPU mirror. Scattering keeps a floor: a silty body stays lit at depth where a clear one goes dark, which is why the clear presets degenerate to plain exp(-Kd) and the lake and pond presets show the fall plainly.

It is a factor over the lit terms and nothing else. Emission is added after it, so a lamp on the bottom still glows; a fragment outside the body’s plan or above its top gets a column of zero meters and is untouched. It applies whether the eye is in the water or in the air, because a submerged surface is lit through the water over it whoever is looking. One body per frame — the nearest — reaches the shader, as two lanes on the end of the frame block.

client.render.waterDaylight scales it; the default of 1 is the body’s authored absorption unchanged, and 0 is the old behavior exactly.

Whether a fragment stands in the column is decided by where it is, never by which way its normal faces. The opaque stage once gated the attenuation on an upward shading normal to spare a pond’s own wall skin, and on a swimmer that split the skin into attenuated patches and air-lit ones with stair-stepped seams wherever a normal map crossed the horizon. The wall skin is now spared by the same rim fade the caustic already used (client.render.waterBoundaryFade): both faces of a wall stand on the footprint’s edge, where the fade is still zero, so the light fades in over that distance and a body in the water is lit by depth alone.

The same column also focuses light rather than merely dimming it: a caustic gain, derived from the live wave surface rather than a panning texture, multiplies the direct term alone (ambient is untouched, since it has no direction to focus along). The gain comes from the Jacobian of the surface — its determinant collapses or spreads a bundle of rays the way a real ripple lenses sunlight on a pool floor — and is 1/|det|, clamped at client.render.waterCausticCeiling so a near-zero determinant cannot blow the frame out. The chop takes part as well as the Gerstner sum: it displaces nothing, but its slope varies, so it bends the light crossing it, and it is what makes the net as fine as the ripples instead of as wide as the swell. Every lane fades out over the octave above the receiving pixel’s Nyquist, and a net whose finest surviving lane spans fewer pixels than the ceiling eases toward flat light rather than sparkling. Each color channel samples the surface through its own index of refraction, n ± spread/2, so a fringe separates on the same term dispersion would read; focus depth follows f = nR/(n-1). The falloff is the light depth through the water — stage 1’s term — rather than the eye path, so caustics reach deeper in clear water and reach every lit surface, not just the bed. optics.w is the authored caustic strength gating it. A known approximation: the effect does not fade at a body’s rim, because the opaque stage has no per-vertex shore channel to read there — a body with a soft, sloped bank can therefore show a caustic pattern reaching further up the shore than a real one would.

client.render.waterCaustics scales it; the default of 1 is the body’s authored strength unchanged, and 0 is the old behavior exactly.

PreferenceDefaultWhat it does
client.render.watertrueMaster switch. Off records nothing and makes no copy.
client.render.waterRefractiontrueOffset the transmitted sample by the slope at all.
client.render.waterRefractionStrength0.6Offset per unit of surface slope, in projected view units.
client.render.waterRefractionDepth1.5Water depth in meters at which the offset reaches full strength.
client.render.waterRefractionFade60Eye distance in meters over which the offset fades to nothing.
client.render.waterWavestrueDisplace the surface at all. Off leaves a mirror-flat plane, the chop included.
client.render.waterWaveScale1Multiplier over every preset’s amplitudes, on top of the material’s waveScale.
client.render.waterFoamtrueShoreline and crest foam at all.
client.render.waterFoamSoftness0.45How far the foam’s noise breaks up its edge.
client.render.waterFoamCoverage0.7The most of a texel foam may cover. 1 lets it replace the surface.
client.render.waterFoamPersistencetrueAccumulate crest foam into each body’s decaying history. Off makes foam instantaneous, with no persisting history.
client.render.waterFoamResolution256Side length in texels of each body’s foam history, 32 to 2048.
client.render.waterMirrorBlur1Footprint scale of the image-sky reflection’s mip. 0 pins it to the base level.
client.render.waterEdge0.5Subdivision span in meters — how many vertices a surface gets to carry a wave. Applies live: changing it re-cuts every water body without a map reload. 0 turns cutting off.
client.render.waterMaxVertices65536Per-body vertex budget. A body that would exceed it is cut with a proportionally longer edge, and the clamp is logged.
client.render.waterSurfaceAngle45How far from straight up a triangle’s normal may lean and still count as a surface worth cutting. Walls and floors of a water box are left alone.
client.render.underwaterFog1Density scale over a body’s own absorption for the fog seen from inside it, 0 to 16. 0 turns it off.
client.render.waterBoundaryFade0.1Meters of water behind a body’s side or floor face the medium boundary fades in over, 0 to 4. 0 is a hard step at the face.
client.render.waterDaylight1Density scale over a body’s own absorption for the light reaching what lies under it, 0 to 16. 0 turns it off.
client.render.waterCaustics1Scale over a body’s own caustic strength, the light its surface focuses onto what is under it, 0 to 16. 0 turns it off.
client.render.waterCausticCeiling8The brightest a caustic may focus light to, as a multiple of what flat water lets through, 1 to 64; one over it is the darkest. 1 turns caustics off.
client.render.waterRescaleRatio1.1How far a brush may be scaled from the size its mesh was cut for before it is cut again. Hysteresis, so a gizmo drag does not re-mesh every frame.

Photograph it on the render-evaluation map’s pools: dh render --map core:maps/render-eval --camera waterAbove for the absorption gradient down the steps, --camera waterGrazing for Fresnel at the horizon, --camera waterWaves for the swell in relief and --camera waterFoam for the wash up the lake ramp. The design note — the seam contract and what is still deferred (shore-foam persistence, the waterline, screen-space reflection, caustics) — is Engine/design-notes/water.md.

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.

The mobile client does not run ClientRuntime — it presents frames through HeadlessRenderer — so the same seam is mirrored there: HeadlessRenderer owns its own CameraRoll and exposes ConfigureCameraRoll / AddCameraRollImpulse / UpdateCameraRoll / ResetCameraRoll, and bakes the spring’s current angle into every RenderView it presents. One spring type, one convention, two front ends.

Fall-impact view punch (the first consumer)

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

FallImpactRoll (in DigitalHeaven.Engine.Client) 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: both read the fall’s peak descent speed from FallDescent, the shared severity accumulator that latches the peak on the mover’s own landed edge. Both platforms reach it through the same object: MovementFeel (in the shared client-frame assembly DigitalHeaven.Engine.Client.Frame) owns the accumulator, the landing sound and this punch, and both the desktop host and the iOS app simply hand it a predicted tick. Neither re-derives a landing heuristic of its own, and the punch cannot be one curve on one platform and another elsewhere.

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 landing value — descent speed times ground hardness — reaches FallImpactRoll.PainValue (≈9.9, the same line that layers the fall-pain grunt; both alias the single FeelPreferences.DefaultLandingHurtValue const so they cannot drift — see the landing value), 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 on desktop and to HeadlessRenderer.AddCameraRollImpulse on mobile; a too-soft fall yields a zero impulse and is a no-op.

A spawn or respawn fires the same punch with no fall to measure: the client feeds the fixed FallImpactRoll.SpawnDescentSpeed (12.0 m/s, just above the pain line) through the same ramp, so arriving in the world lands with a firm, unmistakable thump.

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.stiffness10010–600Roll-spring stiffness — higher is snappier (faster peak and return).
client.camera.roll.dampingRatio1.00.2–2.0Damping ratio — 1.0 is critical; below 1 the punch bounces back past level.
client.camera.roll.maxDegrees200–45Hard cap on the roll magnitude, in degrees.
client.camera.fallImpact.minDescent6.00–20Descent speed (m/s) below which a landing fires no view punch.
client.camera.fallImpact.strength1.00–3Overall multiplier on the computed roll impulse (1.0 = shipped feel).
client.feel.landingHurtValue9.90.5–25Landing value (impact speed × ground hardness) 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.00–200Deadzone: 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.01–500Speed at which the widening reaches its ceiling; faster clamps there.
client.camera.speedFov.maxDegrees12.00–60Vertical degrees added to client.fov at full speed.
client.camera.speedFov.stiffness64.01–600Speed-FOV spring stiffness — higher answers the sprint key sooner.
client.camera.speedFov.dampingRatio1.00.2–2.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 landingHurtValue values are threaded into FallImpactRoll.Impulse as parameters on the landing edge, and landingHurtValue also drives the movement audio’s pain-grunt onset — keeping the pure spring and impulse functions device-free and unit-testable. The reads themselves happen once, per tick, inside the shared MovementFeel, so no host owns a preference-push block for the punch and the two platforms cannot be tuned apart.