Developing Profit Pits


Multiplayer and procedurally generated experiences crafted here, with Profit Pits!

I am Raghav Suriyashekar, or as my friends call me, K2314767, part of Group 1 - Quarry Mines Inc.

Welcome to my development journey as a programmer in a team building a multiplayer game for the CI7825 Connected Games module as part of the MSc. Game Developement(Programming) course at Kingston University with an awesome co-developer Marco Farace and amazing design team Roland Thomas, Achitphon Rungcharoenwikrai and Nana Baah.
Check out my blog posts below to see the progress made so far.



It has begun.

February 8, 2025

Core concept discussion. Arrived at a consensus for what the project would be, and more importantly, what us programmers need to do.

Read More

From the words of our designers, the game is :
Profit Pits, a seamless collaborative game where the players are hired miners under Quarry Minds.inc where they must explore the cave systems and aim to discover variety of ores to mine, become a well-oiled machine of teamwork in pursuit of wealth in mining by delivering the processed minerals to your employers to sell.

My primary responsibility was the procedural generation, mesh generation and shaders.
I built a novel algorithm to generate the cave system and the caves within it, which I will be discussing in the next post.
I utilized compute shaders, shader graph and HLSL to create a responsive, performant and infinitely manipulable cave system.
I also worked on the shader to visualize the caves and the cave system, which I will be discussing in a later post.

LOTS AND LOTS AND LOTS OF PLANNING

We had many discussions about the game both during the initial stages and many during the development, as good games usually do. We had many ideas and concepts that we wanted to implement, but we had to be realistic about the time we had and the scope of the project. We used tools like Trello and Miro to plan out the project and the tasks we needed to do.
Must mention Roland's amazing work setting up the Trello board and Miro board for us to use.

Glimpse of some of the planning we did for the project.

THE PLAN WAS WRIT, ONTO DEVELOPMENT!!!!!!

The Pits

February 8, 2025

Building the procedurally generated caves.
This is going to be a challenge.

Read More

To build a cave, which is essentially a cavity, I wanted to explore creating an algorithm from scratch. Through some thought cycles, I settled on a custom solution, a method where volumes are layered additively in stages to form a complex outer hull that defines the cave structure.
I'm calling this method CAVE Fields.




        PSEUDO CODE FOR THE CAVE Fields
        1. Define a base cascade volume.
        2. For every cascade stage iterated through with a depth value, append volumes along the surface of the previous cascade stage.
        3. Randomize between cuboidal and spherical volumes in each stage.
        4. Encapsulate all the generated volumes to form a container.
                    

One of the intriguing techniques I've incorporated is the use of bit manipulation to select the surface face around which the next cascade stage will be generated. This method ensures a high level of precision in the algorithm.

                    
    int randomPower = (int)Helper.Randomize(ref seed, ref output, 0f, 3f);
    int baseResult = (int)Mathf.Pow(2, randomPower);
    int requiredResult = 7 - baseResult;
                    
                

