로블록스 커스텀 무브먼트 아키텍처: 에어 스트레이프 가속 벡터, 버니합 지면 마찰 유예 윈도우 및 운동학 컨트롤러

By DopaBrain 물리 및 무브먼트 메커니즘 엔지니어링 팀 Updated: 2026-10-01 12 min read

파쿠르, 하이퍼 FPS, 서프(Surf) 및 버니합(Bhop) 장르의 로블록스 게임—예를 들어 Evade, Defusal 등—에서 기본 로블록스 Humanoid 조작감은 둔탁하고 제한적입니다. 기본 로블록스 물리 엔진은 공중에서 수평 속도를 강제로 감속시키며, 공중에서 방향을 꺾으면 운동 에너지를 흡수하고, 착지하는 즉시 100% 지면 마찰을 가해 이동 속도를 즉시 0으로 만듭니다.

퀘이크(Quake), 하프라이프, 카운터스트라이크에서 입증된 폭발적인 이동의 쾌감을 재현하려면 기본 Humanoid를 대체하는 커스텀 운동학 캐릭터 컨트롤러(Kinematic Character Controller)가 필수적입니다. 본 가이드에서는 Luau를 활용해 소스 엔진 스타일의 에어 스트레이프 및 버니합 엔진을 구축합니다. Wishdir 공중 가속 벡터 투영 유도, 2~4프레임 지면 마찰 유예 윈도우, 속도 중첩 및 멀티플레이 동기화를 다룹니다.

1. 물리 엔진 결함: 왜 기본 캐릭터의 공중 조작은 뻣뻣한가?

기본 로블록스 물리 엔진은 고급 무브먼트를 원천 차단하는 강제 감속 로직을 가집니다:

2. 수학적 기초: 퀘이크 공중 가속(Air Acceleration) 벡터 투영

에어 스트레이프는 현재 이동 방향과 수직에 가까운 각도로 속도를 연속 가산하는 수학적 원리입니다:

3. 프로덕션 등급 에어 스트레이프 & 버니합 Luau 구현

다음은 RunService.Heartbeat에서 정확한 프레임 단위로 실행되는 모듈형 무브먼트 컨트롤러 전체 코드입니다:

QuakeMovementController.luau (소스 엔진 공중 가속 코어)
--!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) 및 속도 중첩 동역학

버니합의 핵심은 지면 마찰이 작동하기 직전에 다음 점프를 성립시키는 타이밍 메커니즘입니다:

5. 시각적 피드백, 카메라 틸트 및 오디오 풍절음 연출

초고속 무브먼트의 몰입감을 극대화하는 시각 및 청각 피드백 연출:

Frequently Asked Questions

에어 스트레이프 시 단순히 방향만 바뀌지 않고 속도가 증가하는 이유는 무엇인가요?

공중 가속 공식이 오직 '스트레이프 목표 방향'으로의 정사영 속도만을 제한하기 때문입니다. 카메라를 돌려 진행 방향과 거의 직교하는 각도로 이동하면 해당 축의 속도가 0에 가깝기 때문에 엔진이 제한 없이 가속력을 계속 더해 속도가 누적됩니다.

버니합의 '지면 마찰 유예 윈도우'란 무엇인가요?

발이 땅에 닿은 직후 2~4프레임(약 33~66ms) 동안 지면 마찰 계산을 보류하는 버퍼 시간으로, 이 시간 내에 점프가 실행되면 감속 없이 100% 수평 속도를 다음 도약으로 전달할 수 있습니다.

초고속 주행 시 얇은 벽을 뚫고 지나가는 터널링 버그는 어떻게 막나요?

프레임당 이동 거리가 충돌체 두께보다 커지면 발생하므로, 캐릭터 전방 진행 벡터로 연속 충돌 감지(CCD) 또는 사전 Spherecast를 발사하여 벽면을 미리 감지하고 이동을 제한해야 합니다.

Explore More Interactive Tests & Guides

Discover personalized cognitive assessments, stress evaluations, and game psychology tools on DopaBrain.

Go to Portal Hub