@airisu-dev/react-native-live2d-skia 0.0.0-stage → 0.1.0
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 +64 -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 +146 -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 +194 -0
- package/src/loader/modelContext.ts +80 -0
- package/src/loader/modelUrl.ts +112 -0
- package/src/native.ts +4 -0
- package/src/rendering/drawer.ts +256 -0
- package/src/rendering/skia.ts +114 -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,256 @@
|
|
|
1
|
+
// Records a model's drawables into Skia pictures. Everything here is a worklet,
|
|
2
|
+
// so it can run on the JS thread (model.draw) or a background worklet runtime (<Live2D>).
|
|
3
|
+
|
|
4
|
+
import type { DrawableData } from '../specs/react-native-live2d-skia.nitro'
|
|
5
|
+
import { now } from '../utils/clock'
|
|
6
|
+
import {
|
|
7
|
+
BlendMode,
|
|
8
|
+
type SkCanvas,
|
|
9
|
+
type SkImage,
|
|
10
|
+
Skia,
|
|
11
|
+
type SkPaint,
|
|
12
|
+
type SkPicture,
|
|
13
|
+
type SkPoint,
|
|
14
|
+
type SkRect,
|
|
15
|
+
type SkShader,
|
|
16
|
+
type SkVertices,
|
|
17
|
+
VertexMode,
|
|
18
|
+
} from './skia'
|
|
19
|
+
|
|
20
|
+
// Cubism constant flags (Live2DCubismCore.h).
|
|
21
|
+
const csmBlendAdditive = 1 << 0
|
|
22
|
+
const csmBlendMultiplicative = 1 << 1
|
|
23
|
+
const csmIsInvertedMask = 1 << 3
|
|
24
|
+
|
|
25
|
+
function blendModeOf(flags: number): number {
|
|
26
|
+
'worklet'
|
|
27
|
+
if (flags & csmBlendAdditive) return BlendMode.Plus
|
|
28
|
+
if (flags & csmBlendMultiplicative) return BlendMode.Multiply
|
|
29
|
+
return BlendMode.SrcOver
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Mesh {
|
|
33
|
+
vertices: SkVertices
|
|
34
|
+
bounds: SkRect
|
|
35
|
+
shader: SkShader
|
|
36
|
+
/** The model-space positions this mesh was built from, to detect changes. */
|
|
37
|
+
positions: Float32Array
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface StaticMeshData {
|
|
41
|
+
texCoords: SkPoint[]
|
|
42
|
+
indices: number[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sameFloats(a: Float32Array, b: Float32Array): boolean {
|
|
46
|
+
'worklet'
|
|
47
|
+
if (a.length !== b.length) return false
|
|
48
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Added to by a drawer when passed in, for frame stats. */
|
|
53
|
+
export interface DrawStats {
|
|
54
|
+
/** Time spent building Skia vertices (`MakeVertices` and point arrays). */
|
|
55
|
+
meshMs: number
|
|
56
|
+
/** Meshes rebuilt because their vertices moved. */
|
|
57
|
+
meshesBuilt: number
|
|
58
|
+
/** `saveLayer`s for masks: two per masked drawable. */
|
|
59
|
+
layers: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CanvasInfo {
|
|
63
|
+
width: number
|
|
64
|
+
height: number
|
|
65
|
+
originX: number
|
|
66
|
+
originY: number
|
|
67
|
+
pixelsPerUnit: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Records drawables into a picture. A mesh is rebuilt only when its vertices moved: building them is the costliest part of a frame. */
|
|
71
|
+
export function createDrawer(
|
|
72
|
+
textures: SkImage[],
|
|
73
|
+
shaders: SkShader[],
|
|
74
|
+
{ width, height, originX, originY, pixelsPerUnit }: CanvasInfo,
|
|
75
|
+
): (drawables: DrawableData[], stats?: DrawStats) => SkPicture {
|
|
76
|
+
'worklet'
|
|
77
|
+
const bounds = Skia.XYWHRect(0, 0, width, height)
|
|
78
|
+
const paint = Skia.Paint()
|
|
79
|
+
paint.setAntiAlias(true)
|
|
80
|
+
const layerPaint = Skia.Paint()
|
|
81
|
+
const staticData = new Map<number, StaticMeshData>()
|
|
82
|
+
let lastMeshes = new Map<number, Mesh>()
|
|
83
|
+
|
|
84
|
+
const staticDataOf = (d: DrawableData, texture: SkImage): StaticMeshData => {
|
|
85
|
+
let data = staticData.get(d.index)
|
|
86
|
+
if (!data) {
|
|
87
|
+
const uv = new Float32Array(d.uvs)
|
|
88
|
+
const texW = texture.width()
|
|
89
|
+
const texH = texture.height()
|
|
90
|
+
const texCoords: SkPoint[] = []
|
|
91
|
+
for (let i = 0; i < uv.length; i += 2) {
|
|
92
|
+
// Cubism UVs are v-up.
|
|
93
|
+
texCoords.push({ x: uv[i]! * texW, y: (1 - uv[i + 1]!) * texH })
|
|
94
|
+
}
|
|
95
|
+
data = { texCoords, indices: Array.from(new Uint16Array(d.indices)) }
|
|
96
|
+
staticData.set(d.index, data)
|
|
97
|
+
}
|
|
98
|
+
return data
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (drawables, stats) => {
|
|
102
|
+
const byIndex = new Map(drawables.map((d) => [d.index, d]))
|
|
103
|
+
|
|
104
|
+
// Masks reuse the same meshes many times, so look each one up once per frame.
|
|
105
|
+
const meshes = new Map<number, Mesh | null>()
|
|
106
|
+
const meshOf = (index: number): Mesh | null => {
|
|
107
|
+
if (meshes.has(index)) return meshes.get(index)!
|
|
108
|
+
const d = byIndex.get(index)
|
|
109
|
+
const texture = d && textures[d.textureIndex]
|
|
110
|
+
const shader = d && shaders[d.textureIndex]
|
|
111
|
+
let mesh: Mesh | null = null
|
|
112
|
+
if (d && texture && shader && d.indices.byteLength > 0) {
|
|
113
|
+
const positions = new Float32Array(d.positions)
|
|
114
|
+
const last = lastMeshes.get(index)
|
|
115
|
+
if (last && sameFloats(last.positions, positions)) {
|
|
116
|
+
mesh = last
|
|
117
|
+
} else {
|
|
118
|
+
const start = stats ? now() : 0
|
|
119
|
+
const { texCoords, indices } = staticDataOf(d, texture)
|
|
120
|
+
const points: SkPoint[] = []
|
|
121
|
+
let minX = Infinity,
|
|
122
|
+
minY = Infinity,
|
|
123
|
+
maxX = -Infinity,
|
|
124
|
+
maxY = -Infinity
|
|
125
|
+
for (let i = 0; i < positions.length; i += 2) {
|
|
126
|
+
// Cubism is y-up in model units; Skia is y-down in pixels.
|
|
127
|
+
const x = originX + positions[i]! * pixelsPerUnit
|
|
128
|
+
const y = originY - positions[i + 1]! * pixelsPerUnit
|
|
129
|
+
points.push({ x, y })
|
|
130
|
+
minX = Math.min(minX, x)
|
|
131
|
+
maxX = Math.max(maxX, x)
|
|
132
|
+
minY = Math.min(minY, y)
|
|
133
|
+
maxY = Math.max(maxY, y)
|
|
134
|
+
}
|
|
135
|
+
mesh = {
|
|
136
|
+
vertices: Skia.MakeVertices(
|
|
137
|
+
VertexMode.Triangles,
|
|
138
|
+
points,
|
|
139
|
+
texCoords,
|
|
140
|
+
undefined,
|
|
141
|
+
indices,
|
|
142
|
+
),
|
|
143
|
+
bounds: Skia.XYWHRect(minX, minY, maxX - minX, maxY - minY),
|
|
144
|
+
shader,
|
|
145
|
+
positions,
|
|
146
|
+
}
|
|
147
|
+
if (stats) {
|
|
148
|
+
stats.meshMs += now() - start
|
|
149
|
+
stats.meshesBuilt++
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
meshes.set(index, mesh)
|
|
154
|
+
return mesh
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const drawMesh = (canvas: SkCanvas, mesh: Mesh, paint: SkPaint) => {
|
|
158
|
+
paint.setShader(mesh.shader)
|
|
159
|
+
// No per-vertex colors, so the vertex blend mode is unused; the shader alone colors the mesh.
|
|
160
|
+
canvas.drawVertices(mesh.vertices, BlendMode.Dst, paint)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const recorder = Skia.PictureRecorder()
|
|
164
|
+
const canvas = recorder.beginRecording(bounds)
|
|
165
|
+
|
|
166
|
+
for (const d of drawables) {
|
|
167
|
+
if (!d.visible || d.opacity <= 0) continue
|
|
168
|
+
const mesh = meshOf(d.index)
|
|
169
|
+
if (!mesh) continue
|
|
170
|
+
|
|
171
|
+
if (d.masks.length === 0) {
|
|
172
|
+
paint.setAlphaf(d.opacity)
|
|
173
|
+
paint.setBlendMode(blendModeOf(d.constantFlags))
|
|
174
|
+
drawMesh(canvas, mesh, paint)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Draw into a layer, cut it with the union of the masks, then composite
|
|
179
|
+
// the layer with the drawable's blend mode.
|
|
180
|
+
if (stats) stats.layers += 2
|
|
181
|
+
layerPaint.setBlendMode(blendModeOf(d.constantFlags))
|
|
182
|
+
canvas.saveLayer(layerPaint, mesh.bounds)
|
|
183
|
+
paint.setAlphaf(d.opacity)
|
|
184
|
+
paint.setBlendMode(BlendMode.SrcOver)
|
|
185
|
+
drawMesh(canvas, mesh, paint)
|
|
186
|
+
|
|
187
|
+
layerPaint.setBlendMode(
|
|
188
|
+
d.constantFlags & csmIsInvertedMask ? BlendMode.DstOut : BlendMode.DstIn,
|
|
189
|
+
)
|
|
190
|
+
canvas.saveLayer(layerPaint, mesh.bounds)
|
|
191
|
+
paint.setAlphaf(1)
|
|
192
|
+
for (const maskIndex of d.masks) {
|
|
193
|
+
const maskMesh = meshOf(maskIndex)
|
|
194
|
+
if (maskMesh) drawMesh(canvas, maskMesh, paint)
|
|
195
|
+
}
|
|
196
|
+
canvas.restore()
|
|
197
|
+
canvas.restore()
|
|
198
|
+
}
|
|
199
|
+
const picture = recorder.finishRecordingAsPicture()
|
|
200
|
+
// Free the meshes that were replaced or not used this frame. Pictures hold
|
|
201
|
+
// their own references, and the GC doesn't see this native memory.
|
|
202
|
+
const nextMeshes = new Map<number, Mesh>()
|
|
203
|
+
for (const [index, mesh] of meshes) if (mesh) nextMeshes.set(index, mesh)
|
|
204
|
+
for (const [index, mesh] of lastMeshes) {
|
|
205
|
+
if (nextMeshes.get(index) !== mesh) mesh.vertices.dispose()
|
|
206
|
+
}
|
|
207
|
+
lastMeshes = nextMeshes
|
|
208
|
+
return picture
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Raw JSI methods of a native model built with Skia (cpp/drawing/SkiaDrawer.cpp). */
|
|
213
|
+
interface NativeDrawing {
|
|
214
|
+
setTextures(shaders: SkShader[], sizes: number[]): void
|
|
215
|
+
drawTo(canvas: SkCanvas, wantStats: boolean): DrawStats | undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function nativeDrawing(native: object): NativeDrawing | null {
|
|
219
|
+
'worklet'
|
|
220
|
+
const n = native as Partial<NativeDrawing>
|
|
221
|
+
return typeof n.drawTo === 'function' && typeof n.setTextures === 'function'
|
|
222
|
+
? (n as NativeDrawing)
|
|
223
|
+
: null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Hands the textures to the native drawer, if there is one. Call once per model. */
|
|
227
|
+
export function setNativeTextures(native: object, textures: SkImage[], shaders: SkShader[]): void {
|
|
228
|
+
const drawing = nativeDrawing(native)
|
|
229
|
+
if (!drawing) return
|
|
230
|
+
drawing.setTextures(
|
|
231
|
+
shaders,
|
|
232
|
+
textures.flatMap((t) => [t.width(), t.height()]),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Like createDrawer, but drawn in C++ straight from the model. Null when not built with Skia. */
|
|
237
|
+
export function createNativeDrawer(
|
|
238
|
+
native: object,
|
|
239
|
+
{ width, height }: CanvasInfo,
|
|
240
|
+
): ((stats?: DrawStats) => SkPicture) | null {
|
|
241
|
+
'worklet'
|
|
242
|
+
const drawing = nativeDrawing(native)
|
|
243
|
+
if (!drawing) return null
|
|
244
|
+
const bounds = Skia.XYWHRect(0, 0, width, height)
|
|
245
|
+
return (stats) => {
|
|
246
|
+
const recorder = Skia.PictureRecorder()
|
|
247
|
+
const canvas = recorder.beginRecording(bounds)
|
|
248
|
+
const s = drawing.drawTo(canvas, stats !== undefined)
|
|
249
|
+
if (stats && s) {
|
|
250
|
+
stats.meshMs += s.meshMs
|
|
251
|
+
stats.meshesBuilt += s.meshesBuilt
|
|
252
|
+
stats.layers += s.layers
|
|
253
|
+
}
|
|
254
|
+
return recorder.finishRecordingAsPicture()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The Skia package in use: react-native-skia (v3) or @shopify/react-native-skia (v2).
|
|
2
|
+
// Types are declared structurally rather than imported: the app type-checks these
|
|
3
|
+
// sources and may have only one of the two packages.
|
|
4
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
export interface SkPoint {
|
|
7
|
+
x: number
|
|
8
|
+
y: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SkRect {
|
|
12
|
+
x: number
|
|
13
|
+
y: number
|
|
14
|
+
width: number
|
|
15
|
+
height: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SkShader {
|
|
19
|
+
dispose(): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SkPicture {
|
|
23
|
+
dispose(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SkVertices {
|
|
27
|
+
dispose(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SkImage {
|
|
31
|
+
width(): number
|
|
32
|
+
height(): number
|
|
33
|
+
makeShaderOptions(tx: number, ty: number, fm: number, mm: number): SkShader
|
|
34
|
+
dispose(): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SkPaint {
|
|
38
|
+
setAntiAlias(antiAlias: boolean): void
|
|
39
|
+
setAlphaf(alpha: number): void
|
|
40
|
+
setBlendMode(blendMode: number): void
|
|
41
|
+
setShader(shader: SkShader | null): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SkCanvas {
|
|
45
|
+
saveLayer(paint?: SkPaint, bounds?: SkRect | null): number
|
|
46
|
+
restore(): void
|
|
47
|
+
drawVertices(vertices: SkVertices, mode: number, paint: SkPaint): void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SkPictureRecorder {
|
|
51
|
+
beginRecording(bounds?: SkRect): SkCanvas
|
|
52
|
+
finishRecordingAsPicture(): SkPicture
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SkData {
|
|
56
|
+
dispose(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SkiaApi {
|
|
60
|
+
XYWHRect(x: number, y: number, width: number, height: number): SkRect
|
|
61
|
+
Paint(): SkPaint
|
|
62
|
+
PictureRecorder(): SkPictureRecorder
|
|
63
|
+
MakeVertices(
|
|
64
|
+
mode: number,
|
|
65
|
+
positions: SkPoint[],
|
|
66
|
+
textures?: SkPoint[] | null,
|
|
67
|
+
colors?: undefined,
|
|
68
|
+
indices?: number[] | null,
|
|
69
|
+
): SkVertices
|
|
70
|
+
Image: { MakeImageFromEncoded(data: SkData): SkImage | null }
|
|
71
|
+
Data: { fromBytes(bytes: Uint8Array): SkData }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface SkiaModule {
|
|
75
|
+
Skia: SkiaApi
|
|
76
|
+
BlendMode: {
|
|
77
|
+
SrcOver: number
|
|
78
|
+
Dst: number
|
|
79
|
+
DstIn: number
|
|
80
|
+
DstOut: number
|
|
81
|
+
Plus: number
|
|
82
|
+
Multiply: number
|
|
83
|
+
}
|
|
84
|
+
VertexMode: { Triangles: number }
|
|
85
|
+
TileMode: { Clamp: number }
|
|
86
|
+
FilterMode: { Linear: number }
|
|
87
|
+
MipmapMode: { Linear: number }
|
|
88
|
+
Group: ComponentType<{
|
|
89
|
+
transform?: Record<string, number>[]
|
|
90
|
+
children?: ReactNode
|
|
91
|
+
}>
|
|
92
|
+
// A picture or a shared value of one (Reanimated).
|
|
93
|
+
Picture: ComponentType<{ picture: unknown }>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Guarded requires: Metro (Expo enables optional dependencies) bundles the
|
|
97
|
+
// missing one as a require that throws. v3 first: in a monorepo both can be
|
|
98
|
+
// installed, and an app on v2 maps react-native-skia to it in its Metro config.
|
|
99
|
+
function loadSkia(): SkiaModule {
|
|
100
|
+
try {
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
102
|
+
return require('react-native-skia') as SkiaModule
|
|
103
|
+
} catch {}
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
106
|
+
return require('@shopify/react-native-skia') as SkiaModule
|
|
107
|
+
} catch {}
|
|
108
|
+
throw new Error(
|
|
109
|
+
'react-native-live2d-skia needs react-native-skia (v3) or @shopify/react-native-skia (v2).',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const { Skia, BlendMode, VertexMode, TileMode, FilterMode, MipmapMode, Group, Picture } =
|
|
114
|
+
loadSkia()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
2
|
+
import { now } from '../../utils/clock'
|
|
3
|
+
import { createDrawer, createNativeDrawer } from '../drawer'
|
|
4
|
+
import { runFrame } from './loop'
|
|
5
|
+
import { reportError, tickerState } from './state'
|
|
6
|
+
import type { TickerModel, TickerView } from './types'
|
|
7
|
+
|
|
8
|
+
/** Starts showing a model's frames in a view (call on the live2d runtime); stop with `view.running = false`. */
|
|
9
|
+
export function addToTicker(model: TickerModel, view: TickerView) {
|
|
10
|
+
'worklet'
|
|
11
|
+
const ticker = tickerState()
|
|
12
|
+
let entry = ticker.entries.get(model.id)
|
|
13
|
+
if (!entry) {
|
|
14
|
+
try {
|
|
15
|
+
const nativeDraw = createNativeDrawer(model.native, model.canvas)
|
|
16
|
+
entry = {
|
|
17
|
+
native: model.native,
|
|
18
|
+
nativeDraw,
|
|
19
|
+
jsDraw: nativeDraw ? null : createDrawer(model.textures, model.shaders, model.canvas),
|
|
20
|
+
views: [],
|
|
21
|
+
frames: [],
|
|
22
|
+
frame: 0,
|
|
23
|
+
last: null,
|
|
24
|
+
statsStart: now(),
|
|
25
|
+
statsFrames: 0,
|
|
26
|
+
updateMs: 0,
|
|
27
|
+
drawablesMs: 0,
|
|
28
|
+
drawMs: 0,
|
|
29
|
+
drawStats: { meshMs: 0, meshesBuilt: 0, layers: 0 },
|
|
30
|
+
}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
view.running.setBlocking(false)
|
|
33
|
+
scheduleOnRN(reportError, String((e as Error)?.stack ?? e))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
ticker.entries.set(model.id, entry)
|
|
37
|
+
}
|
|
38
|
+
entry.views.push({ view, since: entry.frame })
|
|
39
|
+
// Joining a model that's already animating: show its newest frame right away.
|
|
40
|
+
const newest = entry.frames[entry.frames.length - 1]
|
|
41
|
+
if (newest) view.target.setBlocking({ frame: newest.frame, picture: newest.picture })
|
|
42
|
+
if (!ticker.running) {
|
|
43
|
+
ticker.running = true
|
|
44
|
+
ticker.nextFrameAt = 0
|
|
45
|
+
ticker.loopStatsStart = now()
|
|
46
|
+
const loop = () => {
|
|
47
|
+
const delay = runFrame()
|
|
48
|
+
if (delay !== null) setTimeout(loop, delay)
|
|
49
|
+
}
|
|
50
|
+
loop()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Longest step per frame, so returning from the background doesn't jump motions and physics.
|
|
2
|
+
export const MAX_DELTA_SECONDS = 0.1
|
|
3
|
+
// How far drawing may run ahead of the slowest view's UI thread. Beyond it the
|
|
4
|
+
// model waits, e.g. while the app is in the background and the UI thread doesn't draw.
|
|
5
|
+
export const MAX_FRAMES_AHEAD = 4
|
|
6
|
+
export const DEFAULT_FRAME_MS = 1000 / 60
|
|
7
|
+
export const MIN_FRAME_MS = 1000 / 144
|
|
8
|
+
export const MAX_FRAME_MS = 1000 / 30
|
|
9
|
+
export const STATS_INTERVAL_MS = 1000
|
|
10
|
+
// A capped model draws on the first tick at least this close to its interval.
|
|
11
|
+
export const CAP_TOLERANCE_MS = 1
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Entry, Slot, Ticker } from './state'
|
|
2
|
+
|
|
3
|
+
// The oldest frame a view may be showing.
|
|
4
|
+
export function oldestShown(slot: Slot) {
|
|
5
|
+
'worklet'
|
|
6
|
+
const shown = slot.view.shown.getDirty()
|
|
7
|
+
return shown >= 0 ? shown : slot.since
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// A view stopped (paused or unmounted) but keeps showing its last frame, and
|
|
11
|
+
// its UI thread may still take a newer one already published. Those frames are
|
|
12
|
+
// handed over to it: dropped from the entry unfreed, for the GC to collect.
|
|
13
|
+
export function releaseView(entry: Entry, slot: Slot) {
|
|
14
|
+
'worklet'
|
|
15
|
+
const oldest = oldestShown(slot)
|
|
16
|
+
entry.frames = entry.frames.filter((f) => f.frame < oldest)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Frees frames older than every view's shown frame, keeping one more: the JS
|
|
20
|
+
// thread may have just read the shown picture for a redraw.
|
|
21
|
+
export function freeFrames(entry: Entry) {
|
|
22
|
+
'worklet'
|
|
23
|
+
const keepFrom = Math.min(...entry.views.map(oldestShown)) - 1
|
|
24
|
+
while (entry.frames.length > 0 && entry.frames[0]!.frame < keepFrom) {
|
|
25
|
+
entry.frames.shift()!.picture.dispose()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stopEntry(ticker: Ticker, id: number, entry: Entry) {
|
|
30
|
+
'worklet'
|
|
31
|
+
// Its views are released: no view can show what's left.
|
|
32
|
+
for (const f of entry.frames) f.picture.dispose()
|
|
33
|
+
entry.frames = []
|
|
34
|
+
ticker.entries.delete(id)
|
|
35
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// One animation loop for every <Live2D> on the live2d worklet runtime: each
|
|
2
|
+
// frame, every shown model is updated and drawn once, in one pass. Everything
|
|
3
|
+
// here runs on that runtime, and its state lives in the runtime's globalThis.
|
|
4
|
+
export { addToTicker } from './addToTicker'
|
|
5
|
+
export type { Live2DFrameStats, PublishedFrame, TickerModel, TickerView } from './types'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
2
|
+
import { now } from '../../utils/clock'
|
|
3
|
+
import type { SkPicture } from '../skia'
|
|
4
|
+
import {
|
|
5
|
+
CAP_TOLERANCE_MS,
|
|
6
|
+
DEFAULT_FRAME_MS,
|
|
7
|
+
MAX_DELTA_SECONDS,
|
|
8
|
+
MAX_FRAME_MS,
|
|
9
|
+
MAX_FRAMES_AHEAD,
|
|
10
|
+
MIN_FRAME_MS,
|
|
11
|
+
STATS_INTERVAL_MS,
|
|
12
|
+
} from './constants'
|
|
13
|
+
import { freeFrames, oldestShown, releaseView, stopEntry } from './frames'
|
|
14
|
+
import { type Entry, reportError, tickerState } from './state'
|
|
15
|
+
import { reportEntry } from './stats'
|
|
16
|
+
|
|
17
|
+
function drawEntry(entry: Entry, frameStart: number, wantStats: boolean) {
|
|
18
|
+
'worklet'
|
|
19
|
+
const dt = entry.last === null ? 0 : Math.min((frameStart - entry.last) / 1000, MAX_DELTA_SECONDS)
|
|
20
|
+
entry.last = frameStart
|
|
21
|
+
// Timed from here: frameStart is when the whole loop frame began.
|
|
22
|
+
const start = wantStats ? now() : 0
|
|
23
|
+
entry.native.update(dt)
|
|
24
|
+
const updated = wantStats ? now() : 0
|
|
25
|
+
const stats = wantStats ? entry.drawStats : undefined
|
|
26
|
+
let copied = updated
|
|
27
|
+
let picture: SkPicture
|
|
28
|
+
if (entry.nativeDraw) {
|
|
29
|
+
picture = entry.nativeDraw(stats)
|
|
30
|
+
} else {
|
|
31
|
+
const drawables = entry.native.getDrawables()
|
|
32
|
+
copied = wantStats ? now() : 0
|
|
33
|
+
picture = entry.jsDraw!(drawables, stats)
|
|
34
|
+
}
|
|
35
|
+
entry.frame++
|
|
36
|
+
for (const { view } of entry.views) {
|
|
37
|
+
view.target.setBlocking({ frame: entry.frame, picture })
|
|
38
|
+
}
|
|
39
|
+
entry.frames.push({ frame: entry.frame, picture })
|
|
40
|
+
if (wantStats) {
|
|
41
|
+
const end = now()
|
|
42
|
+
entry.statsFrames++
|
|
43
|
+
entry.updateMs += updated - start
|
|
44
|
+
entry.drawablesMs += copied - updated
|
|
45
|
+
entry.drawMs += end - copied
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Updates and draws every model once. Returns the delay until the next frame, or null to stop. */
|
|
50
|
+
export function runFrame(): number | null {
|
|
51
|
+
'worklet'
|
|
52
|
+
const ticker = tickerState()
|
|
53
|
+
const frameStart = now()
|
|
54
|
+
let displayMs = 0
|
|
55
|
+
for (const [id, entry] of ticker.entries) {
|
|
56
|
+
const stopped = entry.views.filter((s) => !s.view.running.getDirty())
|
|
57
|
+
if (stopped.length > 0) {
|
|
58
|
+
for (const slot of stopped) releaseView(entry, slot)
|
|
59
|
+
entry.views = entry.views.filter((s) => !stopped.includes(s))
|
|
60
|
+
}
|
|
61
|
+
if (entry.views.length === 0) {
|
|
62
|
+
stopEntry(ticker, id, entry)
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
freeFrames(entry)
|
|
66
|
+
for (const { view } of entry.views) {
|
|
67
|
+
const ms = view.displayMs.getDirty()
|
|
68
|
+
if (ms > 0 && (displayMs === 0 || ms < displayMs)) displayMs = ms
|
|
69
|
+
}
|
|
70
|
+
// The fastest view sets the model's frame rate.
|
|
71
|
+
const capMs = Math.min(...entry.views.map((s) => s.view.capMs))
|
|
72
|
+
if (entry.last !== null && frameStart - entry.last < capMs - CAP_TOLERANCE_MS) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
// Wait for a UI thread that's behind, rather than draw frames it won't show.
|
|
76
|
+
if (entry.frame - Math.min(...entry.views.map(oldestShown)) >= MAX_FRAMES_AHEAD) {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const wantStats = entry.views.some((s) => s.view.report !== null)
|
|
80
|
+
try {
|
|
81
|
+
drawEntry(entry, frameStart, wantStats)
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// An uncaught error on this runtime aborts the app: drop the model instead.
|
|
84
|
+
for (const slot of entry.views) {
|
|
85
|
+
slot.view.running.setBlocking(false)
|
|
86
|
+
releaseView(entry, slot)
|
|
87
|
+
}
|
|
88
|
+
stopEntry(ticker, id, entry)
|
|
89
|
+
scheduleOnRN(reportError, String((e as Error)?.stack ?? e))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const end = now()
|
|
94
|
+
ticker.loopWorkMs += end - frameStart
|
|
95
|
+
ticker.loopFrames++
|
|
96
|
+
const interval = Math.min(MAX_FRAME_MS, Math.max(MIN_FRAME_MS, displayMs || DEFAULT_FRAME_MS))
|
|
97
|
+
for (const entry of ticker.entries.values()) {
|
|
98
|
+
const capMs = Math.min(...entry.views.map((s) => s.view.capMs))
|
|
99
|
+
reportEntry(ticker, entry, end, Math.max(interval, capMs))
|
|
100
|
+
}
|
|
101
|
+
if (end - ticker.loopStatsStart >= STATS_INTERVAL_MS) {
|
|
102
|
+
ticker.loopStatsStart = end
|
|
103
|
+
ticker.loopWorkMs = 0
|
|
104
|
+
ticker.loopFrames = 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (ticker.entries.size === 0) {
|
|
108
|
+
ticker.running = false
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
ticker.nextFrameAt += interval
|
|
112
|
+
// More than a frame behind (a stall, or the first frame): start over from now.
|
|
113
|
+
if (ticker.nextFrameAt < end - interval) ticker.nextFrameAt = end + interval
|
|
114
|
+
return Math.max(0, ticker.nextFrameAt - end)
|
|
115
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Live2DModel as NativeModel } from '../../specs/react-native-live2d-skia.nitro'
|
|
2
|
+
import type { createDrawer, DrawStats } from '../drawer'
|
|
3
|
+
import type { SkPicture } from '../skia'
|
|
4
|
+
import type { TickerView } from './types'
|
|
5
|
+
|
|
6
|
+
export interface Slot {
|
|
7
|
+
view: TickerView
|
|
8
|
+
/** The entry's newest frame when the view joined: the oldest it can show before acking one. */
|
|
9
|
+
since: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Frame {
|
|
13
|
+
frame: number
|
|
14
|
+
picture: SkPicture
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Entry {
|
|
18
|
+
native: NativeModel
|
|
19
|
+
/** Draws the model's current state: in C++ when built with Skia, else in JS. */
|
|
20
|
+
nativeDraw: ((stats?: DrawStats) => SkPicture) | null
|
|
21
|
+
jsDraw: ReturnType<typeof createDrawer> | null
|
|
22
|
+
views: Slot[]
|
|
23
|
+
/**
|
|
24
|
+
* Frames a view may still show, oldest first. Freed explicitly once no view
|
|
25
|
+
* can show them: the GC doesn't see their native memory.
|
|
26
|
+
*/
|
|
27
|
+
frames: Frame[]
|
|
28
|
+
frame: number
|
|
29
|
+
last: number | null
|
|
30
|
+
statsStart: number
|
|
31
|
+
statsFrames: number
|
|
32
|
+
updateMs: number
|
|
33
|
+
drawablesMs: number
|
|
34
|
+
drawMs: number
|
|
35
|
+
drawStats: DrawStats
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Ticker {
|
|
39
|
+
entries: Map<number, Entry>
|
|
40
|
+
running: boolean
|
|
41
|
+
/** When the next frame is due (absolute, so late timer wake-ups don't add up). */
|
|
42
|
+
nextFrameAt: number
|
|
43
|
+
loopStatsStart: number
|
|
44
|
+
loopWorkMs: number
|
|
45
|
+
loopFrames: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const reportError = (message: string) =>
|
|
49
|
+
console.error('[react-native-live2d-skia] animation stopped:', message)
|
|
50
|
+
|
|
51
|
+
export function tickerState(): Ticker {
|
|
52
|
+
'worklet'
|
|
53
|
+
const global = globalThis as unknown as { __live2dSkiaTicker?: Ticker }
|
|
54
|
+
if (!global.__live2dSkiaTicker) {
|
|
55
|
+
global.__live2dSkiaTicker = {
|
|
56
|
+
entries: new Map(),
|
|
57
|
+
running: false,
|
|
58
|
+
nextFrameAt: 0,
|
|
59
|
+
loopStatsStart: 0,
|
|
60
|
+
loopWorkMs: 0,
|
|
61
|
+
loopFrames: 0,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return global.__live2dSkiaTicker
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
2
|
+
import { STATS_INTERVAL_MS } from './constants'
|
|
3
|
+
import type { Entry, Ticker } from './state'
|
|
4
|
+
import type { Live2DFrameStats } from './types'
|
|
5
|
+
|
|
6
|
+
export function reportEntry(ticker: Ticker, entry: Entry, at: number, frameMs: number) {
|
|
7
|
+
'worklet'
|
|
8
|
+
const elapsed = at - entry.statsStart
|
|
9
|
+
if (elapsed < STATS_INTERVAL_MS || entry.statsFrames === 0) return
|
|
10
|
+
const n = entry.statsFrames
|
|
11
|
+
const s = entry.drawStats
|
|
12
|
+
const stats: Live2DFrameStats = {
|
|
13
|
+
fps: (n * 1000) / elapsed,
|
|
14
|
+
targetFps: 1000 / frameMs,
|
|
15
|
+
workMs: (entry.updateMs + entry.drawablesMs + entry.drawMs) / n,
|
|
16
|
+
updateMs: entry.updateMs / n,
|
|
17
|
+
drawablesMs: entry.drawablesMs / n,
|
|
18
|
+
drawMs: entry.drawMs / n,
|
|
19
|
+
meshMs: s.meshMs / n,
|
|
20
|
+
meshesBuilt: s.meshesBuilt / n,
|
|
21
|
+
layers: s.layers / n,
|
|
22
|
+
models: ticker.entries.size,
|
|
23
|
+
loopWorkMs: ticker.loopFrames > 0 ? ticker.loopWorkMs / ticker.loopFrames : 0,
|
|
24
|
+
}
|
|
25
|
+
for (const { view } of entry.views) {
|
|
26
|
+
if (view.report) scheduleOnRN(view.report, stats)
|
|
27
|
+
}
|
|
28
|
+
entry.statsStart = at
|
|
29
|
+
entry.statsFrames = 0
|
|
30
|
+
entry.updateMs = 0
|
|
31
|
+
entry.drawablesMs = 0
|
|
32
|
+
entry.drawMs = 0
|
|
33
|
+
s.meshMs = 0
|
|
34
|
+
s.meshesBuilt = 0
|
|
35
|
+
s.layers = 0
|
|
36
|
+
}
|