@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/Live2dSkia.podspec +159 -0
  3. package/README.md +258 -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 +149 -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 +195 -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/decodeImage.ts +12 -0
  67. package/src/rendering/drawer.ts +256 -0
  68. package/src/rendering/skia.ts +26 -0
  69. package/src/rendering/skiaTypes.ts +99 -0
  70. package/src/rendering/ticker/addToTicker.ts +52 -0
  71. package/src/rendering/ticker/constants.ts +11 -0
  72. package/src/rendering/ticker/frames.ts +35 -0
  73. package/src/rendering/ticker/index.ts +5 -0
  74. package/src/rendering/ticker/loop.ts +115 -0
  75. package/src/rendering/ticker/state.ts +65 -0
  76. package/src/rendering/ticker/stats.ts +36 -0
  77. package/src/rendering/ticker/types.ts +57 -0
  78. package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
  79. package/src/utils/clock.ts +10 -0
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ import type { Synchronizable } from 'react-native-worklets'
2
+ import type { Live2DModel as NativeModel } from '../../specs/react-native-live2d-skia.nitro'
3
+ import type { CanvasInfo } from '../drawer'
4
+ import type { SkImage, SkPicture, SkShader } from '../skia'
5
+
6
+ /** Averages per frame over the last second, in milliseconds unless noted. */
7
+ export interface Live2DFrameStats {
8
+ fps: number
9
+ /** The frame rate the loop aims for: the display's refresh rate, or `maxFps`. */
10
+ targetFps: number
11
+ /** All work for this model's frame on the animation thread. */
12
+ workMs: number
13
+ /** Motions, physics and so on in C++ (`update`). */
14
+ updateMs: number
15
+ /** Copying vertex data out of C++ (`getDrawables`). 0 when drawn in C++. */
16
+ drawablesMs: number
17
+ /** Recording the picture, including `meshMs`. */
18
+ drawMs: number
19
+ /** Building Skia vertices for drawables that moved. */
20
+ meshMs: number
21
+ /** Meshes rebuilt per frame (a count). */
22
+ meshesBuilt: number
23
+ /**
24
+ * Mask layers per frame (a count). Their cost is in the UI thread's Skia
25
+ * draw, which these stats don't cover.
26
+ */
27
+ layers: number
28
+ /** Models animated by the loop, this one included (a count). */
29
+ models: number
30
+ /** All work for a frame of the loop, every model included. */
31
+ loopWorkMs: number
32
+ }
33
+
34
+ /** A newly drawn frame, for the UI thread to pick up. */
35
+ export type PublishedFrame = { frame: number; picture: SkPicture } | null
36
+
37
+ export interface TickerView {
38
+ target: Synchronizable<PublishedFrame>
39
+ /** The frame the UI thread shows (-1 before the first). Frames are freed once every view is past them. */
40
+ shown: Synchronizable<number>
41
+ /** Set to false to stop showing frames here. */
42
+ running: Synchronizable<boolean>
43
+ /** The display's frame interval, measured on the UI thread; 0 until known. */
44
+ displayMs: Synchronizable<number>
45
+ /** Shortest frame time for this view (`maxFps`); 0 for no cap. */
46
+ capMs: number
47
+ /** Gets frame stats about once a second; null if not wanted. */
48
+ report: ((stats: Live2DFrameStats) => void) | null
49
+ }
50
+
51
+ export interface TickerModel {
52
+ id: number
53
+ native: NativeModel
54
+ textures: SkImage[]
55
+ shaders: SkShader[]
56
+ canvas: CanvasInfo
57
+ }
@@ -0,0 +1,88 @@
1
+ import type { HybridObject } from 'react-native-nitro-modules'
2
+
3
+ export interface DrawableData {
4
+ /** Index into the model's drawables; `masks` refers to these. */
5
+ index: number
6
+ /** Cubism's visibility flag. Hidden drawables can still be used as masks. */
7
+ visible: boolean
8
+ textureIndex: number
9
+ opacity: number
10
+ /** Cubism constant flags (`csmBlendAdditive`, `csmIsInvertedMask`, ...). */
11
+ constantFlags: number
12
+ /** Indices of the drawables that clip this one. Empty if unmasked. */
13
+ masks: number[]
14
+ /** float32 x/y pairs in model units, y-up. */
15
+ positions: ArrayBuffer
16
+ /** float32 u/v pairs in 0..1, v-up. */
17
+ uvs: ArrayBuffer
18
+ /** uint16 triangle indices. */
19
+ indices: ArrayBuffer
20
+ }
21
+
22
+ /** Like the Cubism SDK samples: a higher priority interrupts a lower one. */
23
+ export type MotionPriority = 'idle' | 'normal' | 'force'
24
+
25
+ /**
26
+ * Thread-safe: the model can be updated on one thread while another starts
27
+ * motions or moves the focus.
28
+ */
29
+ export interface Live2DModel extends HybridObject<{ ios: 'c++'; android: 'c++' }> {
30
+ readonly canvasWidth: number
31
+ readonly canvasHeight: number
32
+ readonly originX: number
33
+ readonly originY: number
34
+ readonly pixelsPerUnit: number
35
+ readonly drawableCount: number
36
+ /** All drawables sorted by render order (back to front), including hidden ones. */
37
+ getDrawables(): DrawableData[]
38
+ loadPhysics(json: string): void
39
+ loadPose(json: string): void
40
+ /**
41
+ * Loads one `motion3.json` as entry `index` of motion `group`.
42
+ * Fade times come from `model3.json` when it defines them.
43
+ */
44
+ loadMotion(group: string, index: number, json: string): void
45
+ /**
46
+ * Starts a loaded motion, or a random one of `group` without `index`.
47
+ * Returns false if it isn't loaded or a higher priority motion is playing.
48
+ */
49
+ startMotion(group: string, index?: number, priority?: MotionPriority): boolean
50
+ loadExpression(name: string, json: string): void
51
+ /**
52
+ * Fades to a loaded expression, or a random one without `name`.
53
+ * Returns false if it isn't loaded.
54
+ */
55
+ setExpression(name?: string): boolean
56
+ clearExpression(): void
57
+ /**
58
+ * Holds a parameter at `value` until `clearParameter`. Applied after motions,
59
+ * expressions, eye blink, look and breath, before physics and pose.
60
+ */
61
+ setParameter(id: string, value: number): void
62
+ clearParameter(id: string): void
63
+ /**
64
+ * Makes the model look at a point, easing towards it: -1..1 on each axis,
65
+ * y-up, (0, 0) is straight ahead.
66
+ */
67
+ focus(x: number, y: number): void
68
+ /**
69
+ * Names of the `model3.json` hit areas containing a point in model units
70
+ * (y-up, see `originX`/`pixelsPerUnit`), in the order they're listed.
71
+ */
72
+ hitTest(x: number, y: number): string[]
73
+ /**
74
+ * Advances the model by `deltaSeconds`: motions (a random `Idle` motion
75
+ * whenever nothing plays), expression, eye blink, look, breath, held
76
+ * parameters, physics and pose.
77
+ * Pass 0 to just apply the current state.
78
+ */
79
+ update(deltaSeconds: number): void
80
+ }
81
+
82
+ export interface Live2dSkia extends HybridObject<{ ios: 'c++'; android: 'c++' }> {
83
+ /**
84
+ * Loads a `.moc3` file with its `model3.json` (used for eye blink and lip
85
+ * sync parameters and motion fade times). Throws if the moc is invalid.
86
+ */
87
+ loadModel(moc: ArrayBuffer, settingsJson: string): Live2DModel
88
+ }
@@ -0,0 +1,10 @@
1
+ // performance.now() exists on the JS thread and on worklet runtimes, but this
2
+ // package's TypeScript lib (esnext, no DOM) doesn't declare it.
3
+ type WithPerformance = { performance: { now(): number } }
4
+
5
+ export function now(): number {
6
+ 'worklet'
7
+ // Read globalThis here, not from a module constant: a worklet copies what it
8
+ // captures to its runtime, and the global object can't be copied (it's cyclic).
9
+ return (globalThis as unknown as WithPerformance).performance.now()
10
+ }