mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# FIX CRLF always when developer has not set
|
||||
# Linux/Mac: git config --global core.autocrlf input
|
||||
# Windows: git config --global core.autocrlf true
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
* eol=lf
|
||||
|
||||
*.html text
|
||||
*.glsl text
|
||||
*.js text
|
||||
*.md text
|
||||
*.ts text
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Forge CI Linux
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
name: forge
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['22.x']
|
||||
steps:
|
||||
- name: Use Node.js ${{ matrix['node-version'] }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix['node-version'] }}
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Tests
|
||||
run: npm run test
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Forge CI Windows
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
name: forge
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['22.x']
|
||||
steps:
|
||||
- name: Use Node.js ${{ matrix['node-version'] }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix['node-version'] }}
|
||||
|
||||
- name: Set git to use LF
|
||||
run: |
|
||||
git config --global core.autocrlf false
|
||||
git config --global core.eol lf
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Tests
|
||||
run: npm run test
|
||||
@@ -0,0 +1,8 @@
|
||||
*.DS_Store
|
||||
examples/assets
|
||||
examples/js/vendor
|
||||
examples/showcase/assets
|
||||
node_modules/
|
||||
site/
|
||||
*.zip
|
||||
*.gltf
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2025 WORLD LABS TECHNOLOGIES, INC.
|
||||
|
||||
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,196 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img alt="Forge" src="https://github.com/user-attachments/assets/adb2f0d7-df30-49e5-be7f-75c14f008735" width="auto" height="180">
|
||||
</picture>
|
||||
<br>
|
||||
<h3 align="center">An advanced 3D Gaussian Splatting renderer for THREE.js</h3>
|
||||
<div align="center">
|
||||
|
||||
[Features](#features) -
|
||||
[Getting Started](#getting-started) -
|
||||
<a href="https://www.forge.dev/">Documentation</a> -
|
||||
<a href="https://www.forge.dev/">FAQ</a>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/forge-gfx/forge/blob/main/LICENSE)
|
||||
[](https://www.npmjs.com/package/forge)
|
||||
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="https://www.forge.dev" target="_blank">
|
||||
<picture>
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Features
|
||||
|
||||
- Load multiple splats files simultaneously ([demo]())
|
||||
- Combine and composite splats with regular Meshes ([demo]())
|
||||
- Animation ([demo]())
|
||||
- Real Time Visual Effects ([demo]())
|
||||
- Multiple Camera Views ([demo]())
|
||||
- Procedurally generated splats ([demo]())
|
||||
|
||||
Check out all the [examples]()
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Copy code
|
||||
|
||||
Copy code below in an `index.html` file.
|
||||
|
||||
```html
|
||||
<style> body {margin: 0;} </style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/node_modules/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const butterfly = new SplatMesh({ url: "../assets/basic/butterfly.wlg"});
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, 0, -1);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Web Editor
|
||||
|
||||
Remix the [glitch starter template](https://glitch.com/edit/#!/forge-dev)
|
||||
|
||||
### Usage from CDN
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.174.0/three.module.js",
|
||||
"forge": "cdn/url/to/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Install with NPM
|
||||
|
||||
```shell
|
||||
npm install forge-dev
|
||||
```
|
||||
|
||||
## Develop and contribute to the project
|
||||
|
||||
The examples fetch the assets from an external URL. To work offline is possible to downloading the assets files locally with the following command:
|
||||
|
||||
```
|
||||
npm run assets:download
|
||||
```
|
||||
|
||||
Install [Rust](https://www.rust-lang.org/tools/install) if it's not already installed in your machine.
|
||||
|
||||
Next, build Forge by running:
|
||||
```
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
This will first build the Rust Wasm component (can be invoked via `npm run build:wasm`), then Forge itself (`npm run build`).
|
||||
|
||||
Once you've fetched the data and built Forge, you can run run the examples:
|
||||
```
|
||||
npm start
|
||||
```
|
||||
This will run a dev server by default at [http://localhost:8080/](http://localhost:8080/). Check the console log output to see if yours is served on a different port.
|
||||
|
||||
## Build troubleshooting
|
||||
|
||||
First try cleaning all the build files and re-building everything:
|
||||
```
|
||||
npm run clean
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
There's no versioning system for assets. If you need to re-download a specific file you can delete that asset file individually or download all assets from scratch:
|
||||
|
||||
```
|
||||
npm run assets:clean
|
||||
npm run assets:download
|
||||
```
|
||||
|
||||
## Ignore dist directory during development
|
||||
|
||||
To ignore the dist directory and prevent accidental commits and merge conflicts
|
||||
|
||||
```
|
||||
git update-index --assume-unchanged dist/*
|
||||
```
|
||||
|
||||
To revert and be able to commit into to the dist directory again:
|
||||
|
||||
```
|
||||
git update-index --no-assume-unchanged dist/*
|
||||
```
|
||||
|
||||
To liste ignored files in case of need to troubleshoot
|
||||
|
||||
```
|
||||
git ls-files -v | grep '^[a-z]' | cut -c3-
|
||||
```
|
||||
|
||||
## Build docs
|
||||
|
||||
Install [Mkdocs Material](https://squidfunk.github.io/mkdocs-material/)
|
||||
|
||||
```
|
||||
pip install mkdocs-material
|
||||
```
|
||||
|
||||
If you hit an `externally managed environment` error on macOS and if you installed python via `brew` try:
|
||||
|
||||
```
|
||||
brew install mkdocs-material
|
||||
```
|
||||
|
||||
Edit markdown in `/docs` directory
|
||||
|
||||
```
|
||||
mkdocs serve
|
||||
```
|
||||
|
||||
## Build Forge website
|
||||
|
||||
Build the static site and docs in a `site` directory.
|
||||
|
||||
```
|
||||
npm run site:build
|
||||
```
|
||||
|
||||
You can run any static server in the `site` directory but for convenience you can run
|
||||
|
||||
```
|
||||
npm run site:serve
|
||||
```
|
||||
|
||||
## Deploy Forge website
|
||||
|
||||
TO-DO
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"dist/*",
|
||||
"examples/showcase/hello-gsplat",
|
||||
"rust/target",
|
||||
"rust/forge-internal-rs/pkg",
|
||||
"src/vrButton.ts",
|
||||
"site"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+12072
File diff suppressed because one or more lines are too long
Vendored
+1086
File diff suppressed because one or more lines are too long
Vendored
+3398
File diff suppressed because it is too large
Load Diff
Vendored
+12055
File diff suppressed because one or more lines are too long
Vendored
+9610
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="501" height="94" viewBox="0 0 501 94" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.618225 2.26904H137.346L132.185 20.4418C88.172 20.805 78.5674 74.9667 120.859 79.8385C120.859 79.8385 120.859 79.8386 118.408 91.5049L20.8455 91.5634L20.8455 69.2102C20.8455 69.2102 58.0664 68.1922 51.5113 49.0215C44.9561 29.8507 13.0163 42.7018 0.618225 2.26904Z" fill="white"/>
|
||||
<path d="M230.17 1.91944L226.42 21.9194H203.42V38.9194H224.17L220.42 58.9194H203.42V91.9194H179.045V19.0444L172.17 1.91944H230.17Z" fill="white"/>
|
||||
<path d="M264.215 93.1694C256.048 93.1694 249.465 91.7944 244.465 89.0444C239.465 86.2111 235.798 81.4611 233.465 74.7944C231.215 68.0444 230.09 58.7944 230.09 47.0444C230.09 35.2944 231.215 26.0861 233.465 19.4194C235.798 12.6694 239.465 7.91944 244.465 5.16944C249.465 2.3361 256.048 0.919434 264.215 0.919434C272.381 0.919434 278.965 2.3361 283.965 5.16944C288.965 7.91944 292.59 12.6694 294.84 19.4194C297.173 26.0861 298.34 35.2944 298.34 47.0444C298.34 58.7944 297.173 68.0444 294.84 74.7944C292.59 81.4611 288.965 86.2111 283.965 89.0444C278.965 91.7944 272.381 93.1694 264.215 93.1694ZM272.09 46.7944C272.09 39.6278 271.84 34.1694 271.34 30.4194C270.923 26.5861 270.131 23.9611 268.965 22.5444C267.881 21.0444 266.298 20.2944 264.215 20.2944C262.215 20.2944 260.631 21.0444 259.465 22.5444C258.298 23.9611 257.465 26.5861 256.965 30.4194C256.548 34.1694 256.34 39.6278 256.34 46.7944C256.34 53.8778 256.548 59.3778 256.965 63.2944C257.465 67.2111 258.298 69.9611 259.465 71.5444C260.631 73.0444 262.215 73.7944 264.215 73.7944C266.298 73.7944 267.881 73.0444 268.965 71.5444C270.131 69.9611 270.923 67.2111 271.34 63.2944C271.84 59.3778 272.09 53.8778 272.09 46.7944Z" fill="white"/>
|
||||
<path d="M335.355 1.91944C342.689 1.91944 348.814 2.87777 353.73 4.79444C358.647 6.62777 362.314 9.75277 364.73 14.1694C367.23 18.5028 368.48 24.4611 368.48 32.0444C368.48 38.3778 367.522 43.5861 365.605 47.6694C363.689 51.6694 360.772 54.8778 356.855 57.2944L372.105 91.9194H344.98L335.105 62.6694H331.105V91.9194H306.73V19.0444L299.855 1.91944H335.355ZM331.105 45.9194C334.939 45.9194 337.772 44.9611 339.605 43.0444C341.439 41.1278 342.355 37.7528 342.355 32.9194C342.355 29.0861 342.022 26.2528 341.355 24.4194C340.689 22.5028 339.522 21.2111 337.855 20.5444C336.272 19.8778 334.022 19.5444 331.105 19.5444V45.9194Z" fill="white"/>
|
||||
<path d="M400.261 47.4194C400.261 53.5861 400.719 58.5028 401.636 62.1694C402.552 65.8361 404.094 68.5028 406.261 70.1694C408.511 71.8361 411.594 72.8361 415.511 73.1694V64.5444L408.636 47.4194H438.011V87.6694C435.344 89.4194 431.886 90.7944 427.636 91.7944C423.386 92.7111 418.511 93.1694 413.011 93.1694C405.011 93.1694 398.094 91.6694 392.261 88.6694C386.427 85.6694 381.927 80.8361 378.761 74.1694C375.594 67.4194 374.011 58.5444 374.011 47.5444C374.011 36.4611 375.719 27.5028 379.136 20.6694C382.552 13.8361 387.344 8.8361 393.511 5.66943C399.677 2.50277 406.844 0.919434 415.011 0.919434C417.927 0.919434 420.969 1.16943 424.136 1.66943C427.302 2.16943 430.552 2.75277 433.886 3.41944L431.636 23.9194C428.886 23.4194 426.511 23.0444 424.511 22.7944C422.594 22.4611 420.636 22.2944 418.636 22.2944C414.302 22.2944 410.761 23.0028 408.011 24.4194C405.344 25.8361 403.386 28.3778 402.136 32.0444C400.886 35.6278 400.261 40.7528 400.261 47.4194Z" fill="white"/>
|
||||
<path d="M439.87 1.91944H499.745L495.995 21.9194H471.12V35.6694H494.495L490.745 55.6694H471.12V71.9194H500.62L496.87 91.9194H446.745V19.0444L439.87 1.91944Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,152 @@
|
||||
# Controls
|
||||
|
||||
A program using `Forge` can use any camera control scheme they want that is compatible with Three.js and will typically manipulate a `THREE.Camera` object's transform. `Forge` ships with simple, intuitive controls for navigating 3D space that use the keyboard + mouse, game pad, or mobile multi-touch. To add these controls, you can add:
|
||||
|
||||
```typescript
|
||||
const controls = new ForgeControls({
|
||||
canvas: HTMLCanvasElement;
|
||||
});
|
||||
|
||||
renderer.setAnimationLoop((time) => {
|
||||
renderer.render(scene, camera);)
|
||||
controls.update(camera);
|
||||
});
|
||||
```
|
||||
|
||||
`ForgeControls` instantiates two classes `FpsMovement` and `PointerControls` that it updates internally. You can also instantiate and use these two classes separately:
|
||||
|
||||
## `class FpsMovement`
|
||||
|
||||
`FpsMovement` implements controls that will be familiar to anyone who plays First Person Shooters using keyboard + mouse or a gamepad. Creating a `FpsMovement` instance provides many parameters:
|
||||
|
||||
```typescript
|
||||
const fpsMovement = new FpsMovement({
|
||||
moveSpeed?: number;
|
||||
rollSpeed?: number;
|
||||
stickThreshold?: number;
|
||||
rotateSpeed?: number;
|
||||
keycodeMoveMapping?: { [key: string]: THREE.Vector3 };
|
||||
keycodeRotateMapping?: { [key: string]: THREE.Vector3 };
|
||||
gamepadMapping?: {
|
||||
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
|
||||
};
|
||||
capsMultiplier?: number;
|
||||
shiftMultiplier?: number;
|
||||
ctrlMultiplier?: number;
|
||||
xr?: THREE.WebXRManager;
|
||||
});
|
||||
```
|
||||
When gamepads are connected, `FpsMovement` will always use gamepad index 0 for twin-stick movement and rotation.
|
||||
|
||||
If `xr` is passed in, the WebXR controllers can be used as a split gamepad to control movement and rotation. (tested on Quest 3)
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `moveSpeed` | `1.0` | Base movement speed |
|
||||
| `rollSpeed` | `2.0` | Speed of roll rotation |
|
||||
| `stickThreshold` | `0.1` | Deadzone for gamepad analog sticks |
|
||||
| `rotateSpeed` | `2.0` | Speed of rotation when using gamepad or keys |
|
||||
| `keycodeMoveMapping` | `{...WASD_KEYCODE_MOVE, ...ARROW_KEYCODE_MOVE}` | Maps keyboard keys to movement directions |
|
||||
| `keycodeRotateMapping` | `{...QE_KEYCODE_ROTATE, ...ARROW_KEYCODE_ROTATE}` | Maps keyboard keys to rotation directions |
|
||||
| `gamepadMapping` | `{4: "rollLeft", 5: "rollRight", 6: "ctrl", 7: "shift"}` | Maps gamepad buttons to actions |
|
||||
| `capsMultiplier` | `10.0` | Speed multiplier when Caps Lock is active |
|
||||
| `shiftMultiplier` | `5.0` | Speed multiplier when Shift is held |
|
||||
| `ctrlMultiplier` | `0.2` | Speed multiplier when Ctrl is held |
|
||||
| `xr` | `undefined` | Optional WebXR manager for XR controller stick support
|
||||
|
||||
### `update(deltaTime, control)`
|
||||
|
||||
Call this method in your render loop with `control` set to the object to control (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime` in seconds since the last update.
|
||||
The update method handles:
|
||||
|
||||
- Processing keyboard input for movement and rotation
|
||||
- Processing gamepad input for movement and rotation
|
||||
- Applying speed multipliers based on modifier keys and gamepad buttons
|
||||
- Applying movement and rotation to the controlled object
|
||||
|
||||
|
||||
## `class PointerControls`
|
||||
|
||||
`PointerControls` implements pointer/mouse/touch controls on the canvas, for both desktop and mobile web applications. Creating a new control:
|
||||
```typescript
|
||||
const pointerControls = new PointerControls({
|
||||
canvas: HTMLCanvasElement;
|
||||
rotateSpeed?: number;
|
||||
slideSpeed?: number;
|
||||
scrollSpeed?: number;
|
||||
reverseRotate?: boolean;
|
||||
reverseSlide?: boolean;
|
||||
reverseSwipe?: boolean;
|
||||
reverseScroll?: boolean;
|
||||
moveInertia?: number;
|
||||
rotateInertia?: number;
|
||||
doublePress?: ({
|
||||
position,
|
||||
intervalMs,
|
||||
}: { position: THREE.Vector2; intervalMs: number }) => void;
|
||||
})
|
||||
```
|
||||
|
||||
### Require parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `canvas` | The HTML canvas element to attach pointer events to |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `rotateSpeed` | `0.002` | Speed of rotation when dragging |
|
||||
| `slideSpeed` | `0.006` | Speed of sliding when dragging with right button or two fingers |
|
||||
| `scrollSpeed` | `0.0015` | Speed of movement when using mouse wheel |
|
||||
| `reverseRotate` | `false` | Reverse the direction of rotation |
|
||||
| `reverseSlide` | `false` | Reverse the direction of sliding |
|
||||
| `reverseSwipe` | `false` | Reverse the direction of swipe gestures |
|
||||
| `reverseScroll` | `false` | Reverse the direction of scroll wheel movement |
|
||||
| `moveInertia` | `0.15` | Inertia factor for movement |
|
||||
| `rotateInertia` | `0.15` | Inertia factor for rotation |
|
||||
| `doublePress` | `undefined` | Callback function for double-press/double-tap events |
|
||||
|
||||
### `update(deltaTime, control)`
|
||||
|
||||
Call this method in your render loop with `control` set to the object to control (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime` in seconds since the last update.
|
||||
|
||||
The update method handles:
|
||||
|
||||
- Processing pointer/mouse/touch movements for rotation
|
||||
- Handling dual-press touch gestures for camera movement
|
||||
- Applying scroll wheel input
|
||||
- Calculating and applying inertia for smooth motion
|
||||
- Updating the camera position and orientation
|
||||
|
||||
|
||||
## Adding a simple GUI to configure controls
|
||||
|
||||
Add `lil-gui` to your package (`npm add lil-gui`) to provide a simple configurable GUI.
|
||||
|
||||
```typescript
|
||||
import GUI from "lil-gui";
|
||||
|
||||
const gui = new GUI({ title: "Settings + Controls" }).close();
|
||||
const controlOptions = {
|
||||
reversePointerFps: false,
|
||||
reversePointerPan: false,
|
||||
};
|
||||
gui
|
||||
.add(controlOptions, "reversePointerFps")
|
||||
.name("Reverse Pointer FPS")
|
||||
.onChange((value: boolean) => {
|
||||
pointerControls.reverseRotate = value;
|
||||
pointerControls.reverseScroll = value;
|
||||
});
|
||||
gui
|
||||
.add(controlOptions, "reversePointerPan")
|
||||
.name("Reverse Pointer Pan")
|
||||
.onChange((value: boolean) => {
|
||||
pointerControls.reverseSlide = value;
|
||||
pointerControls.reverseSwipe = value;
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# ForgeRenderer
|
||||
|
||||
## Adding to your `THREE.Scene`
|
||||
|
||||
Using Forge begins with creating a `ForgeRenderer` object and adding it to your `THREE.Scene`. You can add it anywhere in the scene, for example at the root:
|
||||
```typescript
|
||||
const forge = new ForgeRenderer({
|
||||
renderer: myThreeWebGlRenderer,
|
||||
});
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(forge);
|
||||
```
|
||||
|
||||
## Larger scenes
|
||||
|
||||
All scene Gsplats are accumulated by SplatAccumulator into a single global PackedSplats, whose coordinates are relative to the ForgeRenderer's origin. Gsplats that are far away from this origin may exhibit float16 quantization artifacts, so if you plan on moving the camera large distances you can instead add the renderer as a child of your `THREE.Camera`, ensuring that coordinates near the camera viewpoint have higher precision:
|
||||
```javascript
|
||||
const aspect = canvas.width / canvas.height;
|
||||
const camera = new THREE.PerspectiveCamera(75, aspect, 0.1, 1000);
|
||||
scene.add(camera);
|
||||
// Add ForgeRenderer as a child of camera to follow it
|
||||
camera.add(forge);
|
||||
```
|
||||
|
||||
## Creating a `ForgeRenderer`
|
||||
|
||||
```typescript
|
||||
const forge = new ForgeRenderer({
|
||||
renderer: THREE.WebGLRenderer;
|
||||
clock?: THREE.Clock;
|
||||
autoUpdate?: boolean;
|
||||
preUpdate?: boolean;
|
||||
originDistance?: number;
|
||||
maxStdDev?: number;
|
||||
enable2DGS?: boolean;
|
||||
preBlurAmount?: number;
|
||||
blurAmount?: number;
|
||||
falloff?: number;
|
||||
clipXY?: number;
|
||||
view?: ForgeViewpointOptions;
|
||||
});
|
||||
```
|
||||
### Required parameters
|
||||
| **Parameter** | Description |
|
||||
| ------------- | ----------- |
|
||||
| **renderer** | Pass in your `THREE.WebGLRenderer` instance so Forge can perform work outside the usual render loop. Should be created with `antialias: false` (default setting) as WebGL anti-aliasing doesn't improve Gaussian Splatting rendering and significantly reduces performance.
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| **Parameter** | Description |
|
||||
| ----------------- | ----------- |
|
||||
| **clock** | Pass in a `THREE.Clock` to synchronize time-based effects across different systems. Alternatively, you can set the `ForgeRenderer` properties `time` and `deltaTime` directly. (default: `new THREE.Clock`)
|
||||
| **autoUpdate** | Controls whether to check and automatically update Gsplat collection after each frame render. (default: `true`)
|
||||
| **preUpdate** | Controls whether to update the Gsplats before or after rendering. For WebXR this *must* be false in order to complete rendering as soon as possible. (default: `false`)
|
||||
| **originDistance** | Distance threshold for ForgeRenderer movement triggering a Gsplat update at the new origin. (default: `1.0`)
|
||||
| **maxStdDev** | Maximum standard deviations from the center to render Gaussians. Values `Math.sqrt(5)`..`Math.sqrt(8)` produce good results and can be tweaked for performance. (default: `Math.sqrt(8)`)
|
||||
| **enable2DGS** | Enable 2D Gaussian splatting rendering ability. When this mode is enabled, any `scale` x/y/z component that is exactly `0` (minimum quantized value) results in the other two non-0 axis being interpreted as an oriented 2D Gaussian Splat, rather instead of the usual projected 3DGS Z-slice. When reading PLY files, scale values less than e^-20 will be interpreted as `0`. (default: `true`)
|
||||
| **preBlurAmount** | Scalar value to add to 2D splat covariance diagonal, effectively blurring + enlarging splats. In scenes trained without the Gsplat anti-aliasing tweak this value was typically 0.3, but with anti-aliasing it is 0.0 (default: `0.0`)
|
||||
| **blurAmount** | Scalar value to add to 2D splat covarianve diagonal, with opacity adjustment to correctly account for "blurring" when anti-aliasing. Typically 0.3 (equivalent to approx 0.5 pixel radius) in scenes trained with anti-aliasing.
|
||||
| **falloff** | Modulate Gaussian kernel falloff. 0 means "no falloff, flat shading", while 1 is the normal Gaussian kernel. (default: `1.0`)
|
||||
| **clipXY** | X/Y clipping boundary factor for Gsplat centers against view frustum. 1.0 clips any centers that are exactly out of bounds, while 1.4 clips centers that are 40% beyond the bounds. (default: `1.4`)
|
||||
| **view** | Configures the `ForgeViewpointOptions` for the default `ForgeViewpoint` associated with this `ForgeRenderer`. Notable option: `sortRadial` (sort by radial distance or Z-depth)
|
||||
|
||||
## `newViewpoint(options: ForgeViewpointOptions)`
|
||||
|
||||
Create a new `ForgeViewpoint` for this `ForgeRenderer`. Note that every `ForgeRenderer` has an initial `forge.defaultView: ForgeViewpoint` created during construction, which is used for default canvas rendering. Calling this method allows you to create additional viewpoints, which can be updated automatically each frame (performing Gsplat sorting every time there is an update), or updated on-demand for controlled rendering for video frame rendering or similar applications.
|
||||
|
||||
## `update({ scene })`
|
||||
|
||||
If `forge.autoUpdate` is `false` then you must manually call `forge.update({ scene })` to have the scene Gsplats re-generated.
|
||||
|
||||
## `renderEnvMap({ renderer, scene, worldCenter, ... })`
|
||||
|
||||
Renders out the scene to an environment map that can be used for image-based lighting or similar applications. First updates Gsplats, sorts them with respect to the provided `worldCenter`, renders 6 cube faces, then pre-filters them using `THREE.PMREMGenerator` and returns a `THREE.Texture` that can assigned directly to a `THREE.MeshStandardMaterial.envMap` property.
|
||||
|
||||
## `recurseSetEnvMap(root, envMap)`
|
||||
|
||||
Utility function to recursively set the `envMap` property for any `THREE.MeshStandardMaterial` within the subtree of `root`.
|
||||
|
||||
## `getRgba({ generator, ... })`
|
||||
|
||||
Utility function that helps extract the Gsplat RGBA values from a `SplatGenerator`, including the result of any real-time RGBA SDF edits applied to a `SplatMesh`. This effectively "bakes" any computed RGBA values, which can now be used as a pipeline input via `SplatMesh.splatRgba` to inject these baked values into the Gsplat data.
|
||||
|
||||
## `readRgba({ generator, ...})`
|
||||
|
||||
Utility function that builds on `getRgba({ generator })` and additionally reads back the RGBA values to the CPU in a Uint8Array with packed RGBA in that byte order.
|
||||
@@ -0,0 +1,81 @@
|
||||
# ForgeViewpoint
|
||||
|
||||
A `ForgeViewpoint` is created from and tied to a `ForgeRenderer`, and represents an independent viewpoint of all the scene Gsplats and their sort order. Making these viewpoints explicit allows us to have multiple, simultaneous viewpoint renders, for example for camera preview panes or overhead map views.
|
||||
|
||||
When creating a `ForgeRenderer` it automatically creates a default viewpoint `.defaultView` that is used in the normal render loop when drawing to the canvas, and is automatically updated whenever the camera moves. Additional viewpoints can be created and configured separately:
|
||||
|
||||
## Creating a `ForgeViewpoint`
|
||||
|
||||
```typescript
|
||||
const viewpoint = forge.newViewpoint({
|
||||
autoUpdate?: boolean;
|
||||
camera?: THREE.Camera;
|
||||
viewToWorld?: THREE.Matrix4;
|
||||
target?: {
|
||||
width: number;
|
||||
height: number;
|
||||
doubleBuffer?: boolean;
|
||||
superXY?: number;
|
||||
};
|
||||
onTextureUpdated?: (texture: THREE.Texture) => void;
|
||||
sortRadial?: boolean;
|
||||
sortDistance?: number;
|
||||
sortCoorient?: boolean;
|
||||
depthBias?: number;
|
||||
sort360?: boolean;
|
||||
});
|
||||
```
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| **Parameter** | Description |
|
||||
| ----------------- | ----------- |
|
||||
| **autoUpdate** | Controls whether to auto-update its sort order whenever the ForgeRenderer updates the Gsplats. If you expect to render/display from this viewpoint most frames, set this to `true`. (default: `false`)
|
||||
| **camera** | Set a `THREE.Camera` for this viewpoint to follow. (default: `undefined`)
|
||||
| **viewToWorld** | Set an explicit view-to-world transformation matrix for this viewpoint (equivalent to `camera.matrixWorld`), overrides any `camera` setting. (default: `undefined`)
|
||||
| **target** | Configure viewpoint with an off-screen render target. (default: `undefined`)
|
||||
| **target.width** | Width of the render target in pixels.
|
||||
| **target.height** | Height of the render target in pixels.
|
||||
| **target.doubleBuffer** | If you want to be able to render a scene that depends on this target's output (for example, a recursive viewport), set this to `true` to enable double buffering. (default: `false`)
|
||||
| **target.superXY** | Super-sampling factor for the render target. Values 1-4 are supported. Note that re-sampling back down to `.width` x `.height` is done on the CPU with simple averaging only when calling `readTarget()`. (default: `1`)
|
||||
| **onTextureUpdated** | Callback function that is called when the render target texture is updated. Receives the texture as a parameter. Use this to update a viewport with the latest viewpoint render each frame. (default: `undefined`)
|
||||
| **sortRadial** | Whether to sort splats radially (geometric distance) from the viewpoint (true) or by Z-depth (false). Most scenes are trained with the Z-depth sort metric and will render more accurately at certain viewpoints. However, radial sorting is more stable under viewpoint rotations. (default: `true`)
|
||||
| **sortDistance** | Distance threshold for re-sorting splats. If the viewpoint moves more than this distance, splats will be re-sorted. (default: `0.01` units)
|
||||
| **sortCoorient** | View direction dot product threshold for re-sorting splats. For `sortRadial: true` we use 0.99 while `sortRadial: false` uses 0.999 because it is more sensitive to view direction. (default: `0.99` if `sortRadial` else `0.999`)
|
||||
| **depthBias** | Constant added to Z-depth to bias values into the positive range for `sortRadial: false`, but also used for culling Gsplats "well behind" the viewpoint origin (default: `1.0`)
|
||||
| **sort360** | Set this to true if rendering a 360 to disable "behind the viewpoint" culling during sorting. This is set automatically when rendering 360 envMaps using the `ForgeRenderer.renderEnvMap()` utility function. (default: `false`)
|
||||
|
||||
## `dispose()`
|
||||
|
||||
Call this when you are done with the `ForgeViewpoint` and want to free up its resources (GPU targets, pixel buffers, etc.)
|
||||
|
||||
## `setAutoUpdate(autoUpdate: boolean)`
|
||||
|
||||
Use this function to change whether this viewpoint will auto-update its sort order whenever the attached `ForgeRenderer` updates the Gsplats. Turn this on or off depending on whether you expect to do renders from this viewpoint most frames.
|
||||
|
||||
## `async prepareRenderPixels({ scene, camera?, viewToWOrld?, update?, forceOrigin? })`
|
||||
|
||||
Render out a viewpoint as a Uint8Array of RGBA values for the provided scene and any `camera`/`viewToWorld` viewpoint overrides. By default `update` is `true`, which triggers its `ForgeRenderer` to check and potentially update the Gsplats. Setting `update` to `false` disables this and sorts the Gsplats as they are. Setting `forceOrigin` (default: `false`) to `true` forces the view update to recalculate the splats with this view origin, potentially altering any view-dependent effects. If you expect view-dependent effects to play a role in the rendering quality, enable this.
|
||||
|
||||
Underneath, `prepareRenderPixels()` simply calls `await this.prepare(...)`, `this.renderTarget(...)`, and finally returns the result `this.readTarget()`, a Promise to a Uint8Array with RGBA values for all the pixels (potentially downsampled if the `superXY` parameter was used). These steps can also be called manually, for example if you need to alter the scene before and after `this.renderTarget(...)` to hide UI elements from being rendered.
|
||||
|
||||
## `async prepare({ scene, camera?, viewToWorld?, update?, forceOrigin? })`
|
||||
|
||||
See above `async prepareRenderPixels()` for explanation of parameters. Awaiting this method updates the Gsplats in the scene and performs a sort of the Gsplats from this viewpoint, preparing it for a subsequent `this.renderTarget()` call in the same tick.
|
||||
|
||||
## `renderTarget({ scene, camera? })`
|
||||
|
||||
Render out the viewpoint to the view target RGBA buffer. Swaps buffers if `doubleBuffer: true` was set. Calls `onTextureUpdated(texture)` with the resulting texture.
|
||||
|
||||
## `async readTarget()`
|
||||
|
||||
Read back the previously rendered target image as a Uint8Array of packed RGBA values (in that order). If `superXY` was set greater than `1` than downsampling is performed in the target pixel array with simple averaging to derive the returned pixel values. Subsequent calls to `this.readTarget()` will reuse the same buffers to minimize memory allocations.
|
||||
|
||||
## `autoPoll()`
|
||||
|
||||
This is called automatically by `ForgeRenderer`, there is no need to call it! The method cannot be private because then ForgeRenderer would not be able to call it.
|
||||
|
||||
## `ForgeViewpoint.EMPTY_TEXTURE`
|
||||
|
||||
If you need an empty `THREE.Texture` to use to initialize a uniform that is updated via `onTextureUpdated(texture)`, this static texture can be handy.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Getting Started
|
||||
|
||||
## Quick Start
|
||||
|
||||
Copy and paste code below in an `index.html` file or remix in the [Web Playground](https://glitch.com/edit/#!/forge-dev)
|
||||
|
||||
```html
|
||||
<style> body {margin: 0;} </style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/node_modules/three/build/three.module.js",
|
||||
"@worldlabsai/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@worldlabsai/forge";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const butterfly = new SplatMesh({ url: "../assets/basic/butterfly.wlg"});
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, 0, -1);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
});
|
||||
</script>
|
||||
```
|
||||
## Install with NPM
|
||||
|
||||
```shell
|
||||
npm install forge-dev
|
||||
```
|
||||
## Develop and contribute to Forge
|
||||
|
||||
Build Forge (It requires [Rust](https://www.rust-lang.org/tools/install) installed in your machine)
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will run a Web server at [http://localhost:8080/](http://localhost:8080/) with the examples.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Home
|
||||
hide:
|
||||
- navigation
|
||||
- toc
|
||||
---
|
||||
|
||||
<div class="hero">
|
||||
<h1><img src="/assets/images/logo-hero.png"/></h1>
|
||||
<h2>An advanced 3D Gaussian Splatting renderer for THREE.js</h2>
|
||||
<a href="/getting-started/" class="md-button md-button--primary">Get started →</a>
|
||||
<img class="hero-image" src="/assets/images/hero-image.png"/>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
# Loading Gsplats
|
||||
|
||||
Forge provides loaders for most popular Gsplat file formats, including `.ply` (original "gsplat" format, some compressed variants, and plain x/y/z/r/g/b point clouds) and `.spz` (Niantic open source compressed format), both auto-detected from the file contents.
|
||||
|
||||
Forge can also load popular formats `.splat` (from `antimatter15/splat`) and `.ksplat` (from `mkkellogg/GaussianSplats3D`) if the file type can be inferred from the URL/path extension, or set explicitly using the `fileType` property when creating a `SplatMesh` or `PackedSplats`.
|
||||
|
||||
## Loading auto-detectable formats `.ply` and `.spz`
|
||||
|
||||
Adding an individual `SplatMesh` from an auto-detectable format is easy and can be done as simply as below:
|
||||
|
||||
```javascript
|
||||
// Load and create SplatMesh in one go
|
||||
const splats = new SplatMesh({ url: "./butterfly.ply" });
|
||||
scene.add(splats);
|
||||
|
||||
scene.add(new SplatMesh({ url: "plyBin/0123456789abcdef" }));
|
||||
scene.add(new SplatMesh({ url: "spzBin/fedcba9876543210" }));
|
||||
```
|
||||
|
||||
### Load via `PackedSplats`
|
||||
|
||||
Alternatively, you can load a `.ply` or `.spz` into a `PackedSplats`, which can then be used as an input source for multiple `SplatMesh` instances in the scene.
|
||||
|
||||
```javascript
|
||||
const packedSplats = new PackedSplats({ url: "./clone.ply" });
|
||||
|
||||
const splats1 = new SplatMesh({ packedSplats });
|
||||
scene.add(splats1);
|
||||
|
||||
const splats2 = new SplatMesh({ packedSplats });
|
||||
scene.add(splats2);
|
||||
```
|
||||
|
||||
### Load via `SplatLoader`
|
||||
|
||||
Finally, you can make use of the `THREE.Loader` infrastructure via the `SplatLoader` class. A `Loader` has a synchronous and asynchronous interface. You can provide progress meters during downloads and invoke completion callbacks as follows:
|
||||
|
||||
```javascript
|
||||
const loader = new SplatLoader();
|
||||
loader.loadAsync(url, (event) => {
|
||||
if (event.type === "progress") {
|
||||
const progress = event.lengthComputable
|
||||
? `${((event.loaded / event.total) * 100).toFixed(2)}%`
|
||||
: `${event.loaded} bytes`;
|
||||
console.log(`Background download progress: ${progress}`);
|
||||
}
|
||||
})
|
||||
.then((packedSplats) => {
|
||||
const splatMesh = new SplatMesh({ packedSplats });
|
||||
// Re-orient from OpenCV to OpenGL coordinates
|
||||
splatMesh.quaternion.set(1, 0, 0, 0);
|
||||
splatMesh.position.set(0, 0, -1);
|
||||
splatMesh.scale.setScalar(0.5);
|
||||
scene.add(splatMesh);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(error);
|
||||
});
|
||||
```
|
||||
|
||||
## Loading additional formats `.splat` and `.ksplat`
|
||||
|
||||
These formats are reliably auto-detected from the file contents, so we use two fall-back mechanism to enable support for these popular formats.
|
||||
|
||||
First, the auto-detection fails on these files, which triggers file type inference via URL/path file extension. If the URL contains the `.splat` or `.ksplat` extensions (stripping out query parameters etc.), we set the corresponding file type.
|
||||
|
||||
```javascript
|
||||
const splats = new SplatMesh({ url: "./butterfly.splats" });
|
||||
scene.add(splats);
|
||||
|
||||
const ksplats = new SplatMesh({ url: "./butterfly.ksplats" });
|
||||
scene.add(ksplats);
|
||||
```
|
||||
If the URL contains a path with no obvious file extension, you can set the field `fileType` when constructing a `SplatMesh` or `PackedSplats`.
|
||||
|
||||
```javascript
|
||||
scene.add(new SplatMesh({
|
||||
url: "splatBin/0123456789abcdef",
|
||||
fileType: SplatFileType.SPLAT,
|
||||
}));
|
||||
|
||||
scene.add(new SplatMesh({
|
||||
url: "ksplatBin/fedcba9876543210",
|
||||
fileType: SplatFileType.KSPLAT,
|
||||
}));
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extrahead %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.hero h2 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.hero p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0rem;
|
||||
}
|
||||
|
||||
.hero img.hero-image {
|
||||
width: 100%;
|
||||
margin-top: 3rem;
|
||||
max-width: 800px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.hero img {
|
||||
display: block;
|
||||
max-width: 550px;
|
||||
margin:auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 60em) {
|
||||
.hero img {
|
||||
max-width: 400px;
|
||||
margin:auto;
|
||||
}
|
||||
.hero h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
<header class="md-header" data-md-component="header">
|
||||
<nav class="md-header__inner md-grid md-custom-header" aria-label="Header">
|
||||
|
||||
<!-- Left: Logo -->
|
||||
<div class="md-header__title">
|
||||
<a href="/" class="md-header__button md-logo">
|
||||
<img src="/assets/images/logo.svg" alt="{{ config.site_name }}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<label class="md-header__button md-icon" for="__search">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"></path></svg>
|
||||
</label>
|
||||
|
||||
{% include "partials/search.html" %}
|
||||
<!-- Right: Custom links + search -->
|
||||
<div class="md-custom-header-right">
|
||||
<a href="/examples/" class="md-nav__link">Examples</a>
|
||||
<a href="/getting-started/" class="md-nav__link">Docs</a>
|
||||
<a href="https://glitch.com/edit/#!/forge-dev" class="md-nav__link">Playground</a>
|
||||
<a href="/viewer/" class="md-nav__link">Viewer</a>
|
||||
<a rel="noopener noreferrer" class="contrast" aria-label="GitHub repository" href="https://github.com/forge-gfx/forge" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 496 475" class="icon-github">
|
||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
*Forge aims to expand what's possible with Gaussian Splatting and help 3D/4D creators bring their visions to life and share it with others.*
|
||||
|
||||
# Overview
|
||||
|
||||
Forge is a dynamic Gaussian splat (Gsplat) renderer built on Three.js. It renders Gsplat-based worlds and objects into Three.js scenes, giving you the ability to fuse AI-generated with photogrammetry and regular triangle meshes. Forge is also programmable and fully dynamic, giving you unprecedented control over how Gsplat elements are generated and rendered into the scene.
|
||||
|
||||
## Features
|
||||
- Render multiple splat objects together with correct sorting
|
||||
- Integrates with Three.js rendering pipeline to fuse Gsplat + mesh-based objects
|
||||
- Portable: Works across almost all devices, targeting 98%+ WebGL2 support
|
||||
- Fast! Renders fast even on low-powered mobile devices
|
||||
- File format support: all major formats supported including .PLY (including compressed), .SPZ, .SPLAT, .KSPLAT
|
||||
- Render multiple viewpoints simultaneously
|
||||
- Fully dynamic: each Gsplat can be transformed and edited for animation
|
||||
- Real-time Gsplat color editing and skeletal animation
|
||||
- Shader graph system to dynamically create/edit Gsplats on the GPU
|
||||
@@ -0,0 +1,188 @@
|
||||
# PackedSplats
|
||||
|
||||
A `PackedSplats` is a collection of Gaussian splats, packed into a format that takes exactly 16 bytes per Gsplat to maximize memory and cache efficiency. The `center` xyz coordinates are encoded as float16 (3 x 2 bytes), `scale` xyz as 3 x uint8 that encode a log scale from e^-9 to e^9, `rgba` as 4 x uint8, and quaternion encoded via axis+angle using 2 x uint8 for octahedral encoding of the axis direction and a uint8 to encode rotation amount from 0..Pi.
|
||||
|
||||
## Creating a `PackedSplats`
|
||||
|
||||
```typescript
|
||||
const packedSplats = new PackedSplats({
|
||||
// Fetch PLY/WLG/SPZ/SPLAT/KSPLAT file from URL
|
||||
url?: string;
|
||||
// Decode raw PLY/WLG/SPZ/SPLAT/KSPLAT file bytes
|
||||
fileBytes?: Uint8Array | ArrayBuffer;
|
||||
// Override file type
|
||||
fileType?: SplatFileType;
|
||||
// Reserve space for at least this many splats for construction
|
||||
maxSplats?: number;
|
||||
// Use provided packed data array, 4 words per splat
|
||||
packedArray?: Uint32Array;
|
||||
// Override number of splats in packed array to a subset
|
||||
numSplats?: number;
|
||||
// Constructor callback to create splats
|
||||
construct?: (splats: PackedSplats) => Promise<void> | void;
|
||||
// Extra splat data, such as sh1..3 components
|
||||
extra?: Record<string, unknown>;
|
||||
});
|
||||
```
|
||||
|
||||
### Optional parameters
|
||||
|
||||
Like for `SplatMesh` you can create a `new PackedSplats()` with no options, will create a new empty instance with 0 Gsplats. Similarly, you can provide an input `url`, `fileBytes`, `fileType` to decode from a file source. You can also create a `PackedSplats` from a raw `Uint32Array` where each successive 4 Uint32 values encodes one "packed" Gsplat. Finally, a `construct(splats)` callback provides an ergonomic way to create Gsplats procedurally with an in-line callback closure.
|
||||
|
||||
| **Parameter** | Description |
|
||||
| ----------------- | ----------- |
|
||||
| **url** | URL to fetch a Gaussian splat file from (supports .ply, .splat, .ksplat, .spz formats). (default: `undefined`)
|
||||
| **fileBytes** | Raw bytes of a Gaussian splat file to decode directly instead of fetching from URL. (default: `undefined`)
|
||||
| **fileType** | Override the file type detection for formats that can't be reliably auto-detected (.splat, .ksplat). (default: `undefined` auto-detects other formats from file contents)
|
||||
| **maxSplats** | Reserve space for at least this many splats when constructing the collection initially. The array will automatically resize past maxSplats so setting it is an optional optimization. (default: `0`)
|
||||
| **packedArray** | Use provided packed data array, where each 4 consecutive uint32 values encode one "packed" Gsplat. (default: `undefined`)
|
||||
| **numSplats** | Override number of splats in packed array to use only a subset. (default: length of packed array / 4)
|
||||
| **construct** | Callback function to programmatically create splats at initialization. (default: `undefined`)
|
||||
| **extra** | Additional splat data, such as spherical harmonics components (sh1, sh2, sh3). (default: `{}`)
|
||||
|
||||
## Encoding / Decoding
|
||||
|
||||
Utility functions are provided in Javascript to pack/unpack these encodings:
|
||||
```javascript
|
||||
|
||||
// Set via packedSplats interface
|
||||
packedSplats.setSplat(index, center, scales, quaternion, opacity, color);
|
||||
|
||||
// Set underlying Uint32 array directly
|
||||
import { utils } from "@forge-gfx/forge";
|
||||
utils.setPackedSplat(packedSplats.packedArray, index, x, y, z, scaleX, scaleY, ...);
|
||||
|
||||
// Set rotation components of underlying Uint32 array directly
|
||||
utils.setPackedSplatQuat(packedSplats.packedArray, index, quatX, quatY, quatZ, quatW);
|
||||
|
||||
// Unpack all Gsplat components from the Uint32 array
|
||||
const { center, scales, quaternion, color, opacity } = utils.unpackSplat(packedSplats.packedArray, index);
|
||||
|
||||
// Unpack all Gsplats with callback
|
||||
packedSplats.forEachSplat((index, center, scales, quaternion, opacity, color) => {
|
||||
// Use unpacked Gsplat data. Changing the inputs directly has no effect.
|
||||
// Update just the scales component
|
||||
utils.setPackedSplatScales(packedSplat.packedArray, index, 0.005, 0.01, 0.015);
|
||||
// Update the entire splat
|
||||
packedSplat.setSplat(index, center, scales, quaternion, opacity, color);
|
||||
});
|
||||
```
|
||||
|
||||
In GLSL / `dyno` shader contexts you can use the following utility functions that are available via `splatDefines.glsl`:
|
||||
|
||||
```glsl
|
||||
// Pack a Gsplat into a uvec4
|
||||
uvec4 packSplat(vec3 center, vec3 scales, vec4 quaternion, vec4 rgba);
|
||||
|
||||
// Unpack a Gsplat from a uvec4
|
||||
void unpackSplat(uvec4 packed, out vec3 center, out vec3 scales, out vec4 quaternion, out vec4 rgba);
|
||||
|
||||
// Fetch and unpack a particular index from a PackedSplats.
|
||||
const gsplat = dyno.readPackedSplat(packedSplats.dyno, index);
|
||||
```
|
||||
|
||||
### Byte Layout
|
||||
|
||||
Each `PackedSplat` occupies 16 bytes (4 × `uint32`), with the following layout of fields by byte offset:
|
||||
|
||||
| Offset (bytes) | Field | Size (bytes) | Description |
|
||||
|----------------|-----------------|--------------|------------------------------------------------------------|
|
||||
| 0 | R | 1 | Red color channel (uint8 0–255 → 0.0–1.0) |
|
||||
| 1 | G | 1 | Green color channel (uint8 0–255 → 0.0–1.0) |
|
||||
| 2 | B | 1 | Blue color channel (uint8 0–255 → 0.0–1.0) |
|
||||
| 3 | A | 1 | Alpha (opacity) channel (uint8 0–255 → 0.0–1.0) |
|
||||
| 4–5 | center.x | 2 | X coordinate of splat center (float16) |
|
||||
| 6–7 | center.y | 2 | Y coordinate of splat center (float16) |
|
||||
| 8–9 | center.z | 2 | Z coordinate of splat center (float16) |
|
||||
| 10 | quat oct.U | 1 | Octahedral quaternion U component (uint8) |
|
||||
| 11 | quat oct.V | 1 | Octahedral quaternion V component (uint8) |
|
||||
| 12 | scale.x | 1 | X scale, log-encoded to uint8 |
|
||||
| 13 | scale.y | 1 | Y scale, log-encoded to uint8 |
|
||||
| 14 | scale.z | 1 | Z scale, log-encoded to uint8 |
|
||||
| 15 | quat angle (θ) | 1 | Encoded quaternion rotation angle (uint8, θ/π·255) |
|
||||
|
||||
### Gsplat RGBA encoding
|
||||
|
||||
RGB values are encoded are uint8 sRGB values with 0..255 mapping to 0..1. When loading from a PLY file these values are derived by calculating `ply[f_dc_0] * SH_C0 + 0.5`.
|
||||
|
||||
Opacity is encoded on a linear scale where 0..255 maps to 0..1.
|
||||
|
||||
### Gsplat center encoding
|
||||
|
||||
The center x/y/z components are encoded as float16, which provides 10 bits of mantissa, or approximately 1K steps (0.1%) of resolution each power of 2 exponent, with a range of up to 32K in distance. If most of the Gsplats are positioned relative to the origin this provides enough positional resolution. Gsplats that are transformed far from the origin, however (for example when bringing multiple `SplatMesh`es together in a scene that are far apart) may lose precision when mapped to the space of `ForgeRenderer`. For scenes where the user camera may move far from the origin, you may want to tie the `ForgeRenderer` origin to your camera by adding it as a child of the camera.
|
||||
|
||||
### Gsplat scales encoding
|
||||
|
||||
The XYZ scales are encoded independently using the following mapping: Any scale values below e^-20 are interpreted as "true zero" scale, and encoded as `uint8(0)`. Any other values quantized by computing `ln(scale_xyz)`, mapping the range e^-9..e^9 to uint8 values 1..254, rounding, and clamping. This logarithmic scale range provides values from 0.0001 up to 8K in scale, with approximately 7% steps between discrete values. Seems to have minimal impact on perceptible visual quality.
|
||||
|
||||
### Gsplat orientation encoding
|
||||
|
||||
We encode a Gsplat's quaternion/orientation by encoding it explicitly in an axis + angle representation: 8 bits for each U/V coordinate for octahedral encoding of the axis direction, and 8 bits to encode the rotation angle range 0..Pi.
|
||||
|
||||
This representation was chosen oven other internal rotation representations because it provided a good mix of speed/simplicity, uniformity of representable orientations, and especially its ability to handle rotations "near identity". Other encodings such as the more common "3 quaternion components" tend to have poor rotational resolution around I(1), which is a particularly important area of that parameter.
|
||||
|
||||
### `extra` Gsplat data
|
||||
|
||||
Each instance of `PackedSplats` also has a property `extra: Record<string, unknown>` that is used to attach additional Gsplat-related data to the `PackedSplats` container. For example, spherical harmonics degrees 1..3 are stored in `sh1: Uint32Array(numSplats * 2)`, `sh2: Uint32Array(numSplats * 4)`, `sh3: Uint32Array(numSplats * 4)`, and intermediate textures are generated by `SplatMesh` and stored as `sh1Texture` etc.
|
||||
|
||||
This structure can be used to extend and store additional data in a `PackedSplats`, but there is no specific convention yet to prevent collisions.
|
||||
|
||||
`sh1` stores each of 3 x 3 RGB signed components as Sint7 (mapping -1..1) in 63 bits (8 bytes) per Gsplat. `sh2` stores each of 5 x 3 RGB signed components as Sint8 in 120 bits (16 bytes). `sh3` stores each of 7 x 3 RGB signed components as Sint6 in 126 bits (16 bytes) to improve memory bandwidth efficiency.
|
||||
|
||||
## `PackedSplats` instance methods
|
||||
|
||||
### `dispose()`
|
||||
|
||||
Call this when you are finished with the PackedSplats and want to free any buffers it holds.
|
||||
|
||||
### `ensureSplats(numSplats)`
|
||||
|
||||
Ensures that `this.packedArray` can fit `numSplats` Gsplats. If it's too small, resize exponentially and copy over the original data.
|
||||
|
||||
Typically you don't need to call this, because calling `this.setSplat(index, ...)` and `this.pushSplat(...)` will automatically call `ensureSplats()` so we have enough splats.
|
||||
|
||||
### `getSplat(index): { center, scales, quaternion, opacity, color }`
|
||||
|
||||
Unpack the 16-byte Gsplat data at `index` into the Three.js components `center: THREE.Vector3`, `scales: THREE.Vector3`, `quaternion: THREE.Quaternion`, `opacity: number 0..1`, `color: THREE.Color 0..1`.
|
||||
|
||||
### `setSplat(index, center, scales, quaternion, opacity, color)`
|
||||
|
||||
Set all PackedSplat components at `index` with the provided Gsplat attributes (can be the same objects returned by `getSplat`). Ensures there is capacity for at least `index+1` Gsplats.
|
||||
|
||||
### `pushSplat(center, scales, quaternion, opacity, color)`
|
||||
|
||||
Effectively calls `this.setSplat(this.numSplats++, center, ...)`, useful on construction where you just want to iterate and create a collection of Gsplats.
|
||||
|
||||
### `forEachSplat(callback: (index, center, scales, quaternion, opacity, color) => void)`
|
||||
|
||||
Iterate over Gsplats index `0..=(this.numSplats-1)`, unpack each Gsplat and invoke the callback function with the Gsplat attributes.
|
||||
|
||||
### `getTexture()`
|
||||
|
||||
Returns a `THREE.DataArrayTexture` representing the PackedSplats content as a Uint32x4 data array texture (2048 x 2048 x depth in size)
|
||||
|
||||
### `getEmpty()`
|
||||
|
||||
Can be used where you need an uninitialized `THREE.DataArrayTexture` like a uniform you will update with the result of `this.getTexture()` later.
|
||||
|
||||
## Generating Gsplats on the GPU
|
||||
|
||||
To generate a large number of Gsplats we can use the `dyno` shader graph system, which allows to create a computation graph mapping `{ index: DynoVal<"int"> }` to `{ gsplat: DynoVal<Gsplat> }` via Javascript code, then have that synthesize GLSL code, which is finally compiled and executed in parallel on the GPU.
|
||||
|
||||
This building block is used by `ForgeRenderer` to traverse each visible `SplatMesh`/`SplatGenerator` and have it "generate" its Gsplats into the global `PackedSplats` array managed by a `SplatAccumulator`. At its core a `PackedSplats` has the ability to run `dyno` computation graphs to produce its contents using the following methods, which are typically managed by `ForgeRenderer`:
|
||||
|
||||
### `generateMapping(splatCounts: number[]): { maxSplats, mapping[] }`
|
||||
|
||||
Given an array of splatCounts (`.numSplats` for each `SplatGenerator`/`SplatMesh` in the scene), compute a "mapping layout" in the composite array of generated outputs.
|
||||
|
||||
### `ensureGenerate(maxSplats)`
|
||||
|
||||
Ensures our `PackedSplats.target` render target has enough space to generate `maxSplats` total Gsplats, and reallocate if not large enough.
|
||||
|
||||
### `generate({ generator, base, count, ... })`
|
||||
|
||||
Executes a `dyno` program specified by `generator` which is any `DynoBlock` that maps `{ index: "int" }` to `{ gsplat: Gsplat }`. This is called in `ForgeRenderer.updateInternal()` to re-generate Gsplats in the scene for `SplatGenerator` instances whose version is newer than what was generated for it last time.
|
||||
|
||||
## Using dynamic PackedSplats inputs in `dyno`
|
||||
|
||||
You can use a `PackedSplats` as a `dyno` block using the function `dyno.readPackedSplats(packedSplats.dyno, dynoIndex)` where `dynoIndex` is of type `DynoVal<"int">` If you need to be able to change the input `PackedSplats` dynamically, however, you should create a `DynoPackedSplats`, whose property `packedSplats` you can change to any `PackedSplats` and that will be used in the `dyno` shader program.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Performance Tuning
|
||||
|
||||
Rendering millions of Gsplats at 60+ fps can be a demanding task, especially for mobile-class GPUs. Each Gsplat is rendered as two triangles that span the footprint of a Gaussian up to `sqrt(8)` standard deviations (default value) from the center. Each Gsplat is rendered as a transparent object and must be blended back-to-front.
|
||||
|
||||
As a quick rule-of-thumb, the following "Gsplat budgets" are recommended:
|
||||
|
||||
- Quest 3: 1 million Gsplats or less, not too many Gsplats concentrated in a small area
|
||||
- Android phone: 1-2 million Gsplats
|
||||
- iPhone: 1-3 million Gsplats
|
||||
- Computer: 1-5 million Gsplats (10-20+ million on some desktops)
|
||||
|
||||
Each Gsplat incurs overhead in transforming it via SplatAccumulator for sorting and rendering, and at around 1 million Gsplats this becomes a bottleneck on some systems. Unintuitively, when a large number of Gsplats are concentrated in a small area (for example 500K Gsplats from a Trellis object at a small screen scale) they can bottleneck the GPU's rendering and blending ability.
|
||||
|
||||
## maxStdDev
|
||||
|
||||
Adjust `ForgeRenderer.maxStdDev` (either directly on `ForgeRenderer` or via constructor options) to a value less than the default `Math.sqrt(8)`. This limits the extent of the Gaussian fall-off, which by default is approx 2.8. For VR a good value is `Math.sqrt(5)`, which is perceptually very similar to the default.
|
||||
@@ -0,0 +1,286 @@
|
||||
# Procedural Splats
|
||||
|
||||
Forge makes it easy to create Gaussian splat collections procedurally, and includes some splat constructors that may be useful for tasks like creating a grid or text made of splats. The example "Procedural Splats" puts some of these to use in a scene.
|
||||
|
||||
## Adding splats to a collection
|
||||
|
||||
To create a `PackedSplats` with custom, procedurally-derived splats use the methods `pushSplat` or `setSplat`:
|
||||
```javascript
|
||||
const splats = new PackedSplats();
|
||||
const center = new THREE.Vector3(0, 0, 0);
|
||||
const scales = new THREE.Vector3(0.1, 0.1, 0.1);
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const opacity = 1.0;
|
||||
const color = new THREE.Color()
|
||||
splats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
...
|
||||
```
|
||||
|
||||
The array in `PackedSplats` will be resized automatically to fit any splats you add. Alternatively, you can use the `construct` initializer callback:
|
||||
```javascript
|
||||
const splats = new PackedSplats({
|
||||
construct: (splats) => {
|
||||
...
|
||||
for (let i = 0; i < NUM_SPLATS; ++i) {
|
||||
// Compute splat #i
|
||||
...
|
||||
splats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Once you've created your `PackedSplats`, render it to the scene via a `SplatMesh`:
|
||||
```javascript
|
||||
const mesh = new SplatMesh({ packedSplats: splats });
|
||||
scene.add(mesh);
|
||||
```
|
||||
|
||||
Alternatively, you can create the `SplatMesh` and its splats in the initializer, which internally passes your constructor to its contained `PackedSplats`:
|
||||
```javascript
|
||||
const mesh = new SplatMesh({
|
||||
constructSplats: (splats) => {
|
||||
for (let i = 0; i < NUM_SPLATS; ++i) {
|
||||
// Compute splat #i
|
||||
...
|
||||
splats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
}
|
||||
},
|
||||
});
|
||||
scene.add(mesh);
|
||||
```
|
||||
|
||||
## Grid
|
||||
|
||||
```javascript
|
||||
import { constructGrid } from "@worldlabsai/forge";
|
||||
|
||||
const grid = new SplatMesh({
|
||||
constructSplats: (splats) => constructGrid({
|
||||
splats,
|
||||
extents: new THREE.Box3(
|
||||
new THREE.Vector3(-10, -10, -10),
|
||||
new THREE.Vector3(10, 10, 10),
|
||||
),
|
||||
}),
|
||||
});
|
||||
scene.add(grid);
|
||||
```
|
||||
|
||||
### Required parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `splats` | PackedSplats object to add splats to |
|
||||
| `extents` | min and max box extents of the grid |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `stepSize` | `1` | step size along each grid axis |
|
||||
| `pointRadius` | `0.01` | spherical radius of each Gsplat |
|
||||
| `pointShadowScale` | `2.0` | relative size of the "shadow copy" of each Gsplat placed behind it |
|
||||
| `opacity` | `1.0` | Gsplat opacity |
|
||||
| `color` | RGB-modulated grid | Gsplat color (THREE.Color) or function to set color for position: ((THREE.Color, THREE.Vector3) => void) |
|
||||
|
||||
|
||||
## XYZ axis
|
||||
|
||||
```javascript
|
||||
import { constructAxes } from "@worldlabsai/forge";
|
||||
|
||||
const axes = new SplatMesh({
|
||||
constructSplats: (splats) => constructAxes({ splats }),
|
||||
});
|
||||
axes.position.set(0, 0, -1);
|
||||
scene.add(axes);
|
||||
```
|
||||
|
||||
### Required parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `splats` | PackedSplats object to add splats to |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `scale` | `0.25` | scale (Gsplat scale along axis) |
|
||||
| `axisRadius` | `0.0075` | radius of the axes (Gsplat scale orthogonal to axis) |
|
||||
| `axisShadowScale` | `2.0` | relative size of the "shadow copy" of each Gsplat placed behind it |
|
||||
| `origins` | `[new THREE.Vector3()]` | origins of the axes (default single axis at origin) |
|
||||
|
||||
|
||||
## Gsplat sphere
|
||||
|
||||
```javascript
|
||||
import { constructSpherePoints } from "@worldlabsai/forge";
|
||||
|
||||
const sphere = new SplatMesh({
|
||||
constructSplats: (splats) => constructSpherePoints({
|
||||
splats,
|
||||
maxDepth: 4,
|
||||
}),
|
||||
});
|
||||
scene.add(sphere);
|
||||
```
|
||||
|
||||
### Required parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `splats` | PackedSplats object to add splats to |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `origin` | `new THREE.Vector3()` | center of the sphere (default: origin) |
|
||||
| `radius` | `1.0` | radius of the sphere |
|
||||
| `maxDepth` | `3` | maximum depth of recursion for subdividing the sphere. Warning: Gsplat count grows exponentially with depth |
|
||||
| `filter` | `null` | filter function to apply to each point, for example to select points in a certain direction or other function ((THREE.Vector3) => boolean) |
|
||||
| `pointRadius` | `0.02` | radius of each oriented Gsplat |
|
||||
| `pointThickness` | `0.001` | flatness of each oriented Gsplat |
|
||||
| `color` | `new THREE.Color(1, 1, 1)` | color of each Gsplat (THREE.Color) or function to set color for point: ((THREE.Color, THREE.Vector3) => void) |
|
||||
|
||||
|
||||
## Rasterizing Text
|
||||
|
||||
```typescript
|
||||
const splats = textSplats({
|
||||
text: string;
|
||||
font?: string;
|
||||
fontSize?: number;
|
||||
color?: THREE.Color;
|
||||
rgb?: THREE.Color;
|
||||
dotRadius?: number;
|
||||
textAlign?: "left" | "center" | "right" | "start" | "end";
|
||||
lineHeight?: number;
|
||||
});
|
||||
scene.add(splats);
|
||||
```
|
||||
|
||||
### Required parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `text` | text string to display |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `font` | `"Arial"` | browser font to render text with |
|
||||
| `fontSize` | `32` | font size in pixels/Gsplats |
|
||||
| `color` | `new THREE.Color(1, 1, 1)` | SplatMesh.recolor tint assuming white Gsplats |
|
||||
| `rgb` | `new THREE.Color(1, 1, 1)` | Individual Gsplat color |
|
||||
| `dotRadius` | `0.8` | Gsplat radius (0.8 covers 1-unit spacing well) |
|
||||
| `textAlign` | `"start"` | text alignment: "left", "center", "right", "start", "end" |
|
||||
| `lineHeight` | `1.0` | line spacing multiplier, lines delimited by "\n" |
|
||||
|
||||
## Turning images into Gsplats
|
||||
|
||||
```typescript
|
||||
const image = imageSplats({
|
||||
url: string;
|
||||
dotRadius?: number;
|
||||
subXY?: number;
|
||||
forEachSplat?: (
|
||||
width: number,
|
||||
height: number,
|
||||
index: number,
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) => number | null;
|
||||
});
|
||||
scene.add(image);
|
||||
```
|
||||
|
||||
### Required parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `url` | URL of the image to convert to splats (example: `url: "./image.png"`) |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `dotRadius` | `0.8` | Radius of each Gsplat, default covers 1-unit spacing well |
|
||||
| `subXY` | `1` | Subsampling factor for the image. Higher values reduce resolution, for example 2 will halve the width and height by averaging |
|
||||
| `forEachSplat` | `undefined` | Optional callback function to modify each Gsplat before it's added. Return null to skip adding the Gsplat, or a number to set the opacity and add the Gsplat with parameter values in the objects center, rgba etc. were passed into the forEachSplat callback. Ending the callback in `return opacity;` will retain the original opacity. |
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
// Load RGBA image from image.png, subsample it 2x
|
||||
// horizontally and vertically, and create Gsplats for
|
||||
// the resulting pixels that have at least 10% opacity.
|
||||
const image = imageSplats({
|
||||
url: "./image.png",
|
||||
subXY: 2,
|
||||
forEachSplat: (width, height, index, center, scales, quaternion, opacity, color) => {
|
||||
// Only keep Gsplats with opacity 10% or higher
|
||||
return (opacity >= 0.1) ? opacity : null;
|
||||
},
|
||||
});
|
||||
scene.add(image);
|
||||
```
|
||||
|
||||
## Particle Effects
|
||||
|
||||
Some of the building blocks are meant to serve as "code inspiration", showing how a particle effect animation can be achieved using a "stateless" `dyno` computation graph that uses only the Gsplat `index` input to produce pseudo-random numbers that drive various randomized particle effects.
|
||||
|
||||
Note that Gsplat sorting takes a little bit of time and can "lag behind" the Gsplat updates each frame, so it's important that there is a reasonably stable correspondence between each frame's Gsplat output for the same `index`.
|
||||
|
||||
In `staticBox`, the AABB is sliced up into a 3D grid of cells, and random X/Y/Z points within those cells are sampled each frame, retaining consistency in Gsplat position between successive frames. See `examples/gsplat-sweets-garden/main.js` for an example that creates a `staticBox`.
|
||||
|
||||
### `generators.snowBox(...options)`
|
||||
|
||||
Similarly, `snowBox` produces Gsplat trajectories that move in a deterministic fashion over time, with high similarity between adjacent frames. See `examples/atmospheric/main.js` for an example that creates a `snowBox`.
|
||||
|
||||
A snowBox instance has a collection of properties that can be tuned to achieve different particle effects. DEFAULT_SNOW and DEFAULT_RAIN are example parameter sets that look a lot like snow and rain, and can be used as a starting point for further tweaking: `const mySnow = { ...DEFAULT_SNOW, density: 500 };`
|
||||
|
||||
```typescript
|
||||
const snowControls = generators.snowBox({
|
||||
box,
|
||||
minY,
|
||||
numSplats,
|
||||
density,
|
||||
anisoScale,
|
||||
minScale,
|
||||
maxScale,
|
||||
fallDirection,
|
||||
fallVelocity,
|
||||
wanderScale,
|
||||
wanderVariance,
|
||||
color1,
|
||||
color2,
|
||||
opacity,
|
||||
onFrame,
|
||||
});
|
||||
scene.add(snowControls.snow);
|
||||
```
|
||||
|
||||
| Parameter | Default Value | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| `box` | `new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1))` | min and max box extents of the snowBox |
|
||||
| `minY` | `Number.NEGATIVE_INFINITY` | minimum y-coordinate to clamp particle position, which can be used to fake hitting a ground plane and lingering there for a bit |
|
||||
| `numSplats` | calculated from box and density | number of Gsplats to generate |
|
||||
| `density` | `100` | density of Gsplats per unit volume |
|
||||
| `anisoScale` | `new THREE.Vector3(1, 1, 1)` | The xyz anisotropic scale of the Gsplat, which can be used for example to elongate rain particles |
|
||||
| `minScale` | `0.001` | Minimum Gsplat particle scale |
|
||||
| `maxScale` | `0.005` | Maximum Gsplat particle scale |
|
||||
| `fallDirection` | `new THREE.Vector3(0, -1, 0)` | The average direction of fall |
|
||||
| `fallVelocity` | `0.02` | The average speed of the fall (multiplied with fallDirection) |
|
||||
| `wanderScale` | `0.01` | The world scale of wandering overlay motion |
|
||||
| `wanderVariance` | `2` | Controls how uniformly the particles wander in sync, more variance means more randomness in the motion |
|
||||
| `color1` | `new THREE.Color(1, 1, 1)` | Color 1 of the two colors interpolated between |
|
||||
| `color2` | `new THREE.Color(0.5, 0.5, 1)` | Color 2 of the two colors interpolated between |
|
||||
| `opacity` | `1` | The base opacity of the Gsplats |
|
||||
| `onFrame` | `undefined` | Optional callback function to call each frame |
|
||||
@@ -0,0 +1,88 @@
|
||||
# Gsplat Editing
|
||||
|
||||
Forge provides the ability to apply "edits" to Gsplats as part of the standard `SplatMesh` pipeline. These edits take the form of a sequence of operations, applied one at a time to the set of Gsplats in its `packedSplats`. Each operation evaluates a 7-dimensional field (RGBA and XYZ displacement) at each point in space that derives from N=1 or more Signed Distance Field shapes (such as spheres, boxes, planes, etc.), blended together and across inside-outisde boundaries.
|
||||
|
||||
The result is a an RGBA,XYZ value for each point in space, which combined with SplatEditRgbaBlendMode.MULTIPLY/SET_RGB/ADD_RGBA can be used to create special effects, for example simulating simple lighting or applying deformations in space, whose parameters can be updated each frame to create animated effects.
|
||||
|
||||
## RGBA blend modes
|
||||
|
||||
When creating a `SplatEdit` you can specify a `rgbaBlendMode?: SplatEditRgbaBlendMode` value from the enum to choose between 3 blend modes:
|
||||
|
||||
| Blend Mode | Description |
|
||||
|-------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| `SplatEditRgbaBlendMode.MULTIPLY` | The RGBA of the splat is multiplied component-wise by the SDF’s RGBA value at that point in space. |
|
||||
| `SplatEditRgbaBlendMode.SET_RGB` | Ignore the Alpha value in the SDF, but set the splat’s RGB to equal the SDF’s RGB value at that point. |
|
||||
| `SplatEditRgbaBlendMode.ADD_RGBA` | Add the SDF’s RGBA value at that point to the RGBA value of the Gsplat. This can produce hyper-saturated results, but is useful to easily “light up” areas. |
|
||||
|
||||
## SDF Shapes
|
||||
|
||||
The following SDF shapes are available in the `SplatEditSdfType` enum:
|
||||
|
||||
| SDF Type | Description | Parameters |
|
||||
|----------|-------------|------------|
|
||||
| `ALL` | Affects all points in space | None |
|
||||
| `PLANE` | Infinite plane | position, rotation |
|
||||
| `SPHERE` | Sphere | position, radius |
|
||||
| `BOX` | Box (with optional corner rounding) | position, rotation, sizes, radius |
|
||||
| `ELLIPSOID` | Ellipsoid | position, rotation, sizes |
|
||||
| `CYLINDER` | Cylinder | position, rotation, size_y |
|
||||
| `CAPSULE` | Capsule | position, rotation, size_y |
|
||||
| `INFINITE_CONE` | Infinite cone | position, rotation, radius=angle |
|
||||
|
||||
## Creating a Gsplat edit operation
|
||||
|
||||
A `SplatEdit` operation can be assigned to a particular `SplatMesh` through its `.edits[]` property or by adding the `SplatEdit` as a child of the `SplatMesh` in the scene hierarchy. If the `SplatEdit` has no `SplatMesh` ancestor, its edits will apply globally to all `SplatMesh`es whose `editable` property is set to default true.
|
||||
|
||||
```typescript
|
||||
const edit = new SplatEdit({
|
||||
name?: string;
|
||||
rgbaBlendMode?: SplatEditRgbaBlendMode;
|
||||
sdfSmooth?: number;
|
||||
softEdge?: number;
|
||||
invert?: boolean;
|
||||
sdfs?: SplatEditSdf[];
|
||||
});
|
||||
scene.add(edit);
|
||||
```
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------------|------------------------------------------|--------------------------------------------------------------------------------------------------------------|
|
||||
| `name` | `undefined` (auto‐generated to `Edit <n>`) | Name of this edit operation. If you omit it, a default `"Edit 1"`, `"Edit 2"`, … is assigned. |
|
||||
| `rgbaBlendMode` | `SplatEditRgbaBlendMode.MULTIPLY` | How the SDF’s RGBA modifies each splat’s RGBA: multiply, overwrite RGB, or add RGBA. |
|
||||
| `sdfSmooth` | `0.0` | Smoothing (in world‐space units) for blending between multiple SDF shapes at their boundaries. |
|
||||
| `softEdge` | `0.0` | Soft‐edge falloff radius (in world‐space units) around each SDF shape’s surface. |
|
||||
| `invert` | `false` | Invert the SDF evaluation (inside/outside swap). |
|
||||
| `sdfs` | `null` | Explicit array of `SplatEditSdf` objects to include. If `null`, any child `SplatEditSdf` instances are used. |
|
||||
|
||||
## Adding an SDF RGBA-XYZ shape to the edit operation
|
||||
|
||||
```typescript
|
||||
const shape1 = new SplatEditSdf({
|
||||
type?: SplatEditSdfType;
|
||||
invert?: boolean;
|
||||
opacity?: number;
|
||||
color?: THREE.Color;
|
||||
displace?: THREE.Vector3;
|
||||
radius?: number;
|
||||
});
|
||||
edit.add(shape1);
|
||||
```
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-------------|------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `type` | `SplatEditSdfType.SPHERE` | The SDF shape type: `ALL`, `PLANE`, `SPHERE`, `BOX`, `ELLIPSOID`, `CYLINDER`, `CAPSULE`, or `INFINITE_CONE`. |
|
||||
| `invert` | `false` | Invert the SDF evaluation, swapping inside and outside regions. |
|
||||
| `opacity` | `1.0` | Opacity / "alpha" value used differently by blending modes |
|
||||
| `color` | `new THREE.Color(1.0, 1.0, 1.0)` | RGB color applied within the shape. |
|
||||
| `displace` | `new THREE.Vector3(0.0, 0.0, 0.0)` | XYZ displacement applied to splat positions inside the shape. |
|
||||
| `radius` | `0.0` | Shape-specific size parameter: sphere radius, box corner rounding, cylinder/capsule radius, or for the infinite cone the angle factor (opening half-angle = π/4 × `radius`). |
|
||||
|
||||
## Adding multiple SDF RGBA-XYZ shapes to the edit operation
|
||||
|
||||
RGBA-XYZ values are computed by blending together values from all SDF shapes using the exponential "softmax" function, which is commutative (so blending order within a `SplatEdit` operation doesn't matter). The parameter `SplatEdit.sdfSmooth` controls the blending scale *between* SDF shapes, while `SplatEdit.softEdge` controls the scale of soft inside-outside shape edit blending. Their default values start at `0.0` and should be increased to soften the effect.
|
||||
|
||||
Note that XYZ displacement values are blended in the same way as RGBA, with a resulting displacement field that can be quite complex but "softly" blending between shapes. These RGBA-XYZ edits, along with time-based and overlapping fields can create many interesting animations and special effects, such as rippling leaves in the wind, an angry fire, or a looping water effects. Simply update the `SplatEdit` and `SplatEditSdf` objects and the operations will be applied immediately to the Gsplats in the scene.
|
||||
@@ -0,0 +1,123 @@
|
||||
# SplatMesh
|
||||
|
||||
A `SplatMesh` is a high-level interface for displaying and manipulating a "Splat mesh", a collection of Gaussian splats that serves as an "object" of sorts. It is analagous to a traditional polygon `THREE.Mesh`, which consists of geometry (points and triangles) and materials (color and lighting). Similarly, a `SplatMesh` contains geometry (Gsplat centers, quaternion, and xyz scales) and materials (RGB color, opacity, spherical harmonics for directional lighting), and can be added anywhere in the scene hierarchy.
|
||||
|
||||
The usual Three.js properties `position`, `quaternion`, `rotation` behave as you would expect, however `scale` only allows uniform scaling and averages the x/y/z scales. Additional properties `recolor` and `opacity` are multiplied in with the final Gsplat color and opacity.
|
||||
|
||||
`SplatMesh` is a subclass of the more fundamental `SplatGenerator`, which itself is a subclass of `THREE.Object3D`. Any methods and properties on `Object3D` are also available in `SplatMesh`. `SplatGenerator` gives you more control over Gsplat generation and modification, but `SplatMesh` has an easier and higher-level API.
|
||||
|
||||
## Creating a `SplatMesh`
|
||||
|
||||
```typescript
|
||||
const splats = new SplatMesh({
|
||||
// Fetch PLY/WLG/SPZ/SPLAT/KSPLAT file from URL
|
||||
url?: string;
|
||||
// Decode raw PLY/WLG/SPZ/SPLAT/KSPLAT file bytes
|
||||
fileBytes?: Uint8Array | ArrayBuffer;
|
||||
// Override file type
|
||||
fileType?: SplatFileType;
|
||||
// Use PackedSplats object as source
|
||||
packedSplats?: PackedSplats;
|
||||
// Reserve space for at least this many splats for construction
|
||||
maxSplats?: number;
|
||||
// Constructor callback to create splats
|
||||
constructSplats?: (splats: PackedSplats) => Promise<void> | void;
|
||||
// Callback for when mesh initialization is complete
|
||||
onLoad?: (mesh: SplatMesh) => Promise<void> | void;
|
||||
// Toggle controls whether SplatEdits have an effect, default true
|
||||
editable?: boolean;
|
||||
// Frame callback to update mesh. Call mesh.updateVersion() if we need to re-generate
|
||||
onFrame?: ({
|
||||
mesh,
|
||||
time,
|
||||
deltaTime,
|
||||
}: { mesh: SplatMesh; time: number; deltaTime: number }) => void;
|
||||
// Object-space and world-space Gsplat modifiers to apply
|
||||
objectModifier?: GsplatModifier;
|
||||
worldModifier?: GsplatModifier;
|
||||
});
|
||||
// Add to scene to show Gsplats (requires ForgeRenderer as well)
|
||||
scene.add(splats);
|
||||
```
|
||||
|
||||
### Optional parameters
|
||||
|
||||
You can create a `new SplatMesh()` with no options, which will create a new default instance with `.numSplats=0`. Alternatively, you can provide an input `url` to fetch and decode, `fileBytes`, `packedSplats` (an existing collection of tightly "packed" Gsplats). Forge supports most Gsplat file types, including .ply, .splat, .ksplat, .spz. To load filetypes .splat and .ksplat (which can't be reliably auto-detected), use the optional `fileType` argument.
|
||||
|
||||
Constructor argument callbacks can be used like `constructSplats` to create a collection of Gsplat programmatically at initialization, `onLoad` when loading and initialization completes, `onFrame` to update state on every frame. Gsplat effects can be injected into the standard Gsplat processinng pipeline that operate in object-space and world-space via `objectModifier` and `worldModifier` respectively.
|
||||
|
||||
| **Parameter** | Description |
|
||||
| ----------------- | ----------- |
|
||||
| **url** | URL to fetch a Gaussian splat file from(supports .ply, .splat, .ksplat, .spz formats). (default: `undefined`)
|
||||
| **fileBytes** | Raw bytes of a Gaussian splat file to decode directly instead of fetching from URL. (default: `undefined`)
|
||||
| **fileType** | Override the file type detection for formats that can't be reliably auto-detected (.splat, .ksplat). (default: `undefined` auto-detects other formats from file contents)
|
||||
| **packedSplats** | Use an existing PackedSplats object as the source instead of loading from a file. Can be used to share a collection of Gsplats among multiple `SplatMesh`es (default: `undefined` creates a new empty `PackedSplats` or decoded from a data source above)
|
||||
| **maxSplats** | Reserve space for at least this many splats when constructing the mesh initially. (default: determined by file)
|
||||
| **constructSplats** | Callback function to programmatically create splats at initialization in provided `PackedSplats`. (default: `undefined`)
|
||||
| **onLoad** | Callback function that is called when mesh initialization is complete. (default: `undefined`)
|
||||
| **editable** | Controls whether SplatEdits have any effect on this mesh. (default: `true`)
|
||||
| **onFrame** | Callback function that is called every frame to update the mesh. Call `mesh.updateVersion()` if splats need to be regenerated due to some change. Calling `updateVersion()` is not necessary for object transformations, recoloring, or opacity adjustments as these are auto-detected. (default: `undefined`)
|
||||
| **objectModifier** | Gsplat modifier to apply in object-space before any transformations. A `GsplatModifier` is a `dyno` shader-graph block that transforms an input `gsplat: DynoVal<Gsplat>` to an output `gsplat: DynoVal<Gsplat>` with `gsplat.center` coordinate in object-space. (default: `undefined`)
|
||||
| **worldModifier** | Gsplat modifier to apply in world-space after transformations. (default: `undefined`)
|
||||
|
||||
## Instance properties
|
||||
|
||||
The constructor argument options `packedSplats`, `editable`, `onFrame`, `objectModifier`, and `worldModifier` can be modified directly on the `SplatMesh`.
|
||||
|
||||
If you modify `packedSplats` you should set `splatMesh.packedSplats.needsUpdate = true` to signal to Three.js that it should re-upload the data to the underlying texture. Use this sparingly with objects with smaller Gsplat counts as it requires a CPU-GPU data transfer for each frame. Thousands to tens of thousands of Gsplats ir fine. (See `hands.ts` for an example of rendering "Gsplat hands" in WebXR using this technique.)
|
||||
|
||||
If you modify `objectModifier` or `worldModifier` you should call `splatMesh.updateGenerator()` to update the pipeline and have it compile to run efficiently on the GPU.
|
||||
|
||||
Additional properties you can modify on a `SplatMesh` instance:
|
||||
|
||||
| **Property** | Description |
|
||||
| ----------------- | ----------- |
|
||||
| **initialized** | A `Promise<SplatMesh>` you can await to ensure fetching, parsing, and initialization has completed
|
||||
| **isInitialized** | A `boolean` indicating whether initialization is complete
|
||||
| **recolor** | A `THREE.Color` that can be used to tint all splats in the mesh. (default: `new THREE.Color(1, 1, 1)`)
|
||||
| **opacity** | Global opacity multiplier for all splats in the mesh. (default: `1`)
|
||||
| **context** | A `SplatMeshContext` consisting of useful scene and object `dyno` uniforms that can be used to in the Gsplat processing pipeline, for example via `objectModifier` and `worldModifier`. (created on construction)
|
||||
| **enableViewToObject** | Set to `true` to have the `viewToObject` property in `context` be updated each frame. If the mesh has `extra.sh1` (first order spherical harmonics directional lighting) this property will always be updated. (default: `false` )
|
||||
| **enableViewToWorld** | Set to `true` to have `context.viewToWorld` updated each frame. (default: `false`)
|
||||
| **enableWorldToView** | Set to `true` to have `context.worldToView` updated each frame. (default: `false`)
|
||||
| **skinning** | Optional `SplatSkinning` instance for animating splats with dual-quaternion skeletal animation. (default: `null`)
|
||||
| **edits** | Optional list of `SplatEdit`s to apply to the mesh. If `null`, any `SplatEdit` children in the scene graph will be added automatically. (default: `null`)
|
||||
| **splatRgba** | Optional `RgbaArray` to overwrite splat RGBA values with custom values. Useful for "baking" RGB and opacity edits into the `SplatMesh`. (default: `null`)
|
||||
| **maxSh** | Maximum Spherical Harmonics level to use. Call `updateGenerator()` after changing. (default: `3`)
|
||||
|
||||
## `dispose()`
|
||||
|
||||
Call this when you are finished with the `SplatMesh` and want to free any buffers it holds (via `packedSplats`).
|
||||
|
||||
## `pushSplat(center, scales, quaternion, opacity, color)`
|
||||
|
||||
Creates a new Gsplat with the provided parameters (all values in "float" space, i.e. 0-1 for opacity and color) and adds it to the end of the `packedSplats`, increasing `numSplats` by 1. If necessary, reallocates the buffer with an exponential doubling strategy to fit the new data, so it's fairly efficient to just `pushSplat(...)` each Gsplat you want to create in a loop.
|
||||
|
||||
## `forEachSplat(callback: (index, center, scales, quaternion, opacity, color) => void)`
|
||||
|
||||
This method iterates over all Gsplats in this instance's `packedSplats`, invoking the provided callback with `index: number` in `0..=(this.numSplats-1)` and `center: THREE.Vector3`, `scales: THREE.Vector3`, `quaternion: THREE.Quaternion`, `opacity: number` (0..1), and `color: THREE.Color` (rgb values in 0..1). Note that the objects passed in as `center` etc. are the same for every callback invocation: these objects are reused for efficiency. *Changing these values has no effect* as they are decoded/unpacked copies of the underlying data. To update the `packedSplats`, call `.packedSplats.setSplat(index, center, scales, quaternion, opacity, color)`.
|
||||
|
||||
## `updateGenerator()`
|
||||
|
||||
Call this whenever something changes in the Gsplat processing pipeline, for example changing `maxSh` or updating `objectModifier` or `worldModifier`. Compiled generators are cached for efficiency and re-use when the same pipeline structure emerges after successive changes.
|
||||
|
||||
## `update(...)`
|
||||
|
||||
This is called automatically by `ForgeRenderer` and you should not have to call it. It updates parameters for the generated pipeline and calls `updateGenerator()` if the pipeline needs to change.
|
||||
|
||||
## `raycast(raycaster, intersects: { distance, point, object}[])`
|
||||
|
||||
This method conforms to the standard `THREE.Raycaster` API, performing object-ray intersections using this method to populate the provided `intersects[]` array with each intersection point.
|
||||
|
||||
Usage example:
|
||||
```javascript
|
||||
const raycaster = new THREE.Raycaster();
|
||||
canvas.addEventListener("click", (event) => {
|
||||
raycaster.setFromCamera(new THREE.Vector2(
|
||||
(event.clientX / canvas.width) * 2 - 1,
|
||||
-(event.clientY / canvas.height) * 2 + 1,
|
||||
), camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children);
|
||||
const splatIndex = intersects.findIndex((i) => i.object instanceof SplatMesh);
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url(https://fonts.googleapis.com/css2?family=Cinzel:wght@400..900&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Limelight&display=swap);
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-dark: white;
|
||||
--color-light: black;
|
||||
--md-primary-fg-color: #d43e4c;
|
||||
--md-accent-fg-color: #cb6065;
|
||||
--md-text-font: "Inter";
|
||||
}
|
||||
|
||||
.md-typeset .md-button.md-button--primary {
|
||||
background-color: --md-primary-fg-color;
|
||||
border-color: --md-primary-fg-color;
|
||||
color: white;
|
||||
padding: 0.75em 2em;
|
||||
font-size: 1rem;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
|
||||
.md-button.md-button--primary:hover {
|
||||
opacity: 1.0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.md-header__button.md-logo {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.md-search__form {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.icon-github {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.icon-github path {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.md-logo {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.md-logo:hover {
|
||||
text-decoration: none;
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.md-header {
|
||||
padding: 5px;
|
||||
padding-top: 15px;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.md-header__inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.md-header__links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.md-nav {
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.md-nav__link {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md-nav__link:hover,
|
||||
.md-nav__link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.md-header__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.md-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.md-header__topic {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.md-custom-header,
|
||||
.md-header__title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.md-custom-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.md-custom-header-right .md-nav__link:hover {
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# System Design
|
||||
|
||||
One of the biggest challenges in real-time Gaussian splat (Gsplat) rendering is sorting the splats so that they can be drawn and blended in back-to-front order, known as Painter's algorithm.
|
||||
|
||||
## Rendering data flow cycle
|
||||
|
||||
`ForgeRenderer` is a key component in Forge that manages this process. It traverses the visible Three.js scene graph and compiles a complete list of all Gsplats across the scene, generated by instances of `SplatMesh` in the scene hierarchy.
|
||||
|
||||
Each `ForgeRenderer` has a default `ForgeViewpoint`, which reads back a list of all Gsplat distances from the viewpoint, the computes the Gsplat draw order using an efficient bucket sort algorithm, run in a background worker thread via `SplatWorker`. You can spawn additional `ForgeViewpoint`s to create multiple simultaneous render viewpoints.
|
||||
|
||||
Finally, on the next Three.js render() call, `ForgeRenderer` invokes a single instanced geometry draw call to draw all the scene's Gsplats in the correct back-to-front order, merging with other opaque Three.js geometry using the Z buffer.
|
||||
|
||||
```typescript
|
||||
const forge = new ForgeRenderer({ renderer: webGlRenderer });
|
||||
// Add it to the scene and it will manage rendering of SplatMeshes
|
||||
scene.add(forge);
|
||||
```
|
||||
|
||||
This design allows Gsplats from distinct scenes / object splat files to coexist in space and sort correctly w.r.t. each other's Gsplats. Gsplats from independent `SplatMesh`es are aggregated using a `SplatAccumulator`, which produces a `PackedSplats`, a collection Gsplats stored in a cache-efficient 16-byte/Gsplat format.
|
||||
|
||||
## "Programmable Gsplats"
|
||||
|
||||
Forge also uses this opportunity to run a user-programmable data pipeline on each Gsplat on the GPU. The standard pipeline provide high-level controls, such as rigid transforms, adjusting RGB / opacity, and spherical harmonics, but also special effects (via `SplatEdit`) and a skeletal animation system (`SplatSkinning`). The standard pipeline also allows injecting arbitrary code to modify each Gsplat via `dyno` shader graph system.
|
||||
|
||||
`SplatMesh` derives from a more general base class `SplatGenerator`, which itself derives from `THREE.Object3D`. As such, it can be placed anywhere in the scene hierarchy and obeys expected local and global coordinate transforms. A `SplatGenerator` is the most general form of a "Gsplat object", whose Gsplats are produced programmatically via a `dyno` shader graph that maps `{ index: "int" }` to `{ gsplat: "Gsplat" }`. A `SplatMesh` is a higher-level object that implements such a mapping, reading source Gsplats from a template (loaded via a `url` constructor parameter or otherwise) at the given `index`, then applying functions such as transforming to world space.
|
||||
|
||||
In contrast, implementing a `SplatGenerator` gives you full control to write any function that programmatically computes a Gsplat's attributes (center, scales, quaternion, rgba). These could be stateless (relying only on `index`, random-number generators, etc), or could rely on a complex combination of textures and global parameters for real-time procedural generation, and can vary with time to produce real-time animations.
|
||||
|
||||
The `dyno` shader graph system allows you to create these programmatic pipelines with Javascript code, which is synthesized into GLSL code and compiled and run on the GPU. This `dyno` system powers other components of Forge as well, such as `Readback` (which can perform any computation and read back the resulting value), used to compute the sort distance metric for pairs of Gsplats and read them back for CPU sorting.
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Animation • Skinning</title>
|
||||
<style> body {margin: 0;} </style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh, SplatSkinning } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
function gridToBoneIndex(x, y) {
|
||||
return 15 * (y + 7) + (x + 7);
|
||||
}
|
||||
|
||||
const splatURL = await getAssetFileURL("butterfly.spz");
|
||||
const butterfly = new SplatMesh({
|
||||
url: splatURL,
|
||||
onLoad: (mesh) => {
|
||||
// Make a 15x15 uniform grid of bones on the XY plane
|
||||
const skinning = new SplatSkinning({ mesh, numBones: 15 * 15 });
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
for (let y = -7; y <= 7; y++) {
|
||||
for (let x = -7; x <= 7; x++) {
|
||||
position.set(x * 0.05, y * 0.05, 0);
|
||||
skinning.setRestQuatPos(gridToBoneIndex(x, y), quaternion, position);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute bone indices and weights for each Gsplat such that
|
||||
// it uses the 4 nearest bones in a bilinear manner.
|
||||
const boneIndices = new THREE.Vector4();
|
||||
const weights = new THREE.Vector4();
|
||||
mesh.forEachSplat((index, center) => {
|
||||
const x = center.y / 0.05;
|
||||
const y = center.x / 0.05;
|
||||
const gridX = Math.max(-7, Math.min(6, Math.floor(x)));
|
||||
const gridY = Math.max(-7, Math.min(6, Math.floor(y)));
|
||||
const fractX = Math.max(0, Math.min(1, x - gridX));
|
||||
const fractY = Math.max(0, Math.min(1, y - gridY));
|
||||
boneIndices.set(
|
||||
gridToBoneIndex(gridX, gridY),
|
||||
gridToBoneIndex(gridX + 1, gridY),
|
||||
gridToBoneIndex(gridX, gridY + 1),
|
||||
gridToBoneIndex(gridX + 1, gridY + 1),
|
||||
);
|
||||
weights.set(
|
||||
(1 - fractX) * (1 - fractY),
|
||||
fractX * (1 - fractY),
|
||||
(1 - fractX) * fractY,
|
||||
fractX * fractY,
|
||||
);
|
||||
skinning.setSplatBones(index, boneIndices, weights);
|
||||
});
|
||||
|
||||
mesh.skinning = skinning;
|
||||
mesh.updateGenerator();
|
||||
},
|
||||
});
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, 0, -1);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
if (butterfly.skinning) {
|
||||
// Making sure async loading and initialization is complete
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
for (let y = -7; y <= 7; y++) {
|
||||
for (let x = -7; x <= 7; x++) {
|
||||
position.set(x * 0.05, y * 0.05, 0);
|
||||
// Rotate Gsplats to flap the wings
|
||||
quaternion.setFromEuler(
|
||||
new THREE.Euler(
|
||||
x * 0.09 * Math.sin(time * 0.0011),
|
||||
y * 0.05 * Math.cos(time * 0.0071),
|
||||
0.2 * Math.sin(time * 0.0011),
|
||||
),
|
||||
);
|
||||
butterfly.skinning.setBoneQuatPos(gridToBoneIndex(x, y), quaternion, position);
|
||||
}
|
||||
}
|
||||
butterfly.skinning.updateBones();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"robot-head.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/robot-head.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"forge.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/forge.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"butterfly-wings-closed.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/butterfly-wings-closed.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"butterfly.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/butterfly.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"fireplace.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/fireplace.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"branzino-amarin.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/branzino-amarin.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"burger-from-amboy.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/burger-from-amboy.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"clams-and-caviar-by-ikoyi.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/clams-and-caviar-by-ikoyi.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"coral-caviar.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/coral-caviar.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"double-double-from-InNOut.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/double-Double-from-InNOut.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"gyro.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/gyro.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"iberico-sandwich-by-reserve.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/iberico-sandwich-by-reserve.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"primerib-tamos.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/primerib-tamos.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"steaksandwich-mels.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/steaksandwich-mels.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"pad-thai.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/pad-thai.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"tomahawk-niku.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/food/tomahawk-niku.spz",
|
||||
"directory": "splats/food"
|
||||
},
|
||||
"rubberduck.glb": {
|
||||
"url": "https://forge-gfx.github.io/assets/models/rubberduck.glb",
|
||||
"directory": "models"
|
||||
},
|
||||
"table.glb": {
|
||||
"url": "https://forge-gfx.github.io/assets/models/table.glb",
|
||||
"directory": "models"
|
||||
},
|
||||
"butterfly.png": {
|
||||
"url": "https://forge-gfx.github.io/assets/images/butterfly.png",
|
||||
"directory": "images"
|
||||
},
|
||||
"furry.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/furry.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"pedestal.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/pedestal.spz",
|
||||
"directory": "splats"
|
||||
},
|
||||
"cat.spz": {
|
||||
"url": "https://forge-gfx.github.io/assets/splats/cat.spz",
|
||||
"directory": "splats"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Environment Mapping</title>
|
||||
<style>
|
||||
label {
|
||||
font-family: sans-serif;
|
||||
font-size: 20px;
|
||||
position: absolute;
|
||||
margin: 1rem auto;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<label><input type="checkbox" onchange="toggleMetal(this)" autocomplete="off"> Metal</label>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh, ForgeControls, ForgeRenderer } from "@forge-gfx/forge";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setClearColor(new THREE.Color(0x3b5272), 1.0);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Explicitly create a ForgeRenderer to render environment maps
|
||||
const forge = new ForgeRenderer({ renderer });
|
||||
|
||||
const background = new SplatMesh({ url: "https://d28zzqy0iyovbz.cloudfront.net/a58b478d/scene.compressed.ply" });
|
||||
|
||||
background.position.set(5, -10, -10);
|
||||
background.quaternion.set(1, 0, 0, 0);
|
||||
scene.add(background);
|
||||
// Make sure background is loaded before rendering env map
|
||||
await background.initialized;
|
||||
|
||||
// Add light to make the duck pop out a bit more
|
||||
const rectLight = new THREE.RectAreaLight(0xffffff, 3, 2, 2);
|
||||
rectLight.position.set(0, 1, 1);
|
||||
rectLight.lookAt(0, 0, 0);
|
||||
scene.add(rectLight)
|
||||
|
||||
// Add rubber duck
|
||||
const gltfLoader = new GLTFLoader();
|
||||
const modelURL = await getAssetFileURL('rubberduck.glb');
|
||||
const gltf = await gltfLoader.loadAsync(modelURL);
|
||||
const duck = gltf.scene;
|
||||
duck.position.set(0, -0.2, -1);
|
||||
duck.scale.set(3, 3, 3);
|
||||
scene.add(duck);
|
||||
|
||||
let renderedEnvMap = false;
|
||||
|
||||
const controls = new ForgeControls({ canvas: renderer.domElement });
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
controls.update(camera);
|
||||
renderer.render(scene, camera);
|
||||
duck.rotation.y += 0.01;
|
||||
|
||||
// Make sure we only launch async renderEnvMap once
|
||||
if (!renderedEnvMap) {
|
||||
renderedEnvMap = true;
|
||||
forge.renderEnvMap({
|
||||
scene,
|
||||
// World center to render from
|
||||
worldCenter: duck.position,
|
||||
// Hide the donut so we don't obscure the environment
|
||||
hideObjects: [duck],
|
||||
// Not needed in normal use, guards against "first render" issues
|
||||
update: true,
|
||||
})
|
||||
.then((envMap) => {
|
||||
for (let obj of duck.children) {
|
||||
// Set some reflectivity in the materials of the duck
|
||||
obj.material.envMap = envMap;
|
||||
obj.material.original_color = obj.material.color.clone();
|
||||
obj.material.metalness = 0.2;
|
||||
obj.material.roughness = 0.1;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Toggles between normal duck and metal duck
|
||||
window.toggleMetal = function toggleMetal(el) {
|
||||
const metal = el.checked;
|
||||
for (let obj of duck.children) {
|
||||
obj.material.color.set(metal ? 0xAAAAAA : obj.material.original_color);
|
||||
obj.material.metalness = metal ? 1.0 : 0.2;
|
||||
obj.material.roughness = metal ? 0.02 : 0.1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Carousel</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
// Setup mouse controls to orbit the camera around
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0, -3);
|
||||
controls.minDistance = 2;
|
||||
controls.maxDistance = 3;
|
||||
controls.enablePan = false;
|
||||
controls.enabled = true;
|
||||
controls.update();
|
||||
|
||||
const ASSETS = [
|
||||
{ file: "butterfly.spz", scale: 1.2 },
|
||||
{ file: "furry.spz", scale: 0.45 },
|
||||
{ file: "forge.spz", scale: 1 },
|
||||
];
|
||||
const assets = [];
|
||||
|
||||
const pedestalURL = await getAssetFileURL("pedestal.spz");
|
||||
const pedestalSplat = new SplatMesh({ url: pedestalURL });
|
||||
pedestalSplat.scale.set(0.4, 0.4, 0.4);
|
||||
pedestalSplat.position.set(0, -2.25, -3);
|
||||
pedestalSplat.opacity = 0.0;
|
||||
scene.add(pedestalSplat);
|
||||
|
||||
for (let asset of ASSETS) {
|
||||
const splatURL = await getAssetFileURL(asset.file);
|
||||
const splat = new SplatMesh({ url: splatURL });
|
||||
splat.quaternion.set(1, 0, 0, 0);
|
||||
splat.position.set(0, 0, -3);
|
||||
splat.scale.setScalar(asset.scale);
|
||||
assets.push(splat);
|
||||
}
|
||||
|
||||
let current = 0;
|
||||
scene.add(assets[current]);
|
||||
|
||||
const FADE_TIME = 1000; //ms
|
||||
let fadeOut = null;
|
||||
let fadeIn = null;
|
||||
|
||||
let prevTime;
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
const dt = time - (prevTime ?? time);
|
||||
prevTime = time;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
assets[current].rotation.y = time / 1500;
|
||||
controls.update();
|
||||
|
||||
// fade out
|
||||
if (fadeOut !== null) {
|
||||
fadeOut += dt;
|
||||
|
||||
if (fadeOut < FADE_TIME) {
|
||||
assets[current].opacity = 1 - easeInOutSine(fadeOut / FADE_TIME);
|
||||
} else {
|
||||
fadeOut = null;
|
||||
fadeIn = 0;
|
||||
scene.remove(assets[current]);
|
||||
current = (current + 1) % assets.length;
|
||||
scene.add(assets[current]);
|
||||
}
|
||||
}
|
||||
|
||||
// fade in
|
||||
if (fadeIn !== null) {
|
||||
fadeIn += dt;
|
||||
pedestalSplat.opacity = ASSETS[current].file === "furry.spz" ? 1 : 0;
|
||||
if (fadeIn < FADE_TIME) {
|
||||
assets[current].opacity = easeInOutSine(fadeIn / FADE_TIME);
|
||||
} else {
|
||||
fadeIn = null;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// start fadeout every x seconds
|
||||
setInterval(() => { fadeOut = 0 }, 5000);
|
||||
|
||||
function easeInOutSine(x) {
|
||||
return -(Math.cos(Math.PI * x) - 1) / 2;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Hello World</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const splatURL = await getAssetFileURL("butterfly.spz");
|
||||
const butterfly = new SplatMesh({ url: splatURL });
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, 0, -3);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="refresh" content="0;url=/" />
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
// Definition of all the food assets and some attributes for each
|
||||
export const FOOD_ASSETS = [
|
||||
{
|
||||
name: "Branzino Amarin",
|
||||
file: "branzino-amarin.spz",
|
||||
scale: 0.3,
|
||||
shadowSize: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Iberico Sandwich",
|
||||
file: "iberico-sandwich-by-reserve.spz",
|
||||
scale: 0.6,
|
||||
shadowSize: 0,
|
||||
offsetY: -0.05,
|
||||
},
|
||||
{
|
||||
name: "Coral Caviar",
|
||||
file: "coral-caviar.spz",
|
||||
scale: 0.4,
|
||||
shadowSize: 0.7,
|
||||
},
|
||||
{
|
||||
name: "Pad Thai",
|
||||
file: "pad-thai.spz",
|
||||
scale: 0.2,
|
||||
shadowSize: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Prime Rib",
|
||||
file: "primerib-tamos.spz",
|
||||
scale: 0.3,
|
||||
shadowSize: 1.1,
|
||||
},
|
||||
{
|
||||
name: "Double-Double",
|
||||
file: "double-double-from-InNOut.spz",
|
||||
scale: 0.5,
|
||||
shadowSize: 0.6,
|
||||
offsetY: 0.03,
|
||||
},
|
||||
{
|
||||
name: "Gyro Kebab",
|
||||
file: "gyro.spz",
|
||||
scale: 0.13,
|
||||
shadowSize: 0,
|
||||
offsetY: -0.13,
|
||||
},
|
||||
{
|
||||
name: "Mels Steak Sandwich",
|
||||
file: "steaksandwich-mels.spz",
|
||||
scale: 0.3,
|
||||
shadowSize: 0,
|
||||
offsetY: 0.08,
|
||||
},
|
||||
{
|
||||
name: "Clams and Caviar",
|
||||
file: "clams-and-caviar-by-ikoyi.spz",
|
||||
scale: 0.4,
|
||||
shadowSize: 0.72,
|
||||
},
|
||||
{
|
||||
name: "Tomahawk Niku",
|
||||
file: "tomahawk-niku.spz",
|
||||
scale: 0.3,
|
||||
shadowSize: 1.0,
|
||||
},
|
||||
{
|
||||
name: "Dh Burger",
|
||||
file: "burger-from-amboy.spz",
|
||||
scale: 0.4,
|
||||
shadowSize: 0.6,
|
||||
offsetY: 0.04,
|
||||
},
|
||||
];
|
||||
export const FOOD_URL = "/examples/assets/splats/food/";
|
||||
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Multiple Splats</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<img src="loading.gif" alt="loading" id="loading">
|
||||
<div id="menu">
|
||||
<div class="border">
|
||||
<div class="border">
|
||||
<h3>Tipatat's Splat Restaurant</h3>
|
||||
<h1> Menu </h1>
|
||||
<h2>CHEF SPECIALS</h2>
|
||||
<div id="menu_list"></div>
|
||||
<h2>Food scans by <a href="https://x.com/tipatat" target="_blank">Tipatat</a></h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { FOOD_ASSETS, FOOD_URL } from './food.js';
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
// Add food items to the menu
|
||||
FOOD_ASSETS.forEach((food, i) => {
|
||||
const el = document.createElement("a");
|
||||
el.textContent = food.name;
|
||||
el.href = 'javascript:;';
|
||||
el.addEventListener('click', async function () {
|
||||
await switchToFood(i);
|
||||
});
|
||||
document.getElementById('menu_list').appendChild(el);
|
||||
});
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Setup camera
|
||||
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(0, 0.9, -1.2);
|
||||
|
||||
// Setup lighting
|
||||
const spotLight = new THREE.SpotLight(0xffcc88);
|
||||
spotLight.position.set(0, 1, 0);
|
||||
spotLight.castShadow = true;
|
||||
spotLight.shadow.mapSize.width = 1024;
|
||||
spotLight.shadow.mapSize.height = 1024;
|
||||
spotLight.shadow.camera.near = 0.1;
|
||||
spotLight.shadow.camera.far = 5;
|
||||
spotLight.angle = 0.9;
|
||||
spotLight.penumbra = 1;
|
||||
spotLight.intensity = 3;
|
||||
scene.add(spotLight);
|
||||
|
||||
const fillLight = new THREE.PointLight(0xffcc88, 0.2);
|
||||
fillLight.position.set(0, 0, -3);
|
||||
scene.add(fillLight);
|
||||
|
||||
// Splats don't project shadows, so we add a cylinder below the spotlight to fake one ;)
|
||||
const geometry = new THREE.CylinderGeometry(0.45, 0.45, 0.04, 40, 1);
|
||||
const material = new THREE.MeshPhongMaterial({ colorWrite: false, depthWrite: false });
|
||||
const shadow = new THREE.Mesh(geometry, material);
|
||||
shadow.visible = false;
|
||||
shadow.castShadow = true;
|
||||
shadow.position.set(0, 0.1, 0);
|
||||
scene.add(shadow);
|
||||
|
||||
// Add table
|
||||
const gltfLoader = new GLTFLoader();
|
||||
const modelURL = await getAssetFileURL("table.glb");
|
||||
const gltfTable = await gltfLoader.loadAsync(modelURL);
|
||||
const table = gltfTable.scene;
|
||||
// Set the table cloth to receive shadows
|
||||
const tableCloth = table.children.find(item => item.name == 'cover');
|
||||
tableCloth.receiveShadow = true;
|
||||
scene.add(table);
|
||||
|
||||
// Add floor
|
||||
const plane = new THREE.PlaneGeometry(10, 10);
|
||||
const floormat = new THREE.MeshPhongMaterial({ color: 0x777777 });
|
||||
const floor = new THREE.Mesh(plane, floormat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.set(0, -1.397, 0);
|
||||
scene.add(floor);
|
||||
|
||||
// Setup mouse controls to orbit the camera around
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0.2, 0, 0);
|
||||
controls.minDistance = 0.8;
|
||||
controls.maxDistance = 2.3;
|
||||
controls.enablePan = false;
|
||||
controls.update();
|
||||
|
||||
// Current and next food splats
|
||||
let food, nextFood;
|
||||
|
||||
const loadingIcon = document.getElementById('loading');
|
||||
// Transition length in frames.
|
||||
// Two transitions: one for fading out the old food, another for fading in the next one
|
||||
const TRANSITION_LENGTH = 60;
|
||||
// Transition timers. `null` if transition is not active
|
||||
let fadeOutTime = null;
|
||||
let fadeInTime = null;
|
||||
|
||||
// Load first food by default
|
||||
await switchToFood(0);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
const rotation = time / 10000;
|
||||
table.rotation.y = rotation;
|
||||
if (food) food.rotation.y = -rotation;
|
||||
if (nextFood) nextFood.rotation.y = -rotation;
|
||||
|
||||
|
||||
// fade out
|
||||
if (fadeOutTime !== null) {
|
||||
fadeOutTime++;
|
||||
if (fadeOutTime < TRANSITION_LENGTH) {
|
||||
if (food) food.opacity = 1 - easeInOutSine(fadeOutTime / TRANSITION_LENGTH);
|
||||
} else {
|
||||
// Fade out finished
|
||||
if (food) food.dispose();
|
||||
fadeOutTime = null;
|
||||
// Fade in next food
|
||||
fadeInTime = 0;
|
||||
shadow.visible = true;
|
||||
shadow.scale.setScalar(shadow.nextScale);
|
||||
}
|
||||
}
|
||||
|
||||
// fade in
|
||||
if (fadeInTime != null && nextFood.isInitialized) {
|
||||
fadeInTime++;
|
||||
if (fadeInTime < TRANSITION_LENGTH) {
|
||||
nextFood.opacity = easeInOutSine(fadeInTime / TRANSITION_LENGTH);
|
||||
} else {
|
||||
|
||||
// Fade in finished
|
||||
food = nextFood;
|
||||
fadeInTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
// Change food from menu link
|
||||
async function switchToFood(foodIndex) {
|
||||
|
||||
// already transitioning
|
||||
if (fadeOutTime !== null || fadeInTime !== null) return;
|
||||
|
||||
const foodItem = FOOD_ASSETS[foodIndex];
|
||||
|
||||
const splatURL = await getAssetFileURL(foodItem.file);
|
||||
nextFood = new SplatMesh({ url: splatURL });
|
||||
nextFood.quaternion.set(1, 0, 0, 0);
|
||||
|
||||
// Customize splat depending on the settings set for this food
|
||||
if (foodItem['offsetY']) nextFood.position.set(0, foodItem.offsetY, 0);
|
||||
nextFood.scale.setScalar(foodItem.scale);
|
||||
shadow.nextScale = foodItem.shadowSize;
|
||||
|
||||
// Set opacity to 0 to prepare the fade in transition
|
||||
nextFood.opacity = 0;
|
||||
|
||||
// Setup shadow to the required size, and make it visible only when the splat is initialized
|
||||
nextFood.initialized.then(() => {
|
||||
shadow.visible = false;
|
||||
loadingIcon.style.display = 'none';
|
||||
fadeOutTime = 0; // Fade in next food
|
||||
});
|
||||
|
||||
scene.add(nextFood);
|
||||
|
||||
// toggle menu item
|
||||
const menu_items = document.getElementById('menu_list').children;
|
||||
for (let i = 0; i < menu_items.length; i++) {
|
||||
if (foodIndex == i) {
|
||||
loadingIcon.style.display = 'inline';
|
||||
menu_items[i].appendChild(loadingIcon);
|
||||
}
|
||||
menu_items[i].classList.toggle('active', foodIndex == i);
|
||||
}
|
||||
}
|
||||
|
||||
function easeInOutSine(x) {
|
||||
return -(Math.cos(Math.PI * x) - 1) / 2;
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,91 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Mea+Culpa&display=swap");
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Mea Culpa", cursive;
|
||||
font-weight: 500;
|
||||
font-size: 5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: sans-serif;
|
||||
font-weight: normal;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: "Mea Culpa", cursive;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#menu {
|
||||
position: absolute;
|
||||
left: -12rem;
|
||||
display: inline-block;
|
||||
background-color: rgba(223, 203, 171, 0.8);
|
||||
padding: 2rem 2rem;
|
||||
margin-top: 4rem;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.7rem;
|
||||
left: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
#menu .border {
|
||||
border: 1.0px solid rgb(92, 92, 92);
|
||||
padding: 1rem 3rem;
|
||||
}
|
||||
|
||||
#menu > .border {
|
||||
border: 1.5px solid rgb(49, 49, 49);
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
#menu a {
|
||||
font-style: italic;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: rgb(30, 13, 140);
|
||||
}
|
||||
|
||||
#menu a:hover {
|
||||
/* border-bottom: 1px solid black; */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#menu a.active {
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#menu a.active::before {
|
||||
content: "🙜 ";
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
#menu a.active::after {
|
||||
content: " 🙟";
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
#menu_list {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#menu h2 a {
|
||||
font-weight: normal;
|
||||
color: black;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
#loading {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 40vh;
|
||||
left: 45vw;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export async function getAssetFileURL(assetFile) {
|
||||
try {
|
||||
const response = await fetch("/examples/assets.json");
|
||||
const assetsDirectory = "/examples/assets/";
|
||||
const assetsInfo = await response.json();
|
||||
let url = assetsInfo[assetFile].url;
|
||||
if (window.forgeLocalAssets) {
|
||||
url = `${assetsDirectory}${assetsInfo[assetFile].directory}/${assetFile}`;
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error("Failed to load asset file URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Multiple Splats</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a,
|
||||
footer {
|
||||
color: #797979
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
margin: 10px;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<footer>
|
||||
<div>Food scans by <a href="https://x.com/tipatat">Tipatat</a></div>
|
||||
</footer>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
import { FOOD_ASSETS } from "/examples/interactivity/food.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
camera.position.set(0, 0, 6.5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setClearColor(new THREE.Color(0xFFFFFF), 1);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// add food
|
||||
const foods = [];
|
||||
for (let i = 0; i < FOOD_ASSETS.length; i++) {
|
||||
let splatURL = await getAssetFileURL(FOOD_ASSETS[i].file);
|
||||
const splat = new SplatMesh({ url: splatURL });
|
||||
splat.quaternion.set(1, 0, 0, 0);
|
||||
splat.scale.setScalar(FOOD_ASSETS[i].scale);
|
||||
scene.add(splat);
|
||||
foods.push(splat);
|
||||
}
|
||||
|
||||
// add food master
|
||||
let splatURL = await getAssetFileURL("cat.spz");
|
||||
const master = new SplatMesh({ url: splatURL });
|
||||
master.quaternion.set(1, 0, 0, 0);
|
||||
master.scale.setScalar(0.5);
|
||||
scene.add(master);
|
||||
|
||||
// Setup mouse controls to orbit the camera around
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.minDistance = 0.3;
|
||||
controls.maxDistance = 20;
|
||||
controls.update();
|
||||
|
||||
const RADIUS = 2;
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
controls.update();
|
||||
|
||||
// Rotate food
|
||||
for (let i = 0; i < foods.length; i++) {
|
||||
const ang = (-time / 10000) + i / FOOD_ASSETS.length * Math.PI * 2;
|
||||
foods[i].position.set(Math.cos(ang) * RADIUS, Math.sin(ang) * RADIUS - 0.2, 0);
|
||||
foods[i].rotation.y = i + time / 4000;
|
||||
}
|
||||
|
||||
// Animate food master
|
||||
master.position.y = -0.8 + Math.sin(time / 1000) * 0.1;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Multiple Viewpoints</title>
|
||||
<style> body {margin: 0;} </style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { ForgeRenderer, ForgeViewpoint, SplatMesh } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x000000);
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Explicitly create a ForgeRenderer in the scene to spawn new viewpoints
|
||||
const forge = new ForgeRenderer({ renderer });
|
||||
|
||||
const splatURL = await getAssetFileURL("butterfly.spz");
|
||||
const butterfly = new SplatMesh({ url: splatURL});
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, 0, -1);
|
||||
scene.add(butterfly);
|
||||
|
||||
function createViewpoint(origin, screenPos) {
|
||||
// Create a textured rectangle at the near plane to show the viewpoint
|
||||
const width = 320;
|
||||
const height = 240;
|
||||
const screen = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(0.5 * camera.near, 0.5 * camera.near * height / width),
|
||||
new THREE.MeshBasicMaterial({ map: ForgeViewpoint.EMPTY_TEXTURE }),
|
||||
);
|
||||
screen.position.set(screenPos.x, screenPos.y, -1).multiplyScalar(camera.near);
|
||||
scene.add(screen);
|
||||
|
||||
const viewCamera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
|
||||
viewCamera.position.copy(origin);
|
||||
viewCamera.lookAt(butterfly.position);
|
||||
|
||||
// Create a cone to show the viewpoint camera
|
||||
const cone = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.1, 0.1, 32),
|
||||
new THREE.MeshNormalMaterial(),
|
||||
);
|
||||
cone.position.copy(viewCamera.position);
|
||||
cone.quaternion.copy(viewCamera.quaternion);
|
||||
cone.rotateX(Math.PI / 2);
|
||||
scene.add(cone);
|
||||
|
||||
// Spawn a new viewpoint that auto-updates its sort order, double
|
||||
// buffered in case the texture is used recursively in the scene
|
||||
const viewpoint = forge.newViewpoint({
|
||||
autoUpdate: true,
|
||||
camera: viewCamera,
|
||||
target: { width, height, doubleBuffer: true },
|
||||
onTextureUpdated: (texture) => {
|
||||
// Update the view screen with the rendered viewpoint
|
||||
screen.material.map = texture;
|
||||
},
|
||||
});
|
||||
return viewpoint;
|
||||
}
|
||||
|
||||
const viewpoints = [
|
||||
createViewpoint(new THREE.Vector3(0, 0.5, -1), new THREE.Vector2(-0.5, 0.5)),
|
||||
createViewpoint(new THREE.Vector3(0.5, -0.5, -1), new THREE.Vector2(-0.5, -0.5)),
|
||||
];
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
scene.background.set(0x000000);
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
|
||||
// Adjust the background for visibility and render the viewpoints
|
||||
scene.background.set(0x000040);
|
||||
for (const viewpoint of viewpoints) {
|
||||
viewpoint.renderTarget({ scene, camera: viewpoint.camera });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Procedural Splats</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { ForgeControls, SplatMesh, constructGrid, constructAxes, textSplats, imageSplats } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Generate a Sierpinski pyramid one splat at a time via iteration
|
||||
const pyramid = new SplatMesh({
|
||||
constructSplats: (splats) => {
|
||||
const NUM_SPLATS = 500000;
|
||||
const ITERATIONS = 15;
|
||||
const CORNERS = [
|
||||
new THREE.Vector3(0, 1, -0.7),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0.6, 1, 0.5),
|
||||
new THREE.Vector3(-0.6, 1, 0.5),
|
||||
];
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
const scales = new THREE.Vector3().setScalar(0.0005);
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const opacity = 1;
|
||||
const color = new THREE.Color(1, 1, 1);
|
||||
|
||||
for (let i = 0; i < NUM_SPLATS; i++) {
|
||||
center.set(0, 0, 0);
|
||||
for (let iter = 0; iter < ITERATIONS; iter++) {
|
||||
const corner = CORNERS[Math.floor(Math.random() * 4)];
|
||||
center.add(corner).multiplyScalar(0.5);
|
||||
}
|
||||
color.set(0.3 + center.x - center.z * 0.5, center.z * 1.5, 0.5 + center.z * 2);
|
||||
splats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
}
|
||||
},
|
||||
});
|
||||
pyramid.position.set(0, -0.5, -2);
|
||||
scene.add(pyramid);
|
||||
|
||||
const stars = new SplatMesh({
|
||||
constructSplats: (splats) => {
|
||||
const NUM_SPLATS = 100000;
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
const scales = new THREE.Vector3().setScalar(0.008);
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const opacity = 1;
|
||||
const color = new THREE.Color(1, 1, 1);
|
||||
|
||||
for (let i = 0; i < NUM_SPLATS; i++) {
|
||||
// Distribute stars on a 50x50x50 cube
|
||||
center.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(50);
|
||||
// Random purple-ish color
|
||||
color.set(0.4 + Math.random() * 0.3, 0.5, 0.7 + Math.random() * 0.3);
|
||||
splats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
}
|
||||
},
|
||||
});
|
||||
stars.position.set(0, -0.5, -2);
|
||||
scene.add(stars);
|
||||
|
||||
// Generate splats from rendered text
|
||||
const text = textSplats({
|
||||
text: "I GSplats!",
|
||||
font: "Arial",
|
||||
fontSize: 60,
|
||||
color: new THREE.Color(0xE420B7),
|
||||
});
|
||||
text.scale.setScalar(0.5 / 80);
|
||||
text.position.set(0.15, -1, -2);
|
||||
scene.add(text);
|
||||
|
||||
const text2 = textSplats({
|
||||
text: "WASD + mouse to move",
|
||||
font: "Verdana",
|
||||
fontSize: 30,
|
||||
color: new THREE.Color(0x49EAFF)
|
||||
});
|
||||
text2.scale.setScalar(0.2 / 40);
|
||||
text2.position.set(0, 1.0, -2);
|
||||
scene.add(text2);
|
||||
|
||||
// Generate splats from image pixels with alpha channel
|
||||
const imageURL = await getAssetFileURL("butterfly.png");
|
||||
const image = imageSplats({ url: imageURL });
|
||||
image.scale.setScalar(0.7 / 400);
|
||||
image.position.set(-0.5, -1, -2);
|
||||
scene.add(image);
|
||||
|
||||
const controls = new ForgeControls({ canvas: renderer.domElement });
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
controls.update(camera);
|
||||
renderer.render(scene, camera);
|
||||
|
||||
pyramid.rotation.y = time / 5000;
|
||||
|
||||
// Move stars back and forth
|
||||
stars.position.z = Math.sin(time / 10000) * 10;
|
||||
// Rotate butterfly at different rates on each axis
|
||||
image.rotation.set(
|
||||
-0.3 + 0.35 * Math.sin(time / 190),
|
||||
0.35 * Math.sin(time / 390),
|
||||
0.1 * Math.sin(time / 200)
|
||||
);
|
||||
// Oscilate butterfly y position
|
||||
image.position.y = -0.9 + 0.04 * Math.sin(time / 330);
|
||||
|
||||
// Animate opacity of top text
|
||||
text2.opacity = Math.abs(Math.sin(time / 1000) * 0.3);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Raycasting</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
position: absolute;
|
||||
color: silver;
|
||||
font-family: sans-serif;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
width: 100vw;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>Click to select</header>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh, PackedSplats } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 10);
|
||||
camera.position.set(0, -0.25, -1.5);
|
||||
camera.lookAt(0, -0.15, 0);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Add robots
|
||||
const NUM_ROBOTS = 5;
|
||||
const robots = [];
|
||||
const splatURL = await getAssetFileURL("robot-head.spz");
|
||||
for (let i = 0; i < NUM_ROBOTS; i++) {
|
||||
const robot = new SplatMesh({ url: splatURL });
|
||||
robot.rotation.x = Math.PI;
|
||||
robot.position.set(0, 0, i);
|
||||
robot.speed = 600 + i * 10;
|
||||
robot.angle = 0;
|
||||
robot.clickedTimer = false;
|
||||
scene.add(robot);
|
||||
robots.push(robot);
|
||||
}
|
||||
|
||||
// Raycast logic
|
||||
const raycaster = new THREE.Raycaster();
|
||||
renderer.domElement.addEventListener("click", (event) => {
|
||||
const clickCoords = new THREE.Vector2(
|
||||
(event.clientX / renderer.domElement.width) * 2 - 1,
|
||||
-(event.clientY / renderer.domElement.height) * 2 + 1,
|
||||
);
|
||||
|
||||
// Run raycaster
|
||||
raycaster.setFromCamera(clickCoords, camera);
|
||||
const hits = raycaster.intersectObjects(scene.children);
|
||||
|
||||
// Find first SplatMesh
|
||||
const index = hits.findIndex(hit => hit.object instanceof SplatMesh);
|
||||
if (index >= 0) {
|
||||
const robot = hits[index].object;
|
||||
if (!robot.clickedTimer) {
|
||||
// Tint object and start countdown timer to remove tint
|
||||
robot.recolor.set(1, 0.6, 0.6);
|
||||
robot.clickedTimer = 40;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Main loop
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
// Animate all robots, and check if it's time to remove tint
|
||||
for (let robot of robots) {
|
||||
// Pendulum movement
|
||||
robot.position.x = Math.cos(robot.angle) * 0.6;
|
||||
robot.position.y = Math.sin(robot.angle) * 0.6;
|
||||
robot.angle = Math.PI * 1.5 + Math.sin(time / robot.speed);
|
||||
|
||||
if (robot.clickedTimer > 0) {
|
||||
robot.clickedTimer -= 1;
|
||||
if (robot.clickedTimer == 0) {
|
||||
// Clicked timer expired, remove tint
|
||||
robot.recolor.set(1, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,222 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • VFX Dynamic Lighting</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
header {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
margin: 1rem 2rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: sans-serif;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<label><input type="checkbox" id="debug" autocomplete="off"> Debug Light SDFs</label>
|
||||
<label><input type="checkbox" id="daynight" checked="checked" autocomplete="off"> Enable Lighting</label>
|
||||
</header>
|
||||
|
||||
<canvas id="canvas"></canvas>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
SplatMesh,
|
||||
SplatEdit,
|
||||
ForgeRenderer,
|
||||
SplatEditSdf,
|
||||
SplatEditSdfType,
|
||||
SplatEditRgbaBlendMode,
|
||||
FpsMovement,
|
||||
PointerControls,
|
||||
} from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
|
||||
camera.position.set(0, 0.3, -2.5);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
// Create a ForgeRenderer and add it to the scene to render all the Gsplats.
|
||||
const forge = new ForgeRenderer({ renderer });
|
||||
scene.add(forge);
|
||||
|
||||
const splatURL = await getAssetFileURL("fireplace.spz");
|
||||
const fireplace = new SplatMesh({ url: splatURL });
|
||||
fireplace.quaternion.set(1, 0, 0, 0);
|
||||
fireplace.position.set(0, -1, -10);
|
||||
scene.add(fireplace);
|
||||
|
||||
const fpsMovement = new FpsMovement({ moveSpeed: 2 });
|
||||
const pointerControls = new PointerControls({ canvas });
|
||||
|
||||
let night = true;
|
||||
const lights = [];
|
||||
// Debug SDF spheres
|
||||
const helpers = [];
|
||||
|
||||
function createLight(scene, lightingLayer, position, color, radius, opacity) {
|
||||
const light = new SplatEditSdf({
|
||||
type: SplatEditSdfType.SPHERE,
|
||||
color: color,
|
||||
radius: radius,
|
||||
opacity: opacity,
|
||||
});
|
||||
light.position.copy(position);
|
||||
|
||||
// create a wireframe helper
|
||||
const helper = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(radius, 16, 16),
|
||||
new THREE.MeshBasicMaterial({ wireframe: true, color: color }),
|
||||
);
|
||||
helper.position.copy(light.position);
|
||||
helper.visible = false;
|
||||
scene.add(helper);
|
||||
helpers.push(helper);
|
||||
|
||||
lightingLayer.add(light);
|
||||
lights.push(light);
|
||||
}
|
||||
|
||||
// Create lighting layer
|
||||
const emberLayer = new SplatEdit({
|
||||
rgbaBlendMode: SplatEditRgbaBlendMode.ADD_RGBA,
|
||||
sdfSmooth: 0.1,
|
||||
softEdge: 0.8,
|
||||
});
|
||||
scene.add(emberLayer);
|
||||
|
||||
const lightingLayer = new SplatEdit({
|
||||
rgbaBlendMode: SplatEditRgbaBlendMode.ADD_RGBA,
|
||||
sdfSmooth: 0.1,
|
||||
softEdge: 1.4,
|
||||
});
|
||||
scene.add(lightingLayer);
|
||||
|
||||
const ambientLayer = new SplatEdit({
|
||||
rgbaBlendMode: SplatEditRgbaBlendMode.DARKEN,
|
||||
sdfSmooth: 0.1,
|
||||
softEdge: 0.05,
|
||||
});
|
||||
scene.add(ambientLayer);
|
||||
|
||||
// glowing embers at the base of the fire
|
||||
createLight(
|
||||
scene,
|
||||
emberLayer,
|
||||
new THREE.Vector3(0.5, -1.0, -10.5),
|
||||
new THREE.Color(1, 0.6, 0.4),
|
||||
0.75,
|
||||
1,
|
||||
);
|
||||
|
||||
// light in the fireplace
|
||||
createLight(
|
||||
scene,
|
||||
lightingLayer,
|
||||
new THREE.Vector3(0.3, -1.1, -10.6),
|
||||
new THREE.Color(1, 0.95, 0.2),
|
||||
1.6,
|
||||
0.9,
|
||||
);
|
||||
|
||||
// ambient light throughout the full room
|
||||
createLight(
|
||||
scene,
|
||||
ambientLayer,
|
||||
new THREE.Vector3(0, 1, -11),
|
||||
new THREE.Color(1, 0.8, 0.6),
|
||||
6,
|
||||
0.8,
|
||||
);
|
||||
console.log(helpers);
|
||||
|
||||
let lastTime;
|
||||
|
||||
renderer.setAnimationLoop(time => {
|
||||
const timeSeconds = time * 0.0005;
|
||||
const deltaTime = timeSeconds - (lastTime ?? timeSeconds);
|
||||
lastTime = timeSeconds;
|
||||
|
||||
// fpsMovement.update(deltaTime, camera);
|
||||
pointerControls.update(deltaTime, camera);
|
||||
camera.position.z = -2.5 - Math.sin(timeSeconds * 0.1) * 2;
|
||||
renderer.render(scene, camera);
|
||||
|
||||
// Create flickering fire effect
|
||||
const baseHue = 0.04; // Orange-red base hue
|
||||
const fireHue = 0.03; // Yellow base hue
|
||||
const hueVariation = 0.03; // Slight variation in hue
|
||||
|
||||
// Add some randomness to the flicker
|
||||
const randomFlicker = Math.sin(timeSeconds * 4) * 0.5 + 0.5;
|
||||
|
||||
// we'll combine these to make a more natural flickering effect
|
||||
const mediumFlicker = Math.sin(timeSeconds * 13) * 0.1 + 0.1; // Medium flicker
|
||||
const fastFlicker = Math.sin(timeSeconds * 20) * 0.1 + 0.1; // Fast flicker
|
||||
const slowFlicker = Math.sin(timeSeconds * 6) * 0.04 + 0.5; // Slow base flicker
|
||||
// Combine the flickers
|
||||
const combinedFlicker = (slowFlicker + mediumFlicker + fastFlicker) / 3;
|
||||
|
||||
for (let i = 0; i < lights.length - 1; i++) {
|
||||
const h = baseHue + combinedFlicker * hueVariation; // Slightly varying orange-red hue
|
||||
const s = 0.5 + randomFlicker * 0.3; // High saturation with slight variation
|
||||
const l = 0.5 + combinedFlicker * 0.2; // Varying brightness
|
||||
lights[i].color.setHSL(h, s, l);
|
||||
lights[i].visible = night;
|
||||
}
|
||||
|
||||
lights[lights.length - 1].visible = night;
|
||||
lights[lights.length - 1].color.setHSL(baseHue, 0.5, combinedFlicker);
|
||||
});
|
||||
|
||||
document.getElementById('debug').addEventListener('change', ev => {
|
||||
const visible = ev.target.checked;
|
||||
for (let helper of helpers) {
|
||||
helper.visible = visible;
|
||||
}
|
||||
});
|
||||
document.getElementById('daynight').addEventListener('change', ev => {
|
||||
night = !night;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Hello World</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const splatURL = await getAssetFileURL("butterfly-wings-closed.spz");
|
||||
const butterfly = new SplatMesh({ url: splatURL });
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, -0.5, -3);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Forge • Hello World</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "/examples/js/vendor/three/build/three.module.js",
|
||||
"@forge-gfx/forge": "/dist/forge.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@forge-gfx/forge";
|
||||
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement)
|
||||
|
||||
const splatURL = await getAssetFileURL("butterfly-wings-closed.spz");
|
||||
const butterfly = new SplatMesh({ url: splatURL });
|
||||
butterfly.quaternion.set(1, 0, 0, 0);
|
||||
butterfly.position.set(0, -0.5, -3);
|
||||
scene.add(butterfly);
|
||||
|
||||
renderer.setAnimationLoop(function animate(time) {
|
||||
renderer.render(scene, camera);
|
||||
butterfly.rotation.y += 0.01;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Forge Examples</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
body {
|
||||
background: #D43E4C;
|
||||
color: #fff;
|
||||
font: normal 500 1.2rem/1.2 Inconsolata, Andale Mono, Courier New, monospace;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.header {
|
||||
position: relative;
|
||||
}
|
||||
.resources {
|
||||
font-size: 0; /* Collapse whitespace. */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.resources li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.resources a {
|
||||
color: rgba(0,0,0,.5);
|
||||
display: inline-block;
|
||||
font-size: 1.2rem;
|
||||
margin: -0.25rem -0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.075s ease-in-out;
|
||||
}
|
||||
.resources a:hover {
|
||||
background-color: rgba(0,0,0,0.25);
|
||||
color: #fff;
|
||||
}
|
||||
h1,
|
||||
h2 {
|
||||
line-height: 100%;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 100;
|
||||
letter-spacing: 0.04rem;
|
||||
margin-bottom: 1.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h2 {
|
||||
color: rgba(0,0,0,0.5);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 300;
|
||||
margin: 2rem 0 0.5rem;
|
||||
padding: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
hr {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px dashed rgba(255,255,255,0.25);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.links li + li {
|
||||
box-shadow: inset 0 0.15rem 0.1rem -0.15rem rgba(0,0,0,0.5);
|
||||
}
|
||||
.links a {
|
||||
color: #fff;
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 0.75rem 0;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.075s ease-in-out;
|
||||
}
|
||||
.links a em {
|
||||
font-size: .9rem;
|
||||
font-style: normal;
|
||||
opacity: .6;
|
||||
}
|
||||
.links a:hover {
|
||||
background-color: rgba(0,0,0,0.15);
|
||||
box-shadow: inset 0 0 0 0.15rem rgba(0,0,0,0.15);
|
||||
}
|
||||
.links a:active {
|
||||
background-color: rgba(0,0,0,0.25);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.links li:hover,
|
||||
.links li:hover + li {
|
||||
margin: 0 -0.75rem;
|
||||
}
|
||||
.links li:hover a,
|
||||
.links li:hover + li a {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
@media only screen and (min-width: 1000px) {
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.resources {
|
||||
font-size: 1.2;
|
||||
}
|
||||
.resources li {
|
||||
display: inline-block;
|
||||
}
|
||||
.resources li + li {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>Forge Examples</h1>
|
||||
<ul id="resources" class="resources">
|
||||
<li><a href="https://www.forge.dev/">forge.dev</a></li>
|
||||
<li><a href="https://github.com/forge-gfx/forge">GitHub</a></li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Examples</h2>
|
||||
|
||||
<ul class="links">
|
||||
<li><a href="/examples/animation-skinning/">Animation Skinning</a></li>
|
||||
<li><a href="/examples/envmap/">Environment Map</a></li>
|
||||
<li><a href="/examples/hello-world/">Hello World</a></li>
|
||||
<li><a href="/examples/interactivity/">Interactivity</a></li>
|
||||
<li><a href="/examples/multiple-splats/">Multiple Splats</a></li>
|
||||
<li><a href="/examples/multiple-viewpoints/">Multiple Viewpoints</a></li>
|
||||
<li><a href="/examples/procedural-splats/">Procedural Splats</a></li>
|
||||
<li><a href="/examples/raycasting/">Raycasting</a></li>
|
||||
<li><a href="/examples/vfx-dynamic-lighting/">VFX - Dynamic Lighting</a></li>
|
||||
<li><a href="/examples/vfx-particle-simulation/">VFX - Particle Simulation</a></li>
|
||||
<li><a href="/examples/viewer/">Viewer</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
pre-commit:
|
||||
jobs:
|
||||
- name: lint
|
||||
run: npm run lint
|
||||
- name: test
|
||||
run: npm run test
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
site_name: Forge
|
||||
site_url: https://www.forge.dev/
|
||||
theme:
|
||||
name: material
|
||||
logo: assets/images/logo.png
|
||||
custom_dir: docs/overrides
|
||||
features:
|
||||
- content.code.copy
|
||||
extra_css:
|
||||
- stylesheets/forge.css
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started: getting-started.md
|
||||
- Overview: overview.md
|
||||
- System Design: system-design.md
|
||||
- ForgeRenderer: forge-renderer.md
|
||||
- ForgeViewpoint: forge-viewpoint.md
|
||||
- SplatMesh: splat-mesh.md
|
||||
- PackedSplats: packed-splats.md
|
||||
- Loading Gsplats: loading-splats.md
|
||||
- Procedural Splats: procedural-splats.md
|
||||
- Splat RGBA-XYZ SDF editing: splat-editing.md
|
||||
- Controls: controls.md
|
||||
- Performance tuning: performance.md
|
||||
markdown_extensions:
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.superfences
|
||||
Generated
+1958
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@forge-gfx/forge",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "An advanced 3D Gaussian Splatting renderer for THREE.js",
|
||||
"scripts": {
|
||||
"assets:clean": "node scripts/clean-assets.js",
|
||||
"assets:download": "node scripts/download-assets.js",
|
||||
"build": "vite build --mode production && vite build --mode dev && tsc --emitDeclarationOnly --outFile dist/forge.d.ts",
|
||||
"build:wasm": "node rust/build_wasm.js",
|
||||
"build:watch": "onchange 'src/**/*.{ts,glsl}' -- npm run build",
|
||||
"clean": "rm -rf dist/ && rm -rf node_modules/ && rm -rf rust/target/ && rm -rf rust/forge-internal-rs/pkg/ && npm run assets:clean",
|
||||
"dev": "npm run build && (vite --host & npm run build:watch)",
|
||||
"deploy": "node scripts/deploy.js",
|
||||
"docs": "mkdocs serve",
|
||||
"format": "biome format .",
|
||||
"format:fix": "biome format . --fix",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check . --fix",
|
||||
"lint:summary": "biome check --reporter=summary",
|
||||
"prepare": "npm run build:wasm && lefthook install",
|
||||
"site:build": "npm run build && npm run site:clean && mkdocs build && node scripts/build-site.js",
|
||||
"site:clean": "node scripts/clean-site.js",
|
||||
"site:deploy": "node scripts/deploy-site.js",
|
||||
"site:serve": "node scripts/serve-site.js site",
|
||||
"start": "npm run dev",
|
||||
"test": "node --no-warnings --loader ts-node/esm --test test/**/*.test.ts"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/forge.d.ts",
|
||||
"import": "./dist/forge.module.js",
|
||||
"require": "./dist/forge.cjs.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@types/three": "0.172.0",
|
||||
"lefthook": "1.11.12",
|
||||
"lil-gui": "^0.20.0",
|
||||
"onchange": "7.1.0",
|
||||
"stats.js": "^0.17.0",
|
||||
"three": "^0.172.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-glsl": "^1.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"forge-internal-rs": "file:rust/forge-internal-rs/pkg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
forge-internal-rs/pkg/
|
||||
Generated
+224
@@ -0,0 +1,224 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
|
||||
|
||||
[[package]]
|
||||
name = "forge-internal-rs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"half",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wlg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.95"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c581601827da5c717bfae77d7b187e54293d23d8fb6b700b4b5e9b5828a13cc3"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7b17f197b3050ba473acf9181f7b1d3b66d1cf7356c6cc57886662276e65908"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wlg"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"half",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"forge-internal-rs",
|
||||
"wlg",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
rust-version = "1.82"
|
||||
edition = "2021"
|
||||
license = "Proprietary"
|
||||
authors = ["World Labs Technologies"]
|
||||
repository = "https://github.com/forge-gfx/forge"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.98"
|
||||
bincode = "1.3.3"
|
||||
half = "2.6.0"
|
||||
js-sys = "0.3.77"
|
||||
ruzstd = "0.8.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
wasm-bindgen = "0.2.100"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Exit on any error
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Resolve the script's directory and change to it
|
||||
Set-Location -Path (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
|
||||
# Check if rustup is installed
|
||||
if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Rust tool 'rustup' not found! Please install Rust to build."
|
||||
Write-Host "Visit: https://www.rust-lang.org/tools/install"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure wasm32-unknown-unknown target is installed
|
||||
rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Check if wasm-pack is installed, and install if not
|
||||
if (-not (Get-Command wasm-pack -ErrorAction SilentlyContinue)) {
|
||||
cargo install wasm-pack
|
||||
}
|
||||
|
||||
# Change directory and build using wasm-pack
|
||||
Set-Location -Path "./forge-internal-rs"
|
||||
wasm-pack build --target web
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check if "rustup" tool is installed
|
||||
if ! command -v rustup &> /dev/null; then
|
||||
echo "Rust tool 'rustup' not found! Please install Rust to build."
|
||||
echo "Visit Rust installation page: https://www.rust-lang.org/tools/install"
|
||||
echo "- Likely install one-liner: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd $(dirname "$0")
|
||||
|
||||
# Make sure Rust wasm target is installed
|
||||
rustup target add wasm32-unknown-unknown
|
||||
|
||||
# Make sure wasm-pack is installed
|
||||
cargo install wasm-pack
|
||||
|
||||
cd forge-internal-rs
|
||||
|
||||
# Build the project
|
||||
wasm-pack build --target web
|
||||
@@ -0,0 +1,18 @@
|
||||
const { execSync } = await import("node:child_process");
|
||||
const { platform } = await import("node:os");
|
||||
|
||||
const isWindows = platform() === "win32";
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
const output = execSync(
|
||||
"powershell.exe -ExecutionPolicy Bypass -File ./rust/build_rust_wasm.ps1",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
execSync("rust/build_rust_wasm.sh", { stdio: "inherit" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to build RUST WASM:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "forge-internal-rs"
|
||||
version = "0.1.0"
|
||||
rust-version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
half.workspace = true
|
||||
js-sys.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
wlg = { path = "../wlg" }
|
||||
@@ -0,0 +1,35 @@
|
||||
# forge-internal-rs
|
||||
|
||||
Rust WebAssembly functions for forge-internal.
|
||||
|
||||
## Installing build tools
|
||||
|
||||
First, we need to install Rust. Though it is possible to install it using Homebrew, we recommend installing `rustup` using the approach on the Rust homepage:
|
||||
|
||||
https://www.rust-lang.org/tools/install
|
||||
|
||||
It will most likely involve simply running:
|
||||
```
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
Once you have `rustup` and Rust, we need to install dependencies for building Rust Wasm.
|
||||
```
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo install wasm-pack
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Run the following script inside `forge-internal/rust`:
|
||||
```
|
||||
./build_rust_wasm.sh
|
||||
```
|
||||
|
||||
You can also build it manually by running these commands:
|
||||
```
|
||||
cd forge-internal-rs
|
||||
wasm-pack build --target web
|
||||
```
|
||||
|
||||
The generated files will be in the `pkg/` subdirectory, which is already symlinked in `forge-internal/package.json`.
|
||||
@@ -0,0 +1,154 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use js_sys::{Array, ArrayBuffer, Float32Array, Object, Reflect, Uint16Array, Uint32Array, Uint8Array};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use wlg::decode12_packed;
|
||||
|
||||
mod sort;
|
||||
use sort::{old_sort_internal, OldSortBuffers, sort_internal, SortBuffers};
|
||||
|
||||
mod raycast;
|
||||
use raycast::{raycast_ellipsoids, raycast_spheres};
|
||||
|
||||
const RAYCAST_BUFFER_COUNT: u32 = 65536;
|
||||
|
||||
thread_local! {
|
||||
static SORT_BUFFERS: RefCell<SortBuffers> = RefCell::new(SortBuffers::default());
|
||||
static OLD_SORT_BUFFERS: RefCell<OldSortBuffers> = RefCell::new(OldSortBuffers::default());
|
||||
static RAYCAST_BUFFER: RefCell<Vec<u32>> = RefCell::new(vec![0; RAYCAST_BUFFER_COUNT as usize * 4]);
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn decode_wlg(bytes: ArrayBuffer, tex_width: u32, tex_height: u32) -> Object {
|
||||
let mut bytes = Uint8Array::new(&bytes).to_vec();
|
||||
let (_settings, packed_splats) = match decode12_packed(&mut bytes) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
wasm_bindgen::throw_str(&format!("{}", err));
|
||||
}
|
||||
};
|
||||
|
||||
let num_splats = packed_splats.0.len() / 4;
|
||||
let max_splats = if tex_width != 0 && tex_height != 0 {
|
||||
let width = tex_width as usize;
|
||||
let height = num_splats.div_ceil(width).min(tex_height as usize);
|
||||
let depth = num_splats.div_ceil(width * height);
|
||||
width * height * depth
|
||||
} else {
|
||||
num_splats
|
||||
};
|
||||
|
||||
let packed = Uint32Array::new_with_length(max_splats as u32 * 4);
|
||||
let packed_slice = packed.subarray(0, packed_splats.0.len() as u32);
|
||||
packed_slice.copy_from(&packed_splats.0);
|
||||
|
||||
let result = Object::new();
|
||||
Reflect::set(&result, &JsValue::from_str("numSplats"), &JsValue::from_f64(num_splats as f64)).unwrap();
|
||||
Reflect::set(&result, &JsValue::from_str("packedSplats"), &JsValue::from(packed)).unwrap();
|
||||
result
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn old_sort_splats(
|
||||
max_splats: u32, total_splats: u32, readback: Array, ordering: Uint32Array,
|
||||
) -> u32 {
|
||||
let max_splats = max_splats as usize;
|
||||
let total_splats = total_splats as usize;
|
||||
let num_layers = readback.length() as usize;
|
||||
let layer_size = readback.get(0).dyn_into::<Uint8Array>().unwrap().length() as usize / 4;
|
||||
|
||||
let active_splats = OLD_SORT_BUFFERS.with_borrow_mut(|buffers| {
|
||||
buffers.ensure_size(max_splats);
|
||||
|
||||
// Copy the readback data layers into a contiguous buffer
|
||||
let mut layer_base = 0;
|
||||
for layer in 0..num_layers {
|
||||
let layer_count = layer_size.min(total_splats - layer_base);
|
||||
if layer_count > 0 {
|
||||
let layer_buffer = readback.get(layer as u32).dyn_into::<Uint8Array>().unwrap().buffer();
|
||||
let layer_uint32 = Uint32Array::new_with_byte_offset_and_length(&layer_buffer, 0, layer_count as u32);
|
||||
layer_uint32.copy_to(&mut buffers.readback[layer_base..layer_base + layer_count]);
|
||||
}
|
||||
layer_base += layer_count;
|
||||
}
|
||||
|
||||
let active_splats = match old_sort_internal(buffers, total_splats) {
|
||||
Ok(active_splats) => active_splats,
|
||||
Err(err) => {
|
||||
wasm_bindgen::throw_str(&format!("{}", err));
|
||||
}
|
||||
};
|
||||
|
||||
if active_splats > 0 {
|
||||
// Copy out ordering result
|
||||
let subarray = &buffers.ordering[..active_splats as usize];
|
||||
ordering.subarray(0, active_splats).copy_from(&subarray);
|
||||
}
|
||||
active_splats
|
||||
});
|
||||
|
||||
active_splats
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn sort_splats(
|
||||
num_splats: u32, readback: Uint16Array, ordering: Uint32Array,
|
||||
) -> u32 {
|
||||
let max_splats = readback.length() as usize;
|
||||
|
||||
let active_splats = SORT_BUFFERS.with_borrow_mut(|buffers| {
|
||||
buffers.ensure_size(max_splats);
|
||||
let sub_readback = readback.subarray(0, num_splats);
|
||||
sub_readback.copy_to(&mut buffers.readback[..num_splats as usize]);
|
||||
|
||||
let active_splats = match sort_internal(buffers, num_splats as usize) {
|
||||
Ok(active_splats) => active_splats,
|
||||
Err(err) => {
|
||||
wasm_bindgen::throw_str(&format!("{}", err));
|
||||
}
|
||||
};
|
||||
|
||||
if active_splats > 0 {
|
||||
// Copy out ordering result
|
||||
let subarray = &buffers.ordering[..active_splats as usize];
|
||||
ordering.subarray(0, active_splats).copy_from(&subarray);
|
||||
}
|
||||
active_splats
|
||||
});
|
||||
|
||||
active_splats
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn raycast_splats(
|
||||
origin_x: f32, origin_y: f32, origin_z: f32,
|
||||
dir_x: f32, dir_y: f32, dir_z: f32,
|
||||
near: f32, far: f32,
|
||||
num_splats: u32, packed_splats: Uint32Array,
|
||||
raycast_ellipsoid: bool,
|
||||
) -> Float32Array {
|
||||
let mut distances = Vec::<f32>::new();
|
||||
|
||||
_ = RAYCAST_BUFFER.with_borrow_mut(|buffer| {
|
||||
let mut base = 0;
|
||||
while base < num_splats {
|
||||
let chunk_size = RAYCAST_BUFFER_COUNT.min(num_splats - base);
|
||||
let subarray = packed_splats.subarray(4 * base, 4 * (base + chunk_size));
|
||||
let subbuffer = &mut buffer[0..(4 * chunk_size as usize)];
|
||||
subarray.copy_to(subbuffer);
|
||||
|
||||
if raycast_ellipsoid {
|
||||
raycast_ellipsoids(subbuffer, &mut distances, [origin_x, origin_y, origin_z], [dir_x, dir_y, dir_z], near, far);
|
||||
} else {
|
||||
raycast_spheres(subbuffer, &mut distances, [origin_x, origin_y, origin_z], [dir_x, dir_y, dir_z], near, far);
|
||||
}
|
||||
|
||||
base += chunk_size;
|
||||
}
|
||||
});
|
||||
|
||||
let output = Float32Array::new_with_length(distances.len() as u32);
|
||||
output.copy_from(&distances);
|
||||
output
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use half::f16;
|
||||
use wlg::decode_scale;
|
||||
|
||||
const MIN_OPACITY: f32 = 0.1;
|
||||
|
||||
pub fn raycast_spheres(
|
||||
buffer: &[u32], distances: &mut Vec<f32>,
|
||||
origin: [f32; 3], dir: [f32; 3], near: f32, far: f32,
|
||||
) {
|
||||
let quad_a = vec3_dot(dir, dir);
|
||||
|
||||
for packed in buffer.chunks(4) {
|
||||
let opacity = ((packed[0] >> 24) as u8) as f32 / 255.0;
|
||||
if opacity < MIN_OPACITY {
|
||||
continue;
|
||||
}
|
||||
|
||||
let origin = vec3_sub(origin, extract_center(packed));
|
||||
let scale = extract_scale(packed);
|
||||
|
||||
// Model the Gsplat as a sphere for faster approximate raycasting
|
||||
let radius = (scale[0] + scale[1] + scale[2]) / 3.0;
|
||||
|
||||
let quad_b = vec3_dot(dir, origin);
|
||||
let quad_c = vec3_dot(origin, origin) - radius * radius;
|
||||
let discriminant = quad_b * quad_b - quad_a * quad_c;
|
||||
if discriminant < 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let t = (-quad_b - discriminant.sqrt()) / quad_a;
|
||||
if t >= near && t <= far {
|
||||
distances.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raycast_ellipsoids(
|
||||
buffer: &[u32], distances: &mut Vec<f32>,
|
||||
origin: [f32; 3], dir: [f32; 3], near: f32, far: f32,
|
||||
) {
|
||||
for packed in buffer.chunks(4) {
|
||||
let opacity = ((packed[0] >> 24) as u8) as f32 / 255.0;
|
||||
if opacity < MIN_OPACITY {
|
||||
continue;
|
||||
}
|
||||
|
||||
let origin = vec3_sub(origin, extract_center(packed));
|
||||
let scale = extract_scale(packed);
|
||||
let quat = extract_quat(packed);
|
||||
let inv_quat = [-quat[0], -quat[1], -quat[2], quat[3]];
|
||||
|
||||
// Model the Gsplat as an ellipsoid for higher quality raycasting
|
||||
let local_origin = quat_vec(inv_quat, origin);
|
||||
let local_dir = quat_vec(inv_quat, dir);
|
||||
|
||||
let min_scale = scale[0].max(scale[1]).max(scale[2]) * 0.01;
|
||||
let t = if scale[2] < min_scale {
|
||||
// Treat it as a flat elliptical disk
|
||||
if local_dir[2].abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let t = -local_origin[2] / local_dir[2];
|
||||
let p_x = local_origin[0] + t * local_dir[0];
|
||||
let p_y = local_origin[1] + t * local_dir[1];
|
||||
if sqr(p_x / scale[0]) + sqr(p_y / scale[1]) > 1.0 {
|
||||
continue;
|
||||
}
|
||||
t
|
||||
} else if scale[1] < min_scale {
|
||||
// Treat it as a flat elliptical disk
|
||||
if local_dir[1].abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let t = -local_origin[1] / local_dir[1];
|
||||
let p_x = local_origin[0] + t * local_dir[0];
|
||||
let p_z = local_origin[2] + t * local_dir[2];
|
||||
if sqr(p_x / scale[0]) + sqr(p_z / scale[2]) > 1.0 {
|
||||
continue;
|
||||
}
|
||||
t
|
||||
} else if scale[0] < min_scale {
|
||||
// Treat it as a flat elliptical disk
|
||||
if local_dir[0].abs() < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let t = -local_origin[0] / local_dir[0];
|
||||
let p_y = local_origin[1] + t * local_dir[1];
|
||||
let p_z = local_origin[2] + t * local_dir[2];
|
||||
if sqr(p_y / scale[1]) + sqr(p_z / scale[2]) > 1.0 {
|
||||
continue;
|
||||
}
|
||||
t
|
||||
} else {
|
||||
let inv_scale = [1.0 / scale[0], 1.0 / scale[1], 1.0 / scale[2]];
|
||||
let local_origin = vec3_mul(local_origin, inv_scale);
|
||||
let local_dir = vec3_mul(local_dir, inv_scale);
|
||||
|
||||
let a = vec3_dot(local_dir, local_dir);
|
||||
let b = vec3_dot(local_origin, local_dir);
|
||||
let c = vec3_dot(local_origin, local_origin) - 1.0;
|
||||
let discriminant = b * b - a * c;
|
||||
if discriminant < 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
(-b - discriminant.sqrt()) / a
|
||||
};
|
||||
if t >= near && t <= far {
|
||||
distances.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_center(packed: &[u32]) -> [f32; 3] {
|
||||
let x = f16::from_bits(packed[1] as u16).to_f32();
|
||||
let y = f16::from_bits((packed[1] >> 16) as u16).to_f32();
|
||||
let z = f16::from_bits(packed[2] as u16).to_f32();
|
||||
[x, y, z]
|
||||
}
|
||||
|
||||
fn extract_scale(packed: &[u32]) -> [f32; 3] {
|
||||
let scale_x = decode_scale(packed[3] as u8);
|
||||
let scale_y = decode_scale((packed[3] >> 8) as u8);
|
||||
let scale_z = decode_scale((packed[3] >> 16) as u8);
|
||||
[scale_x, scale_y, scale_z]
|
||||
}
|
||||
|
||||
fn extract_quat(packed: &[u32]) -> [f32; 4] {
|
||||
let quat_x = ((packed[2] >> 16) as i8) as f32 / 127.0;
|
||||
let quat_y = ((packed[2] >> 24) as i8) as f32 / 127.0;
|
||||
let quat_z = ((packed[3] >> 24) as i8) as f32 / 127.0;
|
||||
let quat_w = (1.0 - quat_x * quat_x - quat_y * quat_y - quat_z * quat_z).max(0.0).sqrt();
|
||||
[quat_x, quat_y, quat_z, quat_w]
|
||||
}
|
||||
|
||||
fn sqr(x: f32) -> f32 {
|
||||
x * x
|
||||
}
|
||||
|
||||
fn vec3_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
|
||||
fn vec3_mul(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
}
|
||||
|
||||
fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
fn vec3_cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
]
|
||||
}
|
||||
|
||||
fn quat_vec(q: [f32; 4], v: [f32; 3]) -> [f32; 3] {
|
||||
let q_vec = [q[0], q[1], q[2]];
|
||||
let uv = vec3_cross(q_vec, v);
|
||||
let uuv = vec3_cross(q_vec, uv);
|
||||
[
|
||||
v[0] + 2.0 * (q[3] * uv[0] + uuv[0]),
|
||||
v[1] + 2.0 * (q[3] * uv[1] + uuv[1]),
|
||||
v[2] + 2.0 * (q[3] * uv[2] + uuv[2]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
use anyhow::anyhow;
|
||||
|
||||
const DEPTH_INFINITY: u32 = 0x7c00;
|
||||
const DEPTH_SIZE: usize = DEPTH_INFINITY as usize + 1;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OldSortBuffers {
|
||||
pub readback: Vec<u32>,
|
||||
pub ordering: Vec<u32>,
|
||||
pub buckets: Vec<u32>,
|
||||
}
|
||||
|
||||
impl OldSortBuffers {
|
||||
pub fn ensure_size(&mut self, max_splats: usize) {
|
||||
if self.readback.len() < max_splats {
|
||||
self.readback.resize(max_splats, 0);
|
||||
}
|
||||
if self.ordering.len() < max_splats {
|
||||
self.ordering.resize(max_splats, 0);
|
||||
}
|
||||
if self.buckets.len() < DEPTH_SIZE {
|
||||
self.buckets.resize(DEPTH_SIZE, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn old_sort_internal(buffers: &mut OldSortBuffers, total_splats: usize) -> anyhow::Result<u32> {
|
||||
let OldSortBuffers { readback, ordering, buckets } = buffers;
|
||||
let readback = &readback[..total_splats];
|
||||
|
||||
// Set the bucket counts to zero
|
||||
buckets.clear();
|
||||
buckets.resize(DEPTH_SIZE, 0);
|
||||
|
||||
// Count the number of splats in each bucket
|
||||
for &readback_u32 in readback.iter() {
|
||||
let priority = readback_u32 & 0x7FFF;
|
||||
if priority < DEPTH_INFINITY {
|
||||
buckets[priority as usize] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute bucket starting offset
|
||||
let mut active_splats = 0;
|
||||
for count in buckets.iter_mut() {
|
||||
let new_total = active_splats + *count;
|
||||
*count = active_splats;
|
||||
active_splats = new_total;
|
||||
}
|
||||
|
||||
// Write out splat indices at the right location using bucket offsets
|
||||
for (index, &readback_u32) in readback.iter().enumerate() {
|
||||
let priority = readback_u32 & 0x7FFF;
|
||||
if priority < DEPTH_INFINITY {
|
||||
ordering[buckets[priority as usize] as usize] = index as u32;
|
||||
buckets[priority as usize] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if buckets[DEPTH_SIZE - 1] != active_splats {
|
||||
return Err(anyhow!(
|
||||
"Expected {} active splats but got {}",
|
||||
active_splats,
|
||||
buckets[DEPTH_SIZE - 1]
|
||||
));
|
||||
}
|
||||
Ok(active_splats)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SortBuffers {
|
||||
pub readback: Vec<u16>,
|
||||
pub ordering: Vec<u32>,
|
||||
pub buckets: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SortBuffers {
|
||||
pub fn ensure_size(&mut self, max_splats: usize) {
|
||||
if self.readback.len() < max_splats {
|
||||
self.readback.resize(max_splats, 0);
|
||||
}
|
||||
if self.ordering.len() < max_splats {
|
||||
self.ordering.resize(max_splats, 0);
|
||||
}
|
||||
if self.buckets.len() < DEPTH_SIZE {
|
||||
self.buckets.resize(DEPTH_SIZE, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_internal(buffers: &mut SortBuffers, num_splats: usize) -> anyhow::Result<u32> {
|
||||
let SortBuffers { readback, ordering, buckets } = buffers;
|
||||
let readback = &readback[..num_splats];
|
||||
|
||||
// Set the bucket counts to zero
|
||||
buckets.clear();
|
||||
buckets.resize(DEPTH_SIZE, 0);
|
||||
|
||||
// Count the number of splats in each bucket
|
||||
for &metric in readback.iter() {
|
||||
if (metric as u32) < DEPTH_INFINITY {
|
||||
buckets[metric as usize] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute bucket starting offset
|
||||
let mut active_splats = 0;
|
||||
for count in buckets.iter_mut().rev().skip(1) {
|
||||
let new_total = active_splats + *count;
|
||||
*count = active_splats;
|
||||
active_splats = new_total;
|
||||
}
|
||||
|
||||
// Write out splat indices at the right location using bucket offsets
|
||||
for (index, &metric) in readback.iter().enumerate() {
|
||||
if (metric as u32) < DEPTH_INFINITY {
|
||||
ordering[buckets[metric as usize] as usize] = index as u32;
|
||||
buckets[metric as usize] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if buckets[0] != active_splats {
|
||||
return Err(anyhow!(
|
||||
"Expected {} active splats but got {}",
|
||||
active_splats,
|
||||
buckets[0]
|
||||
));
|
||||
}
|
||||
Ok(active_splats)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "wlg"
|
||||
version = "0.1.0"
|
||||
rust-version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
bincode.workspace = true
|
||||
half.workspace = true
|
||||
serde.workspace = true
|
||||
ruzstd.workspace = true
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
mod ordering;
|
||||
|
||||
// WLG0 includes WLGv1 and WLGv2
|
||||
mod wlg0;
|
||||
|
||||
pub use wlg0::{
|
||||
Wlg0Gaussian, PackedSplats,
|
||||
Wlg0Settings, Wlg0EncodeSettings,
|
||||
decode12, decode12_packed,
|
||||
encode12, encode_scale, decode_scale,
|
||||
};
|
||||
|
||||
// WLG3 will be used for future WLG versions
|
||||
// mod wlg3;
|
||||
@@ -0,0 +1,201 @@
|
||||
// Compute Morton index for 3D coordinates.
|
||||
|
||||
pub fn morton_coord_to_index([x, y, z]: [u16; 3]) -> u64 {
|
||||
fn expand3(x: u16) -> u64 {
|
||||
let mut x = x as u64;
|
||||
x = (x | x << 32) & 0x1f00000000ffff;
|
||||
x = (x | x << 16) & 0x1f0000ff0000ff;
|
||||
x = (x | x << 8) & 0x100f00f00f00f00f;
|
||||
x = (x | x << 4) & 0x10c30c30c30c30c3;
|
||||
x = (x | x << 2) & 0x1249249249249249;
|
||||
x
|
||||
}
|
||||
|
||||
(expand3(x) << 0) | (expand3(y) << 1) | (expand3(z) << 2)
|
||||
}
|
||||
|
||||
pub fn morton_coord_to_index_24([x, y, z]: [u32; 3]) -> u128 {
|
||||
fn expand3_24(x: u32) -> u128 {
|
||||
let mut x = x as u128;
|
||||
x = (x | x << 64) & 0x3ff0000000000000000ffffffffu128;
|
||||
x = (x | x << 32) & 0x3ff00000000ffff00000000ffffu128;
|
||||
x = (x | x << 16) & 0x30000ff0000ff0000ff0000ff0000ffu128;
|
||||
x = (x | x << 8) & 0x300f00f00f00f00f00f00f00f00f00fu128;
|
||||
x = (x | x << 4) & 0x30c30c30c30c30c30c30c30c30c30c3u128;
|
||||
x = (x | x << 2) & 0x9249249249249249249249249249249u128;
|
||||
x
|
||||
}
|
||||
|
||||
(expand3_24(x) << 0) | (expand3_24(y) << 1) | (expand3_24(z) << 2)
|
||||
}
|
||||
|
||||
// Compute Hilbert index for 3D coordinates.
|
||||
|
||||
// Converts a 48-bit Hilbert index to 3D coordinates (x, y, z).
|
||||
pub fn _hilbert_index_to_coord(mut index: u64) -> (u16, u16, u16) {
|
||||
const BITS: u32 = 16;
|
||||
|
||||
// Helper function to decode a Hilbert quad to coordinate bits.
|
||||
fn hilbert_decode_step(quad: u16, s: &mut [u16; 3]) {
|
||||
let (mut x, mut y, mut z) = (0u16, 0u16, 0u16);
|
||||
|
||||
match quad {
|
||||
0 => { x = 0; y = 0; z = 0; },
|
||||
1 => { x = 0; y = 0; z = 1; },
|
||||
2 => { x = 0; y = 1; z = 1; },
|
||||
3 => { x = 0; y = 1; z = 0; },
|
||||
4 => { x = 1; y = 1; z = 0; },
|
||||
5 => { x = 1; y = 1; z = 1; },
|
||||
6 => { x = 1; y = 0; z = 1; },
|
||||
7 => { x = 1; y = 0; z = 0; },
|
||||
_ => {},
|
||||
}
|
||||
|
||||
s[0] = x;
|
||||
s[1] = y;
|
||||
s[2] = z;
|
||||
}
|
||||
|
||||
let mut mask = 1u16; // Start with the least significant bit
|
||||
let mut h = [0u16; 3];
|
||||
let mut s = [0u16; 3];
|
||||
|
||||
for _ in 0..BITS {
|
||||
let quad = (index & 7) as u16; // Extract the last 3 bits
|
||||
index >>= 3;
|
||||
|
||||
hilbert_decode_step(quad, &mut s);
|
||||
|
||||
h[0] |= s[0] * mask;
|
||||
h[1] |= s[1] * mask;
|
||||
h[2] |= s[2] * mask;
|
||||
|
||||
mask <<= 1; // Move to the next bit
|
||||
}
|
||||
|
||||
(h[0], h[1], h[2])
|
||||
}
|
||||
|
||||
// Converts 3D coordinates (x, y, z) to a 48-bit Hilbert index.
|
||||
pub fn hilbert_coord_to_index([x, y, z]: [u16; 3]) -> u64 {
|
||||
const BITS: u32 = 16;
|
||||
|
||||
// Helper function to encode coordinate bits to a Hilbert quad.
|
||||
fn hilbert_encode_step(s: &mut [u16; 3]) -> u16 {
|
||||
let x = s[0];
|
||||
let y = s[1];
|
||||
let z = s[2];
|
||||
|
||||
let mut quad = 0u16;
|
||||
|
||||
if x == 0 && y == 0 && z == 0 { quad = 0; }
|
||||
else if x == 0 && y == 0 && z == 1 { quad = 1; }
|
||||
else if x == 0 && y == 1 && z == 1 { quad = 2; }
|
||||
else if x == 0 && y == 1 && z == 0 { quad = 3; }
|
||||
else if x == 1 && y == 1 && z == 0 { quad = 4; }
|
||||
else if x == 1 && y == 1 && z == 1 { quad = 5; }
|
||||
else if x == 1 && y == 0 && z == 1 { quad = 6; }
|
||||
else if x == 1 && y == 0 && z == 0 { quad = 7; }
|
||||
|
||||
quad
|
||||
}
|
||||
|
||||
let mut index = 0u64;
|
||||
let mut h = [x, y, z];
|
||||
let mut s = [0u16; 3];
|
||||
|
||||
for _ in 0..BITS {
|
||||
s[0] = h[0] & 1;
|
||||
s[1] = h[1] & 1;
|
||||
s[2] = h[2] & 1;
|
||||
|
||||
let quad = hilbert_encode_step(&mut s);
|
||||
index <<= 3;
|
||||
index |= quad as u64;
|
||||
|
||||
h[0] >>= 1;
|
||||
h[1] >>= 1;
|
||||
h[2] >>= 1;
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
|
||||
// Converts a 72-bit Hilbert index to 3D coordinates (x, y, z).
|
||||
pub fn _hilbert_index_coord_24(index: u128) -> (u32, u32, u32) {
|
||||
const BITS: u32 = 24;
|
||||
|
||||
// Helper function to decode a Hilbert quad to coordinates.
|
||||
fn hilbert_decode_step(quad: u32, s: &mut [u32; 3]) {
|
||||
match quad {
|
||||
0 => { s.swap(0, 1); },
|
||||
1 => {},
|
||||
2 => {},
|
||||
3 => { s[2] ^= 1; },
|
||||
4 => { s[0] ^= 1; s[1] ^= 1; s[2] ^= 1; },
|
||||
5 => { s[0] ^= 1; s[1] ^= 1; s[2] ^= 1; s.swap(0, 1); },
|
||||
6 => { s[0] ^= 1; s[1] ^= 1; s[2] ^= 1; s.swap(1, 2); },
|
||||
7 => { s.swap(0, 2); },
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
let mut idx = index;
|
||||
|
||||
let mut mask = 1u32 << (BITS - 1);
|
||||
let mut h = [0u32; 3];
|
||||
let mut s = [0u32; 3];
|
||||
|
||||
for _ in 0..BITS {
|
||||
let quad = (idx & 7) as u32;
|
||||
idx >>= 3;
|
||||
|
||||
hilbert_decode_step(quad, &mut s);
|
||||
h[0] |= s[0] & mask;
|
||||
h[1] |= s[1] & mask;
|
||||
h[2] |= s[2] & mask;
|
||||
|
||||
mask >>= 1;
|
||||
}
|
||||
|
||||
(h[0], h[1], h[2])
|
||||
}
|
||||
|
||||
// Converts 3D coordinates (x, y, z) to a 72-bit Hilbert index.
|
||||
pub fn hilbert_coord_to_index_24([x, y, z]: [u32; 3]) -> u128 {
|
||||
const BITS: u32 = 24;
|
||||
|
||||
// Helper function to encode coordinates to a Hilbert quad.
|
||||
fn hilbert_encode_step(s: &mut [u32; 3]) -> u32 {
|
||||
let mut quad = 0u32;
|
||||
let mut bits = [s[0], s[1], s[2]];
|
||||
|
||||
if bits[0] > bits[1] { bits.swap(0, 1); quad ^= 1; }
|
||||
if bits[1] > bits[2] { bits.swap(1, 2); quad ^= 2; }
|
||||
if bits[0] > bits[1] { bits.swap(0, 1); quad ^= 1; }
|
||||
|
||||
s[0] = bits[0];
|
||||
s[1] = bits[1];
|
||||
s[2] = bits[2];
|
||||
|
||||
quad
|
||||
}
|
||||
|
||||
let mut index = 0u128;
|
||||
let h = [x, y, z];
|
||||
let mut s = [0u32; 3];
|
||||
let mut mask = 1u32 << (BITS - 1);
|
||||
|
||||
for _ in 0..BITS {
|
||||
s[0] = (h[0] & mask) >> (BITS - 1);
|
||||
s[1] = (h[1] & mask) >> (BITS - 1);
|
||||
s[2] = (h[2] & mask) >> (BITS - 1);
|
||||
|
||||
let quad = hilbert_encode_step(&mut s);
|
||||
index = (index << 3) | quad as u128;
|
||||
|
||||
mask >>= 1;
|
||||
}
|
||||
|
||||
index
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use anyhow::anyhow;
|
||||
use std::io::Read;
|
||||
use half::f16;
|
||||
|
||||
use super::{
|
||||
decompress_data, deobfuscate, encode_scale, first_cumulative,
|
||||
PackedSplats, Wlg0Gaussian, Wlg0Header, Wlg0Settings,
|
||||
};
|
||||
|
||||
pub(super) trait Wlg0Decoder {
|
||||
fn init_num_splats(&mut self, num_splats: usize);
|
||||
fn write_center(&mut self, index: usize, dim: usize, value: f32);
|
||||
fn write_scale(&mut self, index: usize, dim: usize, value: f32);
|
||||
fn write_quaternion_f32(&mut self, index: usize, dim: usize, value: f32);
|
||||
fn write_quaternion_i8(&mut self, index: usize, dim: usize, value: i8);
|
||||
fn write_quaternion_full_f32(&mut self, index: usize, value: [f32; 4]);
|
||||
fn write_rgba(&mut self, index: usize, dim: usize, value: u8);
|
||||
}
|
||||
|
||||
impl Wlg0Decoder for Vec<Wlg0Gaussian> {
|
||||
fn init_num_splats(&mut self, num_splats: usize) {
|
||||
self.resize(num_splats, Wlg0Gaussian::default());
|
||||
}
|
||||
|
||||
fn write_center(&mut self, index: usize, dim: usize, value: f32) {
|
||||
self[index].center[dim] = value;
|
||||
}
|
||||
|
||||
fn write_scale(&mut self, index: usize, dim: usize, value: f32) {
|
||||
self[index].ln_scale[dim] = value.ln();
|
||||
}
|
||||
|
||||
fn write_quaternion_f32(&mut self, index: usize, dim: usize, value: f32) {
|
||||
self[index].quaternion[dim] = value;
|
||||
}
|
||||
|
||||
fn write_quaternion_i8(&mut self, _index: usize, _dim: usize, _value: i8) {
|
||||
// No-op sing we use the f32 variant
|
||||
}
|
||||
|
||||
fn write_quaternion_full_f32(&mut self, _index: usize, _value: [f32; 4]) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
fn write_rgba(&mut self, index: usize, dim: usize, value: u8) {
|
||||
self[index].color[dim] = value as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Wlg0Decoder for PackedSplats {
|
||||
fn init_num_splats(&mut self, num_splats: usize) {
|
||||
self.0.resize(num_splats * 4, 0);
|
||||
}
|
||||
|
||||
fn write_center(&mut self, index: usize, dim: usize, value: f32) {
|
||||
match dim {
|
||||
0 => { self.0[index * 4 + 1] |= f16::from_f32(value).to_bits() as u32; },
|
||||
1 => { self.0[index * 4 + 1] |= (f16::from_f32(value).to_bits() as u32) << 16; },
|
||||
2 => { self.0[index * 4 + 2] |= f16::from_f32(value).to_bits() as u32; },
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_scale(&mut self, index: usize, dim: usize, value: f32) {
|
||||
let scale8 = encode_scale(value);
|
||||
match dim {
|
||||
0 => { self.0[index * 4 + 3] |= scale8 as u32; },
|
||||
1 => { self.0[index * 4 + 3] |= (scale8 as u32) << 8; },
|
||||
2 => { self.0[index * 4 + 3] |= (scale8 as u32) << 16; },
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_quaternion_f32(&mut self, _index: usize, _dim: usize, _value: f32) {
|
||||
// No-op since we use the i8 variant
|
||||
}
|
||||
|
||||
fn write_quaternion_i8(&mut self, _index: usize, _dim: usize, _value: i8) {
|
||||
// Old Qxyz encoding:
|
||||
// match dim {
|
||||
// 0 => { self.0[index * 4 + 2] |= ((value as u32) & 0xff) << 16; },
|
||||
// 1 => { self.0[index * 4 + 2] |= ((value as u32) & 0xff) << 24; },
|
||||
// 2 => { self.0[index * 4 + 3] |= ((value as u32) & 0xff) << 24; },
|
||||
// 3 => {
|
||||
// // Quaternion w is inferred from xyz in PackedSplats
|
||||
// },
|
||||
// _ => unreachable!(),
|
||||
// }
|
||||
}
|
||||
|
||||
fn write_quaternion_full_f32(&mut self, index: usize, value: [f32; 4]) {
|
||||
// Encode as OctXy88R8
|
||||
let q = if value[3] < 0.0 { value.map(|v| -v) } else { value };
|
||||
let theta = 2.0 * q[3].acos();
|
||||
|
||||
let xyz_norm = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2]).sqrt();
|
||||
let axis = if xyz_norm < 1e-6 {
|
||||
[1.0, 0.0, 0.0]
|
||||
} else {
|
||||
[q[0] / xyz_norm, q[1] / xyz_norm, q[2] / xyz_norm]
|
||||
};
|
||||
|
||||
let sum = axis[0].abs() + axis[1].abs() + axis[2].abs();
|
||||
let p = [axis[0] / sum, axis[1] / sum];
|
||||
let p = if axis[2] >= 0.0 { p } else {
|
||||
[
|
||||
(1.0 - p[1].abs()) * p[0].signum(),
|
||||
(1.0 - p[0].abs()) * p[1].signum(),
|
||||
]
|
||||
};
|
||||
let uv = [
|
||||
((p[0] * 0.5 + 0.5) * 255.0).round() as u8,
|
||||
((p[1] * 0.5 + 0.5) * 255.0).round() as u8,
|
||||
];
|
||||
|
||||
let angle = (theta * (255.0 / 3.14159265359)).round().clamp(0.0, 255.0) as u8;
|
||||
self.0[index * 4 + 2] |= (uv[0] as u32) << 16;
|
||||
self.0[index * 4 + 2] |= (uv[1] as u32) << 24;
|
||||
self.0[index * 4 + 3] |= (angle as u32) << 24;
|
||||
}
|
||||
|
||||
fn write_rgba(&mut self, index: usize, dim: usize, value: u8) {
|
||||
match dim {
|
||||
0 => { self.0[index * 4] |= value as u32; },
|
||||
1 => { self.0[index * 4] |= (value as u32) << 8; },
|
||||
2 => { self.0[index * 4] |= (value as u32) << 16; },
|
||||
3 => { self.0[index * 4] |= (value as u32) << 24; },
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decode_internal<D: Wlg0Decoder>(
|
||||
data: &mut [u8],
|
||||
settings: &Wlg0Settings,
|
||||
decoder: &mut D,
|
||||
) -> anyhow::Result<()> {
|
||||
if settings.enable_obfuscation {
|
||||
deobfuscate(data);
|
||||
}
|
||||
|
||||
let data = if settings.enable_compression {
|
||||
decompress_data(data)?
|
||||
} else {
|
||||
data.to_vec()
|
||||
};
|
||||
|
||||
let mut reader = std::io::Cursor::new(&data);
|
||||
let header = Wlg0Header::read(&mut reader)?;
|
||||
|
||||
let num_splats = header.num_splats as usize;
|
||||
decoder.init_num_splats(num_splats);
|
||||
if !settings.enable_split_dims {
|
||||
return Err(anyhow!("Unsupported WLG !settings.enable_split_dims"));
|
||||
}
|
||||
|
||||
let mut data_u8: Vec<u8> = vec![0; num_splats];
|
||||
|
||||
if settings.enable_split_center_bytes {
|
||||
let mut data_u32: Vec<u32> = vec![0; num_splats];
|
||||
for d in 0..3 {
|
||||
data_u32.iter_mut().for_each(|v| *v = 0);
|
||||
|
||||
if settings.enable_24bit_center {
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
if settings.enable_first_differences {
|
||||
first_cumulative(&mut data_u8);
|
||||
}
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
data_u32[i] = (byte as u32) << 16;
|
||||
}
|
||||
}
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
if settings.enable_first_differences {
|
||||
first_cumulative(&mut data_u8);
|
||||
}
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
data_u32[i] |= (byte as u32) << 8;
|
||||
}
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
if settings.enable_first_differences {
|
||||
first_cumulative(&mut data_u8);
|
||||
}
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
data_u32[i] |= byte as u32;
|
||||
}
|
||||
|
||||
let resolution = if settings.enable_24bit_center {
|
||||
16777215.0
|
||||
} else {
|
||||
65535.0
|
||||
};
|
||||
let scale = header.center_scale / resolution;
|
||||
let offset = header.center_offset[d];
|
||||
for (i, &value) in data_u32.iter().enumerate() {
|
||||
decoder.write_center(i, d, value as f32 * scale + offset);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Unsupported WLG !settings.enable_split_center_bytes"
|
||||
));
|
||||
}
|
||||
|
||||
for d in 0..3 {
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
let scale = header.ln_scale_max - header.ln_scale_min;
|
||||
let offset = header.ln_scale_min;
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
let float = ((byte as f32 / 255.0) * scale + offset).exp();
|
||||
decoder.write_scale(i, d, float);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut quaternions: Vec<i8> = vec![0; num_splats * 3];
|
||||
for d in 0..3 {
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
quaternions[i * 3 + d] = byte as i8;
|
||||
}
|
||||
}
|
||||
for i in 0..num_splats {
|
||||
let quat: [f32; 3] = [
|
||||
quaternions[i * 3 + 0] as f32 / 127.0,
|
||||
quaternions[i * 3 + 1] as f32 / 127.0,
|
||||
quaternions[i * 3 + 2] as f32 / 127.0,
|
||||
];
|
||||
let w = (1.0 - quat.iter().map(|&v| v.powi(2)).sum::<f32>())
|
||||
.max(0.0)
|
||||
.sqrt();
|
||||
let w8 = (w * 127.0).round() as i8;
|
||||
|
||||
for d in 0..3 {
|
||||
decoder.write_quaternion_f32(i, d, quat[d]);
|
||||
decoder.write_quaternion_i8(i, d, quaternions[i * 3 + d]);
|
||||
}
|
||||
decoder.write_quaternion_f32(i, 3, w);
|
||||
decoder.write_quaternion_i8(i, 3, w8);
|
||||
decoder.write_quaternion_full_f32(i, [quat[0], quat[1], quat[2], w]);
|
||||
}
|
||||
}
|
||||
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
if settings.enable_first_differences {
|
||||
first_cumulative(&mut data_u8);
|
||||
}
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
decoder.write_rgba(i, 3, byte);
|
||||
}
|
||||
|
||||
for d in 0..3 {
|
||||
reader.read_exact(&mut data_u8)?;
|
||||
if settings.enable_first_differences {
|
||||
first_cumulative(&mut data_u8);
|
||||
}
|
||||
for (i, &byte) in data_u8.iter().enumerate() {
|
||||
decoder.write_rgba(i, d, byte);
|
||||
}
|
||||
}
|
||||
|
||||
let position = reader.position() as usize;
|
||||
if position != data.len() {
|
||||
return Err(anyhow!("Invalid WLG data size"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use std::io::Write;
|
||||
|
||||
use super::{compress_data, obfuscate, Wlg0Gaussian, Wlg0Settings, Wlg0Header};
|
||||
use crate::ordering::{
|
||||
hilbert_coord_to_index, hilbert_coord_to_index_24, morton_coord_to_index,
|
||||
morton_coord_to_index_24,
|
||||
};
|
||||
|
||||
// WLG splat representation with fixed-point encoding.
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WlgSplat {
|
||||
order: u128,
|
||||
center: [u32; 3],
|
||||
scale: [u8; 3],
|
||||
quaternion: [i8; 3],
|
||||
opacity: u8,
|
||||
color: [u8; 3],
|
||||
}
|
||||
|
||||
impl WlgSplat {
|
||||
fn new(
|
||||
settings: &Wlg0Settings,
|
||||
index: usize,
|
||||
center: [f32; 3],
|
||||
scale: [f32; 3],
|
||||
mut quaternion: [f32; 4],
|
||||
opacity: f32,
|
||||
color: [f32; 3],
|
||||
) -> Self {
|
||||
use std::array::from_fn;
|
||||
let center = if settings.enable_24bit_center {
|
||||
from_fn(|d| (center[d] * 16777215.0).clamp(0.0, 16777215.0).round() as u32)
|
||||
} else {
|
||||
from_fn(|d| (center[d] * 65535.0).clamp(0.0, 65535.0).round() as u32)
|
||||
};
|
||||
if quaternion[3] < 0.0 {
|
||||
quaternion = from_fn(|d| -quaternion[d]);
|
||||
}
|
||||
Self {
|
||||
order: if settings.enable_hilbert_reordering {
|
||||
// Convert X/Y/Z into a single Hilbert curve index
|
||||
if settings.enable_24bit_center {
|
||||
hilbert_coord_to_index_24(center) as u128
|
||||
} else {
|
||||
hilbert_coord_to_index(from_fn(|d| center[d] as u16)) as u128
|
||||
}
|
||||
} else if settings.enable_morton_reordering {
|
||||
// Interleave X/Y/Z bits into a single Morton index
|
||||
if settings.enable_24bit_center {
|
||||
morton_coord_to_index_24(center) as u128
|
||||
} else {
|
||||
morton_coord_to_index(from_fn(|d| center[d] as u16)) as u128
|
||||
}
|
||||
} else {
|
||||
index as u128
|
||||
},
|
||||
center,
|
||||
scale: from_fn(|d| (scale[d] * 255.0).clamp(0.0, 255.0).round() as u8),
|
||||
quaternion: from_fn(|d| (quaternion[d] * 127.0).clamp(-127.0, 127.0).round() as i8),
|
||||
opacity: (opacity * 255.0).clamp(0.0, 255.0).round() as u8,
|
||||
color: from_fn(|d| (color[d] * 255.0).clamp(0.0, 255.0).round() as u8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encoding functions
|
||||
|
||||
fn first_differences(settings: &Wlg0Settings, mut data: Vec<u8>) -> Vec<u8> {
|
||||
if settings.enable_first_differences {
|
||||
super::first_differences(&mut data);
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
pub(super) fn encode_internal(
|
||||
settings: &Wlg0Settings,
|
||||
gaussians: &[Wlg0Gaussian],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
use std::array::from_fn;
|
||||
let num_splats = gaussians.len();
|
||||
|
||||
// Compute min/max bounds for Gsplat centers
|
||||
let center_min_max = ([f32::INFINITY; 3], [-f32::INFINITY; 3]);
|
||||
let (center_min, center_max) = gaussians.iter().fold(center_min_max, |(min, max), g| {
|
||||
let min = from_fn(|i| min[i].min(g.center[i]));
|
||||
let max = from_fn(|i| max[i].max(g.center[i]));
|
||||
(min, max)
|
||||
});
|
||||
// println!("Center min: {:?}, max: {:?}", center_min, center_max);
|
||||
// Compute scale factor and offset for center values
|
||||
let center_ranges: [f32; 3] = from_fn(|i| center_max[i] - center_min[i]);
|
||||
let center_scale = center_ranges.into_iter().reduce(|a, b| a.max(b)).unwrap();
|
||||
let center_offset = center_min;
|
||||
|
||||
// Compute min/max bounds for Gsplat ln_scales
|
||||
let ln_scale_min_max = (f32::INFINITY, -f32::INFINITY);
|
||||
let (ln_scale_min, ln_scale_max) =
|
||||
gaussians
|
||||
.iter()
|
||||
.fold(ln_scale_min_max, |(mut min, mut max), g| {
|
||||
g.ln_scale.iter().for_each(|&ln_scale| {
|
||||
let ln_scale = ln_scale.clamp(-10.0, 10.0);
|
||||
min = min.min(ln_scale);
|
||||
max = max.max(ln_scale);
|
||||
});
|
||||
(min, max)
|
||||
});
|
||||
// println!("ln_scale min: {}, max: {}", ln_scale_min, ln_scale_max);
|
||||
|
||||
let mut splats: Vec<_> = gaussians
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, g)| {
|
||||
WlgSplat::new(
|
||||
settings,
|
||||
index,
|
||||
from_fn(|d| (g.center[d] - center_offset[d]) / center_scale),
|
||||
from_fn(|d| (g.ln_scale[d] - ln_scale_min) / (ln_scale_max - ln_scale_min)),
|
||||
g.quaternion,
|
||||
g.opacity,
|
||||
from_fn(|d| g.color[d]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Reorder splats using the provided ordering key, either Morton or original index.
|
||||
splats.sort_by_key(|splat| splat.order);
|
||||
|
||||
// Create buffer to write header and splats to
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
|
||||
let header = Wlg0Header {
|
||||
center_scale,
|
||||
center_offset,
|
||||
ln_scale_min,
|
||||
ln_scale_max,
|
||||
num_splats: num_splats as u32,
|
||||
max_sh_order: 0,
|
||||
num_sh_splats: [num_splats as u32, 0, 0, 0],
|
||||
};
|
||||
header.write(&mut buffer)?;
|
||||
|
||||
if settings.enable_split_dims {
|
||||
// Write dimensions separately, i.e. a column-oriented format.
|
||||
|
||||
for d in 0..3 {
|
||||
if settings.enable_split_center_bytes {
|
||||
// Split center values into separate bytes (MSB order)
|
||||
if settings.enable_24bit_center {
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.map(|splat| (splat.center[d] >> 16) as u8)
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
}
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.map(|splat| (splat.center[d] >> 8) as u8)
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
let data: Vec<u8> = splats.iter().map(|splat| splat.center[d] as u8).collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
} else {
|
||||
if settings.enable_24bit_center {
|
||||
// Write 24-bit center values into u8 array in LSB order
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| {
|
||||
let center = splat.center[d];
|
||||
[center as u8, (center >> 8) as u8, (center >> 16) as u8]
|
||||
})
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
} else {
|
||||
let data_u16: Vec<u16> =
|
||||
splats.iter().map(|splat| splat.center[d] as u16).collect();
|
||||
let data: Vec<u8> = data_u16
|
||||
.iter()
|
||||
.flat_map(|&v| v.to_le_bytes().to_vec())
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for d in 0..3 {
|
||||
let data: Vec<u8> = splats.iter().map(|splat| splat.scale[d]).collect();
|
||||
buffer.write_all(&data)?;
|
||||
}
|
||||
for d in 0..3 {
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.map(|splat| splat.quaternion[d] as u8)
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
}
|
||||
{
|
||||
let data: Vec<u8> = splats.iter().map(|splat| splat.opacity).collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
}
|
||||
for d in 0..3 {
|
||||
let data: Vec<u8> = splats.iter().map(|splat| splat.color[d]).collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
}
|
||||
} else {
|
||||
// !enable_split_dims, write all dimensions interleaved (array of structs).
|
||||
|
||||
if settings.enable_split_center_bytes {
|
||||
if settings.enable_24bit_center {
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.center.iter().map(|&v| (v >> 16) as u8))
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
}
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.center.iter().map(|&v| (v >> 8) as u8))
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.center.iter().map(|&v| v as u8))
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
} else {
|
||||
if settings.enable_24bit_center {
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| {
|
||||
splat
|
||||
.center
|
||||
.iter()
|
||||
.flat_map(|&d| [d as u8, (d >> 8) as u8, (d >> 16) as u8])
|
||||
})
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
} else {
|
||||
let data_u16: Vec<u16> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.center.iter().map(|&v| v as u16))
|
||||
.collect();
|
||||
let data: Vec<u8> = data_u16
|
||||
.iter()
|
||||
.flat_map(|&v| v.to_le_bytes().to_vec())
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
}
|
||||
}
|
||||
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.scale.iter().copied())
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.quaternion.iter().map(|&v| v as u8))
|
||||
.collect();
|
||||
buffer.write_all(&data)?;
|
||||
|
||||
let data: Vec<u8> = splats.iter().map(|splat| splat.opacity).collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
|
||||
let data: Vec<u8> = splats
|
||||
.iter()
|
||||
.flat_map(|splat| splat.color.iter().copied())
|
||||
.collect();
|
||||
buffer.write_all(&first_differences(settings, data))?;
|
||||
}
|
||||
|
||||
let mut payload = if settings.enable_compression {
|
||||
compress_data(&buffer, settings.zstd_compression_level)?
|
||||
} else {
|
||||
buffer
|
||||
};
|
||||
|
||||
if settings.enable_obfuscation {
|
||||
obfuscate(&mut payload);
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
mod decode;
|
||||
mod encode;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Wlg0Gaussian {
|
||||
pub center: [f32; 3],
|
||||
pub ln_scale: [f32; 3],
|
||||
pub quaternion: [f32; 4],
|
||||
pub opacity: f32,
|
||||
pub color: [f32; 3],
|
||||
}
|
||||
|
||||
pub const LN_SCALE_MIN: f32 = -9.0;
|
||||
pub const LN_SCALE_MAX: f32 = 9.0;
|
||||
pub const LN_RESCALE: f32 = (LN_SCALE_MAX - LN_SCALE_MIN) / 254.0; // 1..=255
|
||||
|
||||
pub fn encode_scale(scale: f32) -> u8 {
|
||||
if scale == 0.0 {
|
||||
0
|
||||
} else {
|
||||
// Allow scales below LN_SCALE_MIN to be encoded as 0, which signifies a 2DGS
|
||||
((scale.ln() - LN_SCALE_MIN) / LN_RESCALE + 1.0).clamp(0.0, 255.0).round() as u8
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_scale(scale: u8) -> f32 {
|
||||
if scale == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(LN_SCALE_MIN + (scale - 1) as f32 * LN_RESCALE).exp()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PackedSplats(pub Vec<u32>);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Wlg0Signature {
|
||||
magic: [u8; 4], // "WLG0"
|
||||
version: u32, // 1 or 2
|
||||
}
|
||||
|
||||
impl Wlg0Signature {
|
||||
const MAGIC: [u8; 4] = *b"WLG0";
|
||||
|
||||
fn new(version: u32) -> Self {
|
||||
Self {
|
||||
magic: Self::MAGIC,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_version(&self) -> Option<u32> {
|
||||
if self.magic == Self::MAGIC {
|
||||
Some(self.version)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn write<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
|
||||
writer.write_all(&bincode::serialize(self).unwrap())
|
||||
}
|
||||
|
||||
fn read<R: Read>(reader: &mut R) -> std::io::Result<Self> {
|
||||
let mut buf = [0; std::mem::size_of::<Self>()];
|
||||
reader.read_exact(&mut buf)?;
|
||||
Ok(bincode::deserialize(&buf).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Wlg0Settings {
|
||||
pub version: u32,
|
||||
pub enable_compression: bool,
|
||||
pub enable_first_differences: bool,
|
||||
pub enable_morton_reordering: bool,
|
||||
pub enable_hilbert_reordering: bool,
|
||||
pub enable_split_center_bytes: bool,
|
||||
pub enable_obfuscation: bool,
|
||||
pub enable_split_dims: bool,
|
||||
pub enable_24bit_center: bool,
|
||||
pub zstd_compression_level: i32, // 0 selects default level (3), max is 22
|
||||
}
|
||||
|
||||
const WLG012_SETTINGS: [Wlg0Settings; 3] = [
|
||||
// 0: WLGv0: Not a valid version, for testing purposes only
|
||||
Wlg0Settings {
|
||||
version: 0,
|
||||
enable_compression: true,
|
||||
enable_first_differences: true,
|
||||
enable_morton_reordering: false,
|
||||
enable_hilbert_reordering: true,
|
||||
enable_split_center_bytes: true,
|
||||
enable_obfuscation: true,
|
||||
enable_split_dims: true,
|
||||
enable_24bit_center: true,
|
||||
zstd_compression_level: 0,
|
||||
},
|
||||
// 1: WLGv1: Default settings for WLG v1 files
|
||||
Wlg0Settings {
|
||||
version: 1,
|
||||
enable_compression: true,
|
||||
enable_first_differences: true,
|
||||
enable_morton_reordering: true,
|
||||
enable_hilbert_reordering: false,
|
||||
enable_split_center_bytes: true,
|
||||
enable_obfuscation: true,
|
||||
enable_split_dims: true,
|
||||
enable_24bit_center: false,
|
||||
zstd_compression_level: 0,
|
||||
},
|
||||
// 2: WLGv2: Default settings for WLG v2 files
|
||||
Wlg0Settings {
|
||||
version: 2,
|
||||
enable_compression: true,
|
||||
enable_first_differences: true,
|
||||
enable_morton_reordering: true,
|
||||
enable_hilbert_reordering: false,
|
||||
enable_split_center_bytes: true,
|
||||
enable_obfuscation: true,
|
||||
enable_split_dims: true,
|
||||
enable_24bit_center: true,
|
||||
zstd_compression_level: 0,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Wlg0Header {
|
||||
// center[d] = center_int[d] * center_scale + center_offset
|
||||
pub center_scale: f32,
|
||||
pub center_offset: [f32; 3],
|
||||
// scale[d] = exp(scale_int[d] / 255 * (scale_max - scale_min) + scale_min)
|
||||
pub ln_scale_min: f32,
|
||||
pub ln_scale_max: f32,
|
||||
// Total # splats, regardless of SH order
|
||||
pub num_splats: u32,
|
||||
// 0 for "no spherical harmonics"
|
||||
pub max_sh_order: u32,
|
||||
// Supports maximum of SH=3
|
||||
pub num_sh_splats: [u32; 4],
|
||||
}
|
||||
|
||||
impl Wlg0Header {
|
||||
pub fn write<W: Write>(&self, writer: &mut W) -> std::io::Result<()> {
|
||||
writer.write_all(&bincode::serialize(self).unwrap())
|
||||
}
|
||||
|
||||
pub fn read<R: Read>(reader: &mut R) -> std::io::Result<Self> {
|
||||
let mut buf = [0; std::mem::size_of::<Self>()];
|
||||
reader.read_exact(&mut buf)?;
|
||||
Ok(bincode::deserialize(&buf).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn obfuscate(data: &mut [u8]) {
|
||||
let mut prev: u8 = 0;
|
||||
let mut state: u32 = 0x1AB51AB5;
|
||||
for byte in data {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let rand_byte = (state >> 8) as u8;
|
||||
let obf_byte = (*byte ^ prev).rotate_left(3) ^ rand_byte;
|
||||
prev = obf_byte;
|
||||
*byte = obf_byte;
|
||||
}
|
||||
}
|
||||
|
||||
fn deobfuscate(data: &mut [u8]) {
|
||||
let mut prev: u8 = 0;
|
||||
let mut state: u32 = 0x1AB51AB5;
|
||||
for obf_byte in data {
|
||||
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let rand_byte = (state >> 8) as u8;
|
||||
let tmp = (*obf_byte ^ rand_byte).rotate_right(3);
|
||||
let byte = tmp ^ prev;
|
||||
prev = *obf_byte;
|
||||
*obf_byte = byte;
|
||||
}
|
||||
}
|
||||
|
||||
fn compress_data(data: &[u8], _zstd_compression_level: i32) -> anyhow::Result<Vec<u8>> {
|
||||
use ruzstd::encoding::{compress_to_vec, CompressionLevel};
|
||||
let compressed_data = compress_to_vec(data, CompressionLevel::Fastest);
|
||||
Ok(compressed_data)
|
||||
}
|
||||
|
||||
fn decompress_data(data: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let mut decoder = ruzstd::decoding::StreamingDecoder::new(data)?;
|
||||
let mut decompressed_data = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed_data)?;
|
||||
Ok(decompressed_data)
|
||||
}
|
||||
|
||||
fn first_differences(data: &mut [u8]) {
|
||||
// Compute first differences for array to aid compression
|
||||
for i in (1..data.len()).rev() {
|
||||
data[i] = data[i].wrapping_sub(data[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_cumulative(data: &mut [u8]) {
|
||||
// Invert first differences
|
||||
for i in 1..data.len() {
|
||||
data[i] = data[i].wrapping_add(data[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode12(bytes: &mut [u8]) -> anyhow::Result<(Wlg0Settings, Vec<Wlg0Gaussian>)> {
|
||||
let (offset, version) = {
|
||||
let mut reader = std::io::Cursor::new(&bytes);
|
||||
let signature = Wlg0Signature::read(&mut reader)?;
|
||||
let Some(version) = signature.check_version() else {
|
||||
return Err(anyhow!("Invalid WLG signature"));
|
||||
};
|
||||
if (version != 1) && (version != 2) {
|
||||
return Err(anyhow!("Unsupported WLG version"));
|
||||
}
|
||||
let offset = reader.position() as usize;
|
||||
(offset, version)
|
||||
};
|
||||
|
||||
let settings = WLG012_SETTINGS[version as usize].clone();
|
||||
let mut gaussians = Vec::new();
|
||||
decode::decode_internal(&mut bytes[offset..], &settings, &mut gaussians)?;
|
||||
Ok((settings, gaussians))
|
||||
}
|
||||
|
||||
pub fn decode12_packed(bytes: &mut [u8]) -> anyhow::Result<(Wlg0Settings, PackedSplats)> {
|
||||
let (offset, version) = {
|
||||
let mut reader = std::io::Cursor::new(&bytes);
|
||||
let signature = Wlg0Signature::read(&mut reader)?;
|
||||
let Some(version) = signature.check_version() else {
|
||||
return Err(anyhow!("Invalid WLG signature"));
|
||||
};
|
||||
if (version != 1) && (version != 2) {
|
||||
return Err(anyhow!("Unsupported WLG version"));
|
||||
}
|
||||
let offset = reader.position() as usize;
|
||||
(offset, version)
|
||||
};
|
||||
|
||||
let settings = WLG012_SETTINGS[version as usize].clone();
|
||||
let mut packed_splats = PackedSplats(Vec::new());
|
||||
decode::decode_internal(&mut bytes[offset..], &settings, &mut packed_splats)?;
|
||||
Ok((settings, packed_splats))
|
||||
}
|
||||
|
||||
pub enum Wlg0EncodeSettings {
|
||||
Wlg1,
|
||||
Wlg2,
|
||||
Custom(Wlg0Settings),
|
||||
}
|
||||
|
||||
pub fn encode12(
|
||||
settings: &Wlg0EncodeSettings,
|
||||
gaussians: &[Wlg0Gaussian],
|
||||
) -> anyhow::Result<(Wlg0Settings, Vec<u8>)> {
|
||||
let settings = match settings {
|
||||
Wlg0EncodeSettings::Wlg1 => &WLG012_SETTINGS[1],
|
||||
Wlg0EncodeSettings::Wlg2 => &WLG012_SETTINGS[2],
|
||||
Wlg0EncodeSettings::Custom(custom) => custom,
|
||||
}
|
||||
.clone();
|
||||
let payload = encode::encode_internal(&settings, gaussians)?;
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
Wlg0Signature::new(settings.version).write(&mut buffer)?;
|
||||
buffer.write_all(&payload)?;
|
||||
Ok((settings, buffer))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs";
|
||||
import { copyFile, mkdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function copyDir(src, dest) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
copyDir(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example usage:
|
||||
const sourceDir = "examples";
|
||||
const destinationDir = "site/examples";
|
||||
|
||||
copyDir("examples", "site/examples");
|
||||
await copyFile("index.html", "site/examples/index.html");
|
||||
await rename("site/examples/viewer", "site/viewer");
|
||||
await mkdir("site/examples/vendor", { recursive: true });
|
||||
copyDir("node_modules/three", "site/examples/js/vendor/three");
|
||||
copyDir("dist", "site/dist");
|
||||
console.log("Site generated in site directory.");
|
||||
@@ -0,0 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
const assetsDirectory = "examples/assets/";
|
||||
|
||||
if (existsSync(assetsDirectory)) {
|
||||
await rm(assetsDirectory, { recursive: true, force: true });
|
||||
console.log(`Directory ${assetsDirectory} deleted`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
const siteDirectory = "site";
|
||||
|
||||
if (existsSync(siteDirectory)) {
|
||||
await rm(siteDirectory, { recursive: true, force: true });
|
||||
console.log(`Directory ${siteDirectory} deleted`);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createWriteStream, existsSync } from "node:fs";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { get } from "node:https";
|
||||
import { basename, join } from "node:path";
|
||||
import { URL } from "node:url";
|
||||
|
||||
const assetsDirectory = "examples/assets/";
|
||||
|
||||
await mkdir(assetsDirectory, { recursive: true });
|
||||
|
||||
const urls = JSON.parse(await readFile("examples/assets.json", "utf8"));
|
||||
let filesToDownload = 0;
|
||||
|
||||
for (const [key, data] of Object.entries(urls)) {
|
||||
const url = data.url;
|
||||
const directory = assetsDirectory + data.directory;
|
||||
const filePath = join(directory, key);
|
||||
|
||||
await mkdir(directory, { recursive: true });
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
console.log(
|
||||
`File: ${filePath} ' exists. Do not download again. npm run assets:clean to delete`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
downloadFile(url, key, filePath);
|
||||
console.log(`Downloading file ${key}, from URL: ${url}`);
|
||||
}
|
||||
|
||||
function downloadFile(fileUrl, fileName, filePath) {
|
||||
const url = new URL(fileUrl);
|
||||
const file = createWriteStream(filePath);
|
||||
|
||||
const protocol = url.protocol === "https:" ? https.get : http.get;
|
||||
|
||||
filesToDownload++;
|
||||
|
||||
protocol(fileUrl, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
filesToDownload--;
|
||||
console.error(
|
||||
`Download failed: ${response.statusCode} ${response.statusMessage}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
file.close(() => {
|
||||
filesToDownload--;
|
||||
console.log(`${fileName} downloaded to ${filePath}`);
|
||||
if (filesToDownload === 0) {
|
||||
console.log("Success! All assets downloaded");
|
||||
}
|
||||
});
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
console.error(`Error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Resolve __dirname in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Get directory to serve
|
||||
const root = path.resolve(process.argv[2] || ".");
|
||||
|
||||
const mimeTypes = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const requestedPath = decodeURIComponent(req.url.split("?")[0]);
|
||||
const fullPath = path.join(root, requestedPath);
|
||||
const safePath = path.normalize(fullPath);
|
||||
|
||||
if (!safePath.startsWith(root)) {
|
||||
res.writeHead(403);
|
||||
return res.end("Access denied");
|
||||
}
|
||||
|
||||
const stats = await fs.stat(safePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
const indexPath = path.join(safePath, "index.html");
|
||||
try {
|
||||
const content = await fs.readFile(indexPath);
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
return res.end(content);
|
||||
} catch {
|
||||
res.writeHead(403);
|
||||
return res.end("Directory listing is disabled");
|
||||
}
|
||||
} else {
|
||||
const ext = path.extname(safePath).toLowerCase();
|
||||
const contentType = mimeTypes[ext] || "application/octet-stream";
|
||||
const content = await fs.readFile(safePath);
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
res.end(content);
|
||||
}
|
||||
} catch (err) {
|
||||
res.writeHead(err.code === "ENOENT" ? 404 : 500);
|
||||
res.end(
|
||||
err.code === "ENOENT" ? "Not Found" : `Server Error: ${err.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Serving ${root} at http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,856 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { ForgeViewpoint, type ForgeViewpointOptions } from "./ForgeViewpoint";
|
||||
import { PackedSplats } from "./PackedSplats";
|
||||
import { RgbaArray } from "./RgbaArray";
|
||||
import { type GeneratorMapping, SplatAccumulator } from "./SplatAccumulator";
|
||||
import { SplatEdit } from "./SplatEdit";
|
||||
import { SplatGenerator, SplatModifier } from "./SplatGenerator";
|
||||
import { SplatGeometry } from "./SplatGeometry";
|
||||
import { SplatMesh } from "./SplatMesh";
|
||||
import {
|
||||
DynoVec3,
|
||||
DynoVec4,
|
||||
Gsplat,
|
||||
TPackedSplats,
|
||||
dynoBlock,
|
||||
readPackedSplat,
|
||||
transformGsplat,
|
||||
} from "./dyno";
|
||||
import { getShaders } from "./shaders";
|
||||
import {
|
||||
averagePositions,
|
||||
averageQuaternions,
|
||||
cloneClock,
|
||||
withinCoorientDist,
|
||||
} from "./utils";
|
||||
|
||||
// ForgeRenderer aggregates splats from multiple generators into a single
|
||||
// accumulated collection per frame. In normal operation we only need a
|
||||
// maximum of 3 accumulators: One currently being viewed, one currently
|
||||
// being sorted, and one more for generating the next frame. Accumulators
|
||||
// must be "released" by each viewpoint using it, so in unusual cases
|
||||
// such as slow render-outs, we may want to allow more than 3 so the
|
||||
// pipeline can continue generating new frames, but we limit to a maximum
|
||||
// of 5 to avoid excessive memory usage.
|
||||
const MAX_ACCUMULATORS = 5;
|
||||
|
||||
// Scene.onBeforeRender monkey-patch to
|
||||
// inject a ForgeRenderer into a scene with SplatMeshes if there isn't
|
||||
// one already. Restore original Scene.onBeforeRenderer and Scene.add when done.
|
||||
let hasSplatMesh = false;
|
||||
let hasForgeRenderer = false;
|
||||
|
||||
let forgeRendererInstance: ForgeRenderer;
|
||||
|
||||
const sceneAdd = THREE.Scene.prototype.add;
|
||||
THREE.Scene.prototype.add = function (object) {
|
||||
hasSplatMesh = hasSplatMesh || object instanceof SplatMesh;
|
||||
hasForgeRenderer = hasForgeRenderer || object instanceof ForgeRenderer;
|
||||
sceneAdd.call(this, object);
|
||||
return this;
|
||||
};
|
||||
|
||||
const sceneOnBeforeRender = THREE.Scene.prototype.onBeforeRender;
|
||||
THREE.Scene.prototype.onBeforeRender = function (renderer) {
|
||||
if (!hasSplatMesh) {
|
||||
return;
|
||||
}
|
||||
if (!hasForgeRenderer) {
|
||||
const forge = forgeRendererInstance || new ForgeRenderer({ renderer });
|
||||
this.add(forge);
|
||||
}
|
||||
THREE.Scene.prototype.onBeforeRender = sceneOnBeforeRender;
|
||||
THREE.Scene.prototype.add = sceneAdd;
|
||||
};
|
||||
|
||||
export type ForgeRendererOptions = {
|
||||
// Pass in your THREE.WebGLRenderer instance so Forge can perform work
|
||||
// outside the usual render loop. Should be created with antialias: false
|
||||
// (default setting) as WebGL anti-aliasing doesn't improve Gaussian Splatting
|
||||
// rendering and significantly reduces performance.
|
||||
renderer: THREE.WebGLRenderer;
|
||||
// Pass in a THREE.Clock to synchronize time-based effects across different
|
||||
// systems. Alternatively, you can set the ForgeRenderer properties time and
|
||||
// deltaTime directly. (default: new THREE.Clock)
|
||||
clock?: THREE.Clock;
|
||||
// Controls whether to check and automatically update Gsplat collection after
|
||||
// each frame render. (default: true)
|
||||
autoUpdate?: boolean;
|
||||
// Controls whether to update the Gsplats before or after rendering. For WebXR
|
||||
// this must be false in order to complete rendering as soon as possible.
|
||||
// (default: false)
|
||||
preUpdate?: boolean;
|
||||
// Distance threshold for ForgeRenderer movement triggering a Gsplat update at
|
||||
// the new origin. (default: 1.0)
|
||||
originDistance?: number;
|
||||
// Maximum standard deviations from the center to render Gaussians. Values
|
||||
// Math.sqrt(5)..Math.sqrt(8) produce good results and can be tweaked for
|
||||
// performance. (default: Math.sqrt(8))
|
||||
maxStdDev?: number;
|
||||
// Enable 2D Gaussian splatting rendering ability. When this mode is enabled,
|
||||
// any scale x/y/z component that is exactly 0 (minimum quantized value) results
|
||||
// in the other two non-0 axis being interpreted as an oriented 2D Gaussian Splat,
|
||||
// rather instead of the usual projected 3DGS Z-slice. When reading PLY files,
|
||||
// scale values less than e^-20 will be interpreted as 0. (default: true)
|
||||
enable2DGS?: boolean;
|
||||
// Scalar value to add to 2D splat covariance diagonal, effectively blurring +
|
||||
// enlarging splats. In scenes trained without the Gsplat anti-aliasing tweak
|
||||
// this value was typically 0.3, but with anti-aliasing it is 0.0 (default: 0.0)
|
||||
preBlurAmount?: number;
|
||||
// Scalar value to add to 2D splat covarianve diagonal, with opacity adjustment
|
||||
// to correctly account for "blurring" when anti-aliasing. Typically 0.3
|
||||
// (equivalent to approx 0.5 pixel radius) in scenes trained with anti-aliasing.
|
||||
blurAmount?: number;
|
||||
// Modulate Gaussian kernel falloff. 0 means "no falloff, flat shading",
|
||||
// while 1 is the normal Gaussian kernel. (default: 1.0)
|
||||
falloff?: number;
|
||||
// X/Y clipping boundary factor for Gsplat centers against view frustum.
|
||||
// 1.0 clips any centers that are exactly out of bounds, while 1.4 clips
|
||||
// centers that are 40% beyond the bounds. (default: 1.4)
|
||||
clipXY?: number;
|
||||
// Configures the ForgeViewpointOptions for the default ForgeViewpoint
|
||||
// associated with this ForgeRenderer. Notable option: sortRadial (sort by
|
||||
// radial distance or Z-depth)
|
||||
view?: ForgeViewpointOptions;
|
||||
};
|
||||
|
||||
export class ForgeRenderer extends THREE.Mesh {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
material: THREE.ShaderMaterial;
|
||||
uniforms: ReturnType<typeof ForgeRenderer.makeUniforms>;
|
||||
|
||||
autoUpdate: boolean;
|
||||
preUpdate: boolean;
|
||||
originDistance: number;
|
||||
maxStdDev: number;
|
||||
enable2DGS: boolean;
|
||||
preBlurAmount: number;
|
||||
blurAmount: number;
|
||||
falloff: number;
|
||||
clipXY: number;
|
||||
|
||||
time?: number;
|
||||
deltaTime?: number;
|
||||
clock: THREE.Clock;
|
||||
|
||||
// Latest Gsplat collection being displayed
|
||||
active: SplatAccumulator;
|
||||
// Free list of accumulators for reuse
|
||||
private freeAccumulators: SplatAccumulator[];
|
||||
// Total number of accumulators currently allocated
|
||||
private accumulatorCount: number;
|
||||
// Default ForgeViewpoint used for rendering to the canvas
|
||||
defaultView: ForgeViewpoint;
|
||||
// List of ForgeViewpoints with autoUpdate enabled
|
||||
autoViewpoints: ForgeViewpoint[] = [];
|
||||
|
||||
// Dynos used to transform Gsplats to the accumulator coordinate system
|
||||
private rotateToAccumulator = new DynoVec4({ value: new THREE.Quaternion() });
|
||||
private translateToAccumulator = new DynoVec3({ value: new THREE.Vector3() });
|
||||
private modifier: SplatModifier;
|
||||
|
||||
// Last rendered frame number so we know when we're rendering a new frame
|
||||
private lastFrame = -1;
|
||||
// Last update timestamp to compute deltaTime
|
||||
private lastUpdateTime: number | null = null;
|
||||
// List of cameras used for the current viewpoint (for WebXR)
|
||||
private defaultCameras: THREE.Matrix4[] = [];
|
||||
|
||||
// Should be set to the defaultView, but can be temporarily changed to another
|
||||
// viewpoint using prepareViewpoint() for rendering from a different viewpoint.
|
||||
viewpoint: ForgeViewpoint;
|
||||
|
||||
// Holds data needed to perform a scheduled Gsplat update.
|
||||
private pendingUpdate: {
|
||||
scene: THREE.Scene;
|
||||
originToWorld: THREE.Matrix4;
|
||||
} | null = null;
|
||||
|
||||
// Internal ForgeViewpoint used for environment map rendering.
|
||||
private envViewpoint: ForgeViewpoint | null = null;
|
||||
|
||||
// Data and buffers used for environment map rendering
|
||||
private static cubeRender: {
|
||||
target: THREE.WebGLCubeRenderTarget;
|
||||
camera: THREE.CubeCamera;
|
||||
near: number;
|
||||
far: number;
|
||||
} | null = null;
|
||||
private static pmrem: THREE.PMREMGenerator | null = null;
|
||||
|
||||
constructor(options: ForgeRendererOptions) {
|
||||
const uniforms = ForgeRenderer.makeUniforms();
|
||||
const shaders = getShaders();
|
||||
const material = new THREE.ShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: shaders.splatVertex,
|
||||
fragmentShader: shaders.splatFragment,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
blending: THREE.NormalBlending,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
super(EMPTY_GEOMETRY, material);
|
||||
// Disable frustum culling because we want to always draw them all
|
||||
// and cull Gsplats individually in the shader
|
||||
this.frustumCulled = false;
|
||||
|
||||
this.renderer = options.renderer;
|
||||
this.material = material;
|
||||
this.uniforms = uniforms;
|
||||
|
||||
// Create a Gsplat modifier that takes the output of any SplatGenerator
|
||||
// and transforms them into the accumulator's coordinate system
|
||||
const modifier = dynoBlock(
|
||||
{ gsplat: Gsplat },
|
||||
{ gsplat: Gsplat },
|
||||
({ gsplat }) => {
|
||||
if (!gsplat) {
|
||||
throw new Error("gsplat not defined");
|
||||
}
|
||||
gsplat = transformGsplat(gsplat, {
|
||||
rotate: this.rotateToAccumulator,
|
||||
translate: this.translateToAccumulator,
|
||||
});
|
||||
return { gsplat };
|
||||
},
|
||||
);
|
||||
this.modifier = new SplatModifier(modifier);
|
||||
|
||||
this.autoUpdate = options.autoUpdate ?? true;
|
||||
this.preUpdate = options.preUpdate ?? false;
|
||||
this.originDistance = options.originDistance ?? 1;
|
||||
this.maxStdDev = options.maxStdDev ?? Math.sqrt(8.0);
|
||||
this.enable2DGS = options.enable2DGS ?? true;
|
||||
this.preBlurAmount = options.preBlurAmount ?? 0.0;
|
||||
this.blurAmount = options.blurAmount ?? 0.3;
|
||||
this.falloff = options.falloff ?? 1.0;
|
||||
this.clipXY = options.clipXY ?? 1.4;
|
||||
|
||||
this.active = new SplatAccumulator();
|
||||
this.accumulatorCount = 1;
|
||||
this.freeAccumulators = [];
|
||||
// Start with the minimum of 2 total accumulators
|
||||
for (let count = 0; count < 1; ++count) {
|
||||
this.freeAccumulators.push(new SplatAccumulator());
|
||||
this.accumulatorCount += 1;
|
||||
}
|
||||
|
||||
// Create a default ForgeViewpoint that is used when we call render()
|
||||
// on the scene and has the sorted Gsplat collection from that viewpoint.
|
||||
this.defaultView = new ForgeViewpoint({
|
||||
...options.view,
|
||||
autoUpdate: true,
|
||||
forge: this,
|
||||
});
|
||||
this.viewpoint = this.defaultView;
|
||||
this.prepareViewpoint(this.viewpoint);
|
||||
|
||||
this.clock = options.clock ? cloneClock(options.clock) : new THREE.Clock();
|
||||
|
||||
forgeRendererInstance = this;
|
||||
}
|
||||
|
||||
static makeUniforms() {
|
||||
// Create uniforms used for Gsplat vertex and fragment shaders
|
||||
const uniforms = {
|
||||
// Size of render viewport in pixels
|
||||
renderSize: { value: new THREE.Vector2() },
|
||||
// Total number of Gsplats in packedSplats to render
|
||||
numSplats: { value: 0 },
|
||||
// SplatAccumulator to view transformation quaternion
|
||||
renderToViewQuat: { value: new THREE.Quaternion() },
|
||||
// SplatAccumulator to view transformation translation
|
||||
renderToViewPos: { value: new THREE.Vector3() },
|
||||
// Maximum distance (in stddevs) from Gsplat center to render
|
||||
maxStdDev: { value: 1.0 },
|
||||
// Enable interpreting 0-thickness Gsplats as 2DGS
|
||||
enable2DGS: { value: false },
|
||||
// Add to projected 2D splat covariance diagonal (thickens and brightens)
|
||||
preBlurAmount: { value: 0.0 },
|
||||
// Add to 2D splat covariance diagonal and adjust opacity (anti-aliasing)
|
||||
blurAmount: { value: 0.3 },
|
||||
// Modulate Gaussian kernal falloff. 0 means "no falloff, flat shading",
|
||||
// 1 is normal e^-x^2 falloff.
|
||||
falloff: { value: 1.0 },
|
||||
// Clip Gsplats that are clipXY times beyond the +-1 frustum bounds
|
||||
clipXY: { value: 1.4 },
|
||||
// Gsplat collection to render
|
||||
packedSplats: { type: "t", value: PackedSplats.getEmpty() },
|
||||
// Time in seconds for time-based effects
|
||||
time: { value: 0 },
|
||||
// Delta time in seconds since last frame
|
||||
deltaTime: { value: 0 },
|
||||
// Whether to encode Gsplat with linear RGB (for environment mapping)
|
||||
encodeLinear: { value: false },
|
||||
// Debug flag that alternates each frame
|
||||
debugFlag: { value: false },
|
||||
};
|
||||
return uniforms;
|
||||
}
|
||||
|
||||
private canAllocAccumulator(): boolean {
|
||||
// Returns true if can allocate an accumulator immediately
|
||||
return (
|
||||
this.freeAccumulators.length > 0 ||
|
||||
this.accumulatorCount < MAX_ACCUMULATORS
|
||||
);
|
||||
}
|
||||
|
||||
private maybeAllocAccumulator(): SplatAccumulator | null {
|
||||
// Allocate an accumulator immediately if possible, else return null
|
||||
let accumulator = this.freeAccumulators.pop();
|
||||
if (accumulator === undefined) {
|
||||
if (this.accumulatorCount >= MAX_ACCUMULATORS) {
|
||||
return null;
|
||||
}
|
||||
accumulator = new SplatAccumulator();
|
||||
this.accumulatorCount += 1;
|
||||
}
|
||||
accumulator.refCount = 1;
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
releaseAccumulator(accumulator: SplatAccumulator) {
|
||||
// Decrement reference count and recycle if no longer in use
|
||||
accumulator.refCount -= 1;
|
||||
if (accumulator.refCount === 0) {
|
||||
this.freeAccumulators.push(accumulator);
|
||||
}
|
||||
}
|
||||
|
||||
newViewpoint(options: ForgeViewpointOptions) {
|
||||
// Create a new ForgeViewpoint for this ForgeRenderer.
|
||||
// Note that every ForgeRenderer has an initial forge.defaultView: ForgeViewpoint
|
||||
// from construction, which is used for the default canvas render loop.
|
||||
// Calling this method allows you to create additional viewpoints, which can be
|
||||
// updated automatically each frame (performing Gsplat sorting every time there
|
||||
// is an update), or updated on-demand for controlled rendering for video render
|
||||
// or similar applications.
|
||||
return new ForgeViewpoint({ ...options, forge: this });
|
||||
}
|
||||
|
||||
onBeforeRender(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
scene: THREE.Scene,
|
||||
camera: THREE.Camera,
|
||||
) {
|
||||
// Called by Three.js before rendering this ForgeRenderer.
|
||||
// At this point we can't modify the geometry or material, all these must
|
||||
// be set in the scene already before this is called. Update the uniforms
|
||||
// to render the Gsplats from the current active viewpoint.
|
||||
const time = this.time ?? this.clock.getElapsedTime();
|
||||
const deltaTime = time - (this.viewpoint.lastTime ?? time);
|
||||
this.viewpoint.lastTime = time;
|
||||
|
||||
const frame = renderer.info.render.frame;
|
||||
const isNewFrame = frame !== this.lastFrame;
|
||||
this.lastFrame = frame;
|
||||
|
||||
const viewpoint = this.viewpoint;
|
||||
if (viewpoint === this.defaultView) {
|
||||
// When rendering is triggered on the default viewpoint,
|
||||
// perform automatic updates.
|
||||
if (isNewFrame) {
|
||||
if (!renderer.xr.isPresenting) {
|
||||
// Non-WebXR mode, just a single camera
|
||||
this.defaultView.viewToWorld = camera.matrixWorld.clone();
|
||||
this.defaultCameras = [this.defaultView.viewToWorld];
|
||||
} else {
|
||||
// In WebXR mode we are called multiple times, once for each eye,
|
||||
// so use their average to compute the sort center.
|
||||
const cameras = renderer.xr.getCamera().cameras;
|
||||
this.defaultCameras = [
|
||||
cameras[0].matrixWorld,
|
||||
cameras[1].matrixWorld,
|
||||
];
|
||||
this.defaultView.viewToWorld =
|
||||
averageOriginToWorlds(this.defaultCameras) ?? new THREE.Matrix4();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.autoUpdate) {
|
||||
this.update({ scene, viewToWorld: this.defaultView.viewToWorld });
|
||||
}
|
||||
}
|
||||
|
||||
// Update uniforms for rendering
|
||||
|
||||
if (isNewFrame) {
|
||||
// Keep these uniforms the same for both eyes if in WebXR
|
||||
this.uniforms.time.value = time;
|
||||
this.uniforms.deltaTime.value = deltaTime;
|
||||
// Alternating debug flag that can aid in visual debugging
|
||||
this.uniforms.debugFlag.value = (performance.now() / 1000.0) % 2.0 < 1.0;
|
||||
}
|
||||
|
||||
if (viewpoint.target) {
|
||||
// Rendering to a texture target, so its dimensions
|
||||
this.uniforms.renderSize.value.set(
|
||||
viewpoint.target.width,
|
||||
viewpoint.target.height,
|
||||
);
|
||||
} else {
|
||||
// Rendering to the canvas or WebXR
|
||||
const renderSize = renderer.getSize(this.uniforms.renderSize.value);
|
||||
if (renderSize.x === 1 && renderSize.y === 1) {
|
||||
// WebXR mode on Apple Vision Pro returns 1x1 when presenting.
|
||||
// Use a different means to figure out the render size.
|
||||
const baseLayer = renderer.xr.getSession()?.renderState.baseLayer;
|
||||
if (baseLayer) {
|
||||
renderSize.x = baseLayer.framebufferWidth;
|
||||
renderSize.y = baseLayer.framebufferHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update uniforms from instance properties
|
||||
this.uniforms.encodeLinear.value = viewpoint.encodeLinear;
|
||||
this.uniforms.maxStdDev.value = this.maxStdDev;
|
||||
this.uniforms.enable2DGS.value = this.enable2DGS;
|
||||
this.uniforms.preBlurAmount.value = this.preBlurAmount;
|
||||
this.uniforms.blurAmount.value = this.blurAmount;
|
||||
this.uniforms.falloff.value = this.falloff;
|
||||
this.uniforms.clipXY.value = this.clipXY;
|
||||
|
||||
// Calculate the transform from the accumulator to the current camera
|
||||
const accumToWorld =
|
||||
viewpoint.display?.accumulator.toWorld ?? new THREE.Matrix4();
|
||||
const worldToCamera = camera.matrixWorld.clone().invert();
|
||||
const originToCamera = accumToWorld.clone().premultiply(worldToCamera);
|
||||
originToCamera.decompose(
|
||||
this.uniforms.renderToViewPos.value,
|
||||
this.uniforms.renderToViewQuat.value,
|
||||
new THREE.Vector3(),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the uniforms for the given viewpoint.
|
||||
// Note that the client expects to be able to call render() at any point
|
||||
// to update the canvas, so we must switch the viewpoint back to
|
||||
// defaultView when we're finished.
|
||||
prepareViewpoint(viewpoint?: ForgeViewpoint) {
|
||||
this.viewpoint = viewpoint ?? this.viewpoint;
|
||||
|
||||
if (this.viewpoint.display) {
|
||||
const { accumulator, geometry } = this.viewpoint.display;
|
||||
this.uniforms.numSplats.value = accumulator.splats.numSplats;
|
||||
this.uniforms.packedSplats.value = accumulator.splats.getTexture();
|
||||
this.geometry = geometry;
|
||||
} else {
|
||||
// No Gsplats to display for this viewpoint yet
|
||||
this.uniforms.numSplats.value = 0;
|
||||
this.uniforms.packedSplats.value = PackedSplats.getEmpty();
|
||||
this.geometry = EMPTY_GEOMETRY;
|
||||
}
|
||||
}
|
||||
|
||||
// If forge.autoUpdate is false then you must manually call
|
||||
// forge.update({ scene }) to have the scene Gsplats be re-generated.
|
||||
update({
|
||||
scene,
|
||||
viewToWorld,
|
||||
}: { scene: THREE.Scene; viewToWorld?: THREE.Matrix4 }) {
|
||||
// Compute the transform for the ForgeRenderer to use as origin
|
||||
// for Gsplat generation and accumulation.
|
||||
const originToWorld = this.matrixWorld.clone();
|
||||
// Either do the update now, or in the next "tick" depending on preUpdate
|
||||
if (this.preUpdate) {
|
||||
this.updateInternal({ scene, originToWorld, viewToWorld });
|
||||
} else {
|
||||
// Pass the update parameters to be performed on the next tick
|
||||
this.pendingUpdate = {
|
||||
scene,
|
||||
originToWorld,
|
||||
};
|
||||
setTimeout(() => {
|
||||
if (this.pendingUpdate) {
|
||||
const { scene, originToWorld } = this.pendingUpdate;
|
||||
this.pendingUpdate = null;
|
||||
this.updateInternal({ scene, originToWorld, viewToWorld });
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
updateInternal({
|
||||
scene,
|
||||
originToWorld,
|
||||
viewToWorld,
|
||||
}: {
|
||||
scene: THREE.Scene;
|
||||
originToWorld?: THREE.Matrix4;
|
||||
viewToWorld?: THREE.Matrix4;
|
||||
}): boolean {
|
||||
if (!this.canAllocAccumulator()) {
|
||||
// We don't have any available accumulators because of sorting
|
||||
// back pressure, so don't update this time but try again next time.
|
||||
// Signal update not attempted.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Figure out the frame of the ForgeRenderer and current view
|
||||
if (!originToWorld) {
|
||||
originToWorld = this.active.toWorld;
|
||||
}
|
||||
viewToWorld = viewToWorld ?? originToWorld.clone();
|
||||
|
||||
const time = this.time ?? this.clock.getElapsedTime();
|
||||
const deltaTime = time - (this.lastUpdateTime ?? time);
|
||||
this.lastUpdateTime = time;
|
||||
|
||||
// Create a lookup from last active SplatGenerator to Gsplat mapping record
|
||||
const activeMapping = this.active.mapping.reduce((map, record) => {
|
||||
map.set(record.node, record);
|
||||
return map;
|
||||
}, new Map<SplatGenerator, GeneratorMapping>());
|
||||
|
||||
// Traverse visible scene to find all SplatGenerators and global SplatEdits
|
||||
const { generators, globalEdits } = this.compileScene(scene);
|
||||
|
||||
// Let all SplatGenerators run their frameUpdate() method
|
||||
for (const object of generators) {
|
||||
object.frameUpdate?.({
|
||||
object,
|
||||
time,
|
||||
deltaTime,
|
||||
viewToWorld,
|
||||
globalEdits,
|
||||
});
|
||||
}
|
||||
|
||||
// Make sure we have new version numbers for any objects with either
|
||||
// generator or numSplats that have changed since the last frame.
|
||||
for (const object of generators) {
|
||||
const current = activeMapping.get(object);
|
||||
const numSplats = object.generator ? object.numSplats : 0;
|
||||
if (
|
||||
object.generator !== current?.generator ||
|
||||
numSplats !== current?.count
|
||||
) {
|
||||
object.updateVersion();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the origin is within the maximum allowed distance before
|
||||
// we trigger an update.
|
||||
const originUpdate = !withinCoorientDist({
|
||||
matrix1: originToWorld,
|
||||
matrix2: this.active.toWorld,
|
||||
maxDistance: this.originDistance,
|
||||
});
|
||||
|
||||
// Check if we need any update at all
|
||||
const needsUpdate =
|
||||
originUpdate ||
|
||||
generators.length !== activeMapping.size ||
|
||||
generators.some((g) => g.version !== activeMapping.get(g)?.version);
|
||||
|
||||
let accumulator: SplatAccumulator | null = null;
|
||||
if (needsUpdate) {
|
||||
// Need to update, so allocate an accumulator
|
||||
accumulator = this.maybeAllocAccumulator();
|
||||
if (!accumulator) {
|
||||
// This should never happen since we checked canAllocAccumulator() above
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
// Compute whether our view frame has changed enough to warrant
|
||||
// doing a Gsplat sort. Check both distance epsilon and
|
||||
// minimum co-orientation (dot product of quaternions)
|
||||
const originChanged = !withinCoorientDist({
|
||||
matrix1: originToWorld,
|
||||
matrix2: this.active.toWorld,
|
||||
maxDistance: 0.00001,
|
||||
minCoorient: 0.99999,
|
||||
});
|
||||
|
||||
// Compute an ordering of the generators with the rough goal
|
||||
// of keeping unchanging generators near the front to minimize
|
||||
// the number of Gsplats that need to be regenerated.
|
||||
const sorted = generators
|
||||
.map((g, gIndex): [number, number, SplatGenerator] => {
|
||||
const lastGen = activeMapping.get(g);
|
||||
// If no previous generator, sort by absolute version, which will
|
||||
// tend to push frequently updated generators toward the end
|
||||
return !lastGen
|
||||
? [Number.POSITIVE_INFINITY, g.version, g]
|
||||
: // Sort by version deltas then by previous ordering in the mapping,
|
||||
// attempting to keep unchanging generators near the front
|
||||
// to improve our chances of avoiding a re-generation.
|
||||
[g.version - lastGen.version, lastGen.base, g];
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by first then second element of the tuple
|
||||
if (a[0] !== b[0]) {
|
||||
return a[0] - b[0];
|
||||
}
|
||||
return a[1] - b[1];
|
||||
});
|
||||
const genOrder = sorted.map(([_version, _seq, g]) => g);
|
||||
|
||||
// Compute sequential layout of generated splats
|
||||
const splatCounts = genOrder.map((g) => g.numSplats);
|
||||
const { maxSplats, mapping } =
|
||||
accumulator.splats.generateMapping(splatCounts);
|
||||
const newGenerators = genOrder.map((node, gIndex) => {
|
||||
const { base, count } = mapping[gIndex];
|
||||
return {
|
||||
node,
|
||||
generator: node.generator,
|
||||
version: node.version,
|
||||
base,
|
||||
count,
|
||||
};
|
||||
});
|
||||
|
||||
// Compute worldToAccumulator origin transform (no scale)
|
||||
originToWorld
|
||||
.clone()
|
||||
.invert()
|
||||
.decompose(
|
||||
this.translateToAccumulator.value,
|
||||
this.rotateToAccumulator.value,
|
||||
new THREE.Vector3(),
|
||||
);
|
||||
|
||||
// Generate the Gsplats according to the mapping that need updating
|
||||
accumulator.ensureGenerate(maxSplats);
|
||||
const generated = accumulator.generateSplats({
|
||||
renderer: this.renderer,
|
||||
modifier: this.modifier,
|
||||
generators: newGenerators,
|
||||
forceUpdate: originChanged,
|
||||
originToWorld,
|
||||
});
|
||||
|
||||
// Update splat version number
|
||||
accumulator.splatsVersion = this.active.splatsVersion + 1;
|
||||
// Increment the mapping version if the mapping isn't identical to before
|
||||
const hasCorrespondence = accumulator.hasCorrespondence(this.active);
|
||||
accumulator.mappingVersion =
|
||||
this.active.mappingVersion + (hasCorrespondence ? 0 : 1);
|
||||
|
||||
// Release the old accumulator and make the new one active
|
||||
this.releaseAccumulator(this.active);
|
||||
this.active = accumulator;
|
||||
this.prepareViewpoint();
|
||||
}
|
||||
|
||||
// Let the system breath before potentially triggering sorts
|
||||
setTimeout(() => {
|
||||
// Notify all auto-updating viewpoints that we updated the Gsplats
|
||||
for (const view of this.autoViewpoints) {
|
||||
view.autoPoll({ accumulator: accumulator ?? undefined });
|
||||
}
|
||||
}, 1);
|
||||
|
||||
// Signal update was performed
|
||||
return true;
|
||||
}
|
||||
|
||||
private compileScene(scene: THREE.Scene): {
|
||||
generators: SplatGenerator[];
|
||||
globalEdits: SplatEdit[];
|
||||
} {
|
||||
// Take a snapshot of the SplatGenerators and SplatEdits in the scene
|
||||
// to be used to run an update.
|
||||
const generators: SplatGenerator[] = [];
|
||||
scene.traverse((node) => {
|
||||
if (node instanceof SplatGenerator) {
|
||||
generators.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
const globalEdits = new Set<SplatEdit>();
|
||||
scene.traverseVisible((node) => {
|
||||
if (node instanceof SplatEdit) {
|
||||
let ancestor = node.parent;
|
||||
while (ancestor != null && !(ancestor instanceof SplatMesh)) {
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
if (ancestor == null) {
|
||||
// Not part of a SplatMesh so it's a global edit
|
||||
globalEdits.add(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
return { generators, globalEdits: Array.from(globalEdits) };
|
||||
}
|
||||
|
||||
// Renders out the scene to an environment map that can be used for
|
||||
// Image-based lighting or similar applications. First optionally updates Gsplats,
|
||||
// sorts them with respect to the provided worldCenter, renders 6 cube faces,
|
||||
// then pre-filters them using THREE.PMREMGenerator and returns a THREE.Texture
|
||||
// that can assigned directly to a THREE.MeshStandardMaterial.envMap property.
|
||||
async renderEnvMap({
|
||||
renderer,
|
||||
scene,
|
||||
worldCenter,
|
||||
size = 256,
|
||||
near = 0.1,
|
||||
far = 1000,
|
||||
hideObjects = [],
|
||||
update = false,
|
||||
}: {
|
||||
renderer?: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
worldCenter: THREE.Vector3;
|
||||
size?: number;
|
||||
near?: number;
|
||||
far?: number;
|
||||
hideObjects?: THREE.Object3D[];
|
||||
update?: boolean;
|
||||
}): Promise<THREE.Texture> {
|
||||
if (!this.envViewpoint) {
|
||||
this.envViewpoint = this.newViewpoint({ sort360: true });
|
||||
}
|
||||
if (
|
||||
!ForgeRenderer.cubeRender ||
|
||||
ForgeRenderer.cubeRender.target.width !== size ||
|
||||
ForgeRenderer.cubeRender.near !== near ||
|
||||
ForgeRenderer.cubeRender.far !== far
|
||||
) {
|
||||
if (ForgeRenderer.cubeRender) {
|
||||
ForgeRenderer.cubeRender.target.dispose();
|
||||
}
|
||||
const target = new THREE.WebGLCubeRenderTarget(size, {
|
||||
format: THREE.RGBAFormat,
|
||||
generateMipmaps: true,
|
||||
minFilter: THREE.LinearMipMapLinearFilter,
|
||||
});
|
||||
const camera = new THREE.CubeCamera(near, far, target);
|
||||
ForgeRenderer.cubeRender = { target, camera, near, far };
|
||||
}
|
||||
|
||||
if (!ForgeRenderer.pmrem) {
|
||||
ForgeRenderer.pmrem = new THREE.PMREMGenerator(renderer ?? this.renderer);
|
||||
}
|
||||
|
||||
// Prepare the viewpoint, sorting Gsplats for this view origin.
|
||||
const viewToWorld = new THREE.Matrix4().setPosition(worldCenter);
|
||||
await this.envViewpoint?.prepare({ scene, viewToWorld, update });
|
||||
|
||||
const { target, camera } = ForgeRenderer.cubeRender;
|
||||
camera.position.copy(worldCenter);
|
||||
|
||||
// Save the visibility state of objects we want to hide before render
|
||||
const objectVisibility = new Map<THREE.Object3D, boolean>();
|
||||
for (const object of hideObjects) {
|
||||
objectVisibility.set(object, object.visible);
|
||||
object.visible = false;
|
||||
}
|
||||
|
||||
// Update the CubeCamera, which performs 6 cube face renders
|
||||
this.prepareViewpoint(this.envViewpoint);
|
||||
camera.update(renderer ?? this.renderer, scene);
|
||||
|
||||
// Restore viewpoint to default and object visibility
|
||||
this.prepareViewpoint(this.defaultView);
|
||||
for (const [object, visible] of objectVisibility.entries()) {
|
||||
object.visible = visible;
|
||||
}
|
||||
|
||||
// Pre-filter the cube map using THREE.PMREMGenerator
|
||||
return ForgeRenderer.pmrem?.fromCubemap(target.texture).texture;
|
||||
}
|
||||
|
||||
// Utility function to recursively set the envMap property for any
|
||||
// THREE.MeshStandardMaterial within the subtree of root.
|
||||
recurseSetEnvMap(root: THREE.Object3D, envMap: THREE.Texture) {
|
||||
root.traverse((node) => {
|
||||
if (node instanceof THREE.Mesh) {
|
||||
if (Array.isArray(node.material)) {
|
||||
for (const material of node.material) {
|
||||
if (material instanceof THREE.MeshStandardMaterial) {
|
||||
material.envMap = envMap;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (node.material instanceof THREE.MeshStandardMaterial) {
|
||||
node.material.envMap = envMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Utility function that helps extract the Gsplat RGBA values from a
|
||||
// SplatGenerator, including the result of any real-time RGBA SDF edits applied
|
||||
// to a SplatMesh. This effectively "bakes" any computed RGBA values, which can
|
||||
// now be used as a pipeline input via SplatMesh.splatRgba to inject these
|
||||
// baked values into the Gsplat data.
|
||||
getRgba({
|
||||
generator,
|
||||
rgba,
|
||||
}: { generator: SplatGenerator; rgba?: RgbaArray }): RgbaArray {
|
||||
const mapping = this.active.mapping.find(({ node }) => node === generator);
|
||||
if (!mapping) {
|
||||
throw new Error("Generator not found");
|
||||
}
|
||||
|
||||
rgba = rgba ?? new RgbaArray();
|
||||
rgba.fromPackedSplats({
|
||||
packedSplats: this.active.splats,
|
||||
base: mapping.base,
|
||||
count: mapping.count,
|
||||
renderer: this.renderer,
|
||||
});
|
||||
return rgba;
|
||||
}
|
||||
|
||||
// Utility function that builds on getRgba({ generator }) and additionally
|
||||
// reads back the RGBA values to the CPU in a Uint8Array with packed RGBA
|
||||
// in that byte order.
|
||||
async readRgba({
|
||||
generator,
|
||||
rgba,
|
||||
}: { generator: SplatGenerator; rgba?: RgbaArray }): Promise<Uint8Array> {
|
||||
rgba = this.getRgba({ generator, rgba });
|
||||
return rgba.read();
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_GEOMETRY = new SplatGeometry(new Uint32Array(1), 0);
|
||||
|
||||
const reorderSplats = dynoBlock(
|
||||
{ packedSplats: TPackedSplats, index: "int" },
|
||||
{ gsplat: Gsplat },
|
||||
({ packedSplats, index }) => {
|
||||
if (!packedSplats || !index) {
|
||||
throw new Error("Invalid input");
|
||||
}
|
||||
const gsplat = readPackedSplat(packedSplats, index);
|
||||
return { gsplat };
|
||||
},
|
||||
);
|
||||
|
||||
function averageOriginToWorlds(
|
||||
originToWorlds: THREE.Matrix4[],
|
||||
): THREE.Matrix4 | null {
|
||||
if (originToWorlds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
|
||||
const positions: THREE.Vector3[] = [];
|
||||
const quaternions: THREE.Quaternion[] = [];
|
||||
for (const matrix of originToWorlds) {
|
||||
matrix.decompose(position, quaternion, scale);
|
||||
positions.push(position);
|
||||
quaternions.push(quaternion);
|
||||
}
|
||||
|
||||
return new THREE.Matrix4().compose(
|
||||
averagePositions(positions),
|
||||
averageQuaternions(quaternions),
|
||||
new THREE.Vector3(1, 1, 1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { ForgeRenderer } from "./ForgeRenderer";
|
||||
import { DynoPackedSplats } from "./PackedSplats";
|
||||
import { Readback } from "./Readback";
|
||||
import type { SplatAccumulator } from "./SplatAccumulator";
|
||||
import { SplatGeometry } from "./SplatGeometry";
|
||||
import {
|
||||
type DynoBlock,
|
||||
DynoBool,
|
||||
DynoFloat,
|
||||
type DynoVal,
|
||||
DynoVec3,
|
||||
Gsplat,
|
||||
add,
|
||||
combine,
|
||||
defineGsplat,
|
||||
dyno,
|
||||
dynoBlock,
|
||||
dynoConst,
|
||||
mul,
|
||||
packHalf2x16,
|
||||
readPackedSplat,
|
||||
uintToRgba8,
|
||||
unindent,
|
||||
unindentLines,
|
||||
} from "./dyno";
|
||||
import { withWorker } from "./splatWorker";
|
||||
import { FreeList, withinCoorientDist } from "./utils";
|
||||
|
||||
export type ForgeViewpointOptions = {
|
||||
// Controls whether to auto-update its sort order whenever the ForgeRenderer
|
||||
// updates the Gsplats. If you expect to render/display from this viewpoint
|
||||
// most frames, set this to true. (default: false)
|
||||
autoUpdate?: boolean;
|
||||
// Set a THREE.Camera for this viewpoint to follow. (default: undefined)
|
||||
camera?: THREE.Camera;
|
||||
// Set an explicit view-to-world transformation matrix for this viewpoint (equivalent
|
||||
// to camera.matrixWorld), overrides any camera setting. (default: undefined)
|
||||
viewToWorld?: THREE.Matrix4;
|
||||
// Configure viewpoint with an off-screen render target. (default: undefined)
|
||||
target?: {
|
||||
// Width of the render target in pixels.
|
||||
width: number;
|
||||
// Height of the render target in pixels.
|
||||
height: number;
|
||||
// If you want to be able to render a scene that depends on this target's
|
||||
// output (for example, a recursive viewport), set this to true to enable
|
||||
// double buffering. (default: false)
|
||||
doubleBuffer?: boolean;
|
||||
// Super-sampling factor for the render target. Values 1-4 are supported.
|
||||
// Note that re-sampling back down to .width x .height is done on the CPU
|
||||
// with simple averaging only when calling readTarget(). (default: 1)
|
||||
superXY?: number;
|
||||
};
|
||||
// Callback function that is called when the render target texture is updated.
|
||||
// Receives the texture as a parameter. Use this to update a viewport with
|
||||
// the latest viewpoint render each frame. (default: undefined)
|
||||
onTextureUpdated?: (texture: THREE.Texture) => void;
|
||||
// Whether to sort splats radially (geometric distance) from the viewpoint (true)
|
||||
// or by Z-depth (false). Most scenes are trained with the Z-depth sort metric
|
||||
// and will render more accurately at certain viewpoints. However, radial sorting
|
||||
// is more stable under viewpoint rotations. (default: true)
|
||||
sortRadial?: boolean;
|
||||
// Distance threshold for re-sorting splats. If the viewpoint moves more than
|
||||
// this distance, splats will be re-sorted. (default: 0.01 units)
|
||||
sortDistance?: number;
|
||||
// View direction dot product threshold for re-sorting splats. For
|
||||
// sortRadial: true we use 0.99 while sortRadial: false uses 0.999 because it is
|
||||
// more sensitive to view direction. (default: 0.99 if sortRadial else 0.999)
|
||||
sortCoorient?: boolean;
|
||||
// Constant added to Z-depth to bias values into the positive range for
|
||||
// sortRadial: false, but also used for culling Gsplats "well behind"
|
||||
// the viewpoint origin (default: 1.0)
|
||||
depthBias?: number;
|
||||
// Set this to true if rendering a 360 to disable "behind the viewpoint"
|
||||
// culling during sorting. This is set automatically when rendering 360 envMaps
|
||||
// using the ForgeRenderer.renderEnvMap() utility function. (default: false)
|
||||
sort360?: boolean;
|
||||
};
|
||||
|
||||
// A ForgeViewpoint is created from and tied to a ForgeRenderer, and represents
|
||||
// an independent viewpoint of all the scene Gsplats and their sort order. Making
|
||||
// these viewpoints explicit allows us to have multiple, simultaneous viewpoint
|
||||
// renders, for example for camera preview panes or overhead map views.
|
||||
//
|
||||
// When creating a ForgeRenderer it automatically creates a default viewpoint
|
||||
// .defaultView that is used in the normal render loop when drawing to the canvas,
|
||||
// and is automatically updated whenever the camera moves. Additional viewpoints
|
||||
// can be created and configured separately.
|
||||
|
||||
export class ForgeViewpoint {
|
||||
forge: ForgeRenderer;
|
||||
autoUpdate: boolean;
|
||||
camera?: THREE.Camera;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
lastTime: number | null = null;
|
||||
|
||||
target?: THREE.WebGLRenderTarget;
|
||||
private back?: THREE.WebGLRenderTarget;
|
||||
onTextureUpdated?: (texture: THREE.Texture) => void;
|
||||
encodeLinear = false;
|
||||
superXY = 1;
|
||||
private superPixels?: Uint8Array;
|
||||
private pixels?: Uint8Array;
|
||||
|
||||
sortRadial: boolean;
|
||||
sortDistance?: number;
|
||||
sortCoorient?: boolean;
|
||||
depthBias?: number;
|
||||
sort360?: boolean;
|
||||
|
||||
display: {
|
||||
accumulator: SplatAccumulator;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
geometry: SplatGeometry;
|
||||
} | null = null;
|
||||
|
||||
private sorting: { viewToWorld: THREE.Matrix4 } | null = null;
|
||||
private pending: {
|
||||
accumulator?: SplatAccumulator;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
displayed: boolean;
|
||||
} | null = null;
|
||||
private sortingCheck = false;
|
||||
|
||||
private readback: Uint16Array = new Uint16Array(0);
|
||||
private orderingFreelist: FreeList<Uint32Array, number>;
|
||||
|
||||
constructor(options: ForgeViewpointOptions & { forge: ForgeRenderer }) {
|
||||
this.forge = options.forge;
|
||||
this.camera = options.camera;
|
||||
this.viewToWorld = options.viewToWorld ?? new THREE.Matrix4();
|
||||
|
||||
if (options.target) {
|
||||
const { width, height, doubleBuffer } = options.target;
|
||||
const superXY = Math.max(1, Math.min(4, options.target.superXY ?? 1));
|
||||
this.superXY = superXY;
|
||||
if (width * superXY > 8192 || height * superXY > 8192) {
|
||||
throw new Error("Target size too large");
|
||||
}
|
||||
|
||||
this.target = new THREE.WebGLRenderTarget(
|
||||
width * superXY,
|
||||
height * superXY,
|
||||
{
|
||||
format: THREE.RGBAFormat,
|
||||
type: THREE.UnsignedByteType,
|
||||
colorSpace: THREE.SRGBColorSpace,
|
||||
},
|
||||
);
|
||||
if (doubleBuffer) {
|
||||
this.back = new THREE.WebGLRenderTarget(
|
||||
width * superXY,
|
||||
height * superXY,
|
||||
{
|
||||
format: THREE.RGBAFormat,
|
||||
type: THREE.UnsignedByteType,
|
||||
colorSpace: THREE.SRGBColorSpace,
|
||||
},
|
||||
);
|
||||
}
|
||||
this.encodeLinear = true;
|
||||
}
|
||||
this.onTextureUpdated = options.onTextureUpdated;
|
||||
|
||||
this.sortRadial = options.sortRadial ?? true;
|
||||
this.sortDistance = options.sortDistance;
|
||||
this.sortCoorient = options.sortCoorient;
|
||||
this.depthBias = options.depthBias;
|
||||
this.sort360 = options.sort360;
|
||||
|
||||
this.orderingFreelist = new FreeList({
|
||||
allocate: (maxSplats) => new Uint32Array(maxSplats),
|
||||
valid: (ordering, maxSplats) => ordering.length === maxSplats,
|
||||
});
|
||||
|
||||
this.autoUpdate = false;
|
||||
this.setAutoUpdate(options.autoUpdate ?? false);
|
||||
}
|
||||
|
||||
// Call this when you are done with the ForgeViewpoint and want to
|
||||
// free up its resources (GPU targets, pixel buffers, etc.)
|
||||
dispose() {
|
||||
this.setAutoUpdate(false);
|
||||
if (this.target) {
|
||||
this.target.dispose();
|
||||
this.target = undefined;
|
||||
}
|
||||
if (this.back) {
|
||||
this.back.dispose();
|
||||
this.back = undefined;
|
||||
}
|
||||
if (this.display) {
|
||||
this.forge.releaseAccumulator(this.display.accumulator);
|
||||
this.display.geometry.dispose();
|
||||
this.display = null;
|
||||
}
|
||||
if (this.pending?.accumulator) {
|
||||
this.forge.releaseAccumulator(this.pending.accumulator);
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Use this function to change whether this viewpoint will auto-update
|
||||
// its sort order whenever the attached ForgeRenderer updates the Gsplats.
|
||||
// Turn this on or off depending on whether you expect to do renders from
|
||||
// this viewpoint most frames.
|
||||
setAutoUpdate(autoUpdate: boolean) {
|
||||
if (!this.autoUpdate && autoUpdate) {
|
||||
this.forge.autoViewpoints.push(this);
|
||||
} else if (this.autoUpdate && !autoUpdate) {
|
||||
this.forge.autoViewpoints = this.forge.autoViewpoints.filter(
|
||||
(v) => v !== this,
|
||||
);
|
||||
}
|
||||
this.autoUpdate = autoUpdate;
|
||||
}
|
||||
|
||||
// See below async prepareRenderPixels() for explanation of parameters.
|
||||
// Awaiting this method updates the Gsplats in the scene and performs a sort of the
|
||||
// Gsplats from this viewpoint, preparing it for a subsequent this.renderTarget()
|
||||
// call in the same tick.
|
||||
async prepare({
|
||||
scene,
|
||||
camera,
|
||||
viewToWorld,
|
||||
update,
|
||||
forceOrigin,
|
||||
}: {
|
||||
scene: THREE.Scene;
|
||||
camera?: THREE.Camera;
|
||||
viewToWorld?: THREE.Matrix4;
|
||||
update?: boolean;
|
||||
forceOrigin?: boolean;
|
||||
}) {
|
||||
if (viewToWorld) {
|
||||
this.viewToWorld = viewToWorld;
|
||||
} else {
|
||||
this.camera = camera ?? this.camera;
|
||||
if (this.camera) {
|
||||
this.camera.updateMatrixWorld();
|
||||
this.viewToWorld = this.camera.matrixWorld.clone();
|
||||
}
|
||||
}
|
||||
while (update ?? true) {
|
||||
// Force an update, possibly with origin centered at this camera
|
||||
// to yield the best quality output.
|
||||
const originToWorld = forceOrigin ? this.viewToWorld : undefined;
|
||||
const updated = this.forge.updateInternal({ scene, originToWorld });
|
||||
if (updated) {
|
||||
break;
|
||||
}
|
||||
// A bit of a hack, but try again. We shouldn't be starved for long.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const accumulator = this.forge.active;
|
||||
if (accumulator !== this.display?.accumulator) {
|
||||
this.forge.active.refCount += 1;
|
||||
}
|
||||
await this.sortUpdate({ accumulator, viewToWorld: this.viewToWorld });
|
||||
}
|
||||
|
||||
// Render out the viewpoint to the view target RGBA buffer.
|
||||
// Swaps buffers if doubleBuffer: true was set.
|
||||
// Calls onTextureUpdated(texture) with the resulting texture.
|
||||
renderTarget({
|
||||
scene,
|
||||
camera,
|
||||
}: { scene: THREE.Scene; camera?: THREE.Camera }) {
|
||||
const target = this.back ?? this.target;
|
||||
if (!target) {
|
||||
throw new Error("Must initialize ForgeViewpoint with target");
|
||||
}
|
||||
|
||||
camera = camera ?? this.camera;
|
||||
if (!camera) {
|
||||
throw new Error("Must provide camera");
|
||||
}
|
||||
if (camera instanceof THREE.PerspectiveCamera) {
|
||||
const newCam = new THREE.PerspectiveCamera().copy(camera, false);
|
||||
newCam.aspect = target.width / target.height;
|
||||
newCam.updateProjectionMatrix();
|
||||
camera = newCam;
|
||||
}
|
||||
this.viewToWorld = camera.matrixWorld.clone();
|
||||
|
||||
try {
|
||||
this.forge.renderer.setRenderTarget(target);
|
||||
this.forge.prepareViewpoint(this);
|
||||
|
||||
this.forge.renderer.render(scene, camera);
|
||||
} finally {
|
||||
this.forge.prepareViewpoint(this.forge.defaultView);
|
||||
this.forge.renderer.setRenderTarget(null);
|
||||
}
|
||||
|
||||
if (target !== this.target) {
|
||||
// Swap back buffer and target
|
||||
[this.target, this.back] = [this.back, this.target];
|
||||
}
|
||||
this.onTextureUpdated?.(target.texture);
|
||||
}
|
||||
|
||||
// Read back the previously rendered target image as a Uint8Array of packed
|
||||
// RGBA values (in that order). If superXY was set greater than 1 then
|
||||
// downsampling is performed in the target pixel array with simple averaging
|
||||
// to derive the returned pixel values. Subsequent calls to this.readTarget()
|
||||
// will reuse the same buffers to minimize memory allocations.
|
||||
async readTarget(): Promise<Uint8Array> {
|
||||
if (!this.target) {
|
||||
throw new Error("Must initialize ForgeViewpoint with target");
|
||||
}
|
||||
const { width, height } = this.target;
|
||||
const byteSize = width * height * 4;
|
||||
if (!this.superPixels || this.superPixels.length < byteSize) {
|
||||
this.superPixels = new Uint8Array(byteSize);
|
||||
}
|
||||
await this.forge.renderer.readRenderTargetPixelsAsync(
|
||||
this.target,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
this.superPixels,
|
||||
);
|
||||
|
||||
const { superXY } = this;
|
||||
if (superXY === 1) {
|
||||
return this.superPixels;
|
||||
}
|
||||
|
||||
const subWidth = width / superXY;
|
||||
const subHeight = height / superXY;
|
||||
const subSize = subWidth * subHeight * 4;
|
||||
if (!this.pixels || this.pixels.length < subSize) {
|
||||
this.pixels = new Uint8Array(subSize);
|
||||
}
|
||||
|
||||
const { superPixels, pixels } = this;
|
||||
const super2 = superXY * superXY;
|
||||
for (let y = 0; y < subHeight; y++) {
|
||||
const row = y * subWidth;
|
||||
for (let x = 0; x < subWidth; x++) {
|
||||
const superCol = x * superXY;
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
let a = 0;
|
||||
for (let sy = 0; sy < superXY; sy++) {
|
||||
const superRow = (y * superXY + sy) * this.target.width;
|
||||
for (let sx = 0; sx < superXY; sx++) {
|
||||
const superIndex = (superRow + superCol + sx) * 4;
|
||||
r += superPixels[superIndex];
|
||||
g += superPixels[superIndex + 1];
|
||||
b += superPixels[superIndex + 2];
|
||||
a += superPixels[superIndex + 3];
|
||||
}
|
||||
}
|
||||
const pixelIndex = (row + x) * 4;
|
||||
pixels[pixelIndex] = r / super2;
|
||||
pixels[pixelIndex + 1] = g / super2;
|
||||
pixels[pixelIndex + 2] = b / super2;
|
||||
pixels[pixelIndex + 3] = a / super2;
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
|
||||
// Render out a viewpoint as a Uint8Array of RGBA values for the provided scene
|
||||
// and any camera/viewToWorld viewpoint overrides. By default update is true,
|
||||
// which triggers its ForgeRenderer to check and potentially update the Gsplats.
|
||||
// Setting update to false disables this and sorts the Gsplats as they are.
|
||||
// Setting forceOrigin (default: false) to true forces the view update to
|
||||
// recalculate the splats with this view origin, potentially altering any
|
||||
// view-dependent effects. If you expect view-dependent effects to play a role
|
||||
// in the rendering quality, enable this.
|
||||
//
|
||||
// Underneath, prepareRenderPixels() simply calls await this.prepare(...),
|
||||
// this.renderTarget(...), and finally returns the result this.readTarget(),
|
||||
// a Promise to a Uint8Array with RGBA values for all the pixels (potentially
|
||||
// downsampled if the superXY parameter was used). These steps can also be called
|
||||
// manually, for example if you need to alter the scene before and after
|
||||
// this.renderTarget(...) to hide UI elements from being rendered.
|
||||
async prepareRenderPixels({
|
||||
scene,
|
||||
camera,
|
||||
viewToWorld,
|
||||
update,
|
||||
forceOrigin,
|
||||
}: {
|
||||
scene: THREE.Scene;
|
||||
camera?: THREE.Camera;
|
||||
viewToWorld?: THREE.Matrix4;
|
||||
update?: boolean;
|
||||
forceOrigin?: boolean;
|
||||
}) {
|
||||
await this.prepare({ scene, camera, viewToWorld, update, forceOrigin });
|
||||
this.renderTarget({ scene, camera });
|
||||
return this.readTarget();
|
||||
}
|
||||
|
||||
// This is called automatically by ForgeRenderer, there is no need to call it!
|
||||
// The method cannot be private because then ForgeRenderer would
|
||||
// not be able to call it.
|
||||
autoPoll({ accumulator }: { accumulator?: SplatAccumulator }) {
|
||||
if (this.camera) {
|
||||
this.camera.updateMatrixWorld();
|
||||
this.viewToWorld = this.camera.matrixWorld.clone();
|
||||
}
|
||||
|
||||
let needsSort = false;
|
||||
let displayed = false;
|
||||
|
||||
if (!this.display) {
|
||||
// Need to do first sort
|
||||
needsSort = true;
|
||||
} else if (accumulator) {
|
||||
needsSort = true;
|
||||
const { mappingVersion } = this.display.accumulator;
|
||||
if (accumulator.mappingVersion === mappingVersion) {
|
||||
// Splat mapping has not changed, so reuse the existing sorted
|
||||
// geometry to show updates faster. We will still fire off
|
||||
// a re-sort if necessary. First release old accumulator.
|
||||
this.forge.releaseAccumulator(this.display.accumulator);
|
||||
this.display.accumulator = accumulator;
|
||||
displayed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const latestView = this.sorting?.viewToWorld ?? this.display?.viewToWorld;
|
||||
if (
|
||||
latestView &&
|
||||
!withinCoorientDist({
|
||||
matrix1: this.viewToWorld,
|
||||
matrix2: latestView,
|
||||
// By default update sort each 1 cm
|
||||
maxDistance: this.sortDistance ?? 0.01,
|
||||
// By default for radial sort, update for intermittent movement so that
|
||||
// we bring back splats culled by being behind the camera.
|
||||
// For depth sort, small rotations can change sort order a lot, so
|
||||
// update sort for even small rotations.
|
||||
minCoorient: (this.sortCoorient ?? this.sortRadial) ? 0.99 : 0.999,
|
||||
})
|
||||
) {
|
||||
needsSort = true;
|
||||
}
|
||||
|
||||
if (!needsSort) {
|
||||
// Stop here, no sort necessary
|
||||
return;
|
||||
}
|
||||
|
||||
if (accumulator) {
|
||||
// Hold a reference to the accumulator so it isn't released
|
||||
accumulator.refCount += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
accumulator &&
|
||||
this.pending?.accumulator &&
|
||||
this.pending.accumulator !== this.display?.accumulator
|
||||
) {
|
||||
this.forge.releaseAccumulator(this.pending.accumulator);
|
||||
}
|
||||
this.pending = { accumulator, viewToWorld: this.viewToWorld, displayed };
|
||||
|
||||
// Don't await this, just trigger the sort if necessary
|
||||
this.driveSort();
|
||||
}
|
||||
|
||||
private async driveSort() {
|
||||
while (true) {
|
||||
if (this.sorting || !this.pending) {
|
||||
return; // Sort already in process or nothing to sort
|
||||
}
|
||||
|
||||
const { viewToWorld, displayed } = this.pending;
|
||||
let accumulator = this.pending.accumulator ?? this.display?.accumulator;
|
||||
if (!accumulator) {
|
||||
accumulator = this.forge.active;
|
||||
accumulator.refCount += 1;
|
||||
}
|
||||
this.pending = null;
|
||||
if (!accumulator) {
|
||||
throw new Error("No accumulator to sort");
|
||||
}
|
||||
|
||||
this.sorting = { viewToWorld };
|
||||
await this.sortUpdate({ accumulator, viewToWorld, displayed });
|
||||
this.sorting = null;
|
||||
// Continue in loop with any queued sort
|
||||
}
|
||||
}
|
||||
|
||||
private async sortUpdate({
|
||||
accumulator,
|
||||
viewToWorld,
|
||||
displayed = false,
|
||||
}: {
|
||||
accumulator?: SplatAccumulator;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
displayed?: boolean;
|
||||
}) {
|
||||
if (this.sortingCheck) {
|
||||
throw new Error("Only one sort at a time");
|
||||
}
|
||||
this.sortingCheck = true;
|
||||
|
||||
accumulator = accumulator ?? this.forge.active;
|
||||
const { numSplats, maxSplats } = accumulator.splats;
|
||||
let activeSplats = 0;
|
||||
let ordering = this.orderingFreelist.alloc(maxSplats);
|
||||
|
||||
if (numSplats > 0) {
|
||||
const {
|
||||
reader,
|
||||
doubleSortReader,
|
||||
dynoSortRadial,
|
||||
dynoOrigin,
|
||||
dynoDirection,
|
||||
dynoDepthBias,
|
||||
dynoSort360,
|
||||
dynoSplats,
|
||||
} = ForgeViewpoint.makeSorter();
|
||||
const halfMaxSplats = Math.ceil(maxSplats / 2);
|
||||
this.readback = reader.ensureBuffer(halfMaxSplats, this.readback);
|
||||
|
||||
const worldToOrigin = accumulator.toWorld.clone().invert();
|
||||
const viewToOrigin = viewToWorld.clone().premultiply(worldToOrigin);
|
||||
|
||||
dynoSortRadial.value = this.sort360 ? true : this.sortRadial;
|
||||
dynoOrigin.value.set(0, 0, 0).applyMatrix4(viewToOrigin);
|
||||
dynoDirection.value
|
||||
.set(0, 0, -1)
|
||||
.applyMatrix4(viewToOrigin)
|
||||
.sub(dynoOrigin.value)
|
||||
.normalize();
|
||||
dynoDepthBias.value = this.depthBias ?? 1.0;
|
||||
dynoSort360.value = this.sort360 ?? false;
|
||||
dynoSplats.packedSplats = accumulator.splats;
|
||||
|
||||
await reader.renderReadback({
|
||||
renderer: this.forge.renderer,
|
||||
reader: doubleSortReader,
|
||||
count: Math.ceil(numSplats / 2),
|
||||
readback: this.readback,
|
||||
});
|
||||
|
||||
const result = (await withWorker(async (worker) => {
|
||||
return worker.call("sortDoubleSplats", {
|
||||
numSplats,
|
||||
readback: this.readback,
|
||||
ordering,
|
||||
});
|
||||
})) as {
|
||||
readback: Uint16Array;
|
||||
ordering: Uint32Array;
|
||||
activeSplats: number;
|
||||
};
|
||||
this.readback = result.readback;
|
||||
ordering = result.ordering;
|
||||
activeSplats = result.activeSplats;
|
||||
}
|
||||
|
||||
this.updateDisplay({
|
||||
accumulator,
|
||||
viewToWorld,
|
||||
ordering,
|
||||
activeSplats,
|
||||
displayed,
|
||||
});
|
||||
this.sortingCheck = false;
|
||||
}
|
||||
|
||||
private updateDisplay({
|
||||
accumulator,
|
||||
viewToWorld,
|
||||
ordering,
|
||||
activeSplats,
|
||||
displayed = false,
|
||||
}: {
|
||||
accumulator: SplatAccumulator;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
ordering: Uint32Array;
|
||||
activeSplats: number;
|
||||
displayed?: boolean;
|
||||
}) {
|
||||
if (!this.display) {
|
||||
this.display = {
|
||||
accumulator,
|
||||
viewToWorld,
|
||||
geometry: new SplatGeometry(ordering, activeSplats),
|
||||
};
|
||||
} else {
|
||||
if (!displayed && accumulator !== this.display.accumulator) {
|
||||
this.forge.releaseAccumulator(this.display.accumulator);
|
||||
this.display.accumulator = accumulator;
|
||||
}
|
||||
|
||||
this.display.viewToWorld = viewToWorld;
|
||||
|
||||
const oldOrdering = this.display.geometry.ordering;
|
||||
if (oldOrdering.length === ordering.length) {
|
||||
this.display.geometry.update(ordering, activeSplats);
|
||||
} else {
|
||||
this.display.geometry.dispose();
|
||||
// console.log("*** alloc SplatGeometry", ordering.length);
|
||||
this.display.geometry = new SplatGeometry(ordering, activeSplats);
|
||||
}
|
||||
this.orderingFreelist.free(oldOrdering);
|
||||
}
|
||||
if (this.forge.viewpoint === this) {
|
||||
this.forge.prepareViewpoint(this);
|
||||
}
|
||||
}
|
||||
|
||||
// If you need an empty THREE.Texture to use to initialize a uniform that is
|
||||
// updated via onTextureUpdated(texture), this static texture can be handy.
|
||||
static EMPTY_TEXTURE = new THREE.Texture();
|
||||
|
||||
private static dynos: {
|
||||
dynoSortRadial: DynoBool<string>;
|
||||
dynoOrigin: DynoVec3<THREE.Vector3, "value">;
|
||||
dynoDirection: DynoVec3<THREE.Vector3, "value">;
|
||||
dynoDepthBias: DynoFloat<string>;
|
||||
dynoSort360: DynoBool<string>;
|
||||
dynoSplats: DynoPackedSplats;
|
||||
reader: Readback;
|
||||
doubleSortReader: DynoBlock<{ index: "int" }, { rgba8: "vec4" }>;
|
||||
} | null = null;
|
||||
|
||||
private static makeSorter() {
|
||||
if (!ForgeViewpoint.dynos) {
|
||||
const dynoSortRadial = new DynoBool({ value: true });
|
||||
const dynoOrigin = new DynoVec3({ value: new THREE.Vector3() });
|
||||
const dynoDirection = new DynoVec3({ value: new THREE.Vector3() });
|
||||
const dynoDepthBias = new DynoFloat({ value: 1.0 });
|
||||
const dynoSort360 = new DynoBool({ value: false });
|
||||
const dynoSplats = new DynoPackedSplats();
|
||||
|
||||
const reader = new Readback();
|
||||
const doubleSortReader = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ rgba8: "vec4" },
|
||||
({ index }) => {
|
||||
if (!index) {
|
||||
throw new Error("No index");
|
||||
}
|
||||
const sortParams = {
|
||||
sortRadial: dynoSortRadial,
|
||||
sortOrigin: dynoOrigin,
|
||||
sortDirection: dynoDirection,
|
||||
sortDepthBias: dynoDepthBias,
|
||||
sort360: dynoSort360,
|
||||
};
|
||||
const index2 = mul(index, dynoConst("int", 2));
|
||||
|
||||
const gsplat0 = readPackedSplat(dynoSplats, index2);
|
||||
const metric0 = computeSortMetric({ gsplat: gsplat0, ...sortParams });
|
||||
|
||||
const gsplat1 = readPackedSplat(
|
||||
dynoSplats,
|
||||
add(index2, dynoConst("int", 1)),
|
||||
);
|
||||
const metric1 = computeSortMetric({ gsplat: gsplat1, ...sortParams });
|
||||
|
||||
const combined = combine({
|
||||
vectorType: "vec2",
|
||||
x: metric0,
|
||||
y: metric1,
|
||||
});
|
||||
const rgba8 = uintToRgba8(packHalf2x16(combined));
|
||||
return { rgba8 };
|
||||
},
|
||||
);
|
||||
|
||||
ForgeViewpoint.dynos = {
|
||||
dynoSortRadial,
|
||||
dynoOrigin,
|
||||
dynoDirection,
|
||||
dynoDepthBias,
|
||||
dynoSort360,
|
||||
dynoSplats,
|
||||
reader,
|
||||
doubleSortReader,
|
||||
};
|
||||
}
|
||||
return ForgeViewpoint.dynos;
|
||||
}
|
||||
}
|
||||
|
||||
const defineComputeSortMetric = unindent(`
|
||||
float computeSort(Gsplat gsplat, bool sortRadial, vec3 sortOrigin, vec3 sortDirection, float sortDepthBias, bool sort360) {
|
||||
if (!isGsplatActive(gsplat.flags)) {
|
||||
return INFINITY;
|
||||
}
|
||||
|
||||
vec3 center = gsplat.center - sortOrigin;
|
||||
float biasedDepth = dot(center, sortDirection) + sortDepthBias;
|
||||
if (!sort360 && (biasedDepth <= 0.0)) {
|
||||
return INFINITY;
|
||||
}
|
||||
|
||||
return sortRadial ? length(center) : biasedDepth;
|
||||
}
|
||||
`);
|
||||
|
||||
function computeSortMetric({
|
||||
gsplat,
|
||||
sortRadial,
|
||||
sortOrigin,
|
||||
sortDirection,
|
||||
sortDepthBias,
|
||||
sort360,
|
||||
}: {
|
||||
gsplat: DynoVal<typeof Gsplat>;
|
||||
sortRadial: DynoVal<"bool">;
|
||||
sortOrigin: DynoVal<"vec3">;
|
||||
sortDirection: DynoVal<"vec3">;
|
||||
sortDepthBias: DynoVal<"float">;
|
||||
sort360: DynoVal<"bool">;
|
||||
}) {
|
||||
return dyno({
|
||||
inTypes: {
|
||||
gsplat: Gsplat,
|
||||
sortRadial: "bool",
|
||||
sortOrigin: "vec3",
|
||||
sortDirection: "vec3",
|
||||
sortDepthBias: "float",
|
||||
sort360: "bool",
|
||||
},
|
||||
outTypes: { metric: "float" },
|
||||
globals: () => [defineGsplat, defineComputeSortMetric],
|
||||
inputs: {
|
||||
gsplat,
|
||||
sortRadial,
|
||||
sortOrigin,
|
||||
sortDirection,
|
||||
sortDepthBias,
|
||||
sort360,
|
||||
},
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const {
|
||||
gsplat,
|
||||
sortRadial,
|
||||
sortOrigin,
|
||||
sortDirection,
|
||||
sortDepthBias,
|
||||
sort360,
|
||||
} = inputs;
|
||||
return unindentLines(`
|
||||
${outputs.metric} = computeSort(${gsplat}, ${sortRadial}, ${sortOrigin}, ${sortDirection}, ${sortDepthBias}, ${sort360});
|
||||
`);
|
||||
},
|
||||
}).outputs.metric;
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { GsplatGenerator } from "./SplatGenerator";
|
||||
import { type SplatFileType, unpackSplats } from "./SplatLoader";
|
||||
import { SPLAT_TEX_HEIGHT, SPLAT_TEX_WIDTH } from "./defines";
|
||||
import {
|
||||
DynoProgram,
|
||||
DynoProgramTemplate,
|
||||
DynoUniform,
|
||||
dynoBlock,
|
||||
outputPackedSplat,
|
||||
} from "./dyno";
|
||||
import { TPackedSplats, definePackedSplats } from "./dyno/splats";
|
||||
import computeUvec4Template from "./shaders/computeUvec4.glsl";
|
||||
import { getTextureSize, setPackedSplat, unpackSplat } from "./utils";
|
||||
|
||||
// Initialize a PackedSplats collection from source data via
|
||||
// url, fileBytes, or packedArray. Creates an empty array if none are set,
|
||||
// and splat data can be constructed using pushSplat()/setSplat(). The maximum
|
||||
// splat size allocation will grow automatically, starting from maxSplats.
|
||||
export type PackedSplatsOptions = {
|
||||
// URL to fetch a Gaussian splat file from (supports .ply, .splat, .ksplat,
|
||||
// .spz formats). (default: undefined)
|
||||
url?: string;
|
||||
// Raw bytes of a Gaussian splat file to decode directly instead of fetching
|
||||
// from URL. (default: undefined)
|
||||
fileBytes?: Uint8Array | ArrayBuffer;
|
||||
// Override the file type detection for formats that can't be reliably
|
||||
// auto-detected (.splat, .ksplat). (default: undefined auto-detects other
|
||||
// formats from file contents)
|
||||
fileType?: SplatFileType;
|
||||
// Reserve space for at least this many splats when constructing the collection
|
||||
// initially. The array will automatically resize past maxSplats so setting it is
|
||||
// an optional optimization. (default: 0)
|
||||
maxSplats?: number;
|
||||
// Use provided packed data array, where each 4 consecutive uint32 values
|
||||
// encode one "packed" Gsplat. (default: undefined)
|
||||
packedArray?: Uint32Array;
|
||||
// Override number of splats in packed array to use only a subset.
|
||||
// (default: length of packed array / 4)
|
||||
numSplats?: number;
|
||||
// Callback function to programmatically create splats at initialization.
|
||||
// (default: undefined)
|
||||
construct?: (splats: PackedSplats) => Promise<void> | void;
|
||||
// Additional splat data, such as spherical harmonics components (sh1, sh2, sh3). (default: {})
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// A PackedSplats is a collection of Gaussian splats, packed into a format that
|
||||
// takes exactly 16 bytes per Gsplat to maximize memory and cache efficiency.
|
||||
// The center xyz coordinates are encoded as float16 (3 x 2 bytes), scale xyz
|
||||
// as 3 x uint8 that encode a log scale from e^-9 to e^9, rgba as 4 x uint8,
|
||||
// and quaternion encoded via axis+angle using 2 x uint8 for octahedral encoding
|
||||
// of the axis direction and a uint8 to encode rotation amount from 0..Pi.
|
||||
|
||||
export class PackedSplats {
|
||||
maxSplats = 0;
|
||||
numSplats = 0;
|
||||
packedArray: Uint32Array | null = null;
|
||||
extra: Record<string, unknown>;
|
||||
|
||||
initialized: Promise<PackedSplats>;
|
||||
isInitialized = false;
|
||||
|
||||
// Either target or source will be non-null, depending on whether the PackedSplats
|
||||
// is being used as a data source or generated to.
|
||||
target: THREE.WebGLArrayRenderTarget | null = null;
|
||||
source: THREE.DataArrayTexture | null = null;
|
||||
// Set to true if source packedArray is updated to have it upload to GPU
|
||||
needsUpdate = true;
|
||||
|
||||
// A PackedSplats can be used in a dyno graph using the below property dyno:
|
||||
// const gsplat = dyno.readPackedSplats(this.dyno, dynoIndex);
|
||||
dyno: DynoUniform<typeof TPackedSplats, "packedSplats">;
|
||||
|
||||
constructor(options: PackedSplatsOptions = {}) {
|
||||
this.extra = {};
|
||||
this.dyno = new DynoPackedSplats({ packedSplats: this });
|
||||
|
||||
// The following line will be overridden by reinitialize()
|
||||
this.initialized = Promise.resolve(this);
|
||||
this.reinitialize(options);
|
||||
}
|
||||
|
||||
reinitialize(options: PackedSplatsOptions) {
|
||||
this.isInitialized = false;
|
||||
if (options.url || options.fileBytes || options.construct) {
|
||||
// We need to initialize asynchronously given the options
|
||||
this.initialized = this.asyncInitialize(options).then(() => {
|
||||
this.isInitialized = true;
|
||||
return this;
|
||||
});
|
||||
} else {
|
||||
this.initialize(options);
|
||||
this.isInitialized = true;
|
||||
this.initialized = Promise.resolve(this);
|
||||
}
|
||||
}
|
||||
|
||||
initialize(options: PackedSplatsOptions) {
|
||||
if (options.packedArray) {
|
||||
this.packedArray = options.packedArray;
|
||||
// Calculate number of horizontal texture rows that could fit in array.
|
||||
// A properly initialized packedArray should already take into account the
|
||||
// width and height of the texture and be rounded up with padding.
|
||||
this.maxSplats = Math.floor(this.packedArray.length / 4);
|
||||
this.maxSplats =
|
||||
Math.floor(this.maxSplats / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
this.numSplats = Math.min(
|
||||
this.maxSplats,
|
||||
options.numSplats ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
} else {
|
||||
this.maxSplats = options.maxSplats ?? 0;
|
||||
this.numSplats = 0;
|
||||
}
|
||||
this.extra = options.extra ?? {};
|
||||
}
|
||||
|
||||
async asyncInitialize(options: PackedSplatsOptions) {
|
||||
let { url, fileBytes, construct } = options;
|
||||
if (url) {
|
||||
fileBytes = await fetch(url).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${response.status} "${response.statusText}" fetching URL: ${url}`,
|
||||
);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return arrayBuffer;
|
||||
});
|
||||
}
|
||||
|
||||
if (fileBytes) {
|
||||
const unpacked = await unpackSplats({
|
||||
input: fileBytes,
|
||||
fileType: options.fileType,
|
||||
pathOrUrl: url,
|
||||
});
|
||||
this.initialize(unpacked);
|
||||
}
|
||||
if (construct) {
|
||||
const maybePromise = construct(this);
|
||||
// If construct returns a promise, wait for it to complete
|
||||
if (maybePromise instanceof Promise) {
|
||||
await maybePromise;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when you are finished with the PackedSplats and want to free
|
||||
// any buffers it holds.
|
||||
dispose() {
|
||||
if (this.target) {
|
||||
this.target.dispose();
|
||||
this.target = null;
|
||||
}
|
||||
if (this.source) {
|
||||
this.source.dispose();
|
||||
this.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures that this.packedArray can fit numSplats Gsplats. If it's too small,
|
||||
// resize exponentially and copy over the original data.
|
||||
//
|
||||
// Typically you don't need to call this, because calling this.setSplat(index, ...)
|
||||
// and this.pushSplat(...) will automatically call ensureSplats() so we have
|
||||
// enough splats.
|
||||
ensureSplats(numSplats: number): Uint32Array {
|
||||
const targetSize =
|
||||
numSplats <= this.maxSplats
|
||||
? this.maxSplats
|
||||
: // Grow exponentially to avoid frequent reallocations
|
||||
Math.max(numSplats, 2 * this.maxSplats);
|
||||
const currentSize = !this.packedArray ? 0 : this.packedArray.length / 4;
|
||||
|
||||
if (!this.packedArray || targetSize > currentSize) {
|
||||
this.maxSplats = getTextureSize(targetSize).maxSplats;
|
||||
const newArray = new Uint32Array(this.maxSplats * 4);
|
||||
if (this.packedArray) {
|
||||
// Copy over existing data
|
||||
newArray.set(this.packedArray);
|
||||
}
|
||||
this.packedArray = newArray;
|
||||
}
|
||||
return this.packedArray;
|
||||
}
|
||||
|
||||
// Ensure the extra array for the given level is large enough to hold numSplats
|
||||
ensureSplatsSh(level: number, numSplats: number): Uint32Array {
|
||||
let wordsPerSplat: number;
|
||||
let key: string;
|
||||
if (level === 0) {
|
||||
return this.ensureSplats(numSplats);
|
||||
}
|
||||
if (level === 1) {
|
||||
// 3 x 3 uint7 = 63 bits = 2 uint32
|
||||
wordsPerSplat = 2;
|
||||
key = "sh1";
|
||||
} else if (level === 2) {
|
||||
// 5 x 3 uint8 = 120 bits = 4 uint32
|
||||
wordsPerSplat = 4;
|
||||
key = "sh2";
|
||||
} else if (level === 3) {
|
||||
// 7 x 3 uint6 = 126 bits = 4 uint32
|
||||
wordsPerSplat = 4;
|
||||
key = "sh3";
|
||||
} else {
|
||||
throw new Error(`Invalid level: ${level}`);
|
||||
}
|
||||
|
||||
// Figure out our current and desired maxSplats
|
||||
let maxSplats: number = !this.extra[key]
|
||||
? 0
|
||||
: (this.extra[key] as Uint32Array).length / wordsPerSplat;
|
||||
const targetSize =
|
||||
numSplats <= maxSplats ? maxSplats : Math.max(numSplats, 2 * maxSplats);
|
||||
|
||||
if (!this.extra[key] || targetSize > maxSplats) {
|
||||
// Reallocate the array
|
||||
maxSplats = getTextureSize(targetSize).maxSplats;
|
||||
const newArray = new Uint32Array(maxSplats * wordsPerSplat);
|
||||
if (this.extra[key]) {
|
||||
// Copy over existing data
|
||||
newArray.set(this.extra[key] as Uint32Array);
|
||||
}
|
||||
this.extra[key] = newArray;
|
||||
}
|
||||
return this.extra[key] as Uint32Array;
|
||||
}
|
||||
|
||||
// Unpack the 16-byte Gsplat data at index into the Three.js components
|
||||
// center: THREE.Vector3, scales: THREE.Vector3, quaternion: THREE.Quaternion,
|
||||
// opacity: number 0..1, color: THREE.Color 0..1.
|
||||
getSplat(index: number): {
|
||||
center: THREE.Vector3;
|
||||
scales: THREE.Vector3;
|
||||
quaternion: THREE.Quaternion;
|
||||
opacity: number;
|
||||
color: THREE.Color;
|
||||
} {
|
||||
if (!this.packedArray || index >= this.numSplats) {
|
||||
throw new Error("Invalid index");
|
||||
}
|
||||
return unpackSplat(this.packedArray, index);
|
||||
}
|
||||
|
||||
// Set all PackedSplat components at index with the provided Gsplat attributes
|
||||
// (can be the same objects returned by getSplat). Ensures there is capacity
|
||||
// for at least index+1 Gsplats.
|
||||
setSplat(
|
||||
index: number,
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) {
|
||||
const packedSplats = this.ensureSplats(index + 1);
|
||||
setPackedSplat(
|
||||
packedSplats,
|
||||
index,
|
||||
center.x,
|
||||
center.y,
|
||||
center.z,
|
||||
scales.x,
|
||||
scales.y,
|
||||
scales.z,
|
||||
quaternion.x,
|
||||
quaternion.y,
|
||||
quaternion.z,
|
||||
quaternion.w,
|
||||
opacity,
|
||||
color.r,
|
||||
color.g,
|
||||
color.b,
|
||||
);
|
||||
this.numSplats = Math.max(this.numSplats, index + 1);
|
||||
}
|
||||
|
||||
// Effectively calls this.setSplat(this.numSplats++, center, ...), useful on
|
||||
// construction where you just want to iterate and create a collection of Gsplats.
|
||||
pushSplat(
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) {
|
||||
const packedSplats = this.ensureSplats(this.numSplats + 1);
|
||||
setPackedSplat(
|
||||
packedSplats,
|
||||
this.numSplats,
|
||||
center.x,
|
||||
center.y,
|
||||
center.z,
|
||||
scales.x,
|
||||
scales.y,
|
||||
scales.z,
|
||||
quaternion.x,
|
||||
quaternion.y,
|
||||
quaternion.z,
|
||||
quaternion.w,
|
||||
opacity,
|
||||
color.r,
|
||||
color.g,
|
||||
color.b,
|
||||
);
|
||||
++this.numSplats;
|
||||
}
|
||||
|
||||
// Iterate over Gsplats index 0..=(this.numSplats-1), unpack each Gsplat
|
||||
// and invoke the callback function with the Gsplat attributes.
|
||||
forEachSplat(
|
||||
callback: (
|
||||
index: number,
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) => void,
|
||||
) {
|
||||
if (!this.packedArray || !this.numSplats) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < this.numSplats; ++i) {
|
||||
const unpacked = unpackSplat(this.packedArray, i);
|
||||
callback(
|
||||
i,
|
||||
unpacked.center,
|
||||
unpacked.scales,
|
||||
unpacked.quaternion,
|
||||
unpacked.opacity,
|
||||
unpacked.color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensures our PackedSplats.target render target has enough space to generate
|
||||
// maxSplats total Gsplats, and reallocate if not large enough.
|
||||
ensureGenerate(maxSplats: number): boolean {
|
||||
if (this.target && (maxSplats ?? 1) <= this.maxSplats) {
|
||||
return false;
|
||||
}
|
||||
this.dispose();
|
||||
|
||||
const textureSize = getTextureSize(maxSplats ?? 1);
|
||||
const { width, height, depth } = textureSize;
|
||||
this.maxSplats = textureSize.maxSplats;
|
||||
|
||||
// The packed Gsplats are stored in a 2D array texture of max size
|
||||
// 2048 x 2048 x 2048, one RGBA32UI pixel = 4 uint32 = one Gsplat
|
||||
this.target = new THREE.WebGLArrayRenderTarget(width, height, depth, {
|
||||
depthBuffer: false,
|
||||
stencilBuffer: false,
|
||||
generateMipmaps: false,
|
||||
magFilter: THREE.NearestFilter,
|
||||
minFilter: THREE.NearestFilter,
|
||||
});
|
||||
this.target.texture.format = THREE.RGBAIntegerFormat;
|
||||
this.target.texture.type = THREE.UnsignedIntType;
|
||||
this.target.texture.internalFormat = "RGBA32UI";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Given an array of splatCounts (.numSplats for each
|
||||
// SplatGenerator/SplatMesh in the scene), compute a
|
||||
// "mapping layout" in the composite array of generated outputs.
|
||||
generateMapping(splatCounts: number[]): {
|
||||
maxSplats: number;
|
||||
mapping: { base: number; count: number }[];
|
||||
} {
|
||||
let maxSplats = 0;
|
||||
const mapping = splatCounts.map((numSplats) => {
|
||||
const base = maxSplats;
|
||||
// Generation happens in horizonal row chunks, so round up to full width
|
||||
const rounded = Math.ceil(numSplats / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
maxSplats += rounded;
|
||||
return { base, count: numSplats };
|
||||
});
|
||||
return { maxSplats, mapping };
|
||||
}
|
||||
|
||||
// Returns a THREE.DataArrayTexture representing the PackedSplats content as
|
||||
// a Uint32x4 data array texture (2048 x 2048 x depth in size)
|
||||
getTexture(): THREE.DataArrayTexture {
|
||||
if (this.target) {
|
||||
// Return the render target's texture
|
||||
return this.target.texture;
|
||||
}
|
||||
if (this.source || this.packedArray) {
|
||||
// Update source texture if needed and return
|
||||
const source = this.maybeUpdateSource();
|
||||
return source;
|
||||
}
|
||||
|
||||
return PackedSplats.getEmpty();
|
||||
}
|
||||
|
||||
// Check if source texture needs to be created/updated
|
||||
private maybeUpdateSource(): THREE.DataArrayTexture {
|
||||
if (!this.packedArray) {
|
||||
throw new Error("No packed splats");
|
||||
}
|
||||
|
||||
if (this.needsUpdate || !this.source) {
|
||||
this.needsUpdate = false;
|
||||
|
||||
if (this.source) {
|
||||
const { width, height, depth } = this.source.image;
|
||||
if (this.maxSplats !== width * height * depth) {
|
||||
// The existing source texture isn't the right size, so dispose it
|
||||
this.source.dispose();
|
||||
this.source = null;
|
||||
}
|
||||
}
|
||||
if (!this.source) {
|
||||
// Allocate a new source texture of the right size
|
||||
const { width, height, depth } = getTextureSize(this.maxSplats);
|
||||
this.source = new THREE.DataArrayTexture(
|
||||
this.packedArray,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
);
|
||||
this.source.format = THREE.RGBAIntegerFormat;
|
||||
this.source.type = THREE.UnsignedIntType;
|
||||
this.source.internalFormat = "RGBA32UI";
|
||||
this.source.needsUpdate = true;
|
||||
} else if (this.packedArray.buffer !== this.source.image.data.buffer) {
|
||||
// The source texture is the right size, update the data
|
||||
this.source.image.data = new Uint8Array(this.packedArray.buffer);
|
||||
}
|
||||
// Indicate to Three.js that the source texture needs to be uploaded to the GPU
|
||||
this.source.needsUpdate = true;
|
||||
}
|
||||
return this.source;
|
||||
}
|
||||
|
||||
private static emptySource: THREE.DataArrayTexture | null = null;
|
||||
|
||||
// Can be used where you need an uninitialized THREE.DataArrayTexture like
|
||||
// a uniform you will update with the result of this.getTexture() later.
|
||||
static getEmpty(): THREE.DataArrayTexture {
|
||||
if (!PackedSplats.emptySource) {
|
||||
const { width, height, depth, maxSplats } = getTextureSize(1);
|
||||
const emptyArray = new Uint32Array(maxSplats * 4);
|
||||
PackedSplats.emptySource = new THREE.DataArrayTexture(
|
||||
emptyArray,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
);
|
||||
PackedSplats.emptySource.format = THREE.RGBAIntegerFormat;
|
||||
PackedSplats.emptySource.type = THREE.UnsignedIntType;
|
||||
PackedSplats.emptySource.internalFormat = "RGBA32UI";
|
||||
PackedSplats.emptySource.needsUpdate = true;
|
||||
}
|
||||
return PackedSplats.emptySource;
|
||||
}
|
||||
|
||||
// Get a program and THREE.RawShaderMaterial for a given GsplatGenerator,
|
||||
// generating it if necessary and caching the result.
|
||||
prepareProgramMaterial(generator: GsplatGenerator): {
|
||||
program: DynoProgram;
|
||||
material: THREE.RawShaderMaterial;
|
||||
} {
|
||||
let program = PackedSplats.generatorProgram.get(generator);
|
||||
if (!program) {
|
||||
// A Gsplat needs to be turned into a packed uvec4 for the dyno graph
|
||||
const graph = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ output: "uvec4" },
|
||||
({ index }) => {
|
||||
generator.inputs.index = index;
|
||||
const gsplat = generator.outputs.gsplat;
|
||||
const output = outputPackedSplat(gsplat);
|
||||
return { output };
|
||||
},
|
||||
);
|
||||
if (!PackedSplats.programTemplate) {
|
||||
PackedSplats.programTemplate = new DynoProgramTemplate(
|
||||
computeUvec4Template,
|
||||
);
|
||||
}
|
||||
// Create a program from the template and graph
|
||||
program = new DynoProgram({
|
||||
graph,
|
||||
inputs: { index: "index" },
|
||||
outputs: { output: "target" },
|
||||
template: PackedSplats.programTemplate,
|
||||
});
|
||||
Object.assign(program.uniforms, {
|
||||
targetLayer: { value: 0 },
|
||||
targetBase: { value: 0 },
|
||||
targetCount: { value: 0 },
|
||||
});
|
||||
PackedSplats.generatorProgram.set(generator, program);
|
||||
}
|
||||
|
||||
// Prepare and update our material we'll use to render the Gsplats
|
||||
const material = program.prepareMaterial();
|
||||
PackedSplats.mesh.material = material;
|
||||
return { program, material };
|
||||
}
|
||||
|
||||
private saveRenderState(renderer: THREE.WebGLRenderer) {
|
||||
return {
|
||||
xrPresenting: renderer.xr.isPresenting,
|
||||
autoClear: renderer.autoClear,
|
||||
scissorTest: renderer.getScissorTest(),
|
||||
pixelRatio: renderer.getPixelRatio(),
|
||||
};
|
||||
}
|
||||
|
||||
private resetRenderState(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
state: {
|
||||
xrPresenting: boolean;
|
||||
autoClear: boolean;
|
||||
scissorTest: boolean;
|
||||
pixelRatio: number;
|
||||
},
|
||||
) {
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.setPixelRatio(state.pixelRatio);
|
||||
renderer.xr.isPresenting = state.xrPresenting;
|
||||
renderer.autoClear = state.autoClear;
|
||||
renderer.setScissorTest(state.scissorTest);
|
||||
}
|
||||
|
||||
// Executes a dyno program specified by generator which is any DynoBlock that
|
||||
// maps { index: "int" } to { gsplat: Gsplat }. This is called in
|
||||
// ForgeRenderer.updateInternal() to re-generate Gsplats in the scene for
|
||||
// SplatGenerator instances whose version is newer than what was generated
|
||||
// for it last time.
|
||||
generate({
|
||||
generator,
|
||||
base,
|
||||
count,
|
||||
renderer,
|
||||
}: {
|
||||
generator: GsplatGenerator;
|
||||
base: number;
|
||||
count: number;
|
||||
renderer: THREE.WebGLRenderer;
|
||||
}): { nextBase: number } {
|
||||
if (!this.target) {
|
||||
throw new Error("Target must be initialized with ensureSplats");
|
||||
}
|
||||
if (base + count > this.maxSplats) {
|
||||
throw new Error("Base + count exceeds maxSplats");
|
||||
}
|
||||
|
||||
const { program, material } = this.prepareProgramMaterial(generator);
|
||||
program.update();
|
||||
|
||||
const renderState = this.saveRenderState(renderer);
|
||||
|
||||
// Generate the Gsplats in "layer" chunks, in horizontal row ranges,
|
||||
// that cover the total count of Gsplats.
|
||||
const nextBase =
|
||||
Math.ceil((base + count) / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
const layerSize = SPLAT_TEX_WIDTH * SPLAT_TEX_HEIGHT;
|
||||
material.uniforms.targetBase.value = base;
|
||||
material.uniforms.targetCount.value = count;
|
||||
|
||||
// Keep generating layers until we've reached the next generation's base
|
||||
while (base < nextBase) {
|
||||
const layer = Math.floor(base / layerSize);
|
||||
material.uniforms.targetLayer.value = layer;
|
||||
|
||||
const layerBase = layer * layerSize;
|
||||
const layerYStart = Math.floor((base - layerBase) / SPLAT_TEX_WIDTH);
|
||||
const layerYEnd = Math.min(
|
||||
SPLAT_TEX_HEIGHT,
|
||||
Math.ceil((nextBase - layerBase) / SPLAT_TEX_WIDTH),
|
||||
);
|
||||
|
||||
// Render the desired portion of the layer
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setRenderTarget(this.target, layer);
|
||||
renderer.xr.isPresenting = false;
|
||||
renderer.autoClear = false;
|
||||
renderer.setScissorTest(true);
|
||||
renderer.setScissor(
|
||||
0,
|
||||
layerYStart,
|
||||
SPLAT_TEX_WIDTH,
|
||||
layerYEnd - layerYStart,
|
||||
);
|
||||
renderer.render(PackedSplats.scene, PackedSplats.camera);
|
||||
|
||||
base += SPLAT_TEX_WIDTH * (layerYEnd - layerYStart);
|
||||
}
|
||||
|
||||
this.resetRenderState(renderer, renderState);
|
||||
return { nextBase };
|
||||
}
|
||||
|
||||
static programTemplate: DynoProgramTemplate | null = null;
|
||||
|
||||
// Cache for GsplatGenerator programs
|
||||
static generatorProgram = new Map<GsplatGenerator, DynoProgram>();
|
||||
|
||||
// Static Three.js objects for pseudo-compute shader rendering
|
||||
static geometry = new THREE.PlaneGeometry(2, 2);
|
||||
static mesh = new THREE.Mesh(
|
||||
PackedSplats.geometry,
|
||||
new THREE.RawShaderMaterial({ visible: false }),
|
||||
);
|
||||
static scene = new THREE.Scene().add(PackedSplats.mesh);
|
||||
static camera = new THREE.Camera();
|
||||
}
|
||||
|
||||
// You can use a PackedSplats as a dyno block using the function
|
||||
// dyno.readPackedSplats(packedSplats.dyno, dynoIndex) where
|
||||
// dynoIndex is of type DynoVal<"int">. If you need to be able to change
|
||||
// the input PackedSplats dynamically, however, you should create a
|
||||
// DynoPackedSplats, whose property packedSplats you can change to any
|
||||
// PackedSplats and that will be used in the dyno shader program.
|
||||
|
||||
export const dynoPackedSplats = (packedSplats?: PackedSplats) =>
|
||||
new DynoPackedSplats({ packedSplats });
|
||||
|
||||
export class DynoPackedSplats extends DynoUniform<
|
||||
typeof TPackedSplats,
|
||||
"packedSplats",
|
||||
{
|
||||
texture: THREE.DataArrayTexture;
|
||||
numSplats: number;
|
||||
}
|
||||
> {
|
||||
packedSplats?: PackedSplats;
|
||||
|
||||
constructor({ packedSplats }: { packedSplats?: PackedSplats } = {}) {
|
||||
super({
|
||||
key: "packedSplats",
|
||||
type: TPackedSplats,
|
||||
globals: () => [definePackedSplats],
|
||||
value: {
|
||||
texture: PackedSplats.getEmpty(),
|
||||
numSplats: 0,
|
||||
},
|
||||
update: (value) => {
|
||||
value.texture =
|
||||
this.packedSplats?.getTexture() ?? PackedSplats.getEmpty();
|
||||
value.numSplats = this.packedSplats?.numSplats ?? 0;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
this.packedSplats = packedSplats;
|
||||
}
|
||||
}
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { SPLAT_TEX_HEIGHT, SPLAT_TEX_WIDTH } from "./defines";
|
||||
import { type Dyno, OutputRgba8, dynoBlock } from "./dyno";
|
||||
import { DynoProgram, DynoProgramTemplate } from "./dyno/program";
|
||||
import computeVec4Template from "./shaders/computeVec4.glsl";
|
||||
import { getTextureSize } from "./utils";
|
||||
|
||||
// Readback can be used to run a Dyno program that maps an index to a 32-bit
|
||||
// RGBA8 value, which is the only allowed, portable readback format for WebGL2.
|
||||
// Using data packing and conversion you can read back any 32-bit value, which
|
||||
// Forge uses to read back 2 float16 Gsplat distance values per index.
|
||||
|
||||
export type Rgba8Readback = Dyno<{ index: "int" }, { rgba8: "vec4" }>;
|
||||
|
||||
// Readback can be performed with various typed buffers, making it convenient
|
||||
// to encode readback data in a variety of formats.
|
||||
|
||||
export type ReadbackBuffer =
|
||||
| ArrayBuffer
|
||||
| Uint8Array
|
||||
| Int8Array
|
||||
| Uint16Array
|
||||
| Int16Array
|
||||
| Uint32Array
|
||||
| Int32Array
|
||||
| Float32Array;
|
||||
|
||||
export class Readback {
|
||||
renderer?: THREE.WebGLRenderer;
|
||||
target?: THREE.WebGLArrayRenderTarget;
|
||||
capacity: number;
|
||||
count: number;
|
||||
|
||||
constructor({ renderer }: { renderer?: THREE.WebGLRenderer } = {}) {
|
||||
this.renderer = renderer;
|
||||
this.capacity = 0;
|
||||
this.count = 0;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.target) {
|
||||
this.target.dispose();
|
||||
this.target = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have a buffer large enough for the readback of count indices.
|
||||
// Pass in previous bufer of the desired type.
|
||||
ensureBuffer<B extends ReadbackBuffer>(count: number, buffer: B): B {
|
||||
// Readback is performed in a 2D array of pixels, so round up with SPLAT_TEX_WIDTH
|
||||
const roundedCount =
|
||||
Math.ceil(Math.max(1, count) / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
const bytes = roundedCount * 4;
|
||||
if (buffer.byteLength >= bytes) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Need a larger buffer, create a new one of the same type
|
||||
const newBuffer = new ArrayBuffer(bytes);
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
return newBuffer as B;
|
||||
}
|
||||
|
||||
const ctor = buffer.constructor as { new (arrayBuffer: ArrayBuffer): B };
|
||||
return new ctor(newBuffer) as B;
|
||||
}
|
||||
|
||||
// Ensure our render target is large enough for the readback of capacity indices.
|
||||
ensureCapacity(capacity: number) {
|
||||
const { width, height, depth, maxSplats } = getTextureSize(capacity);
|
||||
if (!this.target || maxSplats > this.capacity) {
|
||||
this.dispose();
|
||||
this.capacity = maxSplats;
|
||||
|
||||
// The only portable readback format for WebGL2 is RGBA8
|
||||
this.target = new THREE.WebGLArrayRenderTarget(width, height, depth, {
|
||||
depthBuffer: false,
|
||||
stencilBuffer: false,
|
||||
generateMipmaps: false,
|
||||
magFilter: THREE.NearestFilter,
|
||||
minFilter: THREE.NearestFilter,
|
||||
});
|
||||
this.target.texture.format = THREE.RGBAFormat;
|
||||
this.target.texture.type = THREE.UnsignedByteType;
|
||||
this.target.texture.internalFormat = "RGBA8";
|
||||
}
|
||||
}
|
||||
|
||||
// Get a program and THREE.RawShaderMaterial for a given Rgba8Readback,
|
||||
// generating it if necessary and caching the result.
|
||||
prepareProgramMaterial(reader: Rgba8Readback): {
|
||||
program: DynoProgram;
|
||||
material: THREE.RawShaderMaterial;
|
||||
} {
|
||||
let program = Readback.readbackProgram.get(reader);
|
||||
if (!program) {
|
||||
const graph = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ rgba8: "vec4" },
|
||||
({ index }) => {
|
||||
reader.inputs.index = index;
|
||||
const rgba8 = new OutputRgba8({ rgba8: reader.outputs.rgba8 });
|
||||
return { rgba8 };
|
||||
},
|
||||
);
|
||||
if (!Readback.programTemplate) {
|
||||
Readback.programTemplate = new DynoProgramTemplate(computeVec4Template);
|
||||
}
|
||||
// Create a program from the template and graph
|
||||
program = new DynoProgram({
|
||||
graph,
|
||||
inputs: { index: "index" },
|
||||
outputs: { rgba8: "target" },
|
||||
template: Readback.programTemplate,
|
||||
});
|
||||
Object.assign(program.uniforms, {
|
||||
targetLayer: { value: 0 },
|
||||
targetBase: { value: 0 },
|
||||
targetCount: { value: 0 },
|
||||
});
|
||||
Readback.readbackProgram.set(reader, program);
|
||||
}
|
||||
|
||||
const material = program.prepareMaterial();
|
||||
Readback.mesh.material = material;
|
||||
return { program, material };
|
||||
}
|
||||
|
||||
private saveRenderState(renderer: THREE.WebGLRenderer) {
|
||||
return {
|
||||
xrPresenting: renderer.xr.isPresenting,
|
||||
autoClear: renderer.autoClear,
|
||||
scissorTest: renderer.getScissorTest(),
|
||||
pixelRatio: renderer.getPixelRatio(),
|
||||
};
|
||||
}
|
||||
|
||||
private resetRenderState(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
state: {
|
||||
xrPresenting: boolean;
|
||||
autoClear: boolean;
|
||||
scissorTest: boolean;
|
||||
pixelRatio: number;
|
||||
},
|
||||
) {
|
||||
renderer.setRenderTarget(null);
|
||||
renderer.setPixelRatio(state.pixelRatio);
|
||||
renderer.xr.isPresenting = state.xrPresenting;
|
||||
renderer.autoClear = state.autoClear;
|
||||
renderer.setScissorTest(state.scissorTest);
|
||||
}
|
||||
|
||||
private process({
|
||||
count,
|
||||
material,
|
||||
}: { count: number; material: THREE.RawShaderMaterial }) {
|
||||
const renderer = this.renderer;
|
||||
if (!renderer) {
|
||||
throw new Error("No renderer");
|
||||
}
|
||||
if (!this.target) {
|
||||
throw new Error("No target");
|
||||
}
|
||||
|
||||
// Run the program in "layer" chunks, in horizontal row ranges,
|
||||
// that cover the total count of indices.
|
||||
const layerSize = SPLAT_TEX_WIDTH * SPLAT_TEX_HEIGHT;
|
||||
material.uniforms.targetBase.value = 0;
|
||||
material.uniforms.targetCount.value = count;
|
||||
let baseIndex = 0;
|
||||
|
||||
// Keep generating layers until completed count items
|
||||
while (baseIndex < count) {
|
||||
const layer = Math.floor(baseIndex / layerSize);
|
||||
const layerBase = layer * layerSize;
|
||||
const layerYEnd = Math.min(
|
||||
SPLAT_TEX_HEIGHT,
|
||||
Math.ceil((count - layerBase) / SPLAT_TEX_WIDTH),
|
||||
);
|
||||
material.uniforms.targetLayer.value = layer;
|
||||
|
||||
// Render the desired portion of the layer
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setRenderTarget(this.target, layer);
|
||||
renderer.xr.isPresenting = false;
|
||||
renderer.autoClear = false;
|
||||
renderer.setScissorTest(true);
|
||||
renderer.setScissor(0, 0, SPLAT_TEX_WIDTH, layerYEnd);
|
||||
renderer.render(Readback.scene, Readback.camera);
|
||||
|
||||
baseIndex += SPLAT_TEX_WIDTH * layerYEnd;
|
||||
}
|
||||
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
private async read<B extends ReadbackBuffer>({
|
||||
readback,
|
||||
}: { readback: B }): Promise<B> {
|
||||
const renderer = this.renderer;
|
||||
if (!renderer) {
|
||||
throw new Error("No renderer");
|
||||
}
|
||||
if (!this.target) {
|
||||
throw new Error("No target");
|
||||
}
|
||||
|
||||
const roundedCount =
|
||||
Math.ceil(this.count / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
if (readback.byteLength < roundedCount * 4) {
|
||||
throw new Error(
|
||||
`Readback buffer too small: ${readback.byteLength} < ${roundedCount * 4}`,
|
||||
);
|
||||
}
|
||||
const readbackUint8 = new Uint8Array(
|
||||
readback instanceof ArrayBuffer ? readback : readback.buffer,
|
||||
);
|
||||
|
||||
// We can only read back one 2D array layer of pixels at a time,
|
||||
// so loop through them, initiate the readback, and collect the
|
||||
// completion promises.
|
||||
|
||||
const layerSize = SPLAT_TEX_WIDTH * SPLAT_TEX_HEIGHT;
|
||||
let baseIndex = 0;
|
||||
const promises = [];
|
||||
|
||||
while (baseIndex < this.count) {
|
||||
const layer = Math.floor(baseIndex / layerSize);
|
||||
const layerBase = layer * layerSize;
|
||||
const layerYEnd = Math.min(
|
||||
SPLAT_TEX_HEIGHT,
|
||||
Math.ceil((this.count - layerBase) / SPLAT_TEX_WIDTH),
|
||||
);
|
||||
|
||||
renderer.setPixelRatio(1);
|
||||
renderer.setRenderTarget(this.target, layer);
|
||||
|
||||
// Compute the subarray that this layer of readback corresponds to
|
||||
const readbackSize = SPLAT_TEX_WIDTH * layerYEnd * 4;
|
||||
const subReadback = readbackUint8.subarray(
|
||||
layerBase * 4,
|
||||
layerBase * 4 + readbackSize,
|
||||
);
|
||||
const promise = renderer?.readRenderTargetPixelsAsync(
|
||||
this.target,
|
||||
0,
|
||||
0,
|
||||
SPLAT_TEX_WIDTH,
|
||||
layerYEnd,
|
||||
subReadback,
|
||||
);
|
||||
promises.push(promise);
|
||||
|
||||
baseIndex += SPLAT_TEX_WIDTH * layerYEnd;
|
||||
}
|
||||
return Promise.all(promises).then(() => readback);
|
||||
}
|
||||
|
||||
// Perform render operation to run the Rgba8Readback program
|
||||
// but don't perform the readback yet.
|
||||
render({
|
||||
reader,
|
||||
count,
|
||||
renderer,
|
||||
}: { reader: Rgba8Readback; count: number; renderer?: THREE.WebGLRenderer }) {
|
||||
this.renderer = renderer || this.renderer;
|
||||
if (!this.renderer) {
|
||||
throw new Error("No renderer");
|
||||
}
|
||||
|
||||
this.ensureCapacity(count);
|
||||
|
||||
const { program, material } = this.prepareProgramMaterial(reader);
|
||||
program.update();
|
||||
|
||||
const renderState = this.saveRenderState(this.renderer);
|
||||
this.process({ count, material });
|
||||
this.resetRenderState(this.renderer, renderState);
|
||||
}
|
||||
|
||||
// Perform a readback of the render target, returning a buffer of the
|
||||
// given type.
|
||||
async readback<B extends ReadbackBuffer>({
|
||||
readback,
|
||||
}: { readback: B }): Promise<B> {
|
||||
if (!this.renderer) {
|
||||
throw new Error("No renderer");
|
||||
}
|
||||
const renderState = this.saveRenderState(this.renderer);
|
||||
const promise = this.read({ readback });
|
||||
this.resetRenderState(this.renderer, renderState);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Perform a render and readback operation for the given Rgba8Readback,
|
||||
// and readback buffer (call ensureBuffer first).
|
||||
async renderReadback<B extends ReadbackBuffer>({
|
||||
reader,
|
||||
count,
|
||||
renderer,
|
||||
readback,
|
||||
}: {
|
||||
reader: Rgba8Readback;
|
||||
count: number;
|
||||
renderer?: THREE.WebGLRenderer;
|
||||
readback: B;
|
||||
}): Promise<B> {
|
||||
this.renderer = renderer || this.renderer;
|
||||
if (!this.renderer) {
|
||||
throw new Error("No renderer");
|
||||
}
|
||||
|
||||
this.ensureCapacity(count);
|
||||
|
||||
const { program, material } = this.prepareProgramMaterial(reader);
|
||||
program.update();
|
||||
|
||||
const renderState = this.saveRenderState(this.renderer);
|
||||
|
||||
// Generate output
|
||||
this.process({ count, material });
|
||||
|
||||
// Initiate readback
|
||||
const promise = this.read({ readback });
|
||||
|
||||
this.resetRenderState(this.renderer, renderState);
|
||||
return promise;
|
||||
}
|
||||
|
||||
getTexture(): THREE.DataArrayTexture | undefined {
|
||||
return this.target?.texture;
|
||||
}
|
||||
|
||||
static programTemplate: DynoProgramTemplate | null = null;
|
||||
|
||||
// Cache for Rgba8Readback programs
|
||||
static readbackProgram = new Map<Rgba8Readback, DynoProgram>();
|
||||
|
||||
// Static Three.js objects for pseudo-compute shader rendering
|
||||
static geometry = new THREE.PlaneGeometry(2, 2);
|
||||
static mesh = new THREE.Mesh(
|
||||
Readback.geometry,
|
||||
new THREE.RawShaderMaterial({ visible: false }),
|
||||
);
|
||||
static scene = new THREE.Scene().add(Readback.mesh);
|
||||
static camera = new THREE.Camera();
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { DynoPackedSplats, type PackedSplats } from "./PackedSplats";
|
||||
import { Readback, type Rgba8Readback } from "./Readback";
|
||||
import { SPLAT_TEX_WIDTH } from "./defines";
|
||||
import {
|
||||
Dyno,
|
||||
type DynoBlock,
|
||||
DynoInt,
|
||||
DynoUniform,
|
||||
type DynoVal,
|
||||
add,
|
||||
dynoBlock,
|
||||
readPackedSplatRange,
|
||||
splitGsplat,
|
||||
unindent,
|
||||
unindentLines,
|
||||
} from "./dyno";
|
||||
import { getTextureSize } from "./utils";
|
||||
|
||||
// An RgbaArray is a collection of ordered RGBA8 values, which can be used as a dyno
|
||||
// data source, for example for recoloring Gsplats via SplatMesh.splatRgba.
|
||||
// It can be instantiated from a Uint8Array of RGBA8 values, or it can be
|
||||
// generated using a Rgba8Readback dyno program.
|
||||
|
||||
export type RgbaArrayOptions = {
|
||||
// Reserve space for at least this many RGBA values.
|
||||
capacity?: number;
|
||||
// Use the provided array of RGBA8 values as the source.
|
||||
array?: Uint8Array;
|
||||
// The number of actual RGBA8 values in the array.
|
||||
count?: number;
|
||||
};
|
||||
|
||||
export class RgbaArray {
|
||||
capacity = 0;
|
||||
count = 0;
|
||||
array: Uint8Array | null = null;
|
||||
|
||||
readback: Readback | null = null;
|
||||
source: THREE.DataArrayTexture | null = null;
|
||||
// Set to true if source array is updated to have it upload to GPU
|
||||
needsUpdate = true;
|
||||
|
||||
// Use this as a TRgbaArray in a dyno graph
|
||||
dyno: DynoUniform<typeof TRgbaArray, "rgbaArray">;
|
||||
|
||||
constructor(options: RgbaArrayOptions = {}) {
|
||||
this.dyno = new DynoUniform({
|
||||
key: "rgbaArray",
|
||||
type: TRgbaArray,
|
||||
globals: () => [defineRgbaArray],
|
||||
value: {
|
||||
texture: RgbaArray.getEmpty(),
|
||||
count: 0,
|
||||
},
|
||||
update: (value) => {
|
||||
value.texture =
|
||||
this.readback?.getTexture() ?? this.source ?? RgbaArray.getEmpty();
|
||||
value.count = this.count;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
if (options.array) {
|
||||
// Initialize with given array
|
||||
this.array = options.array;
|
||||
this.capacity = Math.floor(this.array.length / 4);
|
||||
this.capacity =
|
||||
Math.floor(this.capacity / SPLAT_TEX_WIDTH) * SPLAT_TEX_WIDTH;
|
||||
this.count = Math.min(
|
||||
this.capacity,
|
||||
options.count ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
} else {
|
||||
this.capacity = options.capacity ?? 0;
|
||||
this.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Free up resources
|
||||
dispose() {
|
||||
if (this.readback) {
|
||||
this.readback.dispose();
|
||||
this.readback = null;
|
||||
}
|
||||
if (this.source) {
|
||||
this.source.dispose();
|
||||
this.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that our array is large enough to hold capacity RGBA8 values.
|
||||
ensureCapacity(capacity: number): Uint8Array {
|
||||
if (!this.array || capacity > (this.array?.length ?? 0) / 4) {
|
||||
this.capacity = getTextureSize(capacity).maxSplats;
|
||||
const newArray = new Uint8Array(this.capacity * 4);
|
||||
if (this.array) {
|
||||
// Copy over existing data
|
||||
newArray.set(this.array);
|
||||
}
|
||||
this.array = newArray;
|
||||
}
|
||||
return this.array;
|
||||
}
|
||||
|
||||
// Get the THREE.DataArrayTexture from either the readback or the source.
|
||||
getTexture(): THREE.DataArrayTexture {
|
||||
let texture = this.readback?.getTexture();
|
||||
if (this.source || this.array) {
|
||||
texture = this.maybeUpdateSource();
|
||||
}
|
||||
return texture ?? RgbaArray.getEmpty();
|
||||
}
|
||||
|
||||
// Create or get a THREE.DataArrayTexture from the data array.
|
||||
private maybeUpdateSource(): THREE.DataArrayTexture {
|
||||
if (!this.array) {
|
||||
throw new Error("No array");
|
||||
}
|
||||
|
||||
if (this.needsUpdate || !this.source) {
|
||||
this.needsUpdate = false;
|
||||
|
||||
if (this.source) {
|
||||
const { width, height, depth } = this.source.image;
|
||||
if (this.capacity !== width * height * depth) {
|
||||
this.source.dispose();
|
||||
this.source = null;
|
||||
}
|
||||
}
|
||||
if (!this.source) {
|
||||
const { width, height, depth } = getTextureSize(this.capacity);
|
||||
this.source = new THREE.DataArrayTexture(
|
||||
this.array,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
);
|
||||
this.source.format = THREE.RGBAFormat;
|
||||
this.source.type = THREE.UnsignedByteType;
|
||||
this.source.internalFormat = "RGBA8";
|
||||
this.source.needsUpdate = true;
|
||||
} else if (this.array.buffer !== this.source.image.data.buffer) {
|
||||
this.source.image.data = new Uint8Array(this.array.buffer);
|
||||
}
|
||||
this.source.needsUpdate = true;
|
||||
}
|
||||
return this.source;
|
||||
}
|
||||
|
||||
// Generate the RGBA8 values from a Rgba8Readback dyno program.
|
||||
render({
|
||||
reader,
|
||||
count,
|
||||
renderer,
|
||||
}: { reader: Rgba8Readback; count: number; renderer: THREE.WebGLRenderer }) {
|
||||
if (!this.readback) {
|
||||
this.readback = new Readback({ renderer });
|
||||
}
|
||||
this.readback.render({ reader, count, renderer });
|
||||
this.capacity = this.readback.capacity;
|
||||
this.count = this.readback.count;
|
||||
}
|
||||
|
||||
// Extract the RGBA8 values from a PackedSplats collection.
|
||||
fromPackedSplats({
|
||||
packedSplats,
|
||||
base,
|
||||
count,
|
||||
renderer,
|
||||
}: {
|
||||
packedSplats: PackedSplats;
|
||||
base: number;
|
||||
count: number;
|
||||
renderer: THREE.WebGLRenderer;
|
||||
}) {
|
||||
const { dynoSplats, dynoBase, dynoCount, reader } = RgbaArray.makeDynos();
|
||||
dynoSplats.packedSplats = packedSplats;
|
||||
dynoBase.value = base;
|
||||
dynoCount.value = count;
|
||||
this.render({ reader, count, renderer });
|
||||
return this;
|
||||
}
|
||||
|
||||
// Read back the RGBA8 values from the readback buffer.
|
||||
async read(): Promise<Uint8Array> {
|
||||
if (!this.readback) {
|
||||
throw new Error("No readback");
|
||||
}
|
||||
if (!this.array || this.array.length < this.count * 4) {
|
||||
this.array = new Uint8Array(this.capacity * 4);
|
||||
}
|
||||
const result = await this.readback.readback({ readback: this.array });
|
||||
return result.subarray(0, this.count * 4);
|
||||
}
|
||||
|
||||
private static emptySource: THREE.DataArrayTexture | null = null;
|
||||
|
||||
// Can be used where you need an uninitialized THREE.DataArrayTexture like
|
||||
// a uniform you will update with the result of this.getTexture() later.
|
||||
static getEmpty(): THREE.DataArrayTexture {
|
||||
if (!RgbaArray.emptySource) {
|
||||
const emptyArray = new Uint8Array(1 * 4);
|
||||
RgbaArray.emptySource = new THREE.DataArrayTexture(emptyArray, 1, 1, 1);
|
||||
RgbaArray.emptySource.format = THREE.RGBAFormat;
|
||||
RgbaArray.emptySource.type = THREE.UnsignedByteType;
|
||||
RgbaArray.emptySource.internalFormat = "RGBA8";
|
||||
RgbaArray.emptySource.needsUpdate = true;
|
||||
}
|
||||
return RgbaArray.emptySource;
|
||||
}
|
||||
|
||||
private static dynos: {
|
||||
dynoSplats: DynoPackedSplats;
|
||||
dynoBase: DynoInt<string>;
|
||||
dynoCount: DynoInt<string>;
|
||||
reader: DynoBlock<{ index: "int" }, { rgba8: "vec4" }>;
|
||||
} | null = null;
|
||||
|
||||
// Create a dyno program that can extract RGBA8 values from a PackedSplats
|
||||
private static makeDynos() {
|
||||
if (!RgbaArray.dynos) {
|
||||
const dynoSplats = new DynoPackedSplats();
|
||||
const dynoBase = new DynoInt({ value: 0 });
|
||||
const dynoCount = new DynoInt({ value: 0 });
|
||||
const reader = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ rgba8: "vec4" },
|
||||
({ index }) => {
|
||||
if (!index) {
|
||||
throw new Error("index is undefined");
|
||||
}
|
||||
index = add(index, dynoBase);
|
||||
const gsplat = readPackedSplatRange(
|
||||
dynoSplats,
|
||||
index,
|
||||
dynoBase,
|
||||
dynoCount,
|
||||
);
|
||||
return { rgba8: splitGsplat(gsplat).outputs.rgba };
|
||||
},
|
||||
);
|
||||
RgbaArray.dynos = { dynoSplats, dynoBase, dynoCount, reader };
|
||||
}
|
||||
return RgbaArray.dynos;
|
||||
}
|
||||
}
|
||||
|
||||
// Dyno types and definitions
|
||||
|
||||
export const TRgbaArray = { type: "RgbaArray" } as { type: "RgbaArray" };
|
||||
|
||||
export const defineRgbaArray = unindent(`
|
||||
struct RgbaArray {
|
||||
sampler2DArray texture;
|
||||
int count;
|
||||
};
|
||||
`);
|
||||
|
||||
export function readRgbaArray(
|
||||
rgba: DynoVal<typeof TRgbaArray>,
|
||||
index: DynoVal<"int">,
|
||||
): DynoVal<"vec4"> {
|
||||
const dyno = new Dyno<
|
||||
{ rgba: typeof TRgbaArray; index: "int" },
|
||||
{ rgba: "vec4" }
|
||||
>({
|
||||
inTypes: { rgba: TRgbaArray, index: "int" },
|
||||
outTypes: { rgba: "vec4" },
|
||||
inputs: { rgba, index },
|
||||
globals: () => [defineRgbaArray],
|
||||
statements: ({ inputs, outputs }) =>
|
||||
unindentLines(`
|
||||
if ((index >= 0) && (index < ${inputs.rgba}.count)) {
|
||||
${outputs.rgba} = texelFetch(${inputs.rgba}.texture, splatTexCoord(index), 0);
|
||||
} else {
|
||||
${outputs.rgba} = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
`),
|
||||
});
|
||||
return dyno.outputs.rgba;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { PackedSplats } from "./PackedSplats";
|
||||
import type {
|
||||
GsplatGenerator,
|
||||
SplatGenerator,
|
||||
SplatModifier,
|
||||
} from "./SplatGenerator";
|
||||
|
||||
// SplatAccumulator helps manage the generation of splats from multiple
|
||||
// SplatGenerators, keeping track of the splat mapping, coordinate system,
|
||||
// and reference count.
|
||||
|
||||
// A GeneratorMapping describes a Gsplat range that was generated, including
|
||||
// which generator and its version number.
|
||||
export type GeneratorMapping = {
|
||||
node: SplatGenerator;
|
||||
generator?: GsplatGenerator;
|
||||
version: number;
|
||||
base: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export class SplatAccumulator {
|
||||
splats = new PackedSplats();
|
||||
// The transform from Accumulator coordinate system to world coordinates.
|
||||
toWorld = new THREE.Matrix4();
|
||||
// An array of all Gsplat mappings that were used for generation
|
||||
mapping: GeneratorMapping[] = [];
|
||||
// Number of ForgeViewpoints (or other) that reference this accumulator, used
|
||||
// to figure out when it can be recycled for use
|
||||
refCount = 0;
|
||||
|
||||
// Incremented every time the splats are updated/generated.
|
||||
splatsVersion = -1;
|
||||
// Incremented every time the splat mapping/layout is updated.
|
||||
// Splat sort order can be reused between equivalent mapping versions.
|
||||
mappingVersion = -1;
|
||||
|
||||
ensureGenerate(maxSplats: number) {
|
||||
if (this.splats.ensureGenerate(maxSplats)) {
|
||||
// If we had to resize our PackedSplats then clear all previous mappings
|
||||
this.mapping = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all Gsplats from an array of generators
|
||||
generateSplats({
|
||||
renderer,
|
||||
modifier,
|
||||
generators,
|
||||
forceUpdate,
|
||||
originToWorld,
|
||||
}: {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
modifier: SplatModifier;
|
||||
generators: GeneratorMapping[];
|
||||
forceUpdate?: boolean;
|
||||
originToWorld: THREE.Matrix4;
|
||||
}) {
|
||||
// Create a lookup from last SplatGenerator
|
||||
const mapping = this.mapping.reduce((map, record) => {
|
||||
map.set(record.node, record);
|
||||
return map;
|
||||
}, new Map<SplatGenerator, GeneratorMapping>());
|
||||
|
||||
// Run generators that are different from existing mapping
|
||||
let updated = 0;
|
||||
let numSplats = 0;
|
||||
for (const { node, generator, version, base, count } of generators) {
|
||||
const current = mapping.get(node);
|
||||
if (
|
||||
forceUpdate ||
|
||||
generator !== current?.generator ||
|
||||
version !== current?.version ||
|
||||
base !== current?.base ||
|
||||
count !== current?.count
|
||||
) {
|
||||
// Something is different from before so we should generate these Gsplats
|
||||
if (generator && count > 0) {
|
||||
const modGenerator = modifier.apply(generator);
|
||||
try {
|
||||
this.splats.generate({
|
||||
generator: modGenerator,
|
||||
base,
|
||||
count,
|
||||
renderer,
|
||||
});
|
||||
} catch (error) {
|
||||
node.generator = undefined;
|
||||
node.generatorError = error;
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
numSplats = Math.max(numSplats, base + count);
|
||||
}
|
||||
|
||||
this.splats.numSplats = numSplats;
|
||||
this.toWorld = originToWorld;
|
||||
this.mapping = generators;
|
||||
return updated !== 0;
|
||||
}
|
||||
|
||||
// Check if this accumulator has exactly the same generator mapping as
|
||||
// the previous one. If so, we can reuse the Gsplat sort order.
|
||||
hasCorrespondence(other: SplatAccumulator) {
|
||||
if (this.mapping.length !== other.mapping.length) {
|
||||
return false;
|
||||
}
|
||||
return this.mapping.every(({ node, base, count }, i) => {
|
||||
const {
|
||||
node: otherNode,
|
||||
base: otherBase,
|
||||
count: otherCount,
|
||||
} = other.mapping[i];
|
||||
return node === otherNode && base === otherBase && count === otherCount;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import {
|
||||
Dyno,
|
||||
DynoInt,
|
||||
DynoUniform,
|
||||
type DynoVal,
|
||||
Gsplat,
|
||||
unindent,
|
||||
unindentLines,
|
||||
} from "./dyno";
|
||||
import { newArray } from "./utils";
|
||||
|
||||
// Forge provides the ability to apply "edits" to Gsplats as part of the standard
|
||||
// SplatMesh pipeline. These edits take the form of a sequence of operations,
|
||||
// applied one at a time to the set of Gsplats in its packedSplats. Each operation
|
||||
// evaluates a 7-dimensional field (RGBA and XYZ displacement) at each point in
|
||||
// space that derives from N=1 or more Signed Distance Field shapes (such as spheres,
|
||||
// boxes, planes, etc.), blended together and across inside-outisde boundaries.
|
||||
|
||||
// The result is a an RGBA,XYZ value for each point in space, which combined with
|
||||
// SplatEditRgbaBlendMode.MULTIPLY/SET_RGB/ADD_RGBA can be used to create special
|
||||
// effects, for example simulating simple lighting or applying deformations in space,
|
||||
// whose parameters can be updated each frame to create animated effects.
|
||||
|
||||
// RGBA-XYZ values are computed by blending together values from all SDF shapes using
|
||||
// the exponential "softmax" function, which is commutative (so blending order within
|
||||
// a SplatEdit operation doesn't matter). The parameter SplatEdit.sdfSmooth controls
|
||||
// the blending scale between SDF shapes, while SplatEdit.softEdge controls the scale
|
||||
// of soft inside-outside shape edit blending. Their default values start at 0.0 and
|
||||
// should be increased to soften the effect.
|
||||
|
||||
// Note that XYZ displacement values are blended in the same way as RGBA, with a
|
||||
// resulting displacement field that can be quite complex but "softly" blending
|
||||
// between shapes. These RGBA-XYZ edits, along with time-based and overlapping
|
||||
// fields can create many interesting animations and special effects, such as
|
||||
// rippling leaves in the wind, an angry fire, or a looping water effects. Simply
|
||||
// update the SplatEdit and SplatEditSdf objects and the operations will be applied
|
||||
// immediately to the Gsplats in the scene.
|
||||
|
||||
export enum SplatEditSdfType {
|
||||
// ALL: Affects all points in space
|
||||
ALL = "all",
|
||||
// PLANE: Infinite plane (position, rotation)
|
||||
PLANE = "plane",
|
||||
// SPHERE: Sphere (position, radius)
|
||||
SPHERE = "sphere",
|
||||
// BOX: Rounded box (position, rotation, sizes, radius)
|
||||
BOX = "box",
|
||||
// ELLIPSOID: Ellipsoid (position, rotation, sizes)
|
||||
ELLIPSOID = "ellipsoid",
|
||||
// CYLINDER: Cylinder (position, rotation, radius, size_y)
|
||||
CYLINDER = "cylinder",
|
||||
// CAPSULE: Capsule (position, rotation, radius, size_y)
|
||||
CAPSULE = "capsule",
|
||||
// INFINITE_CONE: Infinite cone (position, rotation, radius=angle)
|
||||
INFINITE_CONE = "infinite_cone",
|
||||
}
|
||||
|
||||
function sdfTypeToNumber(type: SplatEditSdfType) {
|
||||
switch (type) {
|
||||
case SplatEditSdfType.ALL:
|
||||
return 0;
|
||||
case SplatEditSdfType.PLANE:
|
||||
return 1;
|
||||
case SplatEditSdfType.SPHERE:
|
||||
return 2;
|
||||
case SplatEditSdfType.BOX:
|
||||
return 3;
|
||||
case SplatEditSdfType.ELLIPSOID:
|
||||
return 4;
|
||||
case SplatEditSdfType.CYLINDER:
|
||||
return 5;
|
||||
case SplatEditSdfType.CAPSULE:
|
||||
return 6;
|
||||
case SplatEditSdfType.INFINITE_CONE:
|
||||
return 7;
|
||||
default:
|
||||
throw new Error(`Unknown SDF type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export enum SplatEditRgbaBlendMode {
|
||||
// The RGBA of the splat is multiplied component-wise by the SDF’s
|
||||
// RGBA value at that point in space.
|
||||
MULTIPLY = "multiply",
|
||||
// Ignore the Alpha value in the SDF, but set the splat’s RGB to
|
||||
// equal the SDF’s RGB value at that point.
|
||||
SET_RGB = "set_rgb",
|
||||
// Add the SDF’s RGBA value at that point to the RGBA value of
|
||||
// the Gsplat. This can produce hyper-saturated results, but is useful
|
||||
// to easily “light up” areas.
|
||||
ADD_RGBA = "add_rgba",
|
||||
}
|
||||
|
||||
function rgbaBlendModeToNumber(mode: SplatEditRgbaBlendMode) {
|
||||
switch (mode) {
|
||||
case SplatEditRgbaBlendMode.MULTIPLY:
|
||||
return 0;
|
||||
case SplatEditRgbaBlendMode.SET_RGB:
|
||||
return 1;
|
||||
case SplatEditRgbaBlendMode.ADD_RGBA:
|
||||
return 2;
|
||||
default:
|
||||
throw new Error(`Unknown blend mode: ${mode}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type SplatEditSdfOptions = {
|
||||
// The SDF shape type: ALL, PLANE, SPHERE, BOX, ELLIPSOID, CYLINDER, CAPSULE,
|
||||
// or INFINITE_CONE. (default: SplatEditSdfType.SPHERE)
|
||||
type?: SplatEditSdfType;
|
||||
// Invert the SDF evaluation, swapping inside and outside regions. (default: false)
|
||||
invert?: boolean;
|
||||
// Opacity / "alpha" value used differently by blending modes (default: 1.0)
|
||||
opacity?: number;
|
||||
// RGB color applied within the shape. (default: new THREE.Color(1.0, 1.0, 1.0))
|
||||
color?: THREE.Color;
|
||||
// XYZ displacement applied to splat positions inside the shape.
|
||||
// (default: new THREE.Vector3(0.0, 0.0, 0.0))
|
||||
displace?: THREE.Vector3;
|
||||
// Shape-specific size parameter: sphere radius, box corner rounding,
|
||||
// cylinder/capsule radius, or for the infinite cone the angle factor
|
||||
// (opening half-angle = π/4 × radius).
|
||||
radius?: number;
|
||||
};
|
||||
|
||||
export class SplatEditSdf extends THREE.Object3D {
|
||||
type: SplatEditSdfType;
|
||||
invert: boolean;
|
||||
opacity: number;
|
||||
color: THREE.Color;
|
||||
displace: THREE.Vector3;
|
||||
radius: number;
|
||||
|
||||
constructor(options: SplatEditSdfOptions = {}) {
|
||||
super();
|
||||
const { type, invert, opacity, color, displace, radius } = options;
|
||||
this.type = type ?? SplatEditSdfType.SPHERE;
|
||||
this.invert = invert ?? false;
|
||||
this.opacity = opacity ?? 1.0;
|
||||
this.color = color ?? new THREE.Color(1.0, 1.0, 1.0);
|
||||
this.displace = displace ?? new THREE.Vector3(0.0, 0.0, 0.0);
|
||||
this.radius = radius ?? 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
export type SplatEditOptions = {
|
||||
// Name of this edit operation. If you omit it, a default "Edit 1", "Edit 2", ...
|
||||
// is assigned.
|
||||
name?: string;
|
||||
// How the SDF’s RGBA modifies each splat’s RGBA: multiply, overwrite RGB,
|
||||
// or add RGBA. (default: MULTIPLY)
|
||||
rgbaBlendMode?: SplatEditRgbaBlendMode;
|
||||
// Smoothing (in world‐space units) for blending between multiple SDF shapes
|
||||
// at their boundaries. (default: 0.0)
|
||||
sdfSmooth?: number;
|
||||
// Soft‐edge falloff radius (in world‐space units) around each SDF shape’s surface.
|
||||
// (default: 0.0)
|
||||
softEdge?: number;
|
||||
// Invert the SDF evaluation (inside/outside swap). (default: false)
|
||||
invert?: boolean;
|
||||
// Explicit array of SplatEditSdf objects to include. If null, any child
|
||||
// SplatEditSdf instances are used.
|
||||
sdfs?: SplatEditSdf[];
|
||||
};
|
||||
|
||||
export class SplatEdit extends THREE.Object3D {
|
||||
// ordering used to apply SplatEdit operations to Gsplats. This is implicitly
|
||||
// increased with each new SplatEdit. Reassigning ordering can be used to
|
||||
// reorder the operations.
|
||||
ordering: number;
|
||||
rgbaBlendMode: SplatEditRgbaBlendMode;
|
||||
sdfSmooth: number;
|
||||
softEdge: number;
|
||||
invert: boolean;
|
||||
|
||||
// Optional list of explicit SDFs to including in this edit. If it is null, then
|
||||
// any SplatEditSdf children in the scene graph will be added automatically.
|
||||
sdfs: SplatEditSdf[] | null;
|
||||
|
||||
// The next ordering number to use for a new SplatEdit, auto-incremented
|
||||
static nextOrdering = 1;
|
||||
|
||||
constructor(options: SplatEditOptions = {}) {
|
||||
const {
|
||||
name,
|
||||
rgbaBlendMode = SplatEditRgbaBlendMode.MULTIPLY,
|
||||
sdfSmooth = 0.0,
|
||||
softEdge = 0.0,
|
||||
invert = false,
|
||||
sdfs = null,
|
||||
} = options;
|
||||
|
||||
super();
|
||||
this.rgbaBlendMode = rgbaBlendMode;
|
||||
this.sdfSmooth = sdfSmooth;
|
||||
this.softEdge = softEdge;
|
||||
this.invert = invert;
|
||||
this.sdfs = sdfs;
|
||||
// Assign and auto-increment unique ordering number for this edit
|
||||
this.ordering = SplatEdit.nextOrdering++;
|
||||
// Automatically assign a default name if not provided
|
||||
this.name = name ?? `Edit ${this.ordering}`;
|
||||
}
|
||||
|
||||
addSdf(sdf: SplatEditSdf) {
|
||||
if (this.sdfs == null) {
|
||||
this.sdfs = [];
|
||||
}
|
||||
this.sdfs.push(sdf);
|
||||
}
|
||||
|
||||
removeSdf(sdf: SplatEditSdf) {
|
||||
if (this.sdfs == null) {
|
||||
return;
|
||||
}
|
||||
this.sdfs = this.sdfs.filter((s) => s !== sdf);
|
||||
}
|
||||
}
|
||||
|
||||
// Dyno implementation of RGBA-XYZ SDF editing.
|
||||
// The SDFs are encoded in a texture while the edits are encoded
|
||||
// as a uniform uvec4 array.
|
||||
|
||||
export class SplatEdits {
|
||||
// Maximum number of SDFs allocated
|
||||
maxSdfs: number;
|
||||
// Number of SDFs currently in use
|
||||
numSdfs: number;
|
||||
// Encoded SDF data
|
||||
sdfData: Uint32Array;
|
||||
// Float interpretation of SDF data
|
||||
sdfFloatData: Float32Array;
|
||||
// Texture with encoded SDF data
|
||||
sdfTexture: THREE.DataTexture;
|
||||
// An SdfArray dyno uniform
|
||||
dynoSdfArray: DynoUniform<typeof SdfArray, "sdfArray">;
|
||||
|
||||
// Maximum number of edits allocated
|
||||
maxEdits: number;
|
||||
// Number of edits currently in use
|
||||
numEdits: number;
|
||||
// Encoded edit data
|
||||
editData: Uint32Array;
|
||||
// Float interpretation of edit data
|
||||
editFloatData: Float32Array;
|
||||
// A dyno uniform for the number of edits
|
||||
dynoNumEdits: DynoUniform<"int", "numEdits">;
|
||||
// A dyno uniform for the encoded edits, one uvec4 per edit
|
||||
dynoEdits: DynoUniform<"uvec4", "edits">;
|
||||
|
||||
constructor({ maxSdfs, maxEdits }: { maxSdfs?: number; maxEdits?: number }) {
|
||||
// Allocate at least 16 SDFs for efficiency
|
||||
this.maxSdfs = Math.max(16, maxSdfs ?? 0);
|
||||
this.numSdfs = 0;
|
||||
|
||||
// Allocate space: 8 x (u)vec4 values per SDF, Uint32 and Float32 arrays
|
||||
this.sdfData = new Uint32Array(this.maxSdfs * 8 * 4);
|
||||
this.sdfFloatData = new Float32Array(this.sdfData.buffer);
|
||||
this.sdfTexture = this.newSdfTexture(this.sdfData, this.maxSdfs);
|
||||
this.dynoSdfArray = new DynoUniform({
|
||||
key: "sdfArray",
|
||||
type: SdfArray,
|
||||
globals: () => [defineSdfArray],
|
||||
value: {
|
||||
numSdfs: 0,
|
||||
sdfTexture: this.sdfTexture,
|
||||
},
|
||||
update: (uniform) => {
|
||||
uniform.numSdfs = this.numSdfs;
|
||||
uniform.sdfTexture = this.sdfTexture;
|
||||
return uniform;
|
||||
},
|
||||
});
|
||||
|
||||
// Allocate at least 16 edits slots for efficiency
|
||||
this.maxEdits = Math.max(16, maxEdits ?? 0);
|
||||
this.numEdits = 0;
|
||||
// Allocate space: 1 uvec4 per edit
|
||||
this.editData = new Uint32Array(this.maxEdits * 4);
|
||||
this.editFloatData = new Float32Array(this.editData.buffer);
|
||||
this.dynoNumEdits = new DynoInt({ value: 0 });
|
||||
this.dynoEdits = this.newEdits(this.editData, this.maxEdits);
|
||||
}
|
||||
|
||||
private newSdfTexture(data: Uint32Array, maxSdfs: number) {
|
||||
const texture = new THREE.DataTexture(
|
||||
data,
|
||||
8,
|
||||
maxSdfs,
|
||||
THREE.RGBAIntegerFormat,
|
||||
THREE.UnsignedIntType,
|
||||
);
|
||||
texture.internalFormat = "RGBA32UI";
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
private newEdits(data: Uint32Array, maxEdits: number) {
|
||||
return new DynoUniform({
|
||||
key: "edits",
|
||||
type: "uvec4",
|
||||
count: maxEdits,
|
||||
globals: () => [defineEdit],
|
||||
value: data,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure our SDF texture and edits uniform array have enough capacity.
|
||||
// Reallocate if not.
|
||||
private ensureCapacity({
|
||||
maxSdfs,
|
||||
maxEdits,
|
||||
}: { maxSdfs: number; maxEdits: number }): boolean {
|
||||
let dynoUpdated = false;
|
||||
if (maxSdfs > this.sdfTexture.image.height) {
|
||||
this.sdfTexture.dispose();
|
||||
// At least double the size to avoid frequent reallocations
|
||||
this.maxSdfs = Math.max(this.maxSdfs * 2, maxSdfs);
|
||||
this.sdfData = new Uint32Array(this.maxSdfs * 8 * 4);
|
||||
this.sdfFloatData = new Float32Array(this.sdfData.buffer);
|
||||
this.sdfTexture = this.newSdfTexture(this.sdfData, this.maxSdfs);
|
||||
}
|
||||
if (maxEdits > (this.dynoEdits.count ?? 0)) {
|
||||
// At least double the size to avoid frequent reallocations
|
||||
this.maxEdits = Math.max(this.maxEdits * 2, maxEdits);
|
||||
this.editData = new Uint32Array(this.maxEdits * 4);
|
||||
this.editFloatData = new Float32Array(this.editData.buffer);
|
||||
this.dynoEdits = this.newEdits(this.editData, this.maxEdits);
|
||||
dynoUpdated = true;
|
||||
}
|
||||
return dynoUpdated;
|
||||
}
|
||||
|
||||
private updateEditData(offset: number, value: number): boolean {
|
||||
// Update an edit uint32 value and return true if it changed
|
||||
const updated = this.editData[offset] !== value;
|
||||
this.editData[offset] = value;
|
||||
return updated;
|
||||
}
|
||||
|
||||
private updateEditFloatData(offset: number, value: number): boolean {
|
||||
// Update an edit float32 value and return true if it changed
|
||||
tempFloat32[0] = value;
|
||||
const updated = this.editFloatData[offset] !== tempFloat32[0];
|
||||
if (updated) {
|
||||
this.editFloatData[offset] = tempFloat32[0];
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private encodeEdit(
|
||||
editIndex: number,
|
||||
{
|
||||
sdfFirst,
|
||||
sdfCount,
|
||||
invert,
|
||||
rgbaBlendMode,
|
||||
softEdge,
|
||||
sdfSmooth,
|
||||
}: {
|
||||
sdfFirst: number;
|
||||
sdfCount: number;
|
||||
invert: boolean;
|
||||
rgbaBlendMode: number;
|
||||
softEdge: number;
|
||||
sdfSmooth: number;
|
||||
},
|
||||
): boolean {
|
||||
const base = editIndex * 4;
|
||||
let updated = false;
|
||||
// Encode the edit fields into the editData array and check if any changed
|
||||
updated =
|
||||
this.updateEditData(base + 0, rgbaBlendMode | (invert ? 1 << 8 : 0)) ||
|
||||
updated;
|
||||
updated =
|
||||
this.updateEditData(base + 1, sdfFirst | (sdfCount << 16)) || updated;
|
||||
updated = this.updateEditFloatData(base + 2, softEdge) || updated;
|
||||
updated = this.updateEditFloatData(base + 3, sdfSmooth) || updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
private updateSdfData(offset: number, value: number): boolean {
|
||||
// Update an SDF uint32 value and return true if it changed
|
||||
const updated = this.sdfData[offset] !== value;
|
||||
this.sdfData[offset] = value;
|
||||
return updated;
|
||||
}
|
||||
|
||||
private updateSdfFloatData(offset: number, value: number): boolean {
|
||||
// Update an SDF float32 value and return true if it changed
|
||||
tempFloat32[0] = value;
|
||||
const updated = this.sdfFloatData[offset] !== tempFloat32[0];
|
||||
if (updated) {
|
||||
this.sdfFloatData[offset] = tempFloat32[0];
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private encodeSdf(
|
||||
sdfIndex: number,
|
||||
{
|
||||
sdfType,
|
||||
invert,
|
||||
center,
|
||||
quaternion,
|
||||
scale,
|
||||
sizes,
|
||||
}: {
|
||||
sdfType: number;
|
||||
invert?: boolean;
|
||||
center?: THREE.Vector3;
|
||||
quaternion?: THREE.Quaternion;
|
||||
scale?: THREE.Vector3;
|
||||
sizes?: THREE.Vector4;
|
||||
},
|
||||
values: THREE.Vector4[],
|
||||
): boolean {
|
||||
// Encode the SDF fields into the sdfData array and check if any changed
|
||||
const base = sdfIndex * (8 * 4);
|
||||
const flags = sdfType | (invert ? 1 << 8 : 0);
|
||||
let updated = false;
|
||||
|
||||
updated = this.updateSdfFloatData(base + 0, center?.x ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 1, center?.y ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 2, center?.z ?? 0) || updated;
|
||||
updated = this.updateSdfData(base + 3, flags) || updated;
|
||||
|
||||
updated = this.updateSdfFloatData(base + 4, quaternion?.x ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 5, quaternion?.y ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 6, quaternion?.z ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 7, quaternion?.w ?? 0) || updated;
|
||||
|
||||
updated = this.updateSdfFloatData(base + 8, scale?.x ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 9, scale?.y ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 10, scale?.z ?? 0) || updated;
|
||||
updated = this.updateSdfData(base + 11, 0) || updated;
|
||||
|
||||
updated = this.updateSdfFloatData(base + 12, sizes?.x ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 13, sizes?.y ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 14, sizes?.z ?? 0) || updated;
|
||||
updated = this.updateSdfFloatData(base + 15, sizes?.w ?? 0) || updated;
|
||||
|
||||
const nValues = Math.min(4, values.length);
|
||||
for (let i = 0; i < nValues; ++i) {
|
||||
const vBase = base + 16 + i * 4;
|
||||
updated = this.updateSdfFloatData(vBase + 0, values[i].x) || updated;
|
||||
updated = this.updateSdfFloatData(vBase + 1, values[i].y) || updated;
|
||||
updated = this.updateSdfFloatData(vBase + 2, values[i].z) || updated;
|
||||
updated = this.updateSdfFloatData(vBase + 3, values[i].w) || updated;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Update the SDFs and edits from an array of SplatEdits and their
|
||||
// associated SplatEditSdfs, updating it for the dyno shader program.
|
||||
update(edits: { edit: SplatEdit; sdfs: SplatEditSdf[] }[]): {
|
||||
updated: boolean;
|
||||
dynoUpdated: boolean;
|
||||
} {
|
||||
const sdfCount = edits.reduce((total, { sdfs }) => total + sdfs.length, 0);
|
||||
const dynoUpdated = this.ensureCapacity({
|
||||
maxEdits: edits.length,
|
||||
maxSdfs: sdfCount,
|
||||
});
|
||||
|
||||
const values = [new THREE.Vector4(), new THREE.Vector4()];
|
||||
const center = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
const sizes = new THREE.Vector4();
|
||||
|
||||
let sdfIndex = 0;
|
||||
let updated = dynoUpdated;
|
||||
|
||||
if (edits.length !== this.dynoNumEdits.value) {
|
||||
this.dynoNumEdits.value = edits.length;
|
||||
this.numEdits = edits.length;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
for (const [editIndex, { edit, sdfs }] of edits.entries()) {
|
||||
updated =
|
||||
this.encodeEdit(editIndex, {
|
||||
sdfFirst: sdfIndex,
|
||||
sdfCount: sdfs.length,
|
||||
invert: edit.invert,
|
||||
rgbaBlendMode: rgbaBlendModeToNumber(edit.rgbaBlendMode),
|
||||
softEdge: edit.softEdge,
|
||||
sdfSmooth: edit.sdfSmooth,
|
||||
}) || updated;
|
||||
|
||||
let sdfUpdated = false;
|
||||
for (const sdf of sdfs) {
|
||||
sizes.set(sdf.scale.x, sdf.scale.y, sdf.scale.z, sdf.radius);
|
||||
// Temporarily set the SDF scale to 1.0 to get the world-to-SDF
|
||||
// transform without scaling. The SDF treats the scale separately.
|
||||
sdf.scale.setScalar(1.0);
|
||||
sdf.updateMatrixWorld();
|
||||
const worldToSdf = sdf.matrixWorld.clone().invert();
|
||||
worldToSdf.decompose(center, quaternion, scale);
|
||||
|
||||
sdf.scale.set(sizes.x, sizes.y, sizes.z);
|
||||
sdf.updateMatrixWorld();
|
||||
|
||||
values[0].set(sdf.color.r, sdf.color.g, sdf.color.b, sdf.opacity);
|
||||
values[1].set(sdf.displace.x, sdf.displace.y, sdf.displace.z, 1.0);
|
||||
|
||||
sdfUpdated =
|
||||
this.encodeSdf(
|
||||
sdfIndex,
|
||||
{
|
||||
sdfType: sdfTypeToNumber(sdf.type),
|
||||
invert: sdf.invert,
|
||||
center,
|
||||
quaternion,
|
||||
scale,
|
||||
sizes,
|
||||
},
|
||||
values,
|
||||
) || sdfUpdated;
|
||||
|
||||
sdfIndex += 1;
|
||||
}
|
||||
this.numSdfs = sdfIndex;
|
||||
if (sdfUpdated) {
|
||||
this.sdfTexture.needsUpdate = true;
|
||||
}
|
||||
updated ||= sdfUpdated;
|
||||
}
|
||||
return { updated, dynoUpdated };
|
||||
}
|
||||
|
||||
// Modify a Gsplat in a dyno shader program using the current edits and SDFs.
|
||||
modify(gsplat: DynoVal<typeof Gsplat>): DynoVal<typeof Gsplat> {
|
||||
return applyGsplatRgbaDisplaceEdits(
|
||||
gsplat,
|
||||
this.dynoSdfArray,
|
||||
this.dynoNumEdits,
|
||||
this.dynoEdits,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dyno types and components:
|
||||
|
||||
// An SdfArray contains a collection of SDFs encoded in a texture.
|
||||
// Each SDF has a type and geometric parameters, but also encodes
|
||||
// 4 x vec4 values, which can all be blended across multiple SDFs.
|
||||
// The SplatEdit system uses 7 of these 16 values to encode RGBA-XYZ edits,
|
||||
// but more can be added, and these SDFs can be used for entirely different
|
||||
// purposes as well.
|
||||
|
||||
export const SdfArray = { type: "SdfArray" } as { type: "SdfArray" };
|
||||
|
||||
export const defineSdfArray = unindent(`
|
||||
struct SdfArray {
|
||||
int numSdfs;
|
||||
usampler2D sdfTexture;
|
||||
};
|
||||
|
||||
void unpackSdfArray(
|
||||
usampler2D sdfTexture, int sdfIndex, out uint flags,
|
||||
out vec3 center, out vec4 quaternion, out vec3 scale, out vec4 sizes,
|
||||
int numValues, out vec4 values[4]
|
||||
) {
|
||||
uvec4 temp = texelFetch(sdfTexture, ivec2(0, sdfIndex), 0);
|
||||
flags = temp.w;
|
||||
center = vec3(uintBitsToFloat(temp.x), uintBitsToFloat(temp.y), uintBitsToFloat(temp.z));
|
||||
|
||||
temp = texelFetch(sdfTexture, ivec2(1, sdfIndex), 0);
|
||||
quaternion = vec4(uintBitsToFloat(temp.x), uintBitsToFloat(temp.y), uintBitsToFloat(temp.z), uintBitsToFloat(temp.w));
|
||||
|
||||
temp = texelFetch(sdfTexture, ivec2(2, sdfIndex), 0);
|
||||
scale = vec3(uintBitsToFloat(temp.x), uintBitsToFloat(temp.y), uintBitsToFloat(temp.z));
|
||||
|
||||
temp = texelFetch(sdfTexture, ivec2(3, sdfIndex), 0);
|
||||
sizes = vec4(uintBitsToFloat(temp.x), uintBitsToFloat(temp.y), uintBitsToFloat(temp.z), uintBitsToFloat(temp.w));
|
||||
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
temp = texelFetch(sdfTexture, ivec2(4 + i, sdfIndex), 0);
|
||||
values[i] = vec4(uintBitsToFloat(temp.x), uintBitsToFloat(temp.y), uintBitsToFloat(temp.z), uintBitsToFloat(temp.w));
|
||||
}
|
||||
}
|
||||
|
||||
const uint SDF_FLAG_TYPE = 0xFFu;
|
||||
const uint SDF_FLAG_INVERT = 1u << 8u;
|
||||
|
||||
const uint SDF_TYPE_ALL = 0u;
|
||||
const uint SDF_TYPE_PLANE = 1u;
|
||||
const uint SDF_TYPE_SPHERE = 2u;
|
||||
const uint SDF_TYPE_BOX = 3u;
|
||||
const uint SDF_TYPE_ELLIPSOID = 4u;
|
||||
const uint SDF_TYPE_CYLINDER = 5u;
|
||||
const uint SDF_TYPE_CAPSULE = 6u;
|
||||
const uint SDF_TYPE_INFINITE_CONE = 7u;
|
||||
|
||||
float evaluateSdfArray(
|
||||
usampler2D sdfTexture, int numSdfs, int sdfFirst, int sdfCount, vec3 pos,
|
||||
float smoothK, int numValues, out vec4 outValues[4]
|
||||
) {
|
||||
float distanceAccum = (smoothK == 0.0) ? 1.0 / 0.0 : 0.0;
|
||||
float maxExp = -1.0 / 0.0;
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
outValues[i] = vec4(0.0);
|
||||
}
|
||||
|
||||
uint flags;
|
||||
vec3 center, scale;
|
||||
vec4 quaternion, sizes;
|
||||
vec4 values[4];
|
||||
|
||||
int sdfLast = min(sdfFirst + sdfCount, numSdfs);
|
||||
for (int index = sdfFirst; index < sdfLast; ++index) {
|
||||
unpackSdfArray(sdfTexture, index, flags, center, quaternion, scale, sizes, numValues, values);
|
||||
uint sdfType = flags & SDF_FLAG_TYPE;
|
||||
vec3 sdfPos = quatVec(quaternion, pos * scale) + center;
|
||||
|
||||
float distance;
|
||||
switch (sdfType) {
|
||||
case SDF_TYPE_ALL:
|
||||
distance = -1.0 / 0.0;
|
||||
break;
|
||||
case SDF_TYPE_PLANE: {
|
||||
distance = sdfPos.z;
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_SPHERE: {
|
||||
distance = length(sdfPos) - sizes.w;
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_BOX: {
|
||||
vec3 q = abs(sdfPos) - sizes.xyz + sizes.w;
|
||||
distance = length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - sizes.w;
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_ELLIPSOID: {
|
||||
vec3 sizes = sizes.xyz;
|
||||
float k0 = length(sdfPos / sizes);
|
||||
float k1 = length(sdfPos / dot(sizes, sizes));
|
||||
distance = k0 * (k0 - 1.0) / k1;
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_CYLINDER: {
|
||||
vec2 d = abs(vec2(length(sdfPos.xz), sdfPos.y)) - sizes.wy;
|
||||
distance = min(max(d.x, d.y), 0.0) + length(max(d, 0.0));
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_CAPSULE: {
|
||||
sdfPos.y -= clamp(sdfPos.y, -0.5 * sizes.y, 0.5 * sizes.y);
|
||||
distance = length(sdfPos) - sizes.w;
|
||||
break;
|
||||
}
|
||||
case SDF_TYPE_INFINITE_CONE: {
|
||||
float angle = 0.25 * PI * sizes.w;
|
||||
vec2 c = vec2(sin(angle), cos(angle));
|
||||
vec2 q = vec2(length(sdfPos.xy), -sdfPos.z);
|
||||
float d = length(q - c * max(dot(q, c), 0.0));
|
||||
distance = d * (((q.x * c.y - q.y * c.x) < 0.0) ? -1.0 : 1.0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((flags & SDF_FLAG_INVERT) != 0u) {
|
||||
distance = -distance;
|
||||
}
|
||||
|
||||
if (smoothK == 0.0) {
|
||||
if (distance < distanceAccum) {
|
||||
distanceAccum = distance;
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
outValues[i] = values[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
float scaledDistance = -distance / smoothK;
|
||||
if (scaledDistance > maxExp) {
|
||||
float scale = exp(maxExp - scaledDistance);
|
||||
distanceAccum *= scale;
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
outValues[i] *= scale;
|
||||
}
|
||||
maxExp = scaledDistance;
|
||||
}
|
||||
|
||||
float weight = exp(scaledDistance - maxExp);
|
||||
distanceAccum += weight;
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
outValues[i] += weight * values[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (smoothK == 0.0) {
|
||||
return distanceAccum;
|
||||
} else {
|
||||
// Very distant SDFs may result in 0 accumulation
|
||||
if (distanceAccum == 0.0) {
|
||||
return 1.0 / 0.0;
|
||||
}
|
||||
for (int i = 0; i < numValues; ++i) {
|
||||
outValues[i] /= distanceAccum;
|
||||
}
|
||||
return (-log(distanceAccum) - maxExp) * smoothK;
|
||||
}
|
||||
}
|
||||
|
||||
float modulateSdfArray(
|
||||
usampler2D sdfTexture, int numSdfs, int sdfFirst, int sdfCount, vec3 pos,
|
||||
float smoothK, int numValues, out vec4 values[4],
|
||||
float softEdge, bool invert
|
||||
) {
|
||||
float distance = evaluateSdfArray(sdfTexture, numSdfs, sdfFirst, sdfCount, pos, smoothK, numValues, values);
|
||||
if (invert) {
|
||||
distance = -distance;
|
||||
}
|
||||
|
||||
return (softEdge == 0.0) ? ((distance < 0.0) ? 1.0 : 0.0)
|
||||
: clamp(-distance / softEdge + 0.5, 0.0, 1.0);
|
||||
}
|
||||
`);
|
||||
|
||||
export const defineEdit = unindent(`
|
||||
const uint EDIT_FLAG_BLEND = 0xFFu;
|
||||
const uint EDIT_BLEND_MULTIPLY = 0u;
|
||||
const uint EDIT_BLEND_SET_RGB = 1u;
|
||||
const uint EDIT_BLEND_ADD_RGBA = 2u;
|
||||
const uint EDIT_FLAG_INVERT = 0x100u;
|
||||
|
||||
void decodeEdit(
|
||||
uvec4 packedEdit, out int sdfFirst, out int sdfCount,
|
||||
out bool invert, out uint rgbaBlendMode, out float softEdge, out float sdfSmooth
|
||||
) {
|
||||
rgbaBlendMode = packedEdit.x & EDIT_FLAG_BLEND;
|
||||
invert = (packedEdit.x & EDIT_FLAG_INVERT) != 0u;
|
||||
|
||||
sdfFirst = int(packedEdit.y & 0xFFFFu);
|
||||
sdfCount = int(packedEdit.y >> 16u);
|
||||
|
||||
softEdge = uintBitsToFloat(packedEdit.z);
|
||||
sdfSmooth = uintBitsToFloat(packedEdit.w);
|
||||
}
|
||||
|
||||
void applyRgbaDisplaceEdit(
|
||||
usampler2D sdfTexture, int numSdfs, int sdfFirst, int sdfCount, inout vec3 pos,
|
||||
float smoothK, float softEdge, bool invert, uint rgbaBlendMode, inout vec4 rgba
|
||||
) {
|
||||
vec4 values[4];
|
||||
float modulate = modulateSdfArray(sdfTexture, numSdfs, sdfFirst, sdfCount, pos, smoothK, 2, values, softEdge, invert);
|
||||
// On Android, moving values[0] is necessary to work around a compiler bug.
|
||||
vec4 sdfRgba = values[0];
|
||||
vec4 sdfDisplaceScale = values[1];
|
||||
|
||||
vec4 target;
|
||||
switch (rgbaBlendMode) {
|
||||
case EDIT_BLEND_MULTIPLY:
|
||||
target = rgba * sdfRgba;
|
||||
break;
|
||||
case EDIT_BLEND_SET_RGB:
|
||||
target = vec4(sdfRgba.rgb, rgba.a * sdfRgba.a);
|
||||
break;
|
||||
case EDIT_BLEND_ADD_RGBA:
|
||||
target = rgba + sdfRgba;
|
||||
break;
|
||||
default:
|
||||
// Debug output if blend mode not set
|
||||
target = vec4(fract(pos), 1.0);
|
||||
}
|
||||
rgba = mix(rgba, target, modulate);
|
||||
pos += sdfDisplaceScale.xyz * modulate;
|
||||
}
|
||||
|
||||
void applyPackedRgbaDisplaceEdit(uvec4 packedEdit, usampler2D sdfTexture, int numSdfs, inout vec3 pos, inout vec4 rgba) {
|
||||
int sdfFirst, sdfCount;
|
||||
bool invert;
|
||||
uint rgbaBlendMode;
|
||||
float softEdge, sdfSmooth;
|
||||
decodeEdit(packedEdit, sdfFirst, sdfCount, invert, rgbaBlendMode, softEdge, sdfSmooth);
|
||||
applyRgbaDisplaceEdit(sdfTexture, numSdfs, sdfFirst, sdfCount, pos, sdfSmooth, softEdge, invert, rgbaBlendMode, rgba);
|
||||
}
|
||||
`);
|
||||
|
||||
function applyGsplatRgbaDisplaceEdits(
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
sdfArray: DynoVal<typeof SdfArray>,
|
||||
numEdits: DynoVal<"int">,
|
||||
rgbaDisplaceEdits: DynoVal<"uvec4">,
|
||||
): DynoVal<typeof Gsplat> {
|
||||
const dyno = new Dyno<
|
||||
{
|
||||
gsplat: typeof Gsplat;
|
||||
sdfArray: typeof SdfArray;
|
||||
numEdits: "int";
|
||||
rgbaDisplaceEdits: "uvec4";
|
||||
},
|
||||
{ gsplat: typeof Gsplat }
|
||||
>({
|
||||
inTypes: {
|
||||
gsplat: Gsplat,
|
||||
sdfArray: SdfArray,
|
||||
numEdits: "int",
|
||||
rgbaDisplaceEdits: "uvec4",
|
||||
},
|
||||
outTypes: { gsplat: Gsplat },
|
||||
globals: () => [defineSdfArray, defineEdit],
|
||||
inputs: { gsplat, sdfArray, numEdits, rgbaDisplaceEdits },
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { sdfArray, numEdits, rgbaDisplaceEdits } = inputs;
|
||||
const { gsplat } = outputs;
|
||||
return unindentLines(`
|
||||
${gsplat} = ${inputs.gsplat};
|
||||
if (isGsplatActive(${gsplat}.flags)) {
|
||||
for (int editIndex = 0; editIndex < ${numEdits}; ++editIndex) {
|
||||
applyPackedRgbaDisplaceEdit(
|
||||
${rgbaDisplaceEdits}[editIndex], ${sdfArray}.sdfTexture, ${sdfArray}.numSdfs,
|
||||
${gsplat}.center, ${gsplat}.rgba
|
||||
);
|
||||
}
|
||||
}
|
||||
`);
|
||||
},
|
||||
});
|
||||
return dyno.outputs.gsplat;
|
||||
}
|
||||
|
||||
const tempFloat32 = new Float32Array(1);
|
||||
@@ -0,0 +1,216 @@
|
||||
import * as THREE from "three";
|
||||
import type { SplatEdit } from "./SplatEdit";
|
||||
import {
|
||||
type Dyno,
|
||||
DynoFloat,
|
||||
type DynoVal,
|
||||
DynoVec3,
|
||||
DynoVec4,
|
||||
Gsplat,
|
||||
dynoBlock,
|
||||
transformGsplat,
|
||||
} from "./dyno";
|
||||
|
||||
// A GsplatGenerator is a dyno program that maps an index to a Gsplat's properties
|
||||
|
||||
export type GsplatGenerator = Dyno<{ index: "int" }, { gsplat: typeof Gsplat }>;
|
||||
|
||||
// A GsplatModifier is a dyno program that inputs a Gsplat, modifies, and outputs it
|
||||
|
||||
export type GsplatModifier = Dyno<
|
||||
{ gsplat: typeof Gsplat },
|
||||
{ gsplat: typeof Gsplat }
|
||||
>;
|
||||
|
||||
// A SplatModifier is a utility class to apply a GsplatModifier to
|
||||
// a GsplatGenerator pipeline, caching the combined result for efficiency.
|
||||
|
||||
export class SplatModifier {
|
||||
modifier: GsplatModifier;
|
||||
cache: Map<GsplatGenerator, GsplatGenerator>;
|
||||
|
||||
constructor(modifier: GsplatModifier) {
|
||||
this.modifier = modifier;
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
apply(generator: GsplatGenerator): GsplatGenerator {
|
||||
let modified = this.cache.get(generator);
|
||||
if (!modified) {
|
||||
modified = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ gsplat: Gsplat },
|
||||
({ index }) => {
|
||||
const { gsplat } = generator.apply({ index });
|
||||
return this.modifier.apply({ gsplat });
|
||||
},
|
||||
);
|
||||
this.cache.set(generator, modified);
|
||||
}
|
||||
return modified;
|
||||
}
|
||||
}
|
||||
|
||||
// A SplatTransformer is a utility class to apply a transform to a Gsplat
|
||||
// via a scale, rotation, and translation. Scale is a single float because
|
||||
// anisotropic scaling of Gsplats is not supported.
|
||||
|
||||
export class SplatTransformer {
|
||||
scale: DynoFloat;
|
||||
rotate: DynoVec4<THREE.Quaternion>;
|
||||
translate: DynoVec3<THREE.Vector3>;
|
||||
|
||||
// Create the dyno uniforms that parameterize the transform, setting them
|
||||
// to initial values that are different from any valid transform.
|
||||
constructor() {
|
||||
this.scale = new DynoFloat({ value: Number.NEGATIVE_INFINITY });
|
||||
this.rotate = new DynoVec4({
|
||||
value: new THREE.Quaternion(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
),
|
||||
});
|
||||
this.translate = new DynoVec3({
|
||||
value: new THREE.Vector3(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the transform to a Gsplat in a dyno program.
|
||||
modify(gsplat: DynoVal<typeof Gsplat>): DynoVal<typeof Gsplat> {
|
||||
return transformGsplat(gsplat, {
|
||||
scale: this.scale,
|
||||
rotate: this.rotate,
|
||||
translate: this.translate,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the uniforms to match the given transform matrix.
|
||||
updateFromMatrix(transform: THREE.Matrix4) {
|
||||
const scale = new THREE.Vector3();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const position = new THREE.Vector3();
|
||||
transform.decompose(position, quaternion, scale);
|
||||
const newScale = (scale.x + scale.y + scale.z) / 3;
|
||||
|
||||
let updated = false;
|
||||
if (newScale !== this.scale.value) {
|
||||
this.scale.value = newScale;
|
||||
updated = true;
|
||||
}
|
||||
if (!position.equals(this.translate.value)) {
|
||||
this.translate.value.copy(position);
|
||||
updated = true;
|
||||
}
|
||||
if (!quaternion.equals(this.rotate.value)) {
|
||||
this.rotate.value.copy(quaternion);
|
||||
updated = true;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Update this transform to match the object's to-world transform.
|
||||
update(object: THREE.Object3D): boolean {
|
||||
object.updateMatrixWorld();
|
||||
return this.updateFromMatrix(object.matrixWorld);
|
||||
}
|
||||
}
|
||||
|
||||
// SplatGenerator is an Object3D that can be placed anywhere in the scene
|
||||
// to generate Gsplats into the world for ForgeRenderer. All Gsplats from
|
||||
// SplatGenerators across the scene will be accumulated into a single
|
||||
// SplatAccumulator, which are sorted and rendered together.
|
||||
//
|
||||
// Each SplatGenerator has two main properties:
|
||||
// - numSplats: the number of Gsplats to generate
|
||||
// - generator: a GsplatGenerator dyno program that maps a splat index
|
||||
// to a Gsplat's properties
|
||||
// Each of these properties can be changed at anytime, however changing
|
||||
// numSplats means we no longer have a correspondence between Gsplats
|
||||
// in successive frames, meaning we can't reuse the previous Gsplat sort
|
||||
// order. Similarly, changing the generator requires re-generating the
|
||||
// shader program, which will trigger a GPU shader compilation the first
|
||||
// time (possibly a perceptible "hickup" in the framerate) but is cached
|
||||
// subsequence times if the generator is the same as one that was used previously.
|
||||
//
|
||||
// A SplatGenerator also has a custom frameUpdate function that is called
|
||||
// on each execution, allowing you to update uniforms or other parameters that
|
||||
// affect the generation. If the Gsplats are changed, you must call
|
||||
// updateVersion() (alternatively, set needsUpdate to true) to trigger a
|
||||
// re-generation of the Gsplats for this SplatGenerator.
|
||||
|
||||
export class SplatGenerator extends THREE.Object3D {
|
||||
numSplats: number;
|
||||
generator?: GsplatGenerator;
|
||||
generatorError?: unknown;
|
||||
frameUpdate?: ({
|
||||
object,
|
||||
time,
|
||||
deltaTime,
|
||||
viewToWorld,
|
||||
globalEdits,
|
||||
}: {
|
||||
object: SplatGenerator;
|
||||
time: number;
|
||||
deltaTime: number;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
globalEdits: SplatEdit[];
|
||||
}) => void;
|
||||
version: number;
|
||||
|
||||
constructor({
|
||||
numSplats,
|
||||
generator,
|
||||
construct,
|
||||
update,
|
||||
}: {
|
||||
numSplats?: number;
|
||||
generator?: GsplatGenerator;
|
||||
construct?: (object: SplatGenerator) => {
|
||||
generator?: GsplatGenerator;
|
||||
numSplats?: number;
|
||||
frameUpdate?: (object: SplatGenerator) => void;
|
||||
};
|
||||
update?: ({
|
||||
object,
|
||||
time,
|
||||
deltaTime,
|
||||
viewToWorld,
|
||||
globalEdits,
|
||||
}: {
|
||||
object: SplatGenerator;
|
||||
time: number;
|
||||
deltaTime: number;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
globalEdits: SplatEdit[];
|
||||
}) => void;
|
||||
}) {
|
||||
super();
|
||||
|
||||
this.numSplats = numSplats ?? 0;
|
||||
this.generator = generator;
|
||||
this.frameUpdate = update;
|
||||
this.version = 0;
|
||||
|
||||
if (construct) {
|
||||
const constructed = construct(this);
|
||||
// If we returned something, update our properties
|
||||
Object.assign(this, constructed);
|
||||
}
|
||||
}
|
||||
|
||||
updateVersion() {
|
||||
this.version += 1;
|
||||
}
|
||||
|
||||
set needsUpdate(value: boolean) {
|
||||
if (value) {
|
||||
this.updateVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// SplatGeometry is an internal class used by ForgeRenderer to render a collection
|
||||
// of Gsplats in a single draw call by extending THREE.InstancedBufferGeometry.
|
||||
// Each Gsplat is drawn as two triangles, with the order of the Gsplats determined
|
||||
// by the instance attribute "ordering".
|
||||
|
||||
export class SplatGeometry extends THREE.InstancedBufferGeometry {
|
||||
ordering: Uint32Array;
|
||||
attribute: THREE.InstancedBufferAttribute;
|
||||
|
||||
constructor(ordering: Uint32Array, activeSplats: number) {
|
||||
super();
|
||||
|
||||
this.ordering = ordering;
|
||||
|
||||
this.setAttribute("position", new THREE.BufferAttribute(QUAD_VERTICES, 3));
|
||||
this.setIndex(new THREE.BufferAttribute(QUAD_INDICES, 1));
|
||||
|
||||
// Hack to work around Three.js
|
||||
// @ts-ignore
|
||||
this._maxInstanceCount = ordering.length;
|
||||
this.instanceCount = activeSplats;
|
||||
|
||||
this.attribute = new THREE.InstancedBufferAttribute(ordering, 1, false, 1);
|
||||
this.attribute.setUsage(THREE.DynamicDrawUsage);
|
||||
this.setAttribute("splatIndex", this.attribute);
|
||||
}
|
||||
|
||||
update(ordering: Uint32Array, activeSplats: number) {
|
||||
this.ordering = ordering;
|
||||
this.attribute.array = ordering;
|
||||
this.instanceCount = activeSplats;
|
||||
this.attribute.addUpdateRange(0, activeSplats);
|
||||
this.attribute.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Each instance draws to triangles covering a quad over coords (-1,-1,0)..(1,1,0)
|
||||
const QUAD_VERTICES = new Float32Array([
|
||||
-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0,
|
||||
]);
|
||||
|
||||
const QUAD_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3]);
|
||||
@@ -0,0 +1,218 @@
|
||||
import { FileLoader, Loader, type LoadingManager } from "three";
|
||||
import { PackedSplats } from "./PackedSplats";
|
||||
import { SplatMesh } from "./SplatMesh";
|
||||
import { PlyReader } from "./ply";
|
||||
import { withWorker } from "./splatWorker";
|
||||
import { decompressPartialGzip, getTextureSize } from "./utils";
|
||||
|
||||
// SplatLoader implements the THREE.Loader interface and supports loading a variety
|
||||
// of differeng Gsplat file formats. Formats .PLY and .SPZ can be auto-detected
|
||||
// from the file contents, while .SPLAT and .KSPLAT require either having the
|
||||
// appropriate file extension as part of the path, or it can be explicitly set
|
||||
// in the loader using the fileType property.
|
||||
|
||||
export class SplatLoader extends Loader {
|
||||
fileLoader: FileLoader;
|
||||
fileType?: SplatFileType;
|
||||
|
||||
constructor(manager?: LoadingManager) {
|
||||
super(manager);
|
||||
this.fileLoader = new FileLoader(manager);
|
||||
}
|
||||
|
||||
load(
|
||||
url: string,
|
||||
onLoad?: (decoded: PackedSplats) => void,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
onError?: (error: unknown) => void,
|
||||
) {
|
||||
this.fileLoader.setResponseType("arraybuffer");
|
||||
this.fileLoader.setCrossOrigin(this.crossOrigin);
|
||||
this.fileLoader.setWithCredentials(this.withCredentials);
|
||||
this.fileLoader.setPath(this.path);
|
||||
this.fileLoader.setResourcePath(this.resourcePath);
|
||||
this.fileLoader.setRequestHeader(this.requestHeader);
|
||||
this.fileLoader.load(
|
||||
url,
|
||||
async (response) => {
|
||||
if (onLoad) {
|
||||
const input = response as ArrayBuffer;
|
||||
const decoded = await unpackSplats({
|
||||
input,
|
||||
fileType: this.fileType,
|
||||
pathOrUrl: url,
|
||||
});
|
||||
onLoad(new PackedSplats(decoded));
|
||||
}
|
||||
},
|
||||
onProgress,
|
||||
onError,
|
||||
);
|
||||
}
|
||||
|
||||
async loadAsync(
|
||||
url: string,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
): Promise<PackedSplats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.load(
|
||||
url,
|
||||
(decoded) => {
|
||||
resolve(decoded);
|
||||
},
|
||||
onProgress,
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
parse(packedSplats: PackedSplats): SplatMesh {
|
||||
return new SplatMesh({ packedSplats });
|
||||
}
|
||||
}
|
||||
|
||||
export enum SplatFileType {
|
||||
PLY = "ply",
|
||||
WLG0 = "wlg0",
|
||||
SPZ = "spz",
|
||||
SPLAT = "splat",
|
||||
KSPLAT = "ksplat",
|
||||
}
|
||||
|
||||
export function getSplatFileType(
|
||||
fileBytes: Uint8Array,
|
||||
): SplatFileType | undefined {
|
||||
const view = new DataView(fileBytes.buffer);
|
||||
if ((view.getUint32(0, true) & 0x00ffffff) === 0x00796c70) {
|
||||
return SplatFileType.PLY;
|
||||
}
|
||||
if (view.getUint32(0, true) === 0x30474c57) {
|
||||
return SplatFileType.WLG0;
|
||||
}
|
||||
if ((view.getUint32(0, true) & 0x00ffffff) === 0x00088b1f) {
|
||||
// Gzipped file, unpack beginning to check magic number
|
||||
const header = decompressPartialGzip(fileBytes, 4);
|
||||
const gView = new DataView(header.buffer);
|
||||
if (gView.getUint32(0, true) === 0x5053474e) {
|
||||
return SplatFileType.SPZ;
|
||||
}
|
||||
// Unknown Gzipped file type
|
||||
return undefined;
|
||||
}
|
||||
// Unknown file type
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Returns the lowercased file extension from a path or URL
|
||||
export function getFileExtension(pathOrUrl: string): string {
|
||||
const noTrailing = pathOrUrl.split(/[?#]/, 1)[0];
|
||||
const lastSlash = Math.max(
|
||||
noTrailing.lastIndexOf("/"),
|
||||
noTrailing.lastIndexOf("\\"),
|
||||
);
|
||||
const filename = noTrailing.slice(lastSlash + 1);
|
||||
const lastDot = filename.lastIndexOf(".");
|
||||
if (lastDot <= 0 || lastDot === filename.length - 1) {
|
||||
return ""; // No extension
|
||||
}
|
||||
return filename.slice(lastDot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export async function unpackSplats({
|
||||
input,
|
||||
fileType,
|
||||
pathOrUrl,
|
||||
}: {
|
||||
input: Uint8Array | ArrayBuffer;
|
||||
fileType?: SplatFileType;
|
||||
pathOrUrl?: string;
|
||||
}): Promise<{
|
||||
packedArray: Uint32Array;
|
||||
numSplats: number;
|
||||
extra?: Record<string, unknown>;
|
||||
}> {
|
||||
const fileBytes =
|
||||
input instanceof ArrayBuffer ? new Uint8Array(input) : input;
|
||||
let splatFileType = fileType;
|
||||
if (!fileType) {
|
||||
splatFileType = getSplatFileType(fileBytes);
|
||||
if (!splatFileType && pathOrUrl) {
|
||||
const extension = getFileExtension(pathOrUrl);
|
||||
if (extension === "ply") {
|
||||
splatFileType = SplatFileType.PLY;
|
||||
} else if (extension === "wlg") {
|
||||
splatFileType = SplatFileType.WLG0;
|
||||
} else if (extension === "spz") {
|
||||
splatFileType = SplatFileType.SPZ;
|
||||
} else if (extension === "splat") {
|
||||
splatFileType = SplatFileType.SPLAT;
|
||||
} else if (extension === "ksplat") {
|
||||
splatFileType = SplatFileType.KSPLAT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (splatFileType === SplatFileType.WLG0) {
|
||||
return await withWorker(async (worker) => {
|
||||
const { packedArray, numSplats } = (await worker.call("decodeWlg", {
|
||||
fileBytes,
|
||||
})) as { packedArray: Uint32Array; numSplats: number };
|
||||
return { packedArray, numSplats };
|
||||
});
|
||||
}
|
||||
if (splatFileType === SplatFileType.PLY) {
|
||||
const ply = new PlyReader({ fileBytes });
|
||||
await ply.parseHeader();
|
||||
const numSplats = ply.numSplats;
|
||||
const maxSplats = getTextureSize(numSplats).maxSplats;
|
||||
const args = { fileBytes, packedArray: new Uint32Array(maxSplats * 4) };
|
||||
return await withWorker(async (worker) => {
|
||||
const { packedArray, numSplats, extra } = (await worker.call(
|
||||
"unpackPly",
|
||||
args,
|
||||
)) as {
|
||||
packedArray: Uint32Array;
|
||||
numSplats: number;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
return { packedArray, numSplats, extra };
|
||||
});
|
||||
}
|
||||
if (splatFileType === SplatFileType.SPZ) {
|
||||
return await withWorker(async (worker) => {
|
||||
const { packedArray, numSplats, extra } = (await worker.call(
|
||||
"decodeSpz",
|
||||
{
|
||||
fileBytes,
|
||||
},
|
||||
)) as {
|
||||
packedArray: Uint32Array;
|
||||
numSplats: number;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
return { packedArray, numSplats, extra };
|
||||
});
|
||||
}
|
||||
if (splatFileType === SplatFileType.SPLAT) {
|
||||
return await withWorker(async (worker) => {
|
||||
const { packedArray, numSplats } = (await worker.call("decodeAntiSplat", {
|
||||
fileBytes,
|
||||
})) as { packedArray: Uint32Array; numSplats: number };
|
||||
return { packedArray, numSplats };
|
||||
});
|
||||
}
|
||||
if (splatFileType === SplatFileType.KSPLAT) {
|
||||
return await withWorker(async (worker) => {
|
||||
const { packedArray, numSplats, extra } = (await worker.call(
|
||||
"decodeKsplat",
|
||||
{ fileBytes },
|
||||
)) as {
|
||||
packedArray: Uint32Array;
|
||||
numSplats: number;
|
||||
extra: Record<string, unknown>;
|
||||
};
|
||||
return { packedArray, numSplats, extra };
|
||||
});
|
||||
}
|
||||
throw new Error("Unknown splat file type");
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import init_wasm, { raycast_splats } from "forge-internal-rs";
|
||||
import { PackedSplats } from "./PackedSplats";
|
||||
import { type RgbaArray, readRgbaArray } from "./RgbaArray";
|
||||
import { SplatEdit, SplatEditSdf, SplatEdits } from "./SplatEdit";
|
||||
import {
|
||||
type GsplatModifier,
|
||||
SplatGenerator,
|
||||
SplatTransformer,
|
||||
} from "./SplatGenerator";
|
||||
import type { SplatFileType } from "./SplatLoader";
|
||||
import type { SplatSkinning } from "./SplatSkinning";
|
||||
import {
|
||||
DynoFloat,
|
||||
DynoUsampler2DArray,
|
||||
type DynoVal,
|
||||
DynoVec4,
|
||||
Gsplat,
|
||||
add,
|
||||
combineGsplat,
|
||||
defineGsplat,
|
||||
dyno,
|
||||
dynoBlock,
|
||||
dynoConst,
|
||||
extendVec,
|
||||
mul,
|
||||
normalize,
|
||||
readPackedSplat,
|
||||
splitGsplat,
|
||||
sub,
|
||||
unindent,
|
||||
unindentLines,
|
||||
} from "./dyno";
|
||||
import { getTextureSize } from "./utils";
|
||||
|
||||
export type SplatMeshOptions = {
|
||||
// URL to fetch a Gaussian splat file from(supports .ply, .splat, .ksplat,
|
||||
// .spz formats). (default: undefined)
|
||||
url?: string;
|
||||
// Raw bytes of a Gaussian splat file to decode directly instead of fetching
|
||||
// from URL. (default: undefined)
|
||||
fileBytes?: Uint8Array | ArrayBuffer;
|
||||
// Override the file type detection for formats that can't be reliably
|
||||
// auto-detected (.splat, .ksplat). (default: undefined auto-detects other
|
||||
// formats from file contents)
|
||||
fileType?: SplatFileType;
|
||||
// Use an existing PackedSplats object as the source instead of loading from
|
||||
// a file. Can be used to share a collection of Gsplats among multiple SplatMeshes
|
||||
// (default: undefined creates a new empty PackedSplats or decoded from a
|
||||
// data source above)
|
||||
packedSplats?: PackedSplats;
|
||||
// Reserve space for at least this many splats when constructing the mesh
|
||||
// initially. (default: determined by file)
|
||||
maxSplats?: number;
|
||||
// Callback function to programmatically create splats at initialization
|
||||
// in provided PackedSplats. (default: undefined)
|
||||
constructSplats?: (splats: PackedSplats) => Promise<void> | void;
|
||||
// Callback function that is called when mesh initialization is complete.
|
||||
// (default: undefined)
|
||||
onLoad?: (mesh: SplatMesh) => Promise<void> | void;
|
||||
// Controls whether SplatEdits have any effect on this mesh. (default: true)
|
||||
editable?: boolean;
|
||||
// Callback function that is called every frame to update the mesh.
|
||||
// Call mesh.updateVersion() if splats need to be regenerated due to some change.
|
||||
// Calling updateVersion() is not necessary for object transformations, recoloring,
|
||||
// or opacity adjustments as these are auto-detected. (default: undefined)
|
||||
onFrame?: ({
|
||||
mesh,
|
||||
time,
|
||||
deltaTime,
|
||||
}: { mesh: SplatMesh; time: number; deltaTime: number }) => void;
|
||||
// Gsplat modifier to apply in object-space before any transformations.
|
||||
// A GsplatModifier is a dyno shader-graph block that transforms an input
|
||||
// gsplat: DynoVal<Gsplat> to an output gsplat: DynoVal<Gsplat> with gsplat.center
|
||||
// coordinate in object-space. (default: undefined)
|
||||
objectModifier?: GsplatModifier;
|
||||
// Gsplat modifier to apply in world-space after transformations.
|
||||
// (default: undefined)
|
||||
worldModifier?: GsplatModifier;
|
||||
};
|
||||
|
||||
export type SplatMeshContext = {
|
||||
transform: SplatTransformer;
|
||||
viewToWorld: SplatTransformer;
|
||||
worldToView: SplatTransformer;
|
||||
viewToObject: SplatTransformer;
|
||||
recolor: DynoVec4<THREE.Vector4>;
|
||||
time: DynoFloat;
|
||||
deltaTime: DynoFloat;
|
||||
};
|
||||
|
||||
export class SplatMesh extends SplatGenerator {
|
||||
// A Promise<SplatMesh> you can await to ensure fetching, parsing,
|
||||
// and initialization has completed
|
||||
initialized: Promise<SplatMesh>;
|
||||
// A boolean indicating whether initialization is complete
|
||||
isInitialized = false;
|
||||
|
||||
// If you modify packedSplats you should set
|
||||
// splatMesh.packedSplats.needsUpdate = true to signal to Three.js that it
|
||||
// should re-upload the data to the underlying texture. Use this sparingly with
|
||||
// objects with smaller Gsplat counts as it requires a CPU-GPU data transfer for
|
||||
// each frame. Thousands to tens of thousands of Gsplats ir fine. (See hands.ts
|
||||
// for an example of rendering "Gsplat hands" in WebXR using this technique.)
|
||||
packedSplats: PackedSplats;
|
||||
|
||||
// A THREE.Color that can be used to tint all splats in the mesh.
|
||||
// (default: new THREE.Color(1, 1, 1))
|
||||
recolor: THREE.Color = new THREE.Color(1, 1, 1);
|
||||
// Global opacity multiplier for all splats in the mesh. (default: 1)
|
||||
opacity = 1;
|
||||
|
||||
// A SplatMeshContext consisting of useful scene and object dyno uniforms that can
|
||||
// be used to in the Gsplat processing pipeline, for example via objectModifier and
|
||||
// worldModifier. (created on construction)
|
||||
context: SplatMeshContext;
|
||||
onFrame?: ({
|
||||
mesh,
|
||||
time,
|
||||
deltaTime,
|
||||
}: { mesh: SplatMesh; time: number; deltaTime: number }) => void;
|
||||
|
||||
objectModifier?: GsplatModifier;
|
||||
worldModifier?: GsplatModifier;
|
||||
// Set to true to have the viewToObject property in context be updated each frame.
|
||||
// If the mesh has extra.sh1 (first order spherical harmonics directional lighting)
|
||||
// this property will always be updated. (default: false)
|
||||
enableViewToObject = false;
|
||||
// Set to true to have context.viewToWorld updated each frame. (default: false)
|
||||
enableViewToWorld = false;
|
||||
// Set to true to have context.worldToView updated each frame. (default: false)
|
||||
enableWorldToView = false;
|
||||
|
||||
// Optional SplatSkinning instance for animating splats with dual-quaternion
|
||||
// skeletal animation. (default: null)
|
||||
skinning: SplatSkinning | null = null;
|
||||
|
||||
// Optional list of SplatEdits to apply to the mesh. If null, any SplatEdit
|
||||
// children in the scene graph will be added automatically. (default: null)
|
||||
edits: SplatEdit[] | null = null;
|
||||
editable: boolean;
|
||||
// Compiled SplatEdits for applying SDF edits to splat RGBA + centers
|
||||
private rgbaDisplaceEdits: SplatEdits | null = null;
|
||||
// Optional RgbaArray to overwrite splat RGBA values with custom values.
|
||||
// Useful for "baking" RGB and opacity edits into the SplatMesh. (default: null)
|
||||
splatRgba: RgbaArray | null = null;
|
||||
|
||||
// Maximum Spherical Harmonics level to use. Call updateGenerator()
|
||||
// after changing. (default: 3)
|
||||
maxSh = 3;
|
||||
|
||||
constructor(options: SplatMeshOptions = {}) {
|
||||
const transform = new SplatTransformer();
|
||||
const viewToWorld = new SplatTransformer();
|
||||
const worldToView = new SplatTransformer();
|
||||
const viewToObject = new SplatTransformer();
|
||||
const recolor = new DynoVec4({
|
||||
value: new THREE.Vector4(
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
),
|
||||
});
|
||||
const time = new DynoFloat({ value: 0 });
|
||||
const deltaTime = new DynoFloat({ value: 0 });
|
||||
const context = {
|
||||
transform,
|
||||
viewToWorld,
|
||||
worldToView,
|
||||
viewToObject,
|
||||
recolor,
|
||||
time,
|
||||
deltaTime,
|
||||
};
|
||||
|
||||
super({
|
||||
update: ({ time, deltaTime, viewToWorld, globalEdits }) =>
|
||||
this.update({ time, deltaTime, viewToWorld, globalEdits }),
|
||||
});
|
||||
|
||||
this.packedSplats = options.packedSplats ?? new PackedSplats();
|
||||
this.numSplats = this.packedSplats.numSplats;
|
||||
this.editable = options.editable ?? true;
|
||||
this.onFrame = options.onFrame;
|
||||
|
||||
this.context = context;
|
||||
this.objectModifier = options.objectModifier;
|
||||
this.worldModifier = options.worldModifier;
|
||||
|
||||
this.updateGenerator();
|
||||
|
||||
if (
|
||||
options.url ||
|
||||
options.fileBytes ||
|
||||
options.constructSplats ||
|
||||
(options.packedSplats && !options.packedSplats.isInitialized)
|
||||
) {
|
||||
// We need to initialize asynchronously given the options
|
||||
this.initialized = this.asyncInitialize(options).then(async () => {
|
||||
this.updateGenerator();
|
||||
|
||||
this.isInitialized = true;
|
||||
if (options.onLoad) {
|
||||
const maybePromise = options.onLoad(this);
|
||||
if (maybePromise instanceof Promise) {
|
||||
await maybePromise;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
});
|
||||
} else {
|
||||
this.isInitialized = true;
|
||||
this.initialized = Promise.resolve(this);
|
||||
if (options.onLoad) {
|
||||
const maybePromise = options.onLoad(this);
|
||||
// If onLoad returns a promise, wait for it to complete
|
||||
if (maybePromise instanceof Promise) {
|
||||
this.initialized = maybePromise.then(() => this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async asyncInitialize(options: SplatMeshOptions) {
|
||||
const { url, fileBytes, fileType, maxSplats, constructSplats } = options;
|
||||
if (url || fileBytes || constructSplats) {
|
||||
const packedSplatsOptions = {
|
||||
url,
|
||||
fileBytes,
|
||||
fileType,
|
||||
maxSplats,
|
||||
construct: constructSplats,
|
||||
};
|
||||
this.packedSplats.reinitialize(packedSplatsOptions);
|
||||
}
|
||||
if (this.packedSplats) {
|
||||
await this.packedSplats.initialized;
|
||||
this.numSplats = this.packedSplats.numSplats;
|
||||
this.updateGenerator();
|
||||
}
|
||||
}
|
||||
|
||||
static staticInitialized = SplatMesh.staticInitialize();
|
||||
static isStaticInitialized = false;
|
||||
|
||||
static dynoTime = new DynoFloat({ value: 0 });
|
||||
|
||||
static async staticInitialize() {
|
||||
await init_wasm();
|
||||
SplatMesh.isStaticInitialized = true;
|
||||
}
|
||||
|
||||
// Creates a new Gsplat with the provided parameters (all values in "float" space,
|
||||
// i.e. 0-1 for opacity and color) and adds it to the end of the packedSplats,
|
||||
// increasing numSplats by 1. If necessary, reallocates the buffer with an exponential
|
||||
// doubling strategy to fit the new data, so it's fairly efficient to just
|
||||
// pushSplat(...) each Gsplat you want to create in a loop.
|
||||
pushSplat(
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) {
|
||||
this.packedSplats.pushSplat(center, scales, quaternion, opacity, color);
|
||||
}
|
||||
|
||||
// This method iterates over all Gsplats in this instance's packedSplats,
|
||||
// invoking the provided callback with index: number in 0..=(this.numSplats-1) and
|
||||
// center: THREE.Vector3, scales: THREE.Vector3, quaternion: THREE.Quaternion,
|
||||
// opacity: number (0..1), and color: THREE.Color (rgb values in 0..1).
|
||||
// Note that the objects passed in as center etc. are the same for every callback
|
||||
// invocation: these objects are reused for efficiency. Changing these values has
|
||||
// no effect as they are decoded/unpacked copies of the underlying data. To update
|
||||
// the packedSplats, call .packedSplats.setSplat(index, center, scales,
|
||||
// quaternion, opacity, color).
|
||||
forEachSplat(
|
||||
callback: (
|
||||
index: number,
|
||||
center: THREE.Vector3,
|
||||
scales: THREE.Vector3,
|
||||
quaternion: THREE.Quaternion,
|
||||
opacity: number,
|
||||
color: THREE.Color,
|
||||
) => void,
|
||||
) {
|
||||
this.packedSplats.forEachSplat(callback);
|
||||
}
|
||||
|
||||
// Call this when you are finished with the SplatMesh and want to free
|
||||
// any buffers it holds (via packedSplats).
|
||||
dispose() {
|
||||
this.packedSplats.dispose();
|
||||
}
|
||||
|
||||
constructGenerator({ transform, viewToObject, recolor }: SplatMeshContext) {
|
||||
const generator = dynoBlock(
|
||||
{ index: "int" },
|
||||
{ gsplat: Gsplat },
|
||||
({ index }) => {
|
||||
if (!index) {
|
||||
throw new Error("index is undefined");
|
||||
}
|
||||
// Read a Gsplat from the PackedSplats template
|
||||
let gsplat = readPackedSplat(this.packedSplats.dyno, index);
|
||||
|
||||
if (this.maxSh >= 1) {
|
||||
// Inject lighting from SH1..SH3
|
||||
const { sh1Texture, sh2Texture, sh3Texture } =
|
||||
this.ensureShTextures();
|
||||
if (sh1Texture) {
|
||||
//Calculate view direction in object space
|
||||
const viewCenterInObject = viewToObject.translate;
|
||||
const { center } = splitGsplat(gsplat).outputs;
|
||||
const viewDir = normalize(sub(center, viewCenterInObject));
|
||||
|
||||
// Evaluate Spherical Harmonics
|
||||
let rgb = evaluateSH1(gsplat, sh1Texture, viewDir);
|
||||
if (this.maxSh >= 2 && sh2Texture) {
|
||||
rgb = add(rgb, evaluateSH2(gsplat, sh2Texture, viewDir));
|
||||
}
|
||||
if (this.maxSh >= 3 && sh3Texture) {
|
||||
rgb = add(rgb, evaluateSH3(gsplat, sh3Texture, viewDir));
|
||||
}
|
||||
|
||||
// Flash off for 0.3 / 1.0 sec for debugging
|
||||
// const fractTime = fract(SplatMesh.dynoTime);
|
||||
// const lessThan05 = lessThan(fractTime, dynoConst("float", 0.3));
|
||||
// rgb = select(lessThan05, dynoConst("vec3", new THREE.Vector3()), rgb);
|
||||
|
||||
// Add SH lighting to RGBA
|
||||
let { rgba } = splitGsplat(gsplat).outputs;
|
||||
rgba = add(rgba, extendVec(rgb, dynoConst("float", 0.0)));
|
||||
gsplat = combineGsplat({ gsplat, rgba });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.splatRgba) {
|
||||
// Overwrite RGBA with baked RGBA values
|
||||
const rgba = readRgbaArray(this.splatRgba.dyno, index);
|
||||
gsplat = combineGsplat({ gsplat, rgba });
|
||||
}
|
||||
|
||||
if (this.skinning) {
|
||||
// Transform according to bones + skinning weights
|
||||
gsplat = this.skinning.modify(gsplat);
|
||||
}
|
||||
|
||||
if (this.objectModifier) {
|
||||
// Inject object-space Gsplat modifier dyno
|
||||
gsplat = this.objectModifier.apply({ gsplat }).gsplat;
|
||||
}
|
||||
|
||||
// Transform from object to world-space
|
||||
gsplat = transform.modify(gsplat);
|
||||
|
||||
// Apply any global recoloring and opacity
|
||||
const recolorRgba = mul(recolor, splitGsplat(gsplat).outputs.rgba);
|
||||
gsplat = combineGsplat({ gsplat, rgba: recolorRgba });
|
||||
|
||||
if (this.rgbaDisplaceEdits) {
|
||||
// Apply RGBA edit layer SDFs
|
||||
gsplat = this.rgbaDisplaceEdits.modify(gsplat);
|
||||
}
|
||||
if (this.worldModifier) {
|
||||
// Inject world-space Gsplat modifier dyno
|
||||
gsplat = this.worldModifier.apply({ gsplat }).gsplat;
|
||||
}
|
||||
|
||||
// We're done! Output resulting Gsplat
|
||||
return { gsplat };
|
||||
},
|
||||
);
|
||||
this.generator = generator;
|
||||
}
|
||||
|
||||
// Call this whenever something changes in the Gsplat processing pipeline,
|
||||
// for example changing maxSh or updating objectModifier or worldModifier.
|
||||
// Compiled generators are cached for efficiency and re-use when the same
|
||||
// pipeline structure emerges after successive changes.
|
||||
updateGenerator() {
|
||||
this.constructGenerator(this.context);
|
||||
}
|
||||
|
||||
// This is called automatically by ForgeRenderer and you should not have to
|
||||
// call it. It updates parameters for the generated pipeline and calls
|
||||
// updateGenerator() if the pipeline needs to change.
|
||||
update({
|
||||
time,
|
||||
viewToWorld,
|
||||
deltaTime,
|
||||
globalEdits,
|
||||
}: {
|
||||
time: number;
|
||||
viewToWorld: THREE.Matrix4;
|
||||
deltaTime: number;
|
||||
globalEdits: SplatEdit[];
|
||||
}) {
|
||||
this.numSplats = this.packedSplats.numSplats;
|
||||
this.context.time.value = time;
|
||||
this.context.deltaTime.value = deltaTime;
|
||||
SplatMesh.dynoTime.value = time;
|
||||
|
||||
const { transform, viewToObject, recolor } = this.context;
|
||||
let updated = transform.update(this);
|
||||
|
||||
if (
|
||||
this.context.viewToWorld.updateFromMatrix(viewToWorld) &&
|
||||
this.enableViewToWorld
|
||||
) {
|
||||
updated = true;
|
||||
}
|
||||
const worldToView = viewToWorld.clone().invert();
|
||||
if (
|
||||
this.context.worldToView.updateFromMatrix(worldToView) &&
|
||||
this.enableWorldToView
|
||||
) {
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const objectToWorld = new THREE.Matrix4().compose(
|
||||
transform.translate.value,
|
||||
transform.rotate.value,
|
||||
new THREE.Vector3().setScalar(transform.scale.value),
|
||||
);
|
||||
const worldToObject = objectToWorld.invert();
|
||||
const viewToObjectMatrix = worldToObject.multiply(viewToWorld);
|
||||
if (
|
||||
viewToObject.updateFromMatrix(viewToObjectMatrix) &&
|
||||
(this.enableViewToObject || this.packedSplats.extra.sh1)
|
||||
) {
|
||||
// Only trigger update if we have view-dependent spherical harmonics
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const newRecolor = new THREE.Vector4(
|
||||
this.recolor.r,
|
||||
this.recolor.g,
|
||||
this.recolor.b,
|
||||
this.opacity,
|
||||
);
|
||||
if (!newRecolor.equals(recolor.value)) {
|
||||
recolor.value.copy(newRecolor);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const edits = this.editable ? (this.edits ?? []).concat(globalEdits) : [];
|
||||
if (this.editable && !this.edits) {
|
||||
// If we haven't set any explicit edits, add any child SplatEdits
|
||||
this.traverseVisible((node) => {
|
||||
if (node instanceof SplatEdit) {
|
||||
edits.push(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
edits.sort((a, b) => a.ordering - b.ordering);
|
||||
const editsSdfs = edits.map((edit) => {
|
||||
if (edit.sdfs != null) {
|
||||
return { edit, sdfs: edit.sdfs };
|
||||
}
|
||||
const sdfs: SplatEditSdf[] = [];
|
||||
edit.traverseVisible((node) => {
|
||||
if (node instanceof SplatEditSdf) {
|
||||
sdfs.push(node);
|
||||
}
|
||||
});
|
||||
return { edit, sdfs };
|
||||
});
|
||||
|
||||
if (editsSdfs.length > 0 && !this.rgbaDisplaceEdits) {
|
||||
const edits = editsSdfs.length;
|
||||
const sdfs = editsSdfs.reduce(
|
||||
(total, edit) => total + edit.sdfs.length,
|
||||
0,
|
||||
);
|
||||
this.rgbaDisplaceEdits = new SplatEdits({
|
||||
maxEdits: edits,
|
||||
maxSdfs: sdfs,
|
||||
});
|
||||
this.updateGenerator();
|
||||
}
|
||||
if (this.rgbaDisplaceEdits) {
|
||||
const editResult = this.rgbaDisplaceEdits.update(editsSdfs);
|
||||
updated ||= editResult.updated;
|
||||
if (editResult.dynoUpdated) {
|
||||
this.updateGenerator();
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
this.updateVersion();
|
||||
}
|
||||
|
||||
this.onFrame?.({ mesh: this, time, deltaTime });
|
||||
}
|
||||
|
||||
// This method conforms to the standard THREE.Raycaster API, performing object-ray
|
||||
// intersections using this method to populate the provided intersects[] array
|
||||
// with each intersection point.
|
||||
raycast(
|
||||
raycaster: THREE.Raycaster,
|
||||
intersects: {
|
||||
distance: number;
|
||||
point: THREE.Vector3;
|
||||
object: THREE.Object3D;
|
||||
}[],
|
||||
) {
|
||||
if (!this.packedSplats.packedArray || !this.packedSplats.numSplats) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { near, far, ray } = raycaster;
|
||||
const worldToMesh = this.matrixWorld.clone().invert();
|
||||
const worldToMeshRot = new THREE.Matrix3().setFromMatrix4(worldToMesh);
|
||||
const origin = ray.origin.clone().applyMatrix4(worldToMesh);
|
||||
const direction = ray.direction.clone().applyMatrix3(worldToMeshRot);
|
||||
const scales = new THREE.Vector3();
|
||||
worldToMesh.decompose(new THREE.Vector3(), new THREE.Quaternion(), scales);
|
||||
const scale = (scales.x * scales.y * scales.z) ** (1.0 / 3.0);
|
||||
|
||||
const RAYCAST_ELLIPSOID = true;
|
||||
const distances = raycast_splats(
|
||||
origin.x,
|
||||
origin.y,
|
||||
origin.z,
|
||||
direction.x,
|
||||
direction.y,
|
||||
direction.z,
|
||||
near,
|
||||
far,
|
||||
this.packedSplats.numSplats,
|
||||
this.packedSplats.packedArray,
|
||||
RAYCAST_ELLIPSOID,
|
||||
);
|
||||
|
||||
for (const distance of distances) {
|
||||
const point = ray.direction
|
||||
.clone()
|
||||
.multiplyScalar(distance)
|
||||
.add(ray.origin);
|
||||
intersects.push({
|
||||
distance,
|
||||
point,
|
||||
object: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private ensureShTextures(): {
|
||||
sh1Texture?: DynoUsampler2DArray<"sh1", THREE.DataArrayTexture>;
|
||||
sh2Texture?: DynoUsampler2DArray<"sh2", THREE.DataArrayTexture>;
|
||||
sh3Texture?: DynoUsampler2DArray<"sh3", THREE.DataArrayTexture>;
|
||||
} {
|
||||
// Ensure we have textures for SH1..SH3 if we have data
|
||||
if (!this.packedSplats.extra.sh1) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let sh1Texture = this.packedSplats.extra.sh1Texture as
|
||||
| DynoUsampler2DArray<"sh1", THREE.DataArrayTexture>
|
||||
| undefined;
|
||||
if (!sh1Texture) {
|
||||
let sh1 = this.packedSplats.extra.sh1 as Uint32Array;
|
||||
const { width, height, depth, maxSplats } = getTextureSize(
|
||||
sh1.length / 2,
|
||||
);
|
||||
if (sh1.length < maxSplats * 2) {
|
||||
const newSh1 = new Uint32Array(maxSplats * 2);
|
||||
newSh1.set(sh1);
|
||||
this.packedSplats.extra.sh1 = newSh1;
|
||||
sh1 = newSh1;
|
||||
}
|
||||
|
||||
const texture = new THREE.DataArrayTexture(sh1, width, height, depth);
|
||||
texture.format = THREE.RGIntegerFormat;
|
||||
texture.type = THREE.UnsignedIntType;
|
||||
texture.internalFormat = "RG32UI";
|
||||
texture.needsUpdate = true;
|
||||
|
||||
sh1Texture = new DynoUsampler2DArray({
|
||||
value: texture,
|
||||
key: "sh1",
|
||||
});
|
||||
this.packedSplats.extra.sh1Texture = sh1Texture;
|
||||
}
|
||||
|
||||
if (!this.packedSplats.extra.sh2) {
|
||||
return { sh1Texture };
|
||||
}
|
||||
|
||||
let sh2Texture = this.packedSplats.extra.sh2Texture as
|
||||
| DynoUsampler2DArray<"sh2", THREE.DataArrayTexture>
|
||||
| undefined;
|
||||
if (!sh2Texture) {
|
||||
let sh2 = this.packedSplats.extra.sh2 as Uint32Array;
|
||||
const { width, height, depth, maxSplats } = getTextureSize(
|
||||
sh2.length / 4,
|
||||
);
|
||||
if (sh2.length < maxSplats * 4) {
|
||||
const newSh2 = new Uint32Array(maxSplats * 4);
|
||||
newSh2.set(sh2);
|
||||
this.packedSplats.extra.sh2 = newSh2;
|
||||
sh2 = newSh2;
|
||||
}
|
||||
|
||||
const texture = new THREE.DataArrayTexture(sh2, width, height, depth);
|
||||
texture.format = THREE.RGBAIntegerFormat;
|
||||
texture.type = THREE.UnsignedIntType;
|
||||
texture.internalFormat = "RGBA32UI";
|
||||
texture.needsUpdate = true;
|
||||
|
||||
sh2Texture = new DynoUsampler2DArray({
|
||||
value: texture,
|
||||
key: "sh2",
|
||||
});
|
||||
this.packedSplats.extra.sh2Texture = sh2Texture;
|
||||
}
|
||||
|
||||
if (!this.packedSplats.extra.sh3) {
|
||||
return { sh1Texture, sh2Texture };
|
||||
}
|
||||
|
||||
let sh3Texture = this.packedSplats.extra.sh3Texture as
|
||||
| DynoUsampler2DArray<"sh3", THREE.DataArrayTexture>
|
||||
| undefined;
|
||||
if (!sh3Texture) {
|
||||
let sh3 = this.packedSplats.extra.sh3 as Uint32Array;
|
||||
const { width, height, depth, maxSplats } = getTextureSize(
|
||||
sh3.length / 4,
|
||||
);
|
||||
if (sh3.length < maxSplats * 4) {
|
||||
const newSh3 = new Uint32Array(maxSplats * 4);
|
||||
newSh3.set(sh3);
|
||||
this.packedSplats.extra.sh3 = newSh3;
|
||||
sh3 = newSh3;
|
||||
}
|
||||
|
||||
const texture = new THREE.DataArrayTexture(sh3, width, height, depth);
|
||||
texture.format = THREE.RGBAIntegerFormat;
|
||||
texture.type = THREE.UnsignedIntType;
|
||||
texture.internalFormat = "RGBA32UI";
|
||||
texture.needsUpdate = true;
|
||||
|
||||
sh3Texture = new DynoUsampler2DArray({
|
||||
value: texture,
|
||||
key: "sh3",
|
||||
});
|
||||
this.packedSplats.extra.sh3Texture = sh3Texture;
|
||||
}
|
||||
|
||||
return { sh1Texture, sh2Texture, sh3Texture };
|
||||
}
|
||||
}
|
||||
|
||||
const defineEvaluateSH1 = unindent(`
|
||||
vec3 evaluateSH1(Gsplat gsplat, usampler2DArray sh1, vec3 viewDir) {
|
||||
// Extract sint7 values packed into 2 x uint32
|
||||
uvec2 packed = texelFetch(sh1, splatTexCoord(gsplat.index), 0).rg;
|
||||
vec3 sh1_0 = vec3(ivec3(
|
||||
int(packed.x << 25u) >> 25,
|
||||
int(packed.x << 18u) >> 25,
|
||||
int(packed.x << 11u) >> 25
|
||||
)) / 63.0;
|
||||
vec3 sh1_1 = vec3(ivec3(
|
||||
int(packed.x << 4u) >> 25,
|
||||
int((packed.x >> 3u) | (packed.y << 29u)) >> 25,
|
||||
int(packed.y << 22u) >> 25
|
||||
)) / 63.0;
|
||||
vec3 sh1_2 = vec3(ivec3(
|
||||
int(packed.y << 15u) >> 25,
|
||||
int(packed.y << 8u) >> 25,
|
||||
int(packed.y << 1u) >> 25
|
||||
)) / 63.0;
|
||||
|
||||
return sh1_0 * (-0.4886025 * viewDir.y)
|
||||
+ sh1_1 * (0.4886025 * viewDir.z)
|
||||
+ sh1_2 * (-0.4886025 * viewDir.x);
|
||||
}
|
||||
`);
|
||||
|
||||
const defineEvaluateSH2 = unindent(`
|
||||
vec3 evaluateSH2(Gsplat gsplat, usampler2DArray sh2, vec3 viewDir) {
|
||||
// Extract sint8 values packed into 4 x uint32
|
||||
uvec4 packed = texelFetch(sh2, splatTexCoord(gsplat.index), 0);
|
||||
vec3 sh2_0 = vec3(ivec3(
|
||||
int(packed.x << 24u) >> 24,
|
||||
int(packed.x << 16u) >> 24,
|
||||
int(packed.x << 8u) >> 24
|
||||
)) / 127.0;
|
||||
vec3 sh2_1 = vec3(ivec3(
|
||||
int(packed.x) >> 24,
|
||||
int(packed.y << 24u) >> 24,
|
||||
int(packed.y << 16u) >> 24
|
||||
)) / 127.0;
|
||||
vec3 sh2_2 = vec3(ivec3(
|
||||
int(packed.y << 8u) >> 24,
|
||||
int(packed.y) >> 24,
|
||||
int(packed.z << 24u) >> 24
|
||||
)) / 127.0;
|
||||
vec3 sh2_3 = vec3(ivec3(
|
||||
int(packed.z << 16u) >> 24,
|
||||
int(packed.z << 8u) >> 24,
|
||||
int(packed.z) >> 24
|
||||
)) / 127.0;
|
||||
vec3 sh2_4 = vec3(ivec3(
|
||||
int(packed.w << 24u) >> 24,
|
||||
int(packed.w << 16u) >> 24,
|
||||
int(packed.w << 8u) >> 24
|
||||
)) / 127.0;
|
||||
|
||||
return sh2_0 * (1.0925484 * viewDir.x * viewDir.y)
|
||||
+ sh2_1 * (1.0925484 * viewDir.y * viewDir.z)
|
||||
+ sh2_2 * (0.3153915 * (2.0 * viewDir.z * viewDir.z - viewDir.x * viewDir.x - viewDir.y * viewDir.y))
|
||||
+ sh2_3 * (1.0925484 * viewDir.x * viewDir.z)
|
||||
+ sh2_4 * (0.5462742 * (viewDir.x * viewDir.x - viewDir.y * viewDir.y));
|
||||
}
|
||||
`);
|
||||
|
||||
const defineEvaluateSH3 = unindent(`
|
||||
vec3 evaluateSH3(Gsplat gsplat, usampler2DArray sh3, vec3 viewDir) {
|
||||
// Extract sint6 values packed into 4 x uint32
|
||||
uvec4 packed = texelFetch(sh3, splatTexCoord(gsplat.index), 0);
|
||||
vec3 sh3_0 = vec3(ivec3(
|
||||
int(packed.x << 26u) >> 26,
|
||||
int(packed.x << 20u) >> 26,
|
||||
int(packed.x << 14u) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_1 = vec3(ivec3(
|
||||
int(packed.x << 8u) >> 26,
|
||||
int(packed.x << 2u) >> 26,
|
||||
int((packed.x >> 4u) | (packed.y << 28u)) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_2 = vec3(ivec3(
|
||||
int(packed.y << 22u) >> 26,
|
||||
int(packed.y << 16u) >> 26,
|
||||
int(packed.y << 10u) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_3 = vec3(ivec3(
|
||||
int(packed.y << 4u) >> 26,
|
||||
int((packed.y >> 2u) | (packed.z << 30u)) >> 26,
|
||||
int(packed.z << 24u) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_4 = vec3(ivec3(
|
||||
int(packed.z << 18u) >> 26,
|
||||
int(packed.z << 12u) >> 26,
|
||||
int(packed.z << 6u) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_5 = vec3(ivec3(
|
||||
int(packed.z) >> 26,
|
||||
int(packed.w << 26u) >> 26,
|
||||
int(packed.w << 20u) >> 26
|
||||
)) / 31.0;
|
||||
vec3 sh3_6 = vec3(ivec3(
|
||||
int(packed.w << 14u) >> 26,
|
||||
int(packed.w << 8u) >> 26,
|
||||
int(packed.w << 2u) >> 26
|
||||
)) / 31.0;
|
||||
|
||||
float xx = viewDir.x * viewDir.x;
|
||||
float yy = viewDir.y * viewDir.y;
|
||||
float zz = viewDir.z * viewDir.z;
|
||||
float xy = viewDir.x * viewDir.y;
|
||||
float yz = viewDir.y * viewDir.z;
|
||||
float zx = viewDir.z * viewDir.x;
|
||||
|
||||
return sh3_0 * (-0.5900436 * viewDir.y * (3.0 * xx - yy))
|
||||
+ sh3_1 * (2.8906114 * xy * viewDir.z) +
|
||||
+ sh3_2 * (-0.4570458 * viewDir.y * (4.0 * zz - xx - yy))
|
||||
+ sh3_3 * (0.3731763 * viewDir.z * (2.0 * zz - 3.0 * xx - 3.0 * yy))
|
||||
+ sh3_4 * (-0.4570458 * viewDir.x * (4.0 * zz - xx - yy))
|
||||
+ sh3_5 * (1.4453057 * viewDir.z * (xx - yy))
|
||||
+ sh3_6 * (-0.5900436 * viewDir.x * (xx - 3.0 * yy));
|
||||
}
|
||||
`);
|
||||
|
||||
export function evaluateSH1(
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
sh1: DynoUsampler2DArray<"sh1", THREE.DataArrayTexture>,
|
||||
viewDir: DynoVal<"vec3">,
|
||||
): DynoVal<"vec3"> {
|
||||
return dyno({
|
||||
inTypes: { gsplat: Gsplat, sh1: "usampler2DArray", viewDir: "vec3" },
|
||||
outTypes: { rgb: "vec3" },
|
||||
inputs: { gsplat, sh1, viewDir },
|
||||
globals: () => [defineGsplat, defineEvaluateSH1],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const statements = unindentLines(`
|
||||
if (isGsplatActive(${inputs.gsplat}.flags)) {
|
||||
${outputs.rgb} = evaluateSH1(${inputs.gsplat}, ${inputs.sh1}, ${inputs.viewDir});
|
||||
} else {
|
||||
${outputs.rgb} = vec3(0.0);
|
||||
}
|
||||
`);
|
||||
return statements;
|
||||
},
|
||||
}).outputs.rgb;
|
||||
}
|
||||
|
||||
export function evaluateSH2(
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
sh2: DynoVal<"usampler2DArray">,
|
||||
viewDir: DynoVal<"vec3">,
|
||||
): DynoVal<"vec3"> {
|
||||
return dyno({
|
||||
inTypes: { gsplat: Gsplat, sh2: "usampler2DArray", viewDir: "vec3" },
|
||||
outTypes: { rgb: "vec3" },
|
||||
inputs: { gsplat, sh2, viewDir },
|
||||
globals: () => [defineGsplat, defineEvaluateSH2],
|
||||
statements: ({ inputs, outputs }) =>
|
||||
unindentLines(`
|
||||
if (isGsplatActive(${inputs.gsplat}.flags)) {
|
||||
${outputs.rgb} = evaluateSH2(${inputs.gsplat}, ${inputs.sh2}, ${inputs.viewDir});
|
||||
} else {
|
||||
${outputs.rgb} = vec3(0.0);
|
||||
}
|
||||
`),
|
||||
}).outputs.rgb;
|
||||
}
|
||||
|
||||
export function evaluateSH3(
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
sh3: DynoVal<"usampler2DArray">,
|
||||
viewDir: DynoVal<"vec3">,
|
||||
): DynoVal<"vec3"> {
|
||||
return dyno({
|
||||
inTypes: { gsplat: Gsplat, sh3: "usampler2DArray", viewDir: "vec3" },
|
||||
outTypes: { rgb: "vec3" },
|
||||
inputs: { gsplat, sh3, viewDir },
|
||||
globals: () => [defineGsplat, defineEvaluateSH3],
|
||||
statements: ({ inputs, outputs }) =>
|
||||
unindentLines(`
|
||||
if (isGsplatActive(${inputs.gsplat}.flags)) {
|
||||
${outputs.rgb} = evaluateSH3(${inputs.gsplat}, ${inputs.sh3}, ${inputs.viewDir});
|
||||
} else {
|
||||
${outputs.rgb} = vec3(0.0);
|
||||
}
|
||||
`),
|
||||
}).outputs.rgb;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// SplatSkinning is an experimental class that implements dual-quaternion
|
||||
// skeletal animation for Gsplats. A skeletal animation system consists
|
||||
// of a set of bones, each with a "rest" pose that consists of a position
|
||||
// and orientation, and a weighting of up to 4 bones for each Gsplat.
|
||||
// By moving and rotating the bones you can animate all the Gsplats like
|
||||
// your would for a normal 3D animated mesh.
|
||||
// Note that the dual-quaternion formulation assumes that mass/volume
|
||||
// is conserved through these transformations, which helps avoid common
|
||||
// issues with linear blend skinning such as joint collapse or bulging.
|
||||
// However, it is not as good a fit for animations that involve explicit
|
||||
// deformations, such as cartoon animations.
|
||||
|
||||
import type { SplatMesh } from "./SplatMesh";
|
||||
import {
|
||||
Dyno,
|
||||
DynoUniform,
|
||||
type DynoVal,
|
||||
Gsplat,
|
||||
unindent,
|
||||
unindentLines,
|
||||
} from "./dyno";
|
||||
import { getTextureSize } from "./utils";
|
||||
|
||||
export type SplatSkinningOptions = {
|
||||
// Specifies the SplatMesh that will be animated.
|
||||
mesh: SplatMesh;
|
||||
// Overrides the number of Gsplats in the mesh that will be animated.
|
||||
// (default: mesh.numSplats)
|
||||
numSplats?: number;
|
||||
// Set the number of bones used to animate the SplatMesh, with a maximum
|
||||
// of 256 (in order to compactly encode the bone index). (default: 256)
|
||||
numBones?: number;
|
||||
};
|
||||
|
||||
export class SplatSkinning {
|
||||
mesh: SplatMesh;
|
||||
numSplats: number;
|
||||
|
||||
// Store the skinning weights for each Gsplat, composed of a 4-vector
|
||||
// of bone indices and weight
|
||||
skinData: Uint16Array;
|
||||
skinTexture: THREE.DataArrayTexture;
|
||||
|
||||
numBones: number;
|
||||
boneData: Float32Array;
|
||||
boneTexture: THREE.DataTexture;
|
||||
|
||||
uniform: DynoUniform<typeof GsplatSkinning, "skinning">;
|
||||
|
||||
constructor(options: SplatSkinningOptions) {
|
||||
this.mesh = options.mesh;
|
||||
this.numSplats = options.numSplats ?? this.mesh.numSplats;
|
||||
|
||||
const { width, height, depth, maxSplats } = getTextureSize(this.numSplats);
|
||||
this.skinData = new Uint16Array(maxSplats * 4);
|
||||
this.skinTexture = new THREE.DataArrayTexture(
|
||||
this.skinData,
|
||||
width,
|
||||
height,
|
||||
depth,
|
||||
);
|
||||
this.skinTexture.format = THREE.RGBAIntegerFormat;
|
||||
this.skinTexture.type = THREE.UnsignedShortType;
|
||||
this.skinTexture.internalFormat = "RGBA16UI";
|
||||
this.skinTexture.needsUpdate = true;
|
||||
|
||||
this.numBones = options.numBones ?? 256;
|
||||
this.boneData = new Float32Array(this.numBones * 16);
|
||||
this.boneTexture = new THREE.DataTexture(
|
||||
this.boneData,
|
||||
4,
|
||||
this.numBones,
|
||||
THREE.RGBAFormat,
|
||||
THREE.FloatType,
|
||||
);
|
||||
this.boneTexture.internalFormat = "RGBA32F";
|
||||
this.boneTexture.needsUpdate = true;
|
||||
|
||||
this.uniform = new DynoUniform({
|
||||
key: "skinning",
|
||||
type: GsplatSkinning,
|
||||
globals: () => [defineGsplatSkinning],
|
||||
value: {
|
||||
numSplats: this.numSplats,
|
||||
numBones: this.numBones,
|
||||
skinTexture: this.skinTexture,
|
||||
boneTexture: this.boneTexture,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply the skeletal animation to a Gsplat in a dyno program.
|
||||
modify(gsplat: DynoVal<typeof Gsplat>): DynoVal<typeof Gsplat> {
|
||||
return applyGsplatSkinning(gsplat, this.uniform);
|
||||
}
|
||||
|
||||
// Set the "rest" pose for a bone with position and quaternion orientation.
|
||||
setRestQuatPos(
|
||||
boneIndex: number,
|
||||
quat: THREE.Quaternion,
|
||||
pos: THREE.Vector3,
|
||||
) {
|
||||
const i16 = boneIndex * 16;
|
||||
this.boneData[i16 + 0] = quat.x;
|
||||
this.boneData[i16 + 1] = quat.y;
|
||||
this.boneData[i16 + 2] = quat.z;
|
||||
this.boneData[i16 + 3] = quat.w;
|
||||
this.boneData[i16 + 4] = pos.x;
|
||||
this.boneData[i16 + 5] = pos.y;
|
||||
this.boneData[i16 + 6] = pos.z;
|
||||
this.boneData[i16 + 7] = 0;
|
||||
this.boneData[i16 + 8] = 0;
|
||||
this.boneData[i16 + 9] = 0;
|
||||
this.boneData[i16 + 10] = 0;
|
||||
this.boneData[i16 + 11] = 1;
|
||||
this.boneData[i16 + 12] = 0;
|
||||
this.boneData[i16 + 13] = 0;
|
||||
this.boneData[i16 + 14] = 0;
|
||||
this.boneData[i16 + 15] = 0;
|
||||
}
|
||||
|
||||
// Set the "current" position and orientation of a bone.
|
||||
setBoneQuatPos(
|
||||
boneIndex: number,
|
||||
quat: THREE.Quaternion,
|
||||
pos: THREE.Vector3,
|
||||
) {
|
||||
const i16 = boneIndex * 16;
|
||||
const origQuat = new THREE.Quaternion(
|
||||
this.boneData[i16 + 0],
|
||||
this.boneData[i16 + 1],
|
||||
this.boneData[i16 + 2],
|
||||
this.boneData[i16 + 3],
|
||||
);
|
||||
const origPos = new THREE.Vector3(
|
||||
this.boneData[i16 + 4],
|
||||
this.boneData[i16 + 5],
|
||||
this.boneData[i16 + 6],
|
||||
);
|
||||
|
||||
const relQuat = origQuat.clone().invert();
|
||||
const relPos = pos.clone().sub(origPos);
|
||||
relPos.applyQuaternion(relQuat);
|
||||
relQuat.multiply(quat);
|
||||
const dual = new THREE.Quaternion(
|
||||
relPos.x,
|
||||
relPos.y,
|
||||
relPos.z,
|
||||
0.0,
|
||||
).multiply(origQuat);
|
||||
|
||||
this.boneData[i16 + 8] = relQuat.x;
|
||||
this.boneData[i16 + 9] = relQuat.y;
|
||||
this.boneData[i16 + 10] = relQuat.z;
|
||||
this.boneData[i16 + 11] = relQuat.w;
|
||||
this.boneData[i16 + 12] = 0.5 * dual.x;
|
||||
this.boneData[i16 + 13] = 0.5 * dual.y;
|
||||
this.boneData[i16 + 14] = 0.5 * dual.z;
|
||||
this.boneData[i16 + 15] = 0.5 * dual.w;
|
||||
}
|
||||
|
||||
// Set up to 4 bone indices and weights for a Gsplat. For fewer than 4 bones,
|
||||
// you can set the remaining weights to 0 (and index=0).
|
||||
setSplatBones(
|
||||
splatIndex: number,
|
||||
boneIndices: THREE.Vector4,
|
||||
weights: THREE.Vector4,
|
||||
) {
|
||||
const i4 = splatIndex * 4;
|
||||
this.skinData[i4 + 0] =
|
||||
Math.min(255, Math.max(0, Math.round(weights.x * 255.0))) +
|
||||
(boneIndices.x << 8);
|
||||
this.skinData[i4 + 1] =
|
||||
Math.min(255, Math.max(0, Math.round(weights.y * 255.0))) +
|
||||
(boneIndices.y << 8);
|
||||
this.skinData[i4 + 2] =
|
||||
Math.min(255, Math.max(0, Math.round(weights.z * 255.0))) +
|
||||
(boneIndices.z << 8);
|
||||
this.skinData[i4 + 3] =
|
||||
Math.min(255, Math.max(0, Math.round(weights.w * 255.0))) +
|
||||
(boneIndices.w << 8);
|
||||
}
|
||||
|
||||
// Call this to indicate that the bones have changed and the Gsplats need to be
|
||||
// re-generated with updated skinning.
|
||||
updateBones() {
|
||||
this.boneTexture.needsUpdate = true;
|
||||
this.mesh.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// dyno program definitions for SplatSkinning
|
||||
|
||||
export const GsplatSkinning = { type: "GsplatSkinning" } as {
|
||||
type: "GsplatSkinning";
|
||||
};
|
||||
|
||||
export const defineGsplatSkinning = unindent(`
|
||||
struct GsplatSkinning {
|
||||
int numSplats;
|
||||
int numBones;
|
||||
usampler2DArray skinTexture;
|
||||
sampler2D boneTexture;
|
||||
};
|
||||
`);
|
||||
|
||||
export const defineApplyGsplatSkinning = unindent(`
|
||||
void applyGsplatSkinning(
|
||||
int numSplats, int numBones,
|
||||
usampler2DArray skinTexture, sampler2D boneTexture,
|
||||
int splatIndex, inout vec3 center, inout vec4 quaternion
|
||||
) {
|
||||
if ((splatIndex < 0) || (splatIndex >= numSplats)) {
|
||||
return;
|
||||
}
|
||||
|
||||
uvec4 skinData = texelFetch(skinTexture, splatTexCoord(splatIndex), 0);
|
||||
|
||||
float weights[4];
|
||||
weights[0] = float(skinData.x & 0xffu) / 255.0;
|
||||
weights[1] = float(skinData.y & 0xffu) / 255.0;
|
||||
weights[2] = float(skinData.z & 0xffu) / 255.0;
|
||||
weights[3] = float(skinData.w & 0xffu) / 255.0;
|
||||
|
||||
uint boneIndices[4];
|
||||
boneIndices[0] = (skinData.x >> 8u) & 0xffu;
|
||||
boneIndices[1] = (skinData.y >> 8u) & 0xffu;
|
||||
boneIndices[2] = (skinData.z >> 8u) & 0xffu;
|
||||
boneIndices[3] = (skinData.w >> 8u) & 0xffu;
|
||||
|
||||
vec4 quat = vec4(0.0);
|
||||
vec4 dual = vec4(0.0);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (weights[i] > 0.0) {
|
||||
int boneIndex = int(boneIndices[i]);
|
||||
vec4 boneQuat = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
vec4 boneDual = vec4(0.0);
|
||||
if (boneIndex < numBones) {
|
||||
boneQuat = texelFetch(boneTexture, ivec2(2, boneIndex), 0);
|
||||
boneDual = texelFetch(boneTexture, ivec2(3, boneIndex), 0);
|
||||
}
|
||||
|
||||
if ((i > 0) && (dot(quat, boneQuat) < 0.0)) {
|
||||
// Flip sign if next blend is pointing in the opposite direction
|
||||
boneQuat = -boneQuat;
|
||||
boneDual = -boneDual;
|
||||
}
|
||||
quat += weights[i] * boneQuat;
|
||||
dual += weights[i] * boneDual;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize dual quaternion
|
||||
float norm = length(quat);
|
||||
quat /= norm;
|
||||
dual /= norm;
|
||||
vec3 translate = vec3(
|
||||
2.0 * (-dual.w * quat.x + dual.x * quat.w - dual.y * quat.z + dual.z * quat.y),
|
||||
2.0 * (-dual.w * quat.y + dual.x * quat.z + dual.y * quat.w - dual.z * quat.x),
|
||||
2.0 * (-dual.w * quat.z - dual.x * quat.y + dual.y * quat.x + dual.z * quat.w)
|
||||
);
|
||||
|
||||
center = quatVec(quat, center) + translate;
|
||||
quaternion = quatQuat(quat, quaternion);
|
||||
}
|
||||
`);
|
||||
|
||||
function applyGsplatSkinning(
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
skinning: DynoVal<typeof GsplatSkinning>,
|
||||
): DynoVal<typeof Gsplat> {
|
||||
const dyno = new Dyno<
|
||||
{ gsplat: typeof Gsplat; skinning: typeof GsplatSkinning },
|
||||
{ gsplat: typeof Gsplat }
|
||||
>({
|
||||
inTypes: { gsplat: Gsplat, skinning: GsplatSkinning },
|
||||
outTypes: { gsplat: Gsplat },
|
||||
globals: () => [defineGsplatSkinning, defineApplyGsplatSkinning],
|
||||
inputs: { gsplat, skinning },
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { skinning } = inputs;
|
||||
const { gsplat } = outputs;
|
||||
return unindentLines(`
|
||||
${gsplat} = ${inputs.gsplat};
|
||||
if (isGsplatActive(${gsplat}.flags)) {
|
||||
applyGsplatSkinning(
|
||||
${skinning}.numSplats, ${skinning}.numBones,
|
||||
${skinning}.skinTexture, ${skinning}.boneTexture,
|
||||
${gsplat}.index, ${gsplat}.center, ${gsplat}.quaternion
|
||||
);
|
||||
}
|
||||
`);
|
||||
},
|
||||
});
|
||||
return dyno.outputs.gsplat;
|
||||
}
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// Forge controls for keyboard + mouse, game pad, or mobile multi-touch
|
||||
|
||||
const DEFAULT_MOVEMENT_SPEED = 1.0;
|
||||
const DEFAULT_ROLL_SPEED = 2.0;
|
||||
const DEFAULT_ROTATE_SPEED = 0.002;
|
||||
const DEFAULT_SLIDE_SPEED = 0.006;
|
||||
const DEFAULT_SCROLL_SPEED = 0.0015;
|
||||
const DEFAULT_ROLL_SPRING = 0.0;
|
||||
const DEFAULT_ROTATE_INERTIA = 0.15;
|
||||
const DEFAULT_MOVE_INERTIA = 0.15;
|
||||
const DEFAULT_STICK_THRESHOLD = 0.1;
|
||||
const DEFAULT_FPS_ROTATE_SPEED = 2.0;
|
||||
|
||||
// Time limit for double-finger press (pinch etc)
|
||||
const DUAL_PRESS_MS = 200;
|
||||
// Time limit for double-click/double-tap
|
||||
const DOUBLE_PRESS_LIMIT_MS = 400;
|
||||
// Distance limit for double-click
|
||||
const DOUBLE_PRESS_DISTANCE = 50;
|
||||
|
||||
// Standard WASD movement keys with R+F for up/down
|
||||
const WASD_KEYCODE_MOVE = {
|
||||
KeyW: new THREE.Vector3(0, 0, -1),
|
||||
KeyS: new THREE.Vector3(0, 0, 1),
|
||||
KeyA: new THREE.Vector3(-1, 0, 0),
|
||||
KeyD: new THREE.Vector3(1, 0, 0),
|
||||
KeyR: new THREE.Vector3(0, 1, 0),
|
||||
KeyF: new THREE.Vector3(0, -1, 0),
|
||||
};
|
||||
|
||||
// Arrow key movement with PageUp/PageDown
|
||||
const ARROW_KEYCODE_MOVE = {
|
||||
ArrowUp: new THREE.Vector3(0, 0, -1),
|
||||
ArrowDown: new THREE.Vector3(0, 0, 1),
|
||||
ArrowLeft: new THREE.Vector3(-1, 0, 0),
|
||||
ArrowRight: new THREE.Vector3(1, 0, 0),
|
||||
PageUp: new THREE.Vector3(0, 1, 0),
|
||||
PageDown: new THREE.Vector3(0, -1, 0),
|
||||
};
|
||||
|
||||
// Rolling with Q/E
|
||||
const QE_KEYCODE_ROTATE = {
|
||||
KeyQ: new THREE.Vector3(0, 0, 1),
|
||||
KeyE: new THREE.Vector3(0, 0, -1),
|
||||
};
|
||||
|
||||
// Home/End/Insert/Delete for rotation
|
||||
const ARROW_KEYCODE_ROTATE = {
|
||||
Home: new THREE.Vector3(0, -1, 0),
|
||||
End: new THREE.Vector3(0, 1, 0),
|
||||
Insert: new THREE.Vector3(-1, 0, 0),
|
||||
Delete: new THREE.Vector3(1, 0, 0),
|
||||
};
|
||||
|
||||
// ForgeControls provides simple, intuitive controls for navigating 3D space that
|
||||
// use the keyboard + mouse, game pad, or mobile multi-touch. Internally it
|
||||
// instantiates and updates a `FpsMovement` and `PointerControls` instance.
|
||||
|
||||
export class ForgeControls {
|
||||
fpsMovement: FpsMovement;
|
||||
pointerControls: PointerControls;
|
||||
lastTime = 0;
|
||||
|
||||
constructor({ canvas }: { canvas: HTMLCanvasElement }) {
|
||||
this.fpsMovement = new FpsMovement({});
|
||||
this.pointerControls = new PointerControls({ canvas });
|
||||
}
|
||||
|
||||
update(control: THREE.Object3D) {
|
||||
const time = performance.now();
|
||||
const deltaTime = (time - (this.lastTime || time)) / 1000;
|
||||
this.lastTime = time;
|
||||
|
||||
this.fpsMovement.update(deltaTime, control);
|
||||
this.pointerControls.update(deltaTime, control);
|
||||
}
|
||||
}
|
||||
|
||||
// FpsMovement implements controls that will be familiar to anyone who plays
|
||||
// First Person Shooters using keyboard + mouse or a gamepad. Creating a FpsMovement
|
||||
// instance provides many parameters for configuring the controls.
|
||||
//
|
||||
// When gamepads are connected, FpsMovement will always use gamepad index 0
|
||||
// for twin-stick movement and rotation.
|
||||
//
|
||||
// If xr is passed in, the WebXR controllers can be used as a split gamepad
|
||||
// to control movement and rotation. (tested on Quest 3)
|
||||
|
||||
export class FpsMovement {
|
||||
moveSpeed: number;
|
||||
rollSpeed: number;
|
||||
stickThreshold: number;
|
||||
rotateSpeed: number;
|
||||
keycodeMoveMapping: { [key: string]: THREE.Vector3 };
|
||||
keycodeRotateMapping: { [key: string]: THREE.Vector3 };
|
||||
gamepadMapping: {
|
||||
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
|
||||
};
|
||||
capsMultiplier: number;
|
||||
shiftMultiplier: number;
|
||||
ctrlMultiplier: number;
|
||||
xr?: THREE.WebXRManager;
|
||||
// Enable/disable controls updates
|
||||
enable = true;
|
||||
|
||||
// Currently active event.key values
|
||||
keydown: { [key: string]: boolean };
|
||||
// Currently active event.code values
|
||||
keycode: { [key: string]: boolean };
|
||||
|
||||
constructor({
|
||||
moveSpeed,
|
||||
rollSpeed,
|
||||
stickThreshold,
|
||||
rotateSpeed,
|
||||
keycodeMoveMapping,
|
||||
keycodeRotateMapping,
|
||||
gamepadMapping,
|
||||
capsMultiplier,
|
||||
shiftMultiplier,
|
||||
ctrlMultiplier,
|
||||
xr,
|
||||
}: {
|
||||
// Base movement speed (default DEFAULT_MOVEMENT_SPEED)
|
||||
moveSpeed?: number;
|
||||
// Base roll speed (default DEFAULT_ROLL_SPEED)
|
||||
rollSpeed?: number;
|
||||
// Stick threshold (default DEFAULT_STICK_THRESHOLD)
|
||||
stickThreshold?: number;
|
||||
// Speed of rotation when using gamepad or keys (default DEFAULT_FPS_ROTATE_SPEED)
|
||||
rotateSpeed?: number;
|
||||
// Maps keyboard keys to movement directions
|
||||
// (default {...WASD_KEYCODE_MOVE, ...ARROW_KEYCODE_MOVE})
|
||||
keycodeMoveMapping?: { [key: string]: THREE.Vector3 };
|
||||
// Maps keyboard keys to rotation directions
|
||||
// (default {...QE_KEYCODE_ROTATE, ...ARROW_KEYCODE_ROTATE})
|
||||
keycodeRotateMapping?: { [key: string]: THREE.Vector3 };
|
||||
// Maps gamepad buttons to control actions
|
||||
// (default {4: "rollLeft", 5: "rollRight", 6: "ctrl", 7: "shift"})
|
||||
gamepadMapping?: {
|
||||
[button: number]: "shift" | "ctrl" | "rollLeft" | "rollRight";
|
||||
};
|
||||
// Speed multiplier when Caps Lock is active (default: 10)
|
||||
capsMultiplier?: number;
|
||||
// Speed multiplier when Shift is active (default: 5)
|
||||
shiftMultiplier?: number;
|
||||
// Speed multiplier when Ctrl is active (default: 1/5)
|
||||
ctrlMultiplier?: number;
|
||||
// Optional WebXR manager for XR controller stick support
|
||||
xr?: THREE.WebXRManager;
|
||||
} = {}) {
|
||||
this.moveSpeed = moveSpeed ?? DEFAULT_MOVEMENT_SPEED;
|
||||
this.rollSpeed = rollSpeed ?? DEFAULT_ROLL_SPEED;
|
||||
this.stickThreshold = stickThreshold ?? DEFAULT_STICK_THRESHOLD;
|
||||
this.rotateSpeed = rotateSpeed ?? DEFAULT_FPS_ROTATE_SPEED;
|
||||
this.keycodeMoveMapping = keycodeMoveMapping ?? {
|
||||
...WASD_KEYCODE_MOVE,
|
||||
...ARROW_KEYCODE_MOVE,
|
||||
};
|
||||
this.keycodeRotateMapping = keycodeRotateMapping ?? {
|
||||
...QE_KEYCODE_ROTATE,
|
||||
...ARROW_KEYCODE_ROTATE,
|
||||
};
|
||||
this.gamepadMapping = gamepadMapping ?? {
|
||||
4: "rollLeft",
|
||||
5: "rollRight",
|
||||
6: "ctrl",
|
||||
7: "shift",
|
||||
};
|
||||
this.capsMultiplier = capsMultiplier ?? 10.0;
|
||||
this.shiftMultiplier = shiftMultiplier ?? 5.0;
|
||||
this.ctrlMultiplier = ctrlMultiplier ?? 1.0 / 5.0;
|
||||
|
||||
this.xr = xr;
|
||||
|
||||
this.keydown = {};
|
||||
this.keycode = {};
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
this.keydown[event.key] = true;
|
||||
this.keycode[event.code] = true;
|
||||
});
|
||||
document.addEventListener("keyup", (event) => {
|
||||
this.keydown[event.key] = false;
|
||||
this.keycode[event.code] = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Call this method in your render loop with `control` set to the object to control
|
||||
// (`THREE.Camera` or a `THREE.Object3D` that contains it), with `deltaTime`
|
||||
// in seconds since the last update.
|
||||
update(deltaTime: number, control: THREE.Object3D) {
|
||||
if (!this.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update gamepad / XR controllers
|
||||
|
||||
const sticks = [new THREE.Vector2(), new THREE.Vector2()];
|
||||
const gamepad = navigator.getGamepads()[0];
|
||||
if (gamepad) {
|
||||
sticks[0].set(gamepad.axes[0], gamepad.axes[1]);
|
||||
sticks[1].set(gamepad.axes[2], gamepad.axes[3]);
|
||||
}
|
||||
const gamepadButtons =
|
||||
gamepad?.buttons.map((button) => button.pressed) || [];
|
||||
|
||||
const xrSources = Array.from(this.xr?.getSession()?.inputSources ?? []);
|
||||
for (const source of xrSources) {
|
||||
const gamepad = source.gamepad;
|
||||
if (gamepad) {
|
||||
switch (source.handedness) {
|
||||
case "none": {
|
||||
sticks[0].x += gamepad.axes[0];
|
||||
sticks[0].y += gamepad.axes[1];
|
||||
sticks[1].x += gamepad.axes[2];
|
||||
sticks[1].y += gamepad.axes[3];
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
sticks[0].x += gamepad.axes[2];
|
||||
sticks[0].y += gamepad.axes[3];
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
sticks[1].x += gamepad.axes[2];
|
||||
sticks[1].y += gamepad.axes[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const stick of sticks) {
|
||||
stick.x = Math.abs(stick.x) >= this.stickThreshold ? stick.x : 0;
|
||||
stick.y = Math.abs(stick.y) >= this.stickThreshold ? stick.y : 0;
|
||||
}
|
||||
|
||||
// Rotation
|
||||
|
||||
const rotate = new THREE.Vector3(
|
||||
sticks[1].x,
|
||||
sticks[1].y,
|
||||
0,
|
||||
).multiplyScalar(this.rotateSpeed);
|
||||
|
||||
for (const [keycode, rot] of Object.entries(this.keycodeRotateMapping)) {
|
||||
if (this.keycode[keycode]) {
|
||||
rotate.add(rot);
|
||||
}
|
||||
}
|
||||
for (const button in this.gamepadMapping) {
|
||||
if (gamepadButtons[Number.parseInt(button)]) {
|
||||
switch (this.gamepadMapping[button]) {
|
||||
case "rollLeft":
|
||||
rotate.z += 1;
|
||||
break;
|
||||
case "rollRight":
|
||||
rotate.z -= 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rotate.multiply(
|
||||
new THREE.Vector3(this.rotateSpeed, this.rotateSpeed, this.rollSpeed),
|
||||
);
|
||||
|
||||
if (rotate.manhattanLength() > 0.0) {
|
||||
rotate.multiplyScalar(deltaTime);
|
||||
const eulers = new THREE.Euler().setFromQuaternion(
|
||||
control.quaternion,
|
||||
"YXZ",
|
||||
);
|
||||
eulers.y -= rotate.x;
|
||||
eulers.x = Math.max(
|
||||
-Math.PI / 2,
|
||||
Math.min(Math.PI / 2, eulers.x - rotate.y),
|
||||
);
|
||||
eulers.z = Math.max(-Math.PI, Math.min(Math.PI, eulers.z + rotate.z));
|
||||
control.quaternion.setFromEuler(eulers);
|
||||
}
|
||||
|
||||
// Movement
|
||||
|
||||
const moveVector = new THREE.Vector3(sticks[0].x, 0, sticks[0].y);
|
||||
|
||||
for (const [keycode, move] of Object.entries(this.keycodeMoveMapping)) {
|
||||
if (this.keycode[keycode]) {
|
||||
moveVector.add(move);
|
||||
}
|
||||
}
|
||||
|
||||
let speedMultiplier = 1.0;
|
||||
if (this.keydown.CapsLock) {
|
||||
speedMultiplier *= this.capsMultiplier;
|
||||
}
|
||||
if (this.keycode.ShiftLeft || this.keycode.ShiftRight) {
|
||||
speedMultiplier *= this.shiftMultiplier;
|
||||
}
|
||||
if (this.keycode.ControlLeft || this.keycode.ControlRight) {
|
||||
speedMultiplier *= this.ctrlMultiplier;
|
||||
}
|
||||
for (const button in this.gamepadMapping) {
|
||||
if (gamepadButtons[Number.parseInt(button)]) {
|
||||
switch (this.gamepadMapping[button]) {
|
||||
case "shift":
|
||||
speedMultiplier *= this.shiftMultiplier;
|
||||
break;
|
||||
case "ctrl":
|
||||
speedMultiplier *= this.ctrlMultiplier;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply movement in view direction
|
||||
moveVector.applyQuaternion(control.quaternion);
|
||||
control.position.add(
|
||||
moveVector.multiplyScalar(this.moveSpeed * speedMultiplier * deltaTime),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type PointerState = {
|
||||
initial: THREE.Vector2;
|
||||
last: THREE.Vector2;
|
||||
position: THREE.Vector2;
|
||||
pointerId: number;
|
||||
button?: number;
|
||||
timeStamp: DOMHighResTimeStamp;
|
||||
};
|
||||
|
||||
// `PointerControls` implements pointer/mouse/touch controls on the canvas,
|
||||
// for both desktop and mobile web applications.
|
||||
|
||||
export class PointerControls {
|
||||
canvas: HTMLCanvasElement;
|
||||
rotateSpeed: number;
|
||||
slideSpeed: number;
|
||||
scrollSpeed: number;
|
||||
reverseRotate: boolean;
|
||||
reverseSlide: boolean;
|
||||
reverseSwipe: boolean;
|
||||
reverseScroll: boolean;
|
||||
moveInertia: number;
|
||||
rotateInertia: number;
|
||||
// Enable/disable controls updates
|
||||
enable = true;
|
||||
|
||||
doublePress: ({
|
||||
position,
|
||||
intervalMs,
|
||||
}: { position: THREE.Vector2; intervalMs: number }) => void;
|
||||
// Time limit for double press (default DOUBLE_PRESS_LIMIT_MS)
|
||||
doublePressLimitMs: number;
|
||||
// Distance limit for double press (default DOUBLE_PRESS_DISTANCE)
|
||||
doublePressDistance: number;
|
||||
// Last pointer up event (default: null)
|
||||
lastUp: { position: THREE.Vector2; time: number } | null;
|
||||
|
||||
// Pointer state for currently active rotating pointer
|
||||
rotating: PointerState | null;
|
||||
// Pointer state for currently active sliding pointer
|
||||
sliding: PointerState | null;
|
||||
// Whether we pressed two pointers at the same time
|
||||
dualPress: boolean;
|
||||
// Cumulative scroll movement
|
||||
scroll: THREE.Vector3;
|
||||
|
||||
// Current rotation velocity
|
||||
rotateVelocity: THREE.Vector3;
|
||||
// Current movement velocity
|
||||
moveVelocity: THREE.Vector3;
|
||||
|
||||
constructor({
|
||||
// The HTML canvas element to attach pointer events to
|
||||
canvas,
|
||||
// Speed of rotation (default DEFAULT_ROTATE_SPEED)
|
||||
rotateSpeed,
|
||||
// Speed of sliding when dragging with right/middle mouse button or two fingers
|
||||
// (default DEFAULT_SLIDE_SPEED)
|
||||
slideSpeed,
|
||||
// Speed of movement when using mouse scroll wheel (default DEFAULT_SCROLL_SPEED)
|
||||
scrollSpeed,
|
||||
// Reverse the direction of rotation (default: false)
|
||||
reverseRotate,
|
||||
// Reverse the direction of sliding (default: false)
|
||||
reverseSlide,
|
||||
// Reverse the direction of swipe gestures (default: false)
|
||||
reverseSwipe,
|
||||
// Reverse the direction of scroll wheel movement (default: false)
|
||||
reverseScroll,
|
||||
// Inertia factor for movement (default: DEFAULT_MOVE_INERTIA)
|
||||
moveInertia,
|
||||
// Inertia factor for rotation (default: DEFAULT_ROTATE_INERTIA)
|
||||
rotateInertia,
|
||||
// Callback for double press events (default: () => {})
|
||||
doublePress,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement;
|
||||
rotateSpeed?: number;
|
||||
slideSpeed?: number;
|
||||
scrollSpeed?: number;
|
||||
reverseRotate?: boolean;
|
||||
reverseSlide?: boolean;
|
||||
reverseSwipe?: boolean;
|
||||
reverseScroll?: boolean;
|
||||
moveInertia?: number;
|
||||
rotateInertia?: number;
|
||||
doublePress?: ({
|
||||
position,
|
||||
intervalMs,
|
||||
}: { position: THREE.Vector2; intervalMs: number }) => void;
|
||||
}) {
|
||||
this.canvas = canvas;
|
||||
this.rotateSpeed = rotateSpeed ?? DEFAULT_ROTATE_SPEED;
|
||||
this.slideSpeed = slideSpeed ?? DEFAULT_SLIDE_SPEED;
|
||||
this.scrollSpeed = scrollSpeed ?? DEFAULT_SCROLL_SPEED;
|
||||
this.reverseRotate = reverseRotate ?? false;
|
||||
this.reverseSlide = reverseSlide ?? false;
|
||||
this.reverseSwipe = reverseSwipe ?? false;
|
||||
this.reverseScroll = reverseScroll ?? false;
|
||||
this.moveInertia = moveInertia ?? DEFAULT_MOVE_INERTIA;
|
||||
this.rotateInertia = rotateInertia ?? DEFAULT_ROTATE_INERTIA;
|
||||
|
||||
this.doublePress = doublePress ?? (() => {});
|
||||
this.doublePressLimitMs = DOUBLE_PRESS_LIMIT_MS;
|
||||
this.doublePressDistance = DOUBLE_PRESS_DISTANCE;
|
||||
this.lastUp = null;
|
||||
|
||||
this.rotating = null;
|
||||
this.sliding = null;
|
||||
this.dualPress = false;
|
||||
this.scroll = new THREE.Vector3();
|
||||
|
||||
this.rotateVelocity = new THREE.Vector3();
|
||||
this.moveVelocity = new THREE.Vector3();
|
||||
|
||||
canvas.addEventListener("pointerdown", (event: PointerEvent) => {
|
||||
const position = this.getPointerPosition(event);
|
||||
const initial = position.clone();
|
||||
const last = position.clone();
|
||||
|
||||
// Determine if we're starting a rotation pointer action
|
||||
const isRotate =
|
||||
!this.rotating && (event.pointerType !== "mouse" || event.button === 0);
|
||||
const { pointerId, timeStamp } = event;
|
||||
|
||||
if (isRotate) {
|
||||
this.rotating = { initial, last, position, pointerId, timeStamp };
|
||||
// Capture the pointer so events continue to be delivered even if it leaves the canvas.
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
|
||||
this.dualPress = false;
|
||||
} else if (!this.sliding) {
|
||||
// If it's not a rotation action and we're not yet sliding, the next
|
||||
// pointer activates a sliding action
|
||||
const button = event.pointerType === "mouse" ? event.button : undefined;
|
||||
this.sliding = {
|
||||
initial,
|
||||
last,
|
||||
position,
|
||||
pointerId,
|
||||
button,
|
||||
timeStamp,
|
||||
};
|
||||
// Capture the pointer so events continue to be delivered even if it leaves the canvas.
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
|
||||
// Check if we pressed both pointers at roughly the same time
|
||||
this.dualPress =
|
||||
this.rotating != null &&
|
||||
timeStamp - this.rotating.timeStamp < DUAL_PRESS_MS;
|
||||
}
|
||||
});
|
||||
|
||||
const pointerUp = (event: PointerEvent) => {
|
||||
if (this.rotating?.pointerId === event.pointerId) {
|
||||
this.rotating = null;
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
if (this.dualPress && this.sliding) {
|
||||
canvas.releasePointerCapture(this.sliding.pointerId);
|
||||
this.sliding = null;
|
||||
}
|
||||
} else if (this.sliding?.pointerId === event.pointerId) {
|
||||
this.sliding = null;
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
if (this.dualPress && this.rotating) {
|
||||
canvas.releasePointerCapture(this.rotating.pointerId);
|
||||
this.rotating = null;
|
||||
}
|
||||
}
|
||||
|
||||
const position = this.getPointerPosition(event);
|
||||
const lastUp = this.lastUp;
|
||||
this.lastUp = { position, time: event.timeStamp };
|
||||
if (lastUp) {
|
||||
const distance = lastUp.position.distanceTo(position);
|
||||
if (distance < this.doublePressDistance) {
|
||||
const intervalMs = event.timeStamp - lastUp.time;
|
||||
if (intervalMs < this.doublePressLimitMs) {
|
||||
// We pressed and release twice within the time and distance limits
|
||||
this.lastUp = null;
|
||||
this.doublePress({ position, intervalMs });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerup", pointerUp);
|
||||
document.addEventListener("pointercancel", pointerUp);
|
||||
|
||||
document.addEventListener("pointermove", (event: PointerEvent) => {
|
||||
if (this.rotating?.pointerId === event.pointerId) {
|
||||
this.rotating.position = this.getPointerPosition(event);
|
||||
} else if (this.sliding?.pointerId === event.pointerId) {
|
||||
this.sliding.position = this.getPointerPosition(event);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event: MouseEvent) => {
|
||||
// Prevent context menu appearing on right click
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event: WheelEvent) => {
|
||||
this.scroll.add(
|
||||
new THREE.Vector3(event.deltaX, event.deltaY, event.deltaZ),
|
||||
);
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
getPointerPosition(event: PointerEvent): THREE.Vector2 {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return new THREE.Vector2(
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
);
|
||||
}
|
||||
|
||||
update(deltaTime: number, control: THREE.Object3D) {
|
||||
if (!this.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dualPress && this.rotating && this.sliding) {
|
||||
// We pressed both pointers at the same time, either pinching or sliding
|
||||
const motion = [
|
||||
this.rotating.position.clone().sub(this.rotating.last),
|
||||
this.sliding.position.clone().sub(this.sliding.last),
|
||||
];
|
||||
const coincidence = motion[0].dot(motion[1]);
|
||||
|
||||
if (coincidence >= 0.2) {
|
||||
// Similar directions so slide the camera on the XY plane
|
||||
const totalMotion = motion[0].clone().add(motion[1]);
|
||||
const slide = new THREE.Vector3(totalMotion.x, -totalMotion.y, 0);
|
||||
slide.multiplyScalar(this.slideSpeed * (this.reverseSwipe ? 1 : -1));
|
||||
slide.applyQuaternion(control.quaternion);
|
||||
control.position.add(slide);
|
||||
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
|
||||
} else if (coincidence <= -0.2) {
|
||||
// Opposite directions so either pinch or roll motion
|
||||
const deltaDir = this.sliding.last.clone().sub(this.rotating.last);
|
||||
const deltaDist = deltaDir.length();
|
||||
deltaDir.multiplyScalar(1 / deltaDist).normalize();
|
||||
|
||||
const orthoDir = new THREE.Vector2(-deltaDir.y, deltaDir.x);
|
||||
const motionDir = [motion[0].dot(deltaDir), motion[1].dot(deltaDir)];
|
||||
const motionOrtho = [motion[0].dot(orthoDir), motion[1].dot(orthoDir)];
|
||||
|
||||
// Pinching motion
|
||||
const midpoint = this.rotating.last
|
||||
.clone()
|
||||
.add(this.sliding.last)
|
||||
.multiplyScalar(0.5);
|
||||
let midpointDir = new THREE.Vector3();
|
||||
if (control instanceof THREE.Camera) {
|
||||
const ndcMidpoint = new THREE.Vector2(
|
||||
(midpoint.x / this.canvas.clientWidth) * 2 - 1,
|
||||
-(midpoint.y / this.canvas.clientHeight) * 2 + 1,
|
||||
);
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.setFromCamera(ndcMidpoint, control);
|
||||
midpointDir = raycaster.ray.direction;
|
||||
}
|
||||
const pinchOut = motionDir[1] - motionDir[0];
|
||||
const slide = midpointDir.multiplyScalar(pinchOut * this.slideSpeed);
|
||||
control.position.add(slide);
|
||||
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
|
||||
|
||||
// Rolling motion
|
||||
// Calculate angle of orthogonal motion change over distance deltaDist/2
|
||||
// motionOrtho[0] and 1 are already in float distance
|
||||
const angles = [
|
||||
Math.atan(motionOrtho[0] / (-0.5 * deltaDist)),
|
||||
Math.atan(motionOrtho[1] / (0.5 * deltaDist)),
|
||||
];
|
||||
const rotate = 0.5 * (angles[0] + angles[1]);
|
||||
const eulers = new THREE.Euler().setFromQuaternion(
|
||||
control.quaternion,
|
||||
"YXZ",
|
||||
);
|
||||
eulers.z = Math.max(
|
||||
-Math.PI,
|
||||
Math.min(Math.PI, eulers.z + 0.5 * rotate),
|
||||
);
|
||||
control.quaternion.setFromEuler(eulers);
|
||||
}
|
||||
|
||||
this.rotating.last.copy(this.rotating.position);
|
||||
this.sliding.last.copy(this.sliding.position);
|
||||
} else {
|
||||
// Didn't press both pointers at the same time, so we're in rotating
|
||||
// or FPS mode
|
||||
const rotate = new THREE.Vector3();
|
||||
if (this.rotating && !this.dualPress) {
|
||||
const delta = this.rotating.position.clone().sub(this.rotating.last);
|
||||
this.rotating.last.copy(this.rotating.position);
|
||||
rotate.set(delta.x, delta.y, 0);
|
||||
rotate.multiplyScalar(this.rotateSpeed * (this.reverseRotate ? -1 : 1));
|
||||
// Update rotation velocity from last delta
|
||||
this.rotateVelocity = rotate.clone().multiplyScalar(1 / deltaTime);
|
||||
} else {
|
||||
// Continue to rotate with inertia
|
||||
this.rotateVelocity.multiplyScalar(
|
||||
Math.exp(-deltaTime / this.rotateInertia),
|
||||
);
|
||||
rotate.addScaledVector(this.rotateVelocity, deltaTime);
|
||||
}
|
||||
|
||||
// Apply rotation in Euler angles space
|
||||
const eulers = new THREE.Euler().setFromQuaternion(
|
||||
control.quaternion,
|
||||
"YXZ",
|
||||
);
|
||||
eulers.y -= rotate.x;
|
||||
eulers.x = Math.max(
|
||||
-Math.PI / 2,
|
||||
Math.min(Math.PI / 2, eulers.x - rotate.y),
|
||||
);
|
||||
eulers.z *= Math.exp(-DEFAULT_ROLL_SPRING * deltaTime);
|
||||
control.quaternion.setFromEuler(eulers);
|
||||
|
||||
if (this.sliding && !this.dualPress) {
|
||||
const delta = this.sliding.position.clone().sub(this.sliding.last);
|
||||
this.sliding.last.copy(this.sliding.position);
|
||||
|
||||
// Slide on plane depending on center/right mouse button
|
||||
const slide =
|
||||
this.sliding.button !== 2
|
||||
? new THREE.Vector3(delta.x, 0, delta.y)
|
||||
: new THREE.Vector3(delta.x, -delta.y, 0);
|
||||
slide.multiplyScalar(this.slideSpeed * (this.reverseSlide ? -1 : 1));
|
||||
|
||||
slide.applyQuaternion(control.quaternion);
|
||||
control.position.add(slide);
|
||||
// Update movement velocity from last delta
|
||||
this.moveVelocity = slide.clone().multiplyScalar(1 / deltaTime);
|
||||
} else {
|
||||
// Continue to move with inertia
|
||||
this.moveVelocity.multiplyScalar(
|
||||
Math.exp(-deltaTime / this.moveInertia),
|
||||
);
|
||||
control.position.addScaledVector(this.moveVelocity, deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
const scroll = this.scroll.multiplyScalar(this.scrollSpeed);
|
||||
scroll.set(scroll.x, scroll.z, scroll.y);
|
||||
if (this.reverseScroll) {
|
||||
scroll.multiplyScalar(-1);
|
||||
}
|
||||
scroll.applyQuaternion(control.quaternion);
|
||||
control.position.add(scroll);
|
||||
this.scroll.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// LN_SCALE_MIN..LN_SCALE_MAX define the internal scale range of for Gsplats,
|
||||
// covering approx 0.0001..8000 in range with discrete steps 7% apart.
|
||||
// The value "0" is reserved for truly flat scales, indicating a 2DGS.
|
||||
// If these values are changed, the corresponding values in splatDefines.glsl
|
||||
// must also be updated to match.
|
||||
|
||||
export const LN_SCALE_MIN = -9.0;
|
||||
export const LN_SCALE_MAX = 9.0;
|
||||
export const LN_RESCALE = (LN_SCALE_MAX - LN_SCALE_MIN) / 254.0; // 1..=255
|
||||
export const SCALE_MIN = Math.exp(LN_SCALE_MIN);
|
||||
export const SCALE_MAX = Math.exp(LN_SCALE_MAX);
|
||||
|
||||
// Gsplats are stored in textures that are 2^11 x 2^11 x up to 2^11
|
||||
// Most WebGL2 implementations support 2D textures up to 2^12 x 2^12 (max 16M Gsplats)
|
||||
// 2D array textures and 3D textures up to 2^11 x 2^11 x 2^11 (max 8G Gsplats),
|
||||
// so we use 2D array textures for our representation for higher limits.
|
||||
|
||||
export const SPLAT_TEX_WIDTH_BITS = 11;
|
||||
export const SPLAT_TEX_HEIGHT_BITS = 11;
|
||||
export const SPLAT_TEX_DEPTH_BITS = 11;
|
||||
export const SPLAT_TEX_LAYER_BITS =
|
||||
SPLAT_TEX_WIDTH_BITS + SPLAT_TEX_HEIGHT_BITS;
|
||||
|
||||
export const SPLAT_TEX_WIDTH = 1 << SPLAT_TEX_WIDTH_BITS; // 2048
|
||||
export const SPLAT_TEX_HEIGHT = 1 << SPLAT_TEX_HEIGHT_BITS; // 2048
|
||||
export const SPLAT_TEX_DEPTH = 1 << SPLAT_TEX_DEPTH_BITS; // 2048
|
||||
export const SPLAT_TEX_MIN_HEIGHT = 1;
|
||||
|
||||
export const SPLAT_TEX_WIDTH_MASK = SPLAT_TEX_WIDTH - 1;
|
||||
export const SPLAT_TEX_HEIGHT_MASK = SPLAT_TEX_HEIGHT - 1;
|
||||
export const SPLAT_TEX_DEPTH_MASK = SPLAT_TEX_DEPTH - 1;
|
||||
|
||||
// Enable/disable Gsplat sorting via Rust WASM code. In testing the sorting
|
||||
// time between pure JS and WASM are minimal and don't make a big difference.
|
||||
|
||||
export const WASM_SPLAT_SORT = true;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export * from "./dyno/types";
|
||||
export * from "./dyno/base";
|
||||
export * from "./dyno/value";
|
||||
export * from "./dyno/output";
|
||||
export * from "./dyno/uniforms";
|
||||
export * from "./dyno/program";
|
||||
export * from "./dyno/math";
|
||||
export * from "./dyno/logic";
|
||||
export * from "./dyno/util";
|
||||
export * from "./dyno/splats";
|
||||
export * from "./dyno/transform";
|
||||
export * from "./dyno/control";
|
||||
export * from "./dyno/convert";
|
||||
export * from "./dyno/texture";
|
||||
export * from "./dyno/trig";
|
||||
export * from "./dyno/vecmat";
|
||||
@@ -0,0 +1,575 @@
|
||||
import type { IUniform } from "three";
|
||||
import type { DynoType } from "./types";
|
||||
import {
|
||||
DynoLiteral,
|
||||
DynoOutput,
|
||||
type DynoVal,
|
||||
DynoValue,
|
||||
type HasDynoOut,
|
||||
valType,
|
||||
} from "./value";
|
||||
|
||||
const DEFAULT_INDENT = " ";
|
||||
|
||||
export class Compilation {
|
||||
globals: Set<string> = new Set();
|
||||
statements: string[] = [];
|
||||
uniforms: Record<string, IUniform> = {};
|
||||
declares: Set<string> = new Set();
|
||||
updaters: (() => void)[] = [];
|
||||
sequence = 0;
|
||||
indent: string = DEFAULT_INDENT;
|
||||
|
||||
constructor({ indent }: { indent?: string } = {}) {
|
||||
this.indent = indent ?? DEFAULT_INDENT;
|
||||
}
|
||||
|
||||
nextSequence() {
|
||||
return this.sequence++;
|
||||
}
|
||||
}
|
||||
|
||||
export type IOTypes = Record<string, DynoType>;
|
||||
type GenerateContext<InTypes extends IOTypes, OutTypes extends IOTypes> = {
|
||||
inputs: { [K in keyof InTypes]?: string };
|
||||
outputs: { [K in keyof OutTypes]?: string };
|
||||
compile: Compilation;
|
||||
};
|
||||
|
||||
export class Dyno<InTypes extends IOTypes, OutTypes extends IOTypes> {
|
||||
inTypes: InTypes;
|
||||
outTypes: OutTypes;
|
||||
|
||||
inputs: { [K in keyof InTypes]?: DynoVal<InTypes[K]> };
|
||||
update?: () => void;
|
||||
globals?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
statements?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
generate: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => {
|
||||
globals?: string[];
|
||||
statements?: string[];
|
||||
uniforms?: Record<string, IUniform>;
|
||||
};
|
||||
|
||||
constructor({
|
||||
inTypes,
|
||||
outTypes,
|
||||
inputs,
|
||||
update,
|
||||
globals,
|
||||
statements,
|
||||
generate,
|
||||
}: {
|
||||
inTypes?: InTypes;
|
||||
outTypes?: OutTypes;
|
||||
inputs?: { [K in keyof InTypes]?: DynoVal<InTypes[K]> };
|
||||
update?: () => void;
|
||||
globals?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
statements?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
generate?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => {
|
||||
globals?: string[];
|
||||
statements?: string[];
|
||||
uniforms?: Record<string, IUniform>;
|
||||
};
|
||||
}) {
|
||||
this.inTypes = inTypes ?? ({} as InTypes);
|
||||
this.outTypes = outTypes ?? ({} as OutTypes);
|
||||
this.inputs = inputs ?? {};
|
||||
this.update = update;
|
||||
|
||||
this.globals = globals;
|
||||
this.statements = statements;
|
||||
this.generate =
|
||||
generate ??
|
||||
(({ inputs, outputs, compile }) => {
|
||||
return {
|
||||
globals: this.globals?.({ inputs, outputs, compile }),
|
||||
statements: this.statements?.({ inputs, outputs, compile }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get outputs(): { [K in keyof OutTypes]: DynoVal<OutTypes[K]> } {
|
||||
const outputs = {} as { [K in keyof OutTypes]: DynoVal<OutTypes[K]> };
|
||||
for (const key in this.outTypes) {
|
||||
outputs[key] = new DynoOutput(this, key);
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
|
||||
apply(inputs: { [K in keyof InTypes]?: DynoVal<InTypes[K]> }): {
|
||||
[K in keyof OutTypes]: DynoVal<OutTypes[K]>;
|
||||
} {
|
||||
Object.assign(this.inputs, inputs);
|
||||
return this.outputs;
|
||||
}
|
||||
|
||||
compile({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: {
|
||||
inputs: { [K in keyof InTypes]?: string };
|
||||
outputs: { [K in keyof OutTypes]?: string };
|
||||
compile: Compilation;
|
||||
}): string[] {
|
||||
const result = [
|
||||
`// ${this.constructor.name}(${Object.values(inputs).join(", ")}) => (${Object.values(outputs).join(", ")})`,
|
||||
];
|
||||
|
||||
const declares: (keyof OutTypes)[] = [];
|
||||
for (const key in outputs) {
|
||||
const name = outputs[key];
|
||||
if (name && !compile.declares.has(name)) {
|
||||
compile.declares.add(name);
|
||||
declares.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const { globals, statements, uniforms } = this.generate({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
});
|
||||
for (const global of globals ?? []) {
|
||||
compile.globals.add(global);
|
||||
}
|
||||
for (const key in uniforms) {
|
||||
compile.uniforms[key] = uniforms[key];
|
||||
}
|
||||
if (this.update) {
|
||||
compile.updaters.push(this.update);
|
||||
}
|
||||
|
||||
for (const key of declares) {
|
||||
const name = outputs[key];
|
||||
if (name) {
|
||||
if (!compile.uniforms[name]) {
|
||||
result.push(`${dynoDeclare(name, this.outTypes[key])};`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (statements?.length) {
|
||||
result.push("{");
|
||||
result.push(...statements.map((line) => compile.indent + line));
|
||||
result.push("}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export type DynoBlockType<InTypes extends IOTypes, OutTypes extends IOTypes> = (
|
||||
inputs: { [K in keyof InTypes]?: DynoVal<InTypes[K]> },
|
||||
outputs: { [K in keyof OutTypes]?: DynoVal<OutTypes[K]> },
|
||||
{ roots }: { roots: Dyno<InTypes, OutTypes>[] },
|
||||
) => { [K in keyof OutTypes]?: DynoVal<OutTypes[K]> } | undefined;
|
||||
|
||||
export class DynoBlock<
|
||||
InTypes extends IOTypes,
|
||||
OutTypes extends IOTypes,
|
||||
> extends Dyno<InTypes, OutTypes> {
|
||||
construct: DynoBlockType<InTypes, OutTypes>;
|
||||
|
||||
constructor({
|
||||
inTypes,
|
||||
outTypes,
|
||||
inputs,
|
||||
update,
|
||||
globals,
|
||||
construct,
|
||||
}: {
|
||||
inTypes?: InTypes;
|
||||
outTypes?: OutTypes;
|
||||
inputs?: { [K in keyof InTypes]?: DynoVal<InTypes[K]> };
|
||||
update?: () => void;
|
||||
globals?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
construct: DynoBlockType<InTypes, OutTypes>;
|
||||
}) {
|
||||
super({
|
||||
inTypes,
|
||||
outTypes,
|
||||
inputs,
|
||||
update,
|
||||
globals,
|
||||
generate: (args) => this.generateBlock(args),
|
||||
});
|
||||
this.construct = construct;
|
||||
}
|
||||
|
||||
generateBlock({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: {
|
||||
inputs: { [K in keyof InTypes]?: string };
|
||||
outputs: { [K in keyof OutTypes]?: string };
|
||||
compile: Compilation;
|
||||
}) {
|
||||
const blockInputs: { [K in keyof InTypes]?: DynoVal<InTypes[K]> } = {};
|
||||
const blockOutputs: { [K in keyof OutTypes]?: DynoVal<OutTypes[K]> } = {};
|
||||
|
||||
for (const key in inputs) {
|
||||
if (inputs[key] != null) {
|
||||
blockInputs[key] = new DynoLiteral(this.inTypes[key], inputs[key]);
|
||||
}
|
||||
}
|
||||
for (const key in outputs) {
|
||||
if (outputs[key] != null) {
|
||||
blockOutputs[key] = new DynoValue(this.outTypes[key]);
|
||||
}
|
||||
}
|
||||
|
||||
const options = { roots: [] };
|
||||
const returned = this.construct(blockInputs, blockOutputs, options);
|
||||
|
||||
for (const global of this.globals?.({ inputs, outputs, compile }) ?? []) {
|
||||
compile.globals.add(global);
|
||||
}
|
||||
|
||||
const ordering: Dyno<IOTypes, IOTypes>[] = [];
|
||||
const nodeOuts = new Map<
|
||||
Dyno<IOTypes, IOTypes>,
|
||||
{ sequence: number; outNames: Map<string, string>; newOuts: Set<string> }
|
||||
>();
|
||||
|
||||
function visit(
|
||||
node: Dyno<IOTypes, IOTypes>,
|
||||
outKey?: string,
|
||||
outName?: string,
|
||||
) {
|
||||
let outs = nodeOuts.get(node);
|
||||
if (!outs) {
|
||||
// First time visiting this node
|
||||
outs = {
|
||||
sequence: compile.nextSequence(),
|
||||
outNames: new Map(),
|
||||
newOuts: new Set(),
|
||||
};
|
||||
nodeOuts.set(node, outs);
|
||||
|
||||
for (const key in node.inputs) {
|
||||
let input = node.inputs[key];
|
||||
while (input) {
|
||||
if (input instanceof DynoValue) {
|
||||
if (input instanceof DynoOutput) {
|
||||
visit(input.dyno, input.key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Must be as HasDynoOut<T>
|
||||
input = input.dynoOut();
|
||||
}
|
||||
}
|
||||
ordering.push(node);
|
||||
}
|
||||
if (outKey) {
|
||||
if (!outName) {
|
||||
outs.newOuts.add(outKey);
|
||||
}
|
||||
outs.outNames.set(outKey, outName ?? `${outKey}_${outs.sequence}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of options.roots) {
|
||||
visit(root);
|
||||
}
|
||||
|
||||
for (const key in blockOutputs) {
|
||||
let value = returned?.[key] ?? blockOutputs[key];
|
||||
while (value) {
|
||||
if (value instanceof DynoValue) {
|
||||
if (value instanceof DynoOutput) {
|
||||
visit(value.dyno, value.key, outputs[key]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Must be as HasDynoOut<T>
|
||||
value = value.dynoOut();
|
||||
}
|
||||
blockOutputs[key] = value;
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
|
||||
for (const dyno of ordering) {
|
||||
// compile.statements.push(`// ${dyno.constructor.name}(${Object.values(inputs).join(", ")}) => (${Object.values(outputs).join(", ")})`);
|
||||
|
||||
const inputs: Record<string, string> = {};
|
||||
const outputs: Record<string, string> = {};
|
||||
|
||||
for (const key in dyno.inputs) {
|
||||
let value = dyno.inputs[key];
|
||||
while (value) {
|
||||
if (value instanceof DynoValue) {
|
||||
if (value instanceof DynoLiteral) {
|
||||
inputs[key] = value.getLiteral();
|
||||
} else if (value instanceof DynoOutput) {
|
||||
const source = nodeOuts.get(value.dyno)?.outNames.get(value.key);
|
||||
if (!source) {
|
||||
throw new Error(
|
||||
`Source not found for ${value.dyno.constructor.name}.${value.key}`,
|
||||
);
|
||||
}
|
||||
inputs[key] = source;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Must be as HasDynOut<T>
|
||||
value = value.dynoOut();
|
||||
}
|
||||
}
|
||||
|
||||
const outs = nodeOuts.get(dyno) ?? { outNames: new Map() };
|
||||
for (const [key, name] of outs.outNames.entries()) {
|
||||
outputs[key] = name;
|
||||
}
|
||||
|
||||
const newSteps = dyno.compile({ inputs, outputs, compile });
|
||||
steps.push(newSteps);
|
||||
}
|
||||
|
||||
const literalOutputs = [];
|
||||
for (const key in outputs) {
|
||||
if (blockOutputs[key] instanceof DynoLiteral) {
|
||||
literalOutputs.push(
|
||||
`${outputs[key]} = ${blockOutputs[key].getLiteral()};`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (literalOutputs.length > 0) {
|
||||
steps.push(literalOutputs);
|
||||
}
|
||||
|
||||
const statements = steps.flatMap((step, index) => {
|
||||
// Add a blank line between steps
|
||||
return index === 0 ? step : ["", ...step];
|
||||
});
|
||||
return { statements };
|
||||
}
|
||||
}
|
||||
|
||||
export function dynoBlock<
|
||||
InTypes extends Record<string, DynoType>,
|
||||
OutTypes extends Record<string, DynoType>,
|
||||
>(
|
||||
inTypes: InTypes,
|
||||
outTypes: OutTypes,
|
||||
construct: DynoBlockType<InTypes, OutTypes>,
|
||||
{ update, globals }: { update?: () => void; globals?: () => string[] } = {},
|
||||
) {
|
||||
return new DynoBlock({ inTypes, outTypes, construct, update, globals });
|
||||
}
|
||||
|
||||
export function dyno<
|
||||
InTypes extends Record<string, DynoType>,
|
||||
OutTypes extends Record<string, DynoType>,
|
||||
>({
|
||||
inTypes,
|
||||
outTypes,
|
||||
inputs,
|
||||
update,
|
||||
globals,
|
||||
statements,
|
||||
generate,
|
||||
}: {
|
||||
inTypes: InTypes;
|
||||
outTypes: OutTypes;
|
||||
inputs?: { [K in keyof InTypes]?: DynoVal<InTypes[K]> };
|
||||
update?: () => void;
|
||||
globals?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
statements?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => string[];
|
||||
generate?: ({
|
||||
inputs,
|
||||
outputs,
|
||||
compile,
|
||||
}: GenerateContext<InTypes, OutTypes>) => {
|
||||
globals?: string[];
|
||||
statements?: string[];
|
||||
uniforms?: Record<string, IUniform>;
|
||||
};
|
||||
}) {
|
||||
return new Dyno({
|
||||
inTypes,
|
||||
outTypes,
|
||||
inputs,
|
||||
update,
|
||||
globals,
|
||||
statements,
|
||||
generate,
|
||||
});
|
||||
}
|
||||
|
||||
export function dynoDeclare(name: string, type: DynoType, count?: number) {
|
||||
const typeStr = typeof type === "string" ? type : type.type;
|
||||
if (!typeStr) {
|
||||
throw new Error(`Invalid DynoType: ${String(type)}`);
|
||||
}
|
||||
return `${typeStr} ${name}${count != null ? `[${count}]` : ""}`;
|
||||
}
|
||||
|
||||
export function unindentLines(s: string): string[] {
|
||||
let seenNonEmpty = false;
|
||||
const lines = s
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmedLine = line.trimEnd();
|
||||
if (seenNonEmpty) {
|
||||
return trimmedLine;
|
||||
}
|
||||
if (trimmedLine.length > 0) {
|
||||
seenNonEmpty = true;
|
||||
return trimmedLine;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((line) => line != null);
|
||||
while (lines.length > 0 && lines[lines.length - 1].length === 0) {
|
||||
lines.pop();
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const indent = lines[0].match(/^\s*/)?.[0];
|
||||
if (!indent) {
|
||||
return lines; // No indent, return as is
|
||||
}
|
||||
// Remove indent from the beginning of each line
|
||||
const regex = new RegExp(`^${indent}`);
|
||||
return lines.map((line) => line.replace(regex, ""));
|
||||
}
|
||||
|
||||
export function unindent(s: string): string {
|
||||
return unindentLines(s).join("\n");
|
||||
}
|
||||
|
||||
export class UnaryOp<
|
||||
A extends DynoType,
|
||||
OutType extends DynoType,
|
||||
OutKey extends string,
|
||||
>
|
||||
extends Dyno<{ a: A }, { [key in OutKey]: OutType }>
|
||||
implements HasDynoOut<OutType>
|
||||
{
|
||||
constructor({
|
||||
a,
|
||||
outKey,
|
||||
outTypeFunc,
|
||||
}: { a: DynoVal<A>; outKey: OutKey; outTypeFunc: (aType: A) => OutType }) {
|
||||
const inTypes = { a: valType(a) };
|
||||
const outType = outTypeFunc(valType(a));
|
||||
const outTypes = { [outKey]: outType } as { [key in OutKey]: OutType };
|
||||
super({ inTypes, outTypes, inputs: { a } });
|
||||
this.outKey = outKey;
|
||||
}
|
||||
|
||||
outKey: OutKey;
|
||||
dynoOut(): DynoValue<OutType> {
|
||||
return new DynoOutput(this, this.outKey);
|
||||
}
|
||||
}
|
||||
|
||||
export class BinaryOp<
|
||||
A extends DynoType,
|
||||
B extends DynoType,
|
||||
OutType extends DynoType,
|
||||
OutKey extends string,
|
||||
>
|
||||
extends Dyno<{ a: A; b: B }, { [key in OutKey]: OutType }>
|
||||
implements HasDynoOut<OutType>
|
||||
{
|
||||
constructor({
|
||||
a,
|
||||
b,
|
||||
outKey,
|
||||
outTypeFunc,
|
||||
}: {
|
||||
a: DynoVal<A>;
|
||||
b: DynoVal<B>;
|
||||
outKey: OutKey;
|
||||
outTypeFunc: (aType: A, bType: B) => OutType;
|
||||
}) {
|
||||
const inTypes = { a: valType(a), b: valType(b) };
|
||||
const outType = outTypeFunc(valType(a), valType(b));
|
||||
const outTypes = { [outKey]: outType } as { [key in OutKey]: OutType };
|
||||
super({ inTypes, outTypes, inputs: { a, b } });
|
||||
this.outKey = outKey;
|
||||
}
|
||||
|
||||
outKey: OutKey;
|
||||
dynoOut(): DynoValue<OutType> {
|
||||
return new DynoOutput(this, this.outKey);
|
||||
}
|
||||
}
|
||||
|
||||
export class TrinaryOp<
|
||||
A extends DynoType,
|
||||
B extends DynoType,
|
||||
C extends DynoType,
|
||||
OutType extends DynoType,
|
||||
OutKey extends string,
|
||||
>
|
||||
extends Dyno<{ a: A; b: B; c: C }, { [key in OutKey]: OutType }>
|
||||
implements HasDynoOut<OutType>
|
||||
{
|
||||
constructor({
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
outKey,
|
||||
outTypeFunc,
|
||||
}: {
|
||||
a: DynoVal<A>;
|
||||
b: DynoVal<B>;
|
||||
c: DynoVal<C>;
|
||||
outKey: OutKey;
|
||||
outTypeFunc: (aType: A, bType: B, cType: C) => OutType;
|
||||
}) {
|
||||
const inTypes = { a: valType(a), b: valType(b), c: valType(c) };
|
||||
const outType = outTypeFunc(valType(a), valType(b), valType(c));
|
||||
const outTypes = { [outKey]: outType } as { [key in OutKey]: OutType };
|
||||
super({ inTypes, outTypes, inputs: { a, b, c } });
|
||||
this.outKey = outKey;
|
||||
}
|
||||
|
||||
outKey: OutKey;
|
||||
dynoOut(): DynoValue<OutType> {
|
||||
return new DynoOutput(this, this.outKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// TODO:
|
||||
// if, switch, for, comment,
|
||||
// arrayIndex, arrayLength,
|
||||
|
||||
export const dynoIf = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
export const dynoSwitch = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
export const dynoFor = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
export const comment = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
export const arrayIndex = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
export const arrayLength = () => {
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
import { UnaryOp } from "./base";
|
||||
import { type SimpleTypes, typeLiteral } from "./types";
|
||||
import type { DynoVal } from "./value";
|
||||
|
||||
export const bool = <T extends "bool" | "int" | "uint" | "float">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"bool"> => new Bool({ value });
|
||||
export const int = <T extends "bool" | "int" | "uint" | "float">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"int"> => new Int({ value });
|
||||
export const uint = <T extends "bool" | "int" | "uint" | "float">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"uint"> => new Uint({ value });
|
||||
export const float = <T extends "bool" | "int" | "uint" | "float">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"float"> => new Float({ value });
|
||||
|
||||
export const bvec2 = <T extends "bool" | "bvec2" | "ivec2" | "uvec2" | "vec2">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"bvec2"> => new BVec2({ value });
|
||||
export const bvec3 = <T extends "bool" | "bvec3" | "ivec3" | "uvec3" | "vec3">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"bvec3"> => new BVec3({ value });
|
||||
export const bvec4 = <T extends "bool" | "bvec4" | "ivec4" | "uvec4" | "vec4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"bvec4"> => new BVec4({ value });
|
||||
|
||||
export const ivec2 = <T extends "int" | "bvec2" | "ivec2" | "uvec2" | "vec2">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"ivec2"> => new IVec2({ value });
|
||||
export const ivec3 = <T extends "int" | "bvec3" | "ivec3" | "uvec3" | "vec3">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"ivec3"> => new IVec3({ value });
|
||||
export const ivec4 = <T extends "int" | "bvec4" | "ivec4" | "uvec4" | "vec4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"ivec4"> => new IVec4({ value });
|
||||
|
||||
export const uvec2 = <T extends "uint" | "bvec2" | "ivec2" | "uvec2" | "vec2">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"uvec2"> => new UVec2({ value });
|
||||
export const uvec3 = <T extends "uint" | "bvec3" | "ivec3" | "uvec3" | "vec3">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"uvec3"> => new UVec3({ value });
|
||||
export const uvec4 = <T extends "uint" | "bvec4" | "ivec4" | "uvec4" | "vec4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"uvec4"> => new UVec4({ value });
|
||||
|
||||
export const vec2 = <
|
||||
T extends "float" | "bvec2" | "ivec2" | "uvec2" | "vec2" | "vec3" | "vec4",
|
||||
>(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"vec2"> => new Vec2({ value });
|
||||
export const vec3 = <
|
||||
T extends "float" | "bvec3" | "ivec3" | "uvec3" | "vec3" | "vec4",
|
||||
>(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"vec3"> => new Vec3({ value });
|
||||
export const vec4 = <T extends "float" | "bvec4" | "ivec4" | "uvec4" | "vec4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"vec4"> => new Vec4({ value });
|
||||
|
||||
export const mat2 = <T extends "float" | "mat2" | "mat3" | "mat4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"mat2"> => new Mat2({ value });
|
||||
export const mat3 = <T extends "float" | "mat2" | "mat3" | "mat4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"mat3"> => new Mat3({ value });
|
||||
export const mat4 = <T extends "float" | "mat2" | "mat3" | "mat4">(
|
||||
value: DynoVal<T>,
|
||||
): DynoVal<"mat4"> => new Mat4({ value });
|
||||
|
||||
export const floatBitsToInt = (value: DynoVal<"float">): DynoVal<"int"> =>
|
||||
new FloatBitsToInt({ value });
|
||||
export const floatBitsToUint = (value: DynoVal<"float">): DynoVal<"uint"> =>
|
||||
new FloatBitsToUint({ value });
|
||||
export const intBitsToFloat = (value: DynoVal<"int">): DynoVal<"float"> =>
|
||||
new IntBitsToFloat({ value });
|
||||
export const uintBitsToFloat = (value: DynoVal<"uint">): DynoVal<"float"> =>
|
||||
new UintBitsToFloat({ value });
|
||||
|
||||
export const packSnorm2x16 = (value: DynoVal<"vec2">): DynoVal<"uint"> =>
|
||||
new PackSnorm2x16({ value });
|
||||
export const unpackSnorm2x16 = (value: DynoVal<"uint">): DynoVal<"vec2"> =>
|
||||
new UnpackSnorm2x16({ value });
|
||||
export const packUnorm2x16 = (value: DynoVal<"vec2">): DynoVal<"uint"> =>
|
||||
new PackUnorm2x16({ value });
|
||||
export const unpackUnorm2x16 = (value: DynoVal<"uint">): DynoVal<"vec2"> =>
|
||||
new UnpackUnorm2x16({ value });
|
||||
|
||||
export const packHalf2x16 = (value: DynoVal<"vec2">): DynoVal<"uint"> =>
|
||||
new PackHalf2x16({ value });
|
||||
export const unpackHalf2x16 = (value: DynoVal<"uint">): DynoVal<"vec2"> =>
|
||||
new UnpackHalf2x16({ value });
|
||||
|
||||
export const uintToRgba8 = (value: DynoVal<"uint">): DynoVal<"vec4"> =>
|
||||
new UintToRgba8({ value });
|
||||
|
||||
export class SimpleCast<
|
||||
Allowed extends SimpleTypes,
|
||||
OutType extends SimpleTypes,
|
||||
OutKey extends string,
|
||||
> extends UnaryOp<Allowed, OutType, OutKey> {
|
||||
constructor({
|
||||
value,
|
||||
outType,
|
||||
outKey,
|
||||
}: { value: DynoVal<Allowed>; outType: OutType; outKey: OutKey }) {
|
||||
super({ a: value, outTypeFunc: () => outType, outKey });
|
||||
this.statements = ({ inputs, outputs }) => [
|
||||
`${outputs[outKey]} = ${typeLiteral(outType)}(${inputs.a});`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class Bool extends SimpleCast<
|
||||
"bool" | "int" | "uint" | "float",
|
||||
"bool",
|
||||
"bool"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "int" | "uint" | "float"> }) {
|
||||
super({ value, outType: "bool", outKey: "bool" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Int extends SimpleCast<
|
||||
"bool" | "int" | "uint" | "float",
|
||||
"int",
|
||||
"int"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "int" | "uint" | "float"> }) {
|
||||
super({ value, outType: "int", outKey: "int" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Uint extends SimpleCast<
|
||||
"bool" | "int" | "uint" | "float",
|
||||
"uint",
|
||||
"uint"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "int" | "uint" | "float"> }) {
|
||||
super({ value, outType: "uint", outKey: "uint" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Float extends SimpleCast<
|
||||
"bool" | "int" | "uint" | "float",
|
||||
"float",
|
||||
"float"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "int" | "uint" | "float"> }) {
|
||||
super({ value, outType: "float", outKey: "float" });
|
||||
}
|
||||
}
|
||||
|
||||
export class BVec2 extends SimpleCast<
|
||||
"bool" | "bvec2" | "ivec2" | "uvec2" | "vec2",
|
||||
"bvec2",
|
||||
"bvec2"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "bvec2" | "ivec2" | "uvec2" | "vec2"> }) {
|
||||
super({ value, outType: "bvec2", outKey: "bvec2" });
|
||||
}
|
||||
}
|
||||
|
||||
export class BVec3 extends SimpleCast<
|
||||
"bool" | "bvec3" | "ivec3" | "uvec3" | "vec3",
|
||||
"bvec3",
|
||||
"bvec3"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "bvec3" | "ivec3" | "uvec3" | "vec3"> }) {
|
||||
super({ value, outType: "bvec3", outKey: "bvec3" });
|
||||
}
|
||||
}
|
||||
|
||||
export class BVec4 extends SimpleCast<
|
||||
"bool" | "bvec4" | "ivec4" | "uvec4" | "vec4",
|
||||
"bvec4",
|
||||
"bvec4"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"bool" | "bvec4" | "ivec4" | "uvec4" | "vec4"> }) {
|
||||
super({ value, outType: "bvec4", outKey: "bvec4" });
|
||||
}
|
||||
}
|
||||
|
||||
export class IVec2 extends SimpleCast<
|
||||
"int" | "bvec2" | "ivec2" | "uvec2" | "vec2",
|
||||
"ivec2",
|
||||
"ivec2"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"int" | "bvec2" | "ivec2" | "uvec2" | "vec2"> }) {
|
||||
super({ value, outType: "ivec2", outKey: "ivec2" });
|
||||
}
|
||||
}
|
||||
|
||||
export class IVec3 extends SimpleCast<
|
||||
"int" | "bvec3" | "ivec3" | "uvec3" | "vec3",
|
||||
"ivec3",
|
||||
"ivec3"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"int" | "bvec3" | "ivec3" | "uvec3" | "vec3"> }) {
|
||||
super({ value, outType: "ivec3", outKey: "ivec3" });
|
||||
}
|
||||
}
|
||||
|
||||
export class IVec4 extends SimpleCast<
|
||||
"int" | "bvec4" | "ivec4" | "uvec4" | "vec4",
|
||||
"ivec4",
|
||||
"ivec4"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"int" | "bvec4" | "ivec4" | "uvec4" | "vec4"> }) {
|
||||
super({ value, outType: "ivec4", outKey: "ivec4" });
|
||||
}
|
||||
}
|
||||
|
||||
export class UVec2 extends SimpleCast<
|
||||
"uint" | "ivec2" | "bvec2" | "uvec2" | "vec2",
|
||||
"uvec2",
|
||||
"uvec2"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"uint" | "ivec2" | "bvec2" | "uvec2" | "vec2"> }) {
|
||||
super({ value, outType: "uvec2", outKey: "uvec2" });
|
||||
}
|
||||
}
|
||||
|
||||
export class UVec3 extends SimpleCast<
|
||||
"uint" | "ivec3" | "bvec3" | "uvec3" | "vec3",
|
||||
"uvec3",
|
||||
"uvec3"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"uint" | "ivec3" | "bvec3" | "uvec3" | "vec3"> }) {
|
||||
super({ value, outType: "uvec3", outKey: "uvec3" });
|
||||
}
|
||||
}
|
||||
|
||||
export class UVec4 extends SimpleCast<
|
||||
"uint" | "ivec4" | "bvec4" | "uvec4" | "vec4",
|
||||
"uvec4",
|
||||
"uvec4"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"uint" | "ivec4" | "bvec4" | "uvec4" | "vec4"> }) {
|
||||
super({ value, outType: "uvec4", outKey: "uvec4" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Vec2 extends SimpleCast<
|
||||
"float" | "bvec2" | "ivec2" | "uvec2" | "vec2" | "vec3" | "vec4",
|
||||
"vec2",
|
||||
"vec2"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: {
|
||||
value: DynoVal<
|
||||
"float" | "bvec2" | "ivec2" | "uvec2" | "vec2" | "vec3" | "vec4"
|
||||
>;
|
||||
}) {
|
||||
super({ value, outType: "vec2", outKey: "vec2" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Vec3 extends SimpleCast<
|
||||
"float" | "bvec3" | "ivec3" | "uvec3" | "vec3" | "vec2" | "vec4",
|
||||
"vec3",
|
||||
"vec3"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: {
|
||||
value: DynoVal<
|
||||
"float" | "bvec3" | "ivec3" | "uvec3" | "vec3" | "vec2" | "vec4"
|
||||
>;
|
||||
}) {
|
||||
super({ value, outType: "vec3", outKey: "vec3" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Vec4 extends SimpleCast<
|
||||
"float" | "bvec4" | "ivec4" | "uvec4" | "vec4",
|
||||
"vec4",
|
||||
"vec4"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"float" | "bvec4" | "ivec4" | "uvec4" | "vec4"> }) {
|
||||
super({ value, outType: "vec4", outKey: "vec4" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Mat2 extends SimpleCast<
|
||||
"float" | "mat2" | "mat3" | "mat4",
|
||||
"mat2",
|
||||
"mat2"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"float" | "mat2" | "mat3" | "mat4"> }) {
|
||||
super({ value, outType: "mat2", outKey: "mat2" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Mat3 extends SimpleCast<
|
||||
"float" | "mat2" | "mat3" | "mat4",
|
||||
"mat3",
|
||||
"mat3"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"float" | "mat2" | "mat3" | "mat4"> }) {
|
||||
super({ value, outType: "mat3", outKey: "mat3" });
|
||||
}
|
||||
}
|
||||
|
||||
export class Mat4 extends SimpleCast<
|
||||
"float" | "mat2" | "mat3" | "mat4",
|
||||
"mat4",
|
||||
"mat4"
|
||||
> {
|
||||
constructor({
|
||||
value,
|
||||
}: { value: DynoVal<"float" | "mat2" | "mat3" | "mat4"> }) {
|
||||
super({ value, outType: "mat4", outKey: "mat4" });
|
||||
}
|
||||
}
|
||||
|
||||
export class FloatBitsToInt extends UnaryOp<"float", "int", "int"> {
|
||||
constructor({ value }: { value: DynoVal<"float"> }) {
|
||||
super({ a: value, outKey: "int", outTypeFunc: () => "int" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.int} = floatBitsToInt(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class FloatBitsToUint extends UnaryOp<"float", "uint", "uint"> {
|
||||
constructor({ value }: { value: DynoVal<"float"> }) {
|
||||
super({ a: value, outKey: "uint", outTypeFunc: () => "uint" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.uint} = floatBitsToUint(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IntBitsToFloat extends UnaryOp<"int", "float", "float"> {
|
||||
constructor({ value }: { value: DynoVal<"int"> }) {
|
||||
super({ a: value, outKey: "float", outTypeFunc: () => "float" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.float} = intBitsToFloat(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UintBitsToFloat extends UnaryOp<"uint", "float", "float"> {
|
||||
constructor({ value }: { value: DynoVal<"uint"> }) {
|
||||
super({ a: value, outKey: "float", outTypeFunc: () => "float" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.float} = uintBitsToFloat(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PackSnorm2x16 extends UnaryOp<"vec2", "uint", "uint"> {
|
||||
constructor({ value }: { value: DynoVal<"vec2"> }) {
|
||||
super({ a: value, outKey: "uint", outTypeFunc: () => "uint" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.uint} = packSnorm2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UnpackSnorm2x16 extends UnaryOp<"uint", "vec2", "vec2"> {
|
||||
constructor({ value }: { value: DynoVal<"uint"> }) {
|
||||
super({ a: value, outKey: "vec2", outTypeFunc: () => "vec2" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.vec2} = unpackSnorm2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PackUnorm2x16 extends UnaryOp<"vec2", "uint", "uint"> {
|
||||
constructor({ value }: { value: DynoVal<"vec2"> }) {
|
||||
super({ a: value, outKey: "uint", outTypeFunc: () => "uint" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.uint} = packUnorm2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UnpackUnorm2x16 extends UnaryOp<"uint", "vec2", "vec2"> {
|
||||
constructor({ value }: { value: DynoVal<"uint"> }) {
|
||||
super({ a: value, outKey: "vec2", outTypeFunc: () => "vec2" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.vec2} = unpackUnorm2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PackHalf2x16 extends UnaryOp<"vec2", "uint", "uint"> {
|
||||
constructor({ value }: { value: DynoVal<"vec2"> }) {
|
||||
super({ a: value, outKey: "uint", outTypeFunc: () => "uint" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.uint} = packHalf2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UnpackHalf2x16 extends UnaryOp<"uint", "vec2", "vec2"> {
|
||||
constructor({ value }: { value: DynoVal<"uint"> }) {
|
||||
super({ a: value, outKey: "vec2", outTypeFunc: () => "vec2" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.vec2} = unpackHalf2x16(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UintToRgba8 extends UnaryOp<"uint", "vec4", "rgba8"> {
|
||||
constructor({ value }: { value: DynoVal<"uint"> }) {
|
||||
super({ a: value, outKey: "rgba8", outTypeFunc: () => "vec4" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [
|
||||
`uvec4 uRgba = uvec4(${inputs.a} & 0xffu, (${inputs.a} >> 8u) & 0xffu, (${inputs.a} >> 16u) & 0xffu, (${inputs.a} >> 24u) & 0xffu);`,
|
||||
`${outputs.rgba8} = vec4(uRgba) / 255.0;`,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { BinaryOp, TrinaryOp, UnaryOp } from "./base";
|
||||
import {
|
||||
type AllIntTypes,
|
||||
type BoolTypes,
|
||||
type IntTypes,
|
||||
type ScalarTypes,
|
||||
type SimpleTypes,
|
||||
type UintTypes,
|
||||
type ValueTypes,
|
||||
isBoolType,
|
||||
isIntType,
|
||||
isScalarType,
|
||||
isUintType,
|
||||
isVector2Type,
|
||||
isVector3Type,
|
||||
} from "./types";
|
||||
import { type DynoVal, valType } from "./value";
|
||||
|
||||
export const and = <T extends "bool" | AllIntTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<T> => new And({ a, b });
|
||||
export const or = <T extends "bool" | AllIntTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<T> => new Or({ a, b });
|
||||
export const xor = <T extends "bool" | AllIntTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<T> => new Xor({ a, b });
|
||||
export const not = <T extends BoolTypes | AllIntTypes>(
|
||||
a: DynoVal<T>,
|
||||
): DynoVal<T> => new Not({ a });
|
||||
|
||||
export const lessThan = <T extends ValueTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<CompareOutput<T>> => new LessThan({ a, b });
|
||||
export const lessThanEqual = <T extends ValueTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<CompareOutput<T>> => new LessThanEqual({ a, b });
|
||||
export const greaterThan = <T extends ValueTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<CompareOutput<T>> => new GreaterThan({ a, b });
|
||||
export const greaterThanEqual = <T extends ValueTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<CompareOutput<T>> => new GreaterThanEqual({ a, b });
|
||||
export const equal = <T extends ValueTypes | BoolTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<EqualOutput<T>> => new Equal({ a, b });
|
||||
export const notEqual = <T extends ValueTypes | BoolTypes>(
|
||||
a: DynoVal<T>,
|
||||
b: DynoVal<T>,
|
||||
): DynoVal<NotEqualOutput<T>> => new NotEqual({ a, b });
|
||||
|
||||
export const any = <T extends "bvec2" | "bvec3" | "bvec4">(
|
||||
a: DynoVal<T>,
|
||||
): DynoVal<"bool"> => new Any({ a });
|
||||
export const all = <T extends "bvec2" | "bvec3" | "bvec4">(
|
||||
a: DynoVal<T>,
|
||||
): DynoVal<"bool"> => new All({ a });
|
||||
export const select = <T extends SimpleTypes>(
|
||||
cond: DynoVal<"bool">,
|
||||
t: DynoVal<T>,
|
||||
f: DynoVal<T>,
|
||||
): DynoVal<T> => new Select({ cond, t, f });
|
||||
|
||||
export const compXor = <T extends BoolTypes | AllIntTypes>(
|
||||
a: DynoVal<T>,
|
||||
): DynoVal<CompXorOutput<T>> => new CompXor({ a });
|
||||
|
||||
export class And<T extends "bool" | AllIntTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
T,
|
||||
"and"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({ a, b, outTypeFunc: (aType: T, bType: T) => aType, outKey: "and" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.and === "bool") {
|
||||
return [`${outputs.and} = ${inputs.a} && ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.and} = ${inputs.a} & ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Or<T extends "bool" | AllIntTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
T,
|
||||
"or"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({ a, b, outTypeFunc: (aType: T, bType: T) => aType, outKey: "or" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.or === "bool") {
|
||||
return [`${outputs.or} = ${inputs.a} || ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.or} = ${inputs.a} | ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Xor<T extends "bool" | AllIntTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
T,
|
||||
"xor"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({ a, b, outTypeFunc: (aType: T, bType: T) => aType, outKey: "xor" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.xor === "bool") {
|
||||
return [`${outputs.xor} = ${inputs.a} ^^ ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.xor} = ${inputs.a} ^ ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Not<T extends BoolTypes | AllIntTypes> extends UnaryOp<
|
||||
T,
|
||||
T,
|
||||
"not"
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<T> }) {
|
||||
super({ a, outTypeFunc: (aType: T) => aType, outKey: "not" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.not === "bool") {
|
||||
return [`${outputs.not} = !${inputs.a};`];
|
||||
}
|
||||
return [`${outputs.not} = not(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LessThan<T extends ValueTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
CompareOutput<T>,
|
||||
"lessThan"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({
|
||||
a,
|
||||
b,
|
||||
outTypeFunc: (aType: T, bType: T) => compareOutputType(aType, "lessThan"),
|
||||
outKey: "lessThan",
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.lessThan === "bool") {
|
||||
return [`${outputs.lessThan} = ${inputs.a} < ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.lessThan} = lessThan(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LessThanEqual<T extends ValueTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
CompareOutput<T>,
|
||||
"lessThanEqual"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({
|
||||
a,
|
||||
b,
|
||||
outTypeFunc: (aType: T, bType: T) =>
|
||||
compareOutputType(aType, "lessThanEqual"),
|
||||
outKey: "lessThanEqual",
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.lessThanEqual === "bool") {
|
||||
return [`${outputs.lessThanEqual} = ${inputs.a} <= ${inputs.b};`];
|
||||
}
|
||||
return [
|
||||
`${outputs.lessThanEqual} = lessThanEqual(${inputs.a}, ${inputs.b});`,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GreaterThan<T extends ValueTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
CompareOutput<T>,
|
||||
"greaterThan"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({
|
||||
a,
|
||||
b,
|
||||
outTypeFunc: (aType: T, bType: T) =>
|
||||
compareOutputType(aType, "greaterThan"),
|
||||
outKey: "greaterThan",
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.greaterThan === "bool") {
|
||||
return [`${outputs.greaterThan} = ${inputs.a} > ${inputs.b};`];
|
||||
}
|
||||
return [
|
||||
`${outputs.greaterThan} = greaterThan(${inputs.a}, ${inputs.b});`,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GreaterThanEqual<T extends ValueTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
CompareOutput<T>,
|
||||
"greaterThanEqual"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({
|
||||
a,
|
||||
b,
|
||||
outTypeFunc: (aType: T, bType: T) =>
|
||||
compareOutputType(aType, "greaterThanEqual"),
|
||||
outKey: "greaterThanEqual",
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.greaterThanEqual === "bool") {
|
||||
return [`${outputs.greaterThanEqual} = ${inputs.a} >= ${inputs.b};`];
|
||||
}
|
||||
return [
|
||||
`${outputs.greaterThanEqual} = greaterThanEqual(${inputs.a}, ${inputs.b});`,
|
||||
];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Equal<T extends ValueTypes | BoolTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
EqualOutput<T>,
|
||||
"equal"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({ a, b, outTypeFunc: equalOutputType<T>, outKey: "equal" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.equal === "bool") {
|
||||
return [`${outputs.equal} = ${inputs.a} == ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.equal} = equal(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class NotEqual<T extends ValueTypes | BoolTypes> extends BinaryOp<
|
||||
T,
|
||||
T,
|
||||
NotEqualOutput<T>,
|
||||
"notEqual"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<T>; b: DynoVal<T> }) {
|
||||
super({ a, b, outTypeFunc: notEqualOutputType<T>, outKey: "notEqual" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (this.outTypes.notEqual === "bool") {
|
||||
return [`${outputs.notEqual} = ${inputs.a} != ${inputs.b};`];
|
||||
}
|
||||
return [`${outputs.notEqual} = notEqual(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Any<T extends BoolTypes> extends UnaryOp<T, "bool", "any"> {
|
||||
constructor({ a }: { a: DynoVal<T> }) {
|
||||
super({ a, outTypeFunc: (aType: T) => "bool", outKey: "any" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.any} = any(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class All<T extends BoolTypes> extends UnaryOp<T, "bool", "all"> {
|
||||
constructor({ a }: { a: DynoVal<T> }) {
|
||||
super({ a, outTypeFunc: (aType: T) => "bool", outKey: "all" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.all} = all(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Select<T extends SimpleTypes> extends TrinaryOp<
|
||||
"bool",
|
||||
T,
|
||||
T,
|
||||
T,
|
||||
"select"
|
||||
> {
|
||||
constructor({
|
||||
cond,
|
||||
t,
|
||||
f,
|
||||
}: { cond: DynoVal<"bool">; t: DynoVal<T>; f: DynoVal<T> }) {
|
||||
super({
|
||||
a: cond,
|
||||
b: t,
|
||||
c: f,
|
||||
outKey: "select",
|
||||
outTypeFunc: (aType: "bool", bType: T, cType: T) => bType,
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
const { a: cond, b: t, c: f } = inputs;
|
||||
return [`${outputs.select} = (${cond}) ? (${t}) : (${f});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type CompareOutput<T extends ValueTypes> = T extends ScalarTypes
|
||||
? "bool"
|
||||
: T extends "ivec2" | "uvec2" | "vec2"
|
||||
? "bvec2"
|
||||
: T extends "ivec3" | "uvec3" | "vec3"
|
||||
? "bvec3"
|
||||
: T extends "ivec4" | "uvec4" | "vec4"
|
||||
? "bvec4"
|
||||
: never;
|
||||
|
||||
function compareOutputType<T extends ValueTypes>(
|
||||
type: T,
|
||||
operator: string,
|
||||
): CompareOutput<T> {
|
||||
if (isScalarType(type)) {
|
||||
return "bool" as CompareOutput<T>;
|
||||
}
|
||||
if (type === "ivec2" || type === "uvec2" || type === "vec2") {
|
||||
return "bvec2" as CompareOutput<T>;
|
||||
}
|
||||
if (type === "ivec3" || type === "uvec3" || type === "vec3") {
|
||||
return "bvec3" as CompareOutput<T>;
|
||||
}
|
||||
if (type === "ivec4" || type === "uvec4" || type === "vec4") {
|
||||
return "bvec4" as CompareOutput<T>;
|
||||
}
|
||||
throw new Error(`Invalid ${operator} type: ${type}`);
|
||||
}
|
||||
|
||||
type EqualOutput<A extends ValueTypes | BoolTypes> = A extends ScalarTypes
|
||||
? "bool"
|
||||
: A extends BoolTypes
|
||||
? A
|
||||
: A extends "ivec2" | "uvec2" | "vec2"
|
||||
? "bvec2"
|
||||
: A extends "ivec3" | "uvec3" | "vec3"
|
||||
? "bvec3"
|
||||
: A extends "ivec4" | "uvec4" | "vec4"
|
||||
? "bvec4"
|
||||
: never;
|
||||
|
||||
function equalOutputType<A extends ValueTypes | BoolTypes>(
|
||||
type: A,
|
||||
operator = "equal",
|
||||
): EqualOutput<A> {
|
||||
if (isScalarType(type)) {
|
||||
return "bool" as EqualOutput<A>;
|
||||
}
|
||||
if (isBoolType(type)) {
|
||||
return type as EqualOutput<A>;
|
||||
}
|
||||
if (type === "ivec2" || type === "uvec2" || type === "vec2") {
|
||||
return "bvec2" as EqualOutput<A>;
|
||||
}
|
||||
if (type === "ivec3" || type === "uvec3" || type === "vec3") {
|
||||
return "bvec3" as EqualOutput<A>;
|
||||
}
|
||||
if (type === "ivec4" || type === "uvec4" || type === "vec4") {
|
||||
return "bvec4" as EqualOutput<A>;
|
||||
}
|
||||
throw new Error(`Invalid ${operator} type: ${type}`);
|
||||
}
|
||||
|
||||
type NotEqualOutput<A extends ValueTypes | BoolTypes> = EqualOutput<A>;
|
||||
|
||||
function notEqualOutputType<A extends ValueTypes | BoolTypes>(
|
||||
type: A,
|
||||
): NotEqualOutput<A> {
|
||||
return equalOutputType(type, "notEqual");
|
||||
}
|
||||
|
||||
type CompXorOutput<A extends BoolTypes | AllIntTypes> = A extends BoolTypes
|
||||
? "bool"
|
||||
: A extends IntTypes
|
||||
? "int"
|
||||
: A extends UintTypes
|
||||
? "uint"
|
||||
: never;
|
||||
|
||||
function compXorOutputType<A extends BoolTypes | AllIntTypes>(
|
||||
type: A,
|
||||
): CompXorOutput<A> {
|
||||
if (isBoolType(type)) {
|
||||
return "bool" as CompXorOutput<A>;
|
||||
}
|
||||
if (isIntType(type)) {
|
||||
return "int" as CompXorOutput<A>;
|
||||
}
|
||||
if (isUintType(type)) {
|
||||
return "uint" as CompXorOutput<A>;
|
||||
}
|
||||
throw new Error(`Invalid compXor type: ${type}`);
|
||||
}
|
||||
|
||||
export class CompXor<T extends BoolTypes | AllIntTypes> extends UnaryOp<
|
||||
T,
|
||||
CompXorOutput<T>,
|
||||
"compXor"
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<T> }) {
|
||||
const outType = compXorOutputType(valType(a));
|
||||
super({ a, outTypeFunc: (aType: T) => outType, outKey: "compXor" });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
if (isScalarType(this.outTypes.compXor)) {
|
||||
return [`${outputs.compXor} = ${inputs.a};`];
|
||||
}
|
||||
const components = isVector2Type(outType)
|
||||
? ["x", "y"]
|
||||
: isVector3Type(outType)
|
||||
? ["x", "y", "z"]
|
||||
: ["x", "y", "z", "w"];
|
||||
const operands = components.map((c) => `${inputs.a}.${c}`);
|
||||
const operator = isBoolType(outType) ? "^^" : "^";
|
||||
return [`${outputs.compXor} = ${operands.join(` ${operator} `)};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { BinaryOp, Dyno, TrinaryOp, UnaryOp } from "./base";
|
||||
import {
|
||||
type AddOutput,
|
||||
type ClampOutput,
|
||||
type DivOutput,
|
||||
type IModOutput,
|
||||
type IsInfOutput,
|
||||
type IsNanOutput,
|
||||
type MaxOutput,
|
||||
type MinOutput,
|
||||
type MixOutput,
|
||||
type ModOutput,
|
||||
type MulOutput,
|
||||
type SmoothstepOutput,
|
||||
type StepOutput,
|
||||
type SubOutput,
|
||||
absOutputType,
|
||||
addOutputType,
|
||||
ceilOutputType,
|
||||
clampOutputType,
|
||||
divOutputType,
|
||||
exp2OutputType,
|
||||
expOutputType,
|
||||
floorOutputType,
|
||||
fractOutputType,
|
||||
imodOutputType,
|
||||
inversesqrtOutputType,
|
||||
isInfOutputType,
|
||||
isNanOutputType,
|
||||
log2OutputType,
|
||||
logOutputType,
|
||||
maxOutputType,
|
||||
minOutputType,
|
||||
mixOutputType,
|
||||
modOutputType,
|
||||
modfOutputType,
|
||||
mulOutputType,
|
||||
negOutputType,
|
||||
powOutputType,
|
||||
roundOutputType,
|
||||
signOutputType,
|
||||
smoothstepOutputType,
|
||||
sqrOutputType,
|
||||
sqrtOutputType,
|
||||
stepOutputType,
|
||||
subOutputType,
|
||||
truncOutputType,
|
||||
} from "./mathTypes";
|
||||
import type {
|
||||
AllIntTypes,
|
||||
AllSignedTypes,
|
||||
AllValueTypes,
|
||||
BoolTypes,
|
||||
FloatTypes,
|
||||
SignedTypes,
|
||||
ValueTypes,
|
||||
} from "./types";
|
||||
import { type DynoVal, valType } from "./value";
|
||||
|
||||
export const add = <A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<AddOutput<A, B>> => new Add({ a, b });
|
||||
export const sub = <A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<SubOutput<A, B>> => new Sub({ a, b });
|
||||
export const mul = <A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<MulOutput<A, B>> => new Mul({ a, b });
|
||||
export const div = <A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<DivOutput<A, B>> => new Div({ a, b });
|
||||
export const imod = <A extends AllIntTypes, B extends AllIntTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<IModOutput<A, B>> => new IMod({ a, b });
|
||||
export const mod = <A extends FloatTypes, B extends FloatTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<ModOutput<A, B>> => new Mod({ a, b });
|
||||
export const modf = <A extends FloatTypes>(a: DynoVal<A>) =>
|
||||
new Modf({ a }).outputs;
|
||||
|
||||
export const neg = <A extends AllSignedTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Neg({ a });
|
||||
export const abs = <A extends SignedTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Abs({ a });
|
||||
export const sign = <A extends SignedTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Sign({ a });
|
||||
export const floor = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Floor({ a });
|
||||
export const ceil = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Ceil({ a });
|
||||
export const trunc = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Trunc({ a });
|
||||
export const round = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Round({ a });
|
||||
export const fract = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Fract({ a });
|
||||
|
||||
export const pow = <A extends FloatTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<A>,
|
||||
): DynoVal<A> => new Pow({ a, b });
|
||||
export const exp = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Exp({ a });
|
||||
export const exp2 = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Exp2({ a });
|
||||
export const log = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Log({ a });
|
||||
export const log2 = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Log2({ a });
|
||||
export const sqr = <A extends ValueTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Sqr({ a });
|
||||
export const sqrt = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new Sqrt({ a });
|
||||
export const inversesqrt = <A extends FloatTypes>(a: DynoVal<A>): DynoVal<A> =>
|
||||
new InverseSqrt({ a });
|
||||
|
||||
export const min = <A extends ValueTypes, B extends ValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<MinOutput<A, B>> => new Min({ a, b });
|
||||
export const max = <A extends ValueTypes, B extends ValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<B>,
|
||||
): DynoVal<MaxOutput<A, B>> => new Max({ a, b });
|
||||
export const clamp = <A extends ValueTypes, MinMax extends ValueTypes>(
|
||||
a: DynoVal<A>,
|
||||
min: DynoVal<MinMax>,
|
||||
max: DynoVal<MinMax>,
|
||||
): DynoVal<ClampOutput<A, MinMax>> => new Clamp({ a, min, max });
|
||||
export const mix = <A extends FloatTypes, T extends FloatTypes | BoolTypes>(
|
||||
a: DynoVal<A>,
|
||||
b: DynoVal<A>,
|
||||
t: DynoVal<T>,
|
||||
): DynoVal<MixOutput<A, T>> => new Mix({ a, b, t });
|
||||
export const step = <A extends FloatTypes, B extends FloatTypes>(
|
||||
edge: DynoVal<A>,
|
||||
x: DynoVal<B>,
|
||||
): DynoVal<StepOutput<A, B>> => new Step({ edge, x });
|
||||
export const smoothstep = <X extends FloatTypes, Edge extends X | "float">(
|
||||
edge0: DynoVal<Edge>,
|
||||
edge1: DynoVal<Edge>,
|
||||
x: DynoVal<X>,
|
||||
): DynoVal<SmoothstepOutput<Edge, Edge, X>> =>
|
||||
new Smoothstep({ edge0, edge1, x });
|
||||
|
||||
export const isNan = <A extends FloatTypes>(
|
||||
a: DynoVal<A>,
|
||||
): DynoVal<IsNanOutput<A>> => new IsNan({ a });
|
||||
export const isInf = <A extends FloatTypes>(
|
||||
a: DynoVal<A>,
|
||||
): DynoVal<IsInfOutput<A>> => new IsInf({ a });
|
||||
|
||||
export class Add<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> extends BinaryOp<A, B, AddOutput<A, B>, "sum"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "sum", outTypeFunc: addOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.sum} = ${inputs.a} + ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Sub<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> extends BinaryOp<A, B, SubOutput<A, B>, "difference"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "difference", outTypeFunc: subOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.difference} = ${inputs.a} - ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Mul<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> extends BinaryOp<A, B, MulOutput<A, B>, "product"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "product", outTypeFunc: mulOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.product} = ${inputs.a} * ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Div<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> extends BinaryOp<A, B, DivOutput<A, B>, "quotient"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "quotient", outTypeFunc: divOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.quotient} = ${inputs.a} / ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IMod<
|
||||
A extends AllIntTypes,
|
||||
B extends AllIntTypes,
|
||||
> extends BinaryOp<A, B, IModOutput<A, B>, "remainder"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "remainder", outTypeFunc: imodOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.remainder} = ${inputs.a} % ${inputs.b};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Mod<A extends FloatTypes, B extends FloatTypes> extends BinaryOp<
|
||||
A,
|
||||
B,
|
||||
ModOutput<A, B>,
|
||||
"remainder"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "remainder", outTypeFunc: modOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.remainder} = mod(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Modf<A extends FloatTypes> extends Dyno<
|
||||
{ a: A },
|
||||
{ fract: A; integer: A }
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
const inTypes = { a: valType(a) };
|
||||
const outType = modfOutputType<A>(inTypes.a);
|
||||
const outTypes = {
|
||||
fract: outType,
|
||||
integer: outType,
|
||||
};
|
||||
super({ inTypes, outTypes, inputs: { a } });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.fract} = modf(${inputs.a}, ${outputs.integer});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Neg<A extends AllSignedTypes> extends UnaryOp<A, A, "neg"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "neg", outTypeFunc: negOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.neg} = -${inputs.a};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Abs<A extends SignedTypes> extends UnaryOp<A, A, "abs"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "abs", outTypeFunc: absOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.abs} = abs(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Sign<A extends SignedTypes> extends UnaryOp<A, A, "sign"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "sign", outTypeFunc: signOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.sign} = sign(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Floor<A extends FloatTypes> extends UnaryOp<A, A, "floor"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "floor", outTypeFunc: floorOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.floor} = floor(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Ceil<A extends FloatTypes> extends UnaryOp<A, A, "ceil"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "ceil", outTypeFunc: ceilOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.ceil} = ceil(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Trunc<A extends FloatTypes> extends UnaryOp<A, A, "trunc"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "trunc", outTypeFunc: truncOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.trunc} = trunc(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Round<A extends FloatTypes> extends UnaryOp<A, A, "round"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "round", outTypeFunc: roundOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.round} = round(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Fract<A extends FloatTypes> extends UnaryOp<A, A, "fract"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "fract", outTypeFunc: fractOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.fract} = fract(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Pow<A extends FloatTypes> extends BinaryOp<A, A, A, "power"> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<A> }) {
|
||||
super({ a, b, outKey: "power", outTypeFunc: powOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.power} = pow(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Exp<A extends FloatTypes> extends UnaryOp<A, A, "exp"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "exp", outTypeFunc: expOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.exp} = exp(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Exp2<A extends FloatTypes> extends UnaryOp<A, A, "exp2"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "exp2", outTypeFunc: exp2OutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.exp2} = exp2(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Log<A extends FloatTypes> extends UnaryOp<A, A, "log"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "log", outTypeFunc: logOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.log} = log(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Log2<A extends FloatTypes> extends UnaryOp<A, A, "log2"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "log2", outTypeFunc: log2OutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.log2} = log2(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Sqr<A extends ValueTypes> extends UnaryOp<A, A, "sqr"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "sqr", outTypeFunc: sqrOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.sqr} = ${inputs.a} * ${inputs.a};`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Sqrt<A extends FloatTypes> extends UnaryOp<A, A, "sqrt"> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "sqrt", outTypeFunc: sqrtOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.sqrt} = sqrt(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class InverseSqrt<A extends FloatTypes> extends UnaryOp<
|
||||
A,
|
||||
A,
|
||||
"inversesqrt"
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "inversesqrt", outTypeFunc: inversesqrtOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.inversesqrt} = inversesqrt(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Min<A extends ValueTypes, B extends ValueTypes> extends BinaryOp<
|
||||
A,
|
||||
B,
|
||||
MinOutput<A, B>,
|
||||
"min"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "min", outTypeFunc: minOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.min} = min(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Max<A extends ValueTypes, B extends ValueTypes> extends BinaryOp<
|
||||
A,
|
||||
B,
|
||||
MaxOutput<A, B>,
|
||||
"max"
|
||||
> {
|
||||
constructor({ a, b }: { a: DynoVal<A>; b: DynoVal<B> }) {
|
||||
super({ a, b, outKey: "max", outTypeFunc: maxOutputType<A, B> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.max} = max(${inputs.a}, ${inputs.b});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Clamp<
|
||||
A extends ValueTypes,
|
||||
MinMax extends ValueTypes,
|
||||
> extends TrinaryOp<A, MinMax, MinMax, ClampOutput<A, MinMax>, "clamp"> {
|
||||
constructor({
|
||||
a,
|
||||
min,
|
||||
max,
|
||||
}: { a: DynoVal<A>; min: DynoVal<MinMax>; max: DynoVal<MinMax> }) {
|
||||
super({
|
||||
a,
|
||||
b: min,
|
||||
c: max,
|
||||
outKey: "clamp",
|
||||
outTypeFunc: clampOutputType<A, MinMax>,
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
const { a, b: min, c: max } = inputs;
|
||||
return [`${outputs.clamp} = clamp(${a}, ${min}, ${max});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Mix<
|
||||
A extends FloatTypes,
|
||||
T extends FloatTypes | BoolTypes,
|
||||
> extends TrinaryOp<A, A, T, MixOutput<A, T>, "mix"> {
|
||||
constructor({ a, b, t }: { a: DynoVal<A>; b: DynoVal<A>; t: DynoVal<T> }) {
|
||||
super({ a, b, c: t, outKey: "mix", outTypeFunc: mixOutputType<A, T> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
const { a, b, c: t } = inputs;
|
||||
return [`${outputs.mix} = mix(${a}, ${b}, ${t});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Step<
|
||||
Edge extends FloatTypes,
|
||||
X extends FloatTypes,
|
||||
> extends BinaryOp<Edge, X, StepOutput<Edge, X>, "step"> {
|
||||
constructor({ edge, x }: { edge: DynoVal<Edge>; x: DynoVal<X> }) {
|
||||
super({
|
||||
a: edge,
|
||||
b: x,
|
||||
outKey: "step",
|
||||
outTypeFunc: stepOutputType<Edge, X>,
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
const { a: edge, b: x } = inputs;
|
||||
return [`${outputs.step} = step(${edge}, ${x});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Smoothstep<
|
||||
X extends FloatTypes,
|
||||
Edge extends X | "float",
|
||||
> extends TrinaryOp<
|
||||
Edge,
|
||||
Edge,
|
||||
X,
|
||||
SmoothstepOutput<Edge, Edge, X>,
|
||||
"smoothstep"
|
||||
> {
|
||||
constructor({
|
||||
edge0,
|
||||
edge1,
|
||||
x,
|
||||
}: { edge0: DynoVal<Edge>; edge1: DynoVal<Edge>; x: DynoVal<X> }) {
|
||||
super({
|
||||
a: edge0,
|
||||
b: edge1,
|
||||
c: x,
|
||||
outKey: "smoothstep",
|
||||
outTypeFunc: smoothstepOutputType<Edge, Edge, X>,
|
||||
});
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
const { a: edge0, b: edge1, c: x } = inputs;
|
||||
return [`${outputs.smoothstep} = smoothstep(${edge0}, ${edge1}, ${x});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IsNan<A extends FloatTypes> extends UnaryOp<
|
||||
A,
|
||||
IsNanOutput<A>,
|
||||
"isNan"
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "isNan", outTypeFunc: isNanOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.isNan} = isNan(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IsInf<A extends FloatTypes> extends UnaryOp<
|
||||
A,
|
||||
IsInfOutput<A>,
|
||||
"isInf"
|
||||
> {
|
||||
constructor({ a }: { a: DynoVal<A> }) {
|
||||
super({ a, outKey: "isInf", outTypeFunc: isInfOutputType<A> });
|
||||
this.statements = ({ inputs, outputs }) => {
|
||||
return [`${outputs.isInf} = isInf(${inputs.a});`];
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import {
|
||||
type AllFloatTypes,
|
||||
type AllIntTypes,
|
||||
type AllSignedTypes,
|
||||
type AllValueTypes,
|
||||
type BaseType,
|
||||
type BoolTypes,
|
||||
type FloatTypes,
|
||||
type IntTypes,
|
||||
type SignedTypes,
|
||||
type UintTypes,
|
||||
type ValueTypes,
|
||||
isAllFloatType,
|
||||
isFloatType,
|
||||
isIntType,
|
||||
isMat2,
|
||||
isMat3,
|
||||
isMat4,
|
||||
isUintType,
|
||||
} from "./types";
|
||||
|
||||
export type AddOutput<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> = BaseType &
|
||||
(A extends B
|
||||
? A
|
||||
: A extends "int"
|
||||
? B extends IntTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "int"
|
||||
? A extends IntTypes
|
||||
? A
|
||||
: never
|
||||
: A extends "uint"
|
||||
? B extends UintTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "uint"
|
||||
? A extends UintTypes
|
||||
? A
|
||||
: never
|
||||
: A extends "float"
|
||||
? B extends AllFloatTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "float"
|
||||
? A extends AllFloatTypes
|
||||
? A
|
||||
: never
|
||||
: never);
|
||||
|
||||
export type SubOutput<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> = AddOutput<A, B>;
|
||||
|
||||
export type MulOutput<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> = BaseType &
|
||||
(A extends "int"
|
||||
? B extends IntTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "int"
|
||||
? A extends IntTypes
|
||||
? A
|
||||
: never
|
||||
: A extends "uint"
|
||||
? B extends UintTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "uint"
|
||||
? A extends UintTypes
|
||||
? A
|
||||
: never
|
||||
: A extends "float"
|
||||
? B extends AllFloatTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "float"
|
||||
? A extends AllFloatTypes
|
||||
? A
|
||||
: never
|
||||
: A extends IntTypes
|
||||
? B extends A
|
||||
? A
|
||||
: never
|
||||
: B extends IntTypes
|
||||
? A extends B
|
||||
? A
|
||||
: never
|
||||
: A extends UintTypes
|
||||
? B extends A
|
||||
? A
|
||||
: never
|
||||
: B extends UintTypes
|
||||
? A extends B
|
||||
? A
|
||||
: never
|
||||
: // Vector * Matrix/Vector
|
||||
A extends "vec2"
|
||||
? B extends "vec2" | "mat2" | "mat2x2"
|
||||
? "vec2"
|
||||
: B extends "mat3x2"
|
||||
? "vec3"
|
||||
: B extends "mat4x2"
|
||||
? "vec4"
|
||||
: never
|
||||
: A extends "vec3"
|
||||
? B extends "mat2x3"
|
||||
? "vec2"
|
||||
: B extends "vec3" | "mat3" | "mat3x3"
|
||||
? "vec3"
|
||||
: B extends "mat4x3"
|
||||
? "vec4"
|
||||
: never
|
||||
: A extends "vec4"
|
||||
? B extends "mat2x4"
|
||||
? "vec2"
|
||||
: B extends "mat3x4"
|
||||
? "vec3"
|
||||
: B extends "vec4" | "mat4" | "mat4x4"
|
||||
? "vec4"
|
||||
: never
|
||||
: // Matrix * Vector
|
||||
B extends "vec2"
|
||||
? A extends "mat2" | "mat2x2"
|
||||
? "vec2"
|
||||
: A extends "mat2x3"
|
||||
? "vec3"
|
||||
: A extends "mat2x4"
|
||||
? "vec4"
|
||||
: never
|
||||
: B extends "vec3"
|
||||
? A extends "mat3x2"
|
||||
? "vec2"
|
||||
: A extends "mat3" | "mat3x3"
|
||||
? "vec3"
|
||||
: A extends "mat3x4"
|
||||
? "vec4"
|
||||
: never
|
||||
: B extends "vec4"
|
||||
? A extends "mat4x2"
|
||||
? "vec2"
|
||||
: A extends "mat4x3"
|
||||
? "vec3"
|
||||
: A extends "mat4" | "mat4x4"
|
||||
? "vec4"
|
||||
: never
|
||||
: // Matrix * Matrix: mat{Acols}x{Arows} * mat{Bcols}x{Brows} => mat{Bcols}x{Arows}
|
||||
A extends "mat2" | "mat2x2" // Acols = 2 => Brows = 2
|
||||
? B extends "mat2" | "mat2x2"
|
||||
? "mat2"
|
||||
: B extends "mat3x2"
|
||||
? "mat3x2"
|
||||
: B extends "mat4x2"
|
||||
? "mat4x2"
|
||||
: never
|
||||
: A extends "mat2x3" // Acols = 2 => Brows = 2
|
||||
? B extends "mat2" | "mat2x2"
|
||||
? "mat2x3"
|
||||
: B extends "mat3x2"
|
||||
? "mat3"
|
||||
: B extends "mat4x2"
|
||||
? "mat4x3"
|
||||
: never
|
||||
: A extends "mat2x4" // Acols = 2 => Brows = 2
|
||||
? B extends "mat2" | "mat2x2"
|
||||
? "mat2x4"
|
||||
: B extends "mat3x2"
|
||||
? "mat3x4"
|
||||
: B extends "mat4x2"
|
||||
? "mat4"
|
||||
: never
|
||||
: A extends "mat3x2" // Acols = 3 => Brows = 3
|
||||
? B extends "mat2x3"
|
||||
? "mat2"
|
||||
: B extends "mat3" | "mat3x3"
|
||||
? "mat3x2"
|
||||
: B extends "mat4x3"
|
||||
? "mat4x2"
|
||||
: never
|
||||
: A extends "mat3" | "mat3x3" // Acols = 3 => Brows = 3
|
||||
? B extends "mat2x3"
|
||||
? "mat2x3"
|
||||
: B extends "mat3" | "mat3x3"
|
||||
? "mat3"
|
||||
: B extends "mat4x3"
|
||||
? "mat4x3"
|
||||
: never
|
||||
: A extends "mat3x4" // Acols = 3 => Brows = 3
|
||||
? B extends "mat2x3"
|
||||
? "mat2x4"
|
||||
: B extends "mat3" | "mat3x3"
|
||||
? "mat3x4"
|
||||
: B extends "mat4x3"
|
||||
? "mat4"
|
||||
: never
|
||||
: A extends "mat4x2" // Acols = 4 => Brows = 4
|
||||
? B extends "mat2x4"
|
||||
? "mat2"
|
||||
: B extends "mat3x4"
|
||||
? "mat3x2"
|
||||
: B extends
|
||||
| "mat4"
|
||||
| "mat4x4"
|
||||
? "mat4x2"
|
||||
: never
|
||||
: A extends "mat4x3" // Acols = 4 => Brows = 4
|
||||
? B extends "mat2x4"
|
||||
? "mat2x3"
|
||||
: B extends "mat3x4"
|
||||
? "mat3"
|
||||
: B extends
|
||||
| "mat4"
|
||||
| "mat4x4"
|
||||
? "mat4x3"
|
||||
: never
|
||||
: A extends "mat4" | "mat4x4" // Acols = 4 => Brows = 4
|
||||
? B extends "mat2x4"
|
||||
? "mat2x4"
|
||||
: B extends "mat3x4"
|
||||
? "mat3x4"
|
||||
: B extends
|
||||
| "mat4"
|
||||
| "mat4x4"
|
||||
? "mat4"
|
||||
: never
|
||||
: never);
|
||||
|
||||
export type DivOutput<
|
||||
A extends AllValueTypes,
|
||||
B extends AllValueTypes,
|
||||
> = AddOutput<A, B>;
|
||||
|
||||
export type IModOutput<
|
||||
A extends AllIntTypes,
|
||||
B extends AllIntTypes,
|
||||
> = BaseType &
|
||||
(A extends B
|
||||
? A
|
||||
: A extends "int"
|
||||
? B extends IntTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "int"
|
||||
? A extends IntTypes
|
||||
? A
|
||||
: never
|
||||
: A extends "uint"
|
||||
? B extends UintTypes
|
||||
? B
|
||||
: never
|
||||
: B extends "uint"
|
||||
? A extends UintTypes
|
||||
? A
|
||||
: never
|
||||
: never);
|
||||
|
||||
export type ModOutput<A extends FloatTypes, B extends FloatTypes> = BaseType &
|
||||
(A extends B ? A : B extends "float" ? A : never);
|
||||
|
||||
export type PowOutput<A extends FloatTypes, B extends FloatTypes> = BaseType &
|
||||
(A extends B ? A : never);
|
||||
|
||||
export type MinOutput<A extends ValueTypes, B extends ValueTypes> = BaseType &
|
||||
(A extends B
|
||||
? A
|
||||
: B extends "float"
|
||||
? A extends FloatTypes
|
||||
? A
|
||||
: never
|
||||
: B extends "int"
|
||||
? A extends IntTypes
|
||||
? A
|
||||
: never
|
||||
: B extends "uint"
|
||||
? A extends UintTypes
|
||||
? A
|
||||
: never
|
||||
: never);
|
||||
export type MaxOutput<A extends ValueTypes, B extends ValueTypes> = MinOutput<
|
||||
A,
|
||||
B
|
||||
>;
|
||||
export type ClampOutput<A extends ValueTypes, B extends ValueTypes> = BaseType &
|
||||
(B extends "float"
|
||||
? A extends FloatTypes
|
||||
? A
|
||||
: never
|
||||
: B extends "int"
|
||||
? A extends IntTypes
|
||||
? A
|
||||
: never
|
||||
: B extends "uint"
|
||||
? A extends UintTypes
|
||||
? A
|
||||
: never
|
||||
: never);
|
||||
export type MixOutput<
|
||||
A extends FloatTypes,
|
||||
T extends FloatTypes | BoolTypes,
|
||||
> = BaseType &
|
||||
(T extends A
|
||||
? A
|
||||
: T extends "float"
|
||||
? A
|
||||
: T extends "bool"
|
||||
? A extends "float"
|
||||
? A
|
||||
: never
|
||||
: T extends "bvec2"
|
||||
? A extends "vec2"
|
||||
? A
|
||||
: never
|
||||
: T extends "bvec3"
|
||||
? A extends "vec3"
|
||||
? A
|
||||
: never
|
||||
: T extends "bvec4"
|
||||
? A extends "vec4"
|
||||
? A
|
||||
: never
|
||||
: never);
|
||||
export type StepOutput<A extends FloatTypes, B extends FloatTypes> = BaseType &
|
||||
(A extends B ? B : A extends "float" ? B : never);
|
||||
export type SmoothstepOutput<
|
||||
A extends FloatTypes,
|
||||
B extends FloatTypes,
|
||||
C extends FloatTypes,
|
||||
> = BaseType &
|
||||
(A extends B ? (A extends C ? C : A extends "float" ? C : never) : never);
|
||||
|
||||
export type IsNanOutput<A extends FloatTypes> = BaseType &
|
||||
(A extends "float"
|
||||
? "bool"
|
||||
: A extends "vec2"
|
||||
? "bvec2"
|
||||
: A extends "vec3"
|
||||
? "bvec3"
|
||||
: A extends "vec4"
|
||||
? "bvec4"
|
||||
: never);
|
||||
export type IsInfOutput<A extends FloatTypes> = IsNanOutput<A>;
|
||||
|
||||
// // Run-time type helper functions
|
||||
|
||||
export function addOutputType<A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
operation = "add",
|
||||
): AddOutput<A, B> {
|
||||
const error = () => {
|
||||
throw new Error(`Invalid ${operation} types: ${a}, ${b}`);
|
||||
};
|
||||
// @ts-ignore
|
||||
if (a === b) return a as AddOutput<A, B>;
|
||||
if (a === "int") {
|
||||
if (isIntType(b)) return b as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
if (b === "int") {
|
||||
if (isIntType(a)) return a as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
if (a === "uint") {
|
||||
if (isUintType(b)) return b as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
if (b === "uint") {
|
||||
if (isUintType(a)) return a as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
if (a === "float") {
|
||||
if (isAllFloatType(b)) return b as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
if (b === "float") {
|
||||
if (isAllFloatType(a)) return a as AddOutput<A, B>;
|
||||
error();
|
||||
}
|
||||
throw new Error(`Invalid ${operation} types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function subOutputType<A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): SubOutput<A, B> {
|
||||
return addOutputType(a, b, "sub");
|
||||
}
|
||||
|
||||
export function mulOutputType<A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): MulOutput<A, B> {
|
||||
const error = () => {
|
||||
throw new Error(`Invalid mul types: ${a}, ${b}`);
|
||||
};
|
||||
const result = (value: unknown) => value as MulOutput<A, B>;
|
||||
if (a === "int") {
|
||||
if (isIntType(b)) return result(b);
|
||||
error();
|
||||
}
|
||||
if (b === "int") {
|
||||
if (isIntType(a)) return result(a);
|
||||
error();
|
||||
}
|
||||
if (a === "uint") {
|
||||
if (isUintType(b)) return result(b);
|
||||
error();
|
||||
}
|
||||
if (b === "uint") {
|
||||
if (isUintType(a)) return result(a);
|
||||
error();
|
||||
}
|
||||
if (a === "float") {
|
||||
if (isAllFloatType(b)) return result(b);
|
||||
error();
|
||||
}
|
||||
if (b === "float") {
|
||||
if (isAllFloatType(a)) return result(a);
|
||||
error();
|
||||
}
|
||||
if (isIntType(a) || isUintType(a) || isIntType(b) || isUintType(b)) {
|
||||
// @ts-ignore
|
||||
if (a === b) return result(a);
|
||||
error();
|
||||
}
|
||||
// Vector * Matrix/Vector
|
||||
if (a === "vec2") {
|
||||
if (b === "vec2" || isMat2(b)) return result("vec2");
|
||||
if (b === "mat3x2") return result("vec3");
|
||||
if (b === "mat4x2") return result("vec4");
|
||||
error();
|
||||
}
|
||||
if (a === "vec3") {
|
||||
if (b === "mat2x3") return result("vec2");
|
||||
if (b === "vec3" || isMat3(b)) return result("vec3");
|
||||
if (b === "mat4x3") return result("vec4");
|
||||
error();
|
||||
}
|
||||
if (a === "vec4") {
|
||||
if (b === "mat2x4") return result("vec2");
|
||||
if (b === "mat3x4") return result("vec3");
|
||||
if (b === "vec4" || isMat4(b)) return result("vec4");
|
||||
error();
|
||||
}
|
||||
// Matrix * Vector
|
||||
if (b === "vec2") {
|
||||
if (isMat2(a)) return result("vec2");
|
||||
if (a === "mat2x3") return result("vec3");
|
||||
if (a === "mat2x4") return result("vec4");
|
||||
error();
|
||||
}
|
||||
if (b === "vec3") {
|
||||
if (a === "mat3x2") return result("vec2");
|
||||
if (isMat3(a)) return result("vec3");
|
||||
if (a === "mat3x4") return result("vec4");
|
||||
error();
|
||||
}
|
||||
if (b === "vec4") {
|
||||
if (a === "mat4x2") return result("vec2");
|
||||
if (a === "mat4x3") return result("vec3");
|
||||
if (isMat4(a)) return result("vec4");
|
||||
error();
|
||||
}
|
||||
// Matrix * Matrix: mat{Acols}x{Arows} * mat{Bcols}x{Brows} => mat{Bcols}x{Arows}
|
||||
if (isMat2(a)) {
|
||||
if (isMat2(b)) return result("mat2");
|
||||
if (b === "mat3x2") return result("mat3x2");
|
||||
if (b === "mat4x2") return result("mat4x2");
|
||||
error();
|
||||
}
|
||||
if (a === "mat2x3") {
|
||||
if (isMat2(b)) return result("mat2x3");
|
||||
if (b === "mat3x2") return result("mat3");
|
||||
if (b === "mat4x2") return result("mat4x3");
|
||||
error();
|
||||
}
|
||||
if (a === "mat2x4") {
|
||||
if (isMat2(b)) return result("mat2x4");
|
||||
if (b === "mat3x2") return result("mat3x4");
|
||||
if (b === "mat4x2") return result("mat4");
|
||||
error();
|
||||
}
|
||||
if (a === "mat3x2") {
|
||||
if (b === "mat2x3") return result("mat2");
|
||||
if (isMat3(b)) return result("mat3x2");
|
||||
if (b === "mat4x3") return result("mat4x2");
|
||||
error();
|
||||
}
|
||||
if (isMat3(a)) {
|
||||
if (b === "mat2x3") return result("mat2x3");
|
||||
if (isMat3(b)) return result("mat3");
|
||||
if (b === "mat4x3") return result("mat4x3");
|
||||
error();
|
||||
}
|
||||
if (a === "mat3x4") {
|
||||
if (b === "mat2x3") return result("mat2x4");
|
||||
if (isMat3(b)) return result("mat3x4");
|
||||
if (b === "mat4x3") return result("mat4");
|
||||
error();
|
||||
}
|
||||
if (a === "mat4x2") {
|
||||
if (b === "mat2x4") return result("mat2");
|
||||
if (b === "mat3x4") return result("mat3x2");
|
||||
if (isMat4(b)) return result("mat4x2");
|
||||
error();
|
||||
}
|
||||
if (a === "mat4x3") {
|
||||
if (b === "mat2x4") return result("mat2x3");
|
||||
if (b === "mat3x4") return result("mat3");
|
||||
if (isMat4(b)) return result("mat4x3");
|
||||
error();
|
||||
}
|
||||
if (isMat4(a)) {
|
||||
if (b === "mat2x4") return result("mat2x4");
|
||||
if (b === "mat3x4") return result("mat3x4");
|
||||
if (isMat4(b)) return result("mat4");
|
||||
error();
|
||||
}
|
||||
throw new Error(`Invalid mul types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function divOutputType<A extends AllValueTypes, B extends AllValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): DivOutput<A, B> {
|
||||
return addOutputType(a, b, "div");
|
||||
}
|
||||
|
||||
export function imodOutputType<A extends AllIntTypes, B extends AllIntTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): IModOutput<A, B> {
|
||||
// @ts-ignore
|
||||
if (a === b) return a as IModOutput<A, B>;
|
||||
if (a === "int") {
|
||||
if (isIntType(b)) return b as IModOutput<A, B>;
|
||||
} else if (b === "int") {
|
||||
if (isIntType(a)) return a as IModOutput<A, B>;
|
||||
} else if (a === "uint") {
|
||||
if (isUintType(b)) return b as IModOutput<A, B>;
|
||||
} else if (b === "uint") {
|
||||
if (isUintType(a)) return a as IModOutput<A, B>;
|
||||
}
|
||||
throw new Error(`Invalid imod types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function modOutputType<A extends FloatTypes, B extends FloatTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): ModOutput<A, B> {
|
||||
// @ts-ignore
|
||||
if (a === b || b === "float") return a as ModOutput<A, B>;
|
||||
throw new Error(`Invalid mod types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function modfOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function negOutputType<A extends AllSignedTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function absOutputType<A extends SignedTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function signOutputType<A extends SignedTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function floorOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function ceilOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function truncOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function roundOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function fractOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function powOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function expOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function exp2OutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function logOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function log2OutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function sqrOutputType<A extends ValueTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function sqrtOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function inversesqrtOutputType<A extends FloatTypes>(a: A): A {
|
||||
return a;
|
||||
}
|
||||
|
||||
export function minOutputType<A extends ValueTypes, B extends ValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
operation = "min",
|
||||
): MinOutput<A, B> {
|
||||
// @ts-ignore
|
||||
if (a === b) return a as MinOutput<A, B>;
|
||||
if (b === "float") {
|
||||
if (isFloatType(a)) return a as MinOutput<A, B>;
|
||||
} else if (b === "int") {
|
||||
if (isIntType(a)) return a as MinOutput<A, B>;
|
||||
} else if (b === "uint") {
|
||||
if (isUintType(a)) return a as MinOutput<A, B>;
|
||||
}
|
||||
throw new Error(`Invalid ${operation} types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function maxOutputType<A extends ValueTypes, B extends ValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): MaxOutput<A, B> {
|
||||
return minOutputType(a, b, "max");
|
||||
}
|
||||
|
||||
export function clampOutputType<A extends ValueTypes, B extends ValueTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
_c: B,
|
||||
): ClampOutput<A, B> {
|
||||
if (b === "float") {
|
||||
if (isFloatType(a)) return a as ClampOutput<A, B>;
|
||||
} else if (b === "int") {
|
||||
if (isIntType(a)) return a as ClampOutput<A, B>;
|
||||
} else if (b === "uint") {
|
||||
if (isUintType(a)) return a as ClampOutput<A, B>;
|
||||
}
|
||||
throw new Error(`Invalid clamp types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function mixOutputType<
|
||||
A extends FloatTypes,
|
||||
C extends FloatTypes | BoolTypes,
|
||||
>(a: A, b: A, c: C): MixOutput<A, C> {
|
||||
// @ts-ignore
|
||||
if (c === a) return a as MixOutput<A, C>;
|
||||
if (c === "float") return a as MixOutput<A, C>;
|
||||
if (c === "bool" && a === "float") return a as MixOutput<A, C>;
|
||||
if (c === "bvec2" && a === "vec2") return a as MixOutput<A, C>;
|
||||
if (c === "bvec3" && a === "vec3") return a as MixOutput<A, C>;
|
||||
if (c === "bvec4" && a === "vec4") return a as MixOutput<A, C>;
|
||||
throw new Error(`Invalid mix types: ${a}, ${b}, ${c}`);
|
||||
}
|
||||
|
||||
export function stepOutputType<A extends FloatTypes, B extends FloatTypes>(
|
||||
a: A,
|
||||
b: B,
|
||||
): StepOutput<A, B> {
|
||||
// @ts-ignore
|
||||
if (a === b || b === "float") return b as StepOutput<A, B>;
|
||||
throw new Error(`Invalid step types: ${a}, ${b}`);
|
||||
}
|
||||
|
||||
export function smoothstepOutputType<
|
||||
A extends FloatTypes,
|
||||
B extends FloatTypes,
|
||||
C extends FloatTypes,
|
||||
>(a: A, b: B, c: C): SmoothstepOutput<A, B, C> {
|
||||
// @ts-ignore
|
||||
if (a === b) {
|
||||
if (a === c || a === "float") return c as SmoothstepOutput<A, B, C>;
|
||||
}
|
||||
throw new Error(`Invalid smoothstep types: ${a}, ${b}, ${c}`);
|
||||
}
|
||||
|
||||
export function isNanOutputType<A extends FloatTypes>(
|
||||
a: A,
|
||||
operation = "isNan",
|
||||
): IsNanOutput<A> {
|
||||
if (a === "float") return "bool" as IsNanOutput<A>;
|
||||
if (a === "vec2") return "bvec2" as IsNanOutput<A>;
|
||||
if (a === "vec3") return "bvec3" as IsNanOutput<A>;
|
||||
if (a === "vec4") return "bvec4" as IsNanOutput<A>;
|
||||
throw new Error(`Invalid ${operation} types: ${a}`);
|
||||
}
|
||||
|
||||
export function isInfOutputType<A extends FloatTypes>(a: A): IsInfOutput<A> {
|
||||
return isNanOutputType(a, "isInf");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Dyno, unindentLines } from "./base";
|
||||
import { Gsplat, defineGsplat } from "./splats";
|
||||
import {
|
||||
DynoOutput,
|
||||
type DynoVal,
|
||||
type DynoValue,
|
||||
type HasDynoOut,
|
||||
} from "./value";
|
||||
|
||||
export const outputPackedSplat = (gsplat: DynoVal<typeof Gsplat>) =>
|
||||
new OutputPackedSplat({ gsplat });
|
||||
export const outputRgba8 = (rgba8: DynoVal<"vec4">) =>
|
||||
new OutputRgba8({ rgba8 });
|
||||
|
||||
export class OutputPackedSplat
|
||||
extends Dyno<{ gsplat: typeof Gsplat }, { output: "uvec4" }>
|
||||
implements HasDynoOut<"uvec4">
|
||||
{
|
||||
constructor({ gsplat }: { gsplat?: DynoVal<typeof Gsplat> }) {
|
||||
super({
|
||||
inTypes: { gsplat: Gsplat },
|
||||
inputs: { gsplat },
|
||||
globals: () => [defineGsplat],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { output } = outputs;
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
const { gsplat } = inputs;
|
||||
if (gsplat) {
|
||||
return unindentLines(`
|
||||
if (isGsplatActive(${gsplat}.flags)) {
|
||||
${output} = packSplat(${gsplat}.center, ${gsplat}.scales, ${gsplat}.quaternion, ${gsplat}.rgba);
|
||||
} else {
|
||||
${output} = uvec4(0u, 0u, 0u, 0u);
|
||||
}
|
||||
`);
|
||||
}
|
||||
return [`${output} = uvec4(0u, 0u, 0u, 0u);`];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<"uvec4"> {
|
||||
return new DynoOutput(this, "output");
|
||||
}
|
||||
}
|
||||
|
||||
export class OutputRgba8
|
||||
extends Dyno<{ rgba8: "vec4" }, { rgba8: "vec4" }>
|
||||
implements HasDynoOut<"vec4">
|
||||
{
|
||||
constructor({ rgba8 }: { rgba8?: DynoVal<"vec4"> }) {
|
||||
super({
|
||||
inTypes: { rgba8: "vec4" },
|
||||
inputs: { rgba8 },
|
||||
statements: ({ inputs, outputs }) => [
|
||||
`target = ${inputs.rgba8 ?? "vec4(0.0, 0.0, 0.0, 0.0)"};`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<"vec4"> {
|
||||
return new DynoOutput(this, "rgba8");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
import { IDENT_VERTEX_SHADER } from "../utils";
|
||||
import { Compilation, type Dyno, type IOTypes } from "./base";
|
||||
|
||||
export class DynoProgram {
|
||||
graph: Dyno<IOTypes, IOTypes>;
|
||||
template: DynoProgramTemplate;
|
||||
inputs: Record<string, string>;
|
||||
outputs: Record<string, string>;
|
||||
shader: string;
|
||||
uniforms: Record<string, THREE.IUniform>;
|
||||
updaters: (() => void)[];
|
||||
|
||||
constructor({
|
||||
graph,
|
||||
inputs,
|
||||
outputs,
|
||||
template,
|
||||
}: {
|
||||
graph: Dyno<IOTypes, IOTypes>;
|
||||
inputs?: Record<string, string>;
|
||||
outputs?: Record<string, string>;
|
||||
template: DynoProgramTemplate;
|
||||
}) {
|
||||
this.graph = graph;
|
||||
this.template = template;
|
||||
this.inputs = inputs ?? {};
|
||||
this.outputs = outputs ?? {};
|
||||
|
||||
const compile = new Compilation({ indent: this.template.indent });
|
||||
for (const key in this.outputs) {
|
||||
if (this.outputs[key]) {
|
||||
compile.declares.add(this.outputs[key]);
|
||||
}
|
||||
}
|
||||
const statements = graph.compile({
|
||||
inputs: this.inputs,
|
||||
outputs: this.outputs,
|
||||
compile,
|
||||
});
|
||||
|
||||
this.shader = template.generate({ globals: compile.globals, statements });
|
||||
this.uniforms = compile.uniforms;
|
||||
this.updaters = compile.updaters;
|
||||
// console.log("*** COMPILED SHADER", this.shader);
|
||||
// console.log("*** UNIFORMS", this.uniforms);
|
||||
}
|
||||
|
||||
prepareMaterial(): THREE.RawShaderMaterial {
|
||||
return getMaterial(this);
|
||||
}
|
||||
|
||||
update() {
|
||||
for (const updater of this.updaters) {
|
||||
updater();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DynoProgramTemplate {
|
||||
before: string;
|
||||
between: string;
|
||||
after: string;
|
||||
indent: string;
|
||||
|
||||
constructor(template: string) {
|
||||
const globals = template.match(/^([ \t]*)\{\{\s*GLOBALS\s*\}\}/m);
|
||||
const statements = template.match(/^([ \t]*)\{\{\s*STATEMENTS\s*\}\}/m);
|
||||
if (!globals || !statements) {
|
||||
throw new Error(
|
||||
"Template must contain {{ GLOBALS }} and {{ STATEMENTS }}",
|
||||
);
|
||||
}
|
||||
|
||||
this.before = template.substring(0, globals.index);
|
||||
this.between = template.substring(
|
||||
(globals.index as number) + globals[0].length,
|
||||
statements.index,
|
||||
);
|
||||
this.after = template.substring(
|
||||
(statements.index as number) + statements[0].length,
|
||||
);
|
||||
this.indent = statements[1];
|
||||
}
|
||||
|
||||
generate({
|
||||
globals,
|
||||
statements,
|
||||
}: { globals: Set<string>; statements: string[] }): string {
|
||||
return (
|
||||
this.before +
|
||||
Array.from(globals).join("\n\n") +
|
||||
this.between +
|
||||
statements.map((s) => this.indent + s).join("\n") +
|
||||
this.after
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const programMaterial = new Map<DynoProgram, THREE.RawShaderMaterial>();
|
||||
|
||||
function getMaterial(program: DynoProgram): THREE.RawShaderMaterial {
|
||||
let material = programMaterial.get(program);
|
||||
if (material) {
|
||||
return material;
|
||||
}
|
||||
|
||||
material = new THREE.RawShaderMaterial({
|
||||
glslVersion: THREE.GLSL3,
|
||||
vertexShader: IDENT_VERTEX_SHADER,
|
||||
fragmentShader: program.shader,
|
||||
uniforms: program.uniforms,
|
||||
});
|
||||
programMaterial.set(program, material);
|
||||
return material;
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { Dyno, UnaryOp, unindent, unindentLines } from "./base";
|
||||
import {
|
||||
DynoOutput,
|
||||
type DynoVal,
|
||||
type DynoValue,
|
||||
type HasDynoOut,
|
||||
} from "./value";
|
||||
|
||||
export const Gsplat = { type: "Gsplat" } as { type: "Gsplat" };
|
||||
export const TPackedSplats = { type: "PackedSplats" } as {
|
||||
type: "PackedSplats";
|
||||
};
|
||||
|
||||
export const numPackedSplats = (
|
||||
packedSplats: DynoVal<typeof TPackedSplats>,
|
||||
): DynoVal<"int"> => new NumPackedSplats({ packedSplats });
|
||||
export const readPackedSplat = (
|
||||
packedSplats: DynoVal<typeof TPackedSplats>,
|
||||
index: DynoVal<"int">,
|
||||
): DynoVal<typeof Gsplat> => new ReadPackedSplat({ packedSplats, index });
|
||||
export const readPackedSplatRange = (
|
||||
packedSplats: DynoVal<typeof TPackedSplats>,
|
||||
index: DynoVal<"int">,
|
||||
base: DynoVal<"int">,
|
||||
count: DynoVal<"int">,
|
||||
): DynoVal<typeof Gsplat> =>
|
||||
new ReadPackedSplatRange({ packedSplats, index, base, count });
|
||||
export const splitGsplat = (gsplat: DynoVal<typeof Gsplat>) =>
|
||||
new SplitGsplat({ gsplat });
|
||||
export const combineGsplat = ({
|
||||
gsplat,
|
||||
flags,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
}: {
|
||||
gsplat?: DynoVal<typeof Gsplat>;
|
||||
flags?: DynoVal<"uint">;
|
||||
index?: DynoVal<"int">;
|
||||
center?: DynoVal<"vec3">;
|
||||
scales?: DynoVal<"vec3">;
|
||||
quaternion?: DynoVal<"vec4">;
|
||||
rgba?: DynoVal<"vec4">;
|
||||
rgb?: DynoVal<"vec3">;
|
||||
opacity?: DynoVal<"float">;
|
||||
x?: DynoVal<"float">;
|
||||
y?: DynoVal<"float">;
|
||||
z?: DynoVal<"float">;
|
||||
r?: DynoVal<"float">;
|
||||
g?: DynoVal<"float">;
|
||||
b?: DynoVal<"float">;
|
||||
}): DynoVal<typeof Gsplat> => {
|
||||
return new CombineGsplat({
|
||||
gsplat,
|
||||
flags,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
});
|
||||
};
|
||||
export const gsplatNormal = (gsplat: DynoVal<typeof Gsplat>): DynoVal<"vec3"> =>
|
||||
new GsplatNormal({ gsplat });
|
||||
|
||||
export const transformGsplat = (
|
||||
gsplat: DynoVal<typeof Gsplat>,
|
||||
{
|
||||
scale,
|
||||
rotate,
|
||||
translate,
|
||||
recolor,
|
||||
}: {
|
||||
scale?: DynoVal<"float">;
|
||||
rotate?: DynoVal<"vec4">;
|
||||
translate?: DynoVal<"vec3">;
|
||||
recolor?: DynoVal<"vec4">;
|
||||
},
|
||||
): DynoVal<typeof Gsplat> => {
|
||||
return new TransformGsplat({ gsplat, scale, rotate, translate, recolor });
|
||||
};
|
||||
|
||||
export const defineGsplat = unindent(`
|
||||
struct Gsplat {
|
||||
vec3 center;
|
||||
uint flags;
|
||||
vec3 scales;
|
||||
int index;
|
||||
vec4 quaternion;
|
||||
vec4 rgba;
|
||||
};
|
||||
const uint GSPLAT_FLAG_ACTIVE = 1u << 0u;
|
||||
|
||||
bool isGsplatActive(uint flags) {
|
||||
return (flags & GSPLAT_FLAG_ACTIVE) != 0u;
|
||||
}
|
||||
`);
|
||||
|
||||
export const definePackedSplats = unindent(`
|
||||
struct PackedSplats {
|
||||
usampler2DArray texture;
|
||||
int numSplats;
|
||||
};
|
||||
`);
|
||||
|
||||
export class NumPackedSplats extends UnaryOp<
|
||||
typeof TPackedSplats,
|
||||
"int",
|
||||
"numSplats"
|
||||
> {
|
||||
constructor({
|
||||
packedSplats,
|
||||
}: { packedSplats: DynoVal<typeof TPackedSplats> }) {
|
||||
super({ a: packedSplats, outKey: "numSplats", outTypeFunc: () => "int" });
|
||||
this.statements = ({ inputs, outputs }) => [
|
||||
`${outputs.numSplats} = ${inputs.a}.numSplats;`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const defineReadPackedSplat = unindent(`
|
||||
bool readPackedSplat(usampler2DArray texture, int numSplats, int index, out Gsplat gsplat) {
|
||||
if ((index >= 0) && (index < numSplats)) {
|
||||
uvec4 packed = texelFetch(texture, splatTexCoord(index), 0);
|
||||
unpackSplat(packed, gsplat.center, gsplat.scales, gsplat.quaternion, gsplat.rgba);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export class ReadPackedSplat
|
||||
extends Dyno<
|
||||
{ packedSplats: typeof TPackedSplats; index: "int" },
|
||||
{ gsplat: typeof Gsplat }
|
||||
>
|
||||
implements HasDynoOut<typeof Gsplat>
|
||||
{
|
||||
constructor({
|
||||
packedSplats,
|
||||
index,
|
||||
}: { packedSplats?: DynoVal<typeof TPackedSplats>; index?: DynoVal<"int"> }) {
|
||||
super({
|
||||
inTypes: { packedSplats: TPackedSplats, index: "int" },
|
||||
outTypes: { gsplat: Gsplat },
|
||||
inputs: { packedSplats, index },
|
||||
globals: () => [defineGsplat, definePackedSplats, defineReadPackedSplat],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { gsplat } = outputs;
|
||||
if (!gsplat) {
|
||||
return [];
|
||||
}
|
||||
const { packedSplats, index } = inputs;
|
||||
let statements: string[];
|
||||
if (packedSplats && index) {
|
||||
statements = unindentLines(`
|
||||
if (readPackedSplat(${packedSplats}.texture, ${packedSplats}.numSplats, ${index}, ${gsplat})) {
|
||||
bool zeroSize = all(equal(${gsplat}.scales, vec3(0.0, 0.0, 0.0)));
|
||||
${gsplat}.flags = zeroSize ? 0u : GSPLAT_FLAG_ACTIVE;
|
||||
} else {
|
||||
${gsplat}.flags = 0u;
|
||||
}
|
||||
`);
|
||||
} else {
|
||||
statements = [`${gsplat}.flags = 0u;`];
|
||||
}
|
||||
statements.push(`${gsplat}.index = ${index ?? "0"};`);
|
||||
return statements;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<typeof Gsplat> {
|
||||
return new DynoOutput(this, "gsplat");
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadPackedSplatRange
|
||||
extends Dyno<
|
||||
{
|
||||
packedSplats: typeof TPackedSplats;
|
||||
index: "int";
|
||||
base: "int";
|
||||
count: "int";
|
||||
},
|
||||
{ gsplat: typeof Gsplat }
|
||||
>
|
||||
implements HasDynoOut<typeof Gsplat>
|
||||
{
|
||||
constructor({
|
||||
packedSplats,
|
||||
index,
|
||||
base,
|
||||
count,
|
||||
}: {
|
||||
packedSplats?: DynoVal<typeof TPackedSplats>;
|
||||
index?: DynoVal<"int">;
|
||||
base?: DynoVal<"int">;
|
||||
count?: DynoVal<"int">;
|
||||
}) {
|
||||
super({
|
||||
inTypes: {
|
||||
packedSplats: TPackedSplats,
|
||||
index: "int",
|
||||
base: "int",
|
||||
count: "int",
|
||||
},
|
||||
outTypes: { gsplat: Gsplat },
|
||||
inputs: { packedSplats, index, base, count },
|
||||
globals: () => [defineGsplat, definePackedSplats, defineReadPackedSplat],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { gsplat } = outputs;
|
||||
if (!gsplat) {
|
||||
return [];
|
||||
}
|
||||
const { packedSplats, index, base, count } = inputs;
|
||||
let statements: string[];
|
||||
if (packedSplats && index && base && count) {
|
||||
statements = unindentLines(`
|
||||
${gsplat}.flags = 0u;
|
||||
if ((${index} >= ${base}) && (${index} < (${base} + ${count}))) {
|
||||
if (readPackedSplat(${packedSplats}.texture, ${packedSplats}.numSplats, ${index}, ${gsplat})) {
|
||||
bool zeroSize = all(equal(${gsplat}.scales, vec3(0.0, 0.0, 0.0)));
|
||||
${gsplat}.flags = zeroSize ? 0u : GSPLAT_FLAG_ACTIVE;
|
||||
}
|
||||
}
|
||||
`);
|
||||
} else {
|
||||
statements = [`${gsplat}.flags = 0u;`];
|
||||
}
|
||||
statements.push(`${gsplat}.index = ${index ?? "0"};`);
|
||||
return statements;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<typeof Gsplat> {
|
||||
return new DynoOutput(this, "gsplat");
|
||||
}
|
||||
}
|
||||
|
||||
export class SplitGsplat extends Dyno<
|
||||
{ gsplat: typeof Gsplat },
|
||||
{
|
||||
flags: "uint";
|
||||
active: "bool";
|
||||
index: "int";
|
||||
center: "vec3";
|
||||
scales: "vec3";
|
||||
quaternion: "vec4";
|
||||
rgba: "vec4";
|
||||
rgb: "vec3";
|
||||
opacity: "float";
|
||||
x: "float";
|
||||
y: "float";
|
||||
z: "float";
|
||||
r: "float";
|
||||
g: "float";
|
||||
b: "float";
|
||||
}
|
||||
> {
|
||||
constructor({ gsplat }: { gsplat?: DynoVal<typeof Gsplat> }) {
|
||||
super({
|
||||
inTypes: { gsplat: Gsplat },
|
||||
outTypes: {
|
||||
flags: "uint",
|
||||
active: "bool",
|
||||
index: "int",
|
||||
center: "vec3",
|
||||
scales: "vec3",
|
||||
quaternion: "vec4",
|
||||
rgba: "vec4",
|
||||
rgb: "vec3",
|
||||
opacity: "float",
|
||||
x: "float",
|
||||
y: "float",
|
||||
z: "float",
|
||||
r: "float",
|
||||
g: "float",
|
||||
b: "float",
|
||||
},
|
||||
inputs: { gsplat },
|
||||
globals: () => [defineGsplat],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { gsplat } = inputs;
|
||||
const {
|
||||
flags,
|
||||
active,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
} = outputs;
|
||||
return [
|
||||
!flags ? null : `${flags} = ${gsplat ? `${gsplat}.flags` : "0u"};`,
|
||||
!active
|
||||
? null
|
||||
: `${active} = isGsplatActive(${gsplat ? `${gsplat}.flags` : "0u"});`,
|
||||
!index ? null : `${index} = ${gsplat ? `${gsplat}.index` : "0"};`,
|
||||
!center
|
||||
? null
|
||||
: `${center} = ${gsplat ? `${gsplat}.center` : "vec3(0.0, 0.0, 0.0)"};`,
|
||||
!scales
|
||||
? null
|
||||
: `${scales} = ${gsplat ? `${gsplat}.scales` : "vec3(0.0, 0.0, 0.0)"};`,
|
||||
!quaternion
|
||||
? null
|
||||
: `${quaternion} = ${gsplat ? `${gsplat}.quaternion` : "vec4(0.0, 0.0, 0.0, 1.0)"};`,
|
||||
!rgba
|
||||
? null
|
||||
: `${rgba} = ${gsplat ? `${gsplat}.rgba` : "vec4(0.0, 0.0, 0.0, 0.0)"};`,
|
||||
!rgb
|
||||
? null
|
||||
: `${rgb} = ${gsplat ? `${gsplat}.rgba.rgb` : "vec3(0.0, 0.0, 0.0)"};`,
|
||||
!opacity
|
||||
? null
|
||||
: `${opacity} = ${gsplat ? `${gsplat}.rgba.a` : "0.0"};`,
|
||||
!x ? null : `${x} = ${gsplat ? `${gsplat}.center.x` : "0.0"};`,
|
||||
!y ? null : `${y} = ${gsplat ? `${gsplat}.center.y` : "0.0"};`,
|
||||
!z ? null : `${z} = ${gsplat ? `${gsplat}.center.z` : "0.0"};`,
|
||||
!r ? null : `${r} = ${gsplat ? `${gsplat}.rgba.r` : "0.0"};`,
|
||||
!g ? null : `${g} = ${gsplat ? `${gsplat}.rgba.g` : "0.0"};`,
|
||||
!b ? null : `${b} = ${gsplat ? `${gsplat}.rgba.b` : "0.0"};`,
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CombineGsplat
|
||||
extends Dyno<
|
||||
{
|
||||
gsplat: typeof Gsplat;
|
||||
flags: "uint";
|
||||
index: "int";
|
||||
center: "vec3";
|
||||
scales: "vec3";
|
||||
quaternion: "vec4";
|
||||
rgba: "vec4";
|
||||
rgb: "vec3";
|
||||
opacity: "float";
|
||||
x: "float";
|
||||
y: "float";
|
||||
z: "float";
|
||||
r: "float";
|
||||
g: "float";
|
||||
b: "float";
|
||||
},
|
||||
{ gsplat: typeof Gsplat }
|
||||
>
|
||||
implements HasDynoOut<typeof Gsplat>
|
||||
{
|
||||
constructor({
|
||||
gsplat,
|
||||
flags,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
}: {
|
||||
gsplat?: DynoVal<typeof Gsplat>;
|
||||
flags?: DynoVal<"uint">;
|
||||
index?: DynoVal<"int">;
|
||||
center?: DynoVal<"vec3">;
|
||||
scales?: DynoVal<"vec3">;
|
||||
quaternion?: DynoVal<"vec4">;
|
||||
rgba?: DynoVal<"vec4">;
|
||||
rgb?: DynoVal<"vec3">;
|
||||
opacity?: DynoVal<"float">;
|
||||
x?: DynoVal<"float">;
|
||||
y?: DynoVal<"float">;
|
||||
z?: DynoVal<"float">;
|
||||
r?: DynoVal<"float">;
|
||||
g?: DynoVal<"float">;
|
||||
b?: DynoVal<"float">;
|
||||
}) {
|
||||
super({
|
||||
inTypes: {
|
||||
gsplat: Gsplat,
|
||||
flags: "uint",
|
||||
index: "int",
|
||||
center: "vec3",
|
||||
scales: "vec3",
|
||||
quaternion: "vec4",
|
||||
rgba: "vec4",
|
||||
rgb: "vec3",
|
||||
opacity: "float",
|
||||
x: "float",
|
||||
y: "float",
|
||||
z: "float",
|
||||
r: "float",
|
||||
g: "float",
|
||||
b: "float",
|
||||
},
|
||||
outTypes: { gsplat: Gsplat },
|
||||
inputs: {
|
||||
gsplat,
|
||||
flags,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
},
|
||||
globals: () => [defineGsplat],
|
||||
statements: ({ inputs, outputs }) => {
|
||||
const { gsplat: outGsplat } = outputs;
|
||||
if (!outGsplat) {
|
||||
return [];
|
||||
}
|
||||
const {
|
||||
gsplat,
|
||||
flags,
|
||||
index,
|
||||
center,
|
||||
scales,
|
||||
quaternion,
|
||||
rgba,
|
||||
rgb,
|
||||
opacity,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
} = inputs;
|
||||
return [
|
||||
`${outGsplat}.flags = ${flags ?? (gsplat ? `${gsplat}.flags` : "0u")};`,
|
||||
`${outGsplat}.index = ${index ?? (gsplat ? `${gsplat}.index` : "0")};`,
|
||||
`${outGsplat}.center = ${center ?? (gsplat ? `${gsplat}.center` : "vec3(0.0, 0.0, 0.0)")};`,
|
||||
`${outGsplat}.scales = ${scales ?? (gsplat ? `${gsplat}.scales` : "vec3(0.0, 0.0, 0.0)")};`,
|
||||
`${outGsplat}.quaternion = ${quaternion ?? (gsplat ? `${gsplat}.quaternion` : "vec4(0.0, 0.0, 0.0, 1.0)")};`,
|
||||
`${outGsplat}.rgba = ${rgba ?? (gsplat ? `${gsplat}.rgba` : "vec4(0.0, 0.0, 0.0, 0.0)")};`,
|
||||
!rgb ? null : `${outGsplat}.rgba.rgb = ${rgb};`,
|
||||
!opacity ? null : `${outGsplat}.rgba.a = ${opacity};`,
|
||||
!x ? null : `${outGsplat}.center.x = ${x};`,
|
||||
!y ? null : `${outGsplat}.center.y = ${y};`,
|
||||
!z ? null : `${outGsplat}.center.z = ${z};`,
|
||||
!r ? null : `${outGsplat}.rgba.r = ${r};`,
|
||||
!g ? null : `${outGsplat}.rgba.g = ${g};`,
|
||||
!b ? null : `${outGsplat}.rgba.b = ${b};`,
|
||||
].filter(Boolean) as string[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<typeof Gsplat> {
|
||||
return new DynoOutput(this, "gsplat");
|
||||
}
|
||||
}
|
||||
|
||||
export const defineGsplatNormal = unindent(`
|
||||
vec3 gsplatNormal(vec3 scales, vec4 quaternion) {
|
||||
float minScale = min(scales.x, min(scales.y, scales.z));
|
||||
vec3 normal;
|
||||
if (scales.z == minScale) {
|
||||
normal = vec3(0.0, 0.0, 1.0);
|
||||
} else if (scales.y == minScale) {
|
||||
normal = vec3(0.0, 1.0, 0.0);
|
||||
} else {
|
||||
normal = vec3(1.0, 0.0, 0.0);
|
||||
}
|
||||
return quatVec(quaternion, normal);
|
||||
}
|
||||
`);
|
||||
|
||||
export class GsplatNormal extends UnaryOp<typeof Gsplat, "vec3", "normal"> {
|
||||
constructor({ gsplat }: { gsplat: DynoVal<typeof Gsplat> }) {
|
||||
super({ a: gsplat, outKey: "normal", outTypeFunc: () => "vec3" });
|
||||
this.globals = () => [defineGsplat, defineGsplatNormal];
|
||||
this.statements = ({ inputs, outputs }) => [
|
||||
`${outputs.normal} = gsplatNormal(${inputs.a}.scales, ${inputs.a}.quaternion);`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class TransformGsplat
|
||||
extends Dyno<
|
||||
{
|
||||
gsplat: typeof Gsplat;
|
||||
scale: "float";
|
||||
rotate: "vec4";
|
||||
translate: "vec3";
|
||||
recolor: "vec4";
|
||||
},
|
||||
{ gsplat: typeof Gsplat }
|
||||
>
|
||||
implements HasDynoOut<typeof Gsplat>
|
||||
{
|
||||
constructor({
|
||||
gsplat,
|
||||
scale,
|
||||
rotate,
|
||||
translate,
|
||||
recolor,
|
||||
}: {
|
||||
gsplat?: DynoVal<typeof Gsplat>;
|
||||
scale?: DynoVal<"float">;
|
||||
rotate?: DynoVal<"vec4">;
|
||||
translate?: DynoVal<"vec3">;
|
||||
recolor?: DynoVal<"vec4">;
|
||||
}) {
|
||||
super({
|
||||
inTypes: {
|
||||
gsplat: Gsplat,
|
||||
scale: "float",
|
||||
rotate: "vec4",
|
||||
translate: "vec3",
|
||||
recolor: "vec4",
|
||||
},
|
||||
outTypes: { gsplat: Gsplat },
|
||||
inputs: { gsplat, scale, rotate, translate, recolor },
|
||||
globals: () => [defineGsplat],
|
||||
statements: ({ inputs, outputs, compile }) => {
|
||||
const { gsplat } = outputs;
|
||||
if (!gsplat || !inputs.gsplat) {
|
||||
return [];
|
||||
}
|
||||
const { scale, rotate, translate, recolor } = inputs;
|
||||
const indent = compile.indent;
|
||||
const statements = [
|
||||
`${gsplat} = ${inputs.gsplat};`,
|
||||
`if (isGsplatActive(${gsplat}.flags)) {`,
|
||||
|
||||
scale ? `${indent}${gsplat}.center *= ${scale};` : null,
|
||||
rotate
|
||||
? `${indent}${gsplat}.center = quatVec(${rotate}, ${gsplat}.center);`
|
||||
: null,
|
||||
translate ? `${indent}${gsplat}.center += ${translate};` : null,
|
||||
|
||||
scale ? `${indent}${gsplat}.scales *= ${scale};` : null,
|
||||
|
||||
rotate
|
||||
? `${indent}${gsplat}.quaternion = quatQuat(${rotate}, ${gsplat}.quaternion);`
|
||||
: null,
|
||||
recolor ? `${indent}${gsplat}.rgba *= ${recolor};` : null,
|
||||
"}",
|
||||
].filter(Boolean) as string[];
|
||||
return statements;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dynoOut(): DynoValue<typeof Gsplat> {
|
||||
return new DynoOutput(this, "gsplat");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user