@airisu-dev/react-native-live2d-skia 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/Live2dSkia.podspec +159 -0
- package/README.md +258 -2
- package/android/CMakeLists.txt +113 -0
- package/android/build.gradle +223 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
- package/app.plugin.js +2 -0
- package/cli/build/index.js +157 -0
- package/cpp/cubism/CubismCompat.hpp +26 -0
- package/cpp/cubism/CubismHelpers.hpp +27 -0
- package/cpp/cubism/CubismRendererStub.cpp +14 -0
- package/cpp/cubism/CubismSetup.cpp +54 -0
- package/cpp/cubism/CubismSetup.hpp +9 -0
- package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
- package/cpp/cubism/Live2DUserModel.cpp +125 -0
- package/cpp/cubism/Live2DUserModel.hpp +57 -0
- package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
- package/cpp/drawing/JsiUnwrap.hpp +25 -0
- package/cpp/drawing/MeshCache.cpp +75 -0
- package/cpp/drawing/MeshCache.hpp +45 -0
- package/cpp/drawing/SkiaDrawer.cpp +138 -0
- package/cpp/drawing/SkiaDrawer.hpp +42 -0
- package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
- package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
- package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
- package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
- package/ios/Bridge.h +8 -0
- package/nitro.json +20 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
- package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
- package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
- package/package.json +149 -4
- package/plugin/build/index.d.ts +14 -0
- package/plugin/build/index.js +81 -0
- package/src/components/Live2D.tsx +131 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useLive2DGesture.ts +140 -0
- package/src/hooks/useLive2DModel.ts +47 -0
- package/src/index.ts +5 -0
- package/src/loader/cubismJson.ts +23 -0
- package/src/loader/index.ts +9 -0
- package/src/loader/loadModel.ts +195 -0
- package/src/loader/modelContext.ts +80 -0
- package/src/loader/modelUrl.ts +112 -0
- package/src/native.ts +4 -0
- package/src/rendering/decodeImage.ts +12 -0
- package/src/rendering/drawer.ts +256 -0
- package/src/rendering/skia.ts +26 -0
- package/src/rendering/skiaTypes.ts +99 -0
- package/src/rendering/ticker/addToTicker.ts +52 -0
- package/src/rendering/ticker/constants.ts +11 -0
- package/src/rendering/ticker/frames.ts +35 -0
- package/src/rendering/ticker/index.ts +5 -0
- package/src/rendering/ticker/loop.ts +115 -0
- package/src/rendering/ticker/state.ts +65 -0
- package/src/rendering/ticker/stats.ts +36 -0
- package/src/rendering/ticker/types.ts +57 -0
- package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
- package/src/utils/clock.ts +10 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Expo config plugin: tells the native builds where the Cubism SDK for Native is.
|
|
3
|
+
//
|
|
4
|
+
// "plugins": [["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]]
|
|
5
|
+
//
|
|
6
|
+
// Cubism Core is proprietary and not shipped: download the SDK with
|
|
7
|
+
// `@airisu-dev/react-native-live2d-skia fetch-sdk`, accepting Live2D's license. The path is relative to the app's root.
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.withLive2dSkia = void 0;
|
|
13
|
+
exports.nativeProperties = nativeProperties;
|
|
14
|
+
exports.skiaPackageOf = skiaPackageOf;
|
|
15
|
+
exports.warnIfMissing = warnIfMissing;
|
|
16
|
+
const config_plugins_1 = require("expo/config-plugins");
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
// Read by android/build.gradle and Live2dSkia.podspec.
|
|
20
|
+
const SDK_PROPERTY = 'live2dSkia.cubismSdkPath';
|
|
21
|
+
const SKIA_PROPERTY = 'live2dSkia.skiaPackage';
|
|
22
|
+
// The Skia packages react-native-live2d-skia can draw with in C++, by preference.
|
|
23
|
+
const SKIA_PACKAGES = ['react-native-skia', '@shopify/react-native-skia'];
|
|
24
|
+
const FETCH_HINT = 'Download it with `@airisu-dev/react-native-live2d-skia fetch-sdk --accept-license` (through npx, bunx, yarn or pnpm).';
|
|
25
|
+
const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
26
|
+
// Paths are written relative to the native folder, so a prebuilt project
|
|
27
|
+
// still builds after it's moved or checked out somewhere else.
|
|
28
|
+
const relativeTo = (dir, target) => path_1.default.relative(dir, target).split(path_1.default.sep).join('/');
|
|
29
|
+
function nativeProperties(projectRoot, platformProjectRoot, sdkPath) {
|
|
30
|
+
const sdkDir = path_1.default.resolve(projectRoot, sdkPath);
|
|
31
|
+
return {
|
|
32
|
+
[SDK_PROPERTY]: relativeTo(platformProjectRoot, sdkDir),
|
|
33
|
+
[SKIA_PROPERTY]: skiaPackageOf(projectRoot),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const withLive2dSkia = (config, props) => {
|
|
37
|
+
const sdkPath = props?.cubismSdkPath;
|
|
38
|
+
if (!sdkPath) {
|
|
39
|
+
throw new Error('[react-native-live2d-skia] Set the Cubism SDK for Native folder: ' +
|
|
40
|
+
'["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]. ' +
|
|
41
|
+
FETCH_HINT);
|
|
42
|
+
}
|
|
43
|
+
config = (0, config_plugins_1.withGradleProperties)(config, (c) => {
|
|
44
|
+
const { projectRoot, platformProjectRoot } = c.modRequest;
|
|
45
|
+
warnIfMissing(path_1.default.resolve(projectRoot, sdkPath));
|
|
46
|
+
const values = nativeProperties(projectRoot, platformProjectRoot, sdkPath);
|
|
47
|
+
c.modResults = c.modResults.filter((item) => !(item.type === 'property' && item.key in values));
|
|
48
|
+
for (const [key, value] of Object.entries(values)) {
|
|
49
|
+
c.modResults.push({ type: 'property', key, value });
|
|
50
|
+
}
|
|
51
|
+
return c;
|
|
52
|
+
});
|
|
53
|
+
config = (0, config_plugins_1.withPodfileProperties)(config, (c) => {
|
|
54
|
+
const { projectRoot, platformProjectRoot } = c.modRequest;
|
|
55
|
+
Object.assign(c.modResults, nativeProperties(projectRoot, platformProjectRoot, sdkPath));
|
|
56
|
+
return c;
|
|
57
|
+
});
|
|
58
|
+
return config;
|
|
59
|
+
};
|
|
60
|
+
exports.withLive2dSkia = withLive2dSkia;
|
|
61
|
+
// The Skia package the app depends on, by its own package.json: in a monorepo
|
|
62
|
+
// both can be installed, but an app uses one. Empty when it has neither: the
|
|
63
|
+
// native build then leaves out the C++ drawer.
|
|
64
|
+
function skiaPackageOf(projectRoot) {
|
|
65
|
+
const appPackage = JSON.parse(fs_1.default.readFileSync(path_1.default.join(projectRoot, 'package.json'), 'utf8'));
|
|
66
|
+
const found = SKIA_PACKAGES.filter((name) => appPackage.dependencies?.[name]);
|
|
67
|
+
if (found.length > 1) {
|
|
68
|
+
console.warn(`[react-native-live2d-skia] The app depends on both ${found.join(' and ')}: drawing with ${found[0]}. Skia says not to install both.`);
|
|
69
|
+
}
|
|
70
|
+
if (found.length === 0) {
|
|
71
|
+
console.warn(`[react-native-live2d-skia] The app depends on neither ${SKIA_PACKAGES.join(' nor ')}. Install one: react-native-live2d-skia draws with Skia.`);
|
|
72
|
+
}
|
|
73
|
+
return found[0] ?? '';
|
|
74
|
+
}
|
|
75
|
+
// Not an error: CI may fetch the SDK after prebuild. The native build checks again.
|
|
76
|
+
function warnIfMissing(sdkDir) {
|
|
77
|
+
if (!fs_1.default.existsSync(path_1.default.join(sdkDir, 'cubism-info.yml'))) {
|
|
78
|
+
console.warn(`[react-native-live2d-skia] Cubism SDK for Native not found at ${sdkDir}. ${FETCH_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.default = (0, config_plugins_1.createRunOncePlugin)(exports.withLive2dSkia, pkg.name, pkg.version);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useFrameCallback, useSharedValue } from 'react-native-reanimated'
|
|
3
|
+
import {
|
|
4
|
+
createSynchronizable,
|
|
5
|
+
createWorkletRuntime,
|
|
6
|
+
scheduleOnRuntime,
|
|
7
|
+
type WorkletRuntime,
|
|
8
|
+
} from 'react-native-worklets'
|
|
9
|
+
import type { Live2DModel } from '../loader/loadModel'
|
|
10
|
+
import { Group, Picture, Skia, type SkPicture } from '../rendering/skia'
|
|
11
|
+
import { addToTicker, type Live2DFrameStats, type PublishedFrame } from '../rendering/ticker'
|
|
12
|
+
|
|
13
|
+
export type { Live2DFrameStats } from '../rendering/ticker'
|
|
14
|
+
|
|
15
|
+
export interface Live2DProps {
|
|
16
|
+
model: Live2DModel | null
|
|
17
|
+
/** Top-left position on the canvas. */
|
|
18
|
+
x?: number
|
|
19
|
+
y?: number
|
|
20
|
+
/** Uniform scale of the model's native canvas size. */
|
|
21
|
+
scale?: number
|
|
22
|
+
/** Stops animating and keeps the current frame. */
|
|
23
|
+
paused?: boolean
|
|
24
|
+
/** Caps the frame rate, e.g. 30 to save battery. Default: the display's refresh rate. */
|
|
25
|
+
maxFps?: number
|
|
26
|
+
/** Called about once a second with timings of the animation loop. */
|
|
27
|
+
onFrameStats?: (stats: Live2DFrameStats) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DISPLAY_SAMPLE_FRAMES = 60
|
|
31
|
+
|
|
32
|
+
// Shown until the model loads. A recorder must begin recording before it can finish.
|
|
33
|
+
const emptyRecorder = Skia.PictureRecorder()
|
|
34
|
+
emptyRecorder.beginRecording(Skia.XYWHRect(0, 0, 0, 0))
|
|
35
|
+
const EMPTY_PICTURE = emptyRecorder.finishRecordingAsPicture()
|
|
36
|
+
|
|
37
|
+
// Models animate on a background thread so a busy JS thread doesn't drop frames:
|
|
38
|
+
// one runtime and one loop (rendering/ticker) for every <Live2D>.
|
|
39
|
+
let runtime: WorkletRuntime | null = null
|
|
40
|
+
const live2dRuntime = () => (runtime ??= createWorkletRuntime({ name: 'live2d' }))
|
|
41
|
+
|
|
42
|
+
export function Live2D({
|
|
43
|
+
model,
|
|
44
|
+
x = 0,
|
|
45
|
+
y = 0,
|
|
46
|
+
scale = 1,
|
|
47
|
+
paused = false,
|
|
48
|
+
maxFps,
|
|
49
|
+
onFrameStats,
|
|
50
|
+
}: Live2DProps) {
|
|
51
|
+
// A shared value lets Skia redraw each frame without a React re-render.
|
|
52
|
+
const picture = useSharedValue<SkPicture>(model?.picture ?? EMPTY_PICTURE)
|
|
53
|
+
// Reset to the load-time frame only when the model changes, so pausing keeps the current frame.
|
|
54
|
+
const shownModel = useRef<Live2DModel | null>(null)
|
|
55
|
+
// The loop publishes frames here. scheduleOnUI can't be called from a custom
|
|
56
|
+
// worklet runtime (outside Bundle Mode), so the UI thread polls instead.
|
|
57
|
+
const [latest] = useState(() => createSynchronizable<PublishedFrame>(null))
|
|
58
|
+
// The frame shown, told back to the loop so it frees only frames no longer shown.
|
|
59
|
+
const [shown] = useState(() => createSynchronizable(-1))
|
|
60
|
+
// The display's frame interval: the shortest over a window of UI frames, so
|
|
61
|
+
// dropped frames don't count. 0 until measured.
|
|
62
|
+
const [displayFrameMs] = useState(() => createSynchronizable(0))
|
|
63
|
+
const sampleMin = useSharedValue(Infinity)
|
|
64
|
+
const sampleCount = useSharedValue(0)
|
|
65
|
+
useFrameCallback(({ timeSincePreviousFrame }) => {
|
|
66
|
+
const next = latest.getDirty()
|
|
67
|
+
if (next && next.frame !== shown.getDirty()) {
|
|
68
|
+
picture.value = next.picture
|
|
69
|
+
shown.setBlocking(next.frame)
|
|
70
|
+
}
|
|
71
|
+
if (timeSincePreviousFrame !== null && timeSincePreviousFrame > 0) {
|
|
72
|
+
sampleMin.value = Math.min(sampleMin.value, timeSincePreviousFrame)
|
|
73
|
+
if (++sampleCount.value >= DISPLAY_SAMPLE_FRAMES) {
|
|
74
|
+
displayFrameMs.setBlocking(sampleMin.value)
|
|
75
|
+
sampleMin.value = Infinity
|
|
76
|
+
sampleCount.value = 0
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Stable for the loop to call; updated after render, as refs mustn't be written while rendering.
|
|
82
|
+
const onFrameStatsRef = useRef(onFrameStats)
|
|
83
|
+
useLayoutEffect(() => {
|
|
84
|
+
onFrameStatsRef.current = onFrameStats
|
|
85
|
+
})
|
|
86
|
+
const reportStats = useCallback((stats: Live2DFrameStats) => onFrameStatsRef.current?.(stats), [])
|
|
87
|
+
const wantStats = onFrameStats !== undefined
|
|
88
|
+
const capFrameMs = maxFps ? 1000 / maxFps : 0
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!model) return
|
|
92
|
+
if (shownModel.current !== model) {
|
|
93
|
+
shownModel.current = model
|
|
94
|
+
// set(), not `.value =`, outside worklets: the React Compiler form.
|
|
95
|
+
picture.set(model.picture)
|
|
96
|
+
}
|
|
97
|
+
if (paused) return
|
|
98
|
+
|
|
99
|
+
// Frame numbers restart when the model starts animating again.
|
|
100
|
+
latest.setBlocking(null)
|
|
101
|
+
shown.setBlocking(-1)
|
|
102
|
+
const running = createSynchronizable(true)
|
|
103
|
+
scheduleOnRuntime(
|
|
104
|
+
live2dRuntime(),
|
|
105
|
+
addToTicker,
|
|
106
|
+
{
|
|
107
|
+
id: model.id,
|
|
108
|
+
native: model.native,
|
|
109
|
+
textures: model.textures,
|
|
110
|
+
shaders: model.shaders,
|
|
111
|
+
canvas: model.canvas,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
target: latest,
|
|
115
|
+
shown,
|
|
116
|
+
running,
|
|
117
|
+
displayMs: displayFrameMs,
|
|
118
|
+
capMs: capFrameMs,
|
|
119
|
+
report: wantStats ? reportStats : null,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
return () => running.setBlocking(false)
|
|
123
|
+
}, [model, paused, picture, latest, shown, displayFrameMs, capFrameMs, wantStats, reportStats])
|
|
124
|
+
|
|
125
|
+
if (!model) return null
|
|
126
|
+
return (
|
|
127
|
+
<Group transform={[{ translateX: x }, { translateY: y }, { scale }]}>
|
|
128
|
+
<Picture picture={picture} />
|
|
129
|
+
</Group>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Live2DGestureOptions,
|
|
3
|
+
Live2DGesturesOptions,
|
|
4
|
+
Live2DGestureTarget,
|
|
5
|
+
} from './useLive2DGesture'
|
|
6
|
+
export { useLive2DGesture, useLive2DGestures } from './useLive2DGesture'
|
|
7
|
+
export type { Live2DModelState } from './useLive2DModel'
|
|
8
|
+
export { useLive2DModel } from './useLive2DModel'
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler'
|
|
3
|
+
import type { Live2DModel } from '../loader/loadModel'
|
|
4
|
+
|
|
5
|
+
export interface Live2DGestureTarget {
|
|
6
|
+
model: Live2DModel | null
|
|
7
|
+
x?: number
|
|
8
|
+
y?: number
|
|
9
|
+
scale?: number
|
|
10
|
+
/**
|
|
11
|
+
* Called with the hit areas under a tap. Return true to skip the default
|
|
12
|
+
* reaction: a `Tap<area>` motion, else a random expression for `Head`, else a `Tap` motion.
|
|
13
|
+
*/
|
|
14
|
+
onHit?: (hitAreas: string[]) => boolean | void
|
|
15
|
+
/** Makes the model draggable by long-press. Called with its new top-left; the app moves it. */
|
|
16
|
+
onMove?: (x: number, y: number) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Live2DGesturesOptions {
|
|
20
|
+
/** Models follow the finger while it's down. Default true. */
|
|
21
|
+
focus?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Live2DGestureOptions
|
|
25
|
+
extends Omit<Live2DGestureTarget, 'model'>,
|
|
26
|
+
Live2DGesturesOptions {}
|
|
27
|
+
|
|
28
|
+
function react(model: Live2DModel, hitAreas: string[]) {
|
|
29
|
+
for (const area of hitAreas) {
|
|
30
|
+
const group = `Tap${area}`
|
|
31
|
+
if (model.motionGroups.includes(group)) {
|
|
32
|
+
model.motion(group)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (area === 'Head' && model.expressionNames.length > 0) {
|
|
36
|
+
model.expression()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Some models have one `Tap` group for every area.
|
|
41
|
+
if (model.motionGroups.includes('Tap')) model.motion('Tap')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LONG_PRESS_MS = 350
|
|
45
|
+
|
|
46
|
+
function toModel({ x = 0, y = 0, scale = 1 }: Live2DGestureTarget, px: number, py: number) {
|
|
47
|
+
return [(px - x) / scale, (py - y) / scale] as const
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function topmostHit(targets: Live2DGestureTarget[], px: number, py: number) {
|
|
51
|
+
// Later targets are drawn over earlier ones.
|
|
52
|
+
for (let i = targets.length - 1; i >= 0; i--) {
|
|
53
|
+
const t = targets[i]!
|
|
54
|
+
if (!t.model) continue
|
|
55
|
+
const hitAreas = t.model.hitTest(...toModel(t, px, py))
|
|
56
|
+
if (hitAreas.length > 0) return { target: t, hitAreas }
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Touch interaction for several `<Live2D>` models in one canvas, for a
|
|
63
|
+
* `<GestureDetector>` around the `<Canvas>`. List targets in drawing order.
|
|
64
|
+
*/
|
|
65
|
+
export function useLive2DGestures(
|
|
66
|
+
targets: Live2DGestureTarget[],
|
|
67
|
+
{ focus = true }: Live2DGesturesOptions = {},
|
|
68
|
+
) {
|
|
69
|
+
// The React Compiler miscompiled these callbacks (a `let` shared between them).
|
|
70
|
+
'use no memo'
|
|
71
|
+
|
|
72
|
+
// Read at event time, so the gesture is built once. Updated after render.
|
|
73
|
+
const latest = useRef(targets)
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
latest.current = targets
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// The callbacks only run on touch events; the refs rule can't tell through RNGH's builder API.
|
|
79
|
+
/* eslint-disable react-hooks/refs */
|
|
80
|
+
return useMemo(() => {
|
|
81
|
+
const focusAll = (px: number, py: number) => {
|
|
82
|
+
for (const t of latest.current) t.model?.focus(...toModel(t, px, py))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pan = Gesture.Pan()
|
|
86
|
+
.runOnJS(true)
|
|
87
|
+
.enabled(focus)
|
|
88
|
+
.onBegin((e) => focusAll(e.x, e.y))
|
|
89
|
+
.onUpdate((e) => focusAll(e.x, e.y))
|
|
90
|
+
.onFinalize(() => {
|
|
91
|
+
for (const t of latest.current) t.model?.resetFocus()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Shorter than a long press, so picking a model up isn't also a tap.
|
|
95
|
+
const tap = Gesture.Tap()
|
|
96
|
+
.runOnJS(true)
|
|
97
|
+
.maxDuration(LONG_PRESS_MS - 50)
|
|
98
|
+
.onEnd((e, success) => {
|
|
99
|
+
if (!success) return
|
|
100
|
+
const hit = topmostHit(latest.current, e.x, e.y)
|
|
101
|
+
if (hit && hit.target.onHit?.(hit.hitAreas) !== true) {
|
|
102
|
+
react(hit.target.model!, hit.hitAreas)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The dragged model (found again by model, as targets can change) and the finger's
|
|
107
|
+
// offset from its top-left. On an object so it survives recompilation.
|
|
108
|
+
const drag: {
|
|
109
|
+
current: { model: Live2DModel; dx: number; dy: number } | null
|
|
110
|
+
} = { current: null }
|
|
111
|
+
const dragPan = Gesture.Pan()
|
|
112
|
+
.runOnJS(true)
|
|
113
|
+
.activateAfterLongPress(LONG_PRESS_MS)
|
|
114
|
+
.onStart((e) => {
|
|
115
|
+
const hit = topmostHit(latest.current, e.x, e.y)
|
|
116
|
+
if (!hit?.target.onMove) return
|
|
117
|
+
const { model, x = 0, y = 0 } = hit.target
|
|
118
|
+
drag.current = { model: model!, dx: e.x - x, dy: e.y - y }
|
|
119
|
+
})
|
|
120
|
+
.onUpdate((e) => {
|
|
121
|
+
if (!drag.current) return
|
|
122
|
+
const { model, dx, dy } = drag.current
|
|
123
|
+
latest.current.find((t) => t.model === model)?.onMove?.(e.x - dx, e.y - dy)
|
|
124
|
+
})
|
|
125
|
+
.onFinalize(() => {
|
|
126
|
+
drag.current = null
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return Gesture.Simultaneous(pan, tap, dragPan)
|
|
130
|
+
}, [focus])
|
|
131
|
+
/* eslint-enable react-hooks/refs */
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Touch interaction for one `<Live2D>`. For several models in one canvas, use `useLive2DGestures`. */
|
|
135
|
+
export function useLive2DGesture(
|
|
136
|
+
model: Live2DModel | null,
|
|
137
|
+
{ focus, ...target }: Live2DGestureOptions = {},
|
|
138
|
+
) {
|
|
139
|
+
return useLive2DGestures([{ model, ...target }], { focus })
|
|
140
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { type Live2DModel, type Live2DModelSource, loadLive2DModel } from '../loader/loadModel'
|
|
3
|
+
import type { Live2DModelContext } from '../loader/modelContext'
|
|
4
|
+
import type { Live2DLoadOptions } from '../loader/modelUrl'
|
|
5
|
+
|
|
6
|
+
export interface Live2DModelState {
|
|
7
|
+
/** The loaded model, `null` while loading or after an error. */
|
|
8
|
+
model: Live2DModel | null
|
|
9
|
+
/** Why the last load failed, e.g. a network error for a URL source. */
|
|
10
|
+
error: Error | null
|
|
11
|
+
loading: boolean
|
|
12
|
+
/** Loads the model again, e.g. after an error. */
|
|
13
|
+
retry: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Loads a model on mount. `options` apply to remote files (see `Live2DLoadOptions`). */
|
|
17
|
+
export function useLive2DModel(
|
|
18
|
+
source: Live2DModelSource | Live2DModelContext | string,
|
|
19
|
+
options?: Live2DLoadOptions,
|
|
20
|
+
): Live2DModelState {
|
|
21
|
+
const [model, setModel] = useState<Live2DModel | null>(null)
|
|
22
|
+
const [error, setError] = useState<Error | null>(null)
|
|
23
|
+
const [attempt, setAttempt] = useState(0)
|
|
24
|
+
// Read once: remount to load another model. Wrapped because a require.context
|
|
25
|
+
// is a function, which useState would call as an initializer.
|
|
26
|
+
const [initialSource] = useState(() => source)
|
|
27
|
+
const [initialOptions] = useState(options)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let cancelled = false
|
|
31
|
+
loadLive2DModel(initialSource, initialOptions).then(
|
|
32
|
+
(m) => !cancelled && setModel(m),
|
|
33
|
+
(e: unknown) => !cancelled && setError(e instanceof Error ? e : new Error(String(e))),
|
|
34
|
+
)
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true
|
|
37
|
+
}
|
|
38
|
+
}, [initialSource, initialOptions, attempt])
|
|
39
|
+
|
|
40
|
+
const retry = useCallback(() => {
|
|
41
|
+
setError(null)
|
|
42
|
+
setModel(null)
|
|
43
|
+
setAttempt((n) => n + 1)
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
return { model, error, loading: !model && !error, retry }
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Serializes a parsed Cubism JSON file (model3, physics3, motion3, ...) for the
|
|
2
|
+
// Cubism Framework's own JSON parser, which is stricter than JSON:
|
|
3
|
+
// - a number must be followed by a newline or a comma, never `}`, `]` or a space
|
|
4
|
+
// (CubismJson::ParseNumeric), so every value goes on its own line;
|
|
5
|
+
// - exponents aren't supported, so numbers like 1e-7 are written out in full.
|
|
6
|
+
// Files from Cubism Editor already look like this; `JSON.stringify(x)` does not.
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDER = '\u0000num:'
|
|
9
|
+
|
|
10
|
+
function plainNumber(n: number): string {
|
|
11
|
+
const s = String(n)
|
|
12
|
+
if (!/e/i.test(s)) return s
|
|
13
|
+
// toFixed(20) keeps far more precision than the Framework's float32 parser reads.
|
|
14
|
+
return n.toFixed(20).replace(/\.?0+$/, '')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toCubismJson(value: unknown): string {
|
|
18
|
+
return JSON.stringify(
|
|
19
|
+
value,
|
|
20
|
+
(_key, v) => (typeof v === 'number' && /e/i.test(String(v)) ? PLACEHOLDER + plainNumber(v) : v),
|
|
21
|
+
'\t',
|
|
22
|
+
).replace(/"\\u0000num:([^"]+)"/g, '$1')
|
|
23
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Asset } from 'expo-asset'
|
|
2
|
+
import { File } from 'expo-file-system'
|
|
3
|
+
import { Live2dSkia } from '../native'
|
|
4
|
+
import { decodeImage } from '../rendering/decodeImage'
|
|
5
|
+
import {
|
|
6
|
+
type CanvasInfo,
|
|
7
|
+
createDrawer,
|
|
8
|
+
createNativeDrawer,
|
|
9
|
+
setNativeTextures,
|
|
10
|
+
} from '../rendering/drawer'
|
|
11
|
+
import {
|
|
12
|
+
FilterMode,
|
|
13
|
+
MipmapMode,
|
|
14
|
+
type SkImage,
|
|
15
|
+
Skia,
|
|
16
|
+
type SkPicture,
|
|
17
|
+
type SkShader,
|
|
18
|
+
TileMode,
|
|
19
|
+
} from '../rendering/skia'
|
|
20
|
+
import type {
|
|
21
|
+
MotionPriority,
|
|
22
|
+
Live2DModel as NativeModel,
|
|
23
|
+
} from '../specs/react-native-live2d-skia.nitro'
|
|
24
|
+
import { toCubismJson } from './cubismJson'
|
|
25
|
+
import { isModelContext, type Live2DModelContext, sourceFromContext } from './modelContext'
|
|
26
|
+
import { fetchBytes, isRemote, type Live2DLoadOptions, redactUrl, sourceFromUrl } from './modelUrl'
|
|
27
|
+
|
|
28
|
+
export type AssetSource = number | string
|
|
29
|
+
|
|
30
|
+
/** A parsed Cubism JSON file, e.g. `require('../model.physics3.json')`. */
|
|
31
|
+
export type CubismJson = object
|
|
32
|
+
|
|
33
|
+
export interface Live2DModelSource {
|
|
34
|
+
/** Parsed `model3.json`. */
|
|
35
|
+
settings: CubismJson
|
|
36
|
+
moc: AssetSource
|
|
37
|
+
/** In the order of `FileReferences.Textures`. */
|
|
38
|
+
textures: AssetSource[]
|
|
39
|
+
physics?: CubismJson
|
|
40
|
+
pose?: CubismJson
|
|
41
|
+
/** By group, in the order of `FileReferences.Motions`. A random `Idle` motion plays when nothing else does. */
|
|
42
|
+
motions?: Record<string, CubismJson[]>
|
|
43
|
+
expressions?: Record<string, CubismJson>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A loaded model. Points are in model pixels: native canvas size, origin top-left, like `picture`. */
|
|
47
|
+
export interface Live2DModel {
|
|
48
|
+
/**
|
|
49
|
+
* Plays a motion of `group`, a random one without `index`. Returns false
|
|
50
|
+
* if there's no such motion or one with a higher priority is playing.
|
|
51
|
+
*/
|
|
52
|
+
motion(group: string, index?: number, priority?: MotionPriority): boolean
|
|
53
|
+
/** Fades to an expression, a random one without `name`. Returns false if there's none. */
|
|
54
|
+
expression(name?: string): boolean
|
|
55
|
+
resetExpression(): void
|
|
56
|
+
/** Holds a parameter at `value`, over motions, until `resetParameter`. */
|
|
57
|
+
setParameter(id: string, value: number): void
|
|
58
|
+
resetParameter(id: string): void
|
|
59
|
+
focus(x: number, y: number): void
|
|
60
|
+
resetFocus(): void
|
|
61
|
+
/** Names of the `model3.json` hit areas at a point, e.g. `['Head', 'Body']`. */
|
|
62
|
+
hitTest(x: number, y: number): string[]
|
|
63
|
+
motionGroups: string[]
|
|
64
|
+
expressionNames: string[]
|
|
65
|
+
|
|
66
|
+
/** Unique per loaded model: <Live2D>s showing the same model share its frames. */
|
|
67
|
+
id: number
|
|
68
|
+
/** Native model. Thread-safe. */
|
|
69
|
+
native: NativeModel
|
|
70
|
+
/** The model drawn at load, at its native canvas size, origin top-left. */
|
|
71
|
+
picture: SkPicture
|
|
72
|
+
width: number
|
|
73
|
+
height: number
|
|
74
|
+
/** Draws the model's current state (after `native.update`) into a new picture. */
|
|
75
|
+
draw(): SkPicture
|
|
76
|
+
/** For drawing on another worklet runtime with `createDrawer`. */
|
|
77
|
+
textures: SkImage[]
|
|
78
|
+
shaders: SkShader[]
|
|
79
|
+
canvas: CanvasInfo
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// For error messages: URLs lose their query, which can hold tokens.
|
|
83
|
+
const describe = (source: AssetSource) =>
|
|
84
|
+
typeof source === 'string' ? redactUrl(source) : String(source)
|
|
85
|
+
|
|
86
|
+
async function readAsset(source: AssetSource, options: Live2DLoadOptions): Promise<ArrayBuffer> {
|
|
87
|
+
// Remote files are fetched directly: expo-asset can't send headers.
|
|
88
|
+
if (isRemote(source)) return fetchBytes(source, options)
|
|
89
|
+
const asset = Asset.fromModule(source)
|
|
90
|
+
// Android release builds embed images as drawables, and expo-asset marks them
|
|
91
|
+
// downloaded with a bare resource name (enough for <Image>), which isn't a file.
|
|
92
|
+
// Download for real: that copies the resource into the cache.
|
|
93
|
+
if (asset.localUri && !asset.localUri.includes(':')) {
|
|
94
|
+
asset.downloaded = false
|
|
95
|
+
asset.localUri = null
|
|
96
|
+
}
|
|
97
|
+
await asset.downloadAsync()
|
|
98
|
+
if (!asset.localUri) {
|
|
99
|
+
throw new Error(`Live2D: failed to download asset ${describe(source)}`)
|
|
100
|
+
}
|
|
101
|
+
return new File(asset.localUri).arrayBuffer()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function loadTexture(source: AssetSource, options: Live2DLoadOptions): Promise<SkImage> {
|
|
105
|
+
const bytes = new Uint8Array(await readAsset(source, options))
|
|
106
|
+
const image = decodeImage(Skia.Image, Skia.Data.fromBytes(bytes))
|
|
107
|
+
if (!image) {
|
|
108
|
+
throw new Error(`Live2D: failed to decode texture ${describe(source)}`)
|
|
109
|
+
}
|
|
110
|
+
return image
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let nextModelId = 1
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Loads a model from its files, from a `require.context` of its folder, or
|
|
117
|
+
* from the URL of its `model3.json` (the files it lists are fetched next to it).
|
|
118
|
+
* `options` apply to every remote file.
|
|
119
|
+
*/
|
|
120
|
+
export async function loadLive2DModel(
|
|
121
|
+
input: Live2DModelSource | Live2DModelContext | string,
|
|
122
|
+
options: Live2DLoadOptions = {},
|
|
123
|
+
): Promise<Live2DModel> {
|
|
124
|
+
const source =
|
|
125
|
+
typeof input === 'string'
|
|
126
|
+
? await sourceFromUrl(input, options)
|
|
127
|
+
: isModelContext(input)
|
|
128
|
+
? sourceFromContext(input)
|
|
129
|
+
: input
|
|
130
|
+
const [moc, textures] = await Promise.all([
|
|
131
|
+
readAsset(source.moc, options),
|
|
132
|
+
Promise.all(source.textures.map((t) => loadTexture(t, options))),
|
|
133
|
+
])
|
|
134
|
+
const native = Live2dSkia.loadModel(moc, toCubismJson(source.settings))
|
|
135
|
+
if (source.physics) native.loadPhysics(toCubismJson(source.physics))
|
|
136
|
+
if (source.pose) native.loadPose(toCubismJson(source.pose))
|
|
137
|
+
for (const [group, motions] of Object.entries(source.motions ?? {})) {
|
|
138
|
+
motions.forEach((motion, i) => {
|
|
139
|
+
native.loadMotion(group, i, toCubismJson(motion))
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
for (const [name, expression] of Object.entries(source.expressions ?? {})) {
|
|
143
|
+
native.loadExpression(name, toCubismJson(expression))
|
|
144
|
+
}
|
|
145
|
+
// Apply the pose and the first idle motion's starting state.
|
|
146
|
+
native.update(0)
|
|
147
|
+
|
|
148
|
+
// Trilinear: models are usually drawn far smaller than their 2048px textures,
|
|
149
|
+
// and without mipmaps thin lines (hair, outlines) break up into jaggies.
|
|
150
|
+
const shaders = textures.map((t) =>
|
|
151
|
+
t.makeShaderOptions(TileMode.Clamp, TileMode.Clamp, FilterMode.Linear, MipmapMode.Linear),
|
|
152
|
+
)
|
|
153
|
+
const width = native.canvasWidth
|
|
154
|
+
const height = native.canvasHeight
|
|
155
|
+
const canvas: CanvasInfo = {
|
|
156
|
+
width,
|
|
157
|
+
height,
|
|
158
|
+
originX: native.originX,
|
|
159
|
+
originY: native.originY,
|
|
160
|
+
pixelsPerUnit: native.pixelsPerUnit,
|
|
161
|
+
}
|
|
162
|
+
setNativeTextures(native, textures, shaders)
|
|
163
|
+
const nativeDraw = createNativeDrawer(native, canvas)
|
|
164
|
+
const drawer = createDrawer(textures, shaders, canvas)
|
|
165
|
+
const draw = nativeDraw ?? (() => drawer(native.getDrawables()))
|
|
166
|
+
// Model pixels (y-down) to model units (y-up).
|
|
167
|
+
const toUnits = (x: number, y: number) =>
|
|
168
|
+
[
|
|
169
|
+
(x - canvas.originX) / canvas.pixelsPerUnit,
|
|
170
|
+
(canvas.originY - y) / canvas.pixelsPerUnit,
|
|
171
|
+
] as const
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
id: nextModelId++,
|
|
175
|
+
motion: (group, index, priority) => native.startMotion(group, index, priority),
|
|
176
|
+
expression: (name) => native.setExpression(name),
|
|
177
|
+
resetExpression: () => native.clearExpression(),
|
|
178
|
+
setParameter: (id, value) => native.setParameter(id, value),
|
|
179
|
+
resetParameter: (id) => native.clearParameter(id),
|
|
180
|
+
// Relative to the canvas center, -1..1, y-up.
|
|
181
|
+
focus: (x, y) => native.focus((x / width) * 2 - 1, 1 - (y / height) * 2),
|
|
182
|
+
resetFocus: () => native.focus(0, 0),
|
|
183
|
+
hitTest: (x, y) => native.hitTest(...toUnits(x, y)),
|
|
184
|
+
motionGroups: Object.keys(source.motions ?? {}),
|
|
185
|
+
expressionNames: Object.keys(source.expressions ?? {}),
|
|
186
|
+
native,
|
|
187
|
+
picture: draw(),
|
|
188
|
+
width,
|
|
189
|
+
height,
|
|
190
|
+
draw,
|
|
191
|
+
textures,
|
|
192
|
+
shaders,
|
|
193
|
+
canvas,
|
|
194
|
+
}
|
|
195
|
+
}
|