해상 무역, 함대전, 해양 서바이벌 게임—예를 들어 Tradelands, SharkBite 및 대규모 해전 시뮬레이터—에서 현실감 넘치는 선박 물리 엔진은 게임의 몰입감을 결정하는 핵심입니다. 하지만 로블록스의 기본 지형 수면(Terrain Water)은 높이가 평평한 수평 그리드에 고정되어 있고 선체 형상에 따른 부력 조절이 불가능하며 역동적인 파도 너울을 물리적으로 구현할 수 없습니다.
최상위 로블록스 개발팀은 지형 수면 물리 대신 Luau로 자체 수학적 부력 엔진을 구축합니다. 본 가이드에서는 선체 다중 프로브(Multi-Probe) 보트 물리 시스템을 구현합니다. 해석적 거스트너(Gerstner) 파고 벡터 유도, 선체 각 지점별 아르키메데스 부력 연산, 방향성 유체 저항력(Drag) 제어 및 Workspace:GetServerTimeNow() 기반 파도 그래픽-물리 완벽 동기화 기법을 다룹니다.
1. 왜 지형 수면(Terrain Water) 대신 커스텀 부력을 사용하는가?
로블록스 지형 복셀 수면 물리는 역동적인 해양 게임 제작 시 다음과 같은 한계를 가집니다:
- 평면 수면의 한계: 지형 수면은 모든 표면을 정적인 수평 평면으로 처리하므로 굽이치는 파도 너울, 폭풍우의 높은 파고를 물리적으로 구현할 수 없습니다.
- 불안정한 부유 진동: 지형 수면에 뜬 파트는 질량 중심 계산 오류로 인해 지속적으로 위아래로 진동하거나 뒤집히는 물리 불안정이 발생합니다.
- 유체 항력 제어 불가: 지형 수면은 선체 단면적이나 용골(Keel) 형상을 고려하지 않고 균일한 선형 감쇠만을 적용하여 부자연스러운 조작감을 낳습니다.
- 커스텀 부력 패러다임: 지형 수면 상호작용을 끄고 선체의 여러 프로브 좌표에서 수학적 파고 함수를 직접 계산하여 상향력과 토크를 가함으로써 완벽한 선박 제어력을 얻습니다.
2. 수학적 기초: 거스트너 파도(Gerstner Waves)와 아르키메데스 원리
실제 바다 파도는 단순한 사인파가 아니며 파도의 마루(Crest)는 뾰족해지고 골(Trough)은 넓어집니다. 이를 거스트너 파도 공식으로 정밀하게 모델링합니다:
- 거스트너 변위 벡터: P(x, z, t) = [x + sum(Q_i * A_i * D_x * cos(theta_i)), y_0 + sum(A_i * sin(theta_i)), z + sum(Q_i * A_i * D_z * cos(theta_i))] (여기서 theta_i = k_i * (D_i . [x, z]) + w_i * t).
- 파도 파라미터: A_i는 파고 진폭, D_i는 진행 방향 벡터, w_i는 각진동수, k_i는 파수(2*pi/파장), Q_i는 마루의 뾰족함을 결정하는 가파름 계수.
- 프로브별 아르키메데스 침수 깊이: 프로브 위치 P_probe에서 침수 깊이 d = math.max(0, WaveHeight(P_probe) - P_probe.Y).
- 상향 부력 연산식: F_buoyant = probe_volume * water_density * gravity * (d / probe_height) (파도 표면 법선 벡터 방향으로 가해짐).
- 유체 항력(Drag) 공식: F_drag = -0.5 * water_density * relative_velocity * |relative_velocity| * drag_coefficient * area.
3. 프로덕션 등급 다중 프로브 선박 부력 Luau 구현
다음은 RunService.Heartbeat에서 실행되는 다중 프로브 선체 샘플링 및 거스트너 파력 연산 모듈 전체 코드입니다:
- 다중 선체 프로브 분산: 이물(선수), 고물(선미), 좌현, 우현에 4~8개의 Attachment 프로브를 배치하여 자연스러운 피치(Pitch) 및 롤(Roll) 모멘트를 형성합니다.
- 실시간 수면 법선 벡터 유도: 파고 함수의 편미분을 통해 파도 표면의 기울기 법선 벡터를 즉시 도출하여 경사면에 맞게 부력을 전달합니다.
- 국소 충격량 가산: ApplyImpulseAtPosition을 사용하여 각 프로브의 월드 좌표에 직접 상향 부력과 항력 댐핑을 가합니다.
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local BoatEngine = {}
BoatEngine.__index = BoatEngine
export type WaveConfig = {
Direction: Vector2,
Amplitude: number,
Wavelength: number,
Speed: number,
Steepness: number,
}
export type ProbeAttachment = {
Attachment: Attachment,
DisplacementVolume: number,
MaxDepth: number,
}
export type BoatChassis = {
RootPart: BasePart,
Probes: { ProbeAttachment },
Waves: { WaveConfig },
WaterDensity: number,
LinearDragCoeff: number,
AngularDragCoeff: number,
}
local GRAVITY = Workspace.Gravity
function BoatEngine.GetWaveHeight(waves: { WaveConfig }, worldPos: Vector3, timeSec: number): (number, Vector3)
local height = 0
local normalX = 0
local normalZ = 0
for _, wave in ipairs(waves) do
local dir = wave.Direction.Unit
local k = (2 * math.pi) / wave.Wavelength
local c = wave.Speed
local w = math.sqrt(GRAVITY * k)
local dot = (dir.X * worldPos.X) + (dir.Y * worldPos.Z)
local phase = (dot * k) - (w * timeSec)
height += wave.Amplitude * math.sin(phase)
local dCos = wave.Amplitude * k * math.cos(phase)
normalX -= dir.X * dCos
normalZ -= dir.Y * dCos
end
local surfaceNormal = Vector3.new(normalX, 1, normalZ).Unit
return height, surfaceNormal
end
function BoatEngine.Step(boat: BoatChassis, dt: number)
local root = boat.RootPart
local currentTime = Workspace:GetServerTimeNow()
for _, probe in ipairs(boat.Probes) do
local probePos = probe.Attachment.WorldPosition
local waveHeight, waveNormal = BoatEngine.GetWaveHeight(boat.Waves, probePos, currentTime)
local immersion = math.clamp((waveHeight - probePos.Y) / probe.MaxDepth, 0, 1)
if immersion > 0 then
-- 아르키메데스 수직 부력 계산
local buoyantForceMagnitude = probe.DisplacementVolume * boat.WaterDensity * GRAVITY * immersion
local buoyantForce = waveNormal * buoyantForceMagnitude
-- 유체 항력 댐핑
local probeVel = root:GetVelocityAtPosition(probePos)
local dragForce = -probeVel * (probeVel.Magnitude * 0.5 * boat.WaterDensity * boat.LinearDragCoeff * immersion)
local totalForce = buoyantForce + dragForce
root:ApplyImpulseAtPosition(totalForce * dt, probePos)
end
end
-- 각속도 선체 회전 감쇠
local angVel = root.AssemblyAngularVelocity
root.AssemblyAngularVelocity = angVel * math.clamp(1 - (boat.AngularDragCoeff * dt), 0, 1)
end
return BoatEngine
4. 유체 동역학 항력, 용골(Keel) 안정성 및 타각(Rudder) 메커니즘
단순 부력만 적용하면 배는 물 위에서 공처럼 튀거나 미끄러집니다. 방향성 유체 역학이 필수적입니다:
- 용골의 측면 양력: 배가 옆으로 밀릴 때 수중 날개 역할을 하는 용골이 측면 복원력을 발생시켜 직진성을 보장합니다.
- 종/횡 항력 비대칭 설계: 전진 방향 수중 저항은 최소화하고 측면 저항은 극대화하여 과도한 표류(Drift) 현상을 억제합니다.
- 키(Rudder) 선회 토크: 전진 속도에 비례하여 선회력을 인가합니다 (Torque_turn = ForwardSpeed * SteerInput * RudderPower).
- 전복 방지 복원 모멘트(Righting Moment): AssemblyCenterOfMass를 선체 바닥 아래로 인위적으로 낮추어 30도 이상 기울어졌을 때 자연스럽게 오뚝이처럼 복원되도록 설계합니다.
5. 멀티플레이어 네트워크 동기화 및 클라이언트 수면 버텍스 셰이더
지연 시간이 다른 여러 클라이언트 환경에서 시각적 수면과 서버 물리 위치를 오차 없이 일치시키는 기법:
- 글로벌 타임 에포크 동기화: Workspace:GetServerTimeNow()를 기준으로 파고를 계산하여 서버와 모든 클라이언트가 1ms 오차 없이 동일한 파도 높이를 산출합니다.
- 클라이언트 수면 메쉬 디포메이션: 클라이언트에서 EditableMesh 또는 Skinned Mesh 본(Bone) 구조를 사용해 파도 시각 메쉬를 렌더링하여 서버 대역폭 낭비를 0%로 줄입니다.
- 선장 클라이언트 네트워크 오너십: 조타기를 잡은 유저에게 선체 파트의 SetNetworkOwner를 부여하여 조타 반응 랙을 0ms로 제거합니다.
- 거리 기반 적응형 파도 LOD: 선박 근처 수면은 4개 고조파 거스트너 파도를 정밀 연산하고, 원거리 해양은 기본 단일 파도로 단순화하여 프레임 드랍을 원천 방지합니다.
Frequently Asked Questions
왜 로블록스 바다 표현에 단순 사인파 대신 거스트너 파도를 써야 하나요?
단순 사인파는 둥근 언덕 형태만 만들어 인공적인 느낌을 줍니다. 거스트너 파도는 수평 벡터 변위를 함께 연산하여 파도의 꼭짓점을 뾰족하게 모으고 골을 넓혀 실제 해양 너울의 물리학을 정확하게 재현하기 때문입니다.
선박 물리가 뒤집히지 않고 안정되려면 몇 개의 프로브가 필요한가요?
안정적인 피치와 롤 모멘트를 생성하기 위해서는 최소 4개의 프로브(선수, 선미, 좌현, 우현)가 필요하며, 대형 전함이나 요트의 경우 6~8개의 프로브를 균등 배치하는 것이 부드러운 승선감을 제공합니다.
서버와 클라이언트 간의 파도 높이가 어긋나지 않게 맞추는 원리는 무엇인가요?
서버와 클라이언트 모두 동기화된 Workspace:GetServerTimeNow()를 시간 인자로 사용해 동일한 시드와 파라미터의 거스트너 공식을 계산하므로, 물리적 충돌과 시각적 파도 높이가 완전히 일치하게 됩니다.