Skip to content

Third-Person Camera

The camera has one number: how far it stands back from the player’s eye. Zero is first person, exactly as it has always been. Everything else (how far up you can look, how much of your body you see, the field of view, the wind in your ears) is a function of that number, so none of them can disagree about where the camera is.

It is a camera-only change. The server, the replication and the prediction are untouched, and nobody else sees anything different: your body is where it always was, facing where you aim, and every other client draws it exactly as before. Traces, interactions and the audio listener all start from your real eye, not from the camera.

ActionDefaultNotes
ZoomScroll wheelOne notch is one detent. Scroll toward you steps out, away steps in. Works while holding Shift or Ctrl. Rebindable, see below
Cycle viewF5First person, then behind you at the last distance you used (6 m the first time), then first person again

Alt plus the wheel is reserved for avatar scale (holding it and scrolling will change your avatar’s size); nothing is bound to it yet, so the zoom refuses an Alt scroll. Shift (sprint) and Ctrl (crouch) do not block the zoom.

The zoom is an action, not a hard-wired wheel, so the wheel can be given to items or a weapon wheel later. client.camera.zoom.binding takes:

ValueMeaning
wheelThe scroll wheel. The default
keys:PageUp,PageDownTwo keys, the first stepping in and the second out. Any key name from the engine’s key table. One press is one detent
noneNo zoom input at all; F5 and the console still work

client.camera.zoom.invert reverses the direction of either.

Zoom is not continuous. It steps along a ladder, one input step per rung:

0, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 13, 16, 25, 40, 65, 105, 170, 275, 450, 650 meters: half-meter to meter steps up to 6 m, where the body is looked over close, then about x1.6 a step.

client.camera.zoom.detents holds the ladder as comma separated meters, ascending. Zero is always first person: a ladder that omits it has it put in front, entries that are not numbers or not above the one before are dropped, and a ladder with nothing usable is the default.

Between detents the camera eases: each frame it closes 1 - exp(-rate * dt) of the gap to the detent, so the curve is the same at 30 and at 240 frames a second. client.camera.zoom.easeRate (10 per second) is the rate. Within client.camera.zoom.firstPersonSnap (2 cm) of zero the camera lands in first person exactly.

At zero the camera is the first-person eye and the body is drawn as the first-person body draws it, head cut and set back. Going out, the camera eases back along your view direction from that eye, and the whole body fades in between client.camera.bodyFade.near (0.25 m) and client.camera.bodyFade.far (0.7 m), by the camera distance alone, so it is fully drawn at the nearest detent (1 m) and only dithers when the camera is inside or nearly touching the head, as a wall pulls it in. The body is drawn in third person whether or not First-person body is on.

A sphere is swept back from the player to find a wall. It starts client.camera.collision.startBack (0 m: at the eye) behind the eye, has radius client.camera.collision.radius (0.7 m), and runs up to the camera’s target distance. Where it hits is the camera distance, which keeps the camera a sphere’s radius clear of the wall. The sweep used to start 2 m behind the eye, and that was the main source of the camera’s jumping: a wall 2 to 3 m behind the player met the start of the sweep, which cut the camera to a fraction of a meter while the sphere only touched it and threw it out to the ray’s distance the moment the start went inside, and a thin wall behind the start was not met at all. A ray down the same line, from just behind the eye, is always cast as well, and the camera stays a sphere’s radius short of what it meets, so a sphere that starts inside geometry, or beyond a wall close behind the player, cannot let the camera through. The camera’s rays see both sides of a surface (TwoSidedTrace): the physics backend’s own ray meets a triangle from its front only, which let the camera through a one-sided plane or a mesh wound the other way, so the ray is cast again from its far end. Only the static world counts: never a player, debris or a trigger.

