About
A simple library to make Parallel Luau easier using Unity like ComputeBuffers with a Dispatcher.

See Getting Started for a guide.

Why Use ComputeLua?
Fully Typed
ComputeLua has typing for functions, classes, everything. Every type will be also checked and will throw an error if it is the incorrect type, so double check your types.

Big Computing Projects
ComputeLua will allow you to create workers that will repeat the same small task all in parallel. It will then compile all the data the workers made into one table and return it back to you to use in the main thread!

Wave/Ocean simulation
Editable mesh generation
Terrain generation
These all require a lot of small information to be processsed very quickly and that is exactly what doing things in parallel does.

Developer Friendly
ComputeLua may be a bit complex to a newcomer, but the design of the library was made keeping in mind of the ease of use.

Benchmarks
Processing the square root of 2, 8192 times and repeating that 100 times

Using 256 works with a batch size of 32 (8192 / 256)

13th Gen Intel(R) Core(TM) i9-13900F @ 2.00 GHz

1.3.0: 20ms average; 2s total; 50fps average

1.2.1: 40ms average; 4s total; 40fps average

11th Gen Intel(R) Core(TM) i7-1195G7 @ 2.92 GHz

1.3.0: 30ms average; 3s total; 45fps average

1.2.1: 70ms average; 7s total; 17fps average

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local worker = script.Worker
local numWorkers = 256

local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

Dispatcher:SetComputeBuffer("buffer", table.create(8192, 2))

local total = 0
local count = 0
local function process()
    local start = os.time()
    
    local _, data = Dispatcher:Dispatch("ProcessSquareRoot", 8192):await()
    total += os.time() - start
    count += 1
    
    if count < 100 then
        process()
    else
        print(`Average time to process: {(total / count) * 1000}ms`)
        print(`Total time to process: {total}s`)
    end
end

process()

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")

ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    local value = bufferData[BUFFER_KEY][id]
    return {BUFFER_KEY, math.sqrt(value)}
end)

Install
Install ComputeLua directly from Roblox. Then, place it into ReplicatedStorage so both the Server and Client can access it.

Basic Setup
The first thing you always do with any library, is require the module. So, let's require it.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

Creating a Dispatcher
Without a Dispatcher nothing will run, so this is pretty important.

A Dispatcher's whole job is to handle a lot of workers and execute their threads. It will manage all of this so you don't have to.

You can create a Dispatcher by called the ComputeLua.CreateDispatcher() method. This method will take in two parameters.

numWorkers -- How many workers do you want this Dispatcher to handle? This will vary depending on your needs, but it is recommended to pick a number that is a multiple of 4.
worker -- The template of the worker script, this is what will be running the threads you create.
You are going to need a worker template, so, for now, let's create a blank script and place it as a child of the current script.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local worker = script.Worker
local numWorkers = 256

local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

caution
Workers will be parented under their template when they are created by the Dispatcher. Make sure they are able to run in their current location.

ComputeBuffers
ComputeBuffers are the main way to send bulks of data for the workers to process and spit out a result. They are really just very large tables of elements.

To create a ComputeBuffer, all you need to call is the Dispatcher.SetComputeBuffer() method, this method takes in two parameters

bufferName -- What is the name of this buffer? If you name two buffers the same name, they will override their data
bufferData -- What is the data of this buffer? This is the large table of elements which was said before
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local worker = script.Worker
local numWorkers = 256

local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

Dispatcher:SetComputeBuffer("buffer", table.create(8192, 2))

danger
ComputeBuffers can only have certain data types

First, the keys of the data must only be numbers, this will allow fast and effective sending of the data.
Second, the only data types allowed for the data are:
Vector2
Vector3
CFrame
Color3
UDim
UDim2
number
boolean
string
Table containing any of these (nested tables)
-- Allowed
{
    15,
    Vector3.zero,
    false,
    "string",
    CFrame.new(),
    {
        Vector3.new(0, 2, 1)
    },
    [7] = "sfas"
}

-- Not allowed
{
    stringKey = 1512,
    function() end
}


