Realistic Water Interaction - Splash, Trails, Bubbles (Optimized + Parallel Luau) FREE OPEN SOURCE

So I’ve been working on this new feature for the past couple days, my goal; to bring realistic water interactions to the game!

I’m not going to overexplain but please try it out!

Here are the features!

  1. Splashing
  2. Walking in shallow water VFX
  3. Underwater ColorCorrection Blur and Air Bubbles!
  4. Fully designed with performance and sounds in mind.

I’m really happy with the result, I’m giving it away for free because I had help from the community.
Realistic Water Effects Tech Demo - Roblox

I had a lot of help from the community!
These are my reference sources! I would like to give a big thanks to the Underwater Effects post for providing the solution to determine when the camera is underwater.
Underwater Effects - Resources / Community Resources - Developer Forum | Roblox
In addition, this post for inspiring me with the texture!
How to make a realistic water splash effect - Resources / Community Tutorials - Developer Forum | Roblox

NOTE: Script uses object values to determine location of effects, you may need to reset the object values if you clone the system into your game, or you can set the location of those object manually in code if that is more convenient.

8 Likes

Love this, however, when I try to move the actor instance along with everything in another place, it seems to error out at line 71, trying to index nil by cloning its child.

  10:34:40.997  Workspace.DyzuOfficial.Actor.WaterEffect:71: attempt to index nil with 'Clone'  -  Client - WaterEffect:71
  10:34:40.997  Stack Begin  -  Studio
  10:34:40.997  Script 'Workspace.DyzuOfficial.Actor.WaterEffect', Line 71  -  Studio - WaterEffect:71
  10:34:40.997  Stack End  -  Studio
2 Likes

I dont understand why that would effect them, I put them in the exact location they were in the demo place…

Could you make a model with a setup guide?

(post deleted by author)

I have updated the resource addressing a bug that would result in splashes to be underwater sometimes. This is no longer the case! Now the splashes will always appear on only the water’s surface.

The technical details are this function

function getWaterSurface(position)
	local rayOrigin = position + Vector3.new(0, height, 0) -- Start the ray above the position
	local rayDirection = Vector3.new(0, -10, 0) -- Cast the ray downwards
	local result = workspace:Raycast(rayOrigin, rayDirection, params)
	if result and result.Material == Enum.Material.Water then
		return result.Position.Y
	end

	return nil -- No water surface found at the given position
end

To illustrate the fix here’s a picture

Another bug I fixed is the freefalling state not triggering splashing, please excuse the bug fixes as this is the day 1 release!

All the issues have been addressed and the script is working as intended, if you already have a copy I would suggest updating it to the current version.

Thanks for showing interest in this project everyone! Don’t forget to leave a like :wink:
I have published important final optimizations to this project which are very important.

For those interested here is the source code! A copy and paste to your version would suffice if you needed to update to the final version. Feel free to rewrite it and use the tech for other projects.

--!native
local splashing=false
local Character=game.Players.LocalPlayer.Character
local Hum=Character.Humanoid

local Root = Character:WaitForChild("HumanoidRootPart")
local rate=.0333
local writetime=os.time()
local refreshrate=20
local params = RaycastParams.new()
task.synchronize()
params.FilterDescendantsInstances = {workspace.Terrain}--{Character, workspace:FindFirstChild("Enemys"), workspace:FindFirstChild("NPCS"), workspace:FindFirstChild("GroundItems"), workspace:FindFirstChild("Houses")}
params.FilterType=Enum.RaycastFilterType.Include

local lastpos=nil


local function multnumseq(sequence,Scale)
	task.desynchronize()
	local numberKeypoints2 = {}
	for i = 1, #sequence.Keypoints do
		local currKeypoint = sequence.Keypoints[i]
		table.insert(numberKeypoints2, NumberSequenceKeypoint.new(currKeypoint.Time,currKeypoint.Value*Scale))	
	end
	task.synchronize()
	return NumberSequence.new(numberKeypoints2)
end	

local function performRaycast(origin, direction, params)
	return workspace:Raycast(origin, direction, params)
end

