Dynamic snowing effect (Particles)

Hi
I have created a snowing effect that uses ParticleEmitters in conjunction with RayCasts to dynamically change the LifeTime of the particles. This way snow doesn’t fall underneath a roof.

Pictures:

The Emitter is constructed of different (15x15) tiles. Each of these tiles has a ParticleEmitter that has it’s LifeTime changed based on the tile’s distance from the ground.

The entire construction moves along with the player (it doesn’t rotate with the player!). This way the snow is always above the player’s character.

As this uses ParticleEmitters it also comes with their disadvantages being:

  • LifeTime is limited, so particles might not completely reach the ground.

  • Amount of emitted particles change on lower graphics settings.

Other inconveniences with this system:

  • A change to the ParticleEmitters must be done multiple times for every emitter

A command to automate this:

for _, v in pairs(workspace.SnowEmitter:GetDescendants()) do 
    if v:IsA("ParticleEmitter") then 
        v.Size = 2
    end 
end
  • Possible performance issues on lower-end devices (change RAYCAST_FRAME_UPDATE to be higher or BATCH_UPDATE_RAYCAST to be lower).

If you quickly want to browse the code:

local RAYCAST_FRAME_UPDATE = 10 -- Number of frames before every raycast (batch) update
local BATCH_UPDATE_RAYCAST = 1 -- Number of parts to raycast from every raycast update

local snowEmitter = workspace.SnowEmitter
local LocalPlayer = game:GetService("Players").LocalPlayer

local partTable = {}
for _, v in pairs(snowEmitter:GetDescendants()) do
	if v:IsA("BasePart") and v.Name ~= "RootPart" then
		table.insert(partTable, v)
	end
end
local newTable = {}

local raycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Blacklist
raycastParams.FilterDescendantsInstances = partTable
raycastParams.IgnoreWater = false

local function copyTable(src, dest)
	for index, value in pairs(src) do
		dest[index] = value
	end
end

local raycastFrameUpdateCounter = 0
game:GetService("RunService").Heartbeat:Connect(function()
	-- Move model
	local char = LocalPlayer.Character
	if char and char:FindFirstChild("HumanoidRootPart") then
		local position = char.HumanoidRootPart.Position
		snowEmitter:SetPrimaryPartCFrame(CFrame.new(0, 30, 0) + position)
	end
	
	-- Raycast
	raycastFrameUpdateCounter += 1
	if raycastFrameUpdateCounter >= RAYCAST_FRAME_UPDATE then
		raycastFrameUpdateCounter = 0
		
		local used = {}
		-- Raycast (in batch of BATCH_UPDATE_RAYCAST)
		if #newTable <= 0 then
			copyTable(partTable, newTable)
		end

		local batchCounter = 0
		for _, v in pairs(newTable) do
			batchCounter += 1
			if batchCounter >= BATCH_UPDATE_RAYCAST then
				local particleEmitter = v.ParticleEmitter
				local raycastResult = workspace:Raycast(v.Position, Vector3.new(0, -100, 0), raycastParams)
				if raycastResult and raycastResult.Position then
					local lifeTime = (raycastResult.Position - v.Position).Magnitude / particleEmitter.Speed.Max
					particleEmitter.Lifetime = NumberRange.new(lifeTime - 5, lifeTime)
					table.insert(used, v)
				end
			end
		end
		-- Remove used parts from table
		for _, v in pairs(used) do
			local found = table.find(newTable, v)
			if found then
				table.remove(newTable, found)
			end
		end	
	end
end)

This code assumes the model named “SnowEmitter” is inside of workspace. This should be a LocalScript!

Here is a place file that should be ready to use:
ParticleSnowDemo.rbxl (26.4 KB)

Keep in mind that some scripting knowledge is needed to implement this effect (aka: enable, disable). There is no API provided, only the script and the model.

Feel free to use this inside of your game, no need to give me credit. I would love to see what you make with this! Please post any bugs, issues, or suggestions to improve this system down below.

48 Likes

Thank you - this script felt a little bit unique and better than all the other ones.

1 Like