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 };