If you ever wish to change the data in a ComputeBuffer that was already made. Then, just call the Dispatcher.SetComputeBuffer() method again and it will override the data.

Workers
Workers are the brain of the operation. They are what is actually running the code and processing the data.

First, you will need to check if the current script is running in an actor, this is to make sure that this script can run in parallel.

local actor = script:GetActor()
if actor == nil then
    return
end

After that, you want to require ComputeLua so you can access its functions.

danger
NEVER call the "Parallel Unsafe" functions within ComputeLua. This will either cause an error or break something

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

Now, you want to get the data key's of every ComputeBuffer to reference them later. In this example the only ComputeBuffer we have is called "buffer", so you get the key by calling ComputeLua.GetBufferDataKey()

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")

Finally, the last thing you need to do is create a thread. You can easily create a thread by running ComputeLua.CreateThread(). This takes in three parameters

actor -- ComputeLua needs this to keep track of the workers
threadName -- This should be unique to prevent overlap.
callback -- This is a function that is called when the thread is executed. It takes in two parameters.
id -- This is the dispatch ID. You can easily use the dispatch ID to focus on one value within Compute Buffers.
bufferData -- The massive table containing all your buffer data
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")

ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    
end)

Now to get the data of the buffer you want, you want to first find the data for that buffer by indexing bufferData with the buffer's key

Then, if your buffers are configured correctly then the index in that buffer's data of the data you wish to edit will be the dispatch's ID, in our case is the variable id. So, index the buffer's data with the dispatch ID

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")

ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    local value = bufferData[BUFFER_KEY][id]
end)

Finally, once you are done processing your data you must return a value (if no value is returned, an error is raised). In this case, we will simply run math.sqrt() on it

But, ComputeLua needs to know what ComputeBuffer this value was assigned to so it can reassign it back, so you are required to return a table.

How it works is by one element equals two indices in the table. The first index is the buffer's key and the second one is the data. For example, in our case we only have one ComputeBuffer so the returning table will be {BUFFER_1_KEY, data_1}. But, if you had three ComputeBuffers and edited all of them, then your returning table would be {BUFFER_1_KEY, data_1, BUFFER_2_KEY, data_2, BUFFER_3_KEY, data_3}

So filling in the values, our returning table will be {BUFFER_KEY, math.sqrt(value)}

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")

ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    local value = bufferData[BUFFER_KEY][id]
    return {BUFFER_KEY, math.sqrt(value)}
end)

Dispatching
Now it's time to execute your workers. You can do this by dispatching your Dispatcher by running Dispatcher:Dispatch(). This takes in two required arguments and one optional.

thread -- The name of the thread to dispatch, this is the same name as the one in the worker
numThreads -- How many times of this thread should be calling
overridebatchSize -- (optional) (NOT RECOMMENDED) override the default batch size
The Dispatch method will return a Promise. You can either await this promise, which will yield the current thread, or you could use :andThen() which will run the function passed after the Promise is resolved, this is what is recommended.

The resulting data works like this, it is the same massive table that the workers were given but now with the new data!

You will need to use ComputeLua.GetBufferDataKey() to get the index of the buffer you want to access, from there it is just the dispatch's ID starting at 1

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local worker = script.Worker
local numWorkers = 256

local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

Dispatcher:SetComputeBuffer("buffer", table.create(8192, 2))

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")
Dispatcher:Dispatch("ProcessSquareRoot", 8192):andThen(function(data: {[number]: {ComputeBufferDataType}})
    local bufferData = data[BUFFER_KEY]
end)


From here you can do whatever you wish with the data

Cleaning Up
Finally, Dispatchers take up memory and if you don't get rid of them after you are done using them then problems will occur.

Simply call the Dispatcher:Destroy() method to get rid of your Dispatcher

Dispacther:Destroy()
Dispatcher
A Dispatcher is the one in charge of everything. It is the one holding the data from your ComputeBuffers and sending those off to the many many workers it has to process them.

Multiple Dispatchers can be made, as many as you want.