local function setupRaycastParams()
	local timer=os.time()
	if timer>writetime+refreshrate then
		writetime=timer
		params = RaycastParams.new()
		task.synchronize()
		params.FilterDescendantsInstances = {workspace.Terrain}--{Character, workspace:FindFirstChild("Enemys"), workspace:FindFirstChild("NPCS"), workspace:FindFirstChild("GroundItems"), workspace:FindFirstChild("Houses")}
		params.FilterType=Enum.RaycastFilterType.Include
		task.desynchronize()
	end
	return params
end

local function detecter(region)
	local material = workspace.Terrain:ReadVoxels(region, 4)   
	local check=false
	for i,v in material do 
		if v then 
			if i~="Size" then
				for t,o in v do 
					for p,l in o do 
						if l==Enum.Material.Water then--and l~=Enum.Material.Air then
							check=true
							return check
						elseif l~=Enum.Material.Water and l~=Enum.Material.Air	then
							return false
						end
					end 
				end
			end
		end
	end
	return check
end
local function isWaterInVoxel(position)
	-- Define the region around the position
	local region = Region3.new(position-Vector3.new(2,2,2), position + Vector3.new(2,2,2)):ExpandToGrid(4)
	return detecter(region)
end

local Splash=script:WaitForChild("SplashObject").Value:Clone()
local Current=script:WaitForChild("CurrentObject").Value
local Ts = game:GetService('TweenService')

local PartsIN = {}
local splashs={}

local function RunWater(Splish,index,weld,scale)
	if weld then	
		weld=weldmotor(Character.PrimaryPart,Splish)
	end

	local origin=Splish.Attachment.Splash.Size
	local origin2=Splish.Attachment.Mist.Size
	if scale~=1 then
		Splish.Attachment.Splash.Size=multnumseq(origin,scale)
		Splish.Attachment.Mist.Size=multnumseq(origin2,scale)
		Splish.Attachment.Splash.Speed=NumberRange.new(2.5*scale)
		Splish.Attachment.Splash.Lifetime=NumberRange.new(1*scale)
		Splish.Attachment.Mist.Speed=NumberRange.new(5*scale)	
	end
	Splish.Attachment.Splash.Enabled = true
	Splish.Attachment.Mist.Enabled = true
	splashs[index].Weld=weld
	task.wait(0.35)
	if weld then
		weld.Enabled=false
		weld:Destroy()
		splashs[index].Weld=nil
		Splish.Anchored=true
	end
	Splish.Attachment.Splash.Enabled = false
	Splish.Attachment.Mist.Enabled = false
	task.wait(0.25)
	if scale~=1 then
		Splish.Attachment.Splash.Size=origin
		Splish.Attachment.Mist.Size=origin2
		Splish.Attachment.Splash.Speed=NumberRange.new(2.5)	
		Splish.Attachment.Mist.Speed=NumberRange.new(5)
	end
	splashs[index].Running=false

end

local Roothalf= -(Root.Size.Y / 2)
local lengthvector=-Hum.HipHeight--(Character.LeftUpperLeg.Size.Y+Character.LeftLowerLeg.Size.Y+Character.LeftFoot.Size.Y)-3
local Debris=game:GetService("Debris")
local currents={}
Hum:GetPropertyChangedSignal("HipHeight"):ConnectParallel(function()
	lengthvector=-Hum.HipHeight
end)
local trans
for i=1, 12 do 
	local obj=Current:Clone()
	obj.Parent=game.ReplicatedStorage
	trans=obj.Beam.Transparency
	--	obj.Anchored=false
	currents[i]={Running=false,Object=obj,}
	local obj=Splash:Clone()
	obj.Parent=game.ReplicatedStorage
	--trans=obj.Beam.Transparency
	--	obj.Anchored=false
	splashs[i]={Running=false,Object=obj}
end

local function getsplash()
	for i,v in splashs do 
		if v.Running==false then
			v.Running=true
			return v,i
		end
	end
	return nil
end
local height=math.min(20,not Character.Humanoid.UseJumpPower and Character.Humanoid.JumpHeight or Character.Humanoid.JumpPower/9)
if not Character.Humanoid.UseJumpPower and Character.Humanoid.JumpHeight then
	Hum:GetPropertyChangedSignal("JumpHeight"):ConnectParallel(function()
		height=math.min(20,not Character.Humanoid.UseJumpPower and Character.Humanoid.JumpHeight or Character.Humanoid.JumpPower/9)
	end)
