딥워큰(Deepwoken), 프론트라인(Frontlines), 데드 어헤드(Dead Ahead) 등 로블록스 최고의 오픈월드 및 액션 게임에서 캐릭터가 불규칙한 지형을 이동할 때 발이 허공에 뜨는 현상은 몰입감을 치명적으로 해칩니다. 기본 R15 및 R6 애니메이션은 수평의 평평한 바닥만을 가정하여 제작되었기 때문에 계단, 바위 지형, 가파른 언덕에서 한쪽 발이 공중에 뜨고 다른 쪽 발은 바닥에 파묻히게 됩니다.
사실적인 보행 표현을 위해 최상위 개발팀은 '절차적 발 착지 역기구학(Foot Placement IK)'을 구현합니다. 본 가이드에서는 Luau를 활용해 2-Bone 해석적 IK 솔버를 구축합니다. 제2코사인 법칙 기반 관절 회전각 유도, 골반 부착점 하향 레이캐스팅, 다리 과신전(Overextension) 방지를 위한 동적 골반 하강(Hip Drop), 지형 법선에 맞춘 발목 회전 정렬 기법을 체계적으로 다룹니다.
1. 왜 기본 캐릭터는 공중에 뜨는가: 절차적 발 IK의 필요성
기본 로블록스 애니메이션 블렌딩은 런타임 지형 굴곡을 인지하지 못합니다:
- 고정된 키프레임 애니메이션: 기존 애니메이션은 골반을 기준으로 고정된 다리 궤적을 재생합니다. 지형 높이가 달라져도 다리는 허공을 밟게 됩니다.
- 경사면 시각적 이질감: 언덕이나 바위 가장자리에 설 때 한쪽 다리가 낭떠러지 위에 붕 떠 있어 물리적 신뢰성을 완전히 파괴합니다.
- 계단 오르기 판정 왜곡: 계단을 오를 때 발이 디딤판을 밟지 않고 계단 모서리를 스케이트 타듯 미끄러져 올라가는 현상이 발생합니다.
- 절차적 IK의 해결책: 런타임에 Motor6D 관절의 Transform을 가로채 실시간 지형 높이에 맞춰 골반, 무릎, 발목의 3차원 회전각을 동적으로 재연산합니다.
2. 수학적 기초: 해석적 2-Bone 제2코사인 법칙 솔버
2개의 뼈(허벅지 UpperLeg -> 종아리 LowerLeg -> 발 Foot)로 구성된 다리 역기구학은 반복 연산인 FABRIK 대신 해석적 제2코사인 법칙으로 즉각 계산됩니다:
- 삼각형 길이 정의: 허벅지 길이 L1, 종아리 길이 L2, 골반 원점에서 목표 발 착지점까지의 직선거리 D = (FootTarget - HipOrigin).Magnitude.
- 빗변 클램핑: 계산 중 NaN 에러(삼각함수 정의역 초과)를 방지하기 위해 거리 D를 유효 범위로 제한합니다: D = math.clamp(D, 0.01, L1 + L2 - 0.001).
- 골반 관절각(Alpha): math.acos((L1^2 + D^2 - L2^2) / (2 * L1 * D)) 공식을 통해 허벅지가 발을 향해 꺾여야 하는 피치 각도를 산출합니다.
- 무릎 굴곡각(Beta): math.acos((L1^2 + L2^2 - D^2) / (2 * L1 * L2)) 공식을 통해 무릎 관절 내부의 굽힘 각도를 계산합니다.
- 평면 정렬 CFrame: 골반, 무릎, 발 목표 지점이 이루는 평면의 법선 벡터를 기준으로 회전 좌표계를 구성하여 다리가 옆으로 꺾이지 않도록 고정합니다.
3. 프로덕션 등급 절차적 발 착지 Luau 구현
다음은 RunService.RenderStepped에서 60fps 이상으로 부드럽게 구동되는 모듈형 발 IK 컨트롤러 전체 코드입니다:
- 양발 독립 레이캐스팅: 좌우 골반 어태치먼트에서 수직 하향으로 레이를 발사하여 지면 접촉점과 경사면 법선 벡터를 추출합니다.
- 골반 하강(Hip Drop) 연산: 지형이 더 낮은 발에 맞춰 골반 높이를 아래로 내려, 높은 쪽 다리는 자연스럽게 접히고 낮은 쪽 다리는 지면에 완전히 닿도록 합니다.
- 발목 표면 회전 정렬: 바닥의 표면 법선 벡터에 맞춰 발목 CFrame을 회전시켜 경사면에서 발가락이 땅속에 파묻히지 않도록 정렬합니다.
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local FootIK = {}
FootIK.__index = FootIK
export type LegRig = {
HipMotor: Motor6D,
KneeMotor: Motor6D,
AnkleMotor: Motor6D,
HipAttachment: Attachment,
ThighLength: number,
CalfLength: number,
CurrentFootOffset: number,
}
export type CharacterIK = {
Character: Model,
RootJoint: Motor6D,
LeftLeg: LegRig,
RightLeg: LegRig,
RayParams: RaycastParams,
HipDropOffset: number,
}
local function SolveTwoBone(origin: CFrame, targetPos: Vector3, l1: number, l2: number): (CFrame, CFrame)
local localized = origin:PointToObjectSpace(targetPos)
local dist = localized.Magnitude
dist = math.clamp(dist, 0.05, (l1 + l2) - 0.001)
local cosAlpha = (l1 * l1 + dist * dist - l2 * l2) / (2 * l1 * dist)
local alpha = math.acos(math.clamp(cosAlpha, -1, 1))
local cosBeta = (l1 * l1 + l2 * l2 - dist * dist) / (2 * l1 * l2)
local beta = math.acos(math.clamp(cosBeta, -1, 1))
local planeRot = CFrame.lookAt(Vector3.zero, localized)
local hipRot = planeRot * CFrame.Angles(alpha, 0, 0)
local kneeRot = CFrame.Angles(-(math.pi - beta), 0, 0)
return hipRot, kneeRot
end
function FootIK.Step(ik: CharacterIK, dt: number)
local char = ik.Character
local rootPart = char.PrimaryPart
if not rootPart then return end
local rayDown = Vector3.new(0, -5, 0)
local leftOrigin = ik.LeftLeg.HipAttachment.WorldPosition
local rightOrigin = ik.RightLeg.HipAttachment.WorldPosition
local leftHit = Workspace:Raycast(leftOrigin, rayDown, ik.RayParams)
local rightHit = Workspace:Raycast(rightOrigin, rayDown, ik.RayParams)
local leftTarget = leftHit and leftHit.Position or (leftOrigin + Vector3.new(0, -3.2, 0))
local rightTarget = rightHit and rightHit.Position or (rightOrigin + Vector3.new(0, -3.2, 0))
-- 골반 하강(Hip Drop) 계산
local leftDeficit = leftOrigin.Y - leftTarget.Y
local rightDeficit = rightOrigin.Y - rightTarget.Y
local maxDeficit = math.max(leftDeficit, rightDeficit)
local targetHipDrop = math.clamp(maxDeficit - 3.2, 0, 1.8)
ik.HipDropOffset = ik.HipDropOffset + (targetHipDrop - ik.HipDropOffset) * math.clamp(dt * 15, 0, 1)
ik.RootJoint.Transform = CFrame.new(0, -ik.HipDropOffset, 0)
-- 좌측 다리 연산
local leftHipCF = ik.LeftLeg.HipMotor.Part0.CFrame * ik.LeftLeg.HipMotor.C0
local lHipRot, lKneeRot = SolveTwoBone(leftHipCF, leftTarget, ik.LeftLeg.ThighLength, ik.LeftLeg.CalfLength)
ik.LeftLeg.HipMotor.Transform = lHipRot
ik.LeftLeg.KneeMotor.Transform = lKneeRot
-- 우측 다리 연산
local rightHipCF = ik.RightLeg.HipMotor.Part0.CFrame * ik.RightLeg.HipMotor.C0
local rHipRot, rKneeRot = SolveTwoBone(rightHipCF, rightTarget, ik.RightLeg.ThighLength, ik.RightLeg.CalfLength)
ik.RightLeg.HipMotor.Transform = rHipRot
ik.RightLeg.KneeMotor.Transform = rKneeRot
end
return FootIK
4. 골반 하강(Hip Drop) 보정 및 발목 표면 법선 정렬
경사면에 선 캐릭터에서 다리 IK만 풀면 아래쪽 다리가 팽팽하게 펴져 로봇처럼 부자연스러워집니다:
- 골반 높이 오프셋 적용: RootJoint Motor6D의 Y축 위치를 아래로 낮추어 골반을 지면에 가깝게 위치시키면 양쪽 다리 모두 유연한 굴곡각을 유지합니다.
- 지수 보간 댐핑: 계단 모서리를 지날 때 카메라가 덜컹거리는 현상을 막기 위해 골반 하강 값에 Lerp 보간을 적용하여 부드럽게 높낮이를 전환합니다.
- 외적(Cross Product) 기반 발목 정렬: 캐릭터 LookVector와 바닥 법선 벡터의 외적을 통해 발바닥 파트가 지형 표면과 평행을 이루도록 회전시킵니다.
- 발끝(Toe) 이중 레이캐스팅: 가파른 경사면에서 발뒤꿈치뿐만 아니라 발끝 레이를 함께 쏘아 발바닥 꺾임을 2단계로 정밀 제어합니다.
5. 렌더링 최적화, 프러스텀 컬링 및 멀티플레이어 LOD
수십 명의 플레이어가 모인 서버에서 모든 캐릭터의 삼각법 연산을 수행할 때 CPU 병목을 방지하는 아키텍처:
- 카메라 시야각(Frustum) 컬링: Camera:WorldToViewportPoint()를 호출하여 화면 뒤에 있는 캐릭터의 발 IK 계산을 완전히 스킵합니다.
- 거리 기반 적응형 LOD: 35 stud 이내 근거리 아바타는 60Hz 전수 계산, 35~70 stud는 30Hz 격프레임 계산, 70 stud 초과는 기본 애니메이션으로 폴백합니다.
- 휴머노이드 상태 기반 조기 탈출: Freefall(낙하), Swimming(수영), Seated(착석), Ragdoll(래그돌) 상태일 때는 레이캐스트를 즉시 비활성화합니다.
- 순수 클라이언트 시뮬레이션: 모든 IK 변형을 클라이언트 RenderStepped에서 로컬로 처리하여 서버 네트워크 대역폭 소모를 0바이트로 유지합니다.
Frequently Asked Questions
로블록스 내장 IKControl 대신 자체 2-Bone 삼각법을 구현하는 이유는 무엇인가요?
내장 IKControl에 비해 자체 삼각법 솔버는 골반 하강(Hip Drop)의 미세 감쇠 조절, 바닥 법선에 맞춘 발목 회전, 커스텀 충돌 마스크 및 카메라 프러스텀 거리별 LOD를 100% 코드로 제어할 수 있기 때문입니다.
계단을 오를 때 발이 미세하게 떨리는 지터(Jitter)는 어떻게 해결하나요?
계단 디딤판과 수직면 사이에서 레이캐스트 히트 좌표가 급변할 때 발생하므로, 발 착지 목표 위치에 지수 이동 평균(EMA) 필터를 적용하면 떨림이 완벽히 사라집니다.
골반을 낮추면 캐릭터 충돌체(HumanoidRootPart) 높이도 낮아져 점프에 영향을 주나요?
아닙니다. 물리적 충돌체인 HumanoidRootPart의 위치는 그대로 유지되며, 시각적 렌더링을 담당하는 RootJoint Motor6D의 Transform 오프셋만 조절되므로 물리 충돌 및 점프 성능은 전혀 영향을 받지 않습니다.