How do I get Part to match the height of ocean script?

I have a skinned mesh ocean script. I wanted to get a part to match the exact height of the mesh however it’s slightly off. Am I doing something wrong in the script? Is there anything I should have added? Please help me get an idea of what’s wrong and how to fix it. Thanks!

Server Script:

local oceanPart = workspace:WaitForChild("OceanTest")
local meshPart = workspace:WaitForChild("OceanMesh")

local RunService = game:GetService("RunService")

local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250
local origin = Vector2.new(0, 0)

local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.Heartbeat:Connect(function()
	local time = tick() / TimeModifier
	local pos = oceanPart.Position
	local waveY = GetWaveHeightAtPos(pos.X, pos.Z, time)
	oceanPart.Position = Vector3.new(pos.X, meshPart.Position.Y + waveY, pos.Z)
end)

Local Script:

local meshPart = workspace:WaitForChild("OceanMesh")
local bones = {}

for _, obj in pairs(meshPart:GetDescendants()) do
	if obj:IsA("Bone") then
		table.insert(bones, obj)
	end
end

local RunService = game:GetService("RunService")
local origin = Vector2.new(0, 0)

local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250

local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.RenderStepped:Connect(function()
	local time = tick() / TimeModifier

	for _, bone in pairs(bones) do
		local worldPos = meshPart.CFrame:PointToWorldSpace(bone.Position)
		local y = GetWaveHeightAtPos(worldPos.X, worldPos.Z, time)
		bone.Transform = CFrame.new(0, y, 0)
	end
end)
4 Likes

Don’t use tick for synced calculations between the client and server, it’s return value is based on the machine’s timezone.

I recommend using workspace:GetServerTimeNow instead:

GetServerTimeNow() returns the client’s best approximation of the current time on the server. This is useful for creating synchronized experiences as every client will get about the same results regardless of their timezone or local clock.

Additionally, there are better APIs now, so avoid using tick at all:

Returns how much time has elapsed, in seconds, since the Unix epoch, on the current local session’s computer. The Unix epoch is represented by 00:00:00 on 1 January 1970.
tick() isn’t officially deprecated, but has a variety of issues. It can be off by up to one second and returns inconsistent results across time zones and operating systems. Use os.time(), os.clock(), or time() instead. Also consider DateTime.UnixTimestamp and DateTime.UnixTimestampMillis.

5 Likes

Possibly use meshPart.Position.Y as the base height, then add your wave height offset on top of that to keep the part aligned with the mesh surface. Something like;

oceanPart.Position = Vector3.new(oceanPart.Position.X, meshPart.Position.Y +
GetWaveHeightAtPos(oceanPart.Position.X, oceanPart.Position.Z, tick() / TimeModifier), oceanPart.Position.Z)

local t = workspace:GetServerTimeNow() / TimeModifier --for the tick

I switched this:

local time = tick() / TimeModifier

to:

local time = workspace:GetServerTimeNow() / TimeModifier

and it was much further off then when using ticks, I then switched it to this:

local time = workspace:GetServerTimeNow() % 1000 / TimeModifier

And it was about as accurate as when I was using ticks. Is there something I did wrong or should have also done. Sorry for responding late, I had dev forum open all day but it just showed me the notifcation.

How you measuring the difference, is it from the server or the client, and roughly how far off is it?

I suspect the issue is network latency. Each update the server makes to oceanPart.Position takes a variable amount of time to replicate to every client, which is problematic, since it is very likely the latency was greater than the time between each heartbeat on the server, i.e. the client is seeing an old state.

Unfortunately there is no perfect solution, but you can compensate by having each client predict the position on the server, i.e. each client models their replication latency and adds that to the time used to compute the ocean height.

Another issue might be that GetServerTimeNow does not have millisecond accuracy, but this would only matter if the ocean’s change in height is substantial enough over a several millisecond time interval.

You do not want to use tick or os.time because these functions are based on the machine’s timezone. e.g. A server in California and a client in Maine would have a 3 hour difference.

1 Like

It uses server time to handle latency and is much more accurate now but there’s still a small problem. The part mostly follows the waves but goes a bit higher and lower than the waves can go by about a stud or less why?

Server Script:

local oceanPart = workspace:WaitForChild("OceanTest")
local meshPart = workspace:WaitForChild("OceanMesh")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local latencyPing = ReplicatedStorage:FindFirstChild("LatencyPing") or Instance.new("RemoteFunction")
latencyPing.Name = "LatencyPing"
latencyPing.Parent = ReplicatedStorage