Past client.camera.collision.maxDistance (20 m) the camera passes through walls and nothing is swept. A wall pulls the camera in fast and eased: the camera may lag at most client.camera.collision.pullInSlackMeters (0.4 m, under the sphere’s radius, so the lens is never in the geometry) behind the wall’s distance and closes the rest at client.camera.collision.pullInRate (30 per second). The nearest wall the sweep found in the last client.camera.collision.releaseHoldSeconds (0.12 s) holds the camera, so a hit that flickers off for a frame on thin or complex geometry does not let the camera go and catch it again. When a wall lets go the camera comes back out at client.camera.zoom.releaseRate (20 per second) instead of the ordinary rate. Measured over 1,392 free walks of 5 seconds across gm_construct with the view swinging 25 degrees to either side, the mean jitter of the distance (root mean square of its second difference) fell from 0.052 to 0.014 m per frame squared, the average number of direction reversals from 0.27 to 0.00 and the deepest the lens stood into a wall from 5.3 m to nothing; the camera still cuts in at most one slack when a corner appears under a swing.

The far zone, where the field of view starts closing toward an isometric look, is easy to fly into by accident, so the zoom has a barrier detent where it starts. Its distance is read off the field-of-view curve (the last key that still holds the flat plateau, about 208 m), not typed in, and it is a detent of its own in the ladder.

Scrolling outward stops there. Leaving the barrier, in either direction, needs a fresh scroll after a short pause (client.camera.zoom.barrierPause, 0.28 s), measured from the last scroll input that did something, of any size, so a flick never carries across. A wheel that keeps turning against the barrier does not restart the pause: it crosses once the pause has run from the notch that arrived, rather than staying shut for as long as the wheel turns. Coming back in stops at the barrier the same way. client.camera.zoom.barrier switches it off. Every other notch counts, whole or fractional (a precise wheel adds up). client.camera.zoom.maxStepsPerBurst (0, no cap) can limit one burst of scrolling to that many detents; inputs closer together than client.camera.zoom.burstGap (0.2 s) are one burst. A step the cap holds back makes no tick; one the barrier holds back tiptoes (below).

Crossing shows a short centered label, Isometric view on the way out and Third person on the way back, fading in and out softly in the HUD band: client.hud.zoomLabel.fadeInSeconds (0.15), holdSeconds (0.8), fadeOutSeconds (0.45) and enabled. It scales with the interface scale.

Past the barrier a narrow field magnifies every twitch, so look input (mouse and stick) is smoothed: a frame-rate independent low pass on the turn, 1 - exp(-dt / seconds) of what is pending each frame, so nothing is lost and it only arrives a little later. client.camera.farLook.smoothing (on) and client.camera.farLook.seconds (0.12). First person and near third person stay raw, and leaving the zone hands back whatever is still held.

Use is picked from the player’s eye along the aim, whatever the camera does: the camera stands on that same line, so the screen’s center ray and the eye’s ray are one. Reach (2.5 m) is measured from the eye, as the server measures it, so a button is offered, ringed and usable within reach of the eye and not within reach of the camera, and the ring reads the very target the press sends. The server’s check is unchanged. Walking at a target, the client holds back the way it walks in client.interact.leadSeconds (0.12 s), because its predicted eye leads the server’s pawn by about that much; standing still it changes nothing. A refused press logs the eye, the target and the distance.

The crosshair is hidden whenever the effective camera distance is above zero. It is closed at its gate, ClientUiState.CrosshairVisible, by a flag the host sets each frame from the camera, never by moving it in the band order: a reticle claims where a shot leaves the eye, and a camera that is not at the eye has no such claim.

In third person your body faces the way you move, with smoothed turning, so strafing turns it sideways while the camera orbits freely. client.camera.thirdPerson.facing is movement (the default) or camera; client.camera.thirdPerson.facingTurnRate (12 per second) is the turn, and under client.camera.thirdPerson.facingMinSpeed (0.5 m/s) the body holds its facing. Other players see it too, and it costs nothing until it matters. The facing rides the wire only while it differs from your aim by more than client.camera.thirdPerson.facingSendDegrees (3): an optional tail of the input message (two bytes) and of the snapshot (a count and six bytes a pawn), absent in first person and once the body has caught up, with no protocol bump. Remotes blend it like the other angles and glide back to the aim when it ends. It is a drawing fact only: movement and aim stay on the aim yaw, on the server and everywhere else.

In first person you may look anywhere. As the camera pulls out, the highest pitch you may look up to slides from the first-person limit (89 degrees) at client.camera.pitch.unlockMeters (0.2 m) to client.camera.pitch.upLimitDegrees (0, level) at client.camera.pitch.lockMeters (1 m). Past client.camera.pitch.farStartMeters (300 m) it slides on to client.camera.pitch.farLimitDegrees (-10) at the far end of the ladder, so a camera at 650 m always looks down. Looking down is never limited.

