Sampling Terrain Water Height at Any Position

Have you ever wanted to create a buoy or boat or make any effect that required the height of a wave in Roblox? Unfortunately, as of now, Roblox provides now way to do this. So, I reverse-engineered Roblox’s current wave function and came up with a pretty accurate approximation, if not the exact equation.

Just a disclaimer, this may not always match the Roblox terrain waves. If Roblox changes its wave function, this script won’t work anymore.

--[[
robro786
5/18/2021
]]

--[[
NOTE: 

Waves use their own clock to calculate wave height. Since that clock is private and invisible from the lua
side, a separate clock needs to be kept in this script to emulate the waves. If these clocks are desynced, the waves
will appear desynced. This means that both clocks must start at the same time, so THIS SCRIPT SHOULD BE
REQUIRED IMMEDIATELY

I recommend initially setting your wavespeed to 0 in studio, then, in a LocalScript, require this module
and then set your wavespeed to the desired values. That way, both clocks start at 0.
]]

local module = {}

local runService = game:GetService("RunService")
local terrain = game.Workspace:WaitForChild("Terrain")

-- these are roblox-set values, they aren't meant to be changed
local MAX_WAVE_MAGNITUDE = 1.94 -- studs (Terrain.WaterWaveSize is NOT measured in studs like the documentation says)
local MAX_WAVELENGTH = 85 -- studs


local function getWavelength()
	local a = math.sqrt(terrain.WaterWaveSize)
	return a * MAX_WAVELENGTH
end

-- approximation, catch up in case this script was required a few seconds late
local wt = time() * terrain.WaterWaveSpeed  -- ideally, time() will be about 0 when this runs
runService.Stepped:Connect(function(t, dt)
	wt += dt * terrain.WaterWaveSpeed 
end)


function module.calcWaterHeightOffset(x, z)
	if terrain.WaterWaveSize > .02 then -- don't divide by 0
		local w = getWavelength()
		local o = math.cos(2 * math.pi * (x + wt) / w) * math.sin(2 * z * math.pi / w) 
		o *= terrain.WaterWaveSize * MAX_WAVE_MAGNITUDE
		return o
	else
		return 0
	end
end

do -- make sure the clock starts before this module returns
	local c = true
	c = runService.Stepped:Connect(function()
		c:Disconnect()
		c = nil
	end)
	
	repeat wait() until not c
end

return module

The only caveat is errors in my approximation will magnify over time and distance from (0, 0). I haven’t done much extensive testing at extreme times and distances, so if anyone makes improvements or notices issues, please let me know!

197 Likes

OK OK That is so realistic. I am loving it.

Mind helping me where do I put that? i’m a bit new with that.

The code I put is meant to run in a modulescript. This isn’t really meant to be a post about code though, I’m more trying to show the formula I came up with, implementation is up to the user. If you want to learn more, try learning how to use modulescripts first.

3 Likes

Can you drop a copy of the studio file? Or at least the script of the Cubes so we can kind of get a visualization of it.

1 Like

water.rbxl (26.6 KB)

5 Likes

Cool calculations and design!

1 Like

Thanks for the great module. I might do a sweet tutorial about it :smiley:

I’ve built something similar w/ custom Gerstner waves, but was struggling to actually have the boat mimic it. Any chance that you’d share your boat demo?

Can’t drop the whole place file, but I can explain how I achieved the effect.

Its got three components: pitch, roll, and height.

Before I explain how I get those values, let me explain how I set up my boats. The first thing to note is the model’s primary part; the boat has a primary part about which the boat should rotate. I just think of it as the closest point to the center of mass with the player in their seat. The primary part is only really used to apply rotations in a relatively realistic manner. To calculate the values I mentioned above, I use a bunch of floating points scattered across the boat I call buoys. There are four important buoys among those, named ‘port,’ ‘starboard,’ ‘stern,’ and ‘bow.’

Here’s how my boat is configured:


The yellow brick is the primary part, the blue parts are bow and stern, and the red parts are port and starboard.

