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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ jobs:
- name: Typecheck files
run: yarn typecheck

- name: Test
run: yarn test

- name: Test example
run: yarn example test

build-library:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -104,6 +109,9 @@ jobs:
run: |
yarn turbo run build:android --cache-dir="${{ env.TURBO_CACHE_DIR }}"

- name: Android unit tests
run: cd example/android && ./gradlew :react-native-splatkit:testDebugUnitTest --console=plain

build-ios:
runs-on: macos-latest

Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Publish
on:
push:
tags:
- 'v*'

permissions:
contents: read
id-token: write

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Setup
uses: ./.github/actions/setup

- name: Node for npm
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org

- name: Install JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'

- name: Check
run: yarn lint && yarn typecheck && yarn test && yarn example test

- name: Android unit tests
run: cd example/android && ./gradlew :react-native-splatkit:testDebugUnitTest --console=plain

- name: Build
run: yarn prepare

- name: Version matches tag
run: |
TAG="${GITHUB_REF_NAME#v}"
VERSION="$(node -p "require('./package.json').version")"
test "$TAG" = "$VERSION" || { echo "tag $TAG does not match package.json $VERSION"; exit 1; }

- name: Dist tag from the version
run: |
case "$GITHUB_REF_NAME" in
*-alpha*) echo "DIST_TAG=alpha" >> "$GITHUB_ENV" ;;
*-beta*) echo "DIST_TAG=beta" >> "$GITHUB_ENV" ;;
*-rc*) echo "DIST_TAG=next" >> "$GITHUB_ENV" ;;
*) echo "DIST_TAG=latest" >> "$GITHUB_ENV" ;;
esac

# Trusted publishing (OIDC, no secret) when it is configured on npmjs.com;
# otherwise the NPM_TOKEN secret. Either way the tarball carries provenance.
- name: Publish
run: npm publish --provenance --access public --tag "$DIST_TAG"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ example/ios/Pods

# Ruby
example/vendor/
example/Gemfile.lock

# node.js
#
Expand Down
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ representative at an online or offline event.

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD].
juanieltupa@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ The `package.json` file contains various scripts for common tasks:

- `yarn`: setup project by installing dependencies.
- `yarn typecheck`: type-check files with TypeScript.
- `yarn lint`: lint files with [ESLint](https://eslint.org/).
- `yarn example start`: start the Metro server for the example app.
- `yarn lint`: lint files with [ESLint](https://eslint.org/).
- `yarn test`: run the Jest tests.
- `yarn example start`: start the Metro server for the example app.
- `yarn example android`: run the example app on Android.
- `yarn example ios`: run the example app on iOS.

### Sending a pull request

> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github).
Expand All @@ -97,5 +98,5 @@ When you're sending a pull request:
- Prefer small pull requests focused on one change.
- Verify that linters and tests are passing.
- Review the documentation to make sure it looks good.
- Follow the pull request template when opening a pull request.
- One sentence per line in Markdown, plain dashes.
- For pull requests that change the API or implementation, discuss with maintainers first by opening an issue.
78 changes: 69 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import { SplatView } from 'react-native-splatkit';

<SplatView
style={StyleSheet.absoluteFill}
source={{ uri: 'file:///sdcard/Download/world.spz' }}
collider={{ uri: 'file:///sdcard/Download/collider.glb' }}
source={{ uri: 'file:///sdcard/Android/data/com.example.app/files/world.spz' }}
collider={{ uri: 'file:///sdcard/Android/data/com.example.app/files/collider.glb' }}
quality="medium"
onEngineReady={(e) => console.log(e.nativeEvent.gpu)}
onWorldReady={(e) => console.log(e.nativeEvent.splatCount, 'splats')}
Expand All @@ -47,43 +47,99 @@ The bytes never cross the bridge.
JavaScript hands over a location and the native side reads it on a background thread, because a world is tens to hundreds of megabytes and serialising that would stall the app for as long as it took.

`file://`, `content://`, `asset://` for a file in the app assets, `http://`, `https://`, or an absolute path.
A file on disk goes to the engine as a path and is mapped, not copied through the Java heap; the other schemes are read to bytes first.
A file on disk goes to the engine as a path and is mapped, not copied through the Java heap; the other schemes are streamed to the app's cache directory once and mapped from there, with `onLoadProgress` along the way.

### Props

| Prop | What it does |
|---|---|
| `source` | The world. SPZ versions 2 to 4; the format is detected from the bytes. |
| `collider` | A GLB mesh. Switches the camera from flying to walking. |
| `quality` | A preset name, `low`, `medium`, `high` (the default) or `ultra`, or a preset plus overrides: `{ preset: 'medium', renderScale: 0.8 }`. The overrides are `renderScale` (0.1 to 2, above 1 supersamples), `shDegree` (0 to 3, the harmonics degree drawn), `splatBudget` (0 draws all), `cullMarginDegrees` and `linearBlending`. The reason behind each preset and its frame times are in the [engine's README](https://github.com/Xget7/splatkit-android/blob/main/packages/splatkit-android/README.md). |
| `quality` | A preset name, `low`, `medium`, `high` (the default) or `ultra`, or a preset plus overrides: `{ preset: 'medium', renderScale: 0.8 }`. The overrides are `renderScale` (0.1 to 2, above 1 supersamples), `shDegree` (0 to 3, rounded, the harmonics degree drawn), `splatBudget` (0 draws all), `cullMarginDegrees` (0 to 90) and `linearBlending`. Out of range values are clamped with a warning in development; an unknown preset falls back to `high`. The reason behind each preset and its frame times are in the [engine's README](https://github.com/Xget7/splatkit-android/blob/main/packages/splatkit-android/README.md). |
| `cameraPose` | `{ x, y, z, yaw?, pitch? }`, meters and radians. Applied when it changes and again when the world and the collider become ready, so it can be set before the world loads. When walking the camera settles on the floor under the point. |
| `motionEnabled` | The gyroscope drives the look direction. |
| `lookSensitivity`, `walkSensitivity` | Gesture tuning. |
| `lookSensitivity`, `walkSensitivity` | Gesture tuning. Radians per pixel for one finger looking (default 0.004) and meters per pixel for two finger walking (default 0.01). |
| `statsInterval` | Milliseconds between `onStats`. 0, the default, turns the event off. |

### Events

`onEngineReady` fires once with `{ available, gpu }`.
When `available` is false the device could not start the renderer and the view stays blank; every other call is a no-op.

`onLoadProgress` gives `{ kind, bytes, total }` at most every 100 ms while a source that is not a local file is being copied; `kind` is `world` or `collider` and `total` is -1 when the server did not say.
`onWorldReady` gives `{ splatCount }`, `onWorldFailed` and `onColliderFailed` give `{ message }`, `onColliderReady` takes no payload, and `onStats` gives `{ fps, frameMs, gpuMs, sortMs, splatCount, pose }`, where `pose` is the camera as of the last frame in the shape of `cameraPose`.
Read it to save a viewpoint and hand it back later.

### Imperative

```tsx
import { SplatView, type SplatViewHandle } from 'react-native-splatkit';

const splat = useRef<SplatViewHandle>(null);

splat.current?.setWalkVelocity(forward, right); // meters per second, for a joystick
splat.current?.setCameraPose({ x: 0, y: 1.5, z: 0 }); // teleport; yaw and pitch optional
splat.current?.startBenchmark(10); // a reproducible turn, timings in logcat
```

## Requirements
## Requirements and setup

React Native 0.85 or newer with the New Architecture (the default since 0.76); there is no interop layer support.
Android 10 (API 29) and a Vulkan 1.1 device.

In `android/build.gradle` of the app set the floor the engine needs:

```groovy
ext {
minSdkVersion = 29
}
```

The library pins 29 itself, so a lower app floor fails at build time with this package's name in the message rather than at runtime.

The engine ships `arm64-v8a` only.
An app that builds every ABI still installs on an x86_64 emulator and then dies on the first frame, so develop on a physical arm64 device, or keep the emulator from installing it at all with `reactNativeArchitectures=arm64-v8a` in `android/gradle.properties`.

### Expo

Works in a development build, not in Expo Go.
Raise the floor with `expo-build-properties`:

```json
["expo-build-properties", { "android": { "minSdkVersion": 29 } }]
```

### Remote sources

A release build refuses plain `http://` from Android 9 on, and the refusal arrives as `onWorldFailed` with "Cleartext HTTP traffic ... not permitted".
Serve worlds over `https://`, or allow cleartext in the host app's manifest or network security config for development.

### Retrying, unloading, caching

React Native resends a prop only when it changes, so after `onWorldFailed` a retry needs a new `uri` (a query string will do) or a new `key` on the view.
The engine has no unload call yet; setting `source` to `undefined` leaves the current world in place.
Sources that are not local files are copied once to the app's cache directory, keyed by URI, and mapped from there; a changed file behind the same URI is not noticed, so change the URI or clear the app cache.
The cache is capped at 512 MB and evicts the least recently used world first, so a handful of worlds stay and a season's worth does not.

### Children

`SplatView` does not lay out React children.
Put a HUD or a joystick in a sibling view, as the example does.

New architecture only.
Android 10 (API 29) and a Vulkan 1.1 device, `arm64-v8a` only: the engine ships that ABI alone, so an x86_64 emulator installs and then dies on the first frame.
Develop on a physical arm64 device.
## Performance

Measured on a Xiaomi Mi 9 (Adreno 640), the 500k splat World Labs kitchen with its collider, preset `medium`, release builds, camera at the origin, same session, phone cooled between runs.
The engine's own benchmark reports the numbers (one turn over 10 s); the binding adds nothing to the frame.

| Host | GPU ms p50 | frame ms p50 | fps |
|---|---|---|---|
| Engine dev app | 12.8 | 16.7 | 59.6 |
| This package, example app | 12.8 | 16.7 | 59.6 |

`statsInterval={16}`, one event per frame, measured the same frame time as `0`, so a HUD can run at any rate.
A remote world is streamed to disk and mapped, so loading a 51 MB file (3.6 M splats) kept the Java heap under 8 MB; read into memory it peaked at 58 MB.
Two views on one screen both render; expect the frame rate to split between them.
The engine's numbers per preset and per scene are in [docs/BENCHMARKS.md](https://github.com/Xget7/splatkit-android/blob/main/docs/BENCHMARKS.md).

## iOS

Expand All @@ -109,6 +165,10 @@ Reading `/sdcard/Download` instead means asking for `READ_EXTERNAL_STORAGE`, whi
Everything from the engine logs under the tag `SplatKit`.
MIUI hides application logs until `adb shell setprop persist.log.tag.SplatKit V`.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop, the checks that run on a pull request and the conventions.

## License

MIT.
2 changes: 1 addition & 1 deletion Splatkit.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Pod::Spec.new do |s|
s.authors = package["author"]

s.platforms = { :ios => min_ios_version_supported }
s.source = { :git => "https://github.com/Xget7/react-native-splatkit.git", :tag => "#{s.version}" }
s.source = { :git => "https://github.com/Xget7/react-native-splatkit.git", :tag => "v#{s.version}" }

s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
s.private_header_files = "ios/**/*.h"
Expand Down
16 changes: 12 additions & 4 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
buildscript {
ext.Splatkit = [
kotlinVersion: "2.1.21",
// The engine needs Android 10 and Vulkan 1.1; there is no fallback renderer
// below that, so the floor is the engine's floor and not React Native's.
minSdkVersion: 29,
compileSdkVersion: 36,
// The one coupling between this package and the engine. Nothing else about
// the renderer is visible from here.
Expand Down Expand Up @@ -42,7 +39,11 @@ android {
compileSdkVersion getExtOrDefault("compileSdkVersion")

defaultConfig {
minSdkVersion getExtOrDefault("minSdkVersion")
// The engine needs Android 10 and Vulkan 1.1; there is no fallback renderer
// below that. This is pinned rather than read from the app so that an app
// on a lower floor fails here, with this package's name in the message,
// instead of deep inside the engine's manifest merge.
minSdkVersion 29
}

compileOptions {
Expand All @@ -53,6 +54,11 @@ android {
kotlinOptions {
jvmTarget = "17"
}

testOptions {
// android.util.Log in the fetcher; the unit tests run on a plain JVM.
unitTests.returnDefaultValues = true
}
}

repositories {
Expand All @@ -66,4 +72,6 @@ dependencies {
// no shaders and no native code of its own; it only puts the engine's view in
// a React Native tree.
api "io.github.xget7:splatkit-android:${getExtOrDefault('splatkitVersion')}"

testImplementation "junit:junit:4.13.2"
}
79 changes: 79 additions & 0 deletions android/src/main/java/com/splatkit/reactnative/QualityMapper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.splatkit.reactnative

import com.facebook.react.bridge.ReadableMap
import com.splatkit.RenderQuality

/**
* The `quality` prop as the JavaScript wrapper sends it: a preset name and one
* value per override, where -1 means "keep the preset's". Absent keys are
* treated the same way.
*
* Never throws. A prop is not a place to crash the app from: an unknown preset,
* a value of the wrong type or a negative number other than -1 is reported
* through [warn] and the preset's value is used instead.
*/
internal object QualityMapper {
const val UNSET = -1

fun fromMap(map: ReadableMap?, warn: (String) -> Unit): RenderQuality {
if (map == null) return RenderQuality.HIGH
val name = map.stringOrNull("preset", warn)
val preset = name?.let(RenderQuality::named) ?: run {
if (name != null) warn("unknown quality preset '$name'; using high")
RenderQuality.HIGH
}
return preset.copy(
renderScale = map.floatOr("renderScale", preset.renderScale, warn),
shDegree = map.intOr("shDegree", preset.shDegree, warn),
splatBudget = map.intOr("splatBudget", preset.splatBudget, warn),
cullMarginDegrees = map.floatOr("cullMarginDegrees", preset.cullMarginDegrees, warn),
linearBlending = when (map.intOr("linearBlending", UNSET, warn)) {
UNSET -> preset.linearBlending
0 -> false
else -> true
},
)
}

private fun ReadableMap.stringOrNull(key: String, warn: (String) -> Unit): String? {
if (!hasKey(key) || isNull(key)) return null
return try {
getString(key)
} catch (e: RuntimeException) {
warn("quality.$key is not a string; ignoring it")
null
}
}

private fun ReadableMap.floatOr(key: String, fallback: Float, warn: (String) -> Unit): Float {
if (!hasKey(key) || isNull(key)) return fallback
val value = try {
getDouble(key)
} catch (e: RuntimeException) {
warn("quality.$key is not a number; ignoring it")
return fallback
}
if (value == UNSET.toDouble()) return fallback
if (value < 0) {
warn("quality.$key $value is negative; ignoring it")
return fallback
}
return value.toFloat()
}

private fun ReadableMap.intOr(key: String, fallback: Int, warn: (String) -> Unit): Int {
if (!hasKey(key) || isNull(key)) return fallback
val value = try {
getInt(key)
} catch (e: RuntimeException) {
warn("quality.$key is not a number; ignoring it")
return fallback
}
if (value == UNSET) return fallback
if (value < 0) {
warn("quality.$key $value is negative; ignoring it")
return fallback
}
return value
}
}
Loading
Loading