The random power generates a number between 0 and 2(inclusive). Raising 2 to this random power gives 1, 2, or 4.
The binary representations of these numbers(shortened to 3 bits) are:
1 : 001
2 : 010
4 : 100
This step isolates an axis, as seen with 0 and 1, to position a cascade volume. Subtracting these values from 7 results in outputs 3, 5, and 6, whose binary representations are:
3 : 011
5 : 101
6 : 110
These subtracted values essentially remove the influence of the missing bit, confining the cascade volume to its plane. Randomizing the two dimensions of the coordinates on the plane gives a position to generate a cascade.

                    
            nodes.Clear();
            nodes.Add(new CaveNodeCuboid(transform.position, false, new Vector3(container.x, container.y, container.z)));
            List currentDepth = new() { nodes[^1] };
            _Bounds = new Bounds();
            
            for (int i = 0; i < depth; i++)
            {
                List _currentDepth = new();
                int num = Mathf.Max(Mathf.CeilToInt((Mathf.Abs(depth - 1.0f) / (float)depth) * countPerLevel), 1);
            
                foreach (ICaveNode node in currentDepth)
                {
                    for (int n = 0; n < num; n++)
                    {
                        int randomPower = (int)Helper.Randomize(ref seed, ref output, 0f, 3f);
                        int baseResult = (int)Mathf.Pow(2, randomPower);
                        int requiredResult = 7 - baseResult;
            
                        if (node is CaveNodeCuboid) // If current node is a cuboid.
                        {
                            Vector3 baseResultVector = new((baseResult & (1 << 2)) == 0 ? 0 : 1, (baseResult & (1 << 1)) == 0 ? 0 : 1, (baseResult & (1 << 0)) == 0 ? 0 : 1);
                            baseResultVector *= Mathf.Sign(Helper.BiasFunction(Helper.Randomize(ref seed, ref output, 0.1f, 1f)));
                            baseResultVector = Vector3.Scale(baseResultVector, (node as CaveNodeCuboid).Dimensions * 0.5f);
                            Vector3 requiredResultVector = new((requiredResult & (1 << 2)) == 0 ? 0 : 1, (requiredResult & (1 << 1)) == 0 ? 0 : 1, (requiredResult & (1 << 0)) == 0 ? 0 : 1);
            
                            if (Helper.Randomize(ref seed, ref output, 0f, 1f) < 0.75f) // Add new Cuboid Node.
                            {
                                // NodePosition should be baseResult * (+/- 1) * coordinate offset + requiredResult * random coordinate offset.
                                var dimensions = (node as CaveNodeCuboid).Dimensions * 0.9f;
                                requiredResultVector = Vector3.Scale(requiredResultVector, new Vector3(Helper.Randomize(ref seed, ref output, -dimensions.x, dimensions.x), Helper.Randomize(ref seed, ref output, -dimensions.y, dimensions.y), Helper.Randomize(ref seed, ref output, -dimensions.z, dimensions.z)));
                                Vector3 nodePosition = (node as CaveNodeCuboid).Position + baseResultVector + requiredResultVector;
                                Vector3 nodeDimensions = randomizeDimensions ? 
                                    new Vector3(Helper.Randomize(ref seed, ref output, dimensions.x * 0.5f, dimensions.x * 2f), Helper.Randomize(ref seed, ref output, dimensions.y * 0.5f, dimensions.y * 2f), Helper.Randomize(ref seed, ref output, dimensions.z * 0.5f, dimensions.z * 2f)) * 0.5f : 
                                    dimensions * 0.5f;
                                CaveNodeCuboid caveNodeCuboid = new(nodePosition, false, nodeDimensions);
                                nodes.Add(caveNodeCuboid);
                                _currentDepth.Add(caveNodeCuboid);
                                var _bounds = new Bounds(nodePosition, nodeDimensions);
                                _Bounds.Encapsulate(_bounds);
                            }
                            else // Add new Spherical Node.
                            {
                                var dimensions = (node as CaveNodeCuboid).Dimensions * 0.9f;
                                requiredResultVector = Vector3.Scale(requiredResultVector, new Vector3(Helper.Randomize(ref seed, ref output, -dimensions.x, dimensions.x), Helper.Randomize(ref seed, ref output, -dimensions.y, dimensions.y), Helper.Randomize(ref seed, ref output, -dimensions.z, dimensions.z)));
                                Vector3 nodePosition = (node as CaveNodeCuboid).Position + baseResultVector + requiredResultVector;
                                var _dimensions = Mathf.Min(container.x, container.y, container.z) * Mathf.Pow(0.75f, depth);
                                CaveNodeSphere caveNodeSphere = new(nodePosition, false, _dimensions);
                                nodes.Add(caveNodeSphere);
                                _currentDepth.Add(caveNodeSphere);
                                var _bounds = new Bounds(nodePosition, Vector3.one * _dimensions);
                                _Bounds.Encapsulate(_bounds);
                            }
                        }
            
                        else if(node is CaveNodeSphere) // If current node is a sphere.
                        {
                            Vector3 newPosVector = UnityEngine.Random.insideUnitSphere * (node as CaveNodeSphere).Radius;
                            Vector3 nodePosition = (node as CaveNodeSphere).Position + newPosVector;
            
                            if (Helper.Randomize(ref seed, ref output, 0f, 1f) < 0.75f) // Add new Cuboid Node.
                            {
                                var dimensions = (Vector3)container * Mathf.Pow(0.9f, depth);
                                Vector3 nodeDimensions = randomizeDimensions ? 
                                    new Vector3(Helper.Randomize(ref seed, ref output, dimensions.x * 0.5f, dimensions.x * 2f), Helper.Randomize(ref seed, ref output, dimensions.y * 0.5f, dimensions.y * 2f), Helper.Randomize(ref seed, ref output, dimensions.z * 0.5f, dimensions.z * 2f)) * 0.5f : 
                                    dimensions * 0.5f;
                                CaveNodeCuboid caveNodeCuboid = new(nodePosition, false, nodeDimensions);
                                nodes.Add(caveNodeCuboid);
                                _currentDepth.Add(caveNodeCuboid);
                                var _bounds = new Bounds(nodePosition, nodeDimensions);
                                _Bounds.Encapsulate(_bounds);
            
                            }
                            else // Add new Spherical Node.
                            {
                                var dimensions = (node as CaveNodeSphere).Radius * 0.9f;
                                CaveNodeSphere caveNodeSphere = new(nodePosition, false, dimensions);
                                nodes.Add(caveNodeSphere);
                                _currentDepth.Add(caveNodeSphere);
                                var _bounds = new Bounds(nodePosition, Vector3.one * dimensions);
                                _Bounds.Encapsulate(_bounds);
                            }
                        }
                    }
                }
                currentDepth.Clear();
                currentDepth.AddRange(_currentDepth);
            }
            
            nodePositions.Clear();
            nodeDimensions.Clear();
            sNodes.Clear();
            _Bounds = new();
            foreach (var n in nodes)
            {
                Bounds b = new Bounds();
                if (n is CaveNodeCuboid)
                {
                    var node = n as CaveNodeCuboid;
                    nodePositions.Add(node.Position);
                    nodeDimensions.Add(node.Dimensions);
                    b = new Bounds(node.Position, node.Dimensions);
                }
                else if (n is CaveNodeSphere)
                {
                    var node = n as CaveNodeSphere;
                    sNodes.Add(new Vector4(node.Position.x, node.Position.y, node.Position.z, node.Radius));
                    b = new Bounds(node.Position, node.Radius * Vector3.one);
                }
                _Bounds = b;
                break;
            }
            foreach (var n in nodes)
            {
                Bounds b = new Bounds();
                if (n is CaveNodeCuboid)
                {
                    var node = n as CaveNodeCuboid;
                    nodePositions.Add(node.Position);
                    nodeDimensions.Add(node.Dimensions);
                    b = new Bounds(node.Position, node.Dimensions);
                }
                else if (n is CaveNodeSphere)
                {
                    var node = n as CaveNodeSphere;
                    sNodes.Add(new Vector4(node.Position.x, node.Position.y, node.Position.z, node.Radius));
                    b = new Bounds(node.Position, node.Radius * Vector3.one);
                }
            
            
                if (b.size.x < 0.2f) continue;
                _Bounds.Encapsulate(b);
            }
            
            _Bounds.Expand(2f);
                        
                