In the air the limit opens. A player who is not grounded may swing the camera under themselves, up to looking straight up at the body, as long as there is room: the camera is kept client.camera.pitch.air.marginMeters (0.3 m) clear of the ground, measured from the whole collision sweep (the sphere that starts client.camera.collision.startBack behind the pivot and ends that far past the camera), so a pitch the limit allows is one the ordinary wall collision leaves alone. The room is asked of the world with a down-trace from where the camera would stand, at pitches stepped upward from the grounded limit; the first pitch that fails, or that has no line of sight to the player, ends the range. It only applies when the feet are at least client.camera.pitch.air.minHeightMeters (1.5 m) off the ground, so an ordinary jump does not move it. The limit follows the room eased: client.camera.pitch.air.easeRate (4 per second) as it opens, client.camera.pitch.air.closeRate (12) as the ground rises toward the camera, so a fall onto the floor brings the view back to the horizon over a fraction of a second instead of snapping or driving the camera into the floor. client.camera.pitch.air.enabled (on) turns it off, and the grounded limit is then the only one. The body fade, the speed field of view, the wind, the occlusion outline and the cutaway read the distance or the camera position and not the pitch, so none of them changes; the cutaway’s capsule test is geometry and cuts what stands between a lens below the body and the body just as from above.

Noclip frees the camera. While you noclip, the camera runs none of its checks: the pitch range is the full first-person one (89 degrees either way at any distance), no clearance check is made for the ground, and no wall pulls the camera in, so it follows the view through geometry and does not jump to avoid a wall. The body fade, the occlusion outline and the cutaway still run, so the body stays findable behind what the camera is inside. When noclip ends, the constraints ease back in instead of snapping: the look-up limit returns at client.camera.noclip.exitRate (6 per second), and a wall the camera is standing in pulls it out at the same rate rather than cutting it to the wall’s distance. client.camera.noclip.free (on) turns the free camera off, and noclip is then an ordinary airborne player. The decision is made in ThirdPersonCamera.Update from the movement mode the predictor already reports, so every client shell gets it.

client.camera.zoomFov.enabled (on) narrows the field as the camera pulls back, along a curve that is flat from a few meters out, then closes to nearly orthographic at the far end. The curve is a weighted Hermite spline over the distance as a fraction of the ladder’s far end, ported from Sandboxes, and it multiplies your own vertical field of view, normalized so that at first person you see exactly the field you configured. At the far end it is about 4 percent of it. The speed field of view is added on top as it always was.

The speed widening of the field of view leaves as the camera pulls back. It is weighted by the inverse of the body’s own fade, so it is full in first person and gone once the body is fully drawn (client.camera.bodyFade.near to far), with no pop in between.

Past client.audio.zoomWind.startDistance (80 m) a looping wind fades in, reaching full gain at client.audio.zoomWind.fullDistance (260 m) at client.audio.zoomWind.maxGain (0.3). It is also scaled by how far you look down: silent looking level, full at client.audio.zoomWind.lookDownDegrees (10) below it, so the horizon stays quiet. The gain follows its target at client.audio.zoomWind.smoothing (1 per second). client.audio.zoomWind.enabled switches it off.

Each detent ticks, with the burst rule: a fast scroll is a burst of steps, and within client.audio.zoomTick.burstSeconds (0.1 s) of its first step only the first three make a sound, soft, soft, then a louder one, so a flick of the wheel ends on an accent. A step refused at either end of the ladder is silent. client.audio.zoomTick.enabled, softVolume, loudVolume, softPitchSpread and loudPitchSpread tune it.

When a wall covers part of your body from the camera, the covered part is outlined: a faint white ring, only where the body is hidden. If a low wall covers your legs only the legs get it. It is the editor’s own outline pass, not a second one: the body goes in through the same seam the editor’s hover uses, and the pass has a hidden-only mode (the ring is full where the silhouette’s surface is covered, nothing where it is seen), decided per pixel by the scene depth the pass already compares against.

