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.
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.
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.
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.
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.