Simulating Water physics in studio

Introduction

Do you want to make a natural looking waterfall? Or maybe flood a room with swimmable water? Or just have something cool to put in your portfolio?

Welp here’s the tutorial for it. We’ll be using the terrian’s Fill feature to simulate water physics.

We will be creating water physics like these here.

Examples

A splash of water

Water on the ground

Waterfall

Running Water

It also doesn’t have to be water, here’s the simulation of dirt.

Theory

Using a physic object, such as a sphere or a base part we can simulate physics then simply add water afterwards. We'll also be using parallel processing to hopefully achieve higher frame rates.

Preparation

Workspace

Not much preparation outside of scripting is required, a good area to generate the water is all that we'll need.

This is the setup I’ll be using to create a miniature waterfall. Make sure all of the parts are anchored.

image

Script

We'll be using RunService's heartbeat and coroutines to create water every frame. Here's the set up.
Run = game:GetService("RunService")
Terrain = workspace.Terrain

Step 1 : Generation

Generating physics object is pretty straightforward but there are a few extra things we'll need to do. Let's first add a new part every 2 seconds and set the parent to a folder inside workspace so everything is easier to track. Also add a new part and link it to the variable `Generator` this will be the original position of the new part so make sure it's anchored with CanCollide Off.

Code
Run = game:GetService("RunService")
Terrain = workspace.Terrain
Generator = workspace.Generator
Folder = Instance.new("Folder", workspace)

Generation = coroutine.create(function()
	while wait(2) do
		local NewPart = Instance.new("Part")
		NewPart.Parent = Folder
		NewPart.Shape = Enum.PartType.Ball
		NewPart.Size = Vector3.new(1, 1, 1)
		NewPart.CFrame = Generator.CFrame
	end
end)

coroutine.resume(Generation)

Once that’s done we should get something like this.

Video

Next we’ll need to generate water in bulk so it can come down as a splash of water rather than a droplet at a time.

Let’s say we want to generate 16 spheres and we want to put the in a grid layout.
Here’s what the grid would have to look like.

Fun fact : The outlines in this grid was made using my outline plugin, will do a release soon.

image

And to make this grid we need to know the size of the generator, and amount of subdivisions.

Normally in roblox you can just use the Size property of the generator and divide it by the subdivisions to get how much to step each repeat and go from there. But here’s a more fundamental point of view that might be helpful for when developing outside of Roblox’s APIs.

Extra Credit : Fundamentals

For this example let’s just say the generator’s position is (0, 0, 0).

We can figure the start point and end point using some math.
We know for a fact that the spheres generate in the middle of the generate part and thus the position returns the middle of the part.

To get the start point we subtract that by the x and z scale of the part’s size divided by 2 same for the end point expect we add.
We’ll also need a difference value to figure out how much to incurment, this can be done by subtracting the EndPoint by the StartPoint.

We can then divide both by the amount subdivisions and get how many times to repeat and get how much much times to repeat via dividing that value to the difference.

StartPoint = Generator.Position - Vector3.new(Generator.Size.X / 2, 0, Generator.Size.Z / 2)
EndPoint   = Generator.Position + Vector3.new(Generator.Size.X / 2, 0, Generator.Size.Z / 2)
Difference = EndPoint - StartPoint
Step       = Vector2.new((Difference.X) / Subdivisions , (Difference.Z) / Subdivisions)
Repeat     = Vector2.new((Difference.X) / Step.X , (Difference.Z) / Step.Y) -- We divide by Step.Y because Vector2's don't have the Z Dimension.

Now here’s what I’ll be using, I’m using two values instead of vector2 (like in the extra credit) but you can do whatever suits you.

I also made it to a function GenerateBatch() with the argument Subdivisions.
So it’s easier to manage later.

Code
function GenerateBatch(Subdivisions)
	local StartPoint = Generator.Position - Vector3.new(Generator.Size.X / 2, 0, Generator.Size.Z / 2)
	local StepX, StepY = (Generator.Size / Subdivisions).X, (Generator.Size / Subdivisions).Z
	for	y = 0, Subdivisions, 1 do
		for	x = 0, Subdivisions, 1 do
			local NewPart = Instance.new("Part")
			NewPart.Parent = Folder
			NewPart.Shape = Enum.PartType.Ball
			NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
			NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
		end
	end
end

After that’s done we can replace the Generation coroutine with GenerateBatch(4) and then we have our batch generation.

Video