Cascade stages.


The developing complexity of each cascade depth builds into the cave's volume.
The resulting cave structure had good detail and organic looking structure but was very angular without any smooth or curved areas, as caves usually feature. Introducing spheres with each cascade depth to build the volume proved to be the perfect addition to fleshing out the volume.



With the volume/cavity of the cave generated, the points lying within in a fixed spatial density containing information about the volumes had to be derived. The points generated in the compute shader contain the volume-bound information into Vector4 data structured buffers. The system uses thread IDs to calculate the appropriate indices, allowing the system to generate points in a volume at a higher density than customarily computed. The points in the coordinate system are generated and are closer to each other, allowing for higher fidelity of the generated mesh in the following steps.



                        bool WithinBounds(float3 pos, float3 boundPos, float3 boundDim)
                        {
                            bool x = (pos.x < boundPos.x + boundDim.x * 0.5 && pos.x > boundPos.x - boundDim.x * 0.5);
                            bool y = (pos.y < boundPos.y + boundDim.y * 0.5 && pos.y > boundPos.y - boundDim.y * 0.5);
                            bool z = (pos.z < boundPos.z + boundDim.z * 0.5 && pos.z > boundPos.z - boundDim.z * 0.5);
                            
                            return x && y && z;
                        }
                        
                        [numthreads(3,3,3)]
                        void GenerateBoundedPoints (uint3 id : SV_DispatchThreadID)
                        {
                            int3 _bounds = ceil(bounds);
                            float3 pos = (id) / 3.0 + _position - _bounds * 0.5;
                            bool boundInNode = false;
                            bool boundInSNode = false;
                             
                            int n = id.x + id.y * 3 + id.z * 3 * 3;
                            int _index = id.x * 1 + id.y * 1 * 3 * _bounds.x + id.z * 1 * 3 * 3 * _bounds.y * _bounds.x;
                            
                            int i;
                            
                            bool boundInCavity = false;
                            bool boundInCamber = false;
                            
                            for (i = 0; i < cavityCount; i++)
                            {
                                boundInCavity = WithinBounds(pos, cavityPositions[i].xyz, cavityDimensions[i].xyz);
                                if (boundInCavity)
                                    i = cavityCount;
                            }
                            
                            for (i = 0; i < camberCount; i++)
                            {
                                boundInCamber = WithinBounds(pos, camberPositions[i].xyz, camberDimensions[i].xyz);
                                if (boundInCamber)
                                    i = camberCount;
                            }
                            
                            for (i = 0; i < nodeCount; i++)
                            {
                                boundInNode = WithinBounds(pos, nodePositions[i].xyz, nodeDimensions[i].xyz);
                                if (boundInNode)
                                    i = nodeCount;
                            }
                            
                            for (i = 0; i < sNodeCount; i++)
                            {
                                boundInSNode = distance(pos, sphericalNodes[i].xyz) < sphericalNodes[i].w;
                                if (boundInSNode)
                                    i = sNodeCount;
                            }
                        
                            if(boundInCavity)
                                positions[_index] = float4(pos, 1);
                            else if(boundInCamber)
                                positions[_index] = float4(pos, 0);
                            else
                                positions[_index] = float4(pos, ((boundInNode || boundInSNode) && pos.y > floorHeight) ? 1 : 0);
                              
                        }
                    

This comopute shader generated the points in a volume corresponding to if it is in the cave Fields or if it is manipulated by being inside a cavity or camber.

The points generated by the compute shader, filtered out to only display those within the generated cave volume.



The mesh generation is taken care of by the MARCHING CUBES algorithm.
This algorithm uses the relative relationship between the 8 vertices of a cube to decide how the intermediate surface should be. Based on the value of the whether the points are part of solid geometry or not. There are 256 permutations of how each vertex of a cube can be and how it's resulting surface would look like.
This can further be optimized to 15 cases, as the cube is symmetric and the surface can be mirrored across the axes. The algorithm uses a lookup table to determine how the surface should be generated based on the values of the vertices.