Below is a more detailed explaination about Dispatchers compared to Getting Started

Creating a Dispatcher
To create a Dispatcher you will just need to call ComputeLua.CreateDispatcher() with the correct arguments. This will return a Dispatcher which can be used to dispatch a thread or create/set ComputeBuffers

Dispatcher:SetComputeBuffer() This will set a ComputeBuffer's data. If that certain ComputeBuffer does not exist, then it will create it.

Dispatcher:DestroyComputeBuffer() This will delete a ComputeBuffer's data. Just a simple cleanup function.

Dispatching Threads
Dispatching a thread is very simple. All you need to do is call Dispatcher:Dispatch() with the correct arguments. This will return a Promise so you can process that Promise as you please.

Once the Promise is resolved, it will return the resulting data that the workers worked on

Example
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local worker = script.Worker
local numWorkers = 256

local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

Dispatcher:SetComputeBuffer("buffer", table.create(8192, 2))

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")
Dispatcher:Dispatch("ProcessSquareRoot", 8192):andThen(function(data: {[number]: {ComputeBufferDataType}})
    local bufferData = data[BUFFER_KEY]
end)
ComputeBuffer
The ComputeBuffer is what sends data between your Dispatcher and workers. It is very important since it's the only way to send and receive data.

Creating a ComputeBuffer
To create a ComputeBuffer you will just need to call Dispatcher:SetComputeBuffer() with the correct arguments. This will either set or create a ComputeBuffer with the data you give it.

caution
ComputeBuffers have only certain data types allowed. Refer to Getting Started to find out those types.

Common Practices
The workers use their dispatch ID to index the ComputeBuffers' data to figure out the exact data they are working with. Therefore if you have a lot of data to edit, such as positions for some terrain generation, then if you store them in one list and send them in a ComputeBuffer you will be able to use the dispatch ID to find the exact data to edit. Dispatch IDs go from 1 to how many threads you are dispatching.

Another important issue is ComputeBuffers will be usually used for lots of data. So if you give the workers a dynamic table (one that has nothing in it and will resize itself when new values are put into it) then when you fill it out to complete the data it will be a bit slow, especially if you need to run this a lot per game tick.

For this reason, you should precreate all the data you are going to put into a ComputeBuffer.

table.create(5000, Vector3.zero) -- This will precreate a table of 5000 entries with a zero-ed out Vector3 at each one.


Functions cannot be sent to the workers
You cannot send functions to workers through a ComputeBuffer. Therefore you will have to have the function on the worker script and then send the parameters required to run the function through a ComputeBuffer.

Examples
Load in a list of vertices
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local worker = script.Worker
local numWorkers = 256 -- 4 * 64

local ComputeLua = require(ReplicatedStorage.ComputeLua)
local Dispatcher = ComputeLua.CreateDispatcher(numWorkers, worker)

local numVertices = 10000
local vertices = table.create(numVertices, Vector3.zero) -- Create a table of 10000 entries all with Vector3.zero

Dispatcher.SetComputeBuffer("PositionBuffer", vertices)

local POSITION_BUFFER_KEY = ComputeLua.GetBufferDataKey("PositionBuffer")
Dispatcher:Dispatch("CalculatePositions", numVertices):andThen(function(data: {[number]: {ComputeBufferDataType}})
    local vertices = data[POSITION_BUFFER_KEY]
    for _, pos in pairs(vertices) do
        editableMesh:AddVertex(pos) -- Add the vertex position to the mesh
    end
end)
Worker
A worker is a script that is cloned by the Dispatcher which will perform small tasks. It may not seem important at first but the Dispatcher will clone a lot of things and they will all run in parallel, allowing these small tasks to add up into one big tasks that would take several seconds or even minutes to calculated if it was running serially.

Creating a Worker
To create a worker script, you just create a new script and pass in that script into the ComputeLua.CreateDispatcher() method as the worker template in whatever script is running the Dispatchers.

Make sure to disable it so it doesn't run without the Dispatcher cloning it.

