chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths-ignore: ['README.md', 'LICENSE']
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths-ignore: ['README.md', 'LICENSE']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22.x]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Lint locales
|
||||
run: npm run lint:locales
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.npmrc
|
||||
.vscode
|
||||
.cursor
|
||||
.idea
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
build
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2011-2026 PlayCanvas Ltd.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,91 @@
|
||||
# SuperSplat Editor
|
||||
|
||||
[](https://github.com/playcanvas/supersplat/releases)
|
||||
[](https://github.com/playcanvas/supersplat/blob/main/LICENSE)
|
||||
[](https://discord.gg/RSaMRzg)
|
||||
[](https://www.reddit.com/r/PlayCanvas)
|
||||
[](https://x.com/intent/follow?screen_name=playcanvas)
|
||||
|
||||
| [SuperSplat Editor](https://superspl.at/editor) | [User Guide](https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/) | [Blog](https://blog.playcanvas.com) | [Forum](https://forum.playcanvas.com) |
|
||||
|
||||
The SuperSplat Editor 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.
|
||||
|
||||
A live version of this tool is available at: https://superspl.at/editor
|
||||
|
||||

|
||||
|
||||
To learn more about using SuperSplat, please refer to the [User Guide](https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/).
|
||||
|
||||
## Local Development
|
||||
|
||||
To initialize a local development environment for SuperSplat, ensure you have [Node.js](https://nodejs.org/) 18 or later installed. Follow these steps:
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/playcanvas/supersplat.git
|
||||
cd supersplat
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Build SuperSplat and start a local web server:
|
||||
|
||||
```sh
|
||||
npm run develop
|
||||
```
|
||||
|
||||
4. Open a web browser tab and make sure network caching is disabled on the network tab and the other application caches are clear:
|
||||
|
||||
- On Safari you can use `Cmd+Option+e` or Develop->Empty Caches.
|
||||
- On Chrome ensure the options "Update on reload" and "Bypass for network" are enabled in the Application->Service workers tab:
|
||||
|
||||
<img width="846" alt="Screenshot 2025-04-25 at 16 53 37" src="https://github.com/user-attachments/assets/888bac6c-25c1-4813-b5b6-4beecf437ac9" />
|
||||
|
||||
5. Navigate to `http://localhost:3000`
|
||||
|
||||
When changes to the source are detected, SuperSplat is rebuilt automatically. Simply refresh your browser to see your changes.
|
||||
|
||||
## Localizing the SuperSplat Editor
|
||||
|
||||
The currently supported languages are available here:
|
||||
|
||||
https://github.com/playcanvas/supersplat/tree/main/static/locales
|
||||
|
||||
### Adding a New Language
|
||||
|
||||
1. Add a new `<locale>.json` file in the `static/locales` directory.
|
||||
|
||||
2. Add the locale to the list here:
|
||||
|
||||
https://github.com/playcanvas/supersplat/blob/main/src/ui/localization.ts
|
||||
|
||||
### Testing Translations
|
||||
|
||||
To test your translations:
|
||||
|
||||
1. Run the development server:
|
||||
|
||||
```sh
|
||||
npm run develop
|
||||
```
|
||||
|
||||
2. Open your browser and navigate to:
|
||||
|
||||
```
|
||||
http://localhost:3000/?lng=<locale>
|
||||
```
|
||||
|
||||
Replace `<locale>` with your language code (e.g., `fr`, `de`, `es`).
|
||||
|
||||
## Contributors
|
||||
|
||||
SuperSplat is made possible by our amazing open source community:
|
||||
|
||||
<a href="https://github.com/playcanvas/supersplat/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=playcanvas/supersplat" />
|
||||
</a>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`playcanvas/supersplat`
|
||||
- 原始仓库:https://github.com/playcanvas/supersplat
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// custom plugin to copy files and watch them
|
||||
export default function copyAndWatch(config) {
|
||||
const resolvedConfig = {
|
||||
targets: []
|
||||
};
|
||||
|
||||
// resolve source directories into files
|
||||
config.targets.forEach(target => {
|
||||
const readRec = pathname => {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
console.log(`skipping missing file ${target.src}`);
|
||||
} else {
|
||||
if (fs.lstatSync(pathname).isDirectory()) {
|
||||
const children = fs.readdirSync(pathname);
|
||||
children.forEach(childPath => {
|
||||
readRec(path.join(pathname, childPath));
|
||||
});
|
||||
} else {
|
||||
let dest;
|
||||
if (fs.lstatSync(target.src).isDirectory()) {
|
||||
dest = path.join(
|
||||
target.dest || '',
|
||||
path.basename(target.destFilename || target.src),
|
||||
path.relative(target.src, pathname)
|
||||
);
|
||||
} else {
|
||||
dest = path.join(target.dest || '', path.basename(target.destFilename || target.src));
|
||||
}
|
||||
resolvedConfig.targets.push({
|
||||
src: pathname,
|
||||
dest: dest,
|
||||
transform: target.transform
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
readRec(target.src);
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'copy-and-watch',
|
||||
async buildStart() {
|
||||
// disable watching during production build
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
resolvedConfig.targets.forEach(target => {
|
||||
this.addWatchFile(target.src);
|
||||
});
|
||||
}
|
||||
},
|
||||
async generateBundle() {
|
||||
resolvedConfig.targets.forEach(target => {
|
||||
const contents = fs.readFileSync(target.src);
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: target.dest,
|
||||
source: target.transform ? target.transform(contents, target.src) : contents
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# SuperSplat User Guide
|
||||
|
||||
Welcome to the SuperSplat User Guide.
|
||||
|
||||
SuperSplat is an open source, browser-based 3D Gaussian Splat Editor. You can use it to view, inspect, transform, combine, crop, clean up and optimize 3D Gaussian Splats.
|
||||
|
||||
## Installing SuperSplat
|
||||
|
||||
SuperSplat is a web app so you do not need to install it. Simply point your browser at:
|
||||
|
||||
https://playcanvas.com/supersplat/editor
|
||||
|
||||
However, for your convenience, you can also install SuperSplat as a PWA (Progressive Web App). This will make SuperSplat appear and behave more like a native application. An app icon for SuperSplat will be generated on your desktop or home screen. Furthermore, .ply files will be associated with the SuperSplat PWA, enabling you to launch SuperSplat more quickly.
|
||||
|
||||
## Loading Splats
|
||||
|
||||
SuperSplat loads splats from .ply files. Only .ply files containing 3D Gaussian Splat data can be loaded. If you attempt to load any other type of data from a .ply file, it will fail.
|
||||
|
||||
There are three ways that you can load a .ply file:
|
||||
|
||||
1. Drag and drop one or more .ply files from your file system into SuperSplat's client area.
|
||||
2. Select the `Scene` > `Open` menu item and select one or more .ply files from your file system.
|
||||
3. Use the `load` query parameter. This is in the form: `https://playcanvas.com/supersplat/editor?load=<PLY_URL>`. An example would be:
|
||||
|
||||
https://playcanvas.com/supersplat/editor?load=https://raw.githubusercontent.com/willeastcott/assets/main/dragon.compressed.ply
|
||||
|
||||
This is a useful mechanism for sharing splats with other people (say on social platforms like X and LinkedIn).
|
||||
|
||||
## Saving Splats
|
||||
|
||||
To save the currently loaded scene, select the `Scene` > `Save` or `Save As` menu items. This will save a `.ply` file to your file system.
|
||||
|
||||
SuperSplat can also export to two additional formats via the `Scene` > `Export` sub-menu:
|
||||
|
||||
* **Compressed Ply**: A lightweight, compressed format that is far smaller than the equivalent uncompressed .ply file. It quantizes splat data and drops spherical harmonics from the output file. See [this article](https://blog.playcanvas.com/compressing-gaussian-splats/) for more details on the format.
|
||||
* **Splat File**: Another compressed format, although not as efficient as the compressed ply format.
|
||||
|
||||
## Controlling the Camera
|
||||
|
||||
The camera controls in SuperSplat are as follows:
|
||||
|
||||
| Control | Description |
|
||||
| ----------------------------------------------- | ------------------------------- |
|
||||
| Left Mouse Button<br>Shift + Right Mouse Button | Orbit camera |
|
||||
| Middle Mouse Button<br>Alt + Right Mouse Button | Dolly camera |
|
||||
| Right Mouse Button | Pan camera |
|
||||
| Left/Right Arrow Keys | Strafe camera left/right |
|
||||
| Up/Down Arrow Keys | Dolly camera forwards/backwards |
|
||||
| F Key | Frame selection |
|
||||
|
||||
To set the target point for orbiting the camera, double click anywhere in the 3D view.
|
||||
|
||||
## Visualizing Splats
|
||||
|
||||
Splats can be rendered in two 'modes':
|
||||
|
||||
* **Centers Mode**: A blue dot is rendered at the center of each Gaussian.
|
||||
* **Rings Mode**: A ring is rendered at the outer boundary of each Gaussian.
|
||||
|
||||
You can disable rendering of the centers or rings (depending on the active mode) by pressing Space. This allows you to view the scene as it would normally appear.
|
||||
|
||||
You can control the pixel size of the center dots in the VIEW OPTIONS panel.
|
||||
|
||||
## Selecting and Deleting Splats
|
||||
|
||||
Cropping splats or deleting unwanted Gaussians is a key function of SuperSplat. To help with this, there are 3 selection tools available:
|
||||
|
||||
* **Picker Select**: Click to select, or click + drag to rect select.
|
||||
* **Brush Select**: Click and drag a selection circle. Change the brush size with the `[` and `]` keys.
|
||||
* **Sphere Select**: Activate a sphere volume to add or remove splats from the current selection. Double click on any splat to reposition the sphere volume.
|
||||
|
||||
Once you are happy with your selection, you can delete it with the Delete key.
|
||||
|
||||
## Transforming Splats
|
||||
|
||||
SuperSplat can translate, rotate and scale splats. To do this, select a splat in the Scene Manager and activate one of the gizmos via the horizontal icon bar.
|
||||
|
||||
To achieve fine grain control over the transform of the selected splat, you can use the TRANSFORM panel (below the SCENE MANAGER panel).
|
||||
|
||||
To set the origin of the currently active gizmo, double click anywhere in the 3D view.
|
||||
|
||||
## Merging Splats
|
||||
|
||||
It is possible to merge multiple .ply files together and output a single, combine .ply file. Simply load any number of .ply files into Scene Manager, perform whatever transformations and edits you require, and then save the result via the `Scene` > `Save` menu item.
|
||||
|
||||
## Inspecting Splat Data
|
||||
|
||||
The Data Panel can be used to analyze the contents of your splat scenes. Initially, it is collapsed at the bottom of the application's window. To open it, click on the panel's header or press the 'D' key.
|
||||
|
||||
The 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 the Ctrl key to remove from the current selection.
|
||||
@@ -0,0 +1,51 @@
|
||||
import playcanvasConfig from '@playcanvas/eslint-config';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
...playcanvasConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.serviceworker,
|
||||
BlobPart: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {}
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-param-type': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
'jsdoc/require-returns-type': 'off',
|
||||
'jsdoc/check-tag-names': 'off',
|
||||
'lines-between-class-members': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'require-atomic-updates': 'off'
|
||||
}
|
||||
}, {
|
||||
files: ['**/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'import/no-unresolved': 'off'
|
||||
}
|
||||
}
|
||||
];
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
/// <reference types="wicg-file-system-access" />
|
||||
|
||||
interface FileSystemFileHandle {
|
||||
remove(): Promise<void>;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
Generated
+7510
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "supersplat",
|
||||
"version": "2.29.0",
|
||||
"author": "PlayCanvas<support@playcanvas.com>",
|
||||
"homepage": "https://playcanvas.com/supersplat/editor",
|
||||
"description": "3D Gaussian Splat Editor",
|
||||
"keywords": [
|
||||
"playcanvas",
|
||||
"ply",
|
||||
"gaussian",
|
||||
"splat",
|
||||
"editor"
|
||||
],
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"watch": "rollup -c -w",
|
||||
"serve": "serve dist -C",
|
||||
"develop": "cross-env BUILD_TYPE=debug concurrently --kill-others \"npm run watch\" \"npm run serve\"",
|
||||
"develop:auto-launch": "concurrently --kill-others \"xdg-open 'http://localhost:3000/'\" \"npm run watch\" \"npm run serve\"",
|
||||
"lint": "eslint src",
|
||||
"lint:locales": "node scripts/check-locales.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playcanvas/eslint-config": "2.1.0",
|
||||
"@playcanvas/splat-transform": "2.7.1",
|
||||
"@playcanvas/pcui": "6.1.4",
|
||||
"@rollup/plugin-alias": "6.0.0",
|
||||
"@rollup/plugin-image": "3.0.3",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-node-resolve": "16.0.3",
|
||||
"@rollup/plugin-strip": "3.0.4",
|
||||
"@rollup/plugin-terser": "1.0.0",
|
||||
"@rollup/plugin-typescript": "12.3.0",
|
||||
"@types/wicg-file-system-access": "2023.10.7",
|
||||
"@typescript-eslint/eslint-plugin": "8.63.0",
|
||||
"@typescript-eslint/parser": "8.63.0",
|
||||
"autoprefixer": "10.5.2",
|
||||
"concurrently": "9.2.3",
|
||||
"cors": "2.8.6",
|
||||
"cross-env": "10.1.0",
|
||||
"eslint": "10.6.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"globals": "17.7.0",
|
||||
"i18next": "26.3.5",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "3.0.6",
|
||||
"mediabunny": "1.50.7",
|
||||
"playcanvas": "2.20.6",
|
||||
"postcss": "8.5.16",
|
||||
"rollup": "4.62.2",
|
||||
"rollup-plugin-scss": "4.0.1",
|
||||
"sass": "1.101.0",
|
||||
"serve": "14.2.6",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchManagers": [
|
||||
"npm"
|
||||
],
|
||||
"groupName": "all npm dependencies",
|
||||
"schedule": [
|
||||
"on monday at 10:00am"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"rangeStrategy": "pin"
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["dependencies"],
|
||||
"rangeStrategy": "widen"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from 'path';
|
||||
|
||||
import alias from '@rollup/plugin-alias';
|
||||
import image from '@rollup/plugin-image';
|
||||
import json from '@rollup/plugin-json';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import strip from '@rollup/plugin-strip';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import postcss from 'postcss';
|
||||
import scss from 'rollup-plugin-scss';
|
||||
import sass from 'sass';
|
||||
|
||||
import copyAndWatch from './copy-and-watch.mjs';
|
||||
|
||||
// prod is release build
|
||||
if (process.env.BUILD_TYPE === 'prod') {
|
||||
process.env.BUILD_TYPE = 'release';
|
||||
}
|
||||
// debug, profile, release
|
||||
const BUILD_TYPE = process.env.BUILD_TYPE || 'release';
|
||||
const ENGINE_DIR = path.resolve(`node_modules/playcanvas/build/playcanvas${BUILD_TYPE === 'debug' ? '.dbg' : ''}/src/index.js`);
|
||||
const PCUI_DIR = path.resolve('node_modules/@playcanvas/pcui');
|
||||
const HREF = process.env.BASE_HREF || '';
|
||||
|
||||
const outputHeader = () => {
|
||||
const BLUE_OUT = '\x1b[34m';
|
||||
const BOLD_OUT = '\x1b[1m';
|
||||
const REGULAR_OUT = '\x1b[22m';
|
||||
const RESET_OUT = '\x1b[0m';
|
||||
|
||||
const title = [
|
||||
'Building SuperSplat',
|
||||
`type ${BOLD_OUT}${BUILD_TYPE}${REGULAR_OUT}`
|
||||
].map(l => `${BLUE_OUT}${l}`).join('\n');
|
||||
console.log(`${BLUE_OUT}${title}${RESET_OUT}\n`);
|
||||
};
|
||||
|
||||
outputHeader();
|
||||
|
||||
const application = {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
copyAndWatch({
|
||||
targets: [
|
||||
{
|
||||
src: 'src/index.html',
|
||||
transform: (contents, filename) => {
|
||||
return contents.toString().replace('__BASE_HREF__', HREF);
|
||||
}
|
||||
},
|
||||
{ src: 'src/manifest.json' },
|
||||
{ src: 'static/images', dest: 'static' },
|
||||
{ src: 'static/icons', dest: 'static' },
|
||||
{ src: 'static/lib', dest: 'static' },
|
||||
{ src: 'static/locales', dest: 'static' },
|
||||
{ src: 'static/env/VertebraeHDRI_v1_512.png', dest: 'static/env' }
|
||||
]
|
||||
}),
|
||||
alias({
|
||||
entries: {
|
||||
'playcanvas': ENGINE_DIR,
|
||||
'@playcanvas/pcui': PCUI_DIR
|
||||
}
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: './tsconfig.json'
|
||||
}),
|
||||
resolve(),
|
||||
image({ dom: false }),
|
||||
json(),
|
||||
scss({
|
||||
sourceMap: true,
|
||||
runtime: sass,
|
||||
processor: (css) => {
|
||||
return postcss([autoprefixer])
|
||||
.process(css, { from: undefined })
|
||||
.then(result => result.css);
|
||||
},
|
||||
fileName: 'index.css',
|
||||
includePaths: [`${PCUI_DIR}/dist`],
|
||||
watch: 'src/ui/scss'
|
||||
}),
|
||||
BUILD_TYPE === 'release' &&
|
||||
strip({
|
||||
include: ['**/*.ts'],
|
||||
functions: ['Debug.exec']
|
||||
}),
|
||||
BUILD_TYPE !== 'debug' && terser()
|
||||
],
|
||||
treeshake: 'smallest',
|
||||
cache: false
|
||||
};
|
||||
|
||||
const serviceWorker = {
|
||||
input: 'src/sw.ts',
|
||||
output: {
|
||||
dir: 'dist',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
resolve(),
|
||||
json(),
|
||||
typescript()
|
||||
// BUILD_TYPE !== 'debug' && terser()
|
||||
],
|
||||
treeshake: 'smallest',
|
||||
cache: false
|
||||
};
|
||||
|
||||
export default [
|
||||
application,
|
||||
serviceWorker
|
||||
];
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Locale consistency check.
|
||||
*
|
||||
* Verifies every translation in static/locales has exactly the same set of keys
|
||||
* as the English reference (en.json, the i18next fallback language). Reports any
|
||||
* key that is:
|
||||
* - missing from a translation (the UI would silently fall back to English), or
|
||||
* - stale (present in a translation but no longer in en.json).
|
||||
*
|
||||
* This is a pure JSON set comparison — it does not scan source, so it has no
|
||||
* false positives and needs no dependencies. It deliberately does NOT check for
|
||||
* unused or undefined keys, since that requires resolving dynamic localize()
|
||||
* call sites and is not worth the complexity. Run via `npm run lint:locales`.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const localesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'static', 'locales');
|
||||
const referenceFile = 'en.json';
|
||||
|
||||
const keysOf = file => new Set(Object.keys(JSON.parse(readFileSync(join(localesDir, file), 'utf8'))));
|
||||
|
||||
const referenceKeys = keysOf(referenceFile);
|
||||
const localeFiles = readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== referenceFile);
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const keys = keysOf(file);
|
||||
const missing = [...referenceKeys].filter(k => !keys.has(k));
|
||||
const stale = [...keys].filter(k => !referenceKeys.has(k));
|
||||
|
||||
if (missing.length || stale.length) {
|
||||
failed = true;
|
||||
console.error(`\n${file}:`);
|
||||
missing.forEach(k => console.error(` missing (untranslated): ${k}`));
|
||||
stale.forEach(k => console.error(` stale (not in ${referenceFile}): ${k}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`\n✖ Locale check failed. Update static/locales so every language has the same keys as ${referenceFile}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✔ All ${localeFiles.length} locales are in sync with ${referenceFile} (${referenceKeys.size} keys).`);
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Interface for animation tracks that can be attached to animatable targets.
|
||||
* Each track owns its keyframes and handles capture, interpolation, and evaluation.
|
||||
*/
|
||||
interface AnimTrack {
|
||||
/** Array of frame numbers where keyframes exist */
|
||||
readonly keys: readonly number[];
|
||||
|
||||
/**
|
||||
* Add a keyframe at the specified frame, capturing current state.
|
||||
* If a key already exists at this frame, it will be updated.
|
||||
* @returns true if the track was modified, false if the operation was a no-op.
|
||||
*/
|
||||
addKey(frame: number): boolean;
|
||||
|
||||
/**
|
||||
* Remove the keyframe at the specified frame.
|
||||
* @returns true if the track was modified, false if the operation was a no-op.
|
||||
*/
|
||||
removeKey(frame: number): boolean;
|
||||
|
||||
/**
|
||||
* Move a keyframe from one frame to another.
|
||||
* @returns true if the track was modified, false if the operation was a no-op.
|
||||
*/
|
||||
moveKey(fromFrame: number, toFrame: number): boolean;
|
||||
|
||||
/**
|
||||
* Copy a keyframe from one frame to another.
|
||||
* The original keyframe remains in place.
|
||||
* @returns true if the track was modified, false if the operation was a no-op.
|
||||
*/
|
||||
copyKey(fromFrame: number, toFrame: number): boolean;
|
||||
|
||||
/**
|
||||
* Clear all keyframes.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
* Return a deep copy of the track's internal state (for undo snapshots).
|
||||
*/
|
||||
snapshot(): unknown;
|
||||
|
||||
/**
|
||||
* Replace the track's internal state with a previously captured snapshot
|
||||
* and fire appropriate change events.
|
||||
*/
|
||||
restore(snapshot: unknown): void;
|
||||
}
|
||||
|
||||
export { AnimTrack };
|
||||
@@ -0,0 +1,131 @@
|
||||
class CubicSpline {
|
||||
// control times
|
||||
times: number[];
|
||||
|
||||
// control data: in-tangent, point, out-tangent
|
||||
knots: number[];
|
||||
|
||||
// dimension of the knot points
|
||||
dim: number;
|
||||
|
||||
constructor(times: number[], knots: number[]) {
|
||||
this.times = times;
|
||||
this.knots = knots;
|
||||
this.dim = knots.length / times.length / 3;
|
||||
}
|
||||
|
||||
evaluate(time: number, result: number[]) {
|
||||
const { times } = this;
|
||||
const last = times.length - 1;
|
||||
|
||||
if (time <= times[0]) {
|
||||
this.getKnot(0, result);
|
||||
} else if (time >= times[last]) {
|
||||
this.getKnot(last, result);
|
||||
} else {
|
||||
let seg = 0;
|
||||
while (time >= times[seg + 1]) {
|
||||
seg++;
|
||||
}
|
||||
this.evaluateSegment(seg, (time - times[seg]) / (times[seg + 1] - times[seg]), result);
|
||||
}
|
||||
}
|
||||
|
||||
getKnot(index: number, result: number[]) {
|
||||
const { knots, dim } = this;
|
||||
const idx = index * 3 * dim;
|
||||
for (let i = 0; i < dim; ++i) {
|
||||
result[i] = knots[idx + i * 3 + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// evaluate the spline segment at the given normalized time t
|
||||
evaluateSegment(segment: number, t: number, result: number[]) {
|
||||
const { knots, dim } = this;
|
||||
|
||||
const t2 = t * t;
|
||||
const twot = t + t;
|
||||
const omt = 1 - t;
|
||||
const omt2 = omt * omt;
|
||||
|
||||
let idx = segment * dim * 3; // each knot has 3 values: tangent in, value, tangent out
|
||||
for (let i = 0; i < dim; ++i) {
|
||||
const p0 = knots[idx + 1]; // p0
|
||||
const m0 = knots[idx + 2]; // outgoing tangent
|
||||
const m1 = knots[idx + dim * 3]; // incoming tangent
|
||||
const p1 = knots[idx + dim * 3 + 1]; // p1
|
||||
idx += 3;
|
||||
|
||||
result[i] =
|
||||
p0 * ((1 + twot) * omt2) +
|
||||
m0 * (t * omt2) +
|
||||
p1 * (t2 * (3 - twot)) +
|
||||
m1 * (t2 * (t - 1));
|
||||
}
|
||||
}
|
||||
|
||||
// calculate cubic spline knots from points
|
||||
// times: time values for each control point
|
||||
// points: control point values to be interpolated (n dimensional)
|
||||
// smoothness: 0 = linear, 1 = smooth
|
||||
static calcKnots(times: number[], points: number[], smoothness: number) {
|
||||
const n = times.length;
|
||||
const dim = points.length / n;
|
||||
const knots = new Array<number>(n * dim * 3);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = times[i];
|
||||
|
||||
for (let j = 0; j < dim; j++) {
|
||||
const idx = i * dim + j;
|
||||
const p = points[idx];
|
||||
|
||||
let tangent;
|
||||
if (i === 0) {
|
||||
tangent = (points[idx + dim] - p) / (times[i + 1] - t);
|
||||
} else if (i === n - 1) {
|
||||
tangent = (p - points[idx - dim]) / (t - times[i - 1]);
|
||||
} else {
|
||||
tangent = (points[idx + dim] - points[idx - dim]) / (times[i + 1] - times[i - 1]);
|
||||
}
|
||||
|
||||
// convert to derivatives w.r.t normalized segment parameter
|
||||
const inScale = i > 0 ? (times[i] - times[i - 1]) : (times[1] - times[0]);
|
||||
const outScale = i < n - 1 ? (times[i + 1] - times[i]) : (times[i] - times[i - 1]);
|
||||
|
||||
knots[idx * 3] = tangent * inScale * smoothness;
|
||||
knots[idx * 3 + 1] = p;
|
||||
knots[idx * 3 + 2] = tangent * outScale * smoothness;
|
||||
}
|
||||
}
|
||||
|
||||
return knots;
|
||||
}
|
||||
|
||||
static fromPoints(times: number[], points: number[], smoothness = 1) {
|
||||
return new CubicSpline(times, CubicSpline.calcKnots(times, points, smoothness));
|
||||
}
|
||||
|
||||
// create a looping spline by duplicating animation points at the end and beginning
|
||||
static fromPointsLooping(length: number, times: number[], points: number[], smoothness = 1) {
|
||||
if (times.length < 2) {
|
||||
return CubicSpline.fromPoints(times, points);
|
||||
}
|
||||
|
||||
const dim = points.length / times.length;
|
||||
const newTimes = times.slice();
|
||||
const newPoints = points.slice();
|
||||
|
||||
// append first two points
|
||||
newTimes.push(length + times[0], length + times[1]);
|
||||
newPoints.push(...points.slice(0, dim * 2));
|
||||
|
||||
// prepend last two points
|
||||
newTimes.splice(0, 0, times[times.length - 2] - length, times[times.length - 1] - length);
|
||||
newPoints.splice(0, 0, ...points.slice(points.length - dim * 2));
|
||||
|
||||
return CubicSpline.fromPoints(newTimes, newPoints, smoothness);
|
||||
}
|
||||
}
|
||||
|
||||
export { CubicSpline };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ReadFileSystem } from '@playcanvas/splat-transform';
|
||||
import { AppBase, Asset, GSplatData, GSplatResource } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
import { loadGSplatData, validateGSplatData } from './io';
|
||||
import { Splat } from './splat';
|
||||
|
||||
// handles loading gsplat assets using splat-transform
|
||||
class AssetLoader {
|
||||
app: AppBase;
|
||||
events: Events;
|
||||
|
||||
constructor(app: AppBase, events: Events) {
|
||||
this.app = app;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
// wrap in-memory GSplatData in a gsplat Asset + GSplatResource registered with
|
||||
// the engine. shared by the splat-transform load path and the PLY sequence
|
||||
// frame source, which already holds decoded GSplatData.
|
||||
createGSplatAsset(gsplatData: GSplatData, filename: string): Asset {
|
||||
const asset = new Asset(filename, 'gsplat', { url: `local-asset-${Date.now()}`, filename });
|
||||
this.app.assets.add(asset);
|
||||
asset.resource = new GSplatResource(this.app.graphicsDevice, gsplatData);
|
||||
return asset;
|
||||
}
|
||||
|
||||
async load(filename: string, fileSystem: ReadFileSystem, animationFrame?: boolean, skipReorder?: boolean) {
|
||||
if (!animationFrame) {
|
||||
this.events.fire('startSpinner');
|
||||
}
|
||||
|
||||
try {
|
||||
// Skip reordering for animation frames (speed) or when explicitly requested (already ordered)
|
||||
const { gsplatData, transform } = await loadGSplatData(filename, fileSystem, skipReorder || animationFrame);
|
||||
validateGSplatData(gsplatData);
|
||||
|
||||
const asset = this.createGSplatAsset(gsplatData, filename);
|
||||
|
||||
return new Splat(asset, transform.rotation);
|
||||
} finally {
|
||||
if (!animationFrame) {
|
||||
this.events.fire('stopSpinner');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { AssetLoader };
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
BLENDEQUATION_ADD,
|
||||
BLENDMODE_ONE,
|
||||
BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDMODE_SRC_ALPHA,
|
||||
CULLFACE_FRONT,
|
||||
BlendState,
|
||||
BoundingBox,
|
||||
Entity,
|
||||
ShaderMaterial,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { Serializer } from './serializer';
|
||||
import { vertexShader, fragmentShader } from './shaders/box-shape-shader';
|
||||
|
||||
const v = new Vec3();
|
||||
const bound = new BoundingBox();
|
||||
|
||||
class BoxShape extends Element {
|
||||
_lenX = 2;
|
||||
_lenY = 2;
|
||||
_lenZ = 2;
|
||||
pivot: Entity;
|
||||
material: ShaderMaterial;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.debug);
|
||||
|
||||
this.pivot = new Entity('boxPivot');
|
||||
this.pivot.addComponent('render', {
|
||||
type: 'box'
|
||||
});
|
||||
}
|
||||
|
||||
add() {
|
||||
const material = new ShaderMaterial({
|
||||
uniqueName: 'boxShape',
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
material.cull = CULLFACE_FRONT;
|
||||
material.blendState = new BlendState(
|
||||
true,
|
||||
BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA
|
||||
);
|
||||
material.update();
|
||||
|
||||
this.pivot.render.meshInstances[0].material = material;
|
||||
this.pivot.render.layers = [this.scene.worldLayer.id];
|
||||
|
||||
this.material = material;
|
||||
|
||||
this.scene.contentRoot.addChild(this.pivot);
|
||||
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.scene.contentRoot.removeChild(this.pivot);
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
}
|
||||
|
||||
serialize(serializer: Serializer): void {
|
||||
serializer.packa(this.pivot.getWorldTransform().data);
|
||||
serializer.pack(this.lenX);
|
||||
serializer.pack(this.lenY);
|
||||
serializer.pack(this.lenZ);
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
this.pivot.setLocalScale(this._lenX, this._lenY, this._lenZ);
|
||||
this.pivot.getWorldTransform().getTranslation(v);
|
||||
this.material.setParameter('boxCen', [v.x, v.y, v.z]);
|
||||
this.material.setParameter('boxLen', [this._lenX * 0.5, this._lenY * 0.5, this._lenZ * 0.5]);
|
||||
|
||||
const device = this.scene.graphicsDevice;
|
||||
device.scope.resolve('targetSize').setValue([device.width, device.height]);
|
||||
}
|
||||
|
||||
moved() {
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
updateBound() {
|
||||
bound.center.copy(this.pivot.getPosition());
|
||||
bound.halfExtents.set(this._lenX, this._lenY, this._lenZ);
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
get worldBound(): BoundingBox | null {
|
||||
return bound;
|
||||
}
|
||||
|
||||
set lenX(lenX: number) {
|
||||
this._lenX = lenX;
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
get lenX() {
|
||||
return this._lenX;
|
||||
}
|
||||
|
||||
set lenY(lenY: number) {
|
||||
this._lenY = lenY;
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
get lenY() {
|
||||
return this._lenY;
|
||||
}
|
||||
|
||||
set lenZ(lenZ: number) {
|
||||
this._lenZ = lenZ;
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
get lenZ() {
|
||||
return this._lenZ;
|
||||
}
|
||||
}
|
||||
|
||||
export { BoxShape };
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
PRIMITIVE_LINES,
|
||||
Entity,
|
||||
Mesh,
|
||||
MeshInstance,
|
||||
ShaderMaterial,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { vertexShader, fragmentShader } from './shaders/debug-shader';
|
||||
|
||||
// temp vectors for frustum geometry calculation (module-scope to avoid allocations)
|
||||
const tmpForward = new Vec3();
|
||||
const tmpRight = new Vec3();
|
||||
const tmpUp = new Vec3();
|
||||
const tmpBase = new Vec3();
|
||||
const tmpTL = new Vec3();
|
||||
const tmpTR = new Vec3();
|
||||
const tmpBL = new Vec3();
|
||||
const tmpBR = new Vec3();
|
||||
const tmpUpTip = new Vec3();
|
||||
|
||||
// lines per camera icon: 4 pyramid + 4 base rect + 2 up indicator = 10
|
||||
const LINES_PER_CAMERA = 10;
|
||||
const VERTS_PER_CAMERA = LINES_PER_CAMERA * 2;
|
||||
|
||||
class CameraPoseGizmos extends Element {
|
||||
entity: Entity;
|
||||
mesh: Mesh;
|
||||
material: ShaderMaterial;
|
||||
meshInstance: MeshInstance;
|
||||
dirty = true;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.debug);
|
||||
}
|
||||
|
||||
add() {
|
||||
const scene = this.scene;
|
||||
const device = scene.graphicsDevice;
|
||||
|
||||
this.material = new ShaderMaterial({
|
||||
uniqueName: 'cameraPoseGizmoMaterial',
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
this.material.depthWrite = true;
|
||||
this.material.depthTest = true;
|
||||
this.material.update();
|
||||
|
||||
this.mesh = new Mesh(device);
|
||||
this.mesh.primitive[0] = {
|
||||
baseVertex: 0,
|
||||
type: PRIMITIVE_LINES,
|
||||
base: 0,
|
||||
count: 0
|
||||
};
|
||||
|
||||
this.meshInstance = new MeshInstance(this.mesh, this.material, null);
|
||||
this.meshInstance.cull = false;
|
||||
|
||||
this.entity = new Entity('cameraPoseGizmos');
|
||||
this.entity.addComponent('render', {
|
||||
meshInstances: [this.meshInstance],
|
||||
layers: [scene.worldLayer.id]
|
||||
});
|
||||
|
||||
scene.app.root.addChild(this.entity);
|
||||
|
||||
// mark dirty when poses or scene bound change
|
||||
const markDirty = () => {
|
||||
this.dirty = true;
|
||||
if (scene.events.invoke('camera.showPoses')) {
|
||||
scene.forceRender = true;
|
||||
}
|
||||
};
|
||||
const { events } = scene;
|
||||
events.on('track.keyAdded', markDirty);
|
||||
events.on('track.keyRemoved', markDirty);
|
||||
events.on('track.keyMoved', markDirty);
|
||||
events.on('track.keyUpdated', markDirty);
|
||||
events.on('track.keysCleared', markDirty);
|
||||
events.on('track.keysLoaded', markDirty);
|
||||
events.on('scene.boundChanged', markDirty);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.entity?.destroy();
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
const { scene } = this;
|
||||
const visible = scene.events.invoke('camera.showPoses') && scene.camera.renderOverlays;
|
||||
|
||||
this.entity.enabled = visible;
|
||||
|
||||
if (visible && this.dirty) {
|
||||
this.dirty = false;
|
||||
this.rebuildMesh();
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildMesh() {
|
||||
const poses = this.scene.events.invoke('camera.poses') as { position: Vec3, target: Vec3 }[];
|
||||
if (!poses || poses.length === 0) {
|
||||
this.mesh.primitive[0].count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = 0.08;
|
||||
const halfW = 0.06;
|
||||
const halfH = 0.04;
|
||||
|
||||
const numVerts = poses.length * VERTS_PER_CAMERA;
|
||||
const positions: number[] = [];
|
||||
const colors = new Uint8Array(numVerts * 4);
|
||||
|
||||
const pushLine = (a: Vec3, b: Vec3) => {
|
||||
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
|
||||
};
|
||||
|
||||
for (const pose of poses) {
|
||||
const { position, target } = pose;
|
||||
|
||||
// forward direction
|
||||
tmpForward.sub2(target, position).normalize();
|
||||
|
||||
// right direction (handle degenerate case when looking straight up/down)
|
||||
if (Math.abs(tmpForward.y) > 0.999) {
|
||||
tmpRight.cross(tmpForward, Vec3.BACK).normalize();
|
||||
} else {
|
||||
tmpRight.cross(tmpForward, Vec3.UP).normalize();
|
||||
}
|
||||
|
||||
// up direction
|
||||
tmpUp.cross(tmpRight, tmpForward);
|
||||
|
||||
// base center (in front of camera position)
|
||||
tmpBase.copy(position).addScaled(tmpForward, depth);
|
||||
|
||||
// frustum corners
|
||||
tmpTL.copy(tmpBase).addScaled(tmpUp, halfH).addScaled(tmpRight, -halfW);
|
||||
tmpTR.copy(tmpBase).addScaled(tmpUp, halfH).addScaled(tmpRight, halfW);
|
||||
tmpBL.copy(tmpBase).addScaled(tmpUp, -halfH).addScaled(tmpRight, -halfW);
|
||||
tmpBR.copy(tmpBase).addScaled(tmpUp, -halfH).addScaled(tmpRight, halfW);
|
||||
|
||||
// pyramid edges from position to corners
|
||||
pushLine(position, tmpTL);
|
||||
pushLine(position, tmpTR);
|
||||
pushLine(position, tmpBL);
|
||||
pushLine(position, tmpBR);
|
||||
|
||||
// base rectangle
|
||||
pushLine(tmpTL, tmpTR);
|
||||
pushLine(tmpTR, tmpBR);
|
||||
pushLine(tmpBR, tmpBL);
|
||||
pushLine(tmpBL, tmpTL);
|
||||
|
||||
// up indicator triangle
|
||||
tmpUpTip.copy(tmpBase).addScaled(tmpUp, halfH * 1.5);
|
||||
pushLine(tmpTL, tmpUpTip);
|
||||
pushLine(tmpTR, tmpUpTip);
|
||||
}
|
||||
|
||||
// fill vertex colors with cyan (0, 255, 255, 255)
|
||||
for (let i = 0; i < numVerts; i++) {
|
||||
const off = i * 4;
|
||||
colors[off] = 0;
|
||||
colors[off + 1] = 255;
|
||||
colors[off + 2] = 255;
|
||||
colors[off + 3] = 255;
|
||||
}
|
||||
|
||||
this.mesh.setPositions(positions);
|
||||
this.mesh.setColors32(colors);
|
||||
this.mesh.update(PRIMITIVE_LINES);
|
||||
}
|
||||
}
|
||||
|
||||
export { CameraPoseGizmos };
|
||||
@@ -0,0 +1,336 @@
|
||||
import { Vec3 } from 'playcanvas';
|
||||
|
||||
import { CubicSpline } from './anim/spline';
|
||||
import { AnimTrack } from './anim-track';
|
||||
import { Events } from './events';
|
||||
|
||||
type Pose = {
|
||||
name: string,
|
||||
frame: number,
|
||||
position: Vec3,
|
||||
target: Vec3,
|
||||
fov?: number
|
||||
};
|
||||
|
||||
/**
|
||||
* Camera animation track that manages camera keyframes and interpolation.
|
||||
* Implements AnimTrack interface so it can be used with the timeline system.
|
||||
*
|
||||
* Fully self-contained: subscribes to timeline events internally for
|
||||
* evaluation and spline rebuilding.
|
||||
*/
|
||||
class CameraAnimTrack implements AnimTrack {
|
||||
private poses: Pose[] = [];
|
||||
private events: Events;
|
||||
private onTimelineChange: ((frame: number) => void) | null = null;
|
||||
|
||||
constructor(events: Events) {
|
||||
this.events = events;
|
||||
|
||||
// Evaluate on timeline playback and scrub
|
||||
events.on('timeline.time', (time: number) => {
|
||||
this.evaluate(time);
|
||||
});
|
||||
|
||||
events.on('timeline.frame', (frame: number) => {
|
||||
this.evaluate(frame);
|
||||
});
|
||||
|
||||
// Rebuild spline when timeline parameters change
|
||||
events.on('timeline.frames', () => {
|
||||
this.rebuildSpline();
|
||||
});
|
||||
|
||||
events.on('timeline.smoothness', () => {
|
||||
this.rebuildSpline();
|
||||
});
|
||||
|
||||
events.on('timeline.loop', () => {
|
||||
this.rebuildSpline();
|
||||
});
|
||||
|
||||
// Clear track when scene is cleared
|
||||
events.on('scene.clear', () => {
|
||||
this.clear();
|
||||
});
|
||||
}
|
||||
|
||||
get keys(): readonly number[] {
|
||||
return this.poses.map(p => p.frame);
|
||||
}
|
||||
|
||||
addKey(frame: number): boolean {
|
||||
const pose = this.events.invoke('camera.getPose');
|
||||
if (!pose) return false;
|
||||
|
||||
const existingIndex = this.poses.findIndex(p => p.frame === frame);
|
||||
|
||||
const newPose: Pose = {
|
||||
name: `camera_${this.poses.length}`,
|
||||
frame,
|
||||
position: new Vec3(pose.position.x, pose.position.y, pose.position.z),
|
||||
target: new Vec3(pose.target.x, pose.target.y, pose.target.z),
|
||||
fov: pose.fov
|
||||
};
|
||||
|
||||
if (existingIndex === -1) {
|
||||
this.poses.push(newPose);
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyAdded', frame);
|
||||
} else {
|
||||
this.poses[existingIndex] = newPose;
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyUpdated', frame);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
removeKey(frame: number): boolean {
|
||||
const index = this.poses.findIndex(p => p.frame === frame);
|
||||
if (index === -1) return false;
|
||||
this.poses.splice(index, 1);
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyRemoved', frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
moveKey(fromFrame: number, toFrame: number): boolean {
|
||||
if (fromFrame === toFrame) return false;
|
||||
|
||||
const index = this.poses.findIndex(p => p.frame === fromFrame);
|
||||
if (index === -1) return false;
|
||||
|
||||
// Remove any existing pose at the target frame
|
||||
const toIndex = this.poses.findIndex(p => p.frame === toFrame);
|
||||
if (toIndex !== -1) {
|
||||
this.poses.splice(toIndex, 1);
|
||||
}
|
||||
|
||||
// Update the frame (re-find index since splice may have shifted it)
|
||||
const movedIndex = this.poses.findIndex(p => p.frame === fromFrame);
|
||||
this.poses[movedIndex].frame = toFrame;
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyMoved', fromFrame, toFrame);
|
||||
return true;
|
||||
}
|
||||
|
||||
copyKey(fromFrame: number, toFrame: number): boolean {
|
||||
if (fromFrame === toFrame) return false;
|
||||
|
||||
const source = this.poses.find(p => p.frame === fromFrame);
|
||||
if (!source) return false;
|
||||
|
||||
// Remove any existing pose at the target frame
|
||||
const toIndex = this.poses.findIndex(p => p.frame === toFrame);
|
||||
if (toIndex !== -1) {
|
||||
this.poses.splice(toIndex, 1);
|
||||
}
|
||||
|
||||
this.poses.push({
|
||||
name: `camera_${this.poses.length}`,
|
||||
frame: toFrame,
|
||||
position: source.position.clone(),
|
||||
target: source.target.clone(),
|
||||
fov: source.fov
|
||||
});
|
||||
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyAdded', toFrame);
|
||||
return true;
|
||||
}
|
||||
|
||||
evaluate(frame: number): void {
|
||||
this.onTimelineChange?.(frame);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.poses.length = 0;
|
||||
this.onTimelineChange = null;
|
||||
this.events.fire('track.keysCleared');
|
||||
}
|
||||
|
||||
snapshot(): Pose[] {
|
||||
return this.poses.map(p => ({
|
||||
name: p.name,
|
||||
frame: p.frame,
|
||||
position: p.position.clone(),
|
||||
target: p.target.clone(),
|
||||
fov: p.fov
|
||||
}));
|
||||
}
|
||||
|
||||
restore(snapshot: unknown): void {
|
||||
this.poses = (snapshot as Pose[]).map(p => ({
|
||||
name: p.name,
|
||||
frame: p.frame,
|
||||
position: p.position.clone(),
|
||||
target: p.target.clone(),
|
||||
fov: p.fov
|
||||
}));
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keysLoaded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a pose directly (used for deserialization and legacy import).
|
||||
*/
|
||||
addPose(pose: Pose): void {
|
||||
if (pose.frame === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
pose.fov ??= this.events.invoke('camera.fov') ?? 60;
|
||||
|
||||
const idx = this.poses.findIndex(p => p.frame === pose.frame);
|
||||
if (idx !== -1) {
|
||||
this.poses[idx] = pose;
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyUpdated', pose.frame);
|
||||
} else {
|
||||
this.poses.push(pose);
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keyAdded', pose.frame);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all poses (used for serialization and legacy consumers).
|
||||
*/
|
||||
getPoses(): readonly Pose[] {
|
||||
return this.poses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load poses from serialized data.
|
||||
*/
|
||||
loadPoses(posesData: Pose[]): void {
|
||||
this.poses.length = 0;
|
||||
posesData.forEach((pose) => {
|
||||
this.poses.push(pose);
|
||||
});
|
||||
this.rebuildSpline();
|
||||
this.events.fire('track.keysLoaded');
|
||||
}
|
||||
|
||||
private rebuildSpline(): void {
|
||||
const duration = this.events.invoke('timeline.frames');
|
||||
const smoothness = this.events.invoke('timeline.smoothness');
|
||||
const loop = this.events.invoke('timeline.loop');
|
||||
|
||||
const orderedPoses = this.poses.slice()
|
||||
.filter(a => a.frame < duration)
|
||||
.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
const times = orderedPoses.map(p => p.frame);
|
||||
const points: number[] = [];
|
||||
for (let i = 0; i < orderedPoses.length; ++i) {
|
||||
const p = orderedPoses[i];
|
||||
points.push(p.position.x, p.position.y, p.position.z);
|
||||
points.push(p.target.x, p.target.y, p.target.z);
|
||||
points.push(p.fov);
|
||||
}
|
||||
|
||||
if (orderedPoses.length > 1) {
|
||||
// when not looping the spline clamps, holding the first/last pose
|
||||
// beyond the outer keys instead of wrapping the end into the start
|
||||
const spline = loop ?
|
||||
CubicSpline.fromPointsLooping(duration, times, points, smoothness) :
|
||||
CubicSpline.fromPoints(times, points, smoothness);
|
||||
const result: number[] = [];
|
||||
const pose = { position: new Vec3(), target: new Vec3(), fov: 0 };
|
||||
|
||||
this.onTimelineChange = (frame: number) => {
|
||||
spline.evaluate(frame, result);
|
||||
pose.position.set(result[0], result[1], result[2]);
|
||||
pose.target.set(result[3], result[4], result[5]);
|
||||
pose.fov = result[6];
|
||||
this.events.fire('camera.setPose', pose, 0);
|
||||
};
|
||||
} else if (orderedPoses.length === 1) {
|
||||
// a single key can't form a spline; hold its pose at every frame
|
||||
const p = orderedPoses[0];
|
||||
const pose = { position: p.position.clone(), target: p.target.clone(), fov: p.fov };
|
||||
|
||||
this.onTimelineChange = () => {
|
||||
this.events.fire('camera.setPose', pose, 0);
|
||||
};
|
||||
} else {
|
||||
this.onTimelineChange = null;
|
||||
}
|
||||
|
||||
// re-evaluate at the current frame so the camera updates immediately
|
||||
this.evaluate(this.events.invoke('timeline.frame'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the camera animation track and expose it via events.
|
||||
* The track is fully self-contained (subscribes to timeline events internally),
|
||||
* so this function only needs to create it, expose it, and handle serialization.
|
||||
*/
|
||||
const registerCameraPosesEvents = (events: Events) => {
|
||||
const track = new CameraAnimTrack(events);
|
||||
|
||||
// Expose the camera animation track
|
||||
events.function('camera.animTrack', () => {
|
||||
return track;
|
||||
});
|
||||
|
||||
// Legacy support: expose poses
|
||||
events.function('camera.poses', () => {
|
||||
return track.getPoses();
|
||||
});
|
||||
|
||||
// Legacy support: add pose directly
|
||||
events.on('camera.addPose', (pose: Pose) => {
|
||||
track.addPose(pose);
|
||||
});
|
||||
|
||||
// Serialization
|
||||
|
||||
events.function('docSerialize.poseSets', (): any[] => {
|
||||
const pack3 = (v: Vec3) => [v.x, v.y, v.z];
|
||||
const poses = track.getPoses();
|
||||
|
||||
if (poses.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
name: 'set0',
|
||||
poses: poses.map((pose) => {
|
||||
return {
|
||||
name: pose.name,
|
||||
frame: pose.frame,
|
||||
position: pack3(pose.position),
|
||||
target: pack3(pose.target),
|
||||
fov: pose.fov
|
||||
};
|
||||
})
|
||||
}];
|
||||
});
|
||||
|
||||
events.function('docDeserialize.poseSets', (poseSets: any[], documentCameraFov?: number) => {
|
||||
if (!poseSets || poseSets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = events.invoke('timeline.frameRate');
|
||||
|
||||
const defaultFov = documentCameraFov ?? events.invoke('camera.fov') ?? 60;
|
||||
|
||||
const loadedPoses: Pose[] = poseSets[0].poses.map((docPose: any, index: number) => {
|
||||
return {
|
||||
name: docPose.name,
|
||||
frame: docPose.frame ?? (index * fps),
|
||||
position: new Vec3(docPose.position),
|
||||
target: new Vec3(docPose.target),
|
||||
fov: docPose.fov ?? defaultFov
|
||||
};
|
||||
});
|
||||
|
||||
track.loadPoses(loadedPoses);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerCameraPosesEvents, CameraAnimTrack, Pose };
|
||||
+839
@@ -0,0 +1,839 @@
|
||||
import {
|
||||
math,
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
ASPECT_MANUAL,
|
||||
FILTER_NEAREST,
|
||||
PIXELFORMAT_RGBA8,
|
||||
PIXELFORMAT_RGBA16F,
|
||||
PIXELFORMAT_DEPTH,
|
||||
PROJECTION_ORTHOGRAPHIC,
|
||||
PROJECTION_PERSPECTIVE,
|
||||
TONEMAP_ACES,
|
||||
TONEMAP_ACES2,
|
||||
TONEMAP_FILMIC,
|
||||
TONEMAP_HEJL,
|
||||
TONEMAP_LINEAR,
|
||||
TONEMAP_NEUTRAL,
|
||||
BoundingBox,
|
||||
Color,
|
||||
Entity,
|
||||
Mat4,
|
||||
Quat,
|
||||
Ray,
|
||||
RenderPass,
|
||||
RenderPassForward,
|
||||
RenderTarget,
|
||||
Texture,
|
||||
Vec3,
|
||||
Vec4
|
||||
} from 'playcanvas';
|
||||
|
||||
import { PointerController } from './controllers';
|
||||
import { Element, ElementType } from './element';
|
||||
import { Picker } from './picker';
|
||||
import { Serializer } from './serializer';
|
||||
import { vertexShader, fragmentShader } from './shaders/blit-shader';
|
||||
import { Splat } from './splat';
|
||||
import { TweenValue } from './tween-value';
|
||||
import { ShaderQuad, SimpleRenderPass } from './utils/simple-render-pass';
|
||||
|
||||
// work globals
|
||||
const forwardVec = new Vec3();
|
||||
const cameraPosition = new Vec3();
|
||||
const ray = new Ray();
|
||||
const vec = new Vec3();
|
||||
const vecb = new Vec3();
|
||||
const va = new Vec3();
|
||||
const m = new Mat4();
|
||||
const v4 = new Vec4();
|
||||
|
||||
// modulo dealing with negative numbers
|
||||
const mod = (n: number, m: number) => ((n % m) + m) % m;
|
||||
|
||||
class Camera extends Element {
|
||||
/**
|
||||
* Calculate the forward vector given azimuth and elevation angles.
|
||||
*
|
||||
* @param {Vec3} result - The Vec3 to store the result in.
|
||||
* @param {number} azim - Azimuth angle in degrees.
|
||||
* @param {number} elev - Elevation angle in degrees.
|
||||
*/
|
||||
static calcForwardVec(result: Vec3, azim: number, elev: number) {
|
||||
const ex = elev * math.DEG_TO_RAD;
|
||||
const ey = azim * math.DEG_TO_RAD;
|
||||
const s1 = Math.sin(-ex);
|
||||
const c1 = Math.cos(-ex);
|
||||
const s2 = Math.sin(-ey);
|
||||
const c2 = Math.cos(-ey);
|
||||
result.set(-c1 * s2, s1, c1 * c2);
|
||||
}
|
||||
|
||||
controller: PointerController;
|
||||
focalPointTween = new TweenValue({ x: 0, y: 0.5, z: 0 });
|
||||
azimElevTween = new TweenValue({ azim: 30, elev: -15 });
|
||||
distanceTween = new TweenValue({ distance: 1 });
|
||||
|
||||
minElev = -90;
|
||||
maxElev = 90;
|
||||
|
||||
sceneRadius = 1;
|
||||
|
||||
flySpeed = 1;
|
||||
|
||||
controlMode: 'orbit' | 'fly' = 'orbit';
|
||||
|
||||
// during fly-mode look, stores the camera position that must stay fixed
|
||||
// while the azim/elev tween smoothly converges
|
||||
lookCameraPos: Vec3 | null = null;
|
||||
|
||||
picker: Picker;
|
||||
|
||||
mainCamera: Entity;
|
||||
|
||||
mainTarget: RenderTarget;
|
||||
splatTarget: RenderTarget;
|
||||
colorTarget: RenderTarget;
|
||||
workTarget: RenderTarget;
|
||||
|
||||
// Render passes
|
||||
clearPass: RenderPass;
|
||||
mainPass: RenderPassForward;
|
||||
splatPass: RenderPassForward;
|
||||
gizmoPass: RenderPassForward;
|
||||
finalPass: SimpleRenderPass;
|
||||
|
||||
// overridden target size
|
||||
targetSizeOverride: { width: number, height: number } = null;
|
||||
|
||||
// when set, overrides the tween-driven pose, fov and clipping planes each
|
||||
// update (used by 360 capture to render arbitrary face orientations that
|
||||
// the azim/elev pose system cannot express)
|
||||
poseOverride: { position: Vec3, rotation: Quat, fov: number, near: number, far: number } | null = null;
|
||||
|
||||
// world transform of the user-facing camera pose. while a pose override
|
||||
// is active this holds the last tween-driven pose, so ui elements (view
|
||||
// cube, overlays) don't track the internal capture poses
|
||||
displayTransform = new Mat4();
|
||||
|
||||
renderOverlays = true;
|
||||
|
||||
updateCameraUniforms: () => void;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.camera);
|
||||
|
||||
// create the camera entity
|
||||
this.mainCamera = new Entity('Camera');
|
||||
this.mainCamera.addComponent('camera');
|
||||
}
|
||||
|
||||
// ortho
|
||||
set ortho(value: boolean) {
|
||||
if (value !== this.ortho) {
|
||||
this.camera.projection = value ? PROJECTION_ORTHOGRAPHIC : PROJECTION_PERSPECTIVE;
|
||||
this.scene.events.fire('camera.ortho', value);
|
||||
}
|
||||
}
|
||||
|
||||
get ortho() {
|
||||
return this.camera.projection === PROJECTION_ORTHOGRAPHIC;
|
||||
}
|
||||
|
||||
// fov
|
||||
set fov(value: number) {
|
||||
this.camera.fov = value;
|
||||
}
|
||||
|
||||
get fov() {
|
||||
return this.camera.fov;
|
||||
}
|
||||
|
||||
// tonemapping
|
||||
set tonemapping(value: string) {
|
||||
const mapping: Record<string, number> = {
|
||||
linear: TONEMAP_LINEAR,
|
||||
neutral: TONEMAP_NEUTRAL,
|
||||
aces: TONEMAP_ACES,
|
||||
aces2: TONEMAP_ACES2,
|
||||
filmic: TONEMAP_FILMIC,
|
||||
hejl: TONEMAP_HEJL
|
||||
};
|
||||
|
||||
const tvalue = mapping[value];
|
||||
|
||||
if (tvalue !== undefined && tvalue !== this.camera.toneMapping) {
|
||||
this.camera.toneMapping = tvalue;
|
||||
this.scene.events.fire('camera.tonemapping', value);
|
||||
}
|
||||
}
|
||||
|
||||
get tonemapping() {
|
||||
switch (this.camera.toneMapping) {
|
||||
case TONEMAP_LINEAR: return 'linear';
|
||||
case TONEMAP_NEUTRAL: return 'neutral';
|
||||
case TONEMAP_ACES: return 'aces';
|
||||
case TONEMAP_ACES2: return 'aces2';
|
||||
case TONEMAP_FILMIC: return 'filmic';
|
||||
case TONEMAP_HEJL: return 'hejl';
|
||||
}
|
||||
return 'linear';
|
||||
}
|
||||
|
||||
// near clip
|
||||
set near(value: number) {
|
||||
this.camera.nearClip = value;
|
||||
}
|
||||
|
||||
get near() {
|
||||
return this.camera.nearClip;
|
||||
}
|
||||
|
||||
// far clip
|
||||
set far(value: number) {
|
||||
this.camera.farClip = value;
|
||||
}
|
||||
|
||||
get far() {
|
||||
return this.camera.farClip;
|
||||
}
|
||||
|
||||
// focal point
|
||||
get focalPoint() {
|
||||
const t = this.focalPointTween.target;
|
||||
return new Vec3(t.x, t.y, t.z);
|
||||
}
|
||||
|
||||
// azimuth, elevation
|
||||
get azimElev() {
|
||||
return this.azimElevTween.target;
|
||||
}
|
||||
|
||||
get azim() {
|
||||
return this.azimElev.azim;
|
||||
}
|
||||
|
||||
get elevation() {
|
||||
return this.azimElev.elev;
|
||||
}
|
||||
|
||||
get distance() {
|
||||
return this.distanceTween.target.distance;
|
||||
}
|
||||
|
||||
setFocalPoint(point: Vec3, dampingFactorFactor: number = 1) {
|
||||
this.lookCameraPos = null;
|
||||
this.focalPointTween.goto(point, dampingFactorFactor * this.scene.config.controls.dampingFactor);
|
||||
}
|
||||
|
||||
// Fly mode: rotate camera around itself, keeping the camera position fixed
|
||||
look(dx: number, dy: number) {
|
||||
const sensitivity = this.scene.config.controls.orbitSensitivity;
|
||||
const d = this.distance * this.sceneRadius / this.fovFactor;
|
||||
|
||||
Camera.calcForwardVec(forwardVec, this.azim, this.elevation);
|
||||
const cameraPos = this.focalPoint.add(forwardVec.clone().mulScalar(d));
|
||||
|
||||
const azim = this.azim - dx * sensitivity;
|
||||
const elev = this.elevation - dy * sensitivity;
|
||||
|
||||
Camera.calcForwardVec(forwardVec, azim, elev);
|
||||
const focalPoint = cameraPos.clone().sub(forwardVec.clone().mulScalar(d));
|
||||
|
||||
this.setAzimElev(azim, elev);
|
||||
this.focalPointTween.goto(focalPoint, this.scene.config.controls.dampingFactor);
|
||||
this.lookCameraPos = cameraPos;
|
||||
}
|
||||
|
||||
setAzimElev(azim: number, elev: number, dampingFactorFactor: number = 1) {
|
||||
// clamp
|
||||
azim = mod(azim, 360);
|
||||
elev = Math.max(this.minElev, Math.min(this.maxElev, elev));
|
||||
|
||||
const t = this.azimElevTween;
|
||||
t.goto({ azim, elev }, dampingFactorFactor * this.scene.config.controls.dampingFactor);
|
||||
|
||||
// handle wraparound
|
||||
if (t.source.azim - azim < -180) {
|
||||
t.source.azim += 360;
|
||||
} else if (t.source.azim - azim > 180) {
|
||||
t.source.azim -= 360;
|
||||
}
|
||||
|
||||
// return to perspective mode on rotation
|
||||
this.ortho = false;
|
||||
}
|
||||
|
||||
setDistance(distance: number, dampingFactorFactor: number = 1) {
|
||||
this.lookCameraPos = null;
|
||||
|
||||
const controls = this.scene.config.controls;
|
||||
|
||||
// clamp
|
||||
distance = Math.max(controls.minZoom, Math.min(controls.maxZoom, distance));
|
||||
|
||||
const t = this.distanceTween;
|
||||
t.goto({ distance }, dampingFactorFactor * controls.dampingFactor);
|
||||
}
|
||||
|
||||
setPose(position: Vec3, target: Vec3, dampingFactorFactor: number = 1) {
|
||||
vec.sub2(target, position);
|
||||
const l = vec.length();
|
||||
const azim = Math.atan2(-vec.x / l, -vec.z / l) * math.RAD_TO_DEG;
|
||||
const elev = Math.asin(vec.y / l) * math.RAD_TO_DEG;
|
||||
this.setFocalPoint(target, dampingFactorFactor);
|
||||
this.setAzimElev(azim, elev, dampingFactorFactor);
|
||||
this.setDistance(l / this.sceneRadius * this.fovFactor, dampingFactorFactor);
|
||||
}
|
||||
|
||||
// set or clear the pose override and apply it immediately so subsequent
|
||||
// splat sorting and rendering see the new transform
|
||||
setPoseOverride(override: Camera['poseOverride']) {
|
||||
this.poseOverride = override;
|
||||
this.onUpdate(0);
|
||||
}
|
||||
|
||||
// transform the world space coordinate to normalized screen coordinate
|
||||
worldToScreen(world: Vec3, screen: Vec3) {
|
||||
const { camera } = this;
|
||||
m.mul2(camera.projectionMatrix, camera.viewMatrix);
|
||||
|
||||
v4.set(world.x, world.y, world.z, 1);
|
||||
m.transformVec4(v4, v4);
|
||||
|
||||
screen.x = v4.x / v4.w * 0.5 + 0.5;
|
||||
screen.y = 1.0 - (v4.y / v4.w * 0.5 + 0.5);
|
||||
screen.z = v4.z / v4.w;
|
||||
}
|
||||
|
||||
add() {
|
||||
const { camera, scene } = this;
|
||||
|
||||
scene.cameraRoot.addChild(this.mainCamera);
|
||||
|
||||
// configure camera to render all layers
|
||||
this.mainCamera.camera.layers = [
|
||||
scene.worldLayer.id,
|
||||
scene.splatLayer.id,
|
||||
scene.gizmoLayer.id
|
||||
];
|
||||
|
||||
// use manual aspect ratio mode so we can set it based on targetSize
|
||||
camera.aspectRatioMode = ASPECT_MANUAL;
|
||||
|
||||
// create render passes
|
||||
const device = scene.graphicsDevice;
|
||||
const { app } = scene;
|
||||
const renderer = app.renderer;
|
||||
const composition = app.scene.layers;
|
||||
|
||||
this.clearPass = new RenderPass(device);
|
||||
this.mainPass = new RenderPassForward(device, composition, app.scene, renderer);
|
||||
this.splatPass = new RenderPassForward(device, composition, app.scene, renderer);
|
||||
this.gizmoPass = new RenderPassForward(device, composition, app.scene, renderer);
|
||||
this.finalPass = new SimpleRenderPass(device,
|
||||
new ShaderQuad(device, vertexShader, fragmentShader, 'final-blit'), {
|
||||
vars: () => {
|
||||
return {
|
||||
srcTexture: this.mainTarget.colorBuffer
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const target = document.getElementById('canvas-container');
|
||||
this.controller = new PointerController(this, target);
|
||||
|
||||
// apply scene config
|
||||
const config = scene.config;
|
||||
const controls = config.controls;
|
||||
|
||||
this.minElev = (controls.minPolarAngle * 180) / Math.PI - 90;
|
||||
this.maxElev = (controls.maxPolarAngle * 180) / Math.PI - 90;
|
||||
|
||||
// tonemapping
|
||||
camera.toneMapping = {
|
||||
linear: TONEMAP_LINEAR,
|
||||
filmic: TONEMAP_FILMIC,
|
||||
hejl: TONEMAP_HEJL,
|
||||
aces: TONEMAP_ACES,
|
||||
aces2: TONEMAP_ACES2,
|
||||
neutral: TONEMAP_NEUTRAL
|
||||
}[config.camera.toneMapping];
|
||||
|
||||
// exposure
|
||||
scene.app.scene.exposure = config.camera.exposure;
|
||||
|
||||
this.fov = config.camera.fov;
|
||||
|
||||
// initial camera position and orientation
|
||||
this.setAzimElev(controls.initialAzim, controls.initialElev, 0);
|
||||
this.setDistance(controls.initialZoom, 0);
|
||||
|
||||
// picker
|
||||
this.picker = new Picker(scene);
|
||||
|
||||
scene.events.on('scene.boundChanged', this.onBoundChanged, this);
|
||||
|
||||
// prepare camera-specific uniforms
|
||||
this.updateCameraUniforms = () => {
|
||||
const device = scene.graphicsDevice;
|
||||
const entity = this.mainCamera;
|
||||
const camera = entity.camera;
|
||||
|
||||
const set = (name: string, vec: Vec3) => {
|
||||
device.scope.resolve(name).setValue([vec.x, vec.y, vec.z]);
|
||||
};
|
||||
|
||||
// get frustum corners in world space
|
||||
const points = camera.camera.getFrustumCorners(-100);
|
||||
const worldTransform = this.worldTransform;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
worldTransform.transformPoint(points[i], points[i]);
|
||||
}
|
||||
|
||||
// near
|
||||
if (camera.projection === PROJECTION_PERSPECTIVE) {
|
||||
// perspective
|
||||
set('near_origin', worldTransform.getTranslation());
|
||||
set('near_x', Vec3.ZERO);
|
||||
set('near_y', Vec3.ZERO);
|
||||
} else {
|
||||
// orthographic
|
||||
set('near_origin', points[3]);
|
||||
set('near_x', va.sub2(points[0], points[3]));
|
||||
set('near_y', va.sub2(points[2], points[3]));
|
||||
}
|
||||
|
||||
// far
|
||||
set('far_origin', points[7]);
|
||||
set('far_x', va.sub2(points[4], points[7]));
|
||||
set('far_y', va.sub2(points[6], points[7]));
|
||||
};
|
||||
|
||||
// temp control of camera start
|
||||
const url = new URL(location.href);
|
||||
const focal = url.searchParams.get('focal');
|
||||
if (focal) {
|
||||
const parts = focal.toString().split(',');
|
||||
if (parts.length === 3) {
|
||||
this.setFocalPoint(new Vec3(parseFloat(parts[0]), parseFloat(parts[1]), parseFloat(parts[2])), 0);
|
||||
}
|
||||
}
|
||||
const angles = url.searchParams.get('angles');
|
||||
if (angles) {
|
||||
const parts = angles.toString().split(',');
|
||||
if (parts.length === 2) {
|
||||
this.setAzimElev(parseFloat(parts[0]), parseFloat(parts[1]), 0);
|
||||
}
|
||||
}
|
||||
const distance = url.searchParams.get('distance');
|
||||
if (distance) {
|
||||
this.setDistance(parseFloat(distance), 0);
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
const { scene } = this;
|
||||
|
||||
this.controller.destroy();
|
||||
this.controller = null;
|
||||
|
||||
// cleanup render passes
|
||||
this.clearPass?.destroy();
|
||||
this.mainPass?.destroy();
|
||||
this.splatPass?.destroy();
|
||||
this.gizmoPass?.destroy();
|
||||
this.finalPass?.destroy();
|
||||
this.camera.framePasses = null;
|
||||
|
||||
scene.cameraRoot.removeChild(this.mainCamera);
|
||||
|
||||
this.picker.destroy();
|
||||
this.picker = null;
|
||||
|
||||
scene.events.off('scene.boundChanged', this.onBoundChanged, this);
|
||||
}
|
||||
|
||||
// handle the scene's bound changing. the camera must be configured to render
|
||||
// the entire extents as well as possible.
|
||||
// also update the existing camera distance to maintain the current view
|
||||
onBoundChanged(bound: BoundingBox) {
|
||||
const prevDistance = this.distanceTween.value.distance * this.sceneRadius;
|
||||
this.sceneRadius = Math.max(1e-03, bound.halfExtents.length());
|
||||
this.setDistance(prevDistance / this.sceneRadius, 0);
|
||||
}
|
||||
|
||||
serialize(serializer: Serializer) {
|
||||
serializer.packa(this.worldTransform.data);
|
||||
serializer.pack(
|
||||
this.fov,
|
||||
this.tonemapping,
|
||||
this.targetSize.width,
|
||||
this.targetSize.height
|
||||
);
|
||||
}
|
||||
|
||||
// handle the viewer canvas resizing
|
||||
rebuildRenderTargets() {
|
||||
const { width, height } = this.targetSize;
|
||||
const { mainTarget, scene } = this;
|
||||
|
||||
// early out if size is unchanged
|
||||
if (mainTarget && mainTarget.width === width && mainTarget.height === height) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mainTarget) {
|
||||
// first time - construct render targets
|
||||
const { graphicsDevice } = scene;
|
||||
|
||||
const createTexture = (name: string, width: number, height: number, format: number) => {
|
||||
return new Texture(graphicsDevice, {
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
format,
|
||||
mipmaps: false,
|
||||
minFilter: FILTER_NEAREST,
|
||||
magFilter: FILTER_NEAREST,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
};
|
||||
|
||||
const colorBuffer = createTexture('cameraColor', width, height, PIXELFORMAT_RGBA16F);
|
||||
const workBuffer = createTexture('workColor', width, height, PIXELFORMAT_RGBA8);
|
||||
const depthBuffer = createTexture('cameraDepth', width, height, PIXELFORMAT_DEPTH);
|
||||
|
||||
// create main render target
|
||||
this.mainTarget = new RenderTarget({
|
||||
colorBuffer,
|
||||
depthBuffer,
|
||||
flipY: false,
|
||||
autoResolve: false
|
||||
});
|
||||
|
||||
// create MRT render target for splat pass
|
||||
this.splatTarget = new RenderTarget({
|
||||
colorBuffers: [
|
||||
colorBuffer, // RT0: main color (shared)
|
||||
workBuffer // RT1: overlay output (shared with workTarget)
|
||||
],
|
||||
depthBuffer,
|
||||
flipY: false,
|
||||
autoResolve: false
|
||||
});
|
||||
|
||||
this.colorTarget = new RenderTarget({
|
||||
colorBuffer,
|
||||
depth: false,
|
||||
autoResolve: false
|
||||
});
|
||||
|
||||
// create work buffer (used for picking, overlay output, and other operations)
|
||||
this.workTarget = new RenderTarget({
|
||||
colorBuffer: workBuffer,
|
||||
depth: false,
|
||||
autoResolve: false
|
||||
});
|
||||
|
||||
// set picker render targets
|
||||
this.picker.setRenderTargets(this.colorTarget, this.workTarget);
|
||||
|
||||
// clear all targets
|
||||
this.clearPass.init(this.splatTarget);
|
||||
this.clearPass.setClearColor(new Color(0, 0, 0, 0));
|
||||
this.clearPass.setClearDepth(1);
|
||||
this.clearPass.setClearStencil(0);
|
||||
|
||||
// configure main pass - world layer with clears
|
||||
this.mainPass.init(this.mainTarget);
|
||||
this.mainPass.addLayer(this.camera, scene.worldLayer, false, false);
|
||||
this.mainPass.addLayer(this.camera, scene.worldLayer, true, false);
|
||||
|
||||
// configure splat pass - MRT target, no clears
|
||||
this.splatPass.init(this.splatTarget);
|
||||
this.splatPass.addLayer(this.camera, scene.splatLayer, false, false);
|
||||
this.splatPass.addLayer(this.camera, scene.splatLayer, true, false);
|
||||
|
||||
// configure gizmo pass
|
||||
this.gizmoPass.init(this.mainTarget);
|
||||
this.gizmoPass.addLayer(this.camera, scene.gizmoLayer, false, false);
|
||||
this.gizmoPass.addLayer(this.camera, scene.gizmoLayer, true, false);
|
||||
this.gizmoPass.renderActions[0].clearDepth = true;
|
||||
this.gizmoPass.renderActions[0].clearStencil = true;
|
||||
|
||||
this.finalPass.init(null);
|
||||
|
||||
// assign render passes to camera
|
||||
this.camera.framePasses = [this.clearPass, this.mainPass, this.splatPass, this.gizmoPass, this.finalPass];
|
||||
} else {
|
||||
// resize existing render targets
|
||||
const { splatTarget, colorTarget, workTarget } = this;
|
||||
|
||||
mainTarget.resize(width, height);
|
||||
workTarget.resize(width, height);
|
||||
colorTarget.resize(width, height);
|
||||
splatTarget.resize(width, height);
|
||||
}
|
||||
|
||||
this.camera.horizontalFov = width > height;
|
||||
this.camera.aspectRatio = width / height;
|
||||
scene.events.fire('camera.resize', { width, height });
|
||||
}
|
||||
|
||||
onUpdate(deltaTime: number) {
|
||||
// controller update
|
||||
this.controller.update(deltaTime);
|
||||
|
||||
// update underlying values
|
||||
this.focalPointTween.update(deltaTime);
|
||||
this.azimElevTween.update(deltaTime);
|
||||
this.distanceTween.update(deltaTime);
|
||||
|
||||
const azimElev = this.azimElevTween.value;
|
||||
const distance = this.distanceTween.value;
|
||||
|
||||
Camera.calcForwardVec(forwardVec, azimElev.azim, azimElev.elev);
|
||||
|
||||
if (this.lookCameraPos) {
|
||||
cameraPosition.copy(this.lookCameraPos);
|
||||
if (this.azimElevTween.timer >= this.azimElevTween.transitionTime) {
|
||||
this.lookCameraPos = null;
|
||||
}
|
||||
} else {
|
||||
cameraPosition.copy(forwardVec);
|
||||
cameraPosition.mulScalar(distance.distance * this.sceneRadius / this.fovFactor);
|
||||
cameraPosition.add(this.focalPointTween.value);
|
||||
}
|
||||
|
||||
if (this.poseOverride) {
|
||||
// cameraRoot has identity transform, so local space is world space
|
||||
const { position, rotation, fov, near, far } = this.poseOverride;
|
||||
this.mainCamera.setLocalPosition(position);
|
||||
this.mainCamera.setLocalRotation(rotation);
|
||||
this.camera.fov = fov;
|
||||
this.near = near;
|
||||
this.far = far;
|
||||
} else {
|
||||
this.mainCamera.setLocalPosition(cameraPosition);
|
||||
this.mainCamera.setLocalEulerAngles(azimElev.elev, azimElev.azim, 0);
|
||||
|
||||
this.fitClippingPlanes(this.mainCamera.getLocalPosition(), this.mainCamera.forward);
|
||||
|
||||
this.displayTransform.copy(this.mainCamera.getWorldTransform());
|
||||
}
|
||||
|
||||
const { camera } = this.mainCamera;
|
||||
const { targetSize } = this;
|
||||
|
||||
// update ortho height
|
||||
camera.orthoHeight = this.distanceTween.value.distance * this.sceneRadius / this.fovFactor * (this.fov / 90) * (camera.horizontalFov ? targetSize.height / targetSize.width : 1);
|
||||
camera.camera._updateViewProjMat();
|
||||
}
|
||||
|
||||
fitClippingPlanes(cameraPosition: Vec3, forwardVec: Vec3) {
|
||||
const bound = this.scene.bound;
|
||||
const boundRadius = bound.halfExtents.length();
|
||||
|
||||
vec.sub2(bound.center, cameraPosition);
|
||||
const dist = vec.dot(forwardVec);
|
||||
|
||||
if (dist > 0) {
|
||||
this.far = dist + boundRadius;
|
||||
// if camera is placed inside the sphere bound calculate near based far
|
||||
this.near = Math.max(1e-6, dist < boundRadius ? this.far / (1024 * 16) : dist - boundRadius);
|
||||
} else {
|
||||
// if the scene is behind the camera
|
||||
this.far = boundRadius * 2;
|
||||
this.near = this.far / (1024 * 16);
|
||||
}
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
this.rebuildRenderTargets();
|
||||
this.updateCameraUniforms();
|
||||
}
|
||||
|
||||
onPostRender() {
|
||||
|
||||
}
|
||||
|
||||
focus(options?: { focalPoint: Vec3, radius: number, speed: number }) {
|
||||
const getSplatFocalPoint = () => {
|
||||
for (const element of this.scene.elements) {
|
||||
if (element.type === ElementType.splat) {
|
||||
const focalPoint = (element as Splat).focalPoint?.();
|
||||
if (focalPoint) {
|
||||
return focalPoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const focalPoint = options ? options.focalPoint : (getSplatFocalPoint() ?? this.scene.bound.center);
|
||||
const focalRadius = options ? options.radius : this.scene.bound.halfExtents.length();
|
||||
|
||||
const fdist = focalRadius / this.sceneRadius;
|
||||
|
||||
this.setDistance(isFinite(fdist) ? fdist : 1, options?.speed ?? 0);
|
||||
this.setFocalPoint(focalPoint, options?.speed ?? 0);
|
||||
}
|
||||
|
||||
get fovFactor() {
|
||||
// use the larger axis fov (which is always this.fov) so camera distance
|
||||
// stays constant regardless of viewport aspect ratio.
|
||||
return Math.sin(this.fov * math.DEG_TO_RAD * 0.5);
|
||||
}
|
||||
|
||||
getRay(screenX: number, screenY: number, ray: Ray) {
|
||||
const { camera, ortho } = this;
|
||||
const cameraPos = this.mainCamera.getPosition();
|
||||
|
||||
// create the pick ray in world space
|
||||
if (ortho) {
|
||||
camera.screenToWorld(screenX, screenY, -1.0, vec);
|
||||
camera.screenToWorld(screenX, screenY, 1.0, vecb);
|
||||
vecb.sub(vec).normalize();
|
||||
ray.set(vec, vecb);
|
||||
} else {
|
||||
camera.screenToWorld(screenX, screenY, 1.0, vec);
|
||||
vec.sub(cameraPos).normalize();
|
||||
ray.set(cameraPos, vec);
|
||||
}
|
||||
}
|
||||
|
||||
// intersect the scene at the given normalized screen coordinate (0-1 range) using depth picking
|
||||
async intersect(x: number, y: number) {
|
||||
const { scene } = this;
|
||||
const splats = scene.getElementsByType(ElementType.splat);
|
||||
|
||||
let closestDepth = Infinity;
|
||||
let closestSplat: Splat | null = null;
|
||||
|
||||
// Find the splat with the smallest depth at this screen position
|
||||
for (let i = 0; i < splats.length; ++i) {
|
||||
const splat = splats[i] as Splat;
|
||||
|
||||
this.picker.prepareDepth(splat);
|
||||
const normalizedDepth = await this.picker.readDepth(x, y);
|
||||
|
||||
if (normalizedDepth !== null && normalizedDepth < closestDepth) {
|
||||
closestDepth = normalizedDepth;
|
||||
closestSplat = splat;
|
||||
}
|
||||
}
|
||||
|
||||
if (!closestSplat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert normalized depth to linear depth
|
||||
const linearDepth = closestDepth * (this.far - this.near) + this.near;
|
||||
|
||||
// Convert normalized coordinates to screen pixels for getRay
|
||||
const screenX = x * scene.canvas.clientWidth;
|
||||
const screenY = y * scene.canvas.clientHeight;
|
||||
|
||||
// Calculate world position from ray and depth
|
||||
this.getRay(screenX, screenY, ray);
|
||||
const t = linearDepth / ray.direction.dot(this.mainCamera.forward);
|
||||
const position = new Vec3();
|
||||
position.copy(ray.origin).add(vec.copy(ray.direction).mulScalar(t));
|
||||
|
||||
return {
|
||||
splat: closestSplat,
|
||||
position: position,
|
||||
distance: t
|
||||
};
|
||||
}
|
||||
|
||||
// intersect the scene at the normalized screen location (0-1 range) and focus the camera on this location
|
||||
async pickFocalPoint(x: number, y: number) {
|
||||
const result = await this.intersect(x, y);
|
||||
if (result) {
|
||||
const { scene } = this;
|
||||
|
||||
this.setFocalPoint(result.position);
|
||||
this.setDistance(result.distance / this.sceneRadius * this.fovFactor);
|
||||
scene.events.fire('camera.focalPointPicked', {
|
||||
camera: this,
|
||||
splat: result.splat,
|
||||
position: result.position
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// pick mode
|
||||
|
||||
// render picker contents
|
||||
pickPrep(splat: Splat, mode: 'add' | 'remove' | 'set' | 'intersect') {
|
||||
this.picker.prepareId(splat, mode);
|
||||
}
|
||||
|
||||
pick(x: number, y: number) {
|
||||
return this.picker.readId(x, y);
|
||||
}
|
||||
|
||||
pickRect(x: number, y: number, width: number, height: number) {
|
||||
return this.picker.readIds(x, y, width, height);
|
||||
}
|
||||
|
||||
docSerialize() {
|
||||
const pack3 = (v: Vec3) => [v.x, v.y, v.z];
|
||||
|
||||
return {
|
||||
focalPoint: pack3(this.focalPointTween.target),
|
||||
azim: this.azim,
|
||||
elev: this.elevation,
|
||||
distance: this.distance,
|
||||
fov: this.fov,
|
||||
tonemapping: this.tonemapping
|
||||
};
|
||||
}
|
||||
|
||||
docDeserialize(settings: any) {
|
||||
this.setFocalPoint(new Vec3(settings.focalPoint), 0);
|
||||
this.setAzimElev(settings.azim, settings.elev, 0);
|
||||
this.setDistance(settings.distance, 0);
|
||||
this.fov = settings.fov;
|
||||
this.tonemapping = settings.tonemapping;
|
||||
}
|
||||
|
||||
// offscreen render mode
|
||||
|
||||
startOffscreenMode(width: number, height: number) {
|
||||
this.targetSizeOverride = { width, height };
|
||||
this.finalPass.enabled = false;
|
||||
this.rebuildRenderTargets();
|
||||
this.onUpdate(0);
|
||||
}
|
||||
|
||||
endOffscreenMode() {
|
||||
this.targetSizeOverride = null;
|
||||
this.finalPass.enabled = true;
|
||||
this.rebuildRenderTargets();
|
||||
this.onUpdate(0);
|
||||
}
|
||||
|
||||
get targetSize() {
|
||||
return this.targetSizeOverride ?? this.scene.targetSize;
|
||||
}
|
||||
|
||||
get camera() {
|
||||
return this.mainCamera.camera;
|
||||
}
|
||||
|
||||
get worldTransform() {
|
||||
return this.mainCamera.getWorldTransform();
|
||||
}
|
||||
|
||||
get position() {
|
||||
return this.mainCamera.getPosition();
|
||||
}
|
||||
|
||||
get forward() {
|
||||
return this.mainCamera.forward;
|
||||
}
|
||||
}
|
||||
|
||||
export { Camera };
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Color } from 'playcanvas';
|
||||
|
||||
const SH_C0 = 0.28209479177387814;
|
||||
|
||||
const dcDecode = (v: number) => v * SH_C0 + 0.5;
|
||||
const dcEncode = (v: number) => (v - 0.5) / SH_C0;
|
||||
|
||||
const sigmoid = (v: number) => 1 / (1 + Math.exp(-v));
|
||||
const invSigmoid = (v: number) => ((v <= 0) ? -400 : ((v >= 1) ? 400 : -Math.log(1 / v - 1)));
|
||||
|
||||
type GradeParams = {
|
||||
tintClr: Color,
|
||||
temperature: number,
|
||||
saturation: number,
|
||||
brightness: number,
|
||||
blackPoint: number,
|
||||
whitePoint: number,
|
||||
transparency: number
|
||||
};
|
||||
|
||||
type RGB = { r: number, g: number, b: number };
|
||||
|
||||
class ColorGrade {
|
||||
private s: RGB;
|
||||
private offset: number;
|
||||
private saturation: number;
|
||||
private transparency: number;
|
||||
|
||||
readonly hasTint: boolean;
|
||||
|
||||
constructor(p: GradeParams) {
|
||||
const scale = 1 / (p.whitePoint - p.blackPoint);
|
||||
this.s = {
|
||||
r: scale * p.tintClr.r * (1 + p.temperature),
|
||||
g: scale * p.tintClr.g,
|
||||
b: scale * p.tintClr.b * (1 - p.temperature)
|
||||
};
|
||||
this.offset = -p.blackPoint + p.brightness;
|
||||
this.saturation = p.saturation;
|
||||
this.transparency = p.transparency;
|
||||
|
||||
this.hasTint = (
|
||||
!p.tintClr.equals(Color.WHITE) ||
|
||||
p.temperature !== 0 ||
|
||||
p.saturation !== 1 ||
|
||||
p.brightness !== 0 ||
|
||||
p.blackPoint !== 0 ||
|
||||
p.whitePoint !== 1
|
||||
);
|
||||
}
|
||||
|
||||
private apply(c: RGB, offset: number) {
|
||||
c.r = offset + c.r * this.s.r;
|
||||
c.g = offset + c.g * this.s.g;
|
||||
c.b = offset + c.b * this.s.b;
|
||||
|
||||
const grey = c.r * 0.299 + c.g * 0.587 + c.b * 0.114;
|
||||
c.r = grey + (c.r - grey) * this.saturation;
|
||||
c.g = grey + (c.g - grey) * this.saturation;
|
||||
c.b = grey + (c.b - grey) * this.saturation;
|
||||
}
|
||||
|
||||
applyDC(c: RGB) {
|
||||
this.apply(c, this.offset);
|
||||
}
|
||||
|
||||
applySH(c: RGB) {
|
||||
this.apply(c, 0);
|
||||
}
|
||||
|
||||
applyOpacity(o: number): number {
|
||||
return invSigmoid(sigmoid(o) * this.transparency);
|
||||
}
|
||||
|
||||
applyAlpha(o: number): number {
|
||||
return sigmoid(o) * this.transparency;
|
||||
}
|
||||
}
|
||||
|
||||
export { ColorGrade, dcDecode, dcEncode, sigmoid, invSigmoid, SH_C0 };
|
||||
export type { GradeParams, RGB };
|
||||
@@ -0,0 +1,19 @@
|
||||
// unified FIFO queue for all async splat work (GPU readbacks + history mutations).
|
||||
// every consumer that needs ordering relative to other commands enqueues a task
|
||||
// here; the queue guarantees strict FIFO across the whole app, so neither
|
||||
// dataProcessor nor edit-history needs its own private chain.
|
||||
class CommandQueue {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
enqueue<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(fn);
|
||||
// swallow errors on the chain itself so a failed task doesn't poison
|
||||
// subsequent ones. the caller still sees the rejection on its own promise.
|
||||
this.tail = next.then(() => {}, (err) => {
|
||||
console.error('CommandQueue task failed', err);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export { CommandQueue };
|
||||
@@ -0,0 +1,479 @@
|
||||
import { Vec3 } from 'playcanvas';
|
||||
|
||||
import { Camera } from './camera';
|
||||
|
||||
const fromWorldPoint = new Vec3();
|
||||
const toWorldPoint = new Vec3();
|
||||
const worldDiff = new Vec3();
|
||||
const moveVec = new Vec3();
|
||||
|
||||
// calculate the distance between two 2d points
|
||||
const dist = (x0: number, y0: number, x1: number, y1: number) => Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2);
|
||||
|
||||
class PointerController {
|
||||
update: (deltaTime: number) => void;
|
||||
destroy: () => void;
|
||||
|
||||
constructor(camera: Camera, target: HTMLElement) {
|
||||
|
||||
// Orbit mode: rotate camera around the focal point
|
||||
const orbit = (dx: number, dy: number) => {
|
||||
const azim = camera.azim - dx * camera.scene.config.controls.orbitSensitivity;
|
||||
const elev = camera.elevation - dy * camera.scene.config.controls.orbitSensitivity;
|
||||
camera.setAzimElev(azim, elev);
|
||||
};
|
||||
|
||||
const look = (dx: number, dy: number) => {
|
||||
camera.look(dx, dy);
|
||||
};
|
||||
|
||||
const pan = (x: number, y: number, dx: number, dy: number) => {
|
||||
// For panning to work at any zoom level, we use screen point to world projection
|
||||
// to work out how far we need to pan the pivotEntity in world space
|
||||
const c = camera.camera;
|
||||
const distance = camera.distanceTween.value.distance * camera.sceneRadius / camera.fovFactor;
|
||||
|
||||
c.screenToWorld(x, y, distance, fromWorldPoint);
|
||||
c.screenToWorld(x - dx, y - dy, distance, toWorldPoint);
|
||||
|
||||
worldDiff.sub2(toWorldPoint, fromWorldPoint);
|
||||
worldDiff.add(camera.focalPoint);
|
||||
|
||||
camera.setFocalPoint(worldDiff);
|
||||
};
|
||||
|
||||
const zoom = (amount: number) => {
|
||||
camera.setDistance(camera.distance - (camera.distance * 0.999 + 0.001) * amount * camera.scene.config.controls.zoomSensitivity, 2);
|
||||
};
|
||||
|
||||
// mouse state
|
||||
let pressedButton = -1; // no button pressed, otherwise 0, 1, or 2
|
||||
let x: number, y: number;
|
||||
|
||||
// middle-mouse click-vs-drag tracking (for MMB single-click to focus)
|
||||
const CLICK_DRAG_THRESHOLD = 4;
|
||||
let mmbStartX = 0, mmbStartY = 0, mmbDragged = false;
|
||||
|
||||
// touch state
|
||||
let touches: { id: number, x: number, y: number}[] = [];
|
||||
let midx: number, midy: number, midlen: number;
|
||||
|
||||
const pointerdown = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
// If a button is already pressed, ignore this press
|
||||
if (pressedButton !== -1) {
|
||||
return;
|
||||
}
|
||||
target.setPointerCapture(event.pointerId);
|
||||
pressedButton = event.button;
|
||||
x = event.offsetX;
|
||||
y = event.offsetY;
|
||||
if (pressedButton === 1) {
|
||||
mmbStartX = x;
|
||||
mmbStartY = y;
|
||||
mmbDragged = false;
|
||||
}
|
||||
} else if (event.pointerType === 'touch') {
|
||||
if (touches.length === 0) {
|
||||
target.setPointerCapture(event.pointerId);
|
||||
}
|
||||
touches.push({
|
||||
x: event.offsetX,
|
||||
y: event.offsetY,
|
||||
id: event.pointerId
|
||||
});
|
||||
|
||||
if (touches.length === 2) {
|
||||
midx = (touches[0].x + touches[1].x) * 0.5;
|
||||
midy = (touches[0].y + touches[1].y) * 0.5;
|
||||
midlen = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pointerup = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
// Only release if this is the button that was initially pressed
|
||||
if (event.button === pressedButton) {
|
||||
// MMB tap (no significant movement) -> focus on cursor point (orbit only; fly uses MMB for zoom)
|
||||
if (pressedButton === 1 && camera.controlMode === 'orbit' && !mmbDragged) {
|
||||
camera.pickFocalPoint(event.offsetX / target.clientWidth, event.offsetY / target.clientHeight);
|
||||
}
|
||||
pressedButton = -1;
|
||||
target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
} else {
|
||||
touches = touches.filter(touch => touch.id !== event.pointerId);
|
||||
if (touches.length === 0) {
|
||||
target.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pointermove = (event: PointerEvent) => {
|
||||
if (event.pointerType === 'mouse') {
|
||||
// Only process if we're tracking a button
|
||||
if (pressedButton === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the button we're tracking is still pressed
|
||||
// 1 = left button, 4 = middle button, 2 = right button
|
||||
const buttonMask = [1, 4, 2][pressedButton];
|
||||
if ((event.buttons & buttonMask) === 0) {
|
||||
// Button is no longer pressed, clean up
|
||||
pressedButton = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = event.offsetX - x;
|
||||
const dy = event.offsetY - y;
|
||||
x = event.offsetX;
|
||||
y = event.offsetY;
|
||||
|
||||
if (camera.controlMode === 'fly') {
|
||||
// Fly mode: left-drag to look around, middle to zoom, right works same as orbit
|
||||
if (pressedButton === 0) {
|
||||
look(dx, dy);
|
||||
} else if (pressedButton === 1) {
|
||||
zoom(dy * -0.02);
|
||||
} else if (pressedButton === 2) {
|
||||
// Right button: same behavior as orbit mode
|
||||
const mod = event.shiftKey || event.ctrlKey ? 'look' :
|
||||
(event.altKey || event.metaKey ? 'zoom' : 'pan');
|
||||
|
||||
if (mod === 'look') {
|
||||
look(dx, dy);
|
||||
} else if (mod === 'zoom') {
|
||||
zoom(dy * -0.02);
|
||||
} else {
|
||||
pan(x, y, dx, dy);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Orbit mode:
|
||||
// - left button: orbit
|
||||
// - middle button (Blender-style): orbit, Shift -> pan, Ctrl -> zoom
|
||||
// (gated on a small drag threshold so a tap can be used to focus on release)
|
||||
// - right button: pan, Shift/Ctrl -> orbit, Alt/Meta -> zoom
|
||||
if (pressedButton === 1 && !mmbDragged) {
|
||||
if (dist(event.offsetX, event.offsetY, mmbStartX, mmbStartY) < CLICK_DRAG_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
mmbDragged = true;
|
||||
}
|
||||
|
||||
let mod: 'orbit' | 'pan' | 'zoom';
|
||||
if (pressedButton === 2) {
|
||||
mod = event.shiftKey || event.ctrlKey ? 'orbit' :
|
||||
(event.altKey || event.metaKey ? 'zoom' : 'pan');
|
||||
} else if (pressedButton === 1) {
|
||||
mod = event.shiftKey ? 'pan' :
|
||||
(event.ctrlKey ? 'zoom' : 'orbit');
|
||||
} else {
|
||||
mod = 'orbit';
|
||||
}
|
||||
|
||||
if (mod === 'orbit') {
|
||||
orbit(dx, dy);
|
||||
} else if (mod === 'zoom') {
|
||||
zoom(dy * -0.02);
|
||||
} else {
|
||||
pan(x, y, dx, dy);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (touches.length === 1) {
|
||||
const touch = touches[0];
|
||||
const dx = event.offsetX - touch.x;
|
||||
const dy = event.offsetY - touch.y;
|
||||
touch.x = event.offsetX;
|
||||
touch.y = event.offsetY;
|
||||
|
||||
if (camera.controlMode === 'fly') {
|
||||
look(dx, dy);
|
||||
} else {
|
||||
orbit(dx, dy);
|
||||
}
|
||||
} else if (touches.length === 2) {
|
||||
const touch = touches[touches.map(t => t.id).indexOf(event.pointerId)];
|
||||
touch.x = event.offsetX;
|
||||
touch.y = event.offsetY;
|
||||
|
||||
const mx = (touches[0].x + touches[1].x) * 0.5;
|
||||
const my = (touches[0].y + touches[1].y) * 0.5;
|
||||
const ml = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y);
|
||||
|
||||
if (camera.controlMode === 'fly') {
|
||||
// In fly mode, pinch moves forward/backward by moving focal point
|
||||
const zoomDelta = (ml - midlen) * 0.01;
|
||||
const worldTransform = camera.mainCamera.getWorldTransform();
|
||||
const zAxis = worldTransform.getZ();
|
||||
moveVec.copy(zAxis).mulScalar(-zoomDelta * camera.flySpeed);
|
||||
const p = camera.focalPoint.add(moveVec);
|
||||
camera.setFocalPoint(p);
|
||||
} else {
|
||||
pan(mx, my, (mx - midx), (my - midy));
|
||||
zoom((ml - midlen) * 0.01);
|
||||
}
|
||||
|
||||
midx = mx;
|
||||
midy = my;
|
||||
midlen = ml;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// A physical mouse wheel and a trackpad two-finger swipe are
|
||||
// indistinguishable from their wheel-event deltas alone - every
|
||||
// per-event heuristic (wheelDelta % 120, deltaMode, fractional or
|
||||
// diagonal deltas) misfires on hi-res mice and in momentum tails
|
||||
// (see issue #919). The one reliable trackpad signal the platform
|
||||
// gives us is the macOS-synthesized pinch: a wheel event with
|
||||
// ctrlKey set while the physical Ctrl key is *not* held. So we no
|
||||
// longer classify the device and instead drive everything from
|
||||
// modifier keys, treating a bare scroll as zoom regardless of device:
|
||||
//
|
||||
// | Input | Action |
|
||||
// |-----------------------------|--------|
|
||||
// | Bare scroll (wheel / swipe) | zoom |
|
||||
// | Pinch (synthetic Ctrl) | zoom |
|
||||
// | Physical Ctrl + scroll | orbit |
|
||||
// | Shift + scroll | pan |
|
||||
|
||||
// Track the physical Ctrl key so we can tell a real Ctrl+scroll
|
||||
// (orbit) from a macOS pinch (zoom). Listeners live on window so they
|
||||
// fire regardless of which element currently has focus.
|
||||
let ctrlDown = false;
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Control') ctrlDown = true;
|
||||
};
|
||||
const keyup = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Control') ctrlDown = false;
|
||||
};
|
||||
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const { deltaX, deltaY } = event;
|
||||
|
||||
// Some browsers (notably Safari/Firefox on macOS) remap a vertical
|
||||
// mouse wheel to deltaX when Shift is held. Only fall back to
|
||||
// deltaX for that remapped case so horizontal-only scrolling
|
||||
// (tilt wheel, horizontal trackpad swipe) is not treated as
|
||||
// zoom / fly movement.
|
||||
const wheelDelta = event.shiftKey && deltaY === 0 ? deltaX : deltaY;
|
||||
|
||||
// Synthetic Ctrl (macOS/Magic Mouse pinch) or Cmd: fine zoom.
|
||||
// Physical Ctrl held down: orbit.
|
||||
const isPinch = (event.ctrlKey && !ctrlDown) || event.metaKey;
|
||||
const isOrbit = event.ctrlKey && ctrlDown;
|
||||
|
||||
if (camera.controlMode === 'fly') {
|
||||
if (isOrbit) {
|
||||
look(deltaX, deltaY);
|
||||
} else if (event.shiftKey) {
|
||||
pan(event.offsetX, event.offsetY, deltaX, deltaY);
|
||||
} else if (camera.ortho) {
|
||||
// moving forward/backward has no visual effect in ortho
|
||||
// (ortho height derives from distance), so zoom instead,
|
||||
// with the same pinch/scroll factors as the orbit path
|
||||
zoom(isPinch ? deltaY * -0.02 : wheelDelta * -0.002);
|
||||
} else {
|
||||
// Bare scroll / pinch: move focal point forward/backward
|
||||
const factor = camera.flySpeed * 0.01;
|
||||
const worldTransform = camera.mainCamera.getWorldTransform();
|
||||
const zAxis = worldTransform.getZ();
|
||||
moveVec.copy(zAxis).mulScalar(wheelDelta * factor);
|
||||
const p = camera.focalPoint.add(moveVec);
|
||||
camera.setFocalPoint(p);
|
||||
}
|
||||
} else if (isOrbit) {
|
||||
orbit(deltaX, deltaY);
|
||||
} else if (event.shiftKey) {
|
||||
pan(event.offsetX, event.offsetY, deltaX, deltaY);
|
||||
} else if (isPinch) {
|
||||
zoom(deltaY * -0.02);
|
||||
} else {
|
||||
zoom(wheelDelta * -0.002);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// FIXME: safari sends canvas as target of dblclick event but chrome sends the target element
|
||||
const canvas = camera.scene.app.graphicsDevice.canvas;
|
||||
|
||||
const dblclick = (event: globalThis.MouseEvent) => {
|
||||
if (event.target === target || event.target === canvas) {
|
||||
// Switch to orbit mode when double-clicking to focus
|
||||
if (camera.controlMode === 'fly') {
|
||||
camera.scene.events.fire('camera.setControlMode', 'orbit');
|
||||
}
|
||||
camera.pickFocalPoint(event.offsetX / target.clientWidth, event.offsetY / target.clientHeight);
|
||||
}
|
||||
};
|
||||
|
||||
// fly movement state (updated via shortcut events)
|
||||
let flyForward = false;
|
||||
let flyBackward = false;
|
||||
let flyLeft = false;
|
||||
let flyRight = false;
|
||||
let flyDown = false;
|
||||
let flyUp = false;
|
||||
|
||||
// track modifier keys for speed control (updated via shortcut events)
|
||||
let fastDown = false;
|
||||
let slowDown = false;
|
||||
|
||||
// Clear all keys when window loses focus to prevent stuck keys
|
||||
const clearAllKeys = () => {
|
||||
flyForward = false;
|
||||
flyBackward = false;
|
||||
flyLeft = false;
|
||||
flyRight = false;
|
||||
flyDown = false;
|
||||
flyUp = false;
|
||||
fastDown = false;
|
||||
slowDown = false;
|
||||
ctrlDown = false;
|
||||
};
|
||||
|
||||
// Helper to switch to fly mode when a fly key is pressed
|
||||
const handleFlyKey = (down: boolean) => {
|
||||
if (down && camera.controlMode !== 'fly') {
|
||||
camera.scene.events.fire('camera.setControlMode', 'fly');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for fly movement shortcut events
|
||||
const events = camera.scene.events;
|
||||
|
||||
const onFlyForward = (down: boolean) => {
|
||||
flyForward = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onFlyBackward = (down: boolean) => {
|
||||
flyBackward = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onFlyLeft = (down: boolean) => {
|
||||
flyLeft = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onFlyRight = (down: boolean) => {
|
||||
flyRight = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onFlyDown = (down: boolean) => {
|
||||
flyDown = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onFlyUp = (down: boolean) => {
|
||||
flyUp = down;
|
||||
handleFlyKey(down);
|
||||
};
|
||||
const onModifierFast = (down: boolean) => {
|
||||
fastDown = down;
|
||||
};
|
||||
const onModifierSlow = (down: boolean) => {
|
||||
slowDown = down;
|
||||
};
|
||||
|
||||
events.on('camera.fly.forward', onFlyForward);
|
||||
events.on('camera.fly.backward', onFlyBackward);
|
||||
events.on('camera.fly.left', onFlyLeft);
|
||||
events.on('camera.fly.right', onFlyRight);
|
||||
events.on('camera.fly.down', onFlyDown);
|
||||
events.on('camera.fly.up', onFlyUp);
|
||||
events.on('camera.modifier.fast', onModifierFast);
|
||||
events.on('camera.modifier.slow', onModifierSlow);
|
||||
|
||||
this.update = (deltaTime: number) => {
|
||||
if (camera.controlMode !== 'fly') return;
|
||||
|
||||
// Fly mode: WASD for movement, Q/E for up/down - moves focal point
|
||||
const forward = (flyForward ? 1 : 0) - (flyBackward ? 1 : 0);
|
||||
const strafe = (flyRight ? 1 : 0) - (flyLeft ? 1 : 0);
|
||||
const vertical = (flyUp ? 1 : 0) - (flyDown ? 1 : 0);
|
||||
|
||||
if (forward || strafe || vertical) {
|
||||
// Calculate speed modifier based on current modifier key state
|
||||
const speedMod = fastDown ? 10 : (slowDown ? 0.1 : 1);
|
||||
const factor = deltaTime * camera.flySpeed * speedMod;
|
||||
const worldTransform = camera.worldTransform;
|
||||
|
||||
moveVec.set(0, 0, 0);
|
||||
|
||||
// Forward/backward along horizontal forward direction (fixed Y)
|
||||
if (forward) {
|
||||
const zAxis = worldTransform.getZ();
|
||||
zAxis.y = 0;
|
||||
zAxis.normalize();
|
||||
moveVec.add(zAxis.mulScalar(-forward * factor));
|
||||
}
|
||||
|
||||
// Strafe left/right (horizontal)
|
||||
if (strafe) {
|
||||
const xAxis = worldTransform.getX();
|
||||
xAxis.y = 0;
|
||||
xAxis.normalize();
|
||||
moveVec.add(xAxis.mulScalar(strafe * factor));
|
||||
}
|
||||
|
||||
// Up/down in world space
|
||||
if (vertical) {
|
||||
moveVec.y += vertical * factor;
|
||||
}
|
||||
|
||||
// Move the focal point (camera follows due to orbit calculation)
|
||||
const p = camera.focalPoint.add(moveVec);
|
||||
camera.setFocalPoint(p);
|
||||
}
|
||||
};
|
||||
|
||||
let destroy: () => void = null;
|
||||
|
||||
const wrap = (target: any, name: string, fn: any, options?: any) => {
|
||||
const callback = (event: any) => {
|
||||
camera.scene.events.fire('camera.controller', name);
|
||||
fn(event);
|
||||
};
|
||||
target.addEventListener(name, callback, options);
|
||||
destroy = () => {
|
||||
destroy?.();
|
||||
target.removeEventListener(name, callback);
|
||||
};
|
||||
};
|
||||
|
||||
wrap(target, 'pointerdown', pointerdown);
|
||||
wrap(target, 'pointerup', pointerup);
|
||||
wrap(target, 'pointermove', pointermove);
|
||||
wrap(target, 'wheel', wheel, { passive: false });
|
||||
wrap(target, 'dblclick', dblclick);
|
||||
wrap(window, 'blur', clearAllKeys);
|
||||
|
||||
// Registered directly (not via wrap) so physical-Ctrl tracking
|
||||
// doesn't fire camera.controller on every keystroke. Capture phase
|
||||
// ensures we see Ctrl keydown/keyup even when a focused UI element
|
||||
// (dialogs, popups) calls stopPropagation() on key events - otherwise
|
||||
// ctrlDown could go stale and a real Ctrl+wheel would be misread as a
|
||||
// synthetic-Ctrl pinch.
|
||||
window.addEventListener('keydown', keydown, { capture: true });
|
||||
window.addEventListener('keyup', keyup, { capture: true });
|
||||
|
||||
this.destroy = () => {
|
||||
destroy?.();
|
||||
window.removeEventListener('keydown', keydown, { capture: true });
|
||||
window.removeEventListener('keyup', keyup, { capture: true });
|
||||
events.off('camera.fly.forward', onFlyForward);
|
||||
events.off('camera.fly.backward', onFlyBackward);
|
||||
events.off('camera.fly.left', onFlyLeft);
|
||||
events.off('camera.fly.right', onFlyRight);
|
||||
events.off('camera.fly.down', onFlyDown);
|
||||
events.off('camera.fly.up', onFlyUp);
|
||||
events.off('camera.modifier.fast', onModifierFast);
|
||||
events.off('camera.modifier.slow', onModifierSlow);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { PointerController };
|
||||
@@ -0,0 +1,27 @@
|
||||
// pool of Uint8Array readback buffers, keyed by byteLength. GPU passes acquire
|
||||
// a buffer for the duration of a single run+consume cycle and release it back
|
||||
// when done. avoids the shared-singleton foot-gun where a stale reference from
|
||||
// a previous run could be overwritten under the caller, while keeping steady
|
||||
// state memory parity with the old model (one buffer in flight, one parked).
|
||||
class BufferPool {
|
||||
private free = new Map<number, Uint8Array[]>();
|
||||
|
||||
acquire(byteLen: number): Uint8Array {
|
||||
const list = this.free.get(byteLen);
|
||||
if (list && list.length) {
|
||||
return list.pop()!;
|
||||
}
|
||||
return new Uint8Array(byteLen);
|
||||
}
|
||||
|
||||
release(buf: Uint8Array): void {
|
||||
const list = this.free.get(buf.byteLength);
|
||||
if (list) {
|
||||
list.push(buf);
|
||||
} else {
|
||||
this.free.set(buf.byteLength, [buf]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { BufferPool };
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
PIXELFORMAT_RGBA32F,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
BoundingBox,
|
||||
GraphicsDevice,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture,
|
||||
Vec3,
|
||||
BlendState
|
||||
} from 'playcanvas';
|
||||
|
||||
import { vertexShader, fragmentShader } from '../shaders/bound-shader';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
const v1 = new Vec3();
|
||||
const v2 = new Vec3();
|
||||
const v3 = new Vec3();
|
||||
const v4 = new Vec3();
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
class CalcBound {
|
||||
private device: GraphicsDevice;
|
||||
private splatParams = new Int32Array(3);
|
||||
private shader: Shader = null;
|
||||
private selectedMinTexture: Texture = null;
|
||||
private selectedMaxTexture: Texture = null;
|
||||
private visibleMinTexture: Texture = null;
|
||||
private visibleMaxTexture: Texture = null;
|
||||
private renderTarget: RenderTarget = null;
|
||||
private selectedMinRenderTarget: RenderTarget = null;
|
||||
private selectedMaxRenderTarget: RenderTarget = null;
|
||||
private visibleMinRenderTarget: RenderTarget = null;
|
||||
private visibleMaxRenderTarget: RenderTarget = null;
|
||||
private selectedMinData: Float32Array = null;
|
||||
private selectedMaxData: Float32Array = null;
|
||||
private visibleMinData: Float32Array = null;
|
||||
private visibleMaxData: Float32Array = null;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
private getResources(width: number) {
|
||||
const { device } = this;
|
||||
|
||||
if (!this.shader) {
|
||||
this.shader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'calcBoundShader',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.selectedMinTexture || this.selectedMinTexture.width !== width) {
|
||||
if (this.selectedMinTexture) {
|
||||
this.selectedMinTexture.destroy();
|
||||
this.selectedMaxTexture.destroy();
|
||||
this.visibleMinTexture.destroy();
|
||||
this.visibleMaxTexture.destroy();
|
||||
this.renderTarget.destroy();
|
||||
this.selectedMinRenderTarget.destroy();
|
||||
this.selectedMaxRenderTarget.destroy();
|
||||
this.visibleMinRenderTarget.destroy();
|
||||
this.visibleMaxRenderTarget.destroy();
|
||||
}
|
||||
|
||||
const createTexture = (name: string) => {
|
||||
return new Texture(device, {
|
||||
name,
|
||||
width,
|
||||
height: 1,
|
||||
format: PIXELFORMAT_RGBA32F,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
};
|
||||
|
||||
this.selectedMinTexture = createTexture('calcBoundSelectedMin');
|
||||
this.selectedMaxTexture = createTexture('calcBoundSelectedMax');
|
||||
this.visibleMinTexture = createTexture('calcBoundVisibleMin');
|
||||
this.visibleMaxTexture = createTexture('calcBoundVisibleMax');
|
||||
|
||||
this.renderTarget = new RenderTarget({
|
||||
colorBuffers: [this.selectedMinTexture, this.selectedMaxTexture, this.visibleMinTexture, this.visibleMaxTexture],
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.selectedMinRenderTarget = new RenderTarget({
|
||||
colorBuffer: this.selectedMinTexture,
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.selectedMaxRenderTarget = new RenderTarget({
|
||||
colorBuffer: this.selectedMaxTexture,
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.visibleMinRenderTarget = new RenderTarget({
|
||||
colorBuffer: this.visibleMinTexture,
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.visibleMaxRenderTarget = new RenderTarget({
|
||||
colorBuffer: this.visibleMaxTexture,
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.selectedMinData = new Float32Array(width * 4);
|
||||
this.selectedMaxData = new Float32Array(width * 4);
|
||||
this.visibleMinData = new Float32Array(width * 4);
|
||||
this.visibleMaxData = new Float32Array(width * 4);
|
||||
}
|
||||
|
||||
return {
|
||||
shader: this.shader,
|
||||
selectedMinTexture: this.selectedMinTexture,
|
||||
selectedMaxTexture: this.selectedMaxTexture,
|
||||
visibleMinTexture: this.visibleMinTexture,
|
||||
visibleMaxTexture: this.visibleMaxTexture,
|
||||
renderTarget: this.renderTarget,
|
||||
selectedMinRenderTarget: this.selectedMinRenderTarget,
|
||||
selectedMaxRenderTarget: this.selectedMaxRenderTarget,
|
||||
visibleMinRenderTarget: this.visibleMinRenderTarget,
|
||||
visibleMaxRenderTarget: this.visibleMaxRenderTarget,
|
||||
selectedMinData: this.selectedMinData,
|
||||
selectedMaxData: this.selectedMaxData,
|
||||
visibleMinData: this.visibleMinData,
|
||||
visibleMaxData: this.visibleMaxData
|
||||
};
|
||||
}
|
||||
|
||||
async run(splat: Splat, selectionBound: BoundingBox, localBound: BoundingBox): Promise<void> {
|
||||
const device = splat.scene.graphicsDevice;
|
||||
const { scope } = device;
|
||||
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA');
|
||||
const splatTransform = splat.transformTexture;
|
||||
const transformPalette = splat.transformPalette.texture;
|
||||
const splatState = splat.stateTexture;
|
||||
|
||||
this.splatParams[0] = transformA.width;
|
||||
this.splatParams[1] = transformA.height;
|
||||
this.splatParams[2] = numSplats;
|
||||
|
||||
// get resources
|
||||
const resources = this.getResources(transformA.width);
|
||||
|
||||
resolve(scope, {
|
||||
transformA,
|
||||
splatTransform,
|
||||
transformPalette,
|
||||
splatState,
|
||||
splat_params: this.splatParams
|
||||
});
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, resources.renderTarget, resources.shader);
|
||||
|
||||
// read all 4 textures asynchronously using the public texture.read() API
|
||||
const [selectedMinData, selectedMaxData, visibleMinData, visibleMaxData] = await Promise.all([
|
||||
resources.selectedMinTexture.read(0, 0, transformA.width, 1, {
|
||||
renderTarget: resources.selectedMinRenderTarget,
|
||||
data: resources.selectedMinData,
|
||||
immediate: false
|
||||
}),
|
||||
resources.selectedMaxTexture.read(0, 0, transformA.width, 1, {
|
||||
renderTarget: resources.selectedMaxRenderTarget,
|
||||
data: resources.selectedMaxData,
|
||||
immediate: false
|
||||
}),
|
||||
resources.visibleMinTexture.read(0, 0, transformA.width, 1, {
|
||||
renderTarget: resources.visibleMinRenderTarget,
|
||||
data: resources.visibleMinData,
|
||||
immediate: false
|
||||
}),
|
||||
resources.visibleMaxTexture.read(0, 0, transformA.width, 1, {
|
||||
renderTarget: resources.visibleMaxRenderTarget,
|
||||
data: resources.visibleMaxData,
|
||||
immediate: false
|
||||
})
|
||||
]);
|
||||
|
||||
// resolve selected bounds
|
||||
v1.set(Infinity, Infinity, Infinity);
|
||||
v2.set(-Infinity, -Infinity, -Infinity);
|
||||
|
||||
for (let i = 0; i < transformA.width; i++) {
|
||||
const a = selectedMinData[i * 4];
|
||||
const b = selectedMinData[i * 4 + 1];
|
||||
const c = selectedMinData[i * 4 + 2];
|
||||
if (isFinite(a)) v1.x = Math.min(v1.x, a);
|
||||
if (isFinite(b)) v1.y = Math.min(v1.y, b);
|
||||
if (isFinite(c)) v1.z = Math.min(v1.z, c);
|
||||
|
||||
const d = selectedMaxData[i * 4];
|
||||
const e = selectedMaxData[i * 4 + 1];
|
||||
const f = selectedMaxData[i * 4 + 2];
|
||||
if (isFinite(d)) v2.x = Math.max(v2.x, d);
|
||||
if (isFinite(e)) v2.y = Math.max(v2.y, e);
|
||||
if (isFinite(f)) v2.z = Math.max(v2.z, f);
|
||||
}
|
||||
|
||||
selectionBound.setMinMax(v1, v2);
|
||||
|
||||
// resolve visible bounds
|
||||
v3.set(Infinity, Infinity, Infinity);
|
||||
v4.set(-Infinity, -Infinity, -Infinity);
|
||||
|
||||
for (let i = 0; i < transformA.width; i++) {
|
||||
const a = visibleMinData[i * 4];
|
||||
const b = visibleMinData[i * 4 + 1];
|
||||
const c = visibleMinData[i * 4 + 2];
|
||||
if (isFinite(a)) v3.x = Math.min(v3.x, a);
|
||||
if (isFinite(b)) v3.y = Math.min(v3.y, b);
|
||||
if (isFinite(c)) v3.z = Math.min(v3.z, c);
|
||||
|
||||
const d = visibleMaxData[i * 4];
|
||||
const e = visibleMaxData[i * 4 + 1];
|
||||
const f = visibleMaxData[i * 4 + 2];
|
||||
if (isFinite(d)) v4.x = Math.max(v4.x, d);
|
||||
if (isFinite(e)) v4.y = Math.max(v4.y, e);
|
||||
if (isFinite(f)) v4.z = Math.max(v4.z, f);
|
||||
}
|
||||
|
||||
localBound.setMinMax(v3, v4);
|
||||
}
|
||||
}
|
||||
|
||||
export { CalcBound };
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
BLENDEQUATION_ADD,
|
||||
BLENDMODE_ONE,
|
||||
PIXELFORMAT_RGBA32F,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
BlendState,
|
||||
GraphicsDevice,
|
||||
Mat4,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { drawPointsWithShader } from './draw-points';
|
||||
import { GRID_DIM, NUM_BINS } from './histogram-config';
|
||||
import {
|
||||
fullscreenVS,
|
||||
tileMinMaxFS,
|
||||
finalReduceFS,
|
||||
binVS,
|
||||
binFS
|
||||
} from '../shaders/histogram-shaders';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
const identity = new Mat4();
|
||||
const zeroVec3 = new Vec3();
|
||||
|
||||
// number of SH coefficients per RGB band, indexed by GSplatResource.shBands.
|
||||
const SH_NUM_COEFFS: { [k: number]: number } = { 0: 0, 1: 3, 2: 8, 3: 15 };
|
||||
|
||||
type CalcHistogramOptions = {
|
||||
entityMatrix?: Mat4;
|
||||
viewMatrix?: Mat4;
|
||||
viewProjection?: Mat4;
|
||||
cameraPos?: Vec3;
|
||||
onScreenOnly?: boolean;
|
||||
};
|
||||
|
||||
type CalcHistogramResult = {
|
||||
selected: Float32Array; // length numBins
|
||||
unselected: Float32Array; // length numBins
|
||||
min: number;
|
||||
max: number;
|
||||
numValues: number;
|
||||
};
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
const getShBands = (splat: Splat): number => {
|
||||
return (splat.entity.gsplat.instance.resource as any).shBands ?? 0;
|
||||
};
|
||||
|
||||
class CalcHistogram {
|
||||
private device: GraphicsDevice;
|
||||
|
||||
// shaders are compiled per SH_BANDS value so that each variant declares only
|
||||
// the SH samplers it actually reads. reduceShader has no SH dependence.
|
||||
private tileShaders: Map<number, Shader> = new Map();
|
||||
private binShaders: Map<number, Shader> = new Map();
|
||||
private reduceShader: Shader = null;
|
||||
|
||||
private tileTex: Texture = null;
|
||||
private tileRT: RenderTarget = null;
|
||||
private minMaxTex: Texture = null;
|
||||
private minMaxRT: RenderTarget = null;
|
||||
private binTex: Texture = null;
|
||||
private binRT: RenderTarget = null;
|
||||
|
||||
private minMaxData = new Float32Array(4);
|
||||
private binData = new Float32Array(NUM_BINS * 4);
|
||||
|
||||
private additiveBlend: BlendState;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
|
||||
this.additiveBlend = new BlendState(
|
||||
true,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE
|
||||
);
|
||||
}
|
||||
|
||||
private ensureSharedResources() {
|
||||
const { device } = this;
|
||||
|
||||
if (!this.reduceShader) {
|
||||
this.reduceShader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'histFinalReduce',
|
||||
attributes: { vertex_position: SEMANTIC_POSITION },
|
||||
vertexGLSL: fullscreenVS,
|
||||
fragmentGLSL: finalReduceFS
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.tileTex) {
|
||||
this.tileTex = new Texture(device, {
|
||||
name: 'histTile',
|
||||
width: GRID_DIM,
|
||||
height: GRID_DIM,
|
||||
format: PIXELFORMAT_RGBA32F,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
this.tileRT = new RenderTarget({ colorBuffer: this.tileTex, depth: false });
|
||||
|
||||
this.minMaxTex = new Texture(device, {
|
||||
name: 'histMinMax',
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: PIXELFORMAT_RGBA32F,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
this.minMaxRT = new RenderTarget({ colorBuffer: this.minMaxTex, depth: false });
|
||||
|
||||
this.binTex = new Texture(device, {
|
||||
name: 'histBins',
|
||||
width: NUM_BINS,
|
||||
height: 1,
|
||||
format: PIXELFORMAT_RGBA32F,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
this.binRT = new RenderTarget({ colorBuffer: this.binTex, depth: false });
|
||||
}
|
||||
}
|
||||
|
||||
private getTileShader(shBands: number): Shader {
|
||||
let shader = this.tileShaders.get(shBands);
|
||||
if (!shader) {
|
||||
const defines = new Map<string, string>();
|
||||
defines.set('SH_BANDS', `${shBands}`);
|
||||
shader = ShaderUtils.createShader(this.device, {
|
||||
uniqueName: `histTileMinMax_SH${shBands}`,
|
||||
attributes: { vertex_position: SEMANTIC_POSITION },
|
||||
vertexGLSL: fullscreenVS,
|
||||
fragmentGLSL: tileMinMaxFS,
|
||||
fragmentDefines: defines
|
||||
});
|
||||
this.tileShaders.set(shBands, shader);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
private getBinShader(shBands: number): Shader {
|
||||
let shader = this.binShaders.get(shBands);
|
||||
if (!shader) {
|
||||
const defines = new Map<string, string>();
|
||||
defines.set('SH_BANDS', `${shBands}`);
|
||||
shader = ShaderUtils.createShader(this.device, {
|
||||
uniqueName: `histBin_SH${shBands}`,
|
||||
attributes: { vertex_position: SEMANTIC_POSITION },
|
||||
vertexGLSL: binVS,
|
||||
fragmentGLSL: binFS,
|
||||
vertexDefines: defines
|
||||
});
|
||||
this.binShaders.set(shBands, shader);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
private setSplatUniforms(splat: Splat, mode: number, options?: CalcHistogramOptions) {
|
||||
const { scope } = this.device;
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const resource = splat.entity.gsplat.instance.resource as any;
|
||||
const transformA = resource.getTexture('transformA');
|
||||
const transformB = resource.getTexture('transformB');
|
||||
const splatColor = resource.getTexture('splatColor');
|
||||
const splatTransform = splat.transformTexture;
|
||||
const transformPalette = splat.transformPalette.texture;
|
||||
const splatState = splat.stateTexture;
|
||||
|
||||
const shBands = getShBands(splat);
|
||||
const numCoeffs = SH_NUM_COEFFS[shBands] ?? 0;
|
||||
|
||||
const entityMatrix = options?.entityMatrix ?? identity;
|
||||
const viewMatrix = options?.viewMatrix ?? identity;
|
||||
const viewProjection = options?.viewProjection ?? identity;
|
||||
const cameraPos = options?.cameraPos ?? zeroVec3;
|
||||
const onScreenOnly = options?.onScreenOnly ? 1 : 0;
|
||||
|
||||
// ColorGrade math, kept in sync with ColorGrade in src/color-grade.ts.
|
||||
const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = splat;
|
||||
const cgInvRange = 1 / (whitePoint - blackPoint);
|
||||
|
||||
const values: any = {
|
||||
transformA,
|
||||
transformB,
|
||||
splatColor,
|
||||
splatTransform,
|
||||
transformPalette,
|
||||
splatState,
|
||||
splat_params: [transformA.width, numSplats],
|
||||
propMode: mode,
|
||||
entityMatrix: entityMatrix.data,
|
||||
viewMatrix: viewMatrix.data,
|
||||
viewProjection: viewProjection.data,
|
||||
cameraWorldPos: [cameraPos.x, cameraPos.y, cameraPos.z],
|
||||
onScreenOnly,
|
||||
cgScale: [
|
||||
cgInvRange * tintClr.r * (1 + temperature),
|
||||
cgInvRange * tintClr.g,
|
||||
cgInvRange * tintClr.b * (1 - temperature)
|
||||
],
|
||||
cgOffset: -blackPoint + brightness,
|
||||
cgSaturation: saturation,
|
||||
transparency
|
||||
};
|
||||
|
||||
if (shBands > 0) {
|
||||
values.splatSH_1to3 = resource.getTexture('splatSH_1to3');
|
||||
values.shNumCoeffs = numCoeffs;
|
||||
}
|
||||
if (shBands > 1) {
|
||||
values.splatSH_4to7 = resource.getTexture('splatSH_4to7');
|
||||
values.splatSH_8to11 = resource.getTexture('splatSH_8to11');
|
||||
}
|
||||
if (shBands > 2) {
|
||||
values.splatSH_12to15 = resource.getTexture('splatSH_12to15');
|
||||
}
|
||||
|
||||
resolve(scope, values);
|
||||
|
||||
return numSplats;
|
||||
}
|
||||
|
||||
private clearRT(rt: RenderTarget) {
|
||||
const d = this.device as any;
|
||||
const oldRt = d.renderTarget;
|
||||
const oldVx = d.vx, oldVy = d.vy, oldVw = d.vw, oldVh = d.vh;
|
||||
const oldSx = d.sx, oldSy = d.sy, oldSw = d.sw, oldSh = d.sh;
|
||||
|
||||
d.setRenderTarget(rt);
|
||||
d.updateBegin();
|
||||
d.setViewport(0, 0, rt.width, rt.height);
|
||||
d.setScissor(0, 0, rt.width, rt.height);
|
||||
d.clear({ color: [0, 0, 0, 0], flags: 1 });
|
||||
d.updateEnd();
|
||||
|
||||
d.setRenderTarget(oldRt);
|
||||
d.setViewport(oldVx, oldVy, oldVw, oldVh);
|
||||
d.setScissor(oldSx, oldSy, oldSw, oldSh);
|
||||
}
|
||||
|
||||
// release all GPU resources owned by this instance. peer data-processor
|
||||
// classes (Intersect, SelectByRange, CalcBound) destroy resources only on
|
||||
// size change; CalcHistogram resources are fixed-size, so this exists for
|
||||
// explicit teardown (context loss, scene reload) rather than per-run reuse.
|
||||
destroy() {
|
||||
this.tileRT?.destroy();
|
||||
this.tileTex?.destroy();
|
||||
this.minMaxRT?.destroy();
|
||||
this.minMaxTex?.destroy();
|
||||
this.binRT?.destroy();
|
||||
this.binTex?.destroy();
|
||||
this.tileRT = null;
|
||||
this.tileTex = null;
|
||||
this.minMaxRT = null;
|
||||
this.minMaxTex = null;
|
||||
this.binRT = null;
|
||||
this.binTex = null;
|
||||
this.tileShaders.clear();
|
||||
this.binShaders.clear();
|
||||
this.reduceShader = null;
|
||||
}
|
||||
|
||||
async run(splat: Splat, mode: number, options?: CalcHistogramOptions): Promise<CalcHistogramResult> {
|
||||
this.ensureSharedResources();
|
||||
const { device } = this;
|
||||
const { scope } = device;
|
||||
|
||||
const shBands = getShBands(splat);
|
||||
const tileShader = this.getTileShader(shBands);
|
||||
const binShader = this.getBinShader(shBands);
|
||||
|
||||
const numSplats = this.setSplatUniforms(splat, mode, options);
|
||||
|
||||
const tileSize = Math.ceil(numSplats / (GRID_DIM * GRID_DIM));
|
||||
scope.resolve('tileSize').setValue(tileSize);
|
||||
scope.resolve('gridDim').setValue(GRID_DIM);
|
||||
|
||||
// pass 1: tile min/max (fullscreen quad over GRID_DIM x GRID_DIM)
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, this.tileRT, tileShader);
|
||||
|
||||
// pass 2: final reduce 64x64 → 1x1
|
||||
scope.resolve('inputTex').setValue(this.tileTex);
|
||||
scope.resolve('gridDim').setValue(GRID_DIM);
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, this.minMaxRT, this.reduceShader);
|
||||
|
||||
// pass 3: clear bins, then additive-blend point dispatch
|
||||
this.clearRT(this.binRT);
|
||||
|
||||
// bin shader needs same splat uniforms + minMax + numBins
|
||||
this.setSplatUniforms(splat, mode, options);
|
||||
scope.resolve('minMax').setValue(this.minMaxTex);
|
||||
scope.resolve('numBins').setValue(NUM_BINS);
|
||||
|
||||
drawPointsWithShader(device, this.binRT, binShader, numSplats, this.additiveBlend);
|
||||
|
||||
// readback minMax (8 bytes) and bins (4 KB)
|
||||
await this.minMaxTex.read(0, 0, 1, 1, {
|
||||
renderTarget: this.minMaxRT,
|
||||
data: this.minMaxData,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
await this.binTex.read(0, 0, NUM_BINS, 1, {
|
||||
renderTarget: this.binRT,
|
||||
data: this.binData,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
let min = this.minMaxData[0];
|
||||
let max = this.minMaxData[1];
|
||||
|
||||
// detect "nothing contributed" (sentinel survives reduction)
|
||||
if (min > max) {
|
||||
min = 0;
|
||||
max = 0;
|
||||
}
|
||||
|
||||
const selected = new Float32Array(NUM_BINS);
|
||||
const unselected = new Float32Array(NUM_BINS);
|
||||
let numValues = 0;
|
||||
for (let i = 0; i < NUM_BINS; i++) {
|
||||
const s = this.binData[i * 4];
|
||||
const u = this.binData[i * 4 + 1];
|
||||
selected[i] = s;
|
||||
unselected[i] = u;
|
||||
numValues += s + u;
|
||||
}
|
||||
|
||||
return { selected, unselected, min, max, numValues };
|
||||
}
|
||||
}
|
||||
|
||||
export { CalcHistogram };
|
||||
export type { CalcHistogramOptions, CalcHistogramResult };
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
PIXELFORMAT_RGBA32F,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
GraphicsDevice,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture,
|
||||
BlendState
|
||||
} from 'playcanvas';
|
||||
|
||||
import { vertexShader, fragmentShader } from '../shaders/position-shader';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
class CalcPositions {
|
||||
private device: GraphicsDevice;
|
||||
private shader: Shader = null;
|
||||
private texture: Texture = null;
|
||||
private renderTarget: RenderTarget = null;
|
||||
private data: Float32Array = null;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
private getResources(width: number, height: number) {
|
||||
const { device } = this;
|
||||
|
||||
if (!this.shader) {
|
||||
this.shader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'calcPositionShader',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.texture || this.texture.width !== width || this.texture.height !== height) {
|
||||
if (this.texture) {
|
||||
this.texture.destroy();
|
||||
this.renderTarget.destroy();
|
||||
}
|
||||
|
||||
this.texture = new Texture(device, {
|
||||
name: 'positionTex',
|
||||
width,
|
||||
height,
|
||||
format: PIXELFORMAT_RGBA32F,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
|
||||
this.renderTarget = new RenderTarget({
|
||||
colorBuffer: this.texture,
|
||||
depth: false
|
||||
});
|
||||
|
||||
this.data = new Float32Array(width * height * 4);
|
||||
}
|
||||
|
||||
return {
|
||||
shader: this.shader,
|
||||
texture: this.texture,
|
||||
renderTarget: this.renderTarget,
|
||||
data: this.data
|
||||
};
|
||||
}
|
||||
|
||||
async run(splat: Splat): Promise<Float32Array> {
|
||||
const { device } = this;
|
||||
const { scope } = device;
|
||||
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA');
|
||||
const splatTransform = splat.transformTexture;
|
||||
const transformPalette = splat.transformPalette.texture;
|
||||
|
||||
// allocate resources
|
||||
const resources = this.getResources(transformA.width, transformA.height);
|
||||
|
||||
resolve(scope, {
|
||||
transformA,
|
||||
splatTransform,
|
||||
transformPalette,
|
||||
splat_params: [transformA.width, numSplats]
|
||||
});
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, resources.renderTarget, resources.shader);
|
||||
|
||||
const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, {
|
||||
renderTarget: resources.renderTarget,
|
||||
data: resources.data,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
return data as Float32Array;
|
||||
}
|
||||
}
|
||||
|
||||
export { CalcPositions };
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
PRIMITIVE_POINTS,
|
||||
SEMANTIC_POSITION,
|
||||
TYPE_FLOAT32,
|
||||
BlendState,
|
||||
DepthState,
|
||||
GraphicsDevice,
|
||||
RenderTarget,
|
||||
Shader,
|
||||
VertexBuffer,
|
||||
VertexFormat
|
||||
} from 'playcanvas';
|
||||
|
||||
let cachedDevice: GraphicsDevice = null;
|
||||
let cachedVB: VertexBuffer = null;
|
||||
|
||||
const getInstancingVB = (device: GraphicsDevice) => {
|
||||
if (cachedVB && cachedDevice === device) {
|
||||
return cachedVB;
|
||||
}
|
||||
const format = new VertexFormat(device, [
|
||||
{ semantic: SEMANTIC_POSITION, components: 1, type: TYPE_FLOAT32 }
|
||||
]);
|
||||
(format as any).instancing = true;
|
||||
cachedVB = new VertexBuffer(device, format, 1);
|
||||
cachedVB.lock();
|
||||
cachedVB.unlock();
|
||||
cachedDevice = device;
|
||||
return cachedVB;
|
||||
};
|
||||
|
||||
const drawPointsWithShader = (
|
||||
device: GraphicsDevice,
|
||||
target: RenderTarget,
|
||||
shader: Shader,
|
||||
count: number,
|
||||
blendState: BlendState
|
||||
) => {
|
||||
const vb = getInstancingVB(device);
|
||||
const d = device as any;
|
||||
|
||||
const oldRt = d.renderTarget;
|
||||
const oldVx = d.vx, oldVy = d.vy, oldVw = d.vw, oldVh = d.vh;
|
||||
const oldSx = d.sx, oldSy = d.sy, oldSw = d.sw, oldSh = d.sh;
|
||||
|
||||
d.setRenderTarget(target);
|
||||
d.updateBegin();
|
||||
|
||||
const w = target ? target.width : d.width;
|
||||
const h = target ? target.height : d.height;
|
||||
d.setViewport(0, 0, w, h);
|
||||
d.setScissor(0, 0, w, h);
|
||||
|
||||
d.setBlendState(blendState);
|
||||
d.setDepthState(DepthState.NODEPTH);
|
||||
d.setVertexBuffer(vb);
|
||||
d.setShader(shader);
|
||||
|
||||
d.draw({
|
||||
type: PRIMITIVE_POINTS,
|
||||
base: 0,
|
||||
count,
|
||||
indexed: false
|
||||
});
|
||||
|
||||
d.updateEnd();
|
||||
d.setRenderTarget(oldRt);
|
||||
d.setViewport(oldVx, oldVy, oldVw, oldVh);
|
||||
d.setScissor(oldSx, oldSy, oldSw, oldSh);
|
||||
};
|
||||
|
||||
export { drawPointsWithShader };
|
||||
@@ -0,0 +1,25 @@
|
||||
// shared sizing constants for histogram and per-splat-mask GPU passes.
|
||||
// keeping them in one place stops calc-histogram, select-by-range, intersect,
|
||||
// and the histogram shaders from drifting independently.
|
||||
|
||||
// histogram tile reduction grid: tile shader writes a GRID_DIM x GRID_DIM
|
||||
// texture, the reduce shader collapses it to 1x1. the histogram shaders
|
||||
// hardcode MAX_GRID_DIM=64 as the unrolled loop bound; if GRID_DIM ever
|
||||
// exceeds 64 the shader bound must be raised alongside it.
|
||||
const GRID_DIM = 64;
|
||||
|
||||
// default bin count for histogram results when the caller does not override.
|
||||
const NUM_BINS = 256;
|
||||
|
||||
// per-splat mask packing: each RGBA8 output texel carries 4 splats. width is
|
||||
// derived from the source transformA texture width via this helper so all
|
||||
// callers (Intersect, SelectByRange) agree on layout.
|
||||
const packedMaskWidth = (sourceWidth: number): number => {
|
||||
return Math.max(1, Math.floor(sourceWidth / 2));
|
||||
};
|
||||
|
||||
const packedMaskHeight = (packedWidth: number, numSplats: number): number => {
|
||||
return Math.ceil(numSplats / (packedWidth * 4));
|
||||
};
|
||||
|
||||
export { GRID_DIM, NUM_BINS, packedMaskWidth, packedMaskHeight };
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
BoundingBox,
|
||||
GraphicsDevice,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
BlendState
|
||||
} from 'playcanvas';
|
||||
|
||||
import { BufferPool } from './buffer-pool';
|
||||
import { CalcBound } from './calc-bound';
|
||||
import { CalcHistogram, CalcHistogramOptions } from './calc-histogram';
|
||||
import { CalcPositions } from './calc-positions';
|
||||
import { Intersect, IntersectOptions } from './intersect';
|
||||
import { SelectByRange, SelectByRangeOptions } from './select-by-range';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
// gpu processor for splat data. methods are plain (no internal serialisation):
|
||||
// callers that need ordering relative to other async work must run them inside
|
||||
// a shared CommandQueue task. see src/command-queue.ts.
|
||||
class DataProcessor {
|
||||
private device: GraphicsDevice;
|
||||
private copyShader: Shader;
|
||||
|
||||
// shared pool of readback buffers used by GPU passes that hand bytes back
|
||||
// to the caller. Callers receive ownership and must call releaseMask() when
|
||||
// done.
|
||||
private bufferPool = new BufferPool();
|
||||
|
||||
// instances
|
||||
private intersectImpl: Intersect;
|
||||
private calcBoundImpl: CalcBound;
|
||||
private calcPositionsImpl: CalcPositions;
|
||||
private calcHistogramImpl: CalcHistogram;
|
||||
private selectByRangeImpl: SelectByRange;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
|
||||
this.copyShader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'copyShader',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentGLSL: `
|
||||
uniform sampler2D colorTex;
|
||||
void main(void) {
|
||||
ivec2 texel = ivec2(gl_FragCoord.xy);
|
||||
gl_FragColor = texelFetch(colorTex, texel, 0);
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
// create instances
|
||||
this.intersectImpl = new Intersect(device);
|
||||
this.calcBoundImpl = new CalcBound(device);
|
||||
this.calcPositionsImpl = new CalcPositions(device);
|
||||
this.calcHistogramImpl = new CalcHistogram(device);
|
||||
this.selectByRangeImpl = new SelectByRange(device);
|
||||
}
|
||||
|
||||
// calculate the intersection of a mask canvas with splat centers.
|
||||
// returns an owned mask buffer the caller must release via releaseMask().
|
||||
intersect(options: IntersectOptions, splat: Splat) {
|
||||
return this.intersectImpl.run(options, splat, this.bufferPool);
|
||||
}
|
||||
|
||||
// use gpu to calculate both selected and visible bounds in a single pass
|
||||
calcBound(splat: Splat, selectionBound: BoundingBox, localBound: BoundingBox): Promise<void> {
|
||||
return this.calcBoundImpl.run(splat, selectionBound, localBound);
|
||||
}
|
||||
|
||||
// calculate world-space splat positions
|
||||
calcPositions(splat: Splat) {
|
||||
return this.calcPositionsImpl.run(splat);
|
||||
}
|
||||
|
||||
// calculate histogram (bin counts + min/max) entirely on GPU
|
||||
calcHistogram(splat: Splat, mode: number, options?: CalcHistogramOptions) {
|
||||
return this.calcHistogramImpl.run(splat, mode, options);
|
||||
}
|
||||
|
||||
// compute a per-splat byte mask (255 = in range and visible, 0 = not) for
|
||||
// the given histogram bucket range. mode matches the propMode dispatch in
|
||||
// src/shaders/splat-value-shader.ts (0..20 = built-in props, 21+N = f_rest_N).
|
||||
// returns an owned mask buffer the caller must release via releaseMask().
|
||||
selectByRange(splat: Splat, mode: number, options: SelectByRangeOptions) {
|
||||
return this.selectByRangeImpl.run(splat, mode, options, this.bufferPool);
|
||||
}
|
||||
|
||||
// release a mask buffer returned by intersect() or selectByRange() back to
|
||||
// the pool so subsequent calls can reuse it without re-allocating.
|
||||
releaseMask(mask: Uint8Array) {
|
||||
this.bufferPool.release(mask);
|
||||
}
|
||||
|
||||
copyRt(source: RenderTarget, dest: RenderTarget) {
|
||||
const { device } = this;
|
||||
|
||||
resolve(device.scope, {
|
||||
colorTex: source.colorBuffer
|
||||
});
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, dest, this.copyShader);
|
||||
}
|
||||
}
|
||||
|
||||
export { DataProcessor };
|
||||
export type { IntersectOptions, CalcHistogramOptions, SelectByRangeOptions };
|
||||
export { MaskOptions, RectOptions, SphereOptions, BoxOptions } from './intersect';
|
||||
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
PIXELFORMAT_RGBA8,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
GraphicsDevice,
|
||||
Mat4,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture,
|
||||
BlendState
|
||||
} from 'playcanvas';
|
||||
|
||||
import { BufferPool } from './buffer-pool';
|
||||
import { packedMaskHeight, packedMaskWidth } from './histogram-config';
|
||||
import { vertexShader, fragmentShader } from '../shaders/intersection-shader';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
type MaskOptions = {
|
||||
mask: Texture;
|
||||
};
|
||||
|
||||
type RectOptions = {
|
||||
rect: { x1: number, y1: number, x2: number, y2: number };
|
||||
};
|
||||
|
||||
type SphereOptions = {
|
||||
sphere: { x: number, y: number, z: number, radius: number };
|
||||
};
|
||||
|
||||
type BoxOptions = {
|
||||
box: { x: number, y: number, z: number, lenx: number, leny: number, lenz: number };
|
||||
};
|
||||
|
||||
type IntersectOptions = MaskOptions | RectOptions | SphereOptions | BoxOptions;
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
class Intersect {
|
||||
private device: GraphicsDevice;
|
||||
private dummyTexture: Texture;
|
||||
private viewProjectionMat = new Mat4();
|
||||
private shader: Shader = null;
|
||||
private texture: Texture = null;
|
||||
private renderTarget: RenderTarget = null;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
this.dummyTexture = new Texture(device, {
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: PIXELFORMAT_RGBA8
|
||||
});
|
||||
}
|
||||
|
||||
private getResources(width: number, numSplats: number) {
|
||||
const { device } = this;
|
||||
|
||||
if (!this.shader) {
|
||||
this.shader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'intersectByMaskShader',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
}
|
||||
|
||||
const resultWidth = packedMaskWidth(width);
|
||||
const resultHeight = packedMaskHeight(resultWidth, numSplats);
|
||||
|
||||
if (!this.texture || this.texture.width !== resultWidth || this.texture.height !== resultHeight) {
|
||||
if (this.texture) {
|
||||
this.texture.destroy();
|
||||
this.renderTarget.destroy();
|
||||
}
|
||||
|
||||
this.texture = new Texture(device, {
|
||||
name: 'intersectTexture',
|
||||
width: resultWidth,
|
||||
height: resultHeight,
|
||||
format: PIXELFORMAT_RGBA8,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
|
||||
this.renderTarget = new RenderTarget({
|
||||
colorBuffer: this.texture,
|
||||
depth: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
shader: this.shader,
|
||||
texture: this.texture,
|
||||
renderTarget: this.renderTarget
|
||||
};
|
||||
}
|
||||
|
||||
async run(options: IntersectOptions, splat: Splat, bufferPool: BufferPool): Promise<Uint8Array> {
|
||||
const { device } = this;
|
||||
const { scope } = device;
|
||||
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const transformA = (splat.entity.gsplat.instance.resource as any).getTexture('transformA');
|
||||
const splatTransform = splat.transformTexture;
|
||||
const transformPalette = splat.transformPalette.texture;
|
||||
|
||||
// update view projection matrix
|
||||
const camera = splat.scene.camera.camera;
|
||||
this.viewProjectionMat.mul2(camera.projectionMatrix, camera.viewMatrix);
|
||||
|
||||
// allocate resources
|
||||
const resources = this.getResources(transformA.width, numSplats);
|
||||
|
||||
resolve(scope, {
|
||||
transformA,
|
||||
splatTransform,
|
||||
transformPalette,
|
||||
splat_params: [transformA.width, numSplats],
|
||||
matrix_model: splat.entity.getWorldTransform().data,
|
||||
matrix_viewProjection: this.viewProjectionMat.data,
|
||||
output_params: [resources.texture.width, resources.texture.height]
|
||||
});
|
||||
|
||||
const maskOptions = options as MaskOptions;
|
||||
|
||||
if (maskOptions.mask) {
|
||||
resolve(scope, {
|
||||
mode: 0,
|
||||
mask: maskOptions.mask,
|
||||
mask_params: [maskOptions.mask.width, maskOptions.mask.height]
|
||||
});
|
||||
} else {
|
||||
resolve(scope, {
|
||||
mask: this.dummyTexture,
|
||||
mask_params: [0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
const rectOptions = options as RectOptions;
|
||||
if (rectOptions.rect) {
|
||||
resolve(scope, {
|
||||
mode: 1,
|
||||
rect_params: [
|
||||
rectOptions.rect.x1 * 2.0 - 1.0,
|
||||
rectOptions.rect.y1 * 2.0 - 1.0,
|
||||
rectOptions.rect.x2 * 2.0 - 1.0,
|
||||
rectOptions.rect.y2 * 2.0 - 1.0
|
||||
]
|
||||
});
|
||||
} else {
|
||||
resolve(scope, {
|
||||
rect_params: [0, 0, 0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
const sphereOptions = options as SphereOptions;
|
||||
if (sphereOptions.sphere) {
|
||||
resolve(scope, {
|
||||
mode: 2,
|
||||
sphere_params: [
|
||||
sphereOptions.sphere.x,
|
||||
sphereOptions.sphere.y,
|
||||
sphereOptions.sphere.z,
|
||||
sphereOptions.sphere.radius
|
||||
]
|
||||
});
|
||||
} else {
|
||||
resolve(scope, {
|
||||
sphere_params: [0, 0, 0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
const boxOptions = options as BoxOptions;
|
||||
if (boxOptions.box) {
|
||||
resolve(scope, {
|
||||
mode: 3,
|
||||
box_params: [
|
||||
boxOptions.box.x,
|
||||
boxOptions.box.y,
|
||||
boxOptions.box.z,
|
||||
0
|
||||
],
|
||||
aabb_params: [
|
||||
boxOptions.box.lenx * 0.5,
|
||||
boxOptions.box.leny * 0.5,
|
||||
boxOptions.box.lenz * 0.5,
|
||||
0
|
||||
]
|
||||
});
|
||||
} else {
|
||||
resolve(scope, {
|
||||
box_params: [0, 0, 0, 0],
|
||||
aabb_params: [0, 0, 0, 0]
|
||||
});
|
||||
}
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, resources.renderTarget, resources.shader);
|
||||
|
||||
const byteLen = resources.texture.width * resources.texture.height * 4;
|
||||
const buffer = bufferPool.acquire(byteLen);
|
||||
|
||||
const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, {
|
||||
renderTarget: resources.renderTarget,
|
||||
data: buffer,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
return data as Uint8Array;
|
||||
}
|
||||
}
|
||||
|
||||
export { Intersect, IntersectOptions, MaskOptions, RectOptions, SphereOptions, BoxOptions };
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
PIXELFORMAT_RGBA8,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
BlendState,
|
||||
GraphicsDevice,
|
||||
Mat4,
|
||||
RenderTarget,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { BufferPool } from './buffer-pool';
|
||||
import { packedMaskHeight, packedMaskWidth } from './histogram-config';
|
||||
import { vertexShader, fragmentShader } from '../shaders/select-by-range-shader';
|
||||
import { Splat } from '../splat';
|
||||
|
||||
const identity = new Mat4();
|
||||
const zeroVec3 = new Vec3();
|
||||
|
||||
// number of SH coefficients per RGB band, indexed by GSplatResource.shBands.
|
||||
const SH_NUM_COEFFS: { [k: number]: number } = { 0: 0, 1: 3, 2: 8, 3: 15 };
|
||||
|
||||
type SelectByRangeOptions = {
|
||||
min: number;
|
||||
max: number;
|
||||
numBins: number;
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
entityMatrix?: Mat4;
|
||||
viewMatrix?: Mat4;
|
||||
viewProjection?: Mat4;
|
||||
cameraPos?: Vec3;
|
||||
onScreenOnly?: boolean;
|
||||
};
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
const getShBands = (splat: Splat): number => {
|
||||
return (splat.entity.gsplat.instance.resource as any).shBands ?? 0;
|
||||
};
|
||||
|
||||
// GPU pass that produces a 1-byte-per-splat selection mask for a given
|
||||
// histogram bucket range. mirrors the packing scheme used by Intersect so the
|
||||
// CPU readback is `numSplats` bytes (with up to 3 bytes of padding at the
|
||||
// end), addressable directly as `mask[splatIdx]`.
|
||||
class SelectByRange {
|
||||
private device: GraphicsDevice;
|
||||
|
||||
// shaders compiled per SH_BANDS, same pattern as CalcHistogram.
|
||||
private shaders: Map<number, Shader> = new Map();
|
||||
private texture: Texture = null;
|
||||
private renderTarget: RenderTarget = null;
|
||||
|
||||
constructor(device: GraphicsDevice) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
private getShader(shBands: number): Shader {
|
||||
let shader = this.shaders.get(shBands);
|
||||
if (!shader) {
|
||||
const defines = new Map<string, string>();
|
||||
defines.set('SH_BANDS', `${shBands}`);
|
||||
shader = ShaderUtils.createShader(this.device, {
|
||||
uniqueName: `selectByRangeShader_SH${shBands}`,
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader,
|
||||
fragmentDefines: defines
|
||||
});
|
||||
this.shaders.set(shBands, shader);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
private getResources(width: number, numSplats: number) {
|
||||
// pack 4 splats per RGBA8 texel; layout shared with Intersect via histogram-config.
|
||||
const resultWidth = packedMaskWidth(width);
|
||||
const resultHeight = packedMaskHeight(resultWidth, numSplats);
|
||||
|
||||
if (!this.texture || this.texture.width !== resultWidth || this.texture.height !== resultHeight) {
|
||||
if (this.texture) {
|
||||
this.texture.destroy();
|
||||
this.renderTarget.destroy();
|
||||
}
|
||||
|
||||
this.texture = new Texture(this.device, {
|
||||
name: 'selectByRangeTexture',
|
||||
width: resultWidth,
|
||||
height: resultHeight,
|
||||
format: PIXELFORMAT_RGBA8,
|
||||
mipmaps: false,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
|
||||
this.renderTarget = new RenderTarget({
|
||||
colorBuffer: this.texture,
|
||||
depth: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
texture: this.texture,
|
||||
renderTarget: this.renderTarget
|
||||
};
|
||||
}
|
||||
|
||||
async run(splat: Splat, mode: number, options: SelectByRangeOptions, bufferPool: BufferPool): Promise<Uint8Array> {
|
||||
const { device } = this;
|
||||
const { scope } = device;
|
||||
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const resource = splat.entity.gsplat.instance.resource as any;
|
||||
const transformA = resource.getTexture('transformA');
|
||||
const transformB = resource.getTexture('transformB');
|
||||
const splatColor = resource.getTexture('splatColor');
|
||||
const splatTransform = splat.transformTexture;
|
||||
const transformPalette = splat.transformPalette.texture;
|
||||
const splatState = splat.stateTexture;
|
||||
|
||||
const shBands = getShBands(splat);
|
||||
const numCoeffs = SH_NUM_COEFFS[shBands] ?? 0;
|
||||
|
||||
const resources = this.getResources(transformA.width, numSplats);
|
||||
const shader = this.getShader(shBands);
|
||||
|
||||
const entityMatrix = options.entityMatrix ?? identity;
|
||||
const viewMatrix = options.viewMatrix ?? identity;
|
||||
const viewProjection = options.viewProjection ?? identity;
|
||||
const cameraPos = options.cameraPos ?? zeroVec3;
|
||||
const onScreenOnly = options.onScreenOnly ? 1 : 0;
|
||||
|
||||
// ColorGrade math, kept in sync with ColorGrade in src/color-grade.ts.
|
||||
const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = splat;
|
||||
const cgInvRange = 1 / (whitePoint - blackPoint);
|
||||
|
||||
const values: any = {
|
||||
transformA,
|
||||
transformB,
|
||||
splatColor,
|
||||
splatTransform,
|
||||
transformPalette,
|
||||
splatState,
|
||||
splat_params: [transformA.width, numSplats],
|
||||
propMode: mode,
|
||||
entityMatrix: entityMatrix.data,
|
||||
viewMatrix: viewMatrix.data,
|
||||
viewProjection: viewProjection.data,
|
||||
cameraWorldPos: [cameraPos.x, cameraPos.y, cameraPos.z],
|
||||
onScreenOnly,
|
||||
cgScale: [
|
||||
cgInvRange * tintClr.r * (1 + temperature),
|
||||
cgInvRange * tintClr.g,
|
||||
cgInvRange * tintClr.b * (1 - temperature)
|
||||
],
|
||||
cgOffset: -blackPoint + brightness,
|
||||
cgSaturation: saturation,
|
||||
transparency,
|
||||
output_params: [resources.texture.width, resources.texture.height],
|
||||
minMax: [options.min, options.max],
|
||||
numBins: options.numBins,
|
||||
rangeStart: options.rangeStart,
|
||||
rangeEnd: options.rangeEnd
|
||||
};
|
||||
|
||||
if (shBands > 0) {
|
||||
values.splatSH_1to3 = resource.getTexture('splatSH_1to3');
|
||||
values.shNumCoeffs = numCoeffs;
|
||||
}
|
||||
if (shBands > 1) {
|
||||
values.splatSH_4to7 = resource.getTexture('splatSH_4to7');
|
||||
values.splatSH_8to11 = resource.getTexture('splatSH_8to11');
|
||||
}
|
||||
if (shBands > 2) {
|
||||
values.splatSH_12to15 = resource.getTexture('splatSH_12to15');
|
||||
}
|
||||
|
||||
resolve(scope, values);
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, resources.renderTarget, shader);
|
||||
|
||||
const byteLen = resources.texture.width * resources.texture.height * 4;
|
||||
const buffer = bufferPool.acquire(byteLen);
|
||||
|
||||
const data = await resources.texture.read(0, 0, resources.texture.width, resources.texture.height, {
|
||||
renderTarget: resources.renderTarget,
|
||||
data: buffer,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
return data as Uint8Array;
|
||||
}
|
||||
}
|
||||
|
||||
export { SelectByRange };
|
||||
export type { SelectByRangeOptions };
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
import { ZipFileSystem, ZipReadFileSystem } from '@playcanvas/splat-transform';
|
||||
|
||||
import { Events } from './events';
|
||||
import { BrowserFileSystem, BlobReadSource } from './io';
|
||||
import { recentFiles } from './recent-files';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
import { serializePly } from './splat-serialize';
|
||||
import { Transform } from './transform';
|
||||
import { i18n } from './ui/localization';
|
||||
|
||||
// ts compiler and vscode find this type, but eslint does not
|
||||
type FilePickerAcceptType = unknown;
|
||||
|
||||
const SuperFileType: FilePickerAcceptType[] = [{
|
||||
description: 'SuperSplat document',
|
||||
accept: {
|
||||
'application/x-supersplat': ['.ssproj']
|
||||
}
|
||||
}];
|
||||
|
||||
type FileSelectorCallback = (fileList: File) => void;
|
||||
|
||||
// helper class to show a file selector dialog.
|
||||
// used when showOpenFilePicker is not available.
|
||||
class FileSelector {
|
||||
show: (callbackFunc: FileSelectorCallback) => void;
|
||||
|
||||
constructor() {
|
||||
const fileSelector = document.createElement('input');
|
||||
fileSelector.setAttribute('id', 'document-file-selector');
|
||||
fileSelector.setAttribute('type', 'file');
|
||||
fileSelector.setAttribute('accept', '.ssproj');
|
||||
fileSelector.setAttribute('multiple', 'false');
|
||||
|
||||
document.body.append(fileSelector);
|
||||
|
||||
let callbackFunc: FileSelectorCallback = null;
|
||||
|
||||
fileSelector.addEventListener('change', () => {
|
||||
callbackFunc(fileSelector.files[0]);
|
||||
});
|
||||
|
||||
fileSelector.addEventListener('cancel', () => {
|
||||
callbackFunc(null);
|
||||
});
|
||||
|
||||
this.show = (func: FileSelectorCallback) => {
|
||||
callbackFunc = func;
|
||||
fileSelector.click();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const registerDocEvents = (scene: Scene, events: Events) => {
|
||||
// construct the file selector
|
||||
const fileSelector = window.showOpenFilePicker ? null : new FileSelector();
|
||||
|
||||
// this file handle is updated as the current document is loaded and saved
|
||||
let documentFileHandle: FileSystemFileHandle = null;
|
||||
|
||||
// show the user a reset confirmation popup
|
||||
const getResetConfirmation = async () => {
|
||||
const result = await events.invoke('showPopup', {
|
||||
type: 'yesno',
|
||||
header: i18n.t('doc.reset'),
|
||||
message: i18n.t(events.invoke('scene.dirty') ? 'doc.unsaved-message' : 'doc.reset-message')
|
||||
});
|
||||
|
||||
if (result.action !== 'yes') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// reset the scene
|
||||
const resetScene = () => {
|
||||
events.fire('scene.clear');
|
||||
events.fire('camera.reset');
|
||||
events.fire('doc.setName', null);
|
||||
documentFileHandle = null;
|
||||
};
|
||||
|
||||
// load the document from the given file
|
||||
const loadDocument = async (file: File) => {
|
||||
events.fire('startSpinner');
|
||||
|
||||
// Create streaming ZIP reader from the file
|
||||
const blobSource = new BlobReadSource(file);
|
||||
const zipFs = new ZipReadFileSystem(blobSource);
|
||||
|
||||
try {
|
||||
// the document's view settings are applied through the same events
|
||||
// as user changes - suspend preference capture so they don't
|
||||
// overwrite the user's stored preferences. resumed in the finally
|
||||
// below so a failed load can't leave capture suspended.
|
||||
events.fire('preferences.suspend');
|
||||
|
||||
// reset the scene
|
||||
resetScene();
|
||||
|
||||
// read document.json via streaming (only reads what's needed)
|
||||
const docSource = await zipFs.createSource('document.json');
|
||||
const docData = await docSource.read().readAll();
|
||||
docSource.close();
|
||||
const document = JSON.parse(new TextDecoder().decode(docData));
|
||||
|
||||
// run through each splat and load it
|
||||
for (let i = 0; i < document.splats.length; ++i) {
|
||||
const filename = `splat_${i}.ply`;
|
||||
const splatSettings = document.splats[i];
|
||||
|
||||
// load splat directly from the zip filesystem (streams on-demand)
|
||||
// skipReorder=true because ssproj PLY files are already in morton order
|
||||
const splat = await scene.assetLoader.load(filename, zipFs, false, true);
|
||||
|
||||
await scene.add(splat);
|
||||
|
||||
splat.docDeserialize(splatSettings);
|
||||
}
|
||||
|
||||
// FIXME: trigger scene bound calc in a better way
|
||||
const tmp = scene.bound;
|
||||
if (tmp === null) {
|
||||
console.error('this should never fire');
|
||||
}
|
||||
|
||||
events.invoke('docDeserialize.timeline', document.timeline);
|
||||
events.invoke('docDeserialize.poseSets', document.poseSets, document.camera?.fov);
|
||||
events.invoke('docDeserialize.view', document.view);
|
||||
scene.camera.docDeserialize(document.camera);
|
||||
|
||||
// refresh the pivot to reflect the loaded transform
|
||||
const currentSelection = events.invoke('selection');
|
||||
if (currentSelection) {
|
||||
const pivot = events.invoke('pivot');
|
||||
const transform = new Transform();
|
||||
const pivotOrigin = events.invoke('pivot.origin');
|
||||
currentSelection.getPivot(pivotOrigin, false, transform);
|
||||
pivot.place(transform);
|
||||
}
|
||||
} catch (error) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('doc.load-failed'),
|
||||
message: `'${error.message ?? error}'`
|
||||
});
|
||||
} finally {
|
||||
// fire events before cleanup so a throwing close can't leave
|
||||
// preference capture suspended or the spinner running
|
||||
events.fire('preferences.resume');
|
||||
events.fire('stopSpinner');
|
||||
|
||||
// Clean up resources
|
||||
zipFs.close();
|
||||
}
|
||||
};
|
||||
|
||||
const saveDocument = async (options: { stream?: FileSystemWritableFileStream, filename?: string }) => {
|
||||
events.fire('startSpinner');
|
||||
|
||||
try {
|
||||
const splats = events.invoke('scene.allSplats') as Splat[];
|
||||
|
||||
const document = {
|
||||
version: 0,
|
||||
camera: scene.camera.docSerialize(),
|
||||
view: events.invoke('docSerialize.view'),
|
||||
poseSets: events.invoke('docSerialize.poseSets'),
|
||||
timeline: events.invoke('docSerialize.timeline'),
|
||||
splats: splats.map(s => s.docSerialize())
|
||||
};
|
||||
|
||||
const serializeSettings = {
|
||||
// even though we support saving selection state, we disable that for now
|
||||
// because including a uint8 array in the document PLY results in slow loading
|
||||
// path.
|
||||
keepStateData: false,
|
||||
keepWorldTransform: true,
|
||||
keepColorTint: true
|
||||
};
|
||||
|
||||
// Create browser filesystem and zip filesystem
|
||||
const browserFs = new BrowserFileSystem(options.filename, options.stream);
|
||||
const browserWriter = await browserFs.createWriter(options.filename);
|
||||
const zipFs = new ZipFileSystem(browserWriter);
|
||||
|
||||
// Write document.json
|
||||
const docWriter = await zipFs.createWriter('document.json');
|
||||
await docWriter.write(new TextEncoder().encode(JSON.stringify(document)));
|
||||
await docWriter.close();
|
||||
|
||||
// Write each splat as PLY
|
||||
for (let i = 0; i < splats.length; ++i) {
|
||||
await serializePly([splats[i]], serializeSettings, zipFs, `splat_${i}.ply`);
|
||||
}
|
||||
|
||||
// Close zip (also closes underlying browser writer)
|
||||
await zipFs.close();
|
||||
} catch (error) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('doc.save-failed'),
|
||||
message: `'${error.message ?? error}'`
|
||||
});
|
||||
} finally {
|
||||
events.fire('stopSpinner');
|
||||
}
|
||||
};
|
||||
|
||||
// handle user requesting a new document
|
||||
events.function('doc.new', async () => {
|
||||
if (!await getResetConfirmation()) {
|
||||
return false;
|
||||
}
|
||||
resetScene();
|
||||
// new documents start from the user's stored preferences rather than
|
||||
// whatever view state the previous document left behind
|
||||
events.fire('preferences.apply');
|
||||
return true;
|
||||
});
|
||||
|
||||
// handle document file being dropped
|
||||
// NOTE: on chrome it's possible to get the FileSystemFileHandle from the DataTransferItem
|
||||
// (which would result in more seamless user experience), but this is not yet supported in
|
||||
// other browsers.
|
||||
events.function('doc.load', async (file: File, handle?: FileSystemFileHandle) => {
|
||||
if (!events.invoke('scene.empty') && !await getResetConfirmation()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await loadDocument(file);
|
||||
|
||||
events.fire('doc.setName', file.name);
|
||||
|
||||
if (handle) {
|
||||
documentFileHandle = handle;
|
||||
recentFiles.add(handle);
|
||||
}
|
||||
});
|
||||
|
||||
events.function('doc.open', async () => {
|
||||
if (!events.invoke('scene.empty') && !await getResetConfirmation()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileSelector) {
|
||||
fileSelector.show(async (file?: File) => {
|
||||
if (file) {
|
||||
await loadDocument(file);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const fileHandles = await window.showOpenFilePicker({
|
||||
id: 'SuperSplatDocumentOpen',
|
||||
multiple: false,
|
||||
types: SuperFileType
|
||||
});
|
||||
|
||||
if (fileHandles?.length === 1) {
|
||||
const fileHandle = fileHandles[0];
|
||||
|
||||
// null file handle incase loadDocument fails
|
||||
await loadDocument(await fileHandle.getFile());
|
||||
|
||||
// store file handle for subsequent saves
|
||||
documentFileHandle = fileHandle;
|
||||
events.fire('doc.setName', fileHandle.name);
|
||||
recentFiles.add(fileHandle);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
events.function('doc.openRecent', async (fileHandle: FileSystemFileHandle) => {
|
||||
if (!events.invoke('scene.empty') && !await getResetConfirmation()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await fileHandle.queryPermission({ mode: 'read' }) !== 'granted') {
|
||||
if (await fileHandle.requestPermission({ mode: 'read' }) !== 'granted') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await loadDocument(await fileHandle.getFile());
|
||||
|
||||
// store file handle for subsequent saves
|
||||
documentFileHandle = fileHandle;
|
||||
events.fire('doc.setName', fileHandle.name);
|
||||
recentFiles.add(fileHandle);
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.error-loading'),
|
||||
message: `${error.message ?? error}`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
events.function('doc.save', async () => {
|
||||
if (documentFileHandle) {
|
||||
try {
|
||||
await saveDocument({
|
||||
stream: await documentFileHandle.createWritable()
|
||||
});
|
||||
events.fire('doc.saved');
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError' && error.name !== 'NotAllowedError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await events.invoke('doc.saveAs');
|
||||
}
|
||||
});
|
||||
|
||||
events.function('doc.saveAs', async () => {
|
||||
if (window.showSaveFilePicker) {
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
id: 'SuperSplatDocumentSave',
|
||||
types: SuperFileType,
|
||||
suggestedName: 'scene.ssproj'
|
||||
});
|
||||
await saveDocument({ stream: await handle.createWritable() });
|
||||
documentFileHandle = handle;
|
||||
events.fire('doc.setName', handle.name);
|
||||
events.fire('doc.saved');
|
||||
recentFiles.add(handle);
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await saveDocument({
|
||||
filename: 'scene.ssproj'
|
||||
});
|
||||
events.fire('doc.saved');
|
||||
}
|
||||
});
|
||||
|
||||
// doc name
|
||||
|
||||
let docName: string = null;
|
||||
|
||||
const setDocName = (name: string) => {
|
||||
if (name !== docName) {
|
||||
docName = name;
|
||||
events.fire('doc.name', docName);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('doc.name', () => {
|
||||
return docName;
|
||||
});
|
||||
|
||||
events.on('doc.setName', (name) => {
|
||||
setDocName(name);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerDocEvents };
|
||||
@@ -0,0 +1,145 @@
|
||||
import { path } from 'playcanvas';
|
||||
|
||||
class DroppedFile {
|
||||
filename: string;
|
||||
file: File;
|
||||
handle?: FileSystemFileHandle;
|
||||
|
||||
constructor(filename: string, file: File, handle?: FileSystemFileHandle) {
|
||||
this.filename = filename;
|
||||
this.file = file;
|
||||
this.handle = handle;
|
||||
}
|
||||
}
|
||||
|
||||
type DropHandlerFunc = (files: Array<DroppedFile>, resetScene: boolean) => void;
|
||||
|
||||
const resolveDirectories = (entries: Array<FileSystemEntry>): Promise<Array<FileSystemFileEntry>> => {
|
||||
const promises: Promise<Array<FileSystemFileEntry>>[] = [];
|
||||
const result: Array<FileSystemFileEntry> = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isFile) {
|
||||
result.push(entry as FileSystemFileEntry);
|
||||
} else if (entry.isDirectory) {
|
||||
promises.push(
|
||||
new Promise<any>((resolve, reject) => {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
|
||||
const p: Promise<any>[] = [];
|
||||
|
||||
const read = () => {
|
||||
reader.readEntries((children: Array<FileSystemEntry>) => {
|
||||
if (children.length > 0) {
|
||||
p.push(resolveDirectories(children));
|
||||
read();
|
||||
} else {
|
||||
Promise.all(p).then((children: Array<Array<FileSystemFileEntry>>) => {
|
||||
resolve(children.flat());
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
read();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(promises).then((children: Array<Array<FileSystemFileEntry>>) => {
|
||||
return result.concat(...children);
|
||||
});
|
||||
};
|
||||
|
||||
const removeCommonPrefix = (urls: Array<DroppedFile>) => {
|
||||
const split = (pathname: string) => {
|
||||
const parts = pathname.split(path.delimiter);
|
||||
const base = parts[0];
|
||||
const rest = parts.slice(1).join(path.delimiter);
|
||||
return [base, rest];
|
||||
};
|
||||
while (true) {
|
||||
const parts = split(urls[0].filename);
|
||||
if (parts[1].length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < urls.length; ++i) {
|
||||
const other = split(urls[i].filename);
|
||||
if (parts[0] !== other[0]) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < urls.length; ++i) {
|
||||
urls[i].filename = split(urls[i].filename)[1];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// configure drag and drop
|
||||
const CreateDropHandler = (target: HTMLElement, dropHandler: DropHandlerFunc) => {
|
||||
|
||||
const dragstart = (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.effectAllowed = 'all';
|
||||
};
|
||||
|
||||
const dragover = (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.effectAllowed = 'all';
|
||||
};
|
||||
|
||||
const drop = async (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
const items = Array.from(ev.dataTransfer.items);
|
||||
|
||||
// handle single file drops so documents can propagate the filesystemfilehandle
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
if (item.getAsFileSystemHandle && item.webkitGetAsEntry().isFile) {
|
||||
const handle = await item.getAsFileSystemHandle();
|
||||
if (handle?.kind === 'file') {
|
||||
const fileHandle = handle as FileSystemFileHandle;
|
||||
const file = await fileHandle.getFile();
|
||||
const droppedFile = new DroppedFile(file.name, file, fileHandle);
|
||||
dropHandler([droppedFile], ev.shiftKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map to entries first
|
||||
const entries = items
|
||||
.map(item => item.webkitGetAsEntry())
|
||||
.filter(v => v);
|
||||
|
||||
// resolve directories to files
|
||||
const resolvedEntries = await resolveDirectories(entries);
|
||||
|
||||
const files = await Promise.all(
|
||||
resolvedEntries.map((entry) => {
|
||||
return new Promise<DroppedFile>((resolve, reject) => {
|
||||
entry.file((entryFile: any) => {
|
||||
resolve(new DroppedFile(entry.fullPath.substring(1), entryFile));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (files.length > 1) {
|
||||
// if all files share a common filename prefix, remove it
|
||||
removeCommonPrefix(files);
|
||||
}
|
||||
|
||||
// finally, call the drop handler
|
||||
dropHandler(files, ev.shiftKey);
|
||||
};
|
||||
|
||||
target.addEventListener('dragstart', dragstart, true);
|
||||
target.addEventListener('dragover', dragover, true);
|
||||
target.addEventListener('drop', drop, true);
|
||||
};
|
||||
|
||||
export { CreateDropHandler };
|
||||
@@ -0,0 +1,143 @@
|
||||
import { CommandQueue } from './command-queue';
|
||||
import { EditOp, MultiOp } from './edit-ops';
|
||||
import { Events } from './events';
|
||||
import { Splat } from './splat';
|
||||
|
||||
// Check if an operation references a specific splat
|
||||
const opReferencesSplat = (op: EditOp, splat: Splat): boolean => {
|
||||
// Handle MultiOp by checking nested operations
|
||||
if (op instanceof MultiOp) {
|
||||
return op.ops.some(nestedOp => opReferencesSplat(nestedOp, splat));
|
||||
}
|
||||
// Check for splat property on the operation
|
||||
return (op as any).splat === splat;
|
||||
};
|
||||
|
||||
class EditHistory {
|
||||
history: EditOp[] = [];
|
||||
cursor = 0;
|
||||
events: Events;
|
||||
|
||||
// shared queue used to serialize every history mutation. the same physical
|
||||
// CommandQueue is shared with DataProcessor callers via scene.commandQueue
|
||||
// and the 'queue' event, so all async splat work applies in initiation order.
|
||||
private commandQueue: CommandQueue;
|
||||
|
||||
constructor(events: Events, commandQueue: CommandQueue) {
|
||||
this.events = events;
|
||||
this.commandQueue = commandQueue;
|
||||
|
||||
events.on('edit.undo', () => this.undo());
|
||||
events.on('edit.redo', () => this.redo());
|
||||
events.on('edit.add', (editOp: EditOp, suppressOp = false) => this.add(editOp, suppressOp));
|
||||
}
|
||||
|
||||
private queue<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
return this.commandQueue.enqueue(fn);
|
||||
}
|
||||
|
||||
add(editOp: EditOp, suppressOp = false) {
|
||||
return this.queue(() => this._add(editOp, suppressOp));
|
||||
}
|
||||
|
||||
canUndo() {
|
||||
return this.cursor > 0;
|
||||
}
|
||||
|
||||
canRedo() {
|
||||
return this.cursor < this.history.length;
|
||||
}
|
||||
|
||||
undo() {
|
||||
return this.queue(async () => {
|
||||
if (this.canUndo()) {
|
||||
await this._undo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
redo(suppressOp = false) {
|
||||
return this.queue(async () => {
|
||||
if (this.canRedo()) {
|
||||
await this._redo(suppressOp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _add(editOp: EditOp, suppressOp = false) {
|
||||
while (this.cursor < this.history.length) {
|
||||
this.history.pop().destroy?.();
|
||||
}
|
||||
this.history.push(editOp);
|
||||
await this._redo(suppressOp);
|
||||
}
|
||||
|
||||
private async _undo() {
|
||||
// only advance the cursor after a successful undo so a thrown editOp leaves
|
||||
// history in a consistent state for subsequent undo/redo.
|
||||
const editOp = this.history[this.cursor - 1];
|
||||
await editOp.undo();
|
||||
this.cursor--;
|
||||
this.events.fire('edit.apply', editOp);
|
||||
this.fireEvents();
|
||||
}
|
||||
|
||||
private async _redo(suppressOp = false) {
|
||||
// only advance the cursor after a successful redo so a thrown editOp leaves
|
||||
// history in a consistent state for subsequent undo/redo.
|
||||
const editOp = this.history[this.cursor];
|
||||
if (!suppressOp) {
|
||||
await editOp.do();
|
||||
}
|
||||
this.cursor++;
|
||||
this.events.fire('edit.apply', editOp);
|
||||
this.fireEvents();
|
||||
}
|
||||
|
||||
fireEvents() {
|
||||
this.events.fire('edit.canUndo', this.canUndo());
|
||||
this.events.fire('edit.canRedo', this.canRedo());
|
||||
}
|
||||
|
||||
clear() {
|
||||
// route through the queue so any in-flight add/undo/redo finishes before we wipe
|
||||
// history, preventing queued ops from running against a cleared state.
|
||||
return this.queue(() => {
|
||||
this.history.forEach((editOp) => {
|
||||
editOp.destroy?.();
|
||||
});
|
||||
this.history = [];
|
||||
this.cursor = 0;
|
||||
this.fireEvents();
|
||||
});
|
||||
}
|
||||
|
||||
// Remove all operations that reference a specific splat
|
||||
removeForSplat(splat: Splat) {
|
||||
// serialize with the queue so we don't reshape history while a queued op is mid-flight
|
||||
// (which could leave queued undo/redo pointing at indices that no longer exist).
|
||||
return this.queue(() => {
|
||||
let newCursor = 0;
|
||||
const newHistory: EditOp[] = [];
|
||||
|
||||
for (let i = 0; i < this.history.length; i++) {
|
||||
const op = this.history[i];
|
||||
// Skip ops referencing the splat; don't destroy them since the caller handles that
|
||||
if (!opReferencesSplat(op, splat)) {
|
||||
// Keep this operation
|
||||
newHistory.push(op);
|
||||
// Track cursor position (count kept operations before original cursor)
|
||||
if (i < this.cursor) {
|
||||
newCursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.history = newHistory;
|
||||
this.cursor = newCursor;
|
||||
this.fireEvents();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { EditHistory };
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
import { Color, Mat4 } from 'playcanvas';
|
||||
|
||||
import { AnimTrack } from './anim-track';
|
||||
import { IndexRanges, sortedPredicate } from './index-ranges';
|
||||
import { Pivot } from './pivot';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
import { State } from './splat-state';
|
||||
import { Transform } from './transform';
|
||||
|
||||
interface EditOp {
|
||||
name: string;
|
||||
do(): void | Promise<void>;
|
||||
undo(): void | Promise<void>;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
const enum BitOp {
|
||||
SET,
|
||||
CLEAR,
|
||||
TOGGLE
|
||||
}
|
||||
|
||||
class StateOp {
|
||||
splat: Splat;
|
||||
ranges: IndexRanges;
|
||||
mask: number;
|
||||
op: BitOp;
|
||||
updateFlags: number;
|
||||
|
||||
constructor(splat: Splat, ranges: IndexRanges, mask: number, op: BitOp, updateFlags = State.selected) {
|
||||
this.splat = splat;
|
||||
this.ranges = ranges;
|
||||
this.mask = mask;
|
||||
this.op = op;
|
||||
this.updateFlags = updateFlags;
|
||||
}
|
||||
|
||||
private apply(op: BitOp) {
|
||||
const { state } = this.splat;
|
||||
const { mask, ranges } = this;
|
||||
|
||||
switch (op) {
|
||||
case BitOp.SET:
|
||||
state.setBits(ranges, mask);
|
||||
break;
|
||||
case BitOp.CLEAR:
|
||||
state.clearBits(ranges, mask);
|
||||
break;
|
||||
case BitOp.TOGGLE:
|
||||
state.toggleBits(ranges, mask);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async do() {
|
||||
this.apply(this.op);
|
||||
await this.splat.updateState(this.updateFlags);
|
||||
}
|
||||
|
||||
async undo() {
|
||||
const undoOp = this.op === BitOp.TOGGLE ? BitOp.TOGGLE :
|
||||
this.op === BitOp.SET ? BitOp.CLEAR : BitOp.SET;
|
||||
this.apply(undoOp);
|
||||
await this.splat.updateState(this.updateFlags);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.splat = null;
|
||||
this.ranges = null;
|
||||
}
|
||||
}
|
||||
|
||||
class SelectAllOp extends StateOp {
|
||||
name = 'selectAll';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === 0), State.selected, BitOp.SET);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectNoneOp extends StateOp {
|
||||
name = 'selectNone';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.selected, BitOp.CLEAR);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectInvertOp extends StateOp {
|
||||
name = 'selectInvert';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & (State.locked | State.deleted)) === 0), State.selected, BitOp.TOGGLE);
|
||||
}
|
||||
}
|
||||
|
||||
class SelectOp extends StateOp {
|
||||
name = 'selectOp';
|
||||
|
||||
// `sel` is a committed snapshot of hits: either a per-splat mask
|
||||
// (Uint8Array, 255 = hit) or a sorted Uint32Array of indices. taking a
|
||||
// committed mask rather than a closure removes the foot-gun where a
|
||||
// predicate captured `state[i]` at call time and was evaluated later.
|
||||
// `op` semantics:
|
||||
// add — select valid splats that are hit and currently unselected
|
||||
// remove — deselect valid splats that are hit and currently selected
|
||||
// set — make selection match the hit mask (toggle valid splats whose
|
||||
// current selection state differs from the mask). NOT a replace —
|
||||
// the underlying BitOp is TOGGLE on the rows where selection and
|
||||
// hit disagree, which leaves locked/deleted bits untouched.
|
||||
// intersect — keep only splats currently selected AND in the hit mask
|
||||
// (clear the selected bit on selected splats that are not hit).
|
||||
constructor(splat: Splat, op: 'add' | 'remove' | 'set' | 'intersect', sel: Uint8Array | Uint32Array) {
|
||||
const splatData = splat.splatData;
|
||||
const state = splatData.getProp('state') as Uint8Array;
|
||||
const isHit = sel instanceof Uint32Array ? sortedPredicate(sel) : (i: number) => sel[i] === 255;
|
||||
|
||||
// single rule applied uniformly: only valid (clean or selected) splats
|
||||
// are considered. consolidates the locked/deleted guard in one place so
|
||||
// each producer doesn't have to remember it for the 'set' (toggle) path.
|
||||
const valid = (i: number) => state[i] === 0 || state[i] === State.selected;
|
||||
|
||||
// op → bit operation and op → predicate, kept as parallel lookups keyed
|
||||
// by the same union so adding an op forces both to be updated together.
|
||||
const bitOps = {
|
||||
add: BitOp.SET,
|
||||
remove: BitOp.CLEAR,
|
||||
set: BitOp.TOGGLE,
|
||||
intersect: BitOp.CLEAR
|
||||
};
|
||||
|
||||
const preds = {
|
||||
add: (i: number) => valid(i) && isHit(i) && state[i] === 0,
|
||||
remove: (i: number) => valid(i) && isHit(i) && state[i] === State.selected,
|
||||
set: (i: number) => valid(i) && ((state[i] === State.selected) !== isHit(i)),
|
||||
intersect: (i: number) => valid(i) && state[i] === State.selected && !isHit(i)
|
||||
};
|
||||
|
||||
super(splat, IndexRanges.fromPredicate(splatData.numSplats, preds[op]), State.selected, bitOps[op]);
|
||||
}
|
||||
}
|
||||
|
||||
class HideSelectionOp extends StateOp {
|
||||
name = 'hideSelection';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.locked, BitOp.SET, State.locked);
|
||||
}
|
||||
}
|
||||
|
||||
class UnhideAllOp extends StateOp {
|
||||
name = 'unhideAll';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & (State.locked | State.deleted)) === State.locked), State.locked, BitOp.CLEAR, State.locked);
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteSelectionOp extends StateOp {
|
||||
name = 'deleteSelection';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => state[i] === State.selected), State.deleted, BitOp.SET, State.deleted);
|
||||
}
|
||||
}
|
||||
|
||||
class ResetOp extends StateOp {
|
||||
name = 'reset';
|
||||
|
||||
constructor(splat: Splat) {
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
super(splat, IndexRanges.fromPredicate(splat.splatData.numSplats, i => (state[i] & State.deleted) !== 0), State.deleted, BitOp.CLEAR, State.deleted);
|
||||
}
|
||||
}
|
||||
|
||||
// op for modifying a splat transform
|
||||
class EntityTransformOp {
|
||||
name = 'entityTransform';
|
||||
splat: Splat;
|
||||
oldt: Transform;
|
||||
newt: Transform;
|
||||
|
||||
constructor(options: { splat: Splat, oldt: Transform, newt: Transform }) {
|
||||
this.splat = options.splat;
|
||||
this.oldt = options.oldt;
|
||||
this.newt = options.newt;
|
||||
}
|
||||
|
||||
do() {
|
||||
this.splat.move(this.newt.position, this.newt.rotation, this.newt.scale);
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.splat.move(this.oldt.position, this.oldt.rotation, this.oldt.scale);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.splat = null;
|
||||
this.oldt = null;
|
||||
this.newt = null;
|
||||
}
|
||||
}
|
||||
|
||||
const mat = new Mat4();
|
||||
|
||||
// op for modifying a subset of individual splats
|
||||
class SplatsTransformOp {
|
||||
name = 'splatsTransform';
|
||||
|
||||
splat: Splat;
|
||||
transform: Mat4;
|
||||
paletteMap: Map<number, number>;
|
||||
|
||||
constructor(options: { splat: Splat, transform: Mat4, paletteMap: Map<number, number> }) {
|
||||
this.splat = options.splat;
|
||||
this.transform = options.transform;
|
||||
this.paletteMap = options.paletteMap;
|
||||
}
|
||||
|
||||
async do() {
|
||||
const { splat, transform, paletteMap } = this;
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
const indices = splat.transformTexture.lock() as Uint16Array;
|
||||
|
||||
// update splat transform palette indices
|
||||
for (let i = 0; i < state.length; ++i) {
|
||||
if (state[i] === State.selected) {
|
||||
indices[i] = paletteMap.get(indices[i]);
|
||||
}
|
||||
}
|
||||
|
||||
splat.transformTexture.unlock();
|
||||
|
||||
splat.transformPalette.alloc(paletteMap.size);
|
||||
|
||||
// update transform palette
|
||||
const { transformPalette } = splat;
|
||||
this.paletteMap.forEach((newIdx, oldIdx) => {
|
||||
transformPalette.getTransform(oldIdx, mat);
|
||||
mat.mul2(transform, mat);
|
||||
transformPalette.setTransform(newIdx, mat);
|
||||
});
|
||||
|
||||
await splat.updatePositions();
|
||||
}
|
||||
|
||||
async undo() {
|
||||
const { splat, paletteMap } = this;
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
const indices = splat.transformTexture.lock() as Uint16Array;
|
||||
|
||||
// invert the palette map
|
||||
const inverseMap = new Map<number, number>();
|
||||
paletteMap.forEach((newIdx, oldIdx) => {
|
||||
inverseMap.set(newIdx, oldIdx);
|
||||
});
|
||||
|
||||
// restore the original transform indices
|
||||
for (let i = 0; i < state.length; ++i) {
|
||||
if (state[i] === State.selected) {
|
||||
indices[i] = inverseMap.get(indices[i]);
|
||||
}
|
||||
}
|
||||
|
||||
splat.transformTexture.unlock();
|
||||
|
||||
splat.transformPalette.free(paletteMap.size);
|
||||
|
||||
await splat.updatePositions();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.splat = null;
|
||||
this.transform = null;
|
||||
this.paletteMap = null;
|
||||
}
|
||||
}
|
||||
|
||||
class PlacePivotOp {
|
||||
name = 'setPivot';
|
||||
pivot: Pivot;
|
||||
oldt: Transform;
|
||||
newt: Transform;
|
||||
|
||||
constructor(options: { pivot: Pivot, oldt: Transform, newt: Transform }) {
|
||||
this.pivot = options.pivot;
|
||||
this.oldt = options.oldt;
|
||||
this.newt = options.newt;
|
||||
}
|
||||
|
||||
do() {
|
||||
this.pivot.place(this.newt);
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.pivot.place(this.oldt);
|
||||
}
|
||||
}
|
||||
|
||||
type ColorAdjustment = {
|
||||
tintClr?: Color
|
||||
temperature?: number,
|
||||
saturation?: number,
|
||||
brightness?: number,
|
||||
blackPoint?: number,
|
||||
whitePoint?: number,
|
||||
transparency?: number
|
||||
};
|
||||
|
||||
class SetSplatColorAdjustmentOp {
|
||||
name = 'setSplatColor';
|
||||
splat: Splat;
|
||||
|
||||
newState: ColorAdjustment;
|
||||
oldState: ColorAdjustment;
|
||||
|
||||
constructor(options: { splat: Splat, oldState: ColorAdjustment, newState: ColorAdjustment }) {
|
||||
const { splat, oldState, newState } = options;
|
||||
this.splat = splat;
|
||||
this.oldState = oldState;
|
||||
this.newState = newState;
|
||||
}
|
||||
|
||||
do() {
|
||||
const { splat } = this;
|
||||
const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = this.newState;
|
||||
if (tintClr) splat.tintClr = tintClr;
|
||||
if (temperature !== null) splat.temperature = temperature;
|
||||
if (saturation !== null) splat.saturation = saturation;
|
||||
if (brightness !== null) splat.brightness = brightness;
|
||||
if (blackPoint !== null) splat.blackPoint = blackPoint;
|
||||
if (whitePoint !== null) splat.whitePoint = whitePoint;
|
||||
if (transparency !== null) splat.transparency = transparency;
|
||||
}
|
||||
|
||||
undo() {
|
||||
const { splat } = this;
|
||||
const { tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = this.oldState;
|
||||
if (tintClr) splat.tintClr = tintClr;
|
||||
if (temperature !== null) splat.temperature = temperature;
|
||||
if (saturation !== null) splat.saturation = saturation;
|
||||
if (brightness !== null) splat.brightness = brightness;
|
||||
if (blackPoint !== null) splat.blackPoint = blackPoint;
|
||||
if (whitePoint !== null) splat.whitePoint = whitePoint;
|
||||
if (transparency !== null) splat.transparency = transparency;
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot-based undo/redo for animation track edits.
|
||||
// Captures the full track state before and after a mutation.
|
||||
class AnimTrackEditOp {
|
||||
name: string;
|
||||
track: AnimTrack;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
|
||||
constructor(name: string, track: AnimTrack, before: unknown, after: unknown) {
|
||||
this.name = name;
|
||||
this.track = track;
|
||||
this.before = before;
|
||||
this.after = after;
|
||||
}
|
||||
|
||||
do() {
|
||||
this.track.restore(this.after);
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.track.restore(this.before);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiOp {
|
||||
name = 'multiOp';
|
||||
ops: EditOp[];
|
||||
|
||||
constructor(ops: EditOp[]) {
|
||||
this.ops = ops;
|
||||
}
|
||||
|
||||
async do() {
|
||||
for (const op of this.ops) {
|
||||
await op.do();
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
for (const op of this.ops) {
|
||||
await op.undo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AddSplatOp {
|
||||
name = 'addSplat';
|
||||
scene: Scene;
|
||||
splat: Splat;
|
||||
|
||||
constructor(scene: Scene, splat: Splat) {
|
||||
this.scene = scene;
|
||||
this.splat = splat;
|
||||
}
|
||||
|
||||
async do() {
|
||||
await this.scene.add(this.splat);
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.scene.remove(this.splat);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.splat.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class SplatRenameOp {
|
||||
name = 'splatRename';
|
||||
splat: Splat;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
|
||||
constructor(splat: Splat, newName: string) {
|
||||
this.splat = splat;
|
||||
this.oldName = splat.name;
|
||||
this.newName = newName;
|
||||
}
|
||||
|
||||
do() {
|
||||
this.splat.name = this.newName;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.splat.name = this.oldName;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
EditOp,
|
||||
SelectAllOp,
|
||||
SelectNoneOp,
|
||||
SelectInvertOp,
|
||||
SelectOp,
|
||||
HideSelectionOp,
|
||||
UnhideAllOp,
|
||||
DeleteSelectionOp,
|
||||
ResetOp,
|
||||
EntityTransformOp,
|
||||
SplatsTransformOp,
|
||||
PlacePivotOp,
|
||||
ColorAdjustment,
|
||||
SetSplatColorAdjustmentOp,
|
||||
AnimTrackEditOp,
|
||||
MultiOp,
|
||||
AddSplatOp,
|
||||
SplatRenameOp
|
||||
};
|
||||
+879
@@ -0,0 +1,879 @@
|
||||
import { MemoryFileSystem } from '@playcanvas/splat-transform';
|
||||
import { Color, Mat4, path, Texture, Vec3, Vec4 } from 'playcanvas';
|
||||
|
||||
import { EditHistory } from './edit-history';
|
||||
import { SelectAllOp, SelectNoneOp, SelectInvertOp, SelectOp, HideSelectionOp, UnhideAllOp, DeleteSelectionOp, ResetOp, MultiOp, AddSplatOp } from './edit-ops';
|
||||
import { Element, ElementType } from './element';
|
||||
import { Events } from './events';
|
||||
import { MappedReadFileSystem } from './io';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
import { serializePly } from './splat-serialize';
|
||||
|
||||
const removeExtension = (filename: string) => {
|
||||
return filename.substring(0, filename.length - path.getExtension(filename).length);
|
||||
};
|
||||
|
||||
// register for editor and scene events
|
||||
const registerEditorEvents = (events: Events, editHistory: EditHistory, scene: Scene) => {
|
||||
const vec = new Vec3();
|
||||
const vec2 = new Vec3();
|
||||
const vec4 = new Vec4();
|
||||
const mat = new Mat4();
|
||||
const SH_C0 = 0.28209479177387814;
|
||||
|
||||
const decodeColorChannel = (value: number) => {
|
||||
return Math.min(1, Math.max(0, 0.5 + value * SH_C0));
|
||||
};
|
||||
|
||||
// get the list of selected splats (currently limited to just a single one)
|
||||
const selectedSplats = () => {
|
||||
const selected = events.invoke('selection') as Splat;
|
||||
return selected?.visible ? [selected] : [];
|
||||
};
|
||||
|
||||
let lastExportCursor = 0;
|
||||
|
||||
// add unsaved changes warning message.
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (!events.invoke('scene.dirty')) {
|
||||
// if the undo cursor matches last export, then we have no unsaved changes
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const msg = 'You have unsaved changes. Are you sure you want to leave?';
|
||||
e.returnValue = msg;
|
||||
return msg;
|
||||
});
|
||||
|
||||
events.function('targetSize', () => {
|
||||
return scene.targetSize;
|
||||
});
|
||||
|
||||
events.on('scene.clear', () => {
|
||||
scene.clear();
|
||||
editHistory.clear();
|
||||
lastExportCursor = 0;
|
||||
});
|
||||
|
||||
// When a splat is removed from the scene, remove all edit operations that reference it
|
||||
events.on('scene.elementRemoved', (element: Element) => {
|
||||
if (element.type === ElementType.splat) {
|
||||
editHistory.removeForSplat(element as Splat);
|
||||
}
|
||||
});
|
||||
|
||||
events.function('scene.dirty', () => {
|
||||
return editHistory.cursor !== lastExportCursor;
|
||||
});
|
||||
|
||||
events.on('doc.saved', () => {
|
||||
lastExportCursor = editHistory.cursor;
|
||||
});
|
||||
|
||||
// force render on some events
|
||||
|
||||
[
|
||||
'camera.mode', 'camera.overlay', 'camera.splatSize', 'view.outlineSelection',
|
||||
'view.centersUseGaussianColor', 'view.bands', 'camera.bound', 'camera.boundDimensions', 'camera.showPoses',
|
||||
'camera.showInfo', 'selection.changed', 'tool.coordSpace'
|
||||
].forEach((eventName) => {
|
||||
events.on(eventName, () => {
|
||||
scene.forceRender = true;
|
||||
});
|
||||
});
|
||||
|
||||
// grid.visible
|
||||
|
||||
const setGridVisible = (visible: boolean) => {
|
||||
if (visible !== scene.grid.visible) {
|
||||
scene.grid.visible = visible;
|
||||
events.fire('grid.visible', visible);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('grid.visible', () => {
|
||||
return scene.grid.visible;
|
||||
});
|
||||
|
||||
events.on('grid.setVisible', (visible: boolean) => {
|
||||
setGridVisible(visible);
|
||||
});
|
||||
|
||||
events.on('grid.toggleVisible', () => {
|
||||
setGridVisible(!scene.grid.visible);
|
||||
});
|
||||
|
||||
setGridVisible(scene.config.show.grid);
|
||||
|
||||
// camera.fovDolly
|
||||
|
||||
let fovDolly = false;
|
||||
|
||||
const setFovDolly = (value: boolean) => {
|
||||
if (value !== fovDolly) {
|
||||
fovDolly = value;
|
||||
events.fire('camera.fovDolly', fovDolly);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.fovDolly', () => {
|
||||
return fovDolly;
|
||||
});
|
||||
|
||||
events.on('camera.setFovDolly', (value: boolean) => {
|
||||
setFovDolly(value);
|
||||
});
|
||||
|
||||
// camera.fov
|
||||
|
||||
const setCameraFov = (fov: number) => {
|
||||
const { camera } = scene;
|
||||
if (fov !== camera.fov) {
|
||||
const oldFovFactor = camera.fovFactor;
|
||||
camera.fov = fov;
|
||||
|
||||
// by default a fov change acts like a lens zoom: scale distance so
|
||||
// the camera's world-space offset from the focal point (distance *
|
||||
// sceneRadius / fovFactor) is unchanged. with auto-dolly enabled
|
||||
// the camera moves instead, preserving the subject's framing.
|
||||
if (!fovDolly) {
|
||||
const { controls } = scene.config;
|
||||
const k = camera.fovFactor / oldFovFactor;
|
||||
const t = camera.distanceTween;
|
||||
for (const s of [t.value, t.source, t.target]) {
|
||||
s.distance = Math.max(controls.minZoom, Math.min(controls.maxZoom, s.distance * k));
|
||||
}
|
||||
}
|
||||
|
||||
events.fire('camera.fov', camera.fov);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.fov', () => {
|
||||
return scene.camera.fov;
|
||||
});
|
||||
|
||||
events.on('camera.setFov', (fov: number) => {
|
||||
setCameraFov(fov);
|
||||
});
|
||||
|
||||
// camera.tonemapping
|
||||
|
||||
events.function('camera.tonemapping', () => {
|
||||
return scene.camera.tonemapping;
|
||||
});
|
||||
|
||||
events.on('camera.setTonemapping', (value: string) => {
|
||||
scene.camera.tonemapping = value;
|
||||
});
|
||||
|
||||
// camera.bound
|
||||
|
||||
let bound = scene.config.show.bound;
|
||||
|
||||
const setBoundVisible = (visible: boolean) => {
|
||||
if (visible !== bound) {
|
||||
bound = visible;
|
||||
events.fire('camera.bound', bound);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.bound', () => {
|
||||
return bound;
|
||||
});
|
||||
|
||||
events.on('camera.setBound', (value: boolean) => {
|
||||
setBoundVisible(value);
|
||||
});
|
||||
|
||||
events.on('camera.toggleBound', () => {
|
||||
setBoundVisible(!events.invoke('camera.bound'));
|
||||
});
|
||||
|
||||
// camera.boundDimensions
|
||||
|
||||
let boundDimensions = scene.config.show.boundDimensions;
|
||||
|
||||
const setBoundDimensionsVisible = (visible: boolean) => {
|
||||
if (visible !== boundDimensions) {
|
||||
boundDimensions = visible;
|
||||
events.fire('camera.boundDimensions', boundDimensions);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.boundDimensions', () => {
|
||||
return boundDimensions;
|
||||
});
|
||||
|
||||
events.on('camera.setBoundDimensions', (value: boolean) => {
|
||||
setBoundDimensionsVisible(value);
|
||||
});
|
||||
|
||||
events.on('camera.toggleBoundDimensions', () => {
|
||||
setBoundDimensionsVisible(!events.invoke('camera.boundDimensions'));
|
||||
});
|
||||
|
||||
// camera.showPoses
|
||||
|
||||
let showPoses = scene.config.show.cameraPoses;
|
||||
|
||||
const setShowPoses = (visible: boolean) => {
|
||||
if (visible !== showPoses) {
|
||||
showPoses = visible;
|
||||
events.fire('camera.showPoses', showPoses);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.showPoses', () => {
|
||||
return showPoses;
|
||||
});
|
||||
|
||||
events.on('camera.setShowPoses', (value: boolean) => {
|
||||
setShowPoses(value);
|
||||
});
|
||||
|
||||
events.on('camera.toggleShowPoses', () => {
|
||||
setShowPoses(!events.invoke('camera.showPoses'));
|
||||
});
|
||||
|
||||
// camera.showInfo
|
||||
|
||||
let showInfo = scene.config.show.cameraInfo;
|
||||
|
||||
const setShowInfo = (visible: boolean) => {
|
||||
if (visible !== showInfo) {
|
||||
showInfo = visible;
|
||||
events.fire('camera.showInfo', showInfo);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.showInfo', () => {
|
||||
return showInfo;
|
||||
});
|
||||
|
||||
events.on('camera.setShowInfo', (value: boolean) => {
|
||||
setShowInfo(value);
|
||||
});
|
||||
|
||||
events.on('camera.toggleShowInfo', () => {
|
||||
setShowInfo(!events.invoke('camera.showInfo'));
|
||||
});
|
||||
|
||||
// camera.focus
|
||||
|
||||
events.on('camera.focus', () => {
|
||||
const splat = selectedSplats()[0];
|
||||
if (splat) {
|
||||
// use current bounds (caller should have awaited the operation that changed data)
|
||||
const bound = splat.numSelected > 0 ?
|
||||
splat.selectionBound :
|
||||
splat.localBound;
|
||||
vec.copy(bound.center);
|
||||
|
||||
const worldTransform = splat.worldTransform;
|
||||
worldTransform.transformPoint(vec, vec);
|
||||
worldTransform.getScale(vec2);
|
||||
|
||||
scene.camera.focus({
|
||||
focalPoint: vec,
|
||||
radius: bound.halfExtents.length() * vec2.x,
|
||||
speed: 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
events.on('camera.reset', () => {
|
||||
const { initialAzim, initialElev, initialZoom } = scene.config.controls;
|
||||
const x = Math.sin(initialAzim * Math.PI / 180) * Math.cos(initialElev * Math.PI / 180);
|
||||
const y = -Math.sin(initialElev * Math.PI / 180);
|
||||
const z = Math.cos(initialAzim * Math.PI / 180) * Math.cos(initialElev * Math.PI / 180);
|
||||
const zoom = initialZoom;
|
||||
|
||||
scene.camera.setPose(new Vec3(x * zoom, y * zoom, z * zoom), new Vec3(0, 0, 0));
|
||||
});
|
||||
|
||||
// handle camera align events
|
||||
events.on('camera.align', (axis: string) => {
|
||||
switch (axis) {
|
||||
case 'px': scene.camera.setAzimElev(90, 0); break;
|
||||
case 'py': scene.camera.setAzimElev(0, -90); break;
|
||||
case 'pz': scene.camera.setAzimElev(0, 0); break;
|
||||
case 'nx': scene.camera.setAzimElev(270, 0); break;
|
||||
case 'ny': scene.camera.setAzimElev(0, 90); break;
|
||||
case 'nz': scene.camera.setAzimElev(180, 0); break;
|
||||
}
|
||||
|
||||
// switch to ortho mode
|
||||
scene.camera.ortho = true;
|
||||
});
|
||||
|
||||
// returns true if the selected splat has selected gaussians
|
||||
events.function('selection.splats', () => {
|
||||
const splat = events.invoke('selection') as Splat;
|
||||
return splat?.numSelected > 0;
|
||||
});
|
||||
|
||||
events.on('select.all', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new SelectAllOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
events.on('select.none', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new SelectNoneOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
events.on('select.invert', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new SelectInvertOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
events.on('select.mask', (op: 'add'|'remove'|'set'|'intersect', mask: Uint8Array | Uint32Array) => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new SelectOp(splat, op, mask));
|
||||
});
|
||||
});
|
||||
|
||||
// run the GPU intersect + the resulting SelectOp inside one queued task so the
|
||||
// gpu readback is ordered relative to other queued history ops (rapid drag +
|
||||
// undo, drag-while-camera-settling, etc).
|
||||
const runSelectIntersect = (splat: Splat, op: 'add'|'remove'|'set'|'intersect', options: any) => {
|
||||
return scene.commandQueue.enqueue(async () => {
|
||||
const data = await scene.dataProcessor.intersect(options, splat);
|
||||
// SelectOp consumes `data` synchronously in its constructor
|
||||
// (IndexRanges.fromPredicate iterates immediately), so we can
|
||||
// return the buffer to the pool as soon as the op is constructed.
|
||||
events.fire('edit.add', new SelectOp(splat, op, data));
|
||||
scene.dataProcessor.releaseMask(data);
|
||||
});
|
||||
};
|
||||
|
||||
events.on('select.bySphere', async (op: 'add'|'remove'|'set'|'intersect', sphere: number[]) => {
|
||||
for (const splat of selectedSplats()) {
|
||||
await runSelectIntersect(splat, op, {
|
||||
sphere: { x: sphere[0], y: sphere[1], z: sphere[2], radius: sphere[3] }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
events.on('select.byBox', async (op: 'add'|'remove'|'set'|'intersect', box: number[]) => {
|
||||
for (const splat of selectedSplats()) {
|
||||
await runSelectIntersect(splat, op, {
|
||||
box: { x: box[0], y: box[1], z: box[2], lenx: box[3], leny: box[4], lenz: box[5] }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
events.function('select.rect', async (op: 'add'|'remove'|'set'|'intersect', rect: any) => {
|
||||
const mode = events.invoke('camera.mode');
|
||||
|
||||
for (const splat of selectedSplats()) {
|
||||
if (mode === 'centers') {
|
||||
await runSelectIntersect(splat, op, {
|
||||
rect: { x1: rect.start.x, y1: rect.start.y, x2: rect.end.x, y2: rect.end.y }
|
||||
});
|
||||
} else if (mode === 'rings') {
|
||||
scene.camera.pickPrep(splat, op);
|
||||
const pick = await scene.camera.pickRect(
|
||||
rect.start.x,
|
||||
rect.start.y,
|
||||
rect.end.x - rect.start.x,
|
||||
rect.end.y - rect.start.y
|
||||
);
|
||||
|
||||
const sortedIds = new Uint32Array(new Set(pick)).sort();
|
||||
events.fire('edit.add', new SelectOp(splat, op, sortedIds));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let maskTexture: Texture = null;
|
||||
|
||||
events.function('select.byMask', async (op: 'add'|'remove'|'set'|'intersect', canvas: HTMLCanvasElement, context: CanvasRenderingContext2D) => {
|
||||
const mode = events.invoke('camera.mode');
|
||||
|
||||
for (const splat of selectedSplats()) {
|
||||
if (mode === 'centers') {
|
||||
// create mask texture
|
||||
if (!maskTexture || maskTexture.width !== canvas.width || maskTexture.height !== canvas.height) {
|
||||
if (maskTexture) {
|
||||
maskTexture.destroy();
|
||||
}
|
||||
maskTexture = new Texture(scene.graphicsDevice);
|
||||
}
|
||||
maskTexture.setSource(canvas);
|
||||
|
||||
await runSelectIntersect(splat, op, {
|
||||
mask: maskTexture
|
||||
});
|
||||
} else if (mode === 'rings') {
|
||||
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// calculate mask bound so we limit pixel operations
|
||||
let mx0 = mask.width - 1;
|
||||
let my0 = mask.height - 1;
|
||||
let mx1 = 0;
|
||||
let my1 = 0;
|
||||
for (let y = 0; y < mask.height; ++y) {
|
||||
for (let x = 0; x < mask.width; ++x) {
|
||||
if (mask.data[(y * mask.width + x) * 4 + 3] === 255) {
|
||||
mx0 = Math.min(mx0, x);
|
||||
my0 = Math.min(my0, y);
|
||||
mx1 = Math.max(mx1, x);
|
||||
my1 = Math.max(my1, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert mask bounds to normalized coordinates
|
||||
const nx0 = mx0 / mask.width;
|
||||
const ny0 = my0 / mask.height;
|
||||
const nx1 = (mx1 + 1) / mask.width;
|
||||
const ny1 = (my1 + 1) / mask.height;
|
||||
const nw = nx1 - nx0;
|
||||
const nh = ny1 - ny0;
|
||||
|
||||
scene.camera.pickPrep(splat, op);
|
||||
const pick = await scene.camera.pickRect(nx0, ny0, nw, nh);
|
||||
|
||||
// Calculate actual pixel dimensions for iteration
|
||||
const { width, height } = scene.targetSize;
|
||||
|
||||
// Convert normalized coordinates to render target pixels
|
||||
const px = Math.floor(nx0 * width);
|
||||
const py = Math.floor(ny0 * height);
|
||||
const pw = Math.max(1, Math.ceil((nx0 + nw) * width) - px);
|
||||
const ph = Math.max(1, Math.ceil((ny0 + nh) * height) - py);
|
||||
|
||||
const selected = new Set<number>();
|
||||
for (let y = 0; y < ph; ++y) {
|
||||
for (let x = 0; x < pw; ++x) {
|
||||
const mx = Math.floor((nx0 + x / width) * mask.width);
|
||||
const my = Math.floor((ny0 + y / height) * mask.height);
|
||||
if (mask.data[(my * mask.width + mx) * 4] === 255) {
|
||||
selected.add(pick[(ph - 1 - y) * pw + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedIds = new Uint32Array(selected).sort();
|
||||
events.fire('edit.add', new SelectOp(splat, op, sortedIds));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
events.function('select.point', async (op: 'add'|'remove'|'set'|'intersect', point: { x: number, y: number }) => {
|
||||
const { width, height } = scene.targetSize;
|
||||
const mode = events.invoke('camera.mode');
|
||||
|
||||
for (const splat of selectedSplats()) {
|
||||
const splatData = splat.splatData;
|
||||
|
||||
if (mode === 'centers') {
|
||||
const x = splatData.getProp('x');
|
||||
const y = splatData.getProp('y');
|
||||
const z = splatData.getProp('z');
|
||||
|
||||
const splatSize = events.invoke('camera.splatSize');
|
||||
const camera = scene.camera.camera;
|
||||
const sx = point.x * width;
|
||||
const sy = point.y * height;
|
||||
|
||||
// calculate final matrix
|
||||
mat.mul2(camera.camera._viewProjMat, splat.worldTransform);
|
||||
|
||||
// materialize hits into an owned mask. SelectOp consumes a
|
||||
// committed snapshot rather than a closure so we never have to
|
||||
// worry about state shifting between capture and apply.
|
||||
const numSplats = splatData.numSplats;
|
||||
const mask = new Uint8Array(numSplats);
|
||||
for (let i = 0; i < numSplats; i++) {
|
||||
vec4.set(x[i], y[i], z[i], 1.0);
|
||||
mat.transformVec4(vec4, vec4);
|
||||
const px = (vec4.x / vec4.w * 0.5 + 0.5) * width;
|
||||
const py = (-vec4.y / vec4.w * 0.5 + 0.5) * height;
|
||||
if (Math.abs(px - sx) < splatSize && Math.abs(py - sy) < splatSize) {
|
||||
mask[i] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
events.fire('edit.add', new SelectOp(splat, op, mask));
|
||||
} else if (mode === 'rings') {
|
||||
scene.camera.pickPrep(splat, op);
|
||||
|
||||
// Use normalized coordinates with minimal size for single pixel pick
|
||||
const pickResult = await scene.camera.pickRect(
|
||||
point.x,
|
||||
point.y,
|
||||
1 / width,
|
||||
1 / height
|
||||
);
|
||||
const pickId = pickResult[0];
|
||||
events.fire('edit.add', new SelectOp(splat, op, new Uint32Array([pickId])));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Eyedropper selection with SelectOp so undo/redo and selection state updates remain consistent.
|
||||
// Threshold acts as a per-channel absolute difference: 0 only matches identical colors while 1 matches everything.
|
||||
// TO DO:
|
||||
// - alternative distance metrics such as HSV.
|
||||
// - alternative UI for threshold, two handles for min/max?
|
||||
events.function('select.colorMatch', async (op: 'add'|'remove'|'set', point: { x: number, y: number }, threshold = 0) => {
|
||||
const splats = selectedSplats();
|
||||
const targetSize = scene.targetSize;
|
||||
if (!splats.length || !targetSize || !point) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = targetSize;
|
||||
if (!width || !height) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp normalized coordinates to valid range
|
||||
const nx = Math.max(0, Math.min(1, point.x));
|
||||
const ny = Math.max(0, Math.min(1, point.y));
|
||||
const colorThreshold = Math.min(1, Math.max(0, Number.isFinite(threshold) ? threshold : 0));
|
||||
|
||||
for (const splat of splats) {
|
||||
scene.camera.pickPrep(splat, 'set');
|
||||
// Use normalized coordinates with minimal size for single pixel pick
|
||||
const pickBuffer = await scene.camera.pickRect(nx, ny, 1 / width, 1 / height);
|
||||
const pickId = pickBuffer?.[0];
|
||||
if (pickId === undefined || pickId === 0xffffffff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reds = splat.splatData.getProp('f_dc_0') as Float32Array;
|
||||
const greens = splat.splatData.getProp('f_dc_1') as Float32Array;
|
||||
const blues = splat.splatData.getProp('f_dc_2') as Float32Array;
|
||||
// validate pickId and color channels exist
|
||||
if (!reds || !greens || !blues || pickId < 0 || pickId >= reds.length) {
|
||||
continue;
|
||||
}
|
||||
// decode color channels for the reference pixel
|
||||
const refR = decodeColorChannel(reds[pickId]);
|
||||
const refG = decodeColorChannel(greens[pickId]);
|
||||
const refB = decodeColorChannel(blues[pickId]);
|
||||
|
||||
// materialize hits into an owned mask up front; SelectOp consumes
|
||||
// a committed snapshot.
|
||||
const numSplats = splat.splatData.numSplats;
|
||||
const mask = new Uint8Array(numSplats);
|
||||
for (let i = 0; i < numSplats; i++) {
|
||||
if (Math.abs(decodeColorChannel(reds[i]) - refR) <= colorThreshold &&
|
||||
Math.abs(decodeColorChannel(greens[i]) - refG) <= colorThreshold &&
|
||||
Math.abs(decodeColorChannel(blues[i]) - refB) <= colorThreshold) {
|
||||
mask[i] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
events.fire('edit.add', new SelectOp(splat, op, mask));
|
||||
}
|
||||
});
|
||||
|
||||
events.on('select.hide', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new HideSelectionOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
events.on('select.unhide', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
events.fire('edit.add', new UnhideAllOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
events.on('select.delete', () => {
|
||||
// Don't delete gaussians when measure tool is active (backspace deletes measure points instead)
|
||||
if (events.invoke('tool.active') === 'measure') {
|
||||
return;
|
||||
}
|
||||
// Don't delete gaussians while a polygon selection is in progress (backspace removes the last point instead)
|
||||
if (events.invoke('polygonSelection.removeLastPoint')) {
|
||||
return;
|
||||
}
|
||||
selectedSplats().forEach((splat) => {
|
||||
editHistory.add(new DeleteSelectionOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
const performSelectionFunc = async (func: 'duplicate' | 'separate') => {
|
||||
const splats = selectedSplats();
|
||||
|
||||
const memFs = new MemoryFileSystem();
|
||||
|
||||
await serializePly(splats, {
|
||||
maxSHBands: 3,
|
||||
selected: true
|
||||
}, memFs);
|
||||
|
||||
const data = memFs.results.get('output.ply');
|
||||
|
||||
if (data) {
|
||||
const splat = splats[0];
|
||||
|
||||
// wrap PLY in a blob and load it
|
||||
const blob = new Blob([data.buffer as ArrayBuffer], { type: 'application/octet-stream' });
|
||||
const filename = `${removeExtension(splat.filename)}.ply`;
|
||||
const fileSystem = new MappedReadFileSystem();
|
||||
fileSystem.addFile(filename, blob);
|
||||
const copy = await scene.assetLoader.load(filename, fileSystem);
|
||||
|
||||
if (func === 'separate') {
|
||||
editHistory.add(new MultiOp([
|
||||
new DeleteSelectionOp(splat),
|
||||
new AddSplatOp(scene, copy)
|
||||
]));
|
||||
} else {
|
||||
editHistory.add(new AddSplatOp(scene, copy));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// duplicate the current selection
|
||||
events.on('edit.duplicate', async () => {
|
||||
await performSelectionFunc('duplicate');
|
||||
});
|
||||
|
||||
events.on('edit.separate', async () => {
|
||||
await performSelectionFunc('separate');
|
||||
});
|
||||
|
||||
events.on('scene.reset', () => {
|
||||
selectedSplats().forEach((splat) => {
|
||||
editHistory.add(new ResetOp(splat));
|
||||
});
|
||||
});
|
||||
|
||||
// camera mode (visual: centers/rings)
|
||||
|
||||
let activeMode = 'centers';
|
||||
|
||||
const setCameraMode = (mode: string) => {
|
||||
if (mode !== activeMode) {
|
||||
activeMode = mode;
|
||||
events.fire('camera.mode', activeMode);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.mode', () => {
|
||||
return activeMode;
|
||||
});
|
||||
|
||||
events.on('camera.setMode', (mode: string) => {
|
||||
setCameraMode(mode);
|
||||
});
|
||||
|
||||
events.on('camera.toggleMode', () => {
|
||||
setCameraMode(events.invoke('camera.mode') === 'centers' ? 'rings' : 'centers');
|
||||
});
|
||||
|
||||
// camera control mode (orbit/fly)
|
||||
|
||||
let controlMode: 'orbit' | 'fly' = 'orbit';
|
||||
|
||||
const setControlMode = (mode: 'orbit' | 'fly') => {
|
||||
if (mode !== controlMode) {
|
||||
controlMode = mode;
|
||||
scene.camera.controlMode = mode;
|
||||
events.fire('camera.controlMode', controlMode);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.controlMode', () => {
|
||||
return controlMode;
|
||||
});
|
||||
|
||||
events.on('camera.setControlMode', (mode: 'orbit' | 'fly') => {
|
||||
setControlMode(mode);
|
||||
});
|
||||
|
||||
events.on('camera.toggleControlMode', () => {
|
||||
setControlMode(controlMode === 'orbit' ? 'fly' : 'orbit');
|
||||
});
|
||||
|
||||
// camera overlay
|
||||
|
||||
let cameraOverlay = scene.config.camera.overlay;
|
||||
|
||||
const setCameraOverlay = (enabled: boolean) => {
|
||||
if (enabled !== cameraOverlay) {
|
||||
cameraOverlay = enabled;
|
||||
events.fire('camera.overlay', cameraOverlay);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.overlay', () => {
|
||||
return cameraOverlay;
|
||||
});
|
||||
|
||||
events.on('camera.setOverlay', (value: boolean) => {
|
||||
setCameraOverlay(value);
|
||||
});
|
||||
|
||||
events.on('camera.toggleOverlay', () => {
|
||||
setCameraOverlay(!events.invoke('camera.overlay'));
|
||||
});
|
||||
|
||||
// splat size
|
||||
|
||||
let splatSize = 2;
|
||||
|
||||
const setSplatSize = (value: number) => {
|
||||
if (value !== splatSize) {
|
||||
splatSize = value;
|
||||
events.fire('camera.splatSize', splatSize);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.splatSize', () => {
|
||||
return splatSize;
|
||||
});
|
||||
|
||||
events.on('camera.setSplatSize', (value: number) => {
|
||||
setSplatSize(value);
|
||||
});
|
||||
|
||||
// camera fly speed
|
||||
|
||||
const setFlySpeed = (value: number) => {
|
||||
if (value !== scene.camera.flySpeed) {
|
||||
scene.camera.flySpeed = value;
|
||||
events.fire('camera.flySpeed', value);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('camera.flySpeed', () => {
|
||||
return scene.camera.flySpeed;
|
||||
});
|
||||
|
||||
events.on('camera.setFlySpeed', (value: number) => {
|
||||
setFlySpeed(value);
|
||||
});
|
||||
|
||||
// outline selection
|
||||
|
||||
let outlineSelection = false;
|
||||
|
||||
const setOutlineSelection = (value: boolean) => {
|
||||
if (value !== outlineSelection) {
|
||||
outlineSelection = value;
|
||||
events.fire('view.outlineSelection', outlineSelection);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('view.outlineSelection', () => {
|
||||
return outlineSelection;
|
||||
});
|
||||
|
||||
events.on('view.setOutlineSelection', (value: boolean) => {
|
||||
setOutlineSelection(value);
|
||||
});
|
||||
|
||||
// view spherical harmonic bands
|
||||
|
||||
let viewBands = scene.config.show.shBands;
|
||||
|
||||
const setViewBands = (value: number) => {
|
||||
if (value !== viewBands) {
|
||||
viewBands = value;
|
||||
events.fire('view.bands', viewBands);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('view.bands', () => {
|
||||
return viewBands;
|
||||
});
|
||||
|
||||
events.on('view.setBands', (value: number) => {
|
||||
setViewBands(value);
|
||||
});
|
||||
|
||||
// centers gaussian color toggle
|
||||
let centersUseGaussianColor = false;
|
||||
events.function('view.centersUseGaussianColor', () => centersUseGaussianColor);
|
||||
events.on('view.setCentersUseGaussianColor', (value: boolean) => {
|
||||
centersUseGaussianColor = value;
|
||||
events.fire('view.centersUseGaussianColor', value);
|
||||
});
|
||||
|
||||
events.function('camera.getPose', () => {
|
||||
const camera = scene.camera;
|
||||
const position = camera.position;
|
||||
const focalPoint = camera.focalPoint;
|
||||
return {
|
||||
position: { x: position.x, y: position.y, z: position.z },
|
||||
target: { x: focalPoint.x, y: focalPoint.y, z: focalPoint.z },
|
||||
fov: camera.fov
|
||||
};
|
||||
});
|
||||
|
||||
events.on('camera.setPose', (pose: { position: Vec3, target: Vec3, fov?: number }, speed = 1) => {
|
||||
// assign fov before setPose so distance is computed using the new fovFactor
|
||||
if (pose.fov !== undefined) {
|
||||
// pose-driven fov (timeline playback, fly-to-pose) is not a user
|
||||
// preference - suspend capture around the notify and the
|
||||
// synchronous ui echo it triggers
|
||||
events.fire('preferences.suspend');
|
||||
try {
|
||||
scene.camera.fov = pose.fov;
|
||||
events.fire('camera.fov', pose.fov);
|
||||
} finally {
|
||||
events.fire('preferences.resume');
|
||||
}
|
||||
}
|
||||
scene.camera.setPose(pose.position, pose.target, speed);
|
||||
});
|
||||
|
||||
// hack: fire events to initialize UI
|
||||
events.fire('camera.fov', scene.camera.fov);
|
||||
events.fire('camera.overlay', cameraOverlay);
|
||||
events.fire('view.bands', viewBands);
|
||||
events.fire('camera.showInfo', showInfo);
|
||||
|
||||
// doc serialization
|
||||
events.function('docSerialize.view', () => {
|
||||
const packC = (c: Color) => [c.r, c.g, c.b, c.a];
|
||||
return {
|
||||
bgColor: packC(events.invoke('bgClr')),
|
||||
selectedColor: packC(events.invoke('selectedClr')),
|
||||
unselectedColor: packC(events.invoke('unselectedClr')),
|
||||
lockedColor: packC(events.invoke('lockedClr')),
|
||||
shBands: events.invoke('view.bands'),
|
||||
centersSize: events.invoke('camera.splatSize'),
|
||||
outlineSelection: events.invoke('view.outlineSelection'),
|
||||
showGrid: events.invoke('grid.visible'),
|
||||
showBound: events.invoke('camera.bound'),
|
||||
showBoundDimensions: events.invoke('camera.boundDimensions'),
|
||||
showCameraPoses: events.invoke('camera.showPoses'),
|
||||
showCameraInfo: events.invoke('camera.showInfo'),
|
||||
flySpeed: events.invoke('camera.flySpeed'),
|
||||
fovDolly: events.invoke('camera.fovDolly')
|
||||
};
|
||||
});
|
||||
|
||||
events.function('docDeserialize.view', (docView: any) => {
|
||||
events.fire('setBgClr', new Color(docView.bgColor));
|
||||
events.fire('setSelectedClr', new Color(docView.selectedColor));
|
||||
events.fire('setUnselectedClr', new Color(docView.unselectedColor));
|
||||
events.fire('setLockedClr', new Color(docView.lockedColor));
|
||||
events.fire('view.setBands', docView.shBands);
|
||||
events.fire('camera.setSplatSize', docView.centersSize);
|
||||
events.fire('view.setOutlineSelection', docView.outlineSelection);
|
||||
events.fire('grid.setVisible', docView.showGrid);
|
||||
events.fire('camera.setBound', docView.showBound);
|
||||
events.fire('camera.setBoundDimensions', docView.showBoundDimensions ?? false);
|
||||
events.fire('camera.setShowPoses', docView.showCameraPoses ?? false);
|
||||
events.fire('camera.setShowInfo', docView.showCameraInfo ?? false);
|
||||
events.fire('camera.setFlySpeed', docView.flySpeed);
|
||||
events.fire('camera.setFovDolly', docView.fovDolly ?? false);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerEditorEvents };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BoundingBox, Quat, Vec3 } from 'playcanvas';
|
||||
|
||||
import { Scene } from './scene';
|
||||
import { Serializer } from './serializer';
|
||||
|
||||
enum ElementType {
|
||||
camera = 'camera',
|
||||
model = 'model',
|
||||
splat = 'splat',
|
||||
shadow = 'shadow',
|
||||
debug = 'debug',
|
||||
other = 'other'
|
||||
}
|
||||
|
||||
const ElementTypeList = [
|
||||
ElementType.camera,
|
||||
ElementType.model,
|
||||
ElementType.splat,
|
||||
ElementType.shadow,
|
||||
ElementType.debug,
|
||||
ElementType.other
|
||||
];
|
||||
|
||||
let nextUid = 1;
|
||||
|
||||
class Element {
|
||||
type: ElementType;
|
||||
scene: Scene = null;
|
||||
uid: number;
|
||||
|
||||
constructor(type: ElementType) {
|
||||
this.type = type;
|
||||
this.uid = nextUid++;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.scene) {
|
||||
this.scene.remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
add(): void | Promise<void> {}
|
||||
|
||||
remove() {}
|
||||
|
||||
serialize(serializer: Serializer) {}
|
||||
|
||||
onUpdate(deltaTime: number) {}
|
||||
|
||||
onPostUpdate() {}
|
||||
|
||||
onPreRender() {}
|
||||
|
||||
onPostRender() {}
|
||||
|
||||
onAdded(element: Element) {}
|
||||
|
||||
onRemoved(element: Element) {}
|
||||
|
||||
move(position?: Vec3, rotation?: Quat, scale?: Vec3) {}
|
||||
|
||||
get worldBound(): BoundingBox | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { ElementType, ElementTypeList, Element };
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Mat4, Quat, Vec3 } from 'playcanvas';
|
||||
|
||||
import { PlacePivotOp, EntityTransformOp, MultiOp } from './edit-ops';
|
||||
import { Events } from './events';
|
||||
import { Pivot } from './pivot';
|
||||
import { Splat } from './splat';
|
||||
import { Transform } from './transform';
|
||||
import { TransformHandler } from './transform-handler';
|
||||
|
||||
const mat = new Mat4();
|
||||
const quat = new Quat();
|
||||
const transform = new Transform();
|
||||
|
||||
class EntityTransformHandler implements TransformHandler {
|
||||
events: Events;
|
||||
splat: Splat;
|
||||
top: EntityTransformOp;
|
||||
pop: PlacePivotOp;
|
||||
bindMat = new Mat4();
|
||||
|
||||
constructor(events: Events) {
|
||||
this.events = events;
|
||||
|
||||
events.on('pivot.started', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.start();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.moved', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.update(pivot.transform);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.ended', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.end();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.origin', (mode: 'center' | 'boundCenter') => {
|
||||
if (this.splat) {
|
||||
this.placePivot();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => {
|
||||
if (this.splat && ['move', 'rotate', 'scale'].includes(this.events.invoke('tool.active'))) {
|
||||
const pivot = events.invoke('pivot') as Pivot;
|
||||
const newt = new Transform(details.position, pivot.transform.rotation, pivot.transform.scale);
|
||||
const op = new PlacePivotOp({ pivot, oldt: pivot.transform.clone(), newt });
|
||||
events.fire('edit.add', op);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
placePivot() {
|
||||
// place initial pivot point
|
||||
const origin = this.events.invoke('pivot.origin');
|
||||
this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', false, transform);
|
||||
this.events.invoke('pivot').place(transform);
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.splat = this.events.invoke('selection') as Splat;
|
||||
if (this.splat) {
|
||||
this.placePivot();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.splat = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
const pivot = this.events.invoke('pivot') as Pivot;
|
||||
const { transform } = pivot;
|
||||
const { entity } = this.splat;
|
||||
|
||||
// calculate bind matrix
|
||||
this.bindMat.setTRS(transform.position, transform.rotation, transform.scale);
|
||||
this.bindMat.invert();
|
||||
this.bindMat.mul2(this.bindMat, entity.getLocalTransform());
|
||||
|
||||
const p = entity.getLocalPosition();
|
||||
const r = entity.getLocalRotation();
|
||||
const s = entity.getLocalScale();
|
||||
|
||||
// create op
|
||||
this.top = new EntityTransformOp({
|
||||
splat: this.splat,
|
||||
oldt: new Transform(p, r, s),
|
||||
newt: new Transform(p, r, s)
|
||||
});
|
||||
|
||||
this.pop = new PlacePivotOp({
|
||||
pivot,
|
||||
oldt: transform.clone(),
|
||||
newt: transform.clone()
|
||||
});
|
||||
}
|
||||
|
||||
update(transform: Transform) {
|
||||
mat.setTRS(transform.position, transform.rotation, transform.scale);
|
||||
mat.mul2(mat, this.bindMat);
|
||||
quat.setFromMat4(mat);
|
||||
|
||||
const t = mat.getTranslation();
|
||||
const r = quat;
|
||||
const s = mat.getScale();
|
||||
|
||||
this.splat.move(t, r, s);
|
||||
this.top.newt.set(t, r, s);
|
||||
this.pop.newt.copy(transform);
|
||||
}
|
||||
|
||||
end() {
|
||||
// if anything changed then register the op with undo/redo system
|
||||
const { oldt, newt } = this.top;
|
||||
|
||||
if (!oldt.equals(newt)) {
|
||||
this.events.fire('edit.add', new MultiOp([this.top, this.pop]));
|
||||
}
|
||||
|
||||
this.top = null;
|
||||
this.pop = null;
|
||||
}
|
||||
}
|
||||
|
||||
export { EntityTransformHandler };
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
FILTER_LINEAR,
|
||||
FILTER_NEAREST,
|
||||
PIXELFORMAT_RGBA8,
|
||||
SEMANTIC_POSITION,
|
||||
drawQuadWithShader,
|
||||
BlendState,
|
||||
GraphicsDevice,
|
||||
Quat,
|
||||
RenderTarget,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Texture
|
||||
} from 'playcanvas';
|
||||
|
||||
import { faceFov, vertexShader, fragmentShader } from './shaders/equirect-shader';
|
||||
|
||||
// renders the six cube faces of a panorama to individual 2d textures and
|
||||
// projects them to an equirectangular target. faces are rendered wider than
|
||||
// 90° (see shaders/equirect-shader.ts) so the projection can blend the
|
||||
// overlap, feathering away per-face splat shape and sort order differences at
|
||||
// face boundaries. face order and the (s, t) bases used by the projection
|
||||
// shader are defined together in shaders/equirect-shader.ts.
|
||||
class EquirectRenderer {
|
||||
// fov of each face camera in degrees; must match the projection shader
|
||||
static faceFov = faceFov;
|
||||
|
||||
// capture-space rotations for the six face cameras: front (-Z), right (+X),
|
||||
// back (+Z), left (-X), up (+Y), down (-Y). multiply by the capture
|
||||
// orientation to get the world-space face rotation.
|
||||
static faceRotations = [
|
||||
new Quat().setFromEulerAngles(0, 0, 0),
|
||||
new Quat().setFromEulerAngles(0, -90, 0),
|
||||
new Quat().setFromEulerAngles(0, 180, 0),
|
||||
new Quat().setFromEulerAngles(0, 90, 0),
|
||||
new Quat().setFromEulerAngles(90, 0, 0),
|
||||
new Quat().setFromEulerAngles(-90, 0, 0)
|
||||
];
|
||||
|
||||
device: GraphicsDevice;
|
||||
faceTargets: RenderTarget[];
|
||||
equirectTarget: RenderTarget;
|
||||
shader: Shader;
|
||||
|
||||
constructor(device: GraphicsDevice, faceSize: number, width: number, height: number) {
|
||||
this.device = device;
|
||||
|
||||
const createTexture = (name: string, width: number, height: number, filter: number) => {
|
||||
return new Texture(device, {
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
format: PIXELFORMAT_RGBA8,
|
||||
mipmaps: false,
|
||||
minFilter: filter,
|
||||
magFilter: filter,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
};
|
||||
|
||||
this.faceTargets = [];
|
||||
for (let i = 0; i < 6; ++i) {
|
||||
this.faceTargets.push(new RenderTarget({
|
||||
colorBuffer: createTexture(`equirectFace${i}`, faceSize, faceSize, FILTER_LINEAR),
|
||||
depth: false,
|
||||
autoResolve: false
|
||||
}));
|
||||
}
|
||||
|
||||
this.equirectTarget = new RenderTarget({
|
||||
colorBuffer: createTexture('equirectColor', width, height, FILTER_NEAREST),
|
||||
depth: false,
|
||||
autoResolve: false
|
||||
});
|
||||
|
||||
this.shader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'equirectShader',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
}
|
||||
|
||||
// project the six captured faces to the equirectangular target
|
||||
project() {
|
||||
const { device, equirectTarget } = this;
|
||||
|
||||
for (let i = 0; i < 6; ++i) {
|
||||
device.scope.resolve(`uFace${i}`).setValue(this.faceTargets[i].colorBuffer);
|
||||
}
|
||||
device.scope.resolve('uTargetSize').setValue([equirectTarget.width, equirectTarget.height]);
|
||||
|
||||
device.setBlendState(BlendState.NOBLEND);
|
||||
drawQuadWithShader(device, equirectTarget, this.shader);
|
||||
}
|
||||
|
||||
// read the projected equirectangular pixels back to the cpu
|
||||
read(data: Uint8Array) {
|
||||
const { equirectTarget } = this;
|
||||
return equirectTarget.colorBuffer.read(0, 0, equirectTarget.width, equirectTarget.height, {
|
||||
renderTarget: equirectTarget,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.faceTargets.forEach((target) => {
|
||||
target.colorBuffer.destroy();
|
||||
target.destroy();
|
||||
});
|
||||
this.equirectTarget.colorBuffer.destroy();
|
||||
this.equirectTarget.destroy();
|
||||
this.faceTargets = [];
|
||||
}
|
||||
}
|
||||
|
||||
export { EquirectRenderer };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventHandler } from 'playcanvas';
|
||||
|
||||
type FunctionCallback = (...args: any[]) => any;
|
||||
|
||||
class Events extends EventHandler {
|
||||
functions = new Map<string, FunctionCallback>();
|
||||
|
||||
// declare an editor function
|
||||
function(name: string, fn: FunctionCallback) {
|
||||
if (this.functions.has(name)) {
|
||||
throw new Error(`error: function ${name} already exists`);
|
||||
}
|
||||
this.functions.set(name, fn);
|
||||
}
|
||||
|
||||
// invoke an editor function
|
||||
invoke(name: string, ...args: any[]) {
|
||||
const fn = this.functions.get(name);
|
||||
if (!fn) {
|
||||
console.log(`error: function not found '${name}'`);
|
||||
return;
|
||||
}
|
||||
return fn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
export { Events };
|
||||
@@ -0,0 +1,610 @@
|
||||
import { path, Quat, Vec3 } from 'playcanvas';
|
||||
|
||||
import { CreateDropHandler } from './drop-handler';
|
||||
import { ElementType } from './element';
|
||||
import { Events } from './events';
|
||||
import { BrowserFileSystem, MappedReadFileSystem } from './io';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
import { serializePly, serializePlyCompressed, SerializeSettings, serializeSog, serializeSplat, serializeSpz, serializeViewer, SogSettings, SpzSettings, ViewerExportSettings, WebGPUUnavailableError } from './splat-serialize';
|
||||
import { i18n } from './ui/localization';
|
||||
|
||||
// ts compiler and vscode find this type, but eslint does not
|
||||
type FilePickerAcceptType = unknown;
|
||||
|
||||
type ExportType = 'ply' | 'splat' | 'sog' | 'spz' | 'viewer';
|
||||
|
||||
type FileType = 'ply' | 'compressedPly' | 'splat' | 'sog' | 'spz' | 'htmlViewer' | 'packageViewer';
|
||||
|
||||
interface SceneExportOptions {
|
||||
filename: string;
|
||||
splatIdx: 'all' | number;
|
||||
serializeSettings: SerializeSettings;
|
||||
|
||||
// ply
|
||||
compressedPly?: boolean;
|
||||
|
||||
// sog
|
||||
sogIterations?: number;
|
||||
|
||||
// spz
|
||||
spzVersion?: 3 | 4;
|
||||
|
||||
// viewer
|
||||
viewerExportSettings?: ViewerExportSettings;
|
||||
}
|
||||
|
||||
const filePickerTypes: { [key: string]: FilePickerAcceptType } = {
|
||||
'ply': {
|
||||
description: 'Gaussian Splat PLY File',
|
||||
accept: {
|
||||
'application/ply': ['.ply']
|
||||
}
|
||||
},
|
||||
'compressedPly': {
|
||||
description: 'Compressed Gaussian Splat PLY File',
|
||||
accept: {
|
||||
'application/ply': ['.ply']
|
||||
}
|
||||
},
|
||||
'sog': {
|
||||
description: 'SOG Scene',
|
||||
accept: {
|
||||
'application/x-gaussian-splat': ['.json', '.sog'],
|
||||
'image/webp': ['.webp']
|
||||
}
|
||||
},
|
||||
'lcc': {
|
||||
description: 'LCC Scene',
|
||||
accept: {
|
||||
'application/x-lcc': ['.lcc', '.bin']
|
||||
}
|
||||
},
|
||||
'splat': {
|
||||
description: 'Splat File',
|
||||
accept: {
|
||||
'application/x-gaussian-splat': ['.splat']
|
||||
}
|
||||
},
|
||||
'ksplat': {
|
||||
description: 'KSplat File',
|
||||
accept: {
|
||||
'application/x-gaussian-splat': ['.ksplat']
|
||||
}
|
||||
},
|
||||
'spz': {
|
||||
description: 'SPZ File (Niantic)',
|
||||
accept: {
|
||||
'application/x-gaussian-splat': ['.spz']
|
||||
}
|
||||
},
|
||||
'indexTxt': {
|
||||
description: 'Colmap Poses (Images.txt)',
|
||||
accept: {
|
||||
'text/plain': ['.txt']
|
||||
}
|
||||
},
|
||||
'htmlViewer': {
|
||||
description: 'Viewer HTML',
|
||||
accept: {
|
||||
'text/html': ['.html']
|
||||
}
|
||||
},
|
||||
'packageViewer': {
|
||||
description: 'Viewer ZIP',
|
||||
accept: {
|
||||
'application/zip': ['.zip']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const allImportTypes = {
|
||||
description: 'Supported Files',
|
||||
accept: {
|
||||
'application/ply': ['.ply'],
|
||||
'application/x-gaussian-splat': ['.json', '.sog', '.splat', '.ksplat', '.spz'],
|
||||
'image/webp': ['.webp'],
|
||||
'application/x-lcc': ['.lcc', '.bin'],
|
||||
'text/plain': ['.txt']
|
||||
}
|
||||
};
|
||||
|
||||
// determine if all files share a common filename prefix followed by
|
||||
// a frame number, e.g. "frame0001.ply", "frame0002.ply", etc.
|
||||
const isPlySequence = (filenames: string[]) => {
|
||||
if (filenames.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const regex = /(.*?)(\d+)(?:\.compressed)?\.ply$/;
|
||||
const baseMatch = filenames[0].match(regex);
|
||||
if (!baseMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 1; i < filenames.length; i++) {
|
||||
const thisMatch = filenames[i].match(regex);
|
||||
if (!thisMatch || thisMatch[1] !== baseMatch[1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// sog comprises a single meta.json file and zero or more .webp files
|
||||
const isSog = (filenames: string[]) => {
|
||||
const count = (extension: string) => filenames.reduce((sum, f) => sum + (f.endsWith(extension) ? 1 : 0), 0);
|
||||
return count('meta.json') === 1;
|
||||
};
|
||||
|
||||
// The LCC file contains meta.lcc, index.bin, data.bin and shcoef.bin (optional)
|
||||
const isLcc = (filenames: string[]) => {
|
||||
const count = (extension: string) => filenames.reduce((sum, f) => sum + (f.endsWith(extension) ? 1 : 0), 0);
|
||||
return count('.lcc') === 1;
|
||||
};
|
||||
|
||||
type ImportFile = {
|
||||
filename: string;
|
||||
url?: string;
|
||||
contents?: File;
|
||||
handle?: FileSystemFileHandle;
|
||||
};
|
||||
|
||||
const vec = new Vec3();
|
||||
|
||||
// load inria camera poses from json file
|
||||
const loadCameraPoses = async (file: ImportFile, events: Events) => {
|
||||
const response = new Response(file.contents);
|
||||
const json = await response.json();
|
||||
|
||||
if (json.length > 0) {
|
||||
// sort entries by trailing number if it exists
|
||||
const sorter = (a: any, b: any) => {
|
||||
const avalue = a.id ?? a.img_name?.match(/\d*$/)?.[0];
|
||||
const bvalue = b.id ?? b.img_name?.match(/\d*$/)?.[0];
|
||||
return (avalue && bvalue) ? parseInt(avalue, 10) - parseInt(bvalue, 10) : 0;
|
||||
};
|
||||
|
||||
json.sort(sorter).forEach((pose: any, i: number) => {
|
||||
if (pose.hasOwnProperty('position') && pose.hasOwnProperty('rotation')) {
|
||||
const p = new Vec3(pose.position);
|
||||
const z = new Vec3(pose.rotation[0][2], pose.rotation[1][2], pose.rotation[2][2]);
|
||||
|
||||
// Use fixed offset along Z-axis direction instead of variable dot product
|
||||
vec.copy(z).mulScalar(10).add(p);
|
||||
|
||||
// compute max FOV from intrinsics (vertical or horizontal, whichever is larger)
|
||||
let fov = 60;
|
||||
if (pose.fx && pose.fy && pose.width && pose.height) {
|
||||
const fovX = 2 * Math.atan(pose.width / (2 * pose.fx)) * (180 / Math.PI);
|
||||
const fovY = 2 * Math.atan(pose.height / (2 * pose.fy)) * (180 / Math.PI);
|
||||
fov = Math.max(fovX, fovY);
|
||||
}
|
||||
|
||||
events.fire('camera.addPose', {
|
||||
name: pose.img_name ?? `${file.filename}_${i}`,
|
||||
frame: i,
|
||||
position: new Vec3(-p.x, -p.y, p.z),
|
||||
target: new Vec3(-vec.x, -vec.y, vec.z),
|
||||
fov
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeExtension = (filename: string) => {
|
||||
return filename.substring(0, filename.length - path.getExtension(filename).length);
|
||||
};
|
||||
|
||||
// https://colmap.github.io/format.html#images-txt
|
||||
const loadImagesTxt = async (file: ImportFile, events: Events) => {
|
||||
const response = new Response(file.contents);
|
||||
const text = await response.text();
|
||||
|
||||
// split into lines, remove comments and empty lines
|
||||
const poses = text.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => !line.startsWith('#')) // remove comments
|
||||
.filter((_, i) => i % 2 === 0) // remove every second line
|
||||
.map((line, i) => {
|
||||
const parts = line.split(' ');
|
||||
if (parts.length !== 10) {
|
||||
return null;
|
||||
}
|
||||
const name = parts[9];
|
||||
const order = parseInt(removeExtension(name).match(/\d+$/)?.[0], 10);
|
||||
return {
|
||||
w: parseFloat(parts[1]),
|
||||
x: parseFloat(parts[2]),
|
||||
y: parseFloat(parts[3]),
|
||||
z: parseFloat(parts[4]),
|
||||
tx: parseFloat(parts[5]),
|
||||
ty: parseFloat(parts[6]),
|
||||
tz: parseFloat(parts[7]),
|
||||
name: name ?? `${file.filename}_${i}`,
|
||||
order: isFinite(order) ? order : i
|
||||
};
|
||||
})
|
||||
.filter(entry => !!entry)
|
||||
.sort((a, b) => (a.order < b.order ? -1 : 1));
|
||||
|
||||
const q = new Quat();
|
||||
const t = new Vec3();
|
||||
|
||||
poses.forEach((pose, i) => {
|
||||
const { w, x, y, z, tx, ty, tz } = pose;
|
||||
|
||||
q.set(x, y, z, w).normalize().invert();
|
||||
t.set(-tx, -ty, -tz);
|
||||
q.transformVector(t, t);
|
||||
|
||||
q.transformVector(Vec3.BACK, vec);
|
||||
vec.mulScalar(10).add(t);
|
||||
|
||||
events.fire('camera.addPose', {
|
||||
name: pose.name,
|
||||
frame: i,
|
||||
position: new Vec3(-t.x, -t.y, t.z),
|
||||
target: new Vec3(-vec.x, -vec.y, vec.z)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// initialize file handler events
|
||||
const initFileHandler = (scene: Scene, events: Events, dropTarget: HTMLElement) => {
|
||||
|
||||
const showLoadError = async (message: string, filename: string) => {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.error-loading'),
|
||||
message: `${message} while loading '${filename}'`
|
||||
});
|
||||
};
|
||||
|
||||
// import splat model(s) - handles single files, SOG, and LCC formats
|
||||
const importSplatModel = async (files: ImportFile[], animationFrame: boolean) => {
|
||||
try {
|
||||
const filenames = files.map(f => f.filename.toLowerCase());
|
||||
|
||||
// Determine the main file based on format
|
||||
let mainIndex: number;
|
||||
if (filenames.some(f => f === 'meta.json')) {
|
||||
mainIndex = filenames.findIndex(f => f === 'meta.json');
|
||||
} else if (filenames.some(f => f.endsWith('.lcc'))) {
|
||||
mainIndex = filenames.findIndex(f => f.endsWith('.lcc'));
|
||||
} else {
|
||||
mainIndex = 0; // Single file case
|
||||
}
|
||||
|
||||
const mainFile = files[mainIndex];
|
||||
const baseUrl = mainFile.url ? new URL('.', new URL(mainFile.url, window.location.href)).href : undefined;
|
||||
|
||||
// Create file system with all local files, falling back to URL loading
|
||||
const fileSystem = new MappedReadFileSystem(baseUrl);
|
||||
files.forEach((f) => {
|
||||
if (f.contents) fileSystem.addFile(f.filename, f.contents);
|
||||
});
|
||||
|
||||
// For URL-only single file, use full URL as filename
|
||||
const filename = (files.length === 1 && !mainFile.contents && mainFile.url) ?
|
||||
mainFile.url :
|
||||
mainFile.filename;
|
||||
|
||||
const model = await scene.assetLoader.load(filename, fileSystem, animationFrame);
|
||||
await scene.add(model);
|
||||
return model;
|
||||
} catch (error) {
|
||||
const displayName = files[0]?.filename ?? 'unknown';
|
||||
await showLoadError(error.message ?? error, displayName);
|
||||
}
|
||||
};
|
||||
|
||||
// figure out what the set of files are (ply sequence, document, sog set, ply) and then import them
|
||||
const importFiles = async (files: ImportFile[], animationFrame = false) => {
|
||||
const filenames = files.map(f => f.filename.toLowerCase());
|
||||
|
||||
const result: Splat[] = [];
|
||||
|
||||
if (isPlySequence(filenames)) {
|
||||
// handle ply sequence
|
||||
events.fire('sequence.setPlyFrames', files.map(f => f.contents));
|
||||
events.fire('timeline.frame', 0);
|
||||
} else if (isSog(filenames) || isLcc(filenames)) {
|
||||
if (isLcc(filenames)) {
|
||||
const response = await events.invoke('showPopup', {
|
||||
type: 'okcancel',
|
||||
header: 'LCC',
|
||||
message: i18n.t('popup.lcc-upload-warning'),
|
||||
link: `${window.location.origin}/upload`
|
||||
});
|
||||
if (response.action === 'cancel') {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const model = await importSplatModel(files, animationFrame);
|
||||
if (model) result.push(model);
|
||||
} else {
|
||||
// check for unrecognized file types
|
||||
for (let i = 0; i < filenames.length; i++) {
|
||||
const filename = filenames[i].toLowerCase();
|
||||
if (['.ssproj', '.ply', '.splat', '.sog', '.webp', 'images.txt', '.json', '.ksplat', '.spz'].every(ext => !filename.endsWith(ext))) {
|
||||
await showLoadError('Unrecognized file type', filename);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// handle multiple files as independent imports
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const filename = filenames[i].toLowerCase();
|
||||
|
||||
if (filename.endsWith('.ssproj')) {
|
||||
// load ssproj document
|
||||
await events.invoke('doc.load', files[i].contents ?? (await fetch(files[i].url)).arrayBuffer(), files[i].handle);
|
||||
} else if (['.ply', '.splat', '.sog', '.ksplat', '.spz'].some(ext => filename.endsWith(ext))) {
|
||||
// load gaussian splat model
|
||||
const model = await importSplatModel([files[i]], animationFrame);
|
||||
if (model) result.push(model);
|
||||
} else if (filename.endsWith('images.txt')) {
|
||||
// load colmap frames
|
||||
await loadImagesTxt(files[i], events);
|
||||
} else if (filename.endsWith('.json')) {
|
||||
// load inria camera poses
|
||||
await loadCameraPoses(files[i], events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
events.function('import', (files: ImportFile[], animationFrame = false) => {
|
||||
return importFiles(files, animationFrame);
|
||||
});
|
||||
|
||||
// create a file selector element as fallback when showOpenFilePicker isn't available
|
||||
let fileSelector: HTMLInputElement;
|
||||
if (!window.showOpenFilePicker) {
|
||||
fileSelector = document.createElement('input');
|
||||
fileSelector.setAttribute('id', 'file-selector');
|
||||
fileSelector.setAttribute('type', 'file');
|
||||
fileSelector.setAttribute('accept', '.ply,.splat,meta.json,.json,.webp,.ssproj,.sog,.lcc,.bin,.txt,.ksplat,.spz');
|
||||
fileSelector.setAttribute('multiple', 'true');
|
||||
|
||||
fileSelector.onchange = () => {
|
||||
const files = [];
|
||||
for (let i = 0; i < fileSelector.files.length; i++) {
|
||||
const file = fileSelector.files[i];
|
||||
files.push({
|
||||
filename: file.name,
|
||||
contents: file
|
||||
});
|
||||
}
|
||||
importFiles(files);
|
||||
fileSelector.value = '';
|
||||
};
|
||||
document.body.append(fileSelector);
|
||||
}
|
||||
|
||||
// create the file drag & drop handler
|
||||
CreateDropHandler(dropTarget, (entries, shift) => {
|
||||
importFiles(entries.map((e) => {
|
||||
return {
|
||||
filename: e.filename,
|
||||
contents: e.file,
|
||||
handle: e.handle
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
// get the list of visible splats containing gaussians
|
||||
const getSplats = () => {
|
||||
return (scene.getElementsByType(ElementType.splat) as Splat[])
|
||||
.filter(splat => splat.visible)
|
||||
.filter(splat => splat.numSplats > 0);
|
||||
};
|
||||
|
||||
events.function('scene.allSplats', () => {
|
||||
return (scene.getElementsByType(ElementType.splat) as Splat[]);
|
||||
});
|
||||
|
||||
events.function('scene.splats', () => {
|
||||
return getSplats();
|
||||
});
|
||||
|
||||
events.function('scene.empty', () => {
|
||||
return getSplats().length === 0;
|
||||
});
|
||||
|
||||
events.function('scene.import', async () => {
|
||||
if (fileSelector) {
|
||||
fileSelector.click();
|
||||
} else {
|
||||
try {
|
||||
const handles = await window.showOpenFilePicker({
|
||||
id: 'SuperSplatFileImport',
|
||||
multiple: true,
|
||||
excludeAcceptAllOption: false,
|
||||
types: [
|
||||
allImportTypes,
|
||||
filePickerTypes.ply,
|
||||
filePickerTypes.compressedPly,
|
||||
filePickerTypes.splat,
|
||||
filePickerTypes.sog,
|
||||
filePickerTypes.lcc,
|
||||
filePickerTypes.ksplat,
|
||||
filePickerTypes.spz,
|
||||
filePickerTypes.indexTxt
|
||||
]
|
||||
});
|
||||
|
||||
const files = [];
|
||||
for (let i = 0; i < handles.length; i++) {
|
||||
files.push({
|
||||
filename: handles[i].name,
|
||||
contents: await handles[i].getFile()
|
||||
});
|
||||
}
|
||||
|
||||
importFiles(files);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// open a folder
|
||||
events.function('scene.openAnimation', async () => {
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker({
|
||||
id: 'SuperSplatFileOpenAnimation',
|
||||
mode: 'readwrite'
|
||||
});
|
||||
|
||||
if (handle) {
|
||||
const files = [];
|
||||
for await (const value of handle.values()) {
|
||||
if (value.kind === 'file') {
|
||||
const file = await value.getFile();
|
||||
if (file.name.toLowerCase().endsWith('.ply')) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
events.fire('sequence.setPlyFrames', files);
|
||||
events.fire('timeline.frame', 0);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
events.function('scene.export', async (exportType: ExportType) => {
|
||||
const splats = getSplats();
|
||||
|
||||
const hasFilePicker = !!window.showSaveFilePicker;
|
||||
|
||||
// show viewer export options
|
||||
const options = await events.invoke('show.exportPopup', exportType, splats.map(s => s.name), !hasFilePicker) as SceneExportOptions;
|
||||
|
||||
// return if user cancelled
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType: FileType =
|
||||
(exportType === 'viewer') ? (options.viewerExportSettings!.type === 'zip' ? 'packageViewer' : 'htmlViewer') :
|
||||
(exportType === 'ply') ? (options.compressedPly ? 'compressedPly' : 'ply') :
|
||||
(exportType === 'sog') ? 'sog' :
|
||||
(exportType === 'spz') ? 'spz' : 'splat';
|
||||
|
||||
if (hasFilePicker) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
id: 'SuperSplatFileExport',
|
||||
types: [filePickerTypes[fileType]],
|
||||
suggestedName: options.filename
|
||||
});
|
||||
await events.invoke('scene.write', fileType, options, await fileHandle.createWritable());
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await events.invoke('scene.write', fileType, options);
|
||||
}
|
||||
});
|
||||
|
||||
events.function('scene.write', async (fileType: FileType, options: SceneExportOptions, stream?: FileSystemWritableFileStream) => {
|
||||
// SOG, SPZ and viewer exports have their own progress UI, other formats use spinner
|
||||
const useSpinner = fileType !== 'sog' && fileType !== 'spz' && fileType !== 'htmlViewer' && fileType !== 'packageViewer';
|
||||
|
||||
if (useSpinner) {
|
||||
events.fire('startSpinner');
|
||||
}
|
||||
|
||||
try {
|
||||
// setTimeout so spinner/progress has a chance to activate
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
|
||||
const { filename, splatIdx, serializeSettings, viewerExportSettings } = options;
|
||||
|
||||
// Create FileSystem for output
|
||||
const fs = new BrowserFileSystem(filename, stream);
|
||||
|
||||
const splats = splatIdx === 'all' ? getSplats() : [getSplats()[splatIdx]];
|
||||
|
||||
switch (fileType) {
|
||||
case 'ply':
|
||||
await serializePly(splats, serializeSettings, fs);
|
||||
break;
|
||||
case 'compressedPly':
|
||||
serializeSettings.minOpacity = 1 / 255;
|
||||
serializeSettings.removeInvalid = true;
|
||||
await serializePlyCompressed(splats, serializeSettings, fs);
|
||||
break;
|
||||
case 'splat':
|
||||
await serializeSplat(splats, serializeSettings, fs);
|
||||
break;
|
||||
case 'sog': {
|
||||
const sogSettings: SogSettings = {
|
||||
...serializeSettings,
|
||||
minOpacity: 1 / 255,
|
||||
removeInvalid: true,
|
||||
iterations: options.sogIterations ?? 10,
|
||||
events
|
||||
};
|
||||
await serializeSog(splats, sogSettings, fs);
|
||||
break;
|
||||
}
|
||||
case 'spz': {
|
||||
const spzSettings: SpzSettings = {
|
||||
...serializeSettings,
|
||||
minOpacity: 1 / 255,
|
||||
removeInvalid: true,
|
||||
version: options.spzVersion ?? 4,
|
||||
events
|
||||
};
|
||||
await serializeSpz(splats, spzSettings, fs);
|
||||
break;
|
||||
}
|
||||
case 'htmlViewer':
|
||||
case 'packageViewer':
|
||||
await serializeViewer(splats, serializeSettings, { ...viewerExportSettings!, events }, fs);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof WebGPUUnavailableError) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.error'),
|
||||
message: i18n.t('popup.webgpu-unavailable')
|
||||
});
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.error'),
|
||||
message: `${message} while saving file`
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (useSpinner) {
|
||||
events.fire('stopSpinner');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { initFileHandler, ExportType, SceneExportOptions };
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Events } from './events';
|
||||
|
||||
const IS_SCENE_DIRTY = 'supersplat:is-scene-dirty';
|
||||
|
||||
interface IsSceneDirtyQuery {
|
||||
type: typeof IS_SCENE_DIRTY;
|
||||
}
|
||||
|
||||
interface IsSceneDirtyResponse {
|
||||
type: typeof IS_SCENE_DIRTY;
|
||||
result: boolean;
|
||||
}
|
||||
|
||||
const isSceneDirtyQuery = (data: any): data is IsSceneDirtyQuery => {
|
||||
return (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
data.type === IS_SCENE_DIRTY
|
||||
);
|
||||
};
|
||||
|
||||
const registerIframeApi = (events: Events) => {
|
||||
window.addEventListener('message', (event: MessageEvent) => {
|
||||
const source = event.source as Window | null;
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSceneDirtyQuery(event.data)) {
|
||||
const response: IsSceneDirtyResponse = {
|
||||
type: IS_SCENE_DIRTY,
|
||||
result: events.invoke('scene.dirty') as boolean
|
||||
};
|
||||
source.postMessage(response, event.origin);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { registerIframeApi };
|
||||
@@ -0,0 +1,89 @@
|
||||
// High bit flags a single index entry (1 uint32) vs a range pair [start, count] (2 uint32s).
|
||||
// This limits index values to 2^31 - 1, which is sufficient for any practical gaussian count.
|
||||
const SINGLE_BIT = 0x80000000;
|
||||
const INDEX_MASK = 0x7FFFFFFF;
|
||||
|
||||
// Emit a run of contiguous indices to the ranges array.
|
||||
const emit = (ranges: number[], start: number, count: number) => {
|
||||
if (count === 1) {
|
||||
ranges.push(start | SINGLE_BIT);
|
||||
} else {
|
||||
ranges.push(start, count);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a cursor-based membership predicate from sorted unique IDs. Returns a function
|
||||
* that tests whether a given index is in the set. Must be called with strictly increasing
|
||||
* values of i (as IndexRanges.fromPredicate guarantees).
|
||||
*/
|
||||
const sortedPredicate = (sortedIds: Uint32Array): (i: number) => boolean => {
|
||||
let cursor = 0;
|
||||
return (i: number) => {
|
||||
if (cursor < sortedIds.length && sortedIds[cursor] === i) {
|
||||
cursor++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact container for storing and iterating sets of indices. Internally stores contiguous
|
||||
* runs as [start, count] pairs and lone indices as single entries with a high-bit flag.
|
||||
* Efficient for spatially coherent data where selections form long contiguous runs.
|
||||
*/
|
||||
class IndexRanges {
|
||||
readonly data: Uint32Array;
|
||||
|
||||
private constructor(data: Uint32Array) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ranges by scanning [0, total) and including indices where pred returns true.
|
||||
* Single pass, O(total).
|
||||
*/
|
||||
static fromPredicate(total: number, pred: (i: number) => boolean) {
|
||||
const ranges: number[] = [];
|
||||
let rangeStart = -1;
|
||||
|
||||
for (let i = 0; i < total; ++i) {
|
||||
if (pred(i)) {
|
||||
if (rangeStart === -1) rangeStart = i;
|
||||
} else if (rangeStart !== -1) {
|
||||
emit(ranges, rangeStart, i - rangeStart);
|
||||
rangeStart = -1;
|
||||
}
|
||||
}
|
||||
if (rangeStart !== -1) {
|
||||
emit(ranges, rangeStart, total - rangeStart);
|
||||
}
|
||||
|
||||
return new IndexRanges(new Uint32Array(ranges));
|
||||
}
|
||||
|
||||
/** Whether there are no indices. */
|
||||
get empty() {
|
||||
return this.data.length === 0;
|
||||
}
|
||||
|
||||
/** Iterate each index. */
|
||||
forEach(fn: (index: number) => void) {
|
||||
const { data } = this;
|
||||
let r = 0;
|
||||
while (r < data.length) {
|
||||
if (data[r] & SINGLE_BIT) {
|
||||
fn(data[r] & INDEX_MASK);
|
||||
r += 1;
|
||||
} else {
|
||||
for (let i = data[r], end = data[r] + data[r + 1]; i < end; i++) {
|
||||
fn(i);
|
||||
}
|
||||
r += 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { IndexRanges, sortedPredicate };
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SuperSplat</title>
|
||||
<meta charset="utf-8" />
|
||||
<base href="__BASE_HREF__">
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
<link rel="shortcut icon" href="#">
|
||||
<meta name="description" content="SuperSplat is an advanced browser-based editor for manipulating and optimizing 3D Gaussian Splats. It is open source and engine agnostic." />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
|
||||
|
||||
<!-- Service worker -->
|
||||
<script>
|
||||
const sw = navigator.serviceWorker;
|
||||
if (sw) {
|
||||
sw.register('./sw.js')
|
||||
.then(reg => console.log('service worker registered', reg))
|
||||
.catch(err => console.log('failed to register service worker', err));
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import './ui/scss/style.scss';
|
||||
import { version as pcuiVersion, revision as pcuiRevision } from '@playcanvas/pcui';
|
||||
import { version as stVersion, revision as stRevision } from '@playcanvas/splat-transform';
|
||||
import { version as engineVersion, revision as engineRevision } from 'playcanvas';
|
||||
|
||||
import { main } from './main';
|
||||
import { version as appVersion } from '../package.json';
|
||||
|
||||
// print out versions of dependent packages
|
||||
// NOTE: add dummy style reference to prevent tree shaking
|
||||
console.log(`SuperSplat v${appVersion} | SplatTransform v${stVersion} (${stRevision}) | Engine v${engineVersion} (${engineRevision}) | PCUI v${pcuiVersion} (${pcuiRevision})`);
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
BLENDMODE_ONE,
|
||||
BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDMODE_SRC_ALPHA,
|
||||
BLENDEQUATION_ADD,
|
||||
CULLFACE_NONE,
|
||||
FUNC_LESSEQUAL,
|
||||
SEMANTIC_POSITION,
|
||||
BlendState,
|
||||
DepthState,
|
||||
Layer,
|
||||
QuadRender,
|
||||
ScopeSpace,
|
||||
Shader,
|
||||
ShaderUtils,
|
||||
Vec3,
|
||||
Mat4
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { Serializer } from './serializer';
|
||||
import { vertexShader, fragmentShader } from './shaders/infinite-grid-shader';
|
||||
|
||||
const resolve = (scope: ScopeSpace, values: any) => {
|
||||
for (const key in values) {
|
||||
scope.resolve(key).setValue(values[key]);
|
||||
}
|
||||
};
|
||||
|
||||
class InfiniteGrid extends Element {
|
||||
shader: Shader;
|
||||
quadRender: QuadRender;
|
||||
blendState = new BlendState(false);
|
||||
depthState = new DepthState(FUNC_LESSEQUAL, true);
|
||||
|
||||
visible = true;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.debug);
|
||||
}
|
||||
|
||||
add() {
|
||||
const device = this.scene.app.graphicsDevice;
|
||||
|
||||
this.shader = ShaderUtils.createShader(device, {
|
||||
uniqueName: 'infinite-grid',
|
||||
attributes: {
|
||||
vertex_position: SEMANTIC_POSITION
|
||||
},
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
|
||||
this.quadRender = new QuadRender(this.shader);
|
||||
|
||||
const blendState = new BlendState(
|
||||
true,
|
||||
BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA
|
||||
);
|
||||
|
||||
const view_position = [0, 0, 0];
|
||||
const viewProjectionMatrix = new Mat4();
|
||||
let plane;
|
||||
|
||||
this.scene.camera.camera.on('preRenderLayer', (layer: Layer, transparent: boolean) => {
|
||||
const { scene } = this;
|
||||
if (this.visible && layer === scene.worldLayer && !transparent && scene.camera.renderOverlays) {
|
||||
const { camera } = scene;
|
||||
|
||||
device.setBlendState(blendState);
|
||||
device.setCullMode(CULLFACE_NONE);
|
||||
device.setDepthState(DepthState.WRITEDEPTH);
|
||||
device.setStencilState(null, null);
|
||||
|
||||
// select the correctly plane in orthographic mode
|
||||
if (camera.ortho) {
|
||||
const cmp = (a:Vec3, b: Vec3) => 1.0 - Math.abs(a.dot(b)) < 1e-03;
|
||||
const z = camera.worldTransform.getZ();
|
||||
plane = cmp(z, Vec3.RIGHT) ? 0 : (cmp(z, Vec3.BACK) ? 2 : 1);
|
||||
} else {
|
||||
// default is xz plane
|
||||
plane = 1;
|
||||
}
|
||||
|
||||
const p = camera.position;
|
||||
view_position[0] = p.x;
|
||||
view_position[1] = p.y;
|
||||
view_position[2] = p.z;
|
||||
|
||||
viewProjectionMatrix.mul2(camera.camera.projectionMatrix, camera.camera.viewMatrix);
|
||||
|
||||
resolve(device.scope, {
|
||||
plane,
|
||||
view_position,
|
||||
matrix_viewProjection: viewProjectionMatrix.data
|
||||
});
|
||||
|
||||
this.quadRender.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.shader.destroy();
|
||||
this.quadRender.destroy();
|
||||
}
|
||||
|
||||
serialize(serializer: Serializer): void {
|
||||
serializer.pack(this.visible);
|
||||
}
|
||||
}
|
||||
|
||||
export { InfiniteGrid };
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* IO module - handles reading and writing splat data.
|
||||
*/
|
||||
|
||||
// Read operations
|
||||
export {
|
||||
BlobReadSource,
|
||||
MappedReadFileSystem,
|
||||
loadGSplatData,
|
||||
validateGSplatData
|
||||
} from './read';
|
||||
|
||||
// Write operations
|
||||
export {
|
||||
BrowserFileSystem,
|
||||
GZipWriter,
|
||||
ProgressWriter
|
||||
} from './write';
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* File system implementations for reading splat data from various sources.
|
||||
*/
|
||||
|
||||
import {
|
||||
BufferedReadStream,
|
||||
ReadFileSystem,
|
||||
ReadSource,
|
||||
ReadStream,
|
||||
UrlReadFileSystem
|
||||
} from '@playcanvas/splat-transform';
|
||||
|
||||
// Read blob in 4MB chunks to balance async overhead vs memory usage
|
||||
const BLOB_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* ReadStream implementation for reading from Blob/File.
|
||||
*/
|
||||
class BlobReadStream extends ReadStream {
|
||||
private blob: Blob;
|
||||
private offset: number;
|
||||
private end: number;
|
||||
|
||||
constructor(blob: Blob, start: number, end: number) {
|
||||
super(end - start);
|
||||
this.blob = blob;
|
||||
this.offset = start;
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
async pull(target: Uint8Array): Promise<number> {
|
||||
const remaining = this.end - this.offset;
|
||||
if (remaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const bytesToRead = Math.min(target.length, remaining);
|
||||
const slice = this.blob.slice(this.offset, this.offset + bytesToRead);
|
||||
const arrayBuffer = await slice.arrayBuffer();
|
||||
target.set(new Uint8Array(arrayBuffer));
|
||||
this.offset += bytesToRead;
|
||||
this.bytesRead += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadSource implementation for Blob/File.
|
||||
*/
|
||||
class BlobReadSource implements ReadSource {
|
||||
readonly size: number;
|
||||
readonly seekable: boolean = true;
|
||||
|
||||
private blob: Blob;
|
||||
private closed: boolean = false;
|
||||
|
||||
constructor(blob: Blob) {
|
||||
this.blob = blob;
|
||||
this.size = blob.size;
|
||||
}
|
||||
|
||||
read(start: number = 0, end: number = this.size): ReadStream {
|
||||
if (this.closed) {
|
||||
throw new Error('Source has been closed');
|
||||
}
|
||||
|
||||
const clampedStart = Math.max(0, Math.min(start, this.size));
|
||||
const clampedEnd = Math.max(clampedStart, Math.min(end, this.size));
|
||||
|
||||
// Wrap with BufferedReadStream to reduce async overhead from blob reads
|
||||
const raw = new BlobReadStream(this.blob, clampedStart, clampedEnd);
|
||||
return new BufferedReadStream(raw, BLOB_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadFileSystem for reading from browser File/Blob objects.
|
||||
* Used for drag & drop and file picker scenarios.
|
||||
*/
|
||||
class BlobReadFileSystem implements ReadFileSystem {
|
||||
private files: Map<string, Blob> = new Map();
|
||||
|
||||
/**
|
||||
* Add a file to the file system.
|
||||
*/
|
||||
set(name: string, blob: Blob): void {
|
||||
this.files.set(name.toLowerCase(), blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file by name.
|
||||
*/
|
||||
get(name: string): Blob | undefined {
|
||||
return this.files.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
createSource(filename: string): Promise<ReadSource> {
|
||||
const blob = this.files.get(filename.toLowerCase());
|
||||
if (!blob) {
|
||||
return Promise.reject(new Error(`File not found: ${filename}`));
|
||||
}
|
||||
return Promise.resolve(new BlobReadSource(blob));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadFileSystem that combines URL-based loading with local file storage.
|
||||
* Used for multi-file formats (SOG, LCC) where some files may be local
|
||||
* and others may need to be fetched from URLs.
|
||||
*/
|
||||
class MappedReadFileSystem implements ReadFileSystem {
|
||||
private blobFs: BlobReadFileSystem;
|
||||
private urlFs: UrlReadFileSystem;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.blobFs = new BlobReadFileSystem();
|
||||
this.urlFs = new UrlReadFileSystem(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a local file.
|
||||
*/
|
||||
addFile(name: string, blob: Blob): void {
|
||||
this.blobFs.set(name, blob);
|
||||
}
|
||||
|
||||
async createSource(filename: string): Promise<ReadSource> {
|
||||
// First check if we have a local blob
|
||||
const localBlob = this.blobFs.get(filename);
|
||||
if (localBlob) {
|
||||
return new BlobReadSource(localBlob);
|
||||
}
|
||||
|
||||
// Fall back to URL loading
|
||||
return await this.urlFs.createSource(filename);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BlobReadSource,
|
||||
MappedReadFileSystem
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* IO Read module - handles loading splat data from various sources.
|
||||
*/
|
||||
|
||||
// File system implementations
|
||||
export {
|
||||
BlobReadSource,
|
||||
MappedReadFileSystem
|
||||
} from './file-systems';
|
||||
|
||||
// Loading functions
|
||||
export {
|
||||
loadGSplatData,
|
||||
validateGSplatData
|
||||
} from './loader';
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Unified loader for all splat file formats using splat-transform.
|
||||
*/
|
||||
|
||||
import {
|
||||
getInputFormat,
|
||||
readFile,
|
||||
sortMortonOrder,
|
||||
Column,
|
||||
ColumnType,
|
||||
DataTable,
|
||||
Options,
|
||||
ReadFileSystem,
|
||||
Transform,
|
||||
ZipReadFileSystem
|
||||
} from '@playcanvas/splat-transform';
|
||||
import { GSplatData } from 'playcanvas';
|
||||
|
||||
type LoadResult = {
|
||||
gsplatData: GSplatData;
|
||||
transform: Transform;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default options for readFile.
|
||||
*/
|
||||
const defaultOptions: Options = {
|
||||
iterations: 10,
|
||||
lodSelect: [0],
|
||||
unbundled: false,
|
||||
lodChunkCount: 512,
|
||||
lodChunkExtent: 16
|
||||
};
|
||||
|
||||
/**
|
||||
* Map splat-transform column types to GSplatData property types.
|
||||
*/
|
||||
const columnTypeToGSplatType = (colType: ColumnType | null): string => {
|
||||
switch (colType) {
|
||||
case 'int8': return 'char';
|
||||
case 'uint8': return 'uchar';
|
||||
case 'int16': return 'short';
|
||||
case 'uint16': return 'ushort';
|
||||
case 'int32': return 'int';
|
||||
case 'uint32': return 'uint';
|
||||
case 'float32': return 'float';
|
||||
case 'float64': return 'double';
|
||||
default: return 'float';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a splat-transform DataTable to PlayCanvas GSplatData.
|
||||
*/
|
||||
const dataTableToGSplatData = (dataTable: DataTable): GSplatData => {
|
||||
const properties = dataTable.columns.map((col: Column) => ({
|
||||
type: columnTypeToGSplatType(col.dataType),
|
||||
name: col.name,
|
||||
storage: col.data,
|
||||
byteSize: col.data.BYTES_PER_ELEMENT
|
||||
}));
|
||||
|
||||
const gsplatData = new GSplatData([{
|
||||
name: 'vertex',
|
||||
count: dataTable.numRows,
|
||||
properties
|
||||
}]);
|
||||
|
||||
// Support loading 2D splats by adding scale_2 property with almost 0 scale
|
||||
if (gsplatData.getProp('scale_0') && gsplatData.getProp('scale_1') && !gsplatData.getProp('scale_2')) {
|
||||
const scale2 = new Float32Array(gsplatData.numSplats).fill(Math.log(1e-6));
|
||||
gsplatData.addProp('scale_2', scale2);
|
||||
|
||||
// Place the new scale_2 property just after scale_1
|
||||
const props = gsplatData.getElement('vertex').properties;
|
||||
props.splice(props.findIndex((prop: any) => prop.name === 'scale_1') + 1, 0, props.splice(props.length - 1, 1)[0]);
|
||||
}
|
||||
|
||||
return gsplatData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a file using splat-transform and convert to GSplatData.
|
||||
* @param filename - The filename to load
|
||||
* @param fileSystem - The file system to read from
|
||||
* @param skipReorder - Skip morton reordering (for files already in morton order or animation playback)
|
||||
*/
|
||||
const loadGSplatData = async (filename: string, fileSystem: ReadFileSystem, skipReorder?: boolean): Promise<LoadResult> => {
|
||||
const inputFormat = getInputFormat(filename);
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
|
||||
// Handle bundled SOG (.sog extension) - wrap with ZipReadFileSystem
|
||||
if (inputFormat === 'sog' && lowerFilename.endsWith('.sog')) {
|
||||
const source = await fileSystem.createSource(filename);
|
||||
const zipFs = new ZipReadFileSystem(source);
|
||||
try {
|
||||
const tables = await readFile({
|
||||
filename: 'meta.json',
|
||||
inputFormat: 'sog',
|
||||
options: defaultOptions,
|
||||
params: [],
|
||||
fileSystem: zipFs
|
||||
});
|
||||
return { gsplatData: dataTableToGSplatData(tables[0]), transform: tables[0].transform };
|
||||
} finally {
|
||||
zipFs.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Read the file using splat-transform
|
||||
const tables = await readFile({
|
||||
filename,
|
||||
inputFormat,
|
||||
options: defaultOptions,
|
||||
params: [],
|
||||
fileSystem
|
||||
});
|
||||
|
||||
// Reorder data into morton order for better render performance.
|
||||
// Skip reordering for:
|
||||
// - SOG format (already in morton order)
|
||||
// - Compressed PLY (already in morton order from write-compressed-ply)
|
||||
// - When skipReorder is true (ssproj files are already ordered, animation frames need speed)
|
||||
const isCompressedPly = lowerFilename.endsWith('.compressed.ply');
|
||||
if (inputFormat !== 'sog' && !isCompressedPly && !skipReorder) {
|
||||
const indices = new Uint32Array(tables[0].numRows);
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
indices[i] = i;
|
||||
}
|
||||
sortMortonOrder(tables[0], indices);
|
||||
tables[0].permuteRowsInPlace(indices);
|
||||
}
|
||||
|
||||
// Convert to GSplatData (use first table, as most formats return single table)
|
||||
// LCC may return multiple tables for different LOD levels - we use the first (highest detail)
|
||||
return { gsplatData: dataTableToGSplatData(tables[0]), transform: tables[0].transform };
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that GSplatData contains required properties.
|
||||
*/
|
||||
const validateGSplatData = (gsplatData: GSplatData): void => {
|
||||
const required = [
|
||||
'x', 'y', 'z',
|
||||
'scale_0', 'scale_1', 'scale_2',
|
||||
'rot_0', 'rot_1', 'rot_2', 'rot_3',
|
||||
'f_dc_0', 'f_dc_1', 'f_dc_2', 'opacity'
|
||||
];
|
||||
|
||||
const missing = required.filter(x => !gsplatData.getProp(x));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`This file does not contain gaussian splatting data. The following properties are missing: ${missing.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
loadGSplatData,
|
||||
validateGSplatData
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Browser FileSystem implementation for splat-transform compatibility.
|
||||
* Provides FileSystem abstraction for browser file operations.
|
||||
*/
|
||||
|
||||
import { MemoryFileSystem, type FileSystem, type Writer } from '@playcanvas/splat-transform';
|
||||
|
||||
/**
|
||||
* Writer implementation for FileSystemWritableFileStream (File System Access API).
|
||||
*/
|
||||
class BrowserFileWriter implements Writer {
|
||||
private stream: FileSystemWritableFileStream;
|
||||
private cursor: number = 0;
|
||||
private ready: Promise<void>;
|
||||
|
||||
constructor(stream: FileSystemWritableFileStream) {
|
||||
this.stream = stream;
|
||||
this.ready = this.stream.seek(0);
|
||||
}
|
||||
|
||||
get bytesWritten(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await this.ready;
|
||||
this.cursor += data.byteLength;
|
||||
await this.stream.write(data as unknown as ArrayBuffer);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.ready;
|
||||
await this.stream.truncate(this.cursor);
|
||||
await this.stream.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download for the given data.
|
||||
*/
|
||||
const triggerDownload = (data: Uint8Array, filename: string): void => {
|
||||
const blob = new Blob([data as BlobPart], { type: 'application/octet-stream' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const lnk = document.createElement('a');
|
||||
lnk.download = filename;
|
||||
lnk.href = url;
|
||||
|
||||
// create a "fake" click-event to trigger the download
|
||||
if (document.createEvent) {
|
||||
const e = document.createEvent('MouseEvents');
|
||||
e.initMouseEvent('click', true, true, window,
|
||||
0, 0, 0, 0, 0, false, false, false,
|
||||
false, 0, null);
|
||||
lnk.dispatchEvent(e);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
lnk.fireEvent?.('onclick');
|
||||
}
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Writer implementation that triggers a browser download on close.
|
||||
* Uses MemoryFileSystem internally for efficient buffer management.
|
||||
*/
|
||||
class BrowserDownloadWriter implements Writer {
|
||||
private memFs: MemoryFileSystem;
|
||||
private innerWriter: Writer;
|
||||
private filename: string;
|
||||
|
||||
constructor(filename: string) {
|
||||
this.filename = filename;
|
||||
this.memFs = new MemoryFileSystem();
|
||||
this.innerWriter = this.memFs.createWriter(filename);
|
||||
}
|
||||
|
||||
get bytesWritten(): number {
|
||||
return this.innerWriter.bytesWritten;
|
||||
}
|
||||
|
||||
write(data: Uint8Array): void {
|
||||
this.innerWriter.write(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.innerWriter.close();
|
||||
const data = this.memFs.results.get(this.filename);
|
||||
if (data) {
|
||||
triggerDownload(data, this.filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystem implementation for browser environments.
|
||||
* Supports both File System Access API (stream) and fallback download.
|
||||
*/
|
||||
class BrowserFileSystem implements FileSystem {
|
||||
private stream?: FileSystemWritableFileStream;
|
||||
private filename: string;
|
||||
|
||||
/**
|
||||
* Create a BrowserFileSystem.
|
||||
* @param filename - The filename for downloads (fallback mode)
|
||||
* @param stream - Optional FileSystemWritableFileStream for direct file access
|
||||
*/
|
||||
constructor(filename: string, stream?: FileSystemWritableFileStream) {
|
||||
this.filename = filename;
|
||||
this.stream = stream;
|
||||
}
|
||||
|
||||
createWriter(_filename: string): Writer {
|
||||
if (this.stream) {
|
||||
return new BrowserFileWriter(this.stream);
|
||||
}
|
||||
return new BrowserDownloadWriter(this.filename);
|
||||
}
|
||||
|
||||
mkdir(_path: string): Promise<void> {
|
||||
// No-op in browser - directories not supported
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
export { BrowserFileSystem };
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* IO Write module - handles writing splat data to various destinations.
|
||||
*/
|
||||
|
||||
// Browser file system
|
||||
export { BrowserFileSystem } from './browser-file-system';
|
||||
|
||||
// Writer utilities
|
||||
export {
|
||||
GZipWriter,
|
||||
ProgressWriter
|
||||
} from './writer';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Writer utilities for splat serialization.
|
||||
*/
|
||||
|
||||
import type { Writer } from '@playcanvas/splat-transform';
|
||||
|
||||
/**
|
||||
* Compress the incoming stream with gzip.
|
||||
*/
|
||||
class GZipWriter implements Writer {
|
||||
write: (data: Uint8Array) => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
|
||||
private cursor = 0;
|
||||
|
||||
get bytesWritten(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
constructor(writer: Writer) {
|
||||
const stream = new CompressionStream('gzip');
|
||||
const streamWriter = stream.writable.getWriter();
|
||||
const streamReader = stream.readable.getReader();
|
||||
|
||||
// hook up the reader side of the compressed stream
|
||||
const reader = (async () => {
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) break;
|
||||
await writer.write(value);
|
||||
}
|
||||
})();
|
||||
|
||||
this.write = async (data: Uint8Array) => {
|
||||
this.cursor += data.byteLength;
|
||||
await streamWriter.ready;
|
||||
await streamWriter.write(data as unknown as ArrayBuffer);
|
||||
};
|
||||
|
||||
this.close = async () => {
|
||||
// close the writer, we're done
|
||||
await streamWriter.close();
|
||||
|
||||
// wait for the reader to finish sending data
|
||||
await reader;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that tracks write progress.
|
||||
*/
|
||||
class ProgressWriter implements Writer {
|
||||
write: (data: Uint8Array) => Promise<void>;
|
||||
close: () => void;
|
||||
|
||||
private cursor = 0;
|
||||
|
||||
get bytesWritten(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
constructor(writer: Writer, totalBytes: number, progress?: (progress: number, total: number) => void) {
|
||||
this.write = async (data: Uint8Array) => {
|
||||
this.cursor += data.byteLength;
|
||||
await writer.write(data);
|
||||
progress?.(this.cursor, totalBytes);
|
||||
};
|
||||
|
||||
this.close = () => {
|
||||
if (this.cursor !== totalBytes) {
|
||||
throw new Error(`ProgressWriter: expected ${totalBytes} bytes, but wrote ${this.cursor} bytes`);
|
||||
}
|
||||
progress?.(this.cursor, totalBytes);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { GZipWriter, ProgressWriter };
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
import { WebPCodec, WorkerQueue } from '@playcanvas/splat-transform';
|
||||
import { Color, createGraphicsDevice } from 'playcanvas';
|
||||
|
||||
import { registerCameraPosesEvents } from './camera-poses';
|
||||
import { CommandQueue } from './command-queue';
|
||||
import { registerDocEvents } from './doc';
|
||||
import { EditHistory } from './edit-history';
|
||||
import { registerEditorEvents } from './editor';
|
||||
import { Events } from './events';
|
||||
import { initFileHandler } from './file-handler';
|
||||
import { registerIframeApi } from './iframe-api';
|
||||
import { registerPreferences } from './preferences';
|
||||
import { registerPublishEvents } from './publish';
|
||||
import { registerRenderEvents } from './render';
|
||||
import { Scene } from './scene';
|
||||
import { getSceneConfig } from './scene-config';
|
||||
import { registerSelectionEvents } from './selection';
|
||||
import { registerSequenceEvents } from './sequence';
|
||||
import { ShortcutManager } from './shortcut-manager';
|
||||
import { registerTimelineEvents } from './timeline';
|
||||
import { BoxSelection } from './tools/box-selection';
|
||||
import { BrushSelection } from './tools/brush-selection';
|
||||
import { EyedropperSelection } from './tools/eyedropper-selection';
|
||||
import { FloodSelection } from './tools/flood-selection';
|
||||
import { LassoSelection } from './tools/lasso-selection';
|
||||
import { MeasureTool } from './tools/measure-tool';
|
||||
import { MoveTool } from './tools/move-tool';
|
||||
import { PolygonSelection } from './tools/polygon-selection';
|
||||
import { RectSelection } from './tools/rect-selection';
|
||||
import { RotateTool } from './tools/rotate-tool';
|
||||
import { ScaleTool } from './tools/scale-tool';
|
||||
import { SphereSelection } from './tools/sphere-selection';
|
||||
import { ToolManager } from './tools/tool-manager';
|
||||
import { registerTrackManagerEvents } from './track-manager';
|
||||
import { registerTransformHandlerEvents } from './transform-handler';
|
||||
import { BoundDimensionsOverlay } from './ui/bound-dimensions-overlay';
|
||||
import { EditorUI } from './ui/editor';
|
||||
import { i18n } from './ui/localization';
|
||||
import { registerSelectCursor } from './ui/select-cursor';
|
||||
|
||||
declare global {
|
||||
interface LaunchParams {
|
||||
readonly files: FileSystemFileHandle[];
|
||||
}
|
||||
|
||||
interface Window {
|
||||
launchQueue: {
|
||||
setConsumer: (callback: (launchParams: LaunchParams) => void) => void;
|
||||
};
|
||||
scene: Scene;
|
||||
}
|
||||
}
|
||||
|
||||
const getURLArgs = () => {
|
||||
// extract settings from command line in non-prod builds only
|
||||
const config = {};
|
||||
|
||||
const apply = (key: string, value: string) => {
|
||||
let obj: any = config;
|
||||
key.split('.').forEach((k, i, a) => {
|
||||
if (i === a.length - 1) {
|
||||
obj[k] = value;
|
||||
} else {
|
||||
if (!obj.hasOwnProperty(k)) {
|
||||
obj[k] = {};
|
||||
}
|
||||
obj = obj[k];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const params = new URLSearchParams(window.location.search.slice(1));
|
||||
params.forEach((value: string, key: string) => {
|
||||
apply(key, value);
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
// root events object
|
||||
const events = new Events();
|
||||
|
||||
// url
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
// shared command queue for all async splat work (GPU readbacks + history mutations).
|
||||
// every consumer that needs ordering relative to other commands enqueues here.
|
||||
const commandQueue = new CommandQueue();
|
||||
|
||||
// edit history (uses the shared queue internally)
|
||||
const editHistory = new EditHistory(events, commandQueue);
|
||||
|
||||
// expose the queue as an event for any module that needs to serialise async work
|
||||
// alongside history mutations.
|
||||
events.function('queue', (fn: () => Promise<void> | void) => commandQueue.enqueue(fn));
|
||||
|
||||
// init localization
|
||||
await i18n.init();
|
||||
|
||||
// Configure WebP WASM for SOG format (used for both reading and writing)
|
||||
WebPCodec.wasmUrl = new URL('static/lib/webp/webp.wasm', document.baseURI).toString();
|
||||
|
||||
// Run SOG writing inline rather than in worker threads. We don't ship
|
||||
// splat-transform's worker.mjs, so leaving the pool enabled makes it try to
|
||||
// spawn a worker that 404s; under SOG's parallel task load it then hangs
|
||||
// instead of falling back, producing an empty export.
|
||||
WorkerQueue.maxWorkers = 0;
|
||||
|
||||
// register events that only need the events object (before UI is created)
|
||||
registerTimelineEvents(events);
|
||||
registerCameraPosesEvents(events);
|
||||
registerTrackManagerEvents(events);
|
||||
registerTransformHandlerEvents(events);
|
||||
registerPublishEvents(events);
|
||||
registerIframeApi(events);
|
||||
|
||||
// initialize shortcuts
|
||||
const shortcutManager = new ShortcutManager(events);
|
||||
events.function('shortcutManager', () => shortcutManager);
|
||||
|
||||
// editor ui
|
||||
const editorUI = new EditorUI(events);
|
||||
|
||||
// create the graphics device
|
||||
const graphicsDevice = await createGraphicsDevice(editorUI.canvas, {
|
||||
deviceTypes: ['webgl2'],
|
||||
antialias: false,
|
||||
depth: false,
|
||||
stencil: false,
|
||||
xrCompatible: false,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
|
||||
const urlArgs = getURLArgs();
|
||||
|
||||
const overrides = [
|
||||
urlArgs
|
||||
];
|
||||
|
||||
// resolve scene config
|
||||
const sceneConfig = getSceneConfig(overrides);
|
||||
|
||||
// construct the manager
|
||||
const scene = new Scene(
|
||||
events,
|
||||
sceneConfig,
|
||||
editorUI.canvas,
|
||||
graphicsDevice,
|
||||
commandQueue
|
||||
);
|
||||
|
||||
// colors
|
||||
const bgClr = new Color();
|
||||
const selectedClr = new Color();
|
||||
const unselectedClr = new Color();
|
||||
const lockedClr = new Color();
|
||||
|
||||
const setClr = (target: Color, value: Color, event: string) => {
|
||||
if (!target.equals(value)) {
|
||||
target.copy(value);
|
||||
events.fire(event, target);
|
||||
}
|
||||
};
|
||||
|
||||
const setBgClr = (clr: Color) => {
|
||||
setClr(bgClr, clr, 'bgClr');
|
||||
};
|
||||
const setSelectedClr = (clr: Color) => {
|
||||
setClr(selectedClr, clr, 'selectedClr');
|
||||
};
|
||||
const setUnselectedClr = (clr: Color) => {
|
||||
setClr(unselectedClr, clr, 'unselectedClr');
|
||||
};
|
||||
const setLockedClr = (clr: Color) => {
|
||||
setClr(lockedClr, clr, 'lockedClr');
|
||||
};
|
||||
|
||||
events.on('setBgClr', (clr: Color) => {
|
||||
setBgClr(clr);
|
||||
});
|
||||
events.on('setSelectedClr', (clr: Color) => {
|
||||
setSelectedClr(clr);
|
||||
});
|
||||
events.on('setUnselectedClr', (clr: Color) => {
|
||||
setUnselectedClr(clr);
|
||||
});
|
||||
events.on('setLockedClr', (clr: Color) => {
|
||||
setLockedClr(clr);
|
||||
});
|
||||
|
||||
events.function('bgClr', () => {
|
||||
return bgClr;
|
||||
});
|
||||
events.function('selectedClr', () => {
|
||||
return selectedClr;
|
||||
});
|
||||
events.function('unselectedClr', () => {
|
||||
return unselectedClr;
|
||||
});
|
||||
events.function('lockedClr', () => {
|
||||
return lockedClr;
|
||||
});
|
||||
|
||||
events.on('bgClr', (clr: Color) => {
|
||||
const cnv = (v: number) => `${Math.max(0, Math.min(255, (v * 255))).toFixed(0)}`;
|
||||
document.body.style.backgroundColor = `rgba(${cnv(clr.r)},${cnv(clr.g)},${cnv(clr.b)},1)`;
|
||||
});
|
||||
events.on('selectedClr', (clr: Color) => {
|
||||
scene.forceRender = true;
|
||||
});
|
||||
events.on('unselectedClr', (clr: Color) => {
|
||||
scene.forceRender = true;
|
||||
});
|
||||
events.on('lockedClr', (clr: Color) => {
|
||||
scene.forceRender = true;
|
||||
});
|
||||
|
||||
// initialize colors from application config
|
||||
const toColor = (value: { r: number, g: number, b: number, a: number }) => {
|
||||
return new Color(value.r, value.g, value.b, value.a);
|
||||
};
|
||||
setBgClr(toColor(sceneConfig.bgClr));
|
||||
setSelectedClr(toColor(sceneConfig.selectedClr));
|
||||
setUnselectedClr(toColor(sceneConfig.unselectedClr));
|
||||
setLockedClr(toColor(sceneConfig.lockedClr));
|
||||
|
||||
// create the mask selection canvas
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
const maskContext = maskCanvas.getContext('2d');
|
||||
maskCanvas.setAttribute('id', 'mask-canvas');
|
||||
maskContext.globalCompositeOperation = 'copy';
|
||||
|
||||
const mask = {
|
||||
canvas: maskCanvas,
|
||||
context: maskContext
|
||||
};
|
||||
|
||||
// tool manager
|
||||
const toolManager = new ToolManager(events);
|
||||
toolManager.register('rectSelection', new RectSelection(events, editorUI.toolsContainer.dom));
|
||||
toolManager.register('brushSelection', new BrushSelection(events, editorUI.toolsContainer.dom, mask));
|
||||
toolManager.register('floodSelection', new FloodSelection(events, editorUI.toolsContainer.dom, mask, editorUI.canvasContainer));
|
||||
toolManager.register('polygonSelection', new PolygonSelection(events, editorUI.toolsContainer.dom, mask));
|
||||
toolManager.register('lassoSelection', new LassoSelection(events, editorUI.toolsContainer.dom, mask));
|
||||
toolManager.register('sphereSelection', new SphereSelection(events, scene, editorUI.canvasContainer));
|
||||
toolManager.register('boxSelection', new BoxSelection(events, scene, editorUI.canvasContainer));
|
||||
toolManager.register('eyedropperSelection', new EyedropperSelection(events, editorUI.toolsContainer.dom, editorUI.canvasContainer));
|
||||
toolManager.register('move', new MoveTool(events, scene));
|
||||
toolManager.register('rotate', new RotateTool(events, scene));
|
||||
toolManager.register('scale', new ScaleTool(events, scene));
|
||||
toolManager.register('measure', new MeasureTool(events, scene, editorUI.toolsContainer.dom, editorUI.canvasContainer));
|
||||
|
||||
const boundDimensionsOverlay = new BoundDimensionsOverlay(events, scene, editorUI.canvasContainer);
|
||||
|
||||
editorUI.toolsContainer.dom.appendChild(maskCanvas);
|
||||
|
||||
// show the active selection op (add/remove/intersect) at the cursor
|
||||
registerSelectCursor(events, editorUI.toolsContainer.dom);
|
||||
|
||||
window.scene = scene;
|
||||
|
||||
// register events that need scene or other dependencies
|
||||
registerEditorEvents(events, editHistory, scene);
|
||||
registerSelectionEvents(events, scene);
|
||||
registerSequenceEvents(events, scene);
|
||||
registerDocEvents(scene, events);
|
||||
registerRenderEvents(scene, events);
|
||||
initFileHandler(scene, events, editorUI.appContainer.dom);
|
||||
|
||||
// apply stored user preferences and start capturing changes to them.
|
||||
// registered after the boot-time initialization events above so they are
|
||||
// never captured as user changes.
|
||||
registerPreferences(events, sceneConfig, urlArgs);
|
||||
|
||||
// load async models
|
||||
scene.start();
|
||||
|
||||
// handle load params
|
||||
const loadList = url.searchParams.getAll('load');
|
||||
const filenameList = url.searchParams.getAll('filename');
|
||||
for (const [i, value] of loadList.entries()) {
|
||||
const decoded = decodeURIComponent(value);
|
||||
const filename = i < filenameList.length ?
|
||||
decodeURIComponent(filenameList[i]) :
|
||||
decoded.split('/').pop();
|
||||
|
||||
await events.invoke('import', [{
|
||||
filename,
|
||||
url: decoded
|
||||
}]);
|
||||
}
|
||||
|
||||
|
||||
// handle OS-based file association in PWA mode
|
||||
if ('launchQueue' in window) {
|
||||
window.launchQueue.setConsumer(async (launchParams: LaunchParams) => {
|
||||
for (const file of launchParams.files) {
|
||||
await events.invoke('import', [{
|
||||
filename: file.name,
|
||||
contents: await file.getFile()
|
||||
}]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export { main };
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "SuperSplat",
|
||||
"short_name": "SuperSplat",
|
||||
"description": "SuperSplat is an advanced browser-based editor for manipulating and optimizing 3D Gaussian Splats. It is open source and engine agnostic.",
|
||||
"display": "fullscreen",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"background_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./static/icons/logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./static/icons/logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "./static/images/screenshot-narrow.jpg",
|
||||
"type": "image/jpeg",
|
||||
"sizes": "2160x3840",
|
||||
"form_factor": "narrow"
|
||||
},
|
||||
{
|
||||
"src": "./static/images/screenshot-wide.jpg",
|
||||
"type": "image/jpeg",
|
||||
"sizes": "3840x2160",
|
||||
"form_factor": "wide"
|
||||
}
|
||||
],
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "./",
|
||||
"accept": {
|
||||
"application/ply": [".ply"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
BlendState,
|
||||
Layer
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { vertexShader, fragmentShader } from './shaders/outline-shader';
|
||||
import { ShaderQuad, SimpleRenderPass } from './utils/simple-render-pass';
|
||||
|
||||
class Outline extends Element {
|
||||
shaderQuad: ShaderQuad;
|
||||
renderPass: SimpleRenderPass;
|
||||
enabled = true;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.other);
|
||||
}
|
||||
|
||||
add() {
|
||||
const device = this.scene.app.graphicsDevice;
|
||||
|
||||
this.shaderQuad = new ShaderQuad(device, vertexShader, fragmentShader, 'apply-outline');
|
||||
this.renderPass = new SimpleRenderPass(device, this.shaderQuad, {
|
||||
blendState: BlendState.ALPHABLEND
|
||||
});
|
||||
|
||||
const clr = [1, 1, 1, 1];
|
||||
|
||||
const { camera, events } = this.scene;
|
||||
|
||||
camera.camera.on('postRenderLayer', (layer: Layer, transparent: boolean) => {
|
||||
// only apply when outline mode is enabled
|
||||
if (!this.enabled || !events.invoke('view.outlineSelection')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// apply at the end of the gizmo layer (after overlay renders)
|
||||
if (layer !== this.scene.gizmoLayer || !transparent) {
|
||||
return;
|
||||
}
|
||||
|
||||
events.invoke('selectedClr').toArray(clr);
|
||||
|
||||
this.renderPass.execute({
|
||||
srcTexture: camera.workTarget.colorBuffer,
|
||||
alphaCutoff: events.invoke('camera.mode') === 'rings' ? 0.0 : 0.8,
|
||||
clr
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
// event listeners are cleaned up when camera is destroyed
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
// no longer need to manage a separate camera
|
||||
}
|
||||
}
|
||||
|
||||
export { Outline };
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
// platform,
|
||||
// SoundManager,
|
||||
// Lightmapper,
|
||||
// BatchManager,
|
||||
AppBase,
|
||||
AppOptions,
|
||||
// script,
|
||||
// AnimationComponentSystem,
|
||||
AnimComponentSystem,
|
||||
// AudioListenerComponentSystem,
|
||||
// AudioSourceComponentSystem,
|
||||
// ButtonComponentSystem,
|
||||
// CollisionComponentSystem,
|
||||
// ElementComponentSystem,
|
||||
// JointComponentSystem,
|
||||
// LayoutChildComponentSystem,
|
||||
// LayoutGroupComponentSystem,
|
||||
// ModelComponentSystem,
|
||||
// ParticleSystemComponentSystem,
|
||||
RenderComponentSystem,
|
||||
// RigidBodyComponentSystem,
|
||||
// ScreenComponentSystem,
|
||||
// ScriptLegacyComponentSystem,
|
||||
// ScrollViewComponentSystem,
|
||||
// ScrollbarComponentSystem,
|
||||
// SoundComponentSystem,
|
||||
// SpriteComponentSystem,
|
||||
// ZoneComponentSystem,
|
||||
CameraComponentSystem,
|
||||
LightComponentSystem,
|
||||
GSplatComponentSystem,
|
||||
// ScriptComponentSystem,
|
||||
RenderHandler,
|
||||
// AnimationHandler,
|
||||
AnimClipHandler,
|
||||
AnimStateGraphHandler,
|
||||
// AudioHandler,
|
||||
// BinaryHandler,
|
||||
ContainerHandler,
|
||||
// CssHandler,
|
||||
CubemapHandler,
|
||||
// FolderHandler,
|
||||
// FontHandler,
|
||||
GSplatHandler,
|
||||
// HierarchyHandler,
|
||||
// HtmlHandler,
|
||||
// JsonHandler,
|
||||
// MaterialHandler,
|
||||
// ModelHandler,
|
||||
// SceneHandler,
|
||||
// ScriptHandler,
|
||||
// ShaderHandler,
|
||||
// SpriteHandler,
|
||||
// TemplateHandler,
|
||||
// TextHandler,
|
||||
// TextureAtlasHandler,
|
||||
TextureHandler
|
||||
// XrManager
|
||||
} from 'playcanvas';
|
||||
|
||||
class PCApp extends AppBase {
|
||||
constructor(canvas: HTMLCanvasElement, options: any) {
|
||||
super(canvas);
|
||||
|
||||
const appOptions = new AppOptions();
|
||||
|
||||
appOptions.graphicsDevice = options.graphicsDevice;
|
||||
this.addComponentSystems(appOptions);
|
||||
this.addResourceHandles(appOptions);
|
||||
|
||||
appOptions.elementInput = options.elementInput;
|
||||
appOptions.keyboard = options.keyboard;
|
||||
appOptions.mouse = options.mouse;
|
||||
appOptions.touch = options.touch;
|
||||
appOptions.gamepads = options.gamepads;
|
||||
|
||||
appOptions.scriptPrefix = options.scriptPrefix;
|
||||
appOptions.assetPrefix = options.assetPrefix;
|
||||
appOptions.scriptsOrder = options.scriptsOrder;
|
||||
|
||||
// appOptions.soundManager = new SoundManager(options);
|
||||
// appOptions.lightmapper = Lightmapper;
|
||||
// appOptions.batchManager = BatchManager;
|
||||
// appOptions.xr = XrManager;
|
||||
|
||||
this.init(appOptions);
|
||||
}
|
||||
|
||||
addComponentSystems(appOptions: AppOptions) {
|
||||
appOptions.componentSystems = [
|
||||
// RigidBodyComponentSystem,
|
||||
// CollisionComponentSystem,
|
||||
// JointComponentSystem,
|
||||
// AnimationComponentSystem,
|
||||
// @ts-ignore
|
||||
AnimComponentSystem,
|
||||
// ModelComponentSystem,
|
||||
// @ts-ignore
|
||||
RenderComponentSystem,
|
||||
// @ts-ignore
|
||||
CameraComponentSystem,
|
||||
// @ts-ignore
|
||||
LightComponentSystem,
|
||||
// script.legacy ? ScriptLegacyComponentSystem : ScriptComponentSystem,
|
||||
// AudioSourceComponentSystem,
|
||||
// SoundComponentSystem,
|
||||
// AudioListenerComponentSystem,
|
||||
// ParticleSystemComponentSystem,
|
||||
// ScreenComponentSystem,
|
||||
// ElementComponentSystem,
|
||||
// ButtonComponentSystem,
|
||||
// ScrollViewComponentSystem,
|
||||
// ScrollbarComponentSystem,
|
||||
// SpriteComponentSystem,
|
||||
// LayoutGroupComponentSystem,
|
||||
// LayoutChildComponentSystem,
|
||||
// ZoneComponentSystem,
|
||||
GSplatComponentSystem
|
||||
];
|
||||
}
|
||||
|
||||
addResourceHandles(appOptions: AppOptions) {
|
||||
appOptions.resourceHandlers = [
|
||||
// @ts-ignore
|
||||
RenderHandler,
|
||||
// AnimationHandler,
|
||||
// @ts-ignore
|
||||
AnimClipHandler,
|
||||
// @ts-ignore
|
||||
AnimStateGraphHandler,
|
||||
// ModelHandler,
|
||||
// MaterialHandler,
|
||||
// @ts-ignore
|
||||
TextureHandler,
|
||||
// TextHandler,
|
||||
// JsonHandler,
|
||||
// AudioHandler,
|
||||
// ScriptHandler,
|
||||
// SceneHandler,
|
||||
// @ts-ignore
|
||||
CubemapHandler,
|
||||
// HtmlHandler,
|
||||
// CssHandler,
|
||||
// ShaderHandler,
|
||||
// HierarchyHandler,
|
||||
// FolderHandler,
|
||||
// FontHandler,
|
||||
// BinaryHandler,
|
||||
// TextureAtlasHandler,
|
||||
// SpriteHandler,
|
||||
// TemplateHandler,
|
||||
// @ts-ignore
|
||||
ContainerHandler,
|
||||
GSplatHandler
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export { PCApp };
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
BLENDEQUATION_ADD,
|
||||
BLENDMODE_ONE,
|
||||
BLENDMODE_ZERO,
|
||||
BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BlendState,
|
||||
Color,
|
||||
GraphicsDevice,
|
||||
RenderPassPicker,
|
||||
RenderTarget
|
||||
} from 'playcanvas';
|
||||
|
||||
import { ElementType } from './element';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
|
||||
const idClearColor = new Color(1, 1, 1, 1);
|
||||
const depthClearColor = new Color(0, 0, 0, 1);
|
||||
|
||||
// Shared buffer for half-to-float conversion
|
||||
const float32 = new Float32Array(1);
|
||||
const uint32 = new Uint32Array(float32.buffer);
|
||||
|
||||
// Convert 16-bit half-float to 32-bit float using bit manipulation
|
||||
const half2Float = (h: number): number => {
|
||||
const sign = (h & 0x8000) << 16; // Move sign to bit 31
|
||||
const exponent = (h & 0x7C00) >> 10; // Extract 5-bit exponent
|
||||
const mantissa = h & 0x03FF; // Extract 10-bit mantissa
|
||||
|
||||
if (exponent === 0) {
|
||||
if (mantissa === 0) {
|
||||
// Zero
|
||||
uint32[0] = sign;
|
||||
} else {
|
||||
// Denormalized: convert to normalized float32
|
||||
let e = -1;
|
||||
let m = mantissa;
|
||||
do {
|
||||
e++;
|
||||
m <<= 1;
|
||||
} while ((m & 0x0400) === 0);
|
||||
uint32[0] = sign | ((127 - 15 - e) << 23) | ((m & 0x03FF) << 13);
|
||||
}
|
||||
} else if (exponent === 31) {
|
||||
// Infinity or NaN
|
||||
uint32[0] = sign | 0x7F800000 | (mantissa << 13);
|
||||
} else {
|
||||
// Normalized: adjust exponent bias from 15 to 127
|
||||
uint32[0] = sign | ((exponent + 127 - 15) << 23) | (mantissa << 13);
|
||||
}
|
||||
|
||||
return float32[0];
|
||||
};
|
||||
|
||||
class Picker {
|
||||
private device: GraphicsDevice;
|
||||
private scene: Scene;
|
||||
|
||||
// Render targets (provided by camera)
|
||||
private depthRenderTarget: RenderTarget | null = null;
|
||||
private idRenderTarget: RenderTarget | null = null;
|
||||
|
||||
// Render pass (shared for depth and ID picking)
|
||||
private renderPass: RenderPassPicker;
|
||||
|
||||
// Blend state for depth accumulation
|
||||
private depthBlendState: BlendState;
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
this.device = scene.graphicsDevice;
|
||||
|
||||
// Create shared render pass for picking
|
||||
this.renderPass = new RenderPassPicker(this.device, this.scene.app.renderer);
|
||||
|
||||
// Blend state for depth accumulation:
|
||||
// RGB: additive depth accumulation (ONE, ONE_MINUS_SRC_ALPHA)
|
||||
// Alpha: multiplicative transmittance (ZERO, ONE_MINUS_SRC_ALPHA) -> T = T * (1 - alpha)
|
||||
this.depthBlendState = new BlendState(
|
||||
true,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA, // RGB blend
|
||||
BLENDEQUATION_ADD, BLENDMODE_ZERO, BLENDMODE_ONE_MINUS_SRC_ALPHA // Alpha blend (transmittance)
|
||||
);
|
||||
}
|
||||
|
||||
// Set render targets from camera
|
||||
setRenderTargets(depthRT: RenderTarget, idRT: RenderTarget) {
|
||||
this.depthRenderTarget = depthRT;
|
||||
this.idRenderTarget = idRT;
|
||||
}
|
||||
|
||||
// Prepare for ID picking by rendering the specified splat
|
||||
prepareId(splat: Splat, mode: 'add' | 'remove' | 'set' | 'intersect') {
|
||||
if (!this.idRenderTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { splatLayer } = this.scene;
|
||||
|
||||
// Hide non-selected elements
|
||||
const splats = this.scene.getElementsByType(ElementType.splat) as Splat[];
|
||||
splats.forEach((s) => {
|
||||
s.entity.enabled = s === splat;
|
||||
});
|
||||
|
||||
// 'intersect' picks against the currently-selected set (same render as
|
||||
// 'remove') so unselected splats can't occlude selected ones and skew it.
|
||||
const pickOp = mode === 'intersect' ? 'remove' : mode;
|
||||
|
||||
// Set picker uniforms
|
||||
this.device.scope.resolve('pickOp').setValue(['add', 'remove', 'set'].indexOf(pickOp));
|
||||
this.device.scope.resolve('pickMode').setValue(0);
|
||||
|
||||
// Render ID picking pass
|
||||
const emptyMap = new Map();
|
||||
this.renderPass.blendState = BlendState.NOBLEND;
|
||||
this.renderPass.init(this.idRenderTarget);
|
||||
this.renderPass.setClearColor(idClearColor);
|
||||
this.renderPass.update(this.scene.camera.camera, this.scene.app.scene, [splatLayer], emptyMap, false);
|
||||
this.renderPass.render();
|
||||
|
||||
// Re-enable all splats
|
||||
splats.forEach((s) => {
|
||||
s.entity.enabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Read single splat ID at normalized screen position (after prepareId)
|
||||
async readId(x: number, y: number): Promise<number> {
|
||||
if (!this.idRenderTarget) {
|
||||
return -1;
|
||||
}
|
||||
// For single pixel read, use a minimal normalized size
|
||||
const rt = this.idRenderTarget;
|
||||
const ids = await this.readIds(x, y, 1 / rt.width, 1 / rt.height);
|
||||
return ids[0];
|
||||
}
|
||||
|
||||
// Read rectangle of splat IDs using normalized coordinates (0-1 range) (after prepareId)
|
||||
async readIds(x: number, y: number, width: number, height: number): Promise<number[]> {
|
||||
if (!this.idRenderTarget) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rt = this.idRenderTarget;
|
||||
const colorBuffer = rt.colorBuffer;
|
||||
|
||||
// Convert normalized coordinates to render target pixels
|
||||
const px = Math.floor(x * rt.width);
|
||||
const py = Math.floor(y * rt.height);
|
||||
const pw = Math.max(1, Math.ceil((x + width) * rt.width) - px);
|
||||
const ph = Math.max(1, Math.ceil((y + height) * rt.height) - py);
|
||||
|
||||
// Flip Y for texture read on WebGL (texture origin is bottom-left)
|
||||
const texY = this.device.isWebGL2 ? rt.height - py - ph : py;
|
||||
|
||||
// Read pixels using texture.read() API
|
||||
const pixels = await colorBuffer.read(px, texY, pw, ph, {
|
||||
renderTarget: rt,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < pw * ph; i++) {
|
||||
// Use >>> 0 to convert signed 32-bit to unsigned (so 0xffffffff instead of -1)
|
||||
result.push(
|
||||
(pixels[i * 4] |
|
||||
(pixels[i * 4 + 1] << 8) |
|
||||
(pixels[i * 4 + 2] << 16) |
|
||||
(pixels[i * 4 + 3] << 24)) >>> 0
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Prepare for depth picking by rendering the specified splat
|
||||
prepareDepth(splat: Splat) {
|
||||
if (!this.depthRenderTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scene } = this;
|
||||
const { app, camera, splatLayer } = scene;
|
||||
const emptyMap = new Map();
|
||||
|
||||
// Hide non-selected elements
|
||||
const splats = scene.getElementsByType(ElementType.splat) as Splat[];
|
||||
splats.forEach((s) => {
|
||||
s.entity.enabled = s === splat;
|
||||
});
|
||||
|
||||
// Set depth estimation mode uniform
|
||||
this.device.scope.resolve('pickOp').setValue(2); // 'set' mode - don't skip any visible splats
|
||||
this.device.scope.resolve('pickMode').setValue(1);
|
||||
|
||||
// Render scene with depth pass
|
||||
this.renderPass.blendState = this.depthBlendState;
|
||||
this.renderPass.init(this.depthRenderTarget);
|
||||
this.renderPass.setClearColor(depthClearColor);
|
||||
this.renderPass.update(camera.camera, app.scene, [splatLayer], emptyMap, false);
|
||||
this.renderPass.render();
|
||||
|
||||
// Re-enable all splats
|
||||
splats.forEach((s) => {
|
||||
s.entity.enabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Read normalized depth (0-1) at normalized screen position (0-1 range) (after prepareDepth)
|
||||
async readDepth(x: number, y: number): Promise<number | null> {
|
||||
if (!this.depthRenderTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rt = this.depthRenderTarget;
|
||||
const colorBuffer = rt.colorBuffer;
|
||||
|
||||
// Convert normalized coordinates to render target pixels
|
||||
const px = Math.floor(x * rt.width);
|
||||
const py = Math.floor(y * rt.height);
|
||||
|
||||
// Flip Y for texture read on WebGL (texture origin is bottom-left)
|
||||
const texY = this.device.isWebGL2 ? rt.height - py - 1 : py;
|
||||
|
||||
// Read the pixel using Texture.read() which handles RGBA16F format
|
||||
const pixels = await colorBuffer.read(px, texY, 1, 1, { renderTarget: rt });
|
||||
|
||||
// Convert half-float values to floats
|
||||
// R channel: accumulated depth * alpha
|
||||
// A channel: transmittance (1 - alpha)
|
||||
const r = half2Float(pixels[0]);
|
||||
const transmittance = half2Float(pixels[3]);
|
||||
const alpha = 1 - transmittance;
|
||||
|
||||
// Check alpha (transmittance close to 1 means nothing visible)
|
||||
if (alpha < 1e-6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return normalized depth (0-1 range)
|
||||
return r / alpha;
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
destroy() {
|
||||
this.renderPass?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export { Picker };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Vec3, Quat } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
import { Transform } from './transform';
|
||||
|
||||
// stores the transform pivot location in world space
|
||||
// the transform tools (translate, rotate, scale) and transform panel modify this pivot
|
||||
// then the active transform handler applies the changes to the current selection.
|
||||
class Pivot {
|
||||
transform = new Transform();
|
||||
|
||||
place: (transform: Transform) => void;
|
||||
start: () => void;
|
||||
move: (transform: Transform) => void;
|
||||
moveTRS: (position: Vec3, rotation: Quat, scale: Vec3) => void;
|
||||
end: () => void;
|
||||
|
||||
constructor(events: Events) {
|
||||
|
||||
this.place = (transform: Transform) => {
|
||||
if (!this.transform.equals(transform)) {
|
||||
this.transform.copy(transform);
|
||||
|
||||
events.fire('pivot.placed', this);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = () => {
|
||||
events.fire('pivot.started', this);
|
||||
};
|
||||
|
||||
this.move = (transform: Transform) => {
|
||||
if (!this.transform.equals(transform)) {
|
||||
this.transform.copy(transform);
|
||||
|
||||
events.fire('pivot.moved', this);
|
||||
}
|
||||
};
|
||||
|
||||
this.moveTRS = (position: Vec3, rotation: Quat, scale: Vec3) => {
|
||||
if (!this.transform.equalsTRS(position, rotation, scale)) {
|
||||
this.transform.set(position, rotation, scale);
|
||||
|
||||
events.fire('pivot.moved', this);
|
||||
}
|
||||
};
|
||||
|
||||
this.end = () => {
|
||||
events.fire('pivot.ended', this);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Color } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
import { SceneConfig } from './scene-config';
|
||||
import { i18n } from './ui/localization';
|
||||
|
||||
const storageKey = 'supersplat:preferences';
|
||||
const storageVersion = 1;
|
||||
|
||||
type PrefValue = boolean | number | string | number[];
|
||||
|
||||
type Descriptor = {
|
||||
// storage key, which doubles as the notify event fired when the setting changes
|
||||
key: string;
|
||||
// command event used to apply a value
|
||||
setCommand: string;
|
||||
// the sceneConfig path a url query parameter would override. when the url
|
||||
// specifies this setting, the stored preference is ignored for the session.
|
||||
urlPath?: string;
|
||||
// default event payload. reads the resolved sceneConfig, which already has
|
||||
// url overrides folded in, so url parameters naturally take precedence.
|
||||
getDefault: () => any;
|
||||
// validate a stored (json) value
|
||||
validate: (value: PrefValue) => boolean;
|
||||
// stored (json) value -> event payload
|
||||
toEvent?: (value: PrefValue) => any;
|
||||
// notify payload -> stored (json) value
|
||||
fromEvent?: (value: any) => PrefValue;
|
||||
};
|
||||
|
||||
// mirror the kebab-case matching used by scene-config's Params so url
|
||||
// detection agrees with how getSceneConfig consumed the query parameters
|
||||
const kebabize = (s: string) => s.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
|
||||
|
||||
const registerPreferences = (events: Events, config: SceneConfig, urlArgs: any) => {
|
||||
// returns true if the url query args specify the given config path
|
||||
const urlHas = (path?: string) => {
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
const lookup = (segments: string[]) => {
|
||||
let obj = urlArgs;
|
||||
for (const segment of segments) {
|
||||
if (typeof obj !== 'object' || obj === null || !obj.hasOwnProperty(segment)) {
|
||||
return undefined;
|
||||
}
|
||||
obj = obj[segment];
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
const parts = path.split('.');
|
||||
return lookup(parts.map(kebabize)) !== undefined || lookup(parts) !== undefined;
|
||||
};
|
||||
|
||||
const isBool = (v: PrefValue) => typeof v === 'boolean';
|
||||
const isNumber = (min: number, max: number) => (v: PrefValue) => typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||
const isEnum = (options: string[]) => (v: PrefValue) => typeof v === 'string' && options.includes(v);
|
||||
const isColor = (v: PrefValue) => Array.isArray(v) && v.length === 4 && v.every(c => typeof c === 'number' && c >= 0 && c <= 1);
|
||||
|
||||
const color = (key: string, setCommand: string, getDefault: () => { r: number, g: number, b: number, a: number }): Descriptor => ({
|
||||
key,
|
||||
setCommand,
|
||||
urlPath: key,
|
||||
getDefault: () => {
|
||||
const c = getDefault();
|
||||
return new Color(c.r, c.g, c.b, c.a);
|
||||
},
|
||||
validate: isColor,
|
||||
toEvent: (v: PrefValue) => {
|
||||
const a = v as number[];
|
||||
return new Color(a[0], a[1], a[2], a[3]);
|
||||
},
|
||||
fromEvent: (c: Color) => [c.r, c.g, c.b, c.a]
|
||||
});
|
||||
|
||||
// the order of this table is the application order. camera.mode and
|
||||
// camera.overlay must come after camera.splatSize because the view panel's
|
||||
// centers size slider side-fires setOverlay/setMode when its value changes.
|
||||
const descriptors: Descriptor[] = [
|
||||
color('bgClr', 'setBgClr', () => config.bgClr),
|
||||
color('selectedClr', 'setSelectedClr', () => config.selectedClr),
|
||||
color('unselectedClr', 'setUnselectedClr', () => config.unselectedClr),
|
||||
color('lockedClr', 'setLockedClr', () => config.lockedClr),
|
||||
{ key: 'camera.tonemapping', setCommand: 'camera.setTonemapping', urlPath: 'camera.toneMapping', getDefault: () => config.camera.toneMapping, validate: isEnum(['linear', 'neutral', 'aces', 'aces2', 'filmic', 'hejl']) },
|
||||
{ key: 'camera.fovDolly', setCommand: 'camera.setFovDolly', getDefault: () => false, validate: isBool },
|
||||
{ key: 'camera.fov', setCommand: 'camera.setFov', urlPath: 'camera.fov', getDefault: () => config.camera.fov, validate: isNumber(10, 120) },
|
||||
{ key: 'view.bands', setCommand: 'view.setBands', urlPath: 'show.shBands', getDefault: () => config.show.shBands, validate: v => typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 3 },
|
||||
{ key: 'camera.flySpeed', setCommand: 'camera.setFlySpeed', getDefault: () => 1, validate: isNumber(0.1, 30) },
|
||||
{ key: 'camera.splatSize', setCommand: 'camera.setSplatSize', getDefault: () => 2, validate: isNumber(0, 10) },
|
||||
{ key: 'view.centersUseGaussianColor', setCommand: 'view.setCentersUseGaussianColor', getDefault: () => false, validate: isBool },
|
||||
{ key: 'view.outlineSelection', setCommand: 'view.setOutlineSelection', getDefault: () => false, validate: isBool },
|
||||
{ key: 'grid.visible', setCommand: 'grid.setVisible', urlPath: 'show.grid', getDefault: () => config.show.grid, validate: isBool },
|
||||
{ key: 'camera.bound', setCommand: 'camera.setBound', urlPath: 'show.bound', getDefault: () => config.show.bound, validate: isBool },
|
||||
{ key: 'camera.boundDimensions', setCommand: 'camera.setBoundDimensions', urlPath: 'show.boundDimensions', getDefault: () => config.show.boundDimensions, validate: isBool },
|
||||
{ key: 'camera.showPoses', setCommand: 'camera.setShowPoses', urlPath: 'show.cameraPoses', getDefault: () => config.show.cameraPoses, validate: isBool },
|
||||
{ key: 'camera.showInfo', setCommand: 'camera.setShowInfo', urlPath: 'show.cameraInfo', getDefault: () => config.show.cameraInfo, validate: isBool },
|
||||
{ key: 'camera.mode', setCommand: 'camera.setMode', getDefault: () => 'centers', validate: isEnum(['centers', 'rings']) },
|
||||
{ key: 'camera.overlay', setCommand: 'camera.setOverlay', urlPath: 'camera.overlay', getDefault: () => config.camera.overlay, validate: isBool },
|
||||
{ key: 'camera.controlMode', setCommand: 'camera.setControlMode', getDefault: () => 'orbit', validate: isEnum(['orbit', 'fly']) }
|
||||
];
|
||||
|
||||
// load and validate the stored blob. unknown keys and invalid values are
|
||||
// discarded so preferences survive settings being added, removed or
|
||||
// reshaped between releases.
|
||||
const load = (): Record<string, PrefValue> => {
|
||||
const result: Record<string, PrefValue> = {};
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw) {
|
||||
const blob = JSON.parse(raw);
|
||||
if (blob?.version === storageVersion && typeof blob.values === 'object' && blob.values) {
|
||||
for (const descriptor of descriptors) {
|
||||
const value = blob.values[descriptor.key];
|
||||
if (value !== undefined && descriptor.validate(value)) {
|
||||
result[descriptor.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// corrupt or inaccessible storage - fall back to defaults
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const values = load();
|
||||
|
||||
// coalesce bursts of changes (slider drags, color picking) into a single
|
||||
// write per task
|
||||
let storageWarned = false;
|
||||
let writeScheduled = false;
|
||||
const scheduleWrite = () => {
|
||||
if (writeScheduled) {
|
||||
return;
|
||||
}
|
||||
writeScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
writeScheduled = false;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ version: storageVersion, values }));
|
||||
} catch (e) {
|
||||
if (!storageWarned) {
|
||||
storageWarned = true;
|
||||
console.warn('preferences will not persist: localStorage is unavailable');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// while suspended, changes are not captured. document loading and camera
|
||||
// pose playback suspend capture so programmatic changes don't overwrite
|
||||
// stored preferences.
|
||||
let suspendDepth = 0;
|
||||
events.on('preferences.suspend', () => {
|
||||
suspendDepth++;
|
||||
});
|
||||
events.on('preferences.resume', () => {
|
||||
suspendDepth = Math.max(0, suspendDepth - 1);
|
||||
});
|
||||
|
||||
// apply stored preferences (or defaults) to the live state. every setting
|
||||
// is applied so side effects between settings self-heal, and the resolved
|
||||
// sceneConfig already contains url overrides, which win over stored values.
|
||||
const apply = () => {
|
||||
events.fire('preferences.suspend');
|
||||
try {
|
||||
for (const descriptor of descriptors) {
|
||||
const stored = urlHas(descriptor.urlPath) ? undefined : values[descriptor.key];
|
||||
const payload = stored !== undefined ? (descriptor.toEvent ? descriptor.toEvent(stored) : stored) : descriptor.getDefault();
|
||||
events.fire(descriptor.setCommand, payload);
|
||||
}
|
||||
} finally {
|
||||
events.fire('preferences.resume');
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
|
||||
// capture user changes. registered after the initial apply so it never
|
||||
// observes its own writes or the boot-time initialization events.
|
||||
descriptors.forEach((descriptor) => {
|
||||
events.on(descriptor.key, (value: any) => {
|
||||
if (suspendDepth > 0) {
|
||||
return;
|
||||
}
|
||||
const stored = descriptor.fromEvent ? descriptor.fromEvent(value) : value;
|
||||
if (!descriptor.validate(stored)) {
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(values[descriptor.key]) === JSON.stringify(stored)) {
|
||||
return;
|
||||
}
|
||||
values[descriptor.key] = stored;
|
||||
scheduleWrite();
|
||||
});
|
||||
});
|
||||
|
||||
// re-apply stored preferences (used by File > New)
|
||||
events.on('preferences.apply', () => {
|
||||
apply();
|
||||
});
|
||||
|
||||
// clear stored preferences and restore defaults. language lives in its
|
||||
// own store (i18nextLng) but its default is 'automatic', so reset it too.
|
||||
events.on('preferences.reset', () => {
|
||||
for (const key in values) {
|
||||
delete values[key];
|
||||
}
|
||||
scheduleWrite();
|
||||
apply();
|
||||
i18n.setLanguage(null);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerPreferences };
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
import type { FileSystem, Writer } from '@playcanvas/splat-transform';
|
||||
|
||||
import { Events } from './events';
|
||||
import { GZipWriter } from './io';
|
||||
import { serializePly, ExperienceSettings, SerializeSettings } from './splat-serialize';
|
||||
import { i18n } from './ui/localization';
|
||||
|
||||
/**
|
||||
* Simple FileSystem wrapper around a single Writer.
|
||||
* Used for cases like GZip compression where we need FileSystem semantics
|
||||
* but only have a single Writer to wrap.
|
||||
* The wrapper makes close() a no-op since the caller manages the writer lifecycle.
|
||||
*/
|
||||
class WriterFileSystem implements FileSystem {
|
||||
private writer: Writer;
|
||||
|
||||
constructor(writer: Writer) {
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
createWriter(_filename: string): Writer {
|
||||
// Return a wrapper that delegates write but makes close a no-op
|
||||
// The caller is responsible for closing the underlying writer
|
||||
const inner = this.writer;
|
||||
return {
|
||||
get bytesWritten() {
|
||||
return inner.bytesWritten;
|
||||
},
|
||||
write: (data: Uint8Array) => inner.write(data),
|
||||
close: () => Promise.resolve()
|
||||
};
|
||||
}
|
||||
|
||||
mkdir(_path: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
token: string;
|
||||
apiServer: string;
|
||||
};
|
||||
|
||||
type Scene = {
|
||||
hash: string;
|
||||
title: string;
|
||||
description: string;
|
||||
format: string;
|
||||
};
|
||||
|
||||
type UserStatus = {
|
||||
user: User;
|
||||
scenes: Scene[];
|
||||
};
|
||||
|
||||
type PublishSettings = {
|
||||
user: User;
|
||||
title: string;
|
||||
description: string;
|
||||
listed: boolean;
|
||||
serializeSettings: SerializeSettings;
|
||||
experienceSettings: ExperienceSettings;
|
||||
overwriteHash?: string;
|
||||
overrideModel?: boolean;
|
||||
overrideAnimation?: boolean;
|
||||
generateLods: boolean;
|
||||
};
|
||||
|
||||
const origin = location.origin;
|
||||
|
||||
// check whether user is logged in
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const urlResponse = await fetch(`${origin}/api/id`);
|
||||
return urlResponse.ok && (await urlResponse.json() as User);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSceneList = async (user: User) => {
|
||||
const response = await fetch(`${user.apiServer}/splats?limit=128`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to get scene list (${response.statusText})`);
|
||||
}
|
||||
|
||||
return (await response.json()).result as Scene[];
|
||||
};
|
||||
|
||||
const fetchSceneSettings = async (user: User, sceneHash: string): Promise<ExperienceSettings> => {
|
||||
const response = await fetch(`${user.apiServer}/splats/${sceneHash}/settings`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch scene settings (${response.statusText})`);
|
||||
}
|
||||
return await response.json() as ExperienceSettings;
|
||||
};
|
||||
|
||||
const updateSceneSettings = async (user: User, sceneHash: string, settings: ExperienceSettings) => {
|
||||
const response = await fetch(`${user.apiServer}/splats/${sceneHash}/settings`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to update scene settings (${response.statusText})`);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
class PublishWriter implements Writer {
|
||||
write: (data: Uint8Array) => void;
|
||||
close: () => Promise<any>;
|
||||
|
||||
private cursor = 0;
|
||||
|
||||
get bytesWritten(): number {
|
||||
return this.cursor;
|
||||
}
|
||||
|
||||
static async create(publishSettings: PublishSettings) {
|
||||
const { user } = publishSettings;
|
||||
|
||||
const filename = 'scene.ply';
|
||||
|
||||
// start upload
|
||||
const startResponse = await fetch(`${user.apiServer}/upload/start-upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ fileName: filename })
|
||||
});
|
||||
|
||||
const startJson = await startResponse.json();
|
||||
|
||||
const result = new PublishWriter();
|
||||
|
||||
const uploadBuf = new Uint8Array(10 * 1024 * 1024); // 10MB buffer
|
||||
const parts: { PartNumber: number, ETag: string }[] = [];
|
||||
let partNumber = 1;
|
||||
let cursor = 0;
|
||||
|
||||
const upload = async () => {
|
||||
if (cursor === 0) return;
|
||||
|
||||
// get signed url for this part
|
||||
const urlResponse = await fetch(`${user.apiServer}/upload/signed-urls`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uploadId: startJson.uploadId,
|
||||
key: startJson.key,
|
||||
parts: 1,
|
||||
partBase: partNumber
|
||||
})
|
||||
});
|
||||
|
||||
if (!urlResponse.ok) {
|
||||
throw new Error(`failed to get signed url (${urlResponse.statusText})`);
|
||||
}
|
||||
|
||||
const urlJson = await urlResponse.json();
|
||||
|
||||
const uploadResponse = await fetch(urlJson.signedUrls[0], {
|
||||
method: 'PUT',
|
||||
body: uploadBuf.slice(0, cursor),
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`failed to upload data (${uploadResponse.statusText})`);
|
||||
}
|
||||
|
||||
parts.push({
|
||||
PartNumber: partNumber,
|
||||
ETag: uploadResponse.headers.get('etag').replace(/^"|"$/g, '')
|
||||
});
|
||||
|
||||
cursor = 0;
|
||||
partNumber++;
|
||||
};
|
||||
|
||||
result.write = async (data: Uint8Array) => {
|
||||
let readcursor = 0;
|
||||
|
||||
while (data.byteLength - readcursor > 0) {
|
||||
const readSize = data.byteLength - readcursor;
|
||||
const writeSize = uploadBuf.byteLength - cursor;
|
||||
const copySize = Math.min(readSize, writeSize);
|
||||
|
||||
uploadBuf.set(data.subarray(readcursor, readcursor + copySize), cursor);
|
||||
|
||||
readcursor += copySize;
|
||||
cursor += copySize;
|
||||
|
||||
if (cursor === uploadBuf.byteLength) {
|
||||
await upload();
|
||||
}
|
||||
}
|
||||
result.cursor += data.byteLength;
|
||||
};
|
||||
|
||||
result.close = async () => {
|
||||
// final upload
|
||||
await upload();
|
||||
|
||||
// complete the multipart upload
|
||||
const completeResult = await fetch(`${user.apiServer}/upload/complete-upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uploadId: startJson.uploadId,
|
||||
key: startJson.key,
|
||||
parts
|
||||
})
|
||||
});
|
||||
|
||||
if (!completeResult.ok) {
|
||||
throw new Error(`failed to complete upload (${completeResult.statusText})`);
|
||||
}
|
||||
|
||||
const completeJson = await completeResult.json();
|
||||
|
||||
const publishFormat = publishSettings.generateLods ? 'ssog' : 'sog';
|
||||
|
||||
const doPublish = () => fetch(`${user.apiServer}/splats/publish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
s3Key: startJson.key,
|
||||
title: publishSettings.title,
|
||||
description: publishSettings.description,
|
||||
listed: publishSettings.listed,
|
||||
settings: publishSettings.experienceSettings,
|
||||
sourceFormat: 'ply',
|
||||
publishFormat
|
||||
}),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const doRepublish = () => fetch(`${user.apiServer}/splats/${publishSettings.overwriteHash}/republish`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
s3Key: startJson.key,
|
||||
settings: publishSettings.experienceSettings,
|
||||
sourceFormat: 'ply',
|
||||
publishFormat
|
||||
}),
|
||||
headers: {
|
||||
'Authorization': `Bearer ${user.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const publishResponse = await (publishSettings.overwriteHash ? doRepublish() : doPublish());
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const registerPublishEvents = (events: Events) => {
|
||||
|
||||
events.function('publish.userStatus', async () => {
|
||||
const user = await fetchUser();
|
||||
if (!user || !user.username) {
|
||||
return null;
|
||||
}
|
||||
const scenes = await fetchSceneList(user);
|
||||
return { user, scenes };
|
||||
});
|
||||
|
||||
events.function('scene.publish', async (publishSettings: PublishSettings) => {
|
||||
try {
|
||||
events.fire('progressStart', 'Publishing...');
|
||||
events.fire('progressUpdate', {
|
||||
text: i18n.t('popup.publish.converting', { ellipsis: true }),
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// delay to allow spinner to show (hopefully 10ms is enough)
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
const { overwriteHash, overrideAnimation } = publishSettings;
|
||||
const overrideModel = publishSettings.overrideModel ?? true;
|
||||
|
||||
const mergeAnimation = (target: ExperienceSettings, source: ExperienceSettings) => {
|
||||
target.animTracks = source.animTracks;
|
||||
target.startMode = source.startMode;
|
||||
};
|
||||
|
||||
if (overwriteHash && !overrideModel) {
|
||||
if (!overrideAnimation) {
|
||||
throw new Error('No overrides selected');
|
||||
}
|
||||
|
||||
// animation-only update: fetch existing settings, merge animation, PUT settings
|
||||
const existingSettings = await fetchSceneSettings(publishSettings.user, overwriteHash);
|
||||
mergeAnimation(existingSettings, publishSettings.experienceSettings);
|
||||
await updateSceneSettings(publishSettings.user, overwriteHash, existingSettings);
|
||||
|
||||
await events.invoke('showPopup', {
|
||||
type: 'info',
|
||||
header: i18n.t('popup.publish.succeeded'),
|
||||
message: i18n.t('popup.publish.message'),
|
||||
link: `${origin}/scene/${overwriteHash}/edit`
|
||||
});
|
||||
} else {
|
||||
// new scene or model override: upload PLY and publish/republish
|
||||
if (overwriteHash) {
|
||||
// republishing with model override: merge with existing settings
|
||||
const existingSettings = await fetchSceneSettings(publishSettings.user, overwriteHash);
|
||||
if (overrideAnimation) {
|
||||
mergeAnimation(existingSettings, publishSettings.experienceSettings);
|
||||
}
|
||||
publishSettings.experienceSettings = existingSettings;
|
||||
}
|
||||
|
||||
const progressFunc = (loaded: number, total: number) => {
|
||||
events.fire('progressUpdate', {
|
||||
text: i18n.t('popup.publish.uploading', { ellipsis: true }),
|
||||
progress: 100 * loaded / total
|
||||
});
|
||||
};
|
||||
|
||||
// create the writer chain: gzip->stream->upload
|
||||
const publishWriter = await PublishWriter.create(publishSettings);
|
||||
const gzipWriter = new GZipWriter(publishWriter);
|
||||
|
||||
const splats = events.invoke('scene.splats');
|
||||
|
||||
// serialize using WriterFileSystem wrapper (close is managed by caller)
|
||||
const fs = new WriterFileSystem(gzipWriter);
|
||||
await serializePly(splats, publishSettings.serializeSettings, fs, 'output.ply', progressFunc);
|
||||
|
||||
await gzipWriter.close();
|
||||
const response = await publishWriter.close();
|
||||
|
||||
if (!response) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.publish.failed'),
|
||||
message: i18n.t('popup.publish.please-try-again')
|
||||
});
|
||||
} else {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'info',
|
||||
header: i18n.t('popup.publish.succeeded'),
|
||||
message: i18n.t('popup.publish.message'),
|
||||
link: `${origin}/scene/${response.hash}/edit`
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('popup.publish.failed'),
|
||||
message: `'${error.message ?? error}'`
|
||||
});
|
||||
} finally {
|
||||
events.fire('progressEnd');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { PublishSettings, UserStatus, registerPublishEvents };
|
||||
@@ -0,0 +1,82 @@
|
||||
const DB_NAME = 'supersplat';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'recent-files';
|
||||
|
||||
interface RecentFile {
|
||||
handle: FileSystemFileHandle;
|
||||
name: string;
|
||||
date: number;
|
||||
}
|
||||
|
||||
// wrap IDBRequest in a promise
|
||||
const wrap = (IDBRequest: IDBRequest): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
IDBRequest.onsuccess = () => resolve(IDBRequest.result);
|
||||
IDBRequest.onerror = () => {
|
||||
console.error('IndexedDB error', IDBRequest.error);
|
||||
reject(IDBRequest.error);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
class RecentFiles {
|
||||
db: Promise<IDBDatabase>;
|
||||
|
||||
constructor() {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
// NOTE: for now we store by filename even though files in
|
||||
// loaded from different directories could have the same name.
|
||||
// We do this because we can't distinguish files from different
|
||||
// directories anyway due to File System Access API limitations.
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'name' });
|
||||
}
|
||||
};
|
||||
this.db = wrap(request);
|
||||
}
|
||||
|
||||
async add(handle: FileSystemFileHandle) {
|
||||
const db = await this.db;
|
||||
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const request = store.put({
|
||||
handle: handle,
|
||||
name: handle.name,
|
||||
date: Date.now()
|
||||
});
|
||||
|
||||
await wrap(request);
|
||||
}
|
||||
|
||||
async get(): Promise<RecentFile[]> {
|
||||
const db = await this.db;
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const result = await wrap(store.getAll()) as RecentFile[];
|
||||
|
||||
// Sort by date descending
|
||||
result.sort((a, b) => b.date - a.date);
|
||||
return result;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
const db = await this.db;
|
||||
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
await wrap(store.clear());
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const db = await this.db;
|
||||
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
return wrap(store.count());
|
||||
}
|
||||
}
|
||||
|
||||
const recentFiles = new RecentFiles();
|
||||
|
||||
export { recentFiles };
|
||||
+694
@@ -0,0 +1,694 @@
|
||||
import { WebPCodec } from '@playcanvas/splat-transform';
|
||||
import { BufferTarget, EncodedPacket, EncodedVideoPacketSource, MkvOutputFormat, MovOutputFormat, Mp4OutputFormat, Output, StreamTarget, WebMOutputFormat } from 'mediabunny';
|
||||
import { Color, path, Quat, Vec3 } from 'playcanvas';
|
||||
|
||||
import { ElementType } from './element';
|
||||
import { EquirectRenderer } from './equirect-renderer';
|
||||
import { Events } from './events';
|
||||
import { Scene } from './scene';
|
||||
import { injectSphericalMetadata } from './spherical-metadata';
|
||||
import { Splat } from './splat';
|
||||
import { i18n } from './ui/localization';
|
||||
|
||||
const nullClr = new Color(0, 0, 0, 0);
|
||||
|
||||
// Lookup maps for video output format and codec configuration
|
||||
const FORMAT_CONFIG: Record<string, { create: (streaming: boolean) => Mp4OutputFormat | MovOutputFormat | MkvOutputFormat | WebMOutputFormat; extension: string }> = {
|
||||
mp4: { create: streaming => new Mp4OutputFormat({ fastStart: streaming ? false : 'in-memory' }), extension: 'mp4' },
|
||||
webm: { create: () => new WebMOutputFormat(), extension: 'webm' },
|
||||
mov: { create: streaming => new MovOutputFormat({ fastStart: streaming ? false : 'in-memory' }), extension: 'mov' },
|
||||
mkv: { create: () => new MkvOutputFormat(), extension: 'mkv' }
|
||||
};
|
||||
|
||||
const CODEC_CONFIG: Record<string, { type: 'avc' | 'hevc' | 'vp9' | 'av1'; codec: (height: number) => string }> = {
|
||||
h264: { type: 'avc', codec: h => (h < 1080 ? 'avc1.420028' : 'avc1.640033') }, // H.264 Constrained Baseline/High profile
|
||||
h265: { type: 'hevc', codec: () => 'hev1.1.6.L120.B0' }, // H.265 Main profile, Level 4.0
|
||||
vp9: { type: 'vp9', codec: () => 'vp09.00.10.08' }, // VP9 Profile 0, Level 1.0
|
||||
av1: { type: 'av1', codec: () => 'av01.0.05M.08' } // AV1 Main Profile, Level 3.1
|
||||
};
|
||||
|
||||
type ImageSettings = {
|
||||
width: number;
|
||||
height: number;
|
||||
transparentBg: boolean;
|
||||
showDebug: boolean;
|
||||
projection?: 'standard' | 'equirect';
|
||||
levelHorizon?: boolean;
|
||||
};
|
||||
|
||||
type VideoSettings = {
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
frameRate: number;
|
||||
width: number;
|
||||
height: number;
|
||||
bitrate: number;
|
||||
transparentBg: boolean;
|
||||
showDebug: boolean;
|
||||
format: 'mp4' | 'webm' | 'mov' | 'mkv';
|
||||
codec: 'h264' | 'h265' | 'vp9' | 'av1';
|
||||
projection?: 'standard' | 'equirect';
|
||||
levelHorizon?: boolean;
|
||||
};
|
||||
|
||||
const removeExtension = (filename: string) => {
|
||||
return filename.substring(0, filename.length - path.getExtension(filename).length);
|
||||
};
|
||||
|
||||
const isInvalidFilenameChar = (char: string) => {
|
||||
return /[<>:"/\\|?*]/.test(char) || char.charCodeAt(0) < 32;
|
||||
};
|
||||
|
||||
const sanitizeFilename = (filename: string) => {
|
||||
const sanitized = Array.from(filename, char => (isInvalidFilenameChar(char) ? '_' : char)).join('').trim();
|
||||
return sanitized.length > 0 ? sanitized : 'supersplat';
|
||||
};
|
||||
|
||||
// extract a plain filename from url-style names (e.g. splats imported via ?load=)
|
||||
const getImportedFilename = (filename: string) => {
|
||||
const trimmed = filename.split(/[?#]/)[0];
|
||||
|
||||
if (trimmed.includes('://') || trimmed.startsWith('blob:')) {
|
||||
try {
|
||||
return path.getBasename(new URL(trimmed).pathname);
|
||||
} catch {
|
||||
// fall through to the raw filename below
|
||||
}
|
||||
}
|
||||
|
||||
return path.getBasename(trimmed);
|
||||
};
|
||||
|
||||
// sort splats and wait for the sort to complete (or a 1s timeout)
|
||||
const sortSplatsAndWait = (scene: Scene, splats: Splat[]) => {
|
||||
return Promise.all(splats.map((splat) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const { instance } = splat.entity.gsplat;
|
||||
instance.sorter.once('updated', resolve);
|
||||
instance.sort(scene.camera.mainCamera);
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const downloadFile = (data: ArrayBuffer | Uint8Array<ArrayBuffer>, filename: string) => {
|
||||
const blob = new Blob([data], { type: 'application/octet-stream' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const el = document.createElement('a');
|
||||
el.download = filename;
|
||||
el.href = url;
|
||||
el.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const registerRenderEvents = (scene: Scene, events: Events) => {
|
||||
let webpCodec: WebPCodec;
|
||||
|
||||
// default base filename for rendered output: the project document name if
|
||||
// set, otherwise the first visible splat's name
|
||||
const baseFilename = () => {
|
||||
const docName = events.invoke('doc.name');
|
||||
const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible);
|
||||
const source = docName || (splats[0]?.name ?? 'supersplat');
|
||||
return sanitizeFilename(removeExtension(getImportedFilename(source)));
|
||||
};
|
||||
|
||||
events.function('render.baseFilename', baseFilename);
|
||||
|
||||
// wait for postrender to fire
|
||||
const postRender = () => {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
const handle = scene.events.on('postrender', () => {
|
||||
handle.off();
|
||||
try {
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
events.function('render.offscreen', async (width: number, height: number): Promise<Uint8Array> => {
|
||||
try {
|
||||
// start rendering to offscreen buffer only
|
||||
scene.camera.startOffscreenMode(width, height);
|
||||
scene.camera.renderOverlays = false;
|
||||
scene.gizmoLayer.enabled = false;
|
||||
|
||||
// render the next frame
|
||||
scene.forceRender = true;
|
||||
|
||||
// for render to finish
|
||||
await postRender();
|
||||
|
||||
// cpu-side buffer to read pixels into
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
|
||||
const { mainTarget, workTarget } = scene.camera;
|
||||
|
||||
scene.dataProcessor.copyRt(mainTarget, workTarget);
|
||||
|
||||
// read the rendered frame
|
||||
await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data });
|
||||
|
||||
// flip y positions to have 0,0 at the top
|
||||
let line = new Uint8Array(width * 4);
|
||||
for (let y = 0; y < height / 2; y++) {
|
||||
line = data.slice(y * width * 4, (y + 1) * width * 4);
|
||||
data.copyWithin(y * width * 4, (height - y - 1) * width * 4, (height - y) * width * 4);
|
||||
data.set(line, (height - y - 1) * width * 4);
|
||||
}
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
scene.camera.endOffscreenMode();
|
||||
scene.camera.renderOverlays = true;
|
||||
scene.gizmoLayer.enabled = true;
|
||||
scene.camera.camera.clearColor.set(0, 0, 0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
events.function('render.image', async (imageSettings: ImageSettings, fileStream?: FileSystemWritableFileStream) => {
|
||||
events.fire('startSpinner');
|
||||
|
||||
let equirect: EquirectRenderer | null = null;
|
||||
let savedFov = 0;
|
||||
let savedOrtho = false;
|
||||
|
||||
try {
|
||||
const { width, height, transparentBg, showDebug, projection, levelHorizon } = imageSettings;
|
||||
const is360 = projection === 'equirect';
|
||||
|
||||
// in 360 mode the offscreen target is a square cube face; the
|
||||
// equirect target holds the output-sized frame
|
||||
const faceSize = Math.min(height, scene.graphicsDevice.maxTextureSize);
|
||||
|
||||
// start rendering to offscreen buffer only
|
||||
scene.camera.startOffscreenMode(is360 ? faceSize : width, is360 ? faceSize : height);
|
||||
scene.camera.renderOverlays = is360 ? false : showDebug;
|
||||
scene.gizmoLayer.enabled = false;
|
||||
if (!transparentBg) {
|
||||
scene.camera.clearPass.setClearColor(events.invoke('bgClr'));
|
||||
}
|
||||
|
||||
// cpu-side buffer to read pixels into
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
|
||||
if (is360) {
|
||||
savedFov = scene.camera.fov;
|
||||
savedOrtho = scene.camera.ortho;
|
||||
equirect = new EquirectRenderer(scene.graphicsDevice, faceSize, width, height);
|
||||
scene.camera.ortho = false;
|
||||
|
||||
// snapshot the current camera pose. supersplat cameras never
|
||||
// roll, so with level horizon the capture frame is the
|
||||
// camera yaw, otherwise yaw and pitch
|
||||
const camPos = new Vec3().copy(scene.camera.position);
|
||||
const qCapture = new Quat();
|
||||
if (levelHorizon ?? true) {
|
||||
qCapture.setFromEulerAngles(0, scene.camera.azim, 0);
|
||||
} else {
|
||||
qCapture.copy(scene.camera.mainCamera.getRotation());
|
||||
}
|
||||
|
||||
// all faces share direction-independent clipping planes so
|
||||
// near-plane culling cannot differ across a face boundary
|
||||
const boundRadius = scene.bound.halfExtents.length();
|
||||
const dist = new Vec3().sub2(scene.bound.center, camPos).length();
|
||||
const far = dist + boundRadius;
|
||||
const near = Math.max(1e-6, dist < boundRadius ? far / (1024 * 16) : dist - boundRadius);
|
||||
|
||||
const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible);
|
||||
const qWorld = new Quat();
|
||||
|
||||
for (let face = 0; face < 6; face++) {
|
||||
qWorld.mul2(qCapture, EquirectRenderer.faceRotations[face]);
|
||||
scene.camera.setPoseOverride({ position: camPos, rotation: qWorld, fov: EquirectRenderer.faceFov, near, far });
|
||||
|
||||
// faces view different directions, so each render must
|
||||
// wait for its own sort
|
||||
await sortSplatsAndWait(scene, splats);
|
||||
|
||||
// render a frame and wait for it to finish
|
||||
scene.forceRender = true;
|
||||
await postRender();
|
||||
|
||||
scene.dataProcessor.copyRt(scene.camera.mainTarget, equirect.faceTargets[face]);
|
||||
}
|
||||
|
||||
// project the faces to the equirect target and read back
|
||||
equirect.project();
|
||||
await equirect.read(data);
|
||||
} else {
|
||||
// render the next frame
|
||||
scene.forceRender = true;
|
||||
|
||||
// for render to finish
|
||||
await postRender();
|
||||
|
||||
const { mainTarget, workTarget } = scene.camera;
|
||||
|
||||
scene.dataProcessor.copyRt(mainTarget, workTarget);
|
||||
|
||||
// read the rendered frame
|
||||
await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data });
|
||||
}
|
||||
|
||||
// flip the buffer vertically: the framebuffer read is bottom-up
|
||||
// but webp (and image files generally) expect top-down rows
|
||||
const line = new Uint8Array(width * 4);
|
||||
for (let y = 0; y < height / 2; y++) {
|
||||
const top = y * width * 4;
|
||||
const bottom = (height - y - 1) * width * 4;
|
||||
line.set(data.subarray(top, top + width * 4));
|
||||
data.copyWithin(top, bottom, bottom + width * 4);
|
||||
data.set(line, bottom);
|
||||
}
|
||||
|
||||
// construct the webp codec
|
||||
if (!webpCodec) {
|
||||
webpCodec = await WebPCodec.create();
|
||||
}
|
||||
|
||||
const bytes = webpCodec.encodeLosslessRGBA(data, width, height);
|
||||
|
||||
if (fileStream) {
|
||||
await fileStream.write(bytes);
|
||||
await fileStream.close();
|
||||
} else {
|
||||
downloadFile(bytes, `${baseFilename()}.webp`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// close the stream even on failure so the caller can remove the
|
||||
// empty file
|
||||
if (fileStream) {
|
||||
try {
|
||||
await fileStream.close();
|
||||
} catch {
|
||||
// stream already closed or errored
|
||||
}
|
||||
}
|
||||
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('panel.render.failed'),
|
||||
message: `'${error.message ?? error}'`
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
if (equirect) {
|
||||
scene.camera.setPoseOverride(null);
|
||||
scene.camera.fov = savedFov;
|
||||
scene.camera.ortho = savedOrtho;
|
||||
equirect.destroy();
|
||||
equirect = null;
|
||||
}
|
||||
|
||||
scene.camera.endOffscreenMode();
|
||||
scene.camera.renderOverlays = true;
|
||||
scene.gizmoLayer.enabled = true;
|
||||
scene.camera.clearPass.setClearColor(nullClr);
|
||||
|
||||
events.fire('stopSpinner');
|
||||
}
|
||||
});
|
||||
|
||||
events.function('render.video', (videoSettings: VideoSettings, fileStream: FileSystemWritableFileStream) => {
|
||||
const renderImpl = async () => {
|
||||
events.fire('progressStart', i18n.t('panel.render.render-video'), true);
|
||||
|
||||
let cancelled = false;
|
||||
const cancelHandler = events.on('progressCancel', () => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
let encoder: VideoEncoder | null = null;
|
||||
let equirect: EquirectRenderer | null = null;
|
||||
let savedFov = 0;
|
||||
let savedOrtho = false;
|
||||
|
||||
try {
|
||||
const { startFrame, endFrame, frameRate, width, height, bitrate, transparentBg, showDebug, format, codec: codecChoice, projection, levelHorizon } = videoSettings;
|
||||
|
||||
const is360 = projection === 'equirect';
|
||||
|
||||
// 360 mp4/mov exports have spherical metadata patched into the
|
||||
// finished buffer, so they render to memory with moov written
|
||||
// last (fastStart false) instead of streaming to disk
|
||||
const taggable = is360 && (format === 'mp4' || format === 'mov');
|
||||
|
||||
const target = (fileStream && !taggable) ? new StreamTarget(fileStream) : new BufferTarget();
|
||||
|
||||
// Configure output format and codec from lookup maps (default to mp4/h264)
|
||||
const formatConfig = FORMAT_CONFIG[format] ?? FORMAT_CONFIG.mp4;
|
||||
const outputFormat = formatConfig.create(taggable || !!fileStream);
|
||||
const fileExtension = formatConfig.extension;
|
||||
|
||||
const codecConfig = CODEC_CONFIG[codecChoice] ?? CODEC_CONFIG.h264;
|
||||
const codecType = codecConfig.type;
|
||||
const codec = codecConfig.codec(height);
|
||||
|
||||
const output = new Output({
|
||||
format: outputFormat,
|
||||
target
|
||||
});
|
||||
|
||||
const videoSource = new EncodedVideoPacketSource(codecType);
|
||||
output.addVideoTrack(videoSource, {
|
||||
rotation: 0,
|
||||
frameRate
|
||||
});
|
||||
|
||||
await output.start();
|
||||
|
||||
let encoderError: Error | null = null;
|
||||
|
||||
// helper to create and configure a VideoEncoder instance
|
||||
const createEncoder = () => {
|
||||
encoderError = null;
|
||||
const enc = new VideoEncoder({
|
||||
output: async (chunk, meta) => {
|
||||
const encodedPacket = EncodedPacket.fromEncodedChunk(chunk);
|
||||
await videoSource.add(encodedPacket, meta);
|
||||
},
|
||||
error: (error) => {
|
||||
encoderError = error;
|
||||
}
|
||||
});
|
||||
enc.configure({ codec, width, height, bitrate });
|
||||
return enc;
|
||||
};
|
||||
|
||||
// fail fast on unsupported configurations (e.g. encoder
|
||||
// dimension limits) instead of erroring mid-render
|
||||
const support = await VideoEncoder.isConfigSupported({ codec, width, height, bitrate });
|
||||
if (!support.supported) {
|
||||
throw new Error(`Unsupported video configuration (${codecChoice} @ ${width}x${height})`);
|
||||
}
|
||||
|
||||
encoder = createEncoder();
|
||||
|
||||
// in 360 mode the offscreen target is a square cube face; the
|
||||
// equirect target holds the output-sized frame
|
||||
const faceSize = Math.min(height, scene.graphicsDevice.maxTextureSize);
|
||||
|
||||
// start rendering to offscreen buffer only
|
||||
scene.camera.startOffscreenMode(is360 ? faceSize : width, is360 ? faceSize : height);
|
||||
scene.camera.renderOverlays = is360 ? false : showDebug;
|
||||
scene.gizmoLayer.enabled = false;
|
||||
if (!transparentBg) {
|
||||
scene.camera.clearPass.setClearColor(events.invoke('bgClr'));
|
||||
}
|
||||
scene.lockedRenderMode = true;
|
||||
|
||||
if (is360) {
|
||||
savedFov = scene.camera.fov;
|
||||
savedOrtho = scene.camera.ortho;
|
||||
equirect = new EquirectRenderer(scene.graphicsDevice, faceSize, width, height);
|
||||
scene.camera.ortho = false;
|
||||
}
|
||||
|
||||
// cpu-side buffer to read pixels into
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const line = new Uint8Array(width * 4);
|
||||
|
||||
// remember last camera position so we can skip sorting if the camera didn't move
|
||||
const last_pos = new Vec3(0, 0, 0);
|
||||
const last_forward = new Vec3(1, 0, 0);
|
||||
|
||||
// helper to sort splats and wait for completion
|
||||
const sortAndWait = (splats: Splat[]) => sortSplatsAndWait(scene, splats);
|
||||
|
||||
// prepare the frame for rendering, returns the newly loaded splat if any
|
||||
const prepareFrame = async (frameTime: number, skipSort = false): Promise<Splat | null> => {
|
||||
// Fire timeline.time for camera animation interpolation
|
||||
events.fire('timeline.time', frameTime);
|
||||
|
||||
// Wait for PLY sequence to load the frame if present
|
||||
const newSplat = await events.invoke('plysequence.setFrameAsync', Math.floor(frameTime)) as Splat | null;
|
||||
|
||||
// manually update the camera so position and rotation are correct
|
||||
scene.camera.onUpdate(0);
|
||||
|
||||
// 360 capture re-sorts per cube face, so skip sorting here
|
||||
if (skipSort) {
|
||||
return newSplat;
|
||||
}
|
||||
|
||||
// If a new PLY was loaded, sort and wait for completion
|
||||
if (newSplat) {
|
||||
await sortAndWait([newSplat]);
|
||||
} else {
|
||||
// No new PLY - sort existing splats if camera moved
|
||||
const pos = scene.camera.position;
|
||||
const forward = scene.camera.forward;
|
||||
if (!last_pos.equals(pos) || !last_forward.equals(forward)) {
|
||||
last_pos.copy(pos);
|
||||
last_forward.copy(forward);
|
||||
|
||||
const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible);
|
||||
await sortAndWait(splats);
|
||||
}
|
||||
}
|
||||
|
||||
return newSplat;
|
||||
};
|
||||
|
||||
// flip, wrap and submit the pixels currently in the data buffer
|
||||
const encodeFrame = async (frameTime: number) => {
|
||||
// flip the buffer vertically
|
||||
for (let y = 0; y < height / 2; y++) {
|
||||
const top = y * width * 4;
|
||||
const bottom = (height - y - 1) * width * 4;
|
||||
line.set(data.subarray(top, top + width * 4));
|
||||
data.copyWithin(top, bottom, bottom + width * 4);
|
||||
data.set(line, bottom);
|
||||
}
|
||||
|
||||
// construct the video frame
|
||||
const videoFrame = new VideoFrame(data, {
|
||||
format: 'RGBA',
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
timestamp: Math.floor(1e6 * frameTime),
|
||||
duration: Math.floor(1e6 / frameRate)
|
||||
});
|
||||
|
||||
// wait for encoder queue to drain if necessary (backpressure handling)
|
||||
while (encoder.encodeQueueSize > 5) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// if the codec was reclaimed (e.g. browser backgrounded the tab),
|
||||
// recreate the encoder and continue
|
||||
let forceKeyFrame = false;
|
||||
if (encoder.state === 'closed' && encoderError?.message?.includes('reclaimed')) {
|
||||
encoder = createEncoder();
|
||||
forceKeyFrame = true;
|
||||
}
|
||||
|
||||
// check for non-recoverable encoder errors
|
||||
if (encoderError) {
|
||||
videoFrame.close();
|
||||
throw encoderError;
|
||||
}
|
||||
|
||||
encoder.encode(videoFrame, { keyFrame: forceKeyFrame });
|
||||
videoFrame.close();
|
||||
};
|
||||
|
||||
// capture the current video frame
|
||||
const captureFrame = async (frameTime: number) => {
|
||||
const { mainTarget, workTarget } = scene.camera;
|
||||
|
||||
scene.dataProcessor.copyRt(mainTarget, workTarget);
|
||||
|
||||
// read the rendered frame
|
||||
await workTarget.colorBuffer.read(0, 0, width, height, { renderTarget: workTarget, data });
|
||||
|
||||
await encodeFrame(frameTime);
|
||||
};
|
||||
|
||||
const animFrameRate = events.invoke('timeline.frameRate');
|
||||
const duration = (endFrame - startFrame) / animFrameRate;
|
||||
const totalFrames = Math.floor(duration * frameRate) + 1;
|
||||
|
||||
// work objects for 360 capture
|
||||
const camPos = new Vec3();
|
||||
const vec = new Vec3();
|
||||
const qCapture = new Quat();
|
||||
const qWorld = new Quat();
|
||||
|
||||
// capture a 360 frame: render the six cube faces from the
|
||||
// animated camera position, re-sorting splats per face
|
||||
// direction, then project to equirect and encode
|
||||
const capture360 = async (frameTime: number) => {
|
||||
// snapshot the animated camera pose. supersplat cameras
|
||||
// never roll, so with level horizon the capture frame is
|
||||
// the camera yaw, otherwise yaw and pitch
|
||||
camPos.copy(scene.camera.position);
|
||||
if (levelHorizon ?? true) {
|
||||
qCapture.setFromEulerAngles(0, scene.camera.azim, 0);
|
||||
} else {
|
||||
qCapture.copy(scene.camera.mainCamera.getRotation());
|
||||
}
|
||||
|
||||
// all faces share direction-independent clipping planes so
|
||||
// near-plane culling cannot differ across a face boundary
|
||||
const boundRadius = scene.bound.halfExtents.length();
|
||||
const dist = vec.sub2(scene.bound.center, camPos).length();
|
||||
const far = dist + boundRadius;
|
||||
const near = Math.max(1e-6, dist < boundRadius ? far / (1024 * 16) : dist - boundRadius);
|
||||
|
||||
const splats = (scene.getElementsByType(ElementType.splat) as Splat[]).filter(splat => splat.visible);
|
||||
|
||||
for (let face = 0; face < 6; face++) {
|
||||
// check for cancellation
|
||||
if (cancelled) return;
|
||||
|
||||
qWorld.mul2(qCapture, EquirectRenderer.faceRotations[face]);
|
||||
scene.camera.setPoseOverride({ position: camPos, rotation: qWorld, fov: EquirectRenderer.faceFov, near, far });
|
||||
|
||||
// faces view different directions, so each render must
|
||||
// wait for its own sort
|
||||
await sortAndWait(splats);
|
||||
|
||||
// render a frame
|
||||
scene.lockedRender = true;
|
||||
|
||||
// wait for render to finish
|
||||
await postRender();
|
||||
|
||||
scene.dataProcessor.copyRt(scene.camera.mainTarget, equirect.faceTargets[face]);
|
||||
|
||||
const frameIndex = Math.round(frameTime * frameRate);
|
||||
events.fire('progressUpdate', {
|
||||
text: i18n.t('panel.render.rendering', { ellipsis: true }),
|
||||
progress: 100 * (frameIndex + (face + 1) / 6) / totalFrames
|
||||
});
|
||||
}
|
||||
|
||||
// project the faces to the equirect target and encode
|
||||
equirect.project();
|
||||
await equirect.read(data);
|
||||
await encodeFrame(frameTime);
|
||||
};
|
||||
|
||||
for (let frameTime = 0; frameTime <= duration; frameTime += 1.0 / frameRate) {
|
||||
// check for cancellation
|
||||
if (cancelled) break;
|
||||
|
||||
if (is360) {
|
||||
// restore animated-pose evaluation before the timeline
|
||||
// advances (fov feeds the tween-to-position mapping)
|
||||
scene.camera.setPoseOverride(null);
|
||||
scene.camera.fov = savedFov;
|
||||
|
||||
// prepare the frame (loads PLY if needed, updates camera)
|
||||
await prepareFrame(startFrame + frameTime * animFrameRate, true);
|
||||
|
||||
await capture360(frameTime);
|
||||
} else {
|
||||
// prepare the frame (loads PLY if needed, updates camera, sorts)
|
||||
await prepareFrame(startFrame + frameTime * animFrameRate);
|
||||
|
||||
// render a frame
|
||||
scene.lockedRender = true;
|
||||
|
||||
// wait for render to finish
|
||||
await postRender();
|
||||
|
||||
// wait for capture
|
||||
await captureFrame(frameTime);
|
||||
|
||||
events.fire('progressUpdate', {
|
||||
text: i18n.t('panel.render.rendering', { ellipsis: true }),
|
||||
progress: 100 * frameTime / duration
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flush and finalize output
|
||||
await encoder.flush();
|
||||
await output.finalize();
|
||||
|
||||
const filename = () => `${baseFilename()}.${fileExtension}`;
|
||||
|
||||
if (taggable) {
|
||||
// patch spherical metadata into the finished buffer so
|
||||
// players auto-detect the equirectangular projection
|
||||
if (!cancelled) {
|
||||
let buffer = (target as BufferTarget).buffer;
|
||||
try {
|
||||
buffer = injectSphericalMetadata(buffer);
|
||||
} catch (error) {
|
||||
console.warn(`failed to inject spherical metadata: ${error.message ?? error}`);
|
||||
}
|
||||
|
||||
if (fileStream) {
|
||||
await fileStream.write(buffer);
|
||||
} else {
|
||||
downloadFile(buffer, filename());
|
||||
}
|
||||
}
|
||||
|
||||
// close the stream even when cancelled so the caller can
|
||||
// remove the empty file
|
||||
if (fileStream) {
|
||||
await fileStream.close();
|
||||
}
|
||||
} else if (!cancelled && !fileStream) {
|
||||
// Download (skip if cancelled -- the caller will delete the file)
|
||||
downloadFile((target as BufferTarget).buffer, filename());
|
||||
}
|
||||
|
||||
return !cancelled;
|
||||
} catch (error) {
|
||||
await events.invoke('showPopup', {
|
||||
type: 'error',
|
||||
header: i18n.t('panel.render.failed'),
|
||||
message: `'${(error as any).message ?? error}'`
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
if (encoder && encoder.state !== 'closed') {
|
||||
encoder.close();
|
||||
}
|
||||
cancelHandler.off();
|
||||
|
||||
if (equirect) {
|
||||
scene.camera.setPoseOverride(null);
|
||||
scene.camera.fov = savedFov;
|
||||
scene.camera.ortho = savedOrtho;
|
||||
equirect.destroy();
|
||||
equirect = null;
|
||||
}
|
||||
|
||||
scene.camera.endOffscreenMode();
|
||||
scene.camera.renderOverlays = true;
|
||||
scene.gizmoLayer.enabled = true;
|
||||
scene.camera.clearPass.setClearColor(nullClr);
|
||||
scene.lockedRenderMode = false;
|
||||
scene.forceRender = true; // camera likely moved, finish with normal render
|
||||
|
||||
events.fire('progressEnd');
|
||||
}
|
||||
};
|
||||
|
||||
// Acquire a Web Lock during encoding to signal the browser that this tab is
|
||||
// actively working, which helps prevent aggressive background throttling and
|
||||
// codec reclamation.
|
||||
if (navigator.locks) {
|
||||
return navigator.locks.request('supersplat-video-render', renderImpl);
|
||||
}
|
||||
return renderImpl();
|
||||
});
|
||||
};
|
||||
|
||||
export { ImageSettings, VideoSettings, registerRenderEvents };
|
||||
@@ -0,0 +1,171 @@
|
||||
type Color = { r: number, g: number, b: number, a: number };
|
||||
|
||||
const DEFAULT_BG_CLR: Color = { r: 0, g: 0, b: 0, a: 1 };
|
||||
const DEFAULT_SELECTED_CLR: Color = { r: 1, g: 1, b: 0, a: 1 };
|
||||
const DEFAULT_UNSELECTED_CLR: Color = { r: 0, g: 0, b: 1, a: 0.5 };
|
||||
const DEFAULT_LOCKED_CLR: Color = { r: 0, g: 0, b: 0, a: 0.05 };
|
||||
|
||||
// default config
|
||||
const sceneConfig = {
|
||||
bgClr: DEFAULT_BG_CLR,
|
||||
selectedClr: DEFAULT_SELECTED_CLR,
|
||||
unselectedClr: DEFAULT_UNSELECTED_CLR,
|
||||
lockedClr: DEFAULT_LOCKED_CLR,
|
||||
camera: {
|
||||
pixelScale: 1,
|
||||
multisample: false,
|
||||
fov: 75,
|
||||
exposure: 1.0,
|
||||
toneMapping: 'linear',
|
||||
overlay: false
|
||||
},
|
||||
show: {
|
||||
grid: true,
|
||||
bound: true,
|
||||
boundDimensions: false,
|
||||
cameraPoses: false,
|
||||
cameraInfo: false,
|
||||
shBands: 3
|
||||
},
|
||||
controls: {
|
||||
dampingFactor: 0.2,
|
||||
minPolarAngle: 0,
|
||||
maxPolarAngle: Math.PI,
|
||||
minZoom: 1e-6,
|
||||
maxZoom: 10.0,
|
||||
initialAzim: -45,
|
||||
initialElev: -10,
|
||||
initialZoom: 1.0,
|
||||
orbitSensitivity: 0.3,
|
||||
zoomSensitivity: 0.4
|
||||
},
|
||||
debug: {
|
||||
showBound: false
|
||||
}
|
||||
};
|
||||
|
||||
type SceneConfig = typeof sceneConfig;
|
||||
|
||||
class Params {
|
||||
sources: any[];
|
||||
|
||||
constructor(sources: any[]) {
|
||||
this.sources = sources;
|
||||
}
|
||||
|
||||
private resolve(configs: any[], path: string[]): any {
|
||||
const get = (obj: any): any => {
|
||||
for (const name of path) {
|
||||
if (!obj.hasOwnProperty(name)) {
|
||||
return undefined;
|
||||
}
|
||||
obj = obj[name];
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
for (const config of configs) {
|
||||
const value = get(config);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get(path: string): any {
|
||||
// https://stackoverflow.com/a/67243723/2405687
|
||||
const kebabize = (s: string) => s.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
|
||||
return this.resolve(this.sources, path.split('.').map(kebabize)) ?? this.resolve(this.sources, path.split('.'));
|
||||
}
|
||||
|
||||
getBool(path: string): boolean | undefined {
|
||||
const value = this.get(path);
|
||||
return typeof value === 'string' ? value.toLowerCase() === 'true' : value === undefined ? undefined : !!value;
|
||||
}
|
||||
|
||||
getNumber(path: string) {
|
||||
const value = this.get(path);
|
||||
return typeof value === 'string' ? parseFloat(value) : value;
|
||||
}
|
||||
|
||||
getVec(path: string) {
|
||||
const value = this.get(path);
|
||||
return typeof value === 'string' ? value.split(',') : undefined;
|
||||
}
|
||||
|
||||
getVec3(path: string) {
|
||||
const value = this.getVec(path);
|
||||
if (value) {
|
||||
const numbers = value.map(v => parseFloat(v));
|
||||
if (value.length === 1) {
|
||||
return { x: numbers[0], y: numbers[0], z: numbers[0] };
|
||||
} else if (value.length === 3) {
|
||||
return { x: numbers[0], y: numbers[1], z: numbers[2] };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getColor(path: string) {
|
||||
const value = this.getVec(path);
|
||||
if (value) {
|
||||
const numbers = value.map(v => parseFloat(v));
|
||||
if (value.length === 1) {
|
||||
return { r: numbers[0], g: numbers[0], b: numbers[0], a: 1 };
|
||||
} else if (value.length === 3) {
|
||||
return { r: numbers[0], g: numbers[1], b: numbers[2], a: 1 };
|
||||
} else if (value.length === 4) {
|
||||
return { r: numbers[0], g: numbers[1], b: numbers[2], a: numbers[3] };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const getSceneConfig = (overrides: any[]) => {
|
||||
const params = new Params(overrides);
|
||||
|
||||
const cmp = (a: any[], b: any[]) => {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
};
|
||||
|
||||
// recurse the object and replace concrete leaf values with overrides
|
||||
const rec = (obj: any, path: string) => {
|
||||
for (const child in obj) {
|
||||
const childPath = `${path}${path.length ? '.' : ''}${child}`;
|
||||
const childValue = obj[child];
|
||||
switch (typeof childValue) {
|
||||
case 'number':
|
||||
obj[child] = params.getNumber(childPath) ?? childValue;
|
||||
break;
|
||||
case 'boolean':
|
||||
obj[child] = params.getBool(childPath) ?? childValue;
|
||||
break;
|
||||
case 'string':
|
||||
obj[child] = params.get(childPath) ?? childValue;
|
||||
break;
|
||||
case 'object': {
|
||||
const keys = Object.keys(childValue).sort();
|
||||
if (cmp(keys, ['a', 'b', 'g', 'r'])) {
|
||||
obj[child] = params.getColor(childPath) ?? childValue;
|
||||
} else if (cmp(keys, ['x', 'y', 'z'])) {
|
||||
obj[child] = params.getVec3(childPath) ?? childValue;
|
||||
} else {
|
||||
rec(childValue, childPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
rec(childValue, childPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rec(sceneConfig, '');
|
||||
|
||||
return sceneConfig;
|
||||
};
|
||||
|
||||
export { SceneConfig, getSceneConfig };
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Element, ElementType, ElementTypeList } from './element';
|
||||
import { Serializer } from './serializer';
|
||||
|
||||
const common = new Set<Element>();
|
||||
|
||||
// this class tracks the state of scene elements and determines what
|
||||
// type of objects have changed in a frame. this allows the rest of
|
||||
// the application to respond to changes like re-rendering
|
||||
// the scene or recalculating the scene bounding box.
|
||||
class SceneState {
|
||||
states: any = {};
|
||||
activeValues: any[];
|
||||
|
||||
serializer = new Serializer((value: any) => {
|
||||
this.activeValues.push(value);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
for (let i = 0; i < ElementTypeList.length; ++i) {
|
||||
this.states[ElementTypeList[i]] = {
|
||||
elements: new Map<Element, number>(),
|
||||
valueStart: [],
|
||||
valueCount: [],
|
||||
values: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
ElementTypeList.forEach((type) => {
|
||||
const state = this.states[type];
|
||||
state.elements.clear();
|
||||
state.valueStart.length = 0;
|
||||
state.valueCount.length = 0;
|
||||
state.values.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
pack(element: Element) {
|
||||
const state = this.states[element.type];
|
||||
const start = state.values.length;
|
||||
|
||||
// let element store its values
|
||||
this.activeValues = state.values;
|
||||
element.serialize(this.serializer);
|
||||
|
||||
state.elements.set(element, state.elements.size);
|
||||
state.valueStart.push(start);
|
||||
state.valueCount.push(state.values.length - start);
|
||||
}
|
||||
|
||||
compare(other: SceneState) {
|
||||
function intersection<K, V>(result: Set<K>, a: Map<K, V>, b: Map<K, V>) {
|
||||
result.clear();
|
||||
for (const e of a.keys()) {
|
||||
if (b.has(e)) {
|
||||
result.add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function diff<K, V>(a: Map<K, V>, b: Set<K>) {
|
||||
for (const e of a.keys()) {
|
||||
if (!b.has(e)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function some<T>(it: IterableIterator<T>, predicate: (t: T) => boolean) {
|
||||
for (const e of it) {
|
||||
if (predicate(e)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = {
|
||||
added: new Array<ElementType>(),
|
||||
removed: new Array<ElementType>(),
|
||||
moved: new Array<ElementType>(),
|
||||
changed: new Array<ElementType>()
|
||||
};
|
||||
|
||||
ElementTypeList.forEach((type) => {
|
||||
const prevState = other.states[type];
|
||||
const currState = this.states[type];
|
||||
|
||||
// generate map of element to index
|
||||
const prev = prevState.elements;
|
||||
const curr = currState.elements;
|
||||
|
||||
// make a set containing the elements present in both the previous and current frame
|
||||
intersection(common, prev, curr);
|
||||
|
||||
// determine if any elements were added
|
||||
if (diff(curr, common)) {
|
||||
result.added.push(type);
|
||||
}
|
||||
|
||||
// determine if any elements were removed
|
||||
if (diff(prev, common)) {
|
||||
result.removed.push(type);
|
||||
}
|
||||
|
||||
// determine if any element moved order
|
||||
if (
|
||||
some(common.values(), (e: Element) => {
|
||||
return prev.get(e) !== curr.get(e);
|
||||
})
|
||||
) {
|
||||
result.moved.push(type);
|
||||
}
|
||||
|
||||
// determine if any element's state changed
|
||||
if (
|
||||
some(common.values(), (e: Element) => {
|
||||
const prevIdx = prev.get(e);
|
||||
const currIdx = curr.get(e);
|
||||
const count = prevState.valueCount[prevIdx];
|
||||
|
||||
if (count !== currState.valueCount[currIdx]) {
|
||||
// number of state values changed
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevStart = prevState.valueStart[prevIdx];
|
||||
const currStart = currState.valueStart[currIdx];
|
||||
const prevValues = prevState.values;
|
||||
const currValues = currState.values;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (prevValues[prevStart + i] !== currValues[currStart + i]) {
|
||||
// state value changed
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
) {
|
||||
result.changed.push(type);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export { SceneState };
|
||||
+413
@@ -0,0 +1,413 @@
|
||||
import {
|
||||
EVENT_POSTRENDER_LAYER,
|
||||
EVENT_PRERENDER_LAYER,
|
||||
LAYERID_DEPTH,
|
||||
SORTMODE_CUSTOM,
|
||||
BoundingBox,
|
||||
CameraComponent,
|
||||
Color,
|
||||
Entity,
|
||||
Layer,
|
||||
GraphicsDevice,
|
||||
MeshInstance,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { AssetLoader } from './asset-loader';
|
||||
import { Camera } from './camera';
|
||||
import { CameraPoseGizmos } from './camera-pose-gizmos';
|
||||
import { CommandQueue } from './command-queue';
|
||||
import { DataProcessor } from './data-processor';
|
||||
import { Element, ElementType, ElementTypeList } from './element';
|
||||
import { Events } from './events';
|
||||
import { InfiniteGrid as Grid } from './infinite-grid';
|
||||
import { Outline } from './outline';
|
||||
import { PCApp } from './pc-app';
|
||||
import { SceneConfig } from './scene-config';
|
||||
import { SceneState } from './scene-state';
|
||||
import { Splat } from './splat';
|
||||
import { SplatOverlay } from './splat-overlay';
|
||||
import { Underlay } from './underlay';
|
||||
|
||||
// sort meshInstances by the aabb corner furthest from the camera
|
||||
const corner = new Vec3();
|
||||
const specialSort = (instances: MeshInstance[], numInstances: number, cameraPos: Vec3, cameraDir: Vec3) => {
|
||||
const distances = new Map<MeshInstance, number>();
|
||||
|
||||
for (let i = 0; i < numInstances; i++) {
|
||||
const instance = instances[i];
|
||||
const { aabb } = instance;
|
||||
const { center, halfExtents } = aabb;
|
||||
|
||||
// loop over all 8 aabb corners and find the furthest distance along the camera view direction
|
||||
let maxDist = -Infinity;
|
||||
for (let cx = -1; cx <= 1; cx += 2) {
|
||||
for (let cy = -1; cy <= 1; cy += 2) {
|
||||
for (let cz = -1; cz <= 1; cz += 2) {
|
||||
corner.set(
|
||||
center.x + cx * halfExtents.x,
|
||||
center.y + cy * halfExtents.y,
|
||||
center.z + cz * halfExtents.z
|
||||
);
|
||||
// project camera-to-corner vector onto camera direction
|
||||
const dist = (corner.x - cameraPos.x) * cameraDir.x +
|
||||
(corner.y - cameraPos.y) * cameraDir.y +
|
||||
(corner.z - cameraPos.z) * cameraDir.z;
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store in map for reuse during sort
|
||||
distances.set(instance, maxDist);
|
||||
}
|
||||
|
||||
// sort instances back-to-front by calculated distance (furthest first)
|
||||
instances.sort((a, b) => distances.get(b) - distances.get(a));
|
||||
};
|
||||
|
||||
class Scene {
|
||||
events: Events;
|
||||
config: SceneConfig;
|
||||
canvas: HTMLCanvasElement;
|
||||
app: PCApp;
|
||||
worldLayer: Layer;
|
||||
splatLayer: Layer;
|
||||
gizmoLayer: Layer;
|
||||
sceneState = [new SceneState(), new SceneState()];
|
||||
elements: Element[] = [];
|
||||
boundStorage = new BoundingBox();
|
||||
boundDirty = true;
|
||||
forceRender = false;
|
||||
|
||||
lockedRenderMode = false;
|
||||
lockedRender = false;
|
||||
|
||||
canvasResize: {width: number; height: number} | null = null;
|
||||
targetSize = {
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
|
||||
dataProcessor: DataProcessor;
|
||||
assetLoader: AssetLoader;
|
||||
camera: Camera;
|
||||
cameraPoseGizmos: CameraPoseGizmos;
|
||||
splatOverlay: SplatOverlay;
|
||||
grid: Grid;
|
||||
outline: Outline;
|
||||
underlay: Underlay;
|
||||
|
||||
// shared queue for serialising async splat work. exposed so subsystems that
|
||||
// need to order their async work alongside edit-history operations can do so
|
||||
// without going through edit-history directly.
|
||||
commandQueue: CommandQueue;
|
||||
|
||||
contentRoot: Entity;
|
||||
cameraRoot: Entity;
|
||||
|
||||
constructor(
|
||||
events: Events,
|
||||
config: SceneConfig,
|
||||
canvas: HTMLCanvasElement,
|
||||
graphicsDevice: GraphicsDevice,
|
||||
commandQueue: CommandQueue
|
||||
) {
|
||||
this.events = events;
|
||||
this.config = config;
|
||||
this.canvas = canvas;
|
||||
this.commandQueue = commandQueue;
|
||||
|
||||
// configure the playcanvas application. we render to an offscreen buffer so require
|
||||
// only the simplest of backbuffers.
|
||||
this.app = new PCApp(canvas, { graphicsDevice });
|
||||
|
||||
// only render the scene when instructed
|
||||
this.app.autoRender = false;
|
||||
// @ts-ignore
|
||||
this.app._allowResize = false;
|
||||
this.app.scene.clusteredLightingEnabled = false;
|
||||
|
||||
// hack: disable lightmapper first bake until we expose option for this
|
||||
// @ts-ignore
|
||||
this.app.off('prerender', this.app._firstBake, this.app);
|
||||
|
||||
// @ts-ignore
|
||||
this.app.loader.getHandler('texture').imgParser.crossOrigin = 'anonymous';
|
||||
|
||||
// this is required to get full res AR mode backbuffer
|
||||
this.app.graphicsDevice.maxPixelRatio = window.devicePixelRatio;
|
||||
|
||||
// configure application canvas
|
||||
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
if (entries.length > 0) {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
if (entry.devicePixelContentBoxSize) {
|
||||
// on non-safari browsers, we are given the pixel-perfect canvas size
|
||||
this.canvasResize = {
|
||||
width: entry.devicePixelContentBoxSize[0].inlineSize,
|
||||
height: entry.devicePixelContentBoxSize[0].blockSize
|
||||
};
|
||||
} else if (entry.contentBoxSize.length > 0) {
|
||||
// on safari browsers we must calculate pixel size from CSS size ourselves
|
||||
// and hope the browser performs the same calculation.
|
||||
const pixelRatio = window.devicePixelRatio;
|
||||
this.canvasResize = {
|
||||
width: Math.ceil(entry.contentBoxSize[0].inlineSize * pixelRatio),
|
||||
height: Math.ceil(entry.contentBoxSize[0].blockSize * pixelRatio)
|
||||
};
|
||||
}
|
||||
}
|
||||
this.forceRender = true;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(window.document.getElementById('canvas-container'));
|
||||
|
||||
// configure depth layers to handle dynamic refraction
|
||||
const depthLayer = this.app.scene.layers.getLayerById(LAYERID_DEPTH);
|
||||
this.app.scene.layers.remove(depthLayer);
|
||||
this.app.scene.layers.insertOpaque(depthLayer, 2);
|
||||
|
||||
// register application callbacks
|
||||
this.app.on('update', (deltaTime: number) => this.onUpdate(deltaTime));
|
||||
this.app.on('prerender', () => this.onPreRender());
|
||||
this.app.on('postrender', () => this.onPostRender());
|
||||
|
||||
// force render on device restored
|
||||
this.app.graphicsDevice.on('devicerestored', () => {
|
||||
this.forceRender = true;
|
||||
});
|
||||
|
||||
// fire pre and post render events on the camera
|
||||
this.app.scene.on(EVENT_PRERENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => {
|
||||
camera.fire('preRenderLayer', layer, transparent);
|
||||
});
|
||||
|
||||
this.app.scene.on(EVENT_POSTRENDER_LAYER, (camera: CameraComponent, layer: Layer, transparent: boolean) => {
|
||||
camera.fire('postRenderLayer', layer, transparent);
|
||||
});
|
||||
|
||||
// get the world layer
|
||||
this.worldLayer = this.app.scene.layers.getLayerByName('World');
|
||||
|
||||
// splat layer - dedicated layer for splat rendering with MRT
|
||||
this.splatLayer = new Layer({
|
||||
name: 'Splat',
|
||||
opaqueSortMode: SORTMODE_CUSTOM,
|
||||
transparentSortMode: SORTMODE_CUSTOM
|
||||
});
|
||||
this.splatLayer.customCalculateSortValues = specialSort;
|
||||
|
||||
// gizmo layer
|
||||
this.gizmoLayer = new Layer({ name: 'Gizmo' });
|
||||
|
||||
const layers = this.app.scene.layers;
|
||||
layers.push(this.splatLayer);
|
||||
layers.push(this.gizmoLayer);
|
||||
|
||||
this.dataProcessor = new DataProcessor(this.app.graphicsDevice);
|
||||
this.assetLoader = new AssetLoader(this.app, events);
|
||||
|
||||
// create root entities
|
||||
this.contentRoot = new Entity('contentRoot');
|
||||
this.app.root.addChild(this.contentRoot);
|
||||
|
||||
this.cameraRoot = new Entity('cameraRoot');
|
||||
this.app.root.addChild(this.cameraRoot);
|
||||
|
||||
// create elements
|
||||
this.camera = new Camera();
|
||||
this.add(this.camera);
|
||||
|
||||
this.cameraPoseGizmos = new CameraPoseGizmos();
|
||||
this.add(this.cameraPoseGizmos);
|
||||
|
||||
this.splatOverlay = new SplatOverlay();
|
||||
this.add(this.splatOverlay);
|
||||
|
||||
this.grid = new Grid();
|
||||
this.add(this.grid);
|
||||
|
||||
this.outline = new Outline();
|
||||
this.add(this.outline);
|
||||
this.underlay = new Underlay();
|
||||
this.add(this.underlay);
|
||||
}
|
||||
|
||||
start() {
|
||||
// start the app
|
||||
this.app.start();
|
||||
}
|
||||
|
||||
clear() {
|
||||
const splats = this.getElementsByType(ElementType.splat);
|
||||
splats.forEach((splat) => {
|
||||
this.remove(splat);
|
||||
(splat as Splat).destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// add a scene element
|
||||
async add(element: Element) {
|
||||
if (!element.scene) {
|
||||
// add the new element
|
||||
element.scene = this;
|
||||
await element.add();
|
||||
this.elements.push(element);
|
||||
|
||||
// notify all elements of scene addition
|
||||
this.forEachElement(e => e !== element && e.onAdded(element));
|
||||
|
||||
// notify listeners
|
||||
this.events.fire('scene.elementAdded', element);
|
||||
}
|
||||
}
|
||||
|
||||
// remove an element from the scene
|
||||
remove(element: Element) {
|
||||
if (element.scene === this) {
|
||||
// remove from list
|
||||
this.elements.splice(this.elements.indexOf(element), 1);
|
||||
|
||||
// notify listeners
|
||||
this.events.fire('scene.elementRemoved', element);
|
||||
|
||||
// notify all elements of scene removal
|
||||
this.forEachElement(e => e.onRemoved(element));
|
||||
|
||||
element.remove();
|
||||
element.scene = null;
|
||||
}
|
||||
}
|
||||
|
||||
// get the scene bound
|
||||
get bound() {
|
||||
if (this.boundDirty) {
|
||||
let valid = false;
|
||||
this.forEachElement((e) => {
|
||||
const bound = e.worldBound;
|
||||
if (bound) {
|
||||
if (!valid) {
|
||||
valid = true;
|
||||
this.boundStorage.copy(bound);
|
||||
} else {
|
||||
this.boundStorage.add(bound);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.boundDirty = false;
|
||||
this.events.fire('scene.boundChanged', this.boundStorage);
|
||||
}
|
||||
|
||||
return this.boundStorage;
|
||||
}
|
||||
|
||||
getElementsByType(elementType: ElementType) {
|
||||
return this.elements.filter(e => e.type === elementType);
|
||||
}
|
||||
|
||||
get graphicsDevice() {
|
||||
return this.app.graphicsDevice;
|
||||
}
|
||||
|
||||
private forEachElement(action: (e: Element) => void) {
|
||||
this.elements.forEach(action);
|
||||
}
|
||||
|
||||
private onUpdate(deltaTime: number) {
|
||||
// allow elements to update
|
||||
this.forEachElement(e => e.onUpdate(deltaTime));
|
||||
|
||||
// fire global update
|
||||
this.events.fire('update', deltaTime);
|
||||
|
||||
// fire a 'serialize' event which listers will use to store their state. we'll use
|
||||
// this to decide if the view has changed and so requires rendering.
|
||||
const i = this.app.frame % 2;
|
||||
const state = this.sceneState[i];
|
||||
state.reset();
|
||||
this.forEachElement(e => state.pack(e));
|
||||
|
||||
// diff with previous state
|
||||
const result = state.compare(this.sceneState[1 - i]);
|
||||
|
||||
// generate the set of all element types that changed
|
||||
const all = new Set([...result.added, ...result.removed, ...result.moved, ...result.changed]);
|
||||
|
||||
// compare with previously serialized
|
||||
if (this.lockedRenderMode) {
|
||||
this.app.renderNextFrame = this.lockedRender;
|
||||
this.lockedRender = false;
|
||||
} else if (!this.app.renderNextFrame) {
|
||||
this.app.renderNextFrame = this.forceRender || all.size > 0;
|
||||
}
|
||||
this.forceRender = false;
|
||||
|
||||
// raise per-type update events
|
||||
ElementTypeList.forEach((type) => {
|
||||
if (all.has(type)) {
|
||||
this.events.fire(`updated:${type}`);
|
||||
}
|
||||
});
|
||||
|
||||
// allow elements to postupdate
|
||||
this.forEachElement(e => e.onPostUpdate());
|
||||
}
|
||||
|
||||
private onPreRender() {
|
||||
if (this.canvasResize) {
|
||||
this.canvas.width = this.canvasResize.width;
|
||||
this.canvas.height = this.canvasResize.height;
|
||||
this.canvasResize = null;
|
||||
}
|
||||
|
||||
// update render target size
|
||||
this.targetSize.width = Math.ceil(this.app.graphicsDevice.width / this.config.camera.pixelScale);
|
||||
this.targetSize.height = Math.ceil(this.app.graphicsDevice.height / this.config.camera.pixelScale);
|
||||
|
||||
this.forEachElement(e => e.onPreRender());
|
||||
|
||||
this.events.fire('prerender', this.camera.displayTransform);
|
||||
|
||||
// debug - display scene bound
|
||||
if (this.config.debug.showBound) {
|
||||
// draw element bounds
|
||||
this.forEachElement((e: Element) => {
|
||||
if (e.type === ElementType.splat) {
|
||||
const splat = e as Splat;
|
||||
|
||||
const local = splat.localBound;
|
||||
this.app.drawWireAlignedBox(
|
||||
local.getMin(),
|
||||
local.getMax(),
|
||||
Color.RED,
|
||||
true,
|
||||
undefined,
|
||||
splat.entity.getWorldTransform());
|
||||
|
||||
const world = splat.worldBound;
|
||||
this.app.drawWireAlignedBox(
|
||||
world.getMin(),
|
||||
world.getMax(),
|
||||
Color.GREEN);
|
||||
}
|
||||
});
|
||||
|
||||
// draw scene bound
|
||||
this.app.drawWireAlignedBox(this.bound.getMin(), this.bound.getMax(), Color.BLUE);
|
||||
}
|
||||
}
|
||||
|
||||
private onPostRender() {
|
||||
this.forEachElement(e => e.onPostRender());
|
||||
|
||||
this.events.fire('postrender');
|
||||
}
|
||||
}
|
||||
|
||||
export { SceneConfig, Scene };
|
||||
@@ -0,0 +1,10 @@
|
||||
// Map the keyboard modifiers held during a selection gesture to a selection op.
|
||||
// shift+ctrl = intersect, shift = add, ctrl = remove, none = set.
|
||||
const opFromModifiers = (e: { shiftKey: boolean; ctrlKey: boolean }) => {
|
||||
if (e.shiftKey && e.ctrlKey) return 'intersect';
|
||||
if (e.shiftKey) return 'add';
|
||||
if (e.ctrlKey) return 'remove';
|
||||
return 'set';
|
||||
};
|
||||
|
||||
export { opFromModifiers };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Element, ElementType } from './element';
|
||||
import { Events } from './events';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
|
||||
const registerSelectionEvents = (events: Events, scene: Scene) => {
|
||||
let selection: Splat = null;
|
||||
|
||||
const setSelection = (splat: Splat) => {
|
||||
if (splat !== selection && (!splat || splat.visible)) {
|
||||
const prev = selection;
|
||||
selection = splat;
|
||||
events.fire('selection.changed', selection, prev);
|
||||
}
|
||||
};
|
||||
|
||||
events.on('selection', (splat: Splat) => {
|
||||
setSelection(splat);
|
||||
});
|
||||
|
||||
events.function('selection', () => {
|
||||
return selection;
|
||||
});
|
||||
|
||||
events.on('selection.next', () => {
|
||||
const splats = scene.getElementsByType(ElementType.splat) as Splat[];
|
||||
if (splats.length > 1) {
|
||||
const idx = splats.indexOf(selection);
|
||||
setSelection(splats[(idx + 1) % splats.length]);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('scene.elementAdded', (element: Element) => {
|
||||
if (element.type === ElementType.splat) {
|
||||
setSelection(element as Splat);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('scene.elementRemoved', (element: Element) => {
|
||||
if (element === selection) {
|
||||
const splats = scene.getElementsByType(ElementType.splat) as Splat[];
|
||||
setSelection(splats.length === 1 ? null : splats.find(v => v !== element));
|
||||
}
|
||||
});
|
||||
|
||||
events.on('splat.visibility', (splat: Splat) => {
|
||||
if (splat === selection && !splat.visible) {
|
||||
setSelection(null);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('camera.focalPointPicked', (details: { splat: Splat }) => {
|
||||
setSelection(details.splat);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerSelectionEvents };
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
import { Asset, Quat } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
import { loadGSplatData, MappedReadFileSystem, validateGSplatData } from './io';
|
||||
import { Scene } from './scene';
|
||||
import { Splat } from './splat';
|
||||
|
||||
type FrameData = {
|
||||
asset: Asset;
|
||||
rotation: Quat;
|
||||
};
|
||||
|
||||
// A source of animation frames. getFrame produces a ready gsplat Asset (plus the
|
||||
// orientation to apply when the persistent splat is first created) for a frame.
|
||||
interface FrameSource {
|
||||
readonly frameCount: number;
|
||||
getFrame(index: number): Promise<FrameData>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
// PLY sequence: a set of frameNNNN.ply files, sorted by trailing frame number.
|
||||
class PlyFrameSource implements FrameSource {
|
||||
private files: File[];
|
||||
private scene: Scene;
|
||||
|
||||
constructor(files: File[], scene: Scene) {
|
||||
this.scene = scene;
|
||||
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking
|
||||
const regex = /(.*?)(\d+)(?:\.compressed)?\.ply$/;
|
||||
const key = (f: File) => f.name?.toLowerCase().match(regex)?.[2];
|
||||
this.files = files.slice().sort((a, b) => {
|
||||
const av = key(a);
|
||||
const bv = key(b);
|
||||
return (av && bv) ? parseInt(av, 10) - parseInt(bv, 10) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
get frameCount() {
|
||||
return this.files.length;
|
||||
}
|
||||
|
||||
async getFrame(index: number): Promise<FrameData> {
|
||||
const file = this.files[index];
|
||||
const fileSystem = new MappedReadFileSystem();
|
||||
fileSystem.addFile(file.name, file);
|
||||
|
||||
// skipReorder: animation frames prioritise load speed over morton ordering
|
||||
const { gsplatData, transform } = await loadGSplatData(file.name, fileSystem, true);
|
||||
validateGSplatData(gsplatData);
|
||||
|
||||
const asset = this.scene.assetLoader.createGSplatAsset(gsplatData, file.name);
|
||||
return { asset, rotation: transform.rotation };
|
||||
}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages animation-sequence playback (PLY sequence).
|
||||
*
|
||||
* A sequence is rendered by a single persistent Splat element whose gaussian data
|
||||
* is swapped in place each frame (Splat.replaceData). This keeps the user's
|
||||
* whole-model transform and visual properties across frames and avoids the full
|
||||
* element teardown/recreate (and scene-reset prompt) of the previous approach.
|
||||
*/
|
||||
const registerSequenceEvents = (events: Events, scene: Scene) => {
|
||||
let source: FrameSource | null = null;
|
||||
let splat: Splat | null = null;
|
||||
let currentFrame = -1;
|
||||
let loading = false;
|
||||
let nextFrame = -1;
|
||||
let loadingPromise: Promise<void> | null = null;
|
||||
|
||||
// apply a frame's data to the persistent splat, creating it on the first frame
|
||||
const applyFrame = async (data: FrameData) => {
|
||||
if (!splat) {
|
||||
splat = new Splat(data.asset, data.rotation);
|
||||
await scene.add(splat);
|
||||
} else {
|
||||
// in-place swap: preserves entity transform, visual props and selection
|
||||
await splat.replaceData(data.asset);
|
||||
}
|
||||
};
|
||||
|
||||
// release an asset whose load was abandoned (source switched mid-load)
|
||||
const discardAsset = (asset: Asset) => {
|
||||
asset.registry?.remove(asset);
|
||||
asset.unload();
|
||||
};
|
||||
|
||||
const setSource = (newSource: FrameSource) => {
|
||||
source?.destroy();
|
||||
source = newSource;
|
||||
currentFrame = -1;
|
||||
nextFrame = -1;
|
||||
|
||||
// tear down the previous sequence's splat so the new source's first frame
|
||||
// is bound as an initial load (applying its rotation/name) rather than
|
||||
// swapped onto the old element
|
||||
if (splat) {
|
||||
scene.remove(splat);
|
||||
splat.destroy();
|
||||
splat = null;
|
||||
}
|
||||
|
||||
events.fire('timeline.frames', source.frameCount);
|
||||
};
|
||||
|
||||
const setFrame = async (frame: number) => {
|
||||
if (!source || frame < 0 || frame >= source.frameCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// coalesce while a frame is in flight (rapid scrubbing)
|
||||
if (loading) {
|
||||
nextFrame = frame;
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame === currentFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
const loadSource = source;
|
||||
try {
|
||||
const data = await source.getFrame(frame);
|
||||
if (source !== loadSource) {
|
||||
// source was switched (or the scene cleared) while loading — discard
|
||||
// this frame's asset rather than applying a stale one
|
||||
discardAsset(data.asset);
|
||||
} else {
|
||||
// applyFrame swaps data in place; replaceData keeps the previous frame
|
||||
// on screen until the new one has rendered, so no extra wait is needed
|
||||
await applyFrame(data);
|
||||
currentFrame = frame;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// process the most recent frame requested while we were loading
|
||||
if (nextFrame !== -1) {
|
||||
const frameToLoad = nextFrame;
|
||||
nextFrame = -1;
|
||||
setFrame(frameToLoad);
|
||||
}
|
||||
};
|
||||
|
||||
events.on('sequence.setPlyFrames', (files: File[]) => {
|
||||
setSource(new PlyFrameSource(files, scene));
|
||||
});
|
||||
|
||||
events.on('timeline.frame', async (frame: number) => {
|
||||
await setFrame(frame);
|
||||
});
|
||||
|
||||
// drop references when the scene is cleared (scene.clear destroys the splat)
|
||||
events.on('scene.clear', () => {
|
||||
source?.destroy();
|
||||
source = null;
|
||||
splat = null;
|
||||
currentFrame = -1;
|
||||
nextFrame = -1;
|
||||
});
|
||||
|
||||
// Async per-frame advance for the video renderer (render.ts). Awaits the frame
|
||||
// swap so the splat is ready to sort, then returns the (persistent) splat when
|
||||
// the frame actually changed, or null when it didn't. Name kept for render.ts.
|
||||
events.function('plysequence.setFrameAsync', async (frame: number): Promise<Splat | null> => {
|
||||
if (!source || frame < 0 || frame >= source.frameCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentFrame === frame && !loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if a load is already in flight, wait for it before deciding
|
||||
if (loading && loadingPromise) {
|
||||
await loadingPromise;
|
||||
}
|
||||
|
||||
if (currentFrame === frame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
loadingPromise = (async () => {
|
||||
loading = true;
|
||||
const loadSource = source;
|
||||
try {
|
||||
const data = await source.getFrame(frame);
|
||||
if (source !== loadSource) {
|
||||
// source switched / scene cleared mid-load — discard the asset
|
||||
discardAsset(data.asset);
|
||||
} else {
|
||||
await applyFrame(data);
|
||||
currentFrame = frame;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
await loadingPromise;
|
||||
return splat;
|
||||
});
|
||||
};
|
||||
|
||||
export { registerSequenceEvents };
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Color, Vec3 } from 'playcanvas';
|
||||
|
||||
// this class is used by elements to store their pertinent state
|
||||
// every frame. the data is then compared with the previous frame's
|
||||
// values in order to determine if any changes happened.
|
||||
class Serializer {
|
||||
constructor(packValue: (value: any) => void) {
|
||||
this.packValue = packValue;
|
||||
}
|
||||
|
||||
packValue: (value: any) => void;
|
||||
|
||||
pack(...args: any[]) {
|
||||
for (let j = 0; j < args.length; ++j) {
|
||||
this.packValue(args[j]);
|
||||
}
|
||||
}
|
||||
|
||||
packa(a: any[] | Float32Array) {
|
||||
for (let j = 0; j < a.length; ++j) {
|
||||
this.packValue(a[j]);
|
||||
}
|
||||
}
|
||||
|
||||
packVec3(v: Vec3) {
|
||||
this.pack(v.x, v.y, v.z);
|
||||
}
|
||||
|
||||
packColor(c: Color) {
|
||||
this.pack(c.r, c.g, c.b, c.a);
|
||||
}
|
||||
}
|
||||
|
||||
export { Serializer };
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { Mat3 } from 'playcanvas';
|
||||
|
||||
/* eslint-disable indent */
|
||||
|
||||
const kSqrt03_02 = Math.sqrt(3.0 / 2.0);
|
||||
const kSqrt01_03 = Math.sqrt(1.0 / 3.0);
|
||||
const kSqrt02_03 = Math.sqrt(2.0 / 3.0);
|
||||
const kSqrt04_03 = Math.sqrt(4.0 / 3.0);
|
||||
const kSqrt01_04 = Math.sqrt(1.0 / 4.0);
|
||||
const kSqrt03_04 = Math.sqrt(3.0 / 4.0);
|
||||
const kSqrt01_05 = Math.sqrt(1.0 / 5.0);
|
||||
const kSqrt03_05 = Math.sqrt(3.0 / 5.0);
|
||||
const kSqrt06_05 = Math.sqrt(6.0 / 5.0);
|
||||
const kSqrt08_05 = Math.sqrt(8.0 / 5.0);
|
||||
const kSqrt09_05 = Math.sqrt(9.0 / 5.0);
|
||||
const kSqrt01_06 = Math.sqrt(1.0 / 6.0);
|
||||
const kSqrt05_06 = Math.sqrt(5.0 / 6.0);
|
||||
const kSqrt03_08 = Math.sqrt(3.0 / 8.0);
|
||||
const kSqrt05_08 = Math.sqrt(5.0 / 8.0);
|
||||
const kSqrt09_08 = Math.sqrt(9.0 / 8.0);
|
||||
const kSqrt05_09 = Math.sqrt(5.0 / 9.0);
|
||||
const kSqrt08_09 = Math.sqrt(8.0 / 9.0);
|
||||
const kSqrt01_10 = Math.sqrt(1.0 / 10.0);
|
||||
const kSqrt03_10 = Math.sqrt(3.0 / 10.0);
|
||||
const kSqrt01_12 = Math.sqrt(1.0 / 12.0);
|
||||
const kSqrt04_15 = Math.sqrt(4.0 / 15.0);
|
||||
const kSqrt01_16 = Math.sqrt(1.0 / 16.0);
|
||||
const kSqrt15_16 = Math.sqrt(15.0 / 16.0);
|
||||
const kSqrt01_18 = Math.sqrt(1.0 / 18.0);
|
||||
const kSqrt01_60 = Math.sqrt(1.0 / 60.0);
|
||||
|
||||
const dp = (n: number, start: number, a: number[] | Float32Array, b: number[] | Float32Array) => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
sum += a[start + i] * b[i];
|
||||
}
|
||||
return sum;
|
||||
};
|
||||
|
||||
const coeffsIn = new Float32Array(15);
|
||||
|
||||
// Rotate spherical harmonics up to band 3 based on https://github.com/andrewwillmott/sh-lib
|
||||
//
|
||||
// This implementation calculates the rotation factors during construction which can then
|
||||
// be used to rotate multiple spherical harmonics cheaply.
|
||||
class SHRotation {
|
||||
apply: (result: Float32Array | number[], src?: Float32Array | number[]) => void;
|
||||
|
||||
constructor(mat: Mat3) {
|
||||
const rot = mat.data;
|
||||
|
||||
// band 1
|
||||
const sh1 = [
|
||||
[rot[4], -rot[7], rot[1]],
|
||||
[-rot[5], rot[8], -rot[2]],
|
||||
[rot[3], -rot[6], rot[0]]
|
||||
];
|
||||
|
||||
// band 2
|
||||
const sh2 = [[
|
||||
kSqrt01_04 * ((sh1[2][2] * sh1[0][0] + sh1[2][0] * sh1[0][2]) + (sh1[0][2] * sh1[2][0] + sh1[0][0] * sh1[2][2])),
|
||||
(sh1[2][1] * sh1[0][0] + sh1[0][1] * sh1[2][0]),
|
||||
kSqrt03_04 * (sh1[2][1] * sh1[0][1] + sh1[0][1] * sh1[2][1]),
|
||||
(sh1[2][1] * sh1[0][2] + sh1[0][1] * sh1[2][2]),
|
||||
kSqrt01_04 * ((sh1[2][2] * sh1[0][2] - sh1[2][0] * sh1[0][0]) + (sh1[0][2] * sh1[2][2] - sh1[0][0] * sh1[2][0]))
|
||||
], [
|
||||
kSqrt01_04 * ((sh1[1][2] * sh1[0][0] + sh1[1][0] * sh1[0][2]) + (sh1[0][2] * sh1[1][0] + sh1[0][0] * sh1[1][2])),
|
||||
sh1[1][1] * sh1[0][0] + sh1[0][1] * sh1[1][0],
|
||||
kSqrt03_04 * (sh1[1][1] * sh1[0][1] + sh1[0][1] * sh1[1][1]),
|
||||
sh1[1][1] * sh1[0][2] + sh1[0][1] * sh1[1][2],
|
||||
kSqrt01_04 * ((sh1[1][2] * sh1[0][2] - sh1[1][0] * sh1[0][0]) + (sh1[0][2] * sh1[1][2] - sh1[0][0] * sh1[1][0]))
|
||||
], [
|
||||
kSqrt01_03 * (sh1[1][2] * sh1[1][0] + sh1[1][0] * sh1[1][2]) - kSqrt01_12 * ((sh1[2][2] * sh1[2][0] + sh1[2][0] * sh1[2][2]) + (sh1[0][2] * sh1[0][0] + sh1[0][0] * sh1[0][2])),
|
||||
kSqrt04_03 * sh1[1][1] * sh1[1][0] - kSqrt01_03 * (sh1[2][1] * sh1[2][0] + sh1[0][1] * sh1[0][0]),
|
||||
sh1[1][1] * sh1[1][1] - kSqrt01_04 * (sh1[2][1] * sh1[2][1] + sh1[0][1] * sh1[0][1]),
|
||||
kSqrt04_03 * sh1[1][1] * sh1[1][2] - kSqrt01_03 * (sh1[2][1] * sh1[2][2] + sh1[0][1] * sh1[0][2]),
|
||||
kSqrt01_03 * (sh1[1][2] * sh1[1][2] - sh1[1][0] * sh1[1][0]) - kSqrt01_12 * ((sh1[2][2] * sh1[2][2] - sh1[2][0] * sh1[2][0]) + (sh1[0][2] * sh1[0][2] - sh1[0][0] * sh1[0][0]))
|
||||
], [
|
||||
kSqrt01_04 * ((sh1[1][2] * sh1[2][0] + sh1[1][0] * sh1[2][2]) + (sh1[2][2] * sh1[1][0] + sh1[2][0] * sh1[1][2])),
|
||||
sh1[1][1] * sh1[2][0] + sh1[2][1] * sh1[1][0],
|
||||
kSqrt03_04 * (sh1[1][1] * sh1[2][1] + sh1[2][1] * sh1[1][1]),
|
||||
sh1[1][1] * sh1[2][2] + sh1[2][1] * sh1[1][2],
|
||||
kSqrt01_04 * ((sh1[1][2] * sh1[2][2] - sh1[1][0] * sh1[2][0]) + (sh1[2][2] * sh1[1][2] - sh1[2][0] * sh1[1][0]))
|
||||
], [
|
||||
kSqrt01_04 * ((sh1[2][2] * sh1[2][0] + sh1[2][0] * sh1[2][2]) - (sh1[0][2] * sh1[0][0] + sh1[0][0] * sh1[0][2])),
|
||||
(sh1[2][1] * sh1[2][0] - sh1[0][1] * sh1[0][0]),
|
||||
kSqrt03_04 * (sh1[2][1] * sh1[2][1] - sh1[0][1] * sh1[0][1]),
|
||||
(sh1[2][1] * sh1[2][2] - sh1[0][1] * sh1[0][2]),
|
||||
kSqrt01_04 * ((sh1[2][2] * sh1[2][2] - sh1[2][0] * sh1[2][0]) - (sh1[0][2] * sh1[0][2] - sh1[0][0] * sh1[0][0]))
|
||||
]];
|
||||
|
||||
// band 3
|
||||
const sh3 = [[
|
||||
kSqrt01_04 * ((sh1[2][2] * sh2[0][0] + sh1[2][0] * sh2[0][4]) + (sh1[0][2] * sh2[4][0] + sh1[0][0] * sh2[4][4])),
|
||||
kSqrt03_02 * (sh1[2][1] * sh2[0][0] + sh1[0][1] * sh2[4][0]),
|
||||
kSqrt15_16 * (sh1[2][1] * sh2[0][1] + sh1[0][1] * sh2[4][1]),
|
||||
kSqrt05_06 * (sh1[2][1] * sh2[0][2] + sh1[0][1] * sh2[4][2]),
|
||||
kSqrt15_16 * (sh1[2][1] * sh2[0][3] + sh1[0][1] * sh2[4][3]),
|
||||
kSqrt03_02 * (sh1[2][1] * sh2[0][4] + sh1[0][1] * sh2[4][4]),
|
||||
kSqrt01_04 * ((sh1[2][2] * sh2[0][4] - sh1[2][0] * sh2[0][0]) + (sh1[0][2] * sh2[4][4] - sh1[0][0] * sh2[4][0]))
|
||||
], [
|
||||
kSqrt01_06 * (sh1[1][2] * sh2[0][0] + sh1[1][0] * sh2[0][4]) + kSqrt01_06 * ((sh1[2][2] * sh2[1][0] + sh1[2][0] * sh2[1][4]) + (sh1[0][2] * sh2[3][0] + sh1[0][0] * sh2[3][4])),
|
||||
sh1[1][1] * sh2[0][0] + (sh1[2][1] * sh2[1][0] + sh1[0][1] * sh2[3][0]),
|
||||
kSqrt05_08 * sh1[1][1] * sh2[0][1] + kSqrt05_08 * (sh1[2][1] * sh2[1][1] + sh1[0][1] * sh2[3][1]),
|
||||
kSqrt05_09 * sh1[1][1] * sh2[0][2] + kSqrt05_09 * (sh1[2][1] * sh2[1][2] + sh1[0][1] * sh2[3][2]),
|
||||
kSqrt05_08 * sh1[1][1] * sh2[0][3] + kSqrt05_08 * (sh1[2][1] * sh2[1][3] + sh1[0][1] * sh2[3][3]),
|
||||
sh1[1][1] * sh2[0][4] + (sh1[2][1] * sh2[1][4] + sh1[0][1] * sh2[3][4]),
|
||||
kSqrt01_06 * (sh1[1][2] * sh2[0][4] - sh1[1][0] * sh2[0][0]) + kSqrt01_06 * ((sh1[2][2] * sh2[1][4] - sh1[2][0] * sh2[1][0]) + (sh1[0][2] * sh2[3][4] - sh1[0][0] * sh2[3][0]))
|
||||
], [
|
||||
kSqrt04_15 * (sh1[1][2] * sh2[1][0] + sh1[1][0] * sh2[1][4]) + kSqrt01_05 * (sh1[0][2] * sh2[2][0] + sh1[0][0] * sh2[2][4]) - kSqrt01_60 * ((sh1[2][2] * sh2[0][0] + sh1[2][0] * sh2[0][4]) - (sh1[0][2] * sh2[4][0] + sh1[0][0] * sh2[4][4])),
|
||||
kSqrt08_05 * sh1[1][1] * sh2[1][0] + kSqrt06_05 * sh1[0][1] * sh2[2][0] - kSqrt01_10 * (sh1[2][1] * sh2[0][0] - sh1[0][1] * sh2[4][0]),
|
||||
sh1[1][1] * sh2[1][1] + kSqrt03_04 * sh1[0][1] * sh2[2][1] - kSqrt01_16 * (sh1[2][1] * sh2[0][1] - sh1[0][1] * sh2[4][1]),
|
||||
kSqrt08_09 * sh1[1][1] * sh2[1][2] + kSqrt02_03 * sh1[0][1] * sh2[2][2] - kSqrt01_18 * (sh1[2][1] * sh2[0][2] - sh1[0][1] * sh2[4][2]),
|
||||
sh1[1][1] * sh2[1][3] + kSqrt03_04 * sh1[0][1] * sh2[2][3] - kSqrt01_16 * (sh1[2][1] * sh2[0][3] - sh1[0][1] * sh2[4][3]),
|
||||
kSqrt08_05 * sh1[1][1] * sh2[1][4] + kSqrt06_05 * sh1[0][1] * sh2[2][4] - kSqrt01_10 * (sh1[2][1] * sh2[0][4] - sh1[0][1] * sh2[4][4]),
|
||||
kSqrt04_15 * (sh1[1][2] * sh2[1][4] - sh1[1][0] * sh2[1][0]) + kSqrt01_05 * (sh1[0][2] * sh2[2][4] - sh1[0][0] * sh2[2][0]) - kSqrt01_60 * ((sh1[2][2] * sh2[0][4] - sh1[2][0] * sh2[0][0]) - (sh1[0][2] * sh2[4][4] - sh1[0][0] * sh2[4][0]))
|
||||
], [
|
||||
kSqrt03_10 * (sh1[1][2] * sh2[2][0] + sh1[1][0] * sh2[2][4]) - kSqrt01_10 * ((sh1[2][2] * sh2[3][0] + sh1[2][0] * sh2[3][4]) + (sh1[0][2] * sh2[1][0] + sh1[0][0] * sh2[1][4])),
|
||||
kSqrt09_05 * sh1[1][1] * sh2[2][0] - kSqrt03_05 * (sh1[2][1] * sh2[3][0] + sh1[0][1] * sh2[1][0]),
|
||||
kSqrt09_08 * sh1[1][1] * sh2[2][1] - kSqrt03_08 * (sh1[2][1] * sh2[3][1] + sh1[0][1] * sh2[1][1]),
|
||||
sh1[1][1] * sh2[2][2] - kSqrt01_03 * (sh1[2][1] * sh2[3][2] + sh1[0][1] * sh2[1][2]),
|
||||
kSqrt09_08 * sh1[1][1] * sh2[2][3] - kSqrt03_08 * (sh1[2][1] * sh2[3][3] + sh1[0][1] * sh2[1][3]),
|
||||
kSqrt09_05 * sh1[1][1] * sh2[2][4] - kSqrt03_05 * (sh1[2][1] * sh2[3][4] + sh1[0][1] * sh2[1][4]),
|
||||
kSqrt03_10 * (sh1[1][2] * sh2[2][4] - sh1[1][0] * sh2[2][0]) - kSqrt01_10 * ((sh1[2][2] * sh2[3][4] - sh1[2][0] * sh2[3][0]) + (sh1[0][2] * sh2[1][4] - sh1[0][0] * sh2[1][0]))
|
||||
], [
|
||||
kSqrt04_15 * (sh1[1][2] * sh2[3][0] + sh1[1][0] * sh2[3][4]) + kSqrt01_05 * (sh1[2][2] * sh2[2][0] + sh1[2][0] * sh2[2][4]) - kSqrt01_60 * ((sh1[2][2] * sh2[4][0] + sh1[2][0] * sh2[4][4]) + (sh1[0][2] * sh2[0][0] + sh1[0][0] * sh2[0][4])),
|
||||
kSqrt08_05 * sh1[1][1] * sh2[3][0] + kSqrt06_05 * sh1[2][1] * sh2[2][0] - kSqrt01_10 * (sh1[2][1] * sh2[4][0] + sh1[0][1] * sh2[0][0]),
|
||||
sh1[1][1] * sh2[3][1] + kSqrt03_04 * sh1[2][1] * sh2[2][1] - kSqrt01_16 * (sh1[2][1] * sh2[4][1] + sh1[0][1] * sh2[0][1]),
|
||||
kSqrt08_09 * sh1[1][1] * sh2[3][2] + kSqrt02_03 * sh1[2][1] * sh2[2][2] - kSqrt01_18 * (sh1[2][1] * sh2[4][2] + sh1[0][1] * sh2[0][2]),
|
||||
sh1[1][1] * sh2[3][3] + kSqrt03_04 * sh1[2][1] * sh2[2][3] - kSqrt01_16 * (sh1[2][1] * sh2[4][3] + sh1[0][1] * sh2[0][3]),
|
||||
kSqrt08_05 * sh1[1][1] * sh2[3][4] + kSqrt06_05 * sh1[2][1] * sh2[2][4] - kSqrt01_10 * (sh1[2][1] * sh2[4][4] + sh1[0][1] * sh2[0][4]),
|
||||
kSqrt04_15 * (sh1[1][2] * sh2[3][4] - sh1[1][0] * sh2[3][0]) + kSqrt01_05 * (sh1[2][2] * sh2[2][4] - sh1[2][0] * sh2[2][0]) - kSqrt01_60 * ((sh1[2][2] * sh2[4][4] - sh1[2][0] * sh2[4][0]) + (sh1[0][2] * sh2[0][4] - sh1[0][0] * sh2[0][0]))
|
||||
], [
|
||||
kSqrt01_06 * (sh1[1][2] * sh2[4][0] + sh1[1][0] * sh2[4][4]) + kSqrt01_06 * ((sh1[2][2] * sh2[3][0] + sh1[2][0] * sh2[3][4]) - (sh1[0][2] * sh2[1][0] + sh1[0][0] * sh2[1][4])),
|
||||
sh1[1][1] * sh2[4][0] + (sh1[2][1] * sh2[3][0] - sh1[0][1] * sh2[1][0]),
|
||||
kSqrt05_08 * sh1[1][1] * sh2[4][1] + kSqrt05_08 * (sh1[2][1] * sh2[3][1] - sh1[0][1] * sh2[1][1]),
|
||||
kSqrt05_09 * sh1[1][1] * sh2[4][2] + kSqrt05_09 * (sh1[2][1] * sh2[3][2] - sh1[0][1] * sh2[1][2]),
|
||||
kSqrt05_08 * sh1[1][1] * sh2[4][3] + kSqrt05_08 * (sh1[2][1] * sh2[3][3] - sh1[0][1] * sh2[1][3]),
|
||||
sh1[1][1] * sh2[4][4] + (sh1[2][1] * sh2[3][4] - sh1[0][1] * sh2[1][4]),
|
||||
kSqrt01_06 * (sh1[1][2] * sh2[4][4] - sh1[1][0] * sh2[4][0]) + kSqrt01_06 * ((sh1[2][2] * sh2[3][4] - sh1[2][0] * sh2[3][0]) - (sh1[0][2] * sh2[1][4] - sh1[0][0] * sh2[1][0]))
|
||||
], [
|
||||
kSqrt01_04 * ((sh1[2][2] * sh2[4][0] + sh1[2][0] * sh2[4][4]) - (sh1[0][2] * sh2[0][0] + sh1[0][0] * sh2[0][4])),
|
||||
kSqrt03_02 * (sh1[2][1] * sh2[4][0] - sh1[0][1] * sh2[0][0]),
|
||||
kSqrt15_16 * (sh1[2][1] * sh2[4][1] - sh1[0][1] * sh2[0][1]),
|
||||
kSqrt05_06 * (sh1[2][1] * sh2[4][2] - sh1[0][1] * sh2[0][2]),
|
||||
kSqrt15_16 * (sh1[2][1] * sh2[4][3] - sh1[0][1] * sh2[0][3]),
|
||||
kSqrt03_02 * (sh1[2][1] * sh2[4][4] - sh1[0][1] * sh2[0][4]),
|
||||
kSqrt01_04 * ((sh1[2][2] * sh2[4][4] - sh1[2][0] * sh2[4][0]) - (sh1[0][2] * sh2[0][4] - sh1[0][0] * sh2[0][0]))
|
||||
]];
|
||||
|
||||
// rotate spherical harmonic coefficients, up to band 3
|
||||
this.apply = (result: Float32Array | number[], src?: Float32Array | number[]) => {
|
||||
if (!src || src === result) {
|
||||
coeffsIn.set(result);
|
||||
src = coeffsIn;
|
||||
}
|
||||
|
||||
// band 1
|
||||
if (result.length < 3) {
|
||||
return;
|
||||
}
|
||||
result[0] = dp(3, 0, src, sh1[0]);
|
||||
result[1] = dp(3, 0, src, sh1[1]);
|
||||
result[2] = dp(3, 0, src, sh1[2]);
|
||||
|
||||
// band 2
|
||||
if (result.length < 8) {
|
||||
return;
|
||||
}
|
||||
result[3] = dp(5, 3, src, sh2[0]);
|
||||
result[4] = dp(5, 3, src, sh2[1]);
|
||||
result[5] = dp(5, 3, src, sh2[2]);
|
||||
result[6] = dp(5, 3, src, sh2[3]);
|
||||
result[7] = dp(5, 3, src, sh2[4]);
|
||||
|
||||
// band 3
|
||||
if (result.length < 15) {
|
||||
return;
|
||||
}
|
||||
result[8] = dp(7, 8, src, sh3[0]);
|
||||
result[9] = dp(7, 8, src, sh3[1]);
|
||||
result[10] = dp(7, 8, src, sh3[2]);
|
||||
result[11] = dp(7, 8, src, sh3[3]);
|
||||
result[12] = dp(7, 8, src, sh3[4]);
|
||||
result[13] = dp(7, 8, src, sh3[5]);
|
||||
result[14] = dp(7, 8, src, sh3[6]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { SHRotation };
|
||||
@@ -0,0 +1,16 @@
|
||||
const vertexShader = /* glsl*/ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl*/ `
|
||||
uniform sampler2D srcTexture;
|
||||
void main(void) {
|
||||
ivec2 texel = ivec2(gl_FragCoord.xy);
|
||||
gl_FragColor = texelFetch(srcTexture, texel, 0);
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,84 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform highp usampler2D transformA; // splat center x, y, z
|
||||
uniform highp usampler2D splatTransform; // transform palette index
|
||||
uniform sampler2D transformPalette; // palette of transforms
|
||||
uniform sampler2D splatState; // per-splat state
|
||||
uniform highp ivec3 splat_params; // texture width, texture height, num splats
|
||||
|
||||
// Custom infinity check that transpiles correctly to WGSL
|
||||
bvec3 isInf(vec3 v) {
|
||||
return greaterThan(abs(v), vec3(1e30));
|
||||
}
|
||||
|
||||
// calculate min and max for a single column of splats
|
||||
// outputs both selected bounds and all visible bounds
|
||||
void main(void) {
|
||||
|
||||
vec3 selectedMin = vec3(1e6);
|
||||
vec3 selectedMax = vec3(-1e6);
|
||||
vec3 visibleMin = vec3(1e6);
|
||||
vec3 visibleMax = vec3(-1e6);
|
||||
|
||||
for (int id = 0; id < splat_params.y; id++) {
|
||||
// calculate splatUV
|
||||
ivec2 splatUV = ivec2(gl_FragCoord.x, id);
|
||||
|
||||
// skip out-of-range splats
|
||||
if ((splatUV.x + splatUV.y * splat_params.x) >= splat_params.z) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// read splat state
|
||||
uint state = uint(texelFetch(splatState, splatUV, 0).r * 255.0);
|
||||
|
||||
// skip deleted splats for both bounds
|
||||
if ((state & 4u) != 0u) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// read splat center
|
||||
vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz);
|
||||
|
||||
// apply optional per-splat transform
|
||||
uint transformIndex = texelFetch(splatTransform, splatUV, 0).r;
|
||||
if (transformIndex > 0u) {
|
||||
// read transform matrix
|
||||
int u = int(transformIndex % 512u) * 3;
|
||||
int v = int(transformIndex / 512u);
|
||||
|
||||
mat3x4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
|
||||
center = vec4(center, 1.0) * t;
|
||||
}
|
||||
|
||||
vec3 safeCenter = mix(center, vec3(1e6), isInf(center));
|
||||
|
||||
// update visible bounds (all non-deleted splats)
|
||||
visibleMin = min(visibleMin, safeCenter);
|
||||
visibleMax = max(visibleMax, mix(center, visibleMax, isInf(center)));
|
||||
|
||||
// update selected bounds (only exactly selected splats)
|
||||
if (state == 1u) {
|
||||
selectedMin = min(selectedMin, safeCenter);
|
||||
selectedMax = max(selectedMax, mix(center, selectedMax, isInf(center)));
|
||||
}
|
||||
}
|
||||
|
||||
pcFragColor0 = vec4(selectedMin, 0.0);
|
||||
pcFragColor1 = vec4(selectedMax, 0.0);
|
||||
pcFragColor2 = vec4(visibleMin, 0.0);
|
||||
pcFragColor3 = vec4(visibleMax, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,111 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 vertex_position;
|
||||
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
|
||||
void main() {
|
||||
gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
// ray-box intersection in box space
|
||||
bool intersectBox(out float t0, out float t1, out int axis0, out int axis1, vec3 pos, vec3 dir, vec3 boxCen, vec3 boxLen)
|
||||
{
|
||||
bvec3 validDir = notEqual(dir, vec3(0.0));
|
||||
vec3 absDir = abs(dir);
|
||||
vec3 signDir = sign(dir);
|
||||
vec3 m = vec3(
|
||||
validDir.x ? 1.0 / absDir.x : 0.0,
|
||||
validDir.y ? 1.0 / absDir.y : 0.0,
|
||||
validDir.z ? 1.0 / absDir.z : 0.0
|
||||
) * signDir;
|
||||
|
||||
vec3 n = m * (pos - boxCen);
|
||||
vec3 k = abs(m) * boxLen;
|
||||
|
||||
vec3 v0 = -n - k;
|
||||
vec3 v1 = -n + k;
|
||||
|
||||
// replace invalid axes with -inf and +inf so the tests below ignore them
|
||||
v0 = mix(vec3(-1.0 / 0.0000001), v0, validDir);
|
||||
v1 = mix(vec3(1.0 / 0.0000001), v1, validDir);
|
||||
|
||||
axis0 = (v0.x > v0.y) ? ((v0.x > v0.z) ? 0 : 2) : ((v0.y > v0.z) ? 1 : 2);
|
||||
axis1 = (v1.x < v1.y) ? ((v1.x < v1.z) ? 0 : 2) : ((v1.y < v1.z) ? 1 : 2);
|
||||
|
||||
t0 = v0[axis0];
|
||||
t1 = v1[axis1];
|
||||
|
||||
if (t0 > t1 || t1 < 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
float calcDepth(in vec3 pos, in mat4 viewProjection) {
|
||||
vec4 v = viewProjection * vec4(pos, 1.0);
|
||||
return (v.z / v.w) * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
uniform sampler2D blueNoiseTex32;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
uniform vec3 boxCen;
|
||||
uniform vec3 boxLen;
|
||||
|
||||
uniform vec3 near_origin;
|
||||
uniform vec3 near_x;
|
||||
uniform vec3 near_y;
|
||||
|
||||
uniform vec3 far_origin;
|
||||
uniform vec3 far_x;
|
||||
uniform vec3 far_y;
|
||||
|
||||
uniform vec2 targetSize;
|
||||
|
||||
bool writeDepth(float alpha) {
|
||||
ivec2 uv = ivec2(gl_FragCoord.xy);
|
||||
ivec2 size = textureSize(blueNoiseTex32, 0);
|
||||
return alpha > texelFetch(blueNoiseTex32, uv % size, 0).y;
|
||||
}
|
||||
|
||||
bool strips(vec3 pos, int axis) {
|
||||
bvec3 b = lessThan(fract(pos * 2.0 + vec3(0.015)), vec3(0.03));
|
||||
b[axis] = false;
|
||||
return any(b);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 clip = gl_FragCoord.xy / targetSize;
|
||||
vec3 worldNear = near_origin + near_x * clip.x + near_y * clip.y;
|
||||
vec3 worldFar = far_origin + far_x * clip.x + far_y * clip.y;
|
||||
vec3 rayDir = normalize(worldFar - worldNear);
|
||||
|
||||
float t0, t1;
|
||||
int axis0, axis1;
|
||||
if (!intersectBox(t0, t1, axis0, axis1, worldNear, rayDir, boxCen, boxLen)) {
|
||||
gl_FragColor = vec4(1.0, 0.0, 0.0, 0.6);
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 frontPos = worldNear + rayDir * t0;
|
||||
bool front = t0 > 0.0 && strips(frontPos - boxCen, axis0);
|
||||
|
||||
vec3 backPos = worldNear + rayDir * t1;
|
||||
bool back = strips(backPos - boxCen, axis1);
|
||||
|
||||
if (front) {
|
||||
gl_FragColor = vec4(1.0, 1.0, 1.0, 0.6);
|
||||
gl_FragDepth = writeDepth(0.6) ? calcDepth(frontPos, matrix_viewProjection) : 1.0;
|
||||
} else if (back) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.6);
|
||||
gl_FragDepth = writeDepth(0.6) ? calcDepth(backPos, matrix_viewProjection) : 1.0;
|
||||
} else {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,37 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 vertex_position;
|
||||
attribute vec4 vertex_color;
|
||||
|
||||
varying vec4 vColor;
|
||||
varying vec2 vZW;
|
||||
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
|
||||
void main(void) {
|
||||
gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
|
||||
|
||||
// store z/w for later use in fragment shader
|
||||
vColor = vertex_color;
|
||||
vZW = gl_Position.zw;
|
||||
|
||||
// disable depth clipping
|
||||
gl_Position.z = 0.0;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec4 vColor;
|
||||
varying vec2 vZW;
|
||||
|
||||
void main(void) {
|
||||
gl_FragColor = vColor;
|
||||
|
||||
// clamp depth in Z to [0, 1] range
|
||||
gl_FragDepth = max(0.0, min(1.0, (vZW.x / vZW.y + 1.0) * 0.5));
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,88 @@
|
||||
// cube faces are rendered with a fov wider than 90° so neighbouring faces
|
||||
// overlap. splats rasterize with slightly different shapes and composite in a
|
||||
// different sort order per face, so a hard face boundary shows as a seam;
|
||||
// blending the overlap feathers the mismatch away. weights ramp from 1 at
|
||||
// blendStart degrees off-axis to 0 at faceFov / 2.
|
||||
const faceFov = 100;
|
||||
const blendStart = 40;
|
||||
|
||||
const outerTan = Math.tan((faceFov / 2) * (Math.PI / 180));
|
||||
const innerTan = Math.tan(blendStart * (Math.PI / 180));
|
||||
const uvScale = 0.5 / outerTan;
|
||||
|
||||
const vertexShader = /* glsl*/ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// project six overlapping face renders to an equirectangular panorama. faces
|
||||
// are captured in the (level or full) camera orientation frame, so the
|
||||
// direction below is a capture-space direction: image center (lon 0, lat 0)
|
||||
// is the capture forward (-Z), lon +PI/2 is capture right (+X), lat +PI/2 is
|
||||
// up (+Y).
|
||||
//
|
||||
// face textures store camera renders bottom-row-first (flipY: false render
|
||||
// targets), which matches gl_FragCoord's bottom-left origin, so t is
|
||||
// up-positive and no flips are needed anywhere.
|
||||
const fragmentShader = /* glsl*/ `
|
||||
uniform sampler2D uFace0; // front -Z
|
||||
uniform sampler2D uFace1; // right +X
|
||||
uniform sampler2D uFace2; // back +Z
|
||||
uniform sampler2D uFace3; // left -X
|
||||
uniform sampler2D uFace4; // up +Y
|
||||
uniform sampler2D uFace5; // down -Y
|
||||
uniform vec2 uTargetSize;
|
||||
|
||||
#define PI 3.141592653589793
|
||||
|
||||
// blend weight and texture uv for a face with the given basis: the
|
||||
// direction is mapped to the face's ndc, p = (dot(d, right), dot(d, up)) /
|
||||
// dot(d, forward), and the weight feathers to zero towards the face edge
|
||||
float faceWeight(vec3 d, vec3 r, vec3 u, vec3 f, out vec2 st) {
|
||||
float dn = dot(d, f);
|
||||
if (dn <= 0.0) {
|
||||
st = vec2(0.0);
|
||||
return 0.0;
|
||||
}
|
||||
vec2 p = vec2(dot(d, r), dot(d, u)) / dn;
|
||||
st = p * ${uvScale.toFixed(8)} + 0.5;
|
||||
return 1.0 - smoothstep(${innerTan.toFixed(8)}, ${outerTan.toFixed(8)}, max(abs(p.x), abs(p.y)));
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec2 uv = gl_FragCoord.xy / uTargetSize;
|
||||
float lon = (uv.x - 0.5) * 2.0 * PI;
|
||||
float lat = (uv.y - 0.5) * PI;
|
||||
|
||||
vec3 d = vec3(sin(lon) * cos(lat), sin(lat), -cos(lon) * cos(lat));
|
||||
|
||||
vec4 acc = vec4(0.0);
|
||||
float wsum = 0.0;
|
||||
vec2 st;
|
||||
float w;
|
||||
|
||||
w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, -1.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace0, st); wsum += w; }
|
||||
|
||||
w = faceWeight(d, vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), vec3(1.0, 0.0, 0.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace1, st); wsum += w; }
|
||||
|
||||
w = faceWeight(d, vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace2, st); wsum += w; }
|
||||
|
||||
w = faceWeight(d, vec3(0.0, 0.0, -1.0), vec3(0.0, 1.0, 0.0), vec3(-1.0, 0.0, 0.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace3, st); wsum += w; }
|
||||
|
||||
w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace4, st); wsum += w; }
|
||||
|
||||
w = faceWeight(d, vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, -1.0), vec3(0.0, -1.0, 0.0), st);
|
||||
if (w > 0.0) { acc += w * texture2D(uFace5, st); wsum += w; }
|
||||
|
||||
gl_FragColor = acc / max(wsum, 1e-5);
|
||||
}
|
||||
`;
|
||||
|
||||
export { faceFov, vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,120 @@
|
||||
import { computeSplatValueGLSL } from './splat-value-shader';
|
||||
|
||||
const fullscreenVS = /* glsl */ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// pass 1: tile min/max
|
||||
// each fragment owns a contiguous range of splat indices and reduces them inline
|
||||
const tileMinMaxFS = /* glsl */ `
|
||||
${computeSplatValueGLSL}
|
||||
|
||||
uniform int tileSize;
|
||||
uniform int gridDim;
|
||||
|
||||
#define MAX_TILE_SIZE 65536
|
||||
|
||||
void main(void) {
|
||||
ivec2 tileXY = ivec2(gl_FragCoord);
|
||||
int tileId = tileXY.y * gridDim + tileXY.x;
|
||||
int baseIdx = tileId * tileSize;
|
||||
int endIdx = min(baseIdx + tileSize, splat_params.y);
|
||||
|
||||
float minVal = 1e30;
|
||||
float maxVal = -1e30;
|
||||
|
||||
for (int k = 0; k < MAX_TILE_SIZE; k++) {
|
||||
int idx = baseIdx + k;
|
||||
if (idx >= endIdx) break;
|
||||
float val;
|
||||
bool sel;
|
||||
bool vis;
|
||||
bool valid = computeSplatValue(idx, val, sel, vis);
|
||||
if (!valid || !vis) continue;
|
||||
minVal = min(minVal, val);
|
||||
maxVal = max(maxVal, val);
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(minVal, maxVal, 0.0, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// pass 2: reduce 64×64 → 1×1
|
||||
const finalReduceFS = /* glsl */ `
|
||||
uniform sampler2D inputTex;
|
||||
uniform int gridDim;
|
||||
|
||||
#define MAX_GRID_DIM 64
|
||||
|
||||
void main(void) {
|
||||
float minVal = 1e30;
|
||||
float maxVal = -1e30;
|
||||
|
||||
for (int y = 0; y < MAX_GRID_DIM; y++) {
|
||||
if (y >= gridDim) break;
|
||||
for (int x = 0; x < MAX_GRID_DIM; x++) {
|
||||
if (x >= gridDim) break;
|
||||
vec2 v = texelFetch(inputTex, ivec2(x, y), 0).rg;
|
||||
minVal = min(minVal, v.x);
|
||||
maxVal = max(maxVal, v.y);
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(minVal, maxVal, 0.0, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// pass 3: bin counting (point rendering, additive blending)
|
||||
const binVS = /* glsl */ `
|
||||
${computeSplatValueGLSL}
|
||||
|
||||
uniform sampler2D minMax;
|
||||
uniform int numBins;
|
||||
|
||||
varying float v_flag;
|
||||
|
||||
void main(void) {
|
||||
float val;
|
||||
bool sel;
|
||||
bool vis;
|
||||
bool valid = computeSplatValue(gl_VertexID, val, sel, vis);
|
||||
bool include = valid && vis;
|
||||
v_flag = include ? (sel ? 2.0 : 1.0) : 0.0;
|
||||
|
||||
if (!include) {
|
||||
gl_Position = vec4(2.0, 2.0, 0.0, 1.0);
|
||||
gl_PointSize = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 mm = texelFetch(minMax, ivec2(0, 0), 0).rg;
|
||||
float minV = mm.x;
|
||||
float maxV = mm.y;
|
||||
float n = (maxV == minV) ? 0.0 : (val - minV) / (maxV - minV);
|
||||
int bin = clamp(int(n * float(numBins)), 0, numBins - 1);
|
||||
|
||||
float xNDC = (float(bin) + 0.5) / float(numBins) * 2.0 - 1.0;
|
||||
gl_Position = vec4(xNDC, 0.0, 0.0, 1.0);
|
||||
gl_PointSize = 1.0;
|
||||
}
|
||||
`;
|
||||
|
||||
const binFS = /* glsl */ `
|
||||
varying float v_flag;
|
||||
void main(void) {
|
||||
float sel = v_flag == 2.0 ? 1.0 : 0.0;
|
||||
float unsel = v_flag == 1.0 ? 1.0 : 0.0;
|
||||
gl_FragColor = vec4(sel, unsel, 0.0, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export {
|
||||
fullscreenVS,
|
||||
tileMinMaxFS,
|
||||
finalReduceFS,
|
||||
binVS,
|
||||
binFS
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
const vertexShader = /* glsl*/ `
|
||||
uniform vec3 near_origin;
|
||||
uniform vec3 near_x;
|
||||
uniform vec3 near_y;
|
||||
|
||||
uniform vec3 far_origin;
|
||||
uniform vec3 far_x;
|
||||
uniform vec3 far_y;
|
||||
|
||||
attribute vec2 vertex_position;
|
||||
|
||||
varying vec3 worldFar;
|
||||
varying vec3 worldNear;
|
||||
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
|
||||
vec2 p = vertex_position * 0.5 + 0.5;
|
||||
worldNear = near_origin + near_x * p.x + near_y * p.y;
|
||||
worldFar = far_origin + far_x * p.x + far_y * p.y;
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl*/ `
|
||||
uniform vec3 view_position;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
uniform sampler2D blueNoiseTex32;
|
||||
|
||||
uniform int plane; // 0: x (yz), 1: y (xz), 2: z (xy)
|
||||
|
||||
vec4 planes[3] = vec4[3](
|
||||
vec4(1.0, 0.0, 0.0, 0.0),
|
||||
vec4(0.0, 1.0, 0.0, 0.0),
|
||||
vec4(0.0, 0.0, 1.0, 0.0)
|
||||
);
|
||||
|
||||
vec3 colors[3] = vec3[3](
|
||||
vec3(1.0, 0.2, 0.2),
|
||||
vec3(0.2, 1.0, 0.2),
|
||||
vec3(0.2, 0.2, 1.0)
|
||||
);
|
||||
|
||||
int axis0[3] = int[3](1, 0, 0);
|
||||
int axis1[3] = int[3](2, 2, 1);
|
||||
|
||||
varying vec3 worldNear;
|
||||
varying vec3 worldFar;
|
||||
|
||||
bool intersectPlane(inout float t, vec3 pos, vec3 dir, vec4 plane) {
|
||||
float d = dot(dir, plane.xyz);
|
||||
if (abs(d) < 1e-06) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float n = -(dot(pos, plane.xyz) + plane.w) / d;
|
||||
if (n < 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
t = n;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c
|
||||
float pristineGrid(in vec2 uv, in vec2 ddx, in vec2 ddy, vec2 lineWidth) {
|
||||
vec2 uvDeriv = vec2(length(vec2(ddx.x, ddy.x)), length(vec2(ddx.y, ddy.y)));
|
||||
bvec2 invertLine = bvec2(lineWidth.x > 0.5, lineWidth.y > 0.5);
|
||||
vec2 targetWidth = vec2(
|
||||
invertLine.x ? 1.0 - lineWidth.x : lineWidth.x,
|
||||
invertLine.y ? 1.0 - lineWidth.y : lineWidth.y
|
||||
);
|
||||
vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5));
|
||||
vec2 lineAA = uvDeriv * 1.5;
|
||||
vec2 gridUV = abs(fract(uv) * 2.0 - 1.0);
|
||||
gridUV.x = invertLine.x ? gridUV.x : 1.0 - gridUV.x;
|
||||
gridUV.y = invertLine.y ? gridUV.y : 1.0 - gridUV.y;
|
||||
vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV);
|
||||
|
||||
grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0);
|
||||
grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0));
|
||||
grid2.x = invertLine.x ? 1.0 - grid2.x : grid2.x;
|
||||
grid2.y = invertLine.y ? 1.0 - grid2.y : grid2.y;
|
||||
|
||||
return mix(grid2.x, 1.0, grid2.y);
|
||||
}
|
||||
|
||||
float calcDepth(vec3 p) {
|
||||
vec4 v = matrix_viewProjection * vec4(p, 1.0);
|
||||
return (v.z / v.w) * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
bool writeDepth(float alpha) {
|
||||
vec2 uv = fract(gl_FragCoord.xy / 32.0);
|
||||
float noise = texture2DLod(blueNoiseTex32, uv, 0.0).y;
|
||||
return alpha > noise;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
vec3 p = worldNear;
|
||||
vec3 v = normalize(worldFar - worldNear);
|
||||
|
||||
// intersect ray with the world xz plane
|
||||
float t;
|
||||
if (!intersectPlane(t, p, v, planes[plane])) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// calculate grid intersection
|
||||
vec3 worldPos = p + v * t;
|
||||
vec2 pos = plane == 0 ? worldPos.yz : (plane == 1 ? worldPos.xz : worldPos.xy);
|
||||
vec2 ddx = dFdx(pos);
|
||||
vec2 ddy = dFdy(pos);
|
||||
|
||||
float epsilon = 1.0 / 255.0;
|
||||
|
||||
// calculate fade
|
||||
float fade = 1.0 - smoothstep(400.0, 1000.0, length(worldPos - view_position));
|
||||
if (fade < epsilon) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec2 levelPos;
|
||||
float levelSize;
|
||||
float levelAlpha;
|
||||
|
||||
// 10m grid with colored main axes
|
||||
levelPos = pos * 0.1;
|
||||
levelSize = 2.0 / 1000.0;
|
||||
levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2(levelSize)) * fade;
|
||||
if (levelAlpha > epsilon) {
|
||||
vec3 color;
|
||||
vec2 loc = abs(levelPos);
|
||||
if (loc.x < levelSize) {
|
||||
if (loc.y < levelSize) {
|
||||
color = vec3(1.0);
|
||||
} else {
|
||||
color = colors[axis1[plane]];
|
||||
}
|
||||
} else if (loc.y < levelSize) {
|
||||
color = colors[axis0[plane]];
|
||||
} else {
|
||||
color = vec3(0.9);
|
||||
}
|
||||
gl_FragColor = vec4(color, levelAlpha);
|
||||
gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 1m grid
|
||||
levelPos = pos;
|
||||
levelSize = 1.0 / 100.0;
|
||||
levelAlpha = pristineGrid(levelPos, ddx, ddy, vec2(levelSize)) * fade;
|
||||
if (levelAlpha > epsilon) {
|
||||
gl_FragColor = vec4(vec3(0.7), levelAlpha);
|
||||
gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 0.1m grid
|
||||
levelPos = pos * 10.0;
|
||||
levelSize = 1.0 / 100.0;
|
||||
levelAlpha = pristineGrid(levelPos, ddx * 10.0, ddy * 10.0, vec2(levelSize)) * fade;
|
||||
if (levelAlpha > epsilon) {
|
||||
gl_FragColor = vec4(vec3(0.7), levelAlpha);
|
||||
gl_FragDepth = writeDepth(levelAlpha) ? calcDepth(worldPos) : 1.0;
|
||||
return;
|
||||
}
|
||||
|
||||
discard;
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,116 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform highp usampler2D transformA; // splat center x, y, z
|
||||
uniform highp usampler2D splatTransform; // transform palette index
|
||||
uniform sampler2D transformPalette; // palette of transforms
|
||||
uniform uvec2 splat_params; // splat texture width, num splats
|
||||
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
|
||||
uniform uvec2 output_params; // output width, height
|
||||
|
||||
// 0: mask, 1: rect, 2: sphere, 3: box
|
||||
uniform int mode;
|
||||
|
||||
// mask params
|
||||
uniform sampler2D mask; // mask in alpha channel
|
||||
uniform vec2 mask_params; // mask width, height
|
||||
|
||||
// rect params
|
||||
uniform vec4 rect_params; // rect x, y, width, height
|
||||
|
||||
// sphere params
|
||||
uniform vec4 sphere_params; // sphere x, y, z, radius
|
||||
|
||||
// box params
|
||||
uniform vec4 box_params; // box x, y, z
|
||||
uniform vec4 aabb_params; // len x, y, z
|
||||
|
||||
void main(void) {
|
||||
// calculate output id
|
||||
uvec2 outputUV = uvec2(gl_FragCoord);
|
||||
uint outputId = (outputUV.x + outputUV.y * output_params.x) * 4u;
|
||||
|
||||
vec4 clr = vec4(0.0);
|
||||
|
||||
for (uint i = 0u; i < 4u; i++) {
|
||||
uint id = outputId + i;
|
||||
|
||||
if (id >= splat_params.y) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// calculate splatUV
|
||||
ivec2 splatUV = ivec2(
|
||||
int(id % splat_params.x),
|
||||
int(id / splat_params.x)
|
||||
);
|
||||
|
||||
// read splat center
|
||||
vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz);
|
||||
|
||||
// apply optional per-splat transform
|
||||
uint transformIndex = texelFetch(splatTransform, splatUV, 0).r;
|
||||
if (transformIndex > 0u) {
|
||||
// read transform matrix
|
||||
int u = int(transformIndex % 512u) * 3;
|
||||
int v = int(transformIndex / 512u);
|
||||
|
||||
mat3x4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
|
||||
center = vec4(center, 1.0) * t;
|
||||
}
|
||||
|
||||
// transform to world space (sphere/box modes test world-space containment)
|
||||
vec3 world = (matrix_model * vec4(center, 1.0)).xyz;
|
||||
|
||||
if (mode == 0 || mode == 1) {
|
||||
// screen-space modes: project to clip space and skip offscreen fragments
|
||||
vec4 clip = matrix_viewProjection * vec4(world, 1.0);
|
||||
vec3 ndc = clip.xyz / clip.w;
|
||||
|
||||
if (!any(greaterThan(abs(ndc), vec3(1.0)))) {
|
||||
if (mode == 0) {
|
||||
// select by mask
|
||||
ivec2 maskUV = ivec2((ndc.xy * vec2(0.5, -0.5) + 0.5) * mask_params);
|
||||
clr[i] = texelFetch(mask, maskUV, 0).a < 1.0 ? 0.0 : 1.0;
|
||||
} else {
|
||||
// select by rect
|
||||
clr[i] = all(greaterThan(ndc.xy * vec2(1.0, -1.0), rect_params.xy)) && all(lessThan(ndc.xy * vec2(1.0, -1.0), rect_params.zw)) ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
} else if (mode == 2) {
|
||||
// select by sphere (world-space, independent of camera frustum)
|
||||
clr[i] = length(world - sphere_params.xyz) < sphere_params.w ? 1.0 : 0.0;
|
||||
} else if (mode == 3) {
|
||||
// select by box (world-space, independent of camera frustum)
|
||||
vec3 relativePosition = world - box_params.xyz;
|
||||
bool isInsideCube = true;
|
||||
if (relativePosition.x < -aabb_params.x || relativePosition.x > aabb_params.x) {
|
||||
isInsideCube = false;
|
||||
}
|
||||
if (relativePosition.y < -aabb_params.y || relativePosition.y > aabb_params.y) {
|
||||
isInsideCube = false;
|
||||
}
|
||||
if (relativePosition.z < -aabb_params.z || relativePosition.z > aabb_params.z) {
|
||||
isInsideCube = false;
|
||||
}
|
||||
clr[i] = isInsideCube ? 1.0 : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor = clr;
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,34 @@
|
||||
const vertexShader = /* glsl*/ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl*/ `
|
||||
uniform sampler2D srcTexture;
|
||||
uniform float alphaCutoff;
|
||||
uniform vec4 clr;
|
||||
|
||||
void main(void) {
|
||||
ivec2 texel = ivec2(gl_FragCoord.xy);
|
||||
|
||||
// skip solid pixels
|
||||
if (texelFetch(srcTexture, texel, 0).a > alphaCutoff) {
|
||||
discard;
|
||||
}
|
||||
|
||||
for (int x = -2; x <= 2; x++) {
|
||||
for (int y = -2; y <= 2; y++) {
|
||||
if ((x != 0) && (y != 0) && (texelFetch(srcTexture, texel + ivec2(x, y), 0).a > alphaCutoff)) {
|
||||
gl_FragColor = clr;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
discard;
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,45 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform highp usampler2D transformA; // splat center x, y, z
|
||||
uniform highp usampler2D splatTransform; // transform palette index
|
||||
uniform sampler2D transformPalette; // palette of transforms
|
||||
uniform ivec2 splat_params; // splat texture width, num splats
|
||||
|
||||
void main(void) {
|
||||
// calculate output id
|
||||
ivec2 splatUV = ivec2(gl_FragCoord);
|
||||
|
||||
// skip if splat index is out of bounds
|
||||
if (splatUV.x + splatUV.y * splat_params.x >= splat_params.y) {
|
||||
discard;
|
||||
}
|
||||
|
||||
// read splat center
|
||||
vec3 center = uintBitsToFloat(texelFetch(transformA, splatUV, 0).xyz);
|
||||
|
||||
// apply optional per-splat transform
|
||||
uint transformIndex = texelFetch(splatTransform, splatUV, 0).r;
|
||||
if (transformIndex > 0u) {
|
||||
// read transform matrix
|
||||
int u = int(transformIndex % 512u) * 3;
|
||||
int v = int(transformIndex / 512u);
|
||||
|
||||
mat3x4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
|
||||
center = vec4(center, 1.0) * t;
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(center, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { computeSplatValueGLSL } from './splat-value-shader';
|
||||
|
||||
// fragment writes a 4-byte texel per output pixel where each channel is 255 if
|
||||
// the corresponding splat falls within the requested histogram-bucket range
|
||||
// (and is visible / not locked / not deleted), or 0 otherwise. callers can
|
||||
// then read the result back as a Uint8Array and use `data[i] === 255` as the
|
||||
// per-splat selection predicate.
|
||||
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec2 vertex_position;
|
||||
void main(void) {
|
||||
gl_Position = vec4(vertex_position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
${computeSplatValueGLSL}
|
||||
|
||||
uniform ivec2 output_params; // result texture (width, height)
|
||||
uniform vec2 minMax; // (min, max) from the last histogram pass
|
||||
uniform int numBins;
|
||||
uniform int rangeStart;
|
||||
uniform int rangeEnd;
|
||||
|
||||
float check(int idx) {
|
||||
float val;
|
||||
bool sel;
|
||||
bool vis;
|
||||
bool valid = computeSplatValue(idx, val, sel, vis);
|
||||
if (!valid || !vis) return 0.0;
|
||||
|
||||
float n = (minMax.y == minMax.x) ? 0.0 : (val - minMax.x) / (minMax.y - minMax.x);
|
||||
int bin = clamp(int(n * float(numBins)), 0, numBins - 1);
|
||||
return (bin >= rangeStart && bin <= rangeEnd) ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
ivec2 outUV = ivec2(gl_FragCoord);
|
||||
int baseIdx = (outUV.y * output_params.x + outUV.x) * 4;
|
||||
|
||||
gl_FragColor = vec4(
|
||||
check(baseIdx),
|
||||
check(baseIdx + 1),
|
||||
check(baseIdx + 2),
|
||||
check(baseIdx + 3)
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,102 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
attribute vec3 vertex_position;
|
||||
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
|
||||
void main() {
|
||||
gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
bool intersectSphere(out float t0, out float t1, vec3 pos, vec3 dir, vec4 sphere) {
|
||||
vec3 L = sphere.xyz - pos;
|
||||
float tca = dot(L, dir);
|
||||
|
||||
float d2 = sphere.w * sphere.w - (dot(L, L) - tca * tca);
|
||||
if (d2 <= 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float thc = sqrt(d2);
|
||||
t0 = tca - thc;
|
||||
t1 = tca + thc;
|
||||
if (t1 <= 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
float calcDepth(in vec3 pos, in mat4 viewProjection) {
|
||||
vec4 v = viewProjection * vec4(pos, 1.0);
|
||||
return (v.z / v.w) * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
vec2 calcAzimuthElev(in vec3 dir) {
|
||||
float azimuth = atan(dir.z, dir.x);
|
||||
float elev = asin(dir.y);
|
||||
return vec2(azimuth, elev) * 180.0 / 3.14159;
|
||||
}
|
||||
|
||||
uniform sampler2D blueNoiseTex32;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
uniform vec4 sphere;
|
||||
|
||||
uniform vec3 near_origin;
|
||||
uniform vec3 near_x;
|
||||
uniform vec3 near_y;
|
||||
|
||||
uniform vec3 far_origin;
|
||||
uniform vec3 far_x;
|
||||
uniform vec3 far_y;
|
||||
|
||||
uniform vec2 targetSize;
|
||||
|
||||
bool writeDepth(float alpha) {
|
||||
vec2 uv = fract(gl_FragCoord.xy / 32.0);
|
||||
float noise = texture2DLod(blueNoiseTex32, uv, 0.0).y;
|
||||
return alpha > noise;
|
||||
}
|
||||
|
||||
bool strips(vec3 lp) {
|
||||
vec2 ae = calcAzimuthElev(normalize(lp));
|
||||
|
||||
float spacing = 180.0 / (2.0 * 3.14159 * sphere.w);
|
||||
float size = 0.03;
|
||||
return fract(ae.x / spacing) < size ||
|
||||
fract(ae.y / spacing) < size;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 clip = gl_FragCoord.xy / targetSize;
|
||||
vec3 worldNear = near_origin + near_x * clip.x + near_y * clip.y;
|
||||
vec3 worldFar = far_origin + far_x * clip.x + far_y * clip.y;
|
||||
|
||||
vec3 rayDir = normalize(worldFar - worldNear);
|
||||
|
||||
float t0, t1;
|
||||
if (!intersectSphere(t0, t1, worldNear, rayDir, sphere)) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 frontPos = worldNear + rayDir * t0;
|
||||
bool front = t0 > 0.0 && strips(frontPos - sphere.xyz);
|
||||
|
||||
vec3 backPos = worldNear + rayDir * t1;
|
||||
bool back = strips(backPos - sphere.xyz);
|
||||
|
||||
if (front) {
|
||||
gl_FragColor = vec4(1.0, 1.0, 1.0, 0.6);
|
||||
gl_FragDepth = writeDepth(0.6) ? calcDepth(frontPos, matrix_viewProjection) : 1.0;
|
||||
} else if (back) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.6);
|
||||
gl_FragDepth = writeDepth(0.6) ? calcDepth(backPos, matrix_viewProjection) : 1.0;
|
||||
} else {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,171 @@
|
||||
const vertexShader = /* glsl */ `
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_viewProjection;
|
||||
|
||||
uniform highp usampler2D splatOrder; // order texture mapping render order to splat ID
|
||||
uniform uint splatTextureSize; // width of order texture
|
||||
|
||||
uniform sampler2D splatState;
|
||||
uniform highp usampler2D splatPosition;
|
||||
uniform highp usampler2D splatTransform; // per-splat index into transform palette
|
||||
uniform sampler2D transformPalette; // palette of transform matrices
|
||||
uniform sampler2D splatColor; // Gaussian color texture (RGBA16F)
|
||||
|
||||
// SH textures (for uncompressed format)
|
||||
#if SH_BANDS > 0
|
||||
uniform highp usampler2D splatSH_1to3;
|
||||
#if SH_BANDS > 1
|
||||
uniform highp usampler2D splatSH_4to7;
|
||||
uniform highp usampler2D splatSH_8to11;
|
||||
#if SH_BANDS > 2
|
||||
uniform highp usampler2D splatSH_12to15;
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
|
||||
uniform vec3 view_position; // camera position in world space
|
||||
|
||||
uniform uvec2 texParams;
|
||||
|
||||
uniform float splatSize;
|
||||
uniform float useGaussianColor; // 0.0 = use selection colors, 1.0 = use gaussian color
|
||||
uniform vec4 selectedClr;
|
||||
uniform vec4 unselectedClr;
|
||||
|
||||
varying vec4 varying_color;
|
||||
|
||||
// calculate the current splat index and uv
|
||||
ivec2 calcSplatUV(uint index, uint width) {
|
||||
return ivec2(int(index % width), int(index / width));
|
||||
}
|
||||
|
||||
#if SH_BANDS > 0
|
||||
|
||||
// include SH evaluation from engine (provides SH_COEFFS, constants, and evalSH)
|
||||
#include "gsplatEvalSHVS"
|
||||
|
||||
// unpack signed 11 10 11 bits
|
||||
vec3 unpack111011s(uint bits) {
|
||||
return vec3((uvec3(bits) >> uvec3(21u, 11u, 0u)) & uvec3(0x7ffu, 0x3ffu, 0x7ffu)) / vec3(2047.0, 1023.0, 2047.0) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
// fetch quantized spherical harmonic coefficients with scale
|
||||
void fetchScale(in uvec4 t, out float scale, out vec3 a, out vec3 b, out vec3 c) {
|
||||
scale = uintBitsToFloat(t.x);
|
||||
a = unpack111011s(t.y);
|
||||
b = unpack111011s(t.z);
|
||||
c = unpack111011s(t.w);
|
||||
}
|
||||
|
||||
// fetch quantized spherical harmonic coefficients
|
||||
void fetchSH(in uvec4 t, out vec3 a, out vec3 b, out vec3 c, out vec3 d) {
|
||||
a = unpack111011s(t.x);
|
||||
b = unpack111011s(t.y);
|
||||
c = unpack111011s(t.z);
|
||||
d = unpack111011s(t.w);
|
||||
}
|
||||
|
||||
void fetchSH1(in uint t, out vec3 a) {
|
||||
a = unpack111011s(t);
|
||||
}
|
||||
|
||||
#if SH_BANDS == 1
|
||||
void readSHData(in ivec2 uv, out vec3 sh[3], out float scale) {
|
||||
fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]);
|
||||
}
|
||||
#elif SH_BANDS == 2
|
||||
void readSHData(in ivec2 uv, out vec3 sh[8], out float scale) {
|
||||
fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]);
|
||||
fetchSH(texelFetch(splatSH_4to7, uv, 0), sh[3], sh[4], sh[5], sh[6]);
|
||||
fetchSH1(texelFetch(splatSH_8to11, uv, 0).x, sh[7]);
|
||||
}
|
||||
#elif SH_BANDS == 3
|
||||
void readSHData(in ivec2 uv, out vec3 sh[15], out float scale) {
|
||||
fetchScale(texelFetch(splatSH_1to3, uv, 0), scale, sh[0], sh[1], sh[2]);
|
||||
fetchSH(texelFetch(splatSH_4to7, uv, 0), sh[3], sh[4], sh[5], sh[6]);
|
||||
fetchSH(texelFetch(splatSH_8to11, uv, 0), sh[7], sh[8], sh[9], sh[10]);
|
||||
fetchSH(texelFetch(splatSH_12to15, uv, 0), sh[11], sh[12], sh[13], sh[14]);
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
void main(void) {
|
||||
// look up splat ID from order texture using gl_VertexID
|
||||
ivec2 orderUV = ivec2(gl_VertexID % int(splatTextureSize), gl_VertexID / int(splatTextureSize));
|
||||
uint splatId = texelFetch(splatOrder, orderUV, 0).r;
|
||||
|
||||
ivec2 splatUV = calcSplatUV(splatId, texParams.x);
|
||||
uint splatState = uint(texelFetch(splatState, splatUV, 0).r * 255.0);
|
||||
|
||||
// check for locked splats (deleted splats are already excluded from order texture)
|
||||
if ((splatState & 2u) != 0u) {
|
||||
// locked
|
||||
gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
|
||||
gl_PointSize = 0.0;
|
||||
} else {
|
||||
mat4 model = matrix_model;
|
||||
|
||||
// handle per-splat transform
|
||||
uint transformIndex = texelFetch(splatTransform, splatUV, 0).r;
|
||||
if (transformIndex > 0u) {
|
||||
// read transform matrix
|
||||
int u = int(transformIndex % 512u) * 3;
|
||||
int v = int(transformIndex / 512u);
|
||||
|
||||
mat4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
t[3] = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
|
||||
model = matrix_model * transpose(t);
|
||||
}
|
||||
|
||||
vec3 center = uintBitsToFloat(texelFetch(splatPosition, splatUV, 0).xyz);
|
||||
|
||||
vec3 gaussianClr;
|
||||
|
||||
if (useGaussianColor > 0.0) {
|
||||
// get base gaussian color
|
||||
gaussianClr = texelFetch(splatColor, splatUV, 0).xyz;
|
||||
|
||||
#if SH_BANDS > 0
|
||||
// calculate world position and view direction
|
||||
vec3 worldPos = (model * vec4(center, 1.0)).xyz;
|
||||
vec3 viewDir = normalize(worldPos - view_position);
|
||||
// transform view direction to model space
|
||||
vec3 modelViewDir = normalize(viewDir * mat3(model));
|
||||
|
||||
// read and evaluate SH
|
||||
vec3 sh[SH_COEFFS];
|
||||
float scale;
|
||||
readSHData(splatUV, sh, scale);
|
||||
gaussianClr += evalSH(sh, modelViewDir) * scale;
|
||||
#endif
|
||||
} else {
|
||||
gaussianClr = unselectedClr.xyz;
|
||||
}
|
||||
|
||||
// choose between selection colors and gaussian color
|
||||
varying_color = vec4(mix(gaussianClr, selectedClr.xyz, (splatState == 1u) ? selectedClr.w : 0.0), unselectedClr.w);
|
||||
|
||||
gl_Position = matrix_viewProjection * model * vec4(center, 1.0);
|
||||
|
||||
// disable depth clipping
|
||||
gl_Position.z = 0.0;
|
||||
|
||||
gl_PointSize = splatSize;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
varying vec4 varying_color;
|
||||
|
||||
void main(void) {
|
||||
gl_FragColor = varying_color;
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader };
|
||||
@@ -0,0 +1,282 @@
|
||||
const vertexShader = /* glsl*/`
|
||||
#include "gsplatCommonVS"
|
||||
|
||||
uniform sampler2D splatState;
|
||||
|
||||
uniform vec4 selectedClr;
|
||||
uniform vec4 lockedClr;
|
||||
|
||||
uniform vec3 clrOffset;
|
||||
uniform vec4 clrScale;
|
||||
|
||||
varying mediump vec4 texCoord_flags; // xy: texCoord, z: selected, w: locked
|
||||
varying mediump vec4 color;
|
||||
|
||||
#if PICK_PASS
|
||||
uniform uint pickOp; // 0: add, 1: remove, 2: set
|
||||
uniform int pickMode; // 0: pick id, 1: depth estimation
|
||||
#endif
|
||||
|
||||
mediump vec4 discardVec = vec4(0.0, 0.0, 2.0, 1.0);
|
||||
|
||||
uniform float saturation;
|
||||
|
||||
vec3 applySaturation(vec3 color) {
|
||||
vec3 grey = vec3(dot(color, vec3(0.299, 0.587, 0.114)));
|
||||
return grey + (color - grey) * saturation;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
// read gaussian details
|
||||
SplatSource source;
|
||||
if (!initSource(source)) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
|
||||
// get per-gaussian edit state, discard if deleted
|
||||
uint vertexState = uint(texelFetch(splatState, splat.uv, 0).r * 255.0 + 0.5) & 7u;
|
||||
|
||||
#if PICK_PASS
|
||||
if (pickOp == 0u) {
|
||||
// add: skip deleted, locked and selected splats
|
||||
if (vertexState != 0u) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
} else if (pickOp == 1u) {
|
||||
// remove: skip deleted, locked and unselected splats
|
||||
if (vertexState != 1u) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// set: skip deleted and locked splats
|
||||
if ((vertexState & 6u) != 0u) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
}
|
||||
#else
|
||||
// skip deleted splats
|
||||
if ((vertexState & 4u) != 0u) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// get center
|
||||
vec3 modelCenter = getCenter();
|
||||
|
||||
SplatCenter center;
|
||||
center.modelCenterOriginal = modelCenter;
|
||||
center.modelCenterModified = modelCenter;
|
||||
if (!initCenter(modelCenter, center)) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
|
||||
SplatCorner corner;
|
||||
if (!initCorner(source, center, corner)) {
|
||||
gl_Position = discardVec;
|
||||
return;
|
||||
}
|
||||
|
||||
gl_Position = center.proj + vec4(corner.offset, 0.0);
|
||||
|
||||
// store texture coord and locked state
|
||||
texCoord_flags = vec4(
|
||||
corner.uv,
|
||||
(vertexState & 1u) != 0u ? 1.0 : 0.0, // selected
|
||||
(vertexState & 2u) != 0u ? 1.0 : 0.0 // locked
|
||||
);
|
||||
|
||||
#if PICK_PASS
|
||||
if (pickMode == 1) {
|
||||
// depth estimation mode: compute normalized depth in vertex shader
|
||||
float linearDepth = -center.view.z;
|
||||
float normalizedDepth = (linearDepth - camera_params.z) / (camera_params.y - camera_params.z);
|
||||
vec4 clr = getColor();
|
||||
color = vec4(normalizedDepth, 0.0, 0.0, 1.0) * clr.a;
|
||||
} else {
|
||||
// pick id
|
||||
uvec4 bits = (uvec4(splat.index) >> uvec4(0u, 8u, 16u, 24u)) & uvec4(255u);
|
||||
color = vec4(bits) / 255.0;
|
||||
}
|
||||
// handle splat color
|
||||
#elif FORWARD_PASS
|
||||
// read color
|
||||
color = getColor();
|
||||
|
||||
// evaluate spherical harmonics
|
||||
#if SH_BANDS > 0
|
||||
// calculate the model-space view direction
|
||||
vec3 dir = normalize(center.view * mat3(center.modelView));
|
||||
|
||||
// read sh coefficients
|
||||
vec3 sh[SH_COEFFS];
|
||||
float scale;
|
||||
readSHData(sh, scale);
|
||||
|
||||
// evaluate
|
||||
color.xyz += evalSH(sh, dir) * scale;
|
||||
#endif
|
||||
|
||||
// apply tint/brightness
|
||||
color = color * clrScale + vec4(clrOffset, 0.0);
|
||||
|
||||
// apply saturation
|
||||
color.xyz = applySaturation(color.xyz);
|
||||
|
||||
// don't allow out-of-range alpha
|
||||
color.a = clamp(color.a, 0.0, 1.0);
|
||||
|
||||
// apply tonemapping
|
||||
color = vec4(prepareOutputFromGamma(max(color.xyz, 0.0), -center.view.z), color.w);
|
||||
|
||||
// apply locked/selected colors
|
||||
if ((vertexState & 2u) != 0u) {
|
||||
// locked
|
||||
color *= lockedClr;
|
||||
} else if ((vertexState & 1u) != 0u) {
|
||||
// selected
|
||||
color.xyz = mix(color.xyz, selectedClr.xyz, selectedClr.a);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShader = /* glsl*/`
|
||||
varying mediump vec4 texCoord_flags;
|
||||
varying mediump vec4 color;
|
||||
|
||||
uniform bool outlineMode;
|
||||
uniform float ringSize;
|
||||
|
||||
#if PICK_PASS
|
||||
uniform int pickMode; // 0: id, 1: depth estimation
|
||||
#endif
|
||||
|
||||
const float EXP4 = exp(-4.0);
|
||||
const float INV_EXP4 = 1.0 / (1.0 - EXP4);
|
||||
|
||||
float normExp(float x) {
|
||||
return (exp(x * -4.0) - EXP4) * INV_EXP4;
|
||||
}
|
||||
|
||||
void main(void) {
|
||||
mediump float A = dot(texCoord_flags.xy, texCoord_flags.xy);
|
||||
|
||||
if (A > 1.0) {
|
||||
discard;
|
||||
}
|
||||
|
||||
#if PICK_PASS
|
||||
if (pickMode == 1) {
|
||||
// depth estimation
|
||||
mediump float alpha = normExp(A);
|
||||
if (alpha < 1.0 / 255.0) {
|
||||
discard;
|
||||
}
|
||||
// we should multiply by alpha here to take into account gaussian falloff,
|
||||
// but it results in less accurate depth for some reason
|
||||
gl_FragColor = color * alpha;
|
||||
} else {
|
||||
// pick id
|
||||
gl_FragColor = color;
|
||||
}
|
||||
#else
|
||||
mediump float norm = normExp(A);
|
||||
mediump float alpha = norm * color.a;
|
||||
|
||||
if (texCoord_flags.w == 0.0 && ringSize > 0.0) {
|
||||
// rings mode
|
||||
if (A < 1.0 - ringSize) {
|
||||
alpha = max(0.05, alpha);
|
||||
} else {
|
||||
alpha = 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
bool selected = texCoord_flags.z != 0.0 && texCoord_flags.w == 0.0;
|
||||
|
||||
if (outlineMode) {
|
||||
pcFragColor0 = vec4(color.xyz * alpha, alpha);
|
||||
pcFragColor1 = vec4(0.0, 0.0, 0.0, selected ? norm : 0.0);
|
||||
} else {
|
||||
if (selected) {
|
||||
pcFragColor0 = vec4(color.xyz * alpha * 0.8, alpha);
|
||||
pcFragColor1 = vec4(color.xyz * alpha * 0.2, alpha);
|
||||
} else {
|
||||
pcFragColor0 = vec4(color.xyz * alpha, alpha);
|
||||
pcFragColor1 = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
`;
|
||||
|
||||
const gsplatCenter = /* glsl*/`
|
||||
uniform highp usampler2D splatTransform; // per-splat index into transform palette
|
||||
uniform sampler2D transformPalette; // palette of transform matrices
|
||||
|
||||
mat4 applyPaletteTransform(mat4 model) {
|
||||
uint transformIndex = texelFetch(splatTransform, splat.uv, 0).r;
|
||||
if (transformIndex == 0u) {
|
||||
return model;
|
||||
}
|
||||
|
||||
// read transform matrix
|
||||
int u = int(transformIndex % 512u) * 3;
|
||||
int v = int(transformIndex / 512u);
|
||||
|
||||
mat4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
t[3] = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
|
||||
return model * transpose(t);
|
||||
}
|
||||
|
||||
uniform mat4 matrix_model;
|
||||
uniform mat4 matrix_view;
|
||||
#ifndef GSPLAT_CENTER_NOPROJ
|
||||
uniform vec4 camera_params; // 1 / far, far, near, isOrtho
|
||||
uniform mat4 matrix_projection;
|
||||
#endif
|
||||
|
||||
// project the model space gaussian center to view and clip space
|
||||
bool initCenter(vec3 modelCenter, inout SplatCenter center) {
|
||||
mat4 modelView = matrix_view * applyPaletteTransform(matrix_model);
|
||||
vec4 centerView = modelView * vec4(modelCenter, 1.0);
|
||||
|
||||
#ifndef GSPLAT_CENTER_NOPROJ
|
||||
|
||||
// early out if splat is behind the camera (perspective only)
|
||||
// orthographic projections don't need this check as frustum culling handles it
|
||||
if (camera_params.w != 1.0 && centerView.z > 0.0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
vec4 centerProj = matrix_projection * centerView;
|
||||
|
||||
// ensure gaussians are not clipped by camera near and far
|
||||
#if WEBGPU
|
||||
centerProj.z = clamp(centerProj.z, 0, abs(centerProj.w));
|
||||
#else
|
||||
centerProj.z = clamp(centerProj.z, -abs(centerProj.w), abs(centerProj.w));
|
||||
#endif
|
||||
|
||||
center.proj = centerProj;
|
||||
center.projMat00 = matrix_projection[0][0];
|
||||
|
||||
#endif
|
||||
|
||||
center.view = centerView.xyz / centerView.w;
|
||||
center.modelView = modelView;
|
||||
return true;
|
||||
}
|
||||
`;
|
||||
|
||||
export { vertexShader, fragmentShader, gsplatCenter };
|
||||
@@ -0,0 +1,393 @@
|
||||
// shared GLSL chunk used by histogram and select-by-range GPU passes.
|
||||
//
|
||||
// declares the texture and uniform interface for reading per-splat data and
|
||||
// computing a single scalar value selected by `propMode`. exposes a small
|
||||
// extractor API (`Splat` struct + `readSplat`, `readColorDC`, `readOpacity`,
|
||||
// `readScale`, `readRotation`, `readSHCoeff`, `readFinalColor`) so callers can
|
||||
// also pull individual fields if they need something other than the propMode
|
||||
// dispatch.
|
||||
//
|
||||
// propMode values:
|
||||
//
|
||||
// 0..2 world.x / world.y / world.z
|
||||
// 3 distance (= length(world))
|
||||
// 4 camera depth (= -(viewMatrix * world).z)
|
||||
// 5..7 "Red"/"Green"/"Blue" = final on-screen color channels.
|
||||
// applyColorGrade(dcDecode(f_dc) + evalSH(viewDir)). view-dependent.
|
||||
// 8 opacity (= splatColor.a * transparency)
|
||||
// 9..11 scale_0 / scale_1 / scale_2 (exp'd in transformB.xyz)
|
||||
// 12 volume (= scale.x * scale.y * scale.z)
|
||||
// 13 surface area (= dot(scale, scale))
|
||||
// 14..17 quat W (reconstructed, always >= 0) / X / Y / Z
|
||||
// 18..20 H / S / V of the final on-screen color (same dependence as 5..7)
|
||||
// 21.. f_rest_N (N = propMode - 21), decoded from the engine's packed SH
|
||||
// textures. only valid when SH_BANDS > 0 and N < 3 * shNumCoeffs.
|
||||
// 66..68 raw "DC R"/"DC G"/"DC B" coefficients: inverse of `dcDecode` applied
|
||||
// to `splatColor.rgb`. camera-independent.
|
||||
//
|
||||
// the active SH_BANDS define controls which SH samplers are declared and which
|
||||
// branches are compiled in. callers must select a matching uniqueName so that
|
||||
// each SH_BANDS variant gets its own cached shader.
|
||||
|
||||
const computeSplatValueGLSL = /* glsl */ `
|
||||
|
||||
#ifndef SH_BANDS
|
||||
#define SH_BANDS 0
|
||||
#endif
|
||||
|
||||
#define SH_C0 0.28209479177387814
|
||||
|
||||
#if SH_BANDS == 1
|
||||
#define SH_COEFFS 3
|
||||
#elif SH_BANDS == 2
|
||||
#define SH_COEFFS 8
|
||||
#elif SH_BANDS == 3
|
||||
#define SH_COEFFS 15
|
||||
#endif
|
||||
|
||||
uniform highp usampler2D transformA;
|
||||
uniform sampler2D transformB;
|
||||
uniform sampler2D splatColor;
|
||||
uniform highp usampler2D splatTransform;
|
||||
uniform sampler2D transformPalette;
|
||||
uniform sampler2D splatState;
|
||||
|
||||
#if SH_BANDS > 0
|
||||
uniform highp usampler2D splatSH_1to3;
|
||||
uniform int shNumCoeffs;
|
||||
#endif
|
||||
#if SH_BANDS > 1
|
||||
uniform highp usampler2D splatSH_4to7;
|
||||
uniform highp usampler2D splatSH_8to11;
|
||||
#endif
|
||||
#if SH_BANDS > 2
|
||||
uniform highp usampler2D splatSH_12to15;
|
||||
#endif
|
||||
|
||||
uniform ivec2 splat_params;
|
||||
uniform int propMode;
|
||||
uniform mat4 entityMatrix;
|
||||
uniform mat4 viewMatrix;
|
||||
uniform mat4 viewProjection;
|
||||
uniform vec3 cameraWorldPos;
|
||||
uniform int onScreenOnly;
|
||||
|
||||
uniform vec3 cgScale;
|
||||
uniform float cgOffset;
|
||||
uniform float cgSaturation;
|
||||
uniform float transparency;
|
||||
|
||||
// SH band weighting constants (matches engine's gsplatEvalSH GLSL chunk).
|
||||
#if SH_BANDS > 0
|
||||
const float SH_C1 = 0.4886025119029199;
|
||||
#if SH_BANDS > 1
|
||||
const float SH_C2_0 = 1.0925484305920792;
|
||||
const float SH_C2_1 = -1.0925484305920792;
|
||||
const float SH_C2_2 = 0.31539156525252005;
|
||||
const float SH_C2_3 = -1.0925484305920792;
|
||||
const float SH_C2_4 = 0.5462742152960396;
|
||||
#endif
|
||||
#if SH_BANDS > 2
|
||||
const float SH_C3_0 = -0.5900435899266435;
|
||||
const float SH_C3_1 = 2.890611442640554;
|
||||
const float SH_C3_2 = -0.4570457994644658;
|
||||
const float SH_C3_3 = 0.3731763325901154;
|
||||
const float SH_C3_4 = -0.4570457994644658;
|
||||
const float SH_C3_5 = 1.445305721320277;
|
||||
const float SH_C3_6 = -0.5900435899266435;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
struct Splat {
|
||||
int idx;
|
||||
ivec2 uv;
|
||||
int state;
|
||||
bool selected; // state == 1
|
||||
bool valid; // state == 0 || state == 1 (not locked / deleted)
|
||||
vec3 localPos; // pre-transform (from transformA)
|
||||
vec3 worldPos; // post entity + per-splat transform
|
||||
bool visible; // passes the onScreenOnly filter
|
||||
};
|
||||
|
||||
vec3 applyColorGrade(vec3 c) {
|
||||
c = cgOffset + c * cgScale;
|
||||
float grey = dot(c, vec3(0.299, 0.587, 0.114));
|
||||
return mix(vec3(grey), c, cgSaturation);
|
||||
}
|
||||
|
||||
vec3 rgb2hsv(vec3 c) {
|
||||
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
|
||||
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
|
||||
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
|
||||
float d = q.x - min(q.w, q.y);
|
||||
float e = 1.0e-10;
|
||||
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
|
||||
}
|
||||
|
||||
#if SH_BANDS > 0
|
||||
// unpack the (R, G, B) SH triplet at coefficient index coeffIdx (0..14) for
|
||||
// the splat at uv. R/B are stored in 11 bits, G in 10 bits, all per-splat
|
||||
// normalized by the max value packed into splatSH_1to3.x.
|
||||
vec3 unpackSHTriplet(int coeffIdx, ivec2 uv) {
|
||||
uvec4 sh1 = texelFetch(splatSH_1to3, uv, 0);
|
||||
float maxV = uintBitsToFloat(sh1.x);
|
||||
|
||||
uint packed = 0u;
|
||||
if (coeffIdx < 3) {
|
||||
packed = sh1[coeffIdx + 1];
|
||||
}
|
||||
#if SH_BANDS > 1
|
||||
else if (coeffIdx < 7) {
|
||||
packed = texelFetch(splatSH_4to7, uv, 0)[coeffIdx - 3];
|
||||
}
|
||||
else if (coeffIdx < 11) {
|
||||
packed = texelFetch(splatSH_8to11, uv, 0)[coeffIdx - 7];
|
||||
}
|
||||
#endif
|
||||
#if SH_BANDS > 2
|
||||
else if (coeffIdx < 15) {
|
||||
packed = texelFetch(splatSH_12to15, uv, 0)[coeffIdx - 11];
|
||||
}
|
||||
#endif
|
||||
|
||||
uint encR = (packed >> 21) & 0x7FFu;
|
||||
uint encG = (packed >> 11) & 0x3FFu;
|
||||
uint encB = packed & 0x7FFu;
|
||||
|
||||
vec3 normalized = vec3(
|
||||
(float(encR) / 2047.0) * 2.0 - 1.0,
|
||||
(float(encG) / 1023.0) * 2.0 - 1.0,
|
||||
(float(encB) / 2047.0) * 2.0 - 1.0
|
||||
);
|
||||
|
||||
return normalized * maxV;
|
||||
}
|
||||
#endif
|
||||
|
||||
// populate s from the given index. returns false when the splat is
|
||||
// out-of-bounds or in a locked / deleted state; in that case only idx, state,
|
||||
// valid, and selected are reliably set.
|
||||
bool readSplat(int idx, out Splat s) {
|
||||
s.idx = idx;
|
||||
s.uv = ivec2(0);
|
||||
s.state = 0;
|
||||
s.selected = false;
|
||||
s.valid = false;
|
||||
s.localPos = vec3(0.0);
|
||||
s.worldPos = vec3(0.0);
|
||||
s.visible = false;
|
||||
|
||||
if (idx >= splat_params.y) return false;
|
||||
s.uv = ivec2(idx % splat_params.x, idx / splat_params.x);
|
||||
|
||||
s.state = int(texelFetch(splatState, s.uv, 0).r * 255.0 + 0.5);
|
||||
s.selected = (s.state == 1);
|
||||
bool clean = (s.state == 0);
|
||||
if (!(s.selected || clean)) return false;
|
||||
s.valid = true;
|
||||
|
||||
uvec4 transformAData = texelFetch(transformA, s.uv, 0);
|
||||
s.localPos = uintBitsToFloat(transformAData.xyz);
|
||||
|
||||
vec3 pos = s.localPos;
|
||||
uint ti = texelFetch(splatTransform, s.uv, 0).r;
|
||||
if (ti > 0u) {
|
||||
int u = int(ti % 512u) * 3;
|
||||
int v = int(ti / 512u);
|
||||
mat3x4 t;
|
||||
t[0] = texelFetch(transformPalette, ivec2(u, v), 0);
|
||||
t[1] = texelFetch(transformPalette, ivec2(u + 1, v), 0);
|
||||
t[2] = texelFetch(transformPalette, ivec2(u + 2, v), 0);
|
||||
pos = vec4(s.localPos, 1.0) * t;
|
||||
}
|
||||
|
||||
s.worldPos = (entityMatrix * vec4(pos, 1.0)).xyz;
|
||||
|
||||
s.visible = true;
|
||||
if (onScreenOnly == 1) {
|
||||
vec4 clip = viewProjection * vec4(s.worldPos, 1.0);
|
||||
if (clip.w <= 0.0) {
|
||||
s.visible = false;
|
||||
} else {
|
||||
// OpenGL-style clip space: ndc in [-1, 1] on all axes.
|
||||
vec3 ndc = clip.xyz / clip.w;
|
||||
if (any(greaterThan(abs(ndc), vec3(1.0)))) {
|
||||
s.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// color-graded DC color (no SH contribution). this is what was historically
|
||||
// returned for the "Red"/"Green"/"Blue" propModes, now exposed as a building
|
||||
// block.
|
||||
vec3 readColorDC(Splat s) {
|
||||
return applyColorGrade(texelFetch(splatColor, s.uv, 0).rgb);
|
||||
}
|
||||
|
||||
// post-grade alpha = sigmoid(opacity) * transparency.
|
||||
float readOpacity(Splat s) {
|
||||
return texelFetch(splatColor, s.uv, 0).a * transparency;
|
||||
}
|
||||
|
||||
// exponentiated scale per axis.
|
||||
vec3 readScale(Splat s) {
|
||||
return texelFetch(transformB, s.uv, 0).xyz;
|
||||
}
|
||||
|
||||
// quaternion components as (W, X, Y, Z). W is reconstructed and always >= 0
|
||||
// because the engine canonicalises rotation signs during splat construction.
|
||||
vec4 readRotation(Splat s) {
|
||||
vec2 qxy = unpackHalf2x16(texelFetch(transformA, s.uv, 0).w);
|
||||
float qz = texelFetch(transformB, s.uv, 0).w;
|
||||
float qw = sqrt(max(0.0, 1.0 - qxy.x * qxy.x - qxy.y * qxy.y - qz * qz));
|
||||
return vec4(qw, qxy.x, qxy.y, qz);
|
||||
}
|
||||
|
||||
#if SH_BANDS > 0
|
||||
// returns the single-channel f_rest value at the given index (0..44 for
|
||||
// shBands 3). identical layout to getSHData in the engine.
|
||||
float readSHCoeff(Splat s, int fRestIdx) {
|
||||
int channel = fRestIdx / shNumCoeffs;
|
||||
int coeffIdx = fRestIdx % shNumCoeffs;
|
||||
vec3 triplet = unpackSHTriplet(coeffIdx, s.uv);
|
||||
if (channel == 0) return triplet.r;
|
||||
if (channel == 1) return triplet.g;
|
||||
return triplet.b;
|
||||
}
|
||||
#endif
|
||||
|
||||
// final on-screen color: dcDecode(f_dc) + evalSH(viewDir), then ColorGrade.
|
||||
// view-dependent. for shBands == 0 this collapses to the DC-only color.
|
||||
vec3 readFinalColor(Splat s) {
|
||||
vec3 color = texelFetch(splatColor, s.uv, 0).rgb;
|
||||
|
||||
#if SH_BANDS > 0
|
||||
vec3 dir = normalize(s.worldPos - cameraWorldPos);
|
||||
float x = dir.x;
|
||||
float y = dir.y;
|
||||
float z = dir.z;
|
||||
|
||||
vec3 sh0 = unpackSHTriplet(0, s.uv);
|
||||
vec3 sh1 = unpackSHTriplet(1, s.uv);
|
||||
vec3 sh2 = unpackSHTriplet(2, s.uv);
|
||||
color += SH_C1 * (-sh0 * y + sh1 * z - sh2 * x);
|
||||
|
||||
#if SH_BANDS > 1
|
||||
float xx = x * x;
|
||||
float yy = y * y;
|
||||
float zz = z * z;
|
||||
float xy = x * y;
|
||||
float yz = y * z;
|
||||
float xz = x * z;
|
||||
|
||||
vec3 sh3 = unpackSHTriplet(3, s.uv);
|
||||
vec3 sh4 = unpackSHTriplet(4, s.uv);
|
||||
vec3 sh5 = unpackSHTriplet(5, s.uv);
|
||||
vec3 sh6 = unpackSHTriplet(6, s.uv);
|
||||
vec3 sh7 = unpackSHTriplet(7, s.uv);
|
||||
color +=
|
||||
sh3 * (SH_C2_0 * xy) +
|
||||
sh4 * (SH_C2_1 * yz) +
|
||||
sh5 * (SH_C2_2 * (2.0 * zz - xx - yy)) +
|
||||
sh6 * (SH_C2_3 * xz) +
|
||||
sh7 * (SH_C2_4 * (xx - yy));
|
||||
#endif
|
||||
|
||||
#if SH_BANDS > 2
|
||||
vec3 sh8 = unpackSHTriplet(8, s.uv);
|
||||
vec3 sh9 = unpackSHTriplet(9, s.uv);
|
||||
vec3 sh10 = unpackSHTriplet(10, s.uv);
|
||||
vec3 sh11 = unpackSHTriplet(11, s.uv);
|
||||
vec3 sh12 = unpackSHTriplet(12, s.uv);
|
||||
vec3 sh13 = unpackSHTriplet(13, s.uv);
|
||||
vec3 sh14 = unpackSHTriplet(14, s.uv);
|
||||
color +=
|
||||
sh8 * (SH_C3_0 * y * (3.0 * xx - yy)) +
|
||||
sh9 * (SH_C3_1 * xy * z) +
|
||||
sh10 * (SH_C3_2 * y * (4.0 * zz - xx - yy)) +
|
||||
sh11 * (SH_C3_3 * z * (2.0 * zz - 3.0 * xx - 3.0 * yy)) +
|
||||
sh12 * (SH_C3_4 * x * (4.0 * zz - xx - yy)) +
|
||||
sh13 * (SH_C3_5 * z * (xx - yy)) +
|
||||
sh14 * (SH_C3_6 * x * (xx - 3.0 * yy));
|
||||
#endif
|
||||
#endif
|
||||
|
||||
return applyColorGrade(color);
|
||||
}
|
||||
|
||||
// computes the scalar value for the splat at the given index. out-params
|
||||
// receive the value, whether the splat is selected, and whether it passes the
|
||||
// onScreenOnly filter (always true when onScreenOnly == 0).
|
||||
bool computeSplatValue(int idx, out float value, out bool selected, out bool visible) {
|
||||
value = 0.0;
|
||||
Splat s;
|
||||
if (!readSplat(idx, s)) {
|
||||
selected = false;
|
||||
visible = false;
|
||||
return false;
|
||||
}
|
||||
selected = s.selected;
|
||||
visible = s.visible;
|
||||
|
||||
if (propMode == 0) value = s.worldPos.x;
|
||||
else if (propMode == 1) value = s.worldPos.y;
|
||||
else if (propMode == 2) value = s.worldPos.z;
|
||||
else if (propMode == 3) value = length(s.worldPos);
|
||||
else if (propMode == 4) value = -(viewMatrix * vec4(s.worldPos, 1.0)).z;
|
||||
else if (propMode == 5 || propMode == 6 || propMode == 7) {
|
||||
vec3 rgb = readFinalColor(s);
|
||||
if (propMode == 5) value = rgb.r;
|
||||
else if (propMode == 6) value = rgb.g;
|
||||
else value = rgb.b;
|
||||
}
|
||||
else if (propMode == 8) {
|
||||
value = readOpacity(s);
|
||||
}
|
||||
else if (propMode >= 9 && propMode <= 13) {
|
||||
vec3 sc = readScale(s);
|
||||
if (propMode == 9) value = sc.x;
|
||||
else if (propMode == 10) value = sc.y;
|
||||
else if (propMode == 11) value = sc.z;
|
||||
else if (propMode == 12) value = sc.x * sc.y * sc.z;
|
||||
else value = dot(sc, sc);
|
||||
}
|
||||
else if (propMode >= 14 && propMode <= 17) {
|
||||
vec4 q = readRotation(s);
|
||||
if (propMode == 14) value = q.x; // W
|
||||
else if (propMode == 15) value = q.y; // X
|
||||
else if (propMode == 16) value = q.z; // Y
|
||||
else value = q.w; // Z
|
||||
}
|
||||
else if (propMode == 18 || propMode == 19 || propMode == 20) {
|
||||
// rgb2hsv expects channels in [0, 1]; the renderer clamps the final
|
||||
// pixel at output, so HSV is well-defined only on the clamped color.
|
||||
// without this, unclamped HDR / SH-shifted channels yield nonsense
|
||||
// S/V (e.g. (max - min)/max blowing up when max ≈ 0).
|
||||
vec3 hsv = rgb2hsv(clamp(readFinalColor(s), 0.0, 1.0));
|
||||
if (propMode == 18) value = hsv.x * 360.0;
|
||||
else if (propMode == 19) value = hsv.y;
|
||||
else value = hsv.z;
|
||||
}
|
||||
#if SH_BANDS > 0
|
||||
else if (propMode >= 21 && propMode <= 65) {
|
||||
value = readSHCoeff(s, propMode - 21);
|
||||
}
|
||||
#endif
|
||||
else if (propMode >= 66 && propMode <= 68) {
|
||||
// raw f_dc_N coefficient: invert the engine's dcDecode.
|
||||
vec3 dc = texelFetch(splatColor, s.uv, 0).rgb;
|
||||
float c;
|
||||
if (propMode == 66) c = dc.r;
|
||||
else if (propMode == 67) c = dc.g;
|
||||
else c = dc.b;
|
||||
value = (c - 0.5) / SH_C0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
`;
|
||||
|
||||
export { computeSplatValueGLSL };
|
||||
@@ -0,0 +1,141 @@
|
||||
import { platform } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
import { Shortcuts, ShortcutBinding } from './shortcuts';
|
||||
|
||||
// Mac uses different symbols for modifier keys
|
||||
const isMac = platform.name === 'osx';
|
||||
|
||||
// Default shortcut bindings - the source of truth for key mappings
|
||||
const defaultShortcuts: Record<string, ShortcutBinding> = {
|
||||
// Navigation
|
||||
'camera.reset': { keys: ['f'], shift: 'required' },
|
||||
'camera.focus': { keys: ['f'] },
|
||||
'camera.toggleControlMode': { keys: ['v'] },
|
||||
|
||||
// Show
|
||||
'camera.toggleOverlay': { keys: ['Tab'] },
|
||||
'camera.toggleMode': { keys: ['m'] },
|
||||
'grid.toggleVisible': { keys: ['g'] },
|
||||
'camera.toggleShowInfo': { keys: ['i'] },
|
||||
'select.hide': { keys: ['h'] },
|
||||
'select.unhide': { keys: ['h'], shift: 'required' },
|
||||
|
||||
// Playback
|
||||
'timeline.togglePlay': { keys: [' '] },
|
||||
'timeline.prevFrame': { keys: [','], repeat: true },
|
||||
'timeline.nextFrame': { keys: ['.'], repeat: true },
|
||||
'timeline.prevKey': { keys: ['<'], shift: 'optional', repeat: true },
|
||||
'timeline.nextKey': { keys: ['>'], shift: 'optional', repeat: true },
|
||||
'track.addKey': { keys: ['Enter'] },
|
||||
'track.removeKey': { keys: ['Enter'], shift: 'required' },
|
||||
|
||||
// Selection
|
||||
'select.all': { keys: ['a'], ctrl: 'required', capture: true },
|
||||
'select.none': { keys: ['a'], ctrl: 'required', shift: 'required', capture: true },
|
||||
'select.invert': { keys: ['i'], ctrl: 'required' },
|
||||
'select.delete': { keys: ['Delete', 'Backspace'] },
|
||||
|
||||
// Tools
|
||||
'tool.move': { keys: ['1'] },
|
||||
'tool.rotate': { keys: ['2'] },
|
||||
'tool.scale': { keys: ['3'] },
|
||||
'tool.rectSelection': { keys: ['r'] },
|
||||
'tool.lassoSelection': { keys: ['l'] },
|
||||
'tool.polygonSelection': { keys: ['p'] },
|
||||
'tool.brushSelection': { keys: ['b'] },
|
||||
'tool.floodSelection': { keys: ['o'] },
|
||||
'tool.eyedropperSelection': { keys: ['e'], ctrl: 'required', capture: true },
|
||||
'tool.brushSelection.smaller': { keys: ['['], repeat: true },
|
||||
'tool.brushSelection.bigger': { keys: [']'], repeat: true },
|
||||
'tool.deactivate': { keys: ['Escape'] },
|
||||
'tool.toggleCoordSpace': { keys: ['c'], shift: 'required' },
|
||||
|
||||
// Other
|
||||
'edit.undo': { keys: ['z'], ctrl: 'required', repeat: true, capture: true },
|
||||
'edit.redo': { keys: ['z'], ctrl: 'required', shift: 'required', repeat: true, capture: true },
|
||||
'dataPanel.toggle': { keys: ['d'], ctrl: 'required', capture: true },
|
||||
'timelinePanel.toggle': { keys: ['t'], ctrl: 'required', capture: true },
|
||||
|
||||
// Camera fly keys - use physical positions (codes) for WASD layout on non-QWERTY keyboards
|
||||
'camera.fly.forward': { codes: ['KeyW'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.fly.backward': { codes: ['KeyS'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.fly.left': { codes: ['KeyA'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.fly.right': { codes: ['KeyD'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.fly.down': { codes: ['KeyQ'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.fly.up': { codes: ['KeyE'], held: true, shift: 'optional', alt: 'optional' },
|
||||
'camera.modifier.fast': { codes: ['ShiftLeft', 'ShiftRight'], held: true, alt: 'optional' },
|
||||
'camera.modifier.slow': { codes: ['AltLeft', 'AltRight'], held: true, shift: 'optional' }
|
||||
};
|
||||
|
||||
class ShortcutManager {
|
||||
private bindings: Record<string, ShortcutBinding>;
|
||||
|
||||
constructor(events: Events) {
|
||||
// Clone the defaults so they can be modified without affecting the originals
|
||||
this.bindings = {};
|
||||
for (const id in defaultShortcuts) {
|
||||
this.bindings[id] = { ...defaultShortcuts[id] };
|
||||
}
|
||||
|
||||
// Create shortcuts and register all bindings
|
||||
const shortcuts = new Shortcuts(events);
|
||||
for (const id in this.bindings) {
|
||||
const binding = this.bindings[id];
|
||||
shortcuts.register({
|
||||
event: id,
|
||||
keys: binding.keys,
|
||||
codes: binding.codes,
|
||||
ctrl: binding.ctrl,
|
||||
shift: binding.shift,
|
||||
alt: binding.alt,
|
||||
held: binding.held,
|
||||
repeat: binding.repeat,
|
||||
capture: binding.capture
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shortcut binding by its event ID.
|
||||
*/
|
||||
get(id: string): ShortcutBinding | undefined {
|
||||
return this.bindings[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a shortcut for display (e.g., "Ctrl + Shift + Z" or "⌘⇧Z" on Mac).
|
||||
*/
|
||||
formatShortcut(id: string): string {
|
||||
const binding = this.bindings[id];
|
||||
if (!binding) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Use Mac symbols: ⌘ (Cmd), ⌥ (Option), ⇧ (Shift)
|
||||
if (binding.ctrl === 'required') parts.push(isMac ? '⌘' : 'Ctrl');
|
||||
if (binding.alt === 'required') parts.push(isMac ? '⌥' : 'Alt');
|
||||
if (binding.shift === 'required') parts.push(isMac ? '⇧' : 'Shift');
|
||||
|
||||
// Get the first key or code for display
|
||||
let keyDisplay = binding.keys?.[0] ?? binding.codes?.[0];
|
||||
if (!keyDisplay) return '';
|
||||
|
||||
if (keyDisplay === ' ') {
|
||||
keyDisplay = 'Space';
|
||||
} else if (keyDisplay === 'Escape') {
|
||||
keyDisplay = 'Esc';
|
||||
} else if (keyDisplay.startsWith('Key')) {
|
||||
// Physical key codes like 'KeyW' -> 'W'
|
||||
keyDisplay = keyDisplay.slice(3);
|
||||
} else if (keyDisplay.length === 1) {
|
||||
keyDisplay = keyDisplay.toUpperCase();
|
||||
}
|
||||
|
||||
parts.push(keyDisplay);
|
||||
|
||||
return isMac ? parts.join(' ') : parts.join(' + ');
|
||||
}
|
||||
}
|
||||
|
||||
export { ShortcutManager };
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Events } from './events';
|
||||
|
||||
/**
|
||||
* Modifier key requirement state.
|
||||
* - 'required': modifier must be pressed
|
||||
* - 'forbidden': modifier must NOT be pressed (default if unspecified)
|
||||
* - 'optional': don't care either way
|
||||
*/
|
||||
type ModifierState = 'required' | 'forbidden' | 'optional';
|
||||
|
||||
/**
|
||||
* A shortcut binding definition.
|
||||
*/
|
||||
interface ShortcutBinding {
|
||||
keys?: string[]; // list of keys
|
||||
codes?: string[]; // list of codes
|
||||
ctrl?: ModifierState;
|
||||
shift?: ModifierState;
|
||||
alt?: ModifierState;
|
||||
held?: boolean;
|
||||
repeat?: boolean; // whether to fire on keyboard repeat events (for non-held shortcuts)
|
||||
capture?: boolean; // whether to use capture phase for the event listener
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering a shortcut handler.
|
||||
* Extends ShortcutBinding with event/func handler.
|
||||
*/
|
||||
interface ShortcutOptions extends ShortcutBinding {
|
||||
event?: string;
|
||||
func?: (down?: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a modifier key state matches the requirement.
|
||||
*/
|
||||
const checkMod = (requirement: ModifierState | undefined, isPressed: boolean): boolean => {
|
||||
switch (requirement) {
|
||||
case 'required': return isPressed;
|
||||
case 'optional': return true;
|
||||
case 'forbidden':
|
||||
default: return !isPressed;
|
||||
}
|
||||
};
|
||||
|
||||
class Shortcuts {
|
||||
shortcuts: ShortcutOptions[] = [];
|
||||
|
||||
constructor(events: Events) {
|
||||
const shortcuts = this.shortcuts;
|
||||
|
||||
const handleEvent = (e: KeyboardEvent, down: boolean, capture: boolean) => {
|
||||
// skip if focus is elsewhere (input fields, modals, etc.)
|
||||
if (e.target !== document.body) return;
|
||||
|
||||
const isCtrlKey = e.code.startsWith('Control');
|
||||
const isShiftKey = e.code.startsWith('Shift');
|
||||
const isAltKey = e.code.startsWith('Alt');
|
||||
|
||||
for (let i = 0; i < shortcuts.length; i++) {
|
||||
const options = shortcuts[i];
|
||||
|
||||
const ctrlMatch = isCtrlKey || checkMod(options.ctrl, !!(e.ctrlKey || e.metaKey));
|
||||
const shiftMatch = isShiftKey || checkMod(options.shift, e.shiftKey);
|
||||
const altMatch = isAltKey || checkMod(options.alt, e.altKey);
|
||||
|
||||
// Match if key matches keys array OR code matches codes array
|
||||
const keyMatches = (options.keys?.some(k => k.toLowerCase() === e.key.toLowerCase()) ||
|
||||
options.codes?.some(c => c === e.code)) ?? false;
|
||||
|
||||
if (keyMatches &&
|
||||
((options.capture ?? false) === capture) &&
|
||||
ctrlMatch && shiftMatch && altMatch) {
|
||||
|
||||
// consume the event
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (options.held) {
|
||||
// Skip repeated keydown events, but fire on initial down and all up events
|
||||
if (down && e.repeat) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Non-held: ignore up events
|
||||
// Also ignore repeated keydown events unless repeat is explicitly allowed
|
||||
if (!down || (e.repeat && !options.repeat)) return;
|
||||
}
|
||||
|
||||
if (options.event) {
|
||||
// Only pass 'down' state for held shortcuts
|
||||
if (options.held) {
|
||||
events.fire(options.event, down);
|
||||
} else {
|
||||
events.fire(options.event);
|
||||
}
|
||||
} else {
|
||||
options.func(down);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// register keyboard handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
handleEvent(e, true, false);
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
handleEvent(e, false, false);
|
||||
});
|
||||
|
||||
// also handle capture phase
|
||||
document.addEventListener('keydown', (e) => {
|
||||
handleEvent(e, true, true);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
handleEvent(e, false, true);
|
||||
}, true);
|
||||
}
|
||||
|
||||
register(options: ShortcutOptions) {
|
||||
this.shortcuts.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
export { Shortcuts, ModifierState, ShortcutBinding };
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
BLENDEQUATION_ADD,
|
||||
BLENDMODE_ONE,
|
||||
BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDMODE_SRC_ALPHA,
|
||||
CULLFACE_FRONT,
|
||||
BlendState,
|
||||
BoundingBox,
|
||||
Entity,
|
||||
ShaderMaterial,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { Serializer } from './serializer';
|
||||
import { vertexShader, fragmentShader } from './shaders/sphere-shape-shader';
|
||||
|
||||
const v = new Vec3();
|
||||
const bound = new BoundingBox();
|
||||
|
||||
class SphereShape extends Element {
|
||||
_radius = 1;
|
||||
pivot: Entity;
|
||||
material: ShaderMaterial;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.debug);
|
||||
|
||||
this.pivot = new Entity('spherePivot');
|
||||
this.pivot.addComponent('render', {
|
||||
type: 'box'
|
||||
});
|
||||
const r = this._radius * 2;
|
||||
this.pivot.setLocalScale(r, r, r);
|
||||
}
|
||||
|
||||
add() {
|
||||
const material = new ShaderMaterial({
|
||||
uniqueName: 'sphereShape',
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
material.cull = CULLFACE_FRONT;
|
||||
material.blendState = new BlendState(
|
||||
true,
|
||||
BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA,
|
||||
BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA
|
||||
);
|
||||
material.update();
|
||||
|
||||
this.pivot.render.meshInstances[0].material = material;
|
||||
this.pivot.render.layers = [this.scene.worldLayer.id];
|
||||
|
||||
this.material = material;
|
||||
|
||||
this.scene.contentRoot.addChild(this.pivot);
|
||||
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.scene.contentRoot.removeChild(this.pivot);
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
}
|
||||
|
||||
serialize(serializer: Serializer): void {
|
||||
serializer.packa(this.pivot.getWorldTransform().data);
|
||||
serializer.pack(this.radius);
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
this.pivot.getWorldTransform().getTranslation(v);
|
||||
this.material.setParameter('sphere', [v.x, v.y, v.z, this.radius]);
|
||||
|
||||
const device = this.scene.graphicsDevice;
|
||||
device.scope.resolve('targetSize').setValue([device.width, device.height]);
|
||||
}
|
||||
|
||||
moved() {
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
updateBound() {
|
||||
bound.center.copy(this.pivot.getPosition());
|
||||
bound.halfExtents.set(this.radius, this.radius, this.radius);
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
get worldBound(): BoundingBox | null {
|
||||
return bound;
|
||||
}
|
||||
|
||||
set radius(radius: number) {
|
||||
this._radius = radius;
|
||||
|
||||
const r = this._radius * 2;
|
||||
this.pivot.setLocalScale(r, r, r);
|
||||
|
||||
this.updateBound();
|
||||
}
|
||||
|
||||
get radius() {
|
||||
return this._radius;
|
||||
}
|
||||
}
|
||||
|
||||
export { SphereShape };
|
||||
@@ -0,0 +1,185 @@
|
||||
// Injects Spherical Video V1 metadata into an MP4/MOV buffer so players
|
||||
// (YouTube, VLC, Quest) auto-detect the equirectangular projection.
|
||||
// https://github.com/google/spatial-media/blob/master/docs/spherical-video-rfc.md
|
||||
//
|
||||
// The metadata is a uuid box appended as the last child of the video trak.
|
||||
// Throws on unexpected input; callers should fall back to the untagged buffer.
|
||||
|
||||
const SPHERICAL_UUID = new Uint8Array([
|
||||
0xff, 0xcc, 0x82, 0x63, 0xf8, 0x55, 0x4a, 0x93,
|
||||
0x88, 0x14, 0x58, 0x7a, 0x02, 0x52, 0x1f, 0xdd
|
||||
]);
|
||||
|
||||
const SPHERICAL_XML =
|
||||
'<?xml version="1.0"?>' +
|
||||
'<rdf:SphericalVideo xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:GSpherical="http://ns.google.com/videos/1.0/spherical/">' +
|
||||
'<GSpherical:Spherical>true</GSpherical:Spherical>' +
|
||||
'<GSpherical:Stitched>true</GSpherical:Stitched>' +
|
||||
'<GSpherical:StitchingSoftware>SuperSplat</GSpherical:StitchingSoftware>' +
|
||||
'<GSpherical:ProjectionType>equirectangular</GSpherical:ProjectionType>' +
|
||||
'</rdf:SphericalVideo>';
|
||||
|
||||
type Box = {
|
||||
type: string;
|
||||
start: number;
|
||||
size: number;
|
||||
headerSize: number;
|
||||
};
|
||||
|
||||
// read the box header at offset, returning null when truncated or malformed
|
||||
const readBox = (view: DataView, offset: number, end: number): Box | null => {
|
||||
if (offset + 8 > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let size = view.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
view.getUint8(offset + 4),
|
||||
view.getUint8(offset + 5),
|
||||
view.getUint8(offset + 6),
|
||||
view.getUint8(offset + 7)
|
||||
);
|
||||
let headerSize = 8;
|
||||
|
||||
if (size === 1) {
|
||||
// 64-bit largesize (mdat can use this for long clips)
|
||||
if (offset + 16 > end) {
|
||||
return null;
|
||||
}
|
||||
size = view.getUint32(offset + 8) * 4294967296 + view.getUint32(offset + 12);
|
||||
headerSize = 16;
|
||||
} else if (size === 0) {
|
||||
// box extends to the end of the file
|
||||
size = end - offset;
|
||||
}
|
||||
|
||||
if (size < headerSize || offset + size > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { type, start: offset, size, headerSize };
|
||||
};
|
||||
|
||||
// list the direct children of a container box (or the whole file)
|
||||
const childBoxes = (view: DataView, start: number, end: number): Box[] => {
|
||||
const children: Box[] = [];
|
||||
let offset = start;
|
||||
while (offset < end) {
|
||||
const box = readBox(view, offset, end);
|
||||
if (!box) {
|
||||
break;
|
||||
}
|
||||
children.push(box);
|
||||
offset = box.start + box.size;
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
const findChild = (view: DataView, parent: Box, type: string): Box | null => {
|
||||
const children = childBoxes(view, parent.start + parent.headerSize, parent.start + parent.size);
|
||||
return children.find(child => child.type === type) ?? null;
|
||||
};
|
||||
|
||||
// a trak is the video track when its mdia > hdlr handler_type is 'vide'
|
||||
const isVideoTrak = (view: DataView, trak: Box): boolean => {
|
||||
const mdia = findChild(view, trak, 'mdia');
|
||||
const hdlr = mdia && findChild(view, mdia, 'hdlr');
|
||||
if (!hdlr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// hdlr payload: version/flags (4) + pre_defined (4) + handler_type (4)
|
||||
const offset = hdlr.start + hdlr.headerSize + 8;
|
||||
return offset + 4 <= hdlr.start + hdlr.size &&
|
||||
String.fromCharCode(
|
||||
view.getUint8(offset),
|
||||
view.getUint8(offset + 1),
|
||||
view.getUint8(offset + 2),
|
||||
view.getUint8(offset + 3)
|
||||
) === 'vide';
|
||||
};
|
||||
|
||||
// add delta to every chunk offset in the stco/co64 boxes of a moov subtree.
|
||||
// only needed when moov precedes mdat (fast-start layout); with moov written
|
||||
// last the chunk offsets are unaffected by the insertion.
|
||||
const patchChunkOffsets = (view: DataView, box: Box, delta: number) => {
|
||||
const containers = ['moov', 'trak', 'mdia', 'minf', 'stbl'];
|
||||
|
||||
if (containers.includes(box.type)) {
|
||||
const children = childBoxes(view, box.start + box.headerSize, box.start + box.size);
|
||||
children.forEach(child => patchChunkOffsets(view, child, delta));
|
||||
} else if (box.type === 'stco' || box.type === 'co64') {
|
||||
// full box: version/flags (4) + entry_count (4) + entries
|
||||
const count = view.getUint32(box.start + box.headerSize + 4);
|
||||
let offset = box.start + box.headerSize + 8;
|
||||
for (let i = 0; i < count; ++i) {
|
||||
if (box.type === 'stco') {
|
||||
view.setUint32(offset, view.getUint32(offset) + delta);
|
||||
offset += 4;
|
||||
} else {
|
||||
const value = view.getUint32(offset) * 4294967296 + view.getUint32(offset + 4) + delta;
|
||||
view.setUint32(offset, Math.floor(value / 4294967296));
|
||||
view.setUint32(offset + 4, value % 4294967296);
|
||||
offset += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const injectSphericalMetadata = (buffer: ArrayBuffer): ArrayBuffer => {
|
||||
const view = new DataView(buffer);
|
||||
const topLevel = childBoxes(view, 0, buffer.byteLength);
|
||||
|
||||
if (topLevel.length === 0 || topLevel[topLevel.length - 1].start + topLevel[topLevel.length - 1].size !== buffer.byteLength) {
|
||||
throw new Error('malformed container');
|
||||
}
|
||||
|
||||
const moov = topLevel.find(box => box.type === 'moov');
|
||||
if (!moov) {
|
||||
throw new Error('moov box not found');
|
||||
}
|
||||
|
||||
const moovChildren = childBoxes(view, moov.start + moov.headerSize, moov.start + moov.size);
|
||||
const trak = moovChildren.find(box => box.type === 'trak' && isVideoTrak(view, box));
|
||||
if (!trak) {
|
||||
throw new Error('video trak not found');
|
||||
}
|
||||
|
||||
if (moov.headerSize !== 8 || trak.headerSize !== 8) {
|
||||
throw new Error('unexpected 64-bit box size');
|
||||
}
|
||||
|
||||
// build the uuid box: size (4) + 'uuid' (4) + extended type (16) + xml
|
||||
const xml = new TextEncoder().encode(SPHERICAL_XML);
|
||||
const uuidBox = new Uint8Array(24 + xml.length);
|
||||
const uuidView = new DataView(uuidBox.buffer);
|
||||
uuidView.setUint32(0, uuidBox.length);
|
||||
uuidBox.set([0x75, 0x75, 0x69, 0x64], 4); // 'uuid'
|
||||
uuidBox.set(SPHERICAL_UUID, 8);
|
||||
uuidBox.set(xml, 24);
|
||||
|
||||
// splice the uuid box in as the last child of the video trak
|
||||
const src = new Uint8Array(buffer);
|
||||
const trakEnd = trak.start + trak.size;
|
||||
const out = new Uint8Array(buffer.byteLength + uuidBox.length);
|
||||
out.set(src.subarray(0, trakEnd), 0);
|
||||
out.set(uuidBox, trakEnd);
|
||||
out.set(src.subarray(trakEnd), trakEnd + uuidBox.length);
|
||||
|
||||
// grow the ancestor box sizes
|
||||
const outView = new DataView(out.buffer);
|
||||
outView.setUint32(moov.start, moov.size + uuidBox.length);
|
||||
outView.setUint32(trak.start, trak.size + uuidBox.length);
|
||||
|
||||
// fast-start layout defense: growing a moov that precedes mdat shifts the
|
||||
// media data, so chunk offsets must follow
|
||||
const mdat = topLevel.find(box => box.type === 'mdat');
|
||||
if (mdat && moov.start < mdat.start) {
|
||||
const outMoov = readBox(outView, moov.start, out.byteLength);
|
||||
patchChunkOffsets(outView, outMoov, uuidBox.length);
|
||||
}
|
||||
|
||||
return out.buffer;
|
||||
};
|
||||
|
||||
export { injectSphericalMetadata };
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
BLEND_NORMAL,
|
||||
PRIMITIVE_POINTS,
|
||||
SEMANTIC_POSITION,
|
||||
TYPE_FLOAT32,
|
||||
Color,
|
||||
Entity,
|
||||
EventHandler,
|
||||
GSplatResource,
|
||||
ShaderMaterial,
|
||||
Mesh,
|
||||
MeshInstance,
|
||||
VertexBuffer,
|
||||
VertexFormat
|
||||
} from 'playcanvas';
|
||||
|
||||
import { ElementType, Element } from './element';
|
||||
import { vertexShader, fragmentShader } from './shaders/splat-overlay-shader';
|
||||
import { Splat } from './splat';
|
||||
|
||||
const nullClr = new Color(0, 0, 0, 0);
|
||||
|
||||
class SplatOverlay extends Element {
|
||||
entity: Entity;
|
||||
mesh: Mesh;
|
||||
material: ShaderMaterial;
|
||||
meshInstance: MeshInstance;
|
||||
splat: Splat;
|
||||
onSorterUpdated: (count: number) => void;
|
||||
// the sorter we subscribed to in attach(); cached so detach() unsubscribes
|
||||
// from it directly (splat.entity may have been swapped out by replaceData)
|
||||
sorter: EventHandler;
|
||||
|
||||
constructor() {
|
||||
super(ElementType.debug);
|
||||
}
|
||||
|
||||
add() {
|
||||
const scene = this.scene;
|
||||
const device = scene.graphicsDevice;
|
||||
|
||||
this.material = new ShaderMaterial({
|
||||
uniqueName: 'splatOverlayMaterial',
|
||||
vertexGLSL: vertexShader,
|
||||
fragmentGLSL: fragmentShader
|
||||
});
|
||||
this.material.blendType = BLEND_NORMAL;
|
||||
this.material.depthWrite = false;
|
||||
this.material.depthTest = true;
|
||||
this.material.update();
|
||||
|
||||
this.mesh = new Mesh(device);
|
||||
|
||||
// dummy 1-vertex VB so the engine caches the VAO (avoids creating a new one every frame)
|
||||
const format = new VertexFormat(device, [
|
||||
{ semantic: SEMANTIC_POSITION, components: 1, type: TYPE_FLOAT32 }
|
||||
]);
|
||||
format.instancing = true;
|
||||
const vb = new VertexBuffer(device, format, 1);
|
||||
vb.lock();
|
||||
vb.unlock();
|
||||
this.mesh.vertexBuffer = vb;
|
||||
|
||||
this.mesh.primitive[0] = {
|
||||
baseVertex: 0,
|
||||
type: PRIMITIVE_POINTS,
|
||||
base: 0,
|
||||
count: 0
|
||||
};
|
||||
|
||||
this.meshInstance = new MeshInstance(this.mesh, this.material, null);
|
||||
// slightly higher priority so it renders before gizmos
|
||||
this.meshInstance.drawBucket = 128;
|
||||
// disable frustum culling since mesh has no vertex buffer for AABB calculation
|
||||
this.meshInstance.cull = false;
|
||||
|
||||
this.entity = new Entity('splatOverlay');
|
||||
this.entity.addComponent('render', {
|
||||
meshInstances: [this.meshInstance],
|
||||
layers: [scene.gizmoLayer.id]
|
||||
});
|
||||
|
||||
scene.events.on('selection.changed', (selection: Splat) => {
|
||||
if (selection) {
|
||||
this.attach(selection);
|
||||
} else {
|
||||
this.detach();
|
||||
}
|
||||
});
|
||||
|
||||
// re-attach when the attached splat swaps its frame data (animated
|
||||
// sequence): replaceData builds a new entity/instance, so our captured
|
||||
// instance and our entity (parented under the old one) are stale.
|
||||
scene.events.on('splat.replaced', (splat: Splat) => {
|
||||
if (this.splat === splat) {
|
||||
this.attach(splat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.detach();
|
||||
this.entity.destroy();
|
||||
}
|
||||
|
||||
attach(splat: Splat) {
|
||||
// detach from previous splat first
|
||||
this.detach();
|
||||
|
||||
const { mesh, material } = this;
|
||||
const instance = splat.entity.gsplat.instance;
|
||||
const orderTexture = instance.orderTexture;
|
||||
|
||||
// set up order texture uniforms
|
||||
material.setParameter('splatOrder', orderTexture);
|
||||
material.setParameter('splatTextureSize', orderTexture.width);
|
||||
|
||||
// set up other uniforms
|
||||
const resource = instance.resource as GSplatResource;
|
||||
material.setParameter('splatState', splat.stateTexture);
|
||||
material.setParameter('splatPosition', (resource as any).getTexture('transformA'));
|
||||
material.setParameter('splatTransform', splat.transformTexture);
|
||||
material.setParameter('splatColor', (resource as any).getTexture('splatColor'));
|
||||
material.setParameter('texParams', [splat.stateTexture.width, splat.stateTexture.height]);
|
||||
|
||||
// set up SH textures and define based on SH bands
|
||||
const shBands = resource.shBands;
|
||||
material.setDefine('SH_BANDS', `${shBands}`);
|
||||
if (shBands > 0) {
|
||||
material.setParameter('splatSH_1to3', (resource as any).getTexture('splatSH_1to3'));
|
||||
if (shBands > 1) {
|
||||
material.setParameter('splatSH_4to7', (resource as any).getTexture('splatSH_4to7'));
|
||||
material.setParameter('splatSH_8to11', (resource as any).getTexture('splatSH_8to11'));
|
||||
if (shBands > 2) {
|
||||
material.setParameter('splatSH_12to15', (resource as any).getTexture('splatSH_12to15'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
material.update();
|
||||
|
||||
// subscribe to sorter updates for dynamic count, caching the sorter so
|
||||
// detach() can unsubscribe from this exact instance
|
||||
this.onSorterUpdated = () => {
|
||||
mesh.primitive[0].count = instance.sorter.pendingSorted?.count ?? mesh.primitive[0].count;
|
||||
};
|
||||
this.sorter = instance.sorter;
|
||||
this.sorter.on('updated', this.onSorterUpdated);
|
||||
|
||||
// initialize count - numSplats is the current visible count (excluding deleted)
|
||||
mesh.primitive[0].count = splat.numSplats;
|
||||
|
||||
splat.entity.addChild(this.entity);
|
||||
this.splat = splat;
|
||||
}
|
||||
|
||||
detach() {
|
||||
// unsubscribe from the cached sorter (not splat.entity, which replaceData
|
||||
// may have already swapped to a new entity/instance)
|
||||
if (this.sorter && this.onSorterUpdated) {
|
||||
this.sorter.off('updated', this.onSorterUpdated);
|
||||
}
|
||||
this.sorter = null;
|
||||
this.onSorterUpdated = null;
|
||||
|
||||
this.entity.remove();
|
||||
this.splat = null;
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
const { enabled, scene } = this;
|
||||
const { events } = scene;
|
||||
|
||||
this.entity.enabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
const { material } = this;
|
||||
const splatSize = events.invoke('camera.splatSize');
|
||||
const selectedClr = events.invoke('view.outlineSelection') ? nullClr : events.invoke('selectedClr');
|
||||
const unselectedClr = events.invoke('unselectedClr');
|
||||
const useGaussianColor = events.invoke('view.centersUseGaussianColor') ? 1.0 : 0.0;
|
||||
|
||||
material.setParameter('splatSize', splatSize * window.devicePixelRatio);
|
||||
material.setParameter('selectedClr', [selectedClr.r, selectedClr.g, selectedClr.b, selectedClr.a]);
|
||||
material.setParameter('unselectedClr', [unselectedClr.r, unselectedClr.g, unselectedClr.b, unselectedClr.a]);
|
||||
material.setParameter('useGaussianColor', useGaussianColor);
|
||||
material.setParameter('transformPalette', this.splat.transformPalette.texture);
|
||||
|
||||
// pass camera position for SH evaluation
|
||||
const camPos = scene.camera.mainCamera.getPosition();
|
||||
material.setParameter('view_position', [camPos.x, camPos.y, camPos.z]);
|
||||
}
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
const { scene, splat } = this;
|
||||
const { events } = scene;
|
||||
return splat &&
|
||||
events.invoke('camera.splatSize') > 0 &&
|
||||
scene.camera.renderOverlays &&
|
||||
events.invoke('camera.overlay') &&
|
||||
events.invoke('camera.mode') === 'centers';
|
||||
}
|
||||
}
|
||||
|
||||
export { SplatOverlay };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { Texture } from 'playcanvas';
|
||||
|
||||
import { IndexRanges } from './index-ranges';
|
||||
|
||||
enum State {
|
||||
selected = 1,
|
||||
locked = 2,
|
||||
deleted = 4
|
||||
}
|
||||
|
||||
// CPU/GPU mirror of the per-splat state byte (selected/locked/deleted bits).
|
||||
// Mutators record a dirty range; flush() uploads to the GPU texture and refreshes
|
||||
// the cached counts. Replaces the implicit "remember to call updateState() after
|
||||
// mutating state[]" contract with an encapsulated owner.
|
||||
class SplatState {
|
||||
// shared with splatData.getProp('state') so existing read consumers keep
|
||||
// working without any indirection. SplatState is the sole writer.
|
||||
readonly data: Uint8Array;
|
||||
private readonly gpu: Texture;
|
||||
private dirtyLo = -1;
|
||||
private dirtyHi = -1;
|
||||
|
||||
// cached counts, refreshed by flush().
|
||||
numSelected = 0;
|
||||
numLocked = 0;
|
||||
numDeleted = 0;
|
||||
|
||||
constructor(data: Uint8Array, gpu: Texture) {
|
||||
this.data = data;
|
||||
this.gpu = gpu;
|
||||
|
||||
// mark everything dirty so the first flush uploads whatever was loaded
|
||||
// from disk (ply state column) and seeds the cached counts.
|
||||
this.dirtyLo = 0;
|
||||
this.dirtyHi = data.length;
|
||||
}
|
||||
|
||||
private markDirty(lo: number, hi: number) {
|
||||
if (this.dirtyLo < 0) {
|
||||
this.dirtyLo = lo;
|
||||
this.dirtyHi = hi;
|
||||
} else {
|
||||
if (lo < this.dirtyLo) this.dirtyLo = lo;
|
||||
if (hi > this.dirtyHi) this.dirtyHi = hi;
|
||||
}
|
||||
}
|
||||
|
||||
setBits(ranges: IndexRanges, mask: number): void {
|
||||
const { data } = this;
|
||||
let lo = Infinity;
|
||||
let hi = -1;
|
||||
ranges.forEach((i) => {
|
||||
data[i] |= mask;
|
||||
if (i < lo) lo = i;
|
||||
if (i >= hi) hi = i + 1;
|
||||
});
|
||||
if (hi > 0) this.markDirty(lo, hi);
|
||||
}
|
||||
|
||||
clearBits(ranges: IndexRanges, mask: number): void {
|
||||
const { data } = this;
|
||||
let lo = Infinity;
|
||||
let hi = -1;
|
||||
ranges.forEach((i) => {
|
||||
data[i] &= ~mask;
|
||||
if (i < lo) lo = i;
|
||||
if (i >= hi) hi = i + 1;
|
||||
});
|
||||
if (hi > 0) this.markDirty(lo, hi);
|
||||
}
|
||||
|
||||
toggleBits(ranges: IndexRanges, mask: number): void {
|
||||
const { data } = this;
|
||||
let lo = Infinity;
|
||||
let hi = -1;
|
||||
ranges.forEach((i) => {
|
||||
data[i] ^= mask;
|
||||
if (i < lo) lo = i;
|
||||
if (i >= hi) hi = i + 1;
|
||||
});
|
||||
if (hi > 0) this.markDirty(lo, hi);
|
||||
}
|
||||
|
||||
// recount selected/locked/deleted from scratch. cheap relative to a GPU
|
||||
// readback (single CPU pass over numSplats bytes) and only triggered from
|
||||
// flush, so the same call that uploads to GPU also refreshes counts.
|
||||
private recount() {
|
||||
const { data } = this;
|
||||
let numSelected = 0;
|
||||
let numLocked = 0;
|
||||
let numDeleted = 0;
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
const s = data[i];
|
||||
if (s & State.deleted) {
|
||||
numDeleted++;
|
||||
} else if (s & State.locked) {
|
||||
numLocked++;
|
||||
} else if (s & State.selected) {
|
||||
numSelected++;
|
||||
}
|
||||
}
|
||||
this.numSelected = numSelected;
|
||||
this.numLocked = numLocked;
|
||||
this.numDeleted = numDeleted;
|
||||
}
|
||||
|
||||
// upload dirty bytes to the GPU texture and refresh cached counts.
|
||||
// idempotent and cheap when nothing is dirty.
|
||||
flush(): void {
|
||||
if (this.dirtyLo < 0) return;
|
||||
// full upload. sub-rect upload is a worthwhile future optimisation
|
||||
// (would drop a 4M-byte upload to a few KB for small selections) but
|
||||
// requires engine-side support; current path keeps the same behaviour
|
||||
// as the prior `updateState` lock/set/unlock pair.
|
||||
const buffer = this.gpu.lock() as Uint8Array;
|
||||
buffer.set(this.data);
|
||||
this.gpu.unlock();
|
||||
this.recount();
|
||||
this.dirtyLo = -1;
|
||||
this.dirtyHi = -1;
|
||||
}
|
||||
}
|
||||
|
||||
export { State, SplatState };
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
import {
|
||||
ADDRESS_CLAMP_TO_EDGE,
|
||||
FILTER_NEAREST,
|
||||
PIXELFORMAT_R8,
|
||||
PIXELFORMAT_R16U,
|
||||
Asset,
|
||||
BoundingBox,
|
||||
Color,
|
||||
Entity,
|
||||
GSplatData,
|
||||
GSplatResource,
|
||||
Mat4,
|
||||
Quat,
|
||||
Texture,
|
||||
Vec3
|
||||
} from 'playcanvas';
|
||||
|
||||
import { Element, ElementType } from './element';
|
||||
import { Serializer } from './serializer';
|
||||
import { vertexShader, fragmentShader, gsplatCenter } from './shaders/splat-shader';
|
||||
import { State, SplatState } from './splat-state';
|
||||
import { Transform } from './transform';
|
||||
import { TransformPalette } from './transform-palette';
|
||||
|
||||
const vec = new Vec3();
|
||||
const veca = new Vec3();
|
||||
const vecb = new Vec3();
|
||||
|
||||
const boundingPoints =
|
||||
[-1, 1].map((x) => {
|
||||
return [-1, 1].map((y) => {
|
||||
return [-1, 1].map((z) => {
|
||||
return [
|
||||
new Vec3(x, y, z), new Vec3(x * 0.75, y, z),
|
||||
new Vec3(x, y, z), new Vec3(x, y * 0.75, z),
|
||||
new Vec3(x, y, z), new Vec3(x, y, z * 0.75)
|
||||
];
|
||||
});
|
||||
});
|
||||
}).flat(3);
|
||||
|
||||
class Splat extends Element {
|
||||
asset: Asset;
|
||||
splatData: GSplatData;
|
||||
numSplats = 0;
|
||||
numDeleted = 0;
|
||||
numLocked = 0;
|
||||
numSelected = 0;
|
||||
entity: Entity;
|
||||
changedCounter = 0;
|
||||
stateTexture: Texture;
|
||||
// encapsulates per-splat state mirror (cpu Uint8Array + gpu Texture).
|
||||
// all writes go through state.setBits/clearBits/toggleBits, then flush().
|
||||
state: SplatState;
|
||||
transformTexture: Texture;
|
||||
selectionBoundStorage: BoundingBox;
|
||||
localBoundStorage: BoundingBox;
|
||||
worldBoundStorage: BoundingBox;
|
||||
|
||||
_visible = true;
|
||||
transformPalette: TransformPalette;
|
||||
|
||||
selectionAlpha = 1;
|
||||
|
||||
_name = '';
|
||||
_tintClr = new Color(1, 1, 1);
|
||||
_temperature = 0;
|
||||
_saturation = 1;
|
||||
_brightness = 0;
|
||||
_blackPoint = 0;
|
||||
_whitePoint = 1;
|
||||
_transparency = 1;
|
||||
|
||||
measurePoints: Vec3[] = [];
|
||||
measureSelection = -1;
|
||||
|
||||
rebuildMaterial: (bands: number) => void;
|
||||
|
||||
constructor(asset: Asset, rotation: Quat) {
|
||||
super(ElementType.splat);
|
||||
|
||||
const { device } = asset.resource as GSplatResource;
|
||||
|
||||
// create the entity once. its transform persists across frame swaps so
|
||||
// an animated sequence can replace its data without losing the user's
|
||||
// transform (see replaceData).
|
||||
this.entity = new Entity('splatEntity');
|
||||
|
||||
this.selectionBoundStorage = new BoundingBox();
|
||||
|
||||
// create the transform palette (reused across frame swaps; index 0 is identity)
|
||||
this.transformPalette = new TransformPalette(device);
|
||||
|
||||
// rebuilds material chunks/params. reads the *current* gsplat instance and
|
||||
// state/transform textures so it remains valid after a replaceData swap
|
||||
// (the 'view.bands' listener registered in add() keeps pointing at it).
|
||||
this.rebuildMaterial = (bands: number) => {
|
||||
const instance = this.entity.gsplat.instance;
|
||||
const { material } = instance;
|
||||
const { glsl } = material.shaderChunks;
|
||||
glsl.set('gsplatVS', vertexShader);
|
||||
glsl.set('gsplatPS', fragmentShader);
|
||||
glsl.set('gsplatCenterVS', gsplatCenter);
|
||||
|
||||
material.setDefine('SH_BANDS', `${Math.min(bands, (instance.resource as GSplatResource).shBands)}`);
|
||||
material.setParameter('splatState', this.stateTexture);
|
||||
material.setParameter('splatTransform', this.transformTexture);
|
||||
material.update();
|
||||
};
|
||||
|
||||
// bind the initial frame's data, applying the file's load rotation
|
||||
this.bindAsset(asset, rotation);
|
||||
}
|
||||
|
||||
// bind a gsplat asset onto this element's entity: creates the gsplat
|
||||
// component, the per-splat state/transform channels and their gpu textures,
|
||||
// and caches the instance bounds. When `rotation` is supplied (initial load)
|
||||
// the entity rotation is set; on a frame swap it is omitted so the user's
|
||||
// transform is preserved.
|
||||
private bindAsset(asset: Asset, rotation?: Quat) {
|
||||
const splatResource = asset.resource as GSplatResource;
|
||||
const splatData = splatResource.gsplatData as GSplatData;
|
||||
const { device } = splatResource;
|
||||
|
||||
this.asset = asset;
|
||||
this.splatData = splatData;
|
||||
this.numSplats = splatData.numSplats;
|
||||
|
||||
// name and orientation are set on the initial bind only; a frame swap
|
||||
// (replaceData, no rotation) keeps the element's name and transform
|
||||
if (rotation) {
|
||||
this._name = (asset.file as any).filename;
|
||||
this.entity.setLocalRotation(rotation);
|
||||
}
|
||||
|
||||
this.entity.addComponent('gsplat', {
|
||||
asset,
|
||||
unified: false
|
||||
});
|
||||
|
||||
const instance = this.entity.gsplat.instance;
|
||||
|
||||
// added per-splat state channel
|
||||
// bit 1: selected
|
||||
// bit 2: deleted
|
||||
// bit 3: locked
|
||||
if (!splatData.getProp('state')) {
|
||||
splatData.getElement('vertex').properties.push({
|
||||
type: 'uchar',
|
||||
name: 'state',
|
||||
storage: new Uint8Array(splatData.numSplats),
|
||||
byteSize: 1
|
||||
});
|
||||
}
|
||||
|
||||
// per-splat transform matrix
|
||||
splatData.getElement('vertex').properties.push({
|
||||
type: 'ushort',
|
||||
name: 'transform',
|
||||
storage: new Uint16Array(splatData.numSplats),
|
||||
byteSize: 2
|
||||
});
|
||||
|
||||
const { x: width, y: height } = (splatResource as any).textureDimensions;
|
||||
|
||||
// pack spherical harmonic data
|
||||
const createTexture = (name: string, format: number) => {
|
||||
return new Texture(device, {
|
||||
name: name,
|
||||
width: width,
|
||||
height: height,
|
||||
format: format,
|
||||
mipmaps: false,
|
||||
minFilter: FILTER_NEAREST,
|
||||
magFilter: FILTER_NEAREST,
|
||||
addressU: ADDRESS_CLAMP_TO_EDGE,
|
||||
addressV: ADDRESS_CLAMP_TO_EDGE
|
||||
});
|
||||
};
|
||||
|
||||
// create the state texture and the SplatState mirror that owns it.
|
||||
// splatData.getProp('state') aliases state.data so existing read-only
|
||||
// consumers (serialize, status-bar, etc) keep working unchanged.
|
||||
this.stateTexture = createTexture('splatState', PIXELFORMAT_R8);
|
||||
this.state = new SplatState(splatData.getProp('state') as Uint8Array, this.stateTexture);
|
||||
this.transformTexture = createTexture('splatTransform', PIXELFORMAT_R16U);
|
||||
|
||||
this.localBoundStorage = instance.resource.aabb;
|
||||
// @ts-ignore
|
||||
this.worldBoundStorage = instance.meshInstance._aabb;
|
||||
|
||||
// @ts-ignore
|
||||
instance.meshInstance._updateAabb = false;
|
||||
|
||||
// when sort changes, re-render the scene
|
||||
instance.sorter.on('updated', () => {
|
||||
this.changedCounter++;
|
||||
});
|
||||
}
|
||||
|
||||
// wait for the next scene render to complete, with a safety timeout so a
|
||||
// stalled render loop (e.g. a backgrounded tab where rAF is paused) can't
|
||||
// block frame swapping forever. In a live app postrender fires within a
|
||||
// frame, so the timeout never matters.
|
||||
private waitForRender(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// single finish() removes the listener and clears the timeout, so the
|
||||
// common case (postrender fires first) doesn't leave a pending timer.
|
||||
const handles: { off?: { off: () => void }, timer?: ReturnType<typeof setTimeout> } = {};
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
handles.off?.off();
|
||||
clearTimeout(handles.timer);
|
||||
resolve();
|
||||
};
|
||||
handles.off = this.scene.events.on('postrender', finish);
|
||||
// safety: don't block frame swapping forever if the render loop is stalled
|
||||
handles.timer = setTimeout(finish, 200);
|
||||
});
|
||||
}
|
||||
|
||||
// swap in a new frame's gsplat data while preserving this element's identity,
|
||||
// transform and visual properties. used by animated sequence playback so each
|
||||
// frame doesn't recreate the whole element.
|
||||
//
|
||||
// The gsplat lives on this.entity (read in many places), so we can't double
|
||||
// buffer on a child. Instead we bind the new frame to a *fresh* entity, sort
|
||||
// it, and let it render once alongside the still-present old entity before
|
||||
// destroying the old one. This overlap avoids a blank/unsorted frame
|
||||
// flickering on screen during the swap (the old frame masks the new one's
|
||||
// first sort), matching the previous per-frame load behaviour. The user's
|
||||
// transform is carried across so it persists.
|
||||
async replaceData(asset: Asset) {
|
||||
const oldEntity = this.entity;
|
||||
const oldAsset = this.asset;
|
||||
const oldStateTexture = this.stateTexture;
|
||||
const oldTransformTexture = this.transformTexture;
|
||||
|
||||
// carry the current transform onto the new entity
|
||||
const position = oldEntity.getLocalPosition().clone();
|
||||
const rotation = oldEntity.getLocalRotation().clone();
|
||||
const scale = oldEntity.getLocalScale().clone();
|
||||
|
||||
this.entity = new Entity('splatEntity');
|
||||
this.entity.setLocalPosition(position);
|
||||
this.entity.setLocalRotation(rotation);
|
||||
this.entity.setLocalScale(scale);
|
||||
|
||||
// bind the new frame (no rotation: transform already applied above)
|
||||
this.bindAsset(asset);
|
||||
|
||||
// add the new entity to the scene and configure its instance
|
||||
this.scene.contentRoot.addChild(this.entity);
|
||||
this.entity.gsplat.layers = [this.scene.splatLayer.id];
|
||||
this.rebuildMaterial(this.scene.events.invoke('view.bands'));
|
||||
|
||||
// refresh gpu state/counts/bounds, then wait for the new frame to render
|
||||
// before removing the old entity, which keeps the previous frame on screen
|
||||
// in the meantime. Skip the wait during offline video render
|
||||
// (lockedRenderMode): renders are gated on scene.lockedRender there, so
|
||||
// blocking on a render would deadlock — and the render loop sorts+captures
|
||||
// each frame deterministically anyway.
|
||||
await this.updateState(State.deleted);
|
||||
if (!this.scene.lockedRenderMode) {
|
||||
await this.waitForRender();
|
||||
}
|
||||
|
||||
// notify dependents (e.g. the centers overlay, which parents itself under
|
||||
// this.entity) to re-bind to the new entity/instance before the old entity
|
||||
// is destroyed — otherwise they're torn down with it and never re-attach
|
||||
// (no selection.changed fires on a frame swap).
|
||||
this.scene.events.fire('splat.replaced', this);
|
||||
|
||||
// tear down the previous frame
|
||||
oldEntity.destroy();
|
||||
oldStateTexture.destroy();
|
||||
oldTransformTexture.destroy();
|
||||
oldAsset.registry?.remove(oldAsset);
|
||||
oldAsset.unload();
|
||||
|
||||
this.changedCounter++;
|
||||
this.scene.forceRender = true;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.entity.destroy();
|
||||
this.asset.registry.remove(this.asset);
|
||||
this.asset.unload();
|
||||
}
|
||||
|
||||
async updateState(changedState = State.selected) {
|
||||
// uploads dirty range + refreshes counts in one pass.
|
||||
this.state.flush();
|
||||
this.numSplats = this.state.data.length - this.state.numDeleted;
|
||||
this.numLocked = this.state.numLocked;
|
||||
this.numSelected = this.state.numSelected;
|
||||
this.numDeleted = this.state.numDeleted;
|
||||
|
||||
// handle splats being added or removed
|
||||
if (changedState & State.deleted) {
|
||||
await this.updateSorting();
|
||||
} else {
|
||||
await this.updateLocalBounds();
|
||||
}
|
||||
|
||||
this.scene.forceRender = true;
|
||||
this.scene.events.fire('splat.stateChanged', this);
|
||||
}
|
||||
|
||||
async updatePositions() {
|
||||
const data = await this.scene.dataProcessor.calcPositions(this);
|
||||
|
||||
// update the splat centers which are used for render-time sorting
|
||||
const state = this.splatData.getProp('state') as Uint8Array;
|
||||
const { sorter } = this.entity.gsplat.instance;
|
||||
const { centers } = sorter;
|
||||
for (let i = 0; i < this.splatData.numSplats; ++i) {
|
||||
if (state[i] === State.selected) {
|
||||
centers[i * 3 + 0] = data[i * 4];
|
||||
centers[i * 3 + 1] = data[i * 4 + 1];
|
||||
centers[i * 3 + 2] = data[i * 4 + 2];
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateSorting();
|
||||
|
||||
this.scene.forceRender = true;
|
||||
this.scene.events.fire('splat.positionsChanged', this);
|
||||
}
|
||||
|
||||
async updateSorting() {
|
||||
const state = this.splatData.getProp('state') as Uint8Array;
|
||||
|
||||
let mapping;
|
||||
|
||||
// create a sorter mapping to remove deleted splats
|
||||
if (this.numSplats !== state.length) {
|
||||
mapping = new Uint32Array(this.numSplats);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < state.length; ++i) {
|
||||
if ((state[i] & State.deleted) === 0) {
|
||||
mapping[idx++] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update sorting instance
|
||||
this.entity.gsplat.instance.sorter.setMapping(mapping);
|
||||
|
||||
// recalculate bounds after sorting changes
|
||||
await this.updateLocalBounds();
|
||||
}
|
||||
|
||||
get worldTransform() {
|
||||
return this.entity.getWorldTransform();
|
||||
}
|
||||
|
||||
set name(newName: string) {
|
||||
if (newName !== this.name) {
|
||||
this._name = newName;
|
||||
this.scene.events.fire('splat.name', this);
|
||||
}
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get filename() {
|
||||
return (this.asset.file as any).filename;
|
||||
}
|
||||
|
||||
calcSplatWorldPosition(splatId: number, result: Vec3) {
|
||||
if (splatId >= this.splatData.numSplats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// use centers data, which are updated when edits occur
|
||||
const { sorter } = this.entity.gsplat.instance;
|
||||
const { centers } = sorter;
|
||||
|
||||
result.set(
|
||||
centers[splatId * 3 + 0],
|
||||
centers[splatId * 3 + 1],
|
||||
centers[splatId * 3 + 2]
|
||||
);
|
||||
|
||||
this.worldTransform.transformPoint(result, result);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async add() {
|
||||
// add the entity to the scene
|
||||
this.scene.contentRoot.addChild(this.entity);
|
||||
|
||||
// assign splat to the dedicated splat layer (rendered by splat camera with MRT)
|
||||
this.entity.gsplat.layers = [this.scene.splatLayer.id];
|
||||
|
||||
this.scene.events.on('view.bands', this.rebuildMaterial, this);
|
||||
this.rebuildMaterial(this.scene.events.invoke('view.bands'));
|
||||
|
||||
// we must update state in case the state data was loaded from ply
|
||||
await this.updateState();
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.scene.events.off('view.bands', this.rebuildMaterial, this);
|
||||
|
||||
this.scene.contentRoot.removeChild(this.entity);
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
serialize(serializer: Serializer) {
|
||||
serializer.packa(this.entity.getWorldTransform().data);
|
||||
serializer.pack(this.changedCounter);
|
||||
serializer.pack(this.visible);
|
||||
serializer.pack(this.tintClr.r, this.tintClr.g, this.tintClr.b);
|
||||
serializer.pack(this.temperature, this.saturation, this.brightness, this.blackPoint, this.whitePoint, this.transparency);
|
||||
}
|
||||
|
||||
onPreRender() {
|
||||
const events = this.scene.events;
|
||||
const selected = this.scene.camera.renderOverlays && events.invoke('selection') === this;
|
||||
const cameraMode = events.invoke('camera.mode');
|
||||
const cameraOverlay = events.invoke('camera.overlay');
|
||||
|
||||
// configure rings rendering
|
||||
const material = this.entity.gsplat.instance.material;
|
||||
material.setParameter('outlineMode', events.invoke('view.outlineSelection') ? 1 : 0);
|
||||
material.setParameter('ringSize', (selected && cameraOverlay && cameraMode === 'rings') ? 0.04 : 0);
|
||||
|
||||
// configure colors
|
||||
const selectedClr = events.invoke('selectedClr');
|
||||
const unselectedClr = events.invoke('unselectedClr');
|
||||
const lockedClr = events.invoke('lockedClr');
|
||||
|
||||
if (!selected) {
|
||||
material.setParameter('selectedClr', [0, 0, 0, 0]);
|
||||
} else if (events.invoke('view.outlineSelection')) {
|
||||
material.setParameter('selectedClr', [0, 0, 0, 0]);
|
||||
} else {
|
||||
material.setParameter('selectedClr', [selectedClr.r, selectedClr.g, selectedClr.b, selectedClr.a * this.selectionAlpha]);
|
||||
}
|
||||
material.setParameter('unselectedClr', [unselectedClr.r, unselectedClr.g, unselectedClr.b, unselectedClr.a]);
|
||||
material.setParameter('lockedClr', [lockedClr.r, lockedClr.g, lockedClr.b, lockedClr.a]);
|
||||
|
||||
// combine black pointer, white point and brightness
|
||||
const offset = -this.blackPoint + this.brightness;
|
||||
const scale = 1 / (this.whitePoint - this.blackPoint);
|
||||
|
||||
material.setParameter('clrOffset', [offset, offset, offset]);
|
||||
material.setParameter('clrScale', [
|
||||
scale * this.tintClr.r * (1 + this.temperature),
|
||||
scale * this.tintClr.g,
|
||||
scale * this.tintClr.b * (1 - this.temperature),
|
||||
this.transparency
|
||||
]);
|
||||
|
||||
material.setParameter('saturation', this.saturation);
|
||||
material.setParameter('transformPalette', this.transformPalette.texture);
|
||||
|
||||
if (this.visible && selected) {
|
||||
// render bounding box
|
||||
if (events.invoke('camera.bound')) {
|
||||
const bound = this.localBound;
|
||||
const scale = new Mat4().setTRS(bound.center, Quat.IDENTITY, bound.halfExtents);
|
||||
scale.mul2(this.entity.getWorldTransform(), scale);
|
||||
|
||||
for (let i = 0; i < boundingPoints.length / 2; i++) {
|
||||
const a = boundingPoints[i * 2];
|
||||
const b = boundingPoints[i * 2 + 1];
|
||||
scale.transformPoint(a, veca);
|
||||
scale.transformPoint(b, vecb);
|
||||
|
||||
this.scene.app.drawLine(veca, vecb, Color.WHITE, true, this.scene.worldLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.entity.enabled = this.visible;
|
||||
}
|
||||
|
||||
focalPoint() {
|
||||
// GSplatData has a function for calculating an weighted average of the splat positions
|
||||
// to get a focal point for the camera, but we use bound center instead
|
||||
return this.worldBound.center;
|
||||
}
|
||||
|
||||
move(position?: Vec3, rotation?: Quat, scale?: Vec3) {
|
||||
const entity = this.entity;
|
||||
if (position) {
|
||||
entity.setLocalPosition(position);
|
||||
}
|
||||
if (rotation) {
|
||||
entity.setLocalRotation(rotation);
|
||||
}
|
||||
if (scale) {
|
||||
entity.setLocalScale(scale);
|
||||
}
|
||||
|
||||
this.updateWorldBound();
|
||||
|
||||
this.scene.events.fire('splat.moved', this);
|
||||
}
|
||||
|
||||
// calculate both selection and local bounds (async, callers must await)
|
||||
async updateLocalBounds(): Promise<void> {
|
||||
await this.scene.dataProcessor.calcBound(this, this.selectionBoundStorage, this.localBoundStorage);
|
||||
this.updateWorldBound();
|
||||
}
|
||||
|
||||
// update world bound from local bound (synchronous)
|
||||
private updateWorldBound() {
|
||||
this.worldBoundStorage.setFromTransformedAabb(this.localBoundStorage, this.entity.getWorldTransform());
|
||||
this.scene.boundDirty = true;
|
||||
}
|
||||
|
||||
// get the selection bound
|
||||
get selectionBound() {
|
||||
return this.selectionBoundStorage;
|
||||
}
|
||||
|
||||
// get local space bound
|
||||
get localBound() {
|
||||
return this.localBoundStorage;
|
||||
}
|
||||
|
||||
// get world space bound
|
||||
get worldBound() {
|
||||
return this.worldBoundStorage;
|
||||
}
|
||||
|
||||
set visible(value: boolean) {
|
||||
if (value !== this.visible) {
|
||||
this._visible = value;
|
||||
this.scene?.events.fire('splat.visibility', this);
|
||||
}
|
||||
}
|
||||
|
||||
get visible() {
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
set tintClr(value: Color) {
|
||||
if (!this._tintClr.equals(value)) {
|
||||
this._tintClr.set(value.r, value.g, value.b);
|
||||
this.scene.events.fire('splat.tintClr', this);
|
||||
}
|
||||
}
|
||||
|
||||
get tintClr() {
|
||||
return this._tintClr;
|
||||
}
|
||||
|
||||
set temperature(value: number) {
|
||||
if (value !== this._temperature) {
|
||||
this._temperature = value;
|
||||
this.scene.events.fire('splat.temperature', this);
|
||||
}
|
||||
}
|
||||
|
||||
get temperature() {
|
||||
return this._temperature;
|
||||
}
|
||||
|
||||
set saturation(value: number) {
|
||||
if (value !== this._saturation) {
|
||||
this._saturation = value;
|
||||
this.scene.events.fire('splat.saturation', this);
|
||||
}
|
||||
}
|
||||
|
||||
get saturation() {
|
||||
return this._saturation;
|
||||
}
|
||||
|
||||
set brightness(value: number) {
|
||||
if (value !== this._brightness) {
|
||||
this._brightness = value;
|
||||
this.scene.events.fire('splat.brightness', this);
|
||||
}
|
||||
}
|
||||
|
||||
get brightness() {
|
||||
return this._brightness;
|
||||
}
|
||||
|
||||
set blackPoint(value: number) {
|
||||
if (value !== this._blackPoint) {
|
||||
this._blackPoint = value;
|
||||
this.scene.events.fire('splat.blackPoint', this);
|
||||
}
|
||||
}
|
||||
|
||||
get blackPoint() {
|
||||
return this._blackPoint;
|
||||
}
|
||||
|
||||
set whitePoint(value: number) {
|
||||
if (value !== this._whitePoint) {
|
||||
this._whitePoint = value;
|
||||
this.scene.events.fire('splat.whitePoint', this);
|
||||
}
|
||||
}
|
||||
|
||||
get whitePoint() {
|
||||
return this._whitePoint;
|
||||
}
|
||||
|
||||
set transparency(value: number) {
|
||||
if (value !== this._transparency) {
|
||||
this._transparency = value;
|
||||
this.scene.events.fire('splat.transparency', this);
|
||||
}
|
||||
}
|
||||
|
||||
get transparency() {
|
||||
return this._transparency;
|
||||
}
|
||||
|
||||
// get pivot position/rotation/scale (caller should have awaited operation that changed data)
|
||||
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': {
|
||||
const bound = selection ? this.selectionBound : this.localBound;
|
||||
entity.getLocalTransform().transformPoint(bound.center, vec);
|
||||
result.set(vec, entity.getLocalRotation(), entity.getLocalScale());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
docSerialize() {
|
||||
const pack3 = (v: Vec3) => [v.x, v.y, v.z];
|
||||
const pack4 = (q: Quat) => [q.x, q.y, q.z, q.w];
|
||||
const packC = (c: Color) => [c.r, c.g, c.b, c.a];
|
||||
return {
|
||||
name: this.name,
|
||||
position: pack3(this.entity.getLocalPosition()),
|
||||
rotation: pack4(this.entity.getLocalRotation()),
|
||||
scale: pack3(this.entity.getLocalScale()),
|
||||
visible: this.visible,
|
||||
tintClr: packC(this.tintClr),
|
||||
temperature: this.temperature,
|
||||
saturation: this.saturation,
|
||||
brightness: this.brightness,
|
||||
blackPoint: this.blackPoint,
|
||||
whitePoint: this.whitePoint,
|
||||
transparency: this.transparency
|
||||
};
|
||||
}
|
||||
|
||||
docDeserialize(doc: any) {
|
||||
const { name, position, rotation, scale, visible, tintClr, temperature, saturation, brightness, blackPoint, whitePoint, transparency } = doc;
|
||||
|
||||
this.name = name;
|
||||
this.move(new Vec3(position), new Quat(rotation), new Vec3(scale));
|
||||
this.visible = visible;
|
||||
this.tintClr = new Color(tintClr[0], tintClr[1], tintClr[2], tintClr[3]);
|
||||
this.temperature = temperature ?? 0;
|
||||
this.saturation = saturation ?? 1;
|
||||
this.brightness = brightness;
|
||||
this.blackPoint = blackPoint;
|
||||
this.whitePoint = whitePoint;
|
||||
this.transparency = transparency;
|
||||
}
|
||||
}
|
||||
|
||||
export { Splat };
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Mat4, Vec3 } from 'playcanvas';
|
||||
|
||||
import { PlacePivotOp, SplatsTransformOp, MultiOp } from './edit-ops';
|
||||
import { Events } from './events';
|
||||
import { Pivot } from './pivot';
|
||||
import { Splat } from './splat';
|
||||
import { State } from './splat-state';
|
||||
import { Transform } from './transform';
|
||||
import { TransformHandler } from './transform-handler';
|
||||
|
||||
const mat = new Mat4();
|
||||
const mat2 = new Mat4();
|
||||
const transform = new Transform();
|
||||
|
||||
class SplatsTransformHandler implements TransformHandler {
|
||||
events: Events;
|
||||
splat: Splat;
|
||||
pivotStart = new Transform();
|
||||
localToPivot = new Mat4();
|
||||
worldToLocal = new Mat4();
|
||||
|
||||
transform = new Mat4();
|
||||
paletteMap = new Map<number, number>();
|
||||
|
||||
constructor(events: Events) {
|
||||
this.events = events;
|
||||
|
||||
events.on('pivot.started', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.start();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.moved', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.update(pivot.transform);
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.ended', (pivot: Pivot) => {
|
||||
if (this.splat) {
|
||||
this.end();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('selection.changed', (splat) => {
|
||||
if (this.splat && splat === this.splat) {
|
||||
this.placePivot();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('pivot.origin', (mode: 'center' | 'boundCenter') => {
|
||||
if (this.splat) {
|
||||
this.placePivot();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('camera.focalPointPicked', (details: { splat: Splat, position: Vec3 }) => {
|
||||
if (this.splat && ['move', 'rotate', 'scale'].includes(this.events.invoke('tool.active'))) {
|
||||
const pivot = events.invoke('pivot') as Pivot;
|
||||
const oldt = pivot.transform.clone();
|
||||
const newt = new Transform(details.position, pivot.transform.rotation, pivot.transform.scale);
|
||||
const op = new PlacePivotOp({ pivot, oldt, newt });
|
||||
events.fire('edit.add', op);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
placePivot() {
|
||||
const origin = this.events.invoke('pivot.origin');
|
||||
this.splat.getPivot(origin === 'center' ? 'center' : 'boundCenter', true, transform);
|
||||
this.events.invoke('pivot').place(transform);
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.splat = this.events.invoke('selection') as Splat;
|
||||
if (this.splat) {
|
||||
this.placePivot();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.splat = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
const pivot = this.events.invoke('pivot') as Pivot;
|
||||
const { transform } = pivot;
|
||||
const { splat } = this;
|
||||
const { transformPalette } = splat;
|
||||
|
||||
mat.setTRS(transform.position, transform.rotation, transform.scale);
|
||||
|
||||
// calculate local -> pivot transform
|
||||
this.localToPivot.invert(mat);
|
||||
this.localToPivot.mul2(this.localToPivot, splat.entity.getLocalTransform());
|
||||
|
||||
// calculate the world -> local transform
|
||||
this.worldToLocal.invert(splat.entity.getLocalTransform());
|
||||
|
||||
this.pivotStart.copy(transform);
|
||||
|
||||
// allocate a new transform for the current selection
|
||||
const state = splat.splatData.getProp('state') as Uint8Array;
|
||||
const indices = splat.transformTexture.lock() as Uint16Array;
|
||||
|
||||
const { paletteMap } = this;
|
||||
paletteMap.clear();
|
||||
|
||||
for (let i = 0; i < state.length; ++i) {
|
||||
if (state[i] === State.selected) {
|
||||
const oldIdx = indices[i];
|
||||
let newIdx;
|
||||
if (!paletteMap.has(oldIdx)) {
|
||||
newIdx = transformPalette.alloc();
|
||||
paletteMap.set(oldIdx, newIdx);
|
||||
} else {
|
||||
newIdx = paletteMap.get(oldIdx);
|
||||
}
|
||||
|
||||
indices[i] = newIdx;
|
||||
}
|
||||
}
|
||||
|
||||
splat.transformTexture.unlock();
|
||||
|
||||
// initialize transforms
|
||||
this.paletteMap.forEach((newIdx, oldIdx) => {
|
||||
transformPalette.getTransform(oldIdx, mat);
|
||||
transformPalette.setTransform(newIdx, mat);
|
||||
});
|
||||
|
||||
splat.selectionAlpha = 0;
|
||||
splat.scene.outline.enabled = false;
|
||||
splat.scene.underlay.enabled = false;
|
||||
}
|
||||
|
||||
update(transform: Transform) {
|
||||
// calculate updated new pivot -> world transform
|
||||
mat.setTRS(transform.position, transform.rotation, transform.scale);
|
||||
mat.mul2(mat, this.localToPivot); // local -> world
|
||||
mat.mul2(this.worldToLocal, mat); // world -> local
|
||||
|
||||
this.transform.copy(mat);
|
||||
|
||||
// update the transform palette
|
||||
const { transformPalette } = this.splat;
|
||||
this.paletteMap.forEach((newIdx, oldIdx) => {
|
||||
transformPalette.getTransform(oldIdx, mat2);
|
||||
mat2.mul2(mat, mat2);
|
||||
transformPalette.setTransform(newIdx, mat2);
|
||||
});
|
||||
|
||||
// route through the shared queue so overlapping drag ticks don't race
|
||||
// on CalcBound's shared render targets / readback buffers. fire-and-
|
||||
// forget is fine: the final bound is recomputed when end() awaits
|
||||
// updatePositions -> updateSorting -> updateLocalBounds.
|
||||
this.events.invoke('queue', () => this.splat.updateLocalBounds());
|
||||
}
|
||||
|
||||
async end() {
|
||||
const { splat, transform, paletteMap } = this;
|
||||
|
||||
// create op for splat transform (already applied to GPU during update())
|
||||
const top = new SplatsTransformOp({
|
||||
splat,
|
||||
transform: transform.clone(),
|
||||
paletteMap: new Map(paletteMap)
|
||||
});
|
||||
|
||||
// create op for pivot placement
|
||||
const pivot = this.events.invoke('pivot') as Pivot;
|
||||
const oldt = this.pivotStart.clone();
|
||||
const newt = pivot.transform.clone();
|
||||
const pop = new PlacePivotOp({ pivot, newt, oldt });
|
||||
|
||||
// record the editop on the shared command queue BEFORE awaiting any async work.
|
||||
// events.fire synchronously enqueues the add, so any subsequent undo/redo
|
||||
// (e.g. user pressing Ctrl+Z while updatePositions is still resolving) is
|
||||
// guaranteed to land AFTER this op on the queue — which means the undo will
|
||||
// revert this transform operation rather than the prior selection op.
|
||||
this.events.fire('edit.add', new MultiOp([top, pop]), true);
|
||||
|
||||
// enqueue the GPU readback onto the same shared queue so any subsequent
|
||||
// undo/redo waits for it to finish before mutating the sorter's centers buffer.
|
||||
// TODO: consider moving this to update() function above so splats are sorted correctly
|
||||
// for render during drag (which is slower).
|
||||
await this.events.invoke('queue', () => splat.updatePositions());
|
||||
|
||||
splat.selectionAlpha = 1;
|
||||
splat.scene.outline.enabled = true;
|
||||
splat.scene.underlay.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
export { SplatsTransformHandler };
|
||||
@@ -0,0 +1,59 @@
|
||||
import { version as appVersion } from '../package.json';
|
||||
|
||||
// export default null
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
|
||||
const cacheName = `superSplat-v${appVersion}`;
|
||||
|
||||
const cacheUrls = [
|
||||
'./',
|
||||
'./index.css',
|
||||
'./index.html',
|
||||
'./index.js',
|
||||
'./index.js.map',
|
||||
'./manifest.json',
|
||||
'./static/icons/logo-192.png',
|
||||
'./static/icons/logo-512.png',
|
||||
'./static/images/screenshot-narrow.jpg',
|
||||
'./static/images/screenshot-wide.jpg',
|
||||
'./static/lib/webp/webp.mjs',
|
||||
'./static/lib/webp/webp.wasm',
|
||||
'./static/locales/de.json',
|
||||
'./static/locales/en.json',
|
||||
'./static/locales/fr.json',
|
||||
'./static/locales/ja.json',
|
||||
'./static/locales/ko.json',
|
||||
'./static/locales/zh-CN.json'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log(`installing v${appVersion}`);
|
||||
|
||||
// create cache for current version
|
||||
event.waitUntil(
|
||||
caches.open(cacheName)
|
||||
.then((cache) => {
|
||||
cache.addAll(cacheUrls);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', () => {
|
||||
console.log(`activating v${appVersion}`);
|
||||
|
||||
// delete the old caches once this one is activated
|
||||
caches.keys().then((names) => {
|
||||
for (const name of names) {
|
||||
if (name !== cacheName) {
|
||||
caches.delete(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => response ?? fetch(event.request))
|
||||
);
|
||||
});
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { EventHandle } from 'playcanvas';
|
||||
|
||||
import { Events } from './events';
|
||||
|
||||
/**
|
||||
* Register global timeline events.
|
||||
* The timeline manages playback state (frames, frameRate, current frame, playing).
|
||||
* Key management is delegated to individual animation tracks via track.* events.
|
||||
*/
|
||||
const registerTimelineEvents = (events: Events) => {
|
||||
let frames = 180;
|
||||
let frameRate = 30;
|
||||
let smoothness = 1;
|
||||
let loop = true;
|
||||
|
||||
// frames
|
||||
|
||||
const setFrames = (value: number) => {
|
||||
if (value !== frames) {
|
||||
frames = value;
|
||||
events.fire('timeline.frames', frames);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.frames', () => {
|
||||
return frames;
|
||||
});
|
||||
|
||||
events.on('timeline.setFrames', (value: number) => {
|
||||
setFrames(value);
|
||||
});
|
||||
|
||||
// frame rate
|
||||
|
||||
const setFrameRate = (value: number) => {
|
||||
if (value !== frameRate) {
|
||||
frameRate = value;
|
||||
events.fire('timeline.frameRate', frameRate);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.frameRate', () => {
|
||||
return frameRate;
|
||||
});
|
||||
|
||||
events.on('timeline.setFrameRate', (value: number) => {
|
||||
setFrameRate(value);
|
||||
});
|
||||
|
||||
// smoothness
|
||||
|
||||
const setSmoothness = (value: number) => {
|
||||
if (value !== smoothness) {
|
||||
smoothness = value;
|
||||
events.fire('timeline.smoothness', smoothness);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.smoothness', () => {
|
||||
return smoothness;
|
||||
});
|
||||
|
||||
events.on('timeline.setSmoothness', (value: number) => {
|
||||
setSmoothness(value);
|
||||
});
|
||||
|
||||
// loop
|
||||
|
||||
const setLoop = (value: boolean) => {
|
||||
if (value !== loop) {
|
||||
loop = value;
|
||||
events.fire('timeline.loop', loop);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.loop', () => {
|
||||
return loop;
|
||||
});
|
||||
|
||||
events.on('timeline.setLoop', (value: boolean) => {
|
||||
setLoop(value);
|
||||
});
|
||||
|
||||
// current frame
|
||||
let frame = 0;
|
||||
|
||||
const setFrame = (value: number) => {
|
||||
if (value !== frame) {
|
||||
frame = value;
|
||||
events.fire('timeline.frame', frame);
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.frame', () => {
|
||||
return frame;
|
||||
});
|
||||
|
||||
events.on('timeline.setFrame', (value: number) => {
|
||||
setFrame(value);
|
||||
});
|
||||
|
||||
// anim controls
|
||||
let animHandle: EventHandle = null;
|
||||
|
||||
const play = () => {
|
||||
let time = frame;
|
||||
|
||||
// handle application update tick
|
||||
animHandle = events.on('update', (dt: number) => {
|
||||
time = (time + dt * frameRate) % frames;
|
||||
setFrame(Math.floor(time));
|
||||
events.fire('timeline.time', time);
|
||||
});
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
animHandle.off();
|
||||
animHandle = null;
|
||||
};
|
||||
|
||||
// playing state
|
||||
let playing = false;
|
||||
|
||||
const setPlaying = (value: boolean) => {
|
||||
if (value !== playing) {
|
||||
playing = value;
|
||||
events.fire('timeline.playing', playing);
|
||||
if (playing) {
|
||||
play();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
events.function('timeline.playing', () => {
|
||||
return playing;
|
||||
});
|
||||
|
||||
events.on('timeline.setPlaying', (value: boolean) => {
|
||||
setPlaying(value);
|
||||
});
|
||||
|
||||
// shortcut handlers
|
||||
events.on('timeline.togglePlay', () => {
|
||||
setPlaying(!playing);
|
||||
});
|
||||
|
||||
events.on('timeline.prevFrame', () => {
|
||||
setFrame((frame - 1 + frames) % frames);
|
||||
});
|
||||
|
||||
events.on('timeline.nextFrame', () => {
|
||||
setFrame((frame + 1) % frames);
|
||||
});
|
||||
|
||||
// Key navigation - delegates to active track's keys
|
||||
const skipToKey = (dir: 'forward' | 'back') => {
|
||||
const keys = events.invoke('track.keys') as number[] ?? [];
|
||||
|
||||
if (keys.length > 0) {
|
||||
const orderedKeys = keys.slice().sort((a, b) => a - b);
|
||||
const l = orderedKeys.length;
|
||||
|
||||
const nextKeyIndex = orderedKeys.findIndex(k => (dir === 'back' ? k >= frame : k > frame));
|
||||
|
||||
if (nextKeyIndex === -1) {
|
||||
setFrame(orderedKeys[dir === 'back' ? l - 1 : 0]);
|
||||
} else {
|
||||
setFrame(orderedKeys[dir === 'back' ? (nextKeyIndex + l - 1) % l : nextKeyIndex]);
|
||||
}
|
||||
} else {
|
||||
setFrame(dir === 'back' ? 0 : frames - 1);
|
||||
}
|
||||
};
|
||||
|
||||
events.on('timeline.prevKey', () => {
|
||||
skipToKey('back');
|
||||
});
|
||||
|
||||
events.on('timeline.nextKey', () => {
|
||||
skipToKey('forward');
|
||||
});
|
||||
|
||||
// clear timeline state when scene is cleared
|
||||
events.on('scene.clear', () => {
|
||||
events.fire('timeline.frames', frames);
|
||||
});
|
||||
|
||||
// Serialization - only global state, keys are owned by tracks
|
||||
|
||||
events.function('docSerialize.timeline', () => {
|
||||
return {
|
||||
frames,
|
||||
frameRate,
|
||||
frame,
|
||||
smoothness,
|
||||
loop
|
||||
};
|
||||
});
|
||||
|
||||
events.function('docDeserialize.timeline', (data: any = {}) => {
|
||||
// Set values
|
||||
frames = data.frames ?? 180;
|
||||
frameRate = data.frameRate ?? 30;
|
||||
frame = data.frame ?? 0;
|
||||
smoothness = data.smoothness ?? 1;
|
||||
loop = data.loop ?? true;
|
||||
|
||||
// Fire events to update UI (always fire to ensure rebuild)
|
||||
events.fire('timeline.frames', frames);
|
||||
events.fire('timeline.frameRate', frameRate);
|
||||
events.fire('timeline.frame', frame);
|
||||
events.fire('timeline.smoothness', smoothness);
|
||||
events.fire('timeline.loop', loop);
|
||||
});
|
||||
};
|
||||
|
||||
export { registerTimelineEvents };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user