The previous points generation stage is crucial for optimal mesh generation by the Marching Cubes algorithm. The density of the cells used by the Marching Cubes algorithm determines the quality and resolution of the generated mesh.


Wireframe of sample generated caves with this method.


Caves when viewed from the inside and their wireframe representation.

Some examples of the caves generated using this method.


References:
1. Vagabond Dungeon and Cave Generation Method
2. Marching Cubes Algorithm / Sebastian Lague
3. Marching Cubes Algorithm / Freedom Coding
4. Unity Forum - Procedural Mesh and Compute Shader
5. Reddit - 100,000 agents with compute shader
6. Accidental Noise Library
7. Artstation - Danguad
8. Reddit - Cubical Marching Squares vs Dual Contouring
9. Swiftcoder - Isosurface Extraction

Carving detail.

February 19, 2025

Manipulating the Cave Volume and Shape.

Read More

Procedurally generated seeded caves are a go! They have a moderate amout of detail and are within a reasonable tri-count.


Having seeded caves is very beneficial for the game, as it allows for a lot of flexibility in the design of the caves and the gameplay.
However, any detail that needs to be intentional cannot be baked into the seed and/or the algorithm itself.
This is where the next step comes in, where the cave volume can be manipulated to add detail and features to the cave.
The cave volume can be manipulated in a few ways, such as:
1. Defining a volume called "Cavities" that will remove parts of the cave that are within it.
2. Defining a volume called "Cambers" that will add it's volume to the cave.

CAVITIES

Using bounds to define the position and extends of the volume to be removed from the cave.





With the cavities in place, the cave can be manipulated to have very specific features to accomodate for visual or gameplay related purposes.

CAMBERS

Similar to the cavities, bounds define the position and extends of the volume to be added into the cave.



COMBINATION FOR BETTER DETAIL

The cavities and cambers can be combined to create more complex shapes and features in the cave.


Like this tavern bar area built right into the cave structure.


With this, the designers have full control of parts of the cave that needs a fixed appearance.
The designers can define the position and extends of the cavities and cambers to create a very specific look for the cave.
This allows for a lot of flexibility in the design of the caves and the gameplay.

References:
1. Catlike Coding - Compute Shaders
2. Hali Savakis - My take on Shaders: Compute Shaders

The Expansion.

February 23, 2025

From Cavity to Cave.
From Cave to Cave system.

Read More

Caves have some organic looking detail and their volumes can be manipulated to add features and details.
The next step would be to expand it to form a cave system so players can have a more immersive experience.
The cave system is a 2D grid of caves in a layout defined by the syustem dimensions.



With the basic layout of the caves in place, we needed a way to connect the caves to each other.
Fortunately, the cave manipulation can also be used to create tunnels within the volumes. If we make each of the cave's bounds overlap with its neighbours, The cavities will join the caves together and create a tunnel between them.
To generate the cavities that connect the caves, I first generate every axis-aligned connection between neighbouring caves.
There will be

2 * M * N - (M + N)

cave connections for a cave system with dimensions M and N.
If we pick a random number < (M + N) from this collection of connections, we can randomize the cave network to be more organic with tis links and tunnels.
This results in a overview that looks like this



Generating the caves with these cavities gives a nice cave system layout.





The cave system generates in about 0.5 - 1 second and is very controllable in both the connections and dimensions.





With a shader I developed to look into the caves(next post), it works out-of-the-box with the entire system as well.


Small fly through of the cave system.



Looking into the pits. 1/2

March 2, 2025

Shader Magic ahead!

Read More

MY ABSOLUTE FAVOURITE PART OF THE PROJECT.
I love shaders and shader programming, and this was a great opportunity to experiment with some ideas I had in mind for a long time.
One of the ideas was to use the signed distance functions and smoothmin concept.

Taking the minimum of two functions creates a rigid and disconnected result with sharp edges, as seen in the first figure. This is because it directly picks the lower value without any blending. The smoothmin function improves on this by introducing a blending parameter k, which smoothly merges the two functions near their intersection. This results in a more continuous and organic shape, as illustrated in the second figure.


This is the smoothmin function I wrote:


                        float smin( float a, float b, float k )
                        {
                            k *= 4.0;
                            float h = max( k-abs(a-b), 0.0 )/k;
                            return min(a,b) - h*h*k*(1.0/4.0);
                        }
                    

Signed distance functions are mathematical operations that define distances and by extension, the topology of a bounded volume in 2D or 3D. And since they are mathematical functions, we can apply the smoothmin function to them as well, resulting in satisfying blending of defined shapes.


I also explored dithering — a technique that simulates transparency by selectively omitting pixels in a pattern. This creates the illusion of see-through elements without the performance costs of true transparency, which can lead to issues like overdraw in games.

These concepts set up the foundation for the shader I developed to look into the caves.

References:
1. Ray Marching / Sebastian Lague
2. Ray Marching / SimonDev
3. Signed Distance Functions / Iquilezles
4. Retro Dither Effect in Unity
5. Surface-Stable Fractal Dithering Explained

Looking into the pits. 2/2

March 4, 2025

Shader Magic ahead! continues...