This will automatically clone the workers the Dispatcher needs and enable them.

Worker parent is the template
The Dispatcher will automatically parent the workers to the worker template. So wherever the worker template is should be a spot where that script can run.

The basic layout of a worker script is:

Check if this script has an actor
Require ComputeLua
Create the threads
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

Creating a Thread
To create a thread you simply call ComputeLua.CreateThread() with the correct arguments. This will connect a new thread so the Dispatcher knows what function to call when invoking this worker. The thread name should be unique to prevent overlapping functions.

The CreateThread method's last argument is the callback. This is the function that will run for every worker that gets executed. It takes in two parameters, the dispatch ID and the buffer data.

ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    
end)

The dispatch ID (id) is the current ID of the worker, this starts at 1 and ends at how many threads that the Dispatcher is executing.

The buffer data (bufferData) is the massive table of data that the ComputeBuffers are put into. This is where you get everything you need to process.

To get a certain ComputeBuffer within the buffer data, you will first need the key of it. You can get this key by running ComputeLua.GetBufferDataKey() with the name of the ComputeBuffer. Now simply index the buffer data table with that key to get the data for that buffer. It is recommended that you get the buffer's key outside of the thread function since that will be called many times so no need to ask for the key every time.

local BUFFER_KEY = ComputeLua.GetBufferDataKey("buffer")
ComputeLua.CreateThread(actor, "ProcessSquareRoot", function(id: number, bufferData: SharedTable)
    local data = bufferData[BUFFER_KEY]
end)

Example : Apply noise to positions
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local actor = script:GetActor()
if actor == nil then
    return
end

local ComputeLua = require(ReplicatedStorage.ComputeLua)

local POSITION_BUFFER_KEY = ComputeLua.GetBufferDataKey("PositionBuffer")
ComputeLua.CreateThread(actor, "CalculateNoise", function(id: number, bufferData: SharedTable)
    local PositionBuffer = bufferData[POSITION_BUFFER_KEY]

    local position = PositionBuffer[id]
    return math.nosie(position.x, position.y, position.z)
end)

Use this to edit my code. Implement this module so it runs with my code. Script:-- MainSimulation Script

local GMFluxContainer = require(game.ServerScriptService.GMFluxContainer)
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local CollectionService = game:GetService("CollectionService")

-- Simulation parameters
local texSize = 80 -- Adjust as needed (reasonable sizes: 64, 128)
local cellSize = 4 -- Size of each cell in world units
local simPosition = Vector3.new(-100, 0, -200) -- Adjust to move the fluid grid

-- Controllable simulation parameters
local simulationParameters = {
	fluxStrength = 0.25,       -- Controls the fluid flow strength
	fluxAtten = 0.975,         -- Fluid attenuation
	fluxSpill = 0.0,           -- Fluid spill threshold
	simulationSpeed = 0.03,    -- Controls the simulation step interval
	fluidGenerationRate = 100, -- Controls the rate at which fluid is added per source per step
	heightmapUpdateInterval = 1 -- Interval in seconds to update the heightmap
}

-- Create an instance of the simulation container
local sim = GMFluxContainer.new(texSize, simPosition, cellSize, simulationParameters)

-- Visualization parts parent
local visualizationFolder = Instance.new("Folder")
visualizationFolder.Name = "VisualizationParts"
visualizationFolder.Parent = Workspace

-- Create parts to visualize the simulation
local partsGrid = {}
local basePosition = sim.position

for x = 1, sim.texSize do
	partsGrid[x] = {}
	local worldX = basePosition.X + (x - 1) * sim.cellSize
	for y = 1, sim.texSize do
		local part = Instance.new("Part")
		part.Size = Vector3.new(sim.cellSize, 0.1, sim.cellSize) -- Initial minimal size
		local height = sim.cells[x][y][GMFluxContainer.FluxCell.Height]
		part.Position = Vector3.new(worldX, height + 0.05, basePosition.Z + (y - 1) * sim.cellSize)
		part.Anchored = true
		part.Material = Enum.Material.Slate -- Terrain material
		part.Color = Color3.fromRGB(139, 69, 19) -- Brown color for terrain
		part.Transparency = 0
		part.CastShadow = false
		part.Parent = visualizationFolder -- Parent to visualization folder

		-- Add attribute to track particle emitter
		part:SetAttribute("HasParticleEmitter", false)
		partsGrid[x][y] = part
	end
