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.
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.
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!!!!!!
February 8, 2025
Building the procedurally generated caves.
This is going to be a challenge.
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
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.
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.
Similar to the cavities, bounds define the position and extends of the volume to be added into the
cave.



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
February 23, 2025
From Cavity to Cave.
From Cave to Cave system.
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
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.


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
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
March 17, 2025
Populating the caves with mineables and poison.
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.

April 2, 2025
WE RAN INTO A PROBLEM, and we need to BAKE THE CAVES.
I am upset.
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
April 20, 2025
Miscellaneous additions.
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.

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.

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.