Step 2 : Adding water

Since we have our spheres we can start adding water. We’ll do this by adding a coroutine with a heartbeat function that’ll fill the area of each sphere with water. We made the radius half the size to make the fill more precise.

Here’s the fill function.

Terrain:FillBall(Part.Position, Part.Size.X / 2, Enum.Material.Water)
You may have notice that the terrain material can be customized, this could be used as a physics simulation for any material especially with the methods that we'll discuss later.

It’s only a matter of performing this function to every part in the folder (that we created to hold all of the parts).

Code
FillWater = coroutine.create(function() 
	Run.Heartbeat:Connect(function()
		for i,Part in pairs(Folder:GetChildren()) do
			Terrain:FillBall(Part.Position, Part.Size.X / 2, Enum.Material.Water)
		end
	end)
end)

coroutine.resume(FillWater)

But wait, when you run the code something unexpected happen.

Video

If you have a sharp eye you would already know what’s happening here, the spheres are floating on the water?

How do you fix this? Just change the collisions, namely the CollisionGroupId of the sphere. You want the spheres to collide with other parts but not the water.

This will also be a good time to maybe add some transparent borders for the spheres.
I added these two wedges so the spheres wont stay on the rock but instead slide down.

image

Both are set to CollisionGroupId of 1. This line of code place inside of the GenerateBatch()'s x loop will also set the sphere’s CollisionGroupId to 1. NewPart.CollisionGroupId = 1.

Now we got a working simulation!

Video

Of course this doesn’t look good. That’s because the subdivision is still set to 4. Increasing this will make the simulation more precise. Let’s set it to 15 and see what happens.

Code
Generation = coroutine.create(function()
	while wait(2) do
		GenerateBatch(15)
	end
end)

coroutine.resume(Generation)

Now it looks more decent!

Video

Step 3 : Optimization

Now we just add in Parallel Processing which will split the load among many thread instead of just one.
We do this by add a heartbeat function to each batch.
Warning : Adding more threads could actually slow down the simulation use with care.

We can also increase the wait time between batches and destroy the sphere after 10 seconds or so.

We can destroy the spheres after 10 seconds by add this code to the fill water coroutine.

Code
wait(10)
for i,Part in pairs(Folder:GetChildren()) do
	Part:Destroy()
end
return

So now we should have.

FillWater = coroutine.create(function(Folder) 
	Run.Heartbeat:Connect(function()
		for i,Part in pairs(Folder:GetChildren()) do
			Terrain:FillBall(Part.Position, Part.Size.X / 2, Enum.Material.Water)
		end
	end)
	wait(10)
	Folder:Destroy()
	return
end)
	
coroutine.resume(FillWater, Folder)

We could also do a checkered offset that’ll help render better.

image

The dark gray spheres will be generated first and the light grey will be generated later, this will improve performance by splitting the load between two smaller batches instead of a bigger one.

But how do we know when to place each sphere?
We simply use the modulus operation.

Extra Credit : Modulus

A modulus is an operation that returns the remainder of a division problem.
For example 100 / 40 will return 2.5 or 2 R20. The modulus operation 100 % 40 will just return 20.

This can be useful for when you want to only run some code for each multiple of a step.
As i % step == 0 will return true if i is a multiple of step.

How would be achieve this?
We can just check if the current x or y value is a multiple of 2 and spawn or not spawn the sphere depending the the offset.

First we’ll add another argument to the function GenerateBatch(), Offset which will be a bool value.

Then we could add this check to the spawning loop.

Code
if Offset and (((y * Subdivisions) + x) % 2) == 0 then
	local NewPart = Instance.new("Part")
	NewPart.Parent = Folder
	NewPart.Shape = Enum.PartType.Ball
	NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
	NewPart.CollisionGroupId = 1
	NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
end

This will check if the Offset Argument is true and so will check if (((y * Subdivisions) + x) % 2) == 0 is also true. ((y * Subdivisions) + x) will just give out the current cell in a 1 dimensional order. So (2,2) (with Subdivisions being 4) becomes 10.

We also need to check if Offset isn’t true and then check if (((y * Subdivisions) + x) % 2) == 0 is false. We could also just do (((y * Subdivisions) + x) % 2) == 1.

Code
if not Offset and (((y * Subdivisions) + x) % 2) == 1 then
	local NewPart = Instance.new("Part")
	NewPart.Parent = Folder
	NewPart.Shape = Enum.PartType.Ball
	NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
	NewPart.CollisionGroupId = 1
	NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