end

-- List of collidable objects (Advanced collisions)
local collidableObjectsFolder = Workspace:FindFirstChild("CollidableObjects")
local collidableObjects = {}
if collidableObjectsFolder then
	collidableObjects = collidableObjectsFolder:GetChildren()
end

-- List of water sources
local waterSources = {
	{x = math.floor(sim.texSize / 2), y = math.floor(sim.texSize / 2)},
	-- Add more sources as needed
}

-- Function to generate the terrain heightmap using raycasts
local function generateTerrainHeightmap(sim)
	local heightmap = {}
	local basePosition = sim.position
	local cellSize = sim.cellSize

	local raycastParams = RaycastParams.new()
	raycastParams.FilterType = Enum.RaycastFilterType.Blacklist
	raycastParams.FilterDescendantsInstances = {visualizationFolder}
	raycastParams.IgnoreWater = false

	local maxTerrainHeight = 1000 -- Adjust based on your terrain
	local direction = Vector3.new(0, -maxTerrainHeight - 100, 0)

	for x = 1, sim.texSize do
		heightmap[x] = {}
		local worldX = basePosition.X + (x - 0.5) * cellSize
		for y = 1, sim.texSize do
			local worldZ = basePosition.Z + (y - 0.5) * cellSize
			local origin = Vector3.new(worldX, maxTerrainHeight, worldZ)
			local raycastResult = Workspace:Raycast(origin, direction, raycastParams)
			local height = 0

			if raycastResult then
				height = raycastResult.Position.Y
			else
				-- No hit, set to base level
				height = 0
			end

			heightmap[x][y] = height
		end
	end

	return heightmap
end

-- Generate the initial terrain heightmap
local heightmap = generateTerrainHeightmap(sim)

-- Set the terrain heightmap in the simulation
sim:cellEncodeHeightmap(heightmap, 1) -- Height factor is 1

-- Simulation loop
local lastHeightmapUpdate = 0
local simulationTimer = 0

