파쿠르, 하이퍼 FPS, 서프(Surf) 및 버니합(Bhop) 장르의 로블록스 게임—예를 들어 Evade, Defusal 등—에서 기본 로블록스 Humanoid 조작감은 둔탁하고 제한적입니다. 기본 로블록스 물리 엔진은 공중에서 수평 속도를 강제로 감속시키며, 공중에서 방향을 꺾으면 운동 에너지를 흡수하고, 착지하는 즉시 100% 지면 마찰을 가해 이동 속도를 즉시 0으로 만듭니다.
퀘이크(Quake), 하프라이프, 카운터스트라이크에서 입증된 폭발적인 이동의 쾌감을 재현하려면 기본 Humanoid를 대체하는 커스텀 운동학 캐릭터 컨트롤러(Kinematic Character Controller)가 필수적입니다. 본 가이드에서는 Luau를 활용해 소스 엔진 스타일의 에어 스트레이프 및 버니합 엔진을 구축합니다. Wishdir 공중 가속 벡터 투영 유도, 2~4프레임 지면 마찰 유예 윈도우, 속도 중첩 및 멀티플레이 동기화를 다룹니다.
1. 물리 엔진 결함: 왜 기본 캐릭터의 공중 조작은 뻣뻣한가?
기본 로블록스 물리 엔진은 고급 무브먼트를 원천 차단하는 강제 감속 로직을 가집니다:
- 공중 항력(Air Drag) 강제 적용: 플레이어가 도약하면 기본 Humanoid는 수평 속도를 걷기 속도(16 studs/s)로 급격히 감속시킵니다.
- 공중 선회 감속: 점프 중 카메라를 회전하면 진행 방향 벡터의 크기가 줄어들어 공중 궤적 수정이 불가능합니다.
- 0프레임 즉시 마찰: 착지 순간 바닥 재질이 감지되면 즉각적인 마찰 계수가 적용되어 공중에서 축적한 속도가 1프레임 만에 소멸합니다.
- 소스 엔진 물리 패러다임: 퀘이크/소스 엔진은 전체 속도를 제한하지 않고, 플레이어가 입력한 방향(Wishdir)으로의 정사영 속도만을 검사하여 직교 방향의 가속을 무한히 중첩시킵니다.
2. 수학적 기초: 퀘이크 공중 가속(Air Acceleration) 벡터 투영
에어 스트레이프는 현재 이동 방향과 수직에 가까운 각도로 속도를 연속 가산하는 수학적 원리입니다:
- 목표 입력 방향(wishdir): WASD 입력과 카메라 CFrame을 결합한 정규화 벡터: wishdir = (cameraForwardFlat * moveZ + cameraRightFlat * moveX).Unit.
- 현재 방향 정사영 속도: currentspeed = velocity:Dot(wishdir) (플레이어가 목표 방향으로 이미 가지고 있는 속도 성분).
- 가속 여유량(addspeed): addspeed = math.clamp(maxAirSpeed - currentspeed, 0, airAccel * maxAirSpeed * dt).
- 접선 방향 속도 중첩: currentspeed가 maxAirSpeed보다 작을 때만 가속을 더합니다. 카메라를 회전하면서 옆으로 이동(Strafe)하면 currentspeed가 0에 가깝게 유지되므로 속도 크기가 기하급수적으로 폭증합니다.
- 최적 스트레이프 각도: 수학적 최적각 theta = math.acos(maxAirSpeed / currentMagnitude)를 유지할 때 최대 가속 효율을 달성합니다.
3. 프로덕션 등급 에어 스트레이프 & 버니합 Luau 구현
다음은 RunService.Heartbeat에서 정확한 프레임 단위로 실행되는 모듈형 무브먼트 컨트롤러 전체 코드입니다:
- 운동학적 지면 구체 충돌 검사: 발밑에 Spherecast를 발사하여 착지 2~4프레임 전에 지면 접근을 미리 감지합니다.
- 지면 마찰 유예(Grace Window): 착지 후 점프가 선입력된 경우 4프레임(66ms) 동안 지면 마찰을 완전히 무시하여 축적된 수평 속도를 100% 보존합니다.
- LinearVelocity 물리 제동 해제: 기본 Humanoid 제동을 바이패스하고 독립적인 AssemblyLinearVelocity를 직접 인가합니다.
--!strict
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")
local MovementEngine = {}
MovementEngine.__index = MovementEngine
export type ControllerConfig = {
RootPart: BasePart,
MaxAirSpeed: number,
AirAcceleration: number,
GroundMaxSpeed: number,
GroundAcceleration: number,
FrictionCoeff: number,
JumpPower: number,
FrictionGraceFrames: number,
}
export type MovementState = {
Config: ControllerConfig,
Velocity: Vector3,
IsGrounded: boolean,
GraceTimer: number,
JumpBuffered: boolean,
RayParams: RaycastParams,
}
function MovementEngine.New(root: BasePart): MovementState
local rayParams = RaycastParams.new()
rayParams.FilterType = RaycastFilterType.Exclude
rayParams.FilterDescendantsInstances = { root.Parent or root }
local state: MovementState = {
Config = {
RootPart = root,
MaxAirSpeed = 7.5,
AirAcceleration = 120.0,
GroundMaxSpeed = 22.0,
GroundAcceleration = 14.0,
FrictionCoeff = 6.0,
JumpPower = 48.0,
FrictionGraceFrames = 4,
},
Velocity = Vector3.zero,
IsGrounded = true,
GraceTimer = 0,
JumpBuffered = false,
RayParams = rayParams,
}
return state
end
local function Accelerate(vel: Vector3, wishdir: Vector3, wishspeed: number, accel: number, dt: number): Vector3
local currentspeed = vel:Dot(wishdir)
local addspeed = wishspeed - currentspeed
if addspeed <= 0 then
return vel
end
local accelspeed = math.min(accel * wishspeed * dt, addspeed)
return vel + (wishdir * accelspeed)
end
function MovementEngine.Step(state: MovementState, inputDir: Vector3, cameraLook: Vector3, cameraRight: Vector3, dt: number)
local root = state.Config.RootPart
local cfg = state.Config
-- 지면 충돌 검사
local origin = root.Position
local cast = Workspace:Spherecast(origin, 1.0, Vector3.new(0, -1.8, 0), state.RayParams)
local wasGrounded = state.IsGrounded
state.IsGrounded = (cast ~= nil)
-- 버니합을 위한 착지 유예 프레임 설정
if not wasGrounded and state.IsGrounded then
state.GraceTimer = cfg.FrictionGraceFrames
end
-- 2D 수평 Wishdir 벡터 구성
local camForwardFlat = Vector3.new(cameraLook.X, 0, cameraLook.Z).Unit
local camRightFlat = Vector3.new(cameraRight.X, 0, cameraRight.Z).Unit
local wishdir = (camForwardFlat * inputDir.Z + camRightFlat * inputDir.X)
local wishspeed = 0
if wishdir.Magnitude > 0.05 then
wishdir = wishdir.Unit
wishspeed = cfg.GroundMaxSpeed
end
local horizVel = Vector3.new(root.AssemblyLinearVelocity.X, 0, root.AssemblyLinearVelocity.Z)
local vertVel = root.AssemblyLinearVelocity.Y
if state.IsGrounded then
if state.GraceTimer > 0 and state.JumpBuffered then
-- 버니합 성공: 마찰 스킵 후 재도약
vertVel = cfg.JumpPower
state.IsGrounded = false
state.GraceTimer = 0
state.JumpBuffered = false
elseif state.GraceTimer <= 0 then
-- 지면 마찰력 적용
local speed = horizVel.Magnitude
if speed > 0.1 then
local drop = speed * cfg.FrictionCoeff * dt
horizVel = horizVel * math.max(speed - drop, 0) / speed
end
horizVel = Accelerate(horizVel, wishdir, wishspeed, cfg.GroundAcceleration, dt)
else
state.GraceTimer -= 1
end
else
-- 공중 스트레이프 가속 적용
local airWishSpeed = math.min(wishspeed, cfg.MaxAirSpeed)
horizVel = Accelerate(horizVel, wishdir, airWishSpeed, cfg.AirAcceleration, dt)
end
root.AssemblyLinearVelocity = Vector3.new(horizVel.X, vertVel, horizVel.Z)
end
return MovementEngine
4. 지면 마찰 유예(Grace Window) 및 속도 중첩 동역학
버니합의 핵심은 지면 마찰이 작동하기 직전에 다음 점프를 성립시키는 타이밍 메커니즘입니다:
- 선입력 큐 버퍼링: 착지 전 100ms 이내에 점프 키를 누르면 큐에 등록되어 착지 첫 번째 프레임에 즉시 점프가 격발됩니다.
- 0마찰 반발 도약: 유예 타이머(4프레임) 동안 지면 마찰 감속 함수를 건너뛰어 축적된 수평 운동 에너지를 다음 공중 체공 시간으로 그대로 전이시킵니다.
- 속도 한계 소프트캡: 150 studs/s를 초과할 경우 맵 벽면을 뚫고 나가는 터널링을 방지하기 위해 공중 가속 계수를 부드럽게 감쇠시키는 소프트캡 수식을 적용합니다.
- 경사면 서프(Surf) 결합: 지면 경사각이 45도를 초과할 때 중력 가속도를 경사면 벡터로 재투영하여 초고속 활강 궤적을 형성합니다.
5. 시각적 피드백, 카메라 틸트 및 오디오 풍절음 연출
초고속 무브먼트의 몰입감을 극대화하는 시각 및 청각 피드백 연출:
- 다이내믹 더치 롤(Dutch Roll): 급격한 공중 스트레이프 입력 시 카메라를 좌우로 기울여(CFrame.Angles(0, 0, -strafe * 0.04)) 횡방향 가속도 G-포스를 시각화합니다.
- 동적 FOV 확장: 이동 속도가 증가함에 따라 기본 70도에서 최대 95도까지 시야각(Field of View)을 점진적으로 넓혀 속도감을 배가합니다.
- 속도 연동 풍절음(Wind Rush): RunService.RenderStepped에서 수평 속도에 비례해 바람 소리 오디오의 Pitch와 Volume을 모듈레이션합니다.
- 버니합 착지 발소리 음소거: 버니합 연속 성공 시 일반 착지 발소리를 음소거하여 감속 없는 완벽한 체인 성공을 청각적으로 피드백합니다.
Frequently Asked Questions
에어 스트레이프 시 단순히 방향만 바뀌지 않고 속도가 증가하는 이유는 무엇인가요?
공중 가속 공식이 오직 '스트레이프 목표 방향'으로의 정사영 속도만을 제한하기 때문입니다. 카메라를 돌려 진행 방향과 거의 직교하는 각도로 이동하면 해당 축의 속도가 0에 가깝기 때문에 엔진이 제한 없이 가속력을 계속 더해 속도가 누적됩니다.
버니합의 '지면 마찰 유예 윈도우'란 무엇인가요?
발이 땅에 닿은 직후 2~4프레임(약 33~66ms) 동안 지면 마찰 계산을 보류하는 버퍼 시간으로, 이 시간 내에 점프가 실행되면 감속 없이 100% 수평 속도를 다음 도약으로 전달할 수 있습니다.
초고속 주행 시 얇은 벽을 뚫고 지나가는 터널링 버그는 어떻게 막나요?
프레임당 이동 거리가 충돌체 두께보다 커지면 발생하므로, 캐릭터 전방 진행 벡터로 연속 충돌 감지(CCD) 또는 사전 Spherecast를 발사하여 벽면을 미리 감지하고 이동을 제한해야 합니다.