
인게임 물리 버그 트러블슈팅: Unity 지형 맵 뚫림 예방과 검증 로직
플레이어·투사체가 Terrain과 Mesh Collider를 뚫는 원인을 FixedUpdate, 충돌 감지 모드, 콜라이더 상태, 레이어 설정 관점에서 분석하고 사전 예방과 런타임 복구 검증 로직을 Unity C# 예제로 정리합니다.
핵심 요약
지형 맵 뚫림은 대개 프레임마다 이동 거리가 콜라이더 두께 또는 충돌 검사 간격보다 커져 발생하는 터널링(tunneling) 문제다.
Unity에서는 Rigidbody.collisionDetectionMode를 ContinuousDynamic 또는 ContinuousSpeculative로 조정하고 물리 이동을 FixedUpdate와 Rigidbody.MovePosition으로 통일해야 한다.
마지막 방어선으로 지형 높이와 아래쪽 Raycast를 함께 검사해 안전한 마지막 위치로 복구하면 플레이 중 낙하를 막을 수 있다.
지형 맵 뚫림은 왜 발생하는가?
Unity 물리 엔진은 렌더링 프레임마다 충돌을 계산하지 않는다. 기본적으로 Time.fixedDeltaTime 간격의 물리 스텝에서 Rigidbody와 Collider의 겹침 또는 이동 경로를 검사한다. 캐릭터나 투사체가 한 번의 물리 스텝에서 지형 반대편까지 이동하면 두 위치 모두 충돌하지 않는 것으로 판단되어 지형을 통과할 수 있다.
이 현상은 다음 이동 거리와 충돌 가능한 유효 두께의 관계에서 특히 잘 드러난다.
- : 한 물리 스텝에서 이동한 거리
- : Rigidbody 속도
- :
Time.fixedDeltaTime
예를 들어 속도가 60 m/s이고 fixedDeltaTime이 0.02초라면 한 스텝 이동 거리는 1.2 m다. 얇은 Mesh Collider, 경사진 Terrain 경계, 작은 장애물은 이 거리 안에서 쉽게 건너뛸 수 있다.
| 원인 | 증상 | 우선 조치 |
|---|---|---|
transform.position 직접 변경 | Rigidbody 충돌이 불규칙하거나 누락됨 | MovePosition 또는 velocity 사용 |
| Discrete 충돌 감지 | 고속 객체가 얇은 지형을 통과 | Continuous 계열 모드 적용 |
| 렌더링 프레임에서 이동 처리 | 프레임률에 따라 이동·충돌 결과가 달라짐 | FixedUpdate로 이동 통일 |
| Terrain 또는 Mesh Collider의 틈 | 특정 경계와 경사에서만 빠짐 | 콜라이더 메시·타일 경계 검사 |
| 레이어 충돌 비활성화 | 특정 객체만 지형을 통과 | Physics Layer Collision Matrix 확인 |