Read More

With the dithering and signed distance functions in place, it was time to put them to use in the shader.
To test out the implementation, I used a simple scene to see how the SDFs would behave and what values would work well.

This looked very organic and fluid, the SDFs combined very well and made it seem that combinations lead to a bigger result.
Extending this to make a shader that would look into the cave system was the next step.
I implemented this using Shader Graph, which is a visual shader programming tool in Unity that allows for the creation of shaders using a node-based interface.
This was a great learning experience for me, since getting these custom SDFs and smoothmin functions to work required writing custom function nodes.
Implementing the SDFs and smoothmin function in Shader Graph was a bit tricky, but I got it working.

                    
        int numPositions;
        float3 screenPositions[8];

        float sdCircle_float(float2 p, float2 screenDimensions, float2 c, float r)
        {
            float2 vec = p - c;
            vec.x *= screenDimensions.x / screenDimensions.y;
            return (length(vec) - r);
        }

        float SMOOTHMIN_float(float a, float b, float k)
        {
            if (k <= 0)
                k = 0.075;
            k *= 4;
            float h = max(k - abs(a - b), 0.0) / k;
            return min(a, b) - h * k * h * 0.25;
        }

        void SDF_float(float2 p, float2 screenDimensions, float k, out float SDFValue)
        {   
            SDFValue = 1.0;
            for (int i = 0; i < numPositions; i++)
            {
                float sdfVal = sdCircle_float(p, screenDimensions, screenPositions[i].xy, screenPositions[i].z);
                //float sdfVal = sdCircle_float(p, screenDimensions, float2(0.5, 0.5), 0.25);
                SDFValue = SMOOTHMIN_float(sdfVal, SDFValue, k);
            }
            SDFValue = clamp(SDFValue, 0, 1);
            SDFValue = saturate(SDFValue);
        
        }
                    
                

Using these functions in the graph made it very easy to implement the looks I wanted to achieve.



This resulted in a material that allowed loking into the cave, whose window for looking into the cave was augmented by SDFs for every unit in screen space to smoothly blend inton each other allowing for a intuitive, fluid and organic looking structure for teammates in close proximity in the caves.




Dynamically changing SDFs example in the caves.
Notice you can see the outside of the caves not bounded by the SDFs.

Another feature I implemented was colors of the caves based on their world position.
I implemented this again in Shader graph and used custom functions in HLSL to get the colors and patterns I wanted.
I used lacunar noise to layer varying amplitude and frequency noise to get a more organic looking color pattern.
With this, I can alter the colors of the caves to get the desired look I want.

                    
            void CaveColor_float(float3 worldPosition, float lacunarity, float amplitude, float frequency, float4 ColorA, float4 ColorB, out float4 CaveColorOutput)
            {
                float noiseSum = 0;
                lacunarity = (int) lacunarity;
                for (int i = 0; i < lacunarity; i++)
                {
                    float3 pos = worldPosition * frequency;
                    float noise = (snoise(pos * (snoise(worldPosition * 0.2) + 1) * 0.5) + 1) * 0.5 * amplitude;
                    noiseSum += noise;
                    amplitude *= 0.5;
                    frequency *= 2;
                }
                noiseSum /= 5.0;
                noiseSum = clamp(noiseSum, 0, 2);
                float val = pow(abs(noiseSum), 2);
                CaveColorOutput = val * ColorA + (1 - val) * ColorB;
            }
                        
                

With this component of the shader complete, the cave's looks was done.




The shader does a great job of isolating only what is right next to the player.
This also promotes teamwork in close proximity as the SDFs will blend into each other and create a bigger field of view into the caves.



References:
1. Unity Shader Graph Basics: Custom Functions
2. Unity Shader Graph - Custom Function Node

Mineables and Poison

March 17, 2025

Populating the caves with mineables and poison.

Read More

