diff --git a/.eslintrc.json b/.eslintrc.json
index 2affb160e3b..e2d5dd439b9 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -83,6 +83,13 @@
"ecmaVersion": 6
}
},
+ {
+ /* This module uses ES8 async / await */
+ "files": ["./src/components/splat.js"],
+ "parserOptions": {
+ "ecmaVersion": 8
+ }
+ },
{
/* This code is external, and the ES5 restrictions do not apply to it. */
"files": ["./src/lib/**/*.js"],
diff --git a/docs/components/splat.md b/docs/components/splat.md
new file mode 100644
index 00000000000..656cf03f459
--- /dev/null
+++ b/docs/components/splat.md
@@ -0,0 +1,26 @@
+---
+title: splat
+type: components
+layout: docs
+parent_section: components
+source_code: src/components/splat.js
+examples: []
+---
+
+A loader for 3D Gaussian Splats files.
+
+## Example
+```html
+
+
+
+```
+
+## Properties
+
+| Property | Description | Default Value |
+|---------------|---------------------------------------------------|---------------|
+| src | URL to the splat file | |
+| cutoutEntity | entity to define a cutout area (splats outside won't render) | |
+| pixelRatio | To downscale resolution for better performance | 1 |
+| xrPixelRatio | Downscale resolution in VR for better performance | 0.5 |
diff --git a/examples/index.html b/examples/index.html
index 7abca21f73e..bf7599ed7b8 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -139,6 +139,7 @@
Examples
Composite
Curved Mockups
Dynamic Lights
+ Gaussian Splats
Hand Tracking
Hand Tracking Grab Controls
User Interface
diff --git a/examples/showcase/gaussian-splats/index.html b/examples/showcase/gaussian-splats/index.html
new file mode 100644
index 00000000000..57200f189d9
--- /dev/null
+++ b/examples/showcase/gaussian-splats/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+ 3D Gaussian Splatting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/index.js b/src/components/index.js
index 0754d87768d..77eb1219c2d 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -32,6 +32,7 @@ import './rotation.js';
import './scale.js';
import './shadow.js';
import './sound.js';
+import './splat.js';
import './text.js';
import './tracked-controls.js';
import './visible.js';
diff --git a/src/components/splat.js b/src/components/splat.js
new file mode 100644
index 00000000000..abbc50026fe
--- /dev/null
+++ b/src/components/splat.js
@@ -0,0 +1,895 @@
+ /* global Worker, Blob, fetch */
+ import { registerComponent } from '../core/component.js';
+ import THREE from '../lib/three.js';
+
+ registerComponent('splat', {
+ schema: {
+ src: {type: 'asset'},
+ cutoutEntity: {type: 'selector'},
+ pixelRatio: {type: 'number', default: 1},
+ xrPixelRatio: {type: 'number', default: 0.5}
+ },
+
+ init: function () {
+ this.initWorker();
+ },
+
+ update: function () {
+ var data = this.data;
+ var sceneEl = this.el.sceneEl;
+ if (data.src) { this.loadSplat(); }
+ if (data.pixelRatio > 0) {
+ sceneEl.renderer.setPixelRatio(this.data.pixelRatio);
+ }
+ if (data.xrPixelRatio > 0) {
+ sceneEl.renderer.xr.setFramebufferScaleFactor(this.data.xrPixelRatio);
+ }
+ },
+
+ loadSplat: function () {
+ var sceneEl = this.el.sceneEl;
+ var src = this.data.src;
+
+ if (!src) { return; }
+
+ // Postpone if scene not loaded.
+ if (!sceneEl.hasLoaded) {
+ sceneEl.addEventListener('loaded', this.loadSplat.bind(this));
+ return;
+ }
+
+ if (this.data.cutoutEntity) {
+ this.worldToCutoutMatrix = new THREE.Matrix4();
+ this.cutout = this.data.cutoutEntity.object3D;
+ }
+
+ var isPly = src.endsWith('.ply');
+ this.loadedVerticesNumber = 0;
+ this.worker.postMessage({method: 'clear'});
+ if (isPly) {
+ this.loadPlyFile(src);
+ } else {
+ this.loadSplatFile(src);
+ }
+ },
+
+ loadPlyFile: async function loadPlyFile (src) {
+ var response = await fetch(src);
+ var reader = response.body.getReader();
+
+ var rowLength = 3 * 4 + 3 * 4 + 4 + 4;
+ var bytesDownloaded = 0;
+ var bytesFileTotal = response.headers.get('Content-Length');
+ bytesFileTotal = bytesFileTotal ? parseInt(bytesFileTotal) : undefined;
+ var chunks = [];
+ var start = Date.now();
+ var lastReportedProgress = 0;
+
+ while (true) {
+ try {
+ var dataReceived = await reader.read();
+ if (dataReceived.done) {
+ console.log('Completed download.');
+ break;
+ }
+ var newChunk = dataReceived.value;
+ bytesDownloaded += newChunk.length;
+ chunks.push(newChunk);
+
+ // Download progress stats.
+ if (bytesFileTotal) {
+ var mbps = (bytesDownloaded / 1024 / 1024) / ((Date.now() - start) / 1000);
+ var percent = bytesDownloaded / bytesFileTotal * 100;
+ if (percent - lastReportedProgress > 1) {
+ console.log('Download progress:', percent.toFixed(2) + '%', mbps.toFixed(2) + ' Mbps');
+ lastReportedProgress = percent;
+ }
+ } else {
+ console.log('Download progress:', bytesDownloaded, ', unknown total');
+ }
+ } catch (error) {
+ console.error(error);
+ break;
+ }
+ }
+
+ if (bytesDownloaded === 0) { return; }
+ var chunksBufferLength = chunks.reduce(function add (sum, chunk) { return sum + chunk.length; }, 0);
+ var concatenatedChunks = new Uint8Array(chunksBufferLength);
+
+ // Concatenate the chunks into a single Uint8Array
+ var offset = 0;
+ for (var chunk of chunks) {
+ concatenatedChunks.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ concatenatedChunks = new Uint8Array(this.processPlyBuffer(concatenatedChunks.buffer));
+ var numVertices = Math.floor(concatenatedChunks.byteLength / rowLength);
+ await this.initGL(numVertices);
+ this.pushDataBuffer(concatenatedChunks.buffer, numVertices);
+ },
+
+ loadSplatFile: async function loadSplatFile (src) {
+ var response = await fetch(src);
+ var reader = response.body.getReader();
+
+ var rowLength = 3 * 4 + 3 * 4 + 4 + 4;
+ var bytesDownloaded = 0;
+ var bytesProcessed = 0;
+ var bytesFileTotal = response.headers.get('Content-Length');
+ bytesFileTotal = bytesFileTotal ? parseInt(bytesFileTotal) : undefined;
+
+ if (bytesFileTotal) {
+ var numVertices = Math.floor(bytesFileTotal / rowLength);
+ await this.initGL(numVertices);
+ }
+
+ var chunks = [];
+ var start = Date.now();
+ var lastReportedProgress = 0;
+
+ while (true) {
+ try {
+ var dataReceived = await reader.read();
+ if (dataReceived.done) {
+ console.log('Completed download.');
+ break;
+ }
+ var newChunk = dataReceived.value;
+ bytesDownloaded += newChunk.length;
+ chunks.push(newChunk);
+
+ // Downloar progress stats.
+ if (bytesFileTotal) {
+ var mbps = (bytesDownloaded / 1024 / 1024) / ((Date.now() - start) / 1000);
+ var percent = bytesDownloaded / bytesFileTotal * 100;
+ if (percent - lastReportedProgress > 1) {
+ console.log('Download progress:', percent.toFixed(2) + '%', mbps.toFixed(2) + ' Mbps');
+ lastReportedProgress = percent;
+ }
+ } else {
+ console.log('Download progress:', bytesDownloaded, ', unknown total');
+ }
+
+ var bytesToProcess = bytesDownloaded - bytesProcessed;
+ if (bytesFileTotal && bytesToProcess > rowLength) {
+ var concatenatedChunksbuffer = new Uint8Array(bytesToProcess);
+ var offset = 0;
+ for (var chunk of chunks) {
+ concatenatedChunksbuffer.set(chunk, offset);
+ offset += chunk.length;
+ }
+
+ var chunkBytesProcessed = this.processSplatChunk(concatenatedChunksbuffer, bytesToProcess);
+ bytesProcessed += chunkBytesProcessed;
+
+ // Reset chunks array. Copy leftover bytes if chunks not perfect multiple of a splat data structure.
+ chunks.length = 0;
+ if (bytesToProcess > chunkBytesProcessed) {
+ var extraData = new Uint8Array(bytesToProcess - chunkBytesProcessed);
+ extraData.set(concatenatedChunksbuffer.subarray(bytesToProcess - extraData.length, bytesToProcess), 0);
+ chunks.push(extraData);
+ }
+ }
+ } catch (error) {
+ console.error(error);
+ break;
+ }
+ }
+ },
+
+ processSplatChunk: function (chunksBuffer, chunkSize) {
+ var rowLength = 3 * 4 + 3 * 4 + 4 + 4;
+ var vertexCount = Math.floor(chunkSize / rowLength);
+ var bytesProcessed = vertexCount * rowLength;
+ var chunkBuffer = new Uint8Array(bytesProcessed);
+ chunkBuffer.set(chunksBuffer.subarray(0, chunkBuffer.byteLength), 0);
+ this.pushDataBuffer(chunkBuffer.buffer, vertexCount);
+ return bytesProcessed;
+ },
+
+ initGL: async function initGL (numVertices) {
+ console.log('initGL', numVertices);
+ var renderer = this.el.sceneEl.renderer;
+ var gl = renderer.getContext();
+ var maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
+ this.maxVertices = maxTextureSize * maxTextureSize;
+
+ // Clamp number of vertices to maximum texture size.
+ if (numVertices > this.maxVertices) {
+ console.log('Number of vertices limited to ', this.maxVertices, numVertices);
+ numVertices = this.maxVertices;
+ }
+
+ this.bufferTextureWidth = maxTextureSize;
+ this.bufferTextureHeight = Math.floor((numVertices - 1) / maxTextureSize) + 1;
+
+ this.centerAndScaleData = new Float32Array(this.bufferTextureWidth * this.bufferTextureHeight * 4);
+ this.covAndColorData = new Uint32Array(this.bufferTextureWidth * this.bufferTextureHeight * 4);
+ this.centerAndScaleTexture = new THREE.DataTexture(this.centerAndScaleData, this.bufferTextureWidth, this.bufferTextureHeight, THREE.RGBA, THREE.FloatType);
+ this.centerAndScaleTexture.needsUpdate = true;
+
+ this.covAndColorTexture = new THREE.DataTexture(this.covAndColorData, this.bufferTextureWidth, this.bufferTextureHeight, THREE.RGBAIntegerFormat, THREE.UnsignedIntType);
+ this.covAndColorTexture.internalFormat = 'RGBA32UI';
+ this.covAndColorTexture.needsUpdate = true;
+
+ var quadGeometry = new THREE.BufferGeometry();
+ // Why 2.0?
+ var vertices = new Float32Array([
+ -2.0, -2.0, 0.0,
+ 2.0, 2.0, 0.0,
+ -2.0, 2.0, 0.0,
+
+ 2.0, -2.0, 0.0,
+ 2.0, 2.0, 0.0,
+ -2.0, -2.0, 0.0
+ ]);
+ quadGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
+
+ var splatIndexArray = new Uint32Array(this.bufferTextureWidth * this.bufferTextureHeight);
+ var splatIndices = new THREE.InstancedBufferAttribute(splatIndexArray, 1, false);
+ splatIndices.setUsage(THREE.DynamicDrawUsage);
+ var geometry = new THREE.InstancedBufferGeometry().copy(quadGeometry);
+ geometry.setAttribute('splatIndex', splatIndices);
+ geometry.instanceCount = 1;
+
+ var material = new THREE.ShaderMaterial({
+ uniforms: {
+ viewport: {value: new Float32Array([1980, 1080])}, // Dummy. will be overwritten
+ focal: {value: 1000.0}, // Dummy. will be overwritten
+ centerAndScaleTexture: {value: this.centerAndScaleTexture},
+ covAndColorTexture: {value: this.covAndColorTexture},
+ gsProjectionMatrix: {value: this.getProjectionMatrix()},
+ gsModelViewMatrix: {value: this.getModelViewMatrix()}
+ },
+ vertexShader: this.vertexShader,
+ fragmentShader: this.fragmentShader,
+ blending: THREE.CustomBlending,
+ blendSrcAlpha: THREE.OneFactor,
+ depthTest: true,
+ depthWrite: false,
+ transparent: true
+ });
+
+ material.onBeforeRender = (renderer, scene, camera, geometry, object, group) => {
+ var projectionMatrix = this.getProjectionMatrix(camera);
+ mesh.material.uniforms.gsProjectionMatrix.value = projectionMatrix;
+ mesh.material.uniforms.gsModelViewMatrix.value = this.getModelViewMatrix(camera);
+
+ var viewport = new THREE.Vector4();
+ renderer.getCurrentViewport(viewport);
+ var focal = (viewport.w / 2.0) * Math.abs(-1);
+ material.uniforms.viewport.value[0] = viewport.z;
+ material.uniforms.viewport.value[1] = viewport.w;
+ material.uniforms.focal.value = focal;
+ };
+
+ var mesh = this.mesh = new THREE.Mesh(geometry, material);
+ mesh.frustumCulled = false;
+ this.el.setObject3D('mesh', mesh);
+
+ // Init textures.
+ renderer.initTexture(this.centerAndScaleTexture);
+ renderer.initTexture(this.covAndColorTexture);
+ this.startSplatsSort();
+ },
+
+ pushDataBuffer: function (buffer, vertexCount) {
+ if (this.loadedVerticesNumber + vertexCount > this.maxVertices) {
+ console.log('vertexCount limited to ', this.maxVertices, vertexCount);
+ vertexCount = this.maxVertices - this.loadedVerticesNumber;
+ }
+ if (vertexCount <= 0) { return; }
+
+ var uBuffer = new Uint8Array(buffer);
+ var fBuffer = new Float32Array(buffer);
+ var matrices = new Float32Array(vertexCount * 16);
+
+ var covAndColorDataUint8 = new Uint8Array(this.covAndColorData.buffer);
+ var covAndColorDataInt16 = new Int16Array(this.covAndColorData.buffer);
+ for (var i = 0; i < vertexCount; i++) {
+ var quat = new THREE.Quaternion(
+ (uBuffer[32 * i + 28 + 1] - 128) / 128.0,
+ (uBuffer[32 * i + 28 + 2] - 128) / 128.0,
+ -(uBuffer[32 * i + 28 + 3] - 128) / 128.0,
+ (uBuffer[32 * i + 28 + 0] - 128) / 128.0
+ );
+ var center = new THREE.Vector3(
+ fBuffer[8 * i + 0],
+ fBuffer[8 * i + 1],
+ -fBuffer[8 * i + 2]
+ );
+ var scale = new THREE.Vector3(
+ fBuffer[8 * i + 3 + 0],
+ fBuffer[8 * i + 3 + 1],
+ fBuffer[8 * i + 3 + 2]
+ );
+
+ var mtx = new THREE.Matrix4();
+ mtx.makeRotationFromQuaternion(quat);
+ mtx.transpose();
+ mtx.scale(scale);
+ var mtxt = mtx.clone();
+ mtx.transpose();
+ mtx.premultiply(mtxt);
+ mtx.setPosition(center);
+
+ var covIndexes = [0, 1, 2, 5, 6, 10];
+ var maxValue = 0.0;
+ for (var j = 0; j < covIndexes.length; j++) {
+ if (Math.abs(mtx.elements[covIndexes[j]]) > maxValue) {
+ maxValue = Math.abs(mtx.elements[covIndexes[j]]);
+ }
+ }
+
+ var destOffset = this.loadedVerticesNumber * 4 + i * 4;
+ this.centerAndScaleData[destOffset + 0] = center.x;
+ this.centerAndScaleData[destOffset + 1] = center.y;
+ this.centerAndScaleData[destOffset + 2] = center.z;
+ this.centerAndScaleData[destOffset + 3] = maxValue / 32767.0;
+
+ destOffset = this.loadedVerticesNumber * 8 + i * 4 * 2;
+ for (j = 0; j < covIndexes.length; j++) {
+ covAndColorDataInt16[destOffset + j] = parseInt(mtx.elements[covIndexes[j]] * 32767.0 / maxValue);
+ }
+
+ // RGBA
+ destOffset = this.loadedVerticesNumber * 16 + (i * 4 + 3) * 4;
+ covAndColorDataUint8[destOffset + 0] = uBuffer[32 * i + 24 + 0];
+ covAndColorDataUint8[destOffset + 1] = uBuffer[32 * i + 24 + 1];
+ covAndColorDataUint8[destOffset + 2] = uBuffer[32 * i + 24 + 2];
+ covAndColorDataUint8[destOffset + 3] = uBuffer[32 * i + 24 + 3];
+
+ // Store scale and transparent to remove splat in sorting process
+ mtx.elements[15] = Math.max(scale.x, scale.y, scale.z) * uBuffer[32 * i + 24 + 3] / 255.0;
+
+ for (j = 0; j < 16; j++) {
+ matrices[i * 16 + j] = mtx.elements[j];
+ }
+ }
+
+ var renderer = this.el.sceneEl.renderer;
+ var gl = renderer.getContext();
+ while (vertexCount > 0) {
+ var width = 0;
+ var height = 0;
+ var xoffset = (this.loadedVerticesNumber % this.bufferTextureWidth);
+ var yoffset = Math.floor(this.loadedVerticesNumber / this.bufferTextureWidth);
+ if (this.loadedVerticesNumber % this.bufferTextureWidth !== 0) {
+ width = Math.min(this.bufferTextureWidth, xoffset + vertexCount) - xoffset;
+ height = 1;
+ } else if (Math.floor(vertexCount / this.bufferTextureWidth) > 0) {
+ width = this.bufferTextureWidth;
+ height = Math.floor(vertexCount / this.bufferTextureWidth);
+ } else {
+ width = vertexCount % this.bufferTextureWidth;
+ height = 1;
+ }
+
+ var centerAndScaleTextureProperties = renderer.properties.get(this.centerAndScaleTexture);
+ gl.bindTexture(gl.TEXTURE_2D, centerAndScaleTextureProperties.__webglTexture);
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, xoffset, yoffset, width, height, gl.RGBA, gl.FLOAT, this.centerAndScaleData, this.loadedVerticesNumber * 4);
+
+ var covAndColorTextureProperties = renderer.properties.get(this.covAndColorTexture);
+ gl.bindTexture(gl.TEXTURE_2D, covAndColorTextureProperties.__webglTexture);
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, xoffset, yoffset, width, height, gl.RGBA_INTEGER, gl.UNSIGNED_INT, this.covAndColorData, this.loadedVerticesNumber * 4);
+
+ this.loadedVerticesNumber += width * height;
+ vertexCount -= width * height;
+ }
+
+ this.worker.postMessage({
+ method: 'push',
+ matrices: matrices.buffer
+ }, [matrices.buffer]);
+ },
+
+ getProjectionMatrix: (function () {
+ var projectionMatrix = new THREE.Matrix4();
+ return function (camera) {
+ camera = camera || this.el.sceneEl.camera.el.components.camera.camera;
+ projectionMatrix.copy(camera.projectionMatrix);
+ projectionMatrix.elements[4] *= -1;
+ projectionMatrix.elements[5] *= -1;
+ projectionMatrix.elements[6] *= -1;
+ projectionMatrix.elements[7] *= -1;
+ return projectionMatrix;
+ };
+ })(),
+
+ getModelViewMatrix: (function () {
+ var modelMatrix = new THREE.Matrix4();
+ var viewMatrix = new THREE.Matrix4();
+ return function (camera) {
+ // Matrix to go from model (object) local coordinates to world (M)
+ modelMatrix.copy(this.el.object3D.matrixWorld);
+ modelMatrix.elements[1] *= -1;
+ modelMatrix.elements[4] *= -1;
+ modelMatrix.elements[6] *= -1;
+ modelMatrix.elements[9] *= -1;
+ modelMatrix.elements[13] *= -1;
+
+ camera = camera || this.el.sceneEl.camera.el.components.camera.camera;
+ // Matrix to go from local camera coordinates to world (C)
+ viewMatrix.copy(camera.matrixWorld);
+ viewMatrix.elements[1] *= -1;
+ viewMatrix.elements[4] *= -1;
+ viewMatrix.elements[6] *= -1;
+ viewMatrix.elements[9] *= -1;
+ viewMatrix.elements[13] *= -1;
+
+ // Invert to get a matrix to go from world to camera coordinates (C-1)
+ viewMatrix.invert();
+
+ // V = C-1 * M
+ // Model view matrix. Goes first from model (object) coordinates to world and then
+ // from world to camera.
+ var modelViewMatrix = viewMatrix.multiply(modelMatrix);
+ return modelViewMatrix;
+ };
+ })(),
+
+ initWorker: function () {
+ var self = this;
+ var worker = this.worker = new Worker(
+ URL.createObjectURL(
+ new Blob(['(', this.initWorkerCode.toString(), ')(self)'], {
+ type: 'application/javascript'
+ })
+ )
+ );
+
+ worker.onmessage = function updateSplatIndices (e) {
+ var indices = new Uint32Array(e.data.sortedIndices);
+ var mesh = self.mesh;
+ mesh.geometry.attributes.splatIndex.set(indices);
+ mesh.geometry.attributes.splatIndex.needsUpdate = true;
+ mesh.geometry.instanceCount = indices.length;
+ self.startSplatsSort();
+ };
+ },
+
+ startSplatsSort: function () {
+ var modelViewMatrix = this.getModelViewMatrix();
+ var view = new Float32Array([modelViewMatrix.elements[2], modelViewMatrix.elements[6], modelViewMatrix.elements[10], modelViewMatrix.elements[14]]);
+ var worldToCutoutMatrix = this.worldToCutoutMatrix;
+ if (this.cutout) {
+ worldToCutoutMatrix.copy(this.cutout.matrixWorld);
+ worldToCutoutMatrix.invert();
+ worldToCutoutMatrix.multiply(this.el.object3D.matrixWorld);
+ }
+ this.worker.postMessage({
+ method: 'sort',
+ view: view.buffer,
+ cutout: this.cutout ? new Float32Array(worldToCutoutMatrix.elements) : undefined
+ }, [view.buffer]);
+ },
+
+ initWorkerCode: function (self) {
+ var matrices;
+
+ // multiply: matrix4x4 * vector3
+ var mul = function mul (e, x, y, z) {
+ var w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
+
+ return [
+ (e[0] * x + e[4] * y + e[8] * z + e[12]) * w,
+ (e[1] * x + e[5] * y + e[9] * z + e[13]) * w,
+ (e[2] * x + e[6] * y + e[10] * z + e[14]) * w
+ ];
+ };
+
+ var sortSplats = function sortSplats (matrices, view, cutout) {
+ var vertexCount = matrices.length / 16;
+ var threshold = -0.0001;
+
+ var maxDepth = -Infinity;
+ var minDepth = Infinity;
+ var depthList = new Float32Array(vertexCount);
+ var sizeList = new Int32Array(depthList.buffer);
+ var validIndexList = new Int32Array(vertexCount);
+ var validCount = 0;
+
+ // Discard splats behind the camera,
+ // too small or outside of the cutout box (if any defined).
+ for (var i = 0; i < vertexCount; i++) {
+ // Sign of depth is reversed
+ var depth =
+ (view[0] * matrices[i * 16 + 12] +
+ view[1] * matrices[i * 16 + 13] +
+ view[2] * matrices[i * 16 + 14] +
+ view[3]);
+
+ // Skip splats behind of camera.
+ if (depth >= 0) { continue; }
+
+ // Skip if splat is too small.
+ if (matrices[i * 16 + 15] <= threshold * depth) { continue; }
+
+ if (cutout !== undefined) {
+ // Position-based culling
+ var posX = matrices[i * 16 + 12];
+ var posY = matrices[i * 16 + 13];
+ var posZ = matrices[i * 16 + 14];
+
+ // convert to cutout space – not sure why Y axis is inverted
+ var cutoutSpacePos = mul(cutout, posX, -posY, posZ);
+
+ // Skip if splat is outside of the cutout area.
+ if (cutoutSpacePos[0] < -0.5 || cutoutSpacePos[0] > 0.5 ||
+ cutoutSpacePos[1] < -0.5 || cutoutSpacePos[1] > 0.5 ||
+ cutoutSpacePos[2] < -0.5 || cutoutSpacePos[2] > 0.5) {
+ continue;
+ }
+ }
+
+ depthList[validCount] = depth;
+ validIndexList[validCount] = i;
+ validCount++;
+ if (depth > maxDepth) { maxDepth = depth; }
+ if (depth < minDepth) { minDepth = depth; }
+ }
+
+ // Sort the splats by depth that have not been discarded.
+ // 16 bit single-pass counting sort.
+ // Divide depth range in 2^16 slots.
+ var depthInv = (256 * 256 - 1) / (maxDepth - minDepth);
+ var counts0 = new Uint32Array(256 * 256);
+ // Counts number of splats on each depth slot.
+ for (i = 0; i < validCount; i++) {
+ sizeList[i] = ((depthList[i] - minDepth) * depthInv) | 0;
+ counts0[sizeList[i]]++;
+ }
+
+ // Indices range for each of the depth slots.
+ var starts0 = new Uint32Array(256 * 256);
+ for (i = 1; i < 256 * 256; i++) {
+ starts0[i] = starts0[i - 1] + counts0[i - 1];
+ }
+
+ // Sorts the splats by depth.
+ var depthIndex = new Uint32Array(validCount);
+ for (i = 0; i < validCount; i++) {
+ depthIndex[starts0[sizeList[i]]++] = validIndexList[i];
+ }
+
+ return depthIndex;
+ };
+
+ self.onmessage = function onMessage (e) {
+ if (e.data.method === 'clear') {
+ matrices = undefined;
+ }
+ if (e.data.method === 'push') {
+ var newMatrices = new Float32Array(e.data.matrices);
+ if (matrices === undefined) {
+ matrices = newMatrices;
+ } else {
+ var resized = new Float32Array(matrices.length + newMatrices.length);
+ resized.set(matrices);
+ resized.set(newMatrices, matrices.length);
+ matrices = resized;
+ }
+ }
+ if (e.data.method === 'sort') {
+ if (matrices === undefined) {
+ var sortedIndices = new Uint32Array(1);
+ self.postMessage({sortedIndices}, [sortedIndices.buffer]);
+ } else {
+ var view = new Float32Array(e.data.view);
+ var cutout = e.data.cutout !== undefined ? new Float32Array(e.data.cutout) : undefined;
+ sortedIndices = sortSplats(matrices, view, cutout);
+ self.postMessage({sortedIndices}, [sortedIndices.buffer]);
+ }
+ }
+ };
+ },
+
+ processPlyBuffer: function (inputBuffer) {
+ var ubuf = new Uint8Array(inputBuffer);
+ // 10KB ought to be enough for a header...
+ var header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10));
+ var headerEnd = 'end_header\n';
+ var headerEndIndex = header.indexOf(headerEnd);
+ if (headerEndIndex < 0) {
+ throw new Error('Unable to read .ply file header');
+ }
+ var vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)[1]);
+ console.log('Vertex Count', vertexCount);
+ var rowOffset = 0;
+ var offsets = {};
+ var types = {};
+ var TYPE_MAP = {
+ double: 'getFloat64',
+ int: 'getInt32',
+ uint: 'getUint32',
+ float: 'getFloat32',
+ short: 'getInt16',
+ ushort: 'getUint16',
+ uchar: 'getUint8'
+ };
+
+ for (var prop of header
+ .slice(0, headerEndIndex)
+ .split('\n')
+ .filter((k) => k.startsWith('property '))) {
+ var [, type, name] = prop.split(' ');
+ var arrayType = TYPE_MAP[type] || 'getInt8';
+ types[name] = arrayType;
+ offsets[name] = rowOffset;
+ rowOffset += parseInt(arrayType.replace(/[^\d]/g, '')) / 8;
+ }
+ console.log('Bytes per row', rowOffset, types, offsets);
+
+ var dataView = new DataView(
+ inputBuffer,
+ headerEndIndex + headerEnd.length
+ );
+ var row = 0;
+ var attrs = new Proxy(
+ {},
+ {
+ get (target, prop) {
+ if (!types[prop]) throw new Error(prop + ' not found');
+ return dataView[types[prop]](
+ row * rowOffset + offsets[prop],
+ true
+ );
+ }
+ }
+ );
+
+ console.time('calculate importance');
+ var sizeList = new Float32Array(vertexCount);
+ var sizeIndex = new Uint32Array(vertexCount);
+ for (row = 0; row < vertexCount; row++) {
+ sizeIndex[row] = row;
+ if (!types['scale_0']) continue;
+ var size =
+ Math.exp(attrs.scale_0) *
+ Math.exp(attrs.scale_1) *
+ Math.exp(attrs.scale_2);
+ var opacity = 1 / (1 + Math.exp(-attrs.opacity));
+ sizeList[row] = size * opacity;
+ }
+ console.timeEnd('calculate importance');
+
+ console.time('sort');
+ sizeIndex.sort((b, a) => sizeList[a] - sizeList[b]);
+ console.timeEnd('sort');
+
+ // 6*4 + 4 + 4 = 8*4
+ // XYZ - Position (Float32)
+ // XYZ - Scale (Float32)
+ // RGBA - colors (uint8)
+ // IJKL - quaternion/rot (uint8)
+ var rowLength = 3 * 4 + 3 * 4 + 4 + 4;
+ var buffer = new ArrayBuffer(rowLength * vertexCount);
+
+ console.time('build buffer');
+ for (var j = 0; j < vertexCount; j++) {
+ row = sizeIndex[j];
+
+ var position = new Float32Array(buffer, j * rowLength, 3);
+ var scales = new Float32Array(buffer, j * rowLength + 4 * 3, 3);
+ var rgba = new Uint8ClampedArray(
+ buffer,
+ j * rowLength + 4 * 3 + 4 * 3,
+ 4
+ );
+ var rot = new Uint8ClampedArray(
+ buffer,
+ j * rowLength + 4 * 3 + 4 * 3 + 4,
+ 4
+ );
+
+ if (types['scale_0']) {
+ var qlen = Math.sqrt(
+ attrs.rot_0 ** 2 +
+ attrs.rot_1 ** 2 +
+ attrs.rot_2 ** 2 +
+ attrs.rot_3 ** 2
+ );
+
+ rot[0] = (attrs.rot_0 / qlen) * 128 + 128;
+ rot[1] = (attrs.rot_1 / qlen) * 128 + 128;
+ rot[2] = (attrs.rot_2 / qlen) * 128 + 128;
+ rot[3] = (attrs.rot_3 / qlen) * 128 + 128;
+
+ scales[0] = Math.exp(attrs.scale_0);
+ scales[1] = Math.exp(attrs.scale_1);
+ scales[2] = Math.exp(attrs.scale_2);
+ } else {
+ scales[0] = 0.01;
+ scales[1] = 0.01;
+ scales[2] = 0.01;
+
+ rot[0] = 255;
+ rot[1] = 0;
+ rot[2] = 0;
+ rot[3] = 0;
+ }
+
+ position[0] = attrs.x;
+ position[1] = attrs.y;
+ position[2] = attrs.z;
+
+ if (types['f_dc_0']) {
+ var SH_C0 = 0.28209479177387814;
+ rgba[0] = (0.5 + SH_C0 * attrs.f_dc_0) * 255;
+ rgba[1] = (0.5 + SH_C0 * attrs.f_dc_1) * 255;
+ rgba[2] = (0.5 + SH_C0 * attrs.f_dc_2) * 255;
+ } else {
+ rgba[0] = attrs.red;
+ rgba[1] = attrs.green;
+ rgba[2] = attrs.blue;
+ }
+ if (types['opacity']) {
+ rgba[3] = (1 / (1 + Math.exp(-attrs.opacity))) * 255;
+ } else {
+ rgba[3] = 255;
+ }
+ }
+ console.timeEnd('build buffer');
+ return buffer;
+ },
+
+ vertexShader: `
+ precision highp sampler2D;
+ precision highp usampler2D;
+
+ out vec4 vColor;
+ out vec2 vPosition;
+ uniform vec2 viewport;
+ uniform float focal;
+ uniform mat4 gsProjectionMatrix;
+ uniform mat4 gsModelViewMatrix;
+
+ attribute uint splatIndex;
+ uniform sampler2D centerAndScaleTexture;
+ uniform usampler2D covAndColorTexture;
+
+ vec2 unpackInt16(in uint value) {
+ int v = int(value);
+ int v0 = v >> 16;
+ int v1 = (v & 0xFFFF);
+ if((v & 0x8000) != 0)
+ v1 |= 0xFFFF0000;
+ return vec2(float(v1), float(v0));
+ }
+
+ void main () {
+ ivec2 texSize = textureSize(centerAndScaleTexture, 0);
+ ivec2 texPos = ivec2(splatIndex % uint(texSize.x), splatIndex / uint(texSize.x));
+ vec4 centerAndScaleData = texelFetch(centerAndScaleTexture, texPos, 0);
+
+ vec4 center = vec4(centerAndScaleData.xyz, 1);
+
+ // Model view and projection matrices calculated for every frame.
+ // Not sure we cannot use built-in ones.
+ vec4 camspace = gsModelViewMatrix * center;
+ vec4 pos2d = gsProjectionMatrix * camspace;
+
+ float bounds = 1.2 * pos2d.w;
+ if (pos2d.z < -pos2d.w || pos2d.x < -bounds || pos2d.x > bounds
+ || pos2d.y < -bounds || pos2d.y > bounds) {
+ gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
+ return;
+ }
+
+ uvec4 covAndColorData = texelFetch(covAndColorTexture, texPos, 0);
+ // Applies splat scale.
+ vec2 cov3D_M11_M12 = unpackInt16(covAndColorData.x) * centerAndScaleData.w;
+ vec2 cov3D_M13_M22 = unpackInt16(covAndColorData.y) * centerAndScaleData.w;
+ vec2 cov3D_M23_M33 = unpackInt16(covAndColorData.z) * centerAndScaleData.w;
+
+ // 3D covariance matrix.
+ // This is output of splat training with scale applied above.
+ mat3 Vrk = mat3(
+ cov3D_M11_M12.x, cov3D_M11_M12.y, cov3D_M13_M22.x,
+ cov3D_M11_M12.y, cov3D_M13_M22.y, cov3D_M23_M33.x,
+ cov3D_M13_M22.x, cov3D_M23_M33.x, cov3D_M23_M33.y
+ );
+
+ // Project 3D covariance matrix in 2D.
+ // Section 6.2.2 EWA Splatting, 2001
+ // local affine approximation of the projective transformation
+ // Some values and signs different than paper. Not sure why.
+ mat3 J = mat3(
+ focal / camspace.z, 0., -(focal * camspace.x) / (camspace.z * camspace.z),
+ 0., -focal / camspace.z, (focal * camspace.y) / (camspace.z * camspace.z),
+ 0., 0., 0.
+ );
+
+ // Section 4 fig (5). 3D Gaussian Splatting for Real-Time Radiance Field Rendering
+ // Bernhard Kerbl
+ // In paper. cov = J * W * Vrk * transpose (W) * transpose(J)
+ // Need to understand de the difference.
+ mat3 W = transpose(mat3(gsModelViewMatrix));
+ mat3 T = W * J;
+ mat3 cov = transpose(T) * Vrk * T;
+
+ // 2D projection of the center of the splat.
+ vec2 vCenter = vec2(pos2d) / pos2d.w;
+
+ // Covariance matrix determines the orientation and
+ // shape of the ellipse that represents a splat.
+ // 2D projection of the 3D covariance matrix that
+ // represents the original splat ellipsoid.
+ // 0.3 offset to prevent splats too small / degenerate.
+ float diagonal1 = cov[0][0] + 0.3;
+ float offDiagonal = cov[0][1];
+ float diagonal2 = cov[1][1] + 0.3;
+
+ // Calculating the eigen values of the covariance matrix.
+ // Via characteristics equation. det(C - λI) = 0
+ // It's going to be a quadratic function of the form aλ^2 + bλ + c = 0
+ // a = 1
+ // b = -(diagonal1 + diagonal2)
+ // c = offdiagonal ^ 2
+ // Simplified calculation (Radii and rotation section) https://cookierobotics.com/007/
+ // Quadratic formula equivalent to standard by dividing by 2a. Yields simpler calculation.
+ // Standard: aλ^2 + bλ + c
+ // Divided by 2a: λ^2 / 2 + bλ / 2a + c / 2a = 0
+ // Quadratic formula
+ // λ = - b / 2 +- sqr ((b / 2a)^2 - (c / a))
+ // First term of quadratic formula -b / 2
+ float mid = (diagonal1 + diagonal2) / 2.0;
+ // Second term of quadratic formula sqr ((b / 2)^2 - (c)) and c = offdiagonal ^ 2
+ float radius = length(vec2((diagonal1 - diagonal2) / 2.0, offDiagonal));
+ // The two eigen values that represent variance along ellipse principal axis.
+ // +- solutions of the quadratic formula.
+ float lambda1 = mid + radius;
+ // Preven too small. Degenerate ellipse.
+ float lambda2 = max(mid - radius, 0.1);
+
+ // Direction vector of one the ellipse axis.
+ // calculate one of the eigen vectors.
+ // Solution of (C - λI)v = 0 where C=covariance matrix.
+ vec2 diagonalVector = normalize(vec2(offDiagonal, lambda1 - diagonal1));
+ float majorAxisLength = min(sqrt(2.0 * lambda1), 1024.0);
+ vec2 v1 = majorAxisLength * diagonalVector;
+ float minorAxisLength = min(sqrt(2.0 * lambda2), 1024.0);
+ // For the other ellipse axis. we calculate perpendicular vector in projection coords.
+ // swap x,y. flip de sign of one.
+ vec2 v2 = minorAxisLength * vec2(diagonalVector.y, -diagonalVector.x);
+
+ // Solid color of the ellipse / splat. No Spherical Harmonic Coefficients / View dependent colors.
+ uint colorUint = covAndColorData.w;
+ vColor = vec4(
+ float(colorUint & uint(0xFF)) / 255.0,
+ float((colorUint >> uint(8)) & uint(0xFF)) / 255.0,
+ float((colorUint >> uint(16)) & uint(0xFF)) / 255.0,
+ float(colorUint >> uint(24)) / 255.0
+ );
+
+ vPosition = position.xy;
+
+ // Displaces each vertex of the quad representing the splat by:
+ // translate the splat to its position.
+ // displace the vertex by each the ellipse axis vectors and transform to NDC coordinates.
+ gl_Position = vec4(
+ vCenter
+ + position.x * v2 / viewport * 2.0
+ + position.y * v1 / viewport * 2.0, pos2d.z / pos2d.w, 1.0);
+
+ }
+ `,
+
+ fragmentShader: `
+ in vec4 vColor;
+ in vec2 vPosition;
+
+ void main () {
+ // square of vector length.
+ float A = dot(vPosition, vPosition);
+ // discards fragments outside of the ellipse.
+ // otherwise the full quad would be shaded.
+ if (A > 4.0) discard;
+ // Fade the edge of the ellipse.
+ float B = exp(-A) * vColor.a;
+ gl_FragColor = vec4(vColor.rgb, B);
+ }
+ `
+ });