Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Actions can be repeated and applied in any order:
-C, --lod-chunk-count <n> Approx number of Gaussians per LOD chunk in K. Default: 512
-X, --lod-chunk-extent <n> Approx size of an LOD chunk in world units (m). Default: 16
-R, --voxel-resolution <n> Voxel size in world units for .voxel.json. Default: 0.05
-A, --opacity-cutoff <n> Opacity threshold for solid voxels. Default: 0.5
-A, --opacity-cutoff <n> Opacity threshold for solid voxels. Default: 0.1
```

> [!NOTE]
Expand Down
17 changes: 16 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"lib/"
],
"dependencies": {
"meshoptimizer": "^1.0.1",
"webgpu": "^0.3.8"
},
"devDependencies": {
Expand Down
21 changes: 17 additions & 4 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ const parseArguments = async () => {
'lod-chunk-extent': { type: 'string', short: 'X', default: '16' },
unbundled: { type: 'boolean', short: 'U', default: false },
'voxel-resolution': { type: 'string', short: 'R', default: '0.05' },
'opacity-cutoff': { type: 'string', short: 'A', default: '0.5' },
'opacity-cutoff': { type: 'string', short: 'A', default: '0.1' },
'collision-mesh': { type: 'boolean', short: 'K', default: false },
'mesh-simplify': { type: 'string', short: 'T', default: '0.25' },

// per-file options
translate: { type: 'string', short: 't', multiple: true },
Expand Down Expand Up @@ -179,9 +181,15 @@ const parseArguments = async () => {
lodChunkCount: parseInteger(v['lod-chunk-count']),
lodChunkExtent: parseInteger(v['lod-chunk-extent']),
voxelResolution: parseNumber(v['voxel-resolution']),
opacityCutoff: parseNumber(v['opacity-cutoff'])
opacityCutoff: parseNumber(v['opacity-cutoff']),
collisionMesh: v['collision-mesh'],
meshSimplify: parseNumber(v['mesh-simplify'])
Comment thread
slimbuck marked this conversation as resolved.
};

if (!Number.isFinite(options.meshSimplify) || options.meshSimplify < 0 || options.meshSimplify > 1) {
throw new Error(`Invalid mesh-simplify value: ${options.meshSimplify}. Must be a finite number between 0 and 1.`);
}

for (const t of tokens) {
if (t.kind === 'positional') {
files.push({
Expand Down Expand Up @@ -392,7 +400,9 @@ GLOBAL OPTIONS
-C, --lod-chunk-count <n> Approximate number of Gaussians per LOD chunk in K. Default: 512
-X, --lod-chunk-extent <n> Approximate size of an LOD chunk in world units (m). Default: 16
-R, --voxel-resolution <n> Voxel size in world units for .voxel.json. Default: 0.05
-A, --opacity-cutoff <n> Opacity threshold for solid voxels. Default: 0.5
-A, --opacity-cutoff <n> Opacity threshold for solid voxels. Default: 0.1
-K, --collision-mesh Generate collision mesh (.collision.glb) with voxel output
-T, --mesh-simplify <n> Ratio of triangles to keep for collision mesh (0-1). Default: 0.25

EXAMPLES
# Scale then translate
Expand All @@ -410,9 +420,12 @@ EXAMPLES
# Generate LOD with custom chunk size and node split size
splat-transform -O 0,1,2 -C 1024 -X 32 input.lcc output/lod-meta.json

# Generate voxel collision data
# Generate voxel data
splat-transform input.ply output.voxel.json

# Generate voxel data with collision mesh
splat-transform -K input.ply output.voxel.json

# Generate voxel data with custom resolution and opacity threshold
splat-transform -R 0.1 -A 0.3 input.ply output.voxel.json

Expand Down
2 changes: 2 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export { writeLod } from './writers/write-lod';
export { writeGlb } from './writers/write-glb';
export { writeVoxel } from './writers/write-voxel';
export type { WriteVoxelOptions, VoxelMetadata } from './writers/write-voxel';
export { marchingCubes } from './voxel/marching-cubes';
export type { MarchingCubesMesh } from './voxel/marching-cubes';

// Types
export type { Options, Param } from './types';
Expand Down
6 changes: 6 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ type Options = {

/** Opacity threshold for solid voxels - voxels below this are considered empty. Default: 0.5 */
opacityCutoff?: number;

/** Whether to generate a collision mesh (.collision.glb) alongside voxel output. Default: false */
collisionMesh?: boolean;

/** Ratio of triangles to keep when simplifying the collision mesh (0-1). Default: 0.25 */
meshSimplify?: number;
};

/**
Expand Down
121 changes: 121 additions & 0 deletions src/lib/voxel/collision-glb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Build a minimal GLB (glTF 2.0 binary) file containing a single triangle mesh.
*
* The output contains only positions and triangle indices — no normals,
* UVs, or materials — suitable for collision meshes.
*
* @param positions - Vertex positions (3 floats per vertex)
* @param indices - Triangle indices (3 per triangle, unsigned 32-bit)
* @returns GLB file as a Uint8Array
*/
function buildCollisionGlb(positions: Float32Array, indices: Uint32Array): Uint8Array {
const vertexCount = positions.length / 3;
const indexCount = indices.length;

let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
Comment thread
slimbuck marked this conversation as resolved.

const positionsByteLength = positions.byteLength;
const indicesByteLength = indices.byteLength;
const totalBinSize = positionsByteLength + indicesByteLength;

const gltf = {
asset: { version: '2.0', generator: 'splat-transform' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{
primitives: [{
attributes: { POSITION: 0 },
indices: 1
}]
}],
accessors: [
{
bufferView: 0,
componentType: 5126, // FLOAT
count: vertexCount,
type: 'VEC3',
min: [minX, minY, minZ],
max: [maxX, maxY, maxZ]
},
{
bufferView: 1,
componentType: 5125, // UNSIGNED_INT
count: indexCount,
type: 'SCALAR'
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positionsByteLength,
target: 34962 // ARRAY_BUFFER
},
{
buffer: 0,
byteOffset: positionsByteLength,
byteLength: indicesByteLength,
target: 34963 // ELEMENT_ARRAY_BUFFER
}
],
buffers: [{ byteLength: totalBinSize }]
};

const jsonString = JSON.stringify(gltf);
const jsonEncoder = new TextEncoder();
const jsonBytes = jsonEncoder.encode(jsonString);

// JSON chunk must be padded to 4-byte alignment with spaces (0x20)
const jsonPadding = (4 - (jsonBytes.length % 4)) % 4;
const jsonChunkLength = jsonBytes.length + jsonPadding;

// BIN chunk must be padded to 4-byte alignment with zeros
const binPadding = (4 - (totalBinSize % 4)) % 4;
const binChunkLength = totalBinSize + binPadding;

Comment thread
slimbuck marked this conversation as resolved.
// GLB layout: header (12) + JSON chunk header (8) + JSON data + BIN chunk header (8) + BIN data
const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength;
const buffer = new ArrayBuffer(totalLength);
const view = new DataView(buffer);
const byteArray = new Uint8Array(buffer);
let offset = 0;

// GLB header
view.setUint32(offset, 0x46546C67, true); offset += 4; // magic: "glTF"
view.setUint32(offset, 2, true); offset += 4; // version: 2
view.setUint32(offset, totalLength, true); offset += 4; // total length

// JSON chunk header
view.setUint32(offset, jsonChunkLength, true); offset += 4;
view.setUint32(offset, 0x4E4F534A, true); offset += 4; // type: "JSON"

// JSON chunk data
byteArray.set(jsonBytes, offset); offset += jsonBytes.length;
for (let i = 0; i < jsonPadding; i++) {
byteArray[offset++] = 0x20;
}

// BIN chunk header
view.setUint32(offset, binChunkLength, true); offset += 4;
view.setUint32(offset, 0x004E4942, true); offset += 4; // type: "BIN\0"

// BIN chunk data: positions then indices
byteArray.set(new Uint8Array(positions.buffer, positions.byteOffset, positionsByteLength), offset);
offset += positionsByteLength;
byteArray.set(new Uint8Array(indices.buffer, indices.byteOffset, indicesByteLength), offset);

return byteArray;
}

export { buildCollisionGlb };
4 changes: 4 additions & 0 deletions src/lib/voxel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@ export {
} from './sparse-octree.js';

export type { SparseOctree, Bounds } from './sparse-octree.js';

export { marchingCubes } from './marching-cubes.js';

export type { MarchingCubesMesh } from './marching-cubes.js';
Loading