diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..db643aa --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,72 @@ +name: mirror + +# The public iOS and React Native repositories are generated from this one, so a merge to main +# rebuilds them and pushes the result. Their own publish workflows take it from there: bumping a +# package version in a pull request here is the whole release ritual. +# +# MIRROR_TOKEN is a fine-grained personal access token with Contents and Workflows write access to +# both mirrors; the built-in GITHUB_TOKEN cannot reach another repository. +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + mirror: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - platform: ios + repository: Xget7/splatkit-ios + - platform: react-native + repository: Xget7/react-native-splatkit + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Without this the failure is an opaque git authentication error four steps later. + - name: Check the mirror token exists + run: | + if [ -z "${MIRROR_TOKEN}" ]; then + echo "::error::MIRROR_TOKEN is not set. Create a fine-grained token with Contents" \ + "and Workflows write access to ${REPOSITORY} and add it as a repository secret." + exit 1 + fi + env: + MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} + REPOSITORY: ${{ matrix.repository }} + + # The export refuses binaries, oversized files and anything matching a secret pattern, so a + # bad file fails here rather than reaching a public repository. + - id: export + run: | + directory=$(python3 scripts/export-ios-source.py --platform "$PLATFORM" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['directory'])") + echo "directory=$directory" >> "$GITHUB_OUTPUT" + env: + PLATFORM: ${{ matrix.platform }} + + - name: Push the generated tree + run: | + git clone --depth 1 \ + "https://x-access-token:${MIRROR_TOKEN}@github.com/${REPOSITORY}.git" mirror + rsync -a --delete --exclude .git "${DIRECTORY}/" mirror/ + cd mirror + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "No change to mirror." + exit 0 + fi + git commit -m "$(git -C "$GITHUB_WORKSPACE" log -1 --format=%s "$GITHUB_SHA")" \ + -m "Generated from ${GITHUB_REPOSITORY}@${GITHUB_SHA}." + git push + env: + MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} + REPOSITORY: ${{ matrix.repository }} + DIRECTORY: ${{ steps.export.outputs.directory }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87e26cb..1cba41b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,28 +1,57 @@ name: release -# Publishing runs from a tag so the released bytes come from a commit that exists. -# Tag with the version in build.gradle.kts, e.g. `git tag v0.1.0-alpha01 && git push --tags`; -# the deployment is released on Central without a portal step. +# The version in packages/splatkit-android/build.gradle.kts is the release trigger: a merge to +# main that changes it publishes the new version to Maven Central and tags the commit. Maven +# Central takes ten minutes or more to appear on repo1, so the decision is made from the diff +# rather than by asking the registry what exists, which would race with its own mirror. on: push: - tags: ["v*"] + branches: [main] workflow_dispatch: jobs: publish: runs-on: ubuntu-24.04 + permissions: + contents: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - id: state + name: Decide whether the version changed + run: | + coordinates() { + git show "$1:packages/splatkit-android/build.gradle.kts" 2>/dev/null \ + | sed -n 's/.*coordinates(.*"splatkit-android", "\([^"]*\)").*/\1/p' + } + version=$(coordinates HEAD) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "new=true" >> "$GITHUB_OUTPUT" + elif [ "$version" = "$(coordinates HEAD^)" ]; then + echo "new=false" >> "$GITHUB_OUTPUT" + echo "splatkit-android stays at $version; nothing to release." + else + echo "new=true" >> "$GITHUB_OUTPUT" + fi + - uses: actions/setup-java@v4 + if: steps.state.outputs.new == 'true' with: distribution: temurin java-version: "17" - uses: android-actions/setup-android@v3 + if: steps.state.outputs.new == 'true' with: packages: "platforms;android-36 build-tools;36.0.0 ndk;27.1.12297006 cmake;3.22.1" - uses: gradle/actions/setup-gradle@v4 + if: steps.state.outputs.new == 'true' - name: Upload the release to Maven Central + if: steps.state.outputs.new == 'true' working-directory: apps/android-dev run: ./gradlew :splatkit:publishToMavenCentral --no-daemon env: @@ -30,3 +59,18 @@ jobs: ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} + + - name: Tag the release commit + if: steps.state.outputs.new == 'true' + run: | + if git ls-remote --exit-code --tags origin "v$VERSION" >/dev/null 2>&1; then + echo "v$VERSION already tagged." + exit 0 + fi + git tag "v$VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" --title "SplatKit Android $VERSION" --generate-notes \ + $(case "$VERSION" in *alpha*|*beta*|*rc*) echo --prerelease ;; esac) + env: + VERSION: ${{ steps.state.outputs.version }} + GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c99955..59135fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,11 +58,10 @@ Adapter changes also need the Android adapter host tests in its [README](package Check build wiring changes in a fresh React Native app outside this repository, installing the `npm pack` tarball. `prepack` fetches and checksum-verifies the iOS `SplatKitCore.xcframework`; `SPLATKIT_IOS_XCFRAMEWORK_PATH` substitutes a local `scripts/package-ios.sh` build. -## Publish the React Native package +## Release -Release `splatkit-android` and `splatkit-ios` first, then match the Android version in `android/build.gradle` and `scripts/ios-xcframework.json`. -Export with `scripts/export-ios-source.py --platform react-native` and push to [react-native-splatkit](https://github.com/Xget7/react-native-splatkit). -A `v` tag there runs `.github/workflows/publish.yml`, which needs the `NPM_TOKEN` secret and publishes prereleases under the `next` dist-tag. +Bumping a version is the release: merge it to `main` and the workflows publish, tag and mirror on their own. +[docs/RELEASING.md](docs/RELEASING.md) has the whole flow, including the one part still done by hand, building the iOS XCFramework on a Mac. ## Lint the C++ diff --git a/README.md b/README.md index 5cd5298..ad4a915 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Use the native view, forward lifecycle and load worlds asynchronously; see each | Per-view render policy and capabilities | Yes | Yes | | React Native policy prop and events | iPhone 17 Pro validated | Mi 9 validated | -React Native: `npm install @splatkit/react-native`, published from [react-native-splatkit](https://github.com/Xget7/react-native-splatkit); the [example app](apps/react-native/README.md) starts from zero. +React Native: `npm install @splatkit/react-native@next`, published from [react-native-splatkit](https://github.com/Xget7/react-native-splatkit); the [example app](apps/react-native/README.md) starts from zero. One policy prop drives both adapters. `splat-core` owns formats, hierarchy and navigation; `splatkit-engine` owns orchestration; each native SDK owns its GPU resources and view lifecycle. diff --git a/apps/react-native/README.md b/apps/react-native/README.md index d484553..dd9374f 100644 --- a/apps/react-native/README.md +++ b/apps/react-native/README.md @@ -6,7 +6,7 @@ Once the collider is ready a thumb stick appears; it is this app's own control, The SDK draws no walking UI of its own. Inside this monorepo the app installs [`@splatkit/react-native`](../../packages/react-native-splatkit/README.md) from `../../packages`, and builds the Android SDK from source, so the example always exercises the current API. -Outside it, `npm install @splatkit/react-native` is the only change. +Outside it, `npm install @splatkit/react-native@next` is the only change. Linking the package rather than unpacking it costs the example one extra piece of Metro config, in [`metro.config.js`](metro.config.js). The package keeps React and React Native as devDependencies, so from the linked directory Metro resolves them to the package's own `node_modules` and the bundle ends up with two copies of each. @@ -19,7 +19,7 @@ An app that installs the package from npm needs none of this. ```sh npx @react-native-community/cli@latest init MyApp --version 0.87.1 cd MyApp -npm install @splatkit/react-native +npm install @splatkit/react-native@next ``` Then match what this app changes from the template: @@ -33,7 +33,7 @@ Then match what this app changes from the template: ## Run this app -Needs Node 22.11 or newer, the Android SDK with NDK, Xcode 26 and CocoaPods. +Needs Node 22.13 or newer, the Android SDK with NDK, Xcode 26 and CocoaPods. Worlds are `.spz`, `.ply` or `.lodsplat` files and are not committed. ```sh diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..82d8d99 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,65 @@ +# Releasing + +A version number is the release trigger. +Bump it in a pull request, merge to `main`, and the workflows publish; merge anything else and they do nothing. +Nobody tags by hand. + +## The three artifacts + +| Artifact | Version lives in | Published by | Registry | +| --- | --- | --- | --- | +| `io.github.xget7:splatkit-android` | `packages/splatkit-android/build.gradle.kts`, the `coordinates(...)` call | `.github/workflows/release.yml` | Maven Central | +| `@splatkit/react-native` | `packages/react-native-splatkit/package.json` | `publish.yml` in the React Native mirror | npm | +| splatkit-ios `SplatKitCore.xcframework` | `Package.swift`, the binary target URL | a person, see below | GitHub Releases | + +## What a merge to main does + +1. `mirror.yml` regenerates both public repositories with `scripts/export-ios-source.py` and pushes them. + The export refuses binaries, oversized files and anything matching a secret pattern, so a bad file fails the job instead of reaching a public repository. +2. `release.yml` compares the Maven coordinate against the previous commit. + If it changed, it publishes to Maven Central, tags the commit and opens a GitHub release. +3. The React Native mirror's own `publish.yml` asks npm whether `package.json`'s version exists. + If it does not, it publishes with provenance, tags and releases. + +Both publish steps are no-ops when the version did not move, so an ordinary merge is safe. + +### The `latest` dist-tag + +Prereleases publish under the `next` dist-tag. +While every published version is a prerelease, the workflow also points `latest` at the newest one, because otherwise plain `npm install @splatkit/react-native` hands out whatever was published first. +Once a stable version owns `latest`, the workflow stops touching it. + +## Cutting an iOS release + +The XCFramework is built on a Mac, so this part is still manual. + +```sh +rm -rf build/ios-distribution # a cached Xcode SDK path breaks configure after an upgrade +scripts/package-ios.sh # prints the artifact path and its checksum +``` + +Then, in one pull request: + +1. Set the binary target's `url` and `checksum` in `Package.swift` to the new tag and the printed checksum. +2. Set the same version and checksum in `packages/react-native-splatkit/scripts/ios-xcframework.json`, which the npm package fetches at `prepack`. +3. Update the version named in `README.md` and in both iOS READMEs. + +Merge it, then create the release the URL now points at: + +```sh +gh release create v0.1.0-alphaN -R Xget7/splatkit-ios --prerelease \ + --title "SplatKit iOS 0.1.0 alpha N" --notes "..." \ + build/ios-distribution/package.*/SplatKitCore.xcframework.zip +``` + +Create the iOS release before any npm publish that pins it: `npm prepack` downloads the XCFramework and verifies the checksum, so a missing release fails the publish. + +## Secrets + +| Secret | Where | Used by | +| --- | --- | --- | +| `MIRROR_TOKEN` | this repository | `mirror.yml`, to push to both public repositories. A fine-grained token with Contents and Workflows write access to each; the built-in `GITHUB_TOKEN` cannot reach another repository. | +| `MAVEN_CENTRAL_USERNAME`, `MAVEN_CENTRAL_PASSWORD`, `SIGNING_KEY`, `SIGNING_KEY_PASSWORD` | this repository | `release.yml` | +| `NPM_TOKEN` | the React Native mirror | `publish.yml` | + +No workflow that runs on a pull request touches any of them, and none uses `pull_request_target`, so a fork's pull request can run the checks but cannot reach a credential. diff --git a/packages/react-native-splatkit/README.md b/packages/react-native-splatkit/README.md index 1ff0f76..11d4e47 100644 --- a/packages/react-native-splatkit/README.md +++ b/packages/react-native-splatkit/README.md @@ -1,26 +1,40 @@ -# SplatKit React Native +# SplatKit for React Native -`@splatkit/react-native` renders 3D Gaussian splat scenes natively on Metal (iOS) and Vulkan (Android) through one Fabric component, `SplatKitView`, and turns a collider mesh into a walkable, collidable scene. +Real-time 3D Gaussian splatting in a React Native view, rendered natively on Metal and Vulkan. +Point `SplatKitView` at an `.spz` file on disk, give it a collider, and walk through the scene. -[![npm version](https://img.shields.io/npm/v/@splatkit/react-native.svg)](https://www.npmjs.com/package/@splatkit/react-native) +[![npm](https://img.shields.io/npm/v/@splatkit/react-native/next.svg)](https://www.npmjs.com/package/@splatkit/react-native) [![license: MIT](https://img.shields.io/npm/l/@splatkit/react-native.svg)](LICENSE) [![platform: iOS | Android](https://img.shields.io/badge/platform-iOS%20%7C%20Android-lightgrey.svg)](#requirements) - - - -> **Experimental alpha.** APIs change before 1.0. -> The package has been validated on an iPhone 17 Pro and a Mi 9 (Adreno 640); no performance numbers are published beyond that. - -## Features - -- Native renderer, not a WebGL or JS port: Metal on iOS, Vulkan on Android, behind one Fabric view. -- New Architecture (Fabric) only, driven by `@react-native/codegen`. -- Loads `.spz`, `.ply` and `.lodsplat` files, and tiled worlds that stream progressively, straight from an absolute local file path (no JS byte transport). -- LOD selection and residency streaming with host-tunable splat-count budgets. -- A single, versioned per-view render policy that both native adapters validate and report back through `onCapabilities` and `onPolicyEvent`. -- Walk-mode collision: load a collider GLB and the camera becomes a character that stands on floors, is stopped by walls and climbs steps. -- Host-driven navigation: the SDK draws no walking or look UI, and imperative commands drive the camera at touch rate with no React commit per frame. -- Throttled stats (`onStats`, at most 2 Hz) and camera pose (`onCameraPose`, on your own interval) events, so hosts can build a HUD or a minimap without flooding the bridge. +[![architecture: Fabric](https://img.shields.io/badge/architecture-Fabric-blueviolet.svg)](#requirements) + +> **Experimental alpha.** +> The API changes before 1.0, and every release so far is a prerelease published under the `next` dist-tag. +> Verified on physical devices only: an iPhone 17 Pro and a Xiaomi Mi 9 (Adreno 640). + +## Contents + +- [Why](#why) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Loading a world](#loading-a-world) +- [Navigation](#navigation) +- [Quality and performance](#quality-and-performance) +- [API reference](#api-reference) +- [Error codes](#error-codes) +- [Troubleshooting](#troubleshooting) +- [Example app](#example-app) +- [Related](#related) + +## Why + +- **Native, not a port.** Metal on iOS and Vulkan on Android, behind one Fabric component. No WebGL, no WebView, no JS renderer. +- **No bytes cross the bridge.** The view reads the world from an absolute local path and never receives splat data from JavaScript. +- **It scales down.** Hierarchical LOD and residency streaming with budgets you set, so a 6M splat capture runs on a phone from 2019. +- **It tells you what it did.** Every quality request is validated natively and reported back through `onCapabilities` and `onPolicyEvent`, with diagnostics for each fallback. Nothing degrades silently. +- **Walking is real collision.** Load a collider mesh and the camera becomes a character that stands on floors, is blocked by walls and climbs steps. +- **The UI is yours.** The SDK draws no joystick, no HUD and no buttons. It exposes commands and events; you build the controls. ## Requirements @@ -28,306 +42,365 @@ | --- | --- | | React Native | `0.87.x` (peer dependency `~0.87.1`) | | React | `^19.2.3` | -| Architecture | New Architecture (Fabric) enabled; `SplatKitView` has no legacy-bridge fallback | -| iOS | iOS 17.0+, Xcode with CocoaPods, a device or simulator slice matching `SplatKitCore.xcframework` | -| Android | API 29+ (`minSdkVersion 29`), Vulkan 1.1, `arm64-v8a` only | -| Node | `^22.13.0`, `^24.3.0`, or `>=26.0.0` | +| Architecture | New Architecture (Fabric). `SplatKitView` has no legacy bridge fallback. | +| iOS | 17.0+, Xcode with CocoaPods | +| Android | API 29+, Vulkan 1.1, `arm64-v8a` only | +| Node | `^22.13.0`, `^24.3.0` or `>=26.0.0` | ## Installation ```sh -npm install @splatkit/react-native +npm install @splatkit/react-native@next cd ios && pod install ``` -- **iOS**: the podspec vendors `SplatKitCore.xcframework`, fetched and checksum-verified when the npm package is packed. - To test an unreleased native build, set `SPLATKIT_IOS_XCFRAMEWORK_PATH` to a local framework before `npm pack`. - iOS 26 terminates apps that skip the UIScene lifecycle, so a React Native 0.87 template app needs a scene delegate; see the [example app](https://github.com/Xget7/splatkit-android/tree/main/apps/react-native). -- **Android**: the module autolinks through the app's `com.facebook.react` Gradle plugin, which also runs Fabric Codegen, and pulls `io.github.xget7:splatkit-android` from Maven Central. - Set `minSdkVersion = 29` and `reactNativeArchitectures=arm64-v8a` in the host app. +The `@next` tag is required while the package is in alpha. + +**iOS.** +The podspec vendors `SplatKitCore.xcframework`, which is fetched and checksum-verified when the package is packed, so there is nothing to build. +iOS 26 terminates apps that skip the UIScene lifecycle, so a React Native 0.87 template app needs a scene delegate; the [example app](https://github.com/Xget7/splatkit-android/tree/main/apps/react-native) has one. + +**Android.** +The module autolinks through the app's `com.facebook.react` Gradle plugin, which also runs Fabric Codegen, and pulls `io.github.xget7:splatkit-android` from Maven Central. +Set `minSdkVersion = 29` and `reactNativeArchitectures=arm64-v8a` in the host app. ## Quick start ```tsx -import {SplatKitBuilder, SplatKitView, toNativeViewProps} from '@splatkit/react-native'; - -// Conservative placeholder limits for the very first frame; each engine reports its -// real limits in onCapabilities, and the configuration should be rebuilt from those. -const INITIAL_CAPABILITIES = { - limits: { - maxLodCapacitySplats: 1_000_000, - minResidencyCapacitySplats: 100_000, - maxResidencyCapacitySplats: 1_000_000, - }, - supportsComputeTiles: false, - supportsHiZOcclusion: false, - supportsSubgroups: false, - maxTextureDimension: 4096, -}; - -function Scene({worldPath}: {worldPath: string}) { - const configuration = new SplatKitBuilder() - .withWorld({requestId: 'lobby', filePath: worldPath, maxShDegree: 3}) - .withPreset('balanced') - .build(INITIAL_CAPABILITIES); - - return ; +import {useMemo, useState} from 'react'; +import {StyleSheet} from 'react-native'; +import { + QualityPreset, + SplatKitBuilder, + SplatKitView, + conservativeCapabilities, + nativeCapabilitiesFromEvent, + toNativeViewProps, +} from '@splatkit/react-native'; + +export function Scene({worldPath}: {worldPath: string}) { + // An engine reports its real limits through onCapabilities, and those arrive only once it + // exists. Build the first request against conservativeCapabilities, the narrowest limits + // every shipped adapter accepts, then rebuild when the real ones arrive. + const [capabilities, setCapabilities] = useState(conservativeCapabilities); + + const configuration = useMemo( + () => + new SplatKitBuilder() + .withWorld({requestId: 'lobby', filePath: worldPath, maxShDegree: 3}) + .withPreset(QualityPreset.balanced) + .build(capabilities), + [worldPath, capabilities], + ); + + return ( + + setCapabilities(nativeCapabilitiesFromEvent(event.nativeEvent)) + } + /> + ); } ``` -`filePath` must be an absolute, readable local path, never a URL: the view never fetches or receives splat bytes over the bridge. -One finger drag looks around by default (`touchLookEnabled`), and there is no walking control until you add one; see [Walking](#walking). +That renders the world and lets one finger drag to look around. +Walking needs a collider, which [Navigation](#navigation) covers. -### Getting a world file onto a device +Do not ask for limits above `conservativeCapabilities` before the first `onCapabilities` arrives. +A request an adapter rejects is refused before any engine exists, so no capabilities event ever follows and the view has no way to tell you what it wanted instead. -Worlds are not bundled with the app; push or copy them onto the device's sandbox and point `filePath` at that path. +## Loading a world -Android, to the app's external files directory: +`filePath` must be an absolute, readable local path, never a URL, a `require()` asset or a content URI. +Supported formats are `.spz`, `.ply`, `.lodsplat` and tiled worlds that stream progressively. -```sh -adb push world.spz /sdcard/Android/data//files/world.spz -``` +`world` is a transaction, not a setting. +Native reloads when `requestId` changes and ignores every other edit to the object, so changing a load-time budget means changing the id too. + +### Getting a world file onto a device -iOS, to the app's Documents directory on a connected device: +Worlds are not bundled with the app. +Push one into the app's sandbox and point `filePath` at it. ```sh +# Android, into the app's external files directory +adb push world.spz /sdcard/Android/data//files/world.spz + +# iOS, into the app's Documents directory on a connected device xcrun devicectl device copy to --device --domain-type appDataContainer \ --domain-identifier --source world.spz --destination Documents/world.spz ``` -Restart the app after copying a new world file. +Restart the app after copying a new world. -## Walking +## Navigation -The SDK deliberately ships no walking or look UI: `SplatKitView` handles only a one-finger drag to look and a double tap to toggle the gyroscope, and every other control is the host's own. +The SDK ships no navigation UI. +`SplatKitView` handles a one-finger drag to look and a double tap to toggle the gyroscope; every other control is yours to draw. -1. Load a collider mesh through the `collider` prop: `{requestId, filePath}`, an absolute path to a collider GLB. -2. Wait for `onColliderEvent` with `phase: 'ready'` before you show walking controls; `phase: 'failed'` carries `errorCode` and `message`. -3. Tune the walker's shape with the `character` prop: `{eyeHeight, bodyRadius, stepHeight}`, in meters, applied immediately and to any collider loaded later. -4. Drive the camera with `SplatKitCommands`, imported from the package root. - Commands go straight to the native view, bypassing React's render and commit cycle, so a joystick or a look pad can drive the camera at touch rate. +1. Load a collider mesh through the `collider` prop, `{requestId, filePath}`, pointing at an absolute path to a collider GLB. +2. Show your controls when `onColliderEvent` reports `ColliderPhase.ready`. `ColliderPhase.failed` carries `errorCode` and `message`. +3. Shape the walker with the `character` prop, `{eyeHeight, bodyRadius, stepHeight}` in meters, applied immediately and to any collider loaded later. +4. Drive the camera with `SplatKitCommands`. + +Commands go straight to the native view and bypass React's render and commit cycle, so a stick can steer at touch rate without a re-render per frame. ```tsx -import {useRef} from 'react'; +import {useCallback, useRef, useState} from 'react'; import {PanResponder, View} from 'react-native'; -import {SplatKitCommands, SplatKitView} from '@splatkit/react-native'; +import {ColliderPhase, SplatKitCommands, SplatKitView} from '@splatkit/react-native'; -const WALK_SPEED = 1.4; // meters per second at full stick deflection +const WALK_SPEED = 1.4; // meters per second at full deflection const RADIUS = 62; +const clamp = (value: number) => Math.max(-1, Math.min(1, value)); + +export function WalkableScene(props: {colliderPath: string}) { + const view = useRef>(null); + const [walking, setWalking] = useState(false); -function Joystick({viewRef}: {viewRef: React.RefObject>}) { - const responder = useRef( + const stick = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onPanResponderMove: (_event, gesture) => { - const forward = Math.max(-1, Math.min(1, -gesture.dy / RADIUS)) * WALK_SPEED; - const right = Math.max(-1, Math.min(1, gesture.dx / RADIUS)) * WALK_SPEED; - const target = viewRef.current; - if (target) SplatKitCommands.setWalkVelocity(target, forward, right); + const target = view.current; + if (!target) return; + SplatKitCommands.setWalkVelocity( + target, + clamp(-gesture.dy / RADIUS) * WALK_SPEED, + clamp(gesture.dx / RADIUS) * WALK_SPEED, + ); }, onPanResponderRelease: () => { - const target = viewRef.current; + const target = view.current; if (target) SplatKitCommands.setWalkVelocity(target, 0, 0); }, }), ).current; - return ; + const onColliderEvent = useCallback( + (event: {nativeEvent: {phase: ColliderPhase}}) => + setWalking(event.nativeEvent.phase === ColliderPhase.ready), + [], + ); + + return ( + <> + + {walking && } + + ); } - -// const viewRef = useRef(null); -// -// {walking && } ``` -This mirrors the thumb stick in the [example app](https://github.com/Xget7/splatkit-android/tree/main/apps/react-native): the SDK never renders it, the host owns it entirely, and it disappears whenever `onColliderEvent` has not reported `ready`. +An empty `collider.requestId` releases walk mode and returns the camera to free look. -## Performance and quality +## Quality and performance -`SplatKitBuilder` resolves a requested policy against host and native limits into a `SplatKitConfiguration` with `world`, `render` and `performance` (`requested`, `effective`, `diagnostics`): +`SplatKitBuilder` resolves what you ask for against what the host and the adapter allow, and returns a `SplatKitConfiguration` carrying `world`, `render` and `performance` (`requested`, `effective`, `diagnostics`). ```ts -import {SplatKitBuilder, nativeCapabilitiesFromEvent, toNativePolicyProp, toNativeViewProps} from '@splatkit/react-native'; +import { + QualityPreset, + SplatKitBuilder, + toNativePolicyProp, + toNativeViewProps, +} from '@splatkit/react-native'; const configuration = new SplatKitBuilder() .withWorld({requestId: 'lobby-1', filePath: '/absolute/path/lobby.spz', maxShDegree: 3}) - .withPreset('high') + .withPreset(QualityPreset.high) .withPerformance({lodBudgetSplats: 2_500_000}) - .build(capabilities); // a DeviceCapabilities snapshot, e.g. from nativeCapabilitiesFromEvent + .build(capabilities); -const props = toNativeViewProps(configuration); -const policy = toNativePolicyProp(configuration, revision); -// +// ``` -- `withWorld()` accepts world identity, file path and load-time maximum SH degree only. -- `withRender()` accepts only `paused`. -- `withPreset(preset)` replaces the current policy with one of the presets below. +- `withWorld()` takes world identity, file path and the load-time maximum SH degree. +- `withRender()` takes `paused`. +- `withPreset(preset)` replaces the policy with one of the presets below. - `withPerformance(options)` overrides fields on top of the current preset and marks the policy `manual`. -- `build(capabilities)` clamps requested LOD and residency budgets to host limits, caps `shDegree` at the world's `maxShDegree`, and records every fallback in `performance.diagnostics` instead of silently degrading; malformed numbers, invalid enums and unknown options throw. +- `build(capabilities)` clamps budgets to the limits, caps `shDegree` at the world's `maxShDegree`, and records every fallback in `performance.diagnostics`. Malformed numbers, invalid enums and unknown options throw. ### Presets -Every preset rasterizes in `hardware`, with frustum culling and early termination enabled. +Every preset rasterizes in hardware, with frustum culling and early termination on. -| Preset | Render scale | SH degree | LOD budget | Residency budget | Tile size | LOD error px | Alpha threshold | Sub-pixel threshold | Hi-Z occlusion | Sort depth | +| Preset | Render scale | SH | LOD budget | Residency | Tile | LOD error px | Alpha | Sub-pixel | Hi-Z | Sort | | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :---: | ---: | | `highEnd` | 1.25 | 3 | 4,000,000 | 4,000,000 | 16 | 0.75 | 1/255 | 0.35 | Yes | 32 | | `high` | 1.0 | 3 | 3,000,000 | 3,000,000 | 16 | 1.0 | 1/255 | 0.5 | Yes | 32 | | `balanced` | 0.85 | 2 | 2,000,000 | 2,000,000 | 16 | 1.25 | 1/255 | 0.65 | No | 16 | | `performance` | 0.65 | 1 | 1,000,000 | 1,000,000 | 8 | 2.0 | 2/255 | 1.0 | No | 16 | -`withPerformance({raster: 'hybrid'})` opts into experimental screen-tile compositing, which only helps where many large translucent splats overlap one pixel (close-up interiors) and costs more on distant or sparse scenes. -Today only the iOS adapter builds `hybrid`; `computeTile` is not implemented by any adapter. -`onCapabilities.policyRasterMask` reports which raster strategies an adapter actually applies. - -### The `policy` prop and revisions +Lower `lodErrorPixels` sharpens the far field, which is where a captured room looks soft, and costs fill rate. +`withPerformance({raster: RasterStrategy.hybrid})` opts into experimental screen-tile compositing, which helps only where many large translucent splats overlap one pixel and costs more on sparse scenes. +Only the iOS adapter builds `hybrid` today, and `computeTile` is not implemented anywhere. -`policy` carries the versioned renderer policy: `raster`, `tileSize`, `lodErrorPixels`, `alphaThreshold`, `subpixelThreshold`, `enableFrustumCulling`, `enableHiZOcclusion`, `enableEarlyTermination` and `sortDepth`, under a `revision`. -Native re-validates the whole policy and never trusts JS state; give every changed policy a new positive `revision`, since 0 or less means no policy. -Native re-applies the current revision to every new engine, which a world reload also creates, so expect `onPolicyEvent` again after a reload. +### Splat budgets -`onCapabilities` fires once per engine, before its first policy event, and reports splat-count limits, GPU feature support and exactly which policy fields that adapter applies. -Pass its payload through `nativeCapabilitiesFromEvent()` to get the `DeviceCapabilities` that `build()` expects, and rebuild your configuration when it arrives. +| Knob | Set through | Effect | +| --- | --- | --- | +| `lodCapacitySplats` | `withPerformance({lodBudgetSplats})` | LOD tree budget. 0 disables tree building. Load-time. | +| `residencyCapacitySplats` | `withPerformance({residencyCapacitySplats})` | Resident streaming splats, not a memory guarantee. Load-time. | +| `maxShDegree` | `withWorld()` | Load-time cap on stored spherical-harmonics degree. | +| `shDegree` | `withPerformance({shDegree})` | Draw-time SH degree, 0 to 3, clamped to `maxShDegree`. | +| `renderScale` | `withPerformance({renderScale})` | Render resolution scale, `[0.1, 2]`. | -`onPolicyEvent` reports one of three phases: `applied` (the whole request landed), `warning` (one or more fields fell back; `message` explains why) or `rejected` (the previous policy stayed in effect, with `errorCode` `INVALID_POLICY` or `POLICY_PREPARATION_FAILED`). -Every `onPolicyEvent` carries the effective policy values, whichever phase it reports. +The first two are read while the engine builds the world, so raising one takes a new `requestId` and a reload. +Everything else updates in place. +`classifyPolicyChange(previous, next)` tells you which case you are in before you commit to it. -### Splat-count knobs +### The `policy` prop and revisions -| Knob | Lives in | Effect | -| --- | --- | --- | -| `lodCapacitySplats` | `world` (via `withPerformance({lodBudgetSplats})`) | LOD tree budget; 0 disables load-time tree building. Changing it needs a new world load. | -| `residencyCapacitySplats` | `world` (via `withPerformance({residencyCapacitySplats})`) | Resident streaming splats, not a byte or memory guarantee. Changing it needs a new world load. | -| `maxShDegree` | `world` (`withWorld()`) | Load-time cap on stored spherical-harmonics degree; caps `shDegree` below. | -| `shDegree` | view prop (`withPerformance({shDegree})`) | Draw-time SH degree, 0-3, clamped to the world's `maxShDegree`. | -| `renderScale` | view prop (`withPerformance({renderScale})`) | Render resolution scale, `[0.1, 2]`. | +`policy` carries the live renderer policy under a `revision`. +Native revalidates the whole policy and never trusts JavaScript state, so give every change a new positive revision; 0 or less means no policy. +Native reapplies the current revision to each new engine, and a world reload creates one, so expect `onPolicyEvent` again after a reload. -LOD and residency capacities travel inside the `world` request, so changing either requires a new `requestId` and reload; render scale, draw SH degree and the rest of the policy update in place through the `policy` and view props. +`onCapabilities` fires once per engine, before that engine's first policy event, and reports the limits, the GPU features and exactly which policy fields the adapter applies. +`onPolicyEvent` reports `PolicyPhase.applied`, `PolicyPhase.warning` (a field fell back, `message` says why) or `PolicyPhase.rejected` (the previous policy stayed in effect), and always carries the effective values. ## API reference ### `SplatKitView` props -`SplatKitView` extends the standard React Native `ViewProps` (`style`, `pointerEvents`, ...). +Extends the standard `ViewProps`. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `world` | `WorldRequest` | - | Load transaction: `requestId`, absolute `filePath`, `maxShDegree`, `lodCapacitySplats`, `residencyCapacitySplats`. Omit to render nothing. | -| `collider` | `ColliderRequest` | - | `{requestId, filePath}`, the walk-mode counterpart of `world`; an empty `requestId` releases walk mode. | -| `character` | `Character` | - | `{eyeHeight, bodyRadius, stepHeight}`, the walker's shape in meters. | +| `collider` | `ColliderRequest` | - | `{requestId, filePath}`. An empty `requestId` releases walk mode. | +| `character` | `Character` | - | `{eyeHeight, bodyRadius, stepHeight}` in meters. | +| `policy` | `NativeRenderPolicy` | - | The requested renderer policy, under a `revision`. | | `paused` | `boolean` | `false` | Freezes rendering. | | `renderScale` | `number` | `1` | Render resolution scale. | | `shDegree` | `number` (0-3) | `3` | Draw-time spherical-harmonics degree. | | `linearBlending` | `boolean` | `false` | Blends in linear light instead of the encoded space training used. | -| `cullMarginDegrees` | `number` | `10` | CPU fallback's angular culling margin in degrees; the GPU path uses projected bounds instead. | -| `motionEnabled` | `boolean` | `false` | Drives the camera with the gyroscope; ignored where the sensor is missing. | -| `touchLookEnabled` | `boolean` | `true` | Whether a one-finger drag on the view turns the camera. | -| `lookSensitivity` | `number` | `0.004` | Radians per point dragged to look. | -| `cameraPoseInterval` | `number` | `0` | Seconds between `onCameraPose` events; `0` never sends one. | -| `policy` | `NativeRenderPolicy` | - | The requested renderer policy; see [The `policy` prop and revisions](#the-policy-prop-and-revisions). | +| `cullMarginDegrees` | `number` | `10` | Angular culling margin for the CPU fallback; the GPU path uses projected bounds. | +| `motionEnabled` | `boolean` | `false` | Drives the camera from the gyroscope. Ignored where the sensor is missing. | +| `touchLookEnabled` | `boolean` | `true` | Whether a one-finger drag turns the camera. | +| `lookSensitivity` | `number` | `0.004` | Radians per point dragged. | +| `cameraPoseInterval` | `number` | `0` | Seconds between `onCameraPose` events. `0` never sends one. | ### Events -| Event | Payload | Notes | -| --- | --- | --- | -| `onWorldEvent` | `{requestId, phase: 'uploaded' \| 'frameReady' \| 'failed', loadedSplats, errorCode, message}` | Fires per phase of a world load; `errorCode`/`message` are set only on `failed`. | -| `onColliderEvent` | `{requestId, phase: 'ready' \| 'failed', errorCode, message}` | Fires when a collider finishes loading or fails. | -| `onStats` | `{requestId, loadedSplats, drawnSplats, frameMillis, frameTimingAvailable, gpuMillis, gpuTimingAvailable, sortMillis, sortTimingAvailable}` | Throttled to at most 2 Hz by the adapter; a `*TimingAvailable` flag of `false` means its paired value is not meaningful. | -| `onCameraPose` | `{x, y, z, yaw, pitch}` | Throttled to `cameraPoseInterval`, in the world's frame, and sent only when the pose changed. | -| `onPolicyEvent` | `{revision, phase: 'applied' \| 'warning' \| 'rejected', errorCode, message, raster, tileSize, lodErrorPixels, alphaThreshold, subpixelThreshold, enableFrustumCulling, enableHiZOcclusion, enableEarlyTermination, sortDepth}` | One per policy application, including re-application to each new engine a world load creates. | -| `onCapabilities` | `{maxLodCapacitySplats, minResidencyCapacitySplats, maxResidencyCapacitySplats, supportsComputeTiles, supportsHiZOcclusion, supportsSubgroups, maxTextureDimension, policyRaster, policyRasterMask, policyTileSize, policyLodErrorPixels, policyAlphaThreshold, policySubpixelThreshold, policyEnableFrustumCulling, policyEnableHiZOcclusion, policyEnableEarlyTermination, policySortDepth}` | Emitted once per engine, before its first `onPolicyEvent`. | +| Event | Payload | +| --- | --- | +| `onWorldEvent` | `{requestId, phase: WorldPhase, loadedSplats, errorCode, message}`. `errorCode` and `message` are set only on `failed`. | +| `onColliderEvent` | `{requestId, phase: ColliderPhase, errorCode, message}`. | +| `onStats` | `{requestId, loadedSplats, drawnSplats, frameMillis, gpuMillis, sortMillis}` with a `*TimingAvailable` flag beside each timing. Throttled to 2 Hz. | +| `onCameraPose` | `{x, y, z, yaw, pitch}` in the world's frame, throttled to `cameraPoseInterval` and sent only when the pose changed. | +| `onPolicyEvent` | `{revision, phase: PolicyPhase, errorCode, message}` plus every effective policy field. | +| `onCapabilities` | The adapter's limits, GPU feature support and applied policy fields. Once per engine. | -### Commands (`SplatKitCommands`) +A `*TimingAvailable` flag of `false` means its paired value is not a measurement; pass both through `optionalTimingMillis()` to get `number | null`. -Exported from the package root; each command takes the `SplatKitView` ref as its first argument and returns nothing. +### Commands + +Each takes the view ref first and returns nothing. | Command | Signature | Notes | | --- | --- | --- | -| `setWalkVelocity` | `(ref, forward: number, right: number)` | Meters per second, held until called again; forward is where the camera looks. Requires walk mode to be `ready`. | -| `look` | `(ref, deltaYaw: number, deltaPitch: number)` | Radians. Pitch is clamped; ignored while the gyroscope drives the view. | -| `setCameraPose` | `(ref, x: number, y: number, z: number, yaw: number, pitch: number)` | Teleports the camera; while walking, it settles onto the floor under the new point. | +| `setWalkVelocity` | `(ref, forward, right)` | Meters per second, held until called again. Forward is where the camera looks. Needs walk mode ready. | +| `look` | `(ref, deltaYaw, deltaPitch)` | Radians. Pitch is clamped, and this is ignored while the gyroscope drives the view. | +| `setCameraPose` | `(ref, x, y, z, yaw, pitch)` | Teleports. While walking, the camera settles onto the floor under the new point. | + +### Constants -### Contract types and validators (`src/contracts.ts`) +Every enumerated value is a frozen object with a matching type, so there are no bare strings to misspell. -| Export | Shape / signature | +| Export | Members | | --- | --- | -| `SHDegree` | `0 \| 1 \| 2 \| 3` | -| `WorldRequest` | `{requestId, filePath, maxShDegree, lodCapacitySplats, residencyCapacitySplats}` | -| `RenderOptions` | `{paused, renderScale, shDegree}` | -| `ColliderRequest` | `{requestId, filePath}` | -| `Character` | `{eyeHeight, bodyRadius, stepHeight}` | -| `ColliderEvent` | `{requestId, phase, errorCode, message}` | -| `CameraPose` | `{x, y, z, yaw, pitch}` | -| `SplatLimits` | `{maxLodCapacitySplats, minResidencyCapacitySplats, maxResidencyCapacitySplats}` | -| `WorldEvent` | `{requestId, phase, loadedSplats, errorCode, message}` | -| `validateWorldRequest(request, limits)` | Throws on structural violations; does not touch the filesystem or the GPU. | -| `validateColliderRequest(request)` | Throws unless `requestId` is nonempty and `filePath` is absolute. | -| `validateCharacter(character)` | Throws unless the walker's shape is finite and internally consistent. | -| `validateRenderOptions(options)` | Throws unless `paused`, `renderScale` and `shDegree` are in range. | -| `optionalTimingMillis(available, value)` | Returns `value`, or `null` when `available` is `false` (zero is a valid measurement). | - -### Performance types and helpers (`src/performance.ts`) - -| Export | Kind | Purpose | -| --- | --- | --- | -| `SplatKitBuilder` | class | `withWorld()`, `withRender({paused})`, `withPerformance(options)`, `withPreset(preset)`, `build(capabilities)` -> `SplatKitConfiguration`. | -| `toNativeViewProps(configuration)` | function | Returns the `world`, `paused`, `renderScale`, `shDegree` view props. | -| `toNativePolicyProp(configuration, revision)` | function | Returns the `policy` prop for a positive `revision`. | -| `nativeCapabilitiesFromEvent(event)` | function | Converts an `onCapabilities` payload into a `DeviceCapabilities` snapshot for `build()`. | -| `classifyPolicyChange(previous, next)` | function | Classifies a `PerformancePolicy` change as `'none' \| 'nativePropUpdate' \| 'worldReload' \| 'unavailable'`. | +| `QualityPreset` | `highEnd`, `high`, `balanced`, `performance` | +| `qualityPresets` | every preset in order, for rendering a picker | +| `WorldPhase` | `uploaded`, `frameReady`, `failed` | +| `ColliderPhase` | `ready`, `failed` | +| `PolicyPhase` | `applied`, `warning`, `rejected` | +| `RasterStrategy` | `hardware`, `computeTile`, `hybrid` | +| `PerformanceMode` | `auto`, `manual` | +| `PolicyChangeKind` | `none`, `nativePropUpdate`, `worldReload`, `unavailable` | +| `conservativeCapabilities` | the narrowest limits every shipped adapter accepts, for the first request | + +### Builder and helpers + +| Export | Purpose | +| --- | --- | +| `SplatKitBuilder` | `withWorld()`, `withRender()`, `withPerformance()`, `withPreset()`, `build(capabilities)`. | +| `toNativeViewProps(configuration)` | The `world`, `paused`, `renderScale` and `shDegree` props. | +| `toNativePolicyProp(configuration, revision)` | The `policy` prop for a positive revision. | +| `nativeCapabilitiesFromEvent(event)` | An `onCapabilities` payload as a `DeviceCapabilities` snapshot for `build()`. | +| `classifyPolicyChange(previous, next)` | Whether a change needs nothing, a prop update, a world reload, or is unavailable. | +| `optionalTimingMillis(available, value)` | `value`, or `null` when unavailable. Zero is a valid measurement. | -Supporting types: `RasterStrategy` (`'hardware' \| 'computeTile' \| 'hybrid'`), `SortDepth` (`16 \| 32`), `PerformanceMode` (`'auto' \| 'manual'`), `QualityPreset` (`'highEnd' \| 'high' \| 'balanced' \| 'performance'`), `NativePolicySupport`, `DeviceCapabilities`, `NativeRenderPolicy`, `NativeCapabilitiesEvent`, `SplatKitWorldRequest`, `PerformancePolicy`, `PerformanceOptions`, `EffectivePerformancePolicy`, `PerformanceResolution`, `SplatKitConfiguration`, `PolicyChangeKind`. +### Validators -`PerformanceDiagnostic` (`{severity: 'warning', code, option, requested, fallback, message}`) explains every fallback `build()` makes: +`validateWorldRequest(request, limits)`, `validateColliderRequest(request)`, `validateCharacter(character)` and `validateRenderOptions(options)` throw on structural violations. +None of them touch the filesystem or the GPU. + +`PerformanceDiagnostic` explains every fallback `build()` made: | `code` | Meaning | | --- | --- | -| `limit-clamped` | The value was clamped to a host- or native-supplied limit. | -| `native-option-fallback` | Native capabilities say this field is not applied; it falls back to the native default. | -| `native-support-unknown` | Native capabilities have not arrived yet, so support for this field is unknown. | -| `capability-fallback-unavailable` | The requested raster strategy is unsupported; `hardware` is the candidate fallback. | -| `fabric-option-unavailable` | The option has no corresponding Fabric prop (`targetFps` only, which is a request only and never enables dynamic quality). | +| `limit-clamped` | Clamped to a host or native limit. | +| `native-option-fallback` | The adapter does not apply this field; it fell back to the native default. | +| `native-support-unknown` | Capabilities have not arrived, so support is unknown. | +| `capability-fallback-unavailable` | The requested raster strategy is unsupported; `hardware` is the candidate. | +| `fabric-option-unavailable` | The option has no Fabric prop. `targetFps` only, which is a request and never enables dynamic quality. | ## Error codes -Both native adapters share the same failure codes, so a host can branch on `errorCode` without checking platform: +Both adapters share these, so a host branches on `errorCode` without checking the platform. | Code | Reported on | Meaning | | --- | --- | --- | -| `INVALID_REQUEST` | `onWorldEvent`, `onColliderEvent` | The `world` or `collider` request failed structural validation before native ever tried to load it. | +| `INVALID_REQUEST` | `onWorldEvent`, `onColliderEvent` | The request failed structural validation before native tried to load it. | | `WORLD_LOAD_FAILED` | `onWorldEvent` | The world file could not be decoded or loaded. | -| `GPU_UNAVAILABLE` | `onWorldEvent` | The GPU backend could not be initialized (for example, Vulkan device creation failed). | +| `GPU_UNAVAILABLE` | `onWorldEvent` | The GPU backend could not be initialized. | | `COLLIDER_LOAD_FAILED` | `onColliderEvent` | The collider GLB could not be loaded. | -| `INVALID_POLICY` | `onPolicyEvent` | The `policy` prop failed validation (for example, an unknown raster strategy or sort depth). | -| `POLICY_PREPARATION_FAILED` | `onPolicyEvent` | The policy passed validation but native failed to prepare it; the previous policy stays in effect. | +| `INVALID_POLICY` | `onPolicyEvent` | The policy failed validation. | +| `POLICY_PREPARATION_FAILED` | `onPolicyEvent` | The policy was valid but native could not prepare it; the previous one stays. | ## Troubleshooting -**The view is blank or black.** -Check that `world` is set and that `onWorldEvent` ever fires; a view with no `world` prop renders nothing. -On a simulator or emulator, GPU support is often missing or partial: prefer a physical device, especially on Android, where an emulator without full Vulkan 1.1 support reports `GPU_UNAVAILABLE`. +**The view is blank.** +Check that `world` is set and that `onWorldEvent` fires at all; a view with no `world` renders nothing. +Prefer a physical device: emulators often lack full Vulkan 1.1, which reports `GPU_UNAVAILABLE`. + +**The world fails to load.** +`filePath` must be absolute, local and readable. +Confirm the file is really at that path in the sandbox, and read `message` for the loader's reason. -**The world fails to load (`onWorldEvent` with `phase: 'failed'`).** -`filePath` must be an absolute, local, readable path, not a URL, a `require()` asset or a content URI; the view does no fetching and no JS byte transport. -Confirm the file actually exists at that path in the app's sandbox (see [Getting a world file onto a device](#getting-a-world-file-onto-a-device)), and read `message` for the native loader's reason. +**Nothing renders and the log mentions Fabric or Codegen.** +`SplatKitView` is Fabric-only. +Confirm the New Architecture is enabled and that a full rebuild picked up `SplatKitSpec`. -**Nothing renders and the app crashes or logs a Fabric/Codegen error.** -`SplatKitView` is Fabric-only; confirm the New Architecture is enabled and that `npx react-native codegen` (or a full rebuild) picked up `SplatKitSpec`. +**The scene is sharp up close and soft further out.** +That is the LOD error threshold. +Lower `lodErrorPixels` below the preset's value, and watch `onStats` for the fill-rate cost. -**Simulator vs. device.** -The package has only been verified on physical devices, an iPhone 17 Pro and a Mi 9 (Adreno 640). -The iOS Simulator and Android emulators can differ in Metal/Vulkan feature support from real hardware; if `onCapabilities` reports unexpectedly low limits or `supportsComputeTiles`/`supportsHiZOcclusion` as `false`, try a device before filing an issue. +**The HUD shows no frame rate.** +A timing whose `*TimingAvailable` flag is `false` was not measured, which is not the same as zero. +The Vulkan backend reports submitted frames rather than presented ones, and neither backend measures GPU time without timestamp queries. ## Example app -[`apps/react-native`](https://github.com/Xget7/splatkit-android/tree/main/apps/react-native) is a React Native 0.87.1 app built from the community template that installs this package from npm, drags to look, double-taps to toggle the gyroscope, and drives a joystick through `SplatKitCommands` once a collider is ready. -Its README lists every change needed from a fresh template, including `minSdkVersion`, `arm64-v8a`, the iOS 17 deployment target and the scene delegate iOS 26 requires. +[`apps/react-native`](https://github.com/Xget7/splatkit-android/tree/main/apps/react-native) is a React Native 0.87.1 app from the community template that installs this package, draws a thumb stick and a stats and quality HUD, and walks a 6M splat capture. +Its README lists every change a fresh template needs. + +## Related + +- [splatkit-android](https://github.com/Xget7/splatkit-android), the Vulkan SDK and the shared C++ engine. +- [splatkit-ios](https://github.com/Xget7/splatkit-ios), the Metal SDK. ## Contributing -This package is exported from a monorepo; see [CONTRIBUTING.md](CONTRIBUTING.md) for how to build, test and send changes back. +This package is generated from a monorepo; see [CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/packages/react-native-splatkit/distribution/.github/workflows/publish.yml b/packages/react-native-splatkit/distribution/.github/workflows/publish.yml index 9749e0d..6e92480 100644 --- a/packages/react-native-splatkit/distribution/.github/workflows/publish.yml +++ b/packages/react-native-splatkit/distribution/.github/workflows/publish.yml @@ -1,16 +1,20 @@ name: publish -# Publishing runs from a tag so the released bytes come from a commit that exists. Tag with the -# version in package.json, e.g. `git tag v0.1.0-alpha.1 && git push --tags`. +# The version in package.json is the release trigger: a merge to main that names a version npm +# does not have yet publishes it, and a merge that does not is a no-op. That keeps the released +# bytes tied to a commit on main without anyone hand-tagging, and makes the release reviewable as +# an ordinary pull request. This repository is generated from the SplatKit monorepo, so the bump +# lands here through the mirror workflow there. on: push: - tags: ["v*"] + branches: [main] + workflow_dispatch: jobs: publish: runs-on: ubuntu-24.04 permissions: - contents: read + contents: write id-token: write steps: - uses: actions/checkout@v4 @@ -20,11 +24,57 @@ jobs: registry-url: "https://registry.npmjs.org" cache: npm - run: npm ci - # Prereleases go to the next dist-tag, so `npm install` keeps resolving the latest stable version. - - run: | + + - id: state + name: Decide whether this version is new + run: | version=$(node -p "require('./package.json').version") - test "v$version" = "$GITHUB_REF_NAME" - case "$version" in *-*) tag=next ;; *) tag=latest ;; esac + name=$(node -p "require('./package.json').name") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "name=$name" >> "$GITHUB_OUTPUT" + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "new=false" >> "$GITHUB_OUTPUT" + echo "$name@$version is already published; nothing to release." + else + echo "new=true" >> "$GITHUB_OUTPUT" + fi + + # Prereleases go to the next dist-tag so a later stable release owns `latest` by default. + - name: Publish to npm + if: steps.state.outputs.new == 'true' + run: | + case "$VERSION" in *-*) tag=next ;; *) tag=latest ;; esac npm publish --provenance --tag "$tag" env: + VERSION: ${{ steps.state.outputs.version }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Until a stable release exists there is nothing for `latest` to mean, and leaving it on an + # older prerelease makes plain `npm install` hand out a build no README describes. This runs + # on every push so a tag left behind by an earlier release is corrected without a new version. + - name: Point latest at the newest release while all of them are prereleases + run: | + current=$(npm view "$NAME" dist-tags.latest 2>/dev/null || true) + if [ "$current" = "$VERSION" ]; then exit 0; fi + case "$current" in + "" | *-*) + npm dist-tag add "$NAME@$VERSION" latest + echo "latest: ${current:-none} -> $VERSION" + ;; + *) echo "latest is the stable $current; leaving it alone." ;; + esac + env: + NAME: ${{ steps.state.outputs.name }} + VERSION: ${{ steps.state.outputs.version }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Tag the release commit + if: steps.state.outputs.new == 'true' + run: | + git tag "v$VERSION" + git push origin "v$VERSION" + gh release create "v$VERSION" --title "v$VERSION" --generate-notes \ + $(case "$VERSION" in *-*) echo --prerelease ;; esac) + env: + VERSION: ${{ steps.state.outputs.version }} + GH_TOKEN: ${{ github.token }} diff --git a/packages/splatkit-ios/distribution/README.md b/packages/splatkit-ios/distribution/README.md index af456a4..a50cc13 100644 --- a/packages/splatkit-ios/distribution/README.md +++ b/packages/splatkit-ios/distribution/README.md @@ -6,7 +6,7 @@ MIT-licensed sources include the shared C++ engine/core. ## Install Add `https://github.com/Xget7/splatkit-ios` in Xcode Package Dependencies and select product `SplatKit`. -Choose exact version `0.1.0-alpha.3`; `main` may be ahead of that release, as the [changelog](CHANGELOG.md) lists. +Choose exact version `0.1.0-alpha.4`; `main` may be ahead of that release, as the [changelog](CHANGELOG.md) lists. The package downloads the release XCFramework for arm64 devices and arm64/x86_64 simulators. ```swift