Caves, cave systems and the shader are all done.
Now it was time to populate the caves with mineables and poison.
The mineables are the ores that the players will be mining in the game.
The poison is a gas that will be present in the caves and will be harmful to the players.
An extension to the method for generating the points in the bounds while generating the mesh for the caves, was something I built to get certain desired positions in the cave.
Using another compute shader, I filtered out the points using information about their neighbouring points.
I once again took advantage of the opportunity and implemented a filter based on bits and bitwise operations.

                    
            float3 position;
            float3 bounds;
            float floorHeight;
            StructuredBuffer positions;
            int positionsCount;
            AppendStructuredBuffer shellPositions;
            
            int GetIndexFromPosition(int3 pos, int3 bounds)
            {
                return pos.x + pos.y * bounds.x + pos.z * bounds.x * bounds.y;
            }

            [numthreads(8, 8, 8)]
            void GenerateShellPoints(uint3 id : SV_DispatchThreadID)
            {
                int3 _bounds = ceil(bounds);
                int index = GetIndexFromPosition(id.xyz, _bounds);

                if (index >= positionsCount)
                    return;

                if (positions[index].w == 0)
                    return;

                int currentStateValue = positions[index].w;

                int indexTop = GetIndexFromPosition(id.xyz + int3(0, 1, 0), _bounds);
                int indexBottom = GetIndexFromPosition(id.xyz + int3(0, -1, 0), _bounds);
                int indexRight = GetIndexFromPosition(id.xyz + int3(1, 0, 0), _bounds);
                int indexLeft = GetIndexFromPosition(id.xyz + int3(-1, 0, 0), _bounds);
                int indexForward = GetIndexFromPosition(id.xyz + int3(0, 0, 1), _bounds);
                int indexBackward = GetIndexFromPosition(id.xyz + int3(0, 0, -1), _bounds);

                bool indexTopExist = (positionsCount - indexTop) < positionsCount && indexTop > 0;
                bool indexBottomExist = (positionsCount - indexBottom) < positionsCount && indexBottom > 0;
                bool indexRightExist = (positionsCount - indexRight) < positionsCount && indexRight > 0;
                bool indexLeftExist = (positionsCount - indexLeft) < positionsCount && indexLeft > 0;
                bool indexForwardExist = (positionsCount - indexForward) < positionsCount && indexForward > 0;
                bool indexBackwardExist = (positionsCount - indexBackward) < positionsCount && indexBackward > 0;

                //bool allExist = indexTopExist && indexBackwardExist && indexRightExist && indexLeftExist && indexForwardExist && indexBackwardExist;

                int num = 0;

                num += pow(2, 6) * currentStateValue;
                num += pow(2, 5) * (indexTopExist ? positions[indexTop].w : currentStateValue);
                num += pow(2, 4) * (indexBottomExist ? positions[indexBottom].w : currentStateValue);
                num += pow(2, 3) * (indexRightExist ? positions[indexRight].w : currentStateValue);
                num += pow(2, 2) * (indexLeftExist ? positions[indexLeft].w : currentStateValue);
                num += pow(2, 1) * (indexForwardExist ? positions[indexForward].w : currentStateValue);
                num += pow(2, 0) * (indexBackwardExist ? positions[indexBackward].w : currentStateValue);

                // 0 or 127 means they are bounded.
                if (num == 127)
                    return;

                if(positions[indexBottom].w == 1)
                    return;

                if(positions[index].y > floorHeight + 0.5)
                    return;

                float minDim = min(_bounds.x, _bounds.y);
                minDim = min(minDim, _bounds.z);
                minDim = ceil(minDim * 0.5);

                if (distance(positions[index].xyz, position.xyz) >= minDim * 0.5)
                    return;

                int _index = id.x + id.x * id.y + id.x * id.y * id.z;
                shellPositions.Append(float4(positions[index].xyz, _index));

            }
                        
                

Further filtering based on distance from the center of the caves, gives positions that are sufficiently spaced yet don't extend too far out into the corners and crevices of the caves that could be inaccessible by the player.


This produced a set of points that could all potentially be mineables or poison.
Unfortunately, an easy way out that I took was to use an AppendBuffer.
This is a compute buffer that allows data to be appended by the shader in parallel.
This means that while the set of points that are generated for a given seed might be the same in different iterations, the order of the points might not be the same.
This means that the mineables and poison will be in different locations every time the game is run if they are just picked by a set of indices.
To overcome this, I used a seeded random number generator to generate positions on the CPU and then used another compute buffer to approximate the closest point generated by the shader.
This ensures that the mineables and poison are in the same locations every time the game is run.

                    
            int positionCount;
            int shellPositionsCount;
            StructuredBuffer computedShellPositions;
            RWStructuredBuffer mineablesPositions;
            
            [numthreads(4, 1, 1)]
            void ApproximatePoints(uint3 id : SV_DispatchThreadID)
            {
                if(id.x > positionCount)
                    return;

                float minDist = 100000;
                float3 pos = 0;
                for (int i = 0; i < shellPositionsCount; i++)
                {
                    float d = distance(mineablesPositions[id.x].xyz, computedShellPositions[i].xyz);

                    if (d <= minDist)
                    {
                        minDist = d;
                        pos = computedShellPositions[i];
                    }
                }

                mineablesPositions[id.x] = float4(pos, 0);
            }
                         
                

This is the section that generates the seeded random positions.

                    
            mineables = new();
            numMineable = (int)Helper.Randomize(ref seed, ref output, 3, 8);

            positions = new Vector4[numMineable];
            for (int i = 0; i < numMineable; i++)
            {
                positions[i] = new Vector4(Helper.Randomize(ref seed, ref output, -_Bounds.extents.x, _Bounds.extents.x),
                                           Helper.Randomize(ref seed, ref output, -_Bounds.extents.y, _Bounds.extents.y),
                                           Helper.Randomize(ref seed, ref output, -_Bounds.extents.z, _Bounds.extents.z),
                                           0);
            }   
                        
                

Red gizmos show the mineables.


Similar to the mineables, the positions for the poison are generated using the same method.
Green gizmos show the poison.


DISASTER

April 2, 2025

WE RAN INTO A PROBLEM, and we need to BAKE THE CAVES.
I am upset.

Read More

