diff --git a/import-as-string.mjs b/import-as-string.mjs
new file mode 100644
index 0000000..1c4e6e5
--- /dev/null
+++ b/import-as-string.mjs
@@ -0,0 +1,20 @@
+import { createFilter } from '@rollup/pluginutils';
+export function importAsString(options) {
+ const { include, exclude, transform = content => content } = options;
+ const filter = createFilter(include, exclude);
+ return {
+ name: 'importAsString',
+ transform(code, id) {
+ if (filter(id)) {
+ const content = transform(code, id) //
+ .replaceAll('\\', '\\\\')
+ .replaceAll('`', '\\`');
+ return {
+ code: `export default \`${content}\`;`,
+ map: { mappings: '' },
+ };
+ }
+ },
+ };
+}
+export default importAsString;
diff --git a/rollup.config.mjs b/rollup.config.mjs
index 21395a4..68148fe 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -1,5 +1,6 @@
import path from 'path';
import copyAndWatch from './copy-and-watch.mjs';
+import importAsString from './import-as-string.mjs';
import alias from '@rollup/plugin-alias';
import image from '@rollup/plugin-image';
import terser from '@rollup/plugin-terser';
@@ -82,6 +83,9 @@ const application = {
{ src: 'static/env/VertebraeHDRI_v1_512.png', dest: 'static/env' }
]
}),
+ importAsString({
+ include: ['src/templates/*']
+ }),
alias({ entries: aliasEntries }),
resolve(),
image({ dom: false }),
diff --git a/src/declaration.d.ts b/src/declaration.d.ts
index a2e1a74..1e21cfd 100644
--- a/src/declaration.d.ts
+++ b/src/declaration.d.ts
@@ -12,3 +12,8 @@ declare module '*.scss' {
const value: any;
export default value;
}
+
+declare module '*.template' {
+ const content: string;
+ export default content;
+}
diff --git a/src/splat-serialize.ts b/src/splat-serialize.ts
index 02132e9..99d71c4 100644
--- a/src/splat-serialize.ts
+++ b/src/splat-serialize.ts
@@ -11,9 +11,9 @@ import { SHRotation } from './sh-utils';
import { Splat } from './splat';
import { State } from './splat-state';
import { version } from '../package.json';
-import { template as CssTemplate } from './templates/viewer-css';
-import { template as HtmlTemplate } from './templates/viewer-html';
-import { template as ScriptTemplate } from './templates/viewer-script';
+import indexCss from './templates/index.css.template';
+import indexHtml from './templates/index.html.template';
+import indexJs from './templates/index.js.template';
// async function for writing data
type WriteFunc = (data: Uint8Array, finalWrite?: boolean) => void;
@@ -919,42 +919,35 @@ const serializeViewer = async (splats: Splat[], options: ViewerExportSettings, w
compressedData = data;
});
- const htmlFilename = 'index.html';
- const scriptFilename = 'index.js';
- const cssFilename = 'index.css';
- const settingsFilename = 'settings.json';
- const sceneFilename = 'scene.compressed.ply';
-
const { viewerSettings } = options;
if (options.type === 'html') {
const pad = (text: string, spaces: number) => {
const whitespace = ' '.repeat(spaces);
- return text.split('\n').map((line, i) => (i ? whitespace : '') + line).join('\n');
+ return text.split('\n').map(line => whitespace + line).join('\n');
};
- const html = HtmlTemplate
- .replace('{{style}}', ``)
- .replace('{{script}}', ``)
- .replace('{{settingsURL}}', `data:application/json;base64,${encodeBase64(new TextEncoder().encode(JSON.stringify(viewerSettings)))}`)
- .replace('{{contentURL}}', `data:application/ply;base64,${encodeBase64(compressedData)}`);
+ const style = '';
+ const script = '';
+ const settings = '"viewerSettings": "./settings.json"';
+ const content = '';
+
+ const html = indexHtml
+ .replace(style, ``)
+ .replace(script, ``)
+ .replace(settings, `"viewerSettings": "data:application/json;base64,${encodeBase64(new TextEncoder().encode(JSON.stringify(viewerSettings)))}"`)
+ .replace(content, ``);
await write(new TextEncoder().encode(html), true);
} else {
- const html = HtmlTemplate
- .replace('{{style}}', ``)
- .replace('{{script}}', ``)
- .replace('{{settingsURL}}', `./${settingsFilename}`)
- .replace('{{contentURL}}', `./${sceneFilename}`);
-
/* global JSZip */
// @ts-ignore
const zip = new JSZip();
- zip.file(htmlFilename, html);
- zip.file(cssFilename, CssTemplate);
- zip.file(scriptFilename, ScriptTemplate);
- zip.file(settingsFilename, JSON.stringify(viewerSettings, null, 4));
- zip.file(sceneFilename, compressedData);
+ zip.file('index.html', indexHtml);
+ zip.file('index.css', indexCss);
+ zip.file('index.js', indexJs);
+ zip.file('settings.json', JSON.stringify(viewerSettings, null, 4));
+ zip.file('scene.compressed.ply', compressedData);
const result = await zip.generateAsync({ type: 'uint8array' });
await write(result, true);
}
diff --git a/src/templates/viewer-css.ts b/src/templates/index.css.template
similarity index 97%
rename from src/templates/viewer-css.ts
rename to src/templates/index.css.template
index d0416ab..5ab3481 100644
--- a/src/templates/viewer-css.ts
+++ b/src/templates/index.css.template
@@ -1,4 +1,3 @@
-const template = /* css */ `
* {
margin: 0;
padding: 0;
@@ -94,6 +93,4 @@ body {
.buttonSvg {
display: block;
margin: auto;
-}`;
-
-export { template };
+}
\ No newline at end of file
diff --git a/src/templates/viewer-html.ts b/src/templates/index.html.template
similarity index 88%
rename from src/templates/viewer-html.ts
rename to src/templates/index.html.template
index f67bb49..d8da271 100644
--- a/src/templates/viewer-html.ts
+++ b/src/templates/index.html.template
@@ -1,32 +1,32 @@
-const template = /* html */ `
SuperSplat Viewer
- {{style}}
+
+
-
+
-
-
-
-
+
+
+
+
-
+
@@ -112,9 +112,6 @@ const template = /* html */ `
- {{script}}
+
-`;
-
-export { template };
diff --git a/src/templates/index.js.template b/src/templates/index.js.template
new file mode 100644
index 0000000..db5663e
--- /dev/null
+++ b/src/templates/index.js.template
@@ -0,0 +1,237 @@
+import { BoundingBox, Color, Mat4, Script, Vec3 } from 'playcanvas';
+
+// eslint-disable-next-line
+import viewerSettings from "viewerSettings" with { type: "json" };
+
+const nearlyEquals = (a, b, epsilon = 1e-4) => {
+ return !a.some((v, i) => Math.abs(v - b[i]) >= epsilon);
+};
+
+class FrameScene extends Script {
+ constructor(args) {
+ super(args);
+
+ this.position = viewerSettings.camera.position && new Vec3(viewerSettings.camera.position);
+ this.target = viewerSettings.camera.target && new Vec3(viewerSettings.camera.target);
+ }
+
+ frameScene(bbox, smooth = true) {
+ const sceneSize = bbox.halfExtents.length();
+ const distance = sceneSize / Math.sin(this.entity.camera.fov / 180 * Math.PI * 0.5);
+ this.entity.script.cameraControls.sceneSize = sceneSize;
+ this.entity.script.cameraControls.focus(bbox.center, new Vec3(2, 1, 2).normalize().mulScalar(distance).add(bbox.center), smooth);
+ }
+
+ resetCamera(bbox, smooth = true) {
+ const sceneSize = bbox.halfExtents.length();
+ this.entity.script.cameraControls.sceneSize = sceneSize * 0.2;
+ this.entity.script.cameraControls.focus(this.target ?? Vec3.ZERO, this.position ?? new Vec3(2, 1, 2), smooth);
+ }
+
+ initCamera() {
+ const { app } = this;
+ const { graphicsDevice } = app;
+
+ // get the gsplat component
+ const gsplatComponent = app.root.findComponent('gsplat');
+
+ // calculate the bounding box
+ const bbox = gsplatComponent?.instance?.meshInstance?.aabb ?? new BoundingBox();
+ if (bbox.halfExtents.length() > 100 || this.position || this.target) {
+ this.resetCamera(bbox, false);
+ } else {
+ this.frameScene(bbox, false);
+ }
+
+ window.addEventListener('keydown', (e) => {
+ switch (e.key) {
+ case 'f':
+ this.frameScene(bbox);
+ break;
+ case 'r':
+ this.resetCamera(bbox);
+ break;
+ }
+ });
+
+ const prevProj = new Mat4();
+ const prevWorld = new Mat4();
+
+ app.on('framerender', () => {
+ if (!app.autoRender && !app.renderNextFrame) {
+ const world = this.entity.getWorldTransform();
+ if (!nearlyEquals(world.data, prevWorld.data)) {
+ app.renderNextFrame = true;
+ }
+
+ const proj = this.entity.camera.projectionMatrix;
+ if (!nearlyEquals(proj.data, prevProj.data)) {
+ app.renderNextFrame = true;
+ }
+
+ if (app.renderNextFrame) {
+ prevWorld.copy(world);
+ prevProj.copy(proj);
+ }
+ }
+ });
+
+ // wait for first gsplat sort
+ const handle = gsplatComponent?.instance?.sorter?.on('updated', () => {
+ handle.off();
+
+ // request frame render
+ app.renderNextFrame = true;
+
+ // wait for first render to complete
+ const frameHandle = app.on('frameend', () => {
+ frameHandle.off();
+
+ // hide loading indicator
+ document.getElementById('loadingIndicator').classList.add('hidden');
+
+ // emit first frame event on window
+ window.firstFrame?.();
+ });
+ });
+
+ const updateHorizontalFov = (width, height) => {
+ this.entity.camera.horizontalFov = width > height;
+ };
+
+ // handle fov on canvas resize
+ graphicsDevice.on('resizecanvas', (width, height) => {
+ updateHorizontalFov(width, height);
+ app.renderNextFrame = true;
+ });
+
+ // configure on-demand rendering
+ app.autoRender = false;
+ updateHorizontalFov(graphicsDevice.width, graphicsDevice.height);
+ }
+
+ postInitialize() {
+ const assets = this.app.assets.filter(asset => asset.type === 'gsplat');
+ if (assets.length > 0) {
+ const asset = assets[0];
+ if (asset.loaded) {
+ this.initCamera();
+ } else {
+ asset.on('load', () => {
+ this.initCamera();
+ });
+ }
+ }
+ }
+}
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const appElement = await document.querySelector('pc-app').ready();
+ const cameraElement = await document.querySelector('pc-entity[name="camera"]').ready();
+
+ const app = await appElement.app;
+ const camera = cameraElement.entity;
+
+ camera.camera.clearColor = new Color(viewerSettings.background.color);
+ camera.camera.fov = viewerSettings.camera.fov;
+ camera.script.create(FrameScene);
+
+ // Update loading indicator
+ const assets = app.assets.filter(asset => asset.type === 'gsplat');
+ if (assets.length > 0) {
+ const asset = assets[0];
+ const loadingIndicator = document.getElementById('loadingIndicator');
+ asset.on('progress', (received, length) => {
+ const v = (Math.min(1, received / length) * 100).toFixed(0);
+ loadingIndicator.style.backgroundImage = 'linear-gradient(90deg, white 0%, white ' + v + '%, black ' + v + '%, black 100%)';
+ });
+ }
+
+ // On entering/exiting AR, we need to set the camera clear color to transparent black
+ let cameraEntity, skyType = null;
+ const clearColor = new Color();
+
+ app.xr.on('start', () => {
+ if (app.xr.type === 'immersive-ar') {
+ cameraEntity = app.xr.camera;
+ clearColor.copy(cameraEntity.camera.clearColor);
+ cameraEntity.camera.clearColor = new Color(0, 0, 0, 0);
+
+ const sky = document.querySelector('pc-sky');
+ if (sky && sky.type !== 'none') {
+ skyType = sky.type;
+ sky.type = 'none';
+ }
+
+ app.autoRender = true;
+ }
+ });
+
+ app.xr.on('end', () => {
+ if (app.xr.type === 'immersive-ar') {
+ cameraEntity.camera.clearColor = clearColor;
+
+ const sky = document.querySelector('pc-sky');
+ if (sky) {
+ if (skyType) {
+ sky.type = skyType;
+ skyType = null;
+ } else {
+ sky.removeAttribute('type');
+ }
+ }
+
+ app.autoRender = false;
+ }
+ });
+
+ // Get button and info panel elements
+ const dom = ['arMode', 'vrMode', 'enterFullscreen', 'exitFullscreen', 'info', 'infoPanel', 'buttonContainer'].reduce((acc, id) => {
+ acc[id] = document.getElementById(id);
+ return acc;
+ }, {});
+
+ // AR
+ if (app.xr.isAvailable('immersive-ar')) {
+ dom.arMode.classList.remove('hidden');
+ dom.arMode.addEventListener('click', () => app.xr.start(app.root.findComponent('camera'), 'immersive-ar', 'local-floor'));
+ }
+
+ // VR
+ if (app.xr.isAvailable('immersive-vr')) {
+ dom.vrMode.classList.remove('hidden');
+ dom.vrMode.addEventListener('click', () => app.xr.start(app.root.findComponent('camera'), 'immersive-vr', 'local-floor'));
+ }
+
+ // Fullscreen
+ if (document.documentElement.requestFullscreen && document.exitFullscreen) {
+ dom.enterFullscreen.classList.remove('hidden');
+ dom.enterFullscreen.addEventListener('click', () => document.documentElement.requestFullscreen());
+ dom.exitFullscreen.addEventListener('click', () => document.exitFullscreen());
+ document.addEventListener('fullscreenchange', () => {
+ dom.enterFullscreen.classList[document.fullscreenElement ? 'add' : 'remove']('hidden');
+ dom.exitFullscreen.classList[document.fullscreenElement ? 'remove' : 'add']('hidden');
+ });
+ }
+
+ // Info
+ dom.info.addEventListener('click', () => {
+ dom.infoPanel.classList.toggle('hidden');
+ });
+
+ // Keyboard handler
+ window.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ if (app.xr.active) {
+ app.xr.end();
+ }
+ dom.infoPanel.classList.add('hidden');
+ }
+ });
+
+ // Hide UI using ?noui query parameter
+ const url = new URL(location.href);
+ if (url.searchParams.has('noui')) {
+ dom.buttonContainer.classList.add('hidden');
+ }
+});
\ No newline at end of file
diff --git a/src/templates/viewer-script.ts b/src/templates/viewer-script.ts
deleted file mode 100644
index 9c7715c..0000000
--- a/src/templates/viewer-script.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-const template = /* javascript */ `
-import { BoundingBox, Color, Script, Vec3 } from 'playcanvas';
-
-import viewerSettings from "viewerSettings" with { type: "json" };
-
-document.addEventListener('DOMContentLoaded', async () => {
- const position = viewerSettings.camera.position && new Vec3(viewerSettings.camera.position);
- const target = viewerSettings.camera.target && new Vec3(viewerSettings.camera.target);
-
- class FrameScene extends Script {
- frameScene(bbox) {
- const sceneSize = bbox.halfExtents.length();
- const distance = sceneSize / Math.sin(this.entity.camera.fov / 180 * Math.PI * 0.5);
- this.entity.script.cameraControls.sceneSize = sceneSize;
- this.entity.script.cameraControls.focus(bbox.center, new Vec3(2, 1, 2).normalize().mulScalar(distance).add(bbox.center));
- }
-
- resetCamera(bbox) {
- const sceneSize = bbox.halfExtents.length();
- this.entity.script.cameraControls.sceneSize = sceneSize * 0.2;
- this.entity.script.cameraControls.focus(target ?? Vec3.ZERO, position ?? new Vec3(2, 1, 2));
- }
-
- calcBound() {
- const gsplatComponents = this.app.root.findComponents('gsplat');
- return gsplatComponents?.[0]?.instance?.meshInstance?.aabb ?? new BoundingBox();
- }
-
- initCamera() {
- document.getElementById('loadingIndicator').classList.add('hidden');
-
- const bbox = this.calcBound();
- if (bbox.halfExtents.length() > 100 || position || target) {
- this.resetCamera(bbox);
- } else {
- this.frameScene(bbox);
- }
-
- window.addEventListener('keydown', (e) => {
- switch (e.key) {
- case 'f':
- this.frameScene(bbox);
- break;
- case 'r':
- this.resetCamera(bbox);
- break;
- }
- });
- }
-
- postInitialize() {
- const assets = this.app.assets.filter(asset => asset.type === 'gsplat');
- if (assets.length > 0) {
- const asset = assets[0];
- if (asset.loaded) {
- this.initCamera();
- } else {
- asset.on('load', () => {
- this.initCamera();
- });
- }
- }
- }
- }
-
- const appElement = await document.querySelector('pc-app').ready();
- const cameraElement = await document.querySelector('pc-entity[name="camera"]').ready();
-
- const app = await appElement.app;
- const camera = cameraElement.entity;
-
- camera.camera.clearColor = new Color(viewerSettings.background.color);
- camera.camera.fov = viewerSettings.camera.fov;
- camera.script.create(FrameScene);
-
- // On entering/exiting AR, we need to set the camera clear color to transparent black
- let cameraEntity, skyType = null;
- const clearColor = new Color();
-
- app.xr.on('start', () => {
- if (app.xr.type === 'immersive-ar') {
- cameraEntity = app.xr.camera;
- clearColor.copy(cameraEntity.camera.clearColor);
- cameraEntity.camera.clearColor = new Color(0, 0, 0, 0);
-
- const sky = document.querySelector('pc-sky');
- if (sky && sky.type !== 'none') {
- skyType = sky.type;
- sky.type = 'none';
- }
- }
- });
-
- app.xr.on('end', () => {
- if (app.xr.type === 'immersive-ar') {
- cameraEntity.camera.clearColor = clearColor;
-
- const sky = document.querySelector('pc-sky');
- if (sky) {
- if (skyType) {
- sky.type = skyType;
- skyType = null;
- } else {
- sky.removeAttribute('type');
- }
- }
- }
- });
-
- // Get button and info panel elements
- const dom = ['arMode', 'vrMode', 'enterFullscreen', 'exitFullscreen', 'info', 'infoPanel'].reduce((acc, id) => {
- acc[id] = document.getElementById(id);
- return acc;
- }, {});
-
- // AR
- if (app.xr.isAvailable('immersive-ar')) {
- dom.arMode.classList.remove('hidden');
- dom.arMode.addEventListener('click', () => app.xr.start(app.root.findComponent('camera'), 'immersive-ar', 'local-floor'));
- }
-
- // VR
- if (app.xr.isAvailable('immersive-vr')) {
- dom.vrMode.classList.remove('hidden');
- dom.vrMode.addEventListener('click', () => app.xr.start(app.root.findComponent('camera'), 'immersive-vr', 'local-floor'));
- }
-
- // Fullscreen
- if (document.documentElement.requestFullscreen && document.exitFullscreen) {
- dom.enterFullscreen.classList.remove('hidden');
- dom.enterFullscreen.addEventListener('click', () => document.documentElement.requestFullscreen());
- dom.exitFullscreen.addEventListener('click', () => document.exitFullscreen());
- document.addEventListener('fullscreenchange', () => {
- dom.enterFullscreen.classList[document.fullscreenElement ? 'add' : 'remove']('hidden');
- dom.exitFullscreen.classList[document.fullscreenElement ? 'remove' : 'add']('hidden');
- });
- }
-
- // Info
- dom.info.addEventListener('click', () => {
- dom.infoPanel.classList.toggle('hidden');
- });
-
- // Keyboard handler
- window.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') {
- if (app.xr.active) {
- app.xr.end();
- }
- dom.infoPanel.classList.add('hidden');
- }
- });
-});`;
-
-export { template };