# Unity 6 Surface-Walking Tutorial: Omnidirectional Player Movement Welcome to this comprehensive tutorial on building a physics-based surface-walking system in Unity 6! Inspired by games like Dead Space or Super Mario Galaxy, this setup lets your player walk seamlessly on floors, walls, ceilings, and any collider surface. We'll use Rigidbody for natural physics, raycast/collision detection for grounding, custom gravity, and Cinemachine for a dynamic following camera that stays level relative to the surface. This guide assumes basic Unity familiarity (e.g., creating GOs, adding components). We'll start from a blank Unity 6 project and build your exact scene: a 10x10x10 cube room with floor/walls/ceiling grouped under one node, plus a Cinemachine FreeLook camera. **Estimated Time**: 30-45 minutes. **Unity Version**: 6.0.0+ (tested on 6000.0.27f1). **Packages Needed**: Cinemachine (free). **Controls (Final)**: WASD (move/turn/strafe), Space (jump), Left Shift (strafe modifier), Mouse (FreeLook orbit). --- ## Step 1: Project Setup 1. **Create New Project:** - Open Unity Hub > New Project > 3D (Core) template > Name it "GravityWalker" > Create. - Unity 6 ships with PhysX 5.1+ for stable high-speed collisions—perfect for this. 2. **Install Cinemachine:** - Window > Package Manager. - Switch to "Unity Registry" tab > Search "Cinemachine" > Install v3.1.5+ (latest as of Nov 2025). - This adds camera tools—no code needed yet. 3. **Project Settings Tweaks:** - Edit > Project Settings > Physics: - Default Contact Offset: 0.001 (tighter grounding). - Default Solver Iterations: 8 (stable on edges). - Edit > Project Settings > Time > Fixed Timestep: 0.01 (smoother physics ticks). - Edit > Project Settings > Input Manager: Ensure "Fire3" axis is default (Left Shift)—we'll use it for strafing. --- ## Step 2: Scene SetupWe'll build your 10x10x10 cube room (centered at origin for simplicity). 1. **Create Room Group:** - Hierarchy > Right-click > Create Empty > Name "Room". - Position: (0, 5, 0) (centers the room at Y=5, floor at Y=0). 2. **Add Surfaces (Floor, Walls, Ceiling):** - For each: Right-click Room > 3D Object > Cube. - Floor: Name "Floor" > Scale (10, 0.2, 10) > Position (0, 0, 0) > Layer: New Layer "Ground" (Layer 6). - Back Wall: Name "BackWall" > Scale (10, 10, 0.2) > Position (0, 5, 5). - Front Wall: Name "FrontWall" > Scale (10, 10, 0.2) > Position (0, 5, -5). - Left Wall: Name "LeftWall" > Scale (0.2, 10, 10) > Position (-5, 5, 0). - Right Wall: Name "RightWall" > Scale (0.2, 10, 10) > Position (5, 5, 0). - Ceiling: Name "Ceiling" > Scale (10, 0.2, 10) > Position (0, 10, 0). - All: Add BoxCollider (auto-added) > Ensure Layer = Ground. 3. **Player Setup:** - Hierarchy > Right-click > 3D Object > Capsule > Name "Player". - Position: (0, 1.5, 0) (above floor by half-height). - Scale: (1, 1, 1). - Add Rigidbody: Mass=1, Drag=5, Angular Drag=5, FreezeRotation X/Z (constraints). - CapsuleCollider: Radius=0.5, Height=2, Center=(0,1,0). - Layer: Default (not Ground, to avoid self-hits). 4. **Lighting & Main Camera (Temp):** - We will use the Main Camera with Cinemachine (via Cinemachine Brain). - Window > Rendering > Lighting > Environment > Skybox Material: Default. - Add Directional Light if needed (auto-added). Save the scene as "GravityRoom.unity". --- ## Step 3: Cinemachine FreeLook Camera 1. **Create FreeLook:** - Cinemachine > Create FreeLook Camera. - Name "PlayerFollow" (auto-parented). - Cinemachine automatically added a Cinemachine Brain component to the Main Camera. 2. **Configure:** - Select PlayerFollow > CinemachineFreeLook component: - Follow: Drag Player. - Look At: Drag Player. - Top Rig: Height=2.5, Radius=4. - Middle Rig: Height=1.5, Radius=3. - Bottom Rig: Height=0.5, Radius=2. - Recentering: Enabled, Speed=1. - Orbits: Mouse Orbit (default). This gives mouse-orbit follow—tweak radii for closeness. --- ## Step 4: Scripts **Create folder Assets/Scripts.** Add two scripts: SurfaceWalker (core movement) and CameraAdapter (surface-leveling camera). ### 4.1: SurfaceWalker.cs This handles detection (collisions + fallback ray), gravity, orientation, movement (turn/strafe via Fire3), jump. Paste into new C# Script > SurfaceWalker.cs > Attach to Player. csharp ```csharp using UnityEngine; using UnityEngine.InputSystem; // Optional; using legacy Input here public class SurfaceWalker : MonoBehaviour { [Header("Detection")] public float detectionDistance = 2.5f; // Fallback ray length public LayerMask groundMask = 1 << 6; // Ground layer (e.g., Layer 6) public float orientationSpeed = 12f; // Lerp speed for normal/rotation public float groundedTimeout = 0.1f; // Max air time before fallback public float gravityStrength = 30f; // Pull strength [Header("Movement")] public float walkSpeed = 5f; public float jumpForce = 10f; public float climbBoost = 2f; // Extra push on steep surfaces public float turnSpeed = 180f; // Degrees/sec for turning public bool useStrafing = false; // Always strafe override public Transform cameraTransform; // Assign Main Camera if no Cinemachine [Header("Debug Viz")] public bool showGizmos = true; private Rigidbody rb; private Vector3 surfaceNormal = Vector3.up; private Vector3 gravityDir = Vector3.down; public bool isGrounded; // Inspector-visible // Collision tracking private Vector3[] normalBuffer = new Vector3[3]; // Average over 3 frames private int bufferIndex = 0; private float lastContactTime; private Vector3 avgContactNormal = Vector3.up; // Fallback ray caches private Vector3 debugRayOrigin; private Vector3 debugRayDir; private float debugCastDist; // Expose for CameraAdapter public Vector3 SurfaceNormal => surfaceNormal; void Start() { rb = GetComponent<Rigidbody>(); if (rb == null) { Debug.LogError("No Rigidbody!"); return; } rb.useGravity = false; rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ; rb.drag = 5f; // Anti-slip rb.angularDrag = 5f; } void FixedUpdate() { UpdateGrounded(); DetectSurface(); ApplyGravity(); if (isGrounded) { OrientToSurface(); HandleMovement(); // Sync camera adapter GetComponent<CameraAdapter>()?.UpdateSurfaceUp(surfaceNormal); } } void UpdateGrounded() { isGrounded = (Time.time - lastContactTime) < groundedTimeout; } void DetectSurface() { // Buffer-averaged contacts Vector3 bufferedNormal = Vector3.zero; int validBuffers = 0; for (int i = 0; i < normalBuffer.Length; i++) { if (normalBuffer[i] != Vector3.zero) { bufferedNormal += normalBuffer[i]; validBuffers++; } } if (validBuffers > 0) { avgContactNormal = bufferedNormal / validBuffers; surfaceNormal = Vector3.Lerp(surfaceNormal, avgContactNormal, Time.fixedDeltaTime * orientationSpeed); gravityDir = -surfaceNormal; // Ceiling check if (Vector3.Dot(surfaceNormal, Vector3.up) < -0.5f) { Debug.Log("Ceiling Mode! Inverted up: " + surfaceNormal); } return; // Prioritize contacts } // Fallback ray (bias forward for climbs) float skinWidth = 0.02f; Vector3 forwardBias = rb.linearVelocity.normalized * 0.5f; // Lean into motion Vector3 rayOrigin = transform.position + surfaceNormal * skinWidth + forwardBias; Vector3 rayDir = -surfaceNormal; float castDist = detectionDistance; debugRayOrigin = rayOrigin; debugRayDir = rayDir; debugCastDist = castDist; if (Physics.Raycast(rayOrigin, rayDir, out RaycastHit hit, castDist, groundMask)) { surfaceNormal = Vector3.Lerp(surfaceNormal, hit.normal, Time.fixedDeltaTime * orientationSpeed); gravityDir = -surfaceNormal; } else { surfaceNormal = Vector3.Lerp(surfaceNormal, Vector3.up, Time.fixedDeltaTime * orientationSpeed * 0.5f); gravityDir = Vector3.down; } } void ApplyGravity() { rb.AddForce(gravityDir * gravityStrength * rb.mass, ForceMode.Acceleration); } void OrientToSurface() { // Up-only alignment: Preserve forward, just tilt to surface Quaternion upRotation = Quaternion.FromToRotation(transform.up, surfaceNormal); transform.rotation = Quaternion.Slerp(transform.rotation, upRotation * transform.rotation, Time.fixedDeltaTime * orientationSpeed); } void HandleMovement() { // Use Raw for crisp on/off detection (less smoothing) float horizontalRaw = Input.GetAxisRaw("Horizontal"); float verticalRaw = Input.GetAxis("Vertical"); // Keep smoothed for forward Vector2 input = new Vector2(horizontalRaw, verticalRaw); if (input.magnitude > 0.1f) { bool modifierPressed = Input.GetAxis("Fire3") > 0.1f; Debug.Log(quot;Input: {input} | Up: {surfaceNormal} | Grounded: {isGrounded} | Mode: {(useStrafing ? "Always Strafe" : (modifierPressed ? "Fire3 Strafe" : "Turn"))}"); Vector3 playerForward = Vector3.ProjectOnPlane(transform.forward, surfaceNormal).normalized; Vector3 moveDir = playerForward * input.y; // Base: Forward/back // Determine mode: Always strafe OR Fire3 pressed bool strafingThisFrame = useStrafing || modifierPressed; // Horizontal input: Strafe or Turn if (Mathf.Abs(horizontalRaw) > 0.1f) { if (strafingThisFrame) { // Strafe: Move sideways relative to player forward (no rotation) Vector3 playerRight = Vector3.Cross(surfaceNormal, playerForward).normalized; moveDir += playerRight * horizontalRaw; // Add left/right Debug.Log(quot;Strafe | Right Dir: {playerRight}"); // Clamp angular velocity during strafe (prevent drift/circling) Vector3 angularNormal = Vector3.Project(rb.angularVelocity, surfaceNormal); rb.angularVelocity = angularNormal * 0.9f; // Damp tangential spin } else { // Turn: Yaw around surface normal float yaw = horizontalRaw * turnSpeed * Time.fixedDeltaTime; transform.RotateAround(transform.position, surfaceNormal, yaw); Debug.Log(quot;Yaw Turn: {yaw:F2}° around {surfaceNormal}"); } } // Normalize if combined (forward + strafe) if (moveDir.magnitude > 1f) moveDir = moveDir.normalized; // Apply velocity Vector3 currentVel = rb.linearVelocity; Vector3 normalComp = Vector3.Project(currentVel, surfaceNormal); Vector3 tangentialComp = currentVel - normalComp; Vector3 desiredTangential = moveDir * walkSpeed; Vector3 newTangential = Vector3.Lerp(tangentialComp, desiredTangential, Time.fixedDeltaTime * orientationSpeed); rb.linearVelocity = normalComp + newTangential; // Climb boost on steep surfaces float tiltAngle = Vector3.Angle(surfaceNormal, Vector3.up); if (tiltAngle > 60f) { rb.AddForce(moveDir * climbBoost * rb.mass, ForceMode.Acceleration); Debug.Log(quot;Climb Boost! Tilt: {tiltAngle:F1}° | Extra: {climbBoost}"); } Debug.Log(quot;Vel: {rb.linearVelocity} | MoveDir: {moveDir}"); } else if (isGrounded) { // Damp tangential for stop Vector3 currentVel = rb.linearVelocity; Vector3 normalComp = Vector3.Project(currentVel, surfaceNormal); Vector3 tangentialComp = currentVel - normalComp; rb.linearVelocity = normalComp + tangentialComp * 0.9f; // Explicit angular stop (damp rotation if no horizontal input) rb.angularVelocity = Vector3.Project(rb.angularVelocity, surfaceNormal) * 0.5f; } if (Input.GetKeyDown(KeyCode.Space) && isGrounded) { rb.AddForce(surfaceNormal * jumpForce, ForceMode.Impulse); Debug.Log(quot;Jump along: {surfaceNormal}"); } } void OnCollisionStay(Collision collision) { if (((1 << collision.gameObject.layer) & groundMask) == 0) return; lastContactTime = Time.time; Vector3 totalNormal = Vector3.zero; int contactCount = collision.contactCount; for (int i = 0; i < contactCount; i++) { totalNormal += collision.GetContact(i).normal; } Vector3 frameNormal = totalNormal.normalized; // Buffer for smoothing normalBuffer[bufferIndex] = frameNormal; bufferIndex = (bufferIndex + 1) % normalBuffer.Length; Debug.Log(quot;Contacts: {contactCount} | Frame Normal: {frameNormal} | Collider: {collision.gameObject.name}"); } void OnDrawGizmos() { if (!Application.isPlaying || !showGizmos) return; Gizmos.color = isGrounded ? Color.green : Color.blue; Gizmos.DrawRay(debugRayOrigin, debugRayDir * debugCastDist); Gizmos.color = Color.yellow; Gizmos.DrawLine(transform.position, transform.position + surfaceNormal); if (avgContactNormal != Vector3.zero) { Gizmos.color = Color.red; Gizmos.DrawLine(transform.position, transform.position + avgContactNormal); } } } ``` ### 4.2: CameraAdapter.cs Paste into new C# Script > CameraAdapter.cs > Attach to Player. Drag your FreeLook VCam to the vcam field in Inspector. csharp ```csharp using UnityEngine; #if CINEMACHINE using Cinemachine; #endif public class CameraAdapter : MonoBehaviour { public Vector3 surfaceUp { get; private set; } = Vector3.up; [Header("Cinemachine Setup (Optional)")] #if CINEMACHINE public CinemachineVirtualCamera vcam; // Assign your FreeLook's main VCam #endif private SurfaceWalker walker; void Start() { walker = GetComponent<SurfaceWalker>(); if (walker == null) { Debug.LogWarning("SurfaceWalker missing—CameraAdapter won't sync normals!"); } #if CINEMACHINE if (vcam == null) { Debug.Log("Drag CinemachineVirtualCamera to 'vcam' field for auto-leveling."); } #else Debug.Log("Cinemachine not detected—camera leveling skipped."); #endif } void Update() { if (walker != null) { surfaceUp = walker.SurfaceNormal; // Log for debug (remove later) if (surfaceUp != Vector3.up) Debug.Log(quot;Surface Up Synced: {surfaceUp}"); #if CINEMACHINE if (vcam != null) { vcam.transform.up = surfaceUp; // Level camera to surface } #endif } } public void UpdateSurfaceUp(Vector3 newUp) { surfaceUp = newUp; #if CINEMACHINE if (vcam != null) { vcam.transform.up = newUp; } #endif } } ``` --- ## Step 5: Final Configuration & Layers 1. **Layers**: Edit > Project Settings > Tags and Layers > Create "Ground" (Layer 6). Assign to all room surfaces. 2. **Player Inspector:** - SurfaceWalker: Ground Mask = Layer 6 only; Camera Transform = Main Camera (optional if Cinemachine). - Rigidbody: Collision Detection = Continuous Dynamic (anti-tunneling). 3. **FreeLook Inspector**: Ensure Follow/Look At = Player. For strafe/turn, mouse orbits work with new controls. --- ## Step 6: Testing & Tuning 1. **Play Scene: Hit Play** - Floor: WASD walks/turns. Hold Left Shift + A/D strafes left/right. - Wall: Hold W toward wall—player tilts, climbs. Strafe/turn relative to surface. - Ceiling: Crest wall top—flips, walks "upside-down." Camera levels (no nausea). - Jump: Space off surface—fallback ray catches re-landing. - Gizmos: Scene view > Blue/Green ray = detection; Yellow = up normal. 2. **Common Issues & Fixes:** |Issue|Fix| |---|---| |No Grounding|Bump detectionDistance=3f; Check layer mask (1<<6).| |Slips on Edges|Increase gravityStrength=40f; Rigidbody Drag=10f.| |Camera Flips|Ensure CameraAdapter attached + vcam assigned.| |Spin on Strafe|Angular damping already in—tune to 0f for hard stop.| |No Transition|Spawn closer to wall; Continuous Dynamic on Rigidbody.| 3. **Tuning Table:** |Param|Default|Tune For...| |---|---|---| |walkSpeed|5f|Faster runs (8f) or precise (3f).| |turnSpeed|180f|Snappier turns (300f).| |orientationSpeed|12f|Smoother tilts (8f) or quick (20f).| |climbBoost|2f|Steeper walls (3f); disable=0f.| --- If you want to upgrade to the new Input System, go to this page: [[Unity New Input System Upgrade Tutorial]]