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