3D 퍼즐 플랫폼 게임이라는 주제로 팀 프로젝트를 진행하게 되었고, 팀원들과 상의 끝에 대표적인 사례인 Valve의 Portal을 구현해보기로 결정했다.
나는 그중 포탈 기능을 맡게 되었는데, 게임을 해본 경험이 없었기 때문에 어떻게 구현할 수 있을지 자료를 찾아보며 분석했다.
조사 과정에서 알게 된 핵심은 다음과 같다:
포탈 A에서 바라보면 포탈 B의 내부가 보이고,
실제 이동 시에는 클론을 활용해 자연스럽게 착시 효과를 주며
일정 조건을 만족하면 텔레포트가 발생하는 구조라는 점이다.
포탈을 통해 플레이어가 자연스럽게 텔레포트되도록 한다.
포탈을 바라볼 때 포탈 속 공간이 보인다 (렌더링용 카메라).
본체가 어느정도 포탈을 통과하면 클론의 위치로 이동한다.
포탈과 물체의 거리에 따라 시야가 자연스럽게 변화한다.
플레이어가 Portal의 트리거에 진입
PortalTraveller 감지 → 클론 생성 & 위치 매핑
포탈 중심면 기준으로 dot 값이 음수면 → 텔레포트 발생
텔레포트 후 본체는 클론위치로 이동, 클론 꺼짐과 포탈 주변 충돌 복구
private void LateUpdate()
{
UpdatePortalCamera();
}
private void UpdatePortalCamera()
{
// 플레이어와 포탈 사이 거리 계산
float distance = Vector3.Distance(player.position, transform.position);
// 거리에 따라 FOV 값을 보간 (가까울수록 시야각 넓어짐)
float targetFOV = Mathf.Lerp(60f, 100f, distance / maxDistance);
// 포탈 카메라의 Field of View 적용
portalCamera.fieldOfView = targetFOV;
}
포탈 근처에 접근하면 시야가 넓어지도록 FOV(Field of View)를 조정.
이를 통해 플레이어는 포탈에 가까워졌을 때 더 많은 공간이 보이도록 자연스러운 착시 효과를 경험.
Mathf.Lerp()를 활용해 거리 비례로 FOV를 선형 보간하여 부드럽게 시야각이 변화하도록 설계.
LateUpdate()에서 호출되는 이유는, 카메라가 다른 오브젝트보다 마지막에 갱신되도록 하기 위함.
private void OnTriggerEnter(Collider other)
{
if (other.TryGetComponent<PortalTraveller>(out var traveller))
{
OnTravellerEnterPortal(traveller);
SetWallCollision(traveller, true);
}
}
포탈에 닿은 오브젝트가 PortalTraveller 컴포넌트를 가지고 있다면, 포탈을 통과할 수 있는 대상으로 판단.
감지된 Traveller를 OnTravellerEnterPortal()을 통해 등록하고, 충돌 처리를 비활성화.
이는 포탈 뒤 벽과의 충돌을 방지하기 위한 조치.
private void OnTravellerEnterPortal(PortalTraveller traveller)
{
// 리스트에 없으면 추가
if (!travellers.Contains(traveller))
travellers.Add(traveller);
// 클론 없으면 생성
if (traveller.clone == null)
{
traveller.clone = Instantiate(traveller.clonePrefab);
traveller.clone.name = traveller.name + "_Clone";
}
traveller.clone.SetActive(true);
// 상대 포탈 기준으로 클론 위치와 회전 설정
traveller.UpdateCloneTransform(transform, linkedPortal.transform);
}
Traveller는 중복 추가 방지를 위해 리스트에서 중복 체크 후 등록.
clonePrefab을 기반으로 클론을 한 번만 생성하며, 이미 존재할 경우에는 비활성 상태를 활성화.
클론은 상대 포탈의 위치 기준으로 미리 배치되며, UpdateCloneTransform()을 통해 정확한 위치와 회전이 맞춰짐.
private void SetWallCollision(PortalTraveller traveller, bool ignore)
{
Collider travellerCol = traveller.GetComponent<Collider>();
List<Collider> wallCols = GetWallBehindPortal();
if (wallCols != null && travellerCol != null)
{
foreach (Collider wallCol in wallCols)
{
Physics.IgnoreCollision(travellerCol, wallCol, ignore);
}
}
}
포탈 뒤에 있는 벽의 Collider와 Traveller의 Collider 사이 충돌을 제거.
이를 통해 Traveller가 포탈로 진입할 때 벽에 걸리지 않고 부드럽게 이동할 수 있게 함.
반대로 포탈을 벗어날 경우에는 이 충돌 설정을 복구 (OnTriggerExit()에서).
private void CheckTravellers()
{
for (int i = 0; i < travellers.Count; i++)
{
PortalTraveller traveller = travellers[i];
// 클론 위치 계속 갱신
if (traveller.clone != null && traveller.clone.activeSelf)
{
traveller.UpdateCloneTransform(transform, linkedPortal.transform);
}
Vector3 offset = traveller.transform.position - transform.position; // traveller 위치 계산
float dot = Vector3.Dot(transform.forward, offset); // traveller가 앞인지 뒤인지 판별
// 포탈을 일정 이상 통과한 경우 (dot < 0)
if (dot < 0f)
{
// 본체를 클론 위치로 이동시키고
traveller.Teleport(transform, linkedPortal.transform);
// 클론은 더 이상 필요 없으므로 비활성화
if (traveller.clone != null)
traveller.clone.SetActive(false);
}
}
}
이는 포탈을 통해 반대편 공간이 보이는 착시 효과를 주기 위함.
dot 값으로 통과 여부 판단
float dot = Vector3.Dot(transform.forward, offset);
dot < 0: Traveller가 포탈 평면을 넘어간 상태 (즉, 실제로 통과한 상태)
dot > 0: 아직 포탈 앞에 있음 (통과 전)
이 방식은 포탈 중심을 기준으로 어느 방향에 있는지를 판별하기위해 사용.
dot 값이 음수이면 Teleport() 함수를 호출하여 본체를 클론 위치로 이동.
이동 직후 클론은 비활성화되며, 충돌 처리 및 시각적으로도 본체만 남음.
private void OnTriggerExit(Collider other)
{
if (other.TryGetComponent<PortalTraveller>(out var traveller))
{
if (traveller.clone != null)
traveller.clone.SetActive(false); // 클론 비활성화
travellers.Remove(traveller); // 리스트에서 제거
if (travellers.Count == 0)
{
SetWallCollision(traveller, false); // 충돌 복구
}
}
}
✅ 기능 설명
클론 비활성화
Traveller가 포탈에서 벗어났을 경우 , 클론이 더 이상 필요 없으므로 SetActive(false)로 비활성화.
Traveller 리스트에서 제거
Traveller가 포탈에 닿았을 때는 OnTriggerEnter()에서 리스트에 추가했었고,
여기서는 벗어날 때 리스트에서 제거함으로써 포탈 내부 체크 대상에서 제외.
충돌 복구 처리
해당 Traveller가 마지막으로 포탈 안에 있던 객체였다면
→ travellers.Count == 0 조건을 만족
→ 포탈 뒤 벽과의 충돌 제거를 다시 되돌림.
포탈을 벗어난 Traveller가 하나라도 있다면 벽 충돌은 여전히 무시 상태로 유지되므로, 마지막 Traveller가 나갈 때만 복구됩니다.
처음 포탈 구현을 맡았을 때는 단순히 한쪽에서 들어가면 다른 쪽으로 나오는 기능"정도로만 생각했지만,
직접 구현해보면서 포탈 시스템은 단순한 위치 이동을 넘어서 시각적 착시와 공간 왜곡을 함께 설계해야 한다는 걸 느꼈다.
특히 클론을 만들어 반대 포탈에 미리 보여주는 방식은
플레이어에게 공간이 연결되어 있다는 착각을 자연스럽게 전달하는 핵심 요소였다.
이번 구현을 통해 벡터 수학, 카메라 FOV, 충돌 처리 등 다양한 개념을 직접 써볼 수 있었고,
그만큼 포탈이라는 시스템이 물리와 시각 효과를 정교하게 통합한 결과물이라는 걸 새삼 느끼게 되었다.