end

And if we combine these two statements together we should get something like this.

Code
if Offset and (((y * Subdivisions) + x) % 2) == 0 then
	local NewPart = Instance.new("Part")
	NewPart.Parent = Folder
	NewPart.Shape = Enum.PartType.Ball
	NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
	NewPart.CollisionGroupId = 1
	NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
elseif not Offset and (((y * Subdivisions) + x) % 2) == 1 then
	local NewPart = Instance.new("Part")
	NewPart.Parent = Folder
	NewPart.Shape = Enum.PartType.Ball
	NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
	NewPart.CollisionGroupId = 1
	NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
end 

After that its only a matter of changing the Generation coroutine to get it working.

Code
Generation = coroutine.create(function()
	while wait(5) do
		GenerateBatch(15, false)
		print("1")
		wait(2)
		GenerateBatch(15, true)
		print("2")
		wait(3)
	end
end)

coroutine.resume(Generation)

And there we have it the simulation runs more smoother. How smooth? Here’s a before and after video of the simulations, nothing has changed expect for the code and the subdivisions are set to 20 in both simulations.

Videos

Before, we’re using generating spheres in one batch.

Now it’s in two checkered batches and I’ve set the time to wait before the spheres get destroyed to 3.

Closing remarks.

Wow this took a very long time to do, if anyone want it here’s the place file for the simulation.
waterphysics.rbxl (22.2 KB)

Here’s the final code.

Code
Run = game:GetService("RunService")
Terrain = workspace.Terrain
Generator = workspace.Generator
MasterFolder = Instance.new("Folder", workspace)

function GenerateBatch(Subdivisions, Offset)
	local StartPoint = Generator.Position - Vector3.new(Generator.Size.X / 2, 0, Generator.Size.Z / 2)
	local StepX, StepY = (Generator.Size / Subdivisions).X, (Generator.Size / Subdivisions).Z
	local Folder = Instance.new("Folder")
	Folder.Parent = MasterFolder
	for	y = 0, Subdivisions, 1 do
		for	x = 0, Subdivisions, 1 do
			if Offset and (((y * Subdivisions) + x) % 2) == 0 then
				local NewPart = Instance.new("Part")
				NewPart.Parent = Folder
				NewPart.Shape = Enum.PartType.Ball
				NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
				NewPart.CollisionGroupId = 1
				NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
			elseif not Offset and (((y * Subdivisions) + x) % 2) == 1 then
				local NewPart = Instance.new("Part")
				NewPart.Parent = Folder
				NewPart.Shape = Enum.PartType.Ball
				NewPart.Size = Vector3.new(StepX, (StepX + StepY) / 2, StepY)
				NewPart.CollisionGroupId = 1
				NewPart.Position = StartPoint + Vector3.new(x * StepX, Generator.Position.Y, y * StepY)
			end 
		end
	end
	
	local FillWater = coroutine.create(function(Folder) 
		Run.Heartbeat:Connect(function()
			for i,Part in pairs(Folder:GetChildren()) do
				Terrain:FillBall(Part.Position, Part.Size.X / 2, Enum.Material.Water)
			end
		end)
		wait(3)
		Folder:Destroy()
		return
	end)
	
	coroutine.resume(FillWater, Folder)
end

Generation = coroutine.create(function()
	while wait(5) do
		GenerateBatch(20, false)
		wait(2)
		GenerateBatch(20, true)
		wait(3)
	end
end)

I’ll admit it’s not as efficient as I want it to be.
The water will look blocky especially in smaller spaces, it’s advised you scale it up to the level of detail you want. I also don’t recommend setting the subdivision above the size of the generator.
If the generator’s size is (10,2,10) then you shouldn’t go above a subdivision of 10.

You can also set the sphere’s transparency to 1 in the GenerateBatch function.
If you want to have the water cleared out after a certain point then you can just add this code to the end of the script.

Code
Decay = 100
Clear = coroutine.create(function()
	Run.Heartbeat:Connect(function()
		if (math.floor(workspace.DistributedGameTime * 100) % Decay) == 0 then
			Terrain:Clear()
		end
	end)
end)

coroutine.resume(Clear)

If there’s any fatal flaws, something I can improve with my presentation, or if you have any questions, please comment below. I’m lynnlo and I hope this helps you out, or atleast is interesting.

80 Likes

Thanks this helped me alot and was very good 10/10

12 Likes