포탈(Portal), 안티챔버(Antichamber), 스플릿게이트(Splitgate) 같은 혁신적인 게임에서 비유클리드 포탈은 플레이어에게 경이로운 공간 경험을 선사합니다. 맵의 한쪽 벽에 열린 문 너머로 수 킬로미터 떨어진 완전히 다른 공간이 실시간 3D 창문처럼 보이고, 그 문을 통과하는 순간 화면 암전이나 로딩, 속도 끊김 없이 자연스럽게 이어진다면 환상적인 퍼즐과 전투가 가능해집니다.
그러나 로블록스에서 단순히 터치 감지로 캐릭터 CFrame을 바꾸는 주먹구구식 텔레포트는 극심한 카메라 튐, 운동량 소멸, 벽 끼임 현상을 유발합니다. 본 실전 가이드에서는 Luau 기반의 상용급 심리스 포탈 프레임워크를 구축합니다. 상대 CFrame 좌표 변환 행렬 도출, ViewportFrame과 EditableImage를 활용한 실시간 시점 렌더링, 입구에서 출구로의 순간 선속도 및 각속도 벡터 전이, 그리고 프레임 단위 무충돌 관통 기법을 다룹니다.
1. 단순 순간이동의 결함: 왜 일반 텔레포트는 공간감을 깨뜨리는가
로블록스의 기본 좌표 이동 방식은 연속적인 물리적 시각적 법칙을 위배합니다:
- 카메라 짐벌 튐: HumanoidRootPart의 CFrame을 강제로 덮어쓰면 카메라 각도가 순간적으로 회전하며 플레이어에게 심한 멀미와 방향 상실을 유발합니다.
- 운동량 벡터 붕괴: 천장에 거꾸로 붙은 출구 포탈로 들어갔을 때 원래의 수평 속도가 그대로 유지되어 물리 법칙을 정면으로 위배합니다.
- 통과 전 시각적 미리보기 불가: 검은색 단색 텍스처로 가려진 문으로 들어가는 것은 낡은 연출입니다. 유저들은 반대편 공간의 실시간 3D 시차를 기대합니다.
- 비유클리드 해법: 입구와 출구 포탈을 아핀 변환 행렬 쌍으로 묶어 시각 광선, 캐릭터 몸체, 물리적 운동량을 원자적으로 전이시킵니다.
2. 수학적 기반: 상대 CFrame 변환 행렬과 속도 전이 공식
포탈은 입구 A와 출구 B 사이의 좌표계를 180도 반전 연결하는 좌표 변환기입니다:
- 상대 좌표 변환: 월드 좌표 P의 입구 A 기준 로컬 좌표 P_rel = PortalA.CFrame:PointToObjectSpace(P). 출구 B로 전이된 좌표 P_out = PortalB.CFrame:PointToWorldSpace(Vector3.new(-P_rel.X, P_rel.Y, -P_rel.Z)).
- 가상 카메라 시점 행렬: 플레이어 카메라 CFrame C에 대해 출구 가상 카메라 C_virtual = PortalB.CFrame * CFrame.Angles(0, math.pi, 0) * PortalA.CFrame:Inverse() * C.
- 선속도(Velocity) 벡터 전이: 입구 속도 V에 대해 출구 속도 V_out = PortalB.CFrame:VectorToWorldSpace(-PortalA.CFrame:VectorToObjectSpace(V)). 운동량의 크기와 상대 방향이 완벽히 보존됩니다.
- 평면 통과 부호 거리(Signed Distance): 캐릭터 위치 C와 입구 포탈 법선 벡터의 내적 거리 d = (C - PortalA.Position):Dot(Normal). d가 양수에서 음수로 교차하는 순간 통과로 판정합니다.
3. Luau 심리스 포탈 엔진 구현체
RunService.RenderStepped에서 카메라 시점을 매 프레임 동기화하고 초고속 통과를 감지하는 고성능 Luau 스크립트입니다:
- 가상 카메라 시점 동기화: 플레이어 시선의 움직임에 맞춰 반대편 ViewportFrame 내부 카메라의 위치와 화각을 실시간 추적합니다.
- 부호 거리 교차 판정: 이전 프레임과 현재 프레임의 내적 거리를 비교하여 찰나의 순간에도 터널링 없이 정확히 통과를 포착합니다.
- 원자적 속도 및 카메라 인계: 캐릭터의 CFrame, 선속도, 카메라 각도를 단 1프레임 내에 동시 전이하여 시각적 이질감을 0으로 제거합니다.
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
export type PortalPair = {
PortalA: BasePart,
PortalB: BasePart,
Width: number,
Height: number,
ViewportA: ViewportFrame?,
ViewportB: ViewportFrame?,
}
local PortalEngine = {}
PortalEngine.__index = PortalEngine
function PortalEngine.new(portalA: BasePart, portalB: BasePart, width: number, height: number)
local self = setmetatable({}, PortalEngine)
self.PortalA = portalA
self.PortalB = portalB
self.Width = width
self.Height = height
self.LastPositions = {} :: {[Model]: Vector3}
return self
end
function PortalEngine:GetTransformedCFrame(inputCF: CFrame, fromPortal: BasePart, toPortal: BasePart): CFrame
-- 출구 포탈 바깥쪽을 향하도록 Y축 기준 180도 회전 반전
local relative = fromPortal.CFrame:ToObjectSpace(inputCF)
local flipped = CFrame.Angles(0, math.pi, 0) * relative
return toPortal.CFrame:ToWorldSpace(flipped)
end
function PortalEngine:GetTransformedVelocity(velocity: Vector3, fromPortal: BasePart, toPortal: BasePart): Vector3
local localVel = fromPortal.CFrame:VectorToObjectSpace(velocity)
-- 180도 반전 출구에 맞춰 X, Z 속도 반전
local flippedVel = Vector3.new(-localVel.X, localVel.Y, -localVel.Z)
return toPortal.CFrame:VectorToWorldSpace(flippedVel)
end
function PortalEngine:CheckCrossing(character: Model, fromPortal: BasePart, toPortal: BasePart)
local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
if not root then return end
local currentPos = root.Position
local prevPos = self.LastPositions[character] or currentPos
self.LastPositions[character] = currentPos
local portalCF = fromPortal.CFrame
local normal = portalCF.LookVector
local prevDist = (prevPos - portalCF.Position):Dot(normal)
local currDist = (currentPos - portalCF.Position):Dot(normal)
-- 전면(양수)에서 후면(음수)으로의 평면 교차 감지
if prevDist > 0 and currDist <= 0 then
-- 포탈 사각 개구부 영역 내부인지 검증
local localPos = portalCF:PointToObjectSpace(currentPos)
if math.abs(localPos.X) <= self.Width * 0.5 and math.abs(localPos.Y) <= self.Height * 0.5 then
-- 위치, 속도, 카메라의 원자적 일괄 전이
local newCF = self:GetTransformedCFrame(root.CFrame, fromPortal, toPortal)
local newVel = self:GetTransformedVelocity(root.AssemblyLinearVelocity, fromPortal, toPortal)
root.CFrame = newCF
root.AssemblyLinearVelocity = newVel
self.LastPositions[character] = newCF.Position
local camera = Workspace.CurrentCamera
if camera and character == Players.LocalPlayer.Character then
camera.CFrame = self:GetTransformedCFrame(camera.CFrame, fromPortal, toPortal)
end
end
end
end
return PortalEngine
4. 실시간 포탈 렌더링 파이프라인: ViewportFrame과 EditableImage
포탈 표면에 반대편 세계를 거울처럼 투영하는 고성능 렌더링 아키텍처입니다:
- ViewportFrame 시점 투영: 포탈 표면에 SurfaceGui를 부착하고, 반대편 공간의 가상 카메라를 동기화하여 실시간 시차(Parallax) 뷰를 렌더링합니다.
- 프록시 지오메트리 최적화: 뷰포트 내부에는 반경 60스터드 이내의 핵심 정적 메시와 적 플레이어 프록시만 복제하여 프레임 드랍을 원천 방지합니다.
- EditableImage 화면 복제: 최신 고사양 게임에서는 화면 버퍼를 EditableImage로 가져와 픽셀 셰이더를 통해 무한 재귀 포탈 반사를 구현합니다.
- 경사 근평면 클리핑(Oblique Clipping): 가상 카메라의 Near-Clip 평면을 출구 포탈 평면에 맞추어 출구 벽 뒤편의 불필요한 오브젝트가 투시되지 않도록 차단합니다.
5. 충돌체 인계, 공간 음향 래핑 및 상용 최적화
플레이어와 투사체가 포탈 모서리에 걸리지 않고 매끄럽게 통과하도록 만드는 엔지니어링 규칙입니다:
- 충돌 그룹 무충돌 처리: 포탈 개구부를 지날 때 캐릭터 몸체와 포탈 벽 사이의 충돌을 CollisionGroup을 통해 0.1초간 일시 해제하여 끼임 현상을 방지합니다.
- 서브 스터드 모서리 챔퍼링: 포탈 프레임 모서리에 빗면(Chamfer) 충돌체를 배치하여 대각선으로 진입해도 부드럽게 미끄러져 들어가도록 유도합니다.
- 3D 도플러 공간 음향 래핑: 출구 B 주변에서 발생하는 총성과 발소리를 입구 A 위치의 스피커로 전달하여 음향적 공간 연속성을 완성합니다.
- 투사체 궤적 꺾임: 유도 미사일이나 총알 레이캐스트가 포탈 평면에 닿으면 남은 비행 거리와 방향 벡터를 즉시 출구 좌표계로 굴절시킵니다.
Frequently Asked Questions
포탈을 통과할 때 캐릭터의 어깨나 팔이 벽에 걸려 튕겨 나오는 현상은 어떻게 해결하나요?
Humanoid 충돌 볼륨의 두께 때문입니다. 통과가 감지된 순간부터 0.1초 동안 플레이어와 포탈 벽 파트 간의 충돌을 CollisionGroup 또는 CanCollide = false로 일시 비활성화해 주면 벽 끼임 없이 100% 매끄럽게 빠져나갈 수 있습니다.
포탈을 통과하는 순간 화면이 180도 홱 도는 멀미 현상은 어떻게 방지하나요?
카메라에도 캐릭터와 동일한 상대 변환 행렬을 적용해야 합니다. 입구 기준 카메라 CFrame을 180도 반전하여 출구 기준 CFrame으로 연속 변환하면, 플레이어가 바라보던 시선 그대로 부드럽게 반대편 공간으로 시야가 이어집니다.
ViewportFrame 내부에서 반대편의 달리는 캐릭터나 몬스터 애니메이션도 보이나요?
보입니다. ViewportFrame 내부에 Humanoid와 Animator가 장착된 복제 리그를 넣고 애니메이션 트랙을 원본과 동기화하여 재생하면 적의 실시간 움직임이 완벽하게 렌더링됩니다.
두 개의 포탈을 서로 마주 보게 설치하여 무한 낙하 루프를 만들면 어떻게 되나요?
물리적으로는 중력 가속도가 계속 누적되어 종단 속도에 도달하며 무한히 떨어집니다. 시각 렌더링 측면에서는 무한 재귀 호출로 인한 크래시를 방지하기 위해 뷰포트 내부의 재귀 렌더링 깊이를 최대 2~3회로 제한해야 합니다.