The simulation runs at 30 ticks per second. The screen renders at whatever the player's display supports. Between those two cadences sit all the per-frame visual updates, and a camera that needs to follow a player without juddering or showing the void beyond the room geometry.
Two positions per entity
Every moving entity uses a SimulationRenderer. At the end of each simulation tick, RecordPosition(tick) stores the entity's position from the previous tick as _previousPosition and the just-calculated position as _nextPosition. Every render frame, UpdateRender(progress) lerps the entity's visual root between the two.
The progress value is computed from where in the current tick interval the render frame falls. At 30 tps, each tick spans 33ms. A render frame arriving 20ms into that window has a progress of roughly 0.6 — the visual root sits 60% of the way from the previous to the next simulation position.
The visual root is what the renderer, and the camera, follow. The simulation transform is what logic and physics read. For most purposes they refer to the same component, but for anything that renders, the distinction matters: only the visual root is interpolated. The camera must follow it, not the simulation position, or movement appears to jump in 30 tps increments.
Teleporting gets explicit handling. Before a portal transition or elevator arrival, SetAsTeleported() is called. On the next RecordPosition, both positions are set to the same current value. The lerp collapses to a point, preventing the visual root from sliding across the teleport distance in slow motion.
The ordering bug
The camera and entity interpolation both run every render frame. The camera reads the visual root position to know where to go. When the camera was updating first, it read last frame's interpolated positions: entities had not yet been moved forward to the current progress alpha. The camera was one frame behind, and that lag shows most clearly when the player stops — the camera drifts slightly past the player's final position before settling back.
The fix is sequencing. In GameCamera.WarmanUpdate, all visual positions are pushed to the current progress alpha before the camera reads any of them:
// Update interpolated visual positions BEFORE the camera reads them.
// Previously UpdateCamera ran first, reading the visual root at last
// frame's interpolated position, causing one-frame-behind jitter.
if (ObservedRoom.Data != null)
ObservedRoom.Data.WarmanUpdate(time, deltaTime, progress);
UpdateCamera(deltaTime);
The comment is load-bearing. Order dependencies like this are easy to break silently — someone adds a new update call, puts it before or after the wrong thing, and the jitter comes back. The comment explains the rule so it survives the next person to touch the function.
Why clamping the pivot is not enough
Rooms have explicit geometry bounds. Without any camera restriction, the player moving near a room edge would see the void beyond the terrain mesh. The instinct is to clamp the camera pivot position to the room bounds rectangle before applying it to the camera transform. That does not work.
The camera pivot is not the screen edge. The camera sits 30 units above the player and 30 units back, looking down at roughly 45 degrees. When the pivot is exactly at the room boundary, the frustum extends significantly beyond it. The screen does not begin at the pivot — it begins at the camera's near plane and ends at the far plane. In a small room or corridor, a perfectly clamped pivot still shows space outside the geometry through the screen edges.
The correct thing to clamp is not the pivot but the four points where the screen edges contact the world.
CalculateFrustumCorners and the edge planes
Unity's Camera.CalculateFrustumCorners() returns the four corners of the view frustum at a specified depth, in camera-local space. Converting each corner to a world-space ray from the camera gives four directions that define the screen's left, right, top, and bottom edges. Each room pre-computes a CameraEdgePlanes[] array: one horizontal plane per frustum corner, positioned at the room's terrain height. Raycasting each corner ray against its corresponding plane returns the world-space point where that screen edge lands on the ground.
Those four ground-contact points are checked against the room's axis-aligned bounding box. If any point exceeds the bounds on its axis, the desired camera position shifts by the overflow. All four corrections accumulate before the camera transform moves, so corners where two boundaries are simultaneously out of range are handled correctly in one pass.
cam.CalculateFrustumCorners(new Rect(0, 0, 1, 1), cam.farClipPlane,
Camera.MonoOrStereoscopicEye.Mono, frustumCorners);
for (var i = 0; i < 4; i++)
{
var worldSpaceCorner = cameraTransform.TransformVector(frustumCorners[i]);
var ray = new Ray(cameraTransform.position, worldSpaceCorner);
if (room.CameraEdgePlanes[i].Raycast(ray, out var hit))
{
var targetPoint = ray.GetPoint(hit);
// accumulate overflow into desiredCameraPosition
}
}
transform.position = desiredCameraPosition;
The 45-degree rotation makes the mapping fixed
The camera root has a fixed 45-degree Y rotation, applied on room entry. At this angle, the screen's horizontal and vertical axes do not align with the world's X and Z axes. The frustum corners travel along diagonals in world space. The bottom-left screen corner moves in the world's negative-X, negative-Z direction. The top-right screen corner moves in positive-X, positive-Z. And so on for each corner.
The result is a fixed corner-to-axis mapping: bottom-left checks the far Z boundary, top-left checks the far X boundary, top-right checks the near Z boundary, bottom-right checks the near X boundary. This mapping is not computed from the rotation angle at runtime. It is four named constants with corresponding bounds checks. The 45-degree rotation makes the mapping consistent and predictable enough to hard-code.
Rooms opt out of clamping
Large outdoor areas, the overworld, and rooms where the camera should follow freely skip the frustum calculation entirely. Each room file has a ClampToRoomBounds() flag. When false, the camera follows the player visual root directly with no further work:
var clampToRoomBounds = room.Proxy.RoomFile.ClampToRoomBounds();
if (clampToRoomBounds)
{
// frustum corner calculation and clamping
}
else
{
transform.position = cameraTarget.position;
}
The frustum math only runs for rooms that need it. Rooms that do not need it do not pay for it.
Height variation and the far clip plane
The camera system has no concept of room height. Rooms can have significant vertical variation: raised platforms, elevator shafts, cliff drops within a single room boundary. Unity renders geometry only up to the camera's far clip plane. When the default value was too conservative, tall geometry disappeared at the top of the frame as the camera moved through rooms with large elevation changes.
The fix tied the far clip plane to the biome. Each BiomeData asset has a FarClipMaxDistance property. On room entry, ApplyBiomeMood sets it:
mainCamera.farClipPlane = biomeData.FarClipMaxDistance;
Biomes with large vertical ranges get a higher value. Flat biomes keep a tighter value that avoids unnecessary overdraw at depth. The camera did not need to know about room height directly — the biome system already encodes what each environment requires, and the fix went there rather than adding height-awareness to the camera.