Code/PlyReader.cs
using System.Globalization;
using System.IO;
using System.Text;
namespace Sandbox;
/// <summary>
/// Reads PLY point cloud data.
/// </summary>
public sealed class PlyReader
{
public struct PlyData
{
public Vector3[] Positions;
public Color[] Colors;
public float[] Opacities;
public Vector3[] Scales;
public Vector4[] Rotations;
public bool HasColors;
public bool HasOpacity;
public bool HasScaleRotation;
public int VertexCount;
}
private enum PlyFormat
{
Ascii,
BinaryLittleEndian,
BinaryBigEndian
}
private struct PropertyInfo
{
public string Name;
public string Type;
public int ByteSize;
}
/// <summary>
/// Read vertex positions from a PLY file stream.
/// </summary>
public static PlyData Read( Stream stream )
{
using var reader = new BinaryReader( stream, Encoding.ASCII, leaveOpen: true );
// Validate magic number
var magic = ReadAsciiLine( reader );
if ( magic != "ply" )
throw new Exception( "Not a valid PLY file — missing 'ply' magic number" );
var format = PlyFormat.Ascii;
int vertexCount = 0;
var properties = new List<PropertyInfo>();
bool inVertexElement = false;
// Parse header
while ( true )
{
var line = ReadAsciiLine( reader );
if ( line is null )
throw new Exception( "Unexpected end of file while reading PLY header" );
if ( line == "end_header" )
break;
var parts = line.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
if ( parts.Length == 0 )
continue;
switch ( parts[0] )
{
case "format" when parts.Length >= 2:
format = parts[1] switch
{
"ascii" => PlyFormat.Ascii,
"binary_little_endian" => PlyFormat.BinaryLittleEndian,
"binary_big_endian" => PlyFormat.BinaryBigEndian,
_ => throw new Exception( $"Unsupported PLY format: {parts[1]}" )
};
break;
case "element" when parts.Length >= 3:
// When we hit a new element, stop tracking properties for vertex
inVertexElement = parts[1] == "vertex";
if ( inVertexElement )
vertexCount = int.Parse( parts[2], CultureInfo.InvariantCulture );
break;
case "property" when inVertexElement && parts.Length >= 3:
// Skip list properties (eg. face vertex indices)
if ( parts[1] == "list" )
break;
properties.Add( new PropertyInfo
{
Name = parts[2],
Type = parts[1],
ByteSize = GetTypeByteSize( parts[1] )
} );
break;
}
}
if ( vertexCount == 0 )
return new PlyData { Positions = [], VertexCount = 0 };
// Find x, y, z property indices
int xIdx = properties.FindIndex( p => p.Name == "x" );
int yIdx = properties.FindIndex( p => p.Name == "y" );
int zIdx = properties.FindIndex( p => p.Name == "z" );
if ( xIdx < 0 || yIdx < 0 || zIdx < 0 )
throw new Exception( "PLY file missing required x, y, z vertex properties" );
// Look for color data, either SH DC coefficients or direct RGB
int shR = properties.FindIndex( p => p.Name == "f_dc_0" );
int shG = properties.FindIndex( p => p.Name == "f_dc_1" );
int shB = properties.FindIndex( p => p.Name == "f_dc_2" );
bool hasSH = shR >= 0 && shG >= 0 && shB >= 0;
int rgbR = properties.FindIndex( p => p.Name == "red" );
int rgbG = properties.FindIndex( p => p.Name == "green" );
int rgbB = properties.FindIndex( p => p.Name == "blue" );
bool hasRGB = rgbR >= 0 && rgbG >= 0 && rgbB >= 0;
bool hasColors = hasSH || hasRGB;
// Look for opacity (stored as logit in 3DGS PLY files)
int opacityIdx = properties.FindIndex( p => p.Name == "opacity" );
bool hasOpacity = opacityIdx >= 0;
// Look for Gaussian scale and rotation data
int s0 = properties.FindIndex( p => p.Name == "scale_0" );
int s1 = properties.FindIndex( p => p.Name == "scale_1" );
int s2 = properties.FindIndex( p => p.Name == "scale_2" );
int r0 = properties.FindIndex( p => p.Name == "rot_0" );
int r1 = properties.FindIndex( p => p.Name == "rot_1" );
int r2 = properties.FindIndex( p => p.Name == "rot_2" );
int r3 = properties.FindIndex( p => p.Name == "rot_3" );
bool hasScaleRot = s0 >= 0 && s1 >= 0 && s2 >= 0
&& r0 >= 0 && r1 >= 0 && r2 >= 0 && r3 >= 0;
var positions = new Vector3[vertexCount];
var colors = hasColors ? new Color[vertexCount] : null;
var opacities = hasOpacity ? new float[vertexCount] : null;
var scales = hasScaleRot ? new Vector3[vertexCount] : null;
var rotations = hasScaleRot ? new Vector4[vertexCount] : null;
var readParams = new VertexReadParams
{
xIdx = xIdx,
yIdx = yIdx,
zIdx = zIdx,
HasSH = hasSH,
shR = shR,
shG = shG,
shB = shB,
HasRGB = hasRGB,
rgbR = rgbR,
rgbG = rgbG,
rgbB = rgbB,
HasOpacity = hasOpacity,
opacityIdx = opacityIdx,
HasScaleRot = hasScaleRot,
s0 = s0,
s1 = s1,
s2 = s2,
r0 = r0,
r1 = r1,
r2 = r2,
r3 = r3
};
if ( format == PlyFormat.Ascii )
ReadAsciiVertices( reader, properties, positions, colors, opacities, scales, rotations, vertexCount, readParams );
else if ( format == PlyFormat.BinaryLittleEndian )
ReadBinaryVertices( reader, properties, positions, colors, opacities, scales, rotations, vertexCount, readParams );
else
throw new Exception( "Binary big-endian PLY is not supported" );
return new PlyData
{
Positions = positions,
Colors = colors,
Opacities = opacities,
Scales = scales,
Rotations = rotations,
HasColors = hasColors,
HasOpacity = hasOpacity,
HasScaleRotation = hasScaleRot,
VertexCount = vertexCount
};
}
/// <summary>
/// Indices into the property list for position and color fields.
/// </summary>
private struct VertexReadParams
{
public int xIdx, yIdx, zIdx;
public bool HasSH;
public int shR, shG, shB;
public bool HasRGB;
public int rgbR, rgbG, rgbB;
public bool HasOpacity;
public int opacityIdx;
public bool HasScaleRot;
public int s0, s1, s2;
public int r0, r1, r2, r3;
}
/// <summary>
/// Convert SH degree-0 coefficient to linear color.
/// SH_C0 = 0.28209479177387814 = 1 / (2 * sqrt(pi))
/// </summary>
private static Color SHToColor( float dc0, float dc1, float dc2 )
{
const float SH_C0 = 0.28209479177387814f;
float r = Math.Clamp( SH_C0 * dc0 + 0.5f, 0f, 1f );
float g = Math.Clamp( SH_C0 * dc1 + 0.5f, 0f, 1f );
float b = Math.Clamp( SH_C0 * dc2 + 0.5f, 0f, 1f );
return new Color( r, g, b, 1f );
}
/// <summary>
/// Convert 0-255 integer RGB to Color.
/// </summary>
private static Color RGBToColor( float r, float g, float b )
{
return new Color( r / 255f, g / 255f, b / 255f, 1f );
}
/// <summary>
/// Sigmoid function: converts logit-space opacity to 0-1 range.
/// </summary>
private static float Sigmoid( float x ) => 1f / (1f + MathF.Exp( -x ));
private static void ReadAsciiVertices( BinaryReader reader, List<PropertyInfo> properties,
Vector3[] positions, Color[] colors, float[] opacities, Vector3[] scales, Vector4[] rotations,
int vertexCount, VertexReadParams p )
{
for ( int i = 0; i < vertexCount; i++ )
{
var line = ReadAsciiLine( reader );
if ( line is null )
throw new Exception( $"Unexpected end of file at vertex {i}" );
var values = line.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
float x = float.Parse( values[p.xIdx], CultureInfo.InvariantCulture );
float y = float.Parse( values[p.yIdx], CultureInfo.InvariantCulture );
float z = float.Parse( values[p.zIdx], CultureInfo.InvariantCulture );
positions[i] = new Vector3( x, y, z );
if ( colors is not null )
{
if ( p.HasSH )
{
float dc0 = float.Parse( values[p.shR], CultureInfo.InvariantCulture );
float dc1 = float.Parse( values[p.shG], CultureInfo.InvariantCulture );
float dc2 = float.Parse( values[p.shB], CultureInfo.InvariantCulture );
colors[i] = SHToColor( dc0, dc1, dc2 );
}
else if ( p.HasRGB )
{
float r = float.Parse( values[p.rgbR], CultureInfo.InvariantCulture );
float g = float.Parse( values[p.rgbG], CultureInfo.InvariantCulture );
float b = float.Parse( values[p.rgbB], CultureInfo.InvariantCulture );
colors[i] = RGBToColor( r, g, b );
}
}
if ( opacities is not null )
{
float raw = float.Parse( values[p.opacityIdx], CultureInfo.InvariantCulture );
opacities[i] = Sigmoid( raw );
}
if ( scales is not null )
{
float sv0 = float.Parse( values[p.s0], CultureInfo.InvariantCulture );
float sv1 = float.Parse( values[p.s1], CultureInfo.InvariantCulture );
float sv2 = float.Parse( values[p.s2], CultureInfo.InvariantCulture );
scales[i] = new Vector3( MathF.Exp( sv0 ), MathF.Exp( sv1 ), MathF.Exp( sv2 ) );
float rv0 = float.Parse( values[p.r0], CultureInfo.InvariantCulture );
float rv1 = float.Parse( values[p.r1], CultureInfo.InvariantCulture );
float rv2 = float.Parse( values[p.r2], CultureInfo.InvariantCulture );
float rv3 = float.Parse( values[p.r3], CultureInfo.InvariantCulture );
float rlen = MathF.Sqrt( rv0 * rv0 + rv1 * rv1 + rv2 * rv2 + rv3 * rv3 );
if ( rlen > 0f )
rotations[i] = new Vector4( rv0 / rlen, rv1 / rlen, rv2 / rlen, rv3 / rlen );
else
rotations[i] = new Vector4( 1, 0, 0, 0 );
}
}
}
private static void ReadBinaryVertices( BinaryReader reader, List<PropertyInfo> properties,
Vector3[] positions, Color[] colors, float[] opacities, Vector3[] scales, Vector4[] rotations,
int vertexCount, VertexReadParams p )
{
int vertexStride = 0;
var offsets = new int[properties.Count];
for ( int j = 0; j < properties.Count; j++ )
{
offsets[j] = vertexStride;
vertexStride += properties[j].ByteSize;
}
var vertexBytes = new byte[vertexStride];
for ( int i = 0; i < vertexCount; i++ )
{
int read = reader.Read( vertexBytes, 0, vertexStride );
if ( read < vertexStride )
throw new Exception( $"Unexpected end of file at vertex {i}" );
float x = ReadPropertyAsFloat( vertexBytes, offsets[p.xIdx], properties[p.xIdx] );
float y = ReadPropertyAsFloat( vertexBytes, offsets[p.yIdx], properties[p.yIdx] );
float z = ReadPropertyAsFloat( vertexBytes, offsets[p.zIdx], properties[p.zIdx] );
positions[i] = new Vector3( x, y, z );
if ( colors is not null )
{
if ( p.HasSH )
{
float dc0 = ReadPropertyAsFloat( vertexBytes, offsets[p.shR], properties[p.shR] );
float dc1 = ReadPropertyAsFloat( vertexBytes, offsets[p.shG], properties[p.shG] );
float dc2 = ReadPropertyAsFloat( vertexBytes, offsets[p.shB], properties[p.shB] );
colors[i] = SHToColor( dc0, dc1, dc2 );
}
else if ( p.HasRGB )
{
float r = ReadPropertyAsFloat( vertexBytes, offsets[p.rgbR], properties[p.rgbR] );
float g = ReadPropertyAsFloat( vertexBytes, offsets[p.rgbG], properties[p.rgbG] );
float b = ReadPropertyAsFloat( vertexBytes, offsets[p.rgbB], properties[p.rgbB] );
colors[i] = RGBToColor( r, g, b );
}
}
if ( opacities is not null )
{
float raw = ReadPropertyAsFloat( vertexBytes, offsets[p.opacityIdx], properties[p.opacityIdx] );
opacities[i] = Sigmoid( raw );
}
if ( scales is not null )
{
float sv0 = ReadPropertyAsFloat( vertexBytes, offsets[p.s0], properties[p.s0] );
float sv1 = ReadPropertyAsFloat( vertexBytes, offsets[p.s1], properties[p.s1] );
float sv2 = ReadPropertyAsFloat( vertexBytes, offsets[p.s2], properties[p.s2] );
scales[i] = new Vector3( MathF.Exp( sv0 ), MathF.Exp( sv1 ), MathF.Exp( sv2 ) );
float rv0 = ReadPropertyAsFloat( vertexBytes, offsets[p.r0], properties[p.r0] );
float rv1 = ReadPropertyAsFloat( vertexBytes, offsets[p.r1], properties[p.r1] );
float rv2 = ReadPropertyAsFloat( vertexBytes, offsets[p.r2], properties[p.r2] );
float rv3 = ReadPropertyAsFloat( vertexBytes, offsets[p.r3], properties[p.r3] );
float rlen = MathF.Sqrt( rv0 * rv0 + rv1 * rv1 + rv2 * rv2 + rv3 * rv3 );
if ( rlen > 0f )
rotations[i] = new Vector4( rv0 / rlen, rv1 / rlen, rv2 / rlen, rv3 / rlen );
else
rotations[i] = new Vector4( 1, 0, 0, 0 );
}
}
}
private static float ReadPropertyAsFloat( byte[] buffer, int offset, PropertyInfo prop )
{
return prop.Type switch
{
"float" or "float32" => BitConverter.ToSingle( buffer, offset ),
"double" or "float64" => (float)BitConverter.ToDouble( buffer, offset ),
"int" or "int32" => BitConverter.ToInt32( buffer, offset ),
"uint" or "uint32" => BitConverter.ToUInt32( buffer, offset ),
"short" or "int16" => BitConverter.ToInt16( buffer, offset ),
"ushort" or "uint16" => BitConverter.ToUInt16( buffer, offset ),
"char" or "int8" => (sbyte)buffer[offset],
"uchar" or "uint8" => buffer[offset],
_ => throw new Exception( $"Unsupported PLY property type: {prop.Type}" )
};
}
private static int GetTypeByteSize( string type )
{
return type switch
{
"char" or "int8" or "uchar" or "uint8" => 1,
"short" or "int16" or "ushort" or "uint16" => 2,
"int" or "int32" or "uint" or "uint32" or "float" or "float32" => 4,
"double" or "float64" => 8,
_ => throw new Exception( $"Unknown PLY type: {type}" )
};
}
/// <summary>
/// Read a line of ASCII text from a binary reader, handling both \n and \r\n.
/// </summary>
private static string ReadAsciiLine( BinaryReader reader )
{
var sb = new StringBuilder();
try
{
while ( true )
{
byte b = reader.ReadByte();
if ( b == '\n' )
break;
if ( b == '\r' )
continue;
sb.Append( (char)b );
}
}
catch ( EndOfStreamException )
{
if ( sb.Length == 0 )
return null;
}
return sb.ToString();
}
}