Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci-browser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Spark CI Browser
on:
# Manual runs from the Actions tab or `gh workflow run ci-browser.yml --ref <branch>`.
# (Only listed there once this file exists on the default branch.)
workflow_dispatch:
# Auto-run while developing this branch. TODO: remove the push trigger once
# merged so the browser tests only run on demand from main.
push:
branches:
- fix-paged-lod-browser-test
permissions:
contents: read
jobs:
browser:
name: browser tests
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v7

- name: Use Node.js 22.x
uses: actions/setup-node@v7
with:
node-version: '22.x'

- name: Install dependencies
run: npm install

- name: Cache Rust build
uses: actions/cache@v6
with:
path: |
~/.cargo/bin
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
rust/target
key: ${{ runner.os }}-cargo-${{ hashFiles('rust/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-

- name: Add Rust wasm target
run: rustup target add wasm32-unknown-unknown

- name: Install wasm-pack
run: command -v wasm-pack || cargo install wasm-pack --locked

- name: Build spark-rs
run: npm run build:wasm

- name: Cache Playwright browsers
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}

- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium

- name: Browser tests
run: npm run test:browser

- name: Upload Playwright report
# Upload on success or failure, but not when the job was cancelled.
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: test/browser/playwright-report/
retention-days: 14
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ site/
site-repo/
*.zip
*.gltf
test/browser/fixtures/
test-results/
playwright-report/
4 changes: 3 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"*.backup*",
"examples/**/spark.module.js",
"examples/**/*.json",
"examples/**/pkg"
"examples/**/pkg",
"test-results",
"playwright-report"
]
},
"formatter": {
Expand Down
1 change: 1 addition & 0 deletions docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ This will run a Web server at [http://localhost:8080/](http://localhost:8080/) w
- [Spark Overview](overview.md)
- [System Design](system-design.md)
- [SparkRenderer](spark-renderer.md)
- [On-demand rendering](on-demand-rendering.md)
- [SplatMesh](splat-mesh.md)
- [PackedSplats](packed-splats.md)
- [ExtSplats](ext-splats.md)
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/lod-getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ The above parameters adjust the global splat LoD parameters, but you can also ad

- `SplatMesh.behindFoveate` / `SplatMesh.coneFov0` / `SplatMesh.coneFov` / `SplatMesh.coneFoveate`: Override the global `SparkRenderer.behindFoveate` / `SparkRenderer.coneFov0` / `SparkRenderer.coneFov` / `SparkRenderer.coneFoveate` for this object.

## On-demand rendering with LoD and streaming

If your app renders only when something changes rather than on every animation frame, Spark needs a way to request frames as sorts, LoD updates and streamed chunks complete. Pass an `onDirty` callback to `SparkRenderer` and schedule a render whenever it fires (it may fire several times per frame). See [On-demand rendering](on-demand-rendering.md) for details and examples with vanilla Three.js and React Three Fiber.

## `build-lod` command-line tool

To pre-build an LoD tree for a splat file and output a `.RAD` that can be loaded faster in Spark and even streamed in, use the `build-lod` command-line tool:
Expand Down
109 changes: 109 additions & 0 deletions docs/docs/on-demand-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# On-demand rendering

By default a Three.js app renders continuously with `renderer.setAnimationLoop()`, and Spark's asynchronous work (splat sorting, LoD selection, fetching and paging in chunks of a `paged` `SplatMesh`) simply rides along with each frame. If your scene is mostly static, or you want to save power on mobile, you can instead render only when something changed. This page explains what Spark needs from you to make that work, with a vanilla Three.js example and a React Three Fiber example.

## How Spark drives its own work

Spark does its work from inside `renderer.render()`: that is when it checks the camera, kicks off sorts and LoD updates in its workers, and pages in newly streamed chunks. Results come back asynchronously, and each one needs another render to become visible. In a continuous animation loop the next frame is always coming, so this is invisible. In an on-demand app there is no next frame unless someone asks for one, so Spark needs a way to ask.

That is what the `onDirty` option on `SparkRenderer` is for. Spark calls it whenever it has something new to show or needs another frame to make progress:

- A splat sort finished.
- A new LoD selection was computed.
- A `SplatMesh` finished loading.
- A streamed chunk of a `paged` `SplatMesh` landed and is waiting to be paged in.
- LoD work was requested while the LoD worker was still busy, so it needs to be retried.

Do **not** call `render()` on a timer to "poll" for progress; that defeats the purpose and can leave gaps where loading appears to stall. Pass `onDirty` and schedule a render whenever it fires. Note that `onDirty` may be called multiple times in a frame. Therefore, rather than re-rendering immediately in `onDirty` it's better to schedule a render on the next frame and keep a flag to track if it's already been scheduled, exemplified below.

## Vanilla Three.js

```javascript
let renderScheduled = false;
function requestRender() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
renderer.render(scene, camera);
});
}

const spark = new SparkRenderer({ renderer, onDirty: requestRender });
scene.add(spark);

const splats = new SplatMesh({ url: "./my-splats-lod.rad", paged: true });
scene.add(splats);

// Render once to kick things off; from here on Spark asks for frames.
requestRender();
```

`requestRender()` coalesces multiple requests into a single frame, so it is safe to call it from anywhere, as often as you like.

Spark only notices your application's changes during a render, so your application must call `requestRender()` whenever it changes anything visible, including:

- The camera moves, rotates, or changes its projection (FOV, aspect, near/far).
- Any object, including a `SplatMesh`, is added to or removed from the scene, or its `position`, `rotation`, `scale`, or `visible` changes.
- A `SplatMesh` property changes, such as `opacity`, `recolor`, `edits`, or `skinning`.
- Materials, lights, or other non-splat Three.js objects change.
- The window or canvas is resized.

If you use one of the Three.js controls, hook its `change` event:

```javascript
controls.addEventListener("change", requestRender);
```

See `examples/on-demand/` for a complete example with a streamed `.rad` file and a frame counter showing how few frames are actually rendered.

## React Three Fiber

The same pattern maps directly onto React Three Fiber's on-demand mode: set `frameloop="demand"` on the `Canvas` and wire `onDirty` to R3F's `invalidate()`, which schedules exactly one frame and automatically coalesces multiple calls during the same frame. Add both objects to the scene with `<primitive>` so R3F manages their lifetime:

```jsx
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import { useEffect, useMemo } from "react";

function Splats({ url }) {
const { gl, invalidate } = useThree();

const spark = useMemo(
() => new SparkRenderer({ renderer: gl, onDirty: invalidate }),
[gl, invalidate],
);
const splats = useMemo(() => new SplatMesh({ url, paged: true }), [url]);

useEffect(() => () => spark.dispose(), [spark]);
useEffect(() => () => splats.dispose(), [splats]);

return (
<>
<primitive object={spark} />
<primitive object={splats} />
</>
);
}

export function App() {
return (
<Canvas frameloop="demand">
<Splats url="./my-splats-lod.rad" />
{/* drei controls call invalidate() on camera change in demand mode */}
<OrbitControls />
</Canvas>
);
}
```

R3F renders once on mount, that render kicks off Spark's loading, sorting and LoD work, and each completed step calls `invalidate()` to request the next frame. As in the vanilla example, Spark only sees your application's changes during a render, so you must call `invalidate()` after any of the changes listed above: the camera moving, a `SplatMesh` or other object being added, removed, or transformed, `SplatMesh` properties like `opacity` or `recolor` changing, and so on.

Changes made through React props (for example `<primitive object={splats} position={[x, y, z]} />`) trigger this automatically, since R3F calls `invalidate()` when it applies props in demand mode. Changes made imperatively, such as setting `splats.position` or `splats.opacity` from an event handler or effect, do not; call `invalidate()` yourself afterwards. Drei's controls already call `invalidate()` on camera change.

## Related

- [Spark Level-of-Detail](lod-getting-started.md) for building `.rad` files and enabling `paged` streaming.
- [SparkRenderer](spark-renderer.md) for the full list of constructor options, including `onDirty`.
- [Performance tuning](performance.md) for other ways to reduce GPU and CPU load.
1 change: 1 addition & 0 deletions docs/docs/spark-renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const spark = new SparkRenderer({

| **Parameter** | Description |
| ----------------- | ----------- |
| **onDirty** | Callback invoked when Spark needs another render to show new results, e.g. a completed sort or LoD update, or a newly streamed chunk. May fire several times per frame, so schedule a single render rather than rendering inside the callback. Use this to drive [on-demand rendering](on-demand-rendering.md). (default: `undefined`)
| **premultipliedAlpha** | Whether to use premultiplied alpha when accumulating splat RGB. (default: `true`)
| **timer** | Pass in a `THREE.Timer` to synchronize time-based effects across different systems. (default: `new THREE.Timer`)
| **autoUpdate** | Controls whether to check and automatically update splat collection each frame render. (default: `true`)
Expand Down
6 changes: 4 additions & 2 deletions examples.html
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@
'nonlod': './nonlod/index.html',
'extsplats': './extsplats/index.html',
'streaming-lod': './streaming-lod/index.html',
'multi-lod': './multi-lod/index.html'
'multi-lod': './multi-lod/index.html',
'on-demand': './on-demand/index.html'
};

function getExampleFromHash() {
Expand Down Expand Up @@ -478,7 +479,8 @@
<a href="#nonlod" data-example="nonlod" class="example-link">Simultaneous Non-LoD + LoD</a>
<a href="#extsplats" data-example="extsplats" class="example-link">Extended Splats encoding</a>
<a href="#streaming-lod" data-example="streaming-lod" class="example-link">Streaming LoDs</a>
<a href="#multi-lod" data-example="multi-lod" class="example-link">Multiple Streaming LoDs</a>
<a href="#multi-lod" data-example="multi-lod" class="example-link">Multiple Streaming LoDs</a>
<a href="#on-demand" data-example="on-demand" class="example-link">On-demand Rendering</a>
</div>
</div>
<div class="content">
Expand Down
143 changes: 143 additions & 0 deletions examples/on-demand/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html>

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spark • On-demand rendering</title>
<style>
body {
margin: 0;
background-color: black;
}
canvas {
display: block;
touch-action: none;
}
.overlay {
position: fixed;
margin: 12px;
bottom: 0;
color: white;
font-family: sans-serif;
pointer-events: none;
}
.overlay > * {
margin: 6px;
}
</style>
</head>

<body>
<script type="importmap">
{
"imports": {
"three": "../js/vendor/three/build/three.module.js",
"three/addons/": "../js/vendor/three/examples/jsm/",
"@sparkjsdev/spark": "../../dist/spark.module.js",
"lil-gui": "../js/vendor/lil-gui/dist/lil-gui.esm.js"
}
}
</script>
<div class="overlay">
<h2>On-demand rendering</h2>
<p>
No animation loop: frames are rendered only when the camera moves or when
Spark calls <code>onDirty</code> (sort finished, LoD updated, streamed
chunk landed, mesh loaded).
</p>
<p id="stats"></p>
</div>
<canvas id="canvas" tabindex="0" style="outline: none;"></canvas>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { SparkRenderer, SplatMesh } from "@sparkjsdev/spark";
import GUI from "lil-gui";

const stats = document.getElementById("stats");

const scene = new THREE.Scene();
scene.background = new THREE.Color("#cafefe");
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 1000);
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("canvas") });
renderer.setSize(window.innerWidth, window.innerHeight);

// Render at most once per animation frame, and only when asked to.
let renderScheduled = false;
let renders = 0;
let dirtyEvents = 0;
function requestRender() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
renders += 1;
renderer.render(scene, camera);
updateStats();
});
}

