Voxel/VoxelWorld.cs
namespace Monolith;
/// <summary>
/// The monolith itself: a dense grid of chunks. Pure data and math, no scene or network
/// awareness, so it can be unit reasoned about and driven identically on host and clients.
/// </summary>
public sealed class VoxelWorld
{
public Vector3Int Size { get; private set; }
public Vector3Int ChunkCounts { get; private set; }
/// <summary>World-space position of voxel (0,0,0)'s minimum corner.</summary>
public Vector3 Origin { get; private set; }
public long SolidCount { get; private set; }
public long InitialCount { get; private set; }
private VoxelChunk[] chunks;
/// <summary>Chunks whose geometry needs rebuilding. Consumed by the renderer on a budget.</summary>
public readonly HashSet<int> DirtyChunks = new();
public float Progress => InitialCount == 0 ? 1f : 1f - (float)((double)SolidCount / InitialCount);
public VoxelWorld( Vector3Int size )
{
Size = size;
ChunkCounts = new Vector3Int(
CeilDiv( size.x, VoxelChunk.Size ),
CeilDiv( size.y, VoxelChunk.Size ),
CeilDiv( size.z, VoxelChunk.Size ) );
chunks = new VoxelChunk[ChunkCounts.x * ChunkCounts.y * ChunkCounts.z];
for ( int z = 0; z < ChunkCounts.z; z++ )
for ( int y = 0; y < ChunkCounts.y; y++ )
for ( int x = 0; x < ChunkCounts.x; x++ )
chunks[ChunkIndex( x, y, z )] = new VoxelChunk( new Vector3Int( x, y, z ) );
// Centre the monolith on the scene origin so it reads as floating in the void.
Origin = new Vector3( size.x, size.y, size.z ) * -0.5f * Tuning.VoxelSize;
}
private static int CeilDiv( int a, int b ) => (a + b - 1) / b;
public int ChunkCount => chunks.Length;
public VoxelChunk GetChunkByIndex( int i ) => chunks[i];
public int ChunkIndex( int cx, int cy, int cz )
=> cx + cy * ChunkCounts.x + cz * ChunkCounts.x * ChunkCounts.y;
public Vector3Int ChunkCoordFromIndex( int i )
{
int x = i % ChunkCounts.x;
int y = (i / ChunkCounts.x) % ChunkCounts.y;
int z = i / (ChunkCounts.x * ChunkCounts.y);
return new Vector3Int( x, y, z );
}
// ---------------------------------------------------------------- generation
/// <summary>Fills the whole box. Voxels outside <see cref="Size"/> but inside the padded
/// chunk grid are left empty so the monolith has exact dimensions.</summary>
public void FillSolid() => FillShape( ShapeKind.Box );
/// <summary>
/// Fills according to a shape predicate. Deterministic and position-only, so the host and
/// every client build an identical world without transferring anything.
/// </summary>
public void FillShape( ShapeKind shape )
{
foreach ( var chunk in chunks )
chunk.ClearAll();
long solid = 0;
for ( int z = 0; z < Size.z; z++ )
for ( int y = 0; y < Size.y; y++ )
for ( int x = 0; x < Size.x; x++ )
{
if ( !Stages.IsSolid( shape, Size, x, y, z ) )
continue;
SetSolidRaw( x, y, z );
solid++;
}
SolidCount = solid;
InitialCount = solid;
MarkAllDirty();
}
public void MarkAllDirty()
{
DirtyChunks.Clear();
for ( int i = 0; i < chunks.Length; i++ )
{
chunks[i].MeshDirty = true;
DirtyChunks.Add( i );
}
}
private void SetSolidRaw( int x, int y, int z )
{
var chunk = chunks[ChunkIndex( x / VoxelChunk.Size, y / VoxelChunk.Size, z / VoxelChunk.Size )];
chunk.Set( x % VoxelChunk.Size, y % VoxelChunk.Size, z % VoxelChunk.Size );
}
/// <summary>
/// Clients learn the original cube count from the host's snapshot header rather than
/// deriving it, because by the time they join the monolith is already partly mined.
/// </summary>
public void SetInitialCount( long count ) => InitialCount = count;
/// <summary>Recomputes SolidCount from the chunks. Use after applying a snapshot.</summary>
public void RecountSolid()
{
long n = 0;
foreach ( var chunk in chunks )
n += chunk.SolidCount;
SolidCount = n;
if ( InitialCount == 0 )
InitialCount = (long)Size.x * Size.y * Size.z;
}
// ---------------------------------------------------------------- queries
public bool InBounds( int x, int y, int z )
=> x >= 0 && y >= 0 && z >= 0 && x < Size.x && y < Size.y && z < Size.z;
public bool IsSolid( int x, int y, int z )
{
if ( !InBounds( x, y, z ) )
return false;
var chunk = chunks[ChunkIndex( x / VoxelChunk.Size, y / VoxelChunk.Size, z / VoxelChunk.Size )];
if ( chunk.IsEmpty ) return false;
if ( chunk.IsFull ) return true;
return chunk.Get( x % VoxelChunk.Size, y % VoxelChunk.Size, z % VoxelChunk.Size );
}
public Vector3 VoxelToWorld( int x, int y, int z )
=> Origin + new Vector3( x + 0.5f, y + 0.5f, z + 0.5f ) * Tuning.VoxelSize;
public Vector3Int WorldToVoxel( Vector3 world )
{
var local = (world - Origin) / Tuning.VoxelSize;
return new Vector3Int(
(int)MathF.Floor( local.x ),
(int)MathF.Floor( local.y ),
(int)MathF.Floor( local.z ) );
}
public BBox WorldBounds => new(
Origin,
Origin + new Vector3( Size.x, Size.y, Size.z ) * Tuning.VoxelSize );
// ---------------------------------------------------------------- removal
/// <summary>
/// Removes every solid voxel within <paramref name="radius"/> voxels of the centre.
/// Returns how many were actually removed. Deterministic, which is what lets us broadcast
/// the operation rather than the resulting voxel list.
/// </summary>
public int RemoveSphere( Vector3Int centre, float radius )
{
if ( radius < 0.5f )
return RemoveSingle( centre );
int r = (int)MathF.Ceiling( radius );
float rsq = radius * radius;
int removed = 0;
int minX = Math.Max( 0, centre.x - r ), maxX = Math.Min( Size.x - 1, centre.x + r );
int minY = Math.Max( 0, centre.y - r ), maxY = Math.Min( Size.y - 1, centre.y + r );
int minZ = Math.Max( 0, centre.z - r ), maxZ = Math.Min( Size.z - 1, centre.z + r );
for ( int z = minZ; z <= maxZ; z++ )
{
int dz = z - centre.z;
for ( int y = minY; y <= maxY; y++ )
{
int dy = y - centre.y;
int dzy = dz * dz + dy * dy;
if ( dzy > rsq ) continue;
for ( int x = minX; x <= maxX; x++ )
{
int dx = x - centre.x;
if ( dx * dx + dzy > rsq ) continue;
if ( ClearVoxel( x, y, z ) )
removed++;
}
}
}
return removed;
}
public int RemoveSingle( Vector3Int v )
=> ClearVoxel( v.x, v.y, v.z ) ? 1 : 0;
/// <summary>
/// Shatters a fraction of what remains, chosen by a hash of position and a seed.
///
/// Deterministic on purpose: every client runs the same function over the same coordinates
/// and reaches the same result, so this travels over the network as a seed and a fraction
/// rather than as a list of several million voxels.
/// </summary>
public int Fracture( float fraction, int seed )
{
fraction = Math.Clamp( fraction, 0f, 1f );
if ( fraction <= 0f ) return 0;
uint threshold = (uint)(fraction * 1000f);
int removed = 0;
for ( int z = 0; z < Size.z; z++ )
for ( int y = 0; y < Size.y; y++ )
for ( int x = 0; x < Size.x; x++ )
{
if ( !IsSolid( x, y, z ) )
continue;
uint h = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(z * 83492791) ^ (uint)seed;
h ^= h >> 13;
h *= 0x5bd1e995;
h ^= h >> 15;
if ( h % 1000u >= threshold )
continue;
if ( ClearVoxel( x, y, z ) )
removed++;
}
return removed;
}
/// <summary>Clears one voxel and marks the owning chunk (and any touched neighbour) dirty.</summary>
public bool ClearVoxel( int x, int y, int z )
{
if ( !InBounds( x, y, z ) )
return false;
int cx = x / VoxelChunk.Size, cy = y / VoxelChunk.Size, cz = z / VoxelChunk.Size;
int lx = x % VoxelChunk.Size, ly = y % VoxelChunk.Size, lz = z % VoxelChunk.Size;
var index = ChunkIndex( cx, cy, cz );
if ( !chunks[index].Clear( lx, ly, lz ) )
return false;
SolidCount--;
DirtyChunks.Add( index );
// A voxel on a chunk face exposes geometry in the neighbouring chunk too.
if ( lx == 0 ) TouchChunk( cx - 1, cy, cz );
else if ( lx == VoxelChunk.Size - 1 ) TouchChunk( cx + 1, cy, cz );
if ( ly == 0 ) TouchChunk( cx, cy - 1, cz );
else if ( ly == VoxelChunk.Size - 1 ) TouchChunk( cx, cy + 1, cz );
if ( lz == 0 ) TouchChunk( cx, cy, cz - 1 );
else if ( lz == VoxelChunk.Size - 1 ) TouchChunk( cx, cy, cz + 1 );
return true;
}
private void TouchChunk( int cx, int cy, int cz )
{
if ( cx < 0 || cy < 0 || cz < 0 ) return;
if ( cx >= ChunkCounts.x || cy >= ChunkCounts.y || cz >= ChunkCounts.z ) return;
var i = ChunkIndex( cx, cy, cz );
chunks[i].MeshDirty = true;
DirtyChunks.Add( i );
}
// ---------------------------------------------------------------- raycast
/// <summary>
/// Marches the voxel grid directly (Amanatides and Woo) instead of using physics. Exact,
/// allocation free, and it means chunks never need collision meshes.
/// </summary>
public bool TraceRay( Vector3 start, Vector3 direction, float maxDistance, out VoxelHit hit )
{
hit = default;
direction = direction.Normal;
if ( direction.Length < 0.5f )
return false;
var bounds = WorldBounds;
float tEnter = 0f;
// If we start outside the monolith, skip ahead to where the ray enters its box.
if ( !bounds.Contains( start ) )
{
if ( !RayBoxEntry( start, direction, bounds, maxDistance, out tEnter ) )
return false;
}
// Nudge inside so the starting voxel is unambiguous on a face-exact entry.
var p = start + direction * (tEnter + 0.001f);
var v = WorldToVoxel( p );
int stepX = direction.x > 0 ? 1 : -1;
int stepY = direction.y > 0 ? 1 : -1;
int stepZ = direction.z > 0 ? 1 : -1;
float tDeltaX = MathF.Abs( direction.x ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.x );
float tDeltaY = MathF.Abs( direction.y ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.y );
float tDeltaZ = MathF.Abs( direction.z ) < 1e-6f ? float.MaxValue : MathF.Abs( Tuning.VoxelSize / direction.z );
float tMaxX = NextBoundary( p.x, Origin.x, v.x, stepX, direction.x );
float tMaxY = NextBoundary( p.y, Origin.y, v.y, stepY, direction.y );
float tMaxZ = NextBoundary( p.z, Origin.z, v.z, stepZ, direction.z );
var normal = Vector3Int.Zero;
float travelled = tEnter;
// The grid diagonal is a hard upper bound on how many voxels a ray can cross.
int guard = (Size.x + Size.y + Size.z) * 2 + 8;
while ( guard-- > 0 && travelled <= maxDistance )
{
if ( IsSolid( v.x, v.y, v.z ) )
{
hit = new VoxelHit
{
Voxel = v,
Normal = normal,
Distance = travelled,
Position = start + direction * travelled,
Hit = true
};
return true;
}
// Once we are past the far side of the box on any axis we can never come back.
if ( (stepX > 0 && v.x >= Size.x) || (stepX < 0 && v.x < 0) ) return false;
if ( (stepY > 0 && v.y >= Size.y) || (stepY < 0 && v.y < 0) ) return false;
if ( (stepZ > 0 && v.z >= Size.z) || (stepZ < 0 && v.z < 0) ) return false;
if ( tMaxX < tMaxY && tMaxX < tMaxZ )
{
v.x += stepX;
travelled = tMaxX;
tMaxX += tDeltaX;
normal = new Vector3Int( -stepX, 0, 0 );
}
else if ( tMaxY < tMaxZ )
{
v.y += stepY;
travelled = tMaxY;
tMaxY += tDeltaY;
normal = new Vector3Int( 0, -stepY, 0 );
}
else
{
v.z += stepZ;
travelled = tMaxZ;
tMaxZ += tDeltaZ;
normal = new Vector3Int( 0, 0, -stepZ );
}
}
return false;
}
private static float NextBoundary( float pos, float origin, int voxel, int step, float dir )
{
if ( MathF.Abs( dir ) < 1e-6f )
return float.MaxValue;
float boundary = origin + (voxel + (step > 0 ? 1 : 0)) * Tuning.VoxelSize;
return (boundary - pos) / dir;
}
private static bool RayBoxEntry( Vector3 start, Vector3 dir, BBox box, float maxDistance, out float tEnter )
{
tEnter = 0f;
float tMin = 0f;
float tMax = maxDistance;
for ( int axis = 0; axis < 3; axis++ )
{
float o = axis == 0 ? start.x : axis == 1 ? start.y : start.z;
float d = axis == 0 ? dir.x : axis == 1 ? dir.y : dir.z;
float lo = axis == 0 ? box.Mins.x : axis == 1 ? box.Mins.y : box.Mins.z;
float hi = axis == 0 ? box.Maxs.x : axis == 1 ? box.Maxs.y : box.Maxs.z;
if ( MathF.Abs( d ) < 1e-6f )
{
if ( o < lo || o > hi ) return false;
continue;
}
float t1 = (lo - o) / d;
float t2 = (hi - o) / d;
if ( t1 > t2 ) (t1, t2) = (t2, t1);
tMin = MathF.Max( tMin, t1 );
tMax = MathF.Min( tMax, t2 );
if ( tMin > tMax ) return false;
}
tEnter = tMin;
return true;
}
}
public struct VoxelHit
{
public bool Hit;
public Vector3Int Voxel;
public Vector3Int Normal;
public Vector3 Position;
public float Distance;
}