Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3D Gaussian Splat support #5785

Merged
merged 18 commits into from Nov 8, 2023
Binary file added examples/assets/splats/guitar.ply
Binary file not shown.
1 change: 1 addition & 0 deletions examples/src/examples/loaders/index.mjs
Expand Up @@ -4,3 +4,4 @@ export * from './glb.mjs';
export * from './gltf-export.mjs';
export * from './obj.mjs';
export * from './usdz-export.mjs';
export * from './splat.mjs';
102 changes: 102 additions & 0 deletions examples/src/examples/loaders/splat.mjs
@@ -0,0 +1,102 @@
import * as pc from 'playcanvas';

/**
* @typedef {import('../../options.mjs').ExampleOptions} ExampleOptions
* @param {import('../../options.mjs').ExampleOptions} options - The example options.
* @returns {Promise<pc.AppBase>} The example application.
*/
async function example({ canvas, deviceType, assetPath, scriptsPath, glslangPath, twgslPath, pcx }) {

const gfxOptions = {
deviceTypes: [deviceType],
glslangUrl: glslangPath + 'glslang.js',
twgslUrl: twgslPath + 'twgsl.js'
};

const device = await pc.createGraphicsDevice(canvas, gfxOptions);
const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
createOptions.mouse = new pc.Mouse(document.body);
createOptions.touch = new pc.TouchDevice(document.body);

createOptions.componentSystems = [
// @ts-ignore
pc.RenderComponentSystem,
// @ts-ignore
pc.CameraComponentSystem,
// @ts-ignore
pc.LightComponentSystem,
// @ts-ignore
pc.ScriptComponentSystem
];
createOptions.resourceHandlers = [
// @ts-ignore
pc.TextureHandler,
// @ts-ignore
pc.ContainerHandler,
// @ts-ignore
pc.ScriptHandler
];

const app = new pc.AppBase(canvas);
app.init(createOptions);

pcx.registerPlyParser(app);

// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);

// Ensure canvas is resized when window changes size
const resize = () => app.resizeCanvas();
window.addEventListener('resize', resize);
app.on('destroy', () => {
window.removeEventListener('resize', resize);
});

const assets = {
splat: new pc.Asset('splat', 'container', { url: assetPath + 'splats/guitar.ply' }),
orbit: new pc.Asset('script', 'script', { url: scriptsPath + 'camera/orbit-camera.js' })
};

const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
assetListLoader.load(() => {

app.start();

app.scene.ambientLight = new pc.Color(0.2, 0.2, 0.2);

// Create an Entity with a camera component
const camera = new pc.Entity();
camera.addComponent("camera", {
clearColor: new pc.Color(0.2, 0.2, 0.2)
});
camera.setLocalPosition(4, 1, 4);

// add orbit camera script with a mouse and a touch support
camera.addComponent("script");
camera.script.create("orbitCamera", {
attributes: {
inertiaFactor: 0.2,
distanceMax: 60,
frameOnStart: false
}
});
camera.script.create("orbitCameraInputMouse");
camera.script.create("orbitCameraInputTouch");
app.root.addChild(camera);

const entity = assets.splat.resource.instantiateRenderEntity({
cameraEntity: camera,
debugRender: false
});
app.root.addChild(entity);
});
return app;
}

export class SplatExample {
static CATEGORY = 'Loaders';
static NAME = 'Splat';
static example = example;
}
3 changes: 3 additions & 0 deletions extras/index.js
Expand Up @@ -3,3 +3,6 @@ export { MiniStats } from './mini-stats/mini-stats.js';
// exporters
export { UsdzExporter } from './exporters/usdz-exporter.js';
export { GltfExporter } from './exporters/gltf-exporter.js';

// splat
export { registerPlyParser } from './splat/ply-parser.js';
49 changes: 49 additions & 0 deletions extras/splat/ply-parser.js
@@ -0,0 +1,49 @@
import { SplatContainerResource } from './splat-container-resource.js';
import { SplatData } from './splat-data.js';
import { readPly } from './ply-reader.js';

// filter out element data we're not going to use
const elements = [
'x', 'y', 'z',
'red', 'green', 'blue',
'opacity',
'f_dc_0', 'f_dc_1', 'f_dc_2',
'scale_0', 'scale_1', 'scale_2',
'rot_0', 'rot_1', 'rot_2', 'rot_3'
];

class PlyParser {
device;

assets;

maxRetries;

constructor(device, assets, maxRetries) {
this.device = device;
this.assets = assets;
this.maxRetries = maxRetries;
}

async load(url, callback) {
const response = await fetch(url.load);
readPly(response.body.getReader(), new Set(elements))
.then((response) => {
callback(null, new SplatContainerResource(this.device, new SplatData(response)));
})
.catch((err) => {
callback(err, null);
});
}

open(url, data) {
return data;
}
}

const registerPlyParser = (app) => {
const containerHandler = app.loader.getHandler('container');
containerHandler.parsers.ply = new PlyParser(app.graphicsDevice, app.assets, app.loader.maxRetries);
};

