Skip to content
Draft
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
70 changes: 70 additions & 0 deletions .github/workflows/ci-browser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Spark CI Browser
# Manual-only: run from the Actions tab or with
# gh workflow run ci-browser.yml --ref <branch>
# To run automatically, add push / pull_request triggers here.
on:
workflow_dispatch:
inputs:
fuzz_iters:
description: "Fresh fuzz seeds to run in pager-fuzz.spec.ts"
required: false
default: "3"
permissions:
contents: read
jobs:
browser-tests:
name: playwright (paged LoD / on-demand)
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout Repo
uses: actions/checkout@v4

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

# Needed for the spark-rs WASM module and for build-lod, which generates
# the synthetic LoD fixtures under test/fixtures/out/ (see globalSetup).
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
rust/spark-rs/target
rust/build-lod/target
key: ${{ runner.os }}-cargo-browser-${{ hashFiles('rust/**/Cargo.lock') }}

- name: Install dependencies
run: npm install

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

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

- name: Generate LoD fixtures
run: npm run test:fixtures

- name: Browser tests
run: npm run test:browser
env:
CI: true
FUZZ_ITERS: ${{ inputs.fuzz_iters || '3' }}

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: test-results/
if-no-files-found: ignore
retention-days: 7
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/fixtures/out/
test-results/
playwright-report/
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"site-repo",
"docs",
"*.backup*",
"test-results/",
"examples/**/spark.module.js",
"examples/**/*.json",
"examples/**/pkg"
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 render once whenever it fires. 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
99 changes: 99 additions & 0 deletions docs/docs/on-demand-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 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 (at most once per rendered frame) whenever it has something new to show or needs another frame to make progress:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no guarantee that Spark calls it at most once per rendered frame. For example, even in the hello-world example, it's possible to get the following sequence:

  1. Start of rAF (dirty = false)
  2. setDirty -> onDirty is called from updateInternal
  3. Dirty flag is reset at end of onBeforeRender
  4. rAF method ends
  5. setDirty -> onDirty is called from driveSort

Probably best to not give this guarantee, instead advising to always coalesce render request, as users are likely to do that anyway. Even if we'd address the above case, Three.js has the unfortunate fact that a single frame might consist of more than one render call. Either through nested render calls (e.g. with mirrors/portals) or by the user manually calling render multiple times. In the latter the renderer.info.render.frame index isn't a reliable indicator either.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great point, you're right the language is wrong, I will revise it accordingly.


- 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 render once whenever it fires.

## 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.

Your application must also call `requestRender()` for its own changes (camera moves, objects added/removed/transformed, material changes), since Spark only notices those during a render. 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. 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>
);
}
```

Nothing else is required: 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. Anything your own components change (props that move the camera or a `SplatMesh`, adding or removing meshes) should also call `invalidate()`, as usual in demand mode.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be immediately clear to users that changes to SplatMesh (e.g. moving them around) isn't automatically tracked by Spark, whereas initialization is. So perhaps this side-note could be made more explicit, or at least also mentioned in the vanilla Three.js case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably be more explicit about examples that the user's application will have to trigger re-renders, such as moving splats around. Ironically Spark does track splat movement and whether the accumulated set needs to be re-generated, but that isn't wired into the onDirty callback... I'll make this more explicit in both on-demand examples so users know when they will need to invalidate themselves.


## 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 (at most once per rendered frame) when Spark needs another render to show new results, e.g. a completed sort or LoD update, or a newly streamed chunk. 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
Loading
Loading