Every frame, I calculate the water wave height at each buoy. The average of all those heights is the y-component of the boat’s primary part. The angle between the height of the port and starboard buoys is the roll, and the angle between the height of the bow and stern is the pitch angle.

Here’s what those calculations look in real time. Pitch angle is in red (because it’s rotated on the X-axis), and roll angle is blue (because its rotated on the Z-axis).

boat - YouTube

here’s how I integrate those numbers into the final CFrame.

local boatY, pitch, roll = getBoatOrientation()

local prim = boat.PrimaryPart
local p = prim.Position
local look = prim.CFrame.LookVector
p = Vector3.new(p.X, boatY, p.Z)
look = Vector3.new(look.X, 0, look.Z)

local cf = CFrame.new(p, p + look)
cf *= CFrame.Angles(pitch, 0, 0)
cf *= CFrame.Angles(0, 0, roll)

boat:SetPrimaryPartCFrame(cf)

Let me know if you have any other questions!

12 Likes

So I’ve been working on the buoys and have been having issues with the angle calculation (working on the roll atm).

function angleBetweenVectors(v1, v2)
    local angle = math.acos(v1.unit:Dot(v2.unit))
    return angle
end

function roll()
	local port = floaters.port.Position
	local starboard = floaters.starboard.Position
	local newPort = Vector3.new(port.X, originHeight[floaters.port] + WaterHeightCalculator.calcWaterHeightOffset(port.X, port.Z), port.Z)
	local newStarboard = Vector3.new(starboard.X, originHeight[floaters.starboard] + WaterHeightCalculator.calcWaterHeightOffset(starboard.X, starboard.Z), starboard.Z)
		
	return angleBetweenVectors(newPort, newStarboard)
end

I stored the origin heights for all the buoys/floaters in an array and then am retrieving it to calculate the vector difference between the new heights. Is this the correct way?

I’ve attached a video of the weird behavior:


Please ignore the sloppy code, I’m just trying to mock up a working prototype.

Really would appreciate any assistance! :slight_smile:

I saw this sort of magic scripting in another boat game, nice to understand how it works now, cheers buddy.

1 Like

I’m sure you could use some kind of angle between vectors to find the roll, but I found it easier to just use some easy trig. Here’s how I do it:


p is port position, s, is starboard position, Wp is wave height @ p, Ws is wave height @ s. From there you can make a triangle, solve for theta. The sign of your rotation can be determined by math.sign(Ws - Wp). I do the same thing for pitch.

What’s b? If you subtract the port position minus the starboard position, you can’t use math.abs on it? (I believe that’s what the diagram was saying)?

Here’s what I wrote, acting a bit strange.

function roll()
	local port = floaters.port.Position
	local starboard = floaters.starboard.Position
	local portWave = Vector3.new(port.X, originHeight[floaters.port] + WaterHeightCalculator.calcWaterHeightOffset(port.X, port.Z), port.Z)
	local starboardWave = Vector3.new(starboard.X, originHeight[floaters.starboard] + WaterHeightCalculator.calcWaterHeightOffset(starboard.X, starboard.Z), starboard.Z)
	
	local h = portWave.Y - starboardWave.Y
	local b = math.abs(port.Z - starboard.Z)
	local angle = math.atan(h/b)
	
	return angle * math.sign(portWave.Y - starboardWave.Y)
end
2 Likes

Sorry for the confusion. Two lines surrounding a vector indicates the magnitude, not absolute value.

p and s are positions, Ws and Wp are scalars. They’re just the relative height of the wave from sea-level at the x and z of p or s.

Got it. Thanks for the assistance. It seems to be on the wrong side, does the math.sign look right to you?
return angle * math.sign(portWave.Y - starboardWave.Y)

I don’t remember, whatever looks good is probably right. Glad I could help! Let me know if you have any other questions!

Weird, works fine without math.sign

thats crazy. atan must be taking care of sign then.

1 Like