diff --git a/.github/workflows/ci-browser.yml b/.github/workflows/ci-browser.yml new file mode 100644 index 00000000..127959b2 --- /dev/null +++ b/.github/workflows/ci-browser.yml @@ -0,0 +1,69 @@ +name: Spark CI Browser +on: + # Manual runs from the Actions tab or `gh workflow run ci-browser.yml --ref `. + # (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 diff --git a/.gitignore b/.gitignore index ac19000c..d47876c3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ site/ site-repo/ *.zip *.gltf +test/browser/fixtures/ +test-results/ +playwright-report/ diff --git a/biome.json b/biome.json index 28c30a89..ccf01abd 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,9 @@ "*.backup*", "examples/**/spark.module.js", "examples/**/*.json", - "examples/**/pkg" + "examples/**/pkg", + "test-results", + "playwright-report" ] }, "formatter": { diff --git a/docs/docs/index.md b/docs/docs/index.md index e6d69c41..b9bc1601 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -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) diff --git a/docs/docs/lod-getting-started.md b/docs/docs/lod-getting-started.md index bed0ca0f..d62cae36 100644 --- a/docs/docs/lod-getting-started.md +++ b/docs/docs/lod-getting-started.md @@ -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: diff --git a/docs/docs/on-demand-rendering.md b/docs/docs/on-demand-rendering.md new file mode 100644 index 00000000..dc66b3c1 --- /dev/null +++ b/docs/docs/on-demand-rendering.md @@ -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 `` 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 ( + <> + + + + ); +} + +export function App() { + return ( + + + {/* drei controls call invalidate() on camera change in demand mode */} + + + ); +} +``` + +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 ``) 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. diff --git a/docs/docs/spark-renderer.md b/docs/docs/spark-renderer.md index 8a152e8e..3f1de2d9 100644 --- a/docs/docs/spark-renderer.md +++ b/docs/docs/spark-renderer.md @@ -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`) diff --git a/examples.html b/examples.html index 6317fe34..9e28e58a 100644 --- a/examples.html +++ b/examples.html @@ -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() { @@ -478,7 +479,8 @@ Simultaneous Non-LoD + LoD Extended Splats encoding Streaming LoDs - Multiple Streaming LoDs + Multiple Streaming LoDs + On-demand Rendering
diff --git a/examples/on-demand/index.html b/examples/on-demand/index.html new file mode 100644 index 00000000..3da52c6d --- /dev/null +++ b/examples/on-demand/index.html @@ -0,0 +1,143 @@ + + + + + + + Spark • On-demand rendering + + + + + +
+

On-demand rendering

+

+ No animation loop: frames are rendered only when the camera moves or when + Spark calls onDirty (sort finished, LoD updated, streamed + chunk landed, mesh loaded). +

+

+
+ + + + + diff --git a/index.html b/index.html index cecc1bd3..2eeaae16 100644 --- a/index.html +++ b/index.html @@ -164,6 +164,7 @@