latencyPing.OnServerInvoke = function()
	return true
end

local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250
local origin = Vector2.new(0, 0)

local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.Heartbeat:Connect(function()
	local time = workspace:GetServerTimeNow() / TimeModifier
	local pos = oceanPart.Position
	local waveY = GetWaveHeightAtPos(pos.X, pos.Z, time)
	oceanPart.Position = Vector3.new(pos.X, meshPart.Position.Y + waveY, pos.Z)
end)

Local Script:

local meshPart = workspace:WaitForChild("OceanMesh")
local bones = {}
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local latencyPing = ReplicatedStorage:WaitForChild("LatencyPing")

local function getLatency()
	local startTime = tick()
	latencyPing:InvokeServer()
	return (tick() - startTime) / 2
end

local latency = getLatency()

for _, obj in pairs(meshPart:GetDescendants()) do
	if obj:IsA("Bone") then
		table.insert(bones, obj)
	end
end

local origin = Vector2.new(0, 0)
local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250
local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.RenderStepped:Connect(function()
	local time = (workspace:GetServerTimeNow() + latency) / TimeModifier
	for _, bone in pairs(bones) do
		local worldPos = meshPart.CFrame:PointToWorldSpace(bone.Position)
		local y = GetWaveHeightAtPos(worldPos.X, worldPos.Z, time)
		bone.Transform = CFrame.new(0, y - bone.Position.Y, 0)
	end
end)

Thanks for your help so far, it’s improved my script a lot!

If the height is too high or too low then try multiplying Y by .95 (or whatever works) to decrease where you’ve got it at the 100% limit of your calculation.

1 Like

Yes, but you still need to compensate for the replication latency of oceanPart.Position. Additionally, I am unaware if it is ideal to call GetServerTimeNow every frame, but the documentation does not give a clear answer and I have not profiled it’s performance myself.

GetServerTimeNow() is expensive to call compared to DateTime.now(), and is less precise than os.clock(), so it should be used to make sure an event starts at the right real world time or to adjust things periodically to keep a series of events in sync.

Also, I am curious if you really need to position this on the server. If not, then each client should position the part instead, as they will see zero error. Moreover, each client would see approximately the same thing if their clocks are synced with GetServerTimeNow.

1 Like

I switched it to just client side and local rather than server. It had the same issue of going to high above where it should be and too far below. However when I took @Scottifly’s suggestion of multiplying the Y by 0.95 (Although I used 0.7) it worked perfectly. However when I would I move it to anywhere other than 0,0 it was off again. Is there a way to fix this with or without multiplying the the Y? Am I missing anything? Thanks!

Local Script:

local oceanPart = workspace:WaitForChild("OceanTest")
local meshPart = workspace:WaitForChild("OceanMesh")
local bones = {}
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local latencyPing = ReplicatedStorage:WaitForChild("LatencyPing")

local function getLatency()
	local startTime = tick()
	latencyPing:InvokeServer()
	return (tick() - startTime) / 2
end

local latency = getLatency()

for _, obj in pairs(meshPart:GetDescendants()) do
	if obj:IsA("Bone") then
		table.insert(bones, obj)
	end
end

local origin = Vector2.new(0, 0)
local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250
local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.RenderStepped:Connect(function()
	local time = (workspace:GetServerTimeNow() + latency) / TimeModifier

	local pos = oceanPart.Position
	local waveY = GetWaveHeightAtPos(pos.X, pos.Z, time)
	oceanPart.Position = Vector3.new(pos.X, meshPart.Position.Y + waveY * 0.7, pos.Z)

	for _, bone in pairs(bones) do
		local worldPos = meshPart.CFrame:PointToWorldSpace(bone.Position)
		local y = GetWaveHeightAtPos(worldPos.X, worldPos.Z, time)
		bone.Transform = CFrame.new(0, y - bone.Position.Y, 0)
	end
end)

I just realized I might be misunderstanding your issue. Are you saying there is a one stud difference between the maximum y position that the part and bone can attain, or there is up to a one stud difference between their current positions? Additionally, can you clarify how you measured the error? Did you visually look at them while they are moving?

Since your height function computes only the phase as a function of time, there will be no difference in amplitude (maximum y position) no matter how different the client and server clocks are, so I had assumed you meant something else.


If you meant that the amplitude was different:

Can you measure the maximum y position of the part and the ocean to see if their amplitudes are actually different? I am confused why you are seeing a fix by modifying the amplitude of the oceanPart since GetWaveHeightAtPos should compute a signal with an identical amplitude.

Additionally, can you test setting the bone and the oceanPart to same position (e.g. a static one like Vector3.new(0, 0, 0) ), without updating it, and see if they look like they are in different places? Perhaps the way the bone’s position affects the mesh is not what you were expecting, since the only way the amplitudes could really be different is if there is a disconnect between the bone’s visual position and the position property.


Also, you do not need to compensate for latency if you are going to update the oceanPart.Position on the client now because the time will be identical. It seems GetServerTimeNow is performant enough that you can call it each frame. (Besides that, when you measure latency you should be doing it more than once since it changes a lot, which I suggest doing by checking when the client detects an update to the part’s position, not by making a remote call.)

2 Likes

So I took some of what you said and it’s experiencing kind of the same issue, where it’s perfect at 0,0 but off anywhere else. I genuinely have no idea what the issue is.

Local Script:

local meshPart = workspace:WaitForChild("OceanMesh")
local bones = {}
local RunService = game:GetService("RunService")

for _, obj in pairs(meshPart:GetDescendants()) do
	if obj:IsA("Bone") then
		table.insert(bones, obj)
	end
end

local origin = Vector2.new(0, 0)
local WaveLength = 200
local Gravity = 9.81
local Steepness = 0.25
local TimeModifier = 20
local ShallowThreshold = 100
local IntermediateThreshold = 250
local waveDirX = 1
local waveDirZ = 0.5

local function GetWaveHeightAtPos(x, z, time)
	local pos2D = Vector2.new(x, z)
	local distance = (pos2D - origin).Magnitude

	local k = (2 * math.pi) / WaveLength
	local speed = math.sqrt(Gravity / k)
	local steepness = Steepness

	if distance < ShallowThreshold then
		steepness = steepness * 0.4
		speed = speed * 0.5
	elseif distance < IntermediateThreshold then
		steepness = steepness * 0.7
		speed = speed * 0.75
	end

	local a = steepness / k
	local phase = k * (waveDirX * x + waveDirZ * z) - speed * time
	local y = a * math.sin(phase)
	return y
end

RunService.RenderStepped:Connect(function()
	local time = workspace:GetServerTimeNow() / TimeModifier
	for _, bone in pairs(bones) do
		local localPos = bone.Position
		local worldPos = meshPart.CFrame:PointToWorldSpace(localPos)
		local y = GetWaveHeightAtPos(worldPos.X, worldPos.Z, time)
		local localY = localPos.Y
		bone.Transform = CFrame.new(0, y - localY, 0)
	end
end)

Video to help see the issue:

I forgot to say but sorry for getting back to you this late. It was late where I am and I was very tired. Sorry man.

Try what @DrKittyWaffles said to do.
When you are calculating the bone and Part Positions to World CFrame, print each one.
It’ll tell you exactly how much the difference is.
From there you may be able to troubleshoot what is going on.

Since it works at 0, 0 just continue using the local space

local time = workspace:GetServerTimeNow() / TimeModifier
for _, bone in bones do
	local localPos = bone.Position
	local y = GetWaveHeightAtPos(localPos.X, localPos.Z, time)
	bone.Transform = CFrame.new(0, y - localPos.Y, 0)
end
1 Like

Is that the only change? Because bone.Position is in world space, not local space. So would that also change?

No, I made a typo, but I should have clarified that I made an assumption about how you compute the new oceanPart.Position since the most recent script you posted did not contain it,.

Can you show how you are computing oceanPart.Position?

1 Like

I don’t change oceanPart.Position at all. It stays in one place so the bones move correctly with the waves.

What is oceanPart and what is oceanMesh?

My understanding was that oceanMesh is the mesh that the bones control and that oceanPart is the part you were dragging around in the video.

Can you also describe what oceanPart purpose serves? Is it an arbitrary part that should float on top of the ocean wherever it is?

1 Like

OceanPart is a part that floats on the water surface. OceanMesh is the mesh with bones that deform to create the wave motion.

Yes, OceanPart is a floating part that sits on the surface of the water wherever it is placed.

Can you show how you are computing oceanPart.Position so it sits on the surface of the ocean? Additionally, if you changed the code for bone.Position, post that too.

1 Like