Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Previewing a Gaussian Splat .ply in three.js without a splat renderer

If you've trained a 3D Gaussian Splat, or exported one from a capture app, you end up with a .ply file. Open it in a normal 3D viewer and you get one of three things: an error, a cloud of grey dots, or a tangle of random

Previewing a Gaussian Splat .ply in three.js without a splat renderer

If you've trained a 3D Gaussian Splat, or exported one from a capture app, you end up with a .ply file. Open it in a normal 3D viewer and you get one of three things: an error, a cloud of grey dots, or a tangle of random triangles.

I work on a browser-based PLY viewer, and I wanted it to answer one question quickly: what is in this file? A full splat renderer is a bigger project, so the goal here was a fast preview with the right colors, the near-invisible splats left out, and the camera aimed at the subject.

The file I tested with is the bonsai scene from the Mip-NeRF 360 dataset (trained splat from the dylanebert/3dgs collection on Hugging Face). It has 1,157,141 splats in 287 MB.

1. It's a point cloud, not a mesh

A splat PLY has a vertex element and no face element. three.js's PLYLoader gives you a BufferGeometry with no index. If you pass that to a THREE.Mesh, which is what a generic viewer does, three.js treats every three consecutive vertices as a triangle. That's where the random-triangle tangle comes from.

The fix is to check for an index and use THREE.Points when there isn't one:

if (!geometry.getIndex()) {
  return new THREE.Points(geometry, new THREE.PointsMaterial({
    size: pointSize,
    sizeAttenuation: true,
    vertexColors: hasColor,
  }));
}

2. Detecting a splat from the header

The header tells you which kind of PLY you have. Here's the bonsai file (with the 45 f_rest_* lines shortened):

format binary_little_endian 1.0
element vertex 1157141
property float x
property float y
property float z
property float nx
property float ny
property float nz
property float f_dc_0
property float f_dc_1
property float f_dc_2
property float f_rest_0
...
property float f_rest_44
property float opacity
property float scale_0
property float scale_1
property float scale_2
property float rot_0
property float rot_1
property float rot_2
property float rot_3
end_header

There are no red/green/blue properties. A f_dc_0 property is a reliable sign that the file is a splat. Each splat is 62 floats, or 248 bytes. The 45 f_rest_* values make up 72.6% of the file. They hold view-dependent color, and a preview can ignore them.

3. Turning f_dc into a color

f_dc_0..2 are the zero-order spherical-harmonic coefficients for R, G and B. The base color is:

color = 0.5 + SH_C0 * f_dc      where SH_C0 = 0.28209479177387814

PLYLoader ignores properties it doesn't know about unless you map them to an attribute name:

const loader = new PLYLoader();
loader.setCustomPropertyNameMapping({
  shDc: ["f_dc_0", "f_dc_1", "f_dc_2"],
  splatOpacity: ["opacity"],
});
const geometry = loader.parse(buffer);

const dc = geometry.getAttribute("shDc");
const colors = new Float32Array(dc.count * 3);
const c = new THREE.Color();
const clamp = (v: number) => Math.min(1, Math.max(0, 0.5 + SH_C0 * v));
for (let i = 0; i < dc.count; i++) {
  c.setRGB(clamp(dc.getX(i)), clamp(dc.getY(i)), clamp(dc.getZ(i)), THREE.SRGBColorSpace);
  colors.set([c.r, c.g, c.b], i * 3);
}
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));

Pass THREE.SRGBColorSpace to setRGB. The vertex color attribute is expected in linear space, and this value is a display color. If you skip the conversion, the colors come out washed out.

4. Most splats are almost invisible

This one surprised me. Opacity is stored as a logit, so the real alpha is sigmoid(opacity). Across the bonsai file:

alpha below share of splats
0.05 21.8%
0.10 37.7%
0.25 61.9%
0.50 79.5%

A splat renderer blends all of these and the faint ones barely show. A point renderer draws every one as a solid dot, so more than a third of the points are haze that shouldn't be visible.

I drop splats below 10% alpha. The comparison happens in logit space, so there's no exp per point:

const MIN_ALPHA = 0.1;
const minLogit = Math.log(MIN_ALPHA / (1 - MIN_ALPHA)); // ≈ -2.197
for (let i = 0; i < n; i++) {
  if (opacity.getX(i) >= minLogit) keep.push(i);
}

The viewer shows how many it skipped, so nothing disappears silently.

5. Framing: aim at the subject, not at the floaters

My viewer fits the camera to the object's bounding sphere. On a trained splat, a handful of stray points far from the scene stretch that sphere. Here are the bonsai splats' distances from their median position:

percentile distance
50th 8.99
90th 14.42
99th 18.63
max 37.81

The farthest point is twice as far out as the 99th percentile, so the scene took up a small patch in the middle of the view. For point clouds I now sample up to 20k points, take the per-axis median as the center and the 95th-percentile distance as the radius:

const center = new THREE.Vector3(median(xs), median(ys), median(zs));
const dist = xs.map((x, i) =>
  Math.hypot(x - center.x, ys[i] - center.y, zs[i] - center.z)
).sort((a, b) => a - b);
const radius = dist[Math.floor(dist.length * 0.95)];

I use this sphere only when it's much tighter than the exact bounds. A clean scan with no outliers keeps the exact fit, so nothing gets clipped.

Before and after: the same splat with every point drawn and framed on all points, versus near-transparent splats skipped and framed on the 95th percentile

6. Point size

One fixed size doesn't work, because scenes range from millimetres to hundreds of metres. I scale it from the bounding-box diagonal and the point count, then clamp it:

const pointSize = Math.min(diag / 50,
  Math.max(diag / 2000, (diag / Math.sqrt(count)) * 1.5));

Denser clouds get smaller points, and the clamps stop it going to extremes.

What this is not

A real splat renderer draws each splat as an oriented, stretched ellipse (scale_*, rot_*), sorts the splats by depth, alpha-blends them, and evaluates the view-dependent color (f_rest_*). None of that happens here. The goal is a quick check of what a file contains, the kind of check you'd do before loading it into a real splat tool. The viewer says this on screen too.

Two more limits:

  • The viewer takes files up to 200 MB. The full bonsai file is 287 MB, so for the screenshots I kept every second splat (578,571 of them).
  • A splat has no surface, so it can't be exported as STL or 3MF. The viewer refuses with a clear message instead of writing an empty file. If you need a mesh, reconstruct a surface first (for example, Poisson reconstruction in MeshLab or CloudCompare).

Try it

flip3d.app/ply-viewer opens splat PLYs, scanner point clouds and colored meshes. It also shows the file's header fields, so you can see exactly which properties are in the file. Everything runs in your browser and the file isn't uploaded. The code is in the repo: lib/converters/parse.ts (reading and filtering) and components/MeshViewer.tsx (framing).

If you have a PLY that shows up wrong, send it my way in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.