@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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/Live2dSkia.podspec +159 -0
  3. package/README.md +64 -2
  4. package/android/CMakeLists.txt +113 -0
  5. package/android/build.gradle +223 -0
  6. package/android/fix-prefab.gradle +51 -0
  7. package/android/gradle.properties +5 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/cpp/cpp-adapter.cpp +9 -0
  10. package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
  11. package/app.plugin.js +2 -0
  12. package/cli/build/index.js +157 -0
  13. package/cpp/cubism/CubismCompat.hpp +26 -0
  14. package/cpp/cubism/CubismHelpers.hpp +27 -0
  15. package/cpp/cubism/CubismRendererStub.cpp +14 -0
  16. package/cpp/cubism/CubismSetup.cpp +54 -0
  17. package/cpp/cubism/CubismSetup.hpp +9 -0
  18. package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
  19. package/cpp/cubism/Live2DUserModel.cpp +125 -0
  20. package/cpp/cubism/Live2DUserModel.hpp +57 -0
  21. package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
  22. package/cpp/drawing/JsiUnwrap.hpp +25 -0
  23. package/cpp/drawing/MeshCache.cpp +75 -0
  24. package/cpp/drawing/MeshCache.hpp +45 -0
  25. package/cpp/drawing/SkiaDrawer.cpp +138 -0
  26. package/cpp/drawing/SkiaDrawer.hpp +42 -0
  27. package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
  28. package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
  29. package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
  30. package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
  31. package/ios/Bridge.h +8 -0
  32. package/nitro.json +20 -0
  33. package/nitrogen/generated/.gitattributes +1 -0
  34. package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
  35. package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
  36. package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
  37. package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
  39. package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
  40. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
  41. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
  42. package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
  43. package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
  44. package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
  45. package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
  46. package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
  47. package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
  48. package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
  49. package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
  50. package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
  51. package/package.json +146 -4
  52. package/plugin/build/index.d.ts +14 -0
  53. package/plugin/build/index.js +81 -0
  54. package/src/components/Live2D.tsx +131 -0
  55. package/src/components/index.ts +2 -0
  56. package/src/hooks/index.ts +8 -0
  57. package/src/hooks/useLive2DGesture.ts +140 -0
  58. package/src/hooks/useLive2DModel.ts +47 -0
  59. package/src/index.ts +5 -0
  60. package/src/loader/cubismJson.ts +23 -0
  61. package/src/loader/index.ts +9 -0
  62. package/src/loader/loadModel.ts +194 -0
  63. package/src/loader/modelContext.ts +80 -0
  64. package/src/loader/modelUrl.ts +112 -0
  65. package/src/native.ts +4 -0
  66. package/src/rendering/drawer.ts +256 -0
  67. package/src/rendering/skia.ts +114 -0
  68. package/src/rendering/ticker/addToTicker.ts +52 -0
  69. package/src/rendering/ticker/constants.ts +11 -0
  70. package/src/rendering/ticker/frames.ts +35 -0
  71. package/src/rendering/ticker/index.ts +5 -0
  72. package/src/rendering/ticker/loop.ts +115 -0
  73. package/src/rendering/ticker/state.ts +65 -0
  74. package/src/rendering/ticker/stats.ts +36 -0
  75. package/src/rendering/ticker/types.ts +57 -0
  76. package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
  77. 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
+ }