Examples

  • Extended Splats encoding
  • Streaming LoDs
  • Multiple Streaming LoDs
  • +
  • On-demand Rendering
  • diff --git a/mkdocs.yml b/mkdocs.yml index 0d5650ab..bef9330d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -19,6 +19,7 @@ nav: - Overview: docs/overview.md - System Design: docs/system-design.md - SparkRenderer: docs/spark-renderer.md + - On-demand rendering: docs/on-demand-rendering.md - SplatMesh: docs/splat-mesh.md - PackedSplats: docs/packed-splats.md - ExtSplats: docs/ext-splats.md diff --git a/package-lock.json b/package-lock.json index 90a1511a..892e7f86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.2", "@types/three": "0.180.0", "fflate": "^0.8.2", "lefthook": "1.11.12", @@ -231,6 +233,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", @@ -525,6 +543,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/@types/stats.js": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", @@ -1747,6 +1775,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1996,6 +2053,13 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", diff --git a/package.json b/package.json index b2152285..639cced3 100644 --- a/package.json +++ b/package.json @@ -39,15 +39,19 @@ "site:deploy": "npm run site:build && node scripts/deploy-site.js", "site:serve": "node scripts/serve-site.js site", "start": "npm run dev", - "test": "vitest run --reporter=dot test/", + "test": "vitest run --reporter=dot --dir test/unit", + "test:browser": "playwright test -c test/browser/playwright.config.ts", + "test:browser:update": "playwright test -c test/browser/playwright.config.ts --update-snapshots", "test:rust": "cargo test --locked --manifest-path rust/Cargo.toml --workspace", - "types": "tsc -p tsconfig.json --noEmit" + "types": "tsc -p tsconfig.json --noEmit && tsc -p test/browser/tsconfig.json" }, "repository": "sparkjsdev/spark", "files": ["dist"], "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.2", "@types/three": "0.180.0", "fflate": "^0.8.2", "lefthook": "1.11.12", diff --git a/src/SparkRenderer.ts b/src/SparkRenderer.ts index 4f85e2f2..a8c1e14d 100644 --- a/src/SparkRenderer.ts +++ b/src/SparkRenderer.ts @@ -32,7 +32,9 @@ export interface SparkRendererOptions { renderer: THREE.WebGLRenderer; /** * Callback function to be called when SparkRenderer needs to re-render, - * for example when splat sort order or LoD updates complete. + * for example when splat sort order or LoD updates complete. May fire + * several times per frame; schedule a single render rather than rendering + * inside the callback. */ onDirty?: () => void; /** @@ -354,7 +356,7 @@ export class SparkRenderer extends THREE.Mesh { readonly timer: THREE.Timer; private readonly ownsTimer: boolean; lastFrame = -1; - updateTimeoutId = -1; + updateTimeoutId: ReturnType | undefined = undefined; onDirty?: () => void; dirty: boolean; @@ -370,7 +372,7 @@ export class SparkRenderer extends THREE.Mesh { sortDirty = false; lastSortTime = 0; sortWorker: SplatWorker | null = null; - sortTimeoutId = -1; + sortTimeoutId: ReturnType | undefined = undefined; sortedCenter = new THREE.Vector3().setScalar(Number.NEGATIVE_INFINITY); sortedDir = new THREE.Vector3().setScalar(0); readback32 = new Uint32Array(0); @@ -752,9 +754,9 @@ export class SparkRenderer extends THREE.Mesh { autoUpdate: true, }); } else { - if (spark.updateTimeoutId === -1) { + if (spark.updateTimeoutId === undefined) { spark.updateTimeoutId = setTimeout(() => { - spark.updateTimeoutId = -1; + spark.updateTimeoutId = undefined; spark.updateInternal({ scene, camera: useCamera, @@ -1001,9 +1003,9 @@ export class SparkRenderer extends THREE.Mesh { return; } - if (this.sortTimeoutId !== -1) { + if (this.sortTimeoutId !== undefined) { clearTimeout(this.sortTimeoutId); - this.sortTimeoutId = -1; + this.sortTimeoutId = undefined; } const now = performance.now(); @@ -1012,7 +1014,7 @@ export class SparkRenderer extends THREE.Mesh { : now; if (now < nextSortTime) { this.sortTimeoutId = setTimeout(() => { - this.sortTimeoutId = -1; + this.sortTimeoutId = undefined; this.driveSort(); }, nextSortTime - now); return; @@ -1252,6 +1254,7 @@ export class SparkRenderer extends THREE.Mesh { extSplats: this.pagedExtSplats, maxSplats: this.maxPagedSplats, numFetchers: this.numLodFetchers, + onUpdate: () => this.setDirty(), }); const { lodId } = await worker.call("newLodTree", { diff --git a/src/SplatPager.ts b/src/SplatPager.ts index db515a45..2394358f 100644 --- a/src/SplatPager.ts +++ b/src/SplatPager.ts @@ -515,6 +515,11 @@ export interface SplatPagerOptions { * @default 3 */ numFetchers?: number; + /** + * Called after each chunk fetch attempt settles (success or failure); + * a render is needed to page in the chunk or retry. + */ + onUpdate?: () => void; } interface PageUpload { @@ -538,6 +543,7 @@ export class SplatPager { autoDrive: boolean; numFetchers: number; + onUpdate?: () => void; fetchPause = 0; splatsChunkToPage: Map< @@ -623,6 +629,7 @@ export class SplatPager { this.autoDrive = options.autoDrive ?? true; this.numFetchers = options.numFetchers ?? 3; + this.onUpdate = options.onUpdate; this.splatsChunkToPage = new Map(); this.pageToSplatsChunk = new Array(this.maxPages); @@ -851,6 +858,7 @@ export class SplatPager { dispose() { this.autoDrive = false; this.numFetchers = 0; + this.onUpdate = undefined; this.packedTexture.value.dispose(); this.packedTexture.value.source.data = null; @@ -1105,6 +1113,7 @@ export class SplatPager { this.fetchers.length--; this.processFetched(); + this.onUpdate?.(); }); promise.then((data) => { @@ -1240,6 +1249,17 @@ export class SplatPager { } } + /** True while chunks are being fetched or are waiting to be paged in. */ + pending() { + return ( + this.fetchers.length > 0 || + this.fetched.length > 0 || + this.newUploads.length > 0 || + this.readyUploads.length > 0 || + this.lodTreeUpdates.length > 0 + ); + } + consumeLodTreeUpdates() { const updates = this.lodTreeUpdates; this.lodTreeUpdates = []; diff --git a/test/browser/basic.test.ts b/test/browser/basic.test.ts new file mode 100644 index 00000000..6f3cf54b --- /dev/null +++ b/test/browser/basic.test.ts @@ -0,0 +1,71 @@ +import { expect, pngBuffer, test } from "./harness.fixture.js"; + +test("renders furry-logo-pedestal", async ({ harnessPage }) => { + const png = await harnessPage.evaluate(async () => { + const h = window.harness; + h.createSpark(); + h.createCamera({ fov: 60, position: [0, 0, 7] }); + h.addSplatMesh({ + url: "/test/browser/fixtures/furry-logo-pedestal.spz", + quaternion: [1, 0, 0, 0], + }); + await h.settle(); + return h.getPixels(); + }); + + expect(pngBuffer(png)).toMatchSnapshot("basic.png"); +}); + +test("renders with object and camera transforms", async ({ harnessPage }) => { + const png = await harnessPage.evaluate(async () => { + const h = window.harness; + h.createSpark(); + // View from behind, off-axis and slightly above, looking back at the origin. + h.createCamera({ + fov: 60, + position: [1.6, 0.9, -6.7], + lookAt: [0, -0.2, 0], + }); + h.addSplatMesh({ + url: "/test/browser/fixtures/furry-logo-pedestal.spz", + position: [0.3, -0.2, 0.1], + rotation: [180, -20, 8], + scale: 1.15, + }); + await h.settle(); + return h.getPixels(); + }); + + expect(pngBuffer(png)).toMatchSnapshot("basic-transformed.png"); +}); + +test("renders three overlapping instances of shared splats", async ({ + harnessPage, +}) => { + const png = await harnessPage.evaluate(async () => { + const h = window.harness; + h.createSpark(); + h.createCamera({ fov: 60, position: [0.5, 1.0, 8.5], lookAt: [0, 0.2, 0] }); + const packedSplats = h.createPackedSplats({ + url: "/test/browser/fixtures/furry-logo-pedestal.spz", + }); + // Three sizes, tilted so pedestals cut through neighbouring logos. + h.addSplatMesh({ packedSplats, quaternion: [1, 0, 0, 0] }); + h.addSplatMesh({ + packedSplats, + position: [-1.0, 1.4, 0.6], + rotation: [180, 30, 70], + scale: 0.6, + }); + h.addSplatMesh({ + packedSplats, + position: [1.6, -1.0, -1.2], + rotation: [180, -40, -20], + scale: 1.3, + }); + await h.settle(); + return h.getPixels(); + }); + + expect(pngBuffer(png)).toMatchSnapshot("basic-instances.png"); +}); diff --git a/test/browser/global-setup.ts b/test/browser/global-setup.ts new file mode 100644 index 00000000..7aa1ea50 --- /dev/null +++ b/test/browser/global-setup.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const FIXTURES = ["furry-logo-pedestal.spz"]; + +const repoRoot = path.resolve(import.meta.dirname, "../.."); +const fixturesDir = path.join(import.meta.dirname, "fixtures"); + +type AssetEntry = { url: string; directory: string }; + +/** Run build-lod on `input`; it writes `-lod.rad` (and chunks) next to the input. */ +function buildLod(input: string, ...args: string[]) { + console.log(`Building LoD fixture: build-lod ${args.join(" ")} ${input}`); + execFileSync( + "cargo", + [ + "run", + "--manifest-path", + "rust/build-lod/Cargo.toml", + "--release", + // Skip the wgpu-backed SH clustering (unused here) on headless CI runners. + ...(process.env.CI ? ["--no-default-features"] : []), + "--", + input, + ...args, + ], + { cwd: repoRoot, stdio: "inherit" }, + ); +} + +export default async function globalSetup() { + const assets: Record = JSON.parse( + await readFile(path.join(repoRoot, "examples/assets.json"), "utf8"), + ); + await mkdir(fixturesDir, { recursive: true }); + + for (const name of FIXTURES) { + const filePath = path.join(fixturesDir, name); + if (existsSync(filePath)) continue; + + const entry = assets[name]; + if (!entry) throw new Error(`Fixture ${name} not found in assets.json`); + + console.log(`Downloading fixture ${name} from ${entry.url}`); + const response = await fetch(entry.url); + if (!response.ok) { + throw new Error( + `Failed to download ${name}: ${response.status} ${response.statusText}`, + ); + } + await writeFile(filePath, Buffer.from(await response.arrayBuffer())); + } + + const spz = path.join(fixturesDir, "furry-logo-pedestal.spz"); + if (!existsSync(path.join(fixturesDir, "furry-logo-pedestal-lod.rad"))) { + buildLod(spz, "--quick"); + } + + // The chunked build shares the non-chunked output name, so build it in its + // own directory from a copy of the input. + const chunkedDir = path.join(fixturesDir, "chunked"); + const chunkedSpz = path.join(chunkedDir, "furry-logo-pedestal.spz"); + if (!existsSync(path.join(chunkedDir, "furry-logo-pedestal-lod.rad"))) { + await mkdir(chunkedDir, { recursive: true }); + await copyFile(spz, chunkedSpz); + buildLod(chunkedSpz, "--quick", "--rad-chunked"); + } +} diff --git a/test/browser/harness.fixture.ts b/test/browser/harness.fixture.ts new file mode 100644 index 00000000..a3ea7bc0 --- /dev/null +++ b/test/browser/harness.fixture.ts @@ -0,0 +1,53 @@ +import { + type ConsoleMessage, + type Page, + test as base, + expect, +} from "@playwright/test"; + +type Fixtures = { + /** Every console message emitted by the page, in order. */ + consoleMessages: ConsoleMessage[]; + /** A page with the harness loaded; fails the test on page errors or console warnings/errors. */ + harnessPage: Page; +}; + +export const test = base.extend({ + consoleMessages: async ({ page }, use) => { + const messages: ConsoleMessage[] = []; + page.on("console", (msg) => messages.push(msg)); + await use(messages); + }, + + harnessPage: async ({ page, consoleMessages }, use) => { + const errors: Error[] = []; + const firstError = new Promise((_, reject) => + page.once("pageerror", reject), + ); + page.on("pageerror", (error) => errors.push(error)); + + await page.goto("/test/browser/harness.html"); + await Promise.race([ + page.waitForFunction(() => window.harness), + firstError, + ]); + + await use(page); + + expect(errors).toEqual([]); + const problems = consoleMessages.filter( + (msg) => + ["warning", "error"].includes(msg.type()) && + // Chromium GPU performance notices (e.g. "GPU stall due to ReadPixels") + !msg.text().includes("GL Driver Message"), + ); + expect(problems.map((msg) => `${msg.type()}: ${msg.text()}`)).toEqual([]); + }, +}); + +/** Decode the PNG data URL returned by `Harness.getPixels()` for `toMatchSnapshot`. */ +export function pngBuffer(dataUrl: string) { + return Buffer.from(dataUrl.split(",")[1], "base64"); +} + +export { expect }; diff --git a/test/browser/harness.html b/test/browser/harness.html new file mode 100644 index 00000000..176f37dc --- /dev/null +++ b/test/browser/harness.html @@ -0,0 +1,13 @@ + + + + + Spark Browser Test Harness + + + + + + diff --git a/test/browser/harness.ts b/test/browser/harness.ts new file mode 100644 index 00000000..efcb52a1 --- /dev/null +++ b/test/browser/harness.ts @@ -0,0 +1,190 @@ +import * as THREE from "three"; +import { + PackedSplats, + type PackedSplatsOptions, + SparkRenderer, + type SparkRendererOptions, + SplatMesh, + type SplatMeshOptions, +} from "../../src/index.js"; + +declare global { + interface Window { + harness: Harness; + } +} + +export type Transform = { + position?: [number, number, number]; + quaternion?: [number, number, number, number]; + /** Euler XYZ rotation in degrees. */ + rotation?: [number, number, number]; + /** Point the object's -Z axis at this world position (applied after position). */ + lookAt?: [number, number, number]; + scale?: number | [number, number, number]; + visible?: boolean; +}; + +export type CameraOptions = { + type?: "perspective" | "orthographic"; + /** Perspective vertical field of view in degrees. */ + fov?: number; + /** Orthographic half-height in world units. */ + size?: number; + near?: number; + far?: number; +} & Transform; + +function nextFrame() { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +export class Harness { + scene = new THREE.Scene(); + renderer: THREE.WebGLRenderer; + spark?: SparkRenderer; + camera?: THREE.Camera; + private meshes: SplatMesh[] = []; + private renderScheduled = false; + + constructor(width = 256, height = 256) { + this.renderer = new THREE.WebGLRenderer({ preserveDrawingBuffer: true }); + this.renderer.setPixelRatio(1); + this.renderer.setSize(width, height); + document.body.appendChild(this.renderer.domElement); + } + + createSpark(options: Partial> = {}) { + this.spark = new SparkRenderer({ + renderer: this.renderer, + onDirty: () => this.requestRender(), + ...options, + }); + this.scene.add(this.spark); + return this.spark; + } + + createCamera({ + type = "perspective", + fov = 70, + size = 1, + near = 0.01, + far = 1000, + ...transform + }: CameraOptions = {}) { + const { width, height } = this.renderer.getSize(new THREE.Vector2()); + const aspect = width / height; + this.camera = + type === "perspective" + ? new THREE.PerspectiveCamera(fov, aspect, near, far) + : new THREE.OrthographicCamera( + -size * aspect, + size * aspect, + size, + -size, + near, + far, + ); + this.setTransform(this.camera, transform); + return this.camera; + } + + /** Load splats once so several meshes can share them via `addSplatMesh({ packedSplats })`. */ + createPackedSplats(options: PackedSplatsOptions) { + return new PackedSplats(options); + } + + addSplatMesh({ + position, + quaternion, + rotation, + lookAt, + scale, + visible, + ...options + }: SplatMeshOptions & Transform) { + const mesh = new SplatMesh(options); + this.setTransform(mesh, { + position, + quaternion, + rotation, + lookAt, + scale, + visible, + }); + this.scene.add(mesh); + this.meshes.push(mesh); + return mesh; + } + + setTransform( + obj: THREE.Object3D, + { position, quaternion, rotation, lookAt, scale, visible }: Transform, + ) { + if (position) obj.position.set(...position); + if (quaternion) obj.quaternion.set(...quaternion); + if (rotation) { + const [x, y, z] = rotation.map(THREE.MathUtils.degToRad); + obj.rotation.set(x, y, z); + } + if (lookAt) obj.lookAt(...lookAt); + if (typeof scale === "number") obj.scale.setScalar(scale); + else if (scale) obj.scale.set(...scale); + if (visible !== undefined) obj.visible = visible; + } + + /** Schedule a render on the next animation frame, coalescing repeated calls. */ + requestRender() { + if (this.renderScheduled) return; + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderScheduled = false; + this.render(); + }); + } + + /** + * Render until Spark has nothing more to show: all meshes loaded, no render + * pending, and no sort in flight. Note: gaps between streamed chunks of a + * paged mesh can look quiet, so this may return early for paged meshes. + */ + async settle(timeoutMs = 60_000) { + const { spark } = this; + if (!spark) throw new Error("createSpark() must be called before settle()"); + if (!this.camera) { + throw new Error("createCamera() must be called before settle()"); + } + await Promise.all(this.meshes.map((mesh) => mesh.initialized)); + const deadline = performance.now() + timeoutMs; + let quietFrames = 0; + this.requestRender(); + while (quietFrames < 2) { + await nextFrame(); + const busy = + this.renderScheduled || + spark.sorting || + spark.sortDirty || + spark.lodDirty || + spark.lodWorker?.queue != null || + spark.pager?.pending(); + quietFrames = busy ? 0 : quietFrames + 1; + if (performance.now() > deadline) { + throw new Error("Timed out waiting for Spark to settle"); + } + } + } + + /** The current canvas contents as a PNG data URL. */ + getPixels() { + return this.renderer.domElement.toDataURL("image/png"); + } + + private render() { + if (!this.camera) { + throw new Error("createCamera() must be called before rendering"); + } + this.renderer.render(this.scene, this.camera); + } +} + +window.harness = new Harness(); diff --git a/test/browser/lod.test.ts b/test/browser/lod.test.ts new file mode 100644 index 00000000..c2ee332f --- /dev/null +++ b/test/browser/lod.test.ts @@ -0,0 +1,24 @@ +import { expect, pngBuffer, test } from "./harness.fixture.js"; + +for (const [lodSplatCount, snapshot] of [ + [10_000, "lod-10K.png"], + [100_000, "lod-100K.png"], +] as const) { + test(`renders LoD with lodSplatCount=${lodSplatCount}`, async ({ + harnessPage, + }) => { + const png = await harnessPage.evaluate(async (lodSplatCount) => { + const h = window.harness; + h.createSpark({ lodSplatCount }); + h.createCamera({ fov: 60, position: [0, 0, 7] }); + h.addSplatMesh({ + url: "/test/browser/fixtures/furry-logo-pedestal-lod.rad", + quaternion: [1, 0, 0, 0], + }); + await h.settle(); + return h.getPixels(); + }, lodSplatCount); + + expect(pngBuffer(png)).toMatchSnapshot(snapshot); + }); +} diff --git a/test/browser/paged-lod.test.ts b/test/browser/paged-lod.test.ts new file mode 100644 index 00000000..cd1b8bc0 --- /dev/null +++ b/test/browser/paged-lod.test.ts @@ -0,0 +1,27 @@ +import { expect, pngBuffer, test } from "./harness.fixture.js"; + +// Same scenes as lod.test.ts, streamed from a chunked RAD with `paged: true`. +// Once fully paged in, the render should match the non-paged LoD snapshots. +for (const [lodSplatCount, snapshot] of [ + [10_000, "lod-10K.png"], + [100_000, "lod-100K.png"], +] as const) { + test(`renders paged LoD with lodSplatCount=${lodSplatCount}`, async ({ + harnessPage, + }) => { + const png = await harnessPage.evaluate(async (lodSplatCount) => { + const h = window.harness; + h.createSpark({ lodSplatCount }); + h.createCamera({ fov: 60, position: [0, 0, 7] }); + h.addSplatMesh({ + url: "/test/browser/fixtures/chunked/furry-logo-pedestal-lod.rad", + paged: true, + quaternion: [1, 0, 0, 0], + }); + await h.settle(); + return h.getPixels(); + }, lodSplatCount); + + expect(pngBuffer(png)).toMatchSnapshot(snapshot); + }); +} diff --git a/test/browser/playwright.config.ts b/test/browser/playwright.config.ts new file mode 100644 index 00000000..0717ee00 --- /dev/null +++ b/test/browser/playwright.config.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; + +const repoRoot = path.resolve(import.meta.dirname, "../.."); +const port = 8080; +const baseURL = `http://localhost:${port}`; + +export default defineConfig({ + testDir: ".", + globalSetup: "./global-setup.ts", + snapshotPathTemplate: "{testDir}/snapshots/{arg}{ext}", + workers: 1, + timeout: 180_000, + // On CI also write an HTML report for upload as a workflow artifact. + reporter: process.env.CI + ? [ + ["list"], + [ + "html", + { + open: "never", + outputFolder: path.join(import.meta.dirname, "playwright-report"), + }, + ], + ] + : "list", + expect: { + toMatchSnapshot: { + // Per-pixel comparison stays exact, but allow a scattering of differing + // pixels: SwiftShader on Linux x64 vs macOS arm64 flips ~6-16 of 65,536. + // Real regressions change thousands. + threshold: 0, + maxDiffPixelRatio: 0.001, // 65 pixels at 256x256 + }, + }, + use: { + baseURL, + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: { + // Force CPU-based WebGL2 via SwiftShader for deterministic rendering. + args: [ + "--use-angle=swiftshader", + "--use-gl=angle", + "--enable-unsafe-swiftshader", + "--ignore-gpu-blocklist", + ], + }, + }, + }, + ], + webServer: { + command: `npx vite --port ${port} --strictPort`, + // Run Vite from the repo root so it picks up vite.config.ts and serves src/. + cwd: repoRoot, + url: `${baseURL}/test/browser/harness.html`, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/test/browser/snapshots/basic-instances.png b/test/browser/snapshots/basic-instances.png new file mode 100644 index 00000000..76fa8da7 Binary files /dev/null and b/test/browser/snapshots/basic-instances.png differ diff --git a/test/browser/snapshots/basic-transformed.png b/test/browser/snapshots/basic-transformed.png new file mode 100644 index 00000000..55dbfe8f Binary files /dev/null and b/test/browser/snapshots/basic-transformed.png differ diff --git a/test/browser/snapshots/basic.png b/test/browser/snapshots/basic.png new file mode 100644 index 00000000..91decc57 Binary files /dev/null and b/test/browser/snapshots/basic.png differ diff --git a/test/browser/snapshots/lod-100K.png b/test/browser/snapshots/lod-100K.png new file mode 100644 index 00000000..e2f3450e Binary files /dev/null and b/test/browser/snapshots/lod-100K.png differ diff --git a/test/browser/snapshots/lod-10K.png b/test/browser/snapshots/lod-10K.png new file mode 100644 index 00000000..a90334b0 Binary files /dev/null and b/test/browser/snapshots/lod-10K.png differ diff --git a/test/browser/tsconfig.json b/test/browser/tsconfig.json new file mode 100644 index 00000000..10b7fe30 --- /dev/null +++ b/test/browser/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false, + "rootDir": "../..", + "types": [ + "vite/client", + "vite-plugin-glsl/ext", + "vite-plugin-arraybuffer/types", + "node" + ] + }, + "include": ["./**/*.ts"] +} diff --git a/test/utils.test.ts b/test/unit/utils.test.ts similarity index 84% rename from test/utils.test.ts rename to test/unit/utils.test.ts index 2f087995..2ca1e4dc 100644 --- a/test/utils.test.ts +++ b/test/unit/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { floatToUint8 } from "../src/utils.js"; +import { floatToUint8 } from "../../src/utils.js"; describe("floatToUint8", () => { test("returns integer 0 for float value 0", () => {