The systems responsible for generating the caves, meshes and positions for the mineables and poison are all based on compute shaders.
And they were built this way because of the performance and efficiency the compute shaders provide.
We chose Photon Fusion and Hathora as the networtk platform and provider for our multiplayer experience.
Our physics, i.e. rigidbodies, collisions etc. are handled by the CPU of the server we are hosting the game on.
Unfortunately, compute shaders require a GPU to run and we don't have access to a GPU on the server.
So, frustrating as it is, we cannot use the procedural pipeline set up for the cave generaion.
We then made the hard decision to bake the caves and all the required information so that they can be packaged and spawned on the server both for the simplicity of having common elements like the caves spawned for every player automatically and for the fact that all the physics will work out-of-the-box by default since it is handled by the server.

This is the Scriptable Object container for all the cave data.

                    
                [CreateAssetMenu(fileName = "BakedCave", menuName = "Caves/BakedCave", order = 0)]
                public class BakedCave : ScriptableObject
                {
                    [SerializeField] private Mesh m_mesh;
                    [SerializeField] private List m_miningSpots;
                    [SerializeField] private List m_poisonSpots;
                    [SerializeField] private int m_caveLevel;
                    
                    public Mesh Mesh => m_mesh;
                    public List MiningSpots => m_miningSpots;
                    public List PoisonSpots => m_poisonSpots;
                    
                    public void Bake(Mesh mesh, List miningSpots, List poisonSpots, int caveLevel)
                    {
                        m_mesh = mesh;
                        m_miningSpots = miningSpots;
                        m_caveLevel = caveLevel;
                        m_poisonSpots = poisonSpots;
                    }
                }
                        
                

And this is the Scriptable Object container for the cave system data.

                    
                [CreateAssetMenu(fileName = "BakedCaveSystem", menuName = "Caves/BakedCaveSystem", order = 0)]
                public class BakedCaveSystem : ScriptableObject
                {
                    [SerializeField] CaveData m_caveData;
                    [SerializeField] BakedCave[] m_bakedCaves;
                    
                    public CaveData CaveData => m_caveData;
                    public int m_level;
                
                    public void Bake(CaveData caveData, BakedCave[] bakedCaves, int level)
                    {
                        m_caveData = caveData;
                        m_bakedCaves = bakedCaves;
                        m_level = level;
                    }
                    
                    public BakedCave this[int i, int j]
                    {
                        get
                        {
                            if (i >= 0 && i < m_caveData.caveSystemDimensions.x && j >= 0 &&
                                j < m_caveData.caveSystemDimensions.y)
                                return m_bakedCaves[i * m_caveData.caveSystemDimensions.y + j];
                            throw new System.IndexOutOfRangeException(
                                $"Index {i}, {j} is out of range for BakedCaveSystem with dimensions {m_caveData.caveSystemDimensions.x}, {m_caveData.caveSystemDimensions.y}");
                        }
                    }
                }
                        
                

With the containers in place, an editor-time script is used to generate the caves, cave systems and required positions, bake them and save them as respective asset files in our Unity directory.

                    
            [ExecuteInEditMode]
            public class CaveBaker : MonoBehaviour
            {
                [SerializeField] private CaveSystem m_caveSystem;
                [SerializeField] private int m_startingSeed;
                [SerializeField] private int m_bakingCount = 1;
                
                private bool m_generationInProgress;
                
                private void Start()
                {
                    if (m_caveSystem == null)
                    {
                        Debug.LogError("CaveSystem is not assigned.");
                    }
                }
            
                public IEnumerator Bake()
                {
                    #if UNITY_EDITOR
                    m_caveSystem.OnCavesGenerated.AddListener(OnCavesGenerated);
                    for (int i = m_startingSeed; i < m_startingSeed + m_bakingCount; i++)
                    {
                        m_generationInProgress = true;
                        m_caveSystem.Initialize(i);
                        
                        yield return new WaitUntil(() => !m_generationInProgress);
                        
                        // Save the baked cave system
                        var caves = m_caveSystem.CaveGrid;
                        var bakedCaves = new BakedCave[m_caveSystem.caveData.caveSystemDimensions.x * m_caveSystem.caveData.caveSystemDimensions.y];
                        for (int x = 0; x < m_caveSystem.caveData.caveSystemDimensions.x; x++)
                        {
                            for (int y = 0; y < m_caveSystem.caveData.caveSystemDimensions.y; y++)
                            {
                                var cave = caves[x, y];
                                var mesh = !Application.isPlaying ? cave.GetComponent().sharedMesh : cave.GetComponent().mesh;
                                
                                if (!mesh)
                                {
                                    Debug.LogError($"Cave mesh is null for cave at {x}, {y}");
                                    continue;
                                }
                                
                                if(!Directory.Exists($"Assets/Bakes/BakedMeshes"))
                                    Directory.CreateDirectory($"Assets/Bakes/BakedMeshes");
                                
                                // Save the mesh asset
                                var meshPath = $"Assets/Bakes/BakedMeshes/CaveMesh_{i}_{x}_{y}.asset";
                                AssetDatabase.CreateAsset(mesh, meshPath);
                                
                                var bakedCave = ScriptableObject.CreateInstance();
                                bakedCave.Bake(mesh, cave.GetComponent().positions.ToList(), cave.GetComponent().poisonPoints.ToList(), i);
                                
                                if(!Directory.Exists($"Assets/Bakes/BakedCaves"))
                                    Directory.CreateDirectory($"Assets/Bakes/BakedCaves");
                                
                                AssetDatabase.CreateAsset(bakedCave, $"Assets/Bakes/BakedCaves/BakedCave_{i}_{x}_{y}.asset");
                                
                                bakedCaves[x * m_caveSystem.caveData.caveSystemDimensions.y + y] = bakedCave;
                            }
                            
                            // Save the baked cave system
                            // Save the baked cave system
                            var bakedCaveSystem = ScriptableObject.CreateInstance();
                            bakedCaveSystem.Bake(m_caveSystem.caveData, bakedCaves, i);
                                
                            if(!Directory.Exists($"Assets/Bakes/BakedCaveSystems"))
                                Directory.CreateDirectory($"Assets/Bakes/BakedCaveSystems");
                                
                            AssetDatabase.CreateAsset(bakedCaveSystem, $"Assets/Bakes/BakedCaveSystems/BakedCaveSystem_{i}.asset");
                        }
                    }
                    #endif
                    yield return null;
                }
            
                public void OnCavesGenerated()
                {
                    m_generationInProgress = false;
                }
            }
                        
                        

