In modern open-world, naval combat, and seafaring exploration games on Roblox, default terrain water often lacks the physical fidelity, custom wave profiles, dynamic currents, and precise buoyant forces necessary for realistic maritime gameplay. Creators seeking cinematic storm swells, interactive boat physics, or stylized sea dynamics require a custom hydrodynamic simulation pipeline.
Creating convincing ocean water in Luau requires balancing mathematical rigor with real-time performance. In this master technical guide, we construct a high-performance Gerstner wave system. We explore analytical wave sum equations, derive surface normal vectors for lighting, deform procedural water geometry using EditableMesh, and calculate distributed Archimedean buoyant forces with metacentric height stabilization.
1. The Limitations of Default Terrain Water in Physics-Driven Gameplay
While Roblox Smooth Terrain water provides basic swimming and floatation, production maritime simulations encounter major constraints:
- Inflexible Wave Geometry: Built-in water waves are purely graphical normal map displacements; rigid parts cannot physically interact with or ride dynamic wave peaks and troughs.
- Uniform Buoyancy Heuristic: Terrain water applies a simplified vertical damping force that ignores vessel hull geometry, weight distribution, and wave-induced roll/pitch moments.
- Zero Dynamic Current Vectors: Creating directional ocean currents, river rapids, or vortex whirlpools requires manual body movers completely divorced from visual water rendering.
- EditableMesh Revolution: Roblox's EditableMesh API unlocks genuine client-side vertex displacement, allowing synchronized visual waves and deterministic physics evaluation.
2. Mathematical Foundations: Gerstner Wave Summation & Normal Derivations
Standard sine waves produce rounded peaks and troughs, whereas real ocean waves exhibit sharp, trochoidal crests and wide valleys. The Gerstner wave formulation accomplishes this by displacing vertices horizontally toward the wave crest:
- Trochoidal Horizontal Displacement: For horizontal coordinate vector x and wave direction k_hat with steepness Q, x' = x - sum(Q_i * A_i * k_hat_i * sin(dot(k_i, x) - omega_i * t + phi_i)).
- Vertical Crest Elevation: Height y' = sum(A_i * cos(dot(k_i, x) - omega_i * t + phi_i)), producing elevated, pinched crests and flattened troughs.
- Loop Prevention Constraint: The steepness sum must obey sum(Q_i * A_i * ||k_i||) <= 1 to avoid self-intersecting loops and inverted surface normals.
- Analytical Surface Normals: Instead of expensive neighbor-vertex finite difference lookups, normal vector N is derived analytically from partial derivatives dP/dx and dP/dz.
3. Complete Gerstner Wave & Hull Buoyancy Solver Luau Implementation
The following production-ready Luau module evaluates Gerstner wave elevation at arbitrary world positions and computes multi-probe buoyant forces and restoring torques on floating assemblies:
- Analytical Height Evaluation: Returns the exact wave height and surface normal for any world coordinate (X, Z) at timestamp t.
- Multi-Probe Hull Sampling: Evaluates submergence depth across an array of probe points on the boat hull to naturally distribute buoyant forces.
- Metacentric Restoring Torque: Computes Archimedean vertical thrust and torque (r x F_b) relative to the center of mass, ensuring self-righting roll and pitch stability.
--!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 Real-Time Wave Vertex Deformation
To render dynamic Gerstner waves that visually match the physical buoyancy evaluation identically:
- Grid Topology Setup: Generate a tessellated mesh grid with LOD rings centered on the active camera to maintain high triangle density near the viewer.
- Parallel Worker Synchrony: Calculate vertex displacements across Actor threads or parallel Luau workers to prevent RenderStepped frame drops.
- Shared Mathematical Seeds: Both client rendering and physical hull solvers consume the exact same wave parameter table and synchronized Workspace:GetServerTimeNow() clock.
- Normal Lighting Shading: Assign the analytically derived surface normals to EditableMesh vertex normals, producing crisp sunlight reflections and foam highlights.
5. Production Optimization & Multiplayer Replication Architecture
Scaling dynamic water across multiplayer servers demands strict separation of visual and physical computations:
- Client-Side Autonomous Simulation: The server does not simulate visual vertex meshes; clients execute EditableMesh shaders locally with zero network bandwidth overhead.
- Deterministic Physics Authority: Boats calculate buoyant forces locally or on the network owner, replicating only primary Part CFrame and linear/angular velocities.
- Probe Caching & Early Exit: If a boat's bounding sphere is completely above max wave amplitude or below seabed, bypass per-probe raycasts and trigonometric loops.
- Slosh & Wave Slamming Audio: Modulate 3D spatial hydrophone audio emitters based on vertical water impact velocity (depth penetration rate) for visceral audio feedback.
Frequently Asked Questions
Why use Gerstner waves instead of simpler sine wave functions?
Standard sine waves have symmetrical crests and troughs. Real water waves possess sharp, peaked crests and wide, flat troughs. Gerstner waves mathematically displace vertices horizontally toward crests, replicating genuine trochoidal ocean swells and realistic pitch/roll boat kinematics.
How does EditableMesh perform compared to moving thousands of individual Parts?
Moving individual BaseParts incurs immense scene-graph overhead, physics updates, and draw-call penalties. Roblox EditableMesh modifies a single mesh's underlying vertex buffer directly in memory, executing thousands of vertex shifts in a few milliseconds.
How do you prevent floating boats from rolling over and capsizing?
Ensure the center of buoyancy (CB) is positioned such that the metacentric height (GM) remains positive. Placing hull buoyancy probes wider than the center of mass (COM) generates a self-righting restoring torque whenever the vessel tilts.
Does this custom water system sync correctly across different players in multiplayer?
Yes. Because the Gerstner wave formulation is purely deterministic based on wave frequency, amplitude, and time, passing Workspace:GetServerTimeNow() ensures every client calculates identical wave peaks and boat waterlines simultaneously.