-
Notifications
You must be signed in to change notification settings - Fork 398
Fix LoD paged on-demand rendering, add browser test suite #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,3 +8,6 @@ site/ | |
| site-repo/ | ||
| *.zip | ||
| *.gltf | ||
| test/fixtures/out/ | ||
| test-results/ | ||
| playwright-report/ | ||
| 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: | ||
|
|
||
| - 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might not be immediately clear to users that changes to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
There was a problem hiding this comment.
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-worldexample, it's possible to get the following sequence:setDirty -> onDirtyis called fromupdateInternalonBeforeRendersetDirty -> onDirtyis called fromdriveSortProbably 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
rendercall. Either through nested render calls (e.g. with mirrors/portals) or by the user manually callingrendermultiple times. In the latter therenderer.info.render.frameindex isn't a reliable indicator either.There was a problem hiding this comment.
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.