With the caves and cave systems baked, and the mesh data referenced, we can use the addressables system in Unity to load the assets when needed.


Some examples of the baked cave meshes.



Alright! Crisis averted! albiet in a very unsatisfying way, especially to programmers that love procedural generation and compute shaders.
However, we are back on track and the project is moving forward.

References:
1. Photon Fusion Documentation : Network Runner

Fixes and Extras

April 20, 2025

Miscellaneous additions.

Read More

Among the shaders I wrote for this project, the shader for the Poison cloud was a lot fo fun.
We needed a volumetric effect for the poison cloud, so we opted to implement it as a particle system.
At other times, the poison cloud is invidible, but players can use an item, the canary, to reveal the poison cloud in the cave.
So the shader I created culls pixels that lie outside the extends of the canary.

                            
            float _PoisonDistance;
            float3 _CanaryPosition;

            v2f vert (appdata v)
            {
                UNITY_SETUP_INSTANCE_ID(v);
                v2f o;
                UNITY_TRANSFER_INSTANCE_ID(v, o);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.worldPos = mul(v.vertex, unity_ObjectToWorld);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.color = v.color;
                return o;
            }

            float rand(float2 co)
            {
                return frac(sin(dot(co.xy , float2(12.9898, 78.233))) * 43758.5453);
            }

            fixed4 frag (v2f i) : SV_Target
            {

                UNITY_SETUP_INSTANCE_ID(i);
            
                float dist = distance(i.worldPos, _CanaryPosition);
                float random = rand(i.worldPos.xz);
                random = random * 2.0 - 1.0;
            
                if(dist + random * 0.1 > _PoisonDistance)
                    discard;
            
                return i.color * tex2D(_MainTex, i.uv);
            }
                                

                        

This gives a nice revealing effect for the particle system when they come into the range.


Another small addition was to introduce some randomness to break up the harsh edge defined by the bounds.
This also gave a nice smoke/cloud effect to the poison.


Performance

April 26, 2025

How many ms is too many?

Read More

With a lot of the systems in this game being GPU dependant, it could be taken for granted that the performance will be great.
It is.


Looking into the profiler for better information, the majority of the processing and resources are dedicated to the instantiation and setting up of the cave system elements.
The cave and mesh generation doesn't even register in the profiler, whereas the instantiation is taking nearly 900ms.

The game with all it's components, caves and other elements has over 10M tris. It's a lot of triangles but it still runs at around 50-60FPS in the editor.


The Conclusion

May 1, 2025

What a wild ride!

Read More

This project was awesome!
Unfortunately, we don't have another month to work on it.
Many things broke, had to be replaced, or sacrificed in scope to complete it, but it was an amazing learning experience.

I am delighted to experiment with concepts and ideas I have been thinking about for a long time.
Using compute shaders is something I always look forward to, and the tasks I implemented in this project helped me enhance not just my knowledge of compute shaders but the extent to which they can be used to create complex systems.

Future plans for this game:
I would very much be interested to extend the level of procedural generation used.
Features like more detailed cave systems and destructible cave systems that the players can actually mine and chip away to get items.
Would love to explore the cave shader to have a more fluid and organic look into the caves.
Better connectivity within the caves and between the systems.
And many more... Until next time!

I would like to extend my gratitude to my teammates for their hard work, contribution and dedication to the project.
The designers did a great job with the concept, intricacies and progression of the game, and personally, brought forth a very interseting game idea that I would gladly continue working on.
Finally, best for last, I would like to thank my fellow programmer, Marco, for not only building many of the core systems of the game in a way that was both inspiring and efficient, but to compliment my proficiency of shaders to bring the game to a point that I can be proud of in the time we had.
Thank you




This blog site was made in HTML and CSS with some JavaScript to make it more interactive.
It is hosted on my Github pages site and will be up until I decide to re-write it.