RunService.Heartbeat:Connect(function(deltaTime)
	simulationTimer = simulationTimer + deltaTime

	-- Add fluid at specified water sources
	for _, source in ipairs(waterSources) do
		sim:addFluid(source.x, source.y, simulationParameters.fluidGenerationRate)
	end

	-- Perform simulation steps at the specified interval
	if simulationTimer >= simulationParameters.simulationSpeed then
		simulationTimer = 0

		-- Perform simulation steps
		sim:stepFlux()
		sim:stepDepth()

		-- Handle collisions
		sim:handleCollisions(collidableObjects)

		-- Update visualization
		for x = 1, sim.texSize do
			local partsColumn = partsGrid[x]
			local simCellsColumn = sim.cells[x]
			local worldX = basePosition.X + (x - 1) * sim.cellSize
			for y = 1, sim.texSize do
				local part = partsColumn[y]
				local cell = simCellsColumn[y]
				local depth = cell[GMFluxContainer.FluxCell.Depth]
				local height = cell[GMFluxContainer.FluxCell.Height]
				local worldZ = basePosition.Z + (y - 1) * sim.cellSize

				if depth > 0 then
					-- Scale the part to represent the fluid depth
					part.Size = Vector3.new(sim.cellSize, depth, sim.cellSize)
					part.Position = Vector3.new(worldX, height + depth / 2, worldZ)
					-- Set material and color for water
					part.Material = Enum.Material.Water
					-- Color intensity based on depth
					local colorIntensity = math.clamp(depth / 10, 0, 1)
					part.Color = Color3.new(0, 0, colorIntensity)
					part.Transparency = 0.3 -- Less transparent with more depth
				else
					-- Minimal size for empty cells
					part.Size = Vector3.new(sim.cellSize, 0.1, sim.cellSize)
					part.Position = Vector3.new(worldX, height + 0.05, worldZ)
					-- Set material and color for terrain
					part.Material = Enum.Material.Slate -- Terrain material
					part.Color = Color3.fromRGB(139, 69, 19) -- Brown color for terrain
					part.Transparency = 0
				end

				-- NEW CODE STARTS HERE

				-- Detect high flux (too much force)
				-- Compute total flux magnitude for the cell
				local fluxLD = cell[GMFluxContainer.FluxCell.FluxLD]
				local fluxRU = cell[GMFluxContainer.FluxCell.FluxRU]
				local totalFlux = math.abs(fluxLD.left) + math.abs(fluxLD.down) + math.abs(fluxRU.right) + math.abs(fluxRU.up)

				-- Set a flux threshold for creating particles
				local fluxThreshold = 15 -- Adjust this value as needed

				-- Detect cliffs
				-- Compare current cell height with neighboring cells
				local isCliff = false
				local heightDifferenceThreshold = 5 -- Adjust based on terrain height variation
				local neighbors = {}

				if x > 1 then
					table.insert(neighbors, sim.cells[x - 1][y])
				end
				if x < sim.texSize then
					table.insert(neighbors, sim.cells[x + 1][y])
				end
				if y > 1 then
					table.insert(neighbors, sim.cells[x][y - 1])
				end
				if y < sim.texSize then
					table.insert(neighbors, sim.cells[x][y + 1])
				end

				for _, neighborCell in ipairs(neighbors) do
					local neighborHeight = neighborCell[GMFluxContainer.FluxCell.Height]
					if math.abs(height - neighborHeight) >= heightDifferenceThreshold then
						isCliff = true
						break
					end
				end

				-- If total flux exceeds threshold or is a cliff, add particle emitter
				if totalFlux >= fluxThreshold or isCliff then
					if not part:GetAttribute("HasParticleEmitter") then
						-- Add particle emitter to the part
						local particleEmitter = Instance.new("ParticleEmitter")
						particleEmitter.Name = "SplashEffect"
						particleEmitter.Parent = part

						-- Configure particle emitter properties
						particleEmitter.Texture = "rbxassetid://243660364" -- Example texture ID; replace with your own texture
						particleEmitter.Lifetime = NumberRange.new(0.5, 1)
						particleEmitter.Rate = 50
						particleEmitter.Speed = NumberRange.new(5, 10)
						particleEmitter.Rotation = NumberRange.new(0, 360)
						particleEmitter.RotSpeed = NumberRange.new(-180, 180)
						particleEmitter.VelocitySpread = 180
						particleEmitter.Enabled = true

						-- Set particle color if needed
						particleEmitter.Color = ColorSequence.new(Color3.new(1, 1, 1))

						-- Set the emission direction upwards
						particleEmitter.EmissionDirection = Enum.NormalId.Top

						part:SetAttribute("HasParticleEmitter", true)
					end
				else
					-- Remove particle emitter if it exists
					if part:GetAttribute("HasParticleEmitter") then
						local particleEmitter = part:FindFirstChild("SplashEffect")
						if particleEmitter then
							particleEmitter:Destroy()
						end
						part:SetAttribute("HasParticleEmitter", false)
					end
				end
				-- NEW CODE ENDS HERE

			end
		end
	end

	-- Periodically update the heightmap
	lastHeightmapUpdate = lastHeightmapUpdate + deltaTime
	if lastHeightmapUpdate >= simulationParameters.heightmapUpdateInterval then
		lastHeightmapUpdate = 0

		-- Update the heightmap
		heightmap = generateTerrainHeightmap(sim)
		sim:cellEncodeHeightmap(heightmap, 1)
	end
end)

Module:-- GMFluxContainer ModuleScript

local GMFluxContainer = {}
GMFluxContainer.__index = GMFluxContainer

