diff --git a/README.md b/README.md
index 1b5c594f..af54bd94 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,51 @@
# SuperSplat - 3D Gaussian Splat Editor
-SuperSplat is a free and open source tool for inspecting and editing 3D Gaussian Splats. It is built on web technologies and runs in the browser, so there's nothing to download or install.
+| [SuperSplat Editor](https://playcanvas.com/supersplat/editor) | [User Guide](https://github.com/playcanvas/supersplat/wiki) | [Forum](https://forum.playcanvas.com/) | [Discord](https://discord.gg/RSaMRzg) |
-A live version of this tool is available at: https://playcanvas.com/supersplat/editor
-
-
-
-See https://repo-sam.inria.fr/fungraph/3d-gaussian-splatting/ for more information on gaussian splats.
+SuperSplat is a free and open source tool for inspecting, editing, optimizing and publishing 3D Gaussian Splats. It is built on web technologies and runs in the browser, so there's nothing to download or install.
-## Loading Scenes
+A live version of this tool is available at: https://playcanvas.com/supersplat/editor
-To load a Gaussian splat PLY file, drag & drop it onto the application page. Alternatively, use the Scene menu and choose "Open".
+![image](https://github.com/user-attachments/assets/b6cbb5cc-d3cc-4385-8c71-ab2807fd4fba)
-
+To learn more about using SuperSplat, please refer to the [User Guide](https://github.com/playcanvas/supersplat/wiki).
-If you disable the "Load all PLY data" option before loading the file, then the PLY data not required by the editor is excluded (for example the spherical harmonic data). This can save on browser memory.
+## Local Development
-## Editing Scenes
+To initialize a local development environment for SuperSplat, ensure you have [Node.js](https://nodejs.org/) 18 or later installed. Follow these steps:
-Once a PLY file is loaded, you will see it appear in the SCENE MANAGER panel. Use this panel to hide splats, remove them from the scene, orientate them and select the current splat for editing.
+1. Clone the repository:
-
+ ```sh
+ git clone https://github.com/playcanvas/supersplat.git
+ cd supersplat
+ ```
-Use the bottom toolbar to access the selection tools, tranform tools and undo/redo.
+2. Install dependencies:
-
+ ```sh
+ npm install
+ ```
-The SPLAT DATA panel plots various scene properties on a histogram display. You can select splats directly by dragging on the histogram view. Use the Shift key to add to the current selection and Ctrl key to remove from the current selection.
+3. Build SuperSplat and start a local web server:
-
+ ```sh
+ npm run develop
+ ```
-## Saving Results
+4. Open a web browser at `http://localhost:3000`.
-Once you're done editing the scene, use the Scene menu to Save, Save As and Export the scene to the local file system. Only visible splats are written.
+When changes to the source are detected, SuperSplat is rebuilt automatically. Simply refresh your browser to see your changes.
-## Local Development
+When running your local build of SuperSplat in Chrome, we recommend you have the Developer Tools panel open. Also:
-The steps required to clone the repo and run a local development server are as follows:
+1. Visit the Network tab and check `Disable cache`.
+2. Visit the Application tab, select `Service workers` on the left and then check `Update on reload` and `Bypass for network`.
-```sh
-git clone https://github.com/playcanvas/supersplat.git
-cd supersplat
-npm i
-npm run develop
-```
+## Contributors
-The last command `npm run develop` will build and run a local version of the editor on port 3000. Changes to the source are detected and the editor is automatically rebuilt.
+SuperSplat is made possible by our amazing open source community:
-To access the local editor instance, open a browser tab and navigate to `http://localhost:3000`.
+
+
+
diff --git a/package-lock.json b/package-lock.json
index c70a8523..e712d66b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "supersplat",
- "version": "1.13.1",
+ "version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "supersplat",
- "version": "1.13.1",
+ "version": "1.14.0",
"license": "MIT",
"devDependencies": {
"@playcanvas/eslint-config": "^2.0.8",
diff --git a/package.json b/package.json
index 18d46eaf..a64c3cb1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "supersplat",
- "version": "1.13.1",
+ "version": "1.14.0",
"author": "PlayCanvas",
"homepage": "https://playcanvas.com/supersplat/editor",
"description": "3D Gaussian Splat Editor",
diff --git a/src/editor.ts b/src/editor.ts
index 0c936a14..4bd71931 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -420,8 +420,7 @@ const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: S
}
};
- await serializePly({
- splats,
+ await serializePly(splats, {
maxSHBands: 3,
selected: true
}, writeFunc);
diff --git a/src/entity-transform-handler.ts b/src/entity-transform-handler.ts
index 5ac266df..373854c3 100644
--- a/src/entity-transform-handler.ts
+++ b/src/entity-transform-handler.ts
@@ -7,7 +7,6 @@ import { Splat } from './splat';
import { Transform } from './transform';
import { TransformHandler } from './transform-handler';
-const vec = new Vec3();
const mat = new Mat4();
const quat = new Quat();
const transform = new Transform();
@@ -40,6 +39,12 @@ class EntityTransformHandler implements TransformHandler {
}
});
+ events.on('pivot.origin', (mode: 'center' | 'boundCenter') => {
+ if (this.splat) {
+ this.placePivot();
+ }
+ });
+
events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => {
if (this.splat) {
const pivot = events.invoke('pivot') as Pivot;
@@ -52,9 +57,8 @@ class EntityTransformHandler implements TransformHandler {
placePivot() {
// place initial pivot point
- const { entity } = this.splat;
- entity.getLocalTransform().transformPoint(this.splat.localBound.center, vec);
- transform.set(vec, entity.getLocalRotation(), entity.getLocalScale());
+ const origin = this.events.invoke('pivot.origin');
+ this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', false, transform);
this.events.fire('pivot.place', transform);
}
diff --git a/src/file-handler.ts b/src/file-handler.ts
index 1a2bf9bf..4127d2d8 100644
--- a/src/file-handler.ts
+++ b/src/file-handler.ts
@@ -5,7 +5,7 @@ import { ElementType } from './element';
import { Events } from './events';
import { Scene } from './scene';
import { Splat } from './splat';
-import { WriteFunc, serializePly, serializePlyCompressed, serializeSplat, serializeViewer, ViewerExportOptions } from './splat-serialize';
+import { WriteFunc, serializePly, serializePlyCompressed, serializeSplat, serializeViewer, ViewerExportSettings } from './splat-serialize';
import { localize } from './ui/localization';
// ts compiler and vscode find this type, but eslint does not
@@ -22,7 +22,7 @@ interface SceneWriteOptions {
type: ExportType;
filename?: string;
stream?: FileSystemWritableFileStream;
- viewerExportOptions?: ViewerExportOptions
+ viewerExportSettings?: ViewerExportSettings
}
const filePickerTypes: { [key: string]: FilePickerAcceptType } = {
@@ -161,7 +161,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
throw new Error('Unsupported file type');
}
} catch (error) {
- events.invoke('showPopup', {
+ await events.invoke('showPopup', {
type: 'error',
header: localize('popup.error-loading'),
message: `${error.message ?? error} while loading '${filename}'`
@@ -201,11 +201,11 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
const name = entry.file?.name;
if (!name) return false;
const lowerName = name.toLowerCase();
- return lowerName.endsWith('.ply') || lowerName.endsWith('.splat');
+ return lowerName.endsWith('.ply') || lowerName.endsWith('.splat') || lowerName.endsWith('.json');
});
if (entries.length === 0) {
- events.invoke('showPopup', {
+ await events.invoke('showPopup', {
type: 'error',
header: localize('popup.error-loading'),
message: localize('popup.drop-files')
@@ -256,6 +256,10 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
.filter(splat => splat.numSplats > 0);
};
+ events.function('scene.splats', () => {
+ return getSplats();
+ });
+
events.function('scene.empty', () => {
return getSplats().length === 0;
});
@@ -403,26 +407,26 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
const hasFilePicker = window.showSaveFilePicker;
- let viewerExportOptions;
+ let viewerExportSettings;
if (type === 'viewer') {
// show viewer export options
- viewerExportOptions = await events.invoke('show.viewerExportPopup', hasFilePicker ? null : filename);
+ viewerExportSettings = await events.invoke('show.viewerExportPopup', hasFilePicker ? null : filename);
// return if user cancelled
- if (!viewerExportOptions) {
+ if (!viewerExportSettings) {
return;
}
if (hasFilePicker) {
- filename = replaceExtension(filename, viewerExportOptions.type === 'html' ? '.html' : '.zip');
+ filename = replaceExtension(filename, viewerExportSettings.type === 'html' ? '.html' : '.zip');
} else {
- filename = viewerExportOptions.filename;
+ filename = viewerExportSettings.filename;
}
}
if (hasFilePicker) {
try {
- const filePickerType = type === 'viewer' ? (viewerExportOptions.type === 'html' ? filePickerTypes.htmlViewer : filePickerTypes.packageViewer) : filePickerTypes[type];
+ const filePickerType = type === 'viewer' ? (viewerExportSettings.type === 'html' ? filePickerTypes.htmlViewer : filePickerTypes.packageViewer) : filePickerTypes[type];
const fileHandle = await window.showSaveFilePicker({
id: 'SuperSplatFileExport',
@@ -432,7 +436,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
await events.invoke('scene.write', {
type,
stream: await fileHandle.createWritable(),
- viewerExportOptions
+ viewerExportSettings
});
} catch (error) {
if (error.name !== 'AbortError') {
@@ -440,31 +444,30 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
}
}
} else {
- await events.invoke('scene.write', { type, filename, viewerExportOptions });
+ await events.invoke('scene.write', { type, filename, viewerExportSettings });
}
});
- const writeScene = async (type: ExportType, writeFunc: WriteFunc, viewerExportOptions?: ViewerExportOptions) => {
+ const writeScene = async (type: ExportType, writeFunc: WriteFunc, viewerExportSettings?: ViewerExportSettings) => {
const splats = getSplats();
const events = splats[0].scene.events;
- const options = {
- splats: splats,
+ const serializeSettings = {
maxSHBands: events.invoke('view.bands')
};
switch (type) {
case 'ply':
- await serializePly(options, writeFunc);
+ await serializePly(splats, serializeSettings, writeFunc);
break;
case 'compressed-ply':
- await serializePlyCompressed(options, writeFunc);
+ await serializePlyCompressed(splats, serializeSettings, writeFunc);
break;
case 'splat':
- await serializeSplat(options, writeFunc);
+ await serializeSplat(splats, serializeSettings, writeFunc);
break;
case 'viewer':
- await serializeViewer(splats, viewerExportOptions, writeFunc);
+ await serializeViewer(splats, viewerExportSettings, writeFunc);
break;
}
};
@@ -478,7 +481,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
setTimeout(resolve);
});
- const { stream, filename, type, viewerExportOptions } = options;
+ const { stream, filename, type, viewerExportSettings } = options;
if (stream) {
// writer must keep track of written bytes because JS streams don't
@@ -489,7 +492,7 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
};
await stream.seek(0);
- await writeScene(type, writeFunc, viewerExportOptions);
+ await writeScene(type, writeFunc, viewerExportSettings);
await stream.truncate(cursor);
await stream.close();
} else if (filename) {
@@ -515,11 +518,11 @@ const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement,
cursor += chunk.byteLength;
}
};
- await writeScene(type, writeFunc, viewerExportOptions);
+ await writeScene(type, writeFunc, viewerExportSettings);
download(filename, (cursor === data.byteLength) ? data : new Uint8Array(data.buffer, 0, cursor));
}
} catch (error) {
- events.invoke('showPopup', {
+ await events.invoke('showPopup', {
type: 'error',
header: localize('popup.error-loading'),
message: `${error.message ?? error} while saving file`
diff --git a/src/main.ts b/src/main.ts
index 6948b65b..85043317 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,7 +5,7 @@ import { EditHistory } from './edit-history';
import { registerEditorEvents } from './editor';
import { Events } from './events';
import { initFileHandler } from './file-handler';
-import { initMaterials } from './material';
+import { registerPublishEvents } from './publish';
import { Scene } from './scene';
import { getSceneConfig } from './scene-config';
import { registerSelectionEvents } from './selection';
@@ -94,6 +94,10 @@ const initShortcuts = (events: Events) => {
};
const main = async () => {
+ // root events object
+ const events = new Events();
+
+ // url
const url = new URL(window.location.href);
// decode remote storage details
@@ -102,8 +106,9 @@ const main = async () => {
remoteStorageDetails = JSON.parse(decodeURIComponent(url.searchParams.get('remoteStorage')));
} catch (e) { }
- // root events object
- const events = new Events();
+ events.function('app.publish', () => {
+ return url.searchParams.get('publish') !== null;
+ });
// edit history
const editHistory = new EditHistory(events);
@@ -121,9 +126,6 @@ const main = async () => {
powerPreference: 'high-performance'
});
- // monkey-patch materials for premul alpha rendering
- initMaterials();
-
const overrides = [
getURLArgs()
];
@@ -244,6 +246,7 @@ const main = async () => {
registerSelectionEvents(events, scene);
registerTransformHandlerEvents(events);
registerAnimationEvents(events);
+ registerPublishEvents(events);
initShortcuts(events);
initFileHandler(scene, events, editorUI.appContainer.dom, remoteStorageDetails);
diff --git a/src/material.ts b/src/material.ts
deleted file mode 100644
index 9445644c..00000000
--- a/src/material.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Material, BLEND_NONE, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA } from 'playcanvas';
-
-let setBlendTypeOrig: any;
-
-function setBlendType(type: number) {
- // set engine function
- setBlendTypeOrig.call(this, type);
-
- // tweak alpha blending
- switch (type) {
- case BLEND_NONE:
- break;
- default:
- this._blendState.setAlphaBlend(BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA);
- break;
- }
-}
-
-// here we patch the material set blendType function to blend
-// alpha correctly
-
-const initMaterials = () => {
- const blendTypeDescriptor = Object.getOwnPropertyDescriptor(Material.prototype, 'blendType');
-
- // store the original setter
- setBlendTypeOrig = blendTypeDescriptor.set;
-
- // update the setter function
- Object.defineProperty(Material.prototype, 'blendType', {
- set(type) {
- setBlendType.call(this, type);
- },
- get() {
- return blendTypeDescriptor.get.call(this);
- }
- });
-};
-
-export { initMaterials };
diff --git a/src/pivot.ts b/src/pivot.ts
index 8107b201..c0a49cf0 100644
--- a/src/pivot.ts
+++ b/src/pivot.ts
@@ -56,12 +56,36 @@ class Pivot {
}
}
+type PivotOrigin = 'center' | 'boundCenter';
+
const registerPivotEvents = (events: Events) => {
const pivot = new Pivot(events);
events.function('pivot', () => {
return pivot;
});
+
+ // pivot mode
+ let origin: PivotOrigin = 'center';
+
+ const setOrigin = (o: PivotOrigin) => {
+ if (o !== origin) {
+ origin = o;
+ events.fire('pivot.origin', origin);
+ }
+ };
+
+ events.function('pivot.origin', () => {
+ return origin;
+ });
+
+ events.on('pivot.setOrigin', (o: PivotOrigin) => {
+ setOrigin(o === 'center' ? 'center' : 'boundCenter');
+ });
+
+ events.on('pivot.toggleOrigin', () => {
+ setOrigin(origin === 'center' ? 'boundCenter' : 'center');
+ });
};
export { registerPivotEvents, Pivot };
diff --git a/src/publish.ts b/src/publish.ts
new file mode 100644
index 00000000..33711aad
--- /dev/null
+++ b/src/publish.ts
@@ -0,0 +1,143 @@
+import { Events } from './events';
+import { serializePlyCompressed, ViewerSettings, SerializeSettings } from './splat-serialize';
+import { localize } from './ui/localization';
+
+type PublishSettings = {
+ title: string;
+ description: string;
+ listed: boolean;
+ viewerSettings: ViewerSettings;
+ serializeSettings: SerializeSettings;
+};
+
+const origin = location.origin;
+
+// check whether user is logged in
+const testUserStatus = async () => {
+ const urlResponse = await fetch(`${origin}/api/id`);
+ return urlResponse.ok;
+};
+
+const publish = async (data: Uint8Array, publishSettings: PublishSettings) => {
+ const filename = 'scene.ply';
+
+ // get signed url
+ const urlResponse = await fetch(`${origin}/api/upload/signed-url`, {
+ method: 'POST',
+ body: JSON.stringify({ filename }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!urlResponse.ok) {
+ throw new Error(`failed to get signed url (${urlResponse.statusText})`);
+ }
+
+ const json = await urlResponse.json();
+
+ // upload the file to S3
+ const uploadResponse = await fetch(json.signedUrl, {
+ method: 'PUT',
+ body: data,
+ headers: {
+ 'Content-Type': 'binary/octet-stream'
+ }
+ });
+
+ if (!uploadResponse.ok) {
+ throw new Error('failed to upload blob');
+ }
+
+ const publishResponse = await fetch(`${origin}/api/splats/publish`, {
+ method: 'POST',
+ body: JSON.stringify({
+ s3Key: json.s3Key,
+ title: publishSettings.title,
+ description: publishSettings.description,
+ listed: publishSettings.listed,
+ settings: publishSettings.viewerSettings
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!publishResponse.ok) {
+ let msg;
+ try {
+ const err = await publishResponse.json();
+ msg = err.error ?? msg;
+ } catch (e) {
+ msg = 'Failed to publish';
+ }
+
+ throw new Error(msg);
+ }
+
+ return await publishResponse.json();
+};
+
+const registerPublishEvents = (events: Events) => {
+ events.function('scene.publish', async () => {
+ const userValid = await testUserStatus();
+
+ if (!userValid) {
+ // use must be logged in to publish
+ await events.invoke('showPopup', {
+ type: 'error',
+ header: localize('popup.error'),
+ message: localize('publish.please-log-in')
+ });
+ } else {
+ // get publish options
+ const publishSettings: PublishSettings = await events.invoke('show.publishSettingsDialog');
+
+ if (!publishSettings) {
+ return;
+ }
+
+ try {
+ events.fire('startSpinner');
+
+ const splats = events.invoke('scene.splats');
+
+ // serialize/compress
+ let data: Uint8Array = null;
+ await serializePlyCompressed(splats, publishSettings.serializeSettings, (chunk: Uint8Array) => {
+ data = chunk;
+ });
+
+ // publish
+ const response = await publish(data, publishSettings);
+
+ events.fire('stopSpinner');
+
+ if (!response) {
+ await events.invoke('showPopup', {
+ type: 'error',
+ header: localize('publish.failed'),
+ message: localize('publish.please-try-again')
+ });
+ } else {
+ await events.invoke('showPopup', {
+ type: 'info',
+ header: localize('publish.succeeded'),
+ message: localize('publish.message'),
+ link: response.url
+ });
+ }
+ } catch (error) {
+ events.fire('stopSpinner');
+
+ await events.invoke('showPopup', {
+ type: 'error',
+ header: localize('publish.failed'),
+ message: `'${error.message ?? error}'`
+ });
+ }
+ }
+ });
+};
+
+export { PublishSettings, registerPublishEvents };
diff --git a/src/shaders/infinite-grid-shader.ts b/src/shaders/infinite-grid-shader.ts
index 42a762ee..8f63cd76 100644
--- a/src/shaders/infinite-grid-shader.ts
+++ b/src/shaders/infinite-grid-shader.ts
@@ -126,7 +126,7 @@ const fragmentShader = /* glsl*/ `
// 10m grid with colored main axes
levelPos = pos * 0.1;
- levelSize = 1.0 / 1000.0;
+ levelSize = 2.0 / 1000.0;
levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2(levelSize)) * fade;
if (levelAlpha > epsilon) {
vec3 color;
diff --git a/src/splat-serialize.ts b/src/splat-serialize.ts
index 36ba33b1..4eb5cbf0 100644
--- a/src/splat-serialize.ts
+++ b/src/splat-serialize.ts
@@ -16,32 +16,83 @@ import { template as ViewerHtmlTemplate } from './templates/viewer-html-template
// async function for writing data
type WriteFunc = (data: Uint8Array, finalWrite?: boolean) => void;
-type SerializeOptions = {
- splats: Splat[];
+type ViewerSettings = {
+ camera: {
+ fov?: number,
+ position?: number[],
+ target?: number[]
+ },
+ background: {
+ color?: number[]
+ }
+};
+
+type SerializeSettings = {
maxSHBands: number;
- selected?: boolean; // only export selected splats, only used for PLY export
+ selected?: boolean; // only export selected gaussians. used for copy/paste
+ minOpacity?: number; // filter out gaussians with alpha less than or equal to minAlpha
};
-type ViewerExportOptions = {
+type ViewerExportSettings = {
type: 'html' | 'zip';
- shBands: number;
- startPosition: 'default' | 'viewport' | 'pose';
- backgroundColor: number[];
- fov: number;
filename?: string;
+ viewerSettings: ViewerSettings;
+ serializeSettings: SerializeSettings;
};
const generatedByString = `Generated by SuperSplat ${version}`;
-const countTotalSplats = (splats: Splat[]) => {
- return splats.reduce((accum, splat) => {
- return accum + splat.numSplats;
- }, 0);
-};
+// used for converting PLY opacity
+const sigmoid = (v: number) => 1 / (1 + Math.exp(-v));
+
+// create a filter for gaussians
+class GaussianFilter {
+ set: (splat: Splat) => void;
+ test: (i: number) => boolean;
+
+ constructor(serializeSettings: SerializeSettings) {
+ let splat = null;
+ let state: Uint8Array = null;
+ let opacity: Float32Array = null;
+
+ this.set = (s: Splat) => {
+ splat = s;
+ state = splat.splatData.getProp('state') as Uint8Array;
+ opacity = splat.splatData.getProp('opacity') as Float32Array;
+ };
+
+ const onlySelected = serializeSettings.selected ?? false;
+ const minOpacity = serializeSettings.minOpacity ?? 0;
+
+ this.test = (i: number) => {
+ // splat is deleted, always removed
+ if ((state[i] & State.deleted) !== 0) {
+ return false;
+ }
+
+ // optionally filter out unselected gaussians
+ if (onlySelected && ((state[i] & State.selected) === 0)) {
+ return false;
+ }
+
+ // optionally filter based on opacity
+ if (minOpacity > 0 && sigmoid(opacity[i]) < minOpacity) {
+ return false;
+ }
+
+ return true;
+ };
+ }
+}
-const countSelectedSplats = (splats: Splat[]) => {
+// count the total number of gaussians given a filter
+const countGaussians = (splats: Splat[], filter: GaussianFilter) => {
return splats.reduce((accum, splat) => {
- return accum + splat.numSelected;
+ filter.set(splat);
+ for (let i = 0; i < splat.splatData.numSplats; ++i) {
+ accum += filter.test(i) ? 1 : 0;
+ }
+ return accum;
}, 0);
};
@@ -301,9 +352,7 @@ class SingleSplat {
const { transparency } = splat;
if (hasOpacity && transparency !== 1) {
const invSig = (value: number) => ((value <= 0) ? -400 : ((value >= 1) ? 400 : -Math.log(1 / value - 1)));
- const sig = (value: number) => 1 / (1 + Math.exp(-value));
-
- data.opacity = invSig(sig(data.opacity) * transparency);
+ data.opacity = invSig(sigmoid(data.opacity) * transparency);
}
};
@@ -312,13 +361,13 @@ class SingleSplat {
}
}
-const serializePly = async (options: SerializeOptions, write: WriteFunc) => {
- const { splats, maxSHBands } = options;
+const serializePly = async (splats: Splat[], options: SerializeSettings, write: WriteFunc) => {
+ const { maxSHBands } = options;
- // count the number of non-deleted splats
- const totalSplats = options.selected ? countSelectedSplats(splats) : countTotalSplats(splats);
-
- if (totalSplats === 0) {
+ // create filter and count total gaussians
+ const filter = new GaussianFilter(options);
+ const totalGaussians = countGaussians(splats, filter);
+ if (totalGaussians === 0) {
return;
}
@@ -343,7 +392,7 @@ const serializePly = async (options: SerializeOptions, write: WriteFunc) => {
'format binary_little_endian 1.0',
// FIXME: disable for now due to other tooling not supporting any header
// `comment ${generatedByString}`,
- `element vertex ${totalSplats}`,
+ `element vertex ${totalGaussians}`,
propNames.map(p => `property float ${p}`),
'end_header',
''
@@ -361,11 +410,10 @@ const serializePly = async (options: SerializeOptions, write: WriteFunc) => {
for (let e = 0; e < splats.length; ++e) {
const splat = splats[e];
const { splatData } = splat;
- const state = splatData.getProp('state') as Uint8Array;
+ filter.set(splat);
for (let i = 0; i < splatData.numSplats; ++i) {
- if ((state[i] & State.deleted) === State.deleted) continue;
- if (options.selected && (state[i] !== State.selected)) continue;
+ if (!filter.test(i)) continue;
singleSplat.read(splat, i);
@@ -645,16 +693,19 @@ const sortSplats = (splats: Splat[], indices: CompressedIndex[]) => {
indices.sort((a, b) => morton[a.globalIndex] - morton[b.globalIndex]);
};
-const serializePlyCompressed = async (options: SerializeOptions, write: WriteFunc) => {
- const { splats, maxSHBands } = options;
+const serializePlyCompressed = async (splats: Splat[], options: SerializeSettings, write: WriteFunc) => {
+ const { maxSHBands } = options;
+
+ // create filter and count total gaussians
+ const filter = new GaussianFilter(options);
// make a list of indices spanning all splats (so we can sort them together)
const indices: CompressedIndex[] = [];
for (let splatIndex = 0; splatIndex < splats.length; ++splatIndex) {
const splatData = splats[splatIndex].splatData;
- const state = splatData.getProp('state') as Uint8Array;
+ filter.set(splats[splatIndex]);
for (let i = 0; i < splatData.numSplats; ++i) {
- if ((state[i] & State.deleted) === 0) {
+ if (filter.test(i)) {
indices.push({ splatIndex, i, globalIndex: indices.length });
}
}
@@ -795,13 +846,16 @@ const serializePlyCompressed = async (options: SerializeOptions, write: WriteFun
await write(result, true);
};
-const serializeSplat = async (options: SerializeOptions, write: WriteFunc) => {
- const { splats } = options;
-
- const totalSplats = countTotalSplats(splats);
+const serializeSplat = async (splats: Splat[], options: SerializeSettings, write: WriteFunc) => {
+ // create filter and count total gaussians
+ const filter = new GaussianFilter(options);
+ const totalGaussians = countGaussians(splats, filter);
+ if (totalGaussians === 0) {
+ return;
+ }
// position.xyz: float32, scale.xyz: float32, color.rgba: uint8, quaternion.ijkl: uint8
- const result = new Uint8Array(totalSplats * 32);
+ const result = new Uint8Array(totalGaussians * 32);
const dataView = new DataView(result.buffer);
let idx = 0;
@@ -815,12 +869,10 @@ const serializeSplat = async (options: SerializeOptions, write: WriteFunc) => {
for (let e = 0; e < splats.length; ++e) {
const splat = splats[e];
const { splatData } = splat;
- const state = splatData.getProp('state') as Uint8Array;
+ filter.set(splat);
for (let i = 0; i < splatData.numSplats; ++i) {
- if ((state[i] & State.deleted) === State.deleted) {
- continue;
- }
+ if (!filter.test(i)) continue;
singleSplat.read(splat, i);
@@ -858,36 +910,20 @@ const encodeBase64 = (bytes: Uint8Array) => {
return window.btoa(binary);
};
-const serializeViewer = async (splats: Splat[], options: ViewerExportOptions, write: WriteFunc) => {
- const { events } = splats[0].scene;
-
+const serializeViewer = async (splats: Splat[], options: ViewerExportSettings, write: WriteFunc) => {
// create compressed PLY data
let compressedData: Uint8Array;
- await serializePlyCompressed({ splats, maxSHBands: options.shBands }, (data, finalWrite) => {
+ await serializePlyCompressed(splats, options.serializeSettings, (data, finalWrite) => {
compressedData = data;
});
- // use camera clear color
- const bgClr = options.backgroundColor;
- const fov = options.fov;
-
- let pose;
- if (options.startPosition === 'pose') {
- pose = events.invoke('camera.poses')?.[0];
- } else if (options.startPosition === 'viewport') {
- pose = events.invoke('camera.getPose');
- }
-
- const p = pose?.position;
- const t = pose?.target;
+ const settingsFilename = 'settings.json';
+ const sceneFilename = 'scene.compressed.ply';
+ const { viewerSettings } = options;
const html = ViewerHtmlTemplate
- .replace('{{backgroundColor}}', `rgb(${bgClr[0] * 255} ${bgClr[1] * 255} ${bgClr[2] * 255})`)
- .replace('{{clearColor}}', `${bgClr[0]} ${bgClr[1]} ${bgClr[2]}`)
- .replace('{{fov}}', `${fov.toFixed(2)}`)
- .replace('{{resetPosition}}', pose ? `new Vec3(${p.x}, ${p.y}, ${p.z})` : 'null')
- .replace('{{resetTarget}}', pose ? `new Vec3(${t.x}, ${t.y}, ${t.z})` : 'null')
- .replace('{{plyModel}}', options.type === 'html' ? `data:application/ply;base64,${encodeBase64(compressedData)}` : 'scene.compressed.ply');
+ .replace('{{settingsURL}}', options.type === 'html' ? `data:application/json;base64,${encodeBase64(new TextEncoder().encode(JSON.stringify(viewerSettings)))}` : `./${settingsFilename}`)
+ .replace('{{contentURL}}', options.type === 'html' ? `data:application/ply;base64,${encodeBase64(compressedData)}` : `./${sceneFilename}`);
if (options.type === 'html') {
await write(new TextEncoder().encode(html), true);
@@ -896,7 +932,8 @@ const serializeViewer = async (splats: Splat[], options: ViewerExportOptions, wr
// @ts-ignore
const zip = new JSZip();
zip.file('index.html', html);
- zip.file('scene.compressed.ply', compressedData);
+ zip.file(settingsFilename, JSON.stringify(viewerSettings, null, 4));
+ zip.file(sceneFilename, compressedData);
const result = await zip.generateAsync({ type: 'uint8array' });
await write(result, true);
}
@@ -908,6 +945,7 @@ export {
serializePlyCompressed,
serializeSplat,
serializeViewer,
- SerializeOptions,
- ViewerExportOptions
+ ViewerSettings,
+ SerializeSettings,
+ ViewerExportSettings
};
diff --git a/src/splat.ts b/src/splat.ts
index e2375016..30a40d67 100644
--- a/src/splat.ts
+++ b/src/splat.ts
@@ -20,6 +20,7 @@ import { Element, ElementType } from './element';
import { Serializer } from './serializer';
import { vertexShader, fragmentShader, gsplatCenter } from './shaders/splat-shader';
import { State } from './splat-state';
+import { Transform } from './transform';
import { TransformPalette } from './transform-palette';
const vec = new Vec3();
@@ -499,6 +500,19 @@ class Splat extends Element {
get transparency() {
return this._transparency;
}
+
+ getPivot(mode: 'center' | 'boundCenter', selection: boolean, result: Transform) {
+ const { entity } = this;
+ switch (mode) {
+ case 'center':
+ result.set(entity.getLocalPosition(), entity.getLocalRotation(), entity.getLocalScale());
+ break;
+ case 'boundCenter':
+ entity.getLocalTransform().transformPoint((selection ? this.selectionBound : this.localBound).center, vec);
+ result.set(vec, entity.getLocalRotation(), entity.getLocalScale());
+ break;
+ }
+ }
}
export { Splat };
diff --git a/src/splats-transform-handler.ts b/src/splats-transform-handler.ts
index f523674c..1e9ef7c9 100644
--- a/src/splats-transform-handler.ts
+++ b/src/splats-transform-handler.ts
@@ -10,7 +10,6 @@ import { TransformHandler } from './transform-handler';
const mat = new Mat4();
const mat2 = new Mat4();
-const vec = new Vec3();
const transform = new Transform();
class SplatsTransformHandler implements TransformHandler {
@@ -50,6 +49,12 @@ class SplatsTransformHandler implements TransformHandler {
}
});
+ events.on('pivot.origin', (mode: 'center' | 'boundCenter') => {
+ if (this.splat) {
+ this.placePivot();
+ }
+ });
+
events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => {
if (this.splat) {
const pivot = events.invoke('pivot') as Pivot;
@@ -62,12 +67,8 @@ class SplatsTransformHandler implements TransformHandler {
}
placePivot() {
- const { splat } = this;
- const { entity } = splat;
-
- // place the pivot at the center of the selected splats
- entity.getLocalTransform().transformPoint(splat.selectionBound.center, vec);
- transform.set(vec, entity.getLocalRotation(), entity.getLocalScale());
+ const origin = this.events.invoke('pivot.origin');
+ this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', false, transform);
this.events.fire('pivot.place', transform);
}
diff --git a/src/templates/viewer-html-template.ts b/src/templates/viewer-html-template.ts
index 5887fc02..c89b506d 100644
--- a/src/templates/viewer-html-template.ts
+++ b/src/templates/viewer-html-template.ts
@@ -13,7 +13,6 @@ const template = /* html */ `
}
body {
overflow: hidden;
- background-color: {{backgroundColor}};
}
.hidden {
display: none !important;
@@ -78,23 +77,24 @@ const template = /* html */ `
-
+
-
+
-
+
@@ -164,15 +164,11 @@ const template = /* html */ `