지형 뚫림을 예방하려면 무엇을 먼저 설정해야 할까?
1. 이동 책임을 하나로 통일한다
Rigidbody가 붙은 객체는 transform.position이나 transform.Translate로 이동시키지 않는다. 물리 기반 이동이라면 FixedUpdate에서 속도를 설정하거나 Rigidbody.MovePosition을 사용한다.
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public sealed class PlayerMotor : MonoBehaviour
{
[SerializeField] private float moveSpeed = 6f;
private Rigidbody body;
private Vector3 input;
private void Awake()
{
body = GetComponent<Rigidbody>();
body.interpolation = RigidbodyInterpolation.Interpolate;
body.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
body.constraints = RigidbodyConstraints.FreezeRotation;
}
private void Update()
{
input = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
input = Vector3.ClampMagnitude(input, 1f);
}
private void FixedUpdate()
{
Vector3 displacement = input * moveSpeed * Time.fixedDeltaTime;
body.MovePosition(body.position + displacement);
}
}
Update는 입력 수집에 사용하고 실제 물리 이동은 FixedUpdate에 둔다. RigidbodyInterpolation.Interpolate는 충돌 정확도를 높이는 기능이 아니라 물리 스텝 사이의 렌더링 흔들림을 줄이는 옵션이다.
2. 객체 성격에 맞는 Collision Detection Mode를 선택한다
충돌 감지 모드는 모든 객체에 최고 비용 옵션을 적용하는 방식으로 사용하면 안 된다. 빠르게 움직이는 객체와 정적인 지형의 조합에만 제한적으로 적용한다.
| 객체 유형 | 권장 모드 | 적용 이유 |
|---|---|---|
| 일반 플레이어 Rigidbody | ContinuousDynamic | Terrain·벽 등 정적 Collider와의 고속 충돌 안정화 |
| 고속 투사체 | ContinuousDynamic | 이동 경로 기반 검사를 통해 통과 위험 감소 |
| 키네마틱 이동 플랫폼 | ContinuousSpeculative | 예측 기반 접촉으로 키네마틱 객체의 누락 완화 |
| 멀리 있는 장식 오브젝트 | Discrete | 불필요한 물리 비용 방지 |
ContinuousDynamic은 비용이 증가하므로 수백 개의 투사체에 무차별 적용하기보다 투사체별 Cast 검사와 오브젝트 풀링을 함께 고려하는 편이 낫다.
3. 레이어와 콜라이더 구조를 검사한다
Project Settings > Physics의 Layer Collision Matrix에서 플레이어 또는 투사체 레이어와 지형 레이어가 충돌하도록 설정한다. Terrain에는 Terrain Collider가 활성화되어 있어야 하며 메시 지형은 눈에 보이는 렌더 메시와 실제 Mesh Collider가 같은 위치와 스케일을 사용하는지도 확인한다.
특히 Mesh Collider를 런타임에 수정한 뒤에는 sharedMesh 갱신 시점과 콜라이더 재생성 비용을 검토해야 한다. 시각적 메시만 갱신하고 콜라이더 메시를 갱신하지 않으면 플레이어는 보이지 않는 예전 지형과 충돌하거나 새 지형을 통과한다.
런타임 지형 검증 로직은 어떻게 구현할까?
충돌 감지는 예방책이지만 네트워크 보정, 씬 스트리밍 경계, 텔레포트, 외부 플러그인 이동처럼 물리 규칙을 우회하는 상황까지 완전히 막지는 못한다. 따라서 캐릭터에는 “유효한 지면 위에 있는가”를 확인하는 복구 로직을 둔다.
검증 순서는 다음과 같다.
- 매 물리 스텝에서 마지막으로 검증된 안전 위치를 저장한다.
- 현재 위치 아래로 Raycast를 쏘아 지형 또는 지면 레이어가 닿는지 확인한다.
- Terrain이 있는 경우
Terrain.SampleHeight로 허용 가능한 최소 높이를 추가 검사한다. - 허용 범위 아래로 떨어지거나 지면을 찾지 못한 시간이 임계값을 넘으면 안전 위치로 되돌린다.
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public sealed class GroundRecoveryValidator : MonoBehaviour
{
[SerializeField] private LayerMask groundMask;
[SerializeField] private Terrain terrain;
[SerializeField] private float rayStartOffset = 0.5f;
[SerializeField] private float groundCheckDistance = 3f;
[SerializeField] private float terrainTolerance = 1.5f;
[SerializeField] private float invalidGraceSeconds = 0.25f;
private Rigidbody body;
private Vector3 lastSafePosition;
private float invalidTime;
private void Awake()
{
body = GetComponent<Rigidbody>();
lastSafePosition = body.position;
}
private void FixedUpdate()
{
if (IsAboveValidGround(body.position))
{
lastSafePosition = body.position;
invalidTime = 0f;
return;
}
invalidTime += Time.fixedDeltaTime;
if (invalidTime >= invalidGraceSeconds)
{
RecoverToLastSafePosition();
}
}
private bool IsAboveValidGround(Vector3 position)
{
Vector3 origin = position + Vector3.up * rayStartOffset;
if (Physics.Raycast(origin, Vector3.down, groundCheckDistance, groundMask,
QueryTriggerInteraction.Ignore))
{
return true;
}
if (terrain == null)
{
return false;
}
Vector3 local = position - terrain.transform.position;
Vector3 size = terrain.terrainData.size;
bool insideTerrain = local.x >= 0f && local.x <= size.x &&
local.z >= 0f && local.z <= size.z;
if (!insideTerrain)
{
return false;
}
float terrainY = terrain.SampleHeight(position) + terrain.transform.position.y;
return position.y >= terrainY - terrainTolerance;
}
private void RecoverToLastSafePosition()
{
body.position = lastSafePosition;
body.linearVelocity = Vector3.zero;
body.angularVelocity = Vector3.zero;
invalidTime = 0f;
}
}
이 코드는 플레이어 발밑에 지면이 없는 즉시 되돌리지 않고 invalidGraceSeconds만큼 유예한다. 점프, 낙하, 경사면 이동처럼 정상적인 공중 상태를 지형 뚫림으로 오인하는 일을 줄이기 위해서다. 실제 프로젝트에서는 캐릭터 상태 머신의 Grounded, Jumping, Falling, Teleporting 상태와 함께 조건을 세분화하는 것이 좋다.