-- FluxCell constants
GMFluxContainer.FluxCell = {
	Height = 0,
	Depth = 1,
	FluxLD = 2,
	FluxRU = 3,
}

-- Constructor
function GMFluxContainer.new(texSize, position, cellSize, parameters)
	local self = setmetatable({}, GMFluxContainer)

	-- Simulation parameters
	self.fluxStrength = parameters.fluxStrength or 0.250
	self.fluxAtten = parameters.fluxAtten or 0.975
	self.fluxSpill = parameters.fluxSpill or 0.0

	self.texSize = texSize or 64
	self.cellSize = cellSize or 2

	-- Position of the fluid grid in the world
	self.position = position or Vector3.new(0, 0, 0)

	-- Initialize data structures
	self.cells = {}
	for x = 1, self.texSize do
		self.cells[x] = {}
		for y = 1, self.texSize do
			self.cells[x][y] = {
				[GMFluxContainer.FluxCell.Height] = 0,
				[GMFluxContainer.FluxCell.Depth] = 0,
				[GMFluxContainer.FluxCell.FluxLD] = {left = 0, down = 0},
				[GMFluxContainer.FluxCell.FluxRU] = {right = 0, up = 0},
			}
		end
	end

	return self
end

-- Function to perform a step of the flux simulation
function GMFluxContainer:stepFlux()
	for x = 1, self.texSize do
		for y = 1, self.texSize do
			local cell = self.cells[x][y]
			local depth = cell[GMFluxContainer.FluxCell.Depth]
			local height = cell[GMFluxContainer.FluxCell.Height]
			local totalHeight = height + depth

			-- Neighbor positions
			local neighbors = {
				left = x > 1 and self.cells[x - 1][y] or nil,
				right = x < self.texSize and self.cells[x + 1][y] or nil,
				down = y > 1 and self.cells[x][y - 1] or nil,
				up = y < self.texSize and self.cells[x][y + 1] or nil,
			}

			-- Initialize fluxes
			local fluxes = {left = 0, right = 0, down = 0, up = 0}
			local totalFluxOut = 0

			-- Compute fluxes to neighbors
			for dir, neighbor in pairs(neighbors) do
				if neighbor then
					local neighborTotalHeight = neighbor[GMFluxContainer.FluxCell.Height] + neighbor[GMFluxContainer.FluxCell.Depth]
					local fluxComponent = (dir == 'left' or dir == 'down') and cell[GMFluxContainer.FluxCell.FluxLD][dir] or cell[GMFluxContainer.FluxCell.FluxRU][dir]
					local deltaHeight = totalHeight - neighborTotalHeight
					local flux = self.fluxAtten * fluxComponent + self.fluxStrength * math.max(0, deltaHeight)
					fluxes[dir] = flux
					totalFluxOut = totalFluxOut + flux
				end
			end

			-- Limit total flux to available depth
			local maxFlux = math.max(0, depth - self.fluxSpill)
			if totalFluxOut > maxFlux and totalFluxOut > 0 then
				local scale = maxFlux / totalFluxOut
				for dir in pairs(fluxes) do
					fluxes[dir] = fluxes[dir] * scale
				end
			end

			-- Update fluxes in cell
			local fluxLD = cell[GMFluxContainer.FluxCell.FluxLD]
			local fluxRU = cell[GMFluxContainer.FluxCell.FluxRU]
			fluxLD.left = fluxes.left
			fluxLD.down = fluxes.down
			fluxRU.right = fluxes.right
			fluxRU.up = fluxes.up
		end
	end
end

