로블록스의 기본 `PopperCam`과 플레이어 카메라는 소셜 게임에는 충분하지만, 3인칭 슈팅(TPS), 액션 RPG, 고난도 점프맵이 요구하는 정밀한 조작감과 시각적 무게감을 충족하지 못합니다.
`CameraType.Scriptable`을 선언하고 `RenderStepped` 렌더 파이프라인에서 카메라를 직접 제어하면 완벽한 시네마틱 조작감을 구현할 수 있습니다. 2차 미분 스프링 물리로 자연스러운 관성을 부여하고, 3D 스피어캐스트(Spherecast)로 벽 뚫림 현상을 차단하며, 숄더뷰 조준을 결합하여 AAA급 카메라 시스템을 완성할 수 있습니다.
1. 기본 카메라의 한계: 왜 커스텀 카메라를 제작해야 하는가
빠른 속도감의 현대 게임에서 로블록스 기본 카메라가 겪는 문제점:
- 벽 충돌 시 급격한 끊김: 좁은 골목이나 실내 전투 시 기본 카메라는 벽에 부딪힐 때 즉각 위치를 꺾어버려 화면 울렁거림을 유발합니다.
- 물리적 무게감의 부재: 캐릭터 머리를 즉각적으로 추적하기 때문에 카메라에 가속이나 감속 같은 유기적인 질감이 없습니다.
- 시야 가림 현상: 캐릭터가 화면 정중앙에 고정되어 원거리 사격이나 스킬 조준 시 자신의 캐릭터 모델이 타겟을 가리는 치명적 결함이 있습니다.
- Scriptable 모드의 자유도: `CurrentCamera.CameraType = Enum.CameraType.Scriptable`로 설정하여 엔진 기본 스크립트의 간섭 없이 CFrame을 100% 제어합니다.
2. 2차 미분 스프링 물리와 임계 감쇠(Critical Damping)
화면 떨림이나 고무줄 튕김 없이 부드러운 관성 추적을 구현하는 수학적 원리:
- 훅의 법칙과 감쇠비: 스프링 강도((k))와 감쇠 계수((d))의 비율을 조율합니다. 감쇠비 (zeta = 1.0)인 임계 감쇠를 설정하면 오버슈트(목표 지점을 지나쳐 진동하는 현상) 없이 가장 빠르게 목표점에 안착합니다.
- 속도 적분 공식: `force = (target - pos) * k - (vel * d)`를 통해 매 프레임 위치를 갱신하여 점프나 급회전 시 묵직한 카메라 관성을 연출합니다.
- 회전과 위치의 독립 분리: 요(Yaw)와 피치(Pitch) 회전은 마우스 입력에 즉시 반응하게 하고, 위치 오프셋만 스프링 물리를 적용해 조작감과 연출을 동시에 잡습니다.
- 프레임 레이트 독립성: 60Hz, 144Hz, 240Hz 모니터에서 동일한 감각을 유지하도록 반드시 `RenderStepped`의 `dt`를 곱해 물리 적분을 수행합니다.
--!strict
local Workspace = game:GetService("Workspace")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local CameraController = {}
local camera = Workspace.CurrentCamera
local player = Players.LocalPlayer
local yaw = 0
local pitch = 0
local targetDistance = 12
local currentDistance = 12
-- 스프링 물리 상태 (위치, 속도, 강도, 감쇠)
local springPos = Vector3.zero
local springVel = Vector3.zero
local SPRING_STIFFNESS = 140
local SPRING_DAMPING = 22
local SPHERE_RADIUS = 0.6
local RAY_PARAMS = RaycastParams.new()
RAY_PARAMS.FilterType = RaycastFilterType.Exclude
function CameraController.Init()
camera.CameraType = Enum.CameraType.Scriptable
UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
UserInputService.InputChanged:Connect(function(input, processed)
if processed then return end
if input.UserInputType == Enum.UserInputType.MouseMovement then
local sensitivity = 0.003
yaw -= input.Delta.X * sensitivity
pitch = math.clamp(pitch - (input.Delta.Y * sensitivity), -1.2, 1.2)
end
end)
RunService.RenderStepped:Connect(CameraController.Update)
end
function CameraController.Update(dt: number)
local char = player.Character
if not char then return end
local head = char:FindFirstChild("Head") :: BasePart?
if not head then return end
RAY_PARAMS.FilterDescendantsInstances = { char }
-- 1. 기준 초점 CFrame 및 우측 숄더 오프셋 설정
local focusCFrame = CFrame.new(head.Position) * CFrame.Angles(0, yaw, 0) * CFrame.Angles(pitch, 0, 0)
local desiredOffset = Vector3.new(2.2, 1.0, 0) -- 우측 어깨 오프셋
-- 2. 2차 스프링 적분 계산
local force = (desiredOffset - springPos) * SPRING_STIFFNESS - (springVel * SPRING_DAMPING)
springVel += force * dt
springPos += springVel * dt
-- 3. 스피어캐스트를 통한 3D 벽 충돌 사전 예측
local rayOrigin = head.Position
local targetCamPos = (focusCFrame * CFrame.new(springPos)).Position - (focusCFrame.LookVector * targetDistance)
local castDirection = targetCamPos - rayOrigin
local hit = Workspace:Spherecast(rayOrigin, SPHERE_RADIUS, castDirection, RAY_PARAMS)
local adjustedDistance = targetDistance
if hit and hit.Instance and hit.Instance.CanCollide then
adjustedDistance = math.max(1.5, (hit.Position - rayOrigin).Magnitude - SPHERE_RADIUS)
end
-- 거리 보간으로 부드러운 카메라 줌인/줌아웃
currentDistance += (adjustedDistance - currentDistance) * math.min(1, dt * 25)
-- 4. 최종 카메라 CFrame 적용
local finalCFrame = focusCFrame * CFrame.new(springPos) * CFrame.new(0, 0, currentDistance)
camera.CFrame = finalCFrame
end
return CameraController
3. 스피어캐스트(Spherecast) 기반 3차원 벽 관통 방지
벽 너머 공허 공간이 노출되거나 카메라가 오브젝트를 뚫는 현상을 방지하는 기술:
- 선형 레이캐스트의 맹점: 단일 광선은 모서리나 얇은 틈을 지나칠 수 있어, 카메라 렌즈의 근거리 절단면(Near Plane)이 벽을 뚫고 내부 텍스처를 노출시킵니다.
- 체적 스피어캐스트의 우수성: `Workspace:Spherecast`는 두께를 지닌 3차원 구체를 쓸어내리며 투사하므로 렌즈가 벽에 닿기 전에 장애물을 정확히 감지합니다.
- 안전 완충 마진: 충돌 지점에서 구체 반경((0.5)~(0.8) 스터드)만큼 카메라를 앞쪽으로 당겨 벽과 카메라 사이에 안전거리를 확보합니다.
- 점진적 시야 복귀: 좁은 복도를 벗어날 때 즉각 원래 거리로 튕기지 않고 점근적 감쇠 보간을 통해 매끄럽게 카메라 거리를 복원합니다.
4. 동적 숄더 시프트(어깨 전환) 및 ADS 조준 줌
슈팅 및 전투 상황에서 시야각을 극대화하는 숄더뷰 기법:
- 좌우 어깨 전환: Q/E 또는 마우스 휠 클릭 시 스프링 목표값을 `Vector3.new(-2.2, 1.0, 0)`로 반전시켜 코너 모퉁이 시야를 확보합니다.
- 정조준(ADS) 시야각(FOV) 연동: 우클릭 정조준 시 FOV를 70도에서 50도로 좁히고 카메라 오프셋을 캐릭터 얼굴 옆으로 밀착시킵니다.
- 시차(Parallax) 보정: 카메라가 어깨 쪽에 위치하므로 총구에서 발사되는 총알 궤적은 화면 정중앙 3D 초점을 향해 발사되도록 탄도학 벡터를 보정해야 합니다.
- 전력 질주 시야 왜곡: 캐릭터가 고속 달리기 상태에 돌입하면 카메라 거리를 살짝 멀리 당겨 역동적인 속도감을 부여합니다.
5. 모바일 터치 제스처와 멀티플랫폼 조작 최적화
터치스크린, 게임패드, 키보드 마우스 전반을 아우르는 컨트롤러 설계:
- 두 손가락 핀치 투 줌: 모바일 화면에서 두 손가락으로 거리를 조절할 때 `targetDistance`를 5~25 스터드 사이로 부드럽게 조정합니다.
- 우측 화면 드래그 스와이프: 모바일에서는 화면 우측 절반을 카메라 회전 전용 터치 영역으로 분리하여 좌측 가상 조이스틱과 간섭을 차단합니다.
- 게임패드 비선형 응답 곡선: 아날로그 스틱 입력에 2차 지수 함수 `math.sign(x) * (x^2)`를 적용하여 미세 조준과 빠른 180도 회전을 모두 지원합니다.
- 네트워크 대역폭 격리: 카메라 연산은 100% 클라이언트 로컬 메모리에서 완결하며, 절대 서버로 원시 카메라 CFrame을 동기화하지 않습니다.
Frequently Asked Questions
카메라 충돌 감지에 Raycast 대신 Spherecast를 쓰는 이유는 무엇인가요?
Raycast는 두께가 없는 선이라 벽 모서리를 비껴갈 수 있습니다. 두께를 가진 Spherecast를 사용해야 카메라 렌즈가 벽 내부를 파고드는 그래픽 버그를 완벽히 차단할 수 있습니다.
벽 근처를 지날 때 카메라가 덜덜 떨리는 현상을 어떻게 잡나요?
충돌 지점의 거리로 즉시 끊어 이동하지 않고, `currentDist += (targetDist - currentDist) * math.min(1, dt * 25)`와 같은 점근적 보간을 적용하면 떨림이 사라집니다.
RenderStepped에서 매 프레임 스피어캐스트를 돌리면 모바일에서 렉이 걸리지 않나요?
1회의 스피어캐스트와 기본 벡터 연산은 프레임당 0.05ms 미만의 CPU 시간만 소비하므로 모바일 기기에서도 프레임 드랍 없이 부드럽게 동작합니다.