const spark = new SparkRenderer({
renderer,
// Spark asks for a frame whenever it has something new to show or needs
// another render() to make progress on sorting / LoD / streaming.
onDirty: () => {
dirtyEvents += 1;
requestRender();
},
});
scene.add(spark);

// Streamed (paged) LoD splats: chunks are fetched as needed and every
// landing chunk triggers onDirty so the scene refines without a loop.
const world = new SplatMesh({
url: "https://storage.googleapis.com/forge-dev-public/asundqui/rad/260217/coit-40m-sh1-lod.rad",
paged: true,
});
world.quaternion.set(1, 0, 0, 0);
world.scale.setScalar(10.0);
scene.add(world);

camera.position.set(-0.858, 2.203, -1.128);
camera.quaternion.set(-0.043, -0.909, -0.097, 0.402).normalize();

// Application-side changes must also request a render: Spark only notices
// camera / scene changes during render().
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.copy(camera.position).add(camera.getWorldDirection(new THREE.Vector3()));
controls.update();
controls.addEventListener("change", requestRender);

window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
requestRender();
});

const gui = new GUI({ title: "Settings" });
gui.add(spark, "lodSplatScale", 0.01, 2.5, 0.001).name("Level of Detail").onChange(requestRender);

// Count animation frames so it is visible how many frames were skipped.
let ticks = 0;
function tick() {
ticks += 1;
if (ticks % 30 === 0) updateStats();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

function updateStats() {
stats.textContent =
`renders: ${renders} / animation frames: ${ticks}` +
` | onDirty calls: ${dirtyEvents}` +
` | LoD splats shown: ${world.paged?.numSplats ?? 0}`;
}

// Kick things off with one render; everything after this is on demand.
requestRender();
</script>
</body>

</html>
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ <h2>Examples</h2>
<li><a href="/examples/extsplats/">Extended Splats encoding</a></li>
<li><a href="/examples/streaming-lod/">Streaming LoDs</a></li>
<li><a href="/examples/multi-lod/">Multiple Streaming LoDs</a></li>
<li><a href="/examples/on-demand/">On-demand Rendering</a></li>
</ul>
</body>
</html>
Loading
Loading