Cave Unknown is a horror game I am working on as a passion project, and plan to release at some point.
The procedural caves are generated with a cellular automaton algorithm, the marching cubes algorithm, a flood fill algorithm and triplanar projection.
The audio ray tracer is recursive. From the ray data I dynamically calculate attenuation/absorption and RT60 (reverb time). These parameters are sent to FMOD to process the sounds. One trace can spawn multiple audio sources if multiple rays hit you.
Cellular automata usually generates a grid of binary data. In my implementation I use a float range from 0 to 1. This allows for smoother looking caves, and it also allows for more precise destruction. You can see that in the video above.
I programmed all of it in C++ using Unreal Engine 5. I am currently turning this demo into a full horror game.
Everything you see in the videos below is ready for online multiplayer, including the cave destruction.
Learned how to make cool looking caves using cellular automata with float data.
Learned about procedural mesh calculation like calculating its UVs and its normals.
Learned about the marching cubes algorithm. I now know how to generate a mesh from a grid of data.
This was my first time really using C++ with unreal engine. I learned more about the connection between them and how to use them effectively together.
Learned about the networking implementation of unreal engine, and got better at using it (RPCs, RepNotify, authority, etc.)
I extensively worked with FMOD Studio when I made the audio ray tracer. I learned a lot about how audio works in the real world, so I can simulate it in the game. When making the proximity voice chat I had to insert custom steam voice data into FMOD using its lower level functions (PCMReadCallback).
How to use steam subsystem to create and join online lobbies
So much more
The above video showcases the shape of the caves that I am generating. For this particular method of cave generation I did something very unique that I haven't seen before. I used cellular automata, something that traditionally stores its grid values as 0s and 1s, and I used float values between 0 and 1 instead. The reason for this is that having float values allows me to smooth out the cave using linear interpolation in combination with marching cubes to generate the mesh.
Having procedural caves means I can quite easily make the cave destructible, as you can see in this video:
Below are some code snippets used for the cave generation.
void ACaveGenerator::SmoothMap(int _cycles)
{
for (int i = 0; i < _cycles; i++)
{
// Smooth the map using cellular automata, and write to a 'new' map, to prevent diagonal bias
for (int z = 0; z < height; z++) {
for (int y = 0; y < length; y++) {
for (int x = 0; x < width; x++)
{
newMap[x][y][z] = GetNeighbourAvgValue(x, y, z);
}
}
}
// Update actual map
std::swap(map, newMap);
}
}
float ACaveGenerator::GetNeighbourAvgValue(int _x, int _y, int _z)
{
float sum = 0;
int count = 0;
// Loop through all neighbour cells
for (int neighbourZ = _z - 1; neighbourZ <= _z + 1; neighbourZ++) {
for (int neighbourY = _y - 1; neighbourY <= _y + 1; neighbourY++) {
for (int neighbourX = _x - 1; neighbourX <= _x + 1; neighbourX++) {
// Check if neighbour isn't out of bounds of the map
if (neighbourX >= 0 && neighbourX < width && neighbourY >= 0 && neighbourY < length && neighbourZ >= 0 && neighbourZ < height)
{
// Check if the cell we are checking is not itself
if (neighbourX != _x || neighbourY != _y || neighbourZ != _z)
{
sum += map[neighbourX][neighbourY][neighbourZ];
}
}
// If out of bounds, treat it as value of 1
else
{
sum++;
}
count++;
}
}
}
return sum / count;
}
void ACaveGenerator::MarchCubes()
{
for (int z = 0; z < height; z++) {
for (int y = 0; y < length; y++) {
for (int x = 0; x < width; x++) {
if (x != width - 1 && y != length - 1 && z != height - 1)
{
float cubeCornerValues[8];
for (int i = 0; i < 8; i++)
{
FIntVector corner = FIntVector(x, y, z) + MarchingCubeTable::corners[i];
cubeCornerValues[i] = map[corner.X][corner.Y][corner.Z];
}
FIntVector position = FIntVector(x, y, z);
MarchCube(position, GetConfigurationIndex(cubeCornerValues));
}
}
}
}
}
int ACaveGenerator::GetConfigurationIndex(float _cornerValues[])
{
int index = 0;
for (int i = 0; i < 8; i++)
{
if (_cornerValues[i] >= wallThreshold)
index |= 1 << i;
}
return index;
}
void ACaveGenerator::MarchCube(FIntVector& _pos, int _configIndex)
{
if (_configIndex == 0 || _configIndex == 255) return; // Cube is either air, or locked inside, giving no reason to render it
int edgeIndex = 0;
FVector coord = GetCoordinatePosition(_pos.X, _pos.Y, _pos.Z);
for (int t = 0; t < 5; t++) { // Max triangle amount is 5
for (int v = 0; v < 3; v++) // All triangles have 3 vertices
{
int tableValue = MarchingCubeTable::triangles[_configIndex][edgeIndex];
if (tableValue == -1) return;
FVector edgeStart = FVector(coord.X + MarchingCubeTable::edges[tableValue][0].X * caveScale,
coord.Y + MarchingCubeTable::edges[tableValue][0].Y * caveScale,
coord.Z + MarchingCubeTable::edges[tableValue][0].Z * caveScale);
FVector edgeEnd = FVector(coord.X + MarchingCubeTable::edges[tableValue][1].X * caveScale,
coord.Y + MarchingCubeTable::edges[tableValue][1].Y * caveScale,
coord.Z + MarchingCubeTable::edges[tableValue][1].Z * caveScale);
FIntVector corner1 = (FIntVector)MarchingCubeTable::edges[tableValue][0];
FIntVector corner2 = (FIntVector)MarchingCubeTable::edges[tableValue][1];
float value1 = map[_pos.X + corner1.X][_pos.Y + corner1.Y][_pos.Z + corner1.Z];
float value2 = map[_pos.X + corner2.X][_pos.Y + corner2.Y][_pos.Z + corner2.Z];
FVector vertex = GetVertexPosition(edgeStart, edgeEnd, value1, value2);
rawVertices.Add(vertex);
edgeIndex++;
}
}
}
The above video showcases the custom audio ray-tracer that I made. Each time a sound is made, I shoot rays in all directions. Ray can bounce up to a specified amount. Based on what rays hit the player, I can dynamically determine how much reverb, muffling and muting I need to do based on real world physics equations. I used FMOD, and later switched to Wwise for this. I even take the speed of sound into account. If you listen with headphones, you can hear sounds reflecting from the walls, so the player will hear a sound from multiple locations, just like in a real cave.
I ended up scrapping this system as it had some performance- and other issues. I decided to go for a more streamlined approach that spawns rays in all directions using the golden ratio. I spawn these rays not per sound, but from the player position. I then use the distances of the ray hits to estimate the room size with the code below. I then send that room size to Wwise to apply the appropriate reverb. This significantly boosted performance over the previous solution, while not losing any immersion.
Code Snippet
float AAcousticSoundManager::GetEstimatedRoomSizeInCubicCentimeters(const FVector& Location, const TArray<AActor*>& IgnoredActors)
{
UWorld* World = GetWorld();
if (!World || m_NumSamples <= 0)
{
return -1.f;
}
FCollisionQueryParams TraceParams(FName(TEXT("RoomSizeTrace")), true);
TraceParams.bReturnPhysicalMaterial = false;
for (TWeakObjectPtr<AActor> Actor : IgnoredActors)
{
TraceParams.AddIgnoredActor(Actor.Get());
}
float DistanceSum = 0.f;
for (uint16 SampleIndex = 0; SampleIndex < m_NumSamples; ++SampleIndex)
{
// Calculate direction using the golden ratio to perfectly spread them out to optimize estimation
float T = static_cast<float>(SampleIndex) / m_NumSamples;
float Inclination = FMath::Acos(1 - 2 * T);
float Azimuth = AngleIncrement * SampleIndex;
FVector Direction = FVector(FMath::Sin(Inclination) * FMath::Cos(Azimuth),
FMath::Sin(Inclination) * FMath::Sin(Azimuth),
FMath::Cos(Inclination));
FVector End = Location + Direction * m_MaxRayLength;
FHitResult Hit;
bool bHit = World->LineTraceSingleByChannel(Hit, Location, End, ECC_WorldStatic, TraceParams);
if (m_bShowDebugLines)
{
DrawDebugLine(World, Location, End, FColor::Green, false, m_DebugVisibleFor);
}
if (bHit)
{
DistanceSum += Hit.Distance;
}
}
// Calculate area of sphere of average radius to estimate room size
return 2.f * PI * (DistanceSum / m_NumSamples);
}