-- Function to perform a depth update step
function GMFluxContainer:stepDepth()
	for x = 1, self.texSize do
		for y = 1, self.texSize do
			local cell = self.cells[x][y]
			local depth = cell[GMFluxContainer.FluxCell.Depth]
			local fluxOut = cell[GMFluxContainer.FluxCell.FluxLD].left + cell[GMFluxContainer.FluxCell.FluxLD].down +
				cell[GMFluxContainer.FluxCell.FluxRU].right + cell[GMFluxContainer.FluxCell.FluxRU].up
			local fluxIn = 0

			-- Flux in from neighbors
			if x > 1 then
				fluxIn = fluxIn + self.cells[x - 1][y][GMFluxContainer.FluxCell.FluxRU].right
			end
			if x < self.texSize then
				fluxIn = fluxIn + self.cells[x + 1][y][GMFluxContainer.FluxCell.FluxLD].left
			end
			if y > 1 then
				fluxIn = fluxIn + self.cells[x][y - 1][GMFluxContainer.FluxCell.FluxRU].up
			end
			if y < self.texSize then
				fluxIn = fluxIn + self.cells[x][y + 1][GMFluxContainer.FluxCell.FluxLD].down
			end

			-- Update depth
			local newDepth = depth - fluxOut + fluxIn
			newDepth = math.max(newDepth, 0)
			cell[GMFluxContainer.FluxCell.Depth] = newDepth
		end
	end
end

-- Function to handle collisions (Advanced collisions)
function GMFluxContainer:handleCollisions(collidableObjects)
	local cellSize = self.cellSize
	local basePosition = self.position

	for _, obj in ipairs(collidableObjects) do
		local objPos = obj.Position
		local objSizeHalf = obj.Size * 0.5

		local minX = math.floor((objPos.X - objSizeHalf.X - basePosition.X) / cellSize) + 1
		local maxX = math.floor((objPos.X + objSizeHalf.X - basePosition.X) / cellSize) + 1
		local minY = math.floor((objPos.Z - objSizeHalf.Z - basePosition.Z) / cellSize) + 1
		local maxY = math.floor((objPos.Z + objSizeHalf.Z - basePosition.Z) / cellSize) + 1

		-- Clamp to grid boundaries
		minX = math.max(1, minX)
		maxX = math.min(self.texSize, maxX)
		minY = math.max(1, minY)
		maxY = math.min(self.texSize, maxY)

		for x = minX, maxX do
			for y = minY, maxY do
				local cell = self.cells[x][y]
				local height = cell[GMFluxContainer.FluxCell.Height]
				local depth = cell[GMFluxContainer.FluxCell.Depth]
				local cellWorldPosX = basePosition.X + (x - 0.5) * cellSize
				local cellWorldPosZ = basePosition.Z + (y - 0.5) * cellSize

				-- Check if object overlaps with the cell
				if math.abs(objPos.X - cellWorldPosX) <= (objSizeHalf.X + cellSize / 2) and
					math.abs(objPos.Z - cellWorldPosZ) <= (objSizeHalf.Z + cellSize / 2) then

					-- If the object's bottom is below the fluid surface, adjust fluid depth
					local objBottomY = objPos.Y - objSizeHalf.Y
					local objTopY = objPos.Y + objSizeHalf.Y
					if objBottomY <= height + depth then
						local maxDepth = math.max(0, objBottomY - height)
						cell[GMFluxContainer.FluxCell.Depth] = math.min(depth, maxDepth)
					end
				end
			end
		end
	end
end

-- Encode terrain heightmap
function GMFluxContainer:cellEncodeHeightmap(heightmap, factor)
	for x = 1, self.texSize do
		for y = 1, self.texSize do
			local height = heightmap[x][y] * factor
			self.cells[x][y][GMFluxContainer.FluxCell.Height] = height
		end
	end
end

-- Add fluid at a specific position
function GMFluxContainer:addFluid(x, y, amount)
	if x >= 1 and x <= self.texSize and y >= 1 and y <= self.texSize then
		self.cells[x][y][GMFluxContainer.FluxCell.Depth] = self.cells[x][y][GMFluxContainer.FluxCell.Depth] + amount
	end
end

-- Get fluid depth at a position
function GMFluxContainer:getFluidDepthAt(x, y)
	if x >= 1 and x <= self.texSize and y >= 1 and y <= self.texSize then
		return self.cells[x][y][GMFluxContainer.FluxCell.Depth]
	else
		return 0
	end
end

return GMFluxContainer