The camera casts three probe rays at your head, chest and just above your knees; any one blocked is the trigger, and the ring then fades in over client.camera.occlusionOutline.fadeInSeconds (0.15 s) and out over fadeOutSeconds (0.35 s), frame-rate independent. It works at any distance past where your body is drawn, up to client.camera.occlusionOutline.maxDistance (1000 m). client.camera.occlusionOutline.color (RGBA, default white at alpha 64), width (the inner ring in interface pixels, 2) and enabled; it also needs client.editor.outline.enabled. While it is up the ring width of an interaction highlight in the same frame follows it.

client.camera.cutaway.enabled (default on) opens a soft hole through whatever stands between the camera and your body, so a wall or a roof does not hide you. The body is bounded by a vertical capsule from your feet to your eye height, bodyRadius (0.3, as a fraction of eye height) wide, and the hole is that capsule’s footprint on the screen: a fragment is removed only when the ray from the camera through it passes within the capsule’s radius times size (1.3), with an edge softness takes (0.5 of the radius), and the capsule lies beyond the fragment by depthMargin (0.3 m). A wall beside the body, above it or behind it is never touched, however near the camera it stands. The radius never falls below minRadius (0.05 of the screen’s height), so in the far zone, where the body is a few pixels, there is still a hole to see it through. Nothing at or below floorHeight (0.15 m) above your feet is removed, so the ground you stand on stays, and your own body is never cut: every draw of the local avatar carries a flag in the draw’s dither lane (DitherLaneBodyBias), so a tail or an arm nearer the camera than your chest is not dissolved by the hole made for it.

The hole is cut per pixel with the same interleaved gradient noise every other dissolve in the renderer uses: no blending and no second dither, one more progress joined to the draw’s own with a max. The capsule’s two ends and radius, the strength, the floor line, the soft edge and the margin travel in the frame block (cutawayLower, cutawayUpper, cutawayFloor). What the hole shows is whatever lies behind the cut surface, never a tint: the dissolve only discards fragments. The hole fades in and out over fadeSeconds (0.3 s) as the camera leaves and returns to the body, and only exists in third person. The occlusion outline still marks a hidden body with it on. The occlusion prepass does not see it, so ambient occlusion still counts the cut surfaces.

A camera turning on a long boom swings through hundreds of meters, which is the camera orbiting, not the world streaking past. The motion filter takes the change in the boom out of its previous camera, so only the pivot’s own travel and the turn of the view blur. In first person there is no boom and the filter is handed exactly what it always was.

Your own body stays sharp. The filter reads every pixel’s motion off its depth, as if it were static world, so a body that rides the camera in third person (nearly still on screen) would be smeared. Every opaque draw of the local avatar carries the body flag (the same dither-lane flag the cutaway reads), and the opaque pass writes a scene alpha of 0 for those pixels where the world writes 1. The temporal resolve passes that alpha through, and the gather scales each pixel’s velocity by it (motionWorldWeight), so the body gets no motion, its multisampled edge feathers by itself, and the world still blurs from the camera’s real translation. The mask is the body’s own silhouette, so there is no circle, no depth band and no case in which a wall hides the mask: a wall next to the body blurs like any other wall, and a tail or an ear stays sharp wherever it reaches. It holds in first person too, where the body rides the camera the same way.

A thin vertical track on the right edge of the screen shows where the camera is heading while you zoom. It is filled from the bottom (first person) to the top (the far end) on a log scale, has a tick at every detent and a handle at the target, and is drawn in the HUD band like the speedometer, so it shows in play and a pause scrim dims it with the world.

It appears at once on a zoom, holds for client.hud.zoomTrack.holdSeconds (0.23 s), then fades out over client.hud.zoomTrack.fadeSeconds (0.34 s). A step refused at either end flashes that end of the track. client.hud.zoomTrack.width, margin, heightFraction and logOffset lay it out in logical pixels, so it is the same size on a desktop and on a 2x display; client.hud.zoomTrack.enabled hides it.

FileWhere it comes fromLicense
camera/zoom-tick.wav, camera/zoom-tick-soft.wavThe project owner’s own recordingsThe project’s
camera/zoom-cross-in.wav, camera/zoom-cross-out.wavPlaceholders synthesized from noise with ffmpegNone; replace with recordings
camera/zoom-wind.mp3freesound.org 481145, “R22-19 burst of whistling wind” by craigsmithCC0