else 
	Hum:GetPropertyChangedSignal("JumpPower"):ConnectParallel(function()
		height=math.min(20,not Character.Humanoid.UseJumpPower and Character.Humanoid.JumpHeight or Character.Humanoid.JumpPower/9)
	end)
end

local splashsounds={
	["Small"]={"9120584650","9119481281","9117942473","9117940939"},
	["Medium"]={"9119482201"},
	["Large"]={"9119477732"},
}
rbxasset="rbxassetid://"

local soundeb=false
local function RunEffect(Cframe,scale,welds)
	--print("Splish Splash")
	local Splisher,index=getsplash()
	if Splisher then
		local Splish=Splisher.Object
		if welds then
			Splish.Anchored=false
		end
		Splish.Parent=workspace
		local scale=scale
		if scale==nil then scale=1 end
		Splish.Size = Vector3.new(.01, .5, .01)
		Splish.CFrame= Cframe--Vector3.new(Pos.X,Splash.Position.Y,Pos.Z)
		Splish.Decal.Transparency = 0.125

		coroutine.wrap(RunWater)(Splish,index,welds,scale)
		--local height=math.min(20,not Character.Humanoid.UseJumpPower and Character.Humanoid.JumpHeight or Character.Humanoid.JumpPower/9)
		--print(height)
		if soundeb==false then
			soundeb=true
			local SoundId=(height<8 and splashsounds.Small[math.random(1,#splashsounds.Small)]) or (height<12 and splashsounds.Medium[1]) or splashsounds.Large[1]
			local Sound=Splish.Sound
			Sound.SoundId=rbxasset..SoundId
			Sound.Volume = math.random(25,35)*.01
			Sound.PlaybackSpeed = math.random(90,110)*.01
			Sound.Pitch = math.random(80,120)*.01
			Sound:Play()
			task.delay(Sound.TimeLength*.25,function()
				soundeb=false
			end)	
		end
		local timer=height*.1
		local T1 = Ts:Create(Splish,TweenInfo.new(timer,Enum.EasingStyle.Linear),{Size=Vector3.new(height*1, .5, height*.9167)*scale})
		local T2 = Ts:Create(Splish.Decal,TweenInfo.new(timer,Enum.EasingStyle.Linear),{Transparency=1})
		T1:Play()
		T2:Play()
	end
end
local running=false
local function EffectTracker(once,jump)
	--local params = setupRaycastParams()
	local Root=Character.HumanoidRootPart
	local Roothalf= -Root.Size.Y/2--not jump and -(Root.Size.Y or -(Root.Size.Y)
	local lastpos=nil
	repeat	
		--print("Detecting Water")

		local Rootpos=Root.Position
		if Rootpos~=lastpos then
			--lastpos=Rootpos
			task.synchronize()
			local result = performRaycast(Rootpos+Vector3.new(0,Roothalf,0), Vector3.new(0,lengthvector,0), params)
			task.desynchronize()
			--local otherResult = performRaycast(Root.Position, Root.CFrame.LookVector * 2, params)
			local Yoffset=Rootpos.Y + Roothalf
			--print(result)
			if result then--and not otherResult then
				local hitPart = result.Instance
				--print(hitPart)

				if (hitPart.Name=="Terrain") then--or ((Vector3.new(0, Yoffset, 0) - Vector3.new(0, hitPart.Position.Y + hitPart.Size.Y / 2, 0)).magnitude < 2)) then
					local construct=CFrame.new(result.Position)--+(result.Normal))		
					task.synchronize()
					if isWaterInVoxel(construct.Position) then
						--print("Successful Splash!")
						RunEffect(CFrame.new(Character.LeftFoot.Position.X,construct.Position.Y,Character.LeftFoot.Position.Z))
						task.desynchronize()
						task.wait(rate)
						return 	
							--elseif once then
							--	return 
					else 
						local watersurface=getWaterSurface(result.Position)
						if watersurface then
							RunEffect(CFrame.new(Character.LeftFoot.Position.X,watersurface,Character.LeftFoot.Position.Z))
							task.desynchronize()
							task.wait(rate)	
						end
						--	print("Failed splash")
					end
					--return
				end
			end
		else task.wait(rate)	
		end
		task.wait(rate)
	until splashing==false --or running)-- or Bars.Flying.Value
end


local function tweenemitter(v,Scale,duration)
	task.spawn(function()
		local base=v.Transparency
		local frame=(2/30)
		local timer=0
		local numofframes=duration/frame
		local dif=Scale-1
		repeat
			v.Transparency=multnumseq(base,1+(dif*(timer/numofframes)))
			task.wait(frame)
			timer=timer+frame
		until timer>=duration
	end)
end	

function weldmotor(Root1,Root2)
	local w=Instance.new("Motor6D")
	w.Part0,w.Part1 = Root1,Root2
	w.C0 = Root2.CFrame:toObjectSpace(Root1.CFrame):inverse()
	w.Parent = Root1
	w.Name=Root2.Name.."Joint"
	return w
end

local function animatecurrent(tbl,index)

	local currentobj=tbl.Object
	local beam=currentobj.Beam
	local attachment=currentobj.Attachment1
	local start={Width1=math.max(2,Character.Humanoid.WalkSpeed*.5),Width0=Character.LowerTorso.Size.X}
	local timer=Character.Humanoid.WalkSpeed*.125
	local ti=TweenInfo.new(timer)
	--tweenemitter(beam,5,.5)
	--local beamorigintr=beam.Transparency
	beam.Transparency=trans
	beam.Transparency=multnumseq(beam.Transparency,5)

	for t,property in start do 
		beam[t]=property
	end
	local vari=beam.Transparency
	--task.spawn(function()
	for i=1, 12 do 
		beam.Transparency=multnumseq(vari,math.max(.2,(15-i)/15))
		task.wait(.0333)
	end
	--task.delay(,function()
	--task.wait(math.max(.1,timer-(12*.033)))

	--end)
	--	tweenemitter(beam,.2,.3)
	local origin=attachment.CFrame
	task.delay(.25,function() Ts:Create(attachment,ti,{CFrame=attachment.CFrame-attachment.CFrame.Position}):Play()
	end)
	--local goal={Width1=math.min(75,Character.Humanoid.WalkSpeed*.15),Width0=1}	
	local goal={Width1=0,Width0=Character.LowerTorso.Size.X}	
	local tw=Ts:Create(beam,ti,goal)
	tw:Play()
	tw.Completed:Wait()
	beam.Transparency=trans
	for i=1, 30 do 
		beam.Transparency=multnumseq(trans,math.max(1,i*.2))
		task.wait(.0333)
	end
	--currentobj.Parent=game.ReplicatedStorage
	beam.Transparency=trans
	attachment.CFrame=origin--attachment.CFrame:ToWorldSpace(CFrame.new(6, 0, 0))
	currents[index].Running=false 
	--	end
end
function getWaterSurface(position)
	local rayOrigin = position + Vector3.new(0, height, 0) -- Start the ray above the position
	local rayDirection = Vector3.new(0, -10, 0) -- Cast the ray downwards
	local result = workspace:Raycast(rayOrigin, rayDirection, params)
	if result and result.Material == Enum.Material.Water then
		return result.Position.Y
	end

	return nil -- No water surface found at the given position
end


local function getcurrent()
	for i,v in currents do 
		if v.Running==false then
			return v,i
		end
	end
	return nil
end
local weld=false
local pos=Character.PrimaryPart.Position
local debounce2=false
local count=0

local debounce=false
local bottom=Roothalf+lengthvector

Hum.Seated:ConnectParallel(function()
	running=false
	splashing=false
end)

Hum.Died:Connect(function()
	for t,o in currents do 
		o.Object:Destroy()
		currents[t]=nil
	end
	for t,o in splashs do 
		o.Object:Destroy()
		splashs[t]=nil
	end
end)

Hum.Jumping:ConnectParallel(function()
	running=false
	--print("Splashing!")
	splashing=true
	--task.wait(rate)
	swimming=false
	--EffectTracker()
	--	task.wait(rate)
	EffectTracker()
	for i,v in splashs do 
		if v.Weld then
			task.synchronize()
			v.Weld:Destroy()
			v.Object.Anchored=true
			--v.Parent=game.ReplicatedStorage

		end
	end
	for i,v in currents do 
		if v.Weld then
			task.synchronize()
			v.Weld:Destroy()
			v.Object.Anchored=true
			--v.Parent=game.ReplicatedStorage

		end
	end




end)

Hum.FreeFalling:ConnectParallel(function()
	splashing=true
	running=false
	
	swimming=false
	EffectTracker()
end)
Hum.Swimming:ConnectParallel(function()
	if swimming==false then
	running=false
	swimming=true
		EffectTracker(true)
	end
end)

Hum.Running:ConnectParallel(function()
	running=true
	swimming=false
	if debounce==false then--and getWaterSurface(Character.PrimaryPart.Position) then
		debounce=true
		task.desynchronize()	
		repeat
			--RunEffect(Current.CFrame,.35)
			if pos~=Character.PrimaryPart.Position then
				--count=0
				pos=Character.PrimaryPart.Position
				local interest=Character.PrimaryPart.CFrame
				local direction=(interest:ToWorldSpace(CFrame.new(0,bottom,-2))-interest.Position).Position
				task.synchronize()	
				local result = performRaycast(interest.Position, direction, params)
				if result and result.Instance and result.Instance.Name=="Terrain" then
					if isWaterInVoxel( interest:ToWorldSpace(CFrame.new(0,bottom+2,0)).Position) then
						--splashing=true
						task.desynchronize()	
						--splashing=false
						local construct=CFrame.new(result.Position)--+(result.Normal))		
						local lookdirection=interest:ToWorldSpace(CFrame.new(0,0,-5)).Position
						local Currenter,index= getcurrent()
						local Currentss={}	
						local watersurface=getWaterSurface(result.Position)
						Currentss.CFrame=CFrame.new(Vector3.new(interest.Position.X,watersurface and watersurface+.15 or construct.Position.Y+.15,interest.Position.Z),Vector3.new(lookdirection.X,construct.Position.Y,lookdirection.Z))	
						if Currenter and debounce2==false then
							debounce2=true
							if currents[index].Running==false then
								local Current=Currenter.Object

								task.synchronize()
								if Current.Anchored then Current.Anchored=false end 								
								Current.CFrame=Currentss.CFrame
								Current.Parent=Character
								if running==false then running=true
									RunEffect(Current.CFrame)
								end
								currents[index].Running=true
								local weld=weldmotor(Character.PrimaryPart,Current)
								currents[index].Weld=weld

								task.spawn(function()
									animatecurrent(Currenter,index)

									weld:Destroy()
									currents[index].Weld=nil

								end)
								task.delay(.6,function()
									debounce2=false
								end)
								task.wait(.1)
							end
						end
						task.synchronize()
						--if splashing==false then
						RunEffect(Currentss.CFrame,.5,true)
						--end


					else 
						--print("No water")
						--splashing=false
						for i,v in currents do 
							if v.Weld then
								task.synchronize()
								v.Weld.Enabled=false
								v.Weld:Destroy()							
								v.Object.Anchored=true
								--v.Parent=game.ReplicatedStorage
							end
						end
						--Current.Parent=game.ReplicatedStorage
					end

				else
					for i,v in currents do 
						if v.Weld then
							task.synchronize()
							v.Weld.Enabled=false
							v.Weld:Destroy()							
							v.Object.Anchored=true
							--v.Parent=game.ReplicatedStorage
						end
					end

					-- Current.Parent=game.ReplicatedStorage	
				end 
			else 
				--count+=1
				--if count>=10 then
				--splashing=false
				for i,v in currents do 
					if v.Weld then
						task.synchronize()
						v.Weld.Enabled=false
						v.Weld:Destroy()
						v.Object.Anchored=true
						--v.Parent=game.ReplicatedStorage
					end
				end
				--end
				--running=false
			end
			task.wait(.1)
		until running==false
		debounce=false
		task.desynchronize()
	end
	splashing=false
	--splashing=false
end)
--Can be in a seperate Script
local DetectTerrainWater = true
local UnderwaterReverb = true
local DetectPartWater = true

local Blur = game.Lighting:FindFirstChild("Blur") or Instance.new("BlurEffect", game.Lighting)
Blur.Name="Blur"
Blur.Size=10
Blur.Enabled = false

local ColorCorrection = game.Lighting:FindFirstChild("ColorCorrection") or Instance.new("ColorCorrectionEffect", game.Lighting)
ColorCorrection.Name="ColorCorrection"
ColorCorrection.Enabled = false
ColorCorrection.TintColor = Color3.fromRGB(99, 151, 213)
ColorCorrection.Contrast = 0.5
ColorCorrection.Brightness = .75
ColorCorrection.Saturation = 0.6

local UnderwaterAmbienceSound = game.Players.LocalPlayer.PlayerGui:FindFirstChild("UnderwaterAmbienceSound") or Instance.new("Sound", workspace.CurrentCamera)
UnderwaterAmbienceSound.Name = "UnderwaterAmbienceSound"
UnderwaterAmbienceSound.SoundId = "rbxassetid://4626145950"
if not UnderwaterAmbienceSound.IsLoaded then
	UnderwaterAmbienceSound.Loaded:Wait()
end
UnderwaterAmbienceSound.Volume = 0
UnderwaterAmbienceSound.Parent=game.Players.LocalPlayer.PlayerGui
UnderwaterAmbienceSound.Looped = true
UnderwaterAmbienceSound:Play()

local AirBubbles=game.Players.LocalPlayer.Character:WaitForChild("Head"):FindFirstChild("AirBubble") or script.AirObject.Value.AirBubble:Clone()
AirBubbles.Parent=game.Players.LocalPlayer.Character:WaitForChild("Head")
AirBubbles.Bubble.Enabled=false
local state=false
local LastReverb = nil
local constant= Vector3.new(4, 4, 4)

workspace.CurrentCamera:GetPropertyChangedSignal("CFrame"):ConnectParallel(function()
	local WaterFound = false
	if DetectTerrainWater then
		local pos=workspace.CurrentCamera.CFrame.Position+(constant*.5)
		local region = Region3.new(pos, pos+constant)
		local materials, occupancies = workspace.Terrain:ReadVoxels(region:ExpandToGrid(4), 4)
		local size = materials.Size
		for x = 1, size.X, 1 do
			for y = 1, size.Y, 1 do
				for z = 1, size.Z, 1 do
					if materials[x][y][z].Name == "Water" then
						WaterFound = true
						break
					end
				end
			end
		end
	end
	--if WaterFound and AirBubbles.Bubble.Enabled==false then
	--
	--	task.delay(3,function() AirBubbles.Bubble.Enabled=true end)
	--	end
	if WaterFound then
	local torsowater= WaterFound and AirBubbles.Bubble.Enabled==false and  isWaterInVoxel(Character.HumanoidRootPart.Position) 
	if  torsowater then
		--	
		task.synchronize()
		AirBubbles.Bubble.Enabled=true
		task.desynchronize() 
		--elseif  isWaterInVoxel(Character.HumanoidRootPart.Position)  and not  isWaterInVoxel(Character.HumanoidRootPart.Position+constant)   then
		--running=true
		--swimefx(nil)	 	
	end
	end
	if WaterFound and state==false then
		state=true
		task.synchronize()

		if UnderwaterReverb then
			if game.SoundService.AmbientReverb ~= Enum.ReverbType.UnderWater then LastReverb = game.SoundService.AmbientReverb end
			game.SoundService.AmbientReverb = Enum.ReverbType.UnderWater
		end
		UnderwaterAmbienceSound.Volume = math.random(25,35)*.01
		UnderwaterAmbienceSound.PlaybackSpeed = math.random(90,110)*.01
		UnderwaterAmbienceSound.Pitch = math.random(80,120)*.01
		ColorCorrection.Enabled = true
		Blur.Enabled = true
		task.desynchronize()
	elseif state==true and not WaterFound then
		state=false
		task.synchronize()
		AirBubbles.Bubble.Enabled=false
		if UnderwaterReverb then
			game.SoundService.AmbientReverb = LastReverb or Enum.ReverbType.NoReverb
		end
		UnderwaterAmbienceSound.Volume = 0
		ColorCorrection.Enabled = false
		Blur.Enabled = false
		task.desynchronize()
	end
end)

The final updates are the result of implementing this into various projects and fixing any edge case issues I find so if you find any bugs please share them!