export { registerPlyParser };
216 changes: 216 additions & 0 deletions extras/splat/ply-reader.js
@@ -0,0 +1,216 @@
const magicBytes = new Uint8Array([112, 108, 121, 10]); // ply\n
const endHeaderBytes = new Uint8Array([10, 101, 110, 100, 95, 104, 101, 97, 100, 101, 114, 10]); // \nend_header\n

const dataTypeMap = new Map([
['char', Int8Array],
['uchar', Uint8Array],
['short', Int16Array],
['ushort', Uint16Array],
['int', Int32Array],
['uint', Uint32Array],
['float', Float32Array],
['double', Float64Array]
]);

class PlyProperty {
type;

name;

storage;

byteSize;
}

class PlyElement {
name;

count;

properties;
}

// asynchronously read a ply file data
const readPly = async (reader, propertyFilter = null) => {
const concat = (a, b) => {
const c = new Uint8Array(a.byteLength + b.byteLength);
c.set(a);
c.set(b, a.byteLength);
return c;
};

const find = (buf, search) => {
const endIndex = buf.length - search.length;
let i, j;
for (i = 0; i < endIndex; ++i) {
for (j = 0; j < search.length; ++j) {
if (buf[i + j] !== search[j]) {
break;
}
}
if (j === search.length) {
return i;
}
}
return -1;
};

const startsWith = (a, b) => {
if (a.length < b.length) {
return false;
}

for (let i = 0; i < b.length; ++i) {
if (a[i] !== b[i]) {
return false;
}
}

return true;
};

let buf;
let endHeaderIndex;

while (true) {
// get the next chunk
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read();

if (done) {
throw new Error('Stream finished before end of header');
}

// combine new chunk with the previous
buf = buf ? concat(buf, value) : value;

// check magic bytes
if (buf.length >= magicBytes.length && !startsWith(buf, magicBytes)) {
throw new Error('Invalid ply header');
}

// check if we can find the end-of-header marker
endHeaderIndex = find(buf, endHeaderBytes);

if (endHeaderIndex !== -1) {
break;
}
}

// decode buffer header text
const headerText = new TextDecoder('ascii').decode(buf.slice(0, endHeaderIndex));

// split into lines and remove comments
const headerLines = headerText.split('\n')
.filter(line => !line.startsWith('comment '));

// decode header and allocate data storage
const elements = [];
for (let i = 1; i < headerLines.length; ++i) {
const words = headerLines[i].split(' ');

switch (words[0]) {
case 'format':
if (words[1] !== 'binary_little_endian') {
throw new Error('Unsupported ply format');
}
break;
case 'element':
elements.push({
name: words[1],
count: parseInt(words[2], 10),
properties: []
});
break;
case 'property': {
if (!dataTypeMap.has(words[1])) {
throw new Error(`Unrecognized property data type '${words[1]}' in ply header`);
}
const element = elements[elements.length - 1];
const storageType = dataTypeMap.get(words[1]);
const storage = (!propertyFilter || propertyFilter.has(words[2])) ? new storageType(element.count) : null;
element.properties.push({
type: words[1],
name: words[2],
storage: storage,
byteSize: storageType.BYTES_PER_ELEMENT
});
break;
}
default:
throw new Error(`Unrecognized header value '${words[0]}' in ply header`);
}
}

// read data
let readIndex = endHeaderIndex + endHeaderBytes.length;
let remaining = buf.length - readIndex;
let dataView = new DataView(buf.buffer);

for (let i = 0; i < elements.length; ++i) {
const element = elements[i];

for (let e = 0; e < element.count; ++e) {
for (let j = 0; j < element.properties.length; ++j) {
const property = element.properties[j];

// if we've run out of data, load the next chunk
while (remaining < property.byteSize) {
const { value, done } = await reader.read();

if (done) {
throw new Error('Stream finished before end of data');
}

// create buffer with left-over data from previous chunk and the new data
const tmp = new Uint8Array(remaining + value.byteLength);
tmp.set(buf.slice(readIndex));
tmp.set(value, remaining);

buf = tmp;
dataView = new DataView(buf.buffer);
readIndex = 0;
remaining = buf.length;
}

if (property.storage) {
switch (property.type) {
case 'char':
property.storage[e] = dataView.getInt8(readIndex);
break;
case 'uchar':
property.storage[e] = dataView.getUint8(readIndex);
break;
case 'short':
property.storage[e] = dataView.getInt16(readIndex, true);
break;
case 'ushort':
property.storage[e] = dataView.getUint16(readIndex, true);
break;
case 'int':
property.storage[e] = dataView.getInt32(readIndex, true);
break;
case 'uint':
property.storage[e] = dataView.getUint32(readIndex, true);
break;
case 'float':
property.storage[e] = dataView.getFloat32(readIndex, true);
break;
case 'double':
property.storage[e] = dataView.getFloat64(readIndex, true);
break;
}
}

readIndex += property.byteSize;
remaining -= property.byteSize;
}
}
}

// console.log(elements);

return elements;
};

export { readPly, PlyProperty, PlyElement };