Cast 기반 사전 차단이 필요한 경우
매우 빠른 투사체나 대시 이동은 사후 복구보다 이동 전에 충돌 구간을 Cast로 검사하는 편이 더 정확하다. 투사체의 반지름을 반영하려면 Raycast 대신 SphereCast를 사용한다.
Vector3 movement = velocity * Time.fixedDeltaTime;
float distance = movement.magnitude;
if (Physics.SphereCast(transform.position, 0.1f, movement.normalized,
out RaycastHit hit, distance, groundMask, QueryTriggerInteraction.Ignore))
{
transform.position = hit.point - movement.normalized * 0.02f;
HandleImpact(hit.collider);
}
else
{
transform.position += movement;
}
이 방식은 “이번 스텝의 시작점과 끝점 사이에 무엇이 있었는가”를 명시적으로 검사한다. 단, Rigidbody 충돌 처리와 별도로 위치를 바꾸므로 투사체처럼 단순한 이동 모델에 적합하다. 플레이어 캐릭터에는 CharacterController.Move, Rigidbody 기반 이동 또는 엔진의 캐릭터 모터 중 하나를 일관되게 선택한다.
지형 뚫림 버그를 검증하는 테스트 절차
재현이 어려운 물리 버그는 정상 플레이만으로 검증하면 놓치기 쉽다. 다음 조건을 자동 또는 반자동 테스트 시나리오로 고정한다.
- 속도 경계 테스트: 걷기, 달리기, 대시, 넉백의 최대 속도에서 경사·절벽·얇은 메시를 통과하는지 확인한다.
- 프레임률 변동 테스트: 30 FPS, 60 FPS, 120 FPS와 부하 상황에서 동일한 이동 결과가 나오는지 검사한다.
- 고정 시간 간격 테스트:
Time.fixedDeltaTime을 0.02와 0.01로 바꿔 누락 빈도와 CPU 비용을 비교한다. - 경계 테스트: Terrain 타일 연결부, 씬 스트리밍 전환점, 오브젝트가 생성·삭제되는 영역을 반복 통과한다.
- 복구 테스트: 강제로 지형 아래 좌표에 배치한 뒤 안전 위치 복구, 속도 초기화, 카메라 추종 보정이 모두 수행되는지 확인한다.
디버그 빌드에서는 Debug.DrawRay 또는 Gizmos로 Raycast 시작점, 충돌점, lastSafePosition을 표시하면 원인을 빠르게 좁힐 수 있다. 또한 복구 횟수와 발생 좌표를 분석 이벤트로 기록하면 특정 맵 구간이나 디바이스 성능 조건에 버그가 집중되는지 확인할 수 있다.
자주 묻는 질문 (FAQ)
ContinuousDynamic만 설정하면 지형 뚫림이 사라지나요?
아니다. 직접 Transform을 변경하거나 콜라이더 레이어가 충돌하지 않으면 해결되지 않는다. 고속 이동, 메시 틈, 텔레포트에는 Cast 검사나 안전 위치 복구가 추가로 필요하다.
fixedDeltaTime을 0.01로 줄이면 항상 좋은가요?
충돌 누락 가능성은 낮아지지만 물리 계산 횟수가 늘어 CPU 비용도 증가한다. 우선 이동 방식과 Collision Detection Mode를 바로잡고 프로파일링 결과를 근거로 조정한다.
Terrain 검사와 Raycast 검사를 둘 다 해야 하나요?
Terrain만 사용하는 단일 맵이라면 SampleHeight만으로도 높이 기준 검사는 가능하다. 그러나 플랫폼, 건물 바닥, 메시 지형까지 포함한다면 레이어 기반 Raycast를 기본으로 두고 Terrain 검사를 보조 수단으로 사용한다.
정리
지형 맵 뚫림을 안정적으로 막으려면 물리 이동을 FixedUpdate로 통일하고 고속 Rigidbody에 적절한 연속 충돌 감지를 적용해야 한다.
여기에 지면 Raycast, Terrain 높이 검사, 마지막 안전 위치 복구를 계층적으로 더하면 맵 데이터 오류나 예외적인 이동에도 플레이어 경험을 지킬 수 있다.