See Engine/content/core/sounds/PROVENANCE.md. The recordings were raised in level (the ticks by 22 dB, the wind by 17 dB) after the first release played them at a peak far below any footstep.

The barrier is a pause: after any scroll that moved the camera, the next one crosses only once client.camera.zoom.barrierPause (0.28 s) has passed. The camera tells you about it three ways.

  • Sound. Each notch the barrier holds back makes a quiet, low tick (the soft tick at client.audio.zoomBarrier.tiptoePitch 0.55 and tiptoeVolume 0.35), on the same three-per-burst rule as the zoom ticks with a burst of its own. Crossing, in either direction, plays a whoosh (crossVolume 0.5): the noise swells for the way in and is reversed for the way out.
  • The track. An amber bar marks the barrier, wider than a detent tick. While the camera stands on it the bar fills left to right as the pause runs, so a full bar means the next scroll crosses. A crossing pulses the bar outward for client.hud.zoomTrack.crossPulseSeconds (0.4 s). The stretch of track beyond the barrier, the isometric zone, is tinted blue at client.hud.zoomTrack.farTint (0.3).
  • A rubber band. A held scroll pushes the camera a fraction (client.camera.zoom.barrierNudge, 0.12) of the way to the next detent, which springs back at barrierNudgeRate (14 per second), so the wheel is never dead. Set the nudge to 0 for a dead stop. It moves the picture only: the barrier, the field of view and the label still follow the settled distance.

While a headset is live the effective distance is zero: the zoom and F5 are ignored, the track never shows, and the field of view is yours. The remembered distance comes back when the session ends.

DesktopiOS
Camera distance, detents, easing✅🟡 the picture path is shared, but no touch control zooms it yet, so it stays in first person
Wall pull-in✅🟡 as above
Zoom action, F5✅❌ no wheel or function key
Field of view, body fade✅🟡 shared code, not reachable until a control moves the distance
Pitch limits✅❌ not applied until a control moves the distance
Free noclip camera✅🟡 shared code, not reachable until a control moves the distance
Wind and ticks✅🟡 shared code, not reachable until a control moves the distance
Zoom track✅❌

dh --render <dir> --thirdPerson photographs the situations a playtest reported on the map you give it (written for gm_construct): a strafe, a fall past a wall, a flight forward and a wall beside the body, the moving ones with and without motion blur, plus a numbered frame run per case under clips/ to encode with ffmpeg. It also photographs a body in the air over a flat floor with the camera driven by the real ThirdPersonCamera against that floor (airHighUnder, airNearGround, airJustOff, airHeldOnFloor) and a landing clip (clips/airLanding), written for core:maps/flat-map.dh-map. On the gm_construct run it also drives the real camera against the map’s own collision (CameraPathWorld) along each path clip and writes the same path twice under clips/<name>/before and clips/<name>/after, with the distance’s jitter and the lens’s deepest penetration in metrics.txt: pathNoclip flies the body through the walls with the pitch swinging past the horizon, with and without the free noclip camera. The cutaway and the body flag come from the same Engine.Client functions the live host asks (BodyCutaway, PlayerPawnVisual).

dh --render <dir> --firstPerson also photographs the body from the third-person camera at 0, 6, 25, 40 and 275 m (thirdPerson_000m and the rest) and mid-jump at 6 m (thirdPerson_006m_jump), through the same view and field-of-view functions the live client uses. dh --render <dir> --renderUi dh --render <dir> --firstPerson also photographs a wall between camera and body with the cutaway off and on (cutawayFull_off, cutawayFull_on and the half-hidden pair). dh --render <dir> --renderUi includes the zoom track held, mid-fade, flashing at the far limit and held at 2x scale, and the barrier charging, charged and just crossed at both scales.

The policy is in Engine.Client and Engine.Client.Frame, reachable with no host reference: ThirdPersonCamera (the distance, ladder and easing), CameraCollision, ZoomFovCurve, ThirdPersonView (where the camera stands), ZoomTickBurst, ZoomBinding, CameraAudio (wind and ticks) and ZoomTrackOverlay. ClientFrame runs the camera every frame for every host, and ClientFrame.EffectiveFov is the one seam the field of view is read through. The desktop host only feeds it the wheel and F5.