래그돌(Ragdoll) 물리는 캐릭터 피격, 폭발, 추락 시 역동적인 피격감과 긴장감을 부여하는 핵심 물리 시스템입니다. 하지만 최적화되지 않은 래그돌은 사지가 비정상적으로 꼬이거나, 서버와 클라이언트 간 렉(러버밴딩), 캐릭터가 바닥을 뚫고 맵 밖으로 튕겨 나가는 치명적인 버그를 유발합니다.
안정적인 상용 래그돌 시스템을 구축하려면 BallSocketConstraint를 통한 인체 관절 각도 제한, NoCollisionConstraint를 활용한 자가 충돌 방지, 결정론적 NetworkOwnership 분배, 그리고 넘어진 캐릭터를 자연스럽게 애니메이션 상태로 복귀시키는 상태 머신 설계가 필수적입니다.
1. Motor6D 토글 및 BallSocketConstraint 관절 제한
로블록스 래그돌의 기초는 애니메이션 관절을 물리 제약 관절로 실시간 교체하는 것입니다:
- Motor6D 비활성화: 캐릭터 뼈대를 연결하는 Motor6D의 Enabled를 false로 전환합니다. 기상해야 하는 캐릭터라면 절대 Destroy()하지 말고 비활성화만 수행해야 합니다.
- BallSocketConstraint 구면 회전 제약: 3축 구면 회전을 지원하며, LimitsEnabled와 TwistLimitsEnabled를 켜 인체 해부학적 가동 범위를 설정합니다. UpperAngle은 팔다리가 뒤로 꺾이지 않게 막고, TwistLower/UpperAngle은 팔뚝의 기괴한 360도 꼬임을 방지합니다.
- 어태치먼트 사전 생성: 캐릭터 스폰 시점에 관절별 Attachment0과 Attachment1을 미리 생성해 두어 래그돌 전환 시점의 프레임 드랍을 원천 차단합니다.
- Humanoid 상태 제어: Humanoid:ChangeState(Enum.HumanoidStateType.Physics)로 전환하고, 래그돌 중에는 GettingUp 상태를 일시 비활성화하여 애니메이션과 물리 엔진의 충돌을 방지합니다.
2. 충돌 그룹과 NoCollisionConstraint
관절 부위 간 충돌로 인해 캐릭터가 하늘로 솟구치는 폭발 현상을 억제하는 기법:
- 자가 충돌(Self-Collision) 폭발 현상: 물리 연산이 켜진 사지 부품들이 겹치면 물리 솔버가 강한 반발력을 계산하여 캐릭터를 맵 밖으로 날려버립니다.
- NoCollisionConstraint 결합: 상완-하완, 몸통-상완 등 인접한 모든 신체 부위 사이에 NoCollisionConstraint를 연결하여 상호 충돌 연산을 완전히 무시합니다.
- CollisionFilteringService 활용: 래그돌 상태의 파트들을 전용 "RagdollCharacters" 그룹에 배치하여 정적 지형과만 충돌하고 다른 플레이어의 히트박스는 무시하도록 설정합니다.
- CanCollide 최적화: HumanoidRootPart의 CanCollide를 false로 끄고 Torso의 CanCollide만 true로 유지해야 바닥에 걸리지 않고 부드럽게 구릅니다.
--!strict
local Players = game:GetService("Players")
local PhysicsService = game:GetService("PhysicsService")
local RagdollManager = {}
RagdollManager.__index = RagdollManager
type RagdollRig = {
character: Model,
humanoid: Humanoid,
rootPart: BasePart,
motors: { Motor6D },
constraints: { BallSocketConstraint },
}
function RagdollManager.SetupRig(character: Model): RagdollRig
local humanoid = character:WaitForChild("Humanoid") :: Humanoid
local rootPart = character:WaitForChild("HumanoidRootPart") :: BasePart
local motors: { Motor6D } = {}
local constraints: { BallSocketConstraint } = {}
for _, desc in ipairs(character:GetDescendants()) do
if desc:IsA("Motor6D") and desc.Name ~= "Root" then
table.insert(motors, desc)
local part0 = desc.Part0
local part1 = desc.Part1
if part0 and part1 then
-- 어태치먼트 생성
local att0 = Instance.new("Attachment")
att0.CFrame = desc.C0
att0.Name = "RagdollAtt_" .. desc.Name
att0.Parent = part0
local att1 = Instance.new("Attachment")
att1.CFrame = desc.C1
att1.Name = "RagdollAtt_" .. desc.Name
att1.Parent = part1
-- 구면 회전 제약 생성
local bsc = Instance.new("BallSocketConstraint")
bsc.Attachment0 = att0
bsc.Attachment1 = att1
bsc.LimitsEnabled = true
bsc.UpperAngle = 45
bsc.TwistLimitsEnabled = true
bsc.TwistLowerAngle = -30
bsc.TwistUpperAngle = 30
bsc.Enabled = false
bsc.Parent = part0
table.insert(constraints, bsc)
-- 인접 관절 간 충돌 방지
local ncc = Instance.new("NoCollisionConstraint")
ncc.Part0 = part0
ncc.Part1 = part1
ncc.Parent = part0
end
end
end
return {
character = character,
humanoid = humanoid,
rootPart = rootPart,
motors = motors,
constraints = constraints,
}
end
function RagdollManager.EnableRagdoll(rig: RagdollRig)
rig.humanoid:ChangeState(Enum.HumanoidStateType.Physics)
rig.rootPart.CanCollide = false
for _, motor in ipairs(rig.motors) do
motor.Enabled = false
end
for _, bsc in ipairs(rig.constraints) do
bsc.Enabled = true
end
end
function RagdollManager.DisableRagdoll(rig: RagdollRig)
for _, bsc in ipairs(rig.constraints) do
bsc.Enabled = false
end
for _, motor in ipairs(rig.motors) do
motor.Enabled = true
end
rig.rootPart.CanCollide = true
rig.humanoid:ChangeState(Enum.HumanoidStateType.GettingUp)
end
return RagdollManager
3. 네트워크 소유권(Network Ownership) 및 복제 동기화
물리 시뮬레이션을 어느 기기에서 연산하느냐가 래그돌의 부드러움을 결정합니다:
- 로컬 플레이어 네트워크 소유권: 살아있는 플레이어가 래그돌이 될 때는 `BasePart:SetNetworkOwner(player)`를 통해 본인 클라이언트가 0ms 지연으로 물리를 계산하고 서버로 위치를 브로드캐스팅하게 합니다.
- NPC 및 사망자 시체 소유권: 사망한 캐릭터나 NPC는 `SetNetworkOwner(nil)`을 호출하여 서버가 권한을 가져가야 클라이언트 변조나 동기화 어긋남을 방지할 수 있습니다.
- 대역폭 스로틀링: 심하게 구르는 래그돌은 패킷을 다량 소비하므로, 각속도가 0.1 rad/s 미만으로 멈추면 물리 연산을 슬립(Sleep) 처리해야 합니다.
- 순간이동 글리치 방지: Motor6D를 끄는 프레임과 NetworkOwner를 변경하는 프레임을 동시에 처리하지 말고 최소 1 서버 틱의 시간차를 둡니다.
4. 기상 애니메이션 블렌딩 및 상태 복구
넘어진 자세에서 메쉬나 카메라 튕김 없이 부드럽게 일어서는 회복 프로세스:
- 지형 법선 벡터 정렬: Motor6D를 다시 켜기 직전, Torso에서 바닥으로 레이캐스트를 쏴서 지형 기울기에 맞춰 HumanoidRootPart의 CFrame을 보정합니다.
- 원위치 스냅 현상 방어: 넘어져 있는 동안 루트 파트가 공중에 남아있으면 관절을 켜는 순간 캐릭터가 넘어진 자리에서 공중으로 텔레포트합니다. 루트 파트 CFrame을 항상 Torso 위치로 동기화해야 합니다.
- GettingUp 애니메이션 블렌딩: 전용 기상 애니메이션을 재생하면서 0.3초에 걸쳐 Motor6D를 순차적으로 재활성화합니다.
- 무적 시간(i-frame) 부여: 일어나는 도중 연속 피격으로 무한 래그돌 루프에 빠지지 않도록 0.5초의 짧은 슈퍼아머 시간을 제공합니다.
5. 모바일 최적화 및 프로덕션 배포 수칙
모바일 저사양 기기에서 CPU 병목 없이 물리 제약 조건을 가동하기 위한 규칙:
- 관절 제약 개수 제한: 몸통, 목, 어깨, 골반 등 주요 대관절에만 BallSocket을 적용하고, 모자나 날개 등 액세서리는 정적 어태치먼트로 유지합니다.
- 물리 슬립(Sleep) 유도: 지면에 완전히 멈춘 래그돌 부품은 물리 솔버가 자동으로 연산을 중단하여 CPU 점유율을 0으로 떨어뜨립니다.
- 사망 시 가비지 컬렉션: 사망 후 10초 타이머를 두어 래그돌 인스턴스와 제약 조건을 메모리에서 완전 제거합니다.
- MicroProfiler 감사: Ctrl+F6으로 MicroProfiler를 켜고 `physics::step` 단계의 연산 시간이 프레임당 3ms 이하인지 검증합니다.
Frequently Asked Questions
래그돌이 발동되자마자 캐릭터가 하늘로 날아가거나 땅속으로 꺼집니다. 왜 그런가요?
사지 부품들 간의 겹침으로 인한 자가 충돌(Self-Collision) 반발력 때문입니다. 인접한 부품 사이에 NoCollisionConstraint를 연결하고 HumanoidRootPart의 CanCollide를 false로 끄면 해결됩니다.
래그돌 연산은 클라이언트와 서버 중 어디서 해야 하나요?
조작 중인 플레이어 본인은 `SetNetworkOwner(player)`로 클라이언트에서 연산해야 조작감과 시야가 부드럽습니다. 반면 사망한 시체나 NPC는 `SetNetworkOwner(nil)`로 서버가 연산해야 동기화가 깨지지 않습니다.
일어날 때 원래 넘어졌던 자리로 순간이동하는 현상은 어떻게 고치나요?
Motor6D를 다시 켜기 직전에 HumanoidRootPart의 CFrame을 현재 Torso의 월드 좌표와 일치시켜 주어야 제자리에서 자연스럽게 일어납니다.
BallSocketConstraint의 TwistLimits는 왜 필수적인가요?
TwistLimits가 없으면 팔다리가 관절 축을 중심으로 360도 드릴처럼 꼬이는 기괴한 현상이 발생하므로, 인간 관절 가동 범위(-30도 ~ 30도)로 회전을 제한해야 합니다.