로블록스의 현대 오픈월드, 해전, 항해 탐험 게임에서 기본 지형(Smooth Terrain) 워터는 사실적인 해양 게임플레이에 필요한 동적 파고, 커스텀 조류, 정밀한 부력 물리를 온전히 제공하지 못합니다. 폭풍우의 거친 파도나 사실적인 보트 롤링/피칭을 원하는 개발자에게는 커스텀 유체역학 파이프라인이 필수적입니다.
Luau에서 사실적인 바다를 구현하려면 수학적 엄밀성과 60fps 실시간 성능 간의 균형을 잡아야 합니다. 본 마스터 가이드에서는 고성능 게르스트너(Gerstner) 파도 엔진을 구축합니다. 파동 해석 수식을 분석하고, 표면 법선 벡터를 도출하며, EditableMesh로 정점을 변형하고, 다중 프로브 아르키메데스 부력과 메타센터 복원 토크를 완성합니다.
1. 물리 중심 게임플레이에서 기본 지형 워터의 한계
로블록스 내장 지형 워터는 단순 수영과 부유는 지원하지만, 본격적인 항해 시뮬레이션에서는 심각한 한계에 직면합니다:
- 고정된 파도 지오메트리: 기본 파도는 순수 그래픽적 노멀 맵 변위일 뿐이며, 강체 파트가 동적 파도의 마루와 골에 물리적으로 반응할 수 없습니다.
- 단순화된 균일 부력: 지형 워터는 선체 형상, 무게 중심, 파도에 의한 회전 모멘트를 무시하고 수직 감쇠력만 단순 적용합니다.
- 조류 벡터 부재: 방향성 해류, 급류, 소용돌이를 생성하려면 시각적 렌더링과 완전히 분리된 수동 BodyMover를 작성해야 합니다.
- EditableMesh의 혁신: 로블록스의 EditableMesh API는 클라이언트 정점 버퍼에 직접 접근하여, 시각적 파도와 물리 엔진이 100% 동기화되도록 해줍니다.
2. 수학적 기초: 게르스트너 파도 합산 및 법선 벡터 도출
단순 사인파는 둥근 마루와 골을 만들지만, 실제 해양 파도는 날카로운 트로코이드형 마루와 넓은 골을 보입니다. 게르스트너 파도는 정점을 마루 방향으로 수평 이동시켜 이를 완벽히 재현합니다:
- 트로코이드 수평 변위: 수평 좌표 벡터 x와 진행 방향 k, 파도 가파름 Q에 대해 x' = x - sum(Q_i * A_i * k_hat_i * sin(k_i . x - omega_i * t + phi_i)) 연산 적용.
- 수직 마루 융기: 높이 y' = sum(A_i * cos(k_i . x - omega_i * t + phi_i))를 통해 뾰족한 파도 마루와 평평한 골 형성.
- 루프 방지 제약 조건: 메시가 꼬여 자가 교차하는 현상을 방지하기 위해 가파름 합계 sum(Q_i * A_i * ||k_i||) <= 1 유지 필수.
- 해석적 표면 법선: 비용이 큰 인접 정점 차분법 대신, 편미분 dP/dx와 dP/dz의 외적을 통해 수학적으로 정확한 표면 법선 N을 즉시 계산.
3. 완전한 게르스트너 파도 & 선체 부력 솔버 Luau 구현체
임의의 월드 좌표에서 파고와 법선을 산출하고 선체에 분포된 프로브 부력과 복원 토크를 연산하는 프로덕션 Luau 모듈입니다:
- 해석적 파고 산출: 월드 좌표 (X, Z)와 타임스탬프 t에 대한 정확한 수면 높이와 표면 법선 벡터 반환.
- 다중 프로브 선체 샘플링: 선체 바닥에 배치된 프로브 배열을 통해 부분 침수 깊이를 측정하고 유체 부력을 자연스럽게 분산.
- 메타센터 복원 토크: 무게 중심(COM)에 대한 부력 모멘트(r x F_b)와 유체 감쇠를 계산하여 보트 전복을 방지하는 자가 복원력 부여.
--!strict
local RunService = game:GetService("RunService")
export type WaveDef = {
Direction: Vector2,
Amplitude: number,
Wavelength: number,
Speed: number,
Steepness: number,
}
export type WaterHull = {
Model: Model,
RootPart: BasePart,
ProbeOffsets: { Vector3 },
SubmergedVolumePerProbe: number,
WaterDensity: number,
LinearDamping: number,
AngularDamping: number,
}
local GerstnerSolver = {}
GerstnerSolver.__index = GerstnerSolver
local GRAVITY = 196.2
function GerstnerSolver.new(waves: { WaveDef })
local self = setmetatable({}, GerstnerSolver)
self.Waves = waves
return self
end
function GerstnerSolver:GetWaveHeightAndNormal(worldPos: Vector3, timeSec: number): (number, Vector3)
local x = worldPos.X
local z = worldPos.Z
local totalY = 0
local dDx = 0
local dDz = 0
for _, wave in ipairs(self.Waves) do
local dir = wave.Direction.Unit
local k = (2 * math.pi) / wave.Wavelength
local omega = math.sqrt(GRAVITY * k)
local phase = k * (dir.X * x + dir.Y * z) - omega * timeSec
local cosP = math.cos(phase)
local sinP = math.sin(phase)
totalY += wave.Amplitude * cosP
dDx += -dir.X * (k * wave.Amplitude) * sinP
dDz += -dir.Y * (k * wave.Amplitude) * sinP
end
local normal = Vector3.new(-dDx, 1, -dDz).Unit
return totalY, normal
end
function GerstnerSolver:ApplyBuoyancy(hull: WaterHull, dt: number, timeSec: number)
local root = hull.RootPart
local rootCF = root.CFrame
local rootVel = root.AssemblyLinearVelocity
local rootAngVel = root.AssemblyAngularVelocity
local com = root.AssemblyCenterOfMass
local totalForce = Vector3.zero
local totalTorque = Vector3.zero
for _, offset in ipairs(hull.ProbeOffsets) do
local probeWorld = rootCF:PointToWorldSpace(offset)
local waveHeight, waveNormal = self:GetWaveHeightAndNormal(probeWorld, timeSec)
local depth = waveHeight - probeWorld.Y
if depth > 0 then
local displacedVolume = math.min(depth * 1.5, 1.0) * hull.SubmergedVolumePerProbe
local buoyantMagnitude = displacedVolume * hull.WaterDensity * GRAVITY
local buoyantForce = Vector3.new(0, buoyantMagnitude, 0)
local probeVel = rootVel + rootAngVel:Cross(probeWorld - com)
local dragForce = -probeVel * (hull.LinearDamping * displacedVolume)
local netProbeForce = buoyantForce + dragForce
totalForce += netProbeForce
local leverArm = probeWorld - com
totalTorque += leverArm:Cross(netProbeForce)
end
end
local angDampingTorque = -rootAngVel * hull.AngularDamping
totalTorque += angDampingTorque
root:ApplyAssemblyForce(totalForce)
root:ApplyAssemblyTorque(totalTorque)
end
return GerstnerSolver
4. EditableMesh 실시간 파도 정점 변형 기법
물리 부력 시뮬레이션과 100% 일치하는 동적 게르스트너 파도 그래픽 렌더링 파이프라인:
- 그리드 토폴로지 구성: 카메라 중심의 LOD 동심원 링 그리드를 생성하여 관찰자 주변의 정점 밀도를 극대화.
- 병렬 워커 스레드 동기화: Actor 스레드나 병렬 Luau를 활용해 정점 변위를 분산 연산하여 RenderStepped 프레임 저하 방지.
- 공유 수학 시드: 렌더링 셰이더와 물리 솔버가 동일한 파동 매개변수 테이블과 Workspace:GetServerTimeNow() 동기화 시간을 참조.
- 법선 라이팅 셰이딩: 해석적으로 계산된 표면 법선을 EditableMesh 버텍스 노멀에 적용하여 정교한 태양광 하이라이트 및 거품 렌더링 완성.
5. 프로덕션 최적화 및 멀티플레이어 동기화 아키텍처
멀티플레이어 환경에서 대규모 동적 수면을 최적화하기 위한 엔지니어링 규칙:
- 클라이언트 자율 시뮬레이션: 서버는 정점 메시를 렌더링하지 않으며, 클라이언트가 로컬에서 EditableMesh를 실행하여 네트워크 대역폭 0 유지.
- 결정론적 물리 소유권: 보트는 로컬 또는 네트워크 오너 머신에서 부력을 계산하고 기본 Part CFrame 및 선형/각속도만 복제.
- 프로브 조기 탈출: 보트의 바운딩 스피어가 파도 최고 높이 위나 해저면 아래에 있을 경우 삼각함수 연산을 완전히 스킵.
- 선체 충격음 오디오: 수직 입수 충돌 속도(침수율)에 비례하여 3D 공간 음향을 제어함으로써 현실적인 파도 충돌감 제공.
Frequently Asked Questions
단순 사인파 대신 게르스트너 파도를 사용하는 이유는 무엇인가요?
일반 사인파는 마루와 골이 대칭적인 곡선을 이룹니다. 하지만 실제 바다 파도는 마루가 뾰족하고 골이 넓습니다. 게르스트너 파도는 정점을 마루 쪽으로 수평 이동시켜 실제 트로코이드 해양 파도와 보트의 롤링/피칭을 사실적으로 재현합니다.
EditableMesh는 수천 개의 파트를 움직이는 것과 비교해 얼마나 빠른가요?
수천 개의 BasePart를 움직이면 씬 그래프 갱신, 물리 감지, 드로우 콜 병목이 발생합니다. 반면 EditableMesh는 메모리의 단일 정점 버퍼를 직접 수정하므로 수천 개의 정점 변위를 몇 밀리초 내에 처리합니다.
보트가 전복되거나 뒤집히지 않게 하려면 어떻게 해야 하나요?
부심(CB)과 무게중심(COM) 사이의 메타센터 높이(GM)를 양수로 유지해야 합니다. 선체 하부의 부력 프로브를 무게중심보다 좌우로 넓게 배치하면 배가 기울어질 때 자동으로 일어서는 복원 모멘트가 발생합니다.
멀티플레이어에서 모든 플레이어에게 동일한 파도가 보이나요?
네. 게르스트너 파도는 파동 공식과 시간에 기반한 결정론적 수식이므로, 서버 동기화 시간(Workspace:GetServerTimeNow())을 공유하면 모든 클라이언트가 완벽히 동